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**
| 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 |
| \`${cursorLineStart}\` | Start of line |
| \`${cursorLineEnd}\` | End of line |

View File

@@ -4,6 +4,7 @@
### 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)).
## [0.79.0] - 2026-06-08

View File

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

View File

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