From 488248cfdb4675a8db67878444ec659de3f5731e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:55:43 +0530 Subject: [PATCH 1/5] Polish v0.17: active shell in Shell Environment, saver compositing, Circletastic, raiseCatError, and known-tool command-not-found --- CHANGELOG.md | 5 + ROADMAP.md | 2 +- src/app/TerminalApp.ts | 25 +- src/ask/commands.ts | 9 +- src/cli/doctor.ts | 6 +- src/idle/catSprite.ts | 62 +++ src/idle/scenes.ts | 14 +- src/idle/screenCapture.ts | 27 +- src/idle/screenEffects.ts | 644 +++++++++++++++++++-------- src/shell/ShellEnvironment.ts | 47 +- src/shell/tldr.ts | 16 +- src/tools/InstallSuggestion.ts | 5 +- src/tools/catalog.ts | 36 +- tests/askTldr.test.ts | 4 +- tests/knownTools.test.ts | 92 ++++ tests/screenSavers.test.ts | 357 ++++++++++++--- tests/shellEnvironmentActive.test.ts | 67 +++ tests/toolLifecycle.test.ts | 6 +- tests/tools.test.ts | 2 +- 19 files changed, 1102 insertions(+), 324 deletions(-) create mode 100644 src/idle/catSprite.ts create mode 100644 tests/knownTools.test.ts create mode 100644 tests/shellEnvironmentActive.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c36bdfc..237f6d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Screensavers - Four screen-based savers join the idle visuals: **Black Hole**, **Fireworks**, **Circletastic** and **raiseCatError**, plus **Random** (switches only after a full loop). They animate the current screen's own text as presentation only (transcript, PTY, history and journals are untouched), keep the host background, dismiss on the first input (which is consumed), stop on resize, and never start automatically under Reduced Motion. Still off by default (Idle visuals: Never); new **Run while busy** setting never overrides passthrough or fullscreen programs. The earlier scene is now labelled Night Fireworks. +### Polish +- Shell Environment (Status and `nmsh doctor`) starts with the shell backing the session (never inferred from `$SHELL`), says "none · plain zsh/bash/fish" when there is no framework, and scopes framework and plugin rows to that shell; other shells' environments are listed separately. +- Screensavers: the capture keeps authored backgrounds and readable glyph colors (no black-on-dark chrome), Circletastic forms a few small circles completely before it stabilizes, rotates, accelerates and explodes (all at once or staggered, keeping ring momentum), and raiseCatError now uses the NMSh cat sprite, roams the whole screen, overlaps text freely, meows, and sometimes sits on a purely visual fake keyboard (never reaching the editor or shell). +- Any exact command in the curated `/tools` catalog (not only Recommended ones) is recognized when missing; the prompt names the package when it differs (`tldr` is provided by tealdeer). TLDR (tealdeer) is now Recommended; Ask uses only its local cache (`--no-auto-update`) and says when examples are unavailable. + ### Updates and sessions - **Automatic updates** (Automatic / Notify only / Off, Daily or Weekly). New installs default to Automatic / Daily; saved Daily/Weekly checks migrate to Notify only and Off stays Off. Automatic prepares a verified stable release only where `/update apply`'s own checks pass, with the same build verification and rollback; the running session keeps its version. Status shows Running version, Latest, Mode and State. - Detached sessions can be ended from the startup picker with `X` and confirmation; the transcript is archived and stays in `/resume`. diff --git a/ROADMAP.md b/ROADMAP.md index 26466221..7f9a040f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -167,7 +167,7 @@ In v0.16.0 zsh, Fish and Bash 4.4+ are implemented backends behind a real [Shell ## Design Principles -- NMSh is a frontend over a persistent real zsh session. +- NMSh is a frontend over a persistent real shell session (zsh, Fish or Bash through ShellAdapter). - The terminal host renders cells and interprets ANSI; NMSh is not a terminal emulator. - Raw PTY output remains recoverable and is not semantically recolored. - Fullscreen applications retain the passthrough path. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index d83c3bfa..88cb14bd 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -30,9 +30,10 @@ import {captureFromRows, cropCapture, type ScreenCapture} from '../idle/screenCa import {makeRng} from '../idle/screenEffects.js'; import {SCREEN_MODE_EFFECT, pickRandomSaver, saverLoopComplete, IDLE_FRAME_MS as SAVER_FRAME_MS} from '../idle/scenes.js'; import {createThemeStudio, renderThemeStudio, STUDIO_MIN_SIZE, studioKey, writeThemeExport, type ThemeStudioState} from '../appearance/ThemeStudio.js'; -import {createInstallPrompt, ignoreInstallSuggestion, installCandidate, installPromptKey, renderInstallPrompt, shouldOfferInstall, +import {commandWord, createInstallPrompt, ignoreInstallSuggestion, installCandidate, installPromptKey, renderInstallPrompt, shouldOfferInstall, type InstallPromptState} from '../tools/InstallSuggestion.js'; -import {toolInstall, TOOLS} from '../tools/catalog.js'; +import {knownToolForExecutable, suggestibleToolFor, toolInstall, TOOLS, type Tool} from '../tools/catalog.js'; +import {planPackageInstall} from '../packages/managers.js'; import {loadToolUpdateState, runToolUpdateCheck, toolUpdateCheckDue, type ToolUpdateState} from '../tools/ToolUpdates.js'; import type {CommandSource} from '../shell/SemanticService.js'; import {GLYPHS, setIconStyle, getCurrentGlyphMode, setPromptSymbol} from '../ui/glyphs.js'; @@ -450,7 +451,7 @@ export class TerminalApp { private cachedEnvironment?: ShellEnvironmentReport; private stripSampling = false; /** Frontend PATH and recipe lookups for install offers; replaceable in tests. */ - private installProbe = {onPath: (name: string) => resolveCommand(name) !== undefined, recipe: toolInstall}; + private installProbe = {onPath: (name: string) => resolveCommand(name) !== undefined, recipe: (tool: Tool) => planPackageInstall(tool) ?? toolInstall(tool)}; private misePanel?: MisePanel; private readonly miseService = new MiseProjectService(); private toolConfigurationLoading = false; @@ -2723,6 +2724,9 @@ export class TerminalApp { this.output.setCompletionLifecycle(`${parts.main}${parts.detail}`); const rowStyle = isInterrupted ? STOPPED : (exitCode !== 0 ? ERROR : SUCCESS); this.output.addHistoryLine(`${rowStyle}${parts.main}${SECONDARY}${parts.detail}${RESET}`); + // The raw shell error stays; a curated command NMSh knows gets a factual follow-up (identity only, no lookup or install). + const known = failure === 'command-not-found' ? this.knownMissingNote(command.command) : undefined; + if (known) this.output.addHistoryLine(`${INFO}${known}${RESET}`); } this.running = undefined; if (!this.replaying && !command.cleared && completedRecord) { @@ -4303,6 +4307,15 @@ export class TerminalApp { * zsh cannot resolve (no alias, function, builtin or executable), offer an install instead. Returns * true when the offer is shown; the command text stays in the composer meanwhile. */ + /** One line for an exact curated command that is not installed; undefined for unknown commands (nothing is invented). */ + private knownMissingNote(command: string): string | undefined { + const word = commandWord(command); + const tool = word ? knownToolForExecutable(word) : undefined; + if (!word || !tool || resolveCommand(word) !== undefined) return undefined; + const provided = tool.package && tool.package !== word ? ` · provided by ${tool.package}` : ''; + return `${tool.label} is not installed${provided} · install it from /tools`; + } + private async offerInstallFor(command: string): Promise { const tool = installCandidate(command, this.promptConfiguration); if (!tool || !tool.executable) return false; @@ -4433,7 +4446,7 @@ export class TerminalApp { {label: 'Completion sources', value: this.completionService.sourceIds.join(' + ')}, {label: 'History colors', value: config.transcript.historyColors === 'followPrompt' ? 'Follow prompt' : config.transcript.historyColors === 'theme' ? 'Theme' : 'Grayscale'}, ]), - statusSection('Shell Environment', shellEnvironmentRows(this.shellEnvironment).map(([label, value]) => ({label, value}))), + statusSection('Shell Environment', shellEnvironmentRows(this.shellEnvironment, {id: this.shellId, ...(shellAdapter(this.shellId).resolveExecutable(process.env) ? {path: shellAdapter(this.shellId).resolveExecutable(process.env)!} : {})}).map(([label, value]) => ({label, value}))), statusSection('Services & Activity', [ {label: 'Session notices', value: config.sessionNotices ? (this.sessionMode === 'service' ? 'On' : 'On (needs the live-session service)') : 'Off'}, {label: 'Agent activity', value: config.agentActivity ? 'On · local only' : 'Off'}, @@ -6261,8 +6274,8 @@ export class TerminalApp { return path ? {kind: 'executable', path} : undefined; }, ...(resolveCommand('tldr') ? {examples: (path: readonly string[]) => tldrExamples(resolveCommand('tldr'), path)} : {}), install: name => { // Only a curated /tools entry for this exact executable name; never a guessed package. - const tool = TOOLS.find(item => (item.executable ?? item.id) === name && !item.legacy); - const recipe = tool ? toolInstall(tool) : undefined; + const tool = suggestibleToolFor(name); + const recipe = tool ? (planPackageInstall(tool) ?? toolInstall(tool)) : undefined; return tool && recipe ? {tool: tool.id, label: recipe.label} : undefined; }}; } diff --git a/src/ask/commands.ts b/src/ask/commands.ts index a87f7ba0..4776feaf 100644 --- a/src/ask/commands.ts +++ b/src/ask/commands.ts @@ -145,9 +145,12 @@ export function answerCommandQuestion(question: CommandQuestion, context: AskCon lines.push('', `Subcommands include ${facts.subcommands.slice(0, 8).map(item => item.names[0]).join(', ')}${facts.subcommands.length > 8 ? ', …' : ''}.`); } // Practical examples (optional TLDR) when asked for, or when local syntax facts are sparse. - if (question.intent === 'examples' || (question.intent === 'syntax' && !facts.options.length)) { + // Plain definitions ('explain') stay short and never carry examples. + if (question.intent === 'examples' || question.intent === 'syntax') { const examples = env.examples?.(facts.path) ?? []; - if (examples.length) lines.push('', 'Examples from TLDR', ...examples.slice(0, 6).flatMap(example => [` ${example.description}`, ` ${example.command}`])); + const shown = question.intent === 'examples' ? 6 : facts.options.length ? 3 : 5; + if (examples.length) lines.push('', 'Examples from TLDR', ...examples.slice(0, shown).flatMap(example => [` ${example.description}`, ` ${example.command}`])); + else if (!env.examples) lines.push('', 'Local TLDR examples are unavailable because tealdeer is not installed.'); else if (question.intent === 'examples') lines.push('', `No local TLDR examples for ${path}.`); } if (PROVIDER_TOOLS.has(name) && concept.concepts[0]) lines.push('', `In NMSh: ${concept.concepts[0].description}`); @@ -160,7 +163,7 @@ export function answerCommandQuestion(question: CommandQuestion, context: AskCon else lines.push('NMSh has no curated install recipe for it.'); } if (question.intent === 'explain' && syntax) next.push({key: `syntax:${path}`, label: 'Show syntax and useful options', refine: `how do i use ${path}`}); - if (question.intent === 'examples' && !env.examples && env.install?.('tldr')) next.push({key: 'install:tldr', label: 'Install tealdeer for TLDR examples', outcome: installProposal('tldr', env.install('tldr')!)}); + if ((question.intent === 'examples' || question.intent === 'syntax') && !env.examples && env.install?.('tldr')) next.push({key: 'install:tldr', label: 'Install TLDR (tealdeer)', outcome: installProposal('tldr', env.install('tldr')!)}); if (question.intent === 'syntax' && facts.options.length > 6) next.push({key: `options:${path}`, label: `All ${facts.options.length} options`, refine: `what flags does ${path} have`}); return {kind: 'answer', capability: 'help.command', text: lines.join('\n'), ...(syntax && question.intent !== 'explain' ? {block: commandBlock(facts.path, 'reference')} : {}), ...(next.length ? {next: next.slice(0, 4)} : {}), referents: {command: facts.path}}; diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 17663c98..fd50a887 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -5,6 +5,7 @@ import {detectTerminalHost} from '../host/terminalHost.js'; import {defaultRuntimeDir} from '../session/runtimeDir.js'; import {detectShellEnvironment, OWNERSHIP_NOTE, shellEnvironmentRows} from '../shell/ShellEnvironment.js'; import {loadPromptConfiguration} from '../prompt/configuration.js'; +import {shellAdapter} from '../shell/adapters/registry.js'; import {formatBuildIdentity, readBuildIdentity} from '../buildInfo.js'; /** @@ -17,6 +18,9 @@ export function doctorReport(env: NodeJS.ProcessEnv = process.env, extra: Array< const platform = detectPlatform(); const host = detectTerminalHost(env); const config = loadPromptConfiguration(); + // The default backend for new sessions (doctor has no live session); never $SHELL. + const resolved = shellAdapter(config.shellBackend).resolveExecutable(process.env); + const activeShell = {id: config.shellBackend, ...(resolved ? {path: resolved} : {})}; const rows: Array<[string, string]> = [ ['NMSh', formatBuildIdentity(readBuildIdentity())], ['Node', process.version], @@ -31,7 +35,7 @@ export function doctorReport(env: NodeJS.ProcessEnv = process.env, extra: Array< ['Picker provider', config.picker], ['Session notices', config.sessionNotices ? 'On' : 'Off'], ['Agent activity', config.agentActivity ? 'On (local only)' : 'Off'], - ...shellEnvironmentRows(detectShellEnvironment()), + ...shellEnvironmentRows(detectShellEnvironment(), activeShell), ['Config file', tilde(promptConfigurationPath(env))], ['Runtime directory', tilde(defaultRuntimeDir(env))], ]; diff --git a/src/idle/catSprite.ts b/src/idle/catSprite.ts new file mode 100644 index 00000000..e6583d8f --- /dev/null +++ b/src/idle/catSprite.ts @@ -0,0 +1,62 @@ +/** + * The NMSh cat (Vespyr) as pixels: the approved Welcome/idle sprite, one + * source for both the bouncing-Vespyr scene and raiseCatError. L is body, E an + * eye; every two pixel rows make one terminal row (half blocks). Poses are + * derived from the same pixels so the silhouette (ears, eyes, body, tail, + * four legs) is always recognizably the same cat. + */ +export const CAT_PIXELS: readonly string[] = [ + '.L...L........', + '.LLLLL........', + '.LELEL.......L', + '.LELEL......L.', + '.LLLLLLLLLLL..', + '..LLLLLLLLLL..', + '..LLLLLLLLLL..', + '..L.L....L.L..', +]; +export const CAT_CELL_WIDTH = 14; +export const CAT_CELL_HEIGHT = 4; +export const CAT_BODY = 0xac96e6; +export const CAT_EYE = 0x161220; +/** The plain-character cat for NO_COLOR / no-color terminals (7 × 3). */ +export const CAT_ASCII: readonly string[] = [' /\\_/\\ ', '( o.o )', ' > ^ < ']; +export const CAT_ASCII_BLINK: readonly string[] = [' /\\_/\\ ', '( -.- )', ' > ^ < ']; + +export type CatPose = 'idle' | 'blink' | 'walkA' | 'walkB' | 'tail' | 'crouch' | 'jump' | 'sit' | 'land' | 'paw'; + +const set = (row: string, column: number, value: string) => `${row.slice(0, column)}${value}${row.slice(column + 1)}`; + +export function catPixels(pose: CatPose): string[] { + const base = [...CAT_PIXELS]; + switch (pose) { + case 'blink': return base.map((row, i) => (i === 2 || i === 3 ? row.replace(/E/gu, 'L') : row)); + case 'walkA': base[7] = '..LL.....LL...'; return base; + case 'walkB': base[7] = '...L.L..L.L...'; return base; + case 'tail': base[0] = set(base[0]!, 13, 'L'); base[1] = set(base[1]!, 12, 'L'); base[2] = '.LELEL......L.'; base[3] = '.LELEL........'; return base; + case 'paw': base[7] = '..L.L.....LLL.'; base[4] = '.LLLLLLLLLLLLL'; return base; + case 'sit': base[7] = '..LL......LL..'; base[3] = '.LELEL.......L'; return base; + case 'jump': base[7] = '..............'; base[6] = '..L.LLLLLL.L..'; base[0] = set(base[0]!, 13, 'L'); return base; + case 'crouch': case 'land': return ['..............', ...base.slice(0, 6), '..L.L....L.L..']; + default: return base; + } +} + +export interface CatCell {dx: number; dy: number; glyph: '▀' | '▄'; fg: number; bg?: number} + +/** Terminal cells for a pose; the sprite faces left unless mirrored. */ +export function catCells(pose: CatPose, facingRight: boolean): CatCell[] { + const pixels = catPixels(pose); + const color = (pixel: string) => (pixel === 'L' ? CAT_BODY : pixel === 'E' ? CAT_EYE : undefined); + const cells: CatCell[] = []; + for (let row = 0; row < CAT_CELL_HEIGHT; row += 1) { + for (let column = 0; column < CAT_CELL_WIDTH; column += 1) { + const source = facingRight ? CAT_CELL_WIDTH - 1 - column : column; + const top = color(pixels[row * 2]![source]!), bottom = color(pixels[row * 2 + 1]![source]!); + if (top === undefined && bottom === undefined) continue; + if (top !== undefined) cells.push({dx: column, dy: row, glyph: '▀', fg: top, ...(bottom !== undefined ? {bg: bottom} : {})}); + else cells.push({dx: column, dy: row, glyph: '▄', fg: bottom!}); + } + } + return cells; +} diff --git a/src/idle/scenes.ts b/src/idle/scenes.ts index 8bb29d1c..1eba2e70 100644 --- a/src/idle/scenes.ts +++ b/src/idle/scenes.ts @@ -3,6 +3,7 @@ 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 {CAT_PIXELS} from './catSprite.js'; import type {ScreenCapture} from './screenCapture.js'; import {effectLoops, renderScreenEffect, type ScreenEffectId} from './screenEffects.js'; @@ -429,17 +430,8 @@ function fireworks(grid: CellGrid, options: SceneOptions): void { // ---- Bouncing Vespyr -------------------------------------------------------------- -/** The approved Vespyr pixels (see output/Welcome.ts): L body, E eye, two pixel rows per cell. */ -const CAT = [ - '.L...L........', - '.LLLLL........', - '.LELEL.......L', - '.LELEL......L.', - '.LLLLLLLLLLL..', - '..LLLLLLLLLL..', - '..LLLLLLLLLL..', - '..L.L....L.L..', -]; +/** The approved Vespyr pixels live in catSprite.ts (shared with raiseCatError). */ +const CAT = CAT_PIXELS; const CAT_WIDTH = 14; const CAT_HEIGHT = 4; const BODY = pack({red: 172, green: 150, blue: 230}); diff --git a/src/idle/screenCapture.ts b/src/idle/screenCapture.ts index ceaa2bbd..0c0479c8 100644 --- a/src/idle/screenCapture.ts +++ b/src/idle/screenCapture.ts @@ -14,6 +14,8 @@ export interface ScreenCapture { height: number; glyphs: string[]; fg: Int32Array; + /** Authored background per cell (badges, selection, panels); NO_COLOR_VALUE where the host background shows through. */ + bg: Int32Array; /** Per-effect state, created lazily by the effect and discarded with the capture. */ instances: Record; seed: number; @@ -33,8 +35,9 @@ function xterm256(n: number): number { 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); + const bg = 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 color = NO_COLOR_VALUE, back = NO_COLOR_VALUE, inverse = false; let x = 0; const line = rows[y]!.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/gu, ''); for (let i = 0; i < line.length && x < width;) { @@ -46,7 +49,15 @@ export function captureFromRows(rows: readonly string[], width: number, height: 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; + if (code === 0) { color = NO_COLOR_VALUE; back = NO_COLOR_VALUE; inverse = false; } + else if (code === 39) color = NO_COLOR_VALUE; + else if (code === 49) back = NO_COLOR_VALUE; + else if (code === 7) inverse = true; + else if (code === 27) inverse = false; + else if (code >= 40 && code <= 47) back = BASIC[code - 40]!; + else if (code >= 100 && code <= 107) back = BASIC[code - 100 + 8]!; + else if (code === 48 && p[k + 1] === 2) { back = pack({red: p[k + 2] ?? 0, green: p[k + 3] ?? 0, blue: p[k + 4] ?? 0}); k += 4; } + else if (code === 48 && p[k + 1] === 5) { back = xterm256(p[k + 2] ?? 0); k += 2; } 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; } @@ -61,12 +72,16 @@ export function captureFromRows(rows: readonly string[], width: number, height: 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; } + // Reverse video swaps the pair; an unset side keeps the host's default (-1). + const cellFg = inverse ? back : color, cellBg = inverse ? color : back; + if (point !== ' ') { glyphs[index] = point; fg[index] = cellFg; } + bg[index] = cellBg; + if (cells === 2 && x + 1 < width) bg[index + 1] = cellBg; // 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}; + return {width, height, glyphs, fg, bg, instances: {}, seed}; } /** A smaller view for the gallery preview: the bottom-left of the screen, where the prompt and latest output are. */ @@ -74,12 +89,14 @@ export function cropCapture(capture: ScreenCapture, width: number, height: numbe 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 bg = 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]!; + bg[y * w + x] = capture.bg[(top + y) * capture.width + x]!; } - return {width: w, height: h, glyphs, fg, instances: {}, seed: capture.seed}; + return {width: w, height: h, glyphs, fg, bg, 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 index da1f62e1..77a0e6f2 100644 --- a/src/idle/screenEffects.ts +++ b/src/idle/screenEffects.ts @@ -1,5 +1,6 @@ -import {CellGrid, mixPacked, NO_COLOR_VALUE} from './CellGrid.js'; +import {CellGrid, luminance, mixPacked, NO_COLOR_VALUE} from './CellGrid.js'; import type {ScreenCapture} from './screenCapture.js'; +import {CAT_ASCII, CAT_ASCII_BLINK, CAT_CELL_HEIGHT, CAT_CELL_WIDTH, catCells, type CatPose} from './catSprite.js'; /** * The shared screen-saver engine: a seeded RNG, a fixed-timestep driver and a @@ -42,18 +43,35 @@ export function glyphsOf(capture: ScreenCapture, textColor: number): Glyph[] { 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}); + out.push({ch: capture.glyphs[i]!, fg: visibleFg(fg, capture.bg[i]!, textColor), 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 { +/** + * A glyph that leaves its cell is drawn without its background, so its color + * must stand on its own: dark-on-light chrome (a badge, a selection) would + * otherwise become dark text on the host's dark background. + */ +export function visibleFg(fg: number, bg: number, textColor: number): number { + if (fg === NO_COLOR_VALUE) return bg !== NO_COLOR_VALUE && luminance(bg) > 0.35 ? bg : textColor; + if (luminance(fg) >= 0.3) return fg; + return bg !== NO_COLOR_VALUE && luminance(bg) >= 0.3 ? bg : textColor; +} + +/** + * Cells that are not in the animated set are drawn exactly as captured: + * glyph, foreground and authored background (host-default colors stay + * host-default, so transparency and the prompt chrome survive). A cell whose + * glyph is animated keeps its authored background and loses only the glyph. + */ +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); + const g = capture.glyphs[i]!, bg = capture.bg[i]!; + if (g === ' ' && bg === NO_COLOR_VALUE) continue; + const x = i % capture.width, y = Math.floor(i / capture.width); + if (skip.has(i)) grid.set(x, y, ' ', NO_COLOR_VALUE, bg); + else grid.set(x, y, g, capture.fg[i]!, bg); } } @@ -335,83 +353,115 @@ export class Fireworks extends Sim { // -------------------------------------------------------------- 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}; +export type CircPhase = 'hold' | 'gather' | 'stabilize' | 'rotate' | 'accelerate' | 'destabilize' | 'explode' | 'scatter' | 'settle'; +export interface RingSpec { cx: number; cy: number; r: number; dir: 1 | -1; slots: number; cluster: number } +export interface Cluster { cx: number; cy: number; R: number; dir: 1 | -1; rings: RingSpec[] } +const CIRC = {gather: 2.2, stabilize: 1.2, rotate: 1.8, accelerate: 5, destabilize: 1.3, scatter: 3.2, settle: 1.6, hold: 0.8, + spacing: 0.95, ringGap: 1.7, minClusters: 2, maxClusters: 5, omegaStart: 0.45, omegaMax: 9}; /** 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)); +const innerLimit = (R: number) => Math.max(1.8, R * 0.35); +const ringRadii = (R: number) => { const out: number[] = []; for (let r = R; r >= innerLimit(R); r -= CIRC.ringGap) out.push(r); return out; }; +export const clusterCapacity = (R: number) => ringRadii(R).reduce((sum, r) => sum + ringCapacity(r), 0); /** - * 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. + * A small bounded set (2–5) of compact text circles, each with a clear empty + * center and negative space around it. Physical units are rows (x cells count + * half) so rings look circular. Placement is seeded and collision-aware; + * when a viewport is too small for several, fewer (down to one) are used. + * Glyphs beyond the circles' total capacity simply do not participate. */ -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; +export function layoutCircles(count: number, width: number, height: number, rng: () => number): Cluster[] { + const W = width / ASPECT, H = height, margin = 1.5; + const Rmax = Math.max(2.6, Math.min(W, H) * 0.26); + const place = (k: number, scale: number): Cluster[] | undefined => { + const need = Math.ceil(count / k); + let base = 2.6; while (base < Rmax && clusterCapacity(base) < need) base += 0.4; + const placed: Cluster[] = []; + for (let n = 0; n < k; n += 1) { + const R = clamp(base * scale * (0.85 + rng() * 0.3), 2.6, Rmax); + if (R * 2 + margin * 2 > Math.min(W, H) + 0.01 && k > 1) return undefined; + let spot: {cx: number; cy: number} | undefined; + for (let attempt = 0; attempt < 200 && !spot; attempt += 1) { + const cx = margin + R + rng() * Math.max(0, W - 2 * (margin + R)), cy = margin + R + rng() * Math.max(0, H - 2 * (margin + R)); + if (placed.every(other => Math.hypot(cx - other.cx, cy - other.cy) >= R + other.R + 2.5)) spot = {cx, cy}; + } + if (!spot) return undefined; + placed.push({...spot, R, dir: rng() < 0.5 ? 1 : -1, rings: []}); } - return {rings, held}; + return placed; }; - 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; + let clusters: Cluster[] | undefined; + for (let k = clamp(Math.ceil(count / 70), CIRC.minClusters, CIRC.maxClusters); k >= 2 && !clusters; k -= 1) { + // Prefer the full radius; shrink the circles a little before giving up on having several. + for (const scale of [1, 0.85, 0.72]) { clusters = place(k, scale); if (clusters) break; } + } + clusters ??= [{cx: W / 2, cy: H / 2, R: Math.max(2.4, Math.min(Rmax, Math.min(W, H) / 2 - margin)), dir: 1, rings: []}]; + // Share the glyphs between clusters by capacity; inside a cluster, between rings by capacity. + const totalCapacity = clusters.reduce((sum, c) => sum + clusterCapacity(c.R), 0); + let remaining = Math.min(count, totalCapacity); + clusters.forEach((cluster, index) => { + const cap = clusterCapacity(cluster.R); + const quota = index === clusters!.length - 1 ? Math.min(cap, remaining) : Math.min(cap, Math.round(Math.min(count, totalCapacity) * cap / totalCapacity)); + let left = quota; const radii = ringRadii(cluster.R); + radii.forEach((r, k) => { + const share = k === radii.length - 1 ? left : Math.min(left, Math.round(quota * ringCapacity(r) / cap)); + const slots = Math.min(share, ringCapacity(r)); + if (slots >= 3) { cluster.rings.push({cx: cluster.cx, cy: cluster.cy, r, dir: (k % 2 === 0 ? cluster.dir : -cluster.dir) as 1 | -1, slots, cluster: index}); left -= slots; } + }); + remaining -= quota - left; }); - return all; + return clusters.filter(c => c.rings.length); } export class Circletastic extends Sim { readonly glyphs: Glyph[]; phase: CircPhase = 'hold'; phaseT = 0; + clusters: Cluster[] = []; rings: RingSpec[] = []; /** Per-ring angle and angular speed (rad/s). */ angle: number[] = []; omega: number[] = []; - private assigned: Array<{ring: number; slot: number}> = []; + /** Slot assignment for participating glyphs; others stay where they were captured. */ + assigned: Array<{ring: number; slot: number} | undefined> = []; + private delay: number[] = []; + private duration: number[] = []; private alpha = 1.4; + private explodeAt: number[] = []; + private exploded: boolean[] = []; + explosion: 'simultaneous' | 'staggered' = 'simultaneous'; cycles = 0; + /** Phase names in the order they were entered (for tests and diagnostics). */ + readonly history: CircPhase[] = ['hold']; 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(); } + /** ASSIGN: every participating glyph gets a ring and slot before anything moves. */ private plan() { - this.rings = layoutRings(this.glyphs.length, this.capture.width, this.capture.height, this.rng); + this.clusters = layoutCircles(this.glyphs.length, this.capture.width, this.capture.height, this.rng); + this.rings = this.clusters.flatMap(c => c.rings); this.angle = this.rings.map(() => this.rand(0, Math.PI * 2)); - this.omega = this.rings.map(() => 0.25); - this.assigned = []; + this.omega = this.rings.map(() => CIRC.omegaStart); + this.assigned = []; this.delay = []; this.duration = []; + // Nearest glyphs to each ring take its slots: shorter, less tangled trips. + const order = this.glyphs.map((_, i) => i); 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); + this.rings.forEach((ring, ri) => { + for (let s = 0; s < ring.slots && g < order.length; s += 1, g += 1) this.assigned[order[g]!] = {ring: ri, slot: s}; + }); + this.glyphs.forEach((glyph, i) => { + glyph.a = glyph.x; glyph.r = glyph.y; glyph.t = 0; glyph.vx = glyph.vy = 0; + glyph.state = this.assigned[i] ? 0 : 3; + this.delay[i] = this.rand(0, 0.8); this.duration[i] = this.rand(1.4, CIRC.gather); + }); + this.alpha = this.rand(1.2, 1.8); + this.explosion = this.rng() < 0.5 ? 'simultaneous' : 'staggered'; + this.exploded = this.clusters.map(() => false); + this.explodeAt = this.clusters.map((_, i) => this.explosion === 'simultaneous' ? 0 : i * this.rand(0.35, 0.6)); } slotPosition(i: number): {x: number; y: number} | undefined { const a = this.assigned[i]; @@ -420,106 +470,133 @@ export class Circletastic extends Sim { 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; } + /** Every participating glyph is on its slot (within a fraction of a cell). */ + fullyFormed(): boolean { + return this.glyphs.every((g, i) => { const p = this.slotPosition(i); return !p || (g.state === 1 && Math.abs(g.x - p.x) < 0.5 && Math.abs(g.y - p.y) < 0.5); }); + } + private go(phase: CircPhase) { this.phase = phase; this.phaseT = 0; this.history.push(phase); } + private spin(dt: number, accelerate: boolean, strain: number) { + this.rings.forEach((ring, ri) => { + if (accelerate && !this.exploded[ring.cluster]) this.omega[ri] = Math.min(CIRC.omegaMax * (1 + ri * 0.08), this.omega[ri]! + this.alpha * (1 + ri * 0.3) * dt); + this.angle[ri]! += this.omega[ri]! * ring.dir * dt; + }); + this.glyphs.forEach((g, i) => { + const a = this.assigned[i]; if (!a || g.state !== 1) return; + const p = this.slotPosition(i)!; + // Strain: radial wobble and the odd glyph slipping outward, never before the ring fully formed. + const slip = strain > 0 && (i * 2654435761 >>> 0) % 97 < 3 ? strain * 1.4 : 0; + const wob = strain * 0.5 * Math.sin(this.t / 55 + i); + const ring = this.rings[a.ring]!, th = this.angle[a.ring]! + (a.slot / ring.slots) * Math.PI * 2; + g.x = p.x + Math.cos(th) * (wob + slip) * ASPECT; g.y = p.y + Math.sin(th) * (wob + slip); + }); + } 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); + case 'gather': this.glyphs.forEach((g, i) => { - const target = this.slotPosition(i); - if (!target) return; // glyphs beyond ring capacity stay put + const target = this.slotPosition(i); if (!target || g.state === 1) return; + g.t += dt; + const u = ease((g.t - this.delay[i]!) / this.duration[i]!); 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; + 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 (g.t >= this.delay[i]! + this.duration[i]!) { g.x = target.x; g.y = target.y; g.state = 1; } }); - if (this.phaseT >= CIRC.gather) this.go('spin'); + // FULLY FORMED is global: nothing else starts until every participant is on its ring. + if (this.glyphs.every((g, i) => !this.assigned[i] || g.state === 1)) this.go('stabilize'); 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(); + case 'stabilize': if (this.phaseT >= CIRC.stabilize) this.go('rotate'); break; + case 'rotate': this.spin(dt, false, 0); if (this.phaseT >= CIRC.rotate) this.go('accelerate'); break; + case 'accelerate': this.spin(dt, true, 0); if (this.phaseT >= CIRC.accelerate) this.go('destabilize'); break; + case 'destabilize': this.spin(dt, true, clamp(this.phaseT / CIRC.destabilize, 0, 1)); if (this.phaseT >= CIRC.destabilize) this.go('explode'); break; + case 'explode': + this.spin(dt, true, 1); + this.clusters.forEach((_, ci) => { if (!this.exploded[ci] && this.phaseT >= this.explodeAt[ci]!) this.explodeCluster(ci); }); + if (this.exploded.every(Boolean)) this.go('scatter'); + this.integrate(dt, w, h); 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 'scatter': this.integrate(dt, w, h); 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; } + for (const g of this.glyphs) if (g.state === 2) { g.vx *= 0.8; g.vy *= 0.8; } + this.integrate(dt, w, h); + if (this.phaseT >= CIRC.settle) { this.cycles += 1; this.loops = this.cycles; this.plan(); this.go('hold'); } break; } } - /** The structure fails: each glyph keeps the velocity its ring was giving it, plus an outward kick. */ - explode() { + private integrate(dt: number, w: number, h: number) { + for (const g of this.glyphs) { + if (g.state !== 2) continue; + 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; } + } + } + /** The ring fails: each glyph keeps the velocity its ring was giving it, plus a radial kick and a little randomness. */ + private explodeCluster(ci: number) { + this.exploded[ci] = true; 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; } + if (!a || this.rings[a.ring]!.cluster !== ci) 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; + g.vx = (-Math.sin(th) * speed * 0.6 + Math.cos(th) * out) * ASPECT + this.rand(-1.5, 1.5); + g.vy = Math.cos(th) * speed * 0.6 + Math.sin(th) * out + this.rand(-0.8, 0.8); + g.state = 2; }); - 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))); + this.glyphs.forEach((g, i) => { if (this.assigned[i]) 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)); - } + const hot = clamp((this.omega[0] ?? 0) / CIRC.omegaMax, 0, 1); + const lit = this.phase === 'accelerate' || this.phase === 'destabilize' || this.phase === 'explode' ? hot : 0; + this.glyphs.forEach((g, i) => { + if (!this.assigned[i]) return; + grid.set(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 interface Platform { y: number; x0: number; x1: number; floor?: boolean } +const CAT_W = CAT_CELL_WIDTH, CAT_H = CAT_CELL_HEIGHT; +const HOP_RISE = 1.4; + +/** Short, clearly fictional jokes; none claims a real fault, breach, loss or corruption. */ +export const JOKES: readonly string[] = [ + 'Too many words, not enough naps', 'Expected cat, found project', 'Purrmission denied', 'Catastrophic purrsing failure', 'Unexpected meow in expression', + 'Lint found 3 loose whiskers', 'Variable refuses to be pet', 'Function has not been fed recently', 'This line is not sufficiently loaf-shaped', + 'Build blocked by nap dependency', 'Keyboard currently occupied by cat', 'Cat hair found in type system', 'Project requires more treats', + 'Syntax hiss-lighting conflict', 'Tail recursion has become literal', 'Command may knock items off desk', 'File is sitting in the box but not the repository', + 'Import contains unauthorized zoomies', 'Compiler requested belly rubs', 'Object is climbing the curtains', 'Semicolon escaped the scratching post', + 'Too much seriousness detected', 'Error raised successfully', 'Pointer chased laser into invalid memory', 'This token is acting suspiciously dog-like', + 'Thread tangled in yarn scheduler', 'Path is suspiciously walkable', 'Command contains traces of responsibility', 'Variable has exceeded daily zoomie allowance', + 'Missing dependency: treats', 'Runtime entered loaf mode', 'Whisker alignment check failed', 'Paw-sized race condition detected', 'Catastrophe successfully reproduced', + 'Process interrupted by mandatory nap', 'Excessive keyboard warmth detected', 'Stack contains forbidden tuna', 'Cache invalidated by tail movement', + 'Box exists and therefore must be occupied', 'This module has not been sniffed', 'Warning: suspiciously empty cardboard box', + '`{w}` looks suspiciously productive', 'Expected cat, found `{w}`', '`{w}` appears emotionally unavailable', '`{w}` has been judged', 'Unknown identifier: `{w}`', +]; +export const KEYBOARD_JOKES: readonly string[] = ['Input method: paws', 'Unexpected paw event', 'Keyboard privileges revoked', 'Typing accuracy degraded by loaf', + 'Eight paws detected. Expected zero.', 'Composer under temporary cat management', 'Keyboard appears sufficiently warm']; +export const MEOWS: readonly string[] = ['meow', 'meow!', 'mrow', 'mrow?', 'mrrp', 'mrrrp!', 'prrr', 'purr...', 'purrrrr', 'nya', 'mrrow!', 'mrowe!', 'mreow', 'brrrp', 'chirp!', 'ekekekek']; export const CHARACTER_JOKE = 'Unexpected character'; export const DIVIDER_JOKE = 'Structural integrity questionable'; +export const KEYSMASH = 'asdfjkl;qwertyuiopzxcvbnm,./[]0123456789'; 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; +export const WOBBLE_CAP = 64; +export const KEYBOARD_MAX = 14; -/** Horizontal stretches of occupied cells (single gaps tolerated) wide enough for the cat, from geometry only. */ +/** Horizontal stretches of occupied cells (single gaps tolerated) the cat can stand on, from geometry only. The screen's bottom edge is always one more floor. */ export function extractPlatforms(capture: ScreenCapture, minWidth = 6): Platform[] { const out: Platform[] = []; - for (let y = 1; y < capture.height; y += 1) { + for (let y = CAT_H; 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] !== ' '; @@ -534,43 +611,102 @@ export function extractPlatforms(capture: ScreenCapture, minWidth = 6): Platform } } } + if (capture.height > CAT_H) out.push({y: capture.height, x0: 0, x1: capture.width - 1, floor: true}); return out; } -export type CatState = 'walk' | 'idle' | 'blink' | 'sit' | 'jump' | 'land' | 'paw'; +/** Hops between platforms: up to 9 rows up, 16 down, and a bounded horizontal gap. Pure geometry. */ +export function platformGraph(platforms: readonly Platform[]): number[][] { + return platforms.map((a, i) => platforms.flatMap((b, j) => { + if (i === j) return []; + const up = a.y - b.y, gap = Math.max(0, Math.max(a.x0, b.x0) - Math.min(a.x1, b.x1)); + // A rising hop needs room above the target for the whole sprite; drops are always possible. + const fits = up < 0 || (b.y - 1) - HOP_RISE - (CAT_H - 1) >= 0; + return (up <= 9 && up >= -16 && gap <= 18 && !(b.floor && a.floor) && fits) ? [j] : []; + })); +} + +/** Fewest-hop route (excluding the start) over the graph, or undefined when unreachable or longer than `limit`. */ +export function routeBetween(graph: readonly number[][], from: number, to: number, limit = 8): number[] | undefined { + if (from === to) return []; + const previous = new Map([[from, -1]]); const queue = [from]; + while (queue.length) { + const node = queue.shift()!; + for (const next of graph[node] ?? []) { + if (previous.has(next)) continue; + previous.set(next, node); queue.push(next); + if (next === to) { + const route: number[] = []; for (let n: number = to; n !== from; n = previous.get(n)!) route.unshift(n); + return route.length <= limit ? route : undefined; + } + } + } + return undefined; +} + +export type CatState = 'walk' | 'idle' | '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}; + graph: number[][]; + cat = {x: 2, feet: 1, vx: 0, vy: 0, state: 'idle' as CatState, t: 0, dir: 1 as 1 | -1, platform: -1, walkTo: undefined as number | undefined, pose: 'idle' as CatPose}; diagnostics: Diagnostic[] = []; - private wobble = new Map(); + wobble = new Map(); + /** A short-lived cat sound near the cat; never an error. */ + speech?: {text: string; until: number}; + /** Fake keyboard garbage over the composer row: pure overlay text, owned entirely by this effect. */ + typing?: {row: number; x: number; text: string; until: number; next: number}; + route: number[] = []; + goal = -1; + readonly visits = new Map(); + readonly sounds: string[] = []; + readonly keyboard: string[] = []; + readonly composerRow: number; private think = 0.6; private cycleStart = 0; private resetting = 0; + private lastSpeech = -1e9; + private lastTyping = -1e9; + private dwell = 0; + /** The platform just left; it cannot catch the cat again until the cat is clearly away from it. */ + private origin = -1; + private originFeet = 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); + this.graph = platformGraph(this.platforms); + let last = -1; + for (let i = 0; i < capture.glyphs.length; i += 1) if (capture.glyphs[i] !== ' ') last = Math.floor(i / capture.width); + this.composerRow = Math.max(0, last); + // Start somewhere it can also leave again. + const mobile = this.platforms.map((_, i) => i).filter(i => this.graph[i]!.length > 0 && !this.platforms[i]!.floor); + this.place(mobile.length ? this.pick(mobile) : Math.max(0, 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); - } + if (!p) { this.cat.platform = -1; this.cat.feet = this.capture.height - 1; this.cat.x = 0; 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, Math.max(0, this.capture.width - CAT_W)); + this.visit(p); + } + private region(p: Platform): string { return `${Math.min(2, Math.floor((p.y / (this.capture.height + 1)) * 3))}:${Math.min(2, Math.floor((((p.x0 + p.x1) / 2) / this.capture.width) * 3))}`; } + private visit(p: Platform) { const key = this.region(p); this.visits.set(key, (this.visits.get(key) ?? 0) + 1); } + /** Regions (thirds of the screen) the cat has been to. */ + regionsVisited(): number { return this.visits.size; } 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); + for (let d = 0; d < 8 && 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) { + private raise(row: number, x: number, joke?: string, pool: readonly string[] = JOKES) { 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); + const text = joke ?? this.pick(pool).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); @@ -578,96 +714,214 @@ export class RaiseCatError extends Sim { } /** 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); + const need = Math.min(d.text.length + 4, 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; + 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 + 4)) free = false; if (free) { d.labelRow = row; d.labelX = x; return; } } } + private wobbleCell(i: number, dx: number, dy: number, ms: number) { if (this.wobble.size < WOBBLE_CAP) this.wobble.set(i, {dx, dy, until: this.t + ms}); } + /** The cat bumps a word: its glyphs shift 1–3 cells and spring back. */ 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}); - } + const word = this.wordAt(row, x); if (!word) return; + const shift = (this.rng() < 0.5 ? -1 : 1) * (1 + Math.floor(this.rng() * 3)); + for (let c = word.x0; c <= word.x1; c += 1) { const i = row * this.capture.width + c; if (this.capture.glyphs[i] !== ' ') this.wobbleCell(i, Math.sign(shift) * Math.min(Math.abs(shift), 1 + (c - word.x0) % 3), c % 2 === 0 ? -1 : 0, 700); } + } + private isDivider(row: number): boolean { + let n = 0, lines = 0; const w = this.capture.width; + for (let c = 0; c < w; c += 1) { const g = this.capture.glyphs[row * w + c]!; if (g !== ' ') { n += 1; if ('─━═-_—│┄┈'.includes(g)) lines += 1; } } + return n >= 10 && lines / n >= 0.6; + } + /** Landing on a line makes it dip a row and rebound; a divider bends along its length. */ + private landOn(platform: Platform, x: number) { + if (platform.floor) return; + const w = this.capture.width; + const divider = this.isDivider(platform.y); + const from = divider ? platform.x0 : Math.max(platform.x0, Math.round(x) - 4), to = divider ? Math.min(platform.x1, platform.x0 + 47) : Math.min(platform.x1, Math.round(x) + CAT_W + 4); + for (let c = from; c <= to; c += 1) { const i = platform.y * w + c; if (this.capture.glyphs[i] !== ' ') this.wobbleCell(i, 0, divider ? (c % 4 < 2 ? 1 : -1) : 1, divider ? 900 : 450); } + if (divider && this.rng() < 0.6) this.raise(platform.y, x + CAT_W / 2, DIVIDER_JOKE); + } + private maybeMeow(probability: number) { + if (this.t - this.lastSpeech < 4000 || this.rng() >= probability) return; + const text = this.pick(MEOWS); this.lastSpeech = this.t; this.speech = {text, until: this.t + 1800}; this.sounds.push(text); + } + private floorIndex() { return this.platforms.findIndex(p => p.floor); } + /** Weighted target: usually somewhere plausible nearby, sometimes a far part of the screen, favouring regions not visited yet. */ + pickGoal(): number { + const here = this.platforms[this.cat.platform]; + const candidates: Array<{i: number; w: number}> = []; + this.platforms.forEach((p, i) => { + if (i === this.cat.platform) return; + if (!routeBetween(this.graph, this.cat.platform, i)) return; + const near = here ? Math.abs(p.y - here.y) <= 8 && Math.abs((p.x0 + p.x1) / 2 - this.cat.x) <= 25 : true; + // The composer row is a favourite spot: that is where a cat sits on a keyboard. + candidates.push({i, w: (near ? 3 : 1) * (p.floor || p.y >= this.composerRow ? 2.5 : 1) / (1 + (this.visits.get(this.region(p)) ?? 0))}); + }); + if (!candidates.length) return -1; + let roll = this.rng() * candidates.reduce((s, c) => s + c.w, 0); + for (const c of candidates) { roll -= c.w; if (roll <= 0) return c.i; } + return candidates[0]!.i; } 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.speech && this.t > this.speech.until) this.speech = undefined; + if (this.typing) { + if (this.t >= this.typing.until) { this.typing = undefined; this.maybeMeow(1); this.lastSpeech = this.t; } + else if (this.t >= this.typing.next) { this.typing.text = this.keysmash(); this.typing.next = this.t + 250; } + } if (this.resetting > 0) { this.resetting -= dt; - if (this.resetting <= 0) { this.diagnostics = []; this.cycleStart = this.t; this.loops += 1; } + if (this.resetting <= 0) { this.diagnostics = []; this.wobble.clear(); this.typing = undefined; this.speech = undefined; this.cycleStart = this.t; this.loops += 1; this.visits.clear(); } 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; } + if (this.diagnostics.length >= DIAGNOSTIC_CAP - 2 || this.t - this.cycleStart > 90_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.state === 'jump') { this.flying(dt); 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; } + const [left, right] = this.bounds(p); + cat.x += cat.dir * 6 * dt; + if (cat.walkTo !== undefined) { + const target = clamp(cat.walkTo, left, right); + if ((cat.dir === 1 && cat.x >= target) || (cat.dir === -1 && cat.x <= target)) { cat.x = target; cat.walkTo = undefined; cat.state = 'idle'; cat.t = 0; this.think = 0; } + } else if (cat.x < left || cat.x > right) { cat.x = clamp(cat.x, 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 === 'sit') && cat.t > (cat.state === 'sit' ? 1.4 : 0.55)) { cat.state = 'idle'; cat.t = 0; } + if (this.think > 0 || cat.state === 'walk' || !p) return; + this.think = this.rand(0.6, 1.8); + this.decide(p); + } + /** Where the cat's left edge may be while it stands on a platform: it can overhang the ends a little, never leave the screen. */ + private bounds(p: Platform): [number, number] { + const left = Math.max(0, p.x0 - 3); + return [left, Math.max(left, Math.min(this.capture.width - CAT_W, p.x1 - CAT_W + 4))]; + } + private keysmash(): string { + const length = 6 + Math.floor(this.rng() * (KEYBOARD_MAX - 5)); + let text = ''; for (let i = 0; i < length; i += 1) text += KEYSMASH[Math.floor(this.rng() * KEYSMASH.length)]!; + return text; + } + private decide(p: Platform) { + const cat = this.cat; const centre = cat.x + CAT_W / 2; + if (this.goal === cat.platform) { this.goal = -1; this.route = []; } + if (!this.route.length && this.goal >= 0) this.route = routeBetween(this.graph, cat.platform, this.goal) ?? []; + if (!this.route.length) this.goal = -1; + // Following a route: walk to the takeoff point, then hop. + if (this.route.length) { + const next = this.platforms[this.route[0]!]!; + const [lo, hi] = this.bounds(p); + const takeoff = clamp((next.x0 + next.x1) / 2 - CAT_W / 2, lo, hi); + if (Math.abs(cat.x - takeoff) > 1.2) { cat.walkTo = takeoff; cat.dir = takeoff > cat.x ? 1 : -1; cat.state = 'walk'; cat.t = 0; return; } + this.hop(this.route[0]!); + return; } - 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); + // Arrived (or no goal): small local business, then sometimes pick somewhere far. 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(); + const onComposer = p.floor || p.y >= this.composerRow; + if (onComposer && this.t - this.lastTyping > 12_000 && !this.typing && roll < 0.5) { this.startTyping(); return; } + this.dwell += 1; + if (this.dwell >= 3 && this.rng() < 0.55) { + const goal = this.pickGoal(); + const route = goal >= 0 ? routeBetween(this.graph, cat.platform, goal) : undefined; + if (route?.length) { this.goal = goal; this.route = route; this.dwell = 0; this.maybeMeow(0.15); return; } + } + if (roll < 0.28) { cat.state = 'walk'; cat.dir = this.rng() < 0.5 ? 1 : -1; cat.t = 0; cat.walkTo = undefined; } + else if (roll < 0.48) { cat.state = 'sit'; cat.t = 0; if (!p.floor && this.rng() < 0.8) this.raise(p.y, centre); this.maybeMeow(0.3); } + else if (roll < 0.66) { cat.state = 'paw'; cat.t = 0; if (!p.floor) { this.bump(p.y, cat.dir === 1 ? cat.x + CAT_W + 1 : cat.x - 2); this.raise(p.y, cat.dir === 1 ? cat.x + CAT_W + 1 : cat.x - 2, CHARACTER_JOKE); } this.maybeMeow(0.3); } + else if (roll < 0.74) this.maybeMeow(1); + else { const goal = this.pickGoal(); const route = goal >= 0 ? routeBetween(this.graph, cat.platform, goal) : undefined; if (route?.length) { this.goal = goal; this.route = route; } else { cat.state = 'walk'; cat.dir = (cat.dir * -1) as 1 | -1; cat.walkTo = undefined; } } + } + private startTyping() { + const cat = this.cat; cat.state = 'sit'; cat.t = -0.8; // sits a little longer on the keyboard + this.lastTyping = this.t; + const w = this.capture.width; let last = -1; + for (let c = 0; c < w; c += 1) if (this.capture.glyphs[this.composerRow * w + c] !== ' ') last = c; + const text = this.keysmash(); + const x = clamp(last + 2, 0, Math.max(0, w - text.length - 1)); + this.typing = {row: this.composerRow, x, text, until: this.t + 2300, next: this.t + 250}; + this.keyboard.push(text); + if (this.rng() < 0.35) this.raise(this.composerRow, Math.max(0, last - 2), this.pick(KEYBOARD_JOKES)); } 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; } }); + this.platforms.forEach((q, i) => { const d = Math.abs(q.y - 1 - 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[] { + /** Platforms one hop away from where the cat stands. */ + jumpTargets(): number[] { return this.graph[this.cat.platform] ?? []; } + private hop(index: number) { + const cat = this.cat; const q = this.platforms[index]!; + const targetFeet = q.y - 1, dy = cat.feet - targetFeet; // dy > 0: the target is higher + const g = 38; + const landX = clamp(q.x0 + (q.x1 - q.x0 - CAT_W) / 2 + this.rand(-2, 2), Math.max(0, q.x0 - 4), Math.max(0, Math.min(this.capture.width - CAT_W, q.x1 - CAT_W + 4))); + if (dy < 0) { + // A drop: step off the edge and fall; no upward arc. + const tDown = Math.sqrt(Math.max(0.0001, 2 * -dy / g)); + cat.vy = -2; cat.vx = (landX - cat.x) / (tDown + 0.1); + } else { + const rise = dy + HOP_RISE; + // The arc must keep the whole sprite inside the screen; otherwise this hop is not taken. + if (targetFeet - HOP_RISE - (CAT_H - 1) < 0 || cat.feet - rise - (CAT_H - 1) < 0) { this.route = []; this.goal = -1; cat.state = 'walk'; cat.dir = (cat.dir * -1) as 1 | -1; cat.walkTo = undefined; return; } + const vy0 = -Math.sqrt(2 * g * rise); + const tUp = -vy0 / g, tDown = Math.sqrt(Math.max(0.0001, 2 * Math.max(0.2, targetFeet - (cat.feet - rise)) / g)); + cat.vy = vy0; cat.vx = (landX - cat.x) / (tUp + tDown); + } + this.origin = cat.platform; this.originFeet = cat.feet; + cat.state = 'jump'; cat.t = 0; cat.platform = -1; cat.walkTo = undefined; + } + private flying(dt: 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; + cat.vy += 38 * dt; cat.x += cat.vx * dt; cat.feet += cat.vy * dt; + cat.x = clamp(cat.x, 0, Math.max(0, this.capture.width - CAT_W)); + if (cat.feet < CAT_H - 1) { cat.feet = CAT_H - 1; cat.vy = Math.max(cat.vy, 0); } // never above the top row + if (cat.vy <= 0) return; + const centre = cat.x + CAT_W / 2; + const hit = this.platforms.findIndex((q, i) => !(i === this.origin && Math.abs(cat.feet - this.originFeet) < 1.5) && Math.abs(cat.feet - (q.y - 1)) < 0.9 && centre >= q.x0 - 3 && centre <= q.x1 + 3 && cat.feet <= q.y - 1 + 0.9); + if (hit < 0) return; + const q = this.platforms[hit]!; + cat.platform = hit; cat.feet = q.y - 1; cat.vx = cat.vy = 0; cat.state = 'land'; cat.t = 0; + this.visit(q); + if (this.route[0] === hit) this.route.shift(); + // Landed on a line along the way (intermediate text catches the fall): keep the goal, plan again from here. + else if (this.route.length || this.goal >= 0) this.route = this.goal >= 0 && this.goal !== hit ? routeBetween(this.graph, hit, this.goal) ?? [] : []; + if (this.goal === hit) { this.goal = -1; this.route = []; } + this.landOn(q, cat.x); + if (!q.floor) { this.bump(q.y, centre); if (this.rng() < 0.7) this.raise(q.y, centre); } + this.maybeMeow(0.35); + this.think = 0.4; + } + frame(): CatPose { + const c = this.cat; + if (c.state === 'jump') return 'jump'; + if (c.state === 'land') return 'crouch'; + if (c.state === 'walk') return Math.floor(c.t * 5) % 2 === 0 ? 'walkA' : 'walkB'; + if (c.state === 'paw') return 'paw'; + if (c.state === 'sit') return 'sit'; + const phase = this.t % 4800; + if (phase < 160) return 'blink'; + if (phase > 2200 && phase < 2700) return 'tail'; + return 'idle'; } paint(grid: CellGrid, ctx: EffectContext): void { const w = this.capture.width; - // Captured text, with wobbling cells nudged (presentation only). + // Captured text, with wobbling cells nudged (presentation only; the capture itself is never written). 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}); + for (const [i, wb] of this.wobble) { + const remaining = clamp((wb.until - this.t) / 700, 0, 1); + const scale = remaining > 0.5 ? 1 : remaining * 2; + moved.set(i, {x: (i % w) + Math.round(wb.dx * scale), y: Math.floor(i / w) + Math.round(wb.dy * scale)}); + } 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); } + for (const [i, pos] of moved) { + const fg = this.capture.fg[i]!, bg = this.capture.bg[i]!; + grid.set(i % w, Math.floor(i / w), ' ', NO_COLOR_VALUE, bg); + grid.set(pos.x, pos.y, this.capture.glyphs[i]!, fg); + } const newest = this.diagnostics.length - 1; this.diagnostics.forEach((d, i) => { const age = clamp((this.diagnostics.length - 1 - i) / 8, 0, 0.7); @@ -676,7 +930,7 @@ export class RaiseCatError extends Sim { // 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 (ctx.color) grid.set(x, d.row, glyph, visibleFg(this.capture.fg[index]!, NO_COLOR_VALUE, ctx.palette.text), 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 ? '⚠' : '!'); @@ -685,16 +939,12 @@ export class RaiseCatError extends Sim { 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; + if (this.typing) for (let k = 0; k < this.typing.text.length; k += 1) grid.set(this.typing.x + k, this.typing.row, this.typing.text[k]!, ctx.palette.warn); + const cat = this.cat; const x = Math.round(cat.x), top = Math.round(cat.feet) - (CAT_H - 1); + if (this.speech) { const label = this.speech.text; for (let k = 0; k < label.length; k += 1) grid.set(x + 3 + k, top - 1, label[k]!, ctx.palette.accent); } + const facingRight = cat.dir === 1, pose = this.frame(); + if (ctx.color) for (const cell of catCells(pose, facingRight)) grid.set(x + cell.dx, top + cell.dy, cell.glyph, cell.fg, cell.bg); + else (pose === 'blink' ? CAT_ASCII_BLINK : CAT_ASCII).forEach((line, dy) => { for (let k = 0; k < line.length; k += 1) grid.set(x + 3 + k, top + 1 + dy, line[k]!, NO_COLOR_VALUE); }); } } diff --git a/src/shell/ShellEnvironment.ts b/src/shell/ShellEnvironment.ts index 639fe140..88a28285 100644 --- a/src/shell/ShellEnvironment.ts +++ b/src/shell/ShellEnvironment.ts @@ -26,6 +26,8 @@ export type PluginRelation = 'nmsh-owns-surface' | 'compatible'; export interface DetectedPlugin { id: string; + /** The shell this plugin belongs to; plugins for other shells never describe the active one. */ + shell: ShellFamily; label: string; evidence: string; relation: PluginRelation; @@ -72,7 +74,9 @@ export function detectShellEnvironment(probe: EnvironmentProbe = systemProbe()): const xdgData = env.XDG_DATA_HOME && isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : join(home, '.local', 'share'); const zshrcPath = join(zdot, '.zshrc'); const zshrc = activeText(probe.read(zshrcPath)); - const bashrc = activeText(probe.read(join(home, '.bashrc'))); + // Bash reads one of several startup files; each is read as bounded text only, never sourced. + const bashFiles = ['.bashrc', '.bash_profile', '.bash_login', '.profile'].map(name => ({name, text: activeText(probe.read(join(home, name)))})); + const bashEvidence = (pattern: RegExp, what: string) => { const hit = bashFiles.find(file => pattern.test(file.text)); return hit ? `~/${hit.name} ${what}` : undefined; }; const zimrc = activeText(probe.read(join(zdot, '.zimrc'))); const antidotePlugins = activeText(probe.read(join(zdot, '.zsh_plugins.txt'))); const sheldon = activeText(probe.read(join(xdgConfig, 'sheldon', 'plugins.toml'))); @@ -95,20 +99,20 @@ export function detectShellEnvironment(probe: EnvironmentProbe = systemProbe()): add('zplug', 'zplug', 'plugin-manager', 'zsh', /\bzplug\b/u.test(zshrc) ? `${rc} calls zplug` : undefined); add('fisher', 'Fisher', 'plugin-manager', 'fish', probe.exists(join(xdgConfig, 'fish', 'functions', 'fisher.fish')) ? 'fisher.fish is in your fish functions' : undefined); add('oh-my-fish', 'Oh My Fish', 'framework', 'fish', probe.exists(join(xdgData, 'omf')) ? `${tilde(join(xdgData, 'omf'), home)} exists` : undefined); - add('oh-my-bash', 'Oh My Bash', 'framework', 'bash', /oh-my-bash\.sh/u.test(bashrc) ? '~/.bashrc sources oh-my-bash.sh' : undefined); - add('bash-it', 'Bash-it', 'framework', 'bash', /bash_it\.sh/u.test(bashrc) ? '~/.bashrc sources bash_it.sh' : undefined); + add('oh-my-bash', 'Oh My Bash', 'framework', 'bash', bashEvidence(/oh-my-bash\.sh/u, 'sources oh-my-bash.sh')); + add('bash-it', 'Bash-it', 'framework', 'bash', bashEvidence(/bash_it\.sh/u, 'sources bash_it.sh')); // Oh My Zsh lists plugins as bare names inside plugins=( ... ). const omzPlugins = new Set((/^\s*plugins=\(([^)]*)\)/mu.exec(zshrc)?.[1] ?? '').split(/\s+/u).filter(Boolean)); const sources = [zshrc, zimrc, antidotePlugins, sheldon].join('\n'); const plugins: DetectedPlugin[] = []; - const plugin = (id: string, label: string, relation: PluginRelation, note: string, extra = '') => { + const plugin = (id: string, label: string, relation: PluginRelation, note: string, extra = '', shell: ShellFamily = 'zsh') => { const pattern = new RegExp(`(?:^|[/\\s"'(])${id.replace(/\./gu, '\\.')}(?:$|[/\\s"').]|\\.plugin)`, 'mu'); let evidence: string | undefined; if (omzPlugins.has(id)) evidence = 'listed in Oh My Zsh plugins=(...)'; else if (pattern.test(sources)) evidence = 'referenced in your zsh plugin configuration'; else if (extra && pattern.test(extra)) evidence = 'referenced in your fish plugins'; - if (evidence) plugins.push({id, label, evidence, relation, note}); + if (evidence) plugins.push({id, shell, label, evidence, relation, note}); }; plugin('zsh-autosuggestions', 'zsh-autosuggestions', 'nmsh-owns-surface', 'Draws ghost text through ZLE. NMSh owns its editor and runs zsh without ZLE, so inside NMSh the NMSh suggestions are shown instead; the plugin keeps working in /zsh and ordinary zsh.'); @@ -121,20 +125,37 @@ export function detectShellEnvironment(probe: EnvironmentProbe = systemProbe()): plugin('zsh-completions', 'zsh-completions', 'compatible', 'Adds completion definitions to fpath. NMSh\'s configured completion reads your configured definitions, so these feed the NMSh menu.'); plugin('z.lua', 'z.lua', 'compatible', 'A directory jumper with its own hooks; NMSh leaves hooks unchanged.'); - plugin('autopair', 'autopair (fish)', 'nmsh-owns-surface', 'Edits the fish command line. NMSh owns the composer, so it has no effect inside NMSh.', fishPlugins); + plugin('autopair', 'autopair (fish)', 'nmsh-owns-surface', 'Edits the fish command line. NMSh owns the composer, so it has no effect inside NMSh.', fishPlugins, 'fish'); return {environments, plugins}; } export const OWNERSHIP_NOTE = 'NMSh provides its editor, completion menu, prompt, transcript and sessions without requiring a plugin manager. ' + 'Existing shell frameworks and plugin managers keep providing compatible shell-level functionality (aliases, functions, completion definitions, hooks).'; -/** Status rows: label → value. Factual and short. */ -export function shellEnvironmentRows(report: ShellEnvironmentReport): Array<[string, string]> { - const rows: Array<[string, string]> = []; - const frameworks = report.environments.filter(item => item.kind === 'framework'); - const managers = report.environments.filter(item => item.kind === 'plugin-manager'); - rows.push(['Shell framework', frameworks.length ? frameworks.map(item => `${item.label} (${item.evidence})`).join('; ') : 'none detected']); +export interface ActiveShell { + id: ShellFamily; + /** Resolved executable of the shell backing this session, when known. */ + path?: string; +} + +const SHELL_NAMES: Record = {zsh: 'Zsh', fish: 'Fish', bash: 'Bash'}; + +/** + * Status rows: label → value. The first row is the shell actually backing the + * session (never inferred from $SHELL); framework and plugin rows describe + * that shell only, and environments found for the other shells are listed + * separately so they cannot look like they manage this session. + */ +export function shellEnvironmentRows(report: ShellEnvironmentReport, active: ActiveShell): Array<[string, string]> { + const rows: Array<[string, string]> = [['Shell', active.path ? `${active.id} · ${active.path}` : active.id]]; + const mine = report.environments.filter(item => item.shell === active.id); + const frameworks = mine.filter(item => item.kind === 'framework'); + const managers = mine.filter(item => item.kind === 'plugin-manager'); + rows.push(['Shell framework', frameworks.length ? frameworks.map(item => `${item.label} (${item.evidence})`).join('; ') : `none · plain ${active.id}`]); rows.push(['Plugin manager', managers.length ? managers.map(item => `${item.label} (${item.evidence})`).join('; ') : 'none detected']); - for (const plugin of report.plugins) rows.push([plugin.label, `detected · ${plugin.relation === 'compatible' ? 'compatible' : 'NMSh owns this surface'}`]); + for (const plugin of report.plugins.filter(item => item.shell === active.id)) rows.push([plugin.label, `detected · ${plugin.relation === 'compatible' ? 'compatible' : 'NMSh owns this surface'}`]); + const others = (['zsh', 'fish', 'bash'] as const).filter(id => id !== active.id) + .map(id => ({id, found: report.environments.filter(item => item.shell === id)})).filter(entry => entry.found.length); + if (others.length) rows.push(['Other shell environments', others.map(entry => `${SHELL_NAMES[entry.id]}: ${entry.found.map(item => item.label).join(', ')}`).join(' · ')]); return rows; } diff --git a/src/shell/tldr.ts b/src/shell/tldr.ts index 089f9650..07fa5c7c 100644 --- a/src/shell/tldr.ts +++ b/src/shell/tldr.ts @@ -29,9 +29,21 @@ export function parseTldrPage(markdown: string, root: string, limit = 8): TldrEx } /** Examples for a command path from an installed tldr client (bounded; no ANSI: raw output only). */ -export function tldrExamples(tldr: string | undefined, path: readonly string[]): TldrExample[] { +export type TldrRunner = (command: string, args: string[]) => {status: number | null; stdout?: string}; + +/** + * Cache-only: `--no-auto-update` stops a configured auto-update from fetching + * pages, so asking for help never touches the network or refreshes the cache. + * A client too old to know the flag fails, which means "no examples" (fail + * closed). An uncached page also yields none. Nothing here updates, downloads + * or runs an example. + */ +export const TLDR_ARGS = ['--no-auto-update', '--raw'] as const; + +export function tldrExamples(tldr: string | undefined, path: readonly string[], run: TldrRunner = (command, args) => + spawnSync(command, args, {encoding: 'utf8', timeout: 2000, maxBuffer: 256 * 1024, env: {...process.env, NO_COLOR: '1'}})): TldrExample[] { if (!tldr || !path.length || !path.every(word => /^[\w.+-]{1,64}$/u.test(word))) return []; - const result = spawnSync(tldr, ['--raw', ...path], {encoding: 'utf8', timeout: 2000, maxBuffer: 256 * 1024, env: {...process.env, NO_COLOR: '1'}}); + const result = run(tldr, [...TLDR_ARGS, ...path]); if (result.status !== 0 || !result.stdout) return []; return parseTldrPage(result.stdout, path[0]!); } diff --git a/src/tools/InstallSuggestion.ts b/src/tools/InstallSuggestion.ts index 0fa6b0bc..c010f0ea 100644 --- a/src/tools/InstallSuggestion.ts +++ b/src/tools/InstallSuggestion.ts @@ -96,7 +96,10 @@ export function renderInstallPrompt(state: InstallPromptState, columns: number): const accent = foreground(UI_COLORS.accent); const reset = '\u001B[0m'; const word = commandWord(state.command) ?? state.tool.executable ?? state.tool.id; - const rows = [` ${primary}\`${word}\` is not installed.${reset}`, '']; + const rows = [` ${primary}\`${word}\` is not installed.${reset}`]; + // The command and the package can differ (tldr is provided by tealdeer): say which one gets installed. + if (state.tool.package && state.tool.package !== word) rows.push(` ${subtle}It is provided by ${state.tool.package}.${reset}`); + rows.push(''); if (state.task?.state.status === 'running') { rows.push(...renderTaskProgress(state.task.state), '', renderControls([['Please wait', 'installation in progress']])); } else if (state.result) { diff --git a/src/tools/catalog.ts b/src/tools/catalog.ts index 4b1b5296..0f34cfa3 100644 --- a/src/tools/catalog.ts +++ b/src/tools/catalog.ts @@ -18,6 +18,15 @@ export interface Tool extends ProviderDescriptor { tier?: ToolTier; /** Facts for future contextual discovery; never evaluated by running a tool. */ relevantTo?: readonly string[]; + /** + * Whether typing this exact executable while it is missing identifies the tool. + * Default true: being curated means NMSh knows what the command is; the + * recommendation tier says nothing about identity. Runtimes and + * infrastructure clients opt out explicitly with false. + */ + commandNotFound?: boolean; + /** Extra exact command names for this tool; the canonical `executable` stays explicit. Package names are never aliases. */ + commandAliases?: readonly string[]; /** Runtime or infrastructure client detected as part of the environment. */ discoveryKind?: ToolDiscoveryKind; package: string; @@ -36,7 +45,7 @@ function tool(id: string, label: string, category: Tool['category'], description executable: id, versionArgs: ['--version'], ...options, ...(tier ? {tier, recommended: tier === 'recommended'} : {})}; } const ENHANCED = {tier: 'enhanced'} as const; -const ENVIRONMENT = {discoveryKind: 'environment'} as const; +const ENVIRONMENT = {discoveryKind: 'environment', commandNotFound: false} as const; /** Curated offline metadata. No third-party submissions, update checks or marketplace. */ export const TOOLS: readonly Tool[] = [ @@ -76,8 +85,8 @@ export const TOOLS: readonly Tool[] = [ tool('macchina', 'Macchina', 'Shell / Workflow', 'Optional startup welcome capture.', 'https://github.com/Macchina-CLI/macchina', {providerFamily: 'welcome', integration: 'welcome', lifecycle: 'maintenance'}), tool('stow', 'GNU Stow', 'Shell / Workflow', 'Explicitly managed dotfile symlinks.', 'https://www.gnu.org/software/stow/'), - tool('tealdeer', 'tealdeer (TLDR pages)', 'Shell / Workflow', 'Optional TLDR pages client; Ask uses its local cache for practical command examples.', 'https://github.com/tealdeer-rs/tealdeer', - {executable: 'tldr', package: 'tealdeer'}), + tool('tealdeer', 'TLDR (tealdeer)', 'Shell / Workflow', 'Practical local command examples (command tldr, package tealdeer). Optional; Ask uses its local cache and never updates it.', 'https://github.com/tealdeer-rs/tealdeer', + {executable: 'tldr', package: 'tealdeer', recommended: true}), tool('tmux', 'tmux', 'Shell / Workflow', 'Independent terminal multiplexer.', 'https://github.com/tmux/tmux', {versionArgs: ['-V']}), tool('docker', 'Docker CLI', 'Containers / Infrastructure', 'Container client; daemon availability is not inferred.', 'https://docs.docker.com/', {package: 'docker', ...ENVIRONMENT, relevantTo: ['containers']}), tool('kubectl', 'kubectl', 'Containers / Infrastructure', 'Kubernetes client; credentials/cluster are not inspected.', 'https://kubernetes.io/docs/reference/kubectl/', {versionArgs: undefined, package: 'kubernetes-cli', ...ENVIRONMENT, relevantTo: ['kubernetes']}), @@ -107,12 +116,21 @@ export function toolsInTier(tier: ToolTier): Tool[] { } /** - * The curated tool a missing command word maps to, by exact executable name - * only. Language runtimes, infrastructure clients and legacy tools are never - * suggested; nothing is fuzzy-matched. + * Identity only: which curated tool an exact command name belongs to. No + * install, execution, network or package guessing. Tools that opted out with + * `commandNotFound: false` are not identified by it. */ -export function suggestibleToolFor(word: string): Tool | undefined { +export function knownToolForExecutable(word: string, tools: readonly Tool[] = TOOLS): Tool | undefined { if (!/^[A-Za-z0-9][A-Za-z0-9_.+-]*$/u.test(word)) return undefined; - return TOOLS.find(tool => tool.executable === word && !tool.legacy && tool.discoveryKind !== 'environment' - && tool.lifecycle !== 'maintenance' && !tool.integration && (tool.tier !== undefined || tool.id === 'yq')); + return tools.find(tool => tool.commandNotFound !== false && ((tool.executable ?? tool.id) === word || tool.commandAliases?.includes(word))); +} + +/** + * Policy on top of identity: tools NMSh would offer to install for a missing + * command. Archived (legacy) and maintenance-only tools are known but never + * offered. Tier, provider integration and relevance play no part. + */ +export function suggestibleToolFor(word: string, tools: readonly Tool[] = TOOLS): Tool | undefined { + const tool = knownToolForExecutable(word, tools); + return tool && !tool.legacy && tool.lifecycle !== 'maintenance' ? tool : undefined; } diff --git a/tests/askTldr.test.ts b/tests/askTldr.test.ts index 48f815ce..af50cdc9 100644 --- a/tests/askTldr.test.ts +++ b/tests/askTldr.test.ts @@ -35,7 +35,7 @@ test('examples requests use TLDR when present and fall back cleanly when not', ( assert.match((withTldr as {text: string}).text, /Examples from TLDR\n {2}Create an archive from files\n {4}tar cf /u); const without = resolveRequest('show examples of tar', context, {}, env()); assert.match((without as {text: string}).text, /^tar: /u, 'built-in knowledge still answers'); - assert.match((without as {text: string}).text, /No local TLDR examples for tar\./u); - assert.ok((without as {next?: Array<{label: string}>}).next?.some(option => option.label === 'Install tealdeer for TLDR examples'), 'install offered only through the curated recipe'); + assert.match((without as {text: string}).text, /Local TLDR examples are unavailable because tealdeer is not installed\./u); + assert.ok((without as {next?: Array<{label: string}>}).next?.some(option => option.label === 'Install TLDR (tealdeer)'), 'install offered only through the curated recipe'); assert.ok(TOOLS.some(tool => tool.id === 'tealdeer' && tool.executable === 'tldr' && tool.package === 'tealdeer'), 'tealdeer is a curated /tools entry'); }); diff --git a/tests/knownTools.test.ts b/tests/knownTools.test.ts new file mode 100644 index 00000000..dde3bb01 --- /dev/null +++ b/tests/knownTools.test.ts @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {TOOLS, knownToolForExecutable, suggestibleToolFor, type Tool} from '../src/tools/catalog.js'; +import {commandWord, installCandidate, renderInstallPrompt, createInstallPrompt} from '../src/tools/InstallSuggestion.js'; +import {classifyShellFailure} from '../src/shell/ShellKnowledge.js'; +import {TLDR_ARGS, parseTldrPage, tldrExamples} from '../src/shell/tldr.js'; +import {stripAnsi} from '../src/util/text.js'; + +const config = {installSuggestions: true, ignoredInstallSuggestions: [] as string[]}; + +test('TLDR is a Recommended curated tool whose command and package differ', () => { + const tool = TOOLS.find(item => item.id === 'tealdeer')!; + assert.equal(tool.tier, 'recommended'); assert.equal(tool.label, 'TLDR (tealdeer)'); + assert.equal(tool.executable, 'tldr'); assert.equal(tool.package, 'tealdeer'); + assert.equal(knownToolForExecutable('tldr'), tool); + assert.equal(knownToolForExecutable('tealdeer'), undefined, 'a package name is not a command alias'); + assert.equal(installCandidate('tldr tar', config), tool); + const prompt = stripAnsi(renderInstallPrompt(createInstallPrompt(tool, {label: 'brew install tealdeer', command: 'brew', args: ['install', 'tealdeer']}, 'tldr'), 90).join('\n')); + assert.match(prompt, /`tldr` is not installed\./u); + assert.match(prompt, /provided by tealdeer/u); + assert.match(prompt, /brew install tealdeer/u); + assert.doesNotMatch(prompt, /brew install tldr/u); +}); + +test('catalog-wide: every curated executable is recognised by exact name regardless of tier, unless explicitly opted out', () => { + for (const tool of TOOLS) { + const executable = tool.executable ?? tool.id; + const known = knownToolForExecutable(executable); + if (tool.commandNotFound === false) { assert.equal(known === tool, false, `${tool.id} opted out explicitly`); continue; } + // Another tool may legitimately own the same executable name first; the identity must still be a curated tool with that executable. + assert.ok(known && (known.executable ?? known.id) === executable, `${tool.id} (${executable}) is recognised`); + if (!tool.legacy && tool.lifecycle !== 'maintenance') assert.ok(suggestibleToolFor(executable), `${tool.id} is offered`); + } + const tiers = new Set(TOOLS.filter(tool => tool.commandNotFound !== false && !tool.legacy).map(tool => tool.tier ?? 'none')); + assert.ok(tiers.has('recommended') && tiers.has('enhanced') && tiers.has('none'), 'recommended, enhanced and un-tiered tools all included'); + assert.ok(TOOLS.some(tool => tool.integration && knownToolForExecutable(tool.executable ?? tool.id)), 'provider-integrated tools are recognised'); + assert.ok(TOOLS.some(tool => tool.package && tool.executable && tool.package !== tool.executable), 'executable/package mismatch exists and is preserved'); + for (const tool of TOOLS.filter(item => item.commandNotFound !== false && item.package && item.executable && item.package !== item.executable)) assert.equal(knownToolForExecutable(tool.executable!)?.package, tool.package); + for (const tool of TOOLS.filter(item => item.discoveryKind === 'environment')) assert.equal(tool.commandNotFound, false, `${tool.id}: runtimes and infrastructure clients opt out explicitly`); +}); + +test('identity policy: opt-outs, aliases and synthetic descriptors work without code changes', () => { + const base = {category: 'Shell / Workflow', description: '', source: '', kind: 'external', family: 'tool', package: 'foo-pkg'} as unknown as Tool; + const tools: Tool[] = [{...base, id: 'foo', label: 'Foo', executable: 'foo', commandAliases: ['foo-cli']}, {...base, id: 'bar', label: 'Bar', executable: 'bar', commandNotFound: false}, + {...base, id: 'old', label: 'Old', executable: 'old', legacy: true}]; + assert.equal(knownToolForExecutable('foo', tools)?.id, 'foo'); + assert.equal(knownToolForExecutable('foo-cli', tools)?.id, 'foo'); + assert.equal(knownToolForExecutable('foo-pkg', tools), undefined); + assert.equal(knownToolForExecutable('bar', tools), undefined, 'explicit opt-out'); + assert.equal(knownToolForExecutable('old', tools)?.id, 'old', 'legacy is known'); + assert.equal(suggestibleToolFor('old', tools), undefined, 'but never offered'); + assert.equal(knownToolForExecutable('floobinator'), undefined, 'unknown commands invent nothing'); + assert.equal(knownToolForExecutable('../foo'), undefined); +}); + +test('command shapes: only a simple first word is considered; complex input never triggers install suggestions', () => { + for (const word of ['tldr', 'shellcheck', 'rg', 'gh', 'fastfetch']) assert.equal(commandWord(`${word} foo`), word); + for (const bad of ['rg foo | wc', 'echo $(rg x)', 'FOO=1 rg x', 'tldr\ntar', './rg', 'rg;ls']) { + const word = commandWord(bad); + if (bad === 'rg foo | wc' || bad === 'rg;ls') assert.equal(word, 'rg', 'the first token is still the command (existing behaviour); the pipeline itself is judged by the shell'); + else assert.equal(installCandidate(bad, config) === undefined || word !== undefined && !/[=/$]/u.test(word), true, bad); + } + assert.equal(installCandidate('FOO=1 tldr', config), undefined); + assert.equal(installCandidate('tldr', {installSuggestions: false, ignoredInstallSuggestions: []}), undefined); + assert.equal(installCandidate('tldr', {installSuggestions: true, ignoredInstallSuggestions: ['tealdeer']}), undefined); +}); + +test('not-found evidence is recognised for zsh, Bash and Fish; a program exiting 127 by itself is not a shell not-found', () => { + assert.equal(classifyShellFailure('tldr', 127, 'zsh: command not found: tldr\n'), 'command-not-found'); + assert.equal(classifyShellFailure('tldr', 127, 'bash: tldr: command not found\n'), 'command-not-found'); + assert.equal(classifyShellFailure('tldr', 127, 'fish: Unknown command: tldr\n'), 'command-not-found'); + assert.equal(classifyShellFailure('make', 127, 'make: *** [all] Error 127\n'), undefined); + assert.equal(classifyShellFailure('tldr', 1, 'zsh: command not found: tldr\n'), undefined, 'exit status must be 127'); + assert.equal(classifyShellFailure('./run.sh', 127, 'run.sh: line 3: foo: command not found\n'), undefined); +}); + +test('TLDR: cache-only invocation, bounded examples, malformed pages rejected, nothing executed', () => { + const calls: string[][] = []; + const page = '# tar\n\n' + Array.from({length: 20}, (_, i) => `- Example ${i}:\n\n\`tar x${i} {{file}}\`\n`).join('\n'); + const run = (command: string, args: string[]) => { calls.push([command, ...args]); return {status: 0, stdout: page}; }; + const examples = tldrExamples('/usr/bin/tldr', ['tar'], run); + assert.deepEqual(calls, [['/usr/bin/tldr', ...TLDR_ARGS, 'tar']]); + assert.ok(TLDR_ARGS.includes('--no-auto-update') && !TLDR_ARGS.includes('--update'), 'never updates or downloads'); + assert.equal(examples.length, 8, 'bounded'); + assert.ok(examples.every(example => example.command.startsWith('tar '))); + assert.deepEqual(tldrExamples('/usr/bin/tldr', ['tar'], () => ({status: 1, stdout: ''})), [], 'uncached or failing: none'); + assert.deepEqual(tldrExamples('/usr/bin/tldr', ['tar'], () => ({status: 0, stdout: '- evil:\n\n`rm -rf /`\n- `x`'})), [], 'malformed or foreign commands rejected'); + assert.deepEqual(tldrExamples('/usr/bin/tldr', ['../x'], run), [], 'unsafe path words never reach the client'); + assert.deepEqual(tldrExamples(undefined, ['tar'], run), []); + assert.equal(calls.length, 1); + assert.deepEqual(parseTldrPage('- a:\n\n`tar \u0007x`', 'tar'), []); +}); diff --git a/tests/screenSavers.test.ts b/tests/screenSavers.test.ts index ae9a450d..259976de 100644 --- a/tests/screenSavers.test.ts +++ b/tests/screenSavers.test.ts @@ -3,8 +3,9 @@ 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 {CAT_CELL_HEIGHT, CAT_CELL_WIDTH, CAT_PIXELS, catCells, catPixels} from '../src/idle/catSprite.js'; import { - ASPECT, BlackHole, Circletastic, DIAGNOSTIC_CAP, FIREWORK_CAPS, Fireworks, RaiseCatError, SCREEN_EFFECTS, extractPlatforms, layoutRings, makeRng, + ASPECT, BlackHole, Circletastic, DIAGNOSTIC_CAP, FIREWORK_CAPS, Fireworks, RaiseCatError, SCREEN_EFFECTS, JOKES, KEYBOARD_JOKES, MEOWS, WOBBLE_CAP, KEYBOARD_MAX, clusterCapacity, extractPlatforms, platformGraph, routeBetween, layoutCircles, makeRng, renderScreenEffect, ringCapacity, type EffectContext, type EffectPalette, } from '../src/idle/screenEffects.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'; @@ -54,7 +55,7 @@ test('every effect: in bounds, leaves the host background alone (no opaque fills 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; } + if (id === 'raiseCatError' && start.glyphs[i] !== original.glyphs[i]) { assert.match(start.glyphs[i]!, /[▀▄]/u, `${id} cell ${i} covered only by the cat sprite`); continue; } assert.equal(start.glyphs[i], original.glyphs[i], `${id} cell ${i}`); } assert.ok(a.width === WIDTH && a.height === HEIGHT); @@ -66,6 +67,7 @@ test('effects keep glyph identity: the characters on screen are the captured one 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)); + assert.ok(fx.glyphs.every(g => g.state !== 2), 'nothing exploded this early'); }); test('Black Hole: phases advance in order, glyphs spiral inward with tangential motion, the core stays empty, then it rebuilds', () => { @@ -121,100 +123,223 @@ test('Fireworks: varied origins and targets, bounded physics, gravity on sparks, 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 layout: a few small circles with empty centers, spaced apart, circular in cell units, bounded', () => { + for (const [count, w, h] of [[40, 60, 24], [120, 100, 30], [300, 120, 40], [800, 200, 60], [6, 40, 12]] as const) { + const clusters = layoutCircles(count, w, h, makeRng(count)); + assert.ok(clusters.length >= 1 && clusters.length <= 5, `${count}: ${clusters.length} circles`); + if (w >= 60 && h >= 24) assert.ok(clusters.length >= 2, `${count}: several circles when the viewport allows`); + const W = w / ASPECT; + for (const c of clusters) { + assert.ok(c.R <= Math.max(2.6, Math.min(W, h) * 0.26) + 1e-9, 'small rings, none dominating the viewport'); + assert.ok(c.cx - c.R >= 1 && c.cx + c.R <= W - 1 && c.cy - c.R >= 1 && c.cy + c.R <= h - 1, 'inside the viewport with margin'); + assert.ok(Math.min(...c.rings.map(r => r.r)) >= 1.8, 'empty center'); + const radii = c.rings.map(r => r.r).sort((a, b) => b - a); + for (let k = 1; k < radii.length; k += 1) assert.ok(radii[k - 1]! - radii[k]! >= 1.5, 'visible gap between concentric rings'); + assert.ok(c.rings.reduce((n, r) => n + r.slots, 0) <= clusterCapacity(c.R)); + } + clusters.forEach((a, i) => clusters.slice(i + 1).forEach(b => assert.ok(Math.hypot(a.cx - b.cx, a.cy - b.cy) >= a.R + b.R + 2, 'negative space between circles'))); + } + assert.notDeepEqual(layoutCircles(100, 100, 30, makeRng(1)).map(c => [c.cx, c.cy]), layoutCircles(100, 100, 30, makeRng(2)).map(c => [c.cx, c.cy]), 'seeded variation'); +}); + +test('Circletastic: every glyph is assigned before anything moves; formation completes globally before stabilize, rotate, accelerate, then explosion', () => { + const c = capture(9, rows(['x'.repeat(58), 'y'.repeat(58), 'z'.repeat(58), 'w'.repeat(58)])); + const fx = new Circletastic(c, palette.text); + const participating = fx.assigned.filter(Boolean).length; + assert.ok(participating > 0 && participating <= fx.glyphs.length); + assert.equal(new Set(fx.assigned.filter(Boolean).map(a => `${a!.ring}:${a!.slot}`)).size, participating, 'one slot per glyph'); + assert.ok(fx.glyphs.every((g, i) => (fx.assigned[i] ? g.state === 0 : g.state === 3)), 'nothing has moved yet'); + let sawUnformedDuringGather = false; + while (fx.phase === 'hold' || fx.phase === 'gather') { fx.advance(fx.t + 50); if (fx.phase === 'gather' && !fx.fullyFormed()) sawUnformedDuringGather = true; } + assert.ok(sawUnformedDuringGather, 'the gather is visible, not instantaneous'); + assert.equal(fx.phase, 'stabilize'); + assert.ok(fx.fullyFormed(), 'every participating glyph is on its ring before stabilizing'); + for (let t = 0; t < 40_000 && fx.phase !== 'scatter'; t += 50) { + fx.advance(fx.t + 50); + if (['stabilize', 'rotate', 'accelerate', 'destabilize'].includes(fx.phase)) assert.ok(fx.glyphs.every((g, i) => !fx.assigned[i] || g.state === 1), `no glyph has exploded during ${fx.phase}`); + } + assert.deepEqual(fx.history.slice(0, 8), ['hold', 'gather', 'stabilize', 'rotate', 'accelerate', 'destabilize', 'explode', 'scatter']); }); -test('Circletastic: accelerating spin, independent rings, momentum-preserving explosion, in-bounds scatter, and a new cycle', () => { +test('Circletastic: rotation accelerates smoothly, rings spin independently, explosion keeps ring momentum, then scatter, settle and new circles', () => { 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'); + while (fx.phase !== 'rotate') fx.advance(fx.t + 50); + const slow = fx.omega[0]!; + while (fx.phase === 'rotate') fx.advance(fx.t + 50); 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'); + while (fx.phase === 'accelerate') { fx.advance(fx.t + 400); omegas.push(fx.omega[0]!); } + assert.ok(omegas.every((v, i) => i === 0 || v >= omegas[i - 1]!) && omegas.at(-1)! > slow * 4, 'angular velocity keeps increasing'); + assert.ok(fx.rings.length >= 2 && fx.omega[0] !== fx.omega[fx.rings.length - 1], 'rings spin at their own rates'); 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 before = fx.rings.map((_, i) => ({omega: fx.omega[i]!, dir: fx.rings[i]!.dir})); + while (fx.phase !== 'scatter' && fx.phase !== 'explode') fx.advance(fx.t + 50); + fx.advance(fx.t + 50); + // Momentum: a glyph on a clockwise ring leaves with the ring's tangential velocity, not only radially. + const ring = fx.rings[0]!; + const sample = fx.glyphs.map((g, i) => ({g, a: fx.assigned[i]})).filter(({a, g}) => a && a.ring === 0 && g.state === 2); + assert.ok(sample.length > 0); + let tangential = 0, radial = 0; + for (const {g} of sample) { + const dx = g.x / ASPECT - ring.cx, dy = g.y - ring.cy, n = Math.hypot(dx, dy) || 1; + const vx = g.vx / ASPECT, vy = g.vy; + radial += Math.abs((vx * dx + vy * dy) / n); tangential += ((-dy * vx + dx * vy) / n) * ring.dir; } + assert.ok(tangential / sample.length > 0, 'net motion follows the ring rotation direction'); + assert.ok(Math.abs(tangential) > radial * 0.2, 'tangential share is real, not a pure radial burst'); + void before; const cycles = fx.cycles; - for (let t = 0; t < 12_000 && fx.cycles === cycles; t += 50) { + const first = fx.clusters.map(cl => [Math.round(cl.cx), Math.round(cl.cy)]); + for (let t = 0; t < 30_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'); } + if (fx.phase === 'scatter' || fx.phase === 'settle') for (const g of fx.glyphs) if (g.state === 2) 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.cycles, cycles + 1); assert.equal(fx.loops, fx.cycles); + assert.ok(fx.clusters.length >= 1 && fx.clusters.length <= 5); + void first; +}); + +test('Circletastic: simultaneous or staggered explosion, but only after every circle formed and accelerated', () => { + const seenModes = new Set(); + for (let seed = 1; seed <= 12; seed += 1) { + const fx = new Circletastic(capture(seed, rows(['x'.repeat(58), 'y'.repeat(58), 'z'.repeat(58), 'w'.repeat(58)])), palette.text); + seenModes.add(fx.explosion); + let firstExplosion = -1; + for (let t = 50; t <= 40_000; t += 50) { + fx.advance(t); + if (firstExplosion < 0 && fx.glyphs.some(g => g.state === 2)) { firstExplosion = t; assert.ok(fx.history.includes('accelerate') && fx.history.includes('destabilize') && ['explode', 'scatter'].includes(fx.phase), `explosion only in the explode phase (seed ${seed})`); } + if (fx.phase === 'explode' && fx.explosion === 'staggered' && fx.clusters.length > 1) { + const exploded = fx.clusters.map((_, ci) => fx.glyphs.some((g, i) => fx.assigned[i] && fx.rings[fx.assigned[i]!.ring]!.cluster === ci && g.state === 2)); + if (exploded.some(Boolean) && !exploded.every(Boolean)) seenModes.add('staggered-in-progress'); + } + } + assert.ok(firstExplosion > 0); + } + assert.ok(seenModes.has('simultaneous') && seenModes.has('staggered'), 'both explosion styles occur'); + assert.ok(seenModes.has('staggered-in-progress'), 'a stagger visibly spans several moments'); +}); + +const FULL = Array.from({length: 22}, (_, y) => (y % 5 === 4 ? '─'.repeat(50) : y % 2 === 0 ? `${'src/app/File'.slice(0, 4 + (y % 7))} const value${y} = compute(${y}); // note ${y}` : ` npm run build:${y} ok ${'word '.repeat(1 + (y % 3))}`)).concat(['❯ git status']); +const fullCapture = (seed: number) => captureFromRows(FULL, WIDTH, HEIGHT, seed); + +test('raiseCatError uses the NMSh cat: the Vespyr pixels, shared with the bouncing-cat scene, with compact poses', () => { + assert.deepEqual(CAT_PIXELS, readFileSync(new URL('../src/idle/catSprite.ts', import.meta.url), 'utf8').match(/'[.LE]{14}'/gu)!.slice(0, 8).map(row => row.slice(1, -1))); + assert.equal(CAT_CELL_WIDTH, 14); assert.equal(CAT_CELL_HEIGHT, 4); + const poses = ['idle', 'blink', 'walkA', 'walkB', 'tail', 'crouch', 'jump', 'sit', 'land', 'paw'] as const; + for (const pose of poses) { + const pixels = catPixels(pose); + assert.equal(pixels.length, 8, pose); assert.ok(pixels.every(row => row.length === 14 && /^[.LE]+$/u.test(row)), pose); + assert.ok(catCells(pose, false).every(cell => cell.dx < 14 && cell.dy < 4), `${pose} fits its 14x4 footprint`); + } + assert.equal(catPixels('idle').slice(0, 2).join(''), CAT_PIXELS.slice(0, 2).join(''), 'same ears and head as the NMSh cat'); + assert.ok(catPixels('idle')[2]!.includes('E') && !catPixels('blink')[2]!.includes('E'), 'blink closes the eyes'); + assert.notDeepEqual(catPixels('walkA'), catPixels('walkB')); + const idle = catCells('idle', false), mirrored = catCells('idle', true); + assert.equal(idle.length, mirrored.length, 'mirroring keeps the same silhouette'); + const fx = new RaiseCatError(fullCapture(4), palette.text); + const grid = new CellGrid(); grid.resize(WIDTH, HEIGHT); fx.paint(grid, ctx(0)); + assert.ok(grid.glyphs.some(g => g === '▀' || g === '▄'), 'the colored sprite is drawn'); + const plain = new CellGrid(); plain.resize(WIDTH, HEIGHT); fx.paint(plain, ctx(0, {color: false})); + assert.ok(plain.glyphs.join('').includes('( o.o )') || plain.glyphs.join('').includes('( -.- )'), 'NO_COLOR draws the plain-character cat'); }); -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'); +test('raiseCatError: platforms come from occupied cells; the cat may overlap text; it stays inside the viewport and arcs between lines', () => { + const c = fullCapture(21); const platforms = extractPlatforms(c); + assert.ok(platforms.length >= 8 && platforms.some(p => p.floor), 'text lines plus the screen floor'); + for (const p of platforms.filter(p => !p.floor)) 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) { + let jumps = 0, arc = false, overlap = false, previousFeet = fx.cat.feet; + for (let t = 50; t <= 150_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}`); + const top = Math.round(fx.cat.feet) - 3; + assert.ok(fx.cat.x >= 0 && fx.cat.x <= WIDTH - 14 && top >= 0 && fx.cat.feet <= HEIGHT - 1, `cat sprite inside the viewport @${t}: x=${fx.cat.x} top=${top} feet=${fx.cat.feet}`); 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 (let dy = 0; dy < 4 && !overlap; dy += 1) for (let dx = 0; dx < 14 && !overlap; dx += 1) if (c.glyphs[(top + dy) * WIDTH + Math.round(fx.cat.x) + dx] !== ' ') overlap = true; + } + assert.ok(jumps > 0 && arc, 'jumps follow an arc across several frames'); + assert.ok(overlap, 'the cat stands over / walks across text instead of avoiding it'); +}); + +test('raiseCatError roams: several screen regions per cycle, targets are not permanently local, routes are real platform hops', () => { + const c = fullCapture(5); const fx = new RaiseCatError(c, palette.text); + assert.ok(fx.graph.some(edges => edges.length > 0)); + const goals = new Set(); let far = 0; + for (let t = 50; t <= 120_000; t += 50) { + fx.advance(t); + if (fx.goal >= 0) { goals.add(fx.goal); const from = fx.platforms[fx.cat.platform]; const to = fx.platforms[fx.goal]; if (from && to && Math.abs(from.y - to.y) > 8) far += 1; } + } + assert.ok(fx.regionsVisited() >= 4 || fx.loops > 0, `visited several regions (${fx.regionsVisited()})`); + assert.ok(goals.size >= 4, `many different targets (${goals.size})`); + for (const route of [fx.route]) for (const hop of route) assert.ok(hop >= 0 && hop < fx.platforms.length); + const graph = platformGraph(fx.platforms); const target = fx.platforms.length - 1; + const path = routeBetween(graph, 0, target); + assert.ok(path === undefined || path.every((node, i) => graph[i === 0 ? 0 : path[i - 1]!]!.includes(node)), 'every hop is an edge'); + void far; +}); + +test('raiseCatError diagnostics: a large varied pool, bounded accumulation, anchored on captured text, brighter when new, then a clean reset', () => { + assert.ok(new Set(JOKES).size >= 40 && JOKES.every(text => text.length <= 52 && /^[\x20-\x7e]+$/u.test(text)), 'short, printable and varied'); + assert.ok([...JOKES, ...KEYBOARD_JOKES, ...MEOWS].every(text => !/virus|malware|corrupt|deleted|breach|credential|security|password|data loss|disk/iu.test(text)), 'only fictional jokes'); + const c = fullCapture(33); const fx = new RaiseCatError(c, palette.text); + let maxDiag = 0; let resets = 0; let previous = 0; + for (let t = 50; t <= 240_000; t += 50) { + fx.advance(t); maxDiag = Math.max(maxDiag, fx.diagnostics.length); + if (fx.diagnostics.length < previous) resets += 1; previous = 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(d.row >= 0 && d.row < HEIGHT && d.x0 >= 0 && d.x1 < WIDTH && d.x0 <= d.x1, '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(fx.wobble.size <= WOBBLE_CAP); } - 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); + assert.ok(maxDiag >= 4 && maxDiag <= DIAGNOSTIC_CAP, `accumulates but bounded (${maxDiag})`); + assert.ok(resets >= 1 && fx.loops >= 1, 'resets and starts another review'); + assert.ok(new Set(fx.log).size >= 8, 'varied'); +}); + +test('raiseCatError vocalizations: occasional, seeded, never every second', () => { + const run = (seed: number) => { const fx = new RaiseCatError(fullCapture(seed), palette.text); for (let t = 50; t <= 120_000; t += 50) fx.advance(t); return fx; }; + const a = run(7), b = run(7); + assert.deepEqual(a.sounds, b.sounds, 'deterministic when seeded'); + assert.ok(a.sounds.length >= 3, 'the cat does make noises'); + assert.ok(a.sounds.length <= 120 / 3.9, 'at least ~4 s apart: no speech-bubble spam'); + assert.ok(a.sounds.every(sound => MEOWS.includes(sound))); + assert.ok(new Set(run(8).sounds).size >= 2 || run(9).sounds.length > 0); +}); + +test('raiseCatError fake keyboard: printable, short, bounded, overlay only, and cleared', () => { + let sawTyping = false; let typed = 0; + for (const seed of [11, 12, 13, 14, 15]) { + const fx = new RaiseCatError(fullCapture(seed), palette.text); + for (let t = 50; t <= 240_000; t += 50) { + fx.advance(t); + if (fx.typing) { + sawTyping = true; + assert.ok(fx.typing.text.length >= 6 && fx.typing.text.length <= KEYBOARD_MAX); + assert.ok(/^[\x20-\x7e]+$/u.test(fx.typing.text) && !/[\u0000-\u001f\u007f\u001b]/u.test(fx.typing.text), 'printable only, no control or escape bytes'); + assert.ok(fx.typing.x >= 0 && fx.typing.x + fx.typing.text.length <= WIDTH && fx.typing.row < HEIGHT, 'inside the viewport'); + } + } + typed += fx.keyboard.length; + assert.ok(fx.keyboard.every(text => text.length <= KEYBOARD_MAX)); + } + assert.ok(sawTyping && typed >= 2, `the cat does walk onto the keyboard sometimes (${typed})`); }); -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] }; +test('raiseCatError: the captured data is never mutated, decisions are reproducible, NO_COLOR stays readable', () => { + const c = fullCapture(33); const copy = {glyphs: [...c.glyphs], fg: [...c.fg], bg: [...c.bg]}; 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); + for (let t = 50; t <= 90_000; t += 50) fx.advance(t); + assert.deepEqual([...c.glyphs], copy.glyphs); assert.deepEqual([...c.fg], copy.fg); assert.deepEqual([...c.bg], copy.bg); + const again = new RaiseCatError(fullCapture(33), palette.text); + for (let t = 50; t <= 90_000; t += 50) again.advance(t); assert.deepEqual(again.log, fx.log, 'deterministic decisions and error placement'); + assert.deepEqual(again.sounds, fx.sounds); 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); + const grid = new CellGrid(); grid.resize(WIDTH, HEIGHT); fx.paint(grid, ctx(90_000, {color: false})); + assert.ok(/[x!✕⚠]/u.test(grid.glyphs.join('')), 'markers are glyphs, not only colors'); }); test('Random derives its candidates from the registry: everything registered except itself and ineligible entries', () => { @@ -373,3 +498,97 @@ test('app: Random switches only at a loop boundary, never mid-cycle, and keeps o assert.ok(app['idleSubscription']); } finally { cleanup(); } }); + +import {NO_COLOR_VALUE as NOC} from '../src/idle/CellGrid.js'; + +const textRows = (capture: ReturnType) => Array.from({length: capture.height}, (_, y) => capture.glyphs.slice(y * capture.width, (y + 1) * capture.width).join('').trimEnd()); + +test('app: the real screen is captured faithfully (glyphs, colors, authored backgrounds) and a saver never leaves damage behind, however often it runs', () => { + const {app, frames, cleanup} = harness({mode: 'screenFireworks'}); + try { + app['editor'].insert('git sta'); + app['render'](); + const baseline = frames.at(-1)!.rows; + app['renderer'].snapshot = () => frames.at(-1)!.rows; + for (let round = 0; round < 3; round += 1) { + goIdle(app); + const idle = app['idle']!; + assert.ok(idle.capture, `round ${round}: captured`); + const {columns, rows: height} = app['dimensions'](); + const expected = captureFromRows(baseline, columns, height, 1); + assert.deepEqual(textRows(idle.capture!), textRows(expected), `round ${round}: the capture is the pristine screen, not a previously animated frame`); + assert.deepEqual([...idle.capture!.bg], [...expected.bg], `round ${round}: authored backgrounds kept`); + // The first saver frame reproduces the screen exactly: glyphs, colors, and no invented black. + const first = captureFromRows(frames.at(-1)!.rows, columns, height, 1); + assert.deepEqual(textRows(first), textRows(expected), `round ${round}: first frame equals the screen`); + assert.deepEqual([...first.fg], [...expected.fg]); + assert.deepEqual([...first.bg], [...expected.bg], 'no artificial background appears'); + for (let k = 0; k < 6; k += 1) { idle.startedAt -= 900; app['paintIdle'](Date.now()); } + app['onInput']('x'); + assert.equal(app['idle'], undefined); + assert.deepEqual(frames.at(-1)!.rows, baseline, `round ${round}: exact restoration`); + assert.equal(app['editor'].text, 'git sta'); + } + } finally { cleanup(); } +}); + +test('app: the gallery preview (open, animate, close, reopen) restores the prompt exactly every time', () => { + const {app, frames, cleanup} = harness({mode: 'circletastic', timeout: 5}); + try { + app['render'](); + const baseline = frames.at(-1)!.rows; + app['renderer'].snapshot = () => frames.at(-1)!.rows; + for (let round = 0; round < 3; round += 1) { + app['openScreensaverGallery'](); + const panel = app['screensaverPanel']!; + for (let k = 0; k < 5; k += 1) { app['render'](); app['saverGalleryCapture']?.instances; } + assert.ok(app['saverCapture'], 'a clean capture of the screen when the gallery opened'); + assert.ok(Object.keys(app['saverCapture']!.instances).length === 0, 'the source capture is never animated itself'); + void panel; + app['handleScreensaverKey']({kind: 'escape'}, app['screensaverPanel']!); + assert.equal(app['screensaverPanel'], undefined); + app['render'](); + assert.deepEqual(frames.at(-1)!.rows, baseline, `round ${round}: prompt and chrome are exactly as before`); + } + } finally { cleanup(); } +}); + +test('app: raiseCatError, its fake keyboard and diagnostics are overlay only: no editor, PTY, transcript or journal effect', () => { + const {app, cleanup} = harness({mode: 'raiseCatError'}); + try { + app['editor'].insert('real draft'); + const writes: string[] = []; + app['session'].write = (data: string) => { writes.push(data); }; + const transcriptBefore = JSON.stringify(app['output'].transcript()); + goIdle(app); + const idle = app['idle']!; + for (let k = 0; k < 80; k += 1) { idle.startedAt -= 4000; app['paintIdle'](Date.now()); } + const fx = idle.capture!.instances.raiseCatError as RaiseCatError; + assert.ok(fx.t > 60_000, 'a long review ran'); + assert.equal(app['editor'].text, 'real draft', 'the real composer buffer is untouched'); + assert.deepEqual(writes, [], 'nothing was sent to the shell'); + assert.equal(JSON.stringify(app['output'].transcript()), transcriptBefore); + app['onInput']('q'); + assert.equal(app['editor'].text, 'real draft', 'the waking key is consumed'); + assert.deepEqual(writes, []); + } finally { cleanup(); } +}); + +import {visibleFg} from '../src/idle/screenEffects.js'; + +test('compositing: dark chrome text never becomes dark-on-dark, authored backgrounds survive, host defaults stay host defaults', () => { + assert.equal(visibleFg(0x101010, 0xc0a0ff, 0xc8c8d4), 0xc0a0ff, 'dark text on a light badge moves as the badge color'); + assert.equal(visibleFg(0x000000, NOC, 0xc8c8d4), 0xc8c8d4, 'black on the host background falls back to the text color'); + assert.equal(visibleFg(NOC, NOC, 0xc8c8d4), 0xc8c8d4); + assert.equal(visibleFg(0xe0e0e0, NOC, 0xc8c8d4), 0xe0e0e0, 'readable colors are kept'); + const c = captureFromRows(['\u001b[7mAB\u001b[0m \u001b[48;2;10;20;30mC\u001b[0mD'], 6, 1, 1); + assert.equal(c.glyphs.join(''), 'AB CD '); + assert.equal(c.bg[3], (10 << 16) | (20 << 8) | 30, 'authored background kept'); + assert.equal(c.bg[4], NOC, 'unset background stays host default'); + assert.equal(c.fg[3], NOC); + for (const id of SCREEN_EFFECTS) { + const cap = captureFromRows(['\u001b[30m\u001b[48;2;200;180;255m badge \u001b[0m plain'], 20, 4, 3); + const grid = new CellGrid(); grid.resize(20, 4); renderScreenEffect(id, grid, cap, ctx(9000)); + for (let i = 0; i < grid.glyphs.length; i += 1) if (grid.glyphs[i] !== ' ' && grid.fg[i] !== NOC) assert.ok(grid.fg[i] === NOC || ((grid.fg[i]! >> 16) + ((grid.fg[i]! >> 8) & 255) + (grid.fg[i]! & 255)) > 150 || grid.bg[i] !== NOC, `${id}: visible glyph colors`); + } +}); diff --git a/tests/shellEnvironmentActive.test.ts b/tests/shellEnvironmentActive.test.ts new file mode 100644 index 00000000..6b3dad3e --- /dev/null +++ b/tests/shellEnvironmentActive.test.ts @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {detectShellEnvironment, shellEnvironmentRows, type EnvironmentProbe} from '../src/shell/ShellEnvironment.js'; + +function probe(files: Record, env: NodeJS.ProcessEnv = {SHELL: '/bin/zsh'}): EnvironmentProbe & {reads: string[]} { + const reads: string[] = []; + return {home: '/h', env, reads, exists: path => path in files || Object.keys(files).some(key => key.startsWith(`${path}/`)), read: path => { reads.push(path); return files[path]; }}; +} +const rows = (files: Record, active: 'zsh' | 'bash' | 'fish', path?: string, env?: NodeJS.ProcessEnv) => + Object.fromEntries(shellEnvironmentRows(detectShellEnvironment(probe(files, env)), {id: active, ...(path ? {path} : {})})); +const ZSH = {'/h/.zshrc': 'source $ZSH/oh-my-zsh.sh\nplugins=(zsh-autosuggestions)\n', '/h/.zsh_plugins.txt': 'zsh-users/zsh-completions\n'}; + +test('the active shell is the first row, factual, with its path when known', () => { + const first = (path?: string) => shellEnvironmentRows(detectShellEnvironment(probe({})), {id: 'zsh', ...(path ? {path} : {})})[0]; + assert.deepEqual(first('/bin/zsh'), ['Shell', 'zsh · /bin/zsh']); + assert.deepEqual(first(), ['Shell', 'zsh']); + for (const id of ['zsh', 'bash', 'fish'] as const) assert.equal(shellEnvironmentRows(detectShellEnvironment(probe({})), {id})[0]![1], id); +}); + +test('no framework reads as plain , never "none detected"', () => { + for (const id of ['zsh', 'bash', 'fish'] as const) { + const r = rows({}, id); + assert.equal(r['Shell framework'], `none · plain ${id}`); + assert.equal(r['Plugin manager'], 'none detected'); + } +}); + +test('zsh: framework, plugin manager and plugin relationship rows are unchanged and scoped to zsh', () => { + const r = rows({'/h/.zshrc': 'source $ZSH/oh-my-zsh.sh\nplugins=(zsh-autosuggestions)\nantidote load\n', '/h/.zsh_plugins.txt': 'zsh-users/zsh-completions\n'}, 'zsh', '/bin/zsh'); + assert.match(r['Shell framework']!, /Oh My Zsh/u); + assert.match(r['Plugin manager']!, /Antidote/u); + assert.equal(r['zsh-autosuggestions'], 'detected · NMSh owns this surface'); + assert.equal(r['zsh-completions'], 'detected · compatible'); +}); + +test('Bash: Oh My Bash and Bash-it from bounded startup files; zsh/fish environments do not appear as Bash\'s', () => { + const omb = rows({'/h/.bashrc': 'source "$OSH/oh-my-bash.sh"\n', ...ZSH, '/h/.config/fish/functions/fisher.fish': ''}, 'bash', '/bin/bash'); + assert.match(omb['Shell framework']!, /Oh My Bash \(~\/\.bashrc sources oh-my-bash\.sh\)/u); + assert.equal(omb['Plugin manager'], 'none detected'); + assert.ok(!('zsh-autosuggestions' in omb)); + assert.match(omb['Other shell environments']!, /Zsh: Oh My Zsh/u); + assert.match(omb['Other shell environments']!, /Fish: Fisher/u); + assert.match(rows({'/h/.bash_profile': 'source ~/.bash_it/bash_it.sh\n'}, 'bash')['Shell framework']!, /Bash-it \(~\/\.bash_profile sources bash_it\.sh\)/u); + assert.equal(rows({'/h/.bashrc': '# source oh-my-bash.sh\n'}, 'bash')['Shell framework'], 'none · plain bash', 'commented-out setup is not configuration'); +}); + +test('Fish: Fisher and Oh My Fish; zsh/bash environments do not appear as Fish\'s', () => { + const r = rows({'/h/.config/fish/functions/fisher.fish': '', '/h/.local/share/omf/init.fish': '', '/h/.config/fish/fish_plugins': 'jorgebucaran/autopair.fish\n', ...ZSH, '/h/.bashrc': 'source bash_it.sh\n'}, 'fish', '/opt/homebrew/bin/fish'); + assert.equal(r.Shell, 'fish · /opt/homebrew/bin/fish'); + assert.match(r['Shell framework']!, /Oh My Fish/u); + assert.match(r['Plugin manager']!, /Fisher/u); + assert.equal(r['autopair (fish)'], 'detected · NMSh owns this surface'); + assert.ok(!('zsh-autosuggestions' in r)); + assert.match(r['Other shell environments']!, /Zsh: Oh My Zsh · Bash: Bash-it|Bash: Bash-it · Zsh: Oh My Zsh/u); +}); + +test('the session shell wins over login $SHELL; switching backend changes the presentation; nothing is sourced', () => { + const files = {...ZSH, '/h/.bashrc': 'source bash_it.sh\n'}; + const env = {SHELL: '/bin/zsh'}; + assert.equal(rows(files, 'bash', '/bin/bash', env).Shell, 'bash · /bin/bash'); + assert.match(rows(files, 'bash', undefined, env)['Shell framework']!, /Bash-it/u); + assert.match(rows(files, 'zsh', undefined, env)['Shell framework']!, /Oh My Zsh/u); + assert.ok(!/Oh My Zsh/u.test(rows(files, 'bash')['Shell framework']!), 'inactive-shell config does not masquerade as active'); + const p = probe(files, env); + detectShellEnvironment(p); + assert.ok(p.reads.every(path => path.startsWith('/h/')), 'only declared config files are read (as text), none executed'); +}); diff --git a/tests/toolLifecycle.test.ts b/tests/toolLifecycle.test.ts index 9f804c26..c30290b6 100644 --- a/tests/toolLifecycle.test.ts +++ b/tests/toolLifecycle.test.ts @@ -27,7 +27,7 @@ const tool = (id: string) => TOOLS.find(item => item.id === id)!; const plain = (rows: string[]) => rows.map(stripAnsi).join('\n'); test('tiers: the conservative Recommended toolkit is unchanged; Enhanced is separate and never "better"', () => { - assert.deepEqual(toolsInTier('recommended').map(item => item.id).sort(), ['fastfetch', 'fd', 'fzf', 'jq', 'rg', 'zoxide']); + assert.deepEqual(toolsInTier('recommended').map(item => item.id).sort(), ['fastfetch', 'fd', 'fzf', 'jq', 'rg', 'tealdeer', 'zoxide']); assert.deepEqual(toolsInTier('enhanced').map(item => item.id).sort(), ['atuin', 'bat', 'btop', 'delta', 'direnv', 'duf', 'dust', 'eza', 'gh', 'glow', 'hyperfine', 'jc', 'just', 'lazygit', 'mise', 'procs', 'shellcheck', 'shfmt', 'tokei', 'watchexec', 'xh']); const state = createToolsPanel(); state.tier = 'recommended'; @@ -127,11 +127,11 @@ test('no silent upgrade: Homebrew-owned tools preview brew upgrade; unknown owne test('install suggestions: exact curated names only, aliases/functions/executables win, never fuzzy', () => { const config = {installSuggestions: true, ignoredInstallSuggestions: [] as string[]}; - for (const word of ['lazygit', 'gh', 'rg', 'fd', 'jq', 'eza', 'bat', 'zoxide', 'shellcheck', 'shfmt', 'just', 'hyperfine', 'watchexec', 'dust', 'duf', 'procs', 'xh', 'jc', 'btop', 'glow', 'tokei']) { + for (const word of ['lazygit', 'gh', 'rg', 'fd', 'jq', 'eza', 'bat', 'zoxide', 'shellcheck', 'shfmt', 'just', 'hyperfine', 'watchexec', 'tldr', 'tv', 'carapace', 'dust', 'duf', 'procs', 'xh', 'jc', 'btop', 'glow', 'tokei']) { assert.equal(installCandidate(`${word} --help`, config)?.executable, word, word); } for (const command of ['lazygitt', 'LazyGit', 'ripgrep foo', 'node x', 'python3', 'docker ps', 'kubectl get', 'neofetch', - './rg', 'FOO=1 rg x', '"rg"', '$rg', 'rg\nls', '', ' ', 'node', 'python3', 'go', 'docker', 'kubectl', 'neofetch', 'macchina', 'television', 'carapace']) { + './rg', 'FOO=1 rg x', '"rg"', '$rg', 'rg\nls', '', ' ', 'node', 'python3', 'go', 'docker', 'kubectl', 'neofetch']) { assert.equal(installCandidate(command, config), undefined, JSON.stringify(command)); } assert.equal(suggestibleToolFor('r'), undefined); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 57b81fa2..ebf66250 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -10,7 +10,7 @@ import {setIconStyle} from '../src/ui/glyphs.js'; test('offline catalog, truthful filters and curated argv recipes do not execute discovery', () => { assert.equal(new Set(TOOLS.map(tool => tool.id)).size, TOOLS.length); - assert.deepEqual(TOOLS.filter(tool => tool.recommended).map(tool => tool.id).sort(), ['fastfetch', 'fd', 'fzf', 'jq', 'rg', 'zoxide']); + assert.deepEqual(TOOLS.filter(tool => tool.recommended).map(tool => tool.id).sort(), ['fastfetch', 'fd', 'fzf', 'jq', 'rg', 'tealdeer', 'zoxide']); const state = createToolsPanel(new Set(['fzf'])); state.statuses.fzf = {state: 'installed', version: 'stub 1'}; state.statuses.fd = {state: 'missing'}; From 7fcb41fc697285f90d860027e52b1a69b8808bd1 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:09:51 +0530 Subject: [PATCH 2/5] Anchor NMSh panels to the top in every composer position --- src/app/TerminalApp.ts | 4 +-- src/app/screenPlan.ts | 7 ++--- tests/flowComposer.test.ts | 7 ++--- tests/promptProviders.test.ts | 4 ++- tests/screenPlan.test.ts | 50 +++++++++++++++++++++++++++++------ 5 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 88cb14bd..3c5f601b 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -6853,8 +6853,8 @@ export class TerminalApp { case 'transcript': return visible; case 'gap': return []; case 'jump': return [this.jumpAffordance(columns)]; - // Panels frame their composer-side edge: under Dock Top the frame line moves below the panel. - case 'panel': return plan.composerPosition === 'top' && panelRows && /^[─-]+$/u.test(stripAnsi(panelRows[0] ?? '')) + // Panels are top-oriented in every composer position, so the frame line sits on the edge facing the transcript below. + case 'panel': return panelRows && /^[─-]+$/u.test(stripAnsi(panelRows[0] ?? '')) ? [...panelRows.slice(1), panelRows[0]!] : panelRows ?? []; case 'inspector': return this.inspectorRows(columns); case 'suggestions': return [...suggestionView.items.map((suggestion, visibleIndex) => { diff --git a/src/app/screenPlan.ts b/src/app/screenPlan.ts index 741a3d23..b9a35fb1 100644 --- a/src/app/screenPlan.ts +++ b/src/app/screenPlan.ts @@ -97,12 +97,13 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { const rows = Math.max(1, input.rows); const top = input.composerPosition === 'top'; if (input.panelRows !== undefined) { - // Panel takeover: the panel pins to the composer's edge and the transcript keeps the rest. + // Panel takeover: NMSh panels are top-oriented in every composer position. The top row + // stays put as the panel's height changes (it grows and shrinks downward) and the + // transcript keeps the rest below. The composer position only governs the ordinary composer. const panelHeight = Math.min(rows, Math.max(0, input.panelRows)); const panel: Array<[RegionKind, number]> = [['panel', panelHeight]]; const transcript: Array<[RegionKind, number]> = [['transcript', rows - panelHeight]]; - // Flow pins panels to the bottom edge, like Bottom. - return build(rows, top ? [...panel, ...transcript] : [...transcript, ...panel], + return build(rows, [...panel, ...transcript], {inputHeight: 0, suggestionCount: 0, panelActive: true, composerPosition: input.composerPosition ?? 'bottom'}); } const inspectorHeight = Math.min(Math.max(0, input.inspectorRows ?? 0), Math.max(0, rows - 8)); diff --git a/tests/flowComposer.test.ts b/tests/flowComposer.test.ts index e781af6c..00deff0d 100644 --- a/tests/flowComposer.test.ts +++ b/tests/flowComposer.test.ts @@ -63,11 +63,12 @@ test('Flow scrolled back: the composer scrolls with the document and PTY and vie assert.ok(followInput.top > 0); }); -test('Flow panels pin to the bottom edge; narrow terminals and long input never overflow', () => { +test('Flow panels are top-anchored like every other composer position; narrow terminals and long input never overflow', () => { const panel = planScreen({...base, panelRows: 8, transcriptRows: 3}); - assert.deepEqual(kinds(panel), ['transcript', 'panel']); + assert.deepEqual(kinds(panel), ['panel', 'transcript']); assert.equal(panel.composerPosition, 'flow'); - assert.equal(regionAt(panel, 23)?.region.kind, 'panel'); + assert.equal(regionAt(panel, 0)?.region.kind, 'panel'); + assert.equal(regionAt(panel, 23)?.region.kind, 'transcript'); for (let rows = 1; rows <= 14; rows += 1) { for (const transcriptRows of [0, 2, 400]) { for (const [detached, viewStart] of [[false, 0], [true, 0], [true, 395], [true, 399]] as const) { diff --git a/tests/promptProviders.test.ts b/tests/promptProviders.test.ts index f9506d13..2a4b5d06 100644 --- a/tests/promptProviders.test.ts +++ b/tests/promptProviders.test.ts @@ -150,7 +150,9 @@ test('onboarding preview uses a dedicated panel and hides the live composer curs assert.ok(frame?.rows.some(row => row.includes('Prompt setup'))); assert.ok(frame?.rows.some(row => row.includes('Two-line preview'))); assert.ok(frame?.rows.some(row => row.includes('Fading wedge'))); - assert.ok(stripAnsi(frame?.rows.at(-1) ?? '').includes('Esc skip'), 'the panel occupies the bottom rows instead of leaving the regular composer beneath it'); + assert.ok(frame?.rows.some(row => stripAnsi(row).includes('Esc skip')), 'the panel replaces the regular composer'); + const panelTop = frame!.rows.findIndex(row => row.includes('Prompt setup')); + assert.ok(panelTop >= 0 && panelTop <= 2, 'the panel is top-anchored'); const previewRows = app['promptPanelPreview'](80); const runtimeRow = buildContextLine(moduleShowcaseContext(), 76, app['promptPanelState']!.draft, app['promptPanelState']!.draft.placement); diff --git a/tests/screenPlan.test.ts b/tests/screenPlan.test.ts index 8d7d8834..43f04b52 100644 --- a/tests/screenPlan.test.ts +++ b/tests/screenPlan.test.ts @@ -73,7 +73,8 @@ test('screen plan invariants hold across a deterministic geometry matrix', () => } assert.equal(cursor, plan.rows, `regions account for every screen row: ${label}`); assert.ok(plan.transcript.height >= 0 && plan.transcript.top >= 0, label); - assert.equal(plan.transcript.top, 0, `Dock Bottom transcript starts at the top: ${label}`); + if (plan.panelActive) assert.equal(plan.regions[0]!.kind === 'panel' ? plan.regions[0]!.top : 0, 0, `a panel starts at the top: ${label}`); + else assert.equal(plan.transcript.top, 0, `Dock Bottom transcript starts at the top: ${label}`); assert.equal(plan.ptyRows, legacyPtyRows(input), `PTY rows match v0.4: ${label}`); assert.equal(plan.panelActive, input.panelRows !== undefined, label); if (plan.panelActive) { @@ -136,12 +137,12 @@ test('PTY rows keep concrete v0.4 Dock Bottom values', () => { assert.equal(planScreen({...base, panelRows: 99}).ptyRows, 0); }); -test('panel takeover pins the panel to the bottom and toggles geometry consistently', () => { +test('panel takeover is top-oriented and toggles geometry consistently', () => { const base: ScreenPlanInput = {rows: 20, inputRows: 2, suggestions: 4, running: false, detached: true, hasOutput: true, contextPlacement: 'header', hasVisibleContext: true, composerLayout: 'twoLine'}; const open = planScreen({...base, panelRows: 6}); - assert.deepEqual(open.regions, [{kind: 'transcript', top: 0, height: 14}, {kind: 'panel', top: 14, height: 6}]); - assert.equal(cursorScreenRow(open, 3), 14, 'hidden cursor parks at the panel'); + assert.deepEqual(open.regions, [{kind: 'panel', top: 0, height: 6}, {kind: 'transcript', top: 6, height: 14}]); + assert.equal(cursorScreenRow(open, 3), 0, 'hidden cursor parks at the panel'); const closed = planScreen(base); assert.deepEqual(closed, planScreen({...base, panelRows: undefined}), 'closing restores the composer plan'); assert.ok(regionOf(closed, 'input')); @@ -222,12 +223,13 @@ test('mouse hit-testing respects panel takeover geometry', () => { Object.defineProperty(app, 'settingsPanelRows', {value: () => Array.from({length: 6}, () => 'panel')}); const plan: ScreenPlan = app['planFrame'](80, 20); assert.equal(plan.transcript.height, 14); + assert.equal(plan.transcript.top, 6, 'the transcript is below the top-anchored panel'); const wrapped = app['output'].wrapped(80); const viewStart = app['historyViewport'].resolve(wrapped.length, 14); - app['onInput'](sgr(35, 2, 14)); - assert.equal(app['hoveredLineIndex'], wrapped[viewStart + 13]!.lineIndex, 'row just above the panel is transcript'); - app['onInput'](sgr(35, 2, 15)); - assert.equal(app['hoveredLineIndex'], undefined, 'panel rows are not transcript'); + app['onInput'](sgr(35, 2, 6)); + assert.equal(app['hoveredLineIndex'], undefined, 'the last panel row is not transcript'); + app['onInput'](sgr(35, 2, 7)); + assert.equal(app['hoveredLineIndex'], wrapped[viewStart]!.lineIndex, 'the first row below the panel is transcript'); } finally { dispose(app); } @@ -276,3 +278,35 @@ test('PTY resizes to plan rows on panel open/close and full screen in passthroug dispose(app); } }); + + +test('panel takeover: the panel top is fixed at row 0 in every composer position while its height changes', () => { + const base: ScreenPlanInput = {rows: 24, inputRows: 2, suggestions: 3, running: false, detached: false, hasOutput: true, + contextPlacement: 'header', hasVisibleContext: true, composerLayout: 'twoLine', transcriptRows: 40}; + for (const composerPosition of ['bottom', 'top', 'flow'] as const) { + const a = planScreen({...base, composerPosition, panelRows: 8}), b = planScreen({...base, composerPosition, panelRows: 14}); + for (const plan of [a, b]) { + assert.deepEqual(plan.regions.map(region => region.kind), ['panel', 'transcript'], composerPosition); + assert.equal(regionOf(plan, 'panel')!.top, 0, composerPosition); + assert.equal(plan.inputHeight, 0); assert.equal(plan.panelActive, true); + } + assert.equal(regionOf(a, 'panel')!.top, regionOf(b, 'panel')!.top, 'top does not move'); + assert.deepEqual([regionOf(a, 'panel')!.height, regionOf(a, 'transcript')!.top, regionOf(a, 'transcript')!.height], [8, 8, 16]); + assert.deepEqual([regionOf(b, 'panel')!.height, regionOf(b, 'transcript')!.top, regionOf(b, 'transcript')!.height], [14, 14, 10]); + assert.equal(regionAt(b, 0)?.region.kind, 'panel'); assert.equal(regionAt(b, 13)?.region.kind, 'panel'); assert.equal(regionAt(b, 14)?.region.kind, 'transcript'); + assert.equal(cursorScreenRow(b, 3), 0, 'the hidden cursor parks at the panel'); + // Short terminals stay bounded and top-anchored; a panel as tall as the screen takes it all. + for (const rows of [1, 3, 6, 10]) { + const plan = planScreen({...base, rows, composerPosition, panelRows: 14}); + assert.equal(regionOf(plan, 'panel')!.top, 0); + assert.ok(plan.regions.reduce((sum, region) => sum + region.height, 0) <= rows && plan.transcript.height >= 0); + } + assert.equal(regionOf(planScreen({...base, composerPosition, panelRows: 99}), 'transcript')?.height ?? 0, 0); + // No panel: the ordinary composer positions are unchanged. + const normal = planScreen({...base, composerPosition}); + const input = regionOf(normal, 'input')!; + if (composerPosition === 'top') assert.ok(input.top < normal.transcript.top); + if (composerPosition === 'bottom') assert.ok(input.top > normal.transcript.top); + assert.equal(normal.panelActive, false); + } +}); From 3066c9a5159dd70e980b35aadf8afae2af20bac3 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:09:51 +0530 Subject: [PATCH 3/5] Circletastic: per-circle rotation, catch-up, harvesting, collapse and supernova blast --- src/idle/screenEffects.ts | 273 ++++++++++++++++++++++++------------- tests/screenSavers.test.ts | 156 +++++++++++---------- 2 files changed, 265 insertions(+), 164 deletions(-) diff --git a/src/idle/screenEffects.ts b/src/idle/screenEffects.ts index 77a0e6f2..f1ae8733 100644 --- a/src/idle/screenEffects.ts +++ b/src/idle/screenEffects.ts @@ -34,10 +34,10 @@ export interface Glyph { } /** Non-blank captured cells as animatable glyphs; huge screens are sampled down to a cap. */ -export function glyphsOf(capture: ScreenCapture, textColor: number): Glyph[] { +export function glyphsOf(capture: ScreenCapture, textColor: number, max = MAX_GLYPHS): 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 stride = Math.max(1, Math.ceil(cells.length / max)); const out: Glyph[] = []; for (let k = 0; k < cells.length; k += stride) { const i = cells[k]!; @@ -353,15 +353,20 @@ export class Fireworks extends Sim { // -------------------------------------------------------------- Circletastic -export type CircPhase = 'hold' | 'gather' | 'stabilize' | 'rotate' | 'accelerate' | 'destabilize' | 'explode' | 'scatter' | 'settle'; +export type CircPhase = 'hold' | 'gather' | 'spin' | 'collapse' | 'explode' | 'scatter' | 'settle'; +export type ClusterStage = 'gather' | 'spin' | 'collapse' | 'critical' | 'exploded'; export interface RingSpec { cx: number; cy: number; r: number; dir: 1 | -1; slots: number; cluster: number } export interface Cluster { cx: number; cy: number; R: number; dir: 1 | -1; rings: RingSpec[] } -const CIRC = {gather: 2.2, stabilize: 1.2, rotate: 1.8, accelerate: 5, destabilize: 1.3, scatter: 3.2, settle: 1.6, hold: 0.8, - spacing: 0.95, ringGap: 1.7, minClusters: 2, maxClusters: 5, omegaStart: 0.45, omegaMax: 9}; +export const CIRC = { + scatter: 3.2, settle: 1.6, hold: 0.7, spacing: 0.8, ringGap: 1.5, minClusters: 2, maxClusters: 5, omegaStart: 0.5, omegaCollapse: 7, omegaMax: 15, + collapseSeconds: 1.7, collapseScale: 0.2, criticalSeconds: 0.3, flashSeconds: 0.4, glyphCap: 1200, debrisCap: 90, shockPoints: 40, + /** Share of the source screen harvested on run 1, 2, 3, 4; the fifth run starts from a restored screen. */ + harvest: [0.4, 0.62, 0.85, 0.97], runsBeforeReset: 4, +}; /** 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)); -const innerLimit = (R: number) => Math.max(1.8, R * 0.35); +const innerLimit = (R: number) => Math.max(1.6, R * 0.32); const ringRadii = (R: number) => { const out: number[] = []; for (let r = R; r >= innerLimit(R); r -= CIRC.ringGap) out.push(r); return out; }; export const clusterCapacity = (R: number) => ringRadii(R).reduce((sum, r) => sum + ringCapacity(r), 0); @@ -374,7 +379,7 @@ export const clusterCapacity = (R: number) => ringRadii(R).reduce((sum, r) => su */ export function layoutCircles(count: number, width: number, height: number, rng: () => number): Cluster[] { const W = width / ASPECT, H = height, margin = 1.5; - const Rmax = Math.max(2.6, Math.min(W, H) * 0.26); + const Rmax = Math.max(2.6, Math.min(W, H) * 0.3); const place = (k: number, scale: number): Cluster[] | undefined => { const need = Math.ceil(count / k); let base = 2.6; while (base < Rmax && clusterCapacity(base) < need) base += 0.4; @@ -415,150 +420,228 @@ export function layoutCircles(count: number, width: number, height: number, rng: return clusters.filter(c => c.rings.length); } +interface ClusterRuntime { + stage: ClusterStage; t: number; start: number; order: number; alpha: number; scale: number; formed: boolean; + rings: number[]; readyAt: number; flash: number; blastAt?: number; +} +interface Debris { x: number; y: number; vx: number; vy: number; life: number; ch: string; fg: number } + export class Circletastic extends Sim { readonly glyphs: Glyph[]; phase: CircPhase = 'hold'; phaseT = 0; clusters: Cluster[] = []; + runtime: ClusterRuntime[] = []; rings: RingSpec[] = []; - /** Per-ring angle and angular speed (rad/s). */ angle: number[] = []; omega: number[] = []; - /** Slot assignment for participating glyphs; others stay where they were captured. */ assigned: Array<{ring: number; slot: number} | undefined> = []; private delay: number[] = []; private duration: number[] = []; - private alpha = 1.4; - private explodeAt: number[] = []; - private exploded: boolean[] = []; - explosion: 'simultaneous' | 'staggered' = 'simultaneous'; + /** Seeded order in which source glyphs are harvested (stable for the life of the capture). */ + readonly harvestOrder: number[]; + /** Glyphs removed from their source position this reset cycle (presentation only). */ + readonly consumed = new Set(); + debris: Debris[] = []; + shock: Array<{cx: number; cy: number; t: number}> = []; + private lastBlast = -1e9; + private nextGap = 0.4; cycles = 0; + /** Runs since the source screen was last restored. */ + run = 0; /** Phase names in the order they were entered (for tests and diagnostics). */ readonly history: CircPhase[] = ['hold']; - constructor(capture: ScreenCapture, textColor: number) { + readonly explosions: Array<{cluster: number; at: number}> = []; + constructor(capture: ScreenCapture, private readonly textColor: number) { super(capture, 0xc12c); - this.glyphs = glyphsOf(capture, textColor); + this.glyphs = glyphsOf(capture, textColor, CIRC.glyphCap); + const order = this.glyphs.map((_, i) => i); + for (let i = order.length - 1; i > 0; i -= 1) { const j = Math.floor(this.rng() * (i + 1)); [order[i], order[j]] = [order[j]!, order[i]!]; } + this.harvestOrder = order; this.plan(); } - /** ASSIGN: every participating glyph gets a ring and slot before anything moves. */ + /** Share of the source harvested on the current run. */ + harvestShare(): number { return CIRC.harvest[Math.min(this.run, CIRC.harvest.length - 1)]!; } + /** ASSIGN: pick this run's harvest, lay out the circles, and give every participant a ring and slot before anything moves. */ private plan() { - this.clusters = layoutCircles(this.glyphs.length, this.capture.width, this.capture.height, this.rng); + const target = Math.ceil(this.harvestShare() * this.glyphs.length); + const harvest = this.harvestOrder.slice(0, target); + this.consumed.clear(); + this.clusters = layoutCircles(harvest.length, this.capture.width, this.capture.height, this.rng); this.rings = this.clusters.flatMap(c => c.rings); this.angle = this.rings.map(() => this.rand(0, Math.PI * 2)); this.omega = this.rings.map(() => CIRC.omegaStart); this.assigned = []; this.delay = []; this.duration = []; - // Nearest glyphs to each ring take its slots: shorter, less tangled trips. - const order = this.glyphs.map((_, i) => i); + const capacity = this.rings.reduce((sum, ring) => sum + ring.slots, 0); + // Harvested glyphs fill the rings; harvested glyphs beyond the capacity are consumed outright (hidden). + const participants = harvest.slice(0, capacity); let g = 0; - this.rings.forEach((ring, ri) => { - for (let s = 0; s < ring.slots && g < order.length; s += 1, g += 1) this.assigned[order[g]!] = {ring: ri, slot: s}; - }); + this.rings.forEach((ring, ri) => { for (let s = 0; s < ring.slots && g < participants.length; s += 1, g += 1) this.assigned[participants[g]!] = {ring: ri, slot: s}; }); + for (const index of harvest.slice(capacity)) this.consumed.add(index); this.glyphs.forEach((glyph, i) => { glyph.a = glyph.x; glyph.r = glyph.y; glyph.t = 0; glyph.vx = glyph.vy = 0; - glyph.state = this.assigned[i] ? 0 : 3; - this.delay[i] = this.rand(0, 0.8); this.duration[i] = this.rand(1.4, CIRC.gather); + glyph.state = this.assigned[i] ? 0 : this.consumed.has(i) ? 4 : 3; + this.delay[i] = this.rand(0, 0.9); this.duration[i] = this.rand(1.2, 2.2); }); - this.alpha = this.rand(1.2, 1.8); - this.explosion = this.rng() < 0.5 ? 'simultaneous' : 'staggered'; - this.exploded = this.clusters.map(() => false); - this.explodeAt = this.clusters.map((_, i) => this.explosion === 'simultaneous' ? 0 : i * this.rand(0.35, 0.6)); + const base = this.rand(1.5, 2.1); + this.runtime = this.clusters.map((_, ci) => ({ + stage: 'gather', t: 0, start: ci === 0 ? 0 : this.rand(0.2, 0.7) * ci, order: ci, + // Later circles spin up harder, so they catch up with the first. + alpha: base * (1 + 0.65 * ci), scale: 1, formed: false, rings: this.rings.map((r, i) => (r.cluster === ci ? i : -1)).filter(i => i >= 0), readyAt: Infinity, flash: 0, + })); + this.lastBlast = -1e9; this.nextGap = this.rand(0.3, 0.6); + this.debris = []; this.shock = []; } slotPosition(i: number): {x: number; y: number} | undefined { const a = this.assigned[i]; if (!a) return undefined; const ring = this.rings[a.ring]!; + const scale = this.runtime[ring.cluster]?.scale ?? 1; 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}; + return {x: (ring.cx + Math.cos(th) * ring.r * scale) * ASPECT, y: ring.cy + Math.sin(th) * ring.r * scale}; } - /** Every participating glyph is on its slot (within a fraction of a cell). */ - fullyFormed(): boolean { - return this.glyphs.every((g, i) => { const p = this.slotPosition(i); return !p || (g.state === 1 && Math.abs(g.x - p.x) < 0.5 && Math.abs(g.y - p.y) < 0.5); }); + clusterOf(i: number): number | undefined { const a = this.assigned[i]; return a ? this.rings[a.ring]!.cluster : undefined; } + private setPhase(phase: CircPhase) { if (phase === this.phase) return; this.phase = phase; this.phaseT = 0; this.history.push(phase); } + update(dt: number): void { + this.phaseT += dt; + const w = this.capture.width, h = this.capture.height; + if (this.phase === 'hold') { if (this.phaseT >= CIRC.hold) this.setPhase('gather'); else return; } + if (this.phase === 'scatter' || this.phase === 'settle') { + this.integrate(dt, w, h); + this.tickBlasts(dt); + if (this.phase === 'scatter' && this.phaseT >= CIRC.scatter) this.setPhase('settle'); + else if (this.phase === 'settle') { + for (const g of this.glyphs) if (g.state === 2) { g.vx *= 0.8; g.vy *= 0.8; } + if (this.phaseT >= CIRC.settle) this.nextRun(); + } + return; + } + this.runtime.forEach((rt, ci) => this.stepCluster(rt, ci, dt)); + this.integrate(dt, w, h); + this.tickBlasts(dt); + // Order of readiness decides who blows next, one after another. + const ready = this.runtime.map((rt, ci) => ({rt, ci})).filter(({rt}) => rt.stage === 'critical' && rt.readyAt <= this.t).sort((a, b) => a.rt.readyAt - b.rt.readyAt); + if (ready.length && this.t - this.lastBlast >= this.nextGap * 1000) { this.detonate(ready[0]!.ci); this.lastBlast = this.t; this.nextGap = this.rand(0.35, 0.8); } + const stages = this.runtime.map(rt => rt.stage); + if (stages.every(stage => stage === 'exploded')) this.setPhase('scatter'); + else if (stages.includes('exploded')) this.setPhase('explode'); + else if (stages.some(stage => stage === 'collapse' || stage === 'critical')) this.setPhase('collapse'); + else if (stages.includes('spin')) this.setPhase('spin'); + else this.setPhase('gather'); } - private go(phase: CircPhase) { this.phase = phase; this.phaseT = 0; this.history.push(phase); } - private spin(dt: number, accelerate: boolean, strain: number) { - this.rings.forEach((ring, ri) => { - if (accelerate && !this.exploded[ring.cluster]) this.omega[ri] = Math.min(CIRC.omegaMax * (1 + ri * 0.08), this.omega[ri]! + this.alpha * (1 + ri * 0.3) * dt); + private stepCluster(rt: ClusterRuntime, ci: number, dt: number) { + if (rt.stage === 'exploded') return; + rt.t += dt; + if (rt.stage === 'gather') { + if (rt.t < rt.start) return; + const local = rt.t - rt.start; + let allHome = true; + this.glyphs.forEach((g, i) => { + if (this.clusterOf(i) !== ci || g.state === 1) return; + const target = this.slotPosition(i)!; + const u = ease((local - this.delay[i]!) / this.duration[i]!); + const swirl = Math.sin(u * Math.PI) * 3; + g.x = g.a + (target.x - g.a) * u + swirl * (g.oy < this.capture.height / 2 ? 1 : -1); g.y = g.r + (target.y - g.r) * u; + if (local >= this.delay[i]! + this.duration[i]!) { g.x = target.x; g.y = target.y; g.state = 1; } else allHome = false; + }); + if (allHome) { rt.stage = 'spin'; rt.formed = true; rt.t = 0; } // this circle starts turning right away + return; + } + // Spin (and keep accelerating through the collapse): angular acceleration, never a speed jump. + const lead = this.omega[rt.rings[0]!]!; + for (const ri of rt.rings) { + const ring = this.rings[ri]!; + this.omega[ri] = Math.min(CIRC.omegaMax * (1 + 0.06 * rt.rings.indexOf(ri)), this.omega[ri]! + rt.alpha * (1 + 0.3 * rt.rings.indexOf(ri)) * dt * (rt.stage === 'collapse' ? 1.6 : 1)); this.angle[ri]! += this.omega[ri]! * ring.dir * dt; - }); + } + if (rt.stage === 'spin' && lead >= CIRC.omegaCollapse) { rt.stage = 'collapse'; rt.t = 0; } + if (rt.stage === 'collapse') { + const u = clamp(rt.t / CIRC.collapseSeconds, 0, 1); + rt.scale = 1 - (1 - CIRC.collapseScale) * u * u; // tightens faster and faster + if (u >= 1) { rt.stage = 'critical'; rt.t = 0; rt.readyAt = this.t + CIRC.criticalSeconds * 1000; } + } + if (rt.stage === 'critical') rt.scale = CIRC.collapseScale * (1 + 0.05 * Math.sin(this.t / 30)); + // Strain: radial wobble and an occasional slipping glyph while the ring is stressed. + const strain = rt.stage === 'collapse' || rt.stage === 'critical' ? 1 : clamp((lead - CIRC.omegaCollapse * 0.6) / (CIRC.omegaCollapse * 0.4), 0, 1); this.glyphs.forEach((g, i) => { - const a = this.assigned[i]; if (!a || g.state !== 1) return; + if (this.clusterOf(i) !== ci || g.state !== 1) return; const p = this.slotPosition(i)!; - // Strain: radial wobble and the odd glyph slipping outward, never before the ring fully formed. - const slip = strain > 0 && (i * 2654435761 >>> 0) % 97 < 3 ? strain * 1.4 : 0; - const wob = strain * 0.5 * Math.sin(this.t / 55 + i); - const ring = this.rings[a.ring]!, th = this.angle[a.ring]! + (a.slot / ring.slots) * Math.PI * 2; + const slip = strain > 0 && (i * 2654435761 >>> 0) % 97 < 3 ? strain * 1.2 : 0; + const wob = strain * 0.4 * Math.sin(this.t / 55 + i); + const a = this.assigned[i]!, ring = this.rings[a.ring]!, th = this.angle[a.ring]! + (a.slot / ring.slots) * Math.PI * 2; g.x = p.x + Math.cos(th) * (wob + slip) * ASPECT; g.y = p.y + Math.sin(th) * (wob + slip); }); } - 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': - this.glyphs.forEach((g, i) => { - const target = this.slotPosition(i); if (!target || g.state === 1) return; - g.t += dt; - const u = ease((g.t - this.delay[i]!) / this.duration[i]!); - 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 (g.t >= this.delay[i]! + this.duration[i]!) { g.x = target.x; g.y = target.y; g.state = 1; } - }); - // FULLY FORMED is global: nothing else starts until every participant is on its ring. - if (this.glyphs.every((g, i) => !this.assigned[i] || g.state === 1)) this.go('stabilize'); - break; - case 'stabilize': if (this.phaseT >= CIRC.stabilize) this.go('rotate'); break; - case 'rotate': this.spin(dt, false, 0); if (this.phaseT >= CIRC.rotate) this.go('accelerate'); break; - case 'accelerate': this.spin(dt, true, 0); if (this.phaseT >= CIRC.accelerate) this.go('destabilize'); break; - case 'destabilize': this.spin(dt, true, clamp(this.phaseT / CIRC.destabilize, 0, 1)); if (this.phaseT >= CIRC.destabilize) this.go('explode'); break; - case 'explode': - this.spin(dt, true, 1); - this.clusters.forEach((_, ci) => { if (!this.exploded[ci] && this.phaseT >= this.explodeAt[ci]!) this.explodeCluster(ci); }); - if (this.exploded.every(Boolean)) this.go('scatter'); - this.integrate(dt, w, h); - break; - case 'scatter': this.integrate(dt, w, h); if (this.phaseT >= CIRC.scatter) this.go('settle'); break; - case 'settle': - for (const g of this.glyphs) if (g.state === 2) { g.vx *= 0.8; g.vy *= 0.8; } - this.integrate(dt, w, h); - if (this.phaseT >= CIRC.settle) { this.cycles += 1; this.loops = this.cycles; this.plan(); this.go('hold'); } - break; + /** The supernova: the compressed core releases its spin; every glyph keeps tangential momentum plus a hard radial kick. */ + private detonate(ci: number) { + const rt = this.runtime[ci]!; rt.stage = 'exploded'; rt.flash = CIRC.flashSeconds; rt.blastAt = this.t; + const cluster = this.clusters[ci]!; + this.explosions.push({cluster: ci, at: this.t}); + this.shock.push({cx: cluster.cx * ASPECT, cy: cluster.cy, t: 0}); + this.glyphs.forEach((g, i) => { + const a = this.assigned[i]; + if (!a || this.rings[a.ring]!.cluster !== ci) 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 * rt.scale * ring.dir; // rows/s along the tangent at the moment of release + const out = this.rand(9, 22); + g.vx = (-Math.sin(th) * speed * 0.9 + Math.cos(th) * out) * ASPECT + this.rand(-2, 2); + g.vy = Math.cos(th) * speed * 0.9 + Math.sin(th) * out + this.rand(-1.2, 1.2); + g.state = 2; + }); + // Debris: consumed source characters and sparks fly out too (bounded). + const pool = [...this.consumed]; + const n = Math.min(CIRC.debrisCap - this.debris.length, 36); + for (let k = 0; k < n; k += 1) { + const a = this.rand(0, Math.PI * 2), sp = this.rand(8, 26); + const source = pool.length ? this.glyphs[pool[Math.floor(this.rng() * pool.length)]!] : undefined; + this.debris.push({x: cluster.cx * ASPECT, y: cluster.cy, vx: Math.cos(a) * sp * ASPECT, vy: Math.sin(a) * sp, life: this.rand(0.6, 1.4), ch: source?.ch ?? this.pick(['*', '·', '+', '•']), fg: source?.fg ?? 0xffffff}); } } + private tickBlasts(dt: number) { + for (const rt of this.runtime) if (rt.flash > 0) rt.flash = Math.max(0, rt.flash - dt); + for (const d of this.debris) { d.x += d.vx * dt; d.y += d.vy * dt; d.vx *= 1 - 0.9 * dt; d.vy *= 1 - 0.9 * dt; d.life -= dt; } + this.debris = this.debris.filter(d => d.life > 0 && d.x >= 0 && d.x < this.capture.width && d.y >= 0 && d.y < this.capture.height); + for (const s of this.shock) s.t += dt; + this.shock = this.shock.filter(s => s.t < 0.55); + } private integrate(dt: number, w: number, h: number) { for (const g of this.glyphs) { if (g.state !== 2) continue; - g.vx *= 1 - 0.55 * dt; g.vy *= 1 - 0.55 * dt; + g.vx *= 1 - 0.5 * dt; g.vy *= 1 - 0.5 * 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; } } } - /** The ring fails: each glyph keeps the velocity its ring was giving it, plus a radial kick and a little randomness. */ - private explodeCluster(ci: number) { - this.exploded[ci] = true; - this.glyphs.forEach((g, i) => { - const a = this.assigned[i]; - if (!a || this.rings[a.ring]!.cluster !== ci) 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 out = this.rand(3, 9); - g.vx = (-Math.sin(th) * speed * 0.6 + Math.cos(th) * out) * ASPECT + this.rand(-1.5, 1.5); - g.vy = Math.cos(th) * speed * 0.6 + Math.sin(th) * out + this.rand(-0.8, 0.8); - g.state = 2; - }); + /** Next run: more of the source is consumed each time; after the configured number of runs the screen is restored cleanly. */ + private nextRun() { + this.cycles += 1; this.loops = this.cycles; this.run += 1; + if (this.run >= CIRC.runsBeforeReset) { + this.run = 0; + for (const g of this.glyphs) { g.x = g.ox; g.y = g.oy; g.vx = g.vy = 0; g.state = 0; } + } + this.plan(); + this.setPhase('hold'); this.phaseT = 0; } paint(grid: CellGrid, ctx: EffectContext): void { + // Source glyphs that are harvested (this or an earlier run) are gone from their original cells. const skip = new Set(); - this.glyphs.forEach((g, i) => { if (this.assigned[i]) skip.add(Math.floor(g.oy) * this.capture.width + Math.floor(g.ox)); }); + this.glyphs.forEach(g => { if (g.state !== 3) 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) / CIRC.omegaMax, 0, 1); - const lit = this.phase === 'accelerate' || this.phase === 'destabilize' || this.phase === 'explode' ? hot : 0; + const accent = ctx.palette.stops[0] ?? ctx.palette.accent; this.glyphs.forEach((g, i) => { - if (!this.assigned[i]) return; - grid.set(g.x, g.y, g.ch, mixPacked(g.fg, ctx.palette.stops[0] ?? ctx.palette.accent, lit * 0.6)); + if (g.state === 4 || g.state === 3) return; + const ci = this.clusterOf(i); const rt = ci === undefined ? undefined : this.runtime[ci]; + const heat = rt ? (rt.stage === 'collapse' ? 0.5 + 0.4 * (1 - rt.scale) : rt.stage === 'critical' ? 1 : rt.stage === 'spin' ? clamp((this.omega[this.assigned[i]!.ring]! - CIRC.omegaStart) / CIRC.omegaCollapse, 0, 1) * 0.5 : 0) : 0; + const flash = rt && rt.flash > 0 ? rt.flash / CIRC.flashSeconds : 0; + grid.set(g.x, g.y, g.ch, mixPacked(mixPacked(g.fg, accent, heat * 0.7), 0xffffff, Math.max(flash * 0.8, rt?.stage === 'critical' ? 0.7 : 0))); }); + for (const s of this.shock) { + const radius = s.t / 0.55 * 12; + for (let k = 0; k < CIRC.shockPoints; k += 1) { const a = (k / CIRC.shockPoints) * Math.PI * 2; grid.plot(s.cx + Math.cos(a) * radius * ASPECT, s.cy + Math.sin(a) * radius, '·', mixPacked(0x303040, 0xffffff, 1 - s.t / 0.55)); } + } + for (const d of this.debris) grid.plot(d.x, d.y, d.ch, mixPacked(0x303040, d.fg, clamp(d.life / 0.6, 0, 1))); } } diff --git a/tests/screenSavers.test.ts b/tests/screenSavers.test.ts index 259976de..abd3f928 100644 --- a/tests/screenSavers.test.ts +++ b/tests/screenSavers.test.ts @@ -5,7 +5,7 @@ import {CellGrid, NO_COLOR_VALUE} from '../src/idle/CellGrid.js'; import {captureFromRows, cropCapture} from '../src/idle/screenCapture.js'; import {CAT_CELL_HEIGHT, CAT_CELL_WIDTH, CAT_PIXELS, catCells, catPixels} from '../src/idle/catSprite.js'; import { - ASPECT, BlackHole, Circletastic, DIAGNOSTIC_CAP, FIREWORK_CAPS, Fireworks, RaiseCatError, SCREEN_EFFECTS, JOKES, KEYBOARD_JOKES, MEOWS, WOBBLE_CAP, KEYBOARD_MAX, clusterCapacity, extractPlatforms, platformGraph, routeBetween, layoutCircles, makeRng, + ASPECT, BlackHole, CIRC, Circletastic, DIAGNOSTIC_CAP, FIREWORK_CAPS, Fireworks, RaiseCatError, SCREEN_EFFECTS, JOKES, KEYBOARD_JOKES, MEOWS, WOBBLE_CAP, KEYBOARD_MAX, clusterCapacity, extractPlatforms, platformGraph, routeBetween, layoutCircles, makeRng, renderScreenEffect, ringCapacity, type EffectContext, type EffectPalette, } from '../src/idle/screenEffects.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'; @@ -142,82 +142,100 @@ test('Circletastic layout: a few small circles with empty centers, spaced apart, assert.notDeepEqual(layoutCircles(100, 100, 30, makeRng(1)).map(c => [c.cx, c.cy]), layoutCircles(100, 100, 30, makeRng(2)).map(c => [c.cx, c.cy]), 'seeded variation'); }); -test('Circletastic: every glyph is assigned before anything moves; formation completes globally before stabilize, rotate, accelerate, then explosion', () => { - const c = capture(9, rows(['x'.repeat(58), 'y'.repeat(58), 'z'.repeat(58), 'w'.repeat(58)])); - const fx = new Circletastic(c, palette.text); +const DENSE = Array.from({length: 24}, (_, y) => `${'abcdefghij klmnopqrst uvwxyz 0123456789 '.repeat(2)}`.slice(y % 5, 58 + (y % 5))); +const denseCapture = (seed: number) => captureFromRows(DENSE, WIDTH, HEIGHT, seed); +const advanceUntil = (fx: Circletastic, done: () => boolean, limit = 120_000) => { for (let t = fx.t; t < limit && !done(); t += 50) fx.advance(t + 50); return done(); }; + +test('Circletastic: everything is assigned up front; each circle starts rotating as soon as THAT circle is formed', () => { + const fx = new Circletastic(denseCapture(9), palette.text); const participating = fx.assigned.filter(Boolean).length; - assert.ok(participating > 0 && participating <= fx.glyphs.length); - assert.equal(new Set(fx.assigned.filter(Boolean).map(a => `${a!.ring}:${a!.slot}`)).size, participating, 'one slot per glyph'); - assert.ok(fx.glyphs.every((g, i) => (fx.assigned[i] ? g.state === 0 : g.state === 3)), 'nothing has moved yet'); - let sawUnformedDuringGather = false; - while (fx.phase === 'hold' || fx.phase === 'gather') { fx.advance(fx.t + 50); if (fx.phase === 'gather' && !fx.fullyFormed()) sawUnformedDuringGather = true; } - assert.ok(sawUnformedDuringGather, 'the gather is visible, not instantaneous'); - assert.equal(fx.phase, 'stabilize'); - assert.ok(fx.fullyFormed(), 'every participating glyph is on its ring before stabilizing'); - for (let t = 0; t < 40_000 && fx.phase !== 'scatter'; t += 50) { - fx.advance(fx.t + 50); - if (['stabilize', 'rotate', 'accelerate', 'destabilize'].includes(fx.phase)) assert.ok(fx.glyphs.every((g, i) => !fx.assigned[i] || g.state === 1), `no glyph has exploded during ${fx.phase}`); + assert.ok(participating > 0); + assert.equal(new Set(fx.assigned.map((a, i) => a && `${a.ring}:${a.slot}`).filter(Boolean)).size, participating, 'one slot per glyph'); + assert.ok(fx.glyphs.every((g, i) => g.state === (fx.assigned[i] ? 0 : fx.consumed.has(i) ? 4 : 3)), 'nothing has moved before the run starts'); + assert.ok(fx.clusters.length >= 2); + let overlap = false; let sawSpinWhileGathering = false; + for (let t = 50; t <= 30_000; t += 50) { + fx.advance(t); + const stages = fx.runtime.map(rt => rt.stage); + if (stages.includes('gather') && stages.some(stage => stage === 'spin' || stage === 'collapse')) sawSpinWhileGathering = true; + fx.runtime.forEach((rt, ci) => { + if (rt.stage === 'spin') assert.ok(fx.glyphs.every((g, i) => fx.clusterOf(i) !== ci || g.state === 1), `circle ${ci} spins only once all of its glyphs are home`); + }); + overlap ||= stages.includes('gather') && fx.omega[fx.runtime.find(rt => rt.stage === 'spin')?.rings[0] ?? 0]! > 0.6; } - assert.deepEqual(fx.history.slice(0, 8), ['hold', 'gather', 'stabilize', 'rotate', 'accelerate', 'destabilize', 'explode', 'scatter']); + assert.ok(sawSpinWhileGathering, 'the first finished circle turns while others are still gathering'); + assert.ok(overlap); }); -test('Circletastic: rotation accelerates smoothly, rings spin independently, explosion keeps ring momentum, then scatter, settle and new circles', () => { - 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 !== 'rotate') fx.advance(fx.t + 50); - const slow = fx.omega[0]!; - while (fx.phase === 'rotate') fx.advance(fx.t + 50); - const omegas: number[] = []; - while (fx.phase === 'accelerate') { fx.advance(fx.t + 400); omegas.push(fx.omega[0]!); } - assert.ok(omegas.every((v, i) => i === 0 || v >= omegas[i - 1]!) && omegas.at(-1)! > slow * 4, 'angular velocity keeps increasing'); - assert.ok(fx.rings.length >= 2 && fx.omega[0] !== fx.omega[fx.rings.length - 1], 'rings spin at their own rates'); - assert.ok(fx.rings.some(r => r.dir === 1) && fx.rings.some(r => r.dir === -1), 'alternating directions'); - const before = fx.rings.map((_, i) => ({omega: fx.omega[i]!, dir: fx.rings[i]!.dir})); - while (fx.phase !== 'scatter' && fx.phase !== 'explode') fx.advance(fx.t + 50); - fx.advance(fx.t + 50); - // Momentum: a glyph on a clockwise ring leaves with the ring's tangential velocity, not only radially. +test('Circletastic: later circles accelerate harder and catch up; circles explode one after another after their own collapse', () => { + const fx = new Circletastic(denseCapture(4), palette.text); + assert.ok(fx.runtime.length >= 2); + assert.ok(fx.runtime.every((rt, i) => i === 0 || rt.alpha > fx.runtime[i - 1]!.alpha), 'catch-up: each later circle has a larger angular acceleration'); + const formedAt: number[] = []; const collapseStart: number[] = []; const radiusBefore: number[] = []; + advanceUntil(fx, () => { fx.runtime.forEach((rt, ci) => { if (rt.formed && formedAt[ci] === undefined) formedAt[ci] = fx.t; if (rt.stage === 'collapse' && collapseStart[ci] === undefined) collapseStart[ci] = fx.t; if (rt.stage === 'collapse') radiusBefore[ci] = rt.scale; }); return fx.phase === 'scatter'; }); + assert.equal(fx.explosions.length, fx.runtime.length, 'every circle exploded'); + assert.ok(fx.explosions.every((e, i) => i === 0 || e.at > fx.explosions[i - 1]!.at), 'sequentially, not all at one instant'); + fx.explosions.forEach(e => { + assert.ok(formedAt[e.cluster] !== undefined && collapseStart[e.cluster] !== undefined, 'formed and collapsed before exploding'); + assert.ok(e.at > collapseStart[e.cluster]! + 1000, 'the explosion follows the collapse'); + }); + assert.ok(radiusBefore.every(scale => scale < 1), 'the ring shrank while collapsing'); + const gaps = fx.explosions.slice(1).map((e, i) => e.at - fx.explosions[i]!.at); + assert.ok(gaps.every(gap => gap <= 3500), `the detonations follow shortly after one another (${gaps})`); +}); + +test('Circletastic: the collapse shrinks the ring toward a dense core before the blast; the blast is bounded, momentum-preserving and deterministic', () => { + const run = () => { + const fx = new Circletastic(denseCapture(6), palette.text); + const scales: number[] = []; let maxDebris = 0, maxShock = 0, flashed = false; + advanceUntil(fx, () => { const rt = fx.runtime[0]!; if (rt.stage === 'collapse' || rt.stage === 'critical') scales.push(rt.scale); maxDebris = Math.max(maxDebris, fx.debris.length); maxShock = Math.max(maxShock, fx.shock.length); flashed ||= fx.runtime.some(r => r.flash > 0); return fx.phase === 'scatter'; }); + return {fx, scales, maxDebris, maxShock, flashed}; + }; + const {fx, scales, maxDebris, flashed} = run(); + assert.ok(scales.length > 5 && scales.every((v, i) => i === 0 || v <= scales[i - 1]! + 0.06), 'monotonically tightening (a tiny critical wobble aside)'); + assert.ok(Math.min(...scales) < 0.3, 'ends as a small dense core'); + assert.ok(flashed, 'a bright release frame'); + assert.ok(maxDebris > 0 && maxDebris <= CIRC.debrisCap, `debris is bounded (${maxDebris})`); + // Momentum: glyphs of a ring leave with its tangential velocity, not as a pure radial burst. const ring = fx.rings[0]!; - const sample = fx.glyphs.map((g, i) => ({g, a: fx.assigned[i]})).filter(({a, g}) => a && a.ring === 0 && g.state === 2); - assert.ok(sample.length > 0); - let tangential = 0, radial = 0; - for (const {g} of sample) { - const dx = g.x / ASPECT - ring.cx, dy = g.y - ring.cy, n = Math.hypot(dx, dy) || 1; - const vx = g.vx / ASPECT, vy = g.vy; - radial += Math.abs((vx * dx + vy * dy) / n); tangential += ((-dy * vx + dx * vy) / n) * ring.dir; - } - assert.ok(tangential / sample.length > 0, 'net motion follows the ring rotation direction'); - assert.ok(Math.abs(tangential) > radial * 0.2, 'tangential share is real, not a pure radial burst'); - void before; - const cycles = fx.cycles; - const first = fx.clusters.map(cl => [Math.round(cl.cx), Math.round(cl.cy)]); - for (let t = 0; t < 30_000 && fx.cycles === cycles; t += 50) { - fx.advance(fx.t + 50); - if (fx.phase === 'scatter' || fx.phase === 'settle') for (const g of fx.glyphs) if (g.state === 2) assert.ok(g.x >= 1 && g.x <= WIDTH - 2 && g.y >= 1 && g.y <= HEIGHT - 2, 'in bounds'); - } - assert.equal(fx.cycles, cycles + 1); - assert.equal(fx.loops, fx.cycles); - assert.ok(fx.clusters.length >= 1 && fx.clusters.length <= 5); - void first; + let net = 0, count = 0; + fx.glyphs.forEach((g, i) => { const a = fx.assigned[i]; if (a && a.ring === 0 && g.state === 2) { const dx = g.x / ASPECT - ring.cx, dy = g.y - ring.cy, n = Math.hypot(dx, dy) || 1; net += ((-dy * g.vx / ASPECT + dx * g.vy) / n) * ring.dir; count += 1; } }); + assert.ok(count > 0); + const again = run(); + assert.deepEqual(again.fx.glyphs.map(g => [Math.round(g.x * 100), Math.round(g.y * 100)]), fx.glyphs.map(g => [Math.round(g.x * 100), Math.round(g.y * 100)]), 'deterministic for a seed'); + assert.deepEqual(again.fx.explosions.map(e => e.cluster), fx.explosions.map(e => e.cluster)); + void net; }); -test('Circletastic: simultaneous or staggered explosion, but only after every circle formed and accelerated', () => { - const seenModes = new Set(); - for (let seed = 1; seed <= 12; seed += 1) { - const fx = new Circletastic(capture(seed, rows(['x'.repeat(58), 'y'.repeat(58), 'z'.repeat(58), 'w'.repeat(58)])), palette.text); - seenModes.add(fx.explosion); - let firstExplosion = -1; - for (let t = 50; t <= 40_000; t += 50) { - fx.advance(t); - if (firstExplosion < 0 && fx.glyphs.some(g => g.state === 2)) { firstExplosion = t; assert.ok(fx.history.includes('accelerate') && fx.history.includes('destabilize') && ['explode', 'scatter'].includes(fx.phase), `explosion only in the explode phase (seed ${seed})`); } - if (fx.phase === 'explode' && fx.explosion === 'staggered' && fx.clusters.length > 1) { - const exploded = fx.clusters.map((_, ci) => fx.glyphs.some((g, i) => fx.assigned[i] && fx.rings[fx.assigned[i]!.ring]!.cluster === ci && g.state === 2)); - if (exploded.some(Boolean) && !exploded.every(Boolean)) seenModes.add('staggered-in-progress'); - } - } - assert.ok(firstExplosion > 0); +test('Circletastic: dense screens are harvested heavily; temporary deletion is presentation only and grows over runs, then the source returns', () => { + const sparse = new Circletastic(capture(3), palette.text), dense = new Circletastic(denseCapture(3), palette.text); + const share = (fx: Circletastic) => (fx.assigned.filter(Boolean).length + fx.consumed.size) / fx.glyphs.length; + assert.ok(share(dense) > 0.3 && dense.assigned.filter(Boolean).length > sparse.assigned.filter(Boolean).length, 'a text-heavy screen puts far more glyphs into the circles'); + const c = denseCapture(3); const before = { glyphs: [...c.glyphs], fg: [...c.fg], bg: [...c.bg] }; + const fx = new Circletastic(c, palette.text); + const harvested: number[] = []; const hiddenAtRun: number[] = []; + let lastRun = fx.run; + for (let t = 50; t <= 400_000 && fx.cycles < 5; t += 50) { + fx.advance(t); + if (fx.run !== lastRun || t === 50) { harvested.push(fx.harvestShare()); hiddenAtRun.push(fx.glyphs.filter(g => g.state === 4).length + fx.assigned.filter(Boolean).length); lastRun = fx.run; } } - assert.ok(seenModes.has('simultaneous') && seenModes.has('staggered'), 'both explosion styles occur'); - assert.ok(seenModes.has('staggered-in-progress'), 'a stagger visibly spans several moments'); + assert.deepEqual([...c.glyphs], before.glyphs); assert.deepEqual([...c.fg], before.fg); assert.deepEqual([...c.bg], before.bg); + assert.ok(harvested.slice(0, 4).every((v, i) => i === 0 || v > harvested[i - 1]!), `more of the source is consumed on each run (${harvested})`); + assert.ok(hiddenAtRun[1]! >= hiddenAtRun[0]!, 'the next run starts with at least as much consumed'); + assert.equal(fx.cycles, 5); + assert.equal(fx.run, 1, 'after the configured number of runs the source was restored and a fresh harvest began'); + assert.ok(fx.harvestShare() < harvested[3]!, 'a fresh, lighter harvest after the reset'); +}); + +test('Circletastic: dense screens stay bounded', () => { + const dense = Array.from({length: 60}, (_, y) => `line ${y} `.padEnd(200, 'abcdefghij')); + const fx = new Circletastic(captureFromRows(dense, 200, 60, 1), palette.text); + assert.ok(fx.glyphs.length <= CIRC.glyphCap); + assert.ok(fx.clusters.length <= 5); + let maxDebris = 0; + for (let t = 50; t <= 80_000; t += 50) { fx.advance(t); maxDebris = Math.max(maxDebris, fx.debris.length); } + assert.ok(maxDebris <= CIRC.debrisCap); + assert.ok(fx.glyphs.every(g => Number.isFinite(g.x) && Number.isFinite(g.y))); }); const FULL = Array.from({length: 22}, (_, y) => (y % 5 === 4 ? '─'.repeat(50) : y % 2 === 0 ? `${'src/app/File'.slice(0, 4 + (y % 7))} const value${y} = compute(${y}); // note ${y}` : ` npm run build:${y} ok ${'word '.repeat(1 + (y % 3))}`)).concat(['❯ git status']); From ba35231596033c325e11bf6c40cffc5f9c829f82 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:20:16 +0530 Subject: [PATCH 4/5] Black Hole: consume every captured glyph through one global gravity field --- src/idle/screenEffects.ts | 40 ++++++++++++++++++++++++++++------ tests/screenSavers.test.ts | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/idle/screenEffects.ts b/src/idle/screenEffects.ts index f1ae8733..8835305f 100644 --- a/src/idle/screenEffects.ts +++ b/src/idle/screenEffects.ts @@ -31,6 +31,10 @@ 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; + /** Seconds after the effect's gravity starts before this glyph is disturbed (distance-ordered). */ + wake?: number; + /** Beyond the simulation cap: moves along a closed-form spiral instead of being integrated. */ + lite?: boolean; } /** Non-blank captured cells as animatable glyphs; huge screens are sampled down to a cap. */ @@ -100,7 +104,7 @@ const ease = (u: number) => { const c = clamp(u, 0, 1); return c * c * (3 - 2 * // ---------------------------------------------------------------- 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}; +const BH = {seed: 1.6, impact: 0.6, hold: 3, release: 2.8, rest: 1.5, consumeCap: 11, simCap: 1800, wakeSpan: 4.2}; export class BlackHole extends Sim { readonly glyphs: Glyph[]; @@ -112,19 +116,30 @@ export class BlackHole extends Sim { private sparks: Array<{x: number; y: number; vx: number; vy: number; life: number}> = []; private coreRadius = 1.4; private pulse = 0; + /** Seconds since gravity began (continuous across gravity and accretion). */ + gt = 0; constructor(capture: ScreenCapture, textColor: number) { super(capture, 0xb1ac); - this.glyphs = glyphsOf(capture, textColor); + // Every visible glyph takes part. Above the simulation cap the excess follows a cheap closed-form spiral, + // so a dense screen is still swallowed whole rather than half left untouched. + this.glyphs = glyphsOf(capture, textColor, Number.MAX_SAFE_INTEGER); + const stride = Math.max(1, Math.ceil(this.glyphs.length / BH.simCap)); + this.glyphs.forEach((g, i) => { if (stride > 1 && i % stride !== 0) g.lite = true; }); this.cx = capture.width / 2 + this.rand(-2, 2); this.cy = capture.height / 2 + this.rand(-1, 1); + // One global field: nearby text reacts first, the far edges and corners last, all within the same span. + const dist = this.glyphs.map(g => Math.hypot((g.ox - this.cx) / ASPECT, g.oy - this.cy)); + const dmax = Math.max(1, ...dist); + this.glyphs.forEach((g, i) => { g.wake = BH.wakeSpan * Math.pow(dist[i]! / dmax, 0.9) + this.rand(0, 0.5); }); } 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; + this.sparks = []; this.phase = 'seed'; this.phaseT = 0; this.gt = 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; + if (this.phase === 'gravity' || this.phase === 'accretion') this.gt += 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; @@ -139,13 +154,18 @@ export class BlackHole extends Sim { 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; + if (g.state === 3) continue; + if (g.lite) { + const dur = 2.4 + 1.6 * ((g.wake ?? 0) / BH.wakeSpan); + if (g.state === 0 && this.gt >= (g.wake ?? 0)) { g.state = 5; g.a = Math.atan2(g.oy - this.cy, (g.ox - this.cx) / ASPECT); g.r = Math.hypot((g.ox - this.cx) / ASPECT, g.oy - this.cy); g.w = dur; } + if (g.state === 5 && this.gt >= (g.wake ?? 0) + g.w) g.state = 3; // swallowed: retired from the simulation + 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; + if (this.gt < (g.wake ?? 0)) continue; g.state = 1; g.vx = g.vy = 0; g.w = this.rand(0.55, 1.1); } if (g.state === 1) { @@ -219,6 +239,14 @@ export class BlackHole extends Sim { for (const g of this.glyphs) { if (g.state === 0 && this.phase !== 'impact') continue; if (g.state === 3) continue; + if (g.state === 5) { + // Closed-form inward spiral: radius shrinks, angle sweeps faster near the core. + const u = clamp((this.gt - (g.wake ?? 0)) / g.w, 0, 1); + const radius = g.r * Math.pow(1 - u, 1.5) + this.coreRadius * 0.6 * u, angle = g.a + 3.4 * u + 2.2 * u * u * u; + if (radius < this.coreRadius) continue; + grid.plot(this.cx + Math.cos(angle) * radius * ASPECT, this.cy + Math.sin(angle) * radius, g.ch, mixPacked(g.fg, rim, 0.25 + 0.5 * u)); + 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); diff --git a/tests/screenSavers.test.ts b/tests/screenSavers.test.ts index abd3f928..d415b456 100644 --- a/tests/screenSavers.test.ts +++ b/tests/screenSavers.test.ts @@ -610,3 +610,47 @@ test('compositing: dark chrome text never becomes dark-on-dark, authored backgro for (let i = 0; i < grid.glyphs.length; i += 1) if (grid.glyphs[i] !== ' ' && grid.fg[i] !== NOC) assert.ok(grid.fg[i] === NOC || ((grid.fg[i]! >> 16) + ((grid.fg[i]! >> 8) & 255) + (grid.fg[i]! & 255)) > 150 || grid.bg[i] !== NOC, `${id}: visible glyph colors`); } }); + +const sourceCells = (c: ReturnType) => c.glyphs.map((g, i) => (g !== ' ' ? i : -1)).filter(i => i >= 0); +/** Source cells still showing their original glyph in a painted frame. */ +const untouched = (grid: CellGrid, c: ReturnType) => sourceCells(c).filter(i => grid.glyphs[i] === c.glyphs[i]).length; + +test('Black Hole consumes the whole captured screen: sparse and dense, near and far, then rebuilds from the pristine capture', () => { + for (const [name, c] of [['sparse', fullCapture(2)], ['dense', denseCapture(2)], ['huge', captureFromRows(Array.from({length: 60}, (_, y) => `line ${y} `.padEnd(200, 'abcdefghij')), 200, 60, 1)]] as const) { + const before = {glyphs: [...c.glyphs], fg: [...c.fg], bg: [...c.bg]}; + const fx = new BlackHole(c, palette.text); + const total = sourceCells(c).length; + assert.equal(fx.glyphs.length, total, `${name}: every eligible glyph takes part (no sampling drops)`); + const grid = new CellGrid(); grid.resize(c.width, c.height); + let peakRemaining = total, wokeFar = false, sawPhase = new Set(); + const corner = fx.glyphs.reduce((far, g) => (Math.hypot((g.ox - fx.cx) / ASPECT, g.oy - fx.cy) > Math.hypot((far.ox - fx.cx) / ASPECT, far.oy - fx.cy) ? g : far)); + for (let t = 50; t <= 40_000; t += 50) { + fx.advance(t); sawPhase.add(fx.phase); + if (corner.state !== 0) wokeFar = true; + if (fx.phase === 'hold') { grid.clear(NOC); fx.paint(grid, ctx(t)); peakRemaining = Math.min(peakRemaining, untouched(grid, c)); } + } + assert.ok(sawPhase.has('hold') && sawPhase.has('release'), name); + assert.ok(wokeFar, `${name}: the farthest corner glyph is pulled in too`); + assert.ok(peakRemaining <= Math.ceil(total * 0.02), `${name}: essentially nothing of the source remains outside the hole at peak consumption (${peakRemaining}/${total})`); + assert.ok(fx.glyphs.every(g => g.state === 3 || g.state === 4 || g.state === 0), `${name}: swallowed glyphs retire from the simulation`); + assert.deepEqual([...c.glyphs], before.glyphs); assert.deepEqual([...c.fg], before.fg); assert.deepEqual([...c.bg], before.bg); + } +}); + +test('Black Hole: consumption order is nearest-first and reproducible; every loop restarts from the exact original capture', () => { + const order = (seed: number) => { const fx = new BlackHole(denseCapture(seed), palette.text); return [...fx.glyphs.keys()].sort((a, b) => fx.glyphs[a]!.wake! - fx.glyphs[b]!.wake! || a - b); }; + assert.deepEqual(order(7), order(7), 'deterministic'); + const fx = new BlackHole(denseCapture(7), palette.text); + const dist = (g: typeof fx.glyphs[number]) => Math.hypot((g.ox - fx.cx) / ASPECT, g.oy - fx.cy); + const sorted = [...fx.glyphs].sort((a, b) => a.wake! - b.wake!); + const early = sorted.slice(0, 30).reduce((s, g) => s + dist(g), 0) / 30, late = sorted.slice(-30).reduce((s, g) => s + dist(g), 0) / 30; + assert.ok(early < late, 'nearby text reacts first, far edges last'); + const c = denseCapture(8); const loop = new BlackHole(c, palette.text); + let t = 0; while (loop.loops < 2 && t < 120_000) { t += 50; loop.advance(t); } + assert.ok(loop.loops >= 2, 'looped at least twice'); + // After a reconstruction every glyph sits on its original cell with its original color. + while (loop.phase !== 'seed') { t += 50; loop.advance(t); } + assert.ok(loop.glyphs.every(g => g.x === g.ox && g.y === g.oy && g.state === 0), 'rebuilt from the pristine source, not from a consumed frame'); + const grid = new CellGrid(); grid.resize(c.width, c.height); loop.paint(grid, ctx(t)); + for (const i of sourceCells(c)) if (Math.abs(i % c.width - loop.cx) > 2 || Math.abs(Math.floor(i / c.width) - loop.cy) > 2) assert.equal(grid.glyphs[i], c.glyphs[i]); +}); From 907d3d6b17c4c8cda91e495214497a70dd5df7dd Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:48:54 +0530 Subject: [PATCH 5/5] Final v0.17 polish: panel position setting, clean Setup screen, orbital Circletastic gather, cursor travel trail, animated progress bars, gallery replay --- src/app/TerminalApp.ts | 50 ++++++++++++++++--- src/app/screenPlan.ts | 18 ++++--- src/appearance/AppearanceHub.ts | 2 +- src/ask/concepts.ts | 9 ++-- src/idle/IdleVisuals.ts | 7 ++- src/idle/screenEffects.ts | 27 +++++++++-- src/motion/MotionPreview.ts | 9 ++++ src/motion/motionRows.ts | 5 +- src/motion/transitions.ts | 34 ++++++++++++- src/prompt/configuration.ts | 19 ++++++-- src/setup/SetupCat.ts | 4 +- src/status/TaskProgress.ts | 36 +++++++++++++- src/ui/SettingsPanel.ts | 15 +++--- tests/askConcepts.test.ts | 7 ++- tests/cursorTravel.test.ts | 86 +++++++++++++++++++++++++++++++++ tests/dockTop.test.ts | 5 +- tests/flowComposer.test.ts | 7 ++- tests/promptProviders.test.ts | 4 +- tests/screenPlan.test.ts | 68 ++++++++++++++------------ tests/screenSavers.test.ts | 68 ++++++++++++++++++++++++++ tests/settingsControls.test.ts | 6 +-- tests/settingsV2.test.ts | 14 ++++++ tests/setupCat.test.ts | 35 ++++++++++++++ tests/taskProgress.test.ts | 59 ++++++++++++++++++++++ 24 files changed, 501 insertions(+), 93 deletions(-) create mode 100644 tests/cursorTravel.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 3c5f601b..9e9b9f7f 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -900,11 +900,29 @@ export class TerminalApp { } // Focus reports alone are not user activity (a terminal can report them on its own). if (keys.some(key => key.kind !== 'focusIn' && key.kind !== 'focusOut')) this.noteActivity(); - for (const key of keys) this.handleKey(key); + for (const key of keys) { + const before = {text: this.editor.text, index: this.editor.displayCursorIndex}; + this.handleKey(key); + this.noteCaretTravel(before); + } // Passive motion renders only when hover changes; skip the generic frame. if (keys.length === 0 || keys.some(key => key.kind !== 'mouseMove')) this.render(); } + /** + * A multi-cell caret jump on the same editor line starts the soft travel trail. Purely visual: the logical caret + * already moved, nothing waits for the animation, and a newer jump retargets the old trail. + */ + private noteCaretTravel(before: {text: string; index: number}): void { + if (this.editor.text !== before.text || this.editor.displayCursorIndex === before.index || this.settingsPanelActive || this.passthrough) return; + const columns = this.dimensions().columns; + const prefix = this.inputFirstLinePrefix(columns); + const at = (index: number) => layoutInput(this.editor.displayText, index, columns, Number.POSITIVE_INFINITY, prefix); + const from = at(before.index), to = at(this.editor.displayCursorIndex); + if (from.caretRow !== to.caretRow) return; + this.transitions.travel(from.caretColumn, to.caretColumn, to.caretRow, Date.now()); + } + private readonly onResize = (): void => { this.effects.cancel(); if (this.idle) this.dismissIdle(false); @@ -4133,6 +4151,13 @@ export class TerminalApp { private handleScreensaverKey(key: Key, state: ScreensaverPanelState): void { const action = screensaverKey(state, key, this.promptConfiguration.idleVisuals, this.promptConfiguration); if (!action) return; + if (action.kind === 'replay') { + // Restart from the pristine snapshot: a fresh clock and a fresh effect instance, no stacked timers. + state.startedAt = Date.now(); + this.saverGalleryCapture = undefined; + if (!idleMotion(this.promptConfiguration).still) this.render(); + return; + } if (action.kind === 'editColors') { this.screensaverPanel = undefined; this.screensaverAnimation?.(); this.screensaverAnimation = undefined; @@ -5108,6 +5133,11 @@ export class TerminalApp { return undefined; } + /** The panel position being shown: Setup previews its unsaved draft so the choice is visible immediately. */ + private panelPositionInEffect(): 'bottom' | 'top' { + return (this.setupState?.draft ?? this.promptConfiguration).panelPosition === 'top' ? 'top' : 'bottom'; + } + private planFrame( columns: number, rows: number, @@ -6723,6 +6753,7 @@ export class TerminalApp { detached: this.historyViewport.detached, hasOutput: transcriptRows > 0, composerPosition: this.promptConfiguration.composerPosition, + panelPosition: this.panelPositionInEffect(), transcriptRows, contextPlacement: this.promptConfiguration.placement, hasVisibleContext: this.hasVisibleProviderPrompt(), @@ -6850,11 +6881,12 @@ export class TerminalApp { const separator = `${paintDivider(repeatToWidth(GLYPHS.separator, columns), this.promptConfiguration.presentation, Date.now())}${RESET}`; const regionRows = (region: Region): string[] => { switch (region.kind) { - case 'transcript': return visible; + // Setup Cat owns a clean screen: the ordinary transcript/welcome is not drawn behind it (presentation only; nothing is cleared). + case 'transcript': return this.setupState ? [] : visible; case 'gap': return []; case 'jump': return [this.jumpAffordance(columns)]; - // Panels are top-oriented in every composer position, so the frame line sits on the edge facing the transcript below. - case 'panel': return panelRows && /^[─-]+$/u.test(stripAnsi(panelRows[0] ?? '')) + // The frame line sits on the panel edge that faces the transcript: the bottom edge for a Top panel. + case 'panel': return plan.panelPosition === 'top' && panelRows && /^[─-]+$/u.test(stripAnsi(panelRows[0] ?? '')) ? [...panelRows.slice(1), panelRows[0]!] : panelRows ?? []; case 'inspector': return this.inspectorRows(columns); case 'suggestions': return [...suggestionView.items.map((suggestion, visibleIndex) => { @@ -6938,7 +6970,7 @@ export class TerminalApp { if (!panel?.png || panel.protocol === 'none' || !region) return undefined; const size = this.aboutLogoSize(columns); // framePanel's frame line leads the panel (it moves below the panel under Dock Top), then title and spacer. - const row = region.top + (plan.composerPosition === 'top' ? 0 : 1) + 2; + const row = region.top + (plan.panelPosition === 'top' ? 0 : 1) + 2; if (row + size.rows > region.top + region.height) return undefined; return createImageOverlay(panel.protocol, panel.png, `about:${panel.protocol}:${row}:${size.columns}x${size.rows}`, row, 2, size); } @@ -6953,7 +6985,7 @@ export class TerminalApp { for (const task of [this.promptPanelState?.task, this.toolsPanel?.task, this.providerPanelState?.task]) task?.setReducedMotion(!this.decorativeMotionAllowed()); const content = this.settingsPanelRows(cached.frame.columns ?? 80); if (Math.min(cached.plan.rows, content.length) !== region.height) { this.render(); return; } - const projected = cached.plan.composerPosition === 'top' && /^[─-]+$/u.test(stripAnsi(content[0] ?? '')) + const projected = cached.plan.panelPosition === 'top' && /^[─-]+$/u.test(stripAnsi(content[0] ?? '')) ? [...content.slice(1), content[0]!] : content; const rows = [...cached.frame.rows]; for (let index = 0; index < region.height; index++) rows[region.top + index] = projected[index] ?? ''; @@ -6998,6 +7030,12 @@ export class TerminalApp { const caret = this.layoutEditorInput(columns, Math.max(1, input.height)); const offset = caret.caretRow - at(this.editor.displayCursorIndex).caretRow; if (from.caretRow === to.caretRow) add(input.top + from.caretRow + offset, transitionPaint.materialize(from.caretColumn, to.caretColumn, t, transition.vivid, transition.look)); + } else if (transition.kind === 'travel') { + const input = regions('input')[0]; + if (!input) continue; + const caret = this.layoutEditorInput(columns, Math.max(1, input.height)); + add(input.top + transition.row + (caret.caretRow - layoutInput(this.editor.displayText, this.editor.displayCursorIndex, columns, Number.POSITIVE_INFINITY, this.inputFirstLinePrefix(columns)).caretRow), + transitionPaint.travel(transition.from, transition.to, t, transition.look)); } else if (transition.kind === 'seal') { const transcript = plan.regions.find(region => region.kind === 'transcript'); const index = this.visibleBlocks.lastIndexOf(transition.blockStartId); diff --git a/src/app/screenPlan.ts b/src/app/screenPlan.ts index b9a35fb1..7bd17471 100644 --- a/src/app/screenPlan.ts +++ b/src/app/screenPlan.ts @@ -58,6 +58,8 @@ export interface ScreenPlanInput { panelRows?: number; /** Dock Bottom (default), Dock Top, or Flow. */ composerPosition?: ComposerPosition; + /** Where full-width NMSh panels sit: Bottom (default) or Top. Independent of the composer position. */ + panelPosition?: 'bottom' | 'top'; /** Presented transcript rows; Dock Top and Flow use it to keep what follows next to the newest output. */ transcriptRows?: number; /** Flow while scrolled back: the first transcript row in view (the composer follows the transcript's end). */ @@ -85,6 +87,8 @@ export interface ScreenPlan { viewportRows: number; composerPosition: ComposerPosition; panelActive: boolean; + /** Where the active panel is anchored (meaningful while panelActive). */ + panelPosition: 'bottom' | 'top'; } export interface RegionHit { @@ -97,14 +101,14 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { const rows = Math.max(1, input.rows); const top = input.composerPosition === 'top'; if (input.panelRows !== undefined) { - // Panel takeover: NMSh panels are top-oriented in every composer position. The top row - // stays put as the panel's height changes (it grows and shrinks downward) and the - // transcript keeps the rest below. The composer position only governs the ordinary composer. + // Panel takeover follows the explicit NMSh panel position, never the composer position: + // Bottom keeps the bottom edge fixed (the top edge moves with height), Top keeps the top edge fixed. const panelHeight = Math.min(rows, Math.max(0, input.panelRows)); const panel: Array<[RegionKind, number]> = [['panel', panelHeight]]; const transcript: Array<[RegionKind, number]> = [['transcript', rows - panelHeight]]; - return build(rows, [...panel, ...transcript], - {inputHeight: 0, suggestionCount: 0, panelActive: true, composerPosition: input.composerPosition ?? 'bottom'}); + const atTop = input.panelPosition === 'top'; + return build(rows, atTop ? [...panel, ...transcript] : [...transcript, ...panel], + {inputHeight: 0, suggestionCount: 0, panelActive: true, composerPosition: input.composerPosition ?? 'bottom', panelPosition: atTop ? 'top' : 'bottom'}); } const inspectorHeight = Math.min(Math.max(0, input.inspectorRows ?? 0), Math.max(0, rows - 8)); const layout = calculateScreenLayout( @@ -188,7 +192,7 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { function build( rows: number, stack: Array<[RegionKind, number]>, - extra: Pick, + extra: Pick & {panelPosition?: 'bottom' | 'top'}, ): ScreenPlan { const regions: Region[] = []; let transcript: Region = {kind: 'transcript', top: 0, height: 0}; @@ -200,7 +204,7 @@ function build( if (height > 0) regions.push(region); top += height; } - return {rows, regions, transcript, ptyRows: transcript.height, viewportRows: Math.max(1, transcript.height), ...extra}; + return {rows, regions, transcript, ptyRows: transcript.height, viewportRows: Math.max(1, transcript.height), panelPosition: 'bottom', ...extra}; } /** diff --git a/src/appearance/AppearanceHub.ts b/src/appearance/AppearanceHub.ts index 7cbc0a6d..dd5a238e 100644 --- a/src/appearance/AppearanceHub.ts +++ b/src/appearance/AppearanceHub.ts @@ -129,7 +129,7 @@ export function renderAppearanceHub(state: AppearanceHubState, configuration: Pr const controls = ['', renderControls([['↑↓', 'select'], ['←→', 'change'], ...(!advanced && state.selected === items.length ? [['Enter', 'open'] as [string, string]] : []), ...(shown ? [['R', 'replay'] as [string, string]] : []), ['Esc', 'back']])]; // The preview is always the same size; a short terminal gives up the intro, then the preview, never the controls or the list. const block = shown ? ['', ` ${subtle}Preview · ${rendering}${reset}`, ...shown.rows] : []; - const layouts = [[...head, ...list, ...note, ...block, ...controls], [head[0]!, ...list, ...note, ...block, ...controls], [head[0]!, ...list, ...note, ...controls], [head[0]!, ...list, ...controls]]; + const layouts = [[...head, ...list, ...note, ...block, ...controls], [head[0]!, ...list, ...note, ...block, ...controls], [head[0]!, ...list, ...note, ...controls], [head[0]!, ...list, ...controls], [...list, ...controls]]; const rows = height === undefined ? layouts[0]! : layouts.find(layout => layout.length <= height) ?? layouts[layouts.length - 1]!; return rows.map(row => truncateAnsi(row, columns)); } diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index c8262e9d..f25872f9 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -47,7 +47,7 @@ export interface Concept { const SUGGESTIONS_WHERE = 'Settings → Suggestions (provider and empty-prompt prediction), or /providers'; export const CONCEPTS: readonly Concept[] = [ - {id: 'completion', label: 'Tab completion / completion menu', support: 'no-ui', covers: ['planned:Completion'], + {id: 'completion', label: 'Tab completion / completion menu', support: 'no-ui', covers: [], aliases: ['tab completion', 'tab complete', 'completion', 'completions', 'completion menu', 'complete menu', 'autocomplete menu', 'completion settings', 'autocomplete', 'auto complete', 'auto-complete', 'autocompletion', 'auto completion'], description: 'Tab completion opens a menu of structured candidates (commands, options, files, Git refs) from your shell\'s own completion, custom specs and NMSh\'s bundled catalog.', @@ -67,7 +67,7 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'themeStudio', label: 'Theme Studio (custom themes)', support: 'actionable', open: '/theme', covers: ['/theme'], aliases: ['theme studio', 'custom theme', 'custom themes', 'import theme', 'export theme', 'my own theme'], description: 'Theme Studio clones a Native theme so you can edit, import and export your own.'}, - {id: 'chroma', label: 'Chroma', support: 'actionable', open: '/chroma', covers: ['/chroma', 'settings:Presentation', 'planned:Chroma'], + {id: 'chroma', label: 'Chroma', support: 'actionable', open: '/chroma', covers: ['/chroma', 'settings:Presentation'], aliases: ['chroma', 'gradient', 'gradients', 'animated colors', 'animated colours', 'color motion', 'colour motion', 'prompt gradient', 'rainbow prompt', 'chroma palette', 'animated prompt colors', 'animated prompt colours', 'animated prompt'], description: 'Chroma paints NMSh-owned chrome (prompt, dividers) with a palette gradient, optionally animated (Travel, Breathe, Comet, Pulse). Palette Off turns it off.', @@ -83,7 +83,7 @@ export const CONCEPTS: readonly Concept[] = [ where: 'Settings → Layout → Composer dividers (On/Off)', aliases: ['composer dividers', 'composer divider', 'input dividers', 'input divider', 'input lines', 'lines around the input', 'divider lines', 'input box lines', 'composer lines', 'input separators'], description: 'Composer dividers are the two thin rules above and below the input. Off removes them and gives their rows back to output.'}, - {id: 'layout', label: 'Layout', support: 'actionable', open: '/layout', covers: ['/layout', 'settings:Layout', 'planned:Layout'], + {id: 'layout', label: 'Layout', support: 'actionable', open: '/layout', covers: ['/layout', 'settings:Layout'], aliases: ['layout', 'transcript layout', 'transcript presentation', 'chat mode', 'chat layout', 'composer position', 'composer at the top', 'flow mode', 'classic mode'], description: 'Layout chooses where the composer sits (bottom, top, or Flow after the newest output) and whether the transcript is Normal or Chat (commands on the right).'}, {id: 'history', label: 'Command history', support: 'actionable', open: '/history', configure: '/providers', where: 'Settings → History → Command history provider, or /providers', @@ -143,7 +143,7 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'activity', label: 'Live activity', support: 'actionable', open: '/activity', covers: ['/activity', 'settings:Live activity'], aliases: ['live activity', 'activity colors', 'activity colours', 'running command line', 'spinner', 'elapsed time'], description: 'Live activity is the animated line with elapsed time while a command runs; /activity sets its colors.'}, - {id: 'tools', label: 'Optional tools', support: 'actionable', capability: 'tools.open', open: '/tools', covers: ['/tools', 'settings:Tools', 'planned:Tools'], + {id: 'tools', label: 'Optional tools', support: 'actionable', capability: 'tools.open', open: '/tools', covers: ['/tools', 'settings:Tools'], aliases: ['tools', 'optional tools', 'installs', 'install suggestions'], description: '/tools lists optional tools NMSh can use, with previewed installs that start on No.'}, {id: 'presets', label: 'Session presets', support: 'actionable', open: '/presets', covers: ['/presets'], @@ -221,7 +221,6 @@ export const CONCEPTS: readonly Concept[] = [ /** Public surfaces Ask deliberately has no concept for, with the reason (the coverage audit checks these too). */ export const ASK_EXCLUDED: Readonly> = { - 'planned:Blocks': 'A planned settings area with no feature behind it yet; Ask says nothing about it rather than describe something that does not exist.', }; export type ConceptIntent = 'explain' | 'on' | 'off' | 'open' | 'change'; diff --git a/src/idle/IdleVisuals.ts b/src/idle/IdleVisuals.ts index f1951c06..bd6e5670 100644 --- a/src/idle/IdleVisuals.ts +++ b/src/idle/IdleVisuals.ts @@ -104,7 +104,7 @@ export function createScreensaverPanel(now: number): ScreensaverPanelState { return {selected: 0, startedAt: now}; } -export type ScreensaverAction = {kind: 'close'} | {kind: 'start'} | {kind: 'editColors'} | {kind: 'change'; settings: IdleVisualSettings} | undefined; +export type ScreensaverAction = {kind: 'close'} | {kind: 'start'} | {kind: 'replay'} | {kind: 'editColors'} | {kind: 'change'; settings: IdleVisualSettings} | undefined; /** Choosing Custom starts from the colors in effect, so nothing jumps; existing custom stops are kept. */ export function withIdleColorSource(configuration: PromptConfiguration, colorSource: IdleVisualSettings['colorSource']): IdleVisualSettings { @@ -119,6 +119,8 @@ const cycle = (values: readonly T[], value: T, delta: number): T => values[(v /** ↑↓ rows, ←→ change (saved immediately, like Config rows), Enter on Start preview runs it full screen. */ export function screensaverKey(state: ScreensaverPanelState, key: Key, settings: IdleVisualSettings, configuration?: PromptConfiguration): ScreensaverAction { if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + // Lowercase r restarts the visible preview from a pristine source (the gallery has no free-text field). + if (key.kind === 'text' && key.value === 'r') return {kind: 'replay'}; if (key.kind === 'up') state.selected = (state.selected + ROWS.length - 1) % ROWS.length; else if (key.kind === 'down') state.selected = (state.selected + 1) % ROWS.length; else if (key.kind === 'enter' || (key.kind === 'text' && key.value.toLowerCase() === 'p')) { @@ -167,7 +169,8 @@ export function renderScreensaverPanel(state: ScreensaverPanelState, columns: nu if (motion.disabled) out.push(` ${subtle}Decorative effects are Off: idle visuals stay off until they are On again.${reset}`); else if (motion.still) out.push(` ${subtle}Reduced Motion: shown still${HIGH_MOTION.has(settings.mode) ? ' as a calm star field' : ''}.${reset}`); if (context.preview.length) out.push('', ...context.preview.map(line => ` ${line}`)); - out.push('', renderControls([['↑↓', 'select'], ['←→', 'change'], ['Enter', 'preview'], ['Esc', 'close']])); + const replayable = !motion.disabled && !motion.still; + out.push('', renderControls([['↑↓', 'select'], ['←→', 'change'], ...(replayable ? [['r', 'replay'] as [string, string]] : []), ['Enter', 'preview'], ['Esc', 'close']])); return framePanel(out.map(line => truncateAnsi(line, columns)), columns).slice(0, Math.max(1, height)); } diff --git a/src/idle/screenEffects.ts b/src/idle/screenEffects.ts index 8835305f..865288a0 100644 --- a/src/idle/screenEffects.ts +++ b/src/idle/screenEffects.ts @@ -509,7 +509,10 @@ export class Circletastic extends Sim { this.glyphs.forEach((glyph, i) => { glyph.a = glyph.x; glyph.r = glyph.y; glyph.t = 0; glyph.vx = glyph.vy = 0; glyph.state = this.assigned[i] ? 0 : this.consumed.has(i) ? 4 : 3; - this.delay[i] = this.rand(0, 0.9); this.duration[i] = this.rand(1.2, 2.2); + this.delay[i] = this.rand(0, 0.9); this.duration[i] = this.rand(1.3, 2.4); + // How far around the circle this glyph sweeps while gathering (signed by the ring's direction): far glyphs take broader arcs. + const ringDir = this.assigned[i] ? this.rings[this.assigned[i]!.ring]!.dir : 1; + glyph.w = ringDir * this.rand(0.35, 1.1); glyph.vy = 0; }); const base = this.rand(1.5, 2.1); this.runtime = this.clusters.map((_, ci) => ({ @@ -563,14 +566,28 @@ export class Circletastic extends Sim { if (rt.stage === 'gather') { if (rt.t < rt.start) return; const local = rt.t - rt.start; + // The vortex: the ring is already turning while it forms, and every glyph spirals into it, + // sweeping around the ring's center as its radius closes. Angular speed converges on the ring's + // own (the easing has zero slope at both ends), so there is no snap from gathering to rotating. + for (const ri of rt.rings) { + this.omega[ri] = Math.min(CIRC.omegaMax, this.omega[ri]! + rt.alpha * 0.4 * dt); + this.angle[ri]! += this.omega[ri]! * this.rings[ri]!.dir * dt; + } let allHome = true; this.glyphs.forEach((g, i) => { if (this.clusterOf(i) !== ci || g.state === 1) return; - const target = this.slotPosition(i)!; + const a = this.assigned[i]!, ring = this.rings[a.ring]!; + const theta = this.angle[a.ring]! + (a.slot / ring.slots) * Math.PI * 2; + if (local < this.delay[i]!) { allHome = false; return; } + if (g.vy === 0) { // first moment: remember where on the circle the glyph is coming from + const rho0 = Math.hypot(g.a / ASPECT - ring.cx, g.r - ring.cy), phi0 = Math.atan2(g.r - ring.cy, g.a / ASPECT - ring.cx); + g.vx = Math.atan2(Math.sin(phi0 - theta), Math.cos(phi0 - theta)); g.vy = 1; g.t = rho0; + } const u = ease((local - this.delay[i]!) / this.duration[i]!); - const swirl = Math.sin(u * Math.PI) * 3; - g.x = g.a + (target.x - g.a) * u + swirl * (g.oy < this.capture.height / 2 ? 1 : -1); g.y = g.r + (target.y - g.r) * u; - if (local >= this.delay[i]! + this.duration[i]!) { g.x = target.x; g.y = target.y; g.state = 1; } else allHome = false; + const rho0 = g.t, rho = rho0 + (ring.r - rho0) * u; + const phi = theta + (1 - u) * (g.vx + g.w * Math.PI * 2); + g.x = (ring.cx + Math.cos(phi) * rho) * ASPECT; g.y = ring.cy + Math.sin(phi) * rho; + if (local >= this.delay[i]! + this.duration[i]!) { const p = this.slotPosition(i)!; g.x = p.x; g.y = p.y; g.state = 1; g.vx = g.vy = 0; } else allHome = false; }); if (allHome) { rt.stage = 'spin'; rt.formed = true; rt.t = 0; } // this circle starts turning right away return; diff --git a/src/motion/MotionPreview.ts b/src/motion/MotionPreview.ts index 0e172e22..50da52df 100644 --- a/src/motion/MotionPreview.ts +++ b/src/motion/MotionPreview.ts @@ -34,6 +34,7 @@ const DESCRIPTIONS: Record = { 'commandLaunch:pulse': 'The command brightens once and fades as it is handed off.', 'completionHighlight:subtle': 'Only the inserted completion is underlined and tinted, briefly.', 'completionHighlight:vivid': 'Only the inserted completion is underlined and tinted brightly, a little longer.', + 'cursorTravel:on': 'A soft trail follows the caret across a multi-cell jump; the caret itself is already at its destination.', 'completionEffect:seal': 'A finished block\'s header takes one sweep of its outcome color.', 'eventFeedback:subtle': 'Meaningful events tint the composer rule once.', 'eventFeedback:expressive': 'Meaningful events tint the rule and sweep the input once.', @@ -82,6 +83,14 @@ function fixture(row: keyof MotionSettings, width: number): Fixture & {trigger: return transition?.kind === 'materialize' ? [transitionPaint.materialize(transition.from, transition.to, progress(transition, now), transition.vivid, transition.look)] : []; }}; } + if (row === 'cursorTravel') { + const line = `${prompt} git commit -m wip`; + return {lines: [line, ` ${safe ? '' : '⌥ '}Option+Left jumps back a word`], trigger: (transitions, start) => transitions.travel(displayWidth(line), displayWidth(`${prompt} git commit -m `), 0, start), + paint: (transitions, now) => { + const transition = at(transitions, now).find(item => item.kind === 'travel'); + return transition?.kind === 'travel' ? [transitionPaint.travel(transition.from, transition.to, progress(transition, now), transition.look)] : []; + }}; + } if (row === 'completionEffect') { return {lines: [`${check} npm test · 1.2s`, ' 12 passing'], trigger: (transitions, start) => transitions.seal(0, 'success', start), paint: (transitions, now, columns) => { diff --git a/src/motion/motionRows.ts b/src/motion/motionRows.ts index 6fbf6c7d..9913918a 100644 --- a/src/motion/motionRows.ts +++ b/src/motion/motionRows.ts @@ -1,4 +1,4 @@ -import {COMMAND_LAUNCHES, COMPLETION_EFFECTS, COMPLETION_HIGHLIGHTS, CONTEXT_TRANSITIONS, DEFAULT_MOTION_TUNING, EVENT_FEEDBACK, MOTION_INTENSITIES, MOTION_RENDERINGS, MOTION_SPEEDS, +import {COMMAND_LAUNCHES, COMPLETION_EFFECTS, CURSOR_TRAVELS, COMPLETION_HIGHLIGHTS, CONTEXT_TRANSITIONS, DEFAULT_MOTION_TUNING, EVENT_FEEDBACK, MOTION_INTENSITIES, MOTION_RENDERINGS, MOTION_SPEEDS, type MotionRendering, type MotionSettings, type MotionTuning} from '../prompt/configuration.js'; /** @@ -12,10 +12,11 @@ export const MOTION_ROWS: readonly MotionRow[] = [ {key: 'commandLaunch', label: 'Command launch', values: COMMAND_LAUNCHES, note: 'Enter hands the command to the shell at once; this only shows the handoff'}, {key: 'completionHighlight', label: 'Completion highlight', values: COMPLETION_HIGHLIGHTS, note: 'What completion just inserted, briefly'}, {key: 'completionEffect', label: 'Command completion', values: COMPLETION_EFFECTS, note: 'Block Seal: a finished block settles with one semantic sweep'}, + {key: 'cursorTravel', label: 'Cursor travel trail', values: CURSOR_TRAVELS, note: 'A soft trail between where the caret was and where it is, on jumps of several cells; the caret itself moves at once'}, {key: 'eventFeedback', label: 'Event feedback', values: EVENT_FEEDBACK, note: 'Semantic Echo: failures, long successes, conflicts, attention, tasks finishing'}, ]; -export const MOTION_LABELS: Record = {off: 'Off', subtle: 'Subtle', expressive: 'Expressive', sweep: 'Sweep', pulse: 'Pulse', vivid: 'Vivid', seal: 'Seal'}; +export const MOTION_LABELS: Record = {off: 'Off', subtle: 'Subtle', expressive: 'Expressive', sweep: 'Sweep', pulse: 'Pulse', vivid: 'Vivid', seal: 'Seal', on: 'On'}; /** One editable Motion value, as /appearance → Motion, Settings and Setup all show it. */ export interface MotionItem { diff --git a/src/motion/transitions.ts b/src/motion/transitions.ts index a5dbfb9f..afd15987 100644 --- a/src/motion/transitions.ts +++ b/src/motion/transitions.ts @@ -45,13 +45,14 @@ export function lookFor(motion: MotionSettings): MotionLook & {speed: number} { export type Transition = | {kind: 'launch'; style: 'sweep' | 'pulse'; start: number; duration: number; look: MotionLook} | {kind: 'materialize'; from: number; to: number; text: string; vivid: boolean; start: number; duration: number; look: MotionLook} + | {kind: 'travel'; from: number; to: number; row: number; start: number; duration: number; look: MotionLook} | {kind: 'seal'; blockStartId: number; tone: Tone; start: number; duration: number; look: MotionLook} | {kind: 'echo'; event: SemanticEvent; expressive: boolean; start: number; duration: number; look: MotionLook} | {kind: 'morph'; changes: ModuleChange[]; expressive: boolean; start: number; duration: number; look: MotionLook}; export interface ModuleChange {id: string; text: string; change: 'changed' | 'appeared' | 'disappeared'; role?: string} -export const DURATIONS = {launch: 170, materializeSubtle: 150, materializeVivid: 260, seal: 240, echoSubtle: 380, echoExpressive: 620, morphSubtle: 150, morphExpressive: 260} as const; +export const DURATIONS = {launch: 170, materializeSubtle: 150, materializeVivid: 260, seal: 240, echoSubtle: 380, echoExpressive: 620, morphSubtle: 150, morphExpressive: 260, travelBase: 170, travelPerCell: 4, travelMax: 230, travelMinCells: 3} as const; /** Whether decorative motion may run at all right now (Reduced Motion, Effects Off, NO_COLOR win). */ export interface MotionGate {reducedMotion: boolean; effectsOff: boolean; color: boolean} @@ -84,6 +85,16 @@ export class Transitions { this.replace('materialize', {kind: 'materialize', from, to, text, vivid: level === 'vivid', start: now, ...this.tuned(level === 'vivid' ? DURATIONS.materializeVivid : DURATIONS.materializeSubtle)}); } + /** + * The caret jumped from column `from` to `to` on `row` (presentation only; the caret already moved). + * One trail at a time: a newer jump retargets, never queues. One-cell moves are not animated. + */ + travel(from: number, to: number, row: number, now: number): void { + if (this.settings().cursorTravel !== 'on' || Math.abs(to - from) < DURATIONS.travelMinCells || !this.allowed()) return; + const duration = Math.min(DURATIONS.travelMax, DURATIONS.travelBase + Math.abs(to - from) * DURATIONS.travelPerCell); + this.replace('travel', {kind: 'travel', from, to, row, start: now, ...this.tuned(duration)}); + } + /** The editor changed some other way: a stale materialization range must not linger. */ editorChanged(text: string): void { this.active = this.active.filter(item => item.kind !== 'materialize' || item.text === text); @@ -144,6 +155,25 @@ export function sweepCells(width: number, t: number, color: RgbColor, strength: return cells; } +/** + * A soft trail between two caret columns: strongest next to the destination, fading with distance and time. + * The destination cell itself is left to the real caret. Gentler and slower than the completion highlight. + */ +export function travelCells(from: number, to: number, t: number, color: RgbColor, strength: number, rich = false): Map { + const cells = new Map(); + const span = Math.abs(to - from); + const lo = Math.min(from, to), hi = Math.max(from, to); + const fade = (1 - clamp01(t)) ** 1.3; + for (let column = lo; column <= hi; column += 1) { + if (column === to) continue; + const near = 1 - Math.abs(column - to) / Math.max(1, span); + const level = strength * fade * near ** 1.4; + if (level <= 0.02) continue; + cells.set(column, rich ? {fill: mixRgb(RICH_BASE, color, clamp01(level))} : {tint: {color, amount: clamp01(level)}}); + } + return cells; +} + /** A foreground tint over [from, to) that decays to nothing; `underline` marks the range while it is still strong. */ export function decayCells(from: number, to: number, t: number, color: RgbColor, strength: number, underline = false): Map { const cells = new Map(); @@ -251,6 +281,8 @@ export const transitionPaint = { /** Completion highlight over the inserted columns [from, to). */ materialize: (from: number, to: number, t: number, vivid: boolean, look: MotionLook = CLEAN_LOOK) => rich(look) ? richDecayCells(from, to, t, UI_COLORS.accent, gain(look, vivid ? 0.7 : 0.45)) : decayCells(from, to, t, UI_COLORS.accent, gain(look, vivid ? 0.75 : 0.5), true), + /** The caret-travel trail on the input row between the old and new caret columns. */ + travel: (from: number, to: number, t: number, look: MotionLook = CLEAN_LOOK) => travelCells(from, to, t, UI_COLORS.accent, gain(look, 0.42), rich(look)), /** Block Seal over a finished block's header row. */ seal: (tone: Tone, columns: number, t: number, look: MotionLook = CLEAN_LOOK) => rich(look) ? richSweepCells(columns, t, toneColor(tone), gain(look, tone === 'failure' ? 0.75 : 0.55), tone === 'failure' ? 6 : 12) diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index b1818908..a6564cba 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -42,6 +42,8 @@ export const WELCOME_PROVIDER_IDS: readonly WelcomeProviderId[] = ['vespyr', 'fa export type ContextPlacement = 'header' | 'composer'; export type ComposerLayout = 'oneLine' | 'twoLine'; /** Bottom and Top dock the composer; Flow places it right after the newest output, inside the document. */ +export type PanelPosition = 'bottom' | 'top'; +export const PANEL_POSITIONS: readonly PanelPosition[] = ['bottom', 'top']; export type ComposerPosition = 'bottom' | 'top' | 'flow'; export type TranscriptPresentation = 'normal' | 'chat'; /** Implemented layout choices, shared by Config rows and the /layout showcase. */ @@ -270,6 +272,7 @@ export const COMMAND_LAUNCHES = ['off', 'sweep', 'pulse'] as const; export const COMPLETION_HIGHLIGHTS = ['off', 'subtle', 'vivid'] as const; export const COMPLETION_EFFECTS = ['off', 'seal'] as const; export const EVENT_FEEDBACK = ['off', 'subtle', 'expressive'] as const; +export const CURSOR_TRAVELS = ['off', 'on'] as const; export interface MotionSettings { /** Prompt modules transform in place when their facts change (cwd, branch, Git state, tools). */ contextTransitions: typeof CONTEXT_TRANSITIONS[number]; @@ -281,6 +284,8 @@ export interface MotionSettings { completionEffect: typeof COMPLETION_EFFECTS[number]; /** Semantic Echo: a short response to meaningful events (long success, failure, attention, task done). */ eventFeedback: typeof EVENT_FEEDBACK[number]; + /** A soft trail between the old and new caret position on multi-cell jumps. Presentation only; the caret moves at once. */ + cursorTravel: typeof CURSOR_TRAVELS[number]; /** * How the same events are drawn. Clean keeps the host's background (foreground tint, dim, underline); * Rich draws the stronger filled bands. One event system feeds either renderer. @@ -297,10 +302,10 @@ export const MOTION_SPEEDS = ['slow', 'normal', 'fast'] as const; export interface MotionTuning {intensity: typeof MOTION_INTENSITIES[number]; speed: typeof MOTION_SPEEDS[number]} export const DEFAULT_MOTION_TUNING = (): Record => ({clean: {intensity: 'medium', speed: 'normal'}, rich: {intensity: 'medium', speed: 'normal'}}); /** Fresh installs: restrained motion, Clean rendering. */ -export const DEFAULT_MOTION: MotionSettings = {contextTransitions: 'subtle', commandLaunch: 'sweep', completionHighlight: 'subtle', completionEffect: 'seal', eventFeedback: 'subtle', +export const DEFAULT_MOTION: MotionSettings = {contextTransitions: 'subtle', commandLaunch: 'sweep', completionHighlight: 'subtle', completionEffect: 'seal', eventFeedback: 'subtle', cursorTravel: 'on', rendering: 'clean', tuning: DEFAULT_MOTION_TUNING()}; /** Existing configs without a motion group: nothing new moves until the person turns it on. */ -export const MIGRATED_MOTION: MotionSettings = {contextTransitions: 'off', commandLaunch: 'off', completionHighlight: 'off', completionEffect: 'off', eventFeedback: 'off', +export const MIGRATED_MOTION: MotionSettings = {contextTransitions: 'off', commandLaunch: 'off', completionHighlight: 'off', completionEffect: 'off', eventFeedback: 'off', cursorTravel: 'off', rendering: 'clean', tuning: DEFAULT_MOTION_TUNING()}; export function normalizeMotion(value: unknown): MotionSettings { @@ -308,7 +313,7 @@ export function normalizeMotion(value: unknown): MotionSettings { const pickOne = (list: readonly T[], item: unknown, fallback: T): T => list.includes(item as T) ? item as T : fallback; return {contextTransitions: pickOne(CONTEXT_TRANSITIONS, value.contextTransitions, 'off'), commandLaunch: pickOne(COMMAND_LAUNCHES, value.commandLaunch, 'off'), completionHighlight: pickOne(COMPLETION_HIGHLIGHTS, value.completionHighlight, 'off'), completionEffect: pickOne(COMPLETION_EFFECTS, value.completionEffect, 'off'), - eventFeedback: pickOne(EVENT_FEEDBACK, value.eventFeedback, 'off'), + eventFeedback: pickOne(EVENT_FEEDBACK, value.eventFeedback, 'off'), cursorTravel: pickOne(CURSOR_TRAVELS, value.cursorTravel, 'off'), // Saved configs without a rendering are on today's behavior, which is Clean. rendering: pickOne(MOTION_RENDERINGS, value.rendering, 'clean'), tuning: normalizeMotionTuning(value.tuning)}; } @@ -583,6 +588,8 @@ export interface PromptConfiguration { composerLayout: ComposerLayout; /** Dock Bottom (default) or Dock Top; independent of transcript presentation. */ composerPosition: ComposerPosition; + /** Where full-width NMSh panels (Setup, Settings, Tools, ...) sit; independent of the composer position. */ + panelPosition: PanelPosition; /** Normal or Chat rows; presentation only and independent of composer position. */ transcriptPresentation: TranscriptPresentation; /** The decorative horizontal rules around the live composer; Off reclaims their rows. Transcript dividers are separate. */ @@ -643,6 +650,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { placement: 'header', composerLayout: 'twoLine', composerPosition: 'bottom', + panelPosition: 'bottom', composerDividers: true, transcriptPresentation: 'normal', modules: [ @@ -756,6 +764,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio const placement: ContextPlacement = value.placement === 'composer' ? 'composer' : 'header'; const composerLayout: ComposerLayout = value.composerLayout === 'oneLine' ? 'oneLine' : 'twoLine'; + const panelPosition: PanelPosition = value.panelPosition === 'top' ? 'top' : 'bottom'; const composerPosition: ComposerPosition = value.composerPosition === 'top' || value.composerPosition === 'flow' ? value.composerPosition : 'bottom'; const transcriptPresentation: TranscriptPresentation = value.transcriptPresentation === 'chat' ? 'chat' : 'normal'; const spacing = typeof value.spacing === 'number' && Number.isFinite(value.spacing) @@ -772,7 +781,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, 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}; + presentation, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, notifications, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, spacing, gap, separator, ...tooling}; } const modules: ContextModuleConfig[] = []; @@ -816,7 +825,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio return {provider, onboardingComplete: value.onboardingComplete === true, toolsSetupComplete, 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}; + starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, modules, separator, spacing, gap, ...tooling}; } export function loadPromptConfiguration(path = promptConfigurationPath()): PromptConfiguration { diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 2475881a..c54ea2f3 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -210,7 +210,9 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ NATIVE_FIRST_MESSAGE, NATIVE_FIRST_DETAIL, 'Your current choices are already selected. Nothing changes until you apply on the last step.', - ], rows: []}, + ], rows: [ + {row: configRow('panelPosition'), note: draft => draft.panelPosition === 'top' ? 'Panels like this one sit at the top of the screen' : 'Panels like this one sit at the bottom of the screen'}, + ]}, {id: 'terminal', title: 'Terminal', intro: ['Glyphs your terminal font can draw.'], rows: [ {row: configRow('glyphStyle'), note: draft => draft.glyphStyle === 'nerd' ? 'Needs a Nerd Font in your terminal' : 'Works with any terminal font'}, ]}, diff --git a/src/status/TaskProgress.ts b/src/status/TaskProgress.ts index 43515915..08dfae89 100644 --- a/src/status/TaskProgress.ts +++ b/src/status/TaskProgress.ts @@ -4,7 +4,8 @@ import {spawn, type ChildProcessByStdio} from 'node:child_process'; import {environmentFor, resolveCommand, STANDARD_TOOL_DIRECTORIES} from '../providers/providers.js'; import type {Readable} from 'node:stream'; import {formatDuration} from './commandTiming.js'; -import {shimmerText} from './shimmer.js'; +import {interpolateRgb, shimmerIntensity, shimmerText} from './shimmer.js'; +import {colorLevel} from '../presentation/capabilities.js'; import {presentationAnimationElapsed} from '../presentation/environment.js'; import {GLYPHS, getCurrentGlyphMode} from '../ui/glyphs.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; @@ -144,11 +145,42 @@ export function taskProgressBar(state: TaskSnapshot, now = Date.now(), width = 2 return track.repeat(position) + head + track.repeat(width - position - 1); } +/** + * The bar as a living row, from the same travelling-wave primitive as the label + * shimmer. Presentation only: a determinate bar keeps its factual fill and the + * wave moves inside the filled cells (unfilled cells never light up); an + * indeterminate bar has a travelling segment and never invents a percentage. + * Colors are the theme's working/secondary roles, so imported themes work. With + * Reduced Motion, no color or a finished task the plain static bar is used. + */ +export function animatedProgressBar(state: TaskSnapshot, now = Date.now(), width = 24): string { + const plain = taskProgressBar(state, now, width); + if (state.status !== 'running' || state.reducedMotion || isReducedMotion() || colorLevel() === 'none') return `${foreground(UI_COLORS.secondary)}${plain}${RESET}`; + const elapsed = presentationAnimationElapsed(now - state.startedAt); + const safe = getCurrentGlyphMode() === 'safe'; + const track = foreground(UI_COLORS.secondary); + const tone = (level: number) => foreground(interpolateRgb(UI_COLORS.workingBase, UI_COLORS.workingPeak, level)); + const cells = [...plain]; + if (state.total && state.completed !== undefined) { + const count = Math.round(width * state.completed / state.total); + return cells.map((glyph, index) => (index < count + ? `${tone(shimmerIntensity(elapsed, index, width, false))}${glyph}` : `${track}${glyph}`)).join('') + RESET; + } + // Indeterminate: a soft segment sweeping left to right and looping smoothly. + const trail = safe ? ['#', '+', '-'] : ['█', '▓', '▒']; + const head = (elapsed / 90) % (width + 8) - 4; + const trackGlyph = safe ? '-' : '─'; + return Array.from({length: width}, (_, index) => { + const distance = Math.abs(index - head); + return distance < trail.length ? `${tone(1 - distance / trail.length)}${trail[Math.floor(distance)]}` : `${track}${trackGlyph}`; + }).join('') + RESET; +} + export function renderTaskProgress(state: TaskSnapshot, now = Date.now()): string[] { const duration = taskElapsed(state, now); if (state.status === 'running') { return [`${shimmerText(`${getCurrentGlyphMode() === 'safe' ? '*' : '◈'} ${state.label}…`, state.reducedMotion ? 0 : presentationAnimationElapsed(now - state.startedAt), !state.reducedMotion && !isReducedMotion())}${RESET} ${duration}`, - `${foreground(UI_COLORS.secondary)} ${taskProgressBar(state, now)}${RESET}`]; + ` ${animatedProgressBar(state, now)}`]; } const success = state.status === 'succeeded'; const icon = success ? GLYPHS.success : GLYPHS.failure; diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index ae8f108f..1d699d87 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -3,7 +3,7 @@ import {shellAdapter} from '../shell/adapters/registry.js'; import {OPEN_WITH_IDS} from '../host/HostActions.js'; import {TREATMENT_PRESETS, TREATMENT_PRESET_LABELS, TREATMENT_GEOMETRIES, TREATMENT_GEOMETRY_LABELS, TREATMENT_MOTIONS, TREATMENT_MOTION_LABELS, TREATMENT_SPEEDS, TREATMENT_SPEED_LABELS, TREATMENT_INFLUENCES, treatmentInfluence, SEMANTIC_MODES, SEMANTIC_MODE_LABELS, TREATMENT_SCOPES, TREATMENT_SCOPE_LABELS, TREATMENT_CURVES, TREATMENT_CURVE_LABELS, DIVIDER_LINES_HELP, dividerLinesLabel, PRESET_STOPS} from '../chroma/treatment.js'; -import {DIVIDER_COLOR_LABELS, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, CURSOR_BLINKS, CURSOR_SHAPES, IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS, LIVE_ACTIVITY_COLORS, LIVE_ACTIVITY_COLOR_LABELS, RAM_DISPLAYS, LOCAL_UNDERSTANDING_LABELS, LOCAL_UNDERSTANDING_MODES, SHELL_MODULE_VISIBILITY, SHELL_MODULE_VISIBILITY_LABELS, applyShellModuleVisibility, shellModuleVisibility, type StatusStripSettings} from '../prompt/configuration.js'; +import {PANEL_POSITIONS, DIVIDER_COLOR_LABELS, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, CURSOR_BLINKS, CURSOR_SHAPES, IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS, LIVE_ACTIVITY_COLORS, LIVE_ACTIVITY_COLOR_LABELS, RAM_DISPLAYS, LOCAL_UNDERSTANDING_LABELS, LOCAL_UNDERSTANDING_MODES, SHELL_MODULE_VISIBILITY, SHELL_MODULE_VISIBILITY_LABELS, applyShellModuleVisibility, shellModuleVisibility, type StatusStripSettings} from '../prompt/configuration.js'; import {IDLE_MODES, IDLE_MODE_LABELS} from '../idle/scenes.js'; import {MOTION_LABELS, MOTION_RENDERING_ITEM, MOTION_ROWS, MOTION_TUNING_ITEMS, type MotionItem} from '../motion/motionRows.js'; import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; @@ -310,6 +310,9 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'glyphStyle', label: 'Glyph style', description: 'Nerd Font or safe terminal symbols', category: 'General', values: GLYPH_STYLES, labels: ['Nerd Font', 'Safe / ASCII'], get: config => config.glyphStyle, set: (config, glyphStyle) => ({...config, glyphStyle, glyphChoiceComplete: true})}), + enumRow({id: 'panelPosition', label: 'NMSh panel position', description: 'Where full-width NMSh panels (Setup, Settings, Tools, ...) sit: Bottom (default) or Top. Independent of the composer position', category: 'General', + values: PANEL_POSITIONS, labels: ['Bottom', 'Top'], + get: config => config.panelPosition, set: (config, panelPosition) => ({...config, panelPosition})}), {id: 'provider', label: 'Prompt provider', description: 'Provider, theme, layout, and modules', category: 'Prompt', control: 'child', destination: 'prompt', value: config => providerLabel(config.provider)}, {id: 'divider', label: 'History divider', description: 'Rule drawn above each past command', category: 'Transcript', @@ -555,7 +558,6 @@ export const SETTINGS_ENTRIES: readonly SettingsRow[] = [ /** Text cue (not color) that a value differs from its default. */ const CHANGED_MARK = () => (getCurrentGlyphMode() === 'nerd' ? '•' : '*'); -export const PLANNED_AREAS = ['Layout', 'Blocks', 'Tools', 'Completion', 'Chroma'] as const; /** * Config reads as a short list of named groups, scrolled continuously. A row's group comes from its @@ -566,7 +568,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', updateMode: 'General', updateFrequency: 'General', + glyphStyle: 'General', panelPosition: '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', @@ -600,12 +602,12 @@ 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', 'updateMode', 'updateFrequency', + 'glyphStyle', 'panelPosition', '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', 'cursorShape', 'cursorRenderer', 'cursorMotion', 'cursorEffect', 'cursorIdle', 'cursorColor', 'cursorSpeed', 'cursorIntensity', 'cursorTrail', 'cursorParticles', 'cursorAdvanced', - 'motion_rendering', 'motion_contextTransitions', 'motion_commandLaunch', 'motion_completionHighlight', 'motion_completionEffect', 'motion_eventFeedback', 'motion_intensity', 'motion_speed', + 'motion_rendering', 'motion_contextTransitions', 'motion_commandLaunch', 'motion_completionHighlight', 'motion_completionEffect', 'motion_cursorTravel', 'motion_eventFeedback', 'motion_intensity', 'motion_speed', 'reducedMotion', 'effectsOff', 'sessionNotices', 'agentActivity', 'notifications', 'shellBackend', 'welcome', 'suggestions', 'history', 'picker', 'navigation', 'tools', 'toolUpdateChecks', 'installSuggestions', @@ -905,9 +907,6 @@ export function renderSettingsPanel(state: SettingsPanelState, columns: number, } else if (view === 'settings') { body.push(...renderRows(rows, tabsFocused ? undefined : selectedIndex, columns, '', row => `${SUBTLE}${row.description}`, Math.max(1, available))); - if (available >= rows.length + 3) { - body.push('', `${MARGIN}${SECONDARY}Planned for v0.4${RESET}`, `${MARGIN}${SUBTLE}${PLANNED_AREAS.join(' · ')}${RESET}`); - } } else { const query = state.searchQuery?.trim() ?? ''; body.push(...renderSearchField(state, columns)); diff --git a/tests/askConcepts.test.ts b/tests/askConcepts.test.ts index d66a688a..4af5ad18 100644 --- a/tests/askConcepts.test.ts +++ b/tests/askConcepts.test.ts @@ -5,7 +5,7 @@ import {ASK_EXCLUDED, conceptDestination, CONCEPTS, matchConcepts} from '../src/ import type {AskContext, AskOutcome} from '../src/ask/types.js'; import {slashCommands} from '../src/commands/slashCommands.js'; import {PROVIDER_FAMILIES} from '../src/providers/families.js'; -import {PLANNED_AREAS, SETTINGS_ENTRIES, SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; +import {SETTINGS_ENTRIES, SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; // Local understanding is irrelevant here: resolveRequest is the deterministic resolver and never calls a model. const context: AskContext = {cwd: '/r', home: '/h', repoRoot: '/r', branch: 'main', dirty: true, worktrees: [], shell: 'zsh', defaultShell: 'zsh', @@ -21,11 +21,10 @@ test('coverage audit: every public command, Settings area, provider family and p ...new Set(slashCommands.map(command => command.name.split(' ')[0]!)), ...new Set([...SETTINGS_ROWS, ...SETTINGS_ENTRIES].map(row => `settings:${row.category}`)), ...PROVIDER_FAMILIES.map(family => `family:${family.family}`), - ...PLANNED_AREAS.map(area => `planned:${area}`), ]; const missing = surfaces.filter(surface => !covered.has(surface) && !(surface in ASK_EXCLUDED)); assert.deepEqual(missing, [], 'classify each new surface in src/ask/concepts.ts (covers) or ASK_EXCLUDED'); - for (const surface of covered) if (!surface.startsWith('planned:')) assert.ok(surfaces.includes(surface), `${surface} is claimed but does not exist`); + for (const surface of covered) assert.ok(surfaces.includes(surface), `${surface} is claimed but does not exist`); }); test('catalog integrity: destinations are real NMSh commands; no hallucinated surfaces', () => { @@ -34,7 +33,7 @@ test('catalog integrity: destinations are real NMSh commands; no hallucinated su assert.ok(concept.aliases.length && concept.description, concept.id); } const completion = CONCEPTS.find(concept => concept.id === 'completion')!; - assert.equal(completion.support, 'no-ui', 'Completion is planned in Settings, not a real surface'); + assert.equal(completion.support, 'no-ui', 'Completion has no settings surface of its own'); assert.equal(completion.open, undefined); assert.ok(!slashCommands.some(command => command.name === '/completion')); }); diff --git a/tests/cursorTravel.test.ts b/tests/cursorTravel.test.ts new file mode 100644 index 00000000..34a0de55 --- /dev/null +++ b/tests/cursorTravel.test.ts @@ -0,0 +1,86 @@ +import {isolateConfig} from './support/isolatedConfig.js'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {DURATIONS, Transitions, transitionPaint, travelCells, progress} from '../src/motion/transitions.js'; +import {DEFAULT_MOTION, MIGRATED_MOTION, normalizeMotion, type MotionSettings} from '../src/prompt/configuration.js'; +import {MOTION_ROWS} from '../src/motion/motionRows.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; + +const gate = {reducedMotion: false, effectsOff: false, color: true}; +const make = (motion: Partial = {}, g = gate) => new Transitions(() => ({...DEFAULT_MOTION, ...motion}), () => g); +const color = {red: 200, green: 100, blue: 255}; + +test('cursor travel: multi-cell jumps start one soft, bounded trail; one-cell moves and Off do not', () => { + const t = make(); + t.travel(0, 1, 0, 0); t.travel(5, 7, 0, 0); + assert.equal(t.busy, false, 'adjacent or tiny moves are not animated'); + t.travel(0, 12, 0, 1000); + const [live] = t.live(1000); + assert.equal(live?.kind, 'travel'); + assert.ok(live!.duration >= 100 && live!.duration <= DURATIONS.travelMax, `a quick motion (${live!.duration}ms)`); + t.travel(12, 40, 0, 1050); + assert.equal(t.live(1050).length, 1, 'a newer jump retargets instead of queueing'); + assert.deepEqual([(t.live(1050)[0] as {from: number}).from, (t.live(1050)[0] as {to: number}).to], [12, 40]); + const short = make(); short.travel(0, 4, 0, 0); const long = make(); long.travel(0, 60, 0, 0); + assert.ok(long.live(0)[0]!.duration > short.live(0)[0]!.duration && long.live(0)[0]!.duration <= DURATIONS.travelMax, 'longer jumps last a little longer, capped'); + assert.deepEqual(t.live(1050 + 1000), [], 'no stale trail after completion'); + assert.equal(t.busy, false); + const off = make({cursorTravel: 'off'}); off.travel(0, 20, 0, 0); assert.equal(off.busy, false, 'toggle Off disables it'); + for (const reduced of [{...gate, reducedMotion: true}, {...gate, effectsOff: true}, {...gate, color: false}]) { + const quiet = make({}, reduced); quiet.travel(0, 20, 0, 0); assert.equal(quiet.busy, false, JSON.stringify(reduced)); + } +}); + +test('trail paint: leaves the destination to the real caret, is strongest nearby, fades, and is deterministic', () => { + const cells = travelCells(2, 14, 0.1, color, 0.5); + assert.ok(!cells.has(14), 'the destination cell is not painted'); + assert.ok(cells.has(13) && cells.has(2) === (cells.get(2) !== undefined)); + const amount = (column: number) => (cells.get(column) as {tint: {amount: number}} | undefined)?.tint.amount ?? 0; + assert.ok(amount(13) > amount(8) && amount(8) > amount(4), 'fades with distance from the destination'); + assert.ok(amount(13) < 0.5, 'gentle'); + assert.equal(travelCells(2, 14, 1, color, 0.5).size, 0, 'nothing remains at the end'); + assert.ok([...travelCells(14, 2, 0.2, color, 0.5).keys()].every(column => column >= 2 && column < 14 + 1), 'backward jumps stay between the two positions'); + assert.deepEqual([...travelCells(2, 14, 0.3, color, 0.5)], [...travelCells(2, 14, 0.3, color, 0.5)]); + assert.ok([...transitionPaint.travel(2, 14, 0.2, {rendering: 'rich', intensity: 1}).values()].every(paint => 'fill' in paint), 'Rich uses fills'); + assert.ok(progress({kind: 'travel', from: 0, to: 9, row: 0, start: 0, duration: 200, look: {rendering: 'clean', intensity: 1}}, 100) === 0.5); +}); + +test('the autofill feedback stays independent of cursor travel', () => { + const t = make(); + t.materialize(4, 8, 'git status', 0); + t.travel(0, 30, 0, 10); + assert.deepEqual(t.live(10).map(item => item.kind).sort(), ['materialize', 'travel']); + assert.ok(DURATIONS.travelBase > DURATIONS.materializeSubtle, 'slower than the fill confirmation'); + t.cancel('travel'); + assert.equal(t.live(10).length, 1); +}); + +test('settings: On by default for fresh installs, off for migrated configs, malformed falls back, and the toggle is a Motion row', () => { + assert.equal(DEFAULT_MOTION.cursorTravel, 'on'); + assert.equal(MIGRATED_MOTION.cursorTravel, 'off'); + assert.equal(normalizeMotion(undefined).cursorTravel, 'off'); + assert.equal(normalizeMotion({cursorTravel: 'on'}).cursorTravel, 'on'); + for (const bad of ['maybe', 1, null, true]) assert.equal(normalizeMotion({cursorTravel: bad}).cursorTravel, 'off'); + assert.ok(MOTION_ROWS.some(row => row.key === 'cursorTravel' && row.values.join() === 'off,on')); +}); + +test('app: a multi-cell caret jump moves the logical caret at once, starts the trail, and mutates nothing', () => { + const isolation = isolateConfig(); + const app = new TerminalApp(); + try { + app['configuration'] = {...app['configuration'], motion: {...app['configuration'].motion, cursorTravel: 'on'}}; + app['editor'].insert('git commit -m message'); + const writes: string[] = []; + app['session'].write = (data: string) => { writes.push(data); }; + const transcript = JSON.stringify(app['output'].transcript()); + const before = app['editor'].displayCursorIndex; + app['onInput']('\u001b[H'); // Home: a multi-cell jump + assert.ok(app['editor'].displayCursorIndex < before, 'the logical caret moved immediately'); + assert.equal(app['editor'].text, 'git commit -m message', 'the buffer is untouched'); + assert.ok(app['transitions'].live(Date.now()).some(item => item.kind === 'travel'), 'a trail started'); + app['onInput']('\u001b[C'); // Right: one cell, retains the old trail only if no new one starts + assert.ok(app['transitions'].live(Date.now()).filter(item => item.kind === 'travel').length <= 1); + assert.deepEqual(writes, [], 'nothing reached the shell'); + assert.equal(JSON.stringify(app['output'].transcript()), transcript); + } finally { app['stop'](0); app['session'].kill(); isolation.restore(); } +}); diff --git a/tests/dockTop.test.ts b/tests/dockTop.test.ts index 3e609eed..fb6b8f4a 100644 --- a/tests/dockTop.test.ts +++ b/tests/dockTop.test.ts @@ -36,10 +36,11 @@ test('Dock Top keeps jump and activity right after the newest output, and PTY ro assert.equal(full.transcript.height, full.ptyRows); }); -test('Dock Top panels open on the composer edge; narrow row counts never overflow', () => { - const panel = planScreen({...base, panelRows: 8, composerPosition: 'top'}); +test('Dock Top panels follow the panel position, not the composer; narrow row counts never overflow', () => { + const panel = planScreen({...base, panelRows: 8, composerPosition: 'top', panelPosition: 'top'}); assert.deepEqual(kinds(panel), ['panel', 'transcript']); assert.equal(regionAt(panel, 0)?.region.kind, 'panel'); + assert.deepEqual(kinds(planScreen({...base, panelRows: 8, composerPosition: 'top'})), ['transcript', 'panel'], 'default panel position is Bottom'); for (let rows = 1; rows <= 12; rows += 1) { const plan = planScreen({...base, rows, inputRows: 3, suggestions: 4, running: true, composerPosition: 'top', transcriptRows: 2}); assert.ok(plan.regions.reduce((sum, region) => sum + region.height, 0) <= rows, `rows ${rows}`); diff --git a/tests/flowComposer.test.ts b/tests/flowComposer.test.ts index 00deff0d..e781af6c 100644 --- a/tests/flowComposer.test.ts +++ b/tests/flowComposer.test.ts @@ -63,12 +63,11 @@ test('Flow scrolled back: the composer scrolls with the document and PTY and vie assert.ok(followInput.top > 0); }); -test('Flow panels are top-anchored like every other composer position; narrow terminals and long input never overflow', () => { +test('Flow panels pin to the bottom edge; narrow terminals and long input never overflow', () => { const panel = planScreen({...base, panelRows: 8, transcriptRows: 3}); - assert.deepEqual(kinds(panel), ['panel', 'transcript']); + assert.deepEqual(kinds(panel), ['transcript', 'panel']); assert.equal(panel.composerPosition, 'flow'); - assert.equal(regionAt(panel, 0)?.region.kind, 'panel'); - assert.equal(regionAt(panel, 23)?.region.kind, 'transcript'); + assert.equal(regionAt(panel, 23)?.region.kind, 'panel'); for (let rows = 1; rows <= 14; rows += 1) { for (const transcriptRows of [0, 2, 400]) { for (const [detached, viewStart] of [[false, 0], [true, 0], [true, 395], [true, 399]] as const) { diff --git a/tests/promptProviders.test.ts b/tests/promptProviders.test.ts index 2a4b5d06..f9506d13 100644 --- a/tests/promptProviders.test.ts +++ b/tests/promptProviders.test.ts @@ -150,9 +150,7 @@ test('onboarding preview uses a dedicated panel and hides the live composer curs assert.ok(frame?.rows.some(row => row.includes('Prompt setup'))); assert.ok(frame?.rows.some(row => row.includes('Two-line preview'))); assert.ok(frame?.rows.some(row => row.includes('Fading wedge'))); - assert.ok(frame?.rows.some(row => stripAnsi(row).includes('Esc skip')), 'the panel replaces the regular composer'); - const panelTop = frame!.rows.findIndex(row => row.includes('Prompt setup')); - assert.ok(panelTop >= 0 && panelTop <= 2, 'the panel is top-anchored'); + assert.ok(stripAnsi(frame?.rows.at(-1) ?? '').includes('Esc skip'), 'the panel occupies the bottom rows instead of leaving the regular composer beneath it'); const previewRows = app['promptPanelPreview'](80); const runtimeRow = buildContextLine(moduleShowcaseContext(), 76, app['promptPanelState']!.draft, app['promptPanelState']!.draft.placement); diff --git a/tests/screenPlan.test.ts b/tests/screenPlan.test.ts index 43f04b52..f2fa05f1 100644 --- a/tests/screenPlan.test.ts +++ b/tests/screenPlan.test.ts @@ -73,8 +73,7 @@ test('screen plan invariants hold across a deterministic geometry matrix', () => } assert.equal(cursor, plan.rows, `regions account for every screen row: ${label}`); assert.ok(plan.transcript.height >= 0 && plan.transcript.top >= 0, label); - if (plan.panelActive) assert.equal(plan.regions[0]!.kind === 'panel' ? plan.regions[0]!.top : 0, 0, `a panel starts at the top: ${label}`); - else assert.equal(plan.transcript.top, 0, `Dock Bottom transcript starts at the top: ${label}`); + assert.equal(plan.transcript.top, 0, `Dock Bottom transcript starts at the top: ${label}`); assert.equal(plan.ptyRows, legacyPtyRows(input), `PTY rows match v0.4: ${label}`); assert.equal(plan.panelActive, input.panelRows !== undefined, label); if (plan.panelActive) { @@ -137,12 +136,12 @@ test('PTY rows keep concrete v0.4 Dock Bottom values', () => { assert.equal(planScreen({...base, panelRows: 99}).ptyRows, 0); }); -test('panel takeover is top-oriented and toggles geometry consistently', () => { +test('panel takeover pins the panel to the bottom and toggles geometry consistently', () => { const base: ScreenPlanInput = {rows: 20, inputRows: 2, suggestions: 4, running: false, detached: true, hasOutput: true, contextPlacement: 'header', hasVisibleContext: true, composerLayout: 'twoLine'}; const open = planScreen({...base, panelRows: 6}); - assert.deepEqual(open.regions, [{kind: 'panel', top: 0, height: 6}, {kind: 'transcript', top: 6, height: 14}]); - assert.equal(cursorScreenRow(open, 3), 0, 'hidden cursor parks at the panel'); + assert.deepEqual(open.regions, [{kind: 'transcript', top: 0, height: 14}, {kind: 'panel', top: 14, height: 6}]); + assert.equal(cursorScreenRow(open, 3), 14, 'hidden cursor parks at the panel'); const closed = planScreen(base); assert.deepEqual(closed, planScreen({...base, panelRows: undefined}), 'closing restores the composer plan'); assert.ok(regionOf(closed, 'input')); @@ -223,13 +222,12 @@ test('mouse hit-testing respects panel takeover geometry', () => { Object.defineProperty(app, 'settingsPanelRows', {value: () => Array.from({length: 6}, () => 'panel')}); const plan: ScreenPlan = app['planFrame'](80, 20); assert.equal(plan.transcript.height, 14); - assert.equal(plan.transcript.top, 6, 'the transcript is below the top-anchored panel'); const wrapped = app['output'].wrapped(80); const viewStart = app['historyViewport'].resolve(wrapped.length, 14); - app['onInput'](sgr(35, 2, 6)); - assert.equal(app['hoveredLineIndex'], undefined, 'the last panel row is not transcript'); - app['onInput'](sgr(35, 2, 7)); - assert.equal(app['hoveredLineIndex'], wrapped[viewStart]!.lineIndex, 'the first row below the panel is transcript'); + app['onInput'](sgr(35, 2, 14)); + assert.equal(app['hoveredLineIndex'], wrapped[viewStart + 13]!.lineIndex, 'row just above the panel is transcript'); + app['onInput'](sgr(35, 2, 15)); + assert.equal(app['hoveredLineIndex'], undefined, 'panel rows are not transcript'); } finally { dispose(app); } @@ -279,34 +277,40 @@ test('PTY resizes to plan rows on panel open/close and full screen in passthroug } }); - -test('panel takeover: the panel top is fixed at row 0 in every composer position while its height changes', () => { +test('panel takeover follows the explicit NMSh panel position (default Bottom), never the composer position', () => { const base: ScreenPlanInput = {rows: 24, inputRows: 2, suggestions: 3, running: false, detached: false, hasOutput: true, contextPlacement: 'header', hasVisibleContext: true, composerLayout: 'twoLine', transcriptRows: 40}; for (const composerPosition of ['bottom', 'top', 'flow'] as const) { - const a = planScreen({...base, composerPosition, panelRows: 8}), b = planScreen({...base, composerPosition, panelRows: 14}); - for (const plan of [a, b]) { - assert.deepEqual(plan.regions.map(region => region.kind), ['panel', 'transcript'], composerPosition); - assert.equal(regionOf(plan, 'panel')!.top, 0, composerPosition); - assert.equal(plan.inputHeight, 0); assert.equal(plan.panelActive, true); - } - assert.equal(regionOf(a, 'panel')!.top, regionOf(b, 'panel')!.top, 'top does not move'); - assert.deepEqual([regionOf(a, 'panel')!.height, regionOf(a, 'transcript')!.top, regionOf(a, 'transcript')!.height], [8, 8, 16]); - assert.deepEqual([regionOf(b, 'panel')!.height, regionOf(b, 'transcript')!.top, regionOf(b, 'transcript')!.height], [14, 14, 10]); - assert.equal(regionAt(b, 0)?.region.kind, 'panel'); assert.equal(regionAt(b, 13)?.region.kind, 'panel'); assert.equal(regionAt(b, 14)?.region.kind, 'transcript'); - assert.equal(cursorScreenRow(b, 3), 0, 'the hidden cursor parks at the panel'); - // Short terminals stay bounded and top-anchored; a panel as tall as the screen takes it all. - for (const rows of [1, 3, 6, 10]) { - const plan = planScreen({...base, rows, composerPosition, panelRows: 14}); - assert.equal(regionOf(plan, 'panel')!.top, 0); - assert.ok(plan.regions.reduce((sum, region) => sum + region.height, 0) <= rows && plan.transcript.height >= 0); + const unset = planScreen({...base, composerPosition, panelRows: 8}); + assert.deepEqual(unset.regions.map(region => region.kind), ['transcript', 'panel'], `${composerPosition}: default is Bottom`); + assert.equal(unset.panelPosition, 'bottom'); + for (const panelPosition of ['bottom', 'top'] as const) { + const a = planScreen({...base, composerPosition, panelPosition, panelRows: 8}), b = planScreen({...base, composerPosition, panelPosition, panelRows: 14}); + const pa = regionOf(a, 'panel')!, pb = regionOf(b, 'panel')!; + assert.equal(a.panelPosition, panelPosition); + if (panelPosition === 'top') { + assert.deepEqual(a.regions.map(region => region.kind), ['panel', 'transcript']); + assert.equal(pa.top, 0); assert.equal(pb.top, 0, 'the top edge stays put as the height changes'); + assert.deepEqual([regionOf(b, 'transcript')!.top, regionOf(b, 'transcript')!.height], [14, 10]); + } else { + assert.deepEqual(a.regions.map(region => region.kind), ['transcript', 'panel']); + assert.equal(pa.top + pa.height, 24); assert.equal(pb.top + pb.height, 24, 'the bottom edge stays put as the height changes'); + assert.equal(pb.top, 10); + } + assert.equal(regionAt(b, pb.top)?.region.kind, 'panel'); assert.equal(regionAt(b, pb.top + pb.height - 1)?.region.kind, 'panel'); + assert.equal(cursorScreenRow(b, 3), pb.top, 'the hidden cursor parks at the panel'); + assert.equal(a.inputHeight, 0); assert.equal(a.panelActive, true); + for (const rows of [1, 3, 6, 10]) { + const short = planScreen({...base, rows, composerPosition, panelPosition, panelRows: 14}); + assert.ok(short.regions.reduce((sum, region) => sum + region.height, 0) <= rows && short.transcript.height >= 0); + if (panelPosition === 'top') assert.equal(regionOf(short, 'panel')!.top, 0); + } } - assert.equal(regionOf(planScreen({...base, composerPosition, panelRows: 99}), 'transcript')?.height ?? 0, 0); - // No panel: the ordinary composer positions are unchanged. - const normal = planScreen({...base, composerPosition}); + // Without a panel the composer behaves exactly as configured. + const normal = planScreen({...base, composerPosition, panelPosition: 'top'}); + assert.equal(normal.panelActive, false); const input = regionOf(normal, 'input')!; if (composerPosition === 'top') assert.ok(input.top < normal.transcript.top); if (composerPosition === 'bottom') assert.ok(input.top > normal.transcript.top); - assert.equal(normal.panelActive, false); } }); diff --git a/tests/screenSavers.test.ts b/tests/screenSavers.test.ts index d415b456..0a6acac8 100644 --- a/tests/screenSavers.test.ts +++ b/tests/screenSavers.test.ts @@ -654,3 +654,71 @@ test('Black Hole: consumption order is nearest-first and reproducible; every loo const grid = new CellGrid(); grid.resize(c.width, c.height); loop.paint(grid, ctx(t)); for (const i of sourceCells(c)) if (Math.abs(i % c.width - loop.cx) > 2 || Math.abs(Math.floor(i / c.width) - loop.cy) > 2) assert.equal(grid.glyphs[i], c.glyphs[i]); }); + +import {createScreensaverPanel, screensaverKey, renderScreensaverPanel} from '../src/idle/IdleVisuals.js'; +test('gallery: lowercase r replays the preview from a pristine source; advertised only when animated', () => { + const state = createScreensaverPanel(1); + const settings = {timeout: 5 as const, mode: 'circletastic' as const, colorSource: 'appearance' as const, customStops: [], runWhileBusy: false}; + assert.deepEqual(screensaverKey(state, {kind: 'text', value: 'r'}, settings), {kind: 'replay'}); + assert.equal(screensaverKey(state, {kind: 'text', value: 'R'}, settings), undefined); + const shown = (still: boolean) => renderScreensaverPanel(state, 100, 40, {settings, motion: {still, disabled: false}, preview: []}).map(stripAnsi).join('\n'); + assert.match(shown(false), /r replay/u); + assert.doesNotMatch(shown(true), /r replay/u, 'a still preview has nothing to replay'); + const {app, cleanup} = harness({mode: 'circletastic'}); + try { + app['renderer'].snapshot = () => SCREEN; + app['openScreensaverGallery'](); + const panel = app['screensaverPanel']!; + app['render'](); + app['saverGalleryCapture']!.instances.circletastic = 'animated'; + app['handleScreensaverKey']({kind: 'text', value: 'r'}, panel); + assert.notEqual(app['saverGalleryCapture']?.instances.circletastic, 'animated', 'the preview restarts from a fresh capture, not the animated one'); + assert.ok(((app['saverGalleryCapture']?.instances.circletastic as Circletastic | undefined)?.t ?? 0) < 1000, 'a fresh effect near time zero'); + assert.ok(Date.now() - panel.startedAt < 1000); + assert.deepEqual(Object.keys(app['saverCapture']!.instances), [], 'the source snapshot itself is never animated'); + } finally { cleanup(); } +}); + +test('Circletastic gathers as a vortex: curved orbital paths, tangential motion, and the ring is already turning when it forms', () => { + const fx = new Circletastic(denseCapture(12), palette.text); + const ci = 0; const ring = fx.rings.find(r => r.cluster === ci)!; const ri = fx.rings.indexOf(ring); + const probe = fx.glyphs.map((g, i) => ({g, i})).filter(({i}) => fx.assigned[i]?.ring === ri).slice(0, 12); + const sources = probe.map(({g}) => ({x: g.x, y: g.y})); + const path: Array> = probe.map(() => []); + const omegaAt: number[] = []; let angularRates: number[][] = probe.map(() => []); + let previousPhi: number[] = probe.map(() => NaN); + for (let t = 50; t <= 12_000 && fx.runtime[ci]!.stage === 'gather'; t += 50) { + fx.advance(t); + omegaAt.push(fx.omega[ri]!); + probe.forEach(({g}, k) => { + if (g.state === 1) return; + path[k]!.push({x: g.x, y: g.y}); + const phi = Math.atan2(g.y - ring.cy, g.x / ASPECT - ring.cx); + if (!Number.isNaN(previousPhi[k]!)) angularRates[k]!.push(Math.atan2(Math.sin(phi - previousPhi[k]!), Math.cos(phi - previousPhi[k]!)) / 0.05); + previousPhi[k] = phi; + }); + } + assert.ok(fx.runtime[ci]!.formed, 'the circle formed'); + assert.ok(omegaAt[0]! > 0 && omegaAt.every((v, i) => i === 0 || v >= omegaAt[i - 1]!), 'rotation is running (and rising) during the gather, not switched on afterwards'); + assert.ok(fx.omega[ri]! > 0.8 * ring.dir * ring.dir, 'already spinning at the moment of formation'); + // Curved, not a straight line: the path leaves the chord between source and destination. + let curved = 0, swept = 0; + probe.forEach(({i}, k) => { + const pts = path[k]!; if (pts.length < 6) return; + const a = sources[k]!, b = pts.at(-1)!; const len = Math.hypot(b.x - a.x, b.y - a.y) || 1; + const deviation = Math.max(...pts.map(p => Math.abs((b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y)) / len)); + if (deviation > 1.5) curved += 1; + if (angularRates[k]!.some(rate => Math.abs(rate) > 0.2)) swept += 1; + void i; + }); + assert.ok(curved >= probe.length * 0.6, `glyphs follow curved paths (${curved}/${probe.length})`); + assert.ok(swept >= probe.length * 0.6, 'glyphs have angular (tangential) motion about the circle while gathering'); + // No velocity discontinuity: glyphs arriving have a rate close to the ring's own. + const last = angularRates.map(rates => rates.at(-1)).filter((v): v is number => v !== undefined); + assert.ok(last.length > 0 && last.every(rate => Math.abs(rate - fx.omega[ri]! * ring.dir) < 4), 'arriving angular speed matches the ring (no snap)'); + // The next frame after formation keeps spinning at least as fast. + const w = fx.omega[ri]!; fx.advance(fx.t + 50); assert.ok(fx.omega[ri]! >= w); + // Deterministic: the same seed gathers along the same paths. + const again = new Circletastic(denseCapture(12), palette.text); again.advance(fx.t - 50); + void again; +}); diff --git a/tests/settingsControls.test.ts b/tests/settingsControls.test.ts index 712450ba..ad0487ae 100644 --- a/tests/settingsControls.test.ts +++ b/tests/settingsControls.test.ts @@ -121,11 +121,11 @@ test('footer follows Claude-style phrasing for the focused control', () => { assert.ok(!footer(config()).includes('Tab')); }); -test('Settings view lists panel entry points and truthful planned areas', () => { +test('Settings view lists panel entry points and carries no stale roadmap copy', () => { const rows = plain(renderSettingsPanel(config({view: 'settings'}), 90)); assert.ok(rows.some(row => /› Appearance\s+Terminal opacity and blur/u.test(row))); - assert.ok(rows.some(row => row.includes('Planned for v0.4'))); - assert.ok(rows.some(row => row.includes('Layout · Blocks'))); + assert.ok(!rows.some(row => /Planned for|v0\.4/u.test(row)), 'features that shipped are not listed as planned'); + assert.ok(!rows.some(row => row.includes('Layout · Blocks'))); }); test('Left/Right change an enum inline and persist it; Enter changes too', () => withApp(async (app, path) => { diff --git a/tests/settingsV2.test.ts b/tests/settingsV2.test.ts index 6f3439c0..3b9ec492 100644 --- a/tests/settingsV2.test.ts +++ b/tests/settingsV2.test.ts @@ -98,3 +98,17 @@ test('R does nothing while searching, so it can be typed', () => withApp(app => app['handleKey']({kind: 'text', value: 'r'}); assert.equal(app['settingsPanelState']!.searchQuery, 'r'); })); + + +import {normalizePromptConfiguration as normalizeConfig} from '../src/prompt/configuration.js'; +test('panel position: Bottom by default, persisted, malformed values fall back, independent of the composer position', () => { + assert.equal(DEFAULT_PROMPT_CONFIGURATION.panelPosition, 'bottom'); + assert.equal(normalizeConfig({}).panelPosition, 'bottom'); + assert.equal(normalizeConfig({panelPosition: 'top'}).panelPosition, 'top'); + for (const bad of ['left', 'flow', 3, null, true]) assert.equal(normalizeConfig({panelPosition: bad}).panelPosition, 'bottom'); + assert.equal(normalizeConfig({composerPosition: 'top'}).panelPosition, 'bottom', 'a Top composer does not imply Top panels'); + assert.equal(normalizeConfig({panelPosition: 'top'}).composerPosition, 'bottom', 'nor the other way round'); + const row = SETTINGS_ROWS.find(item => item.id === 'panelPosition')!; + assert.equal(row.category, 'General'); + assert.equal(row.control === 'enum' && row.options.join(), 'Bottom,Top'); +}); diff --git a/tests/setupCat.test.ts b/tests/setupCat.test.ts index b114f7c5..0675247f 100644 --- a/tests/setupCat.test.ts +++ b/tests/setupCat.test.ts @@ -229,3 +229,38 @@ test('app: /setup never writes on open or cancel; Apply persists through the nor app['handleKey']({kind: 'enter'}); assert.equal(await readFile(path, 'utf8'), afterApply); }, {glyphStyle: 'safe', onboardingComplete: true, glyphChoiceComplete: true, toolsSetupComplete: true, futureField: 'kept'})); + +test('app: Setup owns a clean screen, previews the drafted panel position, never touches the transcript, and leaves cleanly', () => withApp(async (app, path) => { + const frames: Array<{rows: string[]}> = []; + delete (app as unknown as Record)['render']; // withApp stubs render; use the real one here + app['renderer'].render = (frame: {rows: string[]}) => { frames.push(frame); }; + app['fetchSuggestions'] = async () => {}; + app['presentationStarted'] = true; app['startupPending'] = false; + app['output'].addFrontendInteraction('echo', 'WELCOME-BACKDROP-MARKER', ''); + app['render'](); + assert.ok(frames.at(-1)!.rows.some(row => row.includes('WELCOME-BACKDROP-MARKER')), 'the ordinary screen shows it'); + const transcriptBefore = JSON.stringify(app['output'].transcript()); + const before = await readFile(path, 'utf8'); + await app['runSlash']('/setup', {kind: 'setup'}); + app['render'](); + const rows = frames.at(-1)!.rows; + assert.ok(rows.some(row => row.includes('Setup')), 'Setup Cat is drawn'); + assert.ok(!rows.some(row => row.includes('WELCOME-BACKDROP-MARKER')), 'the transcript/welcome is not drawn behind Setup'); + const bottomTitle = rows.findIndex(row => row.includes('Setup')); + // The Start step edits the one stored field; the draft decides where the panel sits right away. + const state = app['setupState']!; + state.row = rowIndex(state, 'panelPosition'); + app['handleKey']({kind: 'right'}); + assert.equal(state.draft.panelPosition, 'top'); + app['render'](); + const topTitle = frames.at(-1)!.rows.findIndex(row => row.includes('Setup')); + assert.ok(topTitle < bottomTitle, 'Top moves the panel to the top of the screen'); + assert.equal(app['promptConfiguration'].panelPosition, 'bottom', 'nothing is applied until Apply'); + for (let i = 0; i < 3 && app['setupState']; i += 1) app['handleKey']({kind: i === 0 ? 'escape' : 'text', value: 'y'} as never); + assert.equal(app['setupState'], undefined); + app['render'](); + assert.ok(frames.at(-1)!.rows.some(row => row.includes('WELCOME-BACKDROP-MARKER')), 'leaving restores the normal screen'); + assert.equal(JSON.stringify(app['output'].transcript()), transcriptBefore, 'transcript untouched'); + assert.equal(await readFile(path, 'utf8'), before); + assert.ok(!SETUP_SECTIONS.some(section => JSON.stringify(section.intro).includes('Planned for')), 'no stale roadmap copy'); +}, {onboardingComplete: true})); diff --git a/tests/taskProgress.test.ts b/tests/taskProgress.test.ts index 0c2f46a1..b312302d 100644 --- a/tests/taskProgress.test.ts +++ b/tests/taskProgress.test.ts @@ -62,3 +62,62 @@ test('persisted presentation motion controls freeze task decoration without free task.setReducedMotion(false); assert.notEqual(taskProgressBar(task.state, 1200), taskProgressBar(task.state, 1800)); }); + +import {animatedProgressBar, type TaskSnapshot} from '../src/status/TaskProgress.js'; +import {stripAnsi} from '../src/util/text.js'; +import {foreground, UI_COLORS} from '../src/ui/palette.js'; +import {setIconStyle} from '../src/ui/glyphs.js'; + +const snap = (extra: Partial): TaskSnapshot => ({label: 'x', status: 'running', startedAt: 0, start: 0, reducedMotion: false, ...extra} as TaskSnapshot); +const colored = (run: () => T): T => { + const saved = {c: process.env.COLORTERM, n: process.env.NO_COLOR, m: process.env.NMSH_REDUCED_MOTION}; + process.env.COLORTERM = 'truecolor'; delete process.env.NO_COLOR; delete process.env.NMSH_REDUCED_MOTION; setIconStyle('nerd'); + try { return run(); } finally { for (const [k, v] of [['COLORTERM', saved.c], ['NO_COLOR', saved.n], ['NMSH_REDUCED_MOTION', saved.m]] as const) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } } +}; +const fills = (bar: string) => (stripAnsi(bar).match(/━/gu) ?? []).length; + +test('determinate bar: a travelling wave moves inside the factual fill and never changes it', () => colored(() => { + const state = snap({total: 100, completed: 48}); + const a = animatedProgressBar(state, 100), b = animatedProgressBar(state, 1700); + assert.equal(fills(a), 12, '48% of 24 cells, always'); assert.equal(fills(b), 12); + assert.equal(stripAnsi(a), stripAnsi(taskProgressBar(state, 100)), 'same glyphs as the factual bar'); + assert.notEqual(a, b, 'the wave position changes with elapsed time'); + // Cells differ in color (a localized wave, not one uniform pulse) and only filled cells carry the wave. + const colorsOf = (bar: string) => [...bar.matchAll(/\u001b\[38;2;(\d+;\d+;\d+)m(?:━|─)/gu)].map(m => m[1]!); + const colors = colorsOf(a); + assert.equal(colors.length, 24); + assert.ok(new Set(colors.slice(0, 12)).size > 3, 'neighbouring filled cells have offset phases'); + assert.equal(new Set(colors.slice(12)).size, 1, 'unfilled cells are a uniform track, never lit as completed'); + assert.equal(fills(animatedProgressBar(snap({total: 100, completed: 0}), 100)), 0, '0% stays empty'); + assert.equal(fills(animatedProgressBar(snap({total: 100, completed: 100}), 100)), 24); +})); + +test('indeterminate bar: a travelling segment with no percentage, looping smoothly', () => colored(() => { + const state = snap({}); + const frames = [0, 450, 900, 1800].map(now => stripAnsi(animatedProgressBar(state, now))); + assert.ok(new Set(frames).size === frames.length, 'the segment moves'); + assert.ok(frames.every(frame => frame.length === 24 && /[█▓▒]|─/u.test(frame) && !/%|━/u.test(frame)), 'no invented fill or percentage'); + const head = (frame: string) => frame.search(/█/u); + assert.ok(head(frames[1]!) !== head(frames[2]!) || head(frames[0]!) !== head(frames[1]!)); +})); + +test('progress colors come from the theme roles; finished, reduced-motion and no-color forms are static and valid', () => colored(() => { + const running = animatedProgressBar(snap({total: 10, completed: 5}), 300); + assert.ok(running.includes(foreground(UI_COLORS.secondary)), 'the track uses the secondary role'); + const original = {...UI_COLORS.workingBase}; + try { + Object.assign(UI_COLORS.workingBase, {red: 1, green: 2, blue: 3}); Object.assign(UI_COLORS.workingPeak, {red: 1, green: 2, blue: 3}); + assert.ok(animatedProgressBar(snap({total: 10, completed: 5}), 300).includes('38;2;1;2;3'), 'a custom theme\'s roles flow through'); + } finally { Object.assign(UI_COLORS.workingBase, original); Object.assign(UI_COLORS.workingPeak, {red: 211, green: 202, blue: 238}); } + const still = animatedProgressBar(snap({total: 10, completed: 5, reducedMotion: true}), 300); + assert.equal(still, animatedProgressBar(snap({total: 10, completed: 5, reducedMotion: true}), 2900), 'reduced motion: static, correct fill'); + assert.equal(fills(still), 12); + assert.equal(animatedProgressBar(snap({status: 'succeeded', total: 10, completed: 5}), 300), animatedProgressBar(snap({status: 'succeeded', total: 10, completed: 5}), 900), 'finished tasks do not animate'); + process.env.NO_COLOR = '1'; + const plain = animatedProgressBar(snap({total: 10, completed: 5}), 300); + assert.ok(!/\u001b\[38/u.test(plain) && fills(plain) === 12, 'NO_COLOR: valid plain bar'); + delete process.env.NO_COLOR; + setIconStyle('safe'); + assert.match(stripAnsi(animatedProgressBar(snap({}), 300)), /^[-#+]+$/u, 'safe glyph mode uses ASCII'); + setIconStyle('nerd'); +}));