feat: allow multi-line input (#438)
## Description This PR implements multi-line input support for Codex when it asks for user feedback (Issue #344). Users can now use Shift+Enter to add new lines in their responses, making it easier to provide formatted code snippets, lists, or other structured content. ## Changes - Replace the single-line TextInput component with the MultilineTextEditor component in terminal-chat-input.tsx - Add support for Shift+Enter to create new lines - Update key handling logic to properly handle history navigation in a multi-line context - Add reference to the editor to access cursor position information - Update help text to inform users about the Shift+Enter functionality - Add tests for the new functionality ## Testing - Added new test file (terminal-chat-input-multiline.test.tsx) to test the multi-line input functionality - All existing tests continue to pass - Manually tested the feature to ensure it works as expected ## Fixes Closes #344 ## Screenshots N/A ## Additional Notes This implementation maintains backward compatibility while adding the requested multi-line input functionality. The UI remains clean and intuitive, with a simple hint about using Shift+Enter for new lines. --------- Co-authored-by: Thibault Sottiaux <tibo@openai.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import type { MultilineTextEditorHandle } from "./multiline-editor";
|
||||||
import type { ReviewDecision } from "../../utils/agent/review.js";
|
import type { ReviewDecision } from "../../utils/agent/review.js";
|
||||||
import type { HistoryEntry } from "../../utils/storage/command-history.js";
|
import type { HistoryEntry } from "../../utils/storage/command-history.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -5,6 +6,7 @@ import type {
|
|||||||
ResponseItem,
|
ResponseItem,
|
||||||
} from "openai/resources/responses/responses.mjs";
|
} from "openai/resources/responses/responses.mjs";
|
||||||
|
|
||||||
|
import MultilineTextEditor from "./multiline-editor";
|
||||||
import { TerminalChatCommandReview } from "./terminal-chat-command-review.js";
|
import { TerminalChatCommandReview } from "./terminal-chat-command-review.js";
|
||||||
import { log, isLoggingEnabled } from "../../utils/agent/log.js";
|
import { log, isLoggingEnabled } from "../../utils/agent/log.js";
|
||||||
import { loadConfig } from "../../utils/config.js";
|
import { loadConfig } from "../../utils/config.js";
|
||||||
@@ -16,10 +18,15 @@ import {
|
|||||||
addToHistory,
|
addToHistory,
|
||||||
} from "../../utils/storage/command-history.js";
|
} from "../../utils/storage/command-history.js";
|
||||||
import { clearTerminal, onExit } from "../../utils/terminal.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 { Box, Text, useApp, useInput, useStdin } from "ink";
|
||||||
import { fileURLToPath } from "node:url";
|
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";
|
import { useInterval } from "use-interval";
|
||||||
|
|
||||||
const suggestions = [
|
const suggestions = [
|
||||||
@@ -83,6 +90,12 @@ export default function TerminalChatInput({
|
|||||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||||
const [draftInput, setDraftInput] = useState<string>("");
|
const [draftInput, setDraftInput] = useState<string>("");
|
||||||
const [skipNextSubmit, setSkipNextSubmit] = useState<boolean>(false);
|
const [skipNextSubmit, setSkipNextSubmit] = useState<boolean>(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<MultilineTextEditorHandle | null>(null);
|
||||||
|
// Track the caret row across keystrokes
|
||||||
|
const prevCursorRow = useRef<number | null>(null);
|
||||||
|
|
||||||
// Load command history on component mount
|
// Load command history on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -184,9 +197,15 @@ export default function TerminalChatInput({
|
|||||||
}
|
}
|
||||||
if (!confirmationPrompt && !loading) {
|
if (!confirmationPrompt && !loading) {
|
||||||
if (_key.upArrow) {
|
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) {
|
if (historyIndex == null) {
|
||||||
setDraftInput(input);
|
const currentDraft = editorRef.current?.getText?.() ?? input;
|
||||||
|
setDraftInput(currentDraft);
|
||||||
}
|
}
|
||||||
|
|
||||||
let newIndex: number;
|
let newIndex: number;
|
||||||
@@ -197,27 +216,37 @@ export default function TerminalChatInput({
|
|||||||
}
|
}
|
||||||
setHistoryIndex(newIndex);
|
setHistoryIndex(newIndex);
|
||||||
setInput(history[newIndex]?.command ?? "");
|
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 (_key.downArrow) {
|
||||||
if (historyIndex == null) {
|
// Only move forward in history when we're already *in* history mode
|
||||||
return;
|
// 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
|
||||||
}
|
}
|
||||||
|
// Otherwise let it propagate
|
||||||
const newIndex = historyIndex + 1;
|
|
||||||
if (newIndex >= history.length) {
|
|
||||||
setHistoryIndex(null);
|
|
||||||
setInput(draftInput);
|
|
||||||
} else {
|
|
||||||
setHistoryIndex(newIndex);
|
|
||||||
setInput(history[newIndex]?.command ?? "");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (input.trim() === "" && isNew) {
|
||||||
if (_key.tab) {
|
if (_key.tab) {
|
||||||
setSelectedSuggestion(
|
setSelectedSuggestion(
|
||||||
@@ -537,25 +566,27 @@ export default function TerminalChatInput({
|
|||||||
thinkingSeconds={thinkingSeconds}
|
thinkingSeconds={thinkingSeconds}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Box paddingX={1}>
|
<Box>
|
||||||
<TextInput
|
<MultilineTextEditor
|
||||||
focus={active}
|
ref={editorRef}
|
||||||
placeholder={
|
onChange={(txt: string) => {
|
||||||
selectedSuggestion
|
setDraftInput(txt);
|
||||||
? `"${suggestions[selectedSuggestion - 1]}"`
|
|
||||||
: "send a message" +
|
|
||||||
(isNew ? " or press tab to select a suggestion" : "")
|
|
||||||
}
|
|
||||||
showCursor
|
|
||||||
value={input}
|
|
||||||
onChange={(value) => {
|
|
||||||
setDraftInput(value);
|
|
||||||
if (historyIndex != null) {
|
if (historyIndex != null) {
|
||||||
setHistoryIndex(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}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -600,7 +631,7 @@ export default function TerminalChatInput({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
send q or ctrl+c to exit | send "/clear" to reset | send "/help"
|
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 && (
|
{contextLeftPercent > 25 && (
|
||||||
<>
|
<>
|
||||||
{" — "}
|
{" — "}
|
||||||
|
|||||||
157
codex-cli/tests/terminal-chat-input-multiline.test.tsx
Normal file
157
codex-cli/tests/terminal-chat-input-multiline.test.tsx
Normal file
@@ -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<void>,
|
||||||
|
) {
|
||||||
|
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<typeof TerminalChatInput> = {
|
||||||
|
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(<TerminalChatInput {...props} />);
|
||||||
|
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<typeof TerminalChatInput> = {
|
||||||
|
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(
|
||||||
|
<TerminalChatInput {...props} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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<typeof TerminalChatInput> = {
|
||||||
|
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(
|
||||||
|
<TerminalChatInput {...props} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user