fix(tui): restore prompt draft after history browsing

closes #5494
This commit is contained in:
Mario Zechner
2026-06-09 13:05:37 +02:00
parent db3f9953ee
commit e4907b3b09
4 changed files with 45 additions and 29 deletions

View File

@@ -5455,7 +5455,7 @@ export class InteractiveMode {
**Navigation** **Navigation**
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history (Up when empty) | | \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history |
| \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word | | \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word |
| \`${cursorLineStart}\` | Start of line | | \`${cursorLineStart}\` | Start of line |
| \`${cursorLineEnd}\` | End of line | | \`${cursorLineEnd}\` | End of line |

View File

@@ -4,6 +4,7 @@
### Fixed ### Fixed
- Fixed prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)).
- Fixed wrapping for mixed Latin and CJK text so unspaced CJK runs can break at grapheme boundaries without leaving large trailing gaps ([#5495](https://github.com/earendil-works/pi/issues/5495)). - Fixed wrapping for mixed Latin and CJK text so unspaced CJK runs can break at grapheme boundaries without leaving large trailing gaps ([#5495](https://github.com/earendil-works/pi/issues/5495)).
## [0.79.0] - 2026-06-08 ## [0.79.0] - 2026-06-08

View File

@@ -266,6 +266,7 @@ export class Editor implements Component, Focusable {
// Prompt history for up/down navigation // Prompt history for up/down navigation
private history: string[] = []; private history: string[] = [];
private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
private historyDraft: EditorState | null = null;
// Kill ring for Emacs-style kill/yank operations // Kill ring for Emacs-style kill/yank operations
private killRing = new KillRing(); private killRing = new KillRing();
@@ -356,10 +357,6 @@ export class Editor implements Component, Focusable {
} }
} }
private isEditorEmpty(): boolean {
return this.state.lines.length === 1 && this.state.lines[0] === "";
}
private isOnFirstVisualLine(): boolean { private isOnFirstVisualLine(): boolean {
const visualLines = this.buildVisualLineMap(this.lastWidth); const visualLines = this.buildVisualLineMap(this.lastWidth);
const currentVisualLine = this.findCurrentVisualLine(visualLines); const currentVisualLine = this.findCurrentVisualLine(visualLines);
@@ -382,18 +379,33 @@ export class Editor implements Component, Focusable {
// Capture state when first entering history browsing mode // Capture state when first entering history browsing mode
if (this.historyIndex === -1 && newIndex >= 0) { if (this.historyIndex === -1 && newIndex >= 0) {
this.pushUndoSnapshot(); this.pushUndoSnapshot();
this.historyDraft = structuredClone(this.state);
} }
this.historyIndex = newIndex; this.historyIndex = newIndex;
if (this.historyIndex === -1) { if (this.historyIndex === -1) {
// Returned to "current" state - clear editor const draft = this.historyDraft;
this.setTextInternal(""); this.historyDraft = null;
if (draft) {
this.state = draft;
this.preferredVisualCol = null;
this.snappedFromCursorCol = null;
this.scrollOffset = 0;
if (this.onChange) this.onChange(this.getText());
} else {
this.setTextInternal("");
}
} else { } else {
this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end"); this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end");
} }
} }
private exitHistoryBrowsing(): void {
this.historyIndex = -1;
this.historyDraft = null;
}
/** Internal setText that doesn't reset history state - used by navigateHistory */ /** Internal setText that doesn't reset history state - used by navigateHistory */
private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void { private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void {
const lines = text.split("\n"); const lines = text.split("\n");
@@ -758,9 +770,7 @@ export class Editor implements Component, Focusable {
// Arrow key navigation (with history support) // Arrow key navigation (with history support)
if (kb.matches(data, "tui.editor.cursorUp")) { if (kb.matches(data, "tui.editor.cursorUp")) {
if (this.isEditorEmpty()) { if (this.isOnFirstVisualLine() && this.history.length > 0) {
this.navigateHistory(-1);
} else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
this.navigateHistory(-1); this.navigateHistory(-1);
} else if (this.isOnFirstVisualLine()) { } else if (this.isOnFirstVisualLine()) {
// Already at top - jump to start of line // Already at top - jump to start of line
@@ -948,7 +958,7 @@ export class Editor implements Component, Focusable {
setText(text: string): void { setText(text: string): void {
this.cancelAutocomplete(); this.cancelAutocomplete();
this.lastAction = null; this.lastAction = null;
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const normalized = this.normalizeText(text); const normalized = this.normalizeText(text);
// Push undo snapshot if content differs (makes programmatic changes undoable) // Push undo snapshot if content differs (makes programmatic changes undoable)
if (this.getText() !== normalized) { if (this.getText() !== normalized) {
@@ -967,7 +977,7 @@ export class Editor implements Component, Focusable {
this.cancelAutocomplete(); this.cancelAutocomplete();
this.pushUndoSnapshot(); this.pushUndoSnapshot();
this.lastAction = null; this.lastAction = null;
this.historyIndex = -1; this.exitHistoryBrowsing();
this.insertTextAtCursorInternal(text); this.insertTextAtCursorInternal(text);
} }
@@ -1030,7 +1040,7 @@ export class Editor implements Component, Focusable {
// All the editor methods from before... // All the editor methods from before...
private insertCharacter(char: string, skipUndoCoalescing?: boolean): void { private insertCharacter(char: string, skipUndoCoalescing?: boolean): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
// Undo coalescing (fish-style): // Undo coalescing (fish-style):
// - Consecutive word chars coalesce into one undo unit // - Consecutive word chars coalesce into one undo unit
@@ -1091,7 +1101,7 @@ export class Editor implements Component, Focusable {
private handlePaste(pastedText: string): void { private handlePaste(pastedText: string): void {
this.cancelAutocomplete(); this.cancelAutocomplete();
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
this.lastAction = null; this.lastAction = null;
this.pushUndoSnapshot(); this.pushUndoSnapshot();
@@ -1159,7 +1169,7 @@ export class Editor implements Component, Focusable {
private addNewLine(): void { private addNewLine(): void {
this.cancelAutocomplete(); this.cancelAutocomplete();
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
this.lastAction = null; this.lastAction = null;
this.pushUndoSnapshot(); this.pushUndoSnapshot();
@@ -1200,7 +1210,7 @@ export class Editor implements Component, Focusable {
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
this.pastes.clear(); this.pastes.clear();
this.pasteCounter = 0; this.pasteCounter = 0;
this.historyIndex = -1; this.exitHistoryBrowsing();
this.scrollOffset = 0; this.scrollOffset = 0;
this.undoStack.clear(); this.undoStack.clear();
this.lastAction = null; this.lastAction = null;
@@ -1210,7 +1220,7 @@ export class Editor implements Component, Focusable {
} }
private handleBackspace(): void { private handleBackspace(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
this.lastAction = null; this.lastAction = null;
if (this.state.cursorCol > 0) { if (this.state.cursorCol > 0) {
@@ -1427,7 +1437,7 @@ export class Editor implements Component, Focusable {
} }
private deleteToStartOfLine(): void { private deleteToStartOfLine(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const currentLine = this.state.lines[this.state.cursorLine] || ""; const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1462,7 +1472,7 @@ export class Editor implements Component, Focusable {
} }
private deleteToEndOfLine(): void { private deleteToEndOfLine(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const currentLine = this.state.lines[this.state.cursorLine] || ""; const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1494,7 +1504,7 @@ export class Editor implements Component, Focusable {
} }
private deleteWordBackwards(): void { private deleteWordBackwards(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const currentLine = this.state.lines[this.state.cursorLine] || ""; const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1539,7 +1549,7 @@ export class Editor implements Component, Focusable {
} }
private deleteWordForward(): void { private deleteWordForward(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const currentLine = this.state.lines[this.state.cursorLine] || ""; const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1581,7 +1591,7 @@ export class Editor implements Component, Focusable {
} }
private handleForwardDelete(): void { private handleForwardDelete(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
this.lastAction = null; this.lastAction = null;
const currentLine = this.state.lines[this.state.cursorLine] || ""; const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1837,7 +1847,7 @@ export class Editor implements Component, Focusable {
* Insert text at cursor position (used by yank operations). * Insert text at cursor position (used by yank operations).
*/ */
private insertYankedText(text: string): void { private insertYankedText(text: string): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const lines = text.split("\n"); const lines = text.split("\n");
if (lines.length === 1) { if (lines.length === 1) {
@@ -1922,7 +1932,7 @@ export class Editor implements Component, Focusable {
} }
private undo(): void { private undo(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const snapshot = this.undoStack.pop(); const snapshot = this.undoStack.pop();
if (!snapshot) return; if (!snapshot) return;
Object.assign(this.state, snapshot); Object.assign(this.state, snapshot);

View File

@@ -79,16 +79,20 @@ describe("Editor component", () => {
assert.strictEqual(editor.getText(), "first"); assert.strictEqual(editor.getText(), "first");
}); });
it("returns to empty editor on Down arrow after browsing history", () => { it("restores draft on Down arrow after browsing history", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme); const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("prompt"); editor.addToHistory("prompt");
editor.setText("draft");
editor.handleInput("\x1b[D");
editor.handleInput("\x1b[D");
editor.handleInput("\x1b[A"); // Up - shows "prompt" editor.handleInput("\x1b[A"); // Up - shows "prompt"
assert.strictEqual(editor.getText(), "prompt"); assert.strictEqual(editor.getText(), "prompt");
editor.handleInput("\x1b[B"); // Down - clears editor editor.handleInput("\x1b[B"); // Down - restores draft
assert.strictEqual(editor.getText(), ""); assert.strictEqual(editor.getText(), "draft");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 });
}); });
it("navigates forward through history with Down arrow", () => { it("navigates forward through history with Down arrow", () => {
@@ -97,6 +101,7 @@ describe("Editor component", () => {
editor.addToHistory("first"); editor.addToHistory("first");
editor.addToHistory("second"); editor.addToHistory("second");
editor.addToHistory("third"); editor.addToHistory("third");
editor.setText("draft");
// Go to oldest // Go to oldest
editor.handleInput("\x1b[A"); // third editor.handleInput("\x1b[A"); // third
@@ -110,8 +115,8 @@ describe("Editor component", () => {
editor.handleInput("\x1b[B"); // third editor.handleInput("\x1b[B"); // third
assert.strictEqual(editor.getText(), "third"); assert.strictEqual(editor.getText(), "third");
editor.handleInput("\x1b[B"); // empty editor.handleInput("\x1b[B"); // draft
assert.strictEqual(editor.getText(), ""); assert.strictEqual(editor.getText(), "draft");
}); });
it("exits history mode when typing a character", () => { it("exits history mode when typing a character", () => {