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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Screensavers
- Four screen-based savers join the idle visuals: **Black Hole**, **Fireworks**, **Circletastic** and **raiseCatError**, plus **Random** (switches only after a full loop). They animate the current screen's own text as presentation only (transcript, PTY, history and journals are untouched), keep the host background, dismiss on the first input (which is consumed), stop on resize, and never start automatically under Reduced Motion. Still off by default (Idle visuals: Never); new **Run while busy** setting never overrides passthrough or fullscreen programs. The earlier scene is now labelled Night Fireworks.

### Polish
- Shell Environment (Status and `nmsh doctor`) starts with the shell backing the session (never inferred from `$SHELL`), says "none · plain zsh/bash/fish" when there is no framework, and scopes framework and plugin rows to that shell; other shells' environments are listed separately.
- Screensavers: the capture keeps authored backgrounds and readable glyph colors (no black-on-dark chrome), Circletastic forms a few small circles completely before it stabilizes, rotates, accelerates and explodes (all at once or staggered, keeping ring momentum), and raiseCatError now uses the NMSh cat sprite, roams the whole screen, overlaps text freely, meows, and sometimes sits on a purely visual fake keyboard (never reaching the editor or shell).
- Any exact command in the curated `/tools` catalog (not only Recommended ones) is recognized when missing; the prompt names the package when it differs (`tldr` is provided by tealdeer). TLDR (tealdeer) is now Recommended; Ask uses only its local cache (`--no-auto-update`) and says when examples are unavailable.

