Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 28 additions & 6 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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<void> {
const input = this.editor.text;
if (!this.directorySearchActive && this.directoryQuery !== undefined) {
Expand Down
79 changes: 76 additions & 3 deletions tests/composerHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
});

Expand Down
5 changes: 2 additions & 3 deletions tests/composerHistoryRunning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);
Expand Down
Loading