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.
This commit is contained in:
Nico Bailon 2025-12-05 07:57:42 -08:00
parent 029a04c43b
commit c550ed2bca
5 changed files with 350 additions and 18 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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);