mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 12:03:49 +00:00
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:
parent
029a04c43b
commit
c550ed2bca
5 changed files with 350 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue