Merge feat/scroll-previous-prompts with history load on resume

This commit is contained in:
Mario Zechner 2025-12-05 22:02:16 +01:00
commit 30dd31d787
5 changed files with 488 additions and 307 deletions

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