Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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/up-history-empty-draft-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

The Up arrow recalls prompt history only when the input box is empty.
1 change: 1 addition & 0 deletions packages/pi-tui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>`. 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` — history recall only from an empty draft**: the Up-arrow path enters history browsing only when the editor is completely empty or already browsing (`isEditorEmpty() || this.historyIndex > -1`); upstream also enters whenever the cursor sits at column 0 of the first visual line, which sweeps a non-empty draft into history. With a draft present, Up stays pure cursor movement (first visual line → jump to line start). The dedicated `tui.editor.historyPrevious` action applies the same empty-draft guard (`historyNext` needs none — it is already a no-op outside browsing); when the guard rejects a press whose key also matches `cursorUp` (default bindings survive key reuse), the event falls through to the cursor branch so the shared key still moves the cursor instead of dead-ending. A whitespace-only draft counts as content. Guarding tests: "never enters history from a non-empty draft", "treats a whitespace-only draft as content for history recall", and "does not enter history at the start of the first line of a multi-line draft" in `test/editor.test.ts`, plus "does not enter history from a non-empty draft" and "falls through to cursor movement when the guard blocks a shared Up binding" in `test/editor-history-keybindings.test.ts`.

## Acceptance after syncing from upstream

Expand Down
23 changes: 16 additions & 7 deletions packages/pi-tui/src/components/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -873,11 +873,21 @@ export class Editor implements Component, Focusable {
return;
}

// Dedicated history actions always browse entries instead of moving the cursor.
// Dedicated history actions browse entries instead of moving the cursor.
// Like the Up-arrow path, entering history from the draft requires an empty
// editor so a draft in progress is never swept into history browsing.
if (kb.matches(data, "tui.editor.historyPrevious")) {
this.cancelAutocomplete();
this.navigateHistory(-1);
return;
if (this.isEditorEmpty() || this.historyIndex > -1) {
this.navigateHistory(-1);
return;
}
// Guard rejected the entry: if this key also drives cursorUp (defaults
// survive key reuse), fall through so the press still moves the cursor
// instead of dead-ending.
if (!kb.matches(data, "tui.editor.cursorUp")) {
return;
}
}
if (kb.matches(data, "tui.editor.historyNext")) {
this.cancelAutocomplete();
Expand Down Expand Up @@ -946,10 +956,9 @@ export class Editor implements Component, Focusable {

// Arrow key navigation (with history support)
if (kb.matches(data, "tui.editor.cursorUp")) {
if (
this.isOnFirstVisualLine() &&
(this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0)
) {
// History recall is only entered from an empty editor (or while already
// browsing); with a draft in progress, Up stays pure cursor movement.
if (this.isOnFirstVisualLine() && (this.isEditorEmpty() || this.historyIndex > -1)) {
this.navigateHistory(-1);
} else if (this.isOnFirstVisualLine()) {
// Already at top - jump to start of line
Expand Down
53 changes: 48 additions & 5 deletions packages/pi-tui/test/editor-history-keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@ describe("Editor prompt history keybindings", () => {
const editor = new Editor(new TuiMainScreen(new VirtualTerminal()), defaultEditorTheme);
editor.addToHistory("older prompt");
editor.addToHistory("newer\nmultiline prompt");
editor.setText("draft");
editor.handleInput("\x1b[D");
editor.handleInput("\x1b[D");

editor.handleInput("\x10"); // Ctrl+P
assert.strictEqual(editor.getText(), "newer\nmultiline prompt");
Expand All @@ -36,8 +33,54 @@ describe("Editor prompt history keybindings", () => {
assert.strictEqual(editor.getText(), "newer\nmultiline prompt");
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 16 });

editor.handleInput("\x0e"); // Ctrl+N
editor.handleInput("\x0e"); // Ctrl+N - restores the (empty) draft
assert.strictEqual(editor.getText(), "");
});

it("does not enter history from a non-empty draft", () => {
setKeybindings(
new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.editor.historyPrevious": "ctrl+p",
"tui.editor.historyNext": "ctrl+n",
}),
);
const editor = new Editor(new TuiMainScreen(new VirtualTerminal()), defaultEditorTheme);
editor.addToHistory("older prompt");
editor.setText("draft");

editor.handleInput("\x10"); // Ctrl+P with a draft - blocked, draft untouched
assert.strictEqual(editor.getText(), "draft");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 });
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 });

// Clearing the draft re-enables recall
editor.setText("");
editor.handleInput("\x10"); // Ctrl+P on empty editor
assert.strictEqual(editor.getText(), "older prompt");
});

