mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 07:03:25 +00:00
Merge feat/scroll-previous-prompts with history load on resume
This commit is contained in:
commit
30dd31d787
5 changed files with 488 additions and 307 deletions
|
|
@ -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