From c550ed2bcab8db29fd70e2096a390cf80d69cd91 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Fri, 5 Dec 2025 07:57:42 -0800 Subject: [PATCH 1/2] feat(tui): add prompt history navigation with Up/Down arrows Browse previously submitted prompts using Up/Down arrow keys, similar to shell history and Claude Code's prompt history feature. - Up arrow when editor is empty: browse to older prompts - Down arrow when browsing: return to newer prompts or clear editor - Cursor movement within multi-line history entries supported - History is session-scoped, stores up to 100 entries - Consecutive duplicates are not added to history Includes 15 new tests for history navigation behavior. --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 2 + packages/coding-agent/src/tui/tui-renderer.ts | 6 + packages/tui/src/components/editor.ts | 115 +++++++-- packages/tui/test/editor.test.ts | 241 ++++++++++++++++++ 5 files changed, 350 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cbb917f7..83997d2a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **Prompt History Navigation**: Browse previously submitted prompts using Up/Down arrow keys when the editor is empty. Press Up to cycle through older prompts, Down to return to newer ones or clear the editor. Similar to shell history and Claude Code's prompt history feature. History is session-scoped and stores up to 100 entries. + ## [0.12.10] - 2025-12-04 ### Added diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index cd12e872..e7971510 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -661,6 +661,8 @@ Change queue mode with `/queue` command. Setting is saved in `~/.pi/agent/settin **Navigation:** - **Arrow keys**: Move cursor (Up/Down navigate visual lines, Left/Right move by character) +- **Up Arrow** (empty editor): Browse previous prompts (history) +- **Down Arrow** (browsing history): Browse newer prompts or return to empty editor - **Option+Left** / **Ctrl+Left**: Move word backwards - **Option+Right** / **Ctrl+Right**: Move word forwards - **Ctrl+A** / **Home**: Jump to start of line diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 5cba73e7..4546816d 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -516,6 +516,9 @@ export class TuiRenderer { // Update pending messages display this.updatePendingMessagesDisplay(); + // Add to history for up/down arrow navigation + this.editor.addToHistory(text); + // Clear editor this.editor.setText(""); this.ui.requestRender(); @@ -526,6 +529,9 @@ export class TuiRenderer { if (this.onInputCallback) { this.onInputCallback(text); } + + // Add to history for up/down arrow navigation + this.editor.addToHistory(text); }; // Start the UI diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index ba0bc1d9..0bf06d41 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -48,6 +48,10 @@ export class Editor implements Component { private pasteBuffer: string = ""; private isInPaste: boolean = false; + // Prompt history for up/down navigation + private history: string[] = []; + private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. + public onSubmit?: (text: string) => void; public onChange?: (text: string) => void; public disableSubmit: boolean = false; @@ -61,6 +65,66 @@ export class Editor implements Component { this.autocompleteProvider = provider; } + /** + * Add a prompt to history for up/down arrow navigation. + * Called after successful submission. + */ + addToHistory(text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + // Don't add consecutive duplicates + if (this.history.length > 0 && this.history[0] === trimmed) return; + this.history.unshift(trimmed); + // Limit history size + if (this.history.length > 100) { + this.history.pop(); + } + } + + private isEditorEmpty(): boolean { + return this.state.lines.length === 1 && this.state.lines[0] === ""; + } + + private isOnFirstVisualLine(): boolean { + const visualLines = this.buildVisualLineMap(this.lastWidth); + const currentVisualLine = this.findCurrentVisualLine(visualLines); + return currentVisualLine === 0; + } + + private isOnLastVisualLine(): boolean { + const visualLines = this.buildVisualLineMap(this.lastWidth); + const currentVisualLine = this.findCurrentVisualLine(visualLines); + return currentVisualLine === visualLines.length - 1; + } + + private navigateHistory(direction: 1 | -1): void { + if (this.history.length === 0) return; + + const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases + if (newIndex < -1 || newIndex >= this.history.length) return; + + this.historyIndex = newIndex; + + if (this.historyIndex === -1) { + // Returned to "current" state - clear editor + this.setTextInternal(""); + } else { + this.setTextInternal(this.history[this.historyIndex] || ""); + } + } + + /** Internal setText that doesn't reset history state - used by navigateHistory */ + private setTextInternal(text: string): void { + const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + this.state.lines = lines.length === 0 ? [""] : lines; + this.state.cursorLine = this.state.lines.length - 1; + this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0; + + if (this.onChange) { + this.onChange(this.getText()); + } + } + invalidate(): void { // No cached state to invalidate currently } @@ -342,6 +406,7 @@ export class Editor implements Component { }; this.pastes.clear(); this.pasteCounter = 0; + this.historyIndex = -1; // Exit history browsing mode // Notify that editor is now empty if (this.onChange) { @@ -383,11 +448,21 @@ export class Editor implements Component { } // Arrow keys else if (data === "\x1b[A") { - // Up - this.moveCursor(-1, 0); + // Up - history navigation or cursor movement + if (this.isEditorEmpty()) { + this.navigateHistory(-1); // Start browsing history + } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) { + this.navigateHistory(-1); // Navigate to older history entry + } else { + this.moveCursor(-1, 0); // Cursor movement (within text or history entry) + } } else if (data === "\x1b[B") { - // Down - this.moveCursor(1, 0); + // Down - history navigation or cursor movement + if (this.historyIndex > -1 && this.isOnLastVisualLine()) { + this.navigateHistory(1); // Navigate to newer history entry or clear + } else { + this.moveCursor(1, 0); // Cursor movement (within text or history entry) + } } else if (data === "\x1b[C") { // Right this.moveCursor(0, 1); @@ -479,24 +554,14 @@ export class Editor implements Component { } setText(text: string): void { - // Split text into lines, handling different line endings - const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); - - // Ensure at least one empty line - this.state.lines = lines.length === 0 ? [""] : lines; - - // Reset cursor to end of text - this.state.cursorLine = this.state.lines.length - 1; - this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0; - - // Notify of change - if (this.onChange) { - this.onChange(this.getText()); - } + this.historyIndex = -1; // Exit history browsing mode + this.setTextInternal(text); } // All the editor methods from before... private insertCharacter(char: string): void { + this.historyIndex = -1; // Exit history browsing mode + const line = this.state.lines[this.state.cursorLine] || ""; const before = line.slice(0, this.state.cursorCol); @@ -544,6 +609,8 @@ export class Editor implements Component { } private handlePaste(pastedText: string): void { + this.historyIndex = -1; // Exit history browsing mode + // Clean the pasted text const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); @@ -632,6 +699,8 @@ export class Editor implements Component { } private addNewLine(): void { + this.historyIndex = -1; // Exit history browsing mode + const currentLine = this.state.lines[this.state.cursorLine] || ""; const before = currentLine.slice(0, this.state.cursorCol); @@ -651,6 +720,8 @@ export class Editor implements Component { } private handleBackspace(): void { + this.historyIndex = -1; // Exit history browsing mode + if (this.state.cursorCol > 0) { // Delete character in current line const line = this.state.lines[this.state.cursorLine] || ""; @@ -704,6 +775,8 @@ export class Editor implements Component { } private deleteToStartOfLine(): void { + this.historyIndex = -1; // Exit history browsing mode + const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol > 0) { @@ -725,6 +798,8 @@ export class Editor implements Component { } private deleteToEndOfLine(): void { + this.historyIndex = -1; // Exit history browsing mode + const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol < currentLine.length) { @@ -743,6 +818,8 @@ export class Editor implements Component { } private deleteWordBackwards(): void { + this.historyIndex = -1; // Exit history browsing mode + const currentLine = this.state.lines[this.state.cursorLine] || ""; // If at start of line, behave like backspace at column 0 (merge with previous line) @@ -791,6 +868,8 @@ export class Editor implements Component { } private handleForwardDelete(): void { + this.historyIndex = -1; // Exit history browsing mode + const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol < currentLine.length) { diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 9928568f..2df17051 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -4,6 +4,247 @@ import { Editor } from "../src/components/editor.js"; import { defaultEditorTheme } from "./test-themes.js"; describe("Editor component", () => { + describe("Prompt history navigation", () => { + it("does nothing on Up arrow when history is empty", () => { + const editor = new Editor(defaultEditorTheme); + + editor.handleInput("\x1b[A"); // Up arrow + + assert.strictEqual(editor.getText(), ""); + }); + + it("shows most recent history entry on Up arrow when editor is empty", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("first prompt"); + editor.addToHistory("second prompt"); + + editor.handleInput("\x1b[A"); // Up arrow + + assert.strictEqual(editor.getText(), "second prompt"); + }); + + it("cycles through history entries on repeated Up arrow", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + editor.addToHistory("third"); + + editor.handleInput("\x1b[A"); // Up - shows "third" + assert.strictEqual(editor.getText(), "third"); + + editor.handleInput("\x1b[A"); // Up - shows "second" + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("\x1b[A"); // Up - shows "first" + assert.strictEqual(editor.getText(), "first"); + + editor.handleInput("\x1b[A"); // Up - stays at "first" (oldest) + assert.strictEqual(editor.getText(), "first"); + }); + + it("returns to empty editor on Down arrow after browsing history", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("prompt"); + + editor.handleInput("\x1b[A"); // Up - shows "prompt" + assert.strictEqual(editor.getText(), "prompt"); + + editor.handleInput("\x1b[B"); // Down - clears editor + assert.strictEqual(editor.getText(), ""); + }); + + it("navigates forward through history with Down arrow", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + editor.addToHistory("third"); + + // Go to oldest + 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"); + + editor.handleInput("\x1b[B"); // third + assert.strictEqual(editor.getText(), "third"); + + editor.handleInput("\x1b[B"); // empty + assert.strictEqual(editor.getText(), ""); + }); + + it("exits history mode when typing a character", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("old prompt"); + + editor.handleInput("\x1b[A"); // Up - shows "old prompt" + editor.handleInput("x"); // Type a character - exits history mode + + assert.strictEqual(editor.getText(), "old promptx"); + }); + + it("exits history mode on setText", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + + 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"); + }); + + it("does not add empty strings to history", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory(""); + editor.addToHistory(" "); + editor.addToHistory("valid"); + + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "valid"); + + // Should not have more entries + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "valid"); + }); + + it("does not add consecutive duplicates to history", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("same"); + editor.addToHistory("same"); + editor.addToHistory("same"); + + editor.handleInput("\x1b[A"); // "same" + assert.strictEqual(editor.getText(), "same"); + + editor.handleInput("\x1b[A"); // stays at "same" (only one entry) + assert.strictEqual(editor.getText(), "same"); + }); + + it("allows non-consecutive duplicates in history", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + editor.addToHistory("first"); // Not consecutive, should be added + + editor.handleInput("\x1b[A"); // "first" + assert.strictEqual(editor.getText(), "first"); + + editor.handleInput("\x1b[A"); // "second" + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("\x1b[A"); // "first" (older one) + assert.strictEqual(editor.getText(), "first"); + }); + + it("uses cursor movement instead of history when editor has content", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("history item"); + editor.setText("line1\nline2"); + + // Cursor is at end of line2, Up should move to line1 + editor.handleInput("\x1b[A"); // Up - cursor movement + + // Insert character to verify cursor position + editor.handleInput("X"); + + // X should be inserted in line1, not replace with history + assert.strictEqual(editor.getText(), "line1X\nline2"); + }); + + it("limits history to 100 entries", () => { + const editor = new Editor(defaultEditorTheme); + + // Add 105 entries + for (let i = 0; i < 105; i++) { + editor.addToHistory(`prompt ${i}`); + } + + // Navigate to oldest + for (let i = 0; i < 100; i++) { + editor.handleInput("\x1b[A"); + } + + // Should be at entry 5 (oldest kept), not entry 0 + assert.strictEqual(editor.getText(), "prompt 5"); + + // One more Up should not change anything + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "prompt 5"); + }); + + it("allows cursor movement within multi-line history entry with Down", () => { + const editor = new Editor(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", () => { + const editor = new Editor(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 + + // 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 + assert.strictEqual(editor.getText(), "older entry"); + }); + + it("navigates from multi-line entry back to newer via Down after cursor movement", () => { + const editor = new Editor(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 + + // Now Down should move cursor down within the entry + editor.handleInput("\x1b[B"); // Down - cursor to line2 + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + + editor.handleInput("\x1b[B"); // Down - cursor to line3 + 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(), ""); + }); + }); + describe("Unicode text editing behavior", () => { it("inserts mixed ASCII, umlauts, and emojis as literal text", () => { const editor = new Editor(defaultEditorTheme); From 3a5185c5fd23cd92a732ea3e74ac926e2ab1ac24 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 22:00:23 +0100 Subject: [PATCH 2/2] feat(tui): add prompt history navigation with Up/Down arrows - Browse previously submitted prompts using Up/Down arrow keys - History is session-scoped and stores up to 100 entries - Load history from session on continue/resume - Includes 15 tests for history navigation fixes #121 --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/src/tui/tui-renderer.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 83997d2a..581ea365 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **Prompt History Navigation**: Browse previously submitted prompts using Up/Down arrow keys when the editor is empty. Press Up to cycle through older prompts, Down to return to newer ones or clear the editor. Similar to shell history and Claude Code's prompt history feature. History is session-scoped and stores up to 100 entries. +- **Prompt History Navigation**: Browse previously submitted prompts using Up/Down arrow keys when the editor is empty. Press Up to cycle through older prompts, Down to return to newer ones or clear the editor. Similar to shell history and Claude Code's prompt history feature. History is session-scoped and stores up to 100 entries. ([#121](https://github.com/badlogic/pi-mono/pull/121) by [@nicobailon](https://github.com/nicobailon)) ## [0.12.10] - 2025-12-04 diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 4546816d..15fcf992 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -881,6 +881,22 @@ export class TuiRenderer { } // Clear pending tools after rendering initial messages this.pendingTools.clear(); + + // Populate editor history with user messages from the session (oldest first so newest is at index 0) + for (const message of state.messages) { + if (message.role === "user") { + const textBlocks = + typeof message.content === "string" + ? [{ type: "text", text: message.content }] + : message.content.filter((c) => c.type === "text"); + const textContent = textBlocks.map((c) => c.text).join(""); + // Skip compaction summary messages + if (textContent && !textContent.startsWith(SUMMARY_PREFIX)) { + this.editor.addToHistory(textContent); + } + } + } + this.ui.requestRender(); }