diff --git a/CHANGELOG.md b/CHANGELOG.md index 41472b72..e7e06981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Five more bundled theme families** ([#401](https://github.com/raiseCatError/notMyShell/issues/401)): Ayu (Dark, Mirage, Light), Kanagawa (Wave, Dragon, Lotus), Everforest (Dark, Light), Flexoki (Dark, Light) and Night Owl (Night Owl, Light Owl), with colors copied exactly from each project's MIT-licensed source at a recorded revision. They theme NMSh's own prompt fills and chrome only; the terminal's own colors and configuration are never changed. Existing theme ids and saved choices are unchanged, and Lavender stays the default. Theme Studio's Built-in list gains `/` to filter by name, family, or `dark`/`light`. - **`/track`** keeps one live result of a finite command in the transcript, refreshed in place ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). `/track df -h` or `/track --every 5s -- docker system df` writes the command once, then shows a status row (`live · every 5s · updated 13:49:25 · exit 0`, a failing exit, a timeout, paused, stale, refreshing) and the latest result under it, with a stable height. The shell stays free; nothing accumulates per refresh, and find, selection and `/copy` never include the live rows. `/track copy` copies the latest result, `/track refresh`, `pause`, `resume` and `stop` control it, and stopping leaves the last result in the transcript once. Samples run `sh -c` from the starting directory with your shell's PATH (not its aliases or functions), never overlap, and are bounded (200 kB, a timeout, at most 8 trackers); destructive and unknown commands are refused or need a Yes, as for `/watch`. Trackers stop with the window that started them. `/watch` keeps its manager and its behavior; both share one scheduler, which gains stricter interval parsing, refusals for substitutions, background jobs and heredocs, UTF-8-safe bounded capture, visible timeouts and a stop grace period (from draft #394). +### Changed + +- **Up and Down step through history one command at a time, however many lines a command has** ([#389](https://github.com/raiseCatError/notMyShell/issues/389)). Once Up has recalled a command, each Up or Down moves to the next older or newer command, even when the recalled one spans several lines or wraps; before, a recalled five-line command took five extra presses to pass. Typing, pasting, deleting, moving the caret or selecting in a recalled command makes it a draft again, where Up and Down move between its lines as before, and Down past the newest command restores exactly what you had typed. Editing a fresh multi-line draft is unchanged. + ### Fixed - Multi-line progress displays that redraw their own rows (cursor up and erase line, as concurrent downloads and container pulls do) now update in place instead of leaving a copy of every frame in the transcript ([#388](https://github.com/raiseCatError/notMyShell/issues/388)). A program can redraw only its own recent rows, at most a screen height up, never NMSh's lines or earlier commands; `/copy` copies the final rows once. Rows are the terminal's rows: a line wider than the window counts as the rows it occupies, so a display that redraws over its own wrapped line no longer erases the log lines above it at narrow widths. A status row an installer reserves below a scroll region is not written into the log. Absolute cursor positioning is not mapped. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 62148e4a..ebc45cc2 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -480,6 +480,8 @@ export class TerminalApp { private readonly historyService = new HistoryService(); /** Shell-style Up/Down recall in the ordinary composer. Frontend-local; never persisted. */ private readonly composerHistory = new ComposerHistory(); + /** The composer shows an entry recalled by Up/Down and the person has done nothing else since (#389). */ + private historyBrowsing = false; /** * What was submitted in this NMSh session, for recall only. NMSh slash * commands live here and nowhere else: never zsh, Atuin or other history. @@ -2409,6 +2411,9 @@ export class TerminalApp { if (this.running) { this.running.stoppedAt = Date.now(); this.session.write('\u001A'); this.runningDraft = undefined; this.answerSent(); } return; } + // Anything but Up/Down (typing, pasting, deleting, moving the caret, selecting) makes a recalled command a draft: + // Up/Down move within it again, and the next recall starts a fresh snapshot (#389). + if (key.kind !== 'up' && key.kind !== 'down') this.historyBrowsing = false; if (key.kind === 'selectAll') { if (!this.running) this.editor.selectAll(); return; @@ -2486,8 +2491,7 @@ export class TerminalApp { else this.selectedSuggestion = (this.selectedSuggestion + 1) % suggestions.length; } else if (key.kind === 'up' && isSlash && !searchSurface && suggestions.length > 0 && this.leaveSlashMenu()) { // Left the slash menu; fall through to history recall below. - const {columns} = this.dimensions(); - if (!this.editor.moveUp(this.inputColumns(columns), this.railInputPrefix(columns)) && this.composerHistoryEligible()) this.recallHistory('previous'); + this.historyStep('up'); } else if (key.kind === 'complete') { const action = tabCompletionAction(this.shellSuggestions.length, isSlash ? suggestions.length : 0); if (action === 'shell-suggestion') { @@ -2587,22 +2591,20 @@ export class TerminalApp { else if (key.kind === 'selectWordLeft') this.editor.selectWordLeft(); else if (key.kind === 'selectWordRight') this.editor.selectWordRight(); else if (key.kind === 'up') { - const {columns} = this.dimensions(); // Spatial focus: the shelf sits above the composer. Only an already visible shelf, from a completely idle // composer; a hidden shelf is never revealed by ↑, which stays shell history. if (this.composerIdle() && !this.composerHistory.active && this.shelfShown()) { this.shelf.focused = true; this.shelf.selected = 0; this.render(); return; } // Then the background task bar (only from an idle composer, only when tasks are shown), then history. if (this.enterBackgroundBar(key)) return; - if (!this.editor.moveUp(this.inputColumns(columns), this.railInputPrefix(columns)) && this.composerHistoryEligible()) this.recallHistory('previous'); + this.historyStep('up'); } else if (key.kind === 'selectUp') { const {columns} = this.dimensions(); if (this.enterBackgroundBar(key)) return; this.editor.selectUp(this.inputColumns(columns), this.railInputPrefix(columns)); } else if (key.kind === 'down') { - const {columns} = this.dimensions(); // At the newest, empty composer ↓ has nothing to do: it reveals the agent shelf (↑ then focuses it). if (!this.editor.text && !this.composerHistory.active && this.agents.sessions.length && !this.running) { this.revealShelf(); this.render(); return; } - if (!this.editor.moveDown(this.inputColumns(columns), this.railInputPrefix(columns)) && this.composerHistoryEligible()) this.recallHistory('next'); + this.historyStep('down'); } else if (key.kind === 'selectDown') { const {columns} = this.dimensions(); this.editor.selectDown(this.inputColumns(columns), this.railInputPrefix(columns)); @@ -2691,10 +2693,30 @@ export class TerminalApp { : this.composerHistory.next(current); if (text === undefined) return; this.editor.replaceText(text); + // Past the newest entry the draft returns and browsing ends; otherwise the composer shows a recalled entry. + this.historyBrowsing = this.composerHistory.active; this.selectedSuggestion = 0; this.shellSuggestions = []; } + /** + * Up/Down in the shell composer (#389). While it shows an unchanged recalled entry, each press is one history entry, + * however many explicit or wrapped rows the entry has. Otherwise the caret moves between visual rows first, and + * crossing the first or last row enters or leaves history, as for any draft. + */ + private historyStep(direction: 'up' | 'down'): void { + const eligible = this.composerHistoryEligible(); + if (this.historyBrowsing && eligible && this.composerHistory.showing(this.editor.text)) { + this.recallHistory(direction === 'up' ? 'previous' : 'next'); + return; + } + this.historyBrowsing = false; + const {columns} = this.dimensions(); + const moved = direction === 'up' ? this.editor.moveUp(this.inputColumns(columns), this.railInputPrefix(columns)) + : this.editor.moveDown(this.inputColumns(columns), this.railInputPrefix(columns)); + if (!moved && eligible) this.recallHistory(direction === 'up' ? 'previous' : 'next'); + } + private async fetchSuggestions(): Promise { const input = this.editor.text; if (!this.directorySearchActive && this.directoryQuery !== undefined) { diff --git a/tests/composerHistory.test.ts b/tests/composerHistory.test.ts index 72609fbd..a055c54c 100644 --- a/tests/composerHistory.test.ts +++ b/tests/composerHistory.test.ts @@ -147,15 +147,88 @@ test('composer: multiline drafts move by line first, then enter and leave histor } finally { stop(app); } }); -test('composer: a recalled multiline command navigates its own lines before older history', () => { +test('composer (#389): a recalled multiline command is one history step; Up and Down move entry by entry', () => { + const {app, text} = historyApp(['echo oldest', 'echo older', 'printf 1\nprintf 2\nprintf 3\nprintf 4\nprintf 5', 'echo newest']); + try { + app['onInput'](UP); + assert.equal(text(), 'echo newest'); + app['onInput'](UP); + assert.equal(text(), 'printf 1\nprintf 2\nprintf 3\nprintf 4\nprintf 5', 'the five-line entry, recalled whole'); + app['onInput'](UP); + assert.equal(text(), 'echo older', 'one Up past it, not five'); + app['onInput'](DOWN); + assert.equal(text(), 'printf 1\nprintf 2\nprintf 3\nprintf 4\nprintf 5', 'one Down back to it'); + app['onInput'](DOWN); + assert.equal(text(), 'echo newest', 'and one Down past it'); + app['onInput'](UP); + app['onInput'](UP); + app['onInput'](UP); + app['onInput'](UP); + assert.equal(text(), 'echo oldest'); + app['onInput'](UP); + assert.equal(text(), 'echo oldest', 'oldest: no wrap'); + for (let index = 0; index < 4; index++) app['onInput'](DOWN); + assert.equal(text(), '', 'Down past the newest restores the empty draft'); + assert.equal(app['composerHistory'].active, false); + } finally { stop(app); } +}); + +test('composer (#389): a recalled command wider than the composer is one step too (soft-wrapped rows)', () => { + const long = `echo ${'x'.repeat(200)}`; + const {app, text} = historyApp(['echo older', long]); + try { + app['onInput'](UP); + assert.equal(text(), long); + app['onInput'](UP); + assert.equal(text(), 'echo older', 'not one Up per wrapped row'); + app['onInput'](DOWN); + assert.equal(text(), long); + } finally { stop(app); } +}); + +test('composer (#389): editing, moving the caret, selecting or pasting in a recalled command ends browsing', () => { + const multi = 'printf a\nprintf b\nprintf c'; + const cases: Array<[string, (app: TerminalApp) => void, string]> = [ + ['typing', app => app['onInput']('x'), 'printf a\nprintf b\nprintf cx'], + ['moving the caret', app => app['handleKey']({kind: 'left'}), multi], + ['moving a word', app => app['handleKey']({kind: 'wordLeft'}), multi], + ['going to the line start', app => app['handleKey']({kind: 'lineHome'}), multi], + ['selecting', app => app['handleKey']({kind: 'selectLeft'}), multi], + ['pasting', app => app['onInput']('\u001b[200~ y\u001b[201~'), 'printf a\nprintf b\nprintf c y'], + ]; + for (const [name, act, expected] of cases) { + const {app, text} = historyApp(['echo older', multi]); + try { + app['onInput'](UP); + assert.equal(text(), multi, `${name}: recalled`); + act(app); + assert.equal(text(), expected, `${name}: the action itself`); + app['onInput'](UP); + assert.equal(text(), expected, `${name}: Up moves within the text again, not to an older entry`); + assert.notEqual(text(), 'echo older'); + } finally { stop(app); } + } +}); + +test('composer (#389): Down past the newest restores the exact unsent draft, multiline and caret included', () => { const {app, text} = historyApp(['echo older', 'printf a\nprintf b']); try { + app['onInput']('first'); + app['onInput']('\n'); + app['onInput']('second'); + app['handleKey']({kind: 'bufferEnd'}); + app['onInput'](UP); + assert.equal(text(), 'first\nsecond', 'Up inside a fresh multiline draft moves the caret first'); app['onInput'](UP); assert.equal(text(), 'printf a\nprintf b'); app['onInput'](UP); - assert.equal(text(), 'printf a\nprintf b', 'moved to the first line of the recalled command'); + assert.equal(text(), 'echo older', 'browsing: one step per entry'); + app['onInput'](DOWN); + app['onInput'](DOWN); + assert.equal(text(), 'first\nsecond', 'the exact draft'); + assert.equal(app['composerHistory'].active, false); app['onInput'](UP); - assert.equal(text(), 'echo older'); + assert.equal(text(), 'first\nsecond', 'the restored draft is a draft again: Up moves within it'); } finally { stop(app); } }); diff --git a/tests/composerHistoryRunning.test.ts b/tests/composerHistoryRunning.test.ts index 68db7fbe..c9e07074 100644 --- a/tests/composerHistoryRunning.test.ts +++ b/tests/composerHistoryRunning.test.ts @@ -254,7 +254,7 @@ test('composer history: slash suggestions still yield Up; a recalled command ope } finally { f.dispose(); } }); -test('composer history: multiline drafts move by line first; a recalled multiline command still reaches older history and restores the draft', () => { +test('composer history: multiline drafts move by line first; a recalled multiline command is one step (#389) and restores the draft', () => { const f = fixture(['echo older', 'printf a\nprintf b']); try { f.begin('sleep 12'); @@ -267,8 +267,7 @@ test('composer history: multiline drafts move by line first; a recalled multilin f.app['onInput'](UP); assert.equal(f.text(), 'printf a\nprintf b', 'the multiline entry is recalled as one command'); f.app['onInput'](UP); - f.app['onInput'](UP); - assert.equal(f.text(), 'echo older', 'navigation still reaches older entries'); + assert.equal(f.text(), 'echo older', 'one Up reaches the older entry'); f.app['onInput'](DOWN); assert.equal(f.text(), 'printf a\nprintf b', 'Down steps newer, entry by entry'); f.app['onInput'](DOWN);