diff --git a/codex-cli/src/components/chat/terminal-chat-input.tsx b/codex-cli/src/components/chat/terminal-chat-input.tsx index 2146f60c..e783f845 100644 --- a/codex-cli/src/components/chat/terminal-chat-input.tsx +++ b/codex-cli/src/components/chat/terminal-chat-input.tsx @@ -1,3 +1,4 @@ +import type { MultilineTextEditorHandle } from "./multiline-editor"; import type { ReviewDecision } from "../../utils/agent/review.js"; import type { HistoryEntry } from "../../utils/storage/command-history.js"; import type { @@ -5,6 +6,7 @@ import type { ResponseItem, } from "openai/resources/responses/responses.mjs"; +import MultilineTextEditor from "./multiline-editor"; import { TerminalChatCommandReview } from "./terminal-chat-command-review.js"; import { log, isLoggingEnabled } from "../../utils/agent/log.js"; import { loadConfig } from "../../utils/config.js"; @@ -16,10 +18,15 @@ import { addToHistory, } from "../../utils/storage/command-history.js"; import { clearTerminal, onExit } from "../../utils/terminal.js"; -import TextInput from "../vendor/ink-text-input.js"; import { Box, Text, useApp, useInput, useStdin } from "ink"; import { fileURLToPath } from "node:url"; -import React, { useCallback, useState, Fragment, useEffect } from "react"; +import React, { + useCallback, + useState, + Fragment, + useEffect, + useRef, +} from "react"; import { useInterval } from "use-interval"; const suggestions = [ @@ -83,6 +90,12 @@ export default function TerminalChatInput({ const [historyIndex, setHistoryIndex] = useState(null); const [draftInput, setDraftInput] = useState(""); const [skipNextSubmit, setSkipNextSubmit] = useState(false); + // Multiline text editor key to force remount after submission + const [editorKey, setEditorKey] = useState(0); + // Imperative handle from the multiline editor so we can query caret position + const editorRef = useRef(null); + // Track the caret row across keystrokes + const prevCursorRow = useRef(null); // Load command history on component mount useEffect(() => { @@ -184,9 +197,15 @@ export default function TerminalChatInput({ } if (!confirmationPrompt && !loading) { if (_key.upArrow) { - if (history.length > 0) { + // Only recall history when the caret was *already* on the very first + // row *before* this key-press. + const cursorRow = editorRef.current?.getRow?.() ?? 0; + const wasAtFirstRow = (prevCursorRow.current ?? cursorRow) === 0; + + if (history.length > 0 && cursorRow === 0 && wasAtFirstRow) { if (historyIndex == null) { - setDraftInput(input); + const currentDraft = editorRef.current?.getText?.() ?? input; + setDraftInput(currentDraft); } let newIndex: number; @@ -197,27 +216,37 @@ export default function TerminalChatInput({ } setHistoryIndex(newIndex); setInput(history[newIndex]?.command ?? ""); + // Re-mount the editor so it picks up the new initialText + setEditorKey((k) => k + 1); + return; // we handled the key } - return; + // Otherwise let the event propagate so the editor moves the caret } if (_key.downArrow) { - if (historyIndex == null) { - return; + // Only move forward in history when we're already *in* history mode + // AND the caret sits on the last line of the buffer + if (historyIndex != null && editorRef.current?.isCursorAtLastRow()) { + const newIndex = historyIndex + 1; + if (newIndex >= history.length) { + setHistoryIndex(null); + setInput(draftInput); + setEditorKey((k) => k + 1); + } else { + setHistoryIndex(newIndex); + setInput(history[newIndex]?.command ?? ""); + setEditorKey((k) => k + 1); + } + return; // handled } - - const newIndex = historyIndex + 1; - if (newIndex >= history.length) { - setHistoryIndex(null); - setInput(draftInput); - } else { - setHistoryIndex(newIndex); - setInput(history[newIndex]?.command ?? ""); - } - return; + // Otherwise let it propagate } } + // Update the cached cursor position *after* we've potentially handled + // the key so that the next event has the correct "previous" reference. + prevCursorRow.current = editorRef.current?.getRow?.() ?? null; + if (input.trim() === "" && isNew) { if (_key.tab) { setSelectedSuggestion( @@ -537,25 +566,27 @@ export default function TerminalChatInput({ thinkingSeconds={thinkingSeconds} /> ) : ( - - { - setDraftInput(value); + + { + setDraftInput(txt); if (historyIndex != null) { setHistoryIndex(null); } - setInput(value); + setInput(txt); + }} + key={editorKey} + initialText={input} + height={6} + focus={active} + onSubmit={(txt) => { + onSubmit(txt); + setEditorKey((k) => k + 1); + setInput(""); + setHistoryIndex(null); + setDraftInput(""); }} - onSubmit={onSubmit} /> )} @@ -600,7 +631,7 @@ export default function TerminalChatInput({ ) : ( <> send q or ctrl+c to exit | send "/clear" to reset | send "/help" - for commands | press enter to send + for commands | press enter to send | shift+enter for new line {contextLeftPercent > 25 && ( <> {" — "} diff --git a/codex-cli/tests/terminal-chat-input-multiline.test.tsx b/codex-cli/tests/terminal-chat-input-multiline.test.tsx new file mode 100644 index 00000000..b992cd08 --- /dev/null +++ b/codex-cli/tests/terminal-chat-input-multiline.test.tsx @@ -0,0 +1,157 @@ +import React from "react"; +import type { ComponentProps } from "react"; +import { renderTui } from "./ui-test-helpers.js"; +import TerminalChatInput from "../src/components/chat/terminal-chat-input.js"; +import { describe, it, expect, vi } from "vitest"; + +// Helper that lets us type and then immediately flush ink's async timers +async function type( + stdin: NodeJS.WritableStream, + text: string, + flush: () => Promise, +) { + stdin.write(text); + await flush(); +} + +// Mock the createInputItem function to avoid filesystem operations +vi.mock("../src/utils/input-utils.js", () => ({ + createInputItem: vi.fn(async (text: string) => ({ + role: "user", + type: "message", + content: [{ type: "input_text", text }], + })), +})); + +describe("TerminalChatInput multiline functionality", () => { + it("renders the multiline editor component", async () => { + const props: ComponentProps = { + isNew: false, + loading: false, + submitInput: () => {}, + confirmationPrompt: null, + explanation: undefined, + submitConfirmation: () => {}, + setLastResponseId: () => {}, + setItems: () => {}, + contextLeftPercent: 50, + openOverlay: () => {}, + openDiffOverlay: () => {}, + openModelOverlay: () => {}, + openApprovalOverlay: () => {}, + openHelpOverlay: () => {}, + onCompact: () => {}, + interruptAgent: () => {}, + active: true, + thinkingSeconds: 0, + }; + + const { lastFrameStripped } = renderTui(); + const frame = lastFrameStripped(); + + // Check that the help text mentions shift+enter for new line + expect(frame).toContain("shift+enter for new line"); + }); + + it("allows multiline input with shift+enter", async () => { + const submitInput = vi.fn(); + + const props: ComponentProps = { + isNew: false, + loading: false, + submitInput, + confirmationPrompt: null, + explanation: undefined, + submitConfirmation: () => {}, + setLastResponseId: () => {}, + setItems: () => {}, + contextLeftPercent: 50, + openOverlay: () => {}, + openDiffOverlay: () => {}, + openModelOverlay: () => {}, + openApprovalOverlay: () => {}, + openHelpOverlay: () => {}, + onCompact: () => {}, + interruptAgent: () => {}, + active: true, + thinkingSeconds: 0, + }; + + const { stdin, lastFrameStripped, flush, cleanup } = renderTui( + , + ); + + // Type some text + await type(stdin, "first line", flush); + + // Send Shift+Enter (CSI-u format) + await type(stdin, "\u001B[13;2u", flush); + + // Type more text + await type(stdin, "second line", flush); + + // Check that both lines are visible in the editor + const frame = lastFrameStripped(); + expect(frame).toContain("first line"); + expect(frame).toContain("second line"); + + // Submit the multiline input with Enter + await type(stdin, "\r", flush); + + // Check that submitInput was called with the multiline text + expect(submitInput).toHaveBeenCalledTimes(1); + + cleanup(); + }); + + it("allows multiline input with shift+enter (modifyOtherKeys=1 format)", async () => { + const submitInput = vi.fn(); + + const props: ComponentProps = { + isNew: false, + loading: false, + submitInput, + confirmationPrompt: null, + explanation: undefined, + submitConfirmation: () => {}, + setLastResponseId: () => {}, + setItems: () => {}, + contextLeftPercent: 50, + openOverlay: () => {}, + openDiffOverlay: () => {}, + openModelOverlay: () => {}, + openApprovalOverlay: () => {}, + openHelpOverlay: () => {}, + onCompact: () => {}, + interruptAgent: () => {}, + active: true, + thinkingSeconds: 0, + }; + + const { stdin, lastFrameStripped, flush, cleanup } = renderTui( + , + ); + + // Type some text + await type(stdin, "first line", flush); + + // Send Shift+Enter (modifyOtherKeys=1 format) + await type(stdin, "\u001B[27;2;13~", flush); + + // Type more text + await type(stdin, "second line", flush); + + // Check that both lines are visible in the editor + const frame = lastFrameStripped(); + expect(frame).toContain("first line"); + expect(frame).toContain("second line"); + + // Submit the multiline input with Enter + await type(stdin, "\r", flush); + + // Check that submitInput was called with the multiline text + expect(submitInput).toHaveBeenCalledTimes(1); + + cleanup(); + }); +});