fix: only allow going up in history when not already in history if input is empty (#654)

\+ cleanup below input help to be "ctrl+c to exit | "/" to see commands
| enter to send" now that we have command autocompletion
\+ minor other drive-by code cleanups

---------

Signed-off-by: Thibault Sottiaux <tibo@openai.com>
This commit is contained in:
Thibault Sottiaux
2025-04-25 09:39:24 -07:00
committed by GitHub
parent 2759ff39da
commit 866626347b
2 changed files with 45 additions and 75 deletions

View File

@@ -245,30 +245,40 @@ export default function TerminalChatInput({
} }
if (_key.upArrow) { if (_key.upArrow) {
// Only recall history when the caret was *already* on the very first let moveThroughHistory = true;
// Only use history when the caret was *already* on the very first
// row *before* this key-press. // row *before* this key-press.
const cursorRow = editorRef.current?.getRow?.() ?? 0; const cursorRow = editorRef.current?.getRow?.() ?? 0;
const wasAtFirstRow = (prevCursorRow.current ?? cursorRow) === 0; const wasAtFirstRow = (prevCursorRow.current ?? cursorRow) === 0;
if (!(cursorRow === 0 && wasAtFirstRow)) {
moveThroughHistory = false;
}
if (history.length > 0 && cursorRow === 0 && wasAtFirstRow) { // Only use history if we are already in history mode or if the input is empty.
if (historyIndex == null && input.trim() !== "") {
moveThroughHistory = false;
}
// Move through history.
if (history.length && moveThroughHistory) {
let newIndex: number;
if (historyIndex == null) { if (historyIndex == null) {
const currentDraft = editorRef.current?.getText?.() ?? input; const currentDraft = editorRef.current?.getText?.() ?? input;
setDraftInput(currentDraft); setDraftInput(currentDraft);
}
let newIndex: number;
if (historyIndex == null) {
newIndex = history.length - 1; newIndex = history.length - 1;
} else { } else {
newIndex = Math.max(0, historyIndex - 1); newIndex = Math.max(0, historyIndex - 1);
} }
setHistoryIndex(newIndex); setHistoryIndex(newIndex);
setInput(history[newIndex]?.command ?? ""); setInput(history[newIndex]?.command ?? "");
// Re-mount the editor so it picks up the new initialText // Re-mount the editor so it picks up the new initialText
setEditorKey((k) => k + 1); setEditorKey((k) => k + 1);
return; // we handled the key return; // handled
} }
// Otherwise let the event propagate so the editor moves the caret
// Otherwise let it propagate.
} }
if (_key.downArrow) { if (_key.downArrow) {
@@ -339,73 +349,60 @@ export default function TerminalChatInput({
const onSubmit = useCallback( const onSubmit = useCallback(
async (value: string) => { async (value: string) => {
const inputValue = value.trim(); const inputValue = value.trim();
// If the user only entered a slash, do not send a chat message
// If the user only entered a slash, do not send a chat message.
if (inputValue === "/") { if (inputValue === "/") {
setInput(""); setInput("");
return; return;
} }
// Skip this submit if we just autocompleted a slash command
// Skip this submit if we just autocompleted a slash command.
if (skipNextSubmit) { if (skipNextSubmit) {
setSkipNextSubmit(false); setSkipNextSubmit(false);
return; return;
} }
if (!inputValue) { if (!inputValue) {
return; return;
} } else if (inputValue === "/history") {
if (inputValue === "/history") {
setInput(""); setInput("");
openOverlay(); openOverlay();
return; return;
} } else if (inputValue === "/help") {
if (inputValue === "/help") {
setInput(""); setInput("");
openHelpOverlay(); openHelpOverlay();
return; return;
} } else if (inputValue === "/diff") {
if (inputValue === "/diff") {
setInput(""); setInput("");
openDiffOverlay(); openDiffOverlay();
return; return;
} } else if (inputValue === "/compact") {
if (inputValue === "/compact") {
setInput(""); setInput("");
onCompact(); onCompact();
return; return;
} } else if (inputValue.startsWith("/model")) {
if (inputValue.startsWith("/model")) {
setInput(""); setInput("");
openModelOverlay(); openModelOverlay();
return; return;
} } else if (inputValue.startsWith("/approval")) {
if (inputValue.startsWith("/approval")) {
setInput(""); setInput("");
openApprovalOverlay(); openApprovalOverlay();
return; return;
} } else if (inputValue === "exit") {
if (inputValue === "q" || inputValue === ":q" || inputValue === "exit") {
setInput(""); setInput("");
// wait one 60ms frame
setTimeout(() => { setTimeout(() => {
app.exit(); app.exit();
onExit(); onExit();
process.exit(0); process.exit(0);
}, 60); }, 60); // Wait one frame.
return; return;
} else if (inputValue === "/clear" || inputValue === "clear") { } else if (inputValue === "/clear" || inputValue === "clear") {
setInput(""); setInput("");
setSessionId(""); setSessionId("");
setLastResponseId(""); setLastResponseId("");
// Clear the terminal screen (including scrollback) before resetting context
clearTerminal();
// Emit a system notice in the chat; no raw console writes so Ink keeps control. // Clear the terminal screen (including scrollback) before resetting context.
clearTerminal();
// Emit a system message to confirm the clear action. We *append* // Emit a system message to confirm the clear action. We *append*
// it so Ink's <Static> treats it as new output and actually renders it. // it so Ink's <Static> treats it as new output and actually renders it.
@@ -449,7 +446,7 @@ export default function TerminalChatInput({
await clearCommandHistory(); await clearCommandHistory();
setHistory([]); setHistory([]);
// Emit a system message to confirm the history clear action // Emit a system message to confirm the history clear action.
setItems((prev) => [ setItems((prev) => [
...prev, ...prev,
{ {
@@ -466,7 +463,7 @@ export default function TerminalChatInput({
return; return;
} else if (inputValue === "/bug") { } else if (inputValue === "/bug") {
// Generate a GitHub bug report URL prefilled with session details // Generate a GitHub bug report URL prefilled with session details.
setInput(""); setInput("");
try { try {
@@ -519,10 +516,10 @@ export default function TerminalChatInput({
return; return;
} else if (inputValue.startsWith("/")) { } else if (inputValue.startsWith("/")) {
// Handle invalid/unrecognized commands. // Handle invalid/unrecognized commands. Only single-word inputs starting with '/'
// Only single-word inputs starting with '/' (e.g., /command) that are not recognized are caught here. // (e.g., /command) that are not recognized are caught here. Any other input, including
// Any other input, including those starting with '/' but containing spaces // those starting with '/' but containing spaces (e.g., "/command arg"), will fall through
// (e.g., "/command arg"), will fall through and be treated as a regular prompt. // and be treated as a regular prompt.
const trimmed = inputValue.trim(); const trimmed = inputValue.trim();
if (/^\/\S+$/.test(trimmed)) { if (/^\/\S+$/.test(trimmed)) {
@@ -549,11 +546,13 @@ export default function TerminalChatInput({
// detect image file paths for dynamic inclusion // detect image file paths for dynamic inclusion
const images: Array<string> = []; const images: Array<string> = [];
let text = inputValue; let text = inputValue;
// markdown-style image syntax: ![alt](path) // markdown-style image syntax: ![alt](path)
text = text.replace(/!\[[^\]]*?\]\(([^)]+)\)/g, (_m, p1: string) => { text = text.replace(/!\[[^\]]*?\]\(([^)]+)\)/g, (_m, p1: string) => {
images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1); images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
return ""; return "";
}); });
// quoted file paths ending with common image extensions (e.g. '/path/to/img.png') // quoted file paths ending with common image extensions (e.g. '/path/to/img.png')
text = text.replace( text = text.replace(
/['"]([^'"]+?\.(?:png|jpe?g|gif|bmp|webp|svg))['"]/gi, /['"]([^'"]+?\.(?:png|jpe?g|gif|bmp|webp|svg))['"]/gi,
@@ -562,6 +561,7 @@ export default function TerminalChatInput({
return ""; return "";
}, },
); );
// bare file paths ending with common image extensions // bare file paths ending with common image extensions
text = text.replace( text = text.replace(
// eslint-disable-next-line no-useless-escape // eslint-disable-next-line no-useless-escape
@@ -578,10 +578,10 @@ export default function TerminalChatInput({
const inputItem = await createInputItem(text, images); const inputItem = await createInputItem(text, images);
submitInput([inputItem]); submitInput([inputItem]);
// Get config for history persistence // Get config for history persistence.
const config = loadConfig(); const config = loadConfig();
// Add to history and update state // Add to history and update state.
const updatedHistory = await addToHistory(value, history, { const updatedHistory = await addToHistory(value, history, {
maxSize: config.history?.maxSize ?? 1000, maxSize: config.history?.maxSize ?? 1000,
saveHistory: config.history?.saveHistory ?? true, saveHistory: config.history?.saveHistory ?? true,
@@ -723,8 +723,7 @@ export default function TerminalChatInput({
/> />
) : ( ) : (
<Text dimColor> <Text dimColor>
send q or ctrl+c to exit | send "/clear" to reset | send "/help" for ctrl+c to exit | "/" to see commands | enter to send
commands | press enter to send | shift+enter for new line
{contextLeftPercent > 25 && ( {contextLeftPercent > 25 && (
<> <>
{" — "} {" — "}

View File

@@ -24,35 +24,6 @@ vi.mock("../src/utils/input-utils.js", () => ({
})); }));
describe("TerminalChatInput multiline functionality", () => { 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 () => { it("allows multiline input with shift+enter", async () => {
const submitInput = vi.fn(); const submitInput = vi.fn();