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:
committed by
GitHub
parent
2759ff39da
commit
866626347b
@@ -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 pre‑filled with session details
|
// Generate a GitHub bug report URL pre‑filled 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: 
|
// markdown-style image syntax: 
|
||||||
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 && (
|
||||||
<>
|
<>
|
||||||
{" — "}
|
{" — "}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user