From b7978c9e0dccc83d2bee5e34fe8d5fa7fa7298e8 Mon Sep 17 00:00:00 2001 From: daandden <64765666+daandden@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:05:21 +0700 Subject: [PATCH] feat: add undo action and fix hotkeys rendering (#438) * Fix prompt action undo and hotkeys rendering * Fix prompt action test typechecks * Simplify undo and hotkeys PR * Fix editor formatting --- .../coding-agent/src/config/keybindings.ts | 5 +- .../modes/controllers/command-controller.ts | 54 ++----------- .../src/modes/controllers/input-controller.ts | 1 + .../src/modes/prompt-action-autocomplete.ts | 22 +++++- .../src/modes/utils/hotkeys-markdown.ts | 57 ++++++++++++++ .../test/keybindings-display.test.ts | 28 +++++++ .../command-controller-hotkeys.test.ts | 25 ++++++ .../test/prompt-action-autocomplete.test.ts | 31 +++++--- packages/tui/src/components/editor.ts | 76 +++++++++++++++++++ packages/tui/test/editor.test.ts | 35 +++++++++ 10 files changed, 270 insertions(+), 64 deletions(-) create mode 100644 packages/coding-agent/src/modes/utils/hotkeys-markdown.ts create mode 100644 packages/coding-agent/test/keybindings-display.test.ts create mode 100644 packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index fe9d0c776..6bfd3f33b 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -264,10 +264,7 @@ export class KeybindingsManager { * Get display string for an action. */ getDisplayString(action: AppAction): string { - const keys = this.getKeys(action); - if (keys.length === 0) return ""; - if (keys.length === 1) return keys[0]!; - return keys.join("/"); + return formatKeyHints(this.getKeys(action)); } /** diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index c2a3fc74c..5ebb6c558 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -24,6 +24,7 @@ import { DynamicBorder } from "../../modes/components/dynamic-border"; import { PythonExecutionComponent } from "../../modes/components/python-execution"; import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme"; import type { InteractiveModeContext } from "../../modes/types"; +import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown"; import type { AsyncJobSnapshotItem } from "../../session/agent-session"; import type { AuthStorage } from "../../session/auth-storage"; import { outputMeta } from "../../tools/output-meta"; @@ -509,52 +510,13 @@ export class CommandController { const sttKey = this.ctx.keybindings.getDisplayString("toggleSTT") || "Alt+H"; const copyLineKey = this.ctx.keybindings.getDisplayString("copyLine") || "Alt+Shift+L"; const copyPromptKey = this.ctx.keybindings.getDisplayString("copyPrompt") || "Alt+Shift+C"; - const hotkeys = ` - **Navigation** - | Key | Action | - |-----|--------| - | \`Arrow keys\` | Move cursor / browse history (Up when empty) | - | \`Option+Left/Right\` | Move by word | - | \`Ctrl+A\` / \`Home\` / \`Cmd+Left\` | Start of line | - | \`Ctrl+E\` / \`End\` / \`Cmd+Right\` | End of line | - - **Editing** - | Key | Action | - |-----|--------| - | \`Enter\` | Send message | - | \`Shift+Enter\` / \`Alt+Enter\` | New line | - | \`Ctrl+W\` / \`Option+Backspace\` | Delete word backwards | - | \`Ctrl+U\` | Delete to start of line | - | \`Ctrl+K\` | Delete to end of line | - | \`${copyLineKey}\` | Copy current line | - | \`${copyPromptKey}\` | Copy whole prompt | - - **Other** - | Key | Action | - |-----|--------| - | \`Tab\` | Path completion / accept autocomplete | - | \`Escape\` | Cancel autocomplete / abort streaming | - | \`Ctrl+C\` | Clear editor (first) / exit (second) | - | \`Ctrl+D\` | Exit (when editor is empty) | - | \`Ctrl+Z\` | Suspend to background | - | \`Shift+Tab\` | Cycle thinking level | - | \`Ctrl+P\` | Cycle role models (slow/default/smol) | - | \`Shift+Ctrl+P\` | Cycle role models (temporary) | - | \`Alt+P\` | Select model (temporary) | - | \`Ctrl+L\` | Select model (set roles) | - | \`${planModeKey}\` | Toggle plan mode | - | \`Ctrl+R\` | Search prompt history | - | \`${expandToolsKey}\` | Toggle tool output expansion | - | \`Ctrl+T\` | Toggle todo list expansion | - | \`Ctrl+G\` | Edit message in external editor | - | \`${sttKey}\` | Toggle speech-to-text recording | - | \`#\` | Open prompt actions | - | \`/\` | Slash commands | - | \`!\` | Run bash command | - | \`!!\` | Run bash command (excluded from context) | - | \`$\` | Run Python in shared kernel | - | \`$$\` | Run Python (excluded from context) | - `; + const hotkeys = buildHotkeysMarkdown({ + expandToolsKey, + planModeKey, + sttKey, + copyLineKey, + copyPromptKey, + }); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(new DynamicBorder()); this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0)); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index dab6e0fef..a5d8740d1 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -535,6 +535,7 @@ export class InputController { keybindings: this.ctx.keybindings, copyCurrentLine: () => this.handleCopyCurrentLine(), copyPrompt: () => this.handleCopyPrompt(), + undo: prefix => this.ctx.editor.undoPastTransientText(prefix), moveCursorToMessageEnd: () => this.ctx.editor.moveToMessageEnd(), moveCursorToMessageStart: () => this.ctx.editor.moveToMessageStart(), moveCursorToLineStart: () => this.ctx.editor.moveToLineStart(), diff --git a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts index f6340b2c4..d991b8b03 100644 --- a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts +++ b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts @@ -12,12 +12,12 @@ interface PromptActionDefinition { label: string; description: string; keywords: string[]; - execute: () => void; + execute: (prefix: string) => void; } interface PromptActionAutocompleteItem extends AutocompleteItem { actionId: string; - execute: () => void; + execute: (prefix: string) => void; } interface PromptActionAutocompleteOptions { @@ -26,6 +26,7 @@ interface PromptActionAutocompleteOptions { keybindings: KeybindingsManager; copyCurrentLine: () => void; copyPrompt: () => void; + undo: (prefix: string) => void; moveCursorToMessageEnd: () => void; moveCursorToMessageStart: () => void; moveCursorToLineStart: () => void; @@ -141,6 +142,14 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider { onApplied?: () => void; } { if (prefix.startsWith("#") && isPromptActionItem(item)) { + if (item.actionId === "undo") { + return { + lines, + cursorLine, + cursorCol, + onApplied: () => item.execute(prefix), + }; + } const currentLine = lines[cursorLine] || ""; const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); const afterCursor = currentLine.slice(cursorCol); @@ -150,7 +159,7 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider { lines: newLines, cursorLine, cursorCol: beforePrefix.length, - onApplied: item.execute, + onApplied: () => item.execute(prefix), }; } @@ -181,6 +190,13 @@ export function createPromptActionAutocompleteProvider( keywords: ["copy", "prompt", "clipboard", "message"], execute: options.copyPrompt, }, + { + id: "undo", + label: "Undo", + description: formatKeyHints(editorKeybindings.getKeys("undo")), + keywords: ["undo", "revert", "edit", "history"], + execute: options.undo, + }, { id: "cursor-message-end", label: "Move cursor to end of message", diff --git a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts new file mode 100644 index 000000000..a18fc49c2 --- /dev/null +++ b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts @@ -0,0 +1,57 @@ +export interface HotkeysMarkdownBindings { + expandToolsKey: string; + planModeKey: string; + sttKey: string; + copyLineKey: string; + copyPromptKey: string; +} + +export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string { + const { expandToolsKey, planModeKey, sttKey, copyLineKey, copyPromptKey } = bindings; + return [ + "**Navigation**", + "| Key | Action |", + "|-----|--------|", + "| `Arrow keys` | Move cursor / browse history (Up when empty) |", + "| `Option+Left/Right` | Move by word |", + "| `Ctrl+A` / `Home` / `Cmd+Left` | Start of line |", + "| `Ctrl+E` / `End` / `Cmd+Right` | End of line |", + "", + "**Editing**", + "| Key | Action |", + "|-----|--------|", + "| `Enter` | Send message |", + "| `Shift+Enter` / `Alt+Enter` | New line |", + "| `Ctrl+W` / `Option+Backspace` | Delete word backwards |", + "| `Ctrl+U` | Delete to start of line |", + "| `Ctrl+K` | Delete to end of line |", + `| \`${copyLineKey}\` | Copy current line |`, + `| \`${copyPromptKey}\` | Copy whole prompt |`, + "", + "**Other**", + "| Key | Action |", + "|-----|--------|", + "| `Tab` | Path completion / accept autocomplete |", + "| `Escape` | Cancel autocomplete / abort streaming |", + "| `Ctrl+C` | Clear editor (first) / exit (second) |", + "| `Ctrl+D` | Exit (when editor is empty) |", + "| `Ctrl+Z` | Suspend to background |", + "| `Shift+Tab` | Cycle thinking level |", + "| `Ctrl+P` | Cycle role models (slow/default/smol) |", + "| `Shift+Ctrl+P` | Cycle role models (temporary) |", + "| `Alt+P` | Select model (temporary) |", + "| `Ctrl+L` | Select model (set roles) |", + `| \`${planModeKey}\` | Toggle plan mode |`, + "| `Ctrl+R` | Search prompt history |", + `| \`${expandToolsKey}\` | Toggle tool output expansion |`, + "| `Ctrl+T` | Toggle todo list expansion |", + "| `Ctrl+G` | Edit message in external editor |", + `| \`${sttKey}\` | Toggle speech-to-text recording |`, + "| `#` | Open prompt actions |", + "| `/` | Slash commands |", + "| `!` | Run bash command |", + "| `!!` | Run bash command (excluded from context) |", + "| `$` | Run Python in shared kernel |", + "| `$$` | Run Python (excluded from context) |", + ].join("\n"); +} diff --git a/packages/coding-agent/test/keybindings-display.test.ts b/packages/coding-agent/test/keybindings-display.test.ts new file mode 100644 index 000000000..8b1319271 --- /dev/null +++ b/packages/coding-agent/test/keybindings-display.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; +import { KeybindingsManager } from "../src/config/keybindings"; + +describe("KeybindingsManager.getDisplayString", () => { + it("formats a single binding as a human-readable key hint", () => { + const keybindings = KeybindingsManager.inMemory({ + dequeue: "alt+up", + }); + + expect(keybindings.getDisplayString("dequeue")).toBe("Alt+Up"); + }); + + it("formats multiple bindings with the existing separator", () => { + const keybindings = KeybindingsManager.inMemory({ + copyPrompt: ["alt+shift+c", "ctrl+shift+c"], + }); + + expect(keybindings.getDisplayString("copyPrompt")).toBe("Alt+Shift+C/Ctrl+Shift+C"); + }); + + it("returns an empty string when the action has no binding", () => { + const keybindings = KeybindingsManager.inMemory({ + copyPrompt: [], + }); + + expect(keybindings.getDisplayString("copyPrompt")).toBe(""); + }); +}); diff --git a/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts b/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts new file mode 100644 index 000000000..d16851a91 --- /dev/null +++ b/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "bun:test"; +import { buildHotkeysMarkdown } from "../../../src/modes/utils/hotkeys-markdown"; + +describe("buildHotkeysMarkdown", () => { + it("emits flush-left markdown so headings and tables are parsed instead of treated as indented text", () => { + const markdown = buildHotkeysMarkdown({ + expandToolsKey: "Ctrl+O", + planModeKey: "Alt+M", + sttKey: "Alt+H", + copyLineKey: "Alt+Shift+L", + copyPromptKey: "Ctrl+Shift+P", + }); + + const lines = markdown.split("\n"); + expect(lines[0]).toBe("**Navigation**"); + expect(markdown).toContain("| `Ctrl+Shift+P` | Copy whole prompt |"); + expect(markdown).toContain("| `Alt+M` | Toggle plan mode |"); + expect(markdown).toContain("| `#` | Open prompt actions |"); + for (const line of lines) { + if (line.length === 0) continue; + expect(line.startsWith(" ")).toBe(false); + expect(line.startsWith("\t")).toBe(false); + } + }); +}); diff --git a/packages/coding-agent/test/prompt-action-autocomplete.test.ts b/packages/coding-agent/test/prompt-action-autocomplete.test.ts index b46f727e3..8dda96782 100644 --- a/packages/coding-agent/test/prompt-action-autocomplete.test.ts +++ b/packages/coding-agent/test/prompt-action-autocomplete.test.ts @@ -9,6 +9,7 @@ describe("prompt action autocomplete", () => { new EditorKeybindingsManager({ cursorLineStart: ["home", "f6"], cursorLineEnd: "f7", + undo: "f8", }), ); }); @@ -27,6 +28,7 @@ describe("prompt action autocomplete", () => { }), copyCurrentLine: () => {}, copyPrompt: () => {}, + undo: () => {}, moveCursorToMessageEnd: () => {}, moveCursorToMessageStart: () => {}, moveCursorToLineStart: () => {}, @@ -39,6 +41,7 @@ describe("prompt action autocomplete", () => { expect(suggestions?.items.map(item => item.label)).toEqual([ "Copy current line", "Copy whole prompt", + "Undo", "Move cursor to end of message", "Move cursor to beginning of message", "Move cursor to beginning of line", @@ -52,37 +55,42 @@ describe("prompt action autocomplete", () => { "Home/F6", ); expect(suggestions?.items.find(item => item.label === "Move cursor to end of line")?.description).toBe("F7"); + expect(suggestions?.items.find(item => item.label === "Undo")?.description).toBe("F8"); }); - it("executes selected prompt actions and removes the trigger text", async () => { - let messageEndMoves = 0; + it("passes the typed trigger to undo and leaves text removal to the editor", async () => { + let undoCalls = 0; + let undoPrefix = ""; const provider = createPromptActionAutocompleteProvider({ commands: [], basePath: "/tmp", keybindings: KeybindingsManager.inMemory(), copyCurrentLine: () => {}, copyPrompt: () => {}, - moveCursorToMessageEnd: () => { - messageEndMoves += 1; + undo: prefix => { + undoCalls += 1; + undoPrefix = prefix; }, + moveCursorToMessageEnd: () => {}, moveCursorToMessageStart: () => {}, moveCursorToLineStart: () => {}, moveCursorToLineEnd: () => {}, }); - const suggestions = await provider.getSuggestions(["hello #mess"], 0, 11); - const item = suggestions?.items.find(entry => entry.label === "Move cursor to end of message"); + const suggestions = await provider.getSuggestions(["hello #undo"], 0, 11); + const item = suggestions?.items.find(entry => entry.label === "Undo"); expect(item).toBeDefined(); if (!item || !suggestions) { - throw new Error("expected move cursor to end of message suggestion"); + throw new Error("expected undo suggestion"); } - const result = provider.applyCompletion(["hello #mess"], 0, 11, item, suggestions.prefix); - expect(result.lines).toEqual(["hello "]); + const result = provider.applyCompletion(["hello #undo"], 0, 11, item, suggestions.prefix); + expect(result.lines).toEqual(["hello #undo"]); expect(result.cursorLine).toBe(0); - expect(result.cursorCol).toBe(6); + expect(result.cursorCol).toBe(11); result.onApplied?.(); - expect(messageEndMoves).toBe(1); + expect(undoCalls).toBe(1); + expect(undoPrefix).toBe("#undo"); }); it("falls back to normal typing for literal hashtags with no matching action", async () => { @@ -92,6 +100,7 @@ describe("prompt action autocomplete", () => { keybindings: KeybindingsManager.inMemory(), copyCurrentLine: () => {}, copyPrompt: () => {}, + undo: () => {}, moveCursorToMessageEnd: () => {}, moveCursorToMessageStart: () => {}, moveCursorToLineStart: () => {}, diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index d08bfc443..2fc360610 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1162,6 +1162,58 @@ export class Editor implements Component, Focusable { this.#moveToMessageEnd(); } + /** + * Undo the last meaningful edit while ignoring transient text that is still present at the cursor. + * Used for command-like autocomplete actions whose typed trigger should not count as the edit being undone. + */ + undoPastTransientText(transientText: string): void { + if (transientText.length === 0) { + this.#applyUndo(); + return; + } + + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + const transientStartCol = this.#state.cursorCol - transientText.length; + if (transientStartCol < 0 || currentLine.slice(transientStartCol, this.#state.cursorCol) !== transientText) { + this.#applyUndo(); + return; + } + + const beforeTransient = currentLine.slice(0, transientStartCol); + const afterTransient = currentLine.slice(this.#state.cursorCol); + this.#historyIndex = -1; + this.#resetKillSequence(); + this.#preferredVisualCol = null; + this.#state.lines[this.#state.cursorLine] = beforeTransient + afterTransient; + this.#setCursorCol(transientStartCol); + + while (true) { + const snapshot = this.#undoStack.at(-1); + if ( + !snapshot || + !this.#matchesTransientUndoSnapshot( + snapshot, + transientText, + transientStartCol, + beforeTransient, + afterTransient, + ) + ) { + break; + } + this.#undoStack.pop(); + } + + if (this.#undoStack.length === 0) { + if (this.onChange) { + this.onChange(this.getText()); + } + return; + } + + this.#applyUndo(); + } + setText(text: string): void { this.#historyIndex = -1; // Exit history browsing mode this.#resetKillSequence(); @@ -1578,6 +1630,30 @@ export class Editor implements Component, Focusable { } } + #matchesTransientUndoSnapshot( + snapshot: EditorState, + transientText: string, + transientStartCol: number, + beforeTransient: string, + afterTransient: string, + ): boolean { + if (snapshot.cursorLine !== this.#state.cursorLine) return false; + if (snapshot.lines.length !== this.#state.lines.length) return false; + + const transientLength = snapshot.cursorCol - transientStartCol; + if (transientLength < 0 || transientLength >= transientText.length) return false; + + for (let i = 0; i < snapshot.lines.length; i++) { + if (i === this.#state.cursorLine) continue; + if (snapshot.lines[i] !== this.#state.lines[i]) return false; + } + + return ( + snapshot.lines[snapshot.cursorLine] === + beforeTransient + transientText.slice(0, transientLength) + afterTransient + ); + } + #recordKill(text: string, direction: "forward" | "backward", accumulate = this.#lastAction === "kill"): void { if (!text) return; this.#killRing.push(text, { prepend: direction === "backward", accumulate }); diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 180b13dd9..c8672e14a 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1334,6 +1334,41 @@ describe("Editor component", () => { expect(editor.getCursor()).toEqual({ line: 0, col: 8 }); }); + it("undoes the last paste when a transient #undo trigger is executed", () => { + const editor = new Editor(defaultEditorTheme); + + editor.handleInput("\x1b[200~pasted text\x1b[201~"); + expect(editor.getText()).toBe("pasted text"); + + editor.handleInput("#"); + editor.handleInput("u"); + editor.handleInput("n"); + editor.handleInput("d"); + editor.handleInput("o"); + expect(editor.getText()).toBe("pasted text#undo"); + + editor.undoPastTransientText("#undo"); + + expect(editor.getText()).toBe(""); + expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); + }); + + it("removes a transient undo trigger even when there is no earlier edit to restore", () => { + const editor = new Editor(defaultEditorTheme); + + editor.handleInput("#"); + editor.handleInput("u"); + editor.handleInput("n"); + editor.handleInput("d"); + editor.handleInput("o"); + expect(editor.getText()).toBe("#undo"); + + editor.undoPastTransientText("#undo"); + + expect(editor.getText()).toBe(""); + expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); + }); + it("handles multiple consecutive up/down movements", () => { const editor = new Editor(defaultEditorTheme);