it("falls through to cursor movement when the guard blocks a shared Up binding", () => {
setKeybindings(
new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.editor.historyPrevious": "up",
}),
);
const editor = new Editor(new TuiMainScreen(new VirtualTerminal()), defaultEditorTheme);
editor.addToHistory("prompt");
editor.setText("ab\ncd");

// Guard blocks history entry, but the shared default cursorUp binding must
// still move the cursor instead of dead-ending.
editor.handleInput("\x1b[A"); // Up with a draft - cursor to the first line
assert.strictEqual(editor.getText(), "ab\ncd");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 });

editor.handleInput("\x1b[A"); // Up on the first line - jump to line start, no history
assert.strictEqual(editor.getText(), "ab\ncd");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 });

// Empty editor: the same key enters history via historyPrevious
editor.setText("");
editor.handleInput("\x1b[A");
assert.strictEqual(editor.getText(), "prompt");
});
});
58 changes: 42 additions & 16 deletions packages/pi-tui/test/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,23 +135,55 @@ describe("Editor component", () => {
assert.strictEqual(editor.getText(), "first");
});

it("jumps to start before entering history from a non-empty draft", () => {
it("never enters history from a non-empty draft", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);

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
editor.handleInput("\x1b[A"); // Up - jumps to start of line, no history
assert.strictEqual(editor.getText(), "draft");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 });

editor.handleInput("\x1b[A"); // Up at start - shows "prompt"
editor.handleInput("\x1b[A"); // Up at start with content - still no history
assert.strictEqual(editor.getText(), "draft");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 });

// Clearing the draft re-enables history recall
editor.setText("");
editor.handleInput("\x1b[A"); // Up on empty editor - shows "prompt"
assert.strictEqual(editor.getText(), "prompt");

editor.handleInput("\x1b[B"); // Down - restores draft
assert.strictEqual(editor.getText(), "draft");
editor.handleInput("\x1b[B"); // Down - restores empty draft
assert.strictEqual(editor.getText(), "");
});

it("treats a whitespace-only draft as content for history recall", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);

editor.addToHistory("prompt");
editor.setText(" ");

editor.handleInput("\x1b[A"); // Up - whitespace counts as content, no history
assert.strictEqual(editor.getText(), " ");
});

it("does not enter history at the start of the first line of a multi-line draft", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);

editor.addToHistory("prompt");
editor.setText("ab\ncd");

editor.handleInput("\x1b[A"); // Up - moves to first line
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 });

editor.handleInput("\x1b[D");
editor.handleInput("\x1b[D"); // cursor to column 0 of the first line

editor.handleInput("\x1b[A"); // Up at (0, 0) with content - no history
assert.strictEqual(editor.getText(), "ab\ncd");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 });
});

Expand All @@ -161,10 +193,8 @@ describe("Editor component", () => {
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
Expand All @@ -176,8 +206,8 @@ describe("Editor component", () => {
editor.handleInput("\x1b[B"); // third
assert.strictEqual(editor.getText(), "third");

editor.handleInput("\x1b[B"); // draft
assert.strictEqual(editor.getText(), "draft");
editor.handleInput("\x1b[B"); // back to the (empty) draft
assert.strictEqual(editor.getText(), "");
});

it("exits history mode when typing a character", () => {
Expand Down Expand Up @@ -397,16 +427,12 @@ describe("Editor component", () => {
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"
editor.handleInput("\x1b[A"); // recall "!cmd" from the empty editor
assert.strictEqual(editor.getText(), "!cmd");

editor.handleInput("\x1b[B"); // restore draft
assert.strictEqual(editor.getText(), "draft");
editor.handleInput("\x1b[B"); // restore the (empty) draft
assert.strictEqual(editor.getText(), "");
});
});

Expand Down
Loading