diff --git a/.changeset/held-up-key-history-guard.md b/.changeset/held-up-key-history-guard.md new file mode 100644 index 0000000000..99824dbcf5 --- /dev/null +++ b/.changeset/held-up-key-history-guard.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep a held Up key from scrolling the prompt draft into history. diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 2a28020932..310764ebf2 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -366,6 +366,13 @@ export class CustomEditor extends Editor { return; } + // A non-↑ key breaks any held-↑ repeat stream. The base Editor resets + // on keys it sees, but this class consumes some (Ctrl+C, Escape, …) + // before they ever reach super.handleInput — reset here too. + if (!matchesKey(normalized, Key.up)) { + this.resetUpArrowRepeatChain(); + } + // Clipboard reads are asynchronous. Queue every key received while a // paste callback is in flight and replay it once the callback settles // (clipboard read + placeholder insert — compression and the daemon diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 6b68d83e19..255f975b13 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -815,3 +815,33 @@ describe('CustomEditor bash mode file completion', () => { expect(calls.every((call) => call.force === true)).toBe(true); }); }); + +describe('CustomEditor held-Up repeat guard', () => { + it('treats an Up after an intercepted shortcut as a fresh press', () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(1_000); + const editor = makeEditor(); + editor.addToHistory('entry'); + editor.setText('ab'); + editor.render(90); // establish layout width for visual-line math + + editor.handleInput('\u001B[A'); // Up: cursor jumps to line start + expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); + + vi.setSystemTime(1_030); + const onCtrlC = vi.fn(); + editor.onCtrlC = onCtrlC; + editor.handleInput('\x03'); // Ctrl+C: intercepted, never reaches super.handleInput + expect(onCtrlC).toHaveBeenCalled(); + + vi.setSystemTime(1_060); + // 60ms after the previous Up, but the intercepted Ctrl+C broke the + // repeat stream — this is a fresh press and must recall history. + editor.handleInput('\u001B[A'); + expect(editor.getText()).toBe('entry'); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 3ee3f9a003..0f014f71f7 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -14,6 +14,7 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 6. **`src/components/markdown.ts` — `CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232(本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`. 7. **`src/components/editor.ts` — opt-in inline slash autocomplete (`inlineSlashTrigger`)**: when enabled, `/` after whitespace mid-input or at the start of a subsequent line auto-triggers autocomplete (`isAtInlineSlashTrigger`), and typing further token characters (letters, digits, `.`, `-`, `_`, `:`) inside that inline token re-triggers the request (`isInInlineSlashContext`) so the in-flight request from the bare `/` cannot go stale before the menu appears; `:` is required because external skill tokens are shaped `/skill:`. Off by default — prose slashes (paths, fractions) keep upstream behavior. Guarding tests: the "Inline slash trigger" group in `test/editor.test.ts`. 8. **`src/autocomplete.ts` / `src/components/select-list.ts` / `src/components/editor.ts` — `data` on autocomplete items + Enter non-submit for marked completions**: autocomplete items may carry an opaque `data` record; when the selected item's `data.inlineSkill` is set, confirming with Enter applies the completion without submitting the editor (ordinary completions keep upstream Enter-submits behavior). Guarding tests: "does not submit when confirming an inline-marked completion with Enter" and "still submits when confirming an unmarked slash completion with Enter" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — held-↑ repeat guard at the history boundary**: a held Up key must not carry the editor from the draft into prompt history. Repeats are detected via Kitty keyboard protocol event types when the protocol is active, otherwise via a 100ms inter-press heuristic (`UP_ARROW_REPEAT_THRESHOLD_MS`); once history was entered by a discrete press, repeats may keep browsing. Because a legacy keyboard's initial repeat delay (X11 defaults to 660ms; macOS/Windows up to ~1s) outruns that threshold and lets the first autorepeat cross anyway, a crossing armed within `UP_ARROW_INITIAL_DELAY_MAX_MS` (armed on legacy input only — Kitty press/repeat events are exact and never arm it) that is followed by a repeat-classified ↑ snaps back out to the draft; any non-↑ key resets the timing chain (via `resetUpArrowRepeatChain()`, which `setText` and subclass-intercepted shortcuts such as CustomEditor's Ctrl+C also call) so an interrupted stream's next ↑ reads as a fresh press. Known limit: on legacy terminals an autorepeat stream configured slower than ~10Hz produces uniform ≥100ms gaps that are indistinguishable from deliberate rapid tapping, so it is intentionally left unguarded rather than eating the double-tap-to-enter gesture (the draft snapshot still protects the content, and Kitty-protocol terminals are exact at any rate). Guarding tests: "keeps a held Up key from crossing into history (repeat guard)", "snaps back out of history when a held Up key's initial repeat delay outruns the threshold", "keeps browsing history while Up is held once history was entered discretely", "treats an Up after an intervening key as a fresh press", "lets a held key keep browsing after a deliberate Kitty press crossing", and "bars Kitty protocol repeat events from entering history" in `test/editor.test.ts` (plus "treats an Up after an intercepted shortcut as a fresh press" in `apps/kimi-code/test/tui/components/editor/custom-editor.test.ts`). ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 276cac7e7a..b9dfea3db8 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -1,6 +1,6 @@ import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocomplete.ts"; import { getKeybindings } from "../keybindings.ts"; -import { decodePrintableKey, matchesKey } from "../keys.ts"; +import { decodePrintableKey, isKeyRepeat, isKittyProtocolActive, matchesKey } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; import { PasteBurst } from "../paste-burst.ts"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts"; @@ -25,6 +25,20 @@ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g; /** Non-global version for single-segment testing. */ const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/; +/** + * Two ↑ events arriving closer together than this are treated as a held key + * (key repeat) rather than discrete presses. Terminals emit held-key repeats + * every ~25-40ms; humans rarely re-press faster than ~100ms. + */ +const UP_ARROW_REPEAT_THRESHOLD_MS = 100; + +/** + * Upper bound of a keyboard's initial repeat delay (how long a key must be + * held before autorepeat starts) that the history snap-back accounts for. + * X11 defaults to 660ms; macOS and Windows repeat delays top out around 1s. + */ +const UP_ARROW_INITIAL_DELAY_MAX_MS = 1200; + /** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */ function isPasteMarker(segment: string): boolean { return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment); @@ -344,6 +358,14 @@ export class Editor implements Component, Focusable { private historyDraft: EditorState | null = null; private hostHistoryDraft: unknown = undefined; private historyFilter: ((entry: string) => boolean) | null = null; + /** Timestamp of the previous ↑ key event, for held-key repeat detection. */ + private lastUpArrowAt = 0; + /** + * Set when ↑ crosses from the draft into history soon after a previous ↑: + * the crossing may still prove to be a held key's first autorepeat (its + * initial delay outran the repeat threshold), arming the snap-back. + */ + private pendingHeldUpCrossingAt = 0; // Kill ring for Emacs-style kill/yank operations private killRing = new KillRing(); @@ -484,6 +506,34 @@ export class Editor implements Component, Focusable { return currentVisualLine === visualLines.length - 1; } + /** + * Classify this ↑ event: a held-key repeat when the terminal reported a + * repeat event (Kitty keyboard protocol) or — without that protocol — + * when it arrived faster after the previous ↑ than a human re-presses. + * A user holding ↑ to reach the top of a long draft expects to stop + * there, so repeats must not carry the editor from the draft into + * history browsing; once history was entered by a discrete press, + * repeats may keep browsing. + */ + private upArrowRepeatInfo(data: string): { now: number; gap: number; repeat: boolean } { + const now = Date.now(); + const gap = now - this.lastUpArrowAt; + this.lastUpArrowAt = now; + const repeat = isKittyProtocolActive() + ? isKeyRepeat(data) + : gap < UP_ARROW_REPEAT_THRESHOLD_MS; + return { now, gap, repeat }; + } + + /** + * Break the held-↑ repeat stream: the next ↑ reads as a fresh press. + * Called for non-↑ keys here, and by hosts for programmatic text changes + * or subclass-intercepted shortcuts the base class never sees. + */ + resetUpArrowRepeatChain(): void { + this.lastUpArrowAt = 0; + } + private navigateHistory(direction: 1 | -1): void { this.lastAction = null; if (this.history.length === 0) return; @@ -698,6 +748,12 @@ export class Editor implements Component, Focusable { handleInput(data: string): void { const kb = getKeybindings(); + // A non-↑ key between two ↑ presses breaks the repeat stream — the + // next ↑ is a fresh press, not an autorepeat of the earlier one. + if (!kb.matches(data, "tui.editor.cursorUp")) { + this.resetUpArrowRepeatChain(); + } + // Handle character jump mode (awaiting next character to jump to) if (this.jumpMode !== null) { // Cancel if the hotkey is pressed again @@ -946,10 +1002,43 @@ export class Editor implements Component, Focusable { // Arrow key navigation (with history support) if (kb.matches(data, "tui.editor.cursorUp")) { + const { now, gap, repeat } = this.upArrowRepeatInfo(data); + + // Snap back: a repeat-classified ↑ right after a recent crossing + // proves the "discrete" press that crossed was really a held key's + // first autorepeat — the keyboard's initial repeat delay outran + // the repeat threshold. Return to the draft and stay there. + if ( + repeat && + this.pendingHeldUpCrossingAt > 0 && + now - this.pendingHeldUpCrossingAt < UP_ARROW_INITIAL_DELAY_MAX_MS && + this.historyIndex > -1 + ) { + this.pendingHeldUpCrossingAt = 0; + this.navigateHistory(1); + return; + } + if ( this.isOnFirstVisualLine() && - (this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0) + (this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0) && + // A held ↑ must not cross from the draft into history; a discrete + // press still enters, and once browsing, repeats keep browsing. + !(repeat && this.historyIndex === -1 && this.history.length > 0) ) { + if (this.historyIndex === -1) { + // A crossing soon after a previous ↑ may still prove to be a + // held key's first autorepeat — arm the snap-back above. + // Legacy input only: with the Kitty protocol the crossing + // event's press/repeat type is exact, so a press crossing + // is deliberate and holding that key afterwards must be + // free to keep browsing. + this.pendingHeldUpCrossingAt = + !isKittyProtocolActive() && gap < UP_ARROW_INITIAL_DELAY_MAX_MS ? now : 0; + } else { + // Browsing past the first entry is deliberate navigation. + this.pendingHeldUpCrossingAt = 0; + } this.navigateHistory(-1); } else if (this.isOnFirstVisualLine()) { // Already at top - jump to start of line @@ -1144,6 +1233,7 @@ export class Editor implements Component, Focusable { this.cancelAutocomplete(); this.lastAction = null; this.exitHistoryBrowsing(); + this.resetUpArrowRepeatChain(); const normalized = this.normalizeText(text); // Push undo snapshot if content differs (makes programmatic changes undoable) if (this.getText() !== normalized) { diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 7ed25e0241..a5916c1bd6 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -3,6 +3,7 @@ import { describe, it, mock } from "node:test"; import { stripVTControlCharacters } from "node:util"; import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts"; import { Editor, wordWrapLine } from "../src/components/editor.ts"; +import { setKittyProtocolActive } from "../src/keys.ts"; import { PasteBurst } from "../src/paste-burst.ts"; import type { TUI } from "../src/tui.ts"; import { TuiMainScreen } from "../src/tui-main-screen.ts"; @@ -136,48 +137,225 @@ describe("Editor component", () => { }); it("jumps to start before entering history from a non-empty draft", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); + // Mocked clock: the second Up must read as a discrete press, not a + // held-key repeat (repeats are barred from entering history). + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); - editor.addToHistory("prompt"); - editor.setText("draft"); - editor.handleInput("\x1b[D"); - editor.handleInput("\x1b[D"); + editor.addToHistory("prompt"); + editor.setText("draft"); + editor.handleInput("\x1b[D"); + editor.handleInput("\x1b[D"); - editor.handleInput("\x1b[A"); // Up - jumps to start before history browsing - assert.strictEqual(editor.getText(), "draft"); - assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + editor.handleInput("\x1b[A"); // Up - jumps to start before history browsing + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); - editor.handleInput("\x1b[A"); // Up at start - shows "prompt" - assert.strictEqual(editor.getText(), "prompt"); + mock.timers.tick(200); + editor.handleInput("\x1b[A"); // Up at start - shows "prompt" + assert.strictEqual(editor.getText(), "prompt"); - editor.handleInput("\x1b[B"); // Down - restores draft - assert.strictEqual(editor.getText(), "draft"); - assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + editor.handleInput("\x1b[B"); // Down - restores draft + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + } finally { + mock.timers.reset(); + } }); - it("navigates forward through history with Down arrow", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); + it("keeps a held Up key from crossing into history (repeat guard)", () => { + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("older"); + editor.addToHistory("newer"); + editor.setText("line one\nline two\nline three"); + + // Held key: repeats arrive every ~30ms. The cursor climbs to the + // top and stops there instead of entering history. + for (let i = 0; i < 8; i++) { + editor.handleInput("\x1b[A"); + mock.timers.tick(30); + } + assert.strictEqual(editor.getText(), "line one\nline two\nline three"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); - editor.addToHistory("first"); - editor.addToHistory("second"); - editor.addToHistory("third"); - editor.setText("draft"); + // Releasing and pressing again is a discrete press: it enters history. + mock.timers.tick(200); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "newer"); + } finally { + mock.timers.reset(); + } + }); - // Go to oldest - editor.handleInput("\x1b[A"); // start of draft - editor.handleInput("\x1b[A"); // third - editor.handleInput("\x1b[A"); // second - editor.handleInput("\x1b[A"); // first + it("keeps browsing history while Up is held once history was entered discretely", () => { + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("first"); + editor.addToHistory("second"); + editor.addToHistory("third"); - // Navigate back - editor.handleInput("\x1b[B"); // second - assert.strictEqual(editor.getText(), "second"); + editor.handleInput("\x1b[A"); // discrete press - shows "third" + assert.strictEqual(editor.getText(), "third"); - editor.handleInput("\x1b[B"); // third - assert.strictEqual(editor.getText(), "third"); + mock.timers.tick(200); + editor.handleInput("\x1b[A"); // discrete press - shows "second" + assert.strictEqual(editor.getText(), "second"); - editor.handleInput("\x1b[B"); // draft - assert.strictEqual(editor.getText(), "draft"); + // The user then holds the key: the first autorepeat outruns the + // repeat threshold (initial delay), but browsing continues — + // navigation past the first entry is already deliberate. + mock.timers.tick(500); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "first"); + + mock.timers.tick(30); // held-key repeat - no snap-back, keeps browsing + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "first"); + } finally { + mock.timers.reset(); + } + }); + + it("snaps back out of history when a held Up key's initial repeat delay outruns the threshold", () => { + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("older"); + editor.addToHistory("newer"); + editor.setText("one line draft"); + + editor.handleInput("\x1b[A"); // discrete press - jumps to line start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // First autorepeat after the keyboard's initial repeat delay: + // too slow for the repeat threshold, so it still crosses... + mock.timers.tick(500); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "newer"); + + // ...but the next repeat arrives fast and proves the hold: + // snap back out to the draft. + mock.timers.tick(30); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "one line draft"); + + // Still held: further repeats stay on the draft. + mock.timers.tick(30); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "one line draft"); + + // Released and pressed again: a discrete press enters history. + mock.timers.tick(200); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "newer"); + } finally { + mock.timers.reset(); + } + }); + + it("treats an Up after an intervening key as a fresh press", () => { + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("entry"); + + editor.handleInput("\x1b[A"); // discrete press - enters history + assert.strictEqual(editor.getText(), "entry"); + + mock.timers.tick(30); + editor.handleInput("\x1b[B"); // Down restores the empty draft + + mock.timers.tick(30); // quick, but the Down broke the repeat stream + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "entry"); + } finally { + mock.timers.reset(); + } + }); + + it("bars Kitty protocol repeat events from entering history", () => { + setKittyProtocolActive(true); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("prompt"); + editor.setText("draft"); + + editor.handleInput("\x1b[A"); // discrete press - jumps to line start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1b[1;1:2A"); // held-key repeat - stays on the draft + assert.strictEqual(editor.getText(), "draft"); + + editor.handleInput("\x1b[A"); // discrete press - enters history + assert.strictEqual(editor.getText(), "prompt"); + } finally { + setKittyProtocolActive(false); + } + }); + + it("lets a held key keep browsing after a deliberate Kitty press crossing", () => { + setKittyProtocolActive(true); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("older"); + editor.addToHistory("newer"); + editor.setText("draft"); + + editor.handleInput("\x1b[A"); // press - jumps to line start + editor.handleInput("\x1b[A"); // deliberate press - enters history + assert.strictEqual(editor.getText(), "newer"); + + // Holding that same key sends explicit repeat events: they must + // keep browsing, not snap back — the press crossing was exact, + // so no legacy snap-back is ever armed under Kitty. + editor.handleInput("\x1b[1;1:2A"); + assert.strictEqual(editor.getText(), "older"); + } finally { + setKittyProtocolActive(false); + } + }); + + it("navigates forward through history with Down arrow", () => { + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + editor.addToHistory("third"); + editor.setText("draft"); + + // Go to oldest (each Up spaced out so it reads as a discrete press) + editor.handleInput("\x1b[A"); // start of draft + mock.timers.tick(200); + editor.handleInput("\x1b[A"); // third + mock.timers.tick(200); + editor.handleInput("\x1b[A"); // second + mock.timers.tick(200); + editor.handleInput("\x1b[A"); // first + + // Navigate back + editor.handleInput("\x1b[B"); // second + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("\x1b[B"); // third + assert.strictEqual(editor.getText(), "third"); + + editor.handleInput("\x1b[B"); // draft + assert.strictEqual(editor.getText(), "draft"); + } finally { + mock.timers.reset(); + } }); it("exits history mode when typing a character", () => { @@ -192,17 +370,24 @@ describe("Editor component", () => { }); it("exits history mode on setText", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); - editor.addToHistory("first"); - editor.addToHistory("second"); + editor.addToHistory("first"); + editor.addToHistory("second"); - editor.handleInput("\x1b[A"); // Up - shows "second" - editor.setText(""); // External clear + editor.handleInput("\x1b[A"); // Up - shows "second" + editor.setText(""); // External clear - also breaks the ↑ repeat stream - // Up should start fresh from most recent - editor.handleInput("\x1b[A"); - assert.strictEqual(editor.getText(), "second"); + // Up should start fresh from most recent; no clock advance is + // needed because setText reset the repeat chain. + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "second"); + } finally { + mock.timers.reset(); + } }); it("does not add empty strings to history", () => { @@ -394,19 +579,26 @@ describe("Editor component", () => { }); it("still restores the draft with a filter active", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); - editor.addToHistory("!cmd"); - editor.setHistoryFilter((entry) => entry.startsWith("!")); - editor.setText("draft"); - editor.handleInput("\x1b[D"); - editor.handleInput("\x1b[D"); - - editor.handleInput("\x1b[A"); // to line start - editor.handleInput("\x1b[A"); // recall "!cmd" - assert.strictEqual(editor.getText(), "!cmd"); - - editor.handleInput("\x1b[B"); // restore draft - assert.strictEqual(editor.getText(), "draft"); + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("!cmd"); + editor.setHistoryFilter((entry) => entry.startsWith("!")); + editor.setText("draft"); + editor.handleInput("\x1b[D"); + editor.handleInput("\x1b[D"); + + editor.handleInput("\x1b[A"); // to line start + mock.timers.tick(200); // discrete press, not a held-key repeat + editor.handleInput("\x1b[A"); // recall "!cmd" + assert.strictEqual(editor.getText(), "!cmd"); + + editor.handleInput("\x1b[B"); // restore draft + assert.strictEqual(editor.getText(), "draft"); + } finally { + mock.timers.reset(); + } }); }); @@ -480,19 +672,27 @@ describe("Editor component", () => { }); it("saves and restores host state across multiple browse sessions", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); - editor.addToHistory("entry"); - let count = 0; - editor.onHistoryDraftSave = () => "state"; - editor.onHistoryDraftRestore = () => { - count++; - }; + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("entry"); + let count = 0; + editor.onHistoryDraftSave = () => "state"; + editor.onHistoryDraftRestore = () => { + count++; + }; - editor.handleInput("\x1b[A"); // recall - editor.handleInput("\x1b[B"); // restore draft (count=1) - editor.handleInput("\x1b[A"); // recall again - editor.handleInput("\x1b[B"); // restore draft again (count=2) - assert.strictEqual(count, 2); + editor.handleInput("\x1b[A"); // recall + editor.handleInput("\x1b[B"); // restore draft (count=1) + // No clock advance needed: the intervening Down broke the ↑ + // repeat stream, so the next Up reads as a fresh press. + editor.handleInput("\x1b[A"); // recall again + editor.handleInput("\x1b[B"); // restore draft again (count=2) + assert.strictEqual(count, 2); + } finally { + mock.timers.reset(); + } }); });