From 59d70256684fd3d15c66e84587315e8a2b81245f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Jun 2026 10:56:09 +0200 Subject: [PATCH] fix(tui): position cursor for prompt history navigation closes #5454 --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/components/editor.ts | 8 +-- packages/tui/test/editor.test.ts | 72 ++++++++++++--------------- 3 files changed, 41 insertions(+), 43 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2069c7eb..dd487722 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed prompt history navigation to place the cursor at the start when browsing upward and at the end when browsing downward, so repeated Up/Down traverses multiline prompts immediately ([#5454](https://github.com/earendil-works/pi/issues/5454)). + ## [0.78.1] - 2026-06-04 ### Fixed diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index ddbd98ee..382c88ae 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -390,16 +390,16 @@ export class Editor implements Component, Focusable { // Returned to "current" state - clear editor this.setTextInternal(""); } else { - this.setTextInternal(this.history[this.historyIndex] || ""); + this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end"); } } /** Internal setText that doesn't reset history state - used by navigateHistory */ - private setTextInternal(text: string): void { + private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void { const lines = text.split("\n"); this.state.lines = lines.length === 0 ? [""] : lines; - this.state.cursorLine = this.state.lines.length - 1; - this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0); + this.state.cursorLine = cursorPlacement === "start" ? 0 : this.state.lines.length - 1; + this.setCursorCol(cursorPlacement === "start" ? 0 : this.state.lines[this.state.cursorLine]?.length || 0); // Reset scroll - render() will adjust to show cursor this.scrollOffset = 0; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 2d43bc24..938f9646 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -122,7 +122,7 @@ describe("Editor component", () => { editor.handleInput("\x1b[A"); // Up - shows "old prompt" editor.handleInput("x"); // Type a character - exits history mode - assert.strictEqual(editor.getText(), "old promptx"); + assert.strictEqual(editor.getText(), "xold prompt"); }); it("exits history mode on setText", () => { @@ -222,61 +222,55 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "prompt 5"); }); - it("allows cursor movement within multi-line history entry with Down", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); - - editor.addToHistory("line1\nline2\nline3"); - - // Browse to the multi-line entry - editor.handleInput("\x1b[A"); // Up - shows entry, cursor at end of line3 - assert.strictEqual(editor.getText(), "line1\nline2\nline3"); - - // Down should exit history since cursor is on last line - editor.handleInput("\x1b[B"); // Down - assert.strictEqual(editor.getText(), ""); // Exited to empty - }); - - it("allows cursor movement within multi-line history entry with Up", () => { + it("places cursor at start after browsing history upward", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("older entry"); editor.addToHistory("line1\nline2\nline3"); - // Browse to the multi-line entry - editor.handleInput("\x1b[A"); // Up - shows multi-line, cursor at end of line3 + editor.handleInput("\x1b[A"); // Up - shows multi-line entry at start + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); - // Up should move cursor within the entry (not on first line yet) - editor.handleInput("\x1b[A"); // Up - cursor moves to line2 - assert.strictEqual(editor.getText(), "line1\nline2\nline3"); // Still same entry - - editor.handleInput("\x1b[A"); // Up - cursor moves to line1 (now on first visual line) - assert.strictEqual(editor.getText(), "line1\nline2\nline3"); // Still same entry - - // Now Up should navigate to older history entry - editor.handleInput("\x1b[A"); // Up - navigate to older + editor.handleInput("\x1b[A"); // Up again - immediately navigates to older entry assert.strictEqual(editor.getText(), "older entry"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); }); - it("navigates from multi-line entry back to newer via Down after cursor movement", () => { + it("places cursor at end after browsing history downward", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("older entry"); + editor.addToHistory("line1\nline2\nline3"); + editor.addToHistory("newer entry"); + + editor.handleInput("\x1b[A"); // newer entry + editor.handleInput("\x1b[A"); // multi-line entry + editor.handleInput("\x1b[A"); // older entry + + editor.handleInput("\x1b[B"); // Down - shows multi-line entry at end + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 5 }); + + editor.handleInput("\x1b[B"); // Down again - immediately navigates to newer entry + assert.strictEqual(editor.getText(), "newer entry"); + }); + + it("allows opposite-direction cursor movement within multi-line history entry", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("line1\nline2\nline3"); - // Browse to entry and move cursor up - editor.handleInput("\x1b[A"); // Up - shows entry, cursor at end - editor.handleInput("\x1b[A"); // Up - cursor to line2 - editor.handleInput("\x1b[A"); // Up - cursor to line1 + editor.handleInput("\x1b[A"); // Up - shows entry at start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); - // Now Down should move cursor down within the entry - editor.handleInput("\x1b[B"); // Down - cursor to line2 + editor.handleInput("\x1b[B"); // Down - cursor moves to line2 assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 }); - editor.handleInput("\x1b[B"); // Down - cursor to line3 + editor.handleInput("\x1b[A"); // Up - cursor moves back to line1 assert.strictEqual(editor.getText(), "line1\nline2\nline3"); - - // Now on last line, Down should exit history - editor.handleInput("\x1b[B"); // Down - exit to empty - assert.strictEqual(editor.getText(), ""); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); }); });