Skip to content

Commit 2a615d1

Browse files
authored
Merge pull request #59 from Lellansin/feat/prompt-undo-redo
feat: Add prompt undo and redo shortcuts
2 parents 80d81b2 + 71944b9 commit 2a615d1

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";
@@ -123,6 +130,7 @@ export const PromptInput = React.memo(function PromptInput({
123130
const [draftBeforeHistory, setDraftBeforeHistory] = useState<string | null>(null);
124131
const [hasTerminalFocus, setHasTerminalFocus] = useState(true);
125132
const lastCtrlDAt = React.useRef<number>(0);
133+
const undoRedoRef = React.useRef(createPromptUndoRedoState());
126134

127135
const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]);
128136
const slashToken = getCurrentSlashToken(buffer);
@@ -237,6 +245,7 @@ export const PromptInput = React.memo(function PromptInput({
237245
setStatusMessage("Interrupting…");
238246
} else if (!isEmpty(buffer)) {
239247
setBuffer(EMPTY_BUFFER);
248+
clearUndoRedoStacks();
240249
} else {
241250
setStatusMessage("press ctrl+d to exit");
242251
}
@@ -476,6 +485,14 @@ export const PromptInput = React.memo(function PromptInput({
476485
updateBuffer((s) => insertText(s, "\n"));
477486
return;
478487
}
488+
if (key.ctrl && key.shift && input === "-") {
489+
redo();
490+
return;
491+
}
492+
if (key.ctrl && input === "-") {
493+
undo();
494+
return;
495+
}
479496
if (input.startsWith("\u001B")) {
480497
// Unhandled escape sequence (e.g. function keys); ignore to avoid inserting garbage.
481498
return;
@@ -491,14 +508,40 @@ export const PromptInput = React.memo(function PromptInput({
491508
{ isActive: !disabled }
492509
);
493510

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

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

504547
function navigateHistory(direction: -1 | 1): void {
@@ -552,6 +595,7 @@ export const PromptInput = React.memo(function PromptInput({
552595
if (item.kind === "new") {
553596
onSubmit({ text: "", imageUrls: [], command: "new" });
554597
setBuffer(EMPTY_BUFFER);
598+
clearUndoRedoStacks();
555599
setImageUrls([]);
556600
setSelectedSkills([]);
557601
setShowSkillsDropdown(false);
@@ -560,6 +604,7 @@ export const PromptInput = React.memo(function PromptInput({
560604
if (item.kind === "init") {
561605
onSubmit(buildInitPromptSubmission(selectedSkills));
562606
setBuffer(EMPTY_BUFFER);
607+
clearUndoRedoStacks();
563608
setImageUrls([]);
564609
setSelectedSkills([]);
565610
setShowSkillsDropdown(false);
@@ -568,6 +613,7 @@ export const PromptInput = React.memo(function PromptInput({
568613
if (item.kind === "resume") {
569614
onSubmit({ text: "", imageUrls: [], command: "resume" });
570615
setBuffer(EMPTY_BUFFER);
616+
clearUndoRedoStacks();
571617
setImageUrls([]);
572618
setSelectedSkills([]);
573619
setShowSkillsDropdown(false);
@@ -576,6 +622,7 @@ export const PromptInput = React.memo(function PromptInput({
576622
if (item.kind === "mcp") {
577623
onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" });
578624
setBuffer(EMPTY_BUFFER);
625+
clearUndoRedoStacks();
579626
setImageUrls([]);
580627
setSelectedSkills([]);
581628
setShowSkillsDropdown(false);
@@ -584,6 +631,7 @@ export const PromptInput = React.memo(function PromptInput({
584631
if (item.kind === "exit") {
585632
onSubmit({ text: "/exit", imageUrls: [], command: "exit" });
586633
setBuffer(EMPTY_BUFFER);
634+
clearUndoRedoStacks();
587635
return;
588636
}
589637
}
@@ -613,6 +661,7 @@ export const PromptInput = React.memo(function PromptInput({
613661
selectedSkills,
614662
});
615663
setBuffer(EMPTY_BUFFER);
664+
clearUndoRedoStacks();
616665
setImageUrls([]);
617666
setSelectedSkills([]);
618667
setShowSkillsDropdown(false);
@@ -629,6 +678,7 @@ export const PromptInput = React.memo(function PromptInput({
629678
function clearSlashToken(): void {
630679
exitHistoryBrowsing();
631680
setBuffer((state) => removeCurrentSlashToken(state));
681+
clearUndoRedoStacks();
632682
}
633683

634684
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)