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:
Brayden Moon
2025-04-21 01:51:38 +10:00
committed by GitHub
parent fc1e45636e
commit 6d6ca454cd
2 changed files with 221 additions and 33 deletions

View File

@@ -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<number | null>(null);
const [draftInput, setDraftInput] = useState<string>("");
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
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}
/>
) : (
<Box paddingX={1}>
<TextInput
focus={active}
placeholder={
selectedSuggestion
? `"${suggestions[selectedSuggestion - 1]}"`
: "send a message" +
(isNew ? " or press tab to select a suggestion" : "")
}
showCursor
value={input}
onChange={(value) => {
setDraftInput(value);
<Box>
<MultilineTextEditor
ref={editorRef}
onChange={(txt: string) => {
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}
/>
</Box>
)}
@@ -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 && (
<>
{" — "}

View 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();
});
});