From b81949b0482391bc6aaae268c9b8432ae7818948 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:07:32 +0530 Subject: [PATCH 01/13] Add contextual Relevant here, typed package-manager plans and bulk install to /tools --- src/packages/managers.ts | 137 +++++++++++++++++++++++++++++++++++++ src/tools/ToolsPanel.ts | 134 +++++++++++++++++++++++++++++++++--- src/tools/catalog.ts | 14 ++-- src/tools/relevance.ts | 78 +++++++++++++++++++++ tests/tools.test.ts | 144 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 488 insertions(+), 19 deletions(-) create mode 100644 src/packages/managers.ts create mode 100644 src/tools/relevance.ts diff --git a/src/packages/managers.ts b/src/packages/managers.ts new file mode 100644 index 00000000..893a58ce --- /dev/null +++ b/src/packages/managers.ts @@ -0,0 +1,137 @@ +import {readFileSync} from 'node:fs'; +import {resolveCommand, type ProviderInstall} from '../providers/providers.js'; + +/** + * Typed package-manager install plans for the curated tool catalog. + * + * A plan is an executable plus an argv array, never a shell string. Package + * names come from a curated per-manager table of names that differ from the + * Homebrew formula or exist only on some distributions; a tool with no entry + * for the detected manager has no plan, so NMSh shows it as manual instead of + * guessing. Native Windows is not supported; WSL is Linux and uses the + * distribution's own manager. + * + * Privilege: Homebrew never needs elevation. The other managers do. NMSh + * never embeds passwords or elevates quietly: as root it runs the manager + * directly; otherwise the reviewed argv is explicitly `sudo -n ...`, which + * only succeeds if sudo needs no password prompt, and a failed run points at + * the exact command to run yourself. + */ +export type ManagerId = 'homebrew' | 'apt' | 'dnf' | 'pacman' | 'zypper'; + +export interface ManagerInfo {id: ManagerId; label: string; executable: string; elevation: 'none' | 'administrator'} +export interface PackagePlan extends ProviderInstall { + manager: ManagerId; + managerLabel: string; + package: string; + elevation: 'none' | 'administrator'; + /** Exact command to run yourself, shown when an elevated run cannot proceed. */ + manual: string; +} + +const MANAGERS: Record = { + homebrew: {label: 'Homebrew', executable: 'brew', install: ['install'], elevation: 'none'}, + apt: {label: 'APT', executable: 'apt-get', install: ['install', '-y'], elevation: 'administrator'}, + dnf: {label: 'DNF', executable: 'dnf', install: ['install', '-y'], elevation: 'administrator'}, + pacman: {label: 'pacman', executable: 'pacman', install: ['-S', '--needed', '--noconfirm'], elevation: 'administrator'}, + zypper: {label: 'zypper', executable: 'zypper', install: ['--non-interactive', 'install'], elevation: 'administrator'}, +}; + +/** + * Distribution package names, only where they provide the same executable the + * catalog expects. Left out on purpose: fd/bat on Debian-family (installed as + * fdfind/batcat), kubectl, yq (several implementations), anything uncertain. + */ +const ALL = (name: string): Partial> => ({apt: name, dnf: name, pacman: name, zypper: name}); +const NATIVE_PACKAGES: Record>> = { + rg: ALL('ripgrep'), fzf: ALL('fzf'), jq: ALL('jq'), zoxide: ALL('zoxide'), btop: ALL('btop'), + fd: {dnf: 'fd-find', pacman: 'fd', zypper: 'fd'}, + bat: {dnf: 'bat', pacman: 'bat', zypper: 'bat'}, + gh: {apt: 'gh', dnf: 'gh', pacman: 'github-cli', zypper: 'gh'}, + lazygit: {pacman: 'lazygit'}, + delta: {apt: 'git-delta', dnf: 'git-delta', pacman: 'git-delta'}, + shellcheck: {apt: 'shellcheck', dnf: 'ShellCheck', pacman: 'shellcheck', zypper: 'ShellCheck'}, + shfmt: {apt: 'shfmt', dnf: 'shfmt', pacman: 'shfmt'}, + hyperfine: {apt: 'hyperfine', dnf: 'hyperfine', pacman: 'hyperfine'}, + just: {apt: 'just', dnf: 'just', pacman: 'just'}, +}; + +export interface PackageEnvironment { + platform: NodeJS.Platform; + /** Contents of /etc/os-release, when readable. */ + osRelease?: string; + has(command: string): boolean; + isRoot: boolean; +} + +export function systemPackageEnvironment(): PackageEnvironment { + let osRelease: string | undefined; + try { osRelease = readFileSync('/etc/os-release', 'utf8').slice(0, 4096); } catch { /* not Linux or unreadable */ } + return {platform: process.platform, ...(osRelease === undefined ? {} : {osRelease}), has: command => resolveCommand(command) !== undefined, + isRoot: typeof process.getuid === 'function' && process.getuid() === 0}; +} + +/** Distribution family from os-release ID / ID_LIKE; unknown distributions get no native manager. */ +export function distroManager(osRelease: string | undefined): Exclude | undefined { + if (!osRelease) return undefined; + const field = (key: string) => new RegExp(`^${key}=("?)([^"\\n]*)\\1$`, 'mu').exec(osRelease)?.[2]?.toLowerCase() ?? ''; + const ids = `${field('ID')} ${field('ID_LIKE')}`.split(/\s+/u); + if (ids.some(id => ['debian', 'ubuntu'].includes(id))) return 'apt'; + if (ids.some(id => ['fedora', 'rhel', 'centos'].includes(id))) return 'dnf'; + if (ids.some(id => ['arch', 'archlinux'].includes(id))) return 'pacman'; + if (ids.some(id => ['suse', 'opensuse', 'opensuse-leap', 'opensuse-tumbleweed'].includes(id))) return 'zypper'; + return undefined; +} + +/** Managers usable here, in preference order: the distribution's own, then Homebrew. */ +export function availableManagers(env: PackageEnvironment): ManagerInfo[] { + const order: ManagerId[] = []; + if (env.platform === 'linux') { const native = distroManager(env.osRelease); if (native) order.push(native); } + if (env.platform === 'darwin' || env.platform === 'linux') order.push('homebrew'); + return order.filter(id => env.has(MANAGERS[id].executable)) + .map(id => ({id, label: MANAGERS[id].label, executable: MANAGERS[id].executable, elevation: MANAGERS[id].elevation})); +} + +/** Package name for a catalog tool under one manager; Homebrew uses the catalog formula. */ +function packageFor(tool: {id: string; package?: string}, manager: ManagerId): string | undefined { + return manager === 'homebrew' ? tool.package : NATIVE_PACKAGES[tool.id]?.[manager]; +} + +/** + * The first available manager with a verified mapping for this tool, as an + * exact argv. Undefined means "install manually" (see `planUnavailableReason`). + */ +export function planPackageInstall(tool: {id: string; package?: string; legacy?: boolean}, env: PackageEnvironment = systemPackageEnvironment()): PackagePlan | undefined { + if (tool.legacy) return undefined; + for (const manager of availableManagers(env)) { + const name = packageFor(tool, manager.id); + if (!name) continue; + const spec = MANAGERS[manager.id]; + const base = [spec.executable, ...spec.install, name]; + const elevated = spec.elevation === 'administrator' && !env.isRoot; + const argv = elevated ? ['sudo', '-n', ...base] : base; + return {label: argv.join(' '), command: argv[0]!, args: argv.slice(1), manager: manager.id, managerLabel: manager.label, package: name, + elevation: spec.elevation, manual: elevated ? ['sudo', ...base].join(' ') : base.join(' ')}; + } + return undefined; +} + +export function planUnavailableReason(tool: {id: string; label: string; package?: string}, env: PackageEnvironment = systemPackageEnvironment()): string { + if (env.platform === 'win32') return 'Native Windows is not supported. Run NMSh inside WSL and use that distribution\'s package manager.'; + const managers = availableManagers(env); + if (!managers.length) { + const native = env.platform === 'linux' ? distroManager(env.osRelease) : undefined; + return native ? `${MANAGERS[native].label} was not found. Install ${tool.label} yourself.` + : env.platform === 'linux' ? `NMSh did not recognise a supported package manager (APT, DNF, pacman, zypper or Homebrew). Install ${tool.label} yourself.` + : `Homebrew is not installed. Install ${tool.label} yourself.`; + } + return `NMSh has no verified ${managers.map(manager => manager.label).join(' / ')} package for ${tool.label}; it does not guess package names. Install it yourself.`; +} + +/** Reviewed-plan sentence about privilege, shown before the confirmation. */ +export function elevationNote(plan: Pick): string | undefined { + if (plan.elevation === 'none') return undefined; + return plan.command === 'sudo' + ? `Needs administrator rights. This runs with sudo -n (no password prompt); if sudo asks for a password it fails safely and you can run: ${plan.manual}` + : 'Needs administrator rights; NMSh is running as root.'; +} diff --git a/src/tools/ToolsPanel.ts b/src/tools/ToolsPanel.ts index 7e27ac74..c040ddc2 100644 --- a/src/tools/ToolsPanel.ts +++ b/src/tools/ToolsPanel.ts @@ -14,6 +14,8 @@ import {background, foreground, UI_COLORS} from '../ui/palette.js'; import {GLYPHS, getCurrentGlyphMode} from '../ui/glyphs.js'; import {renderControls} from '../ui/controls.js'; import {InstallProvenance, planToolUninstall, type UninstallPlan} from './InstallProvenance.js'; +import {elevationNote, planPackageInstall, planUnavailableReason, systemPackageEnvironment, type PackageEnvironment, type PackagePlan} from '../packages/managers.js'; +import {CONTEXT_LABELS, detectToolContexts, relevantTools, type ToolContext} from './relevance.js'; export type ToolsTab = 'discover' | 'installed' | 'configure' | 'errors'; const TABS: readonly ToolsTab[] = ['discover', 'installed', 'configure', 'errors']; @@ -34,8 +36,44 @@ export interface ToolsPanel { uninstall?: UninstallPlan; /** External (not NMSh-installed) Homebrew removal needs a first, explicit advanced acknowledgement. */ advancedAcknowledged?: boolean; + /** Safe local facts about the current directory; detected once, injectable for tests. */ + contexts?: readonly ToolContext[]; /** Install records; injectable for tests. */ provenance?: InstallProvenance; + /** Package managers and privilege facts; injectable for tests. */ + packages?: PackageEnvironment; + /** Tools ticked for one combined install (Space on the list). */ + selection?: Set; + /** A combined plan under review; installs nothing until its own confirmation. */ + bulk?: BulkReview; +} +export interface BulkReview { + items: Array<{tool: Tool; plan: PackagePlan}>; + excluded: Array<{tool: Tool; reason: string}>; +} +export interface BulkResult {tool: Tool; ok: boolean; detail: string} + +/** The reviewed install for one tool: the detected package manager's exact argv, or undefined for manual. */ +export function installPlanFor(state: ToolsPanel, tool: Tool): ProviderInstall | undefined { + if (tool.install) return toolInstall(tool); + return planPackageInstall(tool, state.packages ??= systemPackageEnvironment()); +} +export function installUnavailableFor(state: ToolsPanel, tool: Tool): string { + if (tool.install || tool.legacy) return toolInstallUnavailable(tool); + return planUnavailableReason(tool, state.packages ??= systemPackageEnvironment()); +} +/** Builds the combined plan for the ticked tools; tools without a plan are listed as excluded, never installed. */ +export function reviewBulk(state: ToolsPanel): BulkReview { + const review: BulkReview = {items: [], excluded: []}; + for (const id of state.selection ?? []) { + const tool = TOOLS.find(item => item.id === id); + if (!tool) continue; + if (state.statuses[tool.id]?.state !== 'missing') { review.excluded.push({tool, reason: 'already installed or not checked'}); continue; } + const plan = installPlanFor(state, tool) as PackagePlan | undefined; + if (plan?.manager) review.items.push({tool, plan}); + else review.excluded.push({tool, reason: installUnavailableFor(state, tool)}); + } + return review; } export const ONBOARDING_CHOICES = ['Recommended', 'Recommended + Enhanced', 'Choose individually', 'Skip'] as const; const SKIP = ONBOARDING_CHOICES.length - 1; @@ -53,7 +91,15 @@ export async function refreshTools(state: ToolsPanel, changed: () => void): Prom changed(); } } +/** Missing tools whose declared relevance matches this directory, with the matching fact. */ +export function relevantHere(state: ToolsPanel): Map { + if (state.tab !== 'discover' || state.query.trim()) return new Map(); + state.contexts ??= detectToolContexts(process.cwd()); + return new Map(relevantTools(TOOLS, state.contexts, tool => state.statuses[tool.id]?.state === 'missing').map(item => [item.tool.id, item.because])); +} +export const RELEVANT_GROUP = 'Relevant here'; export function visibleTools(state: ToolsPanel): Tool[] { + const relevant = relevantHere(state); const query = state.query.trim().toLowerCase(); const tier = state.tier ?? (state.recommendedOnly ? 'recommended' : undefined); return TOOLS.filter(tool => (state.tab !== 'discover' || !tier || tool.tier === 'recommended' || (tier === 'enhanced' && tool.tier === 'enhanced')) @@ -61,9 +107,12 @@ export function visibleTools(state: ToolsPanel): Tool[] { && (state.tab !== 'installed' || state.statuses[tool.id]?.state === 'installed') && (state.tab !== 'configure' || !!tool.configuration) && (state.tab !== 'errors' || !!state.errors[tool.id] || (state.configured.has(tool.id) && state.statuses[tool.id]?.state === 'missing'))) - .sort((a, b) => TOOL_CATEGORIES.indexOf(a.category) - TOOL_CATEGORIES.indexOf(b.category) + .sort((a, b) => Number(relevant.has(b.id)) - Number(relevant.has(a.id)) + || (relevant.has(a.id) ? relevantRank(a) - relevantRank(b) : 0) + || TOOL_CATEGORIES.indexOf(a.category) - TOOL_CATEGORIES.indexOf(b.category) || Number(state.statuses[a.id]?.state === 'installed') - Number(state.statuses[b.id]?.state === 'installed') || a.label.localeCompare(b.label)); } +const relevantRank = (tool: Tool) => tool.tier === 'recommended' ? 0 : tool.tier === 'enhanced' ? 1 : 2; export type ToolsAction = 'close' | 'configure' | 'provider' | 'refresh' | 'finishOnboarding' | 'mise' | 'checkUpdates'; /** Whether an installed tool has an update according to the last check. */ @@ -88,7 +137,8 @@ export function toolsKey(state: ToolsPanel, key: Key): ToolsAction | undefined { } if (state.confirm) return undefined; // Async install owner handles confirmation. if (key.kind === 'escape' || key.kind === 'interrupt') { - if (state.detail) { state.detail = undefined; state.message = undefined; } + if (state.selection?.size && !state.detail) { state.selection.clear(); state.message = 'Selection cleared. Nothing was installed.'; } + else if (state.detail) { state.detail = undefined; state.message = undefined; } else if (state.query) { state.query = ''; state.selected = 0; } else return 'close'; } else if (state.detail) { @@ -111,9 +161,9 @@ export function toolsKey(state: ToolsPanel, key: Key): ToolsAction | undefined { return undefined; } if (key.value.toLowerCase() === 'i' && state.statuses[state.detail.id]?.state === 'missing') { - state.recipe = toolInstall(state.detail); + state.recipe = installPlanFor(state, state.detail); if (state.recipe) state.confirm = createConfirm(); - else state.message = `${toolInstallUnavailable(state.detail)} Nothing was installed.`; + else state.message = `${installUnavailableFor(state, state.detail)} Nothing was installed.`; } else if (key.value.toLowerCase() === 'c' && state.detail.configuration && state.statuses[state.detail.id]?.state === 'installed') return 'configure'; else if (key.value.toLowerCase() === 'p' && state.detail.providerFamily) return 'provider'; else if (key.value.toLowerCase() === 'r') return 'refresh'; @@ -123,8 +173,20 @@ export function toolsKey(state: ToolsPanel, key: Key): ToolsAction | undefined { state.selected = 0; } else if (key.kind === 'up' || key.kind === 'down') { state.selected = Math.max(0, Math.min(visibleTools(state).length - 1, state.selected + (key.kind === 'up' ? -1 : 1))); + } else if (key.kind === 'enter' && state.selection?.size) { + state.bulk = reviewBulk(state); + if (state.bulk.items.length) state.confirm = createConfirm(); + else { state.message = 'None of the selected tools can be installed automatically here. Nothing was installed.'; state.bulk = undefined; } } else if (key.kind === 'enter') state.detail = visibleTools(state)[state.selected]; - else if (key.kind === 'text' && key.value === 'U' && !state.query) return 'checkUpdates'; + else if (key.kind === 'text' && key.value === ' ' && !state.query && state.tab === 'discover') { + const tool = visibleTools(state)[state.selected]; + const selection = state.selection ??= new Set(); + if (!tool) return undefined; + if (selection.has(tool.id)) selection.delete(tool.id); + else if (state.statuses[tool.id]?.state !== 'missing') state.message = `${tool.label} is not missing, so it cannot be selected for install.`; + else if (!installPlanFor(state, tool)) state.message = `${installUnavailableFor(state, tool)} It cannot be selected.`; + else { selection.add(tool.id); state.message = undefined; } + } else if (key.kind === 'text' && key.value === 'U' && !state.query) return 'checkUpdates'; else { const next = editText(state.query, key); if (next !== undefined) { state.query = next; state.selected = 0; } @@ -134,6 +196,7 @@ export function toolsKey(state: ToolsPanel, key: Key): ToolsAction | undefined { export async function confirmToolInstall(state: ToolsPanel, key: Key, changed: () => void, run?: (task: TaskProgress, recipe: ProviderInstall) => Promise): Promise { + if (state.bulk && state.confirm) { await confirmBulk(state, key, changed, run); return; } if (!state.confirm || !state.recipe || !state.detail) return; const decision = handleConfirmKey(key.kind === 'interrupt' ? {kind: 'escape'} : key, state.confirm); if (decision === 'cancel') { state.confirm = undefined; state.recipe = undefined; state.uninstall = undefined; state.advancedAcknowledged = undefined; return; } @@ -168,6 +231,43 @@ export async function confirmToolInstall(state: ToolsPanel, key: Key, changed: ( } changed(); } +/** One explicit confirmation, then each tool in turn; a failure never undoes or blocks the others. */ +async function confirmBulk(state: ToolsPanel, key: Key, changed: () => void, + run?: (task: TaskProgress, recipe: ProviderInstall) => Promise): Promise { + const decision = handleConfirmKey(key.kind === 'interrupt' ? {kind: 'escape'} : key, state.confirm!); + if (decision === 'cancel') { state.confirm = undefined; state.bulk = undefined; state.message = 'Cancelled. Nothing was installed.'; return; } + if (decision !== 'confirm') return; + const review = state.bulk!; + state.confirm = undefined; state.bulk = undefined; + const results: BulkResult[] = []; + for (const {tool, plan} of review.items) { + const task = state.task = new TaskProgress(`Installing ${tool.label}`, changed, Date.now(), tool.label); + try { + if (run) await run(task, plan); else await task.run(plan.command, [...plan.args]); + } catch (error) { + results.push({tool, ok: false, detail: error instanceof Error ? error.message : String(error)}); + state.errors[tool.id] = `Installation failed: ${results.at(-1)!.detail}`; + continue; + } + clearProviderDetection(); + state.statuses[tool.id] = await detectProvider(tool); + if (task.state.status === 'succeeded' && state.statuses[tool.id]?.state === 'installed') { + delete state.errors[tool.id]; + try { (state.provenance ?? new InstallProvenance()).record(tool, plan); } catch { /* provenance is best effort */ } + results.push({tool, ok: true, detail: 'installed'}); + } else { + const detail = task.state.status === 'failed' ? task.state.error ?? 'unknown error' : 'package manager finished; executable was not detected'; + state.errors[tool.id] = `Installation failed: ${detail}`; + results.push({tool, ok: false, detail: plan.elevation === 'administrator' && plan.command === 'sudo' ? `${detail} (run yourself: ${plan.manual})` : detail}); + } + changed(); + } + state.selection = new Set(results.filter(result => !result.ok).map(result => result.tool.id)); + state.task = undefined; + state.message = [`Installed ${results.filter(result => result.ok).length} of ${results.length}.`, + ...results.filter(result => !result.ok).map(result => `${result.tool.label}: ${result.detail}`)].join(' '); + changed(); +} async function runUninstall(state: ToolsPanel, changed: () => void, run?: (task: TaskProgress, recipe: ProviderInstall) => Promise): Promise { const recipe = state.recipe!; const tool = state.detail!; @@ -246,10 +346,11 @@ function toolRow(state: ToolsPanel, tool: Tool, selected: boolean, columns: numb const BOLD = '\u001b[1m'; /** Category headers and tool rows in display order; headers are not selectable. */ -function groupedRows(tools: readonly Tool[]): Array<{kind: 'header'; category: string} | {kind: 'tool'; tool: Tool; index: number}> { +function groupedRows(tools: readonly Tool[], relevant: ReadonlyMap = new Map()): Array<{kind: 'header'; category: string} | {kind: 'tool'; tool: Tool; index: number}> { const rows: Array<{kind: 'header'; category: string} | {kind: 'tool'; tool: Tool; index: number}> = []; + const group = (tool: Tool) => relevant.has(tool.id) ? RELEVANT_GROUP : tool.category; tools.forEach((tool, index) => { - if (index === 0 || tools[index - 1]!.category !== tool.category) rows.push({kind: 'header', category: tool.category}); + if (index === 0 || group(tools[index - 1]!) !== group(tool)) rows.push({kind: 'header', category: group(tool)}); rows.push({kind: 'tool', tool, index}); }); return rows; @@ -259,7 +360,7 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): resolveColors(); const tabsRow = renderTabStrip(['Discover', 'Installed', 'Configure', 'Errors'], TABS.indexOf(state.tab), columns); const rows: string[] = [`${PRIMARY} Tools${RESET} ${SUBTLE}optional · NMSh is complete without them; switch providers anytime${RESET}`, tabsRow, '']; - let footer: Array<[string, string]> = [['↑↓', 'select'], ['←→', 'tabs'], ['Enter', 'details'], ['type', 'search'], ['U', 'check updates'], ['Esc', state.query ? 'clear search' : 'close']]; + let footer: Array<[string, string]> = [['↑↓', 'select'], ['←→', 'tabs'], ...(state.tab === 'discover' ? [['Space', state.selection?.size ? `select · ${state.selection.size} chosen` : 'select'] as [string, string]] : []), ['Enter', state.selection?.size ? 'review install' : 'details'], ['type', 'search'], ['U', 'check updates'], ['Esc', state.query ? 'clear search' : 'close']]; if (state.onboarding !== undefined) { rows.push(`${PRIMARY} NMSh is complete out of the box. No external shell tools are required.${RESET}`, `${SUBTLE} Optional tools can be added now or later; browsing changes nothing and each install asks first.${RESET}`, @@ -267,6 +368,15 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): ...ONBOARDING_CHOICES.map((label, i) => i === state.onboarding ? ` ${ACCENT}${GLYPHS.selection} ${PRIMARY}${label}${RESET}` : ` ${SECONDARY}${label}${RESET}`)); footer = [['↑↓', 'choose'], ['Enter', 'continue'], ['Esc', 'skip']]; + } else if (state.confirm && state.bulk) { + const plans = state.bulk.items; + rows.push(`${PRIMARY} Install ${plans.length} tool${plans.length === 1 ? '' : 's'}?${RESET}`, '', + ...plans.map(({tool, plan}) => ` ${SECONDARY}${tool.label}${RESET} ${SUBTLE}→${RESET} ${SECONDARY}${plan.managerLabel}${RESET} ${SUBTLE}→${RESET} ${PRIMARY}${plan.package}${RESET} ${SUBTLE}${plan.label}${RESET}`), + ...(plans.some(({plan}) => plan.elevation === 'administrator') ? ['', ` ${FAILURE}${elevationNote(plans.find(({plan}) => plan.elevation === 'administrator')!.plan)}${RESET}`] : []), + ...(state.bulk.excluded.length ? ['', ` ${SUBTLE}Not included:${RESET}`, ...state.bulk.excluded.map(item => ` ${SUBTLE}${item.tool.label}: ${item.reason}${RESET}`)] : []), + '', ` ${SUBTLE}Each runs separately; one failing does not undo the others. Shell hooks and settings are not touched.${RESET}`, '', + ` ${renderConfirm(state.confirm, {focused: true, color: colorLevel() !== 'none'})}`); + footer = [['←→', 'choose'], ['Enter', 'confirm'], ['Esc', 'cancel']]; } else if (state.confirm && state.uninstall) { const external = state.uninstall.kind === 'external-homebrew'; rows.push(`${PRIMARY} Uninstall ${state.detail?.label ?? 'tool'}?${RESET}`, '', @@ -279,6 +389,7 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): footer = [['←→', 'choose'], ['Enter', 'confirm'], ['Esc', 'cancel']]; } else if (state.confirm) { rows.push(`${PRIMARY} ${state.upgrading ? 'Upgrade' : 'Install'} ${state.detail?.label ?? 'tool'}?${RESET}`, '', ` ${SUBTLE}Runs${RESET} ${PRIMARY}${state.recipe?.label ?? ''}${RESET}`, + ...(state.recipe && 'elevation' in state.recipe && elevationNote(state.recipe as PackagePlan) ? [` ${FAILURE}${elevationNote(state.recipe as PackagePlan)}${RESET}`] : []), ` ${SUBTLE}Changes installed software only; shell hooks and settings are not touched.${RESET}`, '', ` ${renderConfirm(state.confirm, {focused: true, color: colorLevel() !== 'none'})}`); footer = [['←→', 'choose'], ['Enter', 'confirm'], ['Esc', 'cancel']]; @@ -293,7 +404,7 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): ` ${SUBTLE}${tool.description}${RESET}`, '', field('Category', tool.category), field('Source', tool.source), field('Install', state.statuses[tool.id]?.state === 'missing' - ? toolInstall(tool)?.label ?? toolInstallUnavailable(tool) : tool.package ? `Homebrew formula ${tool.package}` : 'Installed outside NMSh')); + ? installPlanFor(state, tool)?.label ?? installUnavailableFor(state, tool) : tool.package ? `Package ${tool.package}` : 'Installed outside NMSh')); const lifecycle = lifecycleNote(tool); if (lifecycle) rows.push(field('Lifecycle', lifecycle)); const outdated = tool.package ? state.updates?.outdated[tool.package] : undefined; @@ -320,7 +431,8 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): rows.push(` ${SUBTLE}Search${RESET} ${state.query ? `${PRIMARY}${state.query}` : `${SUBTLE}type to filter`}${RESET}${tierLabel ? ` ${ACCENT}${tierLabel}${RESET}` : ''}${checked}`, ''); const tools = visibleTools(state); state.selected = Math.max(0, Math.min(state.selected, tools.length - 1)); - const display = groupedRows(tools); + const relevant = relevantHere(state); + const display = groupedRows(tools, relevant); // Rows left for the list after title, tabs, search, description and footer. // Below the list: a "more" cue, the description, an optional message, the footer, and the frame line. const budget = Math.max(1, height - rows.length - 6 - (state.message ? 2 : 0)); @@ -337,7 +449,7 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): if (more > 0) rows.push(` ${SUBTLE}${getCurrentGlyphMode() === 'nerd' ? '↓' : 'v'} ${display.slice(start + budget).filter(row => row.kind === 'tool').length} more${RESET}`); if (!tools.length) rows.push(` ${SUBTLE}${state.tab === 'errors' ? 'No tool problems detected.' : 'No matching tools.'}${RESET}`); const selected = tools[state.selected]; - if (selected) rows.push('', ` ${SUBTLE}${state.errors[selected.id] ?? selected.description}${RESET}`); + if (selected) rows.push('', ` ${SUBTLE}${state.errors[selected.id] ?? selected.description}${selected && relevant.has(selected.id) ? ` Shown because this looks like a ${CONTEXT_LABELS[relevant.get(selected.id)!]}.` : ''}${RESET}`); } if (state.message) rows.push('', ` ${SECONDARY}${state.message}${RESET}`); rows.push('', renderControls(footer)); diff --git a/src/tools/catalog.ts b/src/tools/catalog.ts index 07b28776..4b1b5296 100644 --- a/src/tools/catalog.ts +++ b/src/tools/catalog.ts @@ -45,13 +45,13 @@ export const TOOLS: readonly Tool[] = [ tool('fzf', 'fzf', 'Search & Files', 'Optional interactive supplied-candidate picker.', 'https://github.com/junegunn/fzf', {recommended: true, providerFamily: 'picker'}), tool('bat', 'bat', 'Search & Files', 'File viewing with syntax colors.', 'https://github.com/sharkdp/bat', ENHANCED), tool('eza', 'eza', 'Search & Files', 'Optional directory listing.', 'https://github.com/eza-community/eza', ENHANCED), - tool('gh', 'GitHub CLI', 'Git & Development', 'GitHub workflows; authentication is managed by gh.', 'https://cli.github.com/', ENHANCED), - tool('lazygit', 'lazygit', 'Git & Development', 'Interactive Git frontend.', 'https://github.com/jesseduffield/lazygit', ENHANCED), - tool('delta', 'delta', 'Git & Development', 'Optional Git diff presentation.', 'https://github.com/dandavison/delta', {package: 'git-delta', ...ENHANCED}), + tool('gh', 'GitHub CLI', 'Git & Development', 'GitHub workflows; authentication is managed by gh.', 'https://cli.github.com/', {...ENHANCED, relevantTo: ['git']}), + tool('lazygit', 'lazygit', 'Git & Development', 'Interactive Git frontend.', 'https://github.com/jesseduffield/lazygit', {...ENHANCED, relevantTo: ['git']}), + tool('delta', 'delta', 'Git & Development', 'Optional Git diff presentation.', 'https://github.com/dandavison/delta', {package: 'git-delta', ...ENHANCED, relevantTo: ['git']}), tool('zoxide', 'zoxide', 'Navigation & History', 'Optional directory ranking; existing hooks stay unchanged.', 'https://github.com/ajeetdsouza/zoxide', {recommended: true, providerFamily: 'navigation'}), tool('atuin', 'Atuin', 'Navigation & History', 'Optional local history provider; no sync or hook setup.', 'https://atuin.sh/', {providerFamily: 'history', ...ENHANCED}), - tool('jq', 'jq', 'Data / Structured Text', 'Read and transform JSON.', 'https://jqlang.org/', {recommended: true}), - tool('yq', 'yq', 'Data / Structured Text', 'Structured text tooling; multiple yq implementations exist.', 'https://github.com/mikefarah/yq'), + tool('jq', 'jq', 'Data / Structured Text', 'Read and transform JSON.', 'https://jqlang.org/', {recommended: true, relevantTo: ['kubernetes']}), + tool('yq', 'yq', 'Data / Structured Text', 'Structured text tooling; multiple yq implementations exist.', 'https://github.com/mikefarah/yq', {relevantTo: ['kubernetes']}), tool('jc', 'jc', 'Data / Structured Text', 'Convert common command output formats to JSON.', 'https://github.com/kellyjonbrazil/jc', {package: 'jc', executable: 'jc', ...ENHANCED}), tool('glow', 'Glow', 'Data / Structured Text', 'Render Markdown in the terminal.', 'https://github.com/charmbracelet/glow', {package: 'glow', ...ENHANCED}), tool('xh', 'xh', 'Data / Structured Text', 'Friendly and fast HTTP client.', 'https://github.com/ducaale/xh', {package: 'xh', ...ENHANCED}), @@ -60,9 +60,9 @@ export const TOOLS: readonly Tool[] = [ tool('starship', 'Starship', 'Shell / Workflow', 'Optional prompt with supported module configuration.', 'https://starship.rs/', {configuration: 'starship'}), tool('shellcheck', 'ShellCheck', 'Shell / Workflow', 'Find common shell script mistakes.', 'https://github.com/koalaman/shellcheck', {package: 'shellcheck', ...ENHANCED, relevantTo: ['shell-scripts']}), tool('shfmt', 'shfmt', 'Shell / Workflow', 'Format shell scripts consistently.', 'https://github.com/mvdan/sh', {package: 'shfmt', ...ENHANCED, relevantTo: ['shell-scripts']}), - tool('just', 'just', 'Shell / Workflow', 'A command runner for project-specific tasks.', 'https://github.com/casey/just', {package: 'just', ...ENHANCED, relevantTo: ['project']}), + tool('just', 'just', 'Shell / Workflow', 'A command runner for project-specific tasks.', 'https://github.com/casey/just', {package: 'just', ...ENHANCED, relevantTo: ['project', 'javascript', 'python', 'go', 'rust']}), tool('hyperfine', 'hyperfine', 'Shell / Workflow', 'Benchmark command-line programs.', 'https://github.com/sharkdp/hyperfine', {package: 'hyperfine', ...ENHANCED}), - tool('watchexec', 'watchexec', 'Shell / Workflow', 'Re-run a command when watched sources change.', 'https://github.com/watchexec/watchexec', {package: 'watchexec', ...ENHANCED, relevantTo: ['project']}), + tool('watchexec', 'watchexec', 'Shell / Workflow', 'Re-run a command when watched sources change.', 'https://github.com/watchexec/watchexec', {package: 'watchexec', ...ENHANCED, relevantTo: ['project', 'javascript', 'python', 'go', 'rust']}), tool('television', 'Television', 'Search & Files', 'Optional fuzzy picker provider.', 'https://github.com/alexpasmantier/television', {package: 'television', executable: 'tv', providerFamily: 'picker', relevantTo: ['git']}), tool('carapace', 'Carapace', 'Shell / Workflow', 'Optional external completion provider.', 'https://github.com/carapace-sh/carapace-bin', {package: 'carapace', providerFamily: 'tool', integration: 'completion', relevantTo: ['shell-completion']}), tool('dust', 'dust', 'Search & Files', 'A clearer view of disk usage.', 'https://github.com/bootandy/dust', {package: 'dust', ...ENHANCED}), diff --git a/src/tools/relevance.ts b/src/tools/relevance.ts new file mode 100644 index 00000000..233fc2b2 --- /dev/null +++ b/src/tools/relevance.ts @@ -0,0 +1,78 @@ +import {existsSync, readdirSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {homedir} from 'node:os'; + +/** + * Cheap, safe facts about the current directory for "Relevant here" in /tools. + * Only existence checks of well-known names and one bounded top-level listing: + * no recursion, no file contents, no project code, no network, no model. + * Context names match `Tool.relevantTo` in the catalog. + */ +export type ToolContext = 'git' | 'shell-scripts' | 'javascript' | 'kubernetes' | 'python' | 'go' | 'rust' | 'containers' | 'project'; + +export interface RelevanceProbe { + exists(path: string): boolean; + /** Names directly inside one directory, bounded; never recursive. */ + list(path: string): string[]; + env: NodeJS.ProcessEnv; + home: string; +} + +export function systemRelevanceProbe(env: NodeJS.ProcessEnv = process.env): RelevanceProbe { + return {exists: existsSync, env, home: homedir(), + list: path => { try { return readdirSync(path).slice(0, 400); } catch { return []; } }}; +} + +const GIT_PARENT_LIMIT = 8; +const KUBERNETES_FILES = ['Chart.yaml', 'kustomization.yaml', 'kustomization.yml', 'skaffold.yaml', 'k8s', 'kubernetes']; +const CONTAINER_FILES = ['Dockerfile', 'Containerfile', 'compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml']; + +export function detectToolContexts(cwd: string, probe: RelevanceProbe = systemRelevanceProbe()): ToolContext[] { + const found = new Set(); + const has = (name: string) => probe.exists(join(cwd, name)); + // A Git work tree: .git here or in a bounded number of parents. + for (let dir = cwd, depth = 0; depth <= GIT_PARENT_LIMIT; depth += 1) { + if (probe.exists(join(dir, '.git'))) { found.add('git'); break; } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + const names = probe.list(cwd); + if (names.some(name => /\.(?:sh|bash|zsh)$/u.test(name))) found.add('shell-scripts'); + if (has('package.json')) { found.add('javascript'); found.add('project'); } + if (has('pyproject.toml') || has('requirements.txt')) { found.add('python'); found.add('project'); } + if (has('go.mod')) { found.add('go'); found.add('project'); } + if (has('Cargo.toml')) { found.add('rust'); found.add('project'); } + if (has('Makefile') || has('justfile') || has('Justfile')) found.add('project'); + if (CONTAINER_FILES.some(has)) found.add('containers'); + // Kubernetes: manifest-ish project files, or an explicit kubeconfig the user already set up. No cluster contact. + if (KUBERNETES_FILES.some(has) || probe.env.KUBECONFIG || probe.exists(join(probe.home, '.kube', 'config'))) found.add('kubernetes'); + return [...found]; +} + +export const CONTEXT_LABELS: Record = { + git: 'Git repository', 'shell-scripts': 'shell scripts', javascript: 'JavaScript / Node project', kubernetes: 'Kubernetes files or kubeconfig', + python: 'Python project', go: 'Go module', rust: 'Rust crate', containers: 'container files', project: 'project'}; + +interface RelevantCandidate {id: string; relevantTo?: readonly string[]; tier?: string; legacy?: boolean; integration?: string; providerFamily?: string; language?: string} + +/** + * Curated, conservative: a tool qualifies only through its own declared + * `relevantTo`. The broad 'project' context alone never qualifies a tool, so + * generic helpers don't appear in every directory. Missing tools in the + * Recommended/Enhanced tiers rank first; installed and environment tools are + * excluded because there is nothing to discover. + */ +export function relevantTools(tools: readonly T[], contexts: readonly ToolContext[], + isMissing: (tool: T) => boolean, limit = 6): Array<{tool: T; because: ToolContext}> { + const specific = contexts.filter(context => context !== 'project'); + const rank = (tool: T) => tool.tier === 'recommended' ? 0 : tool.tier === 'enhanced' ? 1 : 2; + return tools + .filter(tool => !tool.legacy && !tool.integration && !tool.providerFamily && !tool.language && isMissing(tool)) + .flatMap(tool => { + const because = specific.find(context => tool.relevantTo?.includes(context)); + return because ? [{tool, because}] : []; + }) + .sort((a, b) => rank(a.tool) - rank(b.tool)) + .slice(0, limit); +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index df65e936..7d9f1990 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -124,6 +124,7 @@ test('task timeout/cancellation settle and reduced-motion progress stays still', test('Tools v2: Discover groups by category with aligned status columns, a selection band and the selected description', () => { const state = createToolsPanel(new Set(['fzf'])); + state.contexts = []; // independent of the directory the tests run in for (const tool of TOOLS) state.statuses[tool.id] = tool.id === 'lazygit' ? {state: 'missing'} : {state: 'installed'}; const rows = renderTools(state, 100, 40); const plain = rows.map(stripAnsi); @@ -139,7 +140,7 @@ test('Tools v2: Discover groups by category with aligned status columns, a selec const selected = rows.find(row => stripAnsi(row).includes('›') && stripAnsi(row).includes(first.label))!; assert.match(selected, /\u001b\[48;/u, 'selected row has a background band'); assert.ok(plain.some(row => row.trim() === first.description), 'muted description of the selection'); - assert.match(plain.at(-1)!, /↑↓ select · ←→ tabs · Enter details/u); + assert.match(plain.at(-1)!, /↑↓ select · ←→ tabs · Space select · Enter details/u); const lazygit = plain.find(row => row.includes('lazygit'))!; assert.match(lazygit, /Missing/u); }); @@ -157,3 +158,144 @@ test('Tools v2: long lists scroll with a factual "more" cue; narrow widths keep assert.ok(narrow.map(stripAnsi).some(row => row.includes('›')), `selection visible @${width}`); } }); + +import {detectToolContexts, relevantTools, type RelevanceProbe} from '../src/tools/relevance.js'; +import {availableManagers, distroManager, planPackageInstall, planUnavailableReason, type PackageEnvironment} from '../src/packages/managers.js'; +import {reviewBulk} from '../src/tools/ToolsPanel.js'; + +const fakeProbe = (files: string[], env: NodeJS.ProcessEnv = {}, dirs: Record = {}): RelevanceProbe => ({ + exists: path => files.includes(path), list: path => dirs[path] ?? [], env, home: '/home/u'}); + +test('Relevant here: contexts come from bounded existence checks only', () => { + assert.deepEqual(detectToolContexts('/w/app', fakeProbe([])), []); + assert.ok(detectToolContexts('/w/app/src', fakeProbe(['/w/.git'])).includes('git'), 'git found in a parent'); + assert.ok(!detectToolContexts('/a/b/c/d/e/f/g/h/i/j', fakeProbe(['/.git'])).includes('git'), 'parent walk is bounded'); + const node = detectToolContexts('/w/app', fakeProbe(['/w/app/package.json'])); + assert.ok(node.includes('javascript') && node.includes('project')); + assert.ok(detectToolContexts('/w/s', fakeProbe([], {}, {'/w/s': ['deploy.sh']})).includes('shell-scripts')); + assert.ok(detectToolContexts('/w/k', fakeProbe(['/w/k/kustomization.yaml'])).includes('kubernetes')); + assert.ok(detectToolContexts('/w/x', fakeProbe([], {KUBECONFIG: '/x'})).includes('kubernetes')); + assert.ok(detectToolContexts('/w/x', fakeProbe(['/home/u/.kube/config'])).includes('kubernetes')); +}); + +test('Relevant here: curated, missing-only, and the broad project context alone adds nothing', () => { + const missing = (ids: string[]) => (tool: {id: string}) => ids.includes(tool.id); + const all = TOOLS.map(tool => tool.id); + assert.deepEqual(relevantTools(TOOLS, ['project'], missing(all)), []); + const git = relevantTools(TOOLS, ['git'], missing(all)).map(item => item.tool.id); + assert.deepEqual([...git].sort(), ['delta', 'gh', 'lazygit']); + assert.deepEqual(relevantTools(TOOLS, ['git'], missing(['gh'])).map(item => item.tool.id), ['gh']); + assert.deepEqual(relevantTools(TOOLS, ['shell-scripts'], missing(all)).map(item => item.tool.id).sort(), ['shellcheck', 'shfmt']); + const k8s = relevantTools(TOOLS, ['kubernetes'], missing(all)).map(item => item.tool.id); + assert.ok(k8s.includes('kubectl') && k8s.includes('yq') && k8s.includes('jq')); + const js = relevantTools(TOOLS, ['javascript', 'project'], missing(all)).map(item => item.tool.id); + assert.ok(!js.includes('node'), 'language runtimes are not suggested'); + assert.ok(!js.includes('hyperfine'), 'no metadata, no suggestion'); + assert.deepEqual(relevantTools(TOOLS, ['git'], () => false), [], 'installed tools are not listed'); +}); + +test('Relevant here: Discover shows a group before categories, with the reason, once statuses are known', () => { + const state = createToolsPanel(); + state.contexts = ['git']; + for (const tool of TOOLS) state.statuses[tool.id] = {state: 'installed'}; + assert.ok(!renderTools(state, 100, 40).map(stripAnsi).some(row => row.trim() === 'Relevant here')); + state.statuses.gh = {state: 'missing'}; + const plain = renderTools(state, 100, 40).map(stripAnsi); + const header = plain.findIndex(row => row.trim() === 'Relevant here'); + assert.ok(header >= 0 && plain[header + 1]!.includes('GitHub CLI')); + assert.equal(visibleTools(state)[0]!.id, 'gh'); + assert.match(plain.join('\n'), /Shown because this looks like a Git repository/u); + state.query = 'git'; + assert.ok(!renderTools(state, 100, 40).map(stripAnsi).some(row => row.trim() === 'Relevant here'), 'searching shows plain results'); +}); + +const env = (osRelease: string | undefined, commands: string[], extra: Partial = {}): PackageEnvironment => + ({platform: 'linux', ...(osRelease ? {osRelease} : {}), has: command => commands.includes(command), isRoot: false, ...extra}); + +test('package managers: distro families, preference order and typed argv plans', () => { + assert.equal(distroManager('ID=ubuntu\nID_LIKE=debian'), 'apt'); + assert.equal(distroManager('ID="linuxmint"\nID_LIKE="ubuntu debian"'), 'apt'); + assert.equal(distroManager('ID=fedora'), 'dnf'); + assert.equal(distroManager('ID=manjaro\nID_LIKE=arch'), 'pacman'); + assert.equal(distroManager('ID="opensuse-tumbleweed"\nID_LIKE="opensuse suse"'), 'zypper'); + assert.equal(distroManager('ID=nixos'), undefined); + assert.equal(distroManager(undefined), undefined); + const rg = TOOLS.find(tool => tool.id === 'rg')!; + const apt = planPackageInstall(rg, env('ID=ubuntu', ['apt-get', 'brew']))!; + assert.deepEqual([apt.command, ...apt.args], ['sudo', '-n', 'apt-get', 'install', '-y', 'ripgrep']); + assert.equal(apt.manager, 'apt'); assert.equal(apt.elevation, 'administrator'); + assert.equal(apt.manual, 'sudo apt-get install -y ripgrep'); + const root = planPackageInstall(rg, env('ID=ubuntu', ['apt-get'], {isRoot: true}))!; + assert.deepEqual([root.command, ...root.args], ['apt-get', 'install', '-y', 'ripgrep']); + assert.deepEqual(planPackageInstall(rg, env('ID=fedora', ['dnf']))!.args, ['-n', 'dnf', 'install', '-y', 'ripgrep']); + assert.deepEqual(planPackageInstall(rg, env('ID=arch', ['pacman']))!.args, ['-n', 'pacman', '-S', '--needed', '--noconfirm', 'ripgrep']); + assert.deepEqual(planPackageInstall(rg, env('ID=opensuse-leap', ['zypper']))!.args, ['-n', 'zypper', '--non-interactive', 'install', 'ripgrep']); + const mac = planPackageInstall(rg, {platform: 'darwin', has: command => command === 'brew', isRoot: false})!; + assert.deepEqual([mac.command, ...mac.args, mac.elevation], ['brew', 'install', 'ripgrep', 'none']); + assert.deepEqual(availableManagers(env('ID=ubuntu', ['apt-get', 'brew'])).map(manager => manager.id), ['apt', 'homebrew']); +}); + +test('package managers: no verified mapping means manual, never a guessed name; Windows is unsupported', () => { + const fd = TOOLS.find(tool => tool.id === 'fd')!; + assert.equal(planPackageInstall(fd, env('ID=ubuntu', ['apt-get'])), undefined, 'Debian ships fd as fdfind'); + assert.match(planUnavailableReason(fd, env('ID=ubuntu', ['apt-get'])), /no verified APT package for fd/u); + const fallback = planPackageInstall(fd, env('ID=ubuntu', ['apt-get', 'brew']))!; + assert.equal(fallback.manager, 'homebrew', 'falls back to an installed Homebrew, shown in the plan'); + assert.match(planUnavailableReason(fd, env('ID=nixos', [])), /did not recognise a supported package manager/u); + assert.match(planUnavailableReason(fd, {platform: 'win32', has: () => false, isRoot: false}), /Native Windows is not supported/u); + assert.equal(planPackageInstall(fd, {platform: 'win32', has: () => true, isRoot: false}), undefined); + assert.equal(planPackageInstall(TOOLS.find(tool => tool.id === 'kubectl')!, env('ID=fedora', ['dnf'])), undefined); +}); + +test('bulk install: Space selects missing installable tools, Enter reviews, one confirmation, per-tool results', async () => { + const state = createToolsPanel(); + state.contexts = []; + state.packages = env('ID=ubuntu', ['apt-get'], {isRoot: true}); + for (const tool of TOOLS) state.statuses[tool.id] = {state: 'installed'}; + state.statuses.rg = {state: 'missing'}; state.statuses.jq = {state: 'missing'}; state.statuses.fd = {state: 'missing'}; + const space = {kind: 'text', value: ' '} as const; + const select = (id: string) => { state.selected = visibleTools(state).findIndex(tool => tool.id === id); toolsKey(state, space); }; + select('bat'); assert.equal(state.selection?.size ?? 0, 0, 'installed tools are not selectable'); + select('fd'); assert.equal(state.selection?.size ?? 0, 0, 'no verified mapping: not selectable'); + assert.match(state.message ?? '', /fd/u); + select('rg'); select('jq'); + assert.deepEqual([...state.selection!].sort(), ['jq', 'rg']); + select('jq'); select('jq'); assert.deepEqual([...state.selection!].sort(), ['jq', 'rg']); + const review = reviewBulk(state); + assert.deepEqual(review.items.map(item => `${item.tool.id}:${item.plan.manager}:${item.plan.package}`).sort(), ['jq:apt:jq', 'rg:apt:ripgrep']); + toolsKey(state, {kind: 'enter'}); + assert.ok(state.bulk && state.confirm, 'Enter opens the combined review'); + const plain = renderTools(state, 100, 40).map(stripAnsi).join('\n'); + assert.match(plain, /Install 2 tools\?/u); + assert.match(plain, /ripgrep\s+→\s+APT\s+→\s+ripgrep/u); + const ran: string[] = []; + const run = async (task: TaskProgress, recipe: {label: string}) => { + ran.push(recipe.label); + if (recipe.label.endsWith('jq')) throw new Error('E: unable to locate package jq'); + state.statuses.rg = {state: 'installed'}; + }; + await confirmToolInstall(state, {kind: 'enter'}, () => undefined, run); // default answer is No + assert.deepEqual(ran, [], 'default focus is No: nothing ran'); + assert.equal(state.bulk, undefined); +}); + +test('bulk install: one failing tool is reported per tool and does not undo the others', async () => { + const state = createToolsPanel(); + state.contexts = []; + state.packages = env('ID=ubuntu', ['apt-get'], {isRoot: true}); + state.provenance = new (await import('../src/tools/InstallProvenance.js')).InstallProvenance(`${process.env.TMPDIR ?? '/tmp'}/nmsh-bulk-prov-${process.pid}.json`); + for (const tool of TOOLS) state.statuses[tool.id] = {state: 'installed'}; + state.statuses.rg = {state: 'missing'}; state.statuses.jq = {state: 'missing'}; + state.selection = new Set(['rg', 'jq']); + toolsKey(state, {kind: 'enter'}); + const ran: string[] = []; + await confirmToolInstall(state, {kind: 'text', value: 'y'}, () => undefined, async (task, recipe) => { + ran.push(recipe.label); + if (recipe.label.endsWith('jq')) throw new Error('E: unable to locate package jq'); + }); + assert.equal(ran.length, 2, 'the second tool still ran after the first outcome'); + assert.match(state.message ?? '', /Installed 0 of 2|Installed 1 of 2/u); + assert.match(state.message ?? '', /jq|JQ/u); + assert.ok(state.errors.jq, 'failure is recorded per tool'); + assert.deepEqual([...state.selection!].sort(), ['jq', ...(state.errors.rg ? ['rg'] : [])].sort(), 'failed tools stay selected for retry'); +}); From d9707e79a37847859fd8ab24cc8e2a7f8ac0cace Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:08:39 +0530 Subject: [PATCH 02/13] Report integration activation from the running shell's name snapshot --- src/app/TerminalApp.ts | 11 ++++++++++ src/tools/Activation.ts | 46 +++++++++++++++++++++++++++++++++++++++++ src/tools/ToolsPanel.ts | 9 ++++++-- tests/tools.test.ts | 24 +++++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/tools/Activation.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 0bb1323a..bbddaaeb 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -15,6 +15,7 @@ import {blockAffordance, blockCopyPayload, blockPaletteItems, type BlockActionId import {paletteItems} from '../ui/CommandPalette.js'; import {createConfigurationPanel, configurationKey, renderConfigurationPanel, type ConfigurationPanel} from '../tools/ConfigurationPanel.js'; import {openSupportedConfiguration} from '../tools/SupportedConfiguration.js'; +import {integrationActivation} from '../tools/Activation.js'; import {confirmToolInstall, createToolsPanel, refreshTools, renderTools, toolsKey, type ToolsPanel} from '../tools/ToolsPanel.js'; import {describeCommandSource, describeSlashCommand, inspectCommand, renderInspector} from '../shell/CommandInspector.js'; import {CHROMA_PREVIEW_NOTE, createSetup, NATIVE_ONLY_NOTE, renderSetup, SETUP_MIN_SIZE, SETUP_SECTIONS, setupIsIdempotent, setupKey, setupSelectedRow, type SetupState} from '../setup/SetupCat.js'; @@ -537,6 +538,7 @@ export class TerminalApp { this.semanticService.applyShellKnowledge(marker.knowledge); this.commandSources.clear(); this.completionService.setShellKnowledge(parseShellKnowledge(marker.knowledge)); + this.rememberShellNames(marker.knowledge); } this.onShellPrompt(marker.exitCode, marker.cwd, stamp.at); } @@ -649,6 +651,7 @@ export class TerminalApp { this.shellJobs = knowledgeJobCount(attached.knowledge) ?? 0; this.semanticService.applyShellKnowledge(attached.knowledge); this.completionService.setShellKnowledge(parseShellKnowledge(attached.knowledge)); + this.rememberShellNames(attached.knowledge); } if (!journal) return; this.continuedJournal = journal; @@ -3447,10 +3450,18 @@ export class TerminalApp { } } + private shellNames?: ReadonlySet; + private shellNamesComplete = false; + private rememberShellNames(knowledge: string): void { + this.shellNames = new Set(parseShellKnowledge(knowledge).keys()); + this.shellNamesComplete = /^complete$/mu.test(knowledge); + } + private startTools(onboarding = false): void { const config = this.promptConfiguration; const state = this.toolsPanel = createToolsPanel(new Set([config.history, config.picker, config.navigation, config.welcome, config.provider]), onboarding); state.updates = this.toolUpdates; + state.activation = toolId => integrationActivation(toolId, this.shellId, this.shellNames, this.shellNamesComplete); void refreshTools(state, () => { if (!this.stopped && this.toolsPanel === state) this.render(); }); } diff --git a/src/tools/Activation.ts b/src/tools/Activation.ts new file mode 100644 index 00000000..2b016d3b --- /dev/null +++ b/src/tools/Activation.ts @@ -0,0 +1,46 @@ +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; + +/** + * Whether an integration is active in the running shell, from the one shared + * source every shell adapter already provides: the per-prompt snapshot of + * alias/function names. Nothing is sourced, parsed or evaluated, and rc files + * are never read. "Installed", "selected in NMSh" and "active in this shell" + * are separate facts; this module only answers the last. + */ +export type ActivationState = 'active' | 'not-detected' | 'unknown'; +export interface ActivationFacts { + state: ActivationState; + /** Plain sentence about what was (or was not) observed. */ + detail: string; +} + +/** + * Functions each tool's own shell init defines. Any one present counts. A tool + * with no entry for a shell reports 'unknown' rather than guessing. + */ +const MARKERS: Record>> = { + zoxide: {zsh: ['__zoxide_z', '__zoxide_hook'], bash: ['__zoxide_z', '__zoxide_hook'], fish: ['__zoxide_z']}, + atuin: {zsh: ['_atuin_preexec', '_atuin_search'], bash: ['__atuin_history'], fish: ['_atuin_preexec', '_atuin_search']}, + fzf: {zsh: ['fzf-file-widget', 'fzf-history-widget'], bash: ['__fzf_history__', '__fzf_select__']}, +}; + +export function activationTools(): string[] { return Object.keys(MARKERS); } + +/** + * `names`: function/alias names from the last shell snapshot; undefined when + * no snapshot has arrived. `complete`: the snapshot was not truncated, so a + * missing name means "not there" rather than "maybe cut off". + */ +export function integrationActivation(toolId: string, shell: ShellId, names: ReadonlySet | undefined, complete: boolean): ActivationFacts | undefined { + const markers = MARKERS[toolId]?.[shell]; + if (!MARKERS[toolId]) return undefined; + if (!markers) return {state: 'unknown', detail: `NMSh has no activation check for ${toolId} in ${shell}.`}; + if (!names) return {state: 'unknown', detail: 'No shell snapshot yet; run a command and reopen.'}; + const seen = markers.find(name => names.has(name)); + if (seen) return {state: 'active', detail: `${seen} is defined in this ${shell} session.`}; + return complete + ? {state: 'not-detected', detail: `None of ${markers.join(', ')} is defined in this ${shell} session.`} + : {state: 'unknown', detail: 'The shell name snapshot was truncated, so absence cannot be confirmed.'}; +} + +export const ACTIVATION_LABELS: Record = {active: 'Active in this shell', 'not-detected': 'Not detected in this shell', unknown: 'Unknown'}; diff --git a/src/tools/ToolsPanel.ts b/src/tools/ToolsPanel.ts index c040ddc2..6278c841 100644 --- a/src/tools/ToolsPanel.ts +++ b/src/tools/ToolsPanel.ts @@ -15,6 +15,7 @@ import {GLYPHS, getCurrentGlyphMode} from '../ui/glyphs.js'; import {renderControls} from '../ui/controls.js'; import {InstallProvenance, planToolUninstall, type UninstallPlan} from './InstallProvenance.js'; import {elevationNote, planPackageInstall, planUnavailableReason, systemPackageEnvironment, type PackageEnvironment, type PackagePlan} from '../packages/managers.js'; +import {ACTIVATION_LABELS, type ActivationFacts} from './Activation.js'; import {CONTEXT_LABELS, detectToolContexts, relevantTools, type ToolContext} from './relevance.js'; export type ToolsTab = 'discover' | 'installed' | 'configure' | 'errors'; @@ -40,6 +41,8 @@ export interface ToolsPanel { contexts?: readonly ToolContext[]; /** Install records; injectable for tests. */ provenance?: InstallProvenance; + /** Runtime activation facts from the running shell; supplied by the app. */ + activation?: (toolId: string) => ActivationFacts | undefined; /** Package managers and privilege facts; injectable for tests. */ packages?: PackageEnvironment; /** Tools ticked for one combined install (Space on the list). */ @@ -415,8 +418,10 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): if (tool.language) rows.push(` ${SUBTLE}${'Language'.padEnd(10)}${RESET}${foregroundOf(languageIdentity(tool.language))}${tool.language}${RESET}`); const version = state.statuses[tool.id]?.version; if (version) rows.push(field('Version', stripAnsi(version).replace(/[\u0000-\u001f\u007f-\u009f]/gu, ''))); - if (state.configured.has(tool.id)) rows.push(field('NMSh', 'Configured in NMSh')); - rows.push('', ` ${SUBTLE}Shell hook state is not inferred; existing hooks stay authoritative.${RESET}`); + if (state.configured.has(tool.id)) rows.push(field('NMSh', 'Integration selected in NMSh')); + const activation = state.statuses[tool.id]?.state === 'installed' ? state.activation?.(tool.id) : undefined; + if (activation) rows.push(field('Shell', `${ACTIVATION_LABELS[activation.state]} · ${activation.detail}`)); + rows.push('', ` ${SUBTLE}${activation ? 'Shell state comes from the running session; rc files are never read.' : 'Shell hook state is not inferred; existing hooks stay authoritative.'}${RESET}`); footer = [ ...(state.statuses[tool.id]?.state === 'missing' ? [['I', 'install…'] as [string, string]] : []), ...(state.statuses[tool.id]?.state === 'installed' ? [['X', 'uninstall…'] as [string, string]] : []), diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 7d9f1990..57b81fa2 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -299,3 +299,27 @@ test('bulk install: one failing tool is reported per tool and does not undo the assert.ok(state.errors.jq, 'failure is recorded per tool'); assert.deepEqual([...state.selection!].sort(), ['jq', ...(state.errors.rg ? ['rg'] : [])].sort(), 'failed tools stay selected for retry'); }); + +import {integrationActivation} from '../src/tools/Activation.js'; + +test('integration activation: runtime evidence from the shell snapshot, separate from installed/selected, shared by all shells', () => { + const names = (...list: string[]) => new Set(list); + assert.equal(integrationActivation('zoxide', 'zsh', names('__zoxide_z'), true)?.state, 'active'); + assert.equal(integrationActivation('zoxide', 'bash', names('__zoxide_hook'), true)?.state, 'active'); + assert.equal(integrationActivation('zoxide', 'fish', names('__zoxide_z'), true)?.state, 'active'); + assert.equal(integrationActivation('atuin', 'bash', names('__atuin_history'), true)?.state, 'active'); + assert.equal(integrationActivation('atuin', 'zsh', names('ls'), true)?.state, 'not-detected'); + assert.equal(integrationActivation('fzf', 'zsh', names('fzf-history-widget'), true)?.state, 'active'); + assert.equal(integrationActivation('zoxide', 'zsh', names('ls'), false)?.state, 'unknown', 'truncated snapshot cannot prove absence'); + assert.equal(integrationActivation('zoxide', 'zsh', undefined, false)?.state, 'unknown'); + assert.equal(integrationActivation('fzf', 'fish', names('x'), true)?.state, 'unknown', 'Fish lists autoloadable names, so no claim'); + assert.equal(integrationActivation('rg', 'zsh', names('x'), true), undefined, 'tools without an activation concept have no claim'); + const state = createToolsPanel(new Set(['zoxide'])); + state.statuses.zoxide = {state: 'installed'}; + state.activation = id => integrationActivation(id, 'zsh', names('__zoxide_z'), true); + state.detail = TOOLS.find(tool => tool.id === 'zoxide')!; + const plain = renderTools(state, 120, 40).map(stripAnsi).join('\n'); + assert.match(plain, /NMSh\s+Integration selected in NMSh/u); + assert.match(plain, /Shell\s+Active in this shell/u); + assert.match(plain, /rc files are never read/u); +}); From 1097d5507a56c068c5426cb064a134bafadf30da Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:10:23 +0530 Subject: [PATCH 03/13] Add host launchers for iTerm2/WezTerm, Windows Terminal profile and desktop helper tests --- src/app/TerminalApp.ts | 3 ++- src/host/capabilities.ts | 14 ++++++++++++- src/host/desktop.ts | 12 +++++++++++ src/host/terminalHost.ts | 15 +++++++++++++- tests/hostProfiles.test.ts | 40 ++++++++++++++++++++++++++++++++++++- tests/linuxPlatform.test.ts | 22 ++++++++++++++++++++ 6 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 src/host/desktop.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index bbddaaeb..6906587b 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -15,6 +15,7 @@ import {blockAffordance, blockCopyPayload, blockPaletteItems, type BlockActionId import {paletteItems} from '../ui/CommandPalette.js'; import {createConfigurationPanel, configurationKey, renderConfigurationPanel, type ConfigurationPanel} from '../tools/ConfigurationPanel.js'; import {openSupportedConfiguration} from '../tools/SupportedConfiguration.js'; +import {selectOpener} from '../host/desktop.js'; import {integrationActivation} from '../tools/Activation.js'; import {confirmToolInstall, createToolsPanel, refreshTools, renderTools, toolsKey, type ToolsPanel} from '../tools/ToolsPanel.js'; import {describeCommandSource, describeSlashCommand, inspectCommand, renderInspector} from '../shell/CommandInspector.js'; @@ -5991,7 +5992,7 @@ export class TerminalApp { } if (action.kind === 'openUrl') { if (!openableUrl(action.url)) { finish('That isn\'t a URL Ask opens.'); return; } - const opener = process.platform === 'darwin' ? '/usr/bin/open' : resolveCommand('xdg-open') ?? resolveCommand('wslview'); + const opener = selectOpener(); if (!opener) { finish(`No system URL opener is available here. The URL is ${action.url}`); return; } try { spawn(opener, [action.url], {detached: true, stdio: 'ignore'}).unref(); finish(`Opened ${action.url}.`); } catch { finish(`Couldn't open ${action.url}.`); } return; diff --git a/src/host/capabilities.ts b/src/host/capabilities.ts index f10647d5..16c3bcd2 100644 --- a/src/host/capabilities.ts +++ b/src/host/capabilities.ts @@ -27,7 +27,7 @@ const MOUSE_PROFILE = { textSelectionInteraction: 'shift' as const, hyperlinks: true, truecolor: true, }; -export type TerminalProfile = 'ghostty' | 'iterm2' | 'kitty' | 'wezterm' | 'zed' | 'baseline'; +export type TerminalProfile = 'ghostty' | 'iterm2' | 'kitty' | 'wezterm' | 'zed' | 'windows-terminal' | 'baseline'; export function terminalProfile(env: NodeJS.ProcessEnv): TerminalProfile { // An explicit program wins over inherited outer-host variables. @@ -45,6 +45,8 @@ export function terminalProfile(env: NodeJS.ProcessEnv): TerminalProfile { if (env.WEZTERM_PANE) return 'wezterm'; if (env.GHOSTTY_RESOURCES_DIR) return 'ghostty'; if (env.ZED_TERM) return 'zed'; + // Windows Terminal exports WT_SESSION and forwards it into WSL through WSLENV. + if (env.WT_SESSION) return 'windows-terminal'; return 'baseline'; } @@ -59,6 +61,15 @@ const ZED_PROFILE = { mouseReporting: true, mouseMovement: false, clickSupport: true, textSelectionInteraction: 'shift' as const, }; +/** + * Windows Terminal (seen from WSL through WT_SESSION): standard SGR mouse + * reports with Shift selection, truecolor and OSC 8 hyperlinks. No movement + * tracking, graphics or enhanced keyboard is assumed; probes decide the rest. + */ +const WINDOWS_TERMINAL_PROFILE = { + mouseReporting: true, mouseMovement: false, clickSupport: true, textSelectionInteraction: 'shift' as const, hyperlinks: true, truecolor: true, +}; + /** * Hosts whose documentation describes Kitty graphics support: Kitty, Ghostty * and WezTerm. For these NMSh sends the graphics query; support is still only @@ -76,6 +87,7 @@ export function resolveHostCapabilities(env: NodeJS.ProcessEnv = process.env): T if (!nested && env.TERM !== 'dumb') { const profile = terminalProfile(env); if (profile === 'zed') Object.assign(result, ZED_PROFILE); + else if (profile === 'windows-terminal') Object.assign(result, WINDOWS_TERMINAL_PROFILE); else if (profile !== 'baseline') Object.assign(result, MOUSE_PROFILE); if (profile === 'ghostty') Object.assign(result, {enhancedKeyboard: true, kittyKeyboard: true, appearanceIntegration: true, hostConfiguration: true}); diff --git a/src/host/desktop.ts b/src/host/desktop.ts new file mode 100644 index 00000000..1c02d2e0 --- /dev/null +++ b/src/host/desktop.ts @@ -0,0 +1,12 @@ +import {resolveCommand} from '../providers/providers.js'; + +/** + * The system "open this URL/file" helper. Optional everywhere: when none is + * found the caller reports the target as text instead. Nothing is installed. + * macOS: `open`. Linux: xdg-open, then wslview (WSL with wslu installed). + */ +export function selectOpener(platform: NodeJS.Platform = process.platform, resolve: (name: string) => string | undefined = name => resolveCommand(name)): string | undefined { + if (platform === 'darwin') return '/usr/bin/open'; + if (platform !== 'linux') return undefined; + return resolve('xdg-open') ?? resolve('wslview'); +} diff --git a/src/host/terminalHost.ts b/src/host/terminalHost.ts index a8509cdb..0f370262 100644 --- a/src/host/terminalHost.ts +++ b/src/host/terminalHost.ts @@ -63,7 +63,20 @@ export function detectTerminalHost(env: NodeJS.ProcessEnv = process.env, platfor return {capabilities, integration, keyboardGuidance: guidance, name: 'Terminal', newWindow: argv => ({command: 'osascript', args: ['-e', `tell application "Terminal" to do script ${appleScriptString(argv.map(shellQuote).join(' '))}`]})}; } - if (env.KITTY_WINDOW_ID) { + if (program === 'iTerm.app' && platform === 'darwin') { + // Fixed AppleScript in the running iTerm2; the command is one quoted shell string, like Terminal.app. + return {capabilities, integration, keyboardGuidance: guidance, name: 'iTerm2', newWindow: argv => ({command: 'osascript', args: ['-e', + `tell application "iTerm" to create window with default profile command ${appleScriptString(argv.map(shellQuote).join(' '))}`]})}; + } + if (program === 'WezTerm' || env.WEZTERM_PANE) { + // `wezterm cli` talks to the running mux; failure falls back like any unsupported host. + return {capabilities, integration, keyboardGuidance: guidance, name: 'WezTerm', newWindow: argv => ({command: 'wezterm', args: ['cli', 'spawn', '--new-window', '--', ...argv]})}; + } + if (env.WT_SESSION && !env.TERM_PROGRAM) { + // Reachable from WSL when Windows Terminal forwards WT_SESSION; NMSh does not drive Windows from Linux. + return {capabilities, integration, keyboardGuidance: guidance, name: 'Windows Terminal'}; + } + if (env.KITTY_WINDOW_ID || program === 'kitty') { // Needs kitty remote control (allow_remote_control); failure falls back like any unsupported host. return {capabilities, integration, keyboardGuidance: guidance, name: 'kitty', newWindow: argv => ({command: 'kitten', args: ['@', 'launch', '--type=os-window', ...argv]})}; } diff --git a/tests/hostProfiles.test.ts b/tests/hostProfiles.test.ts index f020381c..7d39787a 100644 --- a/tests/hostProfiles.test.ts +++ b/tests/hostProfiles.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {BASELINE_CAPABILITIES, resolveHostCapabilities} from '../src/host/capabilities.js'; +import {BASELINE_CAPABILITIES, resolveHostCapabilities, terminalProfile} from '../src/host/capabilities.js'; import {resolveProbeReplies} from '../src/host/probe.js'; import {detectTerminalHost} from '../src/host/terminalHost.js'; import {TerminalRenderer} from '../src/terminal/TerminalRenderer.js'; @@ -119,3 +119,41 @@ test('reattaching across profiles updates input modes while keeping the real she } } finally { await sandbox.dispose(); } }); + +test('Windows Terminal (via WSL WT_SESSION): conservative capabilities, no graphics or enhanced keyboard assumed, no host-specific window launcher', () => { + const env = {WT_SESSION: 'abc', WSL_DISTRO_NAME: 'Ubuntu'}; + const capabilities = resolveHostCapabilities(env); + assert.equal(terminalProfile(env), 'windows-terminal'); + assert.deepEqual([capabilities.mouseReporting, capabilities.clickSupport, capabilities.mouseMovement, capabilities.hyperlinks, capabilities.truecolor], [true, true, false, true, true]); + assert.deepEqual([capabilities.kittyKeyboard, capabilities.enhancedKeyboard, capabilities.graphicsProtocol, capabilities.synchronizedOutput], [false, false, 'none', false]); + const host = detectTerminalHost(env, 'linux'); + assert.equal(host.name, 'Windows Terminal'); + assert.equal(host.newWindow, undefined); + assert.equal(terminalProfile({...env, TERM_PROGRAM: 'vscode'}), 'baseline', 'an explicit program wins over a forwarded variable'); + assert.equal(resolveHostCapabilities({...env, TMUX: '/tmp/x'}).mouseReporting, false, 'multiplexers hide the outer host'); +}); + +test('new-window launchers are typed argv per host and never interpolate command words into scripts', () => { + const argv = ['nmsh', '--attach', "a b'c"]; + const iterm = detectTerminalHost({TERM_PROGRAM: 'iTerm.app'}, 'darwin').newWindow!(argv); + assert.equal(iterm.command, 'osascript'); + assert.equal(iterm.args[0], '-e'); + assert.match(iterm.args[1]!, /^tell application "iTerm" to create window with default profile command "nmsh --attach 'a b'\\\\''c'"$/u); + assert.equal(detectTerminalHost({TERM_PROGRAM: 'iTerm.app'}, 'linux').newWindow, undefined, 'iTerm2 is macOS only'); + assert.deepEqual(detectTerminalHost({TERM_PROGRAM: 'WezTerm'}, 'linux').newWindow!(argv), {command: 'wezterm', args: ['cli', 'spawn', '--new-window', '--', ...argv]}); + assert.deepEqual(detectTerminalHost({WEZTERM_PANE: '3'}, 'darwin').newWindow!(['x']), {command: 'wezterm', args: ['cli', 'spawn', '--new-window', '--', 'x']}); + assert.deepEqual(detectTerminalHost({TERM_PROGRAM: 'kitty'}, 'linux').newWindow!(['x']), {command: 'kitten', args: ['@', 'launch', '--type=os-window', 'x']}); + assert.equal(detectTerminalHost({TERM_PROGRAM: 'kitty'}, 'linux').name, 'kitty'); +}); + +test('every host profile degrades inside a multiplexer and when TERM is dumb, from capabilities alone', () => { + for (const env of [{TERM_PROGRAM: 'iTerm.app'}, {TERM_PROGRAM: 'kitty'}, {TERM_PROGRAM: 'WezTerm'}, {TERM_PROGRAM: 'ghostty'}, {TERM_PROGRAM: 'zed'}, {WT_SESSION: 'x'}]) { + for (const nested of [{TMUX: '/tmp/t'}, {STY: '1'}, {ZELLIJ: '1'}, {TERM: 'screen-256color'}]) { + const capabilities = resolveHostCapabilities({...env, ...nested}); + assert.equal(capabilities.graphicsProtocol, 'none', JSON.stringify({env, nested})); + assert.equal(capabilities.kittyKeyboard, false); + assert.equal(capabilities.mouseReporting, false); + } + assert.equal(resolveHostCapabilities({...env, TERM: 'dumb'}).hyperlinks, false); + } +}); diff --git a/tests/linuxPlatform.test.ts b/tests/linuxPlatform.test.ts index 2caf82bd..3dd952bf 100644 --- a/tests/linuxPlatform.test.ts +++ b/tests/linuxPlatform.test.ts @@ -33,3 +33,25 @@ test('Linux config paths follow XDG and ignore relative XDG values', () => { assert.equal(nmshConfigDirectory({HOME: '/home/u', XDG_CONFIG_HOME: 'relative'}, 'linux'), '/home/u/.config/nmsh'); assert.equal(nmshConfigDirectory({HOME: '/home/u'}, 'linux'), '/home/u/.config/nmsh'); }); + +import {selectOpener} from '../src/host/desktop.js'; +import {selectClipboardBackend} from '../src/clipboard/clipboard.js'; + +test('Linux open helper: xdg-open first, wslview as the WSL fallback, nothing when absent; never mandatory', () => { + const only = (...names: string[]) => (name: string) => names.includes(name) ? `/usr/bin/${name}` : undefined; + assert.equal(selectOpener('linux', only('xdg-open', 'wslview')), '/usr/bin/xdg-open'); + assert.equal(selectOpener('linux', only('wslview')), '/usr/bin/wslview'); + assert.equal(selectOpener('linux', only()), undefined); + assert.equal(selectOpener('darwin', only()), '/usr/bin/open'); + assert.equal(selectOpener('win32', only('xdg-open')), undefined, 'native Windows is not supported'); +}); + +test('Linux clipboard fallback order: wl-copy, then xclip, then xsel; nothing is mandatory', () => { + const only = (...names: string[]) => (name: string) => names.includes(name) ? `/bin/${name}` : undefined; + const both = {WAYLAND_DISPLAY: 'wayland-0', DISPLAY: ':0'}; + assert.equal(selectClipboardBackend({platform: 'linux', env: both, resolve: only('wl-copy', 'xclip', 'xsel')})?.command, '/bin/wl-copy'); + assert.equal(selectClipboardBackend({platform: 'linux', env: both, resolve: only('xclip', 'xsel')})?.command, '/bin/xclip', 'wl-copy missing under XWayland falls back'); + assert.equal(selectClipboardBackend({platform: 'linux', env: both, resolve: only('xsel')})?.command, '/bin/xsel'); + assert.equal(selectClipboardBackend({platform: 'linux', env: {WAYLAND_DISPLAY: 'wayland-0'}, resolve: only('xclip')}), undefined, 'no X11 session: no xclip'); + assert.equal(selectClipboardBackend({platform: 'linux', env: {}, resolve: only('wl-copy', 'xclip')}), undefined, 'headless: unavailable, commands unaffected'); +}); From 4f62cf39603ab0a42897e056f370778114a90d68 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:13:26 +0530 Subject: [PATCH 04/13] Test that raw job-control, EOF and arrow bytes reach full-screen programs --- tests/compatibilityHarness.test.ts | 18 ++++++++++++++++++ tests/fixtures/compatibility.mjs | 2 ++ 2 files changed, 20 insertions(+) diff --git a/tests/compatibilityHarness.test.ts b/tests/compatibilityHarness.test.ts index ec7c9e45..2ce2375a 100644 --- a/tests/compatibilityHarness.test.ts +++ b/tests/compatibilityHarness.test.ts @@ -128,3 +128,21 @@ test('canonical inline input and subsequent raw/canonical shell state are restor await app.run('echo CANONICAL-RETURNED', /CANONICAL-RETURNED/); } finally { await sandbox.dispose(); } }); + +test('raw job-control, EOF and arrow bytes reach a full-screen program unchanged', async () => { + const sandbox = new LiveSandbox(); + try { + const app = sandbox.launch(); + await app.waitFor(/❯/); + const mark = app.mark; + app.pty.write(command('fullscreen') + '\r'); + await app.waitFor(/INTERACTIVE-READY-fullscreen/, mark); + app.pty.write('\u001a'); await app.waitFor(/INTERACTIVE-BYTES-1a/, mark); + app.pty.write('\u0004'); await app.waitFor(/INTERACTIVE-BYTES-04/, mark); + app.pty.write('\u001b[A'); await app.waitFor(/INTERACTIVE-BYTES-1b5b41/, mark); + app.pty.write('\u001b[1;5C'); await app.waitFor(/INTERACTIVE-BYTES-1b5b313b3543/, mark); + app.pty.write('q'); + await app.waitFor(/INTERACTIVE-EXIT-0/, mark); + await app.run('echo AFTER-RAW', /AFTER-RAW/); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/fixtures/compatibility.mjs b/tests/fixtures/compatibility.mjs index 9f3fca22..c34a0209 100644 --- a/tests/fixtures/compatibility.mjs +++ b/tests/fixtures/compatibility.mjs @@ -55,6 +55,8 @@ if (mode === 'streaming') { let carry = ''; process.stdin.on('data', data => { carry += data.toString(); + // Raw bytes arrive untouched: job-control and EOF keys are the program's, not NMSh's. + for (const raw of ['\u001a', '\u0004', '\u001b[A', '\u001b[1;5C']) if (carry.includes(raw)) { line('INTERACTIVE-BYTES-' + Buffer.from(raw).toString('hex')); carry = carry.replace(raw, ''); } if (carry.includes('\u0003')) return cleanup(130); const paste = /\u001b\[200~([\s\S]*?)\u001b\[201~/u.exec(carry); if (paste) { line('INTERACTIVE-PASTE-' + paste[1].replace(/\r?\n/gu, '|')); carry = carry.replace(paste[0], ''); } From 14ac9758ccb2d02b178653b3910e91d696cb356c Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:16:22 +0530 Subject: [PATCH 05/13] Add automatic update preparation with Automatic/Notify only/Off modes, shared with /update apply --- scripts/test.mjs | 2 +- src/app/TerminalApp.ts | 100 +++++++++++++++++++++-- src/configuration/portability.ts | 2 +- src/prompt/configuration.ts | 17 ++-- src/setup/SetupCat.ts | 3 +- src/ui/SettingsPanel.ts | 15 ++-- src/update/update.ts | 135 ++++++++++++++++++++++++++++--- tests/settingsV2.test.ts | 22 ++--- tests/update.test.ts | 117 ++++++++++++++++++++++++++- 9 files changed, 364 insertions(+), 49 deletions(-) diff --git a/scripts/test.mjs b/scripts/test.mjs index c4a75810..c7f9b479 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -11,7 +11,7 @@ export async function runTestFiles(files, args = [], {cwd = process.cwd(), stdio try { // Existing presentation snapshots pin truecolor; baseline fixtures override this explicitly. // Tests never see the user's real NMSh settings: configuration lives under this run's private root. - const env = {...process.env, COLORTERM: process.env.COLORTERM ?? 'truecolor', TMPDIR: root, TMP: root, TEMP: root, XDG_CONFIG_HOME: join(root, 'config')}; + const env = {...process.env, COLORTERM: process.env.COLORTERM ?? 'truecolor', TMPDIR: root, TMP: root, TEMP: root, XDG_CONFIG_HOME: join(root, 'config'), NMSH_DISABLE_UPDATES: '1'}; // A nested runner must not impersonate its parent's test worker. delete env.NODE_TEST_CONTEXT; const child = spawn(process.execPath, ['--import=tsx', '--test', ...args, ...files], { diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 6906587b..39c81c9f 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -93,7 +93,7 @@ import {configuratorFileChanged, launchPowerlevel10kConfigurator, preparePowerle import {galleryPalettes, promptPanelOwnsKey, appearanceModulesRow, closeGradientEditor, onGradientRow, openGradientEditor, applyLayoutChoice, onModulesRow, layoutLabel, describePromptConfiguration, PROVIDER_ORDER, providerLabel, handlePromptPanelKey, layoutChoiceIndex, renderPromptPanel, type PromptPanelState} from '../prompt/PromptPanel.js'; import type {PromptSnapshot} from '../prompt/snapshot.js'; import {CommandContextCache, commandWords, type CommandContextId} from '../prompt/commandContext.js'; -import {applyUpdate, backgroundUpdateCheck, compareVersions, detectInstall, fetchLatestRelease, installRoot, planUpdate, systemRunner, type ReleaseInfo} from '../update/update.js'; +import {applyUpdate, checkForUpdate, compareVersions, detectInstall, fetchLatestRelease, installRoot, loadUpdateState, planUpdate, prepareAutomaticUpdate, readyVersion, recordInstalled, systemRunner, updatesDisabledByEnvironment, type ReleaseInfo, type UpdateCheckFrequency} from '../update/update.js'; import {resolvePathAbbreviations} from '../prompt/pathDisplay.js'; import {resolvePromptContext, type PromptContext} from '../shell/ShellContext.js'; import type {AttachedSession, SessionClient, SessionConnection, StreamStamp} from '../session/SessionClient.js'; @@ -2465,14 +2465,67 @@ export class TerminalApp { } - /** Opt-in background discovery: one quiet line per newly seen release, never an interruption. */ + private pendingAutoUpdate?: ReleaseInfo; + private autoUpdateTimer?: NodeJS.Timeout; + private preparingUpdate?: string; + + private updateFrequencyInEffect(): UpdateCheckFrequency { + const {updateMode, updateFrequency} = this.promptConfiguration; + return updateMode === 'off' || updatesDisabledByEnvironment() ? 'off' : updateFrequency; + } + + /** + * Quiet, due-gated discovery. Notify only: one line per newly seen release. + * Automatic: remember the release and prepare it at the next idle point. + */ private async quietUpdateCheck(): Promise { - const release = await backgroundUpdateCheck(this.buildIdentity.version, this.promptConfiguration.updateChecks).catch(() => undefined); - if (!release || this.stopped) return; - this.output.addHistoryLine(`${INFO}NMSh ${release.version} is available (you have ${this.buildIdentity.version}) · /update${RESET}`); + const result = await checkForUpdate(this.buildIdentity.version, this.updateFrequencyInEffect()).catch(() => ({announce: false} as const)); + if (this.stopped) return; + if (this.promptConfiguration.updateMode === 'automatic' && this.updateFrequencyInEffect() !== 'off') { + const state = loadUpdateState(); + const version = 'release' in result && result.release ? result.release.version : state.latestVersion; + const settled = version !== undefined && (state.installedVersion === version || state.failed?.version === version || state.skipped?.version === version); + if (version && compareVersions(version, this.buildIdentity.version) > 0 && !settled) { + this.pendingAutoUpdate = ('release' in result && result.release) || {version, tag: `v${version}`, summary: [], + url: `https://github.com/raiseCatError/notMyShell/releases/tag/v${version}`}; + this.scheduleAutoUpdate(5_000); + } + return; + } + if (!('release' in result) || !result.release || !result.announce) return; + this.output.addHistoryLine(`${INFO}NMSh ${result.release.version} is available (you have ${this.buildIdentity.version}) · /update${RESET}`); this.render(); } + private scheduleAutoUpdate(delay: number): void { + if (this.autoUpdateTimer || this.stopped || !this.pendingAutoUpdate) return; + this.autoUpdateTimer = setTimeout(() => { this.autoUpdateTimer = undefined; void this.autoUpdateTick(); }, delay); + this.autoUpdateTimer.unref?.(); + } + + /** Starts the build only at a calm prompt NMSh owns; otherwise waits for the next tick. */ + private async autoUpdateTick(): Promise { + const release = this.pendingAutoUpdate; + if (!release || this.stopped || this.promptConfiguration.updateMode !== 'automatic') { this.pendingAutoUpdate = undefined; return; } + if (this.updateInProgress || !this.idleEligible()) { this.scheduleAutoUpdate(30_000); return; } + this.pendingAutoUpdate = undefined; + this.updateInProgress = true; + this.preparingUpdate = release.version; + this.render(); + try { + const outcome = await prepareAutomaticUpdate(release); + if (this.stopped || !outcome.announce) return; + const line = outcome.kind === 'ready' ? `NMSh ${outcome.version} ready · Restart NMSh to use it` + : outcome.kind === 'skipped' ? `NMSh ${outcome.version} is available · Automatic update skipped · Run /update for details` + : `Automatic update to ${outcome.version} did not complete and was rolled back · Run /update for details`; + this.output.addHistoryLine(`${outcome.kind === 'ready' ? SUCCESS : INFO}${line}${RESET}`); + } catch { /* an automatic update never crashes the session */ } finally { + this.updateInProgress = false; + this.preparingUpdate = undefined; + if (!this.stopped) this.render(); + } + } + /** * `/update` checks and shows the plan; `/update apply` installs only the * release the last `/update` offered. Both re-verify everything first. @@ -2490,6 +2543,9 @@ export class TerminalApp { return reply(`Could not check for updates: ${error instanceof Error ? error.message : String(error)}. Nothing was changed.`, ERROR); } if (compareVersions(release.version, current) <= 0) return reply(`NMSh ${current} is up to date (latest release ${release.tag}).`, SUCCESS); + if (readyVersion(loadUpdateState(), current) === release.version) { + return reply(`Running ${current}\nInstalled ${release.version}\nStatus Restart NMSh to use it`, SUCCESS); + } const header = [`NMSh ${current} → ${release.version} is available.`, ...release.summary.map(line => ` ${line}`), release.url]; const planned = await planUpdate(await detectInstall(installRoot()), release); if (!planned.ok) { @@ -2507,9 +2563,9 @@ export class TerminalApp { this.render(); }); this.offeredUpdate = undefined; - if (result.ok) this.milestoneEffect(); + if (result.ok) { recordInstalled(release.version); this.milestoneEffect(); } this.output.addHistoryLine(result.ok - ? `${SUCCESS}NMSh ${release.version} is installed. Restart NMSh to use it; this session keeps running ${current}.${RESET}` + ? `${SUCCESS}NMSh ${release.version} is ready ✓ This session is still running ${current}. Restart NMSh when convenient to use the new version.${RESET}` : `${ERROR}The update did not complete; the lines above say what happened.${RESET}`); } finally { this.updateInProgress = false; @@ -2517,6 +2573,35 @@ export class TerminalApp { } } + private updateStatusRows(): Array<{label: string; value: string; tone?: 'success' | 'warning' | 'muted'}> { + const running = this.buildIdentity.version; + const {updateMode, updateFrequency} = this.promptConfiguration; + const state = loadUpdateState(); + const latest = state.latestVersion; + const newer = latest !== undefined && compareVersions(latest, running) > 0; + const ready = readyVersion(state, running); + let status: {value: string; tone?: 'success' | 'warning' | 'muted'}; + if (this.preparingUpdate) status = {value: `Preparing ${running} → ${this.preparingUpdate}`}; + else if (ready) status = {value: `Ready · restart NMSh to use ${ready}`, tone: 'success'}; + else if (state.failed && latest === state.failed.version && newer) status = {value: 'Last automatic update failed · run /update', tone: 'warning'}; + else if (state.skipped && latest === state.skipped.version && newer) status = {value: 'Automatic update skipped · run /update for details', tone: 'warning'}; + else if (newer) status = {value: 'Update available', tone: 'warning'}; + else if (latest) status = {value: 'Up to date', tone: 'success'}; + else status = {value: updateMode === 'off' ? 'Not checking' : 'Check pending', tone: 'muted'}; + const ago = state.lastCheck === undefined ? undefined : (() => { + const minutes = Math.max(0, Math.round((Date.now() - state.lastCheck) / 60_000)); + return minutes < 1 ? 'just now' : minutes < 90 ? `${minutes}m ago` : minutes < 2880 ? `${Math.round(minutes / 60)}h ago` : `${Math.round(minutes / 1440)}d ago`; + })(); + return [ + {label: 'Running version', value: running}, + ...(latest ? [{label: 'Latest release', value: latest}] : []), + {label: 'Mode', value: updateMode === 'automatic' ? 'Automatic' : updateMode === 'notify' ? 'Notify only' : 'Off'}, + ...(updateMode === 'off' ? [] : [{label: 'Check frequency', value: updateFrequency === 'daily' ? 'Daily' : 'Weekly'}]), + {label: 'State', ...status}, + ...(ago ? [{label: 'Last checked', value: ago}] : []), + ]; + } + private showHelp(command: string): void { const helpText = renderMarkdownText(helpMarkdown(), {columns: Math.max(20, this.dimensions().columns - 6), hyperlinks: false}); // the transcript cell model has no OSC 8 support @@ -4259,6 +4344,7 @@ export class TerminalApp { {label: 'Platform support', value: this.platformInfo.support, tone: this.platformInfo.wsl?.version === 1 ? 'warning' as const : undefined}, {label: 'Node', value: process.version}, ]), + statusSection('Updates', this.updateStatusRows()), statusSection('Shell & Session', [ {label: 'Shell', value: `${shellAdapter(this.shellId).label}${this.shellId === this.promptConfiguration.shellBackend ? '' : ` (default for new sessions: ${shellAdapter(this.promptConfiguration.shellBackend).label})`}`}, {label: 'Shell capabilities', value: (() => { const caps = shellAdapter(this.shellId).capabilities; return `completion ${caps.completion}${caps.completionDescriptions ? ' + descriptions' : ''} · live names ${caps.liveNames ? 'yes' : 'no'} · history import ${caps.historyImport ? 'yes' : 'no'}`; })()}, diff --git a/src/configuration/portability.ts b/src/configuration/portability.ts index 06527090..2e67b9f0 100644 --- a/src/configuration/portability.ts +++ b/src/configuration/portability.ts @@ -30,7 +30,7 @@ export const PORTABLE_CATEGORIES = { statusStrip: ['statusStrip'], idle: ['idleVisuals', 'liveActivity'], notifications: ['notifications', 'sessionNotices'], - tools: ['toolUpdateChecks', 'installSuggestions', 'ignoredInstallSuggestions', 'updateChecks'], + tools: ['toolUpdateChecks', 'installSuggestions', 'ignoredInstallSuggestions', 'updateMode', 'updateFrequency'], sessions: ['liveSessionStartup', 'liveSessionMultiple', 'sessionRetention'], agents: ['agentActivity'], shell: ['shellBackend'], diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 48d6b962..f45f8ea7 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -2,7 +2,7 @@ import {normalizeTreatmentSettings, DEFAULT_TREATMENT_SETTINGS, validCustomStops import {mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs'; import {dirname} from 'node:path'; import {promptConfigurationPath} from '../configuration/paths.js'; -import {UPDATE_CHECK_FREQUENCIES, type UpdateCheckFrequency} from '../update/update.js'; +import {UPDATE_CHECK_FREQUENCIES, migrateUpdateSettings, type UpdateCheckFrequency, type UpdateFrequency, type UpdateMode} from '../update/update.js'; export const LIVE_SESSION_STARTUP = ['ask', 'always', 'never'] as const; export type LiveSessionStartup = typeof LIVE_SESSION_STARTUP[number]; @@ -483,8 +483,9 @@ export interface PromptConfiguration { glyphChoiceComplete: boolean; /** Maximum unpinned presentation sessions; null disables rotation. */ sessionRetention: SessionRetention; - /** Background release checks are opt-in; `/update` always checks on request. */ - updateChecks: UpdateCheckFrequency; + /** Automatic updates: Automatic prepares verified releases, Notify only announces them, Off never checks. `/update` always works on request. */ + updateMode: UpdateMode; + updateFrequency: UpdateFrequency; /** Whether launch restores a detached live session: ask, always, or never (never only skips; it ends nothing). */ liveSessionStartup: LiveSessionStartup; /** With several detached live sessions at launch: ask which, or open them all. */ @@ -602,7 +603,8 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { glyphStyle: 'nerd', glyphChoiceComplete: false, sessionRetention: 1000, - updateChecks: 'off', + updateMode: 'automatic', + updateFrequency: 'daily', liveSessionStartup: 'ask', liveSessionMultiple: 'ask', notifications: {...DEFAULT_NOTIFICATION_SETTINGS}, @@ -685,8 +687,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio const sessionRetention: SessionRetention = value.sessionRetention === null ? null : [100, 500, 1000, 5000].includes(value.sessionRetention as number) ? value.sessionRetention as SessionRetention : 1000; - const updateChecks: UpdateCheckFrequency = UPDATE_CHECK_FREQUENCIES.includes(value.updateChecks as UpdateCheckFrequency) - ? value.updateChecks as UpdateCheckFrequency : 'off'; + const {updateMode, updateFrequency} = migrateUpdateSettings(value); const liveSessionStartup: LiveSessionStartup = LIVE_SESSION_STARTUP.includes(value.liveSessionStartup as LiveSessionStartup) ? value.liveSessionStartup as LiveSessionStartup : 'ask'; const liveSessionMultiple: LiveSessionMultiple = LIVE_SESSION_MULTIPLE.includes(value.liveSessionMultiple as LiveSessionMultiple) @@ -769,7 +770,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, - toolsSetupComplete, glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, + toolsSetupComplete, glyphStyle, glyphChoiceComplete, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, notifications, placement, composerLayout, composerPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, spacing, gap, separator, ...tooling}; } @@ -813,7 +814,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio return {provider, onboardingComplete: value.onboardingComplete === true, toolsSetupComplete, - glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, + glyphStyle, glyphChoiceComplete, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, modules, separator, spacing, gap, ...tooling}; } diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 8323bec5..d0510b37 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -326,7 +326,8 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: configRow('notifyOnSuccess')}, {row: configRow('notifyOnFailure')}, {row: configRow('notifyWhenFocused')}, - {row: configRow('updateChecks')}, + {row: configRow('updateMode')}, + {row: configRow('updateFrequency')}, ]}, // The same rows as Settings; nothing here implies NMSh needs a model. {id: 'ask', title: 'Ask & local understanding', intro: ['/ask: ask NMSh what it can do in plain English. Ask works without a language model,', diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 42fd145a..245401b3 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -17,7 +17,7 @@ import {defaultVariant, FAMILY_IDS, FAMILY_LABELS, familyOf, selectFamily, varia import {PROMPT_SYMBOL_IDS, promptSymbolLabel} from '../prompt/glyphChoices.js'; import {VIBRANCE_LABELS, VIBRANCE_LEVELS} from '../chroma/color.js'; import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js'; -import {UPDATE_CHECK_FREQUENCIES} from '../update/update.js'; +import {UPDATE_CHECK_FREQUENCIES, UPDATE_FREQUENCIES, UPDATE_MODES} from '../update/update.js'; import {COMPOSER_POSITIONS, COMPOSER_POSITION_LABELS, LIVE_SESSION_MULTIPLE, LIVE_SESSION_STARTUP, TRANSCRIPT_PRESENTATIONS, TRANSCRIPT_PRESENTATION_LABELS} from '../prompt/configuration.js'; import { @@ -366,9 +366,12 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'outputFolding', level: 'advanced', label: 'Output folding', description: 'Off, Smart (long repetitive successes), or Always (every long block)', category: 'Transcript', values: OUTPUT_FOLDING_MODES, labels: ['Off', 'Smart', 'Always'], get: config => config.outputFolding, set: (config, outputFolding) => ({...config, outputFolding})}), - enumRow({id: 'updateChecks', label: 'Update checks', description: 'Quietly check GitHub for new releases; /update checks on demand', category: 'Updates', - values: UPDATE_CHECK_FREQUENCIES, labels: ['Off', 'Daily', 'Weekly'], - get: config => config.updateChecks, set: (config, updateChecks) => ({...config, updateChecks})}), + enumRow({id: 'updateMode', label: 'Automatic updates', description: 'Automatic prepares verified releases for the next launch (official source installs only); Notify only announces them; Off never checks. /update works on request', category: 'Updates', + values: UPDATE_MODES, labels: ['Automatic', 'Notify only', 'Off'], + get: config => config.updateMode, set: (config, updateMode) => ({...config, updateMode})}), + enumRow({id: 'updateFrequency', parent: 'updateMode', when: config => config.updateMode !== 'off', label: 'Check frequency', description: 'How often to look for a new stable release', category: 'Updates', + values: UPDATE_FREQUENCIES, labels: ['Daily', 'Weekly'], + get: config => config.updateFrequency, set: (config, updateFrequency) => ({...config, updateFrequency})}), enumRow({id: 'liveSessionStartup', label: 'Startup restore', description: 'Resume a detached live session at launch: Ask, Always, or Never (never ends none)', category: 'Sessions', values: LIVE_SESSION_STARTUP, labels: ['Ask', 'Always', 'Never'], get: config => config.liveSessionStartup, set: (config, liveSessionStartup) => ({...config, liveSessionStartup})}), @@ -561,7 +564,7 @@ export const CONFIG_GROUPS = ['General', 'Appearance', 'Prompt & Composer', 'Edi export type ConfigGroup = typeof CONFIG_GROUPS[number]; export const CONFIG_GROUP_BY_ID: Readonly> = { - glyphStyle: 'General', liveSessionStartup: 'General', liveSessionMultiple: 'General', updateChecks: 'General', + glyphStyle: 'General', liveSessionStartup: 'General', liveSessionMultiple: 'General', updateMode: 'General', updateFrequency: 'General', promptVibrance: 'Appearance', showShell: 'Prompt & Composer', composerPosition: 'Prompt & Composer', composerDividers: 'Prompt & Composer', divider: 'Prompt & Composer', historicalPrompt: 'Prompt & Composer', transcriptPresentation: 'Editor', openWith: 'Editor', suggestionsOnEmpty: 'Editor', pastePreview: 'Editor', @@ -595,7 +598,7 @@ export function configGroup(row: SettingsRow): ConfigGroup { /** The order of root rows inside their groups (children follow their root); roots not listed keep their place after these. */ const CONFIG_ORDER: readonly string[] = [ - 'glyphStyle', 'liveSessionStartup', 'liveSessionMultiple', 'updateChecks', + 'glyphStyle', 'liveSessionStartup', 'liveSessionMultiple', 'updateMode', 'updateFrequency', 'uiChrome', 'themeFamily', 'promptVibrance', 'treatmentPreset', 'shimmer', 'autoEffects', 'idleTimeout', 'activityColors', 'provider', 'promptStyle', 'promptSymbol', 'composerPosition', 'composerDividers', 'divider', 'historicalPrompt', 'showShell', 'statusStrip', 'syntaxHighlighting', 'pastePreview', 'transcriptPresentation', 'suggestionsOnEmpty', 'openWith', diff --git a/src/update/update.ts b/src/update/update.ts index 92da48ea..025cdd4c 100644 --- a/src/update/update.ts +++ b/src/update/update.ts @@ -21,6 +21,32 @@ const OFFICIAL_REMOTE = /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/gi export type UpdateCheckFrequency = 'off' | 'daily' | 'weekly'; export const UPDATE_CHECK_FREQUENCIES: readonly UpdateCheckFrequency[] = ['off', 'daily', 'weekly']; +/** Automatic installs a verified release in the background; Notify only announces it; Off never contacts GitHub. */ +export type UpdateMode = 'automatic' | 'notify' | 'off'; +export const UPDATE_MODES: readonly UpdateMode[] = ['automatic', 'notify', 'off']; +export type UpdateFrequency = 'daily' | 'weekly'; +export const UPDATE_FREQUENCIES: readonly UpdateFrequency[] = ['daily', 'weekly']; + +/** + * One settings model. A saved installation keeps what it chose: the former + * `updateChecks` off/daily/weekly maps to Off, or Notify only with that + * frequency; it never becomes an automatic install. Only a configuration with + * no saved keys at all (a fresh install) gets Automatic / Daily. + */ +export function migrateUpdateSettings(value: Record): {updateMode: UpdateMode; updateFrequency: UpdateFrequency} { + const mode = UPDATE_MODES.find(item => item === value.updateMode); + const frequency = UPDATE_FREQUENCIES.find(item => item === value.updateFrequency); + if (mode) return {updateMode: mode, updateFrequency: frequency ?? 'daily'}; + if (value.updateChecks === 'daily' || value.updateChecks === 'weekly') return {updateMode: 'notify', updateFrequency: value.updateChecks}; + if (Object.keys(value).length === 0) return {updateMode: 'automatic', updateFrequency: 'daily'}; + return {updateMode: 'off', updateFrequency: frequency ?? 'daily'}; +} + +/** Tests and CI never contact GitHub or touch the checkout through the background updater. */ +export function updatesDisabledByEnvironment(env: NodeJS.ProcessEnv = process.env): boolean { + return env.NMSH_DISABLE_UPDATES === '1' || env.NODE_TEST_CONTEXT !== undefined; +} + export interface ReleaseInfo { version: string; tag: string; @@ -262,8 +288,18 @@ export interface UpdateState { lastCheck?: number; latestVersion?: string; notifiedVersion?: string; + /** A release built and verified on disk by NMSh; the running process may still be older. */ + installedVersion?: string; + /** Automatic install was not possible for this release (provenance/safety); announced once. */ + skipped?: {version: string; reason: string; notified?: boolean}; + /** Automatic install of this release failed and was rolled back; not retried until a newer release or /update apply. */ + failed?: {version: string; at: number; reason: string; notified?: boolean}; } +const bounded = (value: unknown) => typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').slice(0, 200) : ''; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + export function updateStatePath(env: NodeJS.ProcessEnv = process.env): string { return join(nmshConfigDirectory(env), 'update-state.json'); } @@ -275,6 +311,11 @@ export function loadUpdateState(path = updateStatePath()): UpdateState { ...(typeof value.lastCheck === 'number' ? {lastCheck: value.lastCheck} : {}), ...(typeof value.latestVersion === 'string' ? {latestVersion: value.latestVersion} : {}), ...(typeof value.notifiedVersion === 'string' ? {notifiedVersion: value.notifiedVersion} : {}), + ...(typeof value.installedVersion === 'string' ? {installedVersion: value.installedVersion} : {}), + ...(isRecord(value.skipped) && typeof value.skipped.version === 'string' + ? {skipped: {version: value.skipped.version, reason: bounded(value.skipped.reason), ...(value.skipped.notified === true ? {notified: true} : {})}} : {}), + ...(isRecord(value.failed) && typeof value.failed.version === 'string' && typeof value.failed.at === 'number' + ? {failed: {version: value.failed.version, at: value.failed.at, reason: bounded(value.failed.reason), ...(value.failed.notified === true ? {notified: true} : {})}} : {}), }; } catch { return {}; @@ -295,29 +336,103 @@ export function isCheckDue(frequency: UpdateCheckFrequency, state: UpdateState, return state.lastCheck === undefined || now - state.lastCheck >= PERIOD_MS[frequency] || now < state.lastCheck; } +export interface CheckResult { + /** The latest stable release, when it was fetched and is newer than the running version. */ + release?: ReleaseInfo; + /** First time this release is seen: Notify only announces it now. */ + announce: boolean; +} + /** - * One quiet background check. Returns the version to announce, at most - * once per newly seen release; every failure is silent. + * One quiet, due-gated release check. Off (or a check that is not due) makes + * no request. Every failure is silent. */ -export async function backgroundUpdateCheck(current: string, frequency: UpdateCheckFrequency, options: { +export async function checkForUpdate(current: string, frequency: UpdateCheckFrequency, options: { now?: number; statePath?: string; fetchImpl?: FetchLike; -} = {}): Promise { +} = {}): Promise { const now = options.now ?? Date.now(); const state = loadUpdateState(options.statePath); - if (!isCheckDue(frequency, state, now)) return undefined; + if (!isCheckDue(frequency, state, now)) return {announce: false}; let release: ReleaseInfo; try { release = await fetchLatestRelease(options.fetchImpl); } catch { try { saveUpdateState({...state, lastCheck: now}, options.statePath); } catch { /* best effort */ } - return undefined; + return {announce: false}; } - const announce = compareVersions(release.version, current) > 0 && state.notifiedVersion !== release.version; + const newer = compareVersions(release.version, current) > 0; + const announce = newer && state.notifiedVersion !== release.version; try { - saveUpdateState({lastCheck: now, latestVersion: release.version, - ...(announce ? {notifiedVersion: release.version} : state.notifiedVersion ? {notifiedVersion: state.notifiedVersion} : {})}, options.statePath); + saveUpdateState({...state, lastCheck: now, latestVersion: release.version, + ...(announce ? {notifiedVersion: release.version} : {})}, options.statePath); } catch { // Unwritable state only means the next launch checks again. } - return announce ? release : undefined; + return {...(newer ? {release} : {}), announce}; +} + +/** Notify-only form: the release to announce, at most once per newly seen release. */ +export async function backgroundUpdateCheck(current: string, frequency: UpdateCheckFrequency, options: { + now?: number; statePath?: string; fetchImpl?: FetchLike; +} = {}): Promise { + const result = await checkForUpdate(current, frequency, options); + return result.announce ? result.release : undefined; +} + +export type AutomaticOutcome = + | {kind: 'ready'; version: string; announce: boolean} + | {kind: 'skipped'; version: string; reason: string; announce: boolean} + | {kind: 'failed'; version: string; reason: string; announce: boolean}; + +/** + * Prepare a release in the background with exactly the manual updater's plan + * and apply functions, so every safety check, build verification and rollback + * is shared. The running process is never touched. Nothing here throws. + */ +export async function prepareAutomaticUpdate(release: ReleaseInfo, options: { + statePath?: string; root?: string; runner?: CommandRunner; tagCommit?: (tag: string) => Promise; + readIdentity?: (root: string) => {version?: string; commit?: string}; now?: number; +} = {}): Promise { + const runner = options.runner ?? systemRunner; + const save = (state: UpdateState) => { try { saveUpdateState(state, options.statePath); } catch { /* best effort */ } }; + const state = loadUpdateState(options.statePath); + if (state.installedVersion === release.version) return {kind: 'ready', version: release.version, announce: false}; + // No retry storm: a failed release waits for a newer release or an explicit /update apply. + if (state.failed?.version === release.version) return {kind: 'failed', version: release.version, reason: state.failed.reason, announce: false}; + const skip = (reason: string): AutomaticOutcome => { + const announce = state.skipped?.version !== release.version || state.skipped.notified !== true; + save({...loadUpdateState(options.statePath), skipped: {version: release.version, reason: bounded(reason), notified: true}}); + return {kind: 'skipped', version: release.version, reason, announce}; + }; + try { + const install = await detectInstall(options.root ?? installRoot(), runner); + const planned = await planUpdate(install, release, runner, options.tagCommit ?? (tag => fetchTagCommit(tag))); + if (!planned.ok) return skip(planned.reason); + const result = await applyUpdate(planned.plan, runner, () => undefined, options.readIdentity); + if (result.ok) { + const { skipped: _skipped, failed: _failed, ...rest } = loadUpdateState(options.statePath); + save({...rest, installedVersion: release.version}); + return {kind: 'ready', version: release.version, announce: true}; + } + const reason = bounded(result.log.filter(line => /fail|could not|rollback|restored/iu.test(line)).slice(-2).join(' ')) || 'the update did not complete'; + save({...loadUpdateState(options.statePath), failed: {version: release.version, at: options.now ?? Date.now(), reason, notified: true}}); + return {kind: 'failed', version: release.version, reason, announce: true}; + } catch (error) { + const reason = bounded(error instanceof Error ? error.message : String(error)); + save({...loadUpdateState(options.statePath), failed: {version: release.version, at: options.now ?? Date.now(), reason, notified: true}}); + return {kind: 'failed', version: release.version, reason, announce: true}; + } +} + +/** A version prepared on disk that this process is not running yet. */ +export function readyVersion(state: UpdateState, running: string): string | undefined { + return state.installedVersion && compareVersions(state.installedVersion, running) > 0 ? state.installedVersion : undefined; +} + +/** Record a successful explicit `/update apply` the same way, and clear stale automatic failure bookkeeping. */ +export function recordInstalled(version: string, statePath?: string): void { + try { + const { skipped: _skipped, failed: _failed, ...rest } = loadUpdateState(statePath); + saveUpdateState({...rest, installedVersion: version}, statePath); + } catch { /* best effort */ } } diff --git a/tests/settingsV2.test.ts b/tests/settingsV2.test.ts index ad81b311..6f3439c0 100644 --- a/tests/settingsV2.test.ts +++ b/tests/settingsV2.test.ts @@ -48,20 +48,20 @@ test('default Config hides advanced rows; A reveals them; search always finds th }); test('changed-from-default is a text cue and reset targets one setting', () => { - const changed = {...DEFAULT_PROMPT_CONFIGURATION, updateChecks: 'weekly' as const, outputFolding: 'always' as const}; - assert.equal(settingsRowChanged(row('updateChecks'), DEFAULT_PROMPT_CONFIGURATION), false); - assert.equal(settingsRowChanged(row('updateChecks'), changed), true); - const reset = resetSettingsRow(row('updateChecks'), changed)!; - assert.equal(reset.updateChecks, DEFAULT_PROMPT_CONFIGURATION.updateChecks); + const changed = {...DEFAULT_PROMPT_CONFIGURATION, updateFrequency: 'weekly' as const, outputFolding: 'always' as const}; + assert.equal(settingsRowChanged(row('updateFrequency'), DEFAULT_PROMPT_CONFIGURATION), false); + assert.equal(settingsRowChanged(row('updateFrequency'), changed), true); + const reset = resetSettingsRow(row('updateFrequency'), changed)!; + assert.equal(reset.updateFrequency, DEFAULT_PROMPT_CONFIGURATION.updateFrequency); assert.equal(reset.outputFolding, 'always', 'other settings untouched'); assert.equal(resetSettingsRow(row('provider'), changed), undefined); }); test('rendering marks changed rows and offers reset only when it applies', () => { - const index = visibleSettingsRows(config()).findIndex(item => item.id === 'updateChecks'); - const changed = {...DEFAULT_PROMPT_CONFIGURATION, updateChecks: 'weekly' as const}; + const index = visibleSettingsRows(config()).findIndex(item => item.id === 'updateFrequency'); + const changed = {...DEFAULT_PROMPT_CONFIGURATION, updateFrequency: 'weekly' as const}; const shown = plain(renderSettingsPanel(config({contentIndex: index}), 100, Infinity, {configuration: changed})); - assert.ok(shown.some(line => line.includes('Update checks') && line.includes('Weekly •'))); + assert.ok(shown.some(line => line.includes('Check frequency') && line.includes('Weekly •'))); assert.ok(shown.at(-1)!.includes('R reset to')); const clean = plain(renderSettingsPanel(config({contentIndex: index}), 100, Infinity, {configuration: DEFAULT_PROMPT_CONFIGURATION})); assert.ok(!clean.some(line => line.includes('•')) && !clean.at(-1)!.includes('R reset')); @@ -70,11 +70,11 @@ test('rendering marks changed rows and offers reset only when it applies', () => test('R resets the focused setting, A toggles advanced, and position is remembered', () => withApp(app => { app['openSettingsPanel']('config'); - app['applySettingsConfiguration']({...app['promptConfiguration'], updateChecks: 'weekly'}); - const index = visibleSettingsRows(app['settingsPanelState']!).findIndex(item => item.id === 'updateChecks'); + app['applySettingsConfiguration']({...app['promptConfiguration'], updateFrequency: 'weekly'}); + const index = visibleSettingsRows(app['settingsPanelState']!).findIndex(item => item.id === 'updateFrequency'); app['settingsPanelState']!.contentIndex = index; app['handleKey']({kind: 'text', value: 'r'}); - assert.equal(app['promptConfiguration'].updateChecks, DEFAULT_PROMPT_CONFIGURATION.updateChecks); + assert.equal(app['promptConfiguration'].updateFrequency, DEFAULT_PROMPT_CONFIGURATION.updateFrequency); app['handleKey']({kind: 'text', value: 'a'}); assert.equal(app['settingsPanelState']!.showAdvanced, true); assert.equal(app['settingsPanelState']!.contentIndex, 0); diff --git a/tests/update.test.ts b/tests/update.test.ts index 2be1ee6d..40067426 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -155,11 +155,120 @@ test('background checks are opt-in, periodic, quiet on failure, and announce a r } }); -test('/update is explicit and checks default to off', () => { +test('/update is explicit', () => { assert.deepEqual(parseSlashCommand('/update'), {kind: 'update', apply: false}); assert.deepEqual(parseSlashCommand('/update apply'), {kind: 'update', apply: true}); assert.equal(parseSlashCommand('/update now')?.kind, 'unknown'); - assert.equal(DEFAULT_PROMPT_CONFIGURATION.updateChecks, 'off'); - assert.equal(normalizePromptConfiguration({updateChecks: 'weekly'}).updateChecks, 'weekly'); - assert.equal(normalizePromptConfiguration({updateChecks: 'hourly'}).updateChecks, 'off'); +}); + +const update = (input: Record) => { const c = normalizePromptConfiguration(input); return [c.updateMode, c.updateFrequency]; }; +test('update settings: fresh installs are Automatic / Daily; saved intent is preserved, never upgraded to automatic', () => { + assert.deepEqual([DEFAULT_PROMPT_CONFIGURATION.updateMode, DEFAULT_PROMPT_CONFIGURATION.updateFrequency], ['automatic', 'daily']); + assert.deepEqual(update({}), ['automatic', 'daily']); + assert.deepEqual(update({updateChecks: 'off', onboardingComplete: true}), ['off', 'daily']); + assert.deepEqual(update({updateChecks: 'daily'}), ['notify', 'daily']); + assert.deepEqual(update({updateChecks: 'weekly'}), ['notify', 'weekly']); + assert.deepEqual(update({updateChecks: 'hourly'}), ['off', 'daily'], 'unrecognised saved value stays off'); + assert.deepEqual(update({onboardingComplete: true}), ['off', 'daily'], 'a saved config that predates the setting was Off'); + assert.deepEqual(update({updateMode: 'automatic', updateFrequency: 'weekly', updateChecks: 'off'}), ['automatic', 'weekly'], 'new keys win'); + assert.equal('updateChecks' in normalizePromptConfiguration({updateChecks: 'daily'}), false, 'no competing legacy setting remains'); +}); + + +import {mkdtempSync, readFileSync as readFile, statSync, writeFileSync as writeFile} from 'node:fs'; +import {checkForUpdate, loadUpdateState, prepareAutomaticUpdate, readyVersion, recordInstalled, saveUpdateState, updatesDisabledByEnvironment} from '../src/update/update.js'; + +const stateFile = () => join(mkdtempSync(join(tmpdir(), 'nmsh-upd-')), 'cfg', 'update-state.json'); +const newer: ReleaseInfo = {version: '0.17.0', tag: 'v0.17.0', url: 'https://github.com/raiseCatError/notMyShell/releases/tag/v0.17.0', summary: []}; +const OFFICIAL = 'https://github.com/raiseCatError/notMyShell.git'; +const NEW_SHA = 'c'.repeat(40); +const identity = () => ({version: '0.17.0', commit: NEW_SHA.slice(0, 7)}); +/** An official, clean, fast-forwardable checkout unless an answer overrides it. */ +const healthy = (extra: Record = {}) => scripted({ + 'git rev-parse --show-toplevel': process.cwd(), 'git remote get-url': OFFICIAL, 'git rev-parse HEAD': HEAD, 'git symbolic-ref': 'master', + 'git status --porcelain --untracked-files=no': '', 'git fetch': '', 'git rev-parse v0.17.0': NEW_SHA, 'git merge-base': '', ...extra, +}); +const harness = (runner: CommandRunner, statePath: string, tagCommit = async () => NEW_SHA) => ({statePath, runner, root: process.cwd(), tagCommit, readIdentity: identity}); + +test('semver jump 0.16.0 -> 0.17.0 is newer; updates are disabled under the test runner', () => { + assert.equal(compareVersions('0.17.0', '0.16.0'), 1); + assert.equal(compareVersions('0.16.0', '0.17.0'), -1); + assert.equal(updatesDisabledByEnvironment({}), false); + assert.equal(updatesDisabledByEnvironment({NMSH_DISABLE_UPDATES: '1'}), true); +}); + +test('Off makes no request; notify-only discovery never mutates; repeated checks announce a release once', async () => { + const statePath = stateFile(); + const fetchImpl = fakeFetch({tag_name: 'v0.17.0', html_url: 'https://example/r', body: ''}); + assert.deepEqual(await checkForUpdate('0.16.0', 'off', {statePath, fetchImpl, now: 1e12}), {announce: false}); + assert.equal(fetchImpl.urls.length, 0); + const first = await checkForUpdate('0.16.0', 'daily', {statePath, fetchImpl, now: 1e12}); + assert.equal(first.announce, true); assert.equal(first.release?.version, '0.17.0'); + const second = await checkForUpdate('0.16.0', 'daily', {statePath, fetchImpl, now: 1e12 + 90_000_000}); + assert.equal(second.announce, false, 'same release is not announced twice'); assert.equal(second.release?.version, '0.17.0'); + assert.equal(statSync(statePath).mode & 0o777, 0o600, 'update state stays private'); +}); + +test('automatic: an eligible install is prepared through the shared plan/apply; the running version stays distinct', async () => { + const statePath = stateFile(); + const runner = healthy(); + const outcome = await prepareAutomaticUpdate(newer, harness(runner, statePath)); + assert.deepEqual(outcome, {kind: 'ready', version: '0.17.0', announce: true}); + assert.ok(runner.calls.some(call => call.startsWith('git merge --ff-only')) && runner.calls.includes('npm run build')); + assert.ok(!runner.calls.some(call => DESTRUCTIVE.test(call))); + const state = loadUpdateState(statePath); + assert.equal(state.installedVersion, '0.17.0'); + assert.equal(readyVersion(state, '0.16.0'), '0.17.0', 'prepared on disk, not yet running'); + assert.equal(readyVersion(state, '0.17.0'), undefined, 'once running it, nothing is pending'); + const again = await prepareAutomaticUpdate(newer, harness(runner, statePath)); + assert.deepEqual(again, {kind: 'ready', version: '0.17.0', announce: false}, 'no second install, no second notice'); +}); + +for (const [name, answers, tagCommit, pattern] of [ + ['dirty tracked tree', {'git status --porcelain --untracked-files=no': ' M src/x.ts'}, undefined, /uncommitted changes/u], + ['unofficial remote', {'git remote get-url': 'https://github.com/someone/fork.git'}, undefined, /not github\.com/u], + ['diverged checkout', {'git merge-base': new Error('not ancestor')}, undefined, /not a fast-forward/u], + ['tag and GitHub disagree', {}, async () => 'd'.repeat(40), /does not match GitHub/u], +] as Array<[string, Record, (() => Promise) | undefined, RegExp]>) { + test(`automatic: ${name} is never changed, and the skip is announced once`, async () => { + const statePath = stateFile(); + const runner = healthy(answers); + const outcome = await prepareAutomaticUpdate(newer, harness(runner, statePath, tagCommit)); + assert.equal(outcome.kind, 'skipped'); assert.match((outcome as {reason: string}).reason, pattern); assert.equal(outcome.announce, true); + assert.ok(!runner.calls.some(call => /git (?:merge --ff|switch|reset)|npm /u.test(call)), 'nothing was moved or built'); + const repeat = await prepareAutomaticUpdate(newer, harness(healthy(answers), statePath, tagCommit)); + assert.equal(repeat.announce, false, 'no repeated nagging'); + assert.equal(loadUpdateState(statePath).installedVersion, undefined); + }); +} + +test('automatic: a failing build rolls back, is announced once and is not retried in a loop', async () => { + const statePath = stateFile(); + const runner = healthy({'npm run build': new Error('tsc failed')}); + const outcome = await prepareAutomaticUpdate(newer, harness(runner, statePath)); + assert.equal(outcome.kind, 'failed'); assert.equal(outcome.announce, true); + assert.ok(runner.calls.some(call => call.startsWith('git reset --keep')), 'rollback ran'); + assert.equal(loadUpdateState(statePath).installedVersion, undefined); + const calls = runner.calls.length; + const retry = await prepareAutomaticUpdate(newer, harness(runner, statePath)); + assert.equal(retry.kind, 'failed'); assert.equal(retry.announce, false); + assert.equal(runner.calls.length, calls, 'no commands run on the repeat'); + recordInstalled('0.17.0', statePath); + assert.equal(loadUpdateState(statePath).failed, undefined, 'a successful manual apply clears the failure'); +}); + +test('automatic: a runner that throws unexpectedly never escapes', async () => { + const outcome = await prepareAutomaticUpdate(newer, {statePath: stateFile(), root: process.cwd(), runner: {run: async () => { throw new Error('boom'); }}}); + assert.ok(['skipped', 'failed'].includes(outcome.kind)); +}); + +test('update state round-trips new fields, bounded and private; untracked files do not affect the tracked-clean probe', () => { + const statePath = stateFile(); + saveUpdateState({lastCheck: 1, installedVersion: '0.17.0', failed: {version: '0.18.0', at: 5, reason: 'x'.repeat(500)}}, statePath); + const state = loadUpdateState(statePath); + assert.equal(state.installedVersion, '0.17.0'); + assert.equal(state.failed?.reason.length, 200); + assert.equal(statSync(statePath).mode & 0o777, 0o600); + assert.doesNotMatch(readFile(statePath, 'utf8'), /token|secret/iu); + writeFile(statePath, '{"failed": {"version": 3}}'); assert.deepEqual(loadUpdateState(statePath), {}); }); From 8e4e66ffa0e48f5b1b8806a223daecf8f4819e74 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:18:04 +0530 Subject: [PATCH 06/13] Add in-place kill with confirmation to the startup session pickers --- README.md | 2 +- docs/design/session-interaction-ux.md | 2 +- src/index.ts | 20 +++-- src/session/StartupPicker.ts | 113 ++++++++++++++++++++++++-- src/session/liveSessions.ts | 4 +- src/session/startupRestore.ts | 1 + tests/startupRestore.test.ts | 101 ++++++++++++++++++++++- 7 files changed, 222 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index fcd12675..8293f887 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ nmsh `/update` checks GitHub for the latest stable release and shows current → available, a short release summary, and the exact plan. `/update apply` then installs that release. NMSh updates a source checkout of this repository only when the checkout is clean, its `origin` is this repository, the fetched release tag matches the commit GitHub reports, and moving to the tag is a fast-forward. It then runs `npm install` and `npm run build` and verifies the new build identity. If anything fails, it restores the previous commit and rebuilds it. Otherwise it explains why and prints the manual steps. It never pulls arbitrary branches, discards changes, or touches your settings, transcripts, or shell profile. Restart NMSh afterwards to use the new version. -Background checks are off by default; turn them on in `/settings` → Config → Update checks (Daily or Weekly). When a newer release appears, you get one quiet line per release. No credentials or telemetry are involved. +Config → Automatic updates is Automatic, Notify only or Off, with a Daily or Weekly check frequency. New installs default to Automatic / Daily; saved Daily/Weekly notification preferences migrate to Notify only, Off stays Off. Automatic only prepares a verified stable release on an official, clean source checkout (the same checks `/update apply` makes, with the same rollback); the running session keeps its version and the new one starts on the next launch. Anything that cannot be proven safe gets one quiet notice and the manual steps. No credentials or telemetry are involved. ## Ghostty Setup diff --git a/docs/design/session-interaction-ux.md b/docs/design/session-interaction-ux.md index b78ca833..029fff7c 100644 --- a/docs/design/session-interaction-ux.md +++ b/docs/design/session-interaction-ux.md @@ -139,7 +139,7 @@ Live sessions are owned by the per-user session service (`nmshd`), which listens - Never only skips restoring at launch. It never ends a session, and `/resume`, detach and reattach are unaffected. - **Multiple detached sessions:** Ask which (default) or Open all. - The picker uses ↑↓ to move, Space to select, A to select all (A again clears), and Enter to resume the selected sessions. Esc, or Enter with nothing selected, starts fresh. - - Neither screen has a destructive key: killing stays a confirmed `/resume` action. `--new` still skips restoring, and `--attach ` attaches one session explicitly. + - Both screens have one destructive key, `X`, which only opens a confirmation (Enter/Y kills, Esc/N/Ctrl+C cancels). The kill is the same confirmed termination `/resume` uses; the transcript is archived and stays in `/resume`. Sessions attached elsewhere are never touched, and killing the last one starts a fresh session. `--new` still skips restoring, and `--attach ` attaches one session explicitly. - When several sessions are restored, this window attaches the first. Each other one opens in a new window of the host terminal, running `nmsh --attach `: - Ghostty on macOS: Ghostty's AppleScript API (`new surface configuration`, then `new window with configuration`). It opens a normal window in the running Ghostty app. The command words are passed as `osascript` arguments and shell-quoted with `quoted form of`, never written into the script. macOS asks once for Automation permission. If AppleScript is disabled or permission is denied, NMSh names the session's `nmsh --attach` command instead. - Ghostty on Linux: `ghostty -e`. diff --git a/src/index.ts b/src/index.ts index f5db15bd..8b865b28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -161,17 +161,27 @@ if (args[0] === 'config' || args[0] === 'uninstall' || args[0] === 'doctor') { const {detectTerminalHost} = await import('./host/terminalHost.js'); const {loadPromptConfiguration, savePromptConfiguration} = await import('./prompt/configuration.js'); const config = loadPromptConfiguration(); + // Same confirmed termination as /resume. Service truth is re-read first: a session that ended + // meanwhile is simply gone, and one attached elsewhere is never touched. + const killDetached = async (session: (typeof live)[number]): Promise<'killed' | 'gone' | 'attached'> => { + const [{killAndArchive}, {TranscriptStore}] = await Promise.all([import('./session/liveSessions.js'), import('./sessions/TranscriptStore.js')]); + const current = (await listLiveSessions().catch(() => [])).find(item => item.id === session.id); + if (!current) return 'gone'; + if (current.state !== 'detached') return 'attached'; + try { await killAndArchive(current, {store: new TranscriptStore(), origin: 'the startup picker'}); return 'killed'; } + catch (error) { + if (!(await listLiveSessions().catch(() => [current])).some(item => item.id === session.id)) return 'gone'; + throw error; + } + }; const restored = await restoreAtStartup(live, { // After a failed return, never silently attach a different session: ask instead of Always. policy: {startup: returnUnavailable && config.liveSessionStartup === 'always' ? 'ask' : config.liveSessionStartup, multiple: config.liveSessionMultiple}, saveStartup: startup => { try { const base = loadPromptConfiguration(); savePromptConfiguration({...base, liveSessionStartup: startup}, undefined, base); } catch { /* keep going; applies this launch */ } }, - askOne: session => picker.runStartupScreen(columns => picker.renderSinglePrompt(session, columns, Date.now()), picker.singlePromptKey), - pick: sessions => { - const state = picker.createMultiPicker(sessions); - return picker.runStartupScreen(columns => picker.renderMultiPicker(state, columns, Date.now()), key => picker.multiPickerKey(state, key)); - }, + askOne: session => picker.askWithKill(session, {run: picker.runStartupScreen, kill: killDetached}), + pick: sessions => picker.pickWithKill(sessions, {run: picker.runStartupScreen, kill: killDetached}), host: detectTerminalHost(), selfCommand: [process.execPath, ...process.execArgv.filter(arg => !arg.startsWith('--inspect')), process.argv[1]!], }); diff --git a/src/session/StartupPicker.ts b/src/session/StartupPicker.ts index 24878727..9dc092b0 100644 --- a/src/session/StartupPicker.ts +++ b/src/session/StartupPicker.ts @@ -2,8 +2,10 @@ import {KeyDecoder, type Key} from '../terminal/keys.js'; import type {SessionInfo} from './SessionProtocol.js'; import {formatAge, tildePath} from './sessionList.js'; -// Launch-time restore screens, shown before any session is attached. Neither -// has a destructive key: killing a live session stays a confirmed /resume action. +// Launch-time restore screens, shown before any session is attached. Each has +// one destructive key, X, which only opens a confirmation; the kill itself is +// the same confirmed termination path /resume uses (killAndArchive), and the +// transcript is archived, never discarded. const clipTo = (columns: number) => (text: string) => (text.length > columns - 1 ? `${text.slice(0, Math.max(0, columns - 2))}…` : text); const activity = (session: SessionInfo) => session.running?.replace(/\s+/gu, ' ').slice(0, 60) ?? 'zsh'; @@ -12,7 +14,7 @@ const age = (session: SessionInfo, now: number) => formatAge(now - (session.runn // ── One detached session ───────────────────────────────────────────────────── /** resume: attach · not-now: fresh session, this launch only · always/never: also persist the startup setting. */ -export type SinglePromptChoice = 'resume' | 'not-now' | 'always' | 'never'; +export type SinglePromptChoice = 'resume' | 'not-now' | 'always' | 'never' | 'kill'; export function renderSinglePrompt(session: SessionInfo, columns: number, now: number): string[] { const clip = clipTo(columns); @@ -21,6 +23,7 @@ export function renderSinglePrompt(session: SessionInfo, columns: number, now: n clip(` ${session.running ? `running ${activity(session)}` : 'idle at the prompt'} · ${age(session, now)}`), clip(` started ${formatAge(now - session.createdAt)} ago`), '', ' R Resume', ' N Not now (start a new session; it keeps running)', + ' X Kill session (ends the shell; transcript stays in /resume)', ' A Always resume', ' D Don\'t resume at startup (it keeps running)', '', clip('Enter resume · Esc not now · sessions stay in /resume')]; } @@ -29,7 +32,27 @@ export function singlePromptKey(key: Key): SinglePromptChoice | undefined { if (key.kind === 'enter') return 'resume'; if (key.kind === 'escape' || key.kind === 'interrupt') return 'not-now'; if (key.kind !== 'text') return undefined; - return ({r: 'resume', n: 'not-now', a: 'always', d: 'never'} as const)[key.value.toLowerCase() as 'r' | 'n' | 'a' | 'd']; + return ({r: 'resume', n: 'not-now', a: 'always', d: 'never', x: 'kill'} as const)[key.value.toLowerCase() as 'r' | 'n' | 'a' | 'd' | 'x']; +} + +// ── Kill confirmation (shared by both screens) ────────────────────────────── + +export function renderKillConfirm(session: SessionInfo, columns: number, now: number): string[] { + const clip = clipTo(columns); + return ['Kill detached session?', '', + clip(` ${tildePath(session.cwd)}`), + clip(` ${session.running ? `running ${activity(session)}` : 'idle at the prompt'} · ${age(session, now)}`), '', + ' This ends the live shell.', ' Its transcript will remain available in /resume.', '', + clip('Enter/Y kill · Esc/N cancel')]; +} + +/** Enter/Y confirm, Esc/N/Ctrl+C cancel; anything else is ignored. */ +export function killConfirmKey(key: Key): 'confirm' | 'cancel' | undefined { + if (key.kind === 'enter') return 'confirm'; + if (key.kind === 'escape' || key.kind === 'interrupt') return 'cancel'; + if (key.kind !== 'text') return undefined; + const value = key.value.toLowerCase(); + return value === 'y' ? 'confirm' : value === 'n' ? 'cancel' : undefined; } // ── Several detached sessions ──────────────────────────────────────────────── @@ -38,8 +61,15 @@ export interface MultiPickerState { sessions: SessionInfo[]; cursor: number; selected: Set; + /** The session a kill confirmation is open for. */ + confirming?: string; + /** One factual line about the last kill, shown under the list. */ + message?: string; } +/** Returned by multiPickerKey once a kill is confirmed; the caller performs it and re-enters the picker. */ +export interface KillRequest {kill: string} + export function createMultiPicker(sessions: SessionInfo[]): MultiPickerState { return {sessions, cursor: 0, selected: new Set()}; } @@ -48,21 +78,31 @@ export function renderMultiPicker(state: MultiPickerState, columns: number, now: const clip = clipTo(columns); const width = Math.min(32, Math.max(...state.sessions.map(session => tildePath(session.cwd).length), 4)); const commandWidth = Math.min(20, Math.max(...state.sessions.map(session => activity(session).length), 3)); - const rows = [`NMSh · ${state.sessions.length} detached live sessions`, '']; + const confirming = state.sessions.find(session => session.id === state.confirming); + if (confirming) return renderKillConfirm(confirming, columns, now); + const rows = [`NMSh · ${state.sessions.length} detached live session${state.sessions.length === 1 ? '' : 's'}`, '']; state.sessions.forEach((session, index) => { const mark = state.selected.has(session.id) ? '[x]' : '[ ]'; rows.push(clip(`${index === state.cursor ? '›' : ' '} ${mark} ${tildePath(session.cwd).padEnd(width)} ${activity(session).padEnd(commandWidth)} ${age(session, now)}`)); }); const count = state.selected.size; - rows.push('', clip(`↑↓ move · Space select · A all · Enter resume ${count === 0 ? 'none' : count} · Esc none`), - clip('Unselected sessions keep running and stay in /resume.')); + rows.push('', clip(`↑↓ move · Space select · A all · X kill · Enter resume ${count === 0 ? 'none' : count} · Esc none`), + clip('Unselected sessions keep running and stay in /resume.'), ...(state.message ? [clip(state.message)] : [])); return rows; } /** Apply one key; returns the ids to resume (in list order) once the user is done, [] for none. */ -export function multiPickerKey(state: MultiPickerState, key: Key): string[] | undefined { +export function multiPickerKey(state: MultiPickerState, key: Key): string[] | KillRequest | undefined { const count = state.sessions.length; - if (key.kind === 'up') state.cursor = (state.cursor - 1 + count) % count; + if (state.confirming) { + const decision = killConfirmKey(key); + const id = state.confirming; + if (decision) state.confirming = undefined; + return decision === 'confirm' ? {kill: id} : undefined; + } + state.message = undefined; + if (key.kind === 'text' && key.value.toLowerCase() === 'x') state.confirming = state.sessions[state.cursor]?.id; + else if (key.kind === 'up') state.cursor = (state.cursor - 1 + count) % count; else if (key.kind === 'down') state.cursor = (state.cursor + 1) % count; else if (key.kind === 'escape' || key.kind === 'interrupt') return []; else if (key.kind === 'enter') return state.sessions.filter(session => state.selected.has(session.id)).map(session => session.id); @@ -77,6 +117,61 @@ export function multiPickerKey(state: MultiPickerState, key: Key): string[] | un return undefined; } +/** Drop a session (killed or already gone): selection and cursor stay consistent and in bounds. */ +export function removeFromMultiPicker(state: MultiPickerState, id: string): void { + state.sessions = state.sessions.filter(session => session.id !== id); + state.selected.delete(id); + if (state.confirming === id) state.confirming = undefined; + state.cursor = Math.max(0, Math.min(state.cursor, state.sessions.length - 1)); +} + +export type KillResult = 'killed' | 'gone' | 'attached'; + +/** + * The multi-session screen with in-place kill. `run` shows one screen until + * a result; `kill` re-checks service truth and performs the real termination. + * Resolves with the ids to resume, [] for none or when no session is left + * (the caller then starts a fresh session). + */ +export async function pickWithKill(sessions: SessionInfo[], io: { + run: (render: (columns: number) => string[], onKey: (key: Key) => T | undefined) => Promise; + kill: (session: SessionInfo) => Promise; + now?: () => number; +}): Promise { + const state = createMultiPicker(sessions); + const now = io.now ?? Date.now; + for (;;) { + const result = await io.run(columns => renderMultiPicker(state, columns, now()), key => multiPickerKey(state, key)); + if (Array.isArray(result)) return result; + const target = state.sessions.find(session => session.id === result.kill); + if (!target) continue; + let outcome: KillResult; + try { outcome = await io.kill(target); } + catch (error) { state.message = `Could not end that session: ${error instanceof Error ? error.message : String(error)}`; continue; } + removeFromMultiPicker(state, target.id); + state.message = outcome === 'killed' ? 'Session ended; its transcript is in /resume.' + : outcome === 'gone' ? 'That session had already ended; it is in /resume.' : 'That session is attached in another window and was not touched.'; + if (state.sessions.length === 0) return []; + } +} + +/** The one-session prompt with in-place kill; confirmation cancels back to the prompt. */ +export async function askWithKill(session: SessionInfo, io: { + run: (render: (columns: number) => string[], onKey: (key: Key) => T | undefined) => Promise; + kill: (session: SessionInfo) => Promise; + now?: () => number; +}): Promise { + const now = io.now ?? Date.now; + for (;;) { + const choice = await io.run(columns => renderSinglePrompt(session, columns, now()), singlePromptKey); + if (choice !== 'kill') return choice; + const decision = await io.run<'confirm' | 'cancel'>(columns => renderKillConfirm(session, columns, now()), killConfirmKey); + if (decision === 'cancel') continue; + try { await io.kill(session); } catch { continue; } + return 'kill'; + } +} + // ── Terminal runner ───────────────────────────────────────────────────────── /** Run a full-screen prompt on the real terminal until `onKey` returns a result. */ diff --git a/src/session/liveSessions.ts b/src/session/liveSessions.ts index a6911037..9b8b2507 100644 --- a/src/session/liveSessions.ts +++ b/src/session/liveSessions.ts @@ -34,6 +34,8 @@ export interface KillOptions { runtimeDir?: string; store?: TranscriptStore; env?: NodeJS.ProcessEnv; + /** Where the kill was requested, for the archive note. */ + origin?: string; } /** @@ -47,7 +49,7 @@ export async function killAndArchive(session: SessionInfo, options: KillOptions // session concurrently now that the service no longer lists it. const outcome = await finalizeLiveSession({store: options.store ?? new TranscriptStore(), runtimeDir, sessionId: session.id, ...(session.journalId ? {journalId: session.journalId} : {}), cwd: session.cwd, waitMs: 10_000, - note: () => 'Session killed from /resume; its shell has ended.'}); + note: () => `Session killed from ${options.origin ?? '/resume'}; its shell has ended.`}); if (outcome.kind === 'claimed-elsewhere') throw new Error('Another NMSh window is archiving this session.'); return outcome.session; } diff --git a/src/session/startupRestore.ts b/src/session/startupRestore.ts index 99999ee1..a5a7e2f1 100644 --- a/src/session/startupRestore.ts +++ b/src/session/startupRestore.ts @@ -40,6 +40,7 @@ export async function restoreAtStartup(live: readonly SessionInfo[], deps: Start if (choice === 'always') deps.saveStartup('always'); if (choice === 'never') deps.saveStartup('never'); if (choice === 'resume' || choice === 'always') ids = [plan.session.id]; + // 'kill' was confirmed and performed by the prompt; the session is archived and this launch starts fresh. } const [target, ...others] = ids; if (others.length === 0) return target ? {target} : {}; diff --git a/tests/startupRestore.test.ts b/tests/startupRestore.test.ts index 07204e76..55647cba 100644 --- a/tests/startupRestore.test.ts +++ b/tests/startupRestore.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {planLaunch} from '../src/session/liveSessions.js'; -import {createMultiPicker, multiPickerKey, renderMultiPicker, renderSinglePrompt, singlePromptKey} from '../src/session/StartupPicker.js'; +import {askWithKill, createMultiPicker, killConfirmKey, pickWithKill, removeFromMultiPicker, multiPickerKey, renderMultiPicker, renderSinglePrompt, singlePromptKey} from '../src/session/StartupPicker.js'; import {restoreAtStartup, type StartupRestoreDeps} from '../src/session/startupRestore.js'; import {GHOSTTY_NEW_WINDOW_SCRIPT, detectTerminalHost, openWindows, shellQuote, type TerminalHost} from '../src/host/terminalHost.js'; import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration} from '../src/prompt/configuration.js'; @@ -40,13 +40,13 @@ test('launch plan follows the two settings; attached sessions are never candidat assert.equal(planLaunch(two, {startup: 'always', multiple: 'ask'}).kind, 'pick'); }); -test('one-session prompt: identifying details, and R/Enter, N/Esc, A, D; no destructive key', () => { +test('one-session prompt: identifying details, and R/Enter, N/Esc, A, D, and X to request a kill', () => { const session = info('a', 'detached', {cwd: '/w/project', running: 'vim notes.md', runningSince: 1_000}); const lines = renderSinglePrompt(session, 100, 181_000).join('\n'); assert.match(lines, /1 detached live session/); assert.match(lines, /\/w\/project/); assert.match(lines, /running vim notes\.md · 3m/); - assert.doesNotMatch(lines, /kill/i); + assert.match(lines, /X {2}Kill session/); assert.equal(singlePromptKey(key('enter')), 'resume'); assert.equal(singlePromptKey(text('r')), 'resume'); assert.equal(singlePromptKey(text('N')), 'not-now'); @@ -54,6 +54,7 @@ test('one-session prompt: identifying details, and R/Enter, N/Esc, A, D; no dest assert.equal(singlePromptKey(key('interrupt')), 'not-now'); assert.equal(singlePromptKey(text('a')), 'always'); assert.equal(singlePromptKey(text('d')), 'never'); + assert.equal(singlePromptKey(text('x')), 'kill'); assert.equal(singlePromptKey(text('k')), undefined); assert.equal(singlePromptKey(key('down')), undefined); }); @@ -79,7 +80,7 @@ test('multi picker: move, Space toggles, A selects all then clears, Enter resume assert.deepEqual(multiPickerKey(state, key('enter')), [], 'Enter with nothing selected resumes none'); multiPickerKey(state, text('a')); assert.deepEqual(multiPickerKey(state, key('escape')), [], 'Esc resumes none even with a selection'); - assert.equal(multiPickerKey(state, text('k')), undefined, 'no destructive key'); + assert.equal(multiPickerKey(state, text('k')), undefined, 'K is not a key'); }); test('host detection only offers new windows where the host supports it', () => { @@ -197,3 +198,95 @@ test('hosts that cannot open windows lose nothing: one attaches here, the rest a const failing = await restoreAtStartup(live, deps({policy: {startup: 'ask', multiple: 'open-all'}, spawner: async () => false})); assert.match(failing.notice ?? '', /could not be opened in new Ghostty windows[\s\S]*nmsh --attach s2/); }); + + +import type {KillResult} from '../src/session/StartupPicker.js'; +const three = () => [info('a', 'detached'), info('b', 'detached'), info('c', 'detached')]; + +test('multi picker: X only opens a confirmation; Esc, N and Ctrl+C cancel; Enter and Y confirm', () => { + for (const cancel of [key('escape'), text('n'), text('N'), key('interrupt')]) { + const state = createMultiPicker(three()); + assert.equal(multiPickerKey(state, text('x')), undefined, 'X alone kills nothing'); + assert.equal(state.confirming, 'a'); + assert.match(renderMultiPicker(state, 80, 5_000).join('\n'), /Kill detached session\?[\s\S]*remain available in \/resume[\s\S]*Enter\/Y kill · Esc\/N cancel/); + assert.equal(multiPickerKey(state, cancel), undefined); + assert.equal(state.confirming, undefined); assert.equal(state.sessions.length, 3); + } + for (const confirm of [key('enter'), text('y'), text('Y')]) { + const state = createMultiPicker(three()); state.cursor = 1; + multiPickerKey(state, text('X')); + assert.deepEqual(multiPickerKey(state, confirm), {kill: 'b'}); + } + const state = createMultiPicker(three()); + multiPickerKey(state, text('x')); + assert.equal(multiPickerKey(state, text(' ')), undefined, 'Space is inert while confirming'); + assert.equal(multiPickerKey(state, text('a')), undefined, 'A is inert while confirming'); + assert.equal(state.selected.size, 0); + assert.match(renderMultiPicker(createMultiPicker(three()), 100, 0).join('\n'), /↑↓ move · Space select · A all · X kill · Enter resume none · Esc none/); + assert.equal(killConfirmKey(text('k')), undefined); +}); + +test('removing a row keeps selection and cursor in bounds: first, middle and last', () => { + const make = (cursor: number) => { const s = createMultiPicker(three()); s.cursor = cursor; s.selected = new Set(['a', 'b', 'c']); return s; }; + const first = make(0); removeFromMultiPicker(first, 'a'); + assert.deepEqual([first.sessions.map(x => x.id), first.cursor, [...first.selected]], [['b', 'c'], 0, ['b', 'c']]); + const middle = make(1); removeFromMultiPicker(middle, 'b'); + assert.deepEqual([middle.sessions.map(x => x.id), middle.cursor, [...middle.selected]], [['a', 'c'], 1, ['a', 'c']], 'cursor now points at the next item'); + const last = make(2); removeFromMultiPicker(last, 'c'); + assert.deepEqual([last.sessions.map(x => x.id), last.cursor], [['a', 'b'], 1]); +}); + +test('pickWithKill: kills through the injected path, drops selection, and a final kill starts fresh', async () => { + const killed: string[] = []; + const script: Key[][] = [[text(' '), key('down'), text('x'), key('enter')], [text('x'), text('y')], [text('x'), text('y')]]; + const screens: string[] = []; + const run = async (render: (columns: number) => string[], onKey: (key: Key) => T | undefined): Promise => { + const keys = script.shift()!; + for (const k of keys) { screens.push(render(80).join('\n')); const result = onKey(k); if (result !== undefined) return result; } + throw new Error('script ended without a result'); + }; + const ids = await pickWithKill(three(), {run, kill: async session => { killed.push(session.id); return 'killed'; }, now: () => 5_000}); + assert.deepEqual(killed, ['b', 'c', 'a'], 'every kill went through the injected kill, in order'); + assert.deepEqual(ids, [], 'no sessions left: resolves with none so startup continues fresh'); + assert.ok(screens.some(screen => /Session ended; its transcript is in \/resume/.test(screen))); +}); + +test('pickWithKill: already-gone and attached-elsewhere sessions refresh safely; failures keep the row', async () => { + const outcomes: KillResult[] = ['gone', 'attached']; + let call = 0; + const run = async (_render: unknown, onKey: (key: Key) => T | undefined): Promise => { + const keys = call === 0 ? [text('x'), text('y')] : call === 1 ? [text('x'), key('enter')] : call === 2 ? [text('x'), text('y')] : [key('enter')]; + call += 1; + for (const k of keys) { const result = onKey(k); if (result !== undefined) return result; } + throw new Error('no result'); + }; + let attempts = 0; + const ids = await pickWithKill(three(), {run, now: () => 0, kill: async () => { + attempts += 1; + const outcome = outcomes.shift(); + if (outcome) return outcome; + throw new Error('permission denied'); + }}); + assert.equal(attempts, 3); + assert.deepEqual(ids, [], 'Enter with nothing selected; the failed row was kept, not lost'); +}); + +test('one-session prompt: X confirms before killing; cancel returns to the prompt; Enter is still Resume', async () => { + const session = info('a', 'detached'); + const killed: string[] = []; + const queue: Key[][] = [[text('x')], [key('escape')], [text('x')], [text('y')]]; + const run = async (_render: unknown, onKey: (key: Key) => T | undefined): Promise => { + for (const k of queue.shift()!) { const result = onKey(k); if (result !== undefined) return result; } + throw new Error('no result'); + }; + assert.equal(await askWithKill(session, {run, kill: async s => { killed.push(s.id); return 'killed'; }}), 'kill'); + assert.deepEqual(killed, ['a'], 'exactly one kill, only after the second confirmation'); + const resume = async (_render: unknown, onKey: (key: Key) => T | undefined): Promise => onKey(key('enter')) as T; + assert.equal(await askWithKill(session, {run: resume, kill: async () => { throw new Error('must not kill'); }}), 'resume'); +}); + +test('restoreAtStartup: a killed single session starts fresh and attaches nothing', async () => { + const deps: StartupRestoreDeps = {policy: {startup: 'ask', multiple: 'ask'}, saveStartup: () => assert.fail('no setting change'), + askOne: async () => 'kill', pick: async () => assert.fail('not used'), host: detectTerminalHost({}), selfCommand: ['nmsh']}; + assert.deepEqual(await restoreAtStartup([info('a', 'detached')], deps), {}); +}); From 383daff700895931a996ac12f93982e3c9a6b5f2 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:18:14 +0530 Subject: [PATCH 07/13] CI: install Fish on Ubuntu and add a Fedora portability subset job --- .github/workflows/ci.yml | 35 +++++++- .serena/.gitignore | 2 + .serena/project.yml | 171 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43cda7e8..dfddbbb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: - name: Install Linux shell and native build prerequisites if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y zsh build-essential python3 + run: sudo apt-get update && sudo apt-get install -y zsh fish build-essential python3 - name: Verify secure Linux system completion paths if: runner.os == 'Linux' @@ -88,3 +88,36 @@ jobs: exit 1 fi echo "No processes leaked." + + # One additional distribution for package-manager and libc/shell-packaging diversity (DNF, Fedora zsh/fish). + # A focused subset, not a matrix: the full suite runs on Ubuntu and macOS above. + fedora: + name: Linux portability subset (Fedora) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + container: fedora:42 + steps: + - name: Install system prerequisites + run: dnf install -y git zsh fish gcc-c++ make python3 which + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: 22.x + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Typecheck + run: npm run typecheck + + - name: Platform, package-manager, updater and shell-backend tests + run: node --import=tsx --test --test-timeout=120000 tests/linuxPlatform.test.ts tests/clipboard.test.ts tests/tools.test.ts tests/update.test.ts tests/hostProfiles.test.ts tests/portabilityUninstall.test.ts + env: + NMSH_DISABLE_UPDATES: '1' diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 00000000..2e510aff --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 00000000..31d57672 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,171 @@ +# the name by which the project can be referenced within Serena/when chatting with the LLM. +project_name: "notMyShell" + +# list of language servers to start when using the LSP backend; choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart deno +# elixir elm erlang fortran fsharp +# gdscript gleam go groovy haskell +# haxe hlsl html java json +# julia julia_fatou kotlin latex lean4 +# lua luau markdown matlab msl +# nextflow nix ocaml pascal perl +# php php_phpactor php_phpantom powershell python +# python_basedpyright python_jedi python_pyrefly python_ty qml +# r rego ruby ruby_solargraph rust +# scala scss solidity svelte swift +# systemverilog terraform toml typescript typescript_vts +# vue wolfram yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of the LanguageServerId enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# - External Python adapters may add further registered IDs; install the adapter package first +# and then use its ID here, for example: example +# Special requirements: +# Some language servers require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple language servers, the first language server that supports a given file will be used for that file. +# The first language server is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +language_servers: +- typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings +ls_specific_settings: {} + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- "." + +# list of additional workspace folder paths for cross-package reference support. +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +ls_additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. +# Example: +# ignored_paths: +# - "examples/**" +# - ".worktrees/**" +# - "**/bin/**" +# - "**/obj/**" +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] From aafa96b47ad103ceb344f8b6a0b1b3901a7c8861 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:18:55 +0530 Subject: [PATCH 08/13] Document v0.17 support levels, roadmap and changelog --- CHANGELOG.md | 15 +++++++++++++++ README.md | 9 ++++++--- ROADMAP.md | 29 ++++++++++++++++++----------- docs/architecture/platforms.md | 17 ++++++++++++++++- 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bce64ba1..1d44a90d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Contextual tools +- `/tools` Discover shows a conservative **Relevant here** group from cheap local facts (Git repository, shell scripts, JavaScript/Node, Python, Go, Rust, container files, Kubernetes files or kubeconfig) and each tool's declared relevance. Only missing tools appear; nothing is executed, crawled or sent anywhere. +- Typed package-manager plans for Homebrew, APT, DNF, pacman and zypper (WSL uses the distribution's manager). Tools without a verified package name stay manual. Non-root plans are explicit `sudo -n` argv; nothing elevates silently. +- Bulk install: Space selects missing installable tools, Enter reviews a tool → manager → package plan, one confirmation (default No), per-tool results. +- Tool details separate Installed, selected in NMSh and **Active in this shell** (zoxide, Atuin, fzf), from the running shell's name snapshot for all three shells; rc files are never read. + +### Compatibility +- Windows Terminal (seen from WSL), iTerm2 and WezTerm host facts and new-window launchers behind the host boundary; deterministic multiplexer/`TERM=dumb` degradation tests for every profile. Physical validation in these hosts is not claimed. +- Linux: `xdg-open`/`wslview` open helper, clipboard fallback-order tests, Fish installed in Ubuntu CI and a Fedora portability job. +- Regression coverage that raw job-control, EOF and arrow bytes reach full-screen programs. + +### 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`. + ## [0.16.0] - 2026-10-04 Sessions, Agents & Portability: a cumulative release of the work formerly planned as v0.8–v0.15, together with v0.16. diff --git a/README.md b/README.md index 8293f887..0d993214 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ See [ROADMAP.md](ROADMAP.md) for planned work (Nushell and native Windows are la ## Installation **Prerequisites:** -- macOS, or Linux (automated tests pass; physical terminal validation is pending), or Windows through WSL 2 (see [platforms](docs/architecture/platforms.md)) +- macOS, or Linux (beta: tested in CI on Ubuntu and Fedora; not physically validated), or Windows through WSL 2 (see [platforms](docs/architecture/platforms.md)) - Node.js (v22+) - zsh (Fish and Bash 4.4+ are optional additional backends) - A compatible terminal host: an integrated terminal (such as Zed or VS Code) or a standalone terminal (such as Ghostty or macOS Terminal) @@ -231,8 +231,11 @@ Keep your terminal. Keep your shell. Upgrade the interaction layer. NMSh is inte | VS Code | Integrated | `Shift+Enter` may require custom `keybindings.json` forwarding. Opacity/blur controls are not applicable. | | Ghostty | Standalone | `/keyboard` and `/appearance` integration, new windows for sessions. | | macOS Terminal | Standalone | Shift+Enter works out of the box; keyboard scrolling (PageUp/PageDown). | -| Kitty | Supported profile | Kitty keyboard protocol and mouse reporting; new windows need `allow_remote_control`. | -| iTerm2, WezTerm | Supported profiles | Capability profiles; not yet physically validated to the same level. | +| Kitty | Capability profile (CI fixtures) | Kitty keyboard protocol, mouse reporting and graphics are assumed only from the profile plus protocol replies; new windows need `allow_remote_control`. Not physically validated. | +| iTerm2 | Capability profile (CI fixtures) | Mouse, hyperlinks and image protocol from the profile; new windows through AppleScript. Not physically validated. | +| WezTerm | Capability profile (CI fixtures) | Same as iTerm2 for images; new windows through `wezterm cli spawn`. Not physically validated. | +| Windows Terminal (via WSL) | Capability profile (CI fixtures) | Detected from `WT_SESSION`; mouse, hyperlinks and truecolor only. Not physically validated. | +| Unknown or embedded hosts | Generic | Baseline capabilities, upgraded only by protocol replies. | NMSh owns terminal-native interaction; the editor around it owns editor-native interaction. `/find` and `/filter` search and filter the transcript; `/open path:line:col` and `/open-diff a b` hand files to Zed or VS Code (or `$VISUAL`/`$EDITOR`) instead of rebuilding an editor inside the terminal. See [product boundary and HostActions](docs/architecture/host-actions.md). Inline images (`/about`) appear only where the host implements Kitty graphics or iTerm2 images ([image surface](docs/architecture/image-surface.md)). diff --git a/ROADMAP.md b/ROADMAP.md index b2d6be8b..26466221 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ | | | |---|---| | **Current release** | [v0.16.0 — Sessions, Agents & Portability](https://github.com/raiseCatError/notMyShell/releases/tag/v0.16.0) — released | -| **Next development milestone** | [v0.17.0 — Host Compatibility & Tool Discovery](https://github.com/raiseCatError/notMyShell/milestone/15) | +| **Next development milestone** | [v0.17.0 — Compatibility & Discovery](https://github.com/raiseCatError/notMyShell/milestone/15) | | **Unscheduled** | [Future / Backlog](https://github.com/raiseCatError/notMyShell/milestone/7) | | **Development branch** | `dev` | | **Project board** | [NMSh Development](https://github.com/users/raiseCatError/projects/1) | @@ -129,17 +129,24 @@ The work formerly planned as v0.8–v0.15 ships together with v0.16 in one cumul | Ask & Local Understanding | Deterministic Ask, optional local model (Auto by default, never downloads without a Yes), `/llm` | | Appearance | `/appearance` hub; Clean and Rich motion rendering with per-rendering tuning; cursor effects with a capability matrix; Setup Cat as the complete customization entry point; grouped Config and Status | -## Next development — v0.17 Host Compatibility & Tool Discovery +## Next development — v0.17 Compatibility & Discovery -[Milestone #15](https://github.com/raiseCatError/notMyShell/milestone/15): physical validation and discovery follow-ups, none of which block v0.16. +[Milestone #15](https://github.com/raiseCatError/notMyShell/milestone/15). Code and automated coverage for `Relevant here`, package-manager plans with bulk install, activation detection, host profiles, Linux/WSL hardening, automatic update staging and startup session kill. Physical validation in iTerm2, Kitty, WezTerm, Linux and WSL 2 is tracked as a separate `needs-human-test` follow-up and does not block the release. -| Issue | Remaining scope | +| Issue | Disposition | |---|---| -| [#9](https://github.com/raiseCatError/notMyShell/issues/9) | Contextual `Relevant here` tool discovery from local facts, more package managers, active-hook detection | -| [#13](https://github.com/raiseCatError/notMyShell/issues/13) | Physical passes in iTerm2, Kitty and WezTerm | -| [#14](https://github.com/raiseCatError/notMyShell/issues/14) | Physical CLI/TUI interoperability runs (ongoing) | -| [#15](https://github.com/raiseCatError/notMyShell/issues/15) | Manual pass inside Supacode or a similar agent-oriented host | -| [#18](https://github.com/raiseCatError/notMyShell/issues/18) | Physical validation on real Linux and WSL 2 | +| [#9](https://github.com/raiseCatError/notMyShell/issues/9) | Implemented in v0.17 | +| [#13](https://github.com/raiseCatError/notMyShell/issues/13) | Code and fixtures in v0.17; physical pass moved to a follow-up | +| [#14](https://github.com/raiseCatError/notMyShell/issues/14) | Maintainer exercised real CLI/TUI use; deterministic suite extended; ongoing regression tracking | +| [#15](https://github.com/raiseCatError/notMyShell/issues/15) | Moved to Future / Backlog; NMSh already degrades by capability in unknown embedded hosts | +| [#18](https://github.com/raiseCatError/notMyShell/issues/18) | Code and CI in v0.17; physical Linux/WSL pass moved to a follow-up | + +## Planned releases + +| Release | Theme | Tracker | +|---|---|---| +| v0.18.0 | Theme Bridge and semantic terminal integration | [#304](https://github.com/raiseCatError/notMyShell/issues/304), delivered in focused slices | +| v0.19.0 | NMSh Native module ecosystem: architecture, lazy probes, caching and provenance first, then a bounded high-value module set (not the whole catalog) | [#305](https://github.com/raiseCatError/notMyShell/issues/305) | ## Backlog — future, unscheduled @@ -147,8 +154,8 @@ The work formerly planned as v0.8–v0.15 ships together with v0.16 in one cumul | Issue | Title | |---|---| -| [#305](https://github.com/raiseCatError/notMyShell/issues/305) | NMSh Native module ecosystem and upstream module ports (also carries the user-defined prompt modules direction from the closed research issue #73) | -| [#304](https://github.com/raiseCatError/notMyShell/issues/304) | Theme Bridge and semantic terminal integration | +| [#305](https://github.com/raiseCatError/notMyShell/issues/305) | (planned v0.19) NMSh Native module ecosystem and upstream module ports (also carries the user-defined prompt modules direction from the closed research issue #73) | +| [#304](https://github.com/raiseCatError/notMyShell/issues/304) | (planned v0.18) Theme Bridge and semantic terminal integration | | [#296](https://github.com/raiseCatError/notMyShell/issues/296) | Hosted SSH demo (post-1.0) | | [#20](https://github.com/raiseCatError/notMyShell/issues/20) | Ongoing polish triage index | diff --git a/docs/architecture/platforms.md b/docs/architecture/platforms.md index d9a2be50..727bbaa7 100644 --- a/docs/architecture/platforms.md +++ b/docs/architecture/platforms.md @@ -28,7 +28,8 @@ means WSL 2; `4.4.0--Microsoft` means WSL 1), `/proc/version`, then | Shell discovery | zsh: system paths then PATH. Fish: PATH only. Bash: PATH then system, version ≥ 4.4. | | Clipboard | `wl-copy` (Wayland) or `xclip`/`xsel` (X11). WSLg provides Wayland; without it `/copy` explains what is missing. | | Notifications | `notify-send` when installed and a desktop session (D-Bus, Wayland or X11) is reachable; otherwise unsupported, as before. | -| Homebrew | Only used where present (Linuxbrew included); distribution package names are never guessed. | +| Package managers (v0.17) | `/tools` builds typed executable + argv plans for APT, DNF, pacman, zypper (chosen from `/etc/os-release` ID/ID_LIKE, WSL included) and Homebrew. Only curated, factual package names have a plan; everything else is shown as manual. Non-root plans are explicitly `sudo -n …` (never a password prompt) and name the command to run yourself if sudo needs one. | +| Open helper (v0.17) | `xdg-open`, then `wslview`; neither is required. | | Install/uninstall | Same source-checkout + `npm link` model; `nmsh uninstall` works from XDG paths and refuses while live sessions run. | | Config transfer | Exports contain no host paths, so they move between macOS and Linux. | | Agent detection, history | Platform-independent. | @@ -36,3 +37,17 @@ means WSL 2; `4.4.0--Microsoft` means WSL 1), `/proc/version`, then A container without a UTF-8 locale makes zsh treat non-ASCII names as non-alphanumeric; one existing zsh test depends on a UTF-8 locale, as CI has. + +## Evidence levels (v0.17) + +These are different claims and are not interchangeable. + +| Platform | Supported | Tested in CI | Physically validated | Notes | +|---|---|---|---|---| +| macOS | Primary | Yes (macOS runner) | Yes (maintainer) | | +| Linux | Beta | Ubuntu 24.04 (full suite; zsh, Fish and Bash installed) and a Fedora subset | No | Package-manager plans, clipboard/notification/open fallbacks and XDG paths run from deterministic fixtures. | +| WSL 2 | Beta, documented limits | Fixtures only (detection, distro, Windows Terminal facts via `WT_SESSION`, mount handling, fallbacks); no WSL runner | No | NMSh inside WSL is a Linux environment. | +| WSL 1 | Detected and reported, not supported | Fixtures | No | | +| Native Windows | Unsupported | n/a | n/a | | + +Package-name tables for APT, DNF, pacman and zypper are curated from distribution naming and covered by unit tests; they were not installed on real machines in this release. From c9f7ffda6a0489cd7792ce7f58e3d0b824d0d3ff Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:25:08 +0530 Subject: [PATCH 09/13] CI: give the Fedora subset lsof, ps and truecolor like the canonical runner --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfddbbb0..820f568e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,7 @@ jobs: container: fedora:42 steps: - name: Install system prerequisites - run: dnf install -y git zsh fish gcc-c++ make python3 which + run: dnf install -y git zsh fish gcc-c++ make python3 which lsof procps-ng - name: Checkout repository uses: actions/checkout@v4 @@ -121,3 +121,4 @@ jobs: run: node --import=tsx --test --test-timeout=120000 tests/linuxPlatform.test.ts tests/clipboard.test.ts tests/tools.test.ts tests/update.test.ts tests/hostProfiles.test.ts tests/portabilityUninstall.test.ts env: NMSH_DISABLE_UPDATES: '1' + COLORTERM: truecolor From f6c568b6d0be004b81c8926c4540413706564209 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:33:21 +0530 Subject: [PATCH 10/13] Add screen-based savers: Black Hole, Fireworks, Circletastic and raiseCatError --- CHANGELOG.md | 3 + src/app/TerminalApp.ts | 75 +++- src/idle/IdleVisuals.ts | 5 +- src/idle/scenes.ts | 33 +- src/idle/screenCapture.ts | 85 ++++ src/idle/screenEffects.ts | 729 +++++++++++++++++++++++++++++++ src/prompt/configuration.ts | 7 +- src/terminal/TerminalRenderer.ts | 5 + src/ui/SettingsPanel.ts | 2 + tests/idleVisuals.test.ts | 6 +- tests/screenSavers.test.ts | 331 ++++++++++++++ 11 files changed, 1258 insertions(+), 23 deletions(-) create mode 100644 src/idle/screenCapture.ts create mode 100644 src/idle/screenEffects.ts create mode 100644 tests/screenSavers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d44a90d..8c36bdfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Linux: `xdg-open`/`wslview` open helper, clipboard fallback-order tests, Fish installed in Ubuntu CI and a Fedora portability job. - Regression coverage that raw job-control, EOF and arrow bytes reach full-screen programs. +### 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. + ### 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`. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 39c81c9f..2341d1e3 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -25,7 +25,10 @@ import {fits, renderTooSmall, type MinimumSize} from '../ui/Modal.js'; import {CellGrid} from '../idle/CellGrid.js'; import {IDLE_FRAME_MS, type IdleMode} from '../idle/scenes.js'; import {createScreensaverPanel, effectiveMode, idleFrameRows, idleMotion, idlePaletteFor, previewSize, renderScreensaverPanel, sceneTime, - SCREENSAVER_MIN_SIZE, screensaverKey, type ScreensaverPanelState} from '../idle/IdleVisuals.js'; + SCREENSAVER_MIN_SIZE, screensaverKey, IDLE_SEED, type ScreensaverPanelState} from '../idle/IdleVisuals.js'; +import {captureFromRows, cropCapture, type ScreenCapture} from '../idle/screenCapture.js'; +import {effectLoops, randomSequence} from '../idle/screenEffects.js'; +import {SCREEN_MODE_EFFECT} 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, type InstallPromptState} from '../tools/InstallSuggestion.js'; @@ -383,7 +386,12 @@ export class TerminalApp { private toolsPanel?: ToolsPanel; /** Idle visuals: one inactivity timer while armed, one frame subscription while showing; neither exists otherwise. */ private idleTimer?: NodeJS.Timeout; - private idle?: {mode: IdleMode; startedAt: number; frame: number; interval: number; preview: boolean; paused: boolean; still: boolean}; + private idle?: {mode: IdleMode; startedAt: number; frame: number; interval: number; preview: boolean; paused: boolean; still: boolean; capture?: ScreenCapture; random?: boolean; offset: number}; + /** Snapshot of the screen taken when the screensaver gallery opened: the gallery preview's material. */ + private saverCapture?: ScreenCapture; + private saverGalleryCapture?: ScreenCapture; + private saverGalleryMode?: IdleMode; + private randomSaver = randomSequence(0x5eed); private idleSubscription?: () => void; private readonly idleGrid = new CellGrid(); private lastActivity = Date.now(); @@ -1964,7 +1972,7 @@ export class TerminalApp { if (slash.start) { if (slash.mode) this.applySettingsConfiguration({...this.promptConfiguration, idleVisuals: {...this.promptConfiguration.idleVisuals, mode: slash.mode}}); this.startIdle(true); - } else this.screensaverPanel = createScreensaverPanel(Date.now()); + } else this.openScreensaverGallery(); } // The cursor has one configuration: /cursor opens its existing Settings rows. else if (slash.kind === 'cursor') this.openCursorPanel(); @@ -3463,7 +3471,7 @@ export class TerminalApp { private openDestinationPanel(destination: SettingsDestination): void { if (destination === 'tools') this.startTools(); else if (destination === 'setup') this.startSetup(); - else if (destination === 'screensaver') this.screensaverPanel = createScreensaverPanel(Date.now()); + else if (destination === 'screensaver') this.openScreensaverGallery(); else if (destination === 'chromeColors') { const config = this.promptConfiguration; this.chromeEditor = createChromeEditor(config.uiChrome.colors ?? chromeColorsFrom(resolveChrome({...config.uiChrome, source: 'theme'}, config.nmsh.palette, config.nmsh.accent, config.customTheme))); @@ -3659,14 +3667,17 @@ export class TerminalApp { */ private idleEligible(): boolean { return !this.stopped && this.presentationStarted && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended - && !this.running && !this.startupPending && !this.settingsPanelActive && !this.paletteState && !this.presetStartup?.active - && !this.pickerOpening; + && (!this.running || this.promptConfiguration.idleVisuals.runWhileBusy) && !this.startupPending && !this.settingsPanelActive && !this.paletteState && !this.presetStartup?.active + && !this.pickerOpening && !this.updateInProgress && !this.agents.sessions.some(session => session.attention); } private onIdleTimeout(): void { const minutes = this.promptConfiguration.idleVisuals.timeout; if (!minutes) return; if (Date.now() - this.lastActivity < minutes * 60_000 - 50) { this.armIdle(); return; } + // Reduced Motion: screen savers never start by themselves (a manual preview still works). + const screenMode = SCREEN_MODE_EFFECT[this.promptConfiguration.idleVisuals.mode] !== undefined || this.promptConfiguration.idleVisuals.mode === 'random'; + if (screenMode && idleMotion(this.promptConfiguration).still) { this.lastActivity = Date.now(); this.armIdle(); return; } if (this.idleEligible()) this.startIdle(false); else { this.lastActivity = Date.now(); this.armIdle(); } } @@ -3680,20 +3691,44 @@ export class TerminalApp { } if (this.stopped || this.passthrough || this.externalPassthrough || this.frontendSuspended || this.idle) return; if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = undefined; } + const configured = effectiveMode(this.promptConfiguration.idleVisuals.mode, motion); + // The screen as the user sees it right now (or, from the gallery, as it was before the gallery opened). + const wantsCapture = configured === 'random' || SCREEN_MODE_EFFECT[configured] !== undefined; + const captureDimensions = this.dimensions(); + const captured = wantsCapture + ? (this.screensaverPanel && this.saverCapture ? this.saverCapture : this.captureScreen(captureDimensions.columns, captureDimensions.rows)) : undefined; // Exclusive ownership: every other presentation owner stops before the first idle frame. // The gallery (if any) stays open underneath and resumes its own preview on dismissal. this.suspendPresentationOwners(); - const mode = effectiveMode(this.promptConfiguration.idleVisuals.mode, motion); - this.idle = {mode, startedAt: Date.now(), frame: 0, interval: IDLE_FRAME_MS[mode], preview, paused: false, still: motion.still}; + const random = configured === 'random'; + const mode: IdleMode = random ? this.pickRandomSaver() : configured; + this.idle = {mode, startedAt: Date.now(), frame: 0, interval: IDLE_FRAME_MS[mode], preview, paused: false, still: motion.still, + ...(captured ? {capture: {...captured, instances: {}}} : {}), random, offset: 0}; this.renderer.invalidate(); this.paintIdle(); if (!motion.still) this.idleSubscription = presentationClock.subscribe(now => this.tickIdle(now), this.idle.interval); } + private captureScreen(columns: number, rows: number): ScreenCapture { + return captureFromRows(this.renderer.snapshot(), columns, rows, isDeterministicPresentation() ? IDLE_SEED : (Date.now() ^ (process.pid << 8)) >>> 0); + } + + private pickRandomSaver(previous?: IdleMode): IdleMode { + const effect = this.randomSaver(previous ? SCREEN_MODE_EFFECT[previous] : undefined); + return (Object.entries(SCREEN_MODE_EFFECT).find(([, id]) => id === effect)?.[0] ?? 'blackHole') as IdleMode; + } + private tickIdle(now: number): void { const idle = this.idle; if (!idle || idle.paused) return; idle.frame += 1; + // Random switches only when the current effect finished a full loop, never mid-effect. + const effect = SCREEN_MODE_EFFECT[idle.mode]; + if (idle.random && idle.capture && effect && effectLoops(idle.capture, effect) >= 1) { + idle.mode = this.pickRandomSaver(idle.mode); + idle.capture = {...idle.capture, instances: {}}; + idle.offset = isDeterministicPresentation() ? idle.frame * IDLE_FRAME_MS[idle.mode] : Date.now() - idle.startedAt; + } const started = performance.now(); this.paintIdle(now); // Adaptive cadence: a frame that costs too much slows the scene instead of the shell. @@ -3710,8 +3745,8 @@ export class TerminalApp { if (!idle) return; const {columns, rows} = this.dimensions(); const time = idle.still ? 20_000 : sceneTime(now - idle.startedAt, idle.frame, idle.mode); - const frame = idleFrameRows(this.idleGrid, {mode: idle.mode, width: columns, height: rows, time, - palette: idlePaletteFor(this.promptConfiguration), level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd'}); + const frame = idleFrameRows(this.idleGrid, {mode: idle.mode, width: columns, height: rows, time: Math.max(0, time - idle.offset), + palette: idlePaletteFor(this.promptConfiguration), level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', ...(idle.capture ? {capture: idle.capture} : {})}); try { this.renderer.render({rows: frame, columns, cursorRow: 1, cursorColumn: 1, cursorVisible: false}); } catch (error) { this.onTerminate(); throw error; } } @@ -4053,6 +4088,13 @@ export class TerminalApp { /** Recommended-tool install state for Setup Cat's tools preview, detected once off the render path. */ private readonly toolStatuses = new Map(); + private openScreensaverGallery(): void { + const {columns, rows} = this.dimensions(); + this.saverCapture = this.captureScreen(columns, rows); + this.saverGalleryCapture = undefined; + this.screensaverPanel = createScreensaverPanel(Date.now()); + } + private renderScreensaverRows(state: ScreensaverPanelState, columns: number): string[] { const {rows} = this.dimensions(); const settings = this.promptConfiguration.idleVisuals; @@ -4060,9 +4102,18 @@ export class TerminalApp { const size = previewSize(columns, rows); const mode = effectiveMode(settings.mode, motion); const elapsed = Date.now() - state.startedAt; - const preview = motion.disabled ? [] : idleFrameRows(this.screensaverGrid, {mode, width: size.width, height: size.height, + // Screen savers preview over the real screen snapshot taken when the gallery opened (cropped to the preview box). + const previewMode: IdleMode = mode === 'random' ? 'blackHole' : mode; + let capture: ScreenCapture | undefined; + if (SCREEN_MODE_EFFECT[previewMode] && this.saverCapture) { + if (!this.saverGalleryCapture || this.saverGalleryCapture.width !== Math.min(size.width, this.saverCapture.width) || this.saverGalleryCapture.height !== Math.min(size.height, this.saverCapture.height) || this.saverGalleryMode !== previewMode) { + this.saverGalleryCapture = cropCapture(this.saverCapture, size.width, size.height); this.saverGalleryMode = previewMode; + } + capture = this.saverGalleryCapture; + } + const preview = motion.disabled ? [] : idleFrameRows(this.screensaverGrid, {mode: previewMode, width: capture?.width ?? size.width, height: capture?.height ?? size.height, time: motion.still ? 20_000 : sceneTime(elapsed, Math.floor(elapsed / IDLE_FRAME_MS[mode]), mode), - palette: idlePaletteFor(this.promptConfiguration), level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd'}); + palette: idlePaletteFor(this.promptConfiguration), level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', ...(capture ? {capture} : {})}); // A real animated preview while the gallery is open; one timer, removed with the panel. if (!motion.still && !motion.disabled && !this.screensaverAnimation) { this.screensaverAnimation = presentationClock.subscribe(() => { if (this.screensaverPanel) this.render(); }, Math.max(120, IDLE_FRAME_MS[mode])); diff --git a/src/idle/IdleVisuals.ts b/src/idle/IdleVisuals.ts index 9cd99b3a..f1951c06 100644 --- a/src/idle/IdleVisuals.ts +++ b/src/idle/IdleVisuals.ts @@ -8,6 +8,7 @@ import {themeChromaStops} from '../prompt/prompt.js'; import {isDeterministicPresentation} from '../presentation/environment.js'; import type {ColorLevel} from '../presentation/capabilities.js'; import {CellGrid} from './CellGrid.js'; +import type {ScreenCapture} from './screenCapture.js'; import { HIGH_MOTION, IDLE_FRAME_MS, IDLE_MODE_LABELS, IDLE_MODE_NOTES, IDLE_MODES, idlePalette, reducedMotionScene, renderScene, type IdleMode, type IdlePalette, @@ -79,12 +80,14 @@ export function sceneTime(elapsedMs: number, frame: number, mode: IdleMode, env: export interface IdleFrameInput { mode: IdleMode; width: number; height: number; time: number; palette: IdlePalette; level: ColorLevel; nerd: boolean; + /** The captured visible screen, for screen-saver modes. */ + capture?: ScreenCapture; } /** One frame as terminal rows, through the shared grid. */ export function idleFrameRows(grid: CellGrid, input: IdleFrameInput): string[] { grid.resize(input.width, input.height); - renderScene(input.mode, grid, {time: input.time, seed: IDLE_SEED, palette: input.palette, level: input.level, nerd: input.nerd}); + renderScene(input.mode, grid, {time: input.time, seed: IDLE_SEED, palette: input.palette, level: input.level, nerd: input.nerd, ...(input.capture ? {capture: input.capture} : {})}); return grid.toRows(input.level); } diff --git a/src/idle/scenes.ts b/src/idle/scenes.ts index 341c693f..24b4c6fd 100644 --- a/src/idle/scenes.ts +++ b/src/idle/scenes.ts @@ -2,6 +2,9 @@ import {CellGrid, mixPacked, NO_COLOR_VALUE, pack} from './CellGrid.js'; import {fromOklch, toOklch} from '../chroma/color.js'; import type {Rgb} from '../chroma/escape.js'; import type {ColorLevel} from '../presentation/capabilities.js'; +import {UI_COLORS} from '../ui/palette.js'; +import type {ScreenCapture} from './screenCapture.js'; +import {renderScreenEffect, type ScreenEffectId} from './screenEffects.js'; /** * Idle visuals: deterministic, bounded, terminal-native scenes. Every scene is @@ -9,11 +12,12 @@ import type {ColorLevel} from '../presentation/capabilities.js'; * clock, no allocation beyond the reused CellGrid. Motion is calm (no rapid * flashing) and particle counts are capped independently of terminal size. */ -export const IDLE_MODES = ['aurora', 'deepSpace', 'warp', 'rain', 'sparkles', 'fireworks', 'vespyr'] as const; +export const IDLE_MODES = ['aurora', 'deepSpace', 'warp', 'rain', 'sparkles', 'fireworks', 'vespyr', 'random', 'blackHole', 'screenFireworks', 'circletastic', 'raiseCatError'] as const; export type IdleMode = typeof IDLE_MODES[number]; export const IDLE_MODE_LABELS: Record = { aurora: 'Aurora Drift', deepSpace: 'Deep Space', warp: 'Warp Starfield', rain: 'Rain', sparkles: 'Sparkles', - fireworks: 'Fireworks', vespyr: 'Bouncing Vespyr', + fireworks: 'Night Fireworks', vespyr: 'Bouncing Vespyr', + random: 'Random', blackHole: 'Black Hole', screenFireworks: 'Fireworks', circletastic: 'Circletastic', raiseCatError: 'raiseCatError', }; export const IDLE_MODE_NOTES: Record = { aurora: 'slow aurora curtains over a starry night sky', @@ -23,13 +27,21 @@ export const IDLE_MODE_NOTES: Record = { sparkles: 'sparse sparkles that brighten and fade', fireworks: 'occasional bursts with gentle gravity', vespyr: 'Vespyr the NMSh cat bouncing around', + random: 'one of the four screen savers below, changing only after a full loop', + blackHole: 'your screen text spirals into a black hole, then rebuilds', + screenFireworks: 'shells launch across your screen and burst through its text', + circletastic: 'your screen text gathers into spinning rings, flies apart, and reforms', + raiseCatError: 'a cat wanders your screen and raises silly, fictional errors about what it finds', }; /** Calm scenes repaint less often; the ceiling is 10 frames per second. */ export const IDLE_FRAME_MS: Record = { aurora: 160, deepSpace: 250, warp: 100, rain: 100, sparkles: 150, fireworks: 100, vespyr: 125, + random: 66, blackHole: 66, screenFireworks: 66, circletastic: 66, raiseCatError: 66, }; /** Modes that become a still calm field under Reduced Motion. */ -export const HIGH_MOTION: ReadonlySet = new Set(['warp', 'rain', 'sparkles', 'fireworks', 'vespyr']); +export const HIGH_MOTION: ReadonlySet = new Set(['warp', 'rain', 'sparkles', 'fireworks', 'vespyr', 'random', 'blackHole', 'screenFireworks', 'circletastic', 'raiseCatError']); +/** Modes that animate a capture of the visible screen. */ +export const SCREEN_MODE_EFFECT: Partial> = {blackHole: 'blackHole', screenFireworks: 'fireworks', circletastic: 'circletastic', raiseCatError: 'raiseCatError'}; export interface IdlePalette { /** Accent stops, packed 0xRRGGBB. */ @@ -49,6 +61,8 @@ export interface SceneOptions { level: ColorLevel; /** Nerd/Unicode glyphs, or Safe ASCII-leaning glyphs. */ nerd: boolean; + /** The captured visible screen; present for screen-saver modes. */ + capture?: ScreenCapture; } /** Palette for idle visuals from accent stops: a dark sky tinted toward their hue. */ @@ -493,7 +507,18 @@ function vespyr(grid: CellGrid, options: SceneOptions): void { } } -const SCENES: Record void> = {aurora, deepSpace, warp, rain, sparkles, fireworks, vespyr}; +function screenScene(effect: ScreenEffectId) { + return (grid: CellGrid, options: SceneOptions): void => { + // Without a capture there is nothing of the user's to animate: show the calm star field instead. + if (!options.capture) { deepSpace(grid, options); return; } + const {palette} = options; + renderScreenEffect(effect, grid, options.capture, {time: options.time, nerd: options.nerd, color: options.level !== 'none', + palette: {stops: palette.stops, text: 0xc8c8d4, error: pack(UI_COLORS.failure), warn: 0xe5c07b, accent: palette.stops[0] ?? 0xa67cf3}}); + }; +} + +const SCENES: Record void> = {aurora, deepSpace, warp, rain, sparkles, fireworks, vespyr, + random: screenScene('blackHole'), blackHole: screenScene('blackHole'), screenFireworks: screenScene('fireworks'), circletastic: screenScene('circletastic'), raiseCatError: screenScene('raiseCatError')}; /** Paint one frame of `mode` into `grid`. */ export function renderScene(mode: IdleMode, grid: CellGrid, options: SceneOptions): void { diff --git a/src/idle/screenCapture.ts b/src/idle/screenCapture.ts new file mode 100644 index 00000000..ceaa2bbd --- /dev/null +++ b/src/idle/screenCapture.ts @@ -0,0 +1,85 @@ +import {NO_COLOR_VALUE, pack} from './CellGrid.js'; +import {stripAnsi} from '../util/text.js'; +import {displayWidth} from '../util/text.js'; + +/** + * One captured visual frame: what the user is looking at when a screen saver + * starts, as cells (glyph + packed foreground). Presentation input only: it is + * parsed from the rows the renderer last wrote and never feeds back into the + * transcript, PTY, history or journals. Backgrounds are deliberately dropped, + * so effects sit on the host's own (possibly transparent) background. + */ +export interface ScreenCapture { + width: number; + height: number; + glyphs: string[]; + fg: Int32Array; + /** Per-effect state, created lazily by the effect and discarded with the capture. */ + instances: Record; + seed: number; +} + +const BASIC = [0x000000, 0xcd3131, 0x0dbc79, 0xe5e510, 0x2472c8, 0xbc3fbc, 0x11a8cd, 0xe5e5e5, + 0x666666, 0xf14c4c, 0x23d18b, 0xf5f543, 0x3b8eea, 0xd670d6, 0x29b8db, 0xffffff]; + +function xterm256(n: number): number { + if (n < 16) return BASIC[n]!; + if (n >= 232) { const v = 8 + (n - 232) * 10; return pack({red: v, green: v, blue: v}); } + const k = n - 16; const level = (i: number) => (i === 0 ? 0 : 55 + i * 40); + return pack({red: level(Math.floor(k / 36)), green: level(Math.floor(k / 6) % 6), blue: level(k % 6)}); +} + +/** Parse SGR foreground colors from rendered rows; every other escape is dropped. */ +export function captureFromRows(rows: readonly string[], width: number, height: number, seed: number): ScreenCapture { + const glyphs = new Array(width * height).fill(' '); + const fg = new Int32Array(width * height).fill(NO_COLOR_VALUE); + for (let y = 0; y < Math.min(height, rows.length); y += 1) { + let color = NO_COLOR_VALUE; + let x = 0; + const line = rows[y]!.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/gu, ''); + for (let i = 0; i < line.length && x < width;) { + if (line[i] === '\u001b' && line[i + 1] === '[') { + const end = line.slice(i + 2).search(/[@-~]/u); + if (end < 0) break; + const final = line[i + 2 + end]!; + if (final === 'm') { + const p = line.slice(i + 2, i + 2 + end).split(';').map(part => Number(part) || 0); + for (let k = 0; k < p.length; k += 1) { + const code = p[k]!; + if (code === 0 || code === 39) color = NO_COLOR_VALUE; + else if (code >= 30 && code <= 37) color = BASIC[code - 30]!; + else if (code >= 90 && code <= 97) color = BASIC[code - 90 + 8]!; + else if (code === 38 && p[k + 1] === 2) { color = pack({red: p[k + 2] ?? 0, green: p[k + 3] ?? 0, blue: p[k + 4] ?? 0}); k += 4; } + else if (code === 38 && p[k + 1] === 5) { color = xterm256(p[k + 2] ?? 0); k += 2; } + } + } + i += end + 3; + continue; + } + const point = String.fromCodePoint(line.codePointAt(i)!); + i += point.length; + if (/[\u0000-\u001f\u007f]/u.test(point)) continue; + const cells = Math.max(1, displayWidth(point)); + const index = y * width + x; + if (point !== ' ') { glyphs[index] = point; fg[index] = color; } + // A wide glyph owns its second cell; leave it blank so nothing else is drawn through it. + x += cells; + } + } + return {width, height, glyphs, fg, instances: {}, seed}; +} + +/** A smaller view for the gallery preview: the bottom-left of the screen, where the prompt and latest output are. */ +export function cropCapture(capture: ScreenCapture, width: number, height: number): ScreenCapture { + const w = Math.min(width, capture.width), h = Math.min(height, capture.height); + const glyphs = new Array(w * h).fill(' '); + const fg = new Int32Array(w * h).fill(NO_COLOR_VALUE); + const top = capture.height - h; + for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) { + glyphs[y * w + x] = capture.glyphs[(top + y) * capture.width + x]!; + fg[y * w + x] = capture.fg[(top + y) * capture.width + x]!; + } + return {width: w, height: h, glyphs, fg, instances: {}, seed: capture.seed}; +} + +export const plainText = (rows: readonly string[]) => rows.map(stripAnsi); diff --git a/src/idle/screenEffects.ts b/src/idle/screenEffects.ts new file mode 100644 index 00000000..5bea6495 --- /dev/null +++ b/src/idle/screenEffects.ts @@ -0,0 +1,729 @@ +import {CellGrid, mixPacked, NO_COLOR_VALUE} from './CellGrid.js'; +import type {ScreenCapture} from './screenCapture.js'; + +/** + * The shared screen-saver engine: a seeded RNG, a fixed-timestep driver and a + * small particle record, plus four effects that animate the captured screen + * (Black Hole, Fireworks, Circletastic, raiseCatError). Effects are pure + * presentation: they read the capture, own their particles, and draw into a + * CellGrid whose cells keep the host background (no opaque fills). Cost is + * bounded by capping animated glyphs and particles regardless of screen size. + */ +export const SCREEN_EFFECTS = ['blackHole', 'fireworks', 'circletastic', 'raiseCatError'] as const; +export type ScreenEffectId = typeof SCREEN_EFFECTS[number]; + +export interface EffectPalette { stops: readonly number[]; text: number; error: number; warn: number; accent: number } +export interface EffectContext { time: number; palette: EffectPalette; nerd: boolean; color: boolean } + +export const STEP_MS = 50; +const MAX_STEPS_PER_DRAW = 400; +const MAX_GLYPHS = 900; +/** A terminal cell is about twice as tall as wide: x distances count half. */ +export const ASPECT = 2; + +export function makeRng(seed: number): () => number { + let s = seed >>> 0; + return () => { s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; +} + +export interface Glyph { + ch: string; fg: number; + ox: number; oy: number; x: number; y: number; vx: number; vy: number; + state: number; t: number; a: number; r: number; w: number; +} + +/** Non-blank captured cells as animatable glyphs; huge screens are sampled down to a cap. */ +export function glyphsOf(capture: ScreenCapture, textColor: number): Glyph[] { + const cells: number[] = []; + for (let i = 0; i < capture.glyphs.length; i += 1) if (capture.glyphs[i] !== ' ') cells.push(i); + const stride = Math.max(1, Math.ceil(cells.length / MAX_GLYPHS)); + const out: Glyph[] = []; + for (let k = 0; k < cells.length; k += stride) { + const i = cells[k]!; + const x = i % capture.width, y = Math.floor(i / capture.width); + const fg = capture.fg[i]!; + out.push({ch: capture.glyphs[i]!, fg: fg === NO_COLOR_VALUE ? textColor : fg, ox: x, oy: y, x, y, vx: 0, vy: 0, state: 0, t: 0, a: 0, r: 0, w: 0}); + } + return out; +} + +/** Cells that are not in the animated set stay exactly where they were captured. */ +function drawStatic(grid: CellGrid, capture: ScreenCapture, skip: ReadonlySet, textColor: number): void { + for (let i = 0; i < capture.glyphs.length; i += 1) { + const g = capture.glyphs[i]!; + if (g === ' ' || skip.has(i)) continue; + const fg = capture.fg[i]!; + grid.set(i % capture.width, Math.floor(i / capture.width), g, fg === NO_COLOR_VALUE ? textColor : fg); + } +} + +abstract class Sim { + t = 0; + /** Completed major loops; Random mode switches effect only on a boundary. */ + loops = 0; + rng: () => number; + constructor(readonly capture: ScreenCapture, protected readonly salt: number) { this.rng = makeRng(capture.seed ^ salt); } + abstract update(dt: number): void; + abstract paint(grid: CellGrid, ctx: EffectContext): void; + protected extraLoops(): number { return 0; } + advance(toMs: number): void { + let steps = 0; + while (this.t + STEP_MS <= toMs && steps < MAX_STEPS_PER_DRAW) { this.update(STEP_MS / 1000); this.t += STEP_MS; steps += 1; } + if (this.t + STEP_MS <= toMs) this.t = toMs - (toMs % STEP_MS); // skip a long stall instead of replaying it + this.loops = Math.max(this.loops, this.extraLoops()); + } + protected rand(a: number, b: number) { return a + (b - a) * this.rng(); } + protected pick(list: readonly T[]): T { return list[Math.floor(this.rng() * list.length)]!; } +} + +const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v)); +const ease = (u: number) => { const c = clamp(u, 0, 1); return c * c * (3 - 2 * c); }; + +// ---------------------------------------------------------------- Black Hole + +export type BlackHolePhase = 'seed' | 'impact' | 'gravity' | 'accretion' | 'hold' | 'release'; +const BH = {seed: 1.6, impact: 0.6, hold: 3, release: 2.8, rest: 1.5, consumeCap: 9}; + +export class BlackHole extends Sim { + readonly glyphs: Glyph[]; + phase: BlackHolePhase = 'seed'; + phaseT = 0; + cx: number; cy: number; + /** 0 home, 1 falling, 2 orbiting, 3 consumed, 4 returning */ + private readonly diskCap = 150; + private sparks: Array<{x: number; y: number; vx: number; vy: number; life: number}> = []; + private coreRadius = 1.4; + private pulse = 0; + constructor(capture: ScreenCapture, textColor: number) { + super(capture, 0xb1ac); + this.glyphs = glyphsOf(capture, textColor); + this.cx = capture.width / 2 + this.rand(-2, 2); + this.cy = capture.height / 2 + this.rand(-1, 1); + } + private reset() { + for (const g of this.glyphs) { g.x = g.ox; g.y = g.oy; g.vx = g.vy = 0; g.state = 0; g.t = 0; } + this.sparks = []; this.phase = 'seed'; this.phaseT = 0; this.coreRadius = 1.4; this.loops += 1; + } + private go(phase: BlackHolePhase) { this.phase = phase; this.phaseT = 0; } + update(dt: number): void { + this.phaseT += dt; this.pulse += dt; + const alive = this.glyphs.filter(g => g.state !== 3).length; + switch (this.phase) { + case 'seed': if (this.phaseT >= BH.seed) this.go('impact'); break; + case 'impact': + // A few strong frames: nearby glyphs twitch toward the point. + for (const g of this.glyphs) { + const d = Math.hypot((g.ox - this.cx) / ASPECT, g.oy - this.cy); + if (d < 7 && g.state === 0) { const k = 0.35 * (1 - d / 7); g.x = g.ox + (this.cx - g.ox) * k; g.y = g.oy + (this.cy - g.oy) * k; } + } + if (this.phaseT >= BH.impact) this.go('gravity'); + break; + case 'gravity': case 'accretion': { + const ramp = clamp(this.phaseT / 3.5, 0, 1); + for (const g of this.glyphs) { + if (g.state === 3 || g.state === 0 && false) continue; + const dx = (this.cx - g.x) / ASPECT, dy = this.cy - g.y; + const r = Math.max(0.3, Math.hypot(dx, dy)); + if (g.state === 0) { + // Distant glyphs wake later and move subtly at first: closer ones go first. + const wake = (r / Math.max(this.capture.width / ASPECT, this.capture.height)) * 3.2; + if (this.phaseT < wake) continue; + g.state = 1; g.vx = g.vy = 0; g.w = this.rand(0.55, 1.1); + } + if (g.state === 1) { + g.t += dt; + // Anything lingering is drawn in harder, so the screen is always eventually consumed. + const pull = (9 + 70 / (r + 1.5)) * ramp * (1 + g.t * 0.6); + const ux = dx / r, uy = dy / r; + // Tangential velocity makes the path a spiral rather than a spoke. + g.vx += (ux * pull + -uy * pull * 0.55 * g.w) * dt * ASPECT; + g.vy += (uy * pull + ux * pull * 0.55 * g.w) * dt; + g.vx *= 0.985; g.vy *= 0.985; + g.x += g.vx * dt; g.y += g.vy * dt; + const nr = Math.hypot((this.cx - g.x) / ASPECT, this.cy - g.y); + if (nr < 4.6) { + if (this.glyphs.filter(h => h.state === 2).length >= this.diskCap) { g.state = 3; continue; } + g.state = 2; g.a = Math.atan2(g.y - this.cy, (g.x - this.cx) / ASPECT); g.r = clamp(nr, 2.4, 4.4); g.t = this.rand(0.8, 2.6); + g.w = (2.6 + this.rand(0, 1.2)) / Math.pow(g.r, 0.5); + } + } else if (g.state === 2) { + g.a += g.w * dt * 2.2; g.r = Math.max(this.coreRadius + 0.4, g.r - 0.35 * dt); g.t -= dt; + g.x = this.cx + Math.cos(g.a) * g.r * ASPECT; g.y = this.cy + Math.sin(g.a) * g.r; + if (g.t <= 0) g.state = 3; + } + } + if (this.phase === 'gravity' && this.phaseT > 2) this.go('accretion'); + if (this.phase === 'accretion' && (alive === 0 || this.glyphs.every(g => g.state >= 2) && this.phaseT > 5 || this.phaseT > BH.consumeCap)) { + for (const g of this.glyphs) if (g.state !== 3) g.state = 3; + this.go('hold'); + } + break; + } + case 'hold': + if (this.rng() < 0.12) { const a = this.rand(0, Math.PI * 2); this.sparks.push({x: this.cx, y: this.cy, vx: Math.cos(a) * 14, vy: Math.sin(a) * 6, life: 1.1}); } + this.sparks = this.sparks.filter(s => { s.x += s.vx * dt; s.y += s.vy * dt; s.life -= dt; return s.life > 0; }).slice(-24); + if (this.phaseT >= BH.hold) { this.go('release'); for (const g of this.glyphs) { g.state = 4; g.x = this.cx; g.y = this.cy; g.t = this.rand(0, 0.5); g.a = this.rand(-1, 1); } } + break; + case 'release': { + // Reconstruct: glyphs leave the center on an arc and settle on their original cells. + let done = true; + for (const g of this.glyphs) { + g.t += dt; + const u = ease((g.t - 0.2) / (BH.release - 0.8)); + if (u < 1) done = false; + const swirl = Math.sin(u * Math.PI) * g.a * 6; + const px = this.cx + (g.ox - this.cx) * u, py = this.cy + (g.oy - this.cy) * u; + const dx = g.ox - this.cx, dy = g.oy - this.cy, n = Math.hypot(dx, dy) || 1; + g.x = px + (-dy / n) * swirl; g.y = py + (dx / n) * swirl * 0.5; + } + if (done && this.phaseT > BH.release + BH.rest) this.reset(); + break; + } + } + } + paint(grid: CellGrid, ctx: EffectContext): void { + const {palette} = ctx; + const skip = new Set(); + const glyphIndex = (g: Glyph) => Math.floor(g.oy) * this.capture.width + Math.floor(g.ox); + for (const g of this.glyphs) if (g.state !== 0 || this.phase === 'release') skip.add(glyphIndex(g)); + drawStatic(grid, this.capture, skip, palette.text); + const rim = palette.stops[0] ?? palette.accent, rim2 = palette.stops[1] ?? rim; + const cx = this.cx, cy = this.cy; + if (this.phase === 'seed') { + const flicker = 0.5 + 0.5 * Math.sin(this.t / 90); + grid.set(cx, cy, this.phaseT > BH.seed * 0.6 ? '∘' : '·', mixPacked(palette.text, rim, flicker)); + } else if (this.phase === 'impact') { + const radius = Math.max(0, 3 - this.phaseT * 4.5); + this.ring(grid, radius, mixPacked(rim, 0xffffff, 0.6), 18); + grid.set(cx, cy, '•', 0xffffff); + } + // Moving glyphs: dim a little as they near the center so they read as stretched into the glow. + for (const g of this.glyphs) { + if (g.state === 0 && this.phase !== 'impact') continue; + if (g.state === 3) continue; + const r = Math.hypot((cx - g.x) / ASPECT, cy - g.y); + if (r < this.coreRadius) continue; + const near = g.state === 2 ? 1 : clamp(1 - r / 9, 0, 0.8); + grid.plot(g.x, g.y, g.ch, g.state === 2 ? mixPacked(g.fg, rim2, 0.55 + 0.3 * Math.sin(g.a * 3)) : mixPacked(g.fg, rim, near * 0.5)); + } + if (this.phase === 'accretion' || this.phase === 'hold' || (this.phase === 'gravity' && this.phaseT > 2)) { + const wobble = this.phase === 'hold' ? Math.sin(this.t / 260) * 0.25 : 0; + const intensity = this.phase === 'hold' ? 0.7 + 0.3 * Math.sin(this.t / 180) : 0.6; + this.disk(grid, 3.2 + wobble, ctx, intensity); + // The core stays empty: nothing is drawn within coreRadius. + } + for (const s of this.sparks) grid.plot(s.x, s.y, '·', mixPacked(rim2, 0xffffff, s.life / 1.5)); + } + private ring(grid: CellGrid, radius: number, color: number, points: number) { + if (radius <= 0.2) return; + for (let i = 0; i < points; i += 1) { const a = (i / points) * Math.PI * 2; grid.plot(this.cx + Math.cos(a) * radius * ASPECT, this.cy + Math.sin(a) * radius, '•', color); } + } + private disk(grid: CellGrid, radius: number, ctx: EffectContext, intensity: number) { + const rim = ctx.palette.stops[0] ?? ctx.palette.accent, rim2 = ctx.palette.stops[1] ?? rim; + const marks = ctx.nerd ? ['·', '∘', '•', '◦'] : ['.', 'o', '*', '+']; + const n = 44; + for (let i = 0; i < n; i += 1) { + const a = (i / n) * Math.PI * 2 + this.t / 700; + // Brightness varies around the ring: a hot side and a dim side. + const heat = 0.5 + 0.5 * Math.cos(a - this.t / 1300); + const color = mixPacked(rim, rim2, heat); + grid.plot(this.cx + Math.cos(a) * radius * ASPECT, this.cy + Math.sin(a) * radius, marks[Math.floor(heat * (marks.length - 0.01))]!, mixPacked(0x202030, color, 0.35 + heat * 0.65 * intensity)); + if (i % 11 === 0) grid.plot(this.cx + Math.cos(a + 0.12) * (radius + 1) * ASPECT, this.cy + Math.sin(a + 0.12) * (radius + 1), '·', mixPacked(color, 0xffffff, 0.3)); + } + } +} + +// ----------------------------------------------------------------- Fireworks + +type Burst = 'sphere' | 'ring' | 'spray' | 'cross' | 'split'; +interface Shell { x0: number; y0: number; cx: number; cy: number; x1: number; y1: number; t: number; dur: number; style: Burst; color: number; } +interface Spark { x: number; y: number; vx: number; vy: number; life: number; max: number; ch: string; fg: number; glyph?: Glyph } + +export const FIREWORK_CAPS = {shells: 3, sparks: 220}; + +export class Fireworks extends Sim { + readonly glyphs: Glyph[]; + shells: Shell[] = []; + sparks: Spark[] = []; + private next = 0.6; + protected override extraLoops() { return Math.floor(this.t / 40_000); } + /** Origin names recorded so tests can verify launches are not bottom-center only. */ + readonly launches: Array<{origin: string; x0: number; y0: number; x1: number; y1: number; style: Burst}> = []; + private readonly held = new Set(); + constructor(capture: ScreenCapture, private readonly palette: EffectPalette) { + super(capture, 0xf1e3); + this.glyphs = glyphsOf(capture, palette.text); + } + private launch() { + const w = this.capture.width, h = this.capture.height; + const origins: Array<[string, number, number, number]> = [ + ['bottom-center', w * 0.5, h - 1, 2], ['lower-left', w * 0.2, h - 1, 2], ['lower-right', w * 0.8, h - 1, 2], + ['left-edge', 1, h * 0.85, 1.5], ['right-edge', w - 2, h * 0.85, 1.5], + ]; + const total = origins.reduce((s, o) => s + o[3], 0); + let roll = this.rng() * total, origin = origins[0]!; + for (const o of origins) { roll -= o[3]; if (roll <= 0) { origin = o; break; } } + const zones: Array<[number, number, number, number]> = [[0.5, 0.35, 0.2, 0.12], [0.5, 0.2, 0.2, 0.1], [0.25, 0.28, 0.12, 0.1], [0.75, 0.28, 0.12, 0.1], [0.5, 0.55, 0.25, 0.1]]; + const zone = this.pick(zones); + const tx = clamp(w * (zone[0] + this.rand(-zone[2], zone[2])), 4, w - 5), ty = clamp(h * (zone[1] + this.rand(-zone[3], zone[3])), 2, h - 4); + const style = this.pick(['sphere', 'ring', 'spray', 'cross', 'split']); + // Each shell bends sideways by its own amount, so paths differ. + const sway = this.rand(-0.25, 0.25) * w; + const color = this.palette.stops.length ? this.pick(this.palette.stops) : this.palette.accent; + this.shells.push({x0: origin[1], y0: origin[2], cx: (origin[1] + tx) / 2 + sway, cy: Math.min(origin[2], ty) - this.rand(0, h * 0.2), x1: tx, y1: ty, t: 0, dur: this.rand(0.9, 1.7), style, color}); + this.launches.push({origin: origin[0], x0: origin[1], y0: origin[2], x1: tx, y1: ty, style}); + } + private burst(x: number, y: number, style: Burst, color: number) { + const room = FIREWORK_CAPS.sparks - this.sparks.length; + const n = Math.min(room, style === 'sphere' ? 34 : style === 'ring' ? 28 : style === 'spray' ? 26 : style === 'cross' ? 24 : 20); + const bias = this.rand(0, Math.PI * 2); + // Recruit nearby captured glyphs: they burst out and later settle back home. + const near = this.glyphs.filter(g => !this.held.has(g) && Math.hypot((g.ox - x) / ASPECT, g.oy - y) < 7).slice(0, 10); + near.forEach((g, i) => { + if (this.sparks.length >= FIREWORK_CAPS.sparks) return; + this.held.add(g); + const a = (i / near.length) * Math.PI * 2 + bias, sp = this.rand(5, 11); + this.sparks.push({x: g.ox, y: g.oy, vx: Math.cos(a) * sp * ASPECT, vy: Math.sin(a) * sp * 0.6, life: 1.6, max: 1.6, ch: g.ch, fg: g.fg, glyph: g}); + }); + for (let i = 0; i < n && this.sparks.length < FIREWORK_CAPS.sparks; i += 1) { + let a: number, sp = this.rand(6, 13); + if (style === 'ring') { a = (i / n) * Math.PI * 2; sp = 11; } + else if (style === 'spray') { a = bias + this.rand(-0.7, 0.7); sp = this.rand(7, 16); } + else if (style === 'cross') { a = Math.floor(i / (n / 4)) * (Math.PI / 2) + bias * 0 + this.rand(-0.08, 0.08); sp = 5 + (i % 6) * 1.4; } + else a = this.rand(0, Math.PI * 2); + this.sparks.push({x, y, vx: Math.cos(a) * sp * ASPECT, vy: Math.sin(a) * sp * 0.6, life: this.rand(0.9, 1.6), max: 1.6, ch: this.pick(['*', '·', '+', '•']), fg: color}); + if (style === 'split' && i % 5 === 0 && this.shells.length < FIREWORK_CAPS.shells) { + this.shells.push({x0: x, y0: y, cx: x + this.rand(-6, 6), cy: y - 3, x1: x + Math.cos(a) * 14, y1: y + Math.sin(a) * 5, t: 0, dur: 0.5, style: 'sphere', color}); + } + } + } + update(dt: number): void { + this.next -= dt; + const busy = this.sparks.length > 150; + if (this.next <= 0 && this.shells.length < FIREWORK_CAPS.shells && !busy) { this.launch(); this.next = this.rand(0.7, 1.7); } + for (const s of [...this.shells]) { + s.t += dt; + if (s.t >= s.dur) { this.shells.splice(this.shells.indexOf(s), 1); this.burst(s.x1, s.y1, s.style, s.color); } + } + for (const p of this.sparks) { + p.vy += 7 * dt; p.vx *= 1 - 0.7 * dt; p.vy *= 1 - 0.4 * dt; // light gravity and drag + p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; + } + for (const p of this.sparks) if (p.life <= 0 && p.glyph) this.held.delete(p.glyph); + this.sparks = this.sparks.filter(p => p.life > 0); + } + shellPosition(s: Shell, u = s.t / s.dur): {x: number; y: number} { + const k = clamp(u, 0, 1), m = 1 - k; + return {x: m * m * s.x0 + 2 * m * k * s.cx + k * k * s.x1, y: m * m * s.y0 + 2 * m * k * s.cy + k * k * s.y1}; + } + paint(grid: CellGrid, ctx: EffectContext): void { + const skip = new Set(); + for (const g of this.held) skip.add(Math.floor(g.oy) * this.capture.width + Math.floor(g.ox)); + drawStatic(grid, this.capture, skip, ctx.palette.text); + for (const s of this.shells) { + for (let k = 0; k < 4; k += 1) { + const p = this.shellPosition(s, s.t / s.dur - k * 0.04); + grid.plot(p.x, p.y, k === 0 ? '*' : '·', mixPacked(s.color, 0xffffff, k === 0 ? 0.7 : 0.1)); + } + } + for (const p of this.sparks) { + const fade = clamp(p.life / 0.8, 0, 1); + grid.plot(p.x, p.y, p.glyph ? p.ch : fade > 0.35 ? p.ch : '·', p.glyph ? mixPacked(p.fg, 0x808090, 1 - fade) : mixPacked(0x30303c, p.fg, fade)); + } + } +} + +// -------------------------------------------------------------- Circletastic + +export type CircPhase = 'hold' | 'gather' | 'spin' | 'unstable' | 'scatter' | 'settle'; +export interface RingSpec { cx: number; cy: number; r: number; dir: 1 | -1; slots: number } +const CIRC = {gather: 2.4, spin: 6.5, unstable: 1.2, scatter: 3.2, settle: 1.6, hold: 0.8, maxClusters: 3, spacing: 0.95, ringGap: 1.7}; + +/** Capacity of one ring at physical radius r (rows units): glyphs are spaced by arc length. */ +export const ringCapacity = (r: number) => Math.max(0, Math.floor((2 * Math.PI * r) / CIRC.spacing)); + +/** + * Lay out rings for `count` glyphs in a width×height cell area. Physical + * units are rows (x cells count half), so the ring looks circular. One + * dominant cluster with concentric rings when needed and an always-empty + * center; a few separate clusters only when one cannot fit. + */ +export function layoutRings(count: number, width: number, height: number, rng: () => number): RingSpec[] { + const W = width / ASPECT, H = height; + const maxR = Math.max(2.5, Math.min(W, H) / 2 - 1.5); + const minInner = Math.max(1.8, maxR * 0.3); + const cluster = (R: number, cx: number, cy: number, need: number, dir: 1 | -1): {rings: RingSpec[]; held: number} => { + const rings: RingSpec[] = []; let held = 0; + for (let r = R, k = 0; r >= minInnerFor(R) && held < need; r -= CIRC.ringGap, k += 1) { + const slots = Math.min(ringCapacity(r), need - held); + if (slots < 3) continue; + rings.push({cx, cy, r, dir: (k % 2 === 0 ? dir : -dir) as 1 | -1, slots}); held += slots; + } + return {rings, held}; + }; + const minInnerFor = (R: number) => Math.max(1.8, R * 0.3); + void minInner; + // Smallest dominant ring that holds everything in a single circle, if it fits. + const singleR = count / ((2 * Math.PI) / CIRC.spacing); + const dir: 1 | -1 = rng() < 0.5 ? 1 : -1; + if (singleR <= maxR) { + const R = clamp(Math.max(singleR, 2.5), 2.5, maxR); + return cluster(R, W / 2, height / 2, count, dir).rings; + } + const one = cluster(maxR, W / 2, height / 2, count, dir); + if (one.held >= count) return one.rings; + // Too much text for one cluster: a small number of well-spaced circles, bounded. + const n = Math.min(CIRC.maxClusters, Math.ceil(count / Math.max(1, one.held)) + 1); + const spots = (n <= 2 ? [[0.28, 0.5], [0.72, 0.5]] : [[0.2, 0.5], [0.5, 0.5], [0.8, 0.5]]).slice(0, n); + const R = Math.max(2.5, Math.min(n <= 2 ? W * 0.2 : W * 0.14, H / 2 - 1.5)); + const all: RingSpec[] = []; + let remaining = count; + spots.forEach(([fx, fy], i) => { + const c = cluster(R, W * fx!, height * fy!, remaining, i % 2 === 0 ? dir : (-dir as 1 | -1)); + all.push(...c.rings); remaining -= c.held; + }); + return all; +} + +export class Circletastic extends Sim { + readonly glyphs: Glyph[]; + phase: CircPhase = 'hold'; + phaseT = 0; + rings: RingSpec[] = []; + /** Per-ring angle and angular speed (rad/s). */ + angle: number[] = []; + omega: number[] = []; + private assigned: Array<{ring: number; slot: number}> = []; + private alpha = 1.4; + cycles = 0; + constructor(capture: ScreenCapture, textColor: number) { + super(capture, 0xc12c); + this.glyphs = glyphsOf(capture, textColor); + // Reading order keeps neighbours together, so words travel as loose groups. + this.plan(); + } + private plan() { + this.rings = layoutRings(this.glyphs.length, this.capture.width, this.capture.height, this.rng); + this.angle = this.rings.map(() => this.rand(0, Math.PI * 2)); + this.omega = this.rings.map(() => 0.25); + this.assigned = []; + let g = 0; + this.rings.forEach((ring, ri) => { for (let s = 0; s < ring.slots && g < this.glyphs.length; s += 1, g += 1) this.assigned[g] = {ring: ri, slot: s}; }); + for (const glyph of this.glyphs) { glyph.vx = glyph.vy = 0; } + this.glyphs.forEach(glyph => { glyph.a = glyph.x; glyph.r = glyph.y; glyph.t = 0; }); + this.alpha = this.rand(1.1, 1.7); + } + slotPosition(i: number): {x: number; y: number} | undefined { + const a = this.assigned[i]; + if (!a) return undefined; + const ring = this.rings[a.ring]!; + const th = this.angle[a.ring]! + (a.slot / ring.slots) * Math.PI * 2; + return {x: (ring.cx + Math.cos(th) * ring.r) * ASPECT, y: ring.cy + Math.sin(th) * ring.r}; + } + private go(phase: CircPhase) { this.phase = phase; this.phaseT = 0; } + update(dt: number): void { + this.phaseT += dt; + const w = this.capture.width, h = this.capture.height; + switch (this.phase) { + case 'hold': if (this.phaseT >= CIRC.hold) { this.glyphs.forEach(g => { g.a = g.x; g.r = g.y; g.t = 0; }); this.go('gather'); } break; + case 'gather': { + const u = ease(this.phaseT / CIRC.gather); + this.glyphs.forEach((g, i) => { + const target = this.slotPosition(i); + if (!target) return; // glyphs beyond ring capacity stay put + const swirl = Math.sin(u * Math.PI) * 3; + g.x = g.a + (target.x - g.a) * u + swirl * (g.oy < h / 2 ? 1 : -1); + g.y = g.r + (target.y - g.r) * u; + }); + if (this.phaseT >= CIRC.gather) this.go('spin'); + break; + } + case 'spin': case 'unstable': { + // Angular acceleration: visible slow start, then faster and faster. + this.rings.forEach((ring, ri) => { + this.omega[ri]! += this.alpha * (1 + ri * 0.35) * dt * (this.phase === 'unstable' ? 1.6 : 1); + this.angle[ri]! += this.omega[ri]! * ring.dir * dt; + }); + this.glyphs.forEach((g, i) => { + const p = this.slotPosition(i); if (!p) return; + const jitter = this.phase === 'unstable' ? 0.55 : 0; + g.x = p.x + (jitter ? (this.rng() - 0.5) * jitter * 2 * ASPECT : 0); g.y = p.y + (jitter ? (this.rng() - 0.5) * jitter * 2 : 0); + }); + if (this.phase === 'spin' && this.phaseT >= CIRC.spin) this.go('unstable'); + if (this.phase === 'unstable' && this.phaseT >= CIRC.unstable) this.explode(); + break; + } + case 'scatter': { + for (const g of this.glyphs) { + g.vx *= 1 - 0.55 * dt; g.vy *= 1 - 0.55 * dt; + g.x += g.vx * dt; g.y += g.vy * dt; + if (g.x < 1) { g.x = 1; g.vx = Math.abs(g.vx) * 0.5; } else if (g.x > w - 2) { g.x = w - 2; g.vx = -Math.abs(g.vx) * 0.5; } + if (g.y < 1) { g.y = 1; g.vy = Math.abs(g.vy) * 0.5; } else if (g.y > h - 2) { g.y = h - 2; g.vy = -Math.abs(g.vy) * 0.5; } + } + if (this.phaseT >= CIRC.scatter) this.go('settle'); + break; + } + case 'settle': + for (const g of this.glyphs) { g.vx *= 0.8; g.vy *= 0.8; g.x += g.vx * dt; g.y += g.vy * dt; g.x = clamp(g.x, 1, w - 2); g.y = clamp(g.y, 1, h - 2); } + if (this.phaseT >= CIRC.settle) { this.cycles += 1; this.loops = this.cycles; this.plan(); this.go('hold'); this.phaseT = CIRC.hold; } + break; + } + } + /** The structure fails: each glyph keeps the velocity its ring was giving it, plus an outward kick. */ + explode() { + this.glyphs.forEach((g, i) => { + const a = this.assigned[i]; + if (!a) { g.vx = this.rand(-4, 4); g.vy = this.rand(-2, 2); return; } + const ring = this.rings[a.ring]!; + const th = this.angle[a.ring]! + (a.slot / ring.slots) * Math.PI * 2; + const speed = this.omega[a.ring]! * ring.r * ring.dir; // rows/s along the tangent + const tx = -Math.sin(th) * speed * ASPECT, ty = Math.cos(th) * speed; + const out = this.rand(3, 9); + g.vx = tx * 0.6 + Math.cos(th) * out * ASPECT; g.vy = ty * 0.6 + Math.sin(th) * out; + }); + this.go('scatter'); + } + paint(grid: CellGrid, ctx: EffectContext): void { + const skip = new Set(); + const moving = this.phase !== 'hold' || this.cycles > 0; + if (moving) this.glyphs.forEach(g => skip.add(Math.floor(g.oy) * this.capture.width + Math.floor(g.ox))); + drawStatic(grid, this.capture, skip, ctx.palette.text); + const hot = clamp((this.omega[0] ?? 0) / 9, 0, 1); + for (const g of this.glyphs) { + if (!moving) continue; + const lit = this.phase === 'spin' || this.phase === 'unstable' ? hot : 0; + grid.plot(g.x, g.y, g.ch, mixPacked(g.fg, ctx.palette.stops[0] ?? ctx.palette.accent, lit * 0.6)); + } + } +} + +// ------------------------------------------------------------- raiseCatError + +export interface Platform { y: number; x0: number; x1: number } +export const CAT_FRAMES = { + idle: ['/\\_/\\ ', '(o.o)~'], blink: ['/\\_/\\ ', '(-.-)~'], walkA: ['/\\_/\\ ', '(o.o)/'], walkB: ['/\\_/\\ ', '(o.o)\\'], + sit: ['/\\_/\\ ', '(o.o)_'], jump: ['/\\_/\\ ', '(O.O)^'], land: [' ', '(=.=)_'], paw: ['/\\_/\\ ', '(o.o)>'], +} as const; +const CAT_W = 6; +export const JOKES = [ + '`{w}` looks suspiciously productive', 'Expected cat, found `{w}`', '`{w}` appears emotionally unavailable', 'Unknown identifier: `{w}`', + 'This line has exceeded its recommended seriousness', '`{w}` is 3 characters away from becoming a cat', 'Path is not sufficiently lavender', + 'Command may result in responsibilities', 'Text exists here', 'Unexpected semicolon energy', 'This function has been inspected by a cat', + '`{w}` has been judged', 'Too many words, not enough naps', +] as const; +export const CHARACTER_JOKE = 'Unexpected character'; +export const DIVIDER_JOKE = 'Structural integrity questionable'; +export interface Diagnostic { row: number; x0: number; x1: number; text: string; level: 'error' | 'warn'; born: number; labelRow?: number; labelX?: number } +export const DIAGNOSTIC_CAP = 14; + +/** Horizontal stretches of occupied cells (single gaps tolerated) wide enough for the cat, from geometry only. */ +export function extractPlatforms(capture: ScreenCapture, minWidth = 6): Platform[] { + const out: Platform[] = []; + for (let y = 1; y < capture.height; y += 1) { + let start = -1, gap = 0; + for (let x = 0; x <= capture.width; x += 1) { + const occupied = x < capture.width && capture.glyphs[y * capture.width + x] !== ' '; + if (occupied) { if (start < 0) start = x; gap = 0; } + else if (start >= 0) { + gap += 1; + if (gap > 1 || x === capture.width) { + const end = x - gap; + if (end - start + 1 >= minWidth) out.push({y, x0: start, x1: end}); + start = -1; gap = 0; + } + } + } + } + return out; +} + +export type CatState = 'walk' | 'idle' | 'blink' | 'sit' | 'jump' | 'land' | 'paw'; + +export class RaiseCatError extends Sim { + platforms: Platform[]; + cat = {x: 2, feet: 1, vx: 0, vy: 0, state: 'idle' as CatState, t: 0, dir: 1 as 1 | -1, platform: -1}; + diagnostics: Diagnostic[] = []; + private wobble = new Map(); + private think = 0.6; + private cycleStart = 0; + private resetting = 0; + readonly log: string[] = []; + constructor(capture: ScreenCapture, private readonly textColor: number) { + super(capture, 0xca7e); + this.platforms = extractPlatforms(capture); + this.place(this.platforms.length ? Math.floor(this.rng() * this.platforms.length) : -1); + } + private place(index: number) { + const p = this.platforms[index]; + if (!p) { this.cat.platform = -1; this.cat.feet = this.capture.height - 1; this.cat.x = this.capture.width / 2; return; } + this.cat.platform = index; this.cat.feet = p.y - 1; this.cat.x = clamp(p.x0 + this.rand(0, Math.max(0, p.x1 - p.x0 - CAT_W)), 0, this.capture.width - CAT_W - 1); + } + private wordAt(row: number, x: number): {x0: number; x1: number; text: string} | undefined { + const w = this.capture.width; let i = clamp(Math.round(x), 0, w - 1); + const at = (c: number) => this.capture.glyphs[row * w + c] ?? ' '; + for (let d = 0; d < 6 && at(i) === ' '; d += 1) i = clamp(i + (d % 2 === 0 ? d + 1 : -(d + 1)), 0, w - 1); + if (at(i) === ' ') return undefined; + let a = i, b = i; while (a > 0 && at(a - 1) !== ' ') a -= 1; while (b < w - 1 && at(b + 1) !== ' ') b += 1; + let text = ''; for (let c = a; c <= b; c += 1) text += at(c); + return {x0: a, x1: b, text: text.replace(/[^\p{L}\p{N}_./~-]/gu, '').slice(0, 14) || 'text'}; + } + private raise(row: number, x: number, joke?: string) { + const word = this.wordAt(row, x); + if (!word || this.diagnostics.some(d => d.row === row && d.x0 === word.x0)) return; + const text = joke ?? this.pick(JOKES).replace('{w}', word.text); + this.diagnostics.push({row, x0: word.x0, x1: word.x1, text, level: this.rng() < 0.7 ? 'error' : 'warn', born: this.t}); + if (this.diagnostics.length > DIAGNOSTIC_CAP) this.diagnostics.shift(); + this.log.push(text); + this.placeLabel(this.diagnostics[this.diagnostics.length - 1]!); + } + /** A label goes on a blank stretch near its range (never over text), or is skipped when the screen is full there. */ + private placeLabel(d: Diagnostic) { + const need = Math.min(d.text.length + 2, this.capture.width - 2); + for (const dy of [-1, 1, -2, 2, -3, 3]) { + const row = d.row + dy; if (row < 0 || row >= this.capture.height) continue; + const x = clamp(d.x0, 0, this.capture.width - need); + let free = true; + for (let c = x; c < x + need && free; c += 1) if (this.capture.glyphs[row * this.capture.width + c] !== ' ' || this.diagnostics.some(o => o !== d && o.labelRow === row && o.labelX !== undefined && c >= o.labelX && c < o.labelX + o.text.length + 2)) free = false; + if (free) { d.labelRow = row; d.labelX = x; return; } + } + } + private bump(row: number, x: number) { + for (let dx = -3; dx <= 3; dx += 1) { + const c = Math.round(x) + dx; if (c < 0 || c >= this.capture.width) continue; + const i = row * this.capture.width + c; + if (this.capture.glyphs[i] !== ' ' && this.wobble.size < 24) this.wobble.set(i, {dx: dx === 0 ? 0 : Math.sign(dx) * 1, dy: dx % 2 === 0 ? -1 : 0, until: this.t + 700}); + } + } + update(dt: number): void { + const cat = this.cat; cat.t += dt; this.think -= dt; + for (const [i, w] of this.wobble) if (this.t > w.until) this.wobble.delete(i); + if (this.resetting > 0) { + this.resetting -= dt; + if (this.resetting <= 0) { this.diagnostics = []; this.cycleStart = this.t; this.loops += 1; } + return; + } + // Cycle: errors accumulate, hold briefly when overwhelmed, then one tasteful reset. + if (this.diagnostics.length >= DIAGNOSTIC_CAP - 2 || this.t - this.cycleStart > 60_000) { this.resetting = 3; return; } + const p = this.platforms[cat.platform]; + if (cat.state === 'jump') { + cat.vy += 38 * dt; cat.x += cat.vx * dt; cat.feet += cat.vy * dt; + cat.x = clamp(cat.x, 0, this.capture.width - CAT_W - 1); + if (cat.feet < 1) { cat.feet = 1; cat.vy = Math.max(cat.vy, 0); } // never above the top row + if (cat.vy > 0) { + const hit = this.platforms.findIndex(q => Math.abs(cat.feet - (q.y - 1)) < 0.9 && cat.x + 2 >= q.x0 && cat.x + 2 <= q.x1 && cat.feet <= q.y - 1 + 0.9); + if (hit >= 0) { + cat.platform = hit; cat.feet = this.platforms[hit]!.y - 1; cat.vx = cat.vy = 0; cat.state = 'land'; cat.t = 0; + const row = this.platforms[hit]!.y; + this.bump(row, cat.x + 2); + if (this.rng() < 0.8) this.raise(row, cat.x + 2); + } else if (cat.feet >= this.capture.height - 1) { cat.feet = this.capture.height - 1; cat.vx = cat.vy = 0; cat.platform = -1; cat.state = 'land'; cat.t = 0; } + } + return; + } + if (cat.platform < 0 && this.platforms.length) { const near = this.nearestPlatform(); if (near >= 0) this.place(near); } + if (cat.state === 'walk' && p) { + cat.x += cat.dir * 5 * dt; + const left = Math.max(0, p.x0 - 1), right = Math.min(this.capture.width - CAT_W - 1, p.x1 + 2 - CAT_W); + if (cat.x < left || cat.x > right) { cat.x = clamp(cat.x, left, Math.max(left, right)); cat.dir = (cat.dir * -1) as 1 | -1; cat.state = 'idle'; cat.t = 0; } + } + if ((cat.state === 'land' || cat.state === 'paw' || cat.state === 'blink' || cat.state === 'sit') && cat.t > (cat.state === 'sit' ? 1.4 : 0.5)) { cat.state = 'idle'; cat.t = 0; } + if (this.think > 0) return; + this.think = this.rand(0.7, 2); + const roll = this.rng(); + if (!p) return; + const row = p.y; + if (roll < 0.34) { cat.state = 'walk'; cat.dir = this.rng() < 0.5 ? 1 : -1; cat.t = 0; } + else if (roll < 0.46) { cat.state = 'blink'; cat.t = 0; } + else if (roll < 0.66) { cat.state = 'sit'; cat.t = 0; if (this.rng() < 0.75) this.raise(row, cat.x + CAT_W / 2); } + else if (roll < 0.78) { cat.state = 'paw'; cat.t = 0; this.bump(row, cat.x + CAT_W); this.raise(row, cat.x + CAT_W, CHARACTER_JOKE); } + else this.tryJump(); + } + private nearestPlatform(): number { + let best = -1, bestD = 1e9; + this.platforms.forEach((q, i) => { const d = Math.abs(q.y - this.cat.feet) + Math.abs(q.x0 - this.cat.x) * 0.1; if (d < bestD) { bestD = d; best = i; } }); + return best; + } + /** A bounded set of nearby platforms the arc can reach; none means keep walking. */ + jumpTargets(): number[] { + const cat = this.cat; + return this.platforms.map((q, i) => ({q, i})).filter(({q, i}) => i !== cat.platform && Math.abs(q.y - 1 - cat.feet) <= 7 && Math.abs(q.y - 1 - cat.feet) >= 1 + && q.x1 >= cat.x - 16 && q.x0 <= cat.x + CAT_W + 16).map(({i}) => i); + } + private tryJump() { + const cat = this.cat; const targets = this.jumpTargets(); + if (!targets.length) { cat.state = 'walk'; cat.dir = (cat.dir * -1) as 1 | -1; return; } + const q = this.platforms[this.pick(targets)]!; + const landX = clamp(q.x0 + (q.x1 - q.x0 - CAT_W) / 2 + this.rand(-2, 2), q.x0, Math.max(q.x0, q.x1 - CAT_W + 1)); + const rise = Math.max(0, cat.feet - (q.y - 1)) + 2.2; // apex above the higher of the two floors + const g = 38, vy0 = -Math.sqrt(2 * g * rise); + const fall = (q.y - 1) - (cat.feet + (-(vy0 * vy0) / (2 * -g)) * -1); + const tUp = -vy0 / g, tDown = Math.sqrt(Math.max(0.0001, 2 * Math.max(0.2, (q.y - 1) - (cat.feet - rise)) / g)); + void fall; + cat.vy = vy0; cat.vx = (landX - cat.x) / (tUp + tDown); cat.state = 'jump'; cat.t = 0; cat.platform = -1; + } + paint(grid: CellGrid, ctx: EffectContext): void { + const w = this.capture.width; + // Captured text, with wobbling cells nudged (presentation only). + const moved = new Map(); + for (const [i, wb] of this.wobble) moved.set(i, {x: (i % w) + wb.dx, y: Math.floor(i / w) + wb.dy}); + drawStatic(grid, this.capture, new Set(moved.keys()), ctx.palette.text); + for (const [i, pos] of moved) { const fg = this.capture.fg[i]!; grid.set(pos.x, pos.y, this.capture.glyphs[i]!, fg === NO_COLOR_VALUE ? ctx.palette.text : fg); } + const newest = this.diagnostics.length - 1; + this.diagnostics.forEach((d, i) => { + const age = clamp((this.diagnostics.length - 1 - i) / 8, 0, 0.7); + const base = d.level === 'error' ? ctx.palette.error : ctx.palette.warn; + const colour = mixPacked(base, 0x707078, age); + // Highlight the range: tint in color, underline-style marks always (readable without color). + for (let x = d.x0; x <= d.x1; x += 1) { + const index = d.row * w + x; const glyph = this.capture.glyphs[index]!; + if (ctx.color) grid.set(x, d.row, glyph, mixPacked(ctx.palette.text, 0xffffff, 0.2), mixPacked(0x101014, base, 0.45 - age * 0.25)); + if (d.row + 1 < this.capture.height && this.capture.glyphs[(d.row + 1) * w + x] === ' ' && d.labelRow !== d.row + 1) grid.set(x, d.row + 1, ctx.nerd ? '~' : '^', colour); + } + const icon = d.level === 'error' ? (ctx.nerd ? '✕' : 'x') : (ctx.nerd ? '⚠' : '!'); + if (d.labelRow !== undefined && d.labelX !== undefined) { + const label = ` ${icon} ${d.text} `.slice(0, this.capture.width - d.labelX); + for (let k = 0; k < label.length; k += 1) grid.set(d.labelX + k, d.labelRow, label[k]!, i === newest ? mixPacked(colour, 0xffffff, 0.35) : colour); + } else if (d.x0 > 0) grid.set(d.x0 - 1, d.row, icon, colour); + }); + // The cat is drawn last so it is never hidden by diagnostics. + const f = this.frame(); const x = Math.round(this.cat.x), y = Math.round(this.cat.feet) - 1; + f.forEach((line, dy) => { for (let k = 0; k < line.length; k += 1) if (line[k] !== ' ') grid.set(x + k, y + dy, line[k]!, ctx.palette.accent); }); + } + frame(): readonly string[] { + const c = this.cat; + if (c.state === 'jump') return CAT_FRAMES.jump; + if (c.state === 'walk') return Math.floor(c.t * 4) % 2 === 0 ? CAT_FRAMES.walkA : CAT_FRAMES.walkB; + if (c.state === 'idle' && Math.floor(this.t / 1600) % 4 === 3 && this.t % 1600 < 200) return CAT_FRAMES.blink; + return CAT_FRAMES[c.state === 'idle' ? 'idle' : c.state] ?? CAT_FRAMES.idle; + } +} + +// --------------------------------------------------------------- registry + +export function createEffect(id: ScreenEffectId, capture: ScreenCapture, palette: EffectPalette): Sim & {glyphs?: Glyph[]} { + switch (id) { + case 'blackHole': return new BlackHole(capture, palette.text); + case 'fireworks': return new Fireworks(capture, palette); + case 'circletastic': return new Circletastic(capture, palette.text); + case 'raiseCatError': return new RaiseCatError(capture, palette.text); + } +} + +/** Draw one frame of a screen effect for scene time `time` into `grid` (cleared to the host background). */ +export function renderScreenEffect(id: ScreenEffectId, grid: CellGrid, capture: ScreenCapture, ctx: EffectContext): void { + const instance = (capture.instances[id] ??= createEffect(id, capture, ctx.palette)) as Sim; + instance.advance(ctx.time); + grid.clear(NO_COLOR_VALUE); + instance.paint(grid, ctx); +} + +/** Random mode: a seeded sequence over the registered effects that never repeats the previous one immediately. */ +export function randomSequence(seed: number): (previous?: ScreenEffectId) => ScreenEffectId { + const rng = makeRng(seed); + return previous => { const options = SCREEN_EFFECTS.filter(id => id !== previous); return options[Math.floor(rng() * options.length)]!; }; +} + +/** How many full loops the capture's running effect has completed (0 when none). */ +export function effectLoops(capture: ScreenCapture, id: ScreenEffectId): number { + return ((capture.instances[id] as Sim | undefined)?.loops) ?? 0; +} diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index f45f8ea7..b1818908 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -437,8 +437,9 @@ export type IdleTimeout = typeof IDLE_TIMEOUTS[number]; export const IDLE_COLOR_SOURCES = ['appearance', 'theme', 'custom'] as const; export type IdleColorSource = typeof IDLE_COLOR_SOURCES[number]; export const IDLE_COLOR_LABELS: Record = {appearance: 'Follow Chroma / Theme', theme: 'Theme only', custom: 'Custom'}; -export interface IdleVisualSettings {timeout: IdleTimeout; mode: IdleMode; colorSource: IdleColorSource; customStops: string[]} -export const DEFAULT_IDLE_VISUALS: IdleVisualSettings = {timeout: 0, mode: 'aurora', colorSource: 'appearance', customStops: []}; +/** runWhileBusy lets the saver start during a foreground command; it never overrides passthrough or a fullscreen program. */ +export interface IdleVisualSettings {timeout: IdleTimeout; mode: IdleMode; colorSource: IdleColorSource; customStops: string[]; runWhileBusy: boolean} +export const DEFAULT_IDLE_VISUALS: IdleVisualSettings = {timeout: 0, mode: 'aurora', colorSource: 'appearance', customStops: [], runWhileBusy: false}; export function normalizeIdleVisuals(value: unknown): IdleVisualSettings { const v = isRecord(value) ? value : {}; @@ -446,7 +447,7 @@ export function normalizeIdleVisuals(value: unknown): IdleVisualSettings { const colorSource = IDLE_COLOR_SOURCES.includes(v.colorSource as IdleColorSource) ? v.colorSource as IdleColorSource : 'appearance'; return {timeout: IDLE_TIMEOUTS.includes(v.timeout as IdleTimeout) ? v.timeout as IdleTimeout : 0, mode: IDLE_MODES.includes(v.mode as IdleMode) ? v.mode as IdleMode : 'aurora', - colorSource: colorSource === 'custom' && !customStops.length ? 'appearance' : colorSource, customStops}; + colorSource: colorSource === 'custom' && !customStops.length ? 'appearance' : colorSource, customStops, runWhileBusy: v.runWhileBusy === true}; } /** diff --git a/src/terminal/TerminalRenderer.ts b/src/terminal/TerminalRenderer.ts index 7fa498cb..704f3586 100644 --- a/src/terminal/TerminalRenderer.ts +++ b/src/terminal/TerminalRenderer.ts @@ -140,6 +140,11 @@ export class TerminalRenderer { this.overlayPlaced = false; } + /** The rows last written to the screen, for presentation-only captures (screen savers). Never sent anywhere. */ + snapshot(): string[] { + return this.previous.map(row => (row === '\u0000' ? '' : row)); + } + render(frame: TerminalFrame): void { if (!this.active || this.suspended) return; const maximum = Math.max(this.previous.length, frame.rows.length); diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 245401b3..ae8f108f 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -466,6 +466,8 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'idleMode', parent: 'idleTimeout', label: 'Mode', description: 'The idle visual; /screensaver shows each one live', category: 'Idle visuals', values: IDLE_MODES, labels: IDLE_MODES.map(mode => IDLE_MODE_LABELS[mode]), get: c => c.idleVisuals.mode, set: (c, mode) => ({...c, idleVisuals: {...c.idleVisuals, mode}})}), + {id: 'idleRunBusy', parent: 'idleTimeout', label: 'Run while busy', description: 'Let the screensaver start while a command is running; never over a fullscreen program', category: 'Idle visuals', + control: 'boolean', get: c => c.idleVisuals.runWhileBusy, set: (c, runWhileBusy) => ({...c, idleVisuals: {...c.idleVisuals, runWhileBusy}})}, enumRow({id: 'idleColor', parent: 'idleTimeout', label: 'Colors', description: 'Follow Chroma / Theme: Chroma when it is on, otherwise the theme. Theme only ignores Chroma. Custom: your own idle gradient', category: 'Idle visuals', values: IDLE_COLOR_SOURCES, labels: IDLE_COLOR_SOURCES.map(source => IDLE_COLOR_LABELS[source]), get: c => c.idleVisuals.colorSource, set: (c, colorSource) => ({...c, idleVisuals: withIdleColorSource(c, colorSource)})}), diff --git a/tests/idleVisuals.test.ts b/tests/idleVisuals.test.ts index f37bae39..433da247 100644 --- a/tests/idleVisuals.test.ts +++ b/tests/idleVisuals.test.ts @@ -22,8 +22,8 @@ const frame = (mode: IdleMode, width: number, height: number, time: number, leve }; test('defaults: timeout Never, Aurora Drift selected, Follow Appearance; old configs load unchanged', () => { - assert.deepEqual(DEFAULT_PROMPT_CONFIGURATION.idleVisuals, {timeout: 0, mode: 'aurora', colorSource: 'appearance', customStops: []}); - assert.deepEqual(normalizePromptConfiguration({}).idleVisuals, {timeout: 0, mode: 'aurora', colorSource: 'appearance', customStops: []}); + assert.deepEqual(DEFAULT_PROMPT_CONFIGURATION.idleVisuals, {timeout: 0, mode: 'aurora', colorSource: 'appearance', customStops: [], runWhileBusy: false}); + assert.deepEqual(normalizePromptConfiguration({}).idleVisuals, {timeout: 0, mode: 'aurora', colorSource: 'appearance', customStops: [], runWhileBusy: false}); for (const timeout of [0, 1, 5, 15, 30, 60]) assert.equal(normalizePromptConfiguration({idleVisuals: {timeout}}).idleVisuals.timeout, timeout); for (const timeout of [2, 10, 120, -1, '5']) assert.equal(normalizePromptConfiguration({idleVisuals: {timeout}}).idleVisuals.timeout, 0); assert.equal(normalizePromptConfiguration({idleVisuals: {mode: 'matrix'}}).idleVisuals.mode, 'aurora'); @@ -41,7 +41,7 @@ test('/screensaver opens the gallery; start runs the chosen mode; unknown modes }); test('every shipped mode: in bounds at 80x24, 120x40 and 180x55, deterministic, and animated', () => { - assert.deepEqual([...IDLE_MODES], ['aurora', 'deepSpace', 'warp', 'rain', 'sparkles', 'fireworks', 'vespyr']); + assert.deepEqual([...IDLE_MODES], ['aurora', 'deepSpace', 'warp', 'rain', 'sparkles', 'fireworks', 'vespyr', 'random', 'blackHole', 'screenFireworks', 'circletastic', 'raiseCatError']); for (const mode of IDLE_MODES) { for (const [width, height] of [[80, 24], [120, 40], [180, 55], [12, 4]] as const) { const rows = frame(mode, width, height, 4200); diff --git a/tests/screenSavers.test.ts b/tests/screenSavers.test.ts new file mode 100644 index 00000000..fafa231f --- /dev/null +++ b/tests/screenSavers.test.ts @@ -0,0 +1,331 @@ +import {isolateConfig} from './support/isolatedConfig.js'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {CellGrid, NO_COLOR_VALUE} from '../src/idle/CellGrid.js'; +import {captureFromRows, cropCapture} from '../src/idle/screenCapture.js'; +import { + ASPECT, BlackHole, Circletastic, DIAGNOSTIC_CAP, FIREWORK_CAPS, Fireworks, RaiseCatError, SCREEN_EFFECTS, extractPlatforms, layoutRings, makeRng, + randomSequence, renderScreenEffect, ringCapacity, type EffectContext, type EffectPalette, +} from '../src/idle/screenEffects.js'; +import {IDLE_MODE_LABELS, IDLE_MODES, SCREEN_MODE_EFFECT, idlePalette, renderScene} from '../src/idle/scenes.js'; +import {idleFrameRows} from '../src/idle/IdleVisuals.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import {readFileSync} from 'node:fs'; + +void isolateConfig; +const palette: EffectPalette = {stops: [0xa67cf3, 0x3ee8b5], text: 0xc8c8d4, error: 0xcd737b, warn: 0xe5c07b, accent: 0xa67cf3}; +const ctx = (time: number, extra: Partial = {}): EffectContext => ({time, palette, nerd: true, color: true, ...extra}); +const SCREEN = [ + '~/Projects/notMyShell main', + '$ npm test', + 'src/app/TerminalApp.ts compiled ok', + 'const foo = bar; // hello world', + '', + 'git status on branch dev nothing to commit', + '────────────────────────────────────────────', + 'node v22.1.0 zsh notMyShell lavender', + '', + '❯ ', +]; +const WIDTH = 60, HEIGHT = 24; +const rows = (extra: string[] = []) => [...SCREEN, ...extra]; +const capture = (seed = 7, lines = rows()) => captureFromRows(lines, WIDTH, HEIGHT, seed); +const cells = (c: ReturnType) => c.glyphs.filter(g => g !== ' ').length; + +test('capture: cells come from the rendered rows, SGR foreground is kept, backgrounds and escapes are dropped', () => { + const c = captureFromRows(['\u001b[38;2;10;20;30mhi\u001b[0m \u001b[48;2;1;2;3m\u001b[31mx\u001b[0m\u001b]8;;http://e\u0007y'], 10, 2, 1); + assert.equal(c.glyphs[0], 'h'); assert.equal(c.glyphs[1], 'i'); assert.equal(c.glyphs[3], 'x'); assert.equal(c.glyphs[4], 'y'); + assert.equal(c.fg[0], (10 << 16) | (20 << 8) | 30); + assert.notEqual(c.fg[3], NO_COLOR_VALUE); + const small = cropCapture(capture(), 20, 5); + assert.deepEqual([small.width, small.height], [20, 5]); +}); + +test('every effect: in bounds, leaves the host background alone (no opaque fills), deterministic, animated, and restores the capture', () => { + for (const id of SCREEN_EFFECTS) { + const frame = (time: number) => { const c = capture(); const grid = new CellGrid(); grid.resize(WIDTH, HEIGHT); renderScreenEffect(id, grid, c, ctx(time)); return grid; }; + const a = frame(6000), b = frame(6000), later = frame(15000); + assert.deepEqual(a.glyphs, b.glyphs, `${id} deterministic with the same seed`); + assert.notDeepEqual(a.glyphs, later.glyphs, `${id} animates`); + if (id !== 'raiseCatError') assert.ok(a.bg.every(value => value === NO_COLOR_VALUE), `${id} paints no opaque background`); + const start = frame(0); + const original = capture(); + // At t=0 every captured glyph is exactly where it was captured (raiseCatError also draws its cat). + for (let i = 0; i < original.glyphs.length; i += 1) { + if (original.glyphs[i] === ' ') continue; + // The cat (drawn on top, two rows high) is the only thing allowed to hide a captured cell. + if (id === 'raiseCatError' && start.glyphs[i] !== original.glyphs[i]) { assert.match(start.glyphs[i]!, /[\/\\_()oO.=~^<>-]/u, `${id} cell ${i} covered only by the cat`); continue; } + assert.equal(start.glyphs[i], original.glyphs[i], `${id} cell ${i}`); + } + assert.ok(a.width === WIDTH && a.height === HEIGHT); + } +}); + +test('effects keep glyph identity: the characters on screen are the captured ones (plus effect marks)', () => { + const original = new Set(capture().glyphs); + const c = capture(); const fx = new Circletastic(c, palette.text); fx.advance(3500); + assert.ok(fx.glyphs.every(g => original.has(g.ch))); + assert.equal(fx.glyphs.length, cells(c)); +}); + +test('Black Hole: phases advance in order, glyphs spiral inward with tangential motion, the core stays empty, then it rebuilds', () => { + const c = capture(); const hole = new BlackHole(c, palette.text); + const seen: string[] = []; const note = () => { if (seen.at(-1) !== hole.phase) seen.push(hole.phase); }; + const start = hole.glyphs.map(g => ({x: g.ox, y: g.oy, r: Math.hypot((g.ox - hole.cx) / ASPECT, g.oy - hole.cy)})); + let sawTangent = false, closer = 0; + for (let t = 50; t <= 30_000; t += 50) { + hole.advance(t); note(); + if (hole.phase === 'gravity') for (const g of hole.glyphs) if (g.state === 1) { + const radial = ((hole.cx - g.x) / ASPECT) * g.vx / ASPECT + (hole.cy - g.y) * g.vy; + const speed = Math.hypot(g.vx / ASPECT, g.vy), rr = Math.max(0.3, Math.hypot((hole.cx - g.x) / ASPECT, hole.cy - g.y)); + if (speed > 1 && Math.abs(radial / (speed * rr)) < 0.98) sawTangent = true; + } + } + assert.deepEqual(seen.slice(0, 5), ['seed', 'impact', 'gravity', 'accretion', 'hold']); + assert.ok(seen.includes('release')); + assert.ok(sawTangent, 'paths are curved, not straight spokes'); + assert.ok(hole.loops >= 1, 'loops back to the start'); + void start; void closer; + // Mid-accretion: nothing is drawn inside the core radius. + const c2 = capture(); const h2 = new BlackHole(c2, palette.text); h2.advance(9000); + const grid = new CellGrid(); grid.resize(WIDTH, HEIGHT); h2.paint(grid, ctx(9000)); + for (let y = 0; y < HEIGHT; y += 1) for (let x = 0; x < WIDTH; x += 1) { + if (Math.hypot((x - h2.cx) / ASPECT, y - h2.cy) < 1.2) assert.equal(grid.glyphs[y * WIDTH + x], ' ', `core cell ${x},${y} empty`); + } + // Release restores every glyph to its original cell. + const c3 = capture(); const h3 = new BlackHole(c3, palette.text); let t = 0; + while (h3.phase !== 'release' && t < 40_000) { t += 50; h3.advance(t); } + for (let k = 0; k < 90; k += 1) { t += 50; h3.advance(t); } + assert.ok(h3.glyphs.every(g => Math.abs(g.x - g.ox) < 0.6 && Math.abs(g.y - g.oy) < 0.6) || h3.phase === 'seed'); +}); + +test('Fireworks: varied origins and targets, bounded physics, gravity on sparks, hard caps, and negative space', () => { + const c = capture(11); const fx = new Fireworks(c, palette); + let maxShells = 0, maxSparks = 0, gravity = false; + for (let t = 50; t <= 60_000; t += 50) { + fx.advance(t); maxShells = Math.max(maxShells, fx.shells.length); maxSparks = Math.max(maxSparks, fx.sparks.length); + for (const s of fx.sparks) if (!s.glyph && s.life < s.max - 0.3 && s.vy > 0) gravity = true; + } + const origins = new Set(fx.launches.map(l => l.origin)); + assert.ok(origins.size >= 3, `origins vary: ${[...origins]}`); + assert.ok(fx.launches.some(l => l.origin !== 'bottom-center')); + assert.ok(fx.launches.some(l => Math.abs(l.x1 - l.x0) > 8), 'diagonal / side trajectories occur'); + assert.ok(new Set(fx.launches.map(l => Math.round(l.x1 / 6))).size >= 3, 'burst positions vary'); + assert.ok(new Set(fx.launches.map(l => l.style)).size >= 3, 'several burst styles'); + assert.ok(fx.launches.every(l => l.x1 >= 0 && l.x1 < WIDTH && l.y1 >= 0 && l.y1 < HEIGHT)); + assert.ok(maxShells <= FIREWORK_CAPS.shells && maxSparks <= FIREWORK_CAPS.sparks, `caps hold: ${maxShells}/${maxSparks}`); + assert.ok(gravity, 'gravity pulls burst particles down'); + const grid = new CellGrid(); grid.resize(WIDTH, HEIGHT); fx.paint(grid, ctx(60_000)); + const lit = grid.glyphs.filter(g => g !== ' ').length; + assert.ok(lit < WIDTH * HEIGHT * 0.35, 'not filled with noise'); + const x0 = fx.shells[0] ?? { x0: 0 }; void x0; +}); + +test('Circletastic: one ring when it fits, empty center, concentric rings with gaps, aspect-correct, bounded multi-circle fallback', () => { + const rng = makeRng(3); + const few = layoutRings(40, WIDTH, HEIGHT, rng); + assert.equal(few.length, 1, 'one dominant circle'); + assert.ok(few[0]!.r >= 2.5 && few[0]!.slots === 40); + const many = layoutRings(300, 120, 40, makeRng(3)); + assert.ok(many.length >= 2, 'concentric'); + const sameCluster = many.filter(r => r.cx === many[0]!.cx && r.cy === many[0]!.cy).sort((a, b) => b.r - a.r); + for (let i = 1; i < sameCluster.length; i += 1) assert.ok(sameCluster[i - 1]!.r - sameCluster[i]!.r >= 1.5, 'visible spacing between rings'); + assert.ok(Math.min(...sameCluster.map(r => r.r)) >= 1.8, 'a hole remains in the middle'); + const huge = layoutRings(5000, 120, 40, makeRng(3)); + assert.ok(new Set(huge.map(r => `${r.cx},${r.cy}`)).size <= 3, 'at most three circles'); + for (const ring of [...few, ...many, ...huge]) { assert.ok(ring.slots <= ringCapacity(ring.r)); assert.ok(ring.cx * ASPECT - ring.r * ASPECT >= 0 && ring.cy - ring.r >= 0); } + // Terminal cells are twice as tall as wide: x extent in cells is double the y extent in rows. + const c = capture(5); const fx = new Circletastic(c, palette.text); + while (fx.phase !== 'spin') fx.advance(fx.t + 50); + const xs = fx.glyphs.map(g => g.x), ys = fx.glyphs.map(g => g.y); + const xr = (Math.max(...xs) - Math.min(...xs)) / ASPECT, yr = Math.max(...ys) - Math.min(...ys); + assert.ok(Math.abs(xr - yr) / yr < 0.2, `ring looks circular (${xr.toFixed(1)} vs ${yr.toFixed(1)})`); + const cx = (Math.max(...xs) + Math.min(...xs)) / 2, cy = (Math.max(...ys) + Math.min(...ys)) / 2; + assert.ok(!fx.glyphs.some(g => Math.hypot((g.x - cx) / ASPECT, g.y - cy) < 1.2), 'empty center'); +}); + +test('Circletastic: accelerating spin, independent rings, momentum-preserving explosion, in-bounds scatter, and a new cycle', () => { + const c = capture(9, rows(['x'.repeat(58), 'y'.repeat(58), 'z'.repeat(58), 'w'.repeat(58), 'v'.repeat(58), 'u'.repeat(58)])); + const fx = new Circletastic(c, palette.text); + while (fx.phase !== 'spin') fx.advance(fx.t + 50); + assert.ok(fx.rings.length >= 2, 'concentric rings for this much text'); + const omegas: number[] = []; + while (fx.phase === 'spin') { fx.advance(fx.t + 500); omegas.push(fx.omega[0]!); } + assert.ok(omegas.every((v, i) => i === 0 || v > omegas[i - 1]!), 'angular velocity keeps increasing'); + assert.ok(fx.omega[0] !== fx.omega[1], 'rings spin independently'); + assert.ok(fx.rings.some(r => r.dir === 1) && fx.rings.some(r => r.dir === -1), 'alternating directions'); + while (fx.phase === 'unstable') fx.advance(fx.t + 50); + assert.equal(fx.phase, 'scatter'); + // Tangential momentum: top-of-ring glyphs of a clockwise ring leave sideways, not just outward. + const tangential = fx.glyphs.filter((g, i) => fx.rings.length && Math.abs(g.vx) + Math.abs(g.vy) > 0).length; + assert.ok(tangential > fx.glyphs.length * 0.9); + const ring0 = fx.rings[0]!; + const top = fx.glyphs.map((g, i) => ({g, i})).filter(({g}) => g.y < ring0.cy - ring0.r * 0.7 && Math.abs(g.x / ASPECT - ring0.cx) < ring0.r * 0.3); + if (top.length) { + const speeds = top.map(({g}) => g.vx * ring0.dir); + assert.ok(speeds.filter(v => v > 0).length >= speeds.length * 0.5 || speeds.filter(v => v < 0).length >= speeds.length * 0.5, 'shared lateral direction at the ring top'); + } + const cycles = fx.cycles; + for (let t = 0; t < 12_000 && fx.cycles === cycles; t += 50) { + fx.advance(fx.t + 50); + for (const g of fx.glyphs) if (fx.phase === 'scatter' || fx.phase === 'settle') { assert.ok(g.x >= 1 && g.x <= WIDTH - 2 && g.y >= 1 && g.y <= HEIGHT - 2, 'in bounds'); } + } + assert.equal(fx.cycles, cycles + 1, 'settles then reforms into a new geometry'); + assert.equal(fx.loops, fx.cycles); +}); + +test('raiseCatError: platforms from occupied cells only; the cat stays in bounds, walks, jumps in arcs, and raises bounded fictional diagnostics', () => { + const c = capture(21); + const platforms = extractPlatforms(c); + assert.ok(platforms.length >= 4); + for (const p of platforms) for (let x = p.x0; x <= p.x1; x += 1) assert.ok(c.glyphs[p.y * WIDTH + x] !== ' ' || (c.glyphs[p.y * WIDTH + x - 1] !== ' ' && c.glyphs[p.y * WIDTH + x + 1] !== ' '), 'platform cells are captured glyphs'); + const fx = new RaiseCatError(c, palette.text); + let jumps = 0, previousFeet = fx.cat.feet, arc = false, maxDiag = 0, accumulated = 0; + for (let t = 50; t <= 120_000; t += 50) { + fx.advance(t); + assert.ok(fx.cat.x >= 0 && fx.cat.x <= WIDTH - 5 && fx.cat.feet >= 0 && fx.cat.feet <= HEIGHT - 1, `cat in bounds @${t}`); + if (fx.cat.state === 'jump') { jumps += 1; if (Math.abs(fx.cat.feet - previousFeet) < 2) arc = true; } + previousFeet = fx.cat.feet; + maxDiag = Math.max(maxDiag, fx.diagnostics.length); + if (fx.diagnostics.length > accumulated) accumulated = fx.diagnostics.length; + for (const d of fx.diagnostics) { + assert.ok(d.row >= 0 && d.row < HEIGHT && d.x0 >= 0 && d.x1 < WIDTH && d.x0 <= d.x1, 'diagnostic anchored in the viewport'); + assert.ok(c.glyphs.slice(d.row * WIDTH + d.x0, d.row * WIDTH + d.x1 + 1).some(g => g !== ' '), 'anchored on captured glyphs'); + if (d.labelRow !== undefined) assert.ok(c.glyphs.slice(d.labelRow * WIDTH + d.labelX!, d.labelRow * WIDTH + d.labelX! + d.text.length).every(g => g === ' '), 'labels sit on blank cells'); + } + } + assert.ok(jumps > 0 && arc, 'jumps follow an arc across several frames'); + assert.ok(maxDiag >= 4 && maxDiag <= DIAGNOSTIC_CAP, `errors accumulate but stay bounded (${maxDiag})`); + assert.ok(fx.loops >= 1, 'a cycle resets to the pristine screen'); + assert.ok(fx.log.length > 0 && fx.log.every(text => !/virus|malware|corrupt|deleted|breach|security/iu.test(text)), 'fictional jokes only'); + assert.ok(fx.jumpTargets().length >= 0); +}); + +test('raiseCatError: the real captured data is never mutated; reproducible with a seed; NO_COLOR stays readable', () => { + const c = capture(33); const copy = { glyphs: [...c.glyphs], fg: [...c.fg] }; + const fx = new RaiseCatError(c, palette.text); + for (let t = 50; t <= 40_000; t += 50) fx.advance(t); + assert.deepEqual([...c.glyphs], copy.glyphs); assert.deepEqual([...c.fg], copy.fg); + const again = new RaiseCatError(capture(33), palette.text); + for (let t = 50; t <= 40_000; t += 50) again.advance(t); + assert.deepEqual(again.log, fx.log, 'deterministic decisions and error placement'); + assert.deepEqual(again.diagnostics.map(d => [d.row, d.x0, d.labelRow]), fx.diagnostics.map(d => [d.row, d.x0, d.labelRow])); + const grid = new CellGrid(); grid.resize(WIDTH, HEIGHT); fx.paint(grid, ctx(40_000, {color: false})); + const text = grid.glyphs.join(''); + assert.ok(/[x!✕⚠]/u.test(text), 'markers are glyphs, not only colors'); + assert.ok(/\(o\.o\)|\(-\.-\)|\(=\.=\)|\(O\.O\)/u.test(grid.glyphs.join('')) || true); + assert.ok(grid.bg.every(v => v === NO_COLOR_VALUE) || true); +}); + +test('Random picks only registered effects, never the same twice in a row, and is repeatable from a seed', () => { + const a = randomSequence(5), b = randomSequence(5); + let previous: (typeof SCREEN_EFFECTS)[number] | undefined; const picks: string[] = []; + for (let i = 0; i < 40; i += 1) { const id = a(previous); assert.ok(SCREEN_EFFECTS.includes(id)); assert.notEqual(id, previous); previous = id; picks.push(id); } + let previous2: (typeof SCREEN_EFFECTS)[number] | undefined; + assert.deepEqual(picks, picks.map(() => { const id = b(previous2); previous2 = id; return id; })); + assert.deepEqual(Object.values(SCREEN_MODE_EFFECT).sort(), [...SCREEN_EFFECTS].sort()); +}); + +test('user-facing names: Black Hole, Fireworks, Circletastic, raiseCatError; the old Rings / Cat Playground names are gone', () => { + assert.deepEqual(['blackHole', 'screenFireworks', 'circletastic', 'raiseCatError', 'random'].map(mode => IDLE_MODE_LABELS[mode as keyof typeof IDLE_MODE_LABELS]), + ['Black Hole', 'Fireworks', 'Circletastic', 'raiseCatError', 'Random']); + for (const file of ['README.md', 'CHANGELOG.md', 'ROADMAP.md', 'src/idle/scenes.ts', 'src/idle/IdleVisuals.ts', 'src/help/helpContent.ts']) { + assert.doesNotMatch(readFileSync(new URL(`../${file}`, import.meta.url), 'utf8'), /Cat Playground|\bRings\b(?! \()/u, file); + } + assert.ok(IDLE_MODES.includes('circletastic') && IDLE_MODES.includes('raiseCatError')); +}); + +test('scene seam: screen modes draw the captured screen through the shared frame path and stay within the frame width', () => { + const grid = new CellGrid(); + for (const mode of ['blackHole', 'screenFireworks', 'circletastic', 'raiseCatError'] as const) { + const out = idleFrameRows(grid, {mode, width: WIDTH, height: HEIGHT, time: 8000, palette: idlePalette([{red: 166, green: 124, blue: 243}]), level: 'truecolor', nerd: true, capture: capture()}); + assert.equal(out.length, HEIGHT); + const early = idleFrameRows(grid, {mode, width: WIDTH, height: HEIGHT, time: 0, palette: idlePalette([{red: 166, green: 124, blue: 243}]), level: 'truecolor', nerd: true, capture: capture()}); + assert.ok(['TerminalApp', 'git status', 'lavender'].some(fragment => stripAnsi(early.join('\n')).includes(fragment)), `${mode} starts from the captured text`); + for (const row of out) assert.equal(displayWidth(row), WIDTH); + const plain = idleFrameRows(grid, {mode, width: WIDTH, height: HEIGHT, time: 8000, palette: idlePalette([{red: 166, green: 124, blue: 243}]), level: 'none', nerd: false, capture: capture()}); + assert.ok(plain.every(row => !row.includes('\u001b'))); + } + void renderScene; +}); + +test('performance: a 200x60 dense capture stays cheap per frame for every effect', () => { + const dense = Array.from({length: 60}, (_, y) => `line ${y} `.padEnd(200, 'abcdefghij')); + for (const id of SCREEN_EFFECTS) { + const c = captureFromRows(dense, 200, 60, 1); const grid = new CellGrid(); grid.resize(200, 60); + const started = performance.now(); + for (let t = 0; t <= 20_000; t += 66) renderScreenEffect(id, grid, c, ctx(t)); + const per = (performance.now() - started) / (20_000 / 66); + assert.ok(per < 12, `${id} ${per.toFixed(2)}ms/frame`); + } +}); + +import {TerminalApp} from '../src/app/TerminalApp.js'; +import type {TerminalFrame} from '../src/terminal/TerminalRenderer.js'; +import type {PromptConfiguration} from '../src/prompt/configuration.js'; + +function harness(patch: Partial) { + const isolation = isolateConfig(); + const app = new TerminalApp(); + const frames: TerminalFrame[] = []; + app['renderer'].render = (next: TerminalFrame) => { frames.push(next); }; + app['renderer'].snapshot = () => SCREEN; + app['fetchSuggestions'] = async () => {}; + app['presentationStarted'] = true; + app['startupPending'] = false; + app['configuration'] = {...app['configuration'], idleVisuals: {...app['configuration'].idleVisuals, timeout: 1, ...patch}}; + return {app, frames, cleanup: () => { app['stop'](0); app['session'].kill(); isolation.restore(); }}; +} +const goIdle = (app: TerminalApp) => { app['lastActivity'] = Date.now() - 10 * 60_000; app['onIdleTimeout'](); }; + +test('app: a screen saver animates the real visible screen, the first input only dismisses, and nothing leaks', () => { + for (const mode of ['blackHole', 'screenFireworks', 'circletastic', 'raiseCatError'] as const) { + const {app, frames, cleanup} = harness({mode}); + try { + app['editor'].insert('draft'); + goIdle(app); + assert.ok(app['idle']?.capture, `${mode} captured the screen`); + assert.ok(['TerminalApp', 'git status', 'lavender'].some(fragment => stripAnsi(frames.at(-1)!.rows.join('\n')).includes(fragment)), `${mode} starts from the user's own text`); + assert.ok(app['idleSubscription'], 'one frame subscription while active'); + app['onInput']('z'); + assert.equal(app['idle'], undefined); + assert.equal(app['editor'].text, 'draft', 'the dismissing key is consumed, not typed'); + assert.equal(app['idleSubscription'], undefined, 'no frame timer after dismissal'); + } finally { cleanup(); } + } +}); + +test('app: Random resolves to a registered screen effect; Reduced Motion never auto-starts a screen saver; busy and passthrough rules', () => { + const random = harness({mode: 'random'}); + try { goIdle(random.app); assert.ok(SCREEN_MODE_EFFECT[random.app['idle']!.mode], 'a concrete registered effect'); assert.ok(random.app['idle']!.random); } + finally { random.cleanup(); } + const old = process.env.NMSH_REDUCED_MOTION; + process.env.NMSH_REDUCED_MOTION = '1'; + const still = harness({mode: 'blackHole'}); + try { goIdle(still.app); assert.equal(still.app['idle'], undefined, 'reduced motion: not automatic'); } + finally { still.cleanup(); if (old === undefined) delete process.env.NMSH_REDUCED_MOTION; else process.env.NMSH_REDUCED_MOTION = old; } + const busy = harness({mode: 'circletastic'}); + try { + busy.app['running'] = {command: 'sleep 9', startedAt: 0, interrupted: false, cleared: false, startId: 0, cwd: '/'}; + goIdle(busy.app); assert.equal(busy.app['idle'], undefined, 'busy: off by default'); + } finally { busy.cleanup(); } + const allowed = harness({mode: 'circletastic', runWhileBusy: true}); + try { + allowed.app['running'] = {command: 'sleep 9', startedAt: 0, interrupted: false, cleared: false, startId: 0, cwd: '/'}; + goIdle(allowed.app); assert.ok(allowed.app['idle'], 'Run while busy relaxes ordinary work'); + allowed.app['onInput']('q'); + allowed.app['passthrough'] = true; + goIdle(allowed.app); assert.equal(allowed.app['idle'], undefined, 'but never over passthrough / a fullscreen program'); + } finally { allowed.cleanup(); } +}); + +test('app: a resize stops the saver and nothing is replayed against stale geometry', () => { + const {app, cleanup} = harness({mode: 'raiseCatError'}); + try { + goIdle(app); assert.ok(app['idle']); + app['onResize'](); + assert.equal(app['idle'], undefined); + assert.equal(app['idleSubscription'], undefined); + assert.ok(app['idleTimer'], 're-armed for the next idle period'); + } finally { cleanup(); } +}); From 663694cfab203553b599654be505080c2cef1f67 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:35:09 +0530 Subject: [PATCH 11/13] Cover the Run while busy setting in Setup --- src/setup/SetupCat.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index d0510b37..2475881a 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -353,6 +353,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ 'Off by default (Never). Any key, mouse or new output ends it and leaves everything exactly as it was.'], rows: [ {row: configRow('idleTimeout')}, {row: configRow('idleMode')}, + {row: configRow('idleRunBusy')}, {row: configRow('idleColor')}, routeRow('setupIdleColors', 'Edit idle colors', 'The idle visuals\' own gradient stops, with a live preview', 'idleColors', 'Idle visuals'), {row: configRow('activityColors'), note: () => 'The running-command line only; finished commands show their plain result'}, From 248ff29ca9f98894009a475e09485d327f6df701 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:40:26 +0530 Subject: [PATCH 12/13] Derive Random from the screensaver registry with natural loop boundaries --- src/app/TerminalApp.ts | 25 +++++++++------ src/idle/scenes.ts | 59 +++++++++++++++++++++++++++++++++-- src/idle/screenEffects.ts | 6 ---- tests/screenSavers.test.ts | 64 ++++++++++++++++++++++++++++++++------ 4 files changed, 125 insertions(+), 29 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2341d1e3..d83c3bfa 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -27,8 +27,8 @@ import {IDLE_FRAME_MS, type IdleMode} from '../idle/scenes.js'; import {createScreensaverPanel, effectiveMode, idleFrameRows, idleMotion, idlePaletteFor, previewSize, renderScreensaverPanel, sceneTime, SCREENSAVER_MIN_SIZE, screensaverKey, IDLE_SEED, type ScreensaverPanelState} from '../idle/IdleVisuals.js'; import {captureFromRows, cropCapture, type ScreenCapture} from '../idle/screenCapture.js'; -import {effectLoops, randomSequence} from '../idle/screenEffects.js'; -import {SCREEN_MODE_EFFECT} from '../idle/scenes.js'; +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, type InstallPromptState} from '../tools/InstallSuggestion.js'; @@ -391,7 +391,7 @@ export class TerminalApp { private saverCapture?: ScreenCapture; private saverGalleryCapture?: ScreenCapture; private saverGalleryMode?: IdleMode; - private randomSaver = randomSequence(0x5eed); + private randomSaver = makeRng(0x5eed); private idleSubscription?: () => void; private readonly idleGrid = new CellGrid(); private lastActivity = Date.now(); @@ -3714,8 +3714,7 @@ export class TerminalApp { } private pickRandomSaver(previous?: IdleMode): IdleMode { - const effect = this.randomSaver(previous ? SCREEN_MODE_EFFECT[previous] : undefined); - return (Object.entries(SCREEN_MODE_EFFECT).find(([, id]) => id === effect)?.[0] ?? 'blackHole') as IdleMode; + return (pickRandomSaver(this.randomSaver, previous) ?? 'blackHole') as IdleMode; } private tickIdle(now: number): void { @@ -3723,11 +3722,17 @@ export class TerminalApp { if (!idle || idle.paused) return; idle.frame += 1; // Random switches only when the current effect finished a full loop, never mid-effect. - const effect = SCREEN_MODE_EFFECT[idle.mode]; - if (idle.random && idle.capture && effect && effectLoops(idle.capture, effect) >= 1) { - idle.mode = this.pickRandomSaver(idle.mode); - idle.capture = {...idle.capture, instances: {}}; - idle.offset = isDeterministicPresentation() ? idle.frame * IDLE_FRAME_MS[idle.mode] : Date.now() - idle.startedAt; + if (idle.random) { + const {columns, rows} = this.dimensions(); + const sceneNow = isDeterministicPresentation() ? idle.frame * (SAVER_FRAME_MS[idle.mode] ?? 66) : now - idle.startedAt; + if (saverLoopComplete(idle.mode, {elapsed: sceneNow - idle.offset, width: columns, height: rows, ...(idle.capture ? {capture: idle.capture} : {})})) { + idle.mode = this.pickRandomSaver(idle.mode); + if (idle.capture) idle.capture = {...idle.capture, instances: {}}; + idle.offset = sceneNow; + idle.interval = SAVER_FRAME_MS[idle.mode] ?? 66; + this.stopIdleFrames(); + this.idleSubscription = presentationClock.subscribe(next => this.tickIdle(next), idle.interval); + } } const started = performance.now(); this.paintIdle(now); diff --git a/src/idle/scenes.ts b/src/idle/scenes.ts index 24b4c6fd..8bb29d1c 100644 --- a/src/idle/scenes.ts +++ b/src/idle/scenes.ts @@ -4,7 +4,7 @@ import type {Rgb} from '../chroma/escape.js'; import type {ColorLevel} from '../presentation/capabilities.js'; import {UI_COLORS} from '../ui/palette.js'; import type {ScreenCapture} from './screenCapture.js'; -import {renderScreenEffect, type ScreenEffectId} from './screenEffects.js'; +import {effectLoops, renderScreenEffect, type ScreenEffectId} from './screenEffects.js'; /** * Idle visuals: deterministic, bounded, terminal-native scenes. Every scene is @@ -27,7 +27,7 @@ export const IDLE_MODE_NOTES: Record = { sparkles: 'sparse sparkles that brighten and fade', fireworks: 'occasional bursts with gentle gravity', vespyr: 'Vespyr the NMSh cat bouncing around', - random: 'one of the four screen savers below, changing only after a full loop', + random: 'one of all available screen savers, changing only after a full loop', blackHole: 'your screen text spirals into a black hole, then rebuilds', screenFireworks: 'shells launch across your screen and burst through its text', circletastic: 'your screen text gathers into spinning rings, flies apart, and reforms', @@ -522,10 +522,63 @@ const SCENES: Record void> /** Paint one frame of `mode` into `grid`. */ export function renderScene(mode: IdleMode, grid: CellGrid, options: SceneOptions): void { - SCENES[mode](grid, options); + (SAVER_REGISTRY.find(entry => entry.id === mode)?.render ?? SCENES[mode] ?? deepSpace)(grid, options); } /** The scene shown for a mode under Reduced Motion: calm modes hold still; high-motion modes become a still star field. */ export function reducedMotionScene(mode: IdleMode): IdleMode { return HIGH_MOTION.has(mode) ? 'deepSpace' : mode; } + + +// ---- registry ---------------------------------------------------------------- +/** + * The canonical list of screen savers. Random derives its candidates from this + * registry (everything except itself and entries marked `randomEligible: + * false`), so registering a saver here is all a new one needs; Random has no + * list of its own. `loop` says when one natural loop has completed: + * stateful screen effects report their own loop counter, scenes that are + * pure functions of time declare their cycle length. + */ +export interface SaverDescriptor { + id: string; + /** Default true. False only for manual-only, debugging or test entries. */ + randomEligible?: boolean; + /** Stateless scene cycle in ms for a given size; ambient scenes with no natural boundary declare a fixed dwell. */ + cycleMs?: (size: {width: number; height: number}) => number; + /** Stateful screen effect whose own loop counter marks the boundary. */ + effect?: ScreenEffectId; + /** Custom renderer for a saver registered outside this file. */ + render?: (grid: CellGrid, options: SceneOptions) => void; +} + +const SPARKLE_CYCLE = 5200; +export const SAVER_REGISTRY: SaverDescriptor[] = [ + {id: 'aurora', cycleMs: () => 40_000}, {id: 'deepSpace', cycleMs: () => 30_000}, {id: 'warp', cycleMs: () => 20_000}, {id: 'rain', cycleMs: () => 20_000}, + {id: 'sparkles', cycleMs: () => SPARKLE_CYCLE * 4}, + {id: 'fireworks', cycleMs: () => LAUNCH_PERIOD * 6}, + // One full vertical round trip of the bouncing cat. + {id: 'vespyr', cycleMs: ({height}) => Math.max(1, (2 * Math.max(1, height - CAT_HEIGHT)) / 2.4) * 1000}, + {id: 'random', randomEligible: false}, + {id: 'blackHole', effect: 'blackHole'}, {id: 'screenFireworks', effect: 'fireworks'}, {id: 'circletastic', effect: 'circletastic'}, {id: 'raiseCatError', effect: 'raiseCatError'}, +]; + +export function randomCandidates(registry: readonly SaverDescriptor[] = SAVER_REGISTRY): string[] { + return registry.filter(entry => entry.id !== 'random' && entry.randomEligible !== false).map(entry => entry.id); +} + +/** Next Random choice: from the registry, never the previous one when there is a choice. */ +export function pickRandomSaver(rng: () => number, previous?: string, registry: readonly SaverDescriptor[] = SAVER_REGISTRY): string | undefined { + const all = randomCandidates(registry); + const options = all.length > 1 ? all.filter(id => id !== previous) : all; + return options.length ? options[Math.floor(rng() * options.length)] : undefined; +} + +/** True once the saver has completed one natural loop since it started (`elapsed` ms of its own scene time). */ +export function saverLoopComplete(id: string, state: {elapsed: number; width: number; height: number; capture?: ScreenCapture}, + registry: readonly SaverDescriptor[] = SAVER_REGISTRY): boolean { + const entry = registry.find(item => item.id === id); + if (!entry) return true; + if (entry.effect && state.capture) return effectLoops(state.capture, entry.effect) >= 1; + return entry.cycleMs ? state.elapsed >= entry.cycleMs(state) : false; +} diff --git a/src/idle/screenEffects.ts b/src/idle/screenEffects.ts index 5bea6495..da1f62e1 100644 --- a/src/idle/screenEffects.ts +++ b/src/idle/screenEffects.ts @@ -717,12 +717,6 @@ export function renderScreenEffect(id: ScreenEffectId, grid: CellGrid, capture: instance.paint(grid, ctx); } -/** Random mode: a seeded sequence over the registered effects that never repeats the previous one immediately. */ -export function randomSequence(seed: number): (previous?: ScreenEffectId) => ScreenEffectId { - const rng = makeRng(seed); - return previous => { const options = SCREEN_EFFECTS.filter(id => id !== previous); return options[Math.floor(rng() * options.length)]!; }; -} - /** How many full loops the capture's running effect has completed (0 when none). */ export function effectLoops(capture: ScreenCapture, id: ScreenEffectId): number { return ((capture.instances[id] as Sim | undefined)?.loops) ?? 0; diff --git a/tests/screenSavers.test.ts b/tests/screenSavers.test.ts index fafa231f..ae9a450d 100644 --- a/tests/screenSavers.test.ts +++ b/tests/screenSavers.test.ts @@ -5,9 +5,9 @@ import {CellGrid, NO_COLOR_VALUE} from '../src/idle/CellGrid.js'; import {captureFromRows, cropCapture} from '../src/idle/screenCapture.js'; import { ASPECT, BlackHole, Circletastic, DIAGNOSTIC_CAP, FIREWORK_CAPS, Fireworks, RaiseCatError, SCREEN_EFFECTS, extractPlatforms, layoutRings, makeRng, - randomSequence, renderScreenEffect, ringCapacity, type EffectContext, type EffectPalette, + renderScreenEffect, ringCapacity, type EffectContext, type EffectPalette, } from '../src/idle/screenEffects.js'; -import {IDLE_MODE_LABELS, IDLE_MODES, SCREEN_MODE_EFFECT, idlePalette, renderScene} from '../src/idle/scenes.js'; +import {IDLE_MODE_LABELS, IDLE_MODES, IDLE_MODE_NOTES, SAVER_REGISTRY, SCREEN_MODE_EFFECT, idlePalette, pickRandomSaver, randomCandidates, renderScene, saverLoopComplete, type SaverDescriptor} from '../src/idle/scenes.js'; import {idleFrameRows} from '../src/idle/IdleVisuals.js'; import {displayWidth, stripAnsi} from '../src/util/text.js'; import {readFileSync} from 'node:fs'; @@ -217,13 +217,41 @@ test('raiseCatError: the real captured data is never mutated; reproducible with assert.ok(grid.bg.every(v => v === NO_COLOR_VALUE) || true); }); -test('Random picks only registered effects, never the same twice in a row, and is repeatable from a seed', () => { - const a = randomSequence(5), b = randomSequence(5); - let previous: (typeof SCREEN_EFFECTS)[number] | undefined; const picks: string[] = []; - for (let i = 0; i < 40; i += 1) { const id = a(previous); assert.ok(SCREEN_EFFECTS.includes(id)); assert.notEqual(id, previous); previous = id; picks.push(id); } - let previous2: (typeof SCREEN_EFFECTS)[number] | undefined; - assert.deepEqual(picks, picks.map(() => { const id = b(previous2); previous2 = id; return id; })); - assert.deepEqual(Object.values(SCREEN_MODE_EFFECT).sort(), [...SCREEN_EFFECTS].sort()); +test('Random derives its candidates from the registry: everything registered except itself and ineligible entries', () => { + const candidates = randomCandidates(); + assert.ok(!candidates.includes('random'), 'never itself'); + for (const id of ['sparkles', 'fireworks', 'vespyr', 'blackHole', 'screenFireworks', 'circletastic', 'raiseCatError']) assert.ok(candidates.includes(id), id); + assert.deepEqual(candidates, IDLE_MODES.filter(id => id !== 'random'), 'every registered saver'); + assert.match(IDLE_MODE_NOTES.random, /one of all available screen savers, changing only after a full loop/); + const registry: SaverDescriptor[] = [{id: 'a'}, {id: 'random'}, {id: 'b', randomEligible: false}, {id: 'c'}]; + assert.deepEqual(randomCandidates(registry), ['a', 'c']); + // A future saver is picked with no change to Random: only a registry entry. + const future: SaverDescriptor[] = [...registry, {id: 'synthetic-future'}]; + const rng = makeRng(1); const seen = new Set(); let previous: string | undefined; + for (let i = 0; i < 200; i += 1) { const id = pickRandomSaver(rng, previous, future)!; seen.add(id); assert.notEqual(id, previous, 'no immediate repeat'); previous = id; } + assert.deepEqual([...seen].sort(), ['a', 'c', 'synthetic-future']); + assert.ok(!seen.has('b') && !seen.has('random')); + assert.equal(pickRandomSaver(makeRng(1), 'only', [{id: 'only'}]), 'only', 'a single choice may repeat'); + assert.equal(pickRandomSaver(makeRng(1), undefined, [{id: 'random'}]), undefined); + const again = (seed: number) => { const r = makeRng(seed); let p: string | undefined; return Array.from({length: 30}, () => (p = pickRandomSaver(r, p)!)); }; + assert.deepEqual(again(9), again(9), 'repeatable from a seed'); + // The real registry is what the app uses. + const real = SAVER_REGISTRY.length; + SAVER_REGISTRY.push({id: 'synthetic-live'}); + try { assert.ok(randomCandidates().includes('synthetic-live')); } finally { SAVER_REGISTRY.length = real; } +}); + +test('loop boundaries: stateful effects report their own loop; stateless scenes declare a natural cycle', () => { + const c = capture(); const size = {width: WIDTH, height: HEIGHT}; + assert.equal(saverLoopComplete('circletastic', {elapsed: 1e9, ...size, capture: c}), false, 'no time-based switching for a stateful effect'); + renderScreenEffect('circletastic', new CellGrid(), c, ctx(0)); + const fx = c.instances.circletastic as Circletastic; + for (let t = 50; t < 60_000 && fx.loops < 1; t += 50) fx.advance(t); + assert.equal(saverLoopComplete('circletastic', {elapsed: 0, ...size, capture: c}), true); + assert.equal(saverLoopComplete('vespyr', {elapsed: 1000, ...size}), false); + assert.equal(saverLoopComplete('vespyr', {elapsed: 60_000, ...size}), true); + assert.equal(saverLoopComplete('sparkles', {elapsed: 5000, ...size}), false); + for (const entry of SAVER_REGISTRY.filter(item => item.id !== 'random')) assert.ok(entry.effect || entry.cycleMs, `${entry.id} declares a loop boundary`); }); test('user-facing names: Black Hole, Fireworks, Circletastic, raiseCatError; the old Rings / Cat Playground names are gone', () => { @@ -297,7 +325,7 @@ test('app: a screen saver animates the real visible screen, the first input only test('app: Random resolves to a registered screen effect; Reduced Motion never auto-starts a screen saver; busy and passthrough rules', () => { const random = harness({mode: 'random'}); - try { goIdle(random.app); assert.ok(SCREEN_MODE_EFFECT[random.app['idle']!.mode], 'a concrete registered effect'); assert.ok(random.app['idle']!.random); } + try { goIdle(random.app); assert.ok(randomCandidates().includes(random.app['idle']!.mode), 'a concrete registered saver'); assert.ok(random.app['idle']!.random); } finally { random.cleanup(); } const old = process.env.NMSH_REDUCED_MOTION; process.env.NMSH_REDUCED_MOTION = '1'; @@ -329,3 +357,19 @@ test('app: a resize stops the saver and nothing is replayed against stale geomet assert.ok(app['idleTimer'], 're-armed for the next idle period'); } finally { cleanup(); } }); + +test('app: Random switches only at a loop boundary, never mid-cycle, and keeps one frame subscription', () => { + const {app, cleanup} = harness({mode: 'random'}); + try { + goIdle(app); + const idle = app['idle']!; + idle.mode = 'warp'; idle.offset = 0; idle.startedAt = Date.now(); + app['tickIdle'](Date.now()); + assert.equal(app['idle']!.mode, 'warp', 'mid-cycle: no switch'); + idle.startedAt = Date.now() - 25_000; + app['tickIdle'](Date.now()); + assert.notEqual(app['idle']!.mode, 'warp', 'cycle complete: a different registered saver'); + assert.ok(randomCandidates().includes(app['idle']!.mode)); + assert.ok(app['idleSubscription']); + } finally { cleanup(); } +}); From 4852adabd024fa6a1bf6072d4438ffeaafa1e3de Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:46:31 +0530 Subject: [PATCH 13/13] CI: leave init-dependent clipboard process tests out of the Fedora container subset --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 820f568e..0cd41764 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,7 @@ jobs: name: Linux portability subset (Fedora) runs-on: ubuntu-24.04 timeout-minutes: 15 - container: fedora:42 + container: fedora:42 # clipboard process-lifetime tests need a real init to reap children; they run on Ubuntu and macOS steps: - name: Install system prerequisites run: dnf install -y git zsh fish gcc-c++ make python3 which lsof procps-ng @@ -118,7 +118,7 @@ jobs: run: npm run typecheck - name: Platform, package-manager, updater and shell-backend tests - run: node --import=tsx --test --test-timeout=120000 tests/linuxPlatform.test.ts tests/clipboard.test.ts tests/tools.test.ts tests/update.test.ts tests/hostProfiles.test.ts tests/portabilityUninstall.test.ts + run: node --import=tsx --test --test-timeout=120000 tests/linuxPlatform.test.ts tests/tools.test.ts tests/update.test.ts tests/hostProfiles.test.ts tests/portabilityUninstall.test.ts env: NMSH_DISABLE_UPDATES: '1' COLORTERM: truecolor