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:
@@ -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: () => {},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user