### Updates and sessions
- **Automatic updates** (Automatic / Notify only / Off, Daily or Weekly). New installs default to Automatic / Daily; saved Daily/Weekly checks migrate to Notify only and Off stays Off. Automatic prepares a verified stable release only where `/update apply`'s own checks pass, with the same build verification and rollback; the running session keeps its version. Status shows Running version, Latest, Mode and State.
- Detached sessions can be ended from the startup picker with `X` and confirmation; the transcript is archived and stays in `/resume`.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ In v0.16.0 zsh, Fish and Bash 4.4+ are implemented backends behind a real [Shell

## Design Principles

- NMSh is a frontend over a persistent real zsh session.
- NMSh is a frontend over a persistent real shell session (zsh, Fish or Bash through ShellAdapter).
- The terminal host renders cells and interprets ANSI; NMSh is not a terminal emulator.
- Raw PTY output remains recoverable and is not semantically recolored.
- Fullscreen applications retain the passthrough path.
Expand Down
75 changes: 63 additions & 12 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ import {captureFromRows, cropCapture, type ScreenCapture} from '../idle/screenCa
import {makeRng} from '../idle/screenEffects.js';
import {SCREEN_MODE_EFFECT, pickRandomSaver, saverLoopComplete, IDLE_FRAME_MS as SAVER_FRAME_MS} from '../idle/scenes.js';
import {createThemeStudio, renderThemeStudio, STUDIO_MIN_SIZE, studioKey, writeThemeExport, type ThemeStudioState} from '../appearance/ThemeStudio.js';
import {createInstallPrompt, ignoreInstallSuggestion, installCandidate, installPromptKey, renderInstallPrompt, shouldOfferInstall,
import {commandWord, createInstallPrompt, ignoreInstallSuggestion, installCandidate, installPromptKey, renderInstallPrompt, shouldOfferInstall,
type InstallPromptState} from '../tools/InstallSuggestion.js';
import {toolInstall, TOOLS} from '../tools/catalog.js';
import {knownToolForExecutable, suggestibleToolFor, toolInstall, TOOLS, type Tool} from '../tools/catalog.js';
import {planPackageInstall} from '../packages/managers.js';
import {loadToolUpdateState, runToolUpdateCheck, toolUpdateCheckDue, type ToolUpdateState} from '../tools/ToolUpdates.js';
import type {CommandSource} from '../shell/SemanticService.js';
import {GLYPHS, setIconStyle, getCurrentGlyphMode, setPromptSymbol} from '../ui/glyphs.js';
Expand Down Expand Up @@ -450,7 +451,7 @@ export class TerminalApp {
private cachedEnvironment?: ShellEnvironmentReport;
private stripSampling = false;
/** Frontend PATH and recipe lookups for install offers; replaceable in tests. */
private installProbe = {onPath: (name: string) => resolveCommand(name) !== undefined, recipe: toolInstall};
private installProbe = {onPath: (name: string) => resolveCommand(name) !== undefined, recipe: (tool: Tool) => planPackageInstall(tool) ?? toolInstall(tool)};
private misePanel?: MisePanel;
private readonly miseService = new MiseProjectService();
private toolConfigurationLoading = false;
Expand Down Expand Up @@ -899,11 +900,29 @@ export class TerminalApp {
}
// Focus reports alone are not user activity (a terminal can report them on its own).
if (keys.some(key => key.kind !== 'focusIn' && key.kind !== 'focusOut')) this.noteActivity();
for (const key of keys) this.handleKey(key);
for (const key of keys) {
const before = {text: this.editor.text, index: this.editor.displayCursorIndex};
this.handleKey(key);
this.noteCaretTravel(before);
}
// Passive motion renders only when hover changes; skip the generic frame.
if (keys.length === 0 || keys.some(key => key.kind !== 'mouseMove')) this.render();
}

/**
* A multi-cell caret jump on the same editor line starts the soft travel trail. Purely visual: the logical caret
* already moved, nothing waits for the animation, and a newer jump retargets the old trail.
*/
private noteCaretTravel(before: {text: string; index: number}): void {
if (this.editor.text !== before.text || this.editor.displayCursorIndex === before.index || this.settingsPanelActive || this.passthrough) return;
const columns = this.dimensions().columns;
const prefix = this.inputFirstLinePrefix(columns);
const at = (index: number) => layoutInput(this.editor.displayText, index, columns, Number.POSITIVE_INFINITY, prefix);
const from = at(before.index), to = at(this.editor.displayCursorIndex);
if (from.caretRow !== to.caretRow) return;
this.transitions.travel(from.caretColumn, to.caretColumn, to.caretRow, Date.now());
}

private readonly onResize = (): void => {
this.effects.cancel();
if (this.idle) this.dismissIdle(false);
Expand Down Expand Up @@ -2723,6 +2742,9 @@ export class TerminalApp {
this.output.setCompletionLifecycle(`${parts.main}${parts.detail}`);
const rowStyle = isInterrupted ? STOPPED : (exitCode !== 0 ? ERROR : SUCCESS);
this.output.addHistoryLine(`${rowStyle}${parts.main}${SECONDARY}${parts.detail}${RESET}`);
// The raw shell error stays; a curated command NMSh knows gets a factual follow-up (identity only, no lookup or install).
const known = failure === 'command-not-found' ? this.knownMissingNote(command.command) : undefined;
if (known) this.output.addHistoryLine(`${INFO}${known}${RESET}`);
}
this.running = undefined;
if (!this.replaying && !command.cleared && completedRecord) {
Expand Down Expand Up @@ -4129,6 +4151,13 @@ export class TerminalApp {
private handleScreensaverKey(key: Key, state: ScreensaverPanelState): void {
const action = screensaverKey(state, key, this.promptConfiguration.idleVisuals, this.promptConfiguration);
if (!action) return;
if (action.kind === 'replay') {
// Restart from the pristine snapshot: a fresh clock and a fresh effect instance, no stacked timers.
state.startedAt = Date.now();
this.saverGalleryCapture = undefined;
if (!idleMotion(this.promptConfiguration).still) this.render();
return;
}
if (action.kind === 'editColors') {
this.screensaverPanel = undefined;
this.screensaverAnimation?.(); this.screensaverAnimation = undefined;
Expand Down Expand Up @@ -4303,6 +4332,15 @@ export class TerminalApp {
* zsh cannot resolve (no alias, function, builtin or executable), offer an install instead. Returns
* true when the offer is shown; the command text stays in the composer meanwhile.
*/
/** One line for an exact curated command that is not installed; undefined for unknown commands (nothing is invented). */
private knownMissingNote(command: string): string | undefined {
const word = commandWord(command);
const tool = word ? knownToolForExecutable(word) : undefined;
if (!word || !tool || resolveCommand(word) !== undefined) return undefined;
const provided = tool.package && tool.package !== word ? ` · provided by ${tool.package}` : '';
return `${tool.label} is not installed${provided} · install it from /tools`;
}

private async offerInstallFor(command: string): Promise<boolean> {
const tool = installCandidate(command, this.promptConfiguration);
if (!tool || !tool.executable) return false;
Expand Down Expand Up @@ -4433,7 +4471,7 @@ export class TerminalApp {
{label: 'Completion sources', value: this.completionService.sourceIds.join(' + ')},
{label: 'History colors', value: config.transcript.historyColors === 'followPrompt' ? 'Follow prompt' : config.transcript.historyColors === 'theme' ? 'Theme' : 'Grayscale'},
]),
statusSection('Shell Environment', shellEnvironmentRows(this.shellEnvironment).map(([label, value]) => ({label, value}))),
statusSection('Shell Environment', shellEnvironmentRows(this.shellEnvironment, {id: this.shellId, ...(shellAdapter(this.shellId).resolveExecutable(process.env) ? {path: shellAdapter(this.shellId).resolveExecutable(process.env)!} : {})}).map(([label, value]) => ({label, value}))),
statusSection('Services & Activity', [
{label: 'Session notices', value: config.sessionNotices ? (this.sessionMode === 'service' ? 'On' : 'On (needs the live-session service)') : 'Off'},
{label: 'Agent activity', value: config.agentActivity ? 'On · local only' : 'Off'},
Expand Down Expand Up @@ -5095,6 +5133,11 @@ export class TerminalApp {
return undefined;
}

/** The panel position being shown: Setup previews its unsaved draft so the choice is visible immediately. */
private panelPositionInEffect(): 'bottom' | 'top' {
return (this.setupState?.draft ?? this.promptConfiguration).panelPosition === 'top' ? 'top' : 'bottom';
}

private planFrame(
columns: number,
rows: number,
Expand Down Expand Up @@ -6261,8 +6304,8 @@ export class TerminalApp {
return path ? {kind: 'executable', path} : undefined;
}, ...(resolveCommand('tldr') ? {examples: (path: readonly string[]) => tldrExamples(resolveCommand('tldr'), path)} : {}), install: name => {
// Only a curated /tools entry for this exact executable name; never a guessed package.
const tool = TOOLS.find(item => (item.executable ?? item.id) === name && !item.legacy);
const recipe = tool ? toolInstall(tool) : undefined;
const tool = suggestibleToolFor(name);
const recipe = tool ? (planPackageInstall(tool) ?? toolInstall(tool)) : undefined;
return tool && recipe ? {tool: tool.id, label: recipe.label} : undefined;
}};
}
Expand Down Expand Up @@ -6710,6 +6753,7 @@ export class TerminalApp {
detached: this.historyViewport.detached,
hasOutput: transcriptRows > 0,
composerPosition: this.promptConfiguration.composerPosition,
panelPosition: this.panelPositionInEffect(),
transcriptRows,
contextPlacement: this.promptConfiguration.placement,
hasVisibleContext: this.hasVisibleProviderPrompt(),
Expand Down Expand Up @@ -6837,11 +6881,12 @@ export class TerminalApp {
const separator = `${paintDivider(repeatToWidth(GLYPHS.separator, columns), this.promptConfiguration.presentation, Date.now())}${RESET}`;
const regionRows = (region: Region): string[] => {
switch (region.kind) {
case 'transcript': return visible;
// Setup Cat owns a clean screen: the ordinary transcript/welcome is not drawn behind it (presentation only; nothing is cleared).
case 'transcript': return this.setupState ? [] : visible;
case 'gap': return [];
case 'jump': return [this.jumpAffordance(columns)];
// Panels frame their composer-side edge: under Dock Top the frame line moves below the panel.
case 'panel': return plan.composerPosition === 'top' && panelRows && /^[─-]+$/u.test(stripAnsi(panelRows[0] ?? ''))
// The frame line sits on the panel edge that faces the transcript: the bottom edge for a Top panel.
case 'panel': return plan.panelPosition === 'top' && panelRows && /^[─-]+$/u.test(stripAnsi(panelRows[0] ?? ''))
? [...panelRows.slice(1), panelRows[0]!] : panelRows ?? [];
case 'inspector': return this.inspectorRows(columns);
case 'suggestions': return [...suggestionView.items.map((suggestion, visibleIndex) => {
Expand Down Expand Up @@ -6925,7 +6970,7 @@ export class TerminalApp {
if (!panel?.png || panel.protocol === 'none' || !region) return undefined;
const size = this.aboutLogoSize(columns);
// framePanel's frame line leads the panel (it moves below the panel under Dock Top), then title and spacer.
const row = region.top + (plan.composerPosition === 'top' ? 0 : 1) + 2;
const row = region.top + (plan.panelPosition === 'top' ? 0 : 1) + 2;
if (row + size.rows > region.top + region.height) return undefined;
return createImageOverlay(panel.protocol, panel.png, `about:${panel.protocol}:${row}:${size.columns}x${size.rows}`, row, 2, size);
}
Expand All @@ -6940,7 +6985,7 @@ export class TerminalApp {
for (const task of [this.promptPanelState?.task, this.toolsPanel?.task, this.providerPanelState?.task]) task?.setReducedMotion(!this.decorativeMotionAllowed());
const content = this.settingsPanelRows(cached.frame.columns ?? 80);
if (Math.min(cached.plan.rows, content.length) !== region.height) { this.render(); return; }
const projected = cached.plan.composerPosition === 'top' && /^[─-]+$/u.test(stripAnsi(content[0] ?? ''))
const projected = cached.plan.panelPosition === 'top' && /^[─-]+$/u.test(stripAnsi(content[0] ?? ''))
? [...content.slice(1), content[0]!] : content;
const rows = [...cached.frame.rows];
for (let index = 0; index < region.height; index++) rows[region.top + index] = projected[index] ?? '';
Expand Down Expand Up @@ -6985,6 +7030,12 @@ export class TerminalApp {
const caret = this.layoutEditorInput(columns, Math.max(1, input.height));
const offset = caret.caretRow - at(this.editor.displayCursorIndex).caretRow;
if (from.caretRow === to.caretRow) add(input.top + from.caretRow + offset, transitionPaint.materialize(from.caretColumn, to.caretColumn, t, transition.vivid, transition.look));
} else if (transition.kind === 'travel') {
const input = regions('input')[0];
if (!input) continue;
const caret = this.layoutEditorInput(columns, Math.max(1, input.height));
add(input.top + transition.row + (caret.caretRow - layoutInput(this.editor.displayText, this.editor.displayCursorIndex, columns, Number.POSITIVE_INFINITY, this.inputFirstLinePrefix(columns)).caretRow),
transitionPaint.travel(transition.from, transition.to, t, transition.look));
} else if (transition.kind === 'seal') {
const transcript = plan.regions.find(region => region.kind === 'transcript');
const index = this.visibleBlocks.lastIndexOf(transition.blockStartId);
Expand Down
17 changes: 11 additions & 6 deletions src/app/screenPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface ScreenPlanInput {
panelRows?: number;
/** Dock Bottom (default), Dock Top, or Flow. */
composerPosition?: ComposerPosition;
/** Where full-width NMSh panels sit: Bottom (default) or Top. Independent of the composer position. */
panelPosition?: 'bottom' | 'top';
/** Presented transcript rows; Dock Top and Flow use it to keep what follows next to the newest output. */
transcriptRows?: number;
/** Flow while scrolled back: the first transcript row in view (the composer follows the transcript's end). */
Expand Down Expand Up @@ -85,6 +87,8 @@ export interface ScreenPlan {
viewportRows: number;
composerPosition: ComposerPosition;
panelActive: boolean;
/** Where the active panel is anchored (meaningful while panelActive). */
panelPosition: 'bottom' | 'top';
}

export interface RegionHit {
Expand All @@ -97,13 +101,14 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan {
const rows = Math.max(1, input.rows);
const top = input.composerPosition === 'top';
if (input.panelRows !== undefined) {
// Panel takeover: the panel pins to the composer's edge and the transcript keeps the rest.
// Panel takeover follows the explicit NMSh panel position, never the composer position:
// Bottom keeps the bottom edge fixed (the top edge moves with height), Top keeps the top edge fixed.
const panelHeight = Math.min(rows, Math.max(0, input.panelRows));
const panel: Array<[RegionKind, number]> = [['panel', panelHeight]];
const transcript: Array<[RegionKind, number]> = [['transcript', rows - panelHeight]];
// Flow pins panels to the bottom edge, like Bottom.
return build(rows, top ? [...panel, ...transcript] : [...transcript, ...panel],
{inputHeight: 0, suggestionCount: 0, panelActive: true, composerPosition: input.composerPosition ?? 'bottom'});
const atTop = input.panelPosition === 'top';
return build(rows, atTop ? [...panel, ...transcript] : [...transcript, ...panel],
{inputHeight: 0, suggestionCount: 0, panelActive: true, composerPosition: input.composerPosition ?? 'bottom', panelPosition: atTop ? 'top' : 'bottom'});
}
const inspectorHeight = Math.min(Math.max(0, input.inspectorRows ?? 0), Math.max(0, rows - 8));
const layout = calculateScreenLayout(
Expand Down Expand Up @@ -187,7 +192,7 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan {
function build(
rows: number,
stack: Array<[RegionKind, number]>,
extra: Pick<ScreenPlan, 'inputHeight' | 'suggestionCount' | 'panelActive' | 'composerPosition'>,
extra: Pick<ScreenPlan, 'inputHeight' | 'suggestionCount' | 'panelActive' | 'composerPosition'> & {panelPosition?: 'bottom' | 'top'},
): ScreenPlan {
const regions: Region[] = [];
let transcript: Region = {kind: 'transcript', top: 0, height: 0};
Expand All @@ -199,7 +204,7 @@ function build(
if (height > 0) regions.push(region);
top += height;
}
return {rows, regions, transcript, ptyRows: transcript.height, viewportRows: Math.max(1, transcript.height), ...extra};
return {rows, regions, transcript, ptyRows: transcript.height, viewportRows: Math.max(1, transcript.height), panelPosition: 'bottom', ...extra};
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/appearance/AppearanceHub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export function renderAppearanceHub(state: AppearanceHubState, configuration: Pr
const controls = ['', renderControls([['↑↓', 'select'], ['←→', 'change'], ...(!advanced && state.selected === items.length ? [['Enter', 'open'] as [string, string]] : []), ...(shown ? [['R', 'replay'] as [string, string]] : []), ['Esc', 'back']])];
// The preview is always the same size; a short terminal gives up the intro, then the preview, never the controls or the list.
const block = shown ? ['', ` ${subtle}Preview · ${rendering}${reset}`, ...shown.rows] : [];
const layouts = [[...head, ...list, ...note, ...block, ...controls], [head[0]!, ...list, ...note, ...block, ...controls], [head[0]!, ...list, ...note, ...controls], [head[0]!, ...list, ...controls]];
const layouts = [[...head, ...list, ...note, ...block, ...controls], [head[0]!, ...list, ...note, ...block, ...controls], [head[0]!, ...list, ...note, ...controls], [head[0]!, ...list, ...controls], [...list, ...controls]];
const rows = height === undefined ? layouts[0]! : layouts.find(layout => layout.length <= height) ?? layouts[layouts.length - 1]!;
return rows.map(row => truncateAnsi(row, columns));
}
Expand Down
Loading
Loading