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
This commit is contained in:
daandden
2026-03-16 20:05:21 +07:00
committed by GitHub
parent 24ef2302ea
commit b7978c9e0d
10 changed files with 270 additions and 64 deletions
@@ -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));
}
/**
@@ -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));
@@ -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(),
@@ -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",
@@ -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");
}
@@ -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("");
});
});
@@ -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);
}
});
});
@@ -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: () => {},
+76
View File
@@ -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 });
+35
View File
@@ -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);