From 71944b91579cf1ad3f61d3d28605d56bf4554288 Mon Sep 17 00:00:00 2001 From: lellansin Date: Thu, 14 May 2026 11:44:06 +0800 Subject: [PATCH] feat(prompt): add ctrl+- undo and ctrl+shift+- redo Adds Emacs-style undo/redo for the prompt input buffer: - ctrl+- (undo): reverses text changes one step at a time, all the way back to the initial empty buffer. Uses an undo stack capped at 1000 entries. - ctrl+shift+- (redo): restores undone changes one step at a time. New edits clear the redo history. - Pure cursor movement does not create undo entries. - Terminal input parsing handles modifyOtherKeys CSI sequences: \u001B[45;5u and \u001B[27;5;45~ for undo, \u001B[45;6u and \u001B[27;6;45~ for redo, plus raw 0x1F as redo fallback. --- src/tests/promptInputKeys.test.ts | 38 ++++++++++++++++++ src/tests/promptUndoRedo.test.ts | 60 ++++++++++++++++++++++++++++ src/ui/PromptInput.tsx | 52 +++++++++++++++++++++++- src/ui/prompt/useTerminalInput.ts | 66 +++++++++++++++++++++++++++++++ src/ui/promptUndoRedo.ts | 52 ++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/tests/promptUndoRedo.test.ts create mode 100644 src/ui/promptUndoRedo.ts diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 704d44a..69d2075 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -128,6 +128,44 @@ test("parseTerminalInput recognizes ctrl+x as the image attachment clear shortcu assert.equal(isClearImageAttachmentsShortcut(input, key), true); }); +test("parseTerminalInput recognizes ctrl+- modifyOtherKeys sequence (standard)", () => { + const { input, key } = parseTerminalInput("\u001B[45;5u"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.meta, false); +}); + +test("parseTerminalInput recognizes ctrl+- modifyOtherKeys sequence (extended)", () => { + const { input, key } = parseTerminalInput("\u001B[27;5;45~"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.meta, false); +}); + +test("parseTerminalInput recognizes raw 0x1F as ctrl+shift+- (redo)", () => { + const { input, key } = parseTerminalInput("\u001F"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.shift, true); + assert.equal(key.meta, false); +}); + +test("parseTerminalInput recognizes ctrl+shift+- modifyOtherKeys sequence (standard)", () => { + const { input, key } = parseTerminalInput("\u001B[45;6u"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.shift, true); + assert.equal(key.meta, false); +}); + +test("parseTerminalInput recognizes ctrl+shift+- modifyOtherKeys sequence (extended)", () => { + const { input, key } = parseTerminalInput("\u001B[27;6;45~"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.shift, true); + assert.equal(key.meta, false); +}); + test("formatImageAttachmentStatus formats the image count label", () => { assert.equal(formatImageAttachmentStatus(0), ""); assert.equal(formatImageAttachmentStatus(1), "📎 1 image attached"); diff --git a/src/tests/promptUndoRedo.test.ts b/src/tests/promptUndoRedo.test.ts new file mode 100644 index 0000000..c1999f1 --- /dev/null +++ b/src/tests/promptUndoRedo.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { removeCurrentSlashToken } from "../ui"; +import { + clearPromptUndoRedoState, + createPromptUndoRedoState, + recordPromptEdit, + redoPromptEdit, + undoPromptEdit, +} from "../ui/promptUndoRedo"; + +test("prompt undo and redo restore edited buffer states", () => { + const history = createPromptUndoRedoState(); + const empty = { text: "", cursor: 0 }; + const hello = { text: "hello", cursor: 5 }; + + recordPromptEdit(history, empty, hello); + + assert.deepEqual(undoPromptEdit(history, hello), empty); + assert.deepEqual(redoPromptEdit(history, empty), hello); +}); + +test("prompt redo history is cleared after a new edit", () => { + const history = createPromptUndoRedoState(); + const empty = { text: "", cursor: 0 }; + const first = { text: "first", cursor: 5 }; + const second = { text: "second", cursor: 6 }; + + recordPromptEdit(history, empty, first); + assert.deepEqual(undoPromptEdit(history, first), empty); + + recordPromptEdit(history, empty, second); + + assert.equal(redoPromptEdit(history, second), null); +}); + +test("prompt undo ignores cursor-only movement", () => { + const history = createPromptUndoRedoState(); + const before = { text: "hello", cursor: 5 }; + const after = { text: "hello", cursor: 0 }; + + recordPromptEdit(history, before, after); + + assert.equal(undoPromptEdit(history, after), null); +}); + +test("clearing consumed slash token drops undo and redo history", () => { + const history = createPromptUndoRedoState(); + const empty = { text: "", cursor: 0 }; + const slashCommand = { text: "/model", cursor: 6 }; + + recordPromptEdit(history, empty, slashCommand); + const cleared = removeCurrentSlashToken(slashCommand); + clearPromptUndoRedoState(history); + + assert.deepEqual(cleared, { text: "", cursor: 0 }); + assert.equal(undoPromptEdit(history, cleared), null); + assert.equal(redoPromptEdit(history, cleared), null); +}); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index b878961..ce0e4d8 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -21,6 +21,13 @@ import { moveUp, } from "./promptBuffer"; import type { PromptBufferState } from "./promptBuffer"; +import { + clearPromptUndoRedoState, + createPromptUndoRedoState, + recordPromptEdit, + redoPromptEdit, + undoPromptEdit, +} from "./promptUndoRedo"; import { buildSlashCommands, filterSlashCommands, findExactSlashCommand } from "./slashCommands"; import type { SlashCommandItem } from "./slashCommands"; import { readClipboardImageAsync } from "./clipboard"; @@ -122,6 +129,7 @@ export const PromptInput = React.memo(function PromptInput({ const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); const [hasTerminalFocus, setHasTerminalFocus] = useState(true); const lastCtrlDAt = React.useRef(0); + const undoRedoRef = React.useRef(createPromptUndoRedoState()); const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); const slashToken = getCurrentSlashToken(buffer); @@ -236,6 +244,7 @@ export const PromptInput = React.memo(function PromptInput({ setStatusMessage("Interrupting…"); } else if (!isEmpty(buffer)) { setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); } else { setStatusMessage("press ctrl+d to exit"); } @@ -475,6 +484,14 @@ export const PromptInput = React.memo(function PromptInput({ updateBuffer((s) => insertText(s, "\n")); return; } + if (key.ctrl && key.shift && input === "-") { + redo(); + return; + } + if (key.ctrl && input === "-") { + undo(); + return; + } if (input.startsWith("\u001B")) { // Unhandled escape sequence (e.g. function keys); ignore to avoid inserting garbage. return; @@ -490,6 +507,28 @@ export const PromptInput = React.memo(function PromptInput({ { isActive: !disabled } ); + function undo(): void { + const previous = undoPromptEdit(undoRedoRef.current, buffer); + if (!previous) { + return; + } + exitHistoryBrowsing(); + setBuffer(previous); + } + + function redo(): void { + const next = redoPromptEdit(undoRedoRef.current, buffer); + if (!next) { + return; + } + exitHistoryBrowsing(); + setBuffer(next); + } + + function clearUndoRedoStacks(): void { + clearPromptUndoRedoState(undoRedoRef.current); + } + function exitHistoryBrowsing(): void { setHistoryCursor(-1); setDraftBeforeHistory(null); @@ -497,7 +536,11 @@ export const PromptInput = React.memo(function PromptInput({ function updateBuffer(updater: (state: PromptBufferState) => PromptBufferState): void { exitHistoryBrowsing(); - setBuffer(updater); + setBuffer((current) => { + const next = updater(current); + recordPromptEdit(undoRedoRef.current, current, next); + return next; + }); } function navigateHistory(direction: -1 | 1): void { @@ -551,6 +594,7 @@ export const PromptInput = React.memo(function PromptInput({ if (item.kind === "new") { onSubmit({ text: "", imageUrls: [], command: "new" }); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); @@ -559,6 +603,7 @@ export const PromptInput = React.memo(function PromptInput({ if (item.kind === "init") { onSubmit(buildInitPromptSubmission(selectedSkills)); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); @@ -567,6 +612,7 @@ export const PromptInput = React.memo(function PromptInput({ if (item.kind === "resume") { onSubmit({ text: "", imageUrls: [], command: "resume" }); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); @@ -575,6 +621,7 @@ export const PromptInput = React.memo(function PromptInput({ if (item.kind === "mcp") { onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" }); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); @@ -583,6 +630,7 @@ export const PromptInput = React.memo(function PromptInput({ if (item.kind === "exit") { onSubmit({ text: "/exit", imageUrls: [], command: "exit" }); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); return; } } @@ -612,6 +660,7 @@ export const PromptInput = React.memo(function PromptInput({ selectedSkills, }); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); @@ -628,6 +677,7 @@ export const PromptInput = React.memo(function PromptInput({ function clearSlashToken(): void { exitHistoryBrowsing(); setBuffer((state) => removeCurrentSlashToken(state)); + clearUndoRedoStacks(); } function openModelDropdown(): void { diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index af830e5..8013ff6 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -35,9 +35,75 @@ const META_RIGHT_SEQUENCES = new Set(["\u001B[1;3C", "\u001B[3C", "\u001Bf"]); const TERMINAL_FOCUS_IN = "\u001B[I"; const TERMINAL_FOCUS_OUT = "\u001B[O"; +// Ctrl+- (minus) sequences in modifyOtherKeys mode. +// \u001B[45;5u — standard format: keycode=45 ('-'), modifier=5 (Ctrl) +// \u001B[27;5;45~ — extended format for function-like reporting +const CTRL_MINUS_SEQUENCES = new Set(["\u001B[45;5u", "\u001B[27;5;45~"]); + +// Ctrl+Shift+- (minus) sequences in modifyOtherKeys mode. +// \u001B[45;6u — standard format: keycode=45 ('-'), modifier=6 (Ctrl+Shift) +// \u001B[27;6;45~ — extended format for function-like reporting +const CTRL_SHIFT_MINUS_SEQUENCES = new Set(["\u001B[45;6u", "\u001B[27;6;45~"]); + export function parseTerminalInput(data: Buffer | string): { input: string; key: InputKey } { const raw = String(data); let input = raw; + + // Ctrl+- undo shortcut: only via modifyOtherKeys CSI sequences. + // Raw 0x1F is NOT included here because it represents Ctrl+_ (Ctrl+Shift+- + // on US keyboards), which should trigger redo instead. + if (CTRL_MINUS_SEQUENCES.has(raw)) { + input = "-"; + const key: InputKey = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + home: false, + end: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: true, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + focusIn: false, + focusOut: false, + }; + return { input, key }; + } + + // Ctrl+Shift+- redo shortcut: modifyOtherKeys CSI sequences + raw 0x1F fallback. + // \x1F is Ctrl+_ which on US keyboards = Ctrl+Shift+-. + if (CTRL_SHIFT_MINUS_SEQUENCES.has(raw) || raw === "\u001F") { + input = "-"; + const key: InputKey = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + home: false, + end: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: true, + shift: true, + tab: false, + backspace: false, + delete: false, + meta: false, + focusIn: false, + focusOut: false, + }; + return { input, key }; + } + const key: InputKey = { upArrow: raw === "\u001B[A", downArrow: raw === "\u001B[B", diff --git a/src/ui/promptUndoRedo.ts b/src/ui/promptUndoRedo.ts new file mode 100644 index 0000000..9d30f57 --- /dev/null +++ b/src/ui/promptUndoRedo.ts @@ -0,0 +1,52 @@ +import type { PromptBufferState } from "./promptBuffer"; + +export type PromptUndoRedoState = { + undoStack: PromptBufferState[]; + redoStack: PromptBufferState[]; +}; + +export function createPromptUndoRedoState(): PromptUndoRedoState { + return { undoStack: [], redoStack: [] }; +} + +export function recordPromptEdit( + history: PromptUndoRedoState, + current: PromptBufferState, + next: PromptBufferState, + maxUndoEntries = 1000 +): void { + if (next.text === current.text || next.text === history.undoStack.at(-1)?.text) { + return; + } + + history.undoStack.push(current); + if (history.undoStack.length > maxUndoEntries) { + history.undoStack = history.undoStack.slice(-maxUndoEntries); + } + history.redoStack = []; +} + +export function undoPromptEdit(history: PromptUndoRedoState, current: PromptBufferState): PromptBufferState | null { + const previous = history.undoStack.pop(); + if (!previous) { + return null; + } + + history.redoStack.push(current); + return previous; +} + +export function redoPromptEdit(history: PromptUndoRedoState, current: PromptBufferState): PromptBufferState | null { + const next = history.redoStack.pop(); + if (!next) { + return null; + } + + history.undoStack.push(current); + return next; +} + +export function clearPromptUndoRedoState(history: PromptUndoRedoState): void { + history.undoStack = []; + history.redoStack = []; +}