Skip to content

Commit 71944b9

Browse files
committed
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.
1 parent d5ad0fb commit 71944b9

5 files changed

Lines changed: 267 additions & 1 deletion

File tree

src/tests/promptInputKeys.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,44 @@ test("parseTerminalInput recognizes ctrl+x as the image attachment clear shortcu
128128
assert.equal(isClearImageAttachmentsShortcut(input, key), true);
129129
});
130130

131+
test("parseTerminalInput recognizes ctrl+- modifyOtherKeys sequence (standard)", () => {
132+
const { input, key } = parseTerminalInput("\u001B[45;5u");
133+
assert.equal(input, "-");
134+
assert.equal(key.ctrl, true);
135+
assert.equal(key.meta, false);
136+
});
137+
138+
test("parseTerminalInput recognizes ctrl+- modifyOtherKeys sequence (extended)", () => {
139+
const { input, key } = parseTerminalInput("\u001B[27;5;45~");
140+
assert.equal(input, "-");
141+
assert.equal(key.ctrl, true);
142+
assert.equal(key.meta, false);
143+
});
144+
145+
test("parseTerminalInput recognizes raw 0x1F as ctrl+shift+- (redo)", () => {
146+
const { input, key } = parseTerminalInput("\u001F");
147+
assert.equal(input, "-");
148+
assert.equal(key.ctrl, true);
149+
assert.equal(key.shift, true);
150+
assert.equal(key.meta, false);
151+
});
152+
153+
test("parseTerminalInput recognizes ctrl+shift+- modifyOtherKeys sequence (standard)", () => {
154+
const { input, key } = parseTerminalInput("\u001B[45;6u");
155+
assert.equal(input, "-");
156+
assert.equal(key.ctrl, true);
157+
assert.equal(key.shift, true);
158+
assert.equal(key.meta, false);
159+
});
160+
161+
test("parseTerminalInput recognizes ctrl+shift+- modifyOtherKeys sequence (extended)", () => {
162+
const { input, key } = parseTerminalInput("\u001B[27;6;45~");
163+
assert.equal(input, "-");
164+
assert.equal(key.ctrl, true);
165+
assert.equal(key.shift, true);
166+
assert.equal(key.meta, false);
167+
});
168+
131169
test("formatImageAttachmentStatus formats the image count label", () => {
132170
assert.equal(formatImageAttachmentStatus(0), "");
133171
assert.equal(formatImageAttachmentStatus(1), "📎 1 image attached");

src/tests/promptUndoRedo.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import { removeCurrentSlashToken } from "../ui";
5+
import {
6+
clearPromptUndoRedoState,
7+
createPromptUndoRedoState,
8+
recordPromptEdit,
9+
redoPromptEdit,
10+
undoPromptEdit,
11+
} from "../ui/promptUndoRedo";
12+
13+
test("prompt undo and redo restore edited buffer states", () => {
14+
const history = createPromptUndoRedoState();
15+
const empty = { text: "", cursor: 0 };
16+
const hello = { text: "hello", cursor: 5 };
17+
18+
recordPromptEdit(history, empty, hello);
19+
20+
assert.deepEqual(undoPromptEdit(history, hello), empty);
21+
assert.deepEqual(redoPromptEdit(history, empty), hello);
22+
});
23+
24+
test("prompt redo history is cleared after a new edit", () => {
25+
const history = createPromptUndoRedoState();
26+
const empty = { text: "", cursor: 0 };
27+
const first = { text: "first", cursor: 5 };
28+
const second = { text: "second", cursor: 6 };
29+
30+
recordPromptEdit(history, empty, first);
31+
assert.deepEqual(undoPromptEdit(history, first), empty);
32+
33+
recordPromptEdit(history, empty, second);
34+
35+
assert.equal(redoPromptEdit(history, second), null);
36+
});
37+
38+
test("prompt undo ignores cursor-only movement", () => {
39+
const history = createPromptUndoRedoState();
40+
const before = { text: "hello", cursor: 5 };
41+
const after = { text: "hello", cursor: 0 };
42+
43+
recordPromptEdit(history, before, after);
44+
45+
assert.equal(undoPromptEdit(history, after), null);
46+
});
47+
48+
test("clearing consumed slash token drops undo and redo history", () => {
49+
const history = createPromptUndoRedoState();
50+
const empty = { text: "", cursor: 0 };
51+
const slashCommand = { text: "/model", cursor: 6 };
52+
53+
recordPromptEdit(history, empty, slashCommand);
54+
const cleared = removeCurrentSlashToken(slashCommand);
55+
clearPromptUndoRedoState(history);
56+
57+
assert.deepEqual(cleared, { text: "", cursor: 0 });
58+
assert.equal(undoPromptEdit(history, cleared), null);
59+
assert.equal(redoPromptEdit(history, cleared), null);
60+
});

src/ui/PromptInput.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ import {
2121
moveUp,
2222
} from "./promptBuffer";
2323
import type { PromptBufferState } from "./promptBuffer";
24+
import {
25+
clearPromptUndoRedoState,
26+
createPromptUndoRedoState,
27+
recordPromptEdit,
28+
redoPromptEdit,
29+
undoPromptEdit,
30+
} from "./promptUndoRedo";
2431
import { buildSlashCommands, filterSlashCommands, findExactSlashCommand } from "./slashCommands";
2532
import type { SlashCommandItem } from "./slashCommands";
2633
import { readClipboardImageAsync } from "./clipboard";
@@ -122,6 +129,7 @@ export const PromptInput = React.memo(function PromptInput({
122129
const [draftBeforeHistory, setDraftBeforeHistory] = useState<string | null>(null);
123130
const [hasTerminalFocus, setHasTerminalFocus] = useState(true);
124131
const lastCtrlDAt = React.useRef<number>(0);
132+
const undoRedoRef = React.useRef(createPromptUndoRedoState());
125133

126134
const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]);
127135
const slashToken = getCurrentSlashToken(buffer);
@@ -236,6 +244,7 @@ export const PromptInput = React.memo(function PromptInput({
236244
setStatusMessage("Interrupting…");
237245
} else if (!isEmpty(buffer)) {
238246
setBuffer(EMPTY_BUFFER);
247+
clearUndoRedoStacks();
239248
} else {
240249
setStatusMessage("press ctrl+d to exit");
241250
}
@@ -475,6 +484,14 @@ export const PromptInput = React.memo(function PromptInput({
475484
updateBuffer((s) => insertText(s, "\n"));
476485
return;
477486
}
487+
if (key.ctrl && key.shift && input === "-") {
488+
redo();
489+
return;
490+
}
491+
if (key.ctrl && input === "-") {
492+
undo();
493+
return;
494+
}
478495
if (input.startsWith("\u001B")) {
479496
// Unhandled escape sequence (e.g. function keys); ignore to avoid inserting garbage.
480497
return;
@@ -490,14 +507,40 @@ export const PromptInput = React.memo(function PromptInput({
490507
{ isActive: !disabled }
491508
);
492509

510+
function undo(): void {
511+
const previous = undoPromptEdit(undoRedoRef.current, buffer);
512+
if (!previous) {
513+
return;
514+
}
515+
exitHistoryBrowsing();
516+
setBuffer(previous);
517+
}
518+
519+
function redo(): void {
520+
const next = redoPromptEdit(undoRedoRef.current, buffer);
521+
if (!next) {
522+
return;
523+
}
524+
exitHistoryBrowsing();
525+
setBuffer(next);
526+
}
527+
528+
function clearUndoRedoStacks(): void {
529+
clearPromptUndoRedoState(undoRedoRef.current);
530+
}
531+
493532
function exitHistoryBrowsing(): void {
494533
setHistoryCursor(-1);
495534
setDraftBeforeHistory(null);
496535
}
497536

498537
function updateBuffer(updater: (state: PromptBufferState) => PromptBufferState): void {
499538
exitHistoryBrowsing();
500-
setBuffer(updater);
539+
setBuffer((current) => {
540+
const next = updater(current);
541+
recordPromptEdit(undoRedoRef.current, current, next);
542+
return next;
543+
});
501544
}
502545

503546
function navigateHistory(direction: -1 | 1): void {
@@ -551,6 +594,7 @@ export const PromptInput = React.memo(function PromptInput({
551594
if (item.kind === "new") {
552595
onSubmit({ text: "", imageUrls: [], command: "new" });
553596
setBuffer(EMPTY_BUFFER);
597+
clearUndoRedoStacks();
554598
setImageUrls([]);
555599
setSelectedSkills([]);
556600
setShowSkillsDropdown(false);
@@ -559,6 +603,7 @@ export const PromptInput = React.memo(function PromptInput({
559603
if (item.kind === "init") {
560604
onSubmit(buildInitPromptSubmission(selectedSkills));
561605
setBuffer(EMPTY_BUFFER);
606+
clearUndoRedoStacks();
562607
setImageUrls([]);
563608
setSelectedSkills([]);
564609
setShowSkillsDropdown(false);
@@ -567,6 +612,7 @@ export const PromptInput = React.memo(function PromptInput({
567612
if (item.kind === "resume") {
568613
onSubmit({ text: "", imageUrls: [], command: "resume" });
569614
setBuffer(EMPTY_BUFFER);
615+
clearUndoRedoStacks();
570616
setImageUrls([]);
571617
setSelectedSkills([]);
572618
setShowSkillsDropdown(false);
@@ -575,6 +621,7 @@ export const PromptInput = React.memo(function PromptInput({
575621
if (item.kind === "mcp") {
576622
onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" });
577623
setBuffer(EMPTY_BUFFER);
624+
clearUndoRedoStacks();
578625
setImageUrls([]);
579626
setSelectedSkills([]);
580627
setShowSkillsDropdown(false);
@@ -583,6 +630,7 @@ export const PromptInput = React.memo(function PromptInput({
583630
if (item.kind === "exit") {
584631
onSubmit({ text: "/exit", imageUrls: [], command: "exit" });
585632
setBuffer(EMPTY_BUFFER);
633+
clearUndoRedoStacks();
586634
return;
587635
}
588636
}
@@ -612,6 +660,7 @@ export const PromptInput = React.memo(function PromptInput({
612660
selectedSkills,
613661
});
614662
setBuffer(EMPTY_BUFFER);
663+
clearUndoRedoStacks();
615664
setImageUrls([]);
616665
setSelectedSkills([]);
617666
setShowSkillsDropdown(false);
@@ -628,6 +677,7 @@ export const PromptInput = React.memo(function PromptInput({
628677
function clearSlashToken(): void {
629678
exitHistoryBrowsing();
630679
setBuffer((state) => removeCurrentSlashToken(state));
680+
clearUndoRedoStacks();
631681
}
632682

633683
function openModelDropdown(): void {

src/ui/prompt/useTerminalInput.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,75 @@ const META_RIGHT_SEQUENCES = new Set(["\u001B[1;3C", "\u001B[3C", "\u001Bf"]);
3535
const TERMINAL_FOCUS_IN = "\u001B[I";
3636
const TERMINAL_FOCUS_OUT = "\u001B[O";
3737

38+
// Ctrl+- (minus) sequences in modifyOtherKeys mode.
39+
// \u001B[45;5u — standard format: keycode=45 ('-'), modifier=5 (Ctrl)
40+
// \u001B[27;5;45~ — extended format for function-like reporting
41+
const CTRL_MINUS_SEQUENCES = new Set(["\u001B[45;5u", "\u001B[27;5;45~"]);
42+
43+
// Ctrl+Shift+- (minus) sequences in modifyOtherKeys mode.
44+
// \u001B[45;6u — standard format: keycode=45 ('-'), modifier=6 (Ctrl+Shift)
45+
// \u001B[27;6;45~ — extended format for function-like reporting
46+
const CTRL_SHIFT_MINUS_SEQUENCES = new Set(["\u001B[45;6u", "\u001B[27;6;45~"]);
47+
3848
export function parseTerminalInput(data: Buffer | string): { input: string; key: InputKey } {
3949
const raw = String(data);
4050
let input = raw;
51+
52+
// Ctrl+- undo shortcut: only via modifyOtherKeys CSI sequences.
53+
// Raw 0x1F is NOT included here because it represents Ctrl+_ (Ctrl+Shift+-
54+
// on US keyboards), which should trigger redo instead.
55+
if (CTRL_MINUS_SEQUENCES.has(raw)) {
56+
input = "-";
57+
const key: InputKey = {
58+
upArrow: false,
59+
downArrow: false,
60+
leftArrow: false,
61+
rightArrow: false,
62+
home: false,
63+
end: false,
64+
pageDown: false,
65+
pageUp: false,
66+
return: false,
67+
escape: false,
68+
ctrl: true,
69+
shift: false,
70+
tab: false,
71+
backspace: false,
72+
delete: false,
73+
meta: false,
74+
focusIn: false,
75+
focusOut: false,
76+
};
77+
return { input, key };
78+
}
79+
80+
// Ctrl+Shift+- redo shortcut: modifyOtherKeys CSI sequences + raw 0x1F fallback.
81+
// \x1F is Ctrl+_ which on US keyboards = Ctrl+Shift+-.
82+
if (CTRL_SHIFT_MINUS_SEQUENCES.has(raw) || raw === "\u001F") {
83+
input = "-";
84+
const key: InputKey = {
85+
upArrow: false,
86+
downArrow: false,
87+
leftArrow: false,
88+
rightArrow: false,
89+
home: false,
90+
end: false,
91+
pageDown: false,
92+
pageUp: false,
93+
return: false,
94+
escape: false,
95+
ctrl: true,
96+
shift: true,
97+
tab: false,
98+
backspace: false,
99+
delete: false,
100+
meta: false,
101+
focusIn: false,
102+
focusOut: false,
103+
};
104+
return { input, key };
105+
}
106+
41107
const key: InputKey = {
42108
upArrow: raw === "\u001B[A",
43109
downArrow: raw === "\u001B[B",

src/ui/promptUndoRedo.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { PromptBufferState } from "./promptBuffer";
2+
3+
export type PromptUndoRedoState = {
4+
undoStack: PromptBufferState[];
5+
redoStack: PromptBufferState[];
6+
};
7+
8+
export function createPromptUndoRedoState(): PromptUndoRedoState {
9+
return { undoStack: [], redoStack: [] };
10+
}
11+
12+
export function recordPromptEdit(
13+
history: PromptUndoRedoState,
14+
current: PromptBufferState,
15+
next: PromptBufferState,
16+
maxUndoEntries = 1000
17+
): void {
18+
if (next.text === current.text || next.text === history.undoStack.at(-1)?.text) {
19+
return;
20+
}
21+
22+
history.undoStack.push(current);
23+
if (history.undoStack.length > maxUndoEntries) {
24+
history.undoStack = history.undoStack.slice(-maxUndoEntries);
25+
}
26+
history.redoStack = [];
27+
}
28+
29+
export function undoPromptEdit(history: PromptUndoRedoState, current: PromptBufferState): PromptBufferState | null {
30+
const previous = history.undoStack.pop();
31+
if (!previous) {
32+
return null;
33+
}
34+
35+
history.redoStack.push(current);
36+
return previous;
37+
}
38+
39+
export function redoPromptEdit(history: PromptUndoRedoState, current: PromptBufferState): PromptBufferState | null {
40+
const next = history.redoStack.pop();
41+
if (!next) {
42+
return null;
43+
}
44+
45+
history.undoStack.push(current);
46+
return next;
47+
}
48+
49+
export function clearPromptUndoRedoState(history: PromptUndoRedoState): void {
50+
history.undoStack = [];
51+
history.redoStack = [];
52+
}

0 commit comments

Comments
 (0)