Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/held-up-key-history-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep a held Up key from scrolling the prompt draft into history.
35 changes: 33 additions & 2 deletions packages/pi-tui/src/components/editor.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,6 +25,13 @@ 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;
Comment thread
bj456736 marked this conversation as resolved.

/** 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);
Expand Down Expand Up @@ -344,6 +351,8 @@ 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;

// Kill ring for Emacs-style kill/yank operations
private killRing = new KillRing();
Expand Down Expand Up @@ -484,6 +493,24 @@ export class Editor implements Component, Focusable {
return currentVisualLine === visualLines.length - 1;
}

/**
* Whether this ↑ event is a held-key repeat: the terminal reported a
* repeat event (Kitty keyboard protocol), or — without that protocol —
* the key 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 isUpArrowRepeat(data: string): boolean {
const now = Date.now();
const repeat = isKittyProtocolActive()
? isKeyRepeat(data)
: now - this.lastUpArrowAt < UP_ARROW_REPEAT_THRESHOLD_MS;
Comment thread
bj456736 marked this conversation as resolved.
Outdated
this.lastUpArrowAt = now;
return repeat;
}

private navigateHistory(direction: 1 | -1): void {
this.lastAction = null;
if (this.history.length === 0) return;
Expand Down Expand Up @@ -946,9 +973,13 @@ export class Editor implements Component, Focusable {

// Arrow key navigation (with history support)
if (kb.matches(data, "tui.editor.cursorUp")) {
const upArrowRepeat = this.isUpArrowRepeat(data);
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.
!(upArrowRepeat && this.historyIndex === -1 && this.history.length > 0)
) {
this.navigateHistory(-1);
} else if (this.isOnFirstVisualLine()) {
Expand Down
231 changes: 168 additions & 63 deletions packages/pi-tui/test/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -136,48 +137,130 @@ 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("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 });

// 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();
}
});

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.handleInput("\x1b[A"); // discrete press - shows "second"
assert.strictEqual(editor.getText(), "second");

mock.timers.tick(30); // held-key repeat keeps browsing
editor.handleInput("\x1b[A");
assert.strictEqual(editor.getText(), "first");
} 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("navigates forward through history with Down arrow", () => {
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("third");
editor.setText("draft");
editor.addToHistory("first");
editor.addToHistory("second");
editor.addToHistory("third");
editor.setText("draft");

// 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
// 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
editor.handleInput("\x1b[A"); // second
editor.handleInput("\x1b[A"); // first

// Navigate back
editor.handleInput("\x1b[B"); // second
assert.strictEqual(editor.getText(), "second");
// 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"); // third
assert.strictEqual(editor.getText(), "third");

editor.handleInput("\x1b[B"); // draft
assert.strictEqual(editor.getText(), "draft");
editor.handleInput("\x1b[B"); // draft
assert.strictEqual(editor.getText(), "draft");
} finally {
mock.timers.reset();
}
});

it("exits history mode when typing a character", () => {
Expand All @@ -192,17 +275,25 @@ 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

// Up should start fresh from most recent
editor.handleInput("\x1b[A");
assert.strictEqual(editor.getText(), "second");
// Up should start fresh from most recent (spaced out so it reads
// as a discrete press, not a held-key repeat)
mock.timers.tick(200);
editor.handleInput("\x1b[A");
assert.strictEqual(editor.getText(), "second");
} finally {
mock.timers.reset();
}
});

it("does not add empty strings to history", () => {
Expand Down Expand Up @@ -394,19 +485,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();
}
});
});

Expand Down Expand Up @@ -480,19 +578,26 @@ 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)
mock.timers.tick(200); // discrete press, not a held-key repeat
editor.handleInput("\x1b[A"); // recall again
editor.handleInput("\x1b[B"); // restore draft again (count=2)
assert.strictEqual(count, 2);
} finally {
mock.timers.reset();
}
});
});

Expand Down
Loading