diff --git a/AGENTS.md b/AGENTS.md index 7ca18bcf..721a8de3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,7 @@ For substantial implementation work: Key references: - [ROADMAP.md](ROADMAP.md) — product direction and issue index - [GitHub Issues](https://github.com/raiseCatError/notMyShell/issues) — actionable work -- [v0.6.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.6.0) — current stable release +- [v0.7.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.7.0) — current stable release - [#132 Flow / Classic composer](https://github.com/raiseCatError/notMyShell/issues/132) — next planned direction (see [ROADMAP.md](ROADMAP.md)) - [GitHub Project](https://github.com/users/raiseCatError/projects/1) — live development status board - [docs/architecture/terminal-stack.md](docs/architecture/terminal-stack.md) — terminology and stack model diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c87ec73..71b0d780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.7.0] - 2026-10-01 + +UI Foundation & Customization: a shared internal UI toolkit (notMyUI), Chroma color roles, reduced-presentation modes, Markdown-authored help, and Settings v2. + +### Added +- **notMyUI toolkit:** an internal presentation and interaction foundation for NMSh-owned surfaces (not a published package). [docs/architecture/notmyui.md](docs/architecture/notmyui.md) maps each primitive to its real consumers and lists what is deliberately not built. +- **Shared actions and contextual help:** panel and palette actions share one model (identity, label, key, enabled state), and footer help is derived from it, so it stays in step with what a panel can actually do. +- **Shared form controls:** toggles, selects, multi-selects, text fields and confirmations are reusable controls that report proposals while the feature layer persists. Settings rows and Settings search use them. +- **Accessibility baseline:** `NO_COLOR` (or `TERM=dumb`) stops NMSh-generated color escapes while bold, inverse and glyphs remain. `NMSH_COLOR=none|256|truecolor` overrides the color level explicitly. `NMSH_REDUCED_MOTION=1` holds the shimmer and the welcome blink still while durations keep counting. Focus, changed and error states are also carried by text or glyphs, not color alone. See [docs/accessibility/baseline.md](docs/accessibility/baseline.md) for criteria and known gaps. +- **Deterministic presentation:** `NMSH_DETERMINISTIC=1` fixes the displayed completion time, the shimmer and activity phase, and the welcome blink for repeatable captures, and implies reduced motion. Shell behavior, PTY output and measured durations stay real. See [docs/testing/deterministic-presentation.md](docs/testing/deterministic-presentation.md). +- **Chroma:** shared color roles for NMSh-owned UI in three distinct categories (semantic status, theme and identity colors), plus gradients and curves, with truecolor, 256-color and no-color fallback. +- **Surface primitives:** frames, fills, padding, insets, width and alignment, used by the shared panel frame. +- **Semantic motion engine:** a pure engine of motion profiles per state, sampled at an elapsed time. It owns no timers and never changes width. The existing running-command shimmer and activity glyph now run on it. +- **Authored Markdown and `/help`:** `/help` renders NMSh-authored Markdown (headings, tables, code fences, tips, links). Through the transcript, links appear as `text (url)` rather than clickable hyperlinks. +- **Settings v2:** a simple and an advanced view, a changed marker on settings that differ from their defaults, reset of the current setting, search, and a remembered position within a run. + +### Notes +- Authored Markdown applies to NMSh-owned content only. Raw PTY output, transcript command output, `/copy` and archived shell data are never interpreted as authored UI content or recolored. +- Not included: automatic 256-color detection (`NMSH_COLOR=256` selects it), a persisted reduced-motion setting, user key remapping, a shared animation scheduler, Linguist language colors (#176) and transient visual effects (#78). Screen-reader behavior is unverified, and not every surface uses authored Markdown yet. + ## [0.6.0] - 2026-09-29 Sessions & Continuity: persistent live sessions you can detach from and reattach to, a Flow composer, and a `/layout` showcase. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06c4d17f..69ed0c12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ Before starting work, check: - **[ROADMAP.md](ROADMAP.md)** — product direction and what is planned - **[GitHub Issues](https://github.com/raiseCatError/notMyShell/issues)** — concrete actionable work; acceptance criteria in each issue are authoritative -- **[v0.6.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.6.0)** — current stable release +- **[v0.7.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.7.0)** — current stable release - **[#132 Flow / Classic composer](https://github.com/raiseCatError/notMyShell/issues/132)** — next planned direction (see [ROADMAP.md](ROADMAP.md)) - **GitHub Project** — [NMSh Development](https://github.com/users/raiseCatError/projects/1) — live development status board diff --git a/ROADMAP.md b/ROADMAP.md index 963078ec..7571e782 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,9 +4,9 @@ | | | |---|---| -| **Current release** | [v0.6.0 — Sessions & Continuity](https://github.com/raiseCatError/notMyShell/releases/tag/v0.6.0) | +| **Current release** | [v0.7.0 — UI Foundation & Customization](https://github.com/raiseCatError/notMyShell/releases/tag/v0.7.0) | | **Development branch** | `dev` | -| **Next direction** | [v0.7.0 — UI Foundation & Customization](https://github.com/raiseCatError/notMyShell/milestone/6) (planned) | +| **Next direction** | Not yet defined; see the Backlog below and open issues | | **Project board** | [NMSh Development](https://github.com/users/raiseCatError/projects/1) | GitHub issues define actionable remaining work. A merged implementation may still be open with `needs-human-test` while physical terminal checks are pending. Closed issues represent completed work, not a promise that future refinements are finished. @@ -93,6 +93,25 @@ The final release candidate also included the passive-hover selection fix. | [#132](https://github.com/raiseCatError/notMyShell/issues/132), [#133](https://github.com/raiseCatError/notMyShell/issues/133) | Flow composer position; `/layout` showcase | | [#199](https://github.com/raiseCatError/notMyShell/issues/199), [#206](https://github.com/raiseCatError/notMyShell/issues/206), [#211](https://github.com/raiseCatError/notMyShell/issues/211), [#212](https://github.com/raiseCatError/notMyShell/issues/212) | Physical-QA fixes: resize on closed PTY, stray suspended job, inline interactive UIs, multi-session restore | +## Released — v0.7.0 UI Foundation & Customization + +[Milestone #6](https://github.com/raiseCatError/notMyShell/milestone/6) shipped after physical QA of Settings v2, generated help, color and motion modes, surfaces, and Flow/Chat regressions. Release notes are in [CHANGELOG.md](CHANGELOG.md). Screen-reader behavior is unverified; see [accessibility baseline](docs/accessibility/baseline.md). + +### Shipped scope + +| Issue | Implemented scope | +|---|---| +| [#164](https://github.com/raiseCatError/notMyShell/issues/164) | notMyUI internal toolkit and acceptance documentation | +| [#165](https://github.com/raiseCatError/notMyShell/issues/165) | Surface primitives: frames, fills, layout | +| [#166](https://github.com/raiseCatError/notMyShell/issues/166) | Shared actions and generated contextual help | +| [#167](https://github.com/raiseCatError/notMyShell/issues/167) | Shared form controls | +| [#168](https://github.com/raiseCatError/notMyShell/issues/168) | Authored Markdown renderer and `/help` | +| [#169](https://github.com/raiseCatError/notMyShell/issues/169) | Settings v2: simple/advanced view, changed markers, reset, remembered position | +| [#170](https://github.com/raiseCatError/notMyShell/issues/170) | Accessibility baseline, no-color and reduced-motion modes | +| [#171](https://github.com/raiseCatError/notMyShell/issues/171) | Chroma shared color roles, gradients and fallback | +| [#172](https://github.com/raiseCatError/notMyShell/issues/172) | Semantic motion engine; shimmer migrated onto it | +| [#173](https://github.com/raiseCatError/notMyShell/issues/173) | Deterministic presentation mode | + ## Backlog — Research and Future Features These remain open and are not scheduled for a release. diff --git a/docs/accessibility/baseline.md b/docs/accessibility/baseline.md new file mode 100644 index 00000000..5abe2bfa --- /dev/null +++ b/docs/accessibility/baseline.md @@ -0,0 +1,42 @@ +# Accessibility and reduced-presentation baseline + +This applies to NMSh-owned surfaces only: panels, the composer, status rows, help and the transcript chrome. Raw PTY output, archived command output and `/copy` are never restyled. + +Accessibility flows through the shared primitives (`ui/palette`, `ui/glyphs`, `ui/actions`, `ui/formControls`, `presentation/environment`), not a separate renderer. + +## Controls + +| Setting | Effect | +| --- | --- | +| `NO_COLOR=1` (non-empty) or `TERM=dumb` | `foreground()`/`background()` emit nothing. Bold, inverse and glyphs remain. | +| `NMSH_COLOR=none` / `256` / `truecolor` | Explicit override, wins over `NO_COLOR`/`TERM`. `256` maps NMSh colors to the xterm 256 palette. | +| `NMSH_ICONS=safe` | ASCII-safe glyph set (existing). | +| `NMSH_REDUCED_MOTION=1` | Shimmer/activity glyph phase and the welcome blink stay still. Durations keep counting. Deterministic presentation implies it. | + +These are environment controls. A persisted setting would change the public configuration schema and is left to the Settings work. + +## Acceptance criteria + +1. Every action is reachable by keyboard; the pointer only adds shortcuts (`ui/actions`). +2. The focused row is identifiable without color: a pointer glyph (`›`, or `>` in safe mode) or `>` in plain form controls. +3. Changed and error states are text (`(changed)`, `Error: ...`), not color alone (`ui/formControls`). +4. Success/failure rows carry distinct glyphs (`✔`/`✘`, or `+`/`x` in safe mode) in addition to color. +5. With `NO_COLOR`, no NMSh-generated color escape is written; layout is unchanged. +6. With reduced motion, no NMSh-owned decoration changes between frames unless state changes. +7. No width-changing animation. + +## Audit result + +- Met: keyboard operation in palette, Settings, draft panels; pointer-free navigation; safe glyph set; status glyphs; footer help derived from actions. +- Known gaps: command-row background bands (`TranscriptPresenter`) are a color-only cue for "this is a command" under `NO_COLOR`; the prompt prefix is the remaining cue. Provider panels (prompt, welcome, suggestions) keep hand-written footers. +- Not supported: automatic 256-color detection (only explicit `NMSH_COLOR=256`), 16-color output, and a persisted reduced-motion setting. + +## Screen readers + +NMSh redraws full-screen frames in the alternate screen. Terminals expose that to assistive technology inconsistently, so NMSh cannot guarantee screen-reader-friendly output. What it does provide is text-carried state and no pointer requirement; behavior with a specific terminal and reader has not been verified. + +## Motion + +`motion/motion.ts` is a pure engine: profiles per semantic state (`waiting`, `processing`, `streaming`, `transition`, `completion`, `failure`) sampled at an elapsed time. Reduced motion holds every profile still. It never schedules timers, changes width or touches PTY output. + +The shimmer (`status/shimmer.ts`) samples the `processing`/`streaming` profiles. The one existing activity timer is already bounded (100 ms), cleared on exit, and its renders are suppressed during passthrough, so no shared scheduler was added; one should arrive with a second animated consumer. `waiting`, `transition`, `completion` and `failure` have no consumer yet. diff --git a/docs/architecture/notmyui.md b/docs/architecture/notmyui.md new file mode 100644 index 00000000..d7639b4f --- /dev/null +++ b/docs/architecture/notmyui.md @@ -0,0 +1,40 @@ +# notMyUI: internal presentation and interaction toolkit + +Internal to NMSh (not a published package). It exists only for NMSh-owned surfaces; raw PTY output, archived command output and `/copy` are never interpreted or recolored by it. zsh stays authoritative. + +## Primitives and their consumers + +| Primitive | Module | What it owns | Real consumers | +| --- | --- | --- | --- | +| Deterministic clock | `presentation/environment` | `NMSH_DETERMINISTIC`, `NMSH_REDUCED_MOTION` display seams | completion time, shimmer phase, welcome blink | +| Capability | `presentation/capabilities` | color level: none / 256 / truecolor | `chroma/escape`, `ui/palette` | +| Chroma | `chroma/chroma`, `chroma/escape` | which color a cell gets: status / theme / identity roles, gradients, curves, fallback | palette helpers, shimmer, Settings status tones, surfaces | +| Actions | `ui/actions` | identity, label, key, enabled state; derived footers | command palette, layout/syntax/transcript panels | +| Form controls | `ui/formControls` | toggle, select, multi-select, text field, confirmation as proposals | Settings rows, Settings search | +| Surfaces | `ui/surface` | frame, fill, padding, inset, width, alignment | `framePanel` (all panels) | +| Motion | `motion/motion` | semantic motion profiles, sampled purely | shimmer | +| Authored Markdown | `help/markdown` | branded, NMSh-owned content only | `/help` | +| Settings v2 | `ui/SettingsPanel` | simple/advanced rows, changed marker, reset | `/settings` Config | + +Boundaries: Chroma decides color; surfaces and layout decide where cells are; controls report proposals and the feature layer persists; motion yields intensities and owns no timers. + +## Not built (no consumer yet) + +Shared animation scheduler, user-remappable bindings, surface variants beyond the top line in panels, Linguist language colors (#176), transient effects (#78), automatic 256-color detection, persisted reduced-motion setting. + +## Acceptance + +See `docs/accessibility/baseline.md` for accessibility criteria and known gaps. + +## v0.7 physical QA checklist (not yet performed) + +- Ghostty and Terminal.app: `/settings` Config: `A` advanced, `/` search, `R` reset, changed `•` marker, remembered position after Esc and reopen. +- Narrow widths (about 24 to 40 columns) for Settings, palette and `/help`. +- `NO_COLOR=1`: panels stay legible; focus, changed and status cues remain visible; command-row bands (known gap). +- `NMSH_COLOR=256` on a 256-color terminal: palette looks acceptable. +- `NMSH_REDUCED_MOTION=1`: running-command shimmer and welcome blink stay still while the timer counts. +- `NMSH_DETERMINISTIC=1`: completion time and shimmer are stable across runs. +- `/help`: table, code fence and tips render in Ghostty and Terminal.app; safe-glyph mode (`NMSH_ICONS=safe`) shows ASCII. +- Command palette: footer hides Enter/↑↓ when no result matches; keyboard-only run and close. +- Passthrough transitions (vim, fzf) and Flow/Chat layouts unchanged; no motion or repaint artifacts after exit. +- Screen reader behavior is unverified and not promised. diff --git a/docs/testing/deterministic-presentation.md b/docs/testing/deterministic-presentation.md new file mode 100644 index 00000000..22d44fd9 --- /dev/null +++ b/docs/testing/deterministic-presentation.md @@ -0,0 +1,34 @@ +# Deterministic presentation for tests + +Set `NMSH_DETERMINISTIC=1` when launching NMSh to make its current presentation +more repeatable in visual and integration captures: + +```sh +NMSH_DETERMINISTIC=1 npm run dev +``` + +This is a developer/testing aid for visual snapshots, integration tests, and +later VHS/demo tooling. It is opt-in and does not change the normal runtime. + +## Stabilized today + +- NMSh's displayed command completion time is fixed to 09:41 local time. The + measured command duration and the underlying shell event timestamp remain + real. +- NMSh shimmer and live activity spinner presentation use a fixed phase. + Command duration text remains based on real elapsed time. +- Vespyr stays in its open-eye frame; the ambient blink timer is not started. + Its existing blink delay sequence is already deterministic and has no random + choices. + +The fixed completion clock uses the local-time formatter, so it stabilizes the +displayed hour and minute across runs on a host. It does not normalize locale, +terminal width, colors, or other host presentation settings. + +## Deliberately unchanged + +This mode does not freeze `Date`, randomness, or timers globally. Shell commands, +zsh state, PTY output and timing, session expiry, service protocol timing, +timeouts/retries, external programs, persisted timestamps, and filesystem IDs +remain real. NMSh currently has no general animation engine or random ambient +visual choices; this seam does not implement the future #172 animation system. diff --git a/package-lock.json b/package-lock.json index 866f3550..b24cccc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nmsh", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nmsh", - "version": "0.6.0", + "version": "0.7.0", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 8bf8ac87..b5982549 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nmsh", - "version": "0.6.0", + "version": "0.7.0", "description": "A cleaner terminal frontend for zsh with persistent input, autocomplete, semantic highlighting, scrollable history, and better command feedback.", "private": true, "main": "dist/index.js", diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 7e0a242a..f082a700 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -2,8 +2,8 @@ import {homedir} from 'node:os'; import {GLYPHS, setIconStyle} from '../ui/glyphs.js'; import {framePanel} from '../ui/PanelShell.js'; import { - adjustSettingsRow, isInlineEditable, renderSettingsPanel, selectedSettingsRow, settingsItemCount, settingsRowDestination, - settingsView, statusLineCount, switchSettingsView, toggleSettingsRow, type SettingsDestination, type SettingsPanelState, + adjustSettingsRow, isInlineEditable, resetSettingsRow, settingsRowChanged, renderSettingsPanel, selectedSettingsRow, settingsItemCount, settingsRowDestination, + settingsView, statusLineCount, visibleSettingsRows, switchSettingsView, toggleSettingsRow, type SettingsDestination, type SettingsPanelState, type SettingsView, type StatusSections, SETTINGS_ENTRIES, SETTINGS_ROWS, @@ -52,7 +52,11 @@ import {parseSlashCommand, slashCommands, slashSuggestions, suggestionWindow} fr import {copyFeedback, copyStats, writeClipboard} from '../clipboard/clipboard.js'; import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js'; import {layoutInput, graphemes} from '../input/inputLayout.js'; +import {editText} from '../ui/formControls.js'; +import {helpMarkdown} from '../help/helpContent.js'; +import {renderMarkdownText} from '../help/markdown.js'; import {shimmerText} from '../status/shimmer.js'; +import {isReducedMotion, presentationAnimationElapsed, presentationCompletionTime, presentationNow} from '../presentation/environment.js'; import {TaskProgress} from '../status/TaskProgress.js'; import {completedActivity, liveActivityParts} from '../status/activity.js'; import {extractFacts} from '../status/adapters.js'; @@ -96,7 +100,7 @@ const FLOW_EDIT_KEYS: ReadonlySet = new Set(['text', 'paste', 'back const STOPPED = foreground({red: 198, green: 156, blue: 109}); const INFO = SECONDARY; const RESET = '\u001B[0m'; -const PASTE_ATOM_BACKGROUND = '\u001B[48;2;63;65;82m'; +const PASTE_ATOM_BACKGROUND = background({red: 63, green: 65, blue: 82}); const STATUS_REFRESH_MS = 100; export class TerminalApp { @@ -473,7 +477,11 @@ export class TerminalApp { return; } if (this.settingsPanelState) { - this.handleSettingsKey(key, this.settingsPanelState); + const settingsState = this.settingsPanelState; + this.handleSettingsKey(key, settingsState); + if (settingsState.section === 'root' && settingsView(settingsState) === 'config') { + this.settingsMemory = {contentIndex: settingsState.contentIndex ?? 0, searchQuery: settingsState.searchQuery ?? '', showAdvanced: Boolean(settingsState.showAdvanced)}; + } this.render(); return; } @@ -998,8 +1006,7 @@ export class TerminalApp { break; } case 'config': - this.openSettingsPanel('config'); - this.settingsPanelState!.contentIndex = Math.max(0, SETTINGS_ROWS.findIndex(row => row.id === action.rowId)); + this.focusConfigRow(action.rowId); break; case 'toggleComposerPosition': config.composerPosition = ({bottom: 'top', top: 'flow', flow: 'bottom'} as const)[config.composerPosition]; @@ -1382,9 +1389,9 @@ export class TerminalApp { } private showHelp(command: string): void { - const summary = slashCommands.map(item => `${item.name} — ${item.description}`).join(' · '); - const helpText = `${summary}\n\n${INFO}✻ Large multiline paste is one editable atom; Enter submits its original text. Press Ctrl+O beside it to inspect or unwrap.\n✻ Portable Select-All: Alt+A\n✻ VS Code Cmd+A Keybinding JSON:\n { "key": "cmd+a", "command": "workbench.action.terminal.sendSequence", "args": { "text": "\\u001b[97;9u" }, "when": "terminalFocus" }${RESET}`; - this.output.addFrontendInteraction(command, helpText, ACCENT); + const helpText = renderMarkdownText(helpMarkdown(), {columns: Math.max(20, this.dimensions().columns - 6), + hyperlinks: false}); // the transcript cell model has no OSC 8 support + this.output.addFrontendInteraction(command, helpText, INFO); } private onShellData(data: string): void { @@ -1427,7 +1434,8 @@ export class TerminalApp { const outputText = completedRecord?.output ?? ''; const facts = extractFacts(command.command, outputText); const isInterrupted = command.interrupted || exitCode === 130; - const parts = completedActivity(command.command, elapsed, completedAt, isInterrupted ? 0 : exitCode, isInterrupted, facts); + const displayCompletedAt = presentationCompletionTime(completedAt); + const parts = completedActivity(command.command, elapsed, displayCompletedAt, isInterrupted ? 0 : exitCode, isInterrupted, facts); 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}`); @@ -1954,11 +1962,25 @@ export class TerminalApp { } } + /** Config position, search and advanced toggle survive closing and reopening within one run (never persisted). */ + private settingsMemory?: {contentIndex: number; searchQuery: string; showAdvanced: boolean}; + private openSettingsPanel(view: SettingsView): void { - this.settingsPanelState = {section: 'root', view, selectedIndex: 0, contentIndex: 0, + const memory = view === 'config' ? this.settingsMemory : undefined; + this.settingsPanelState = {section: 'root', view, selectedIndex: 0, contentIndex: memory?.contentIndex ?? 0, + searchQuery: memory?.searchQuery, showAdvanced: memory?.showAdvanced, glyphStyle: this.promptConfiguration.glyphStyle, onboarding: false}; } + /** Opens Config on a specific row, revealing it if it is an advanced row; clears any remembered search. */ + private focusConfigRow(rowId: string): void { + this.openSettingsPanel('config'); + const state = this.settingsPanelState!; + state.searchQuery = ''; + state.showAdvanced = state.showAdvanced || SETTINGS_ROWS.find(row => row.id === rowId)?.level === 'advanced'; + state.contentIndex = Math.max(0, visibleSettingsRows(state).findIndex(row => row.id === rowId)); + } + /** * Keys for the shared Settings / Status / Config panel: * - ←/→ switch views, except on an inline-editable Config row where they @@ -1982,12 +2004,15 @@ export class TerminalApp { } const view = settingsView(state); const row = selectedSettingsRow(state); - if (state.searchFocused && key.kind === 'text') { - state.searchQuery = (state.searchQuery ?? '') + key.value; + const editedSearch = state.searchFocused ? editText(state.searchQuery ?? '', key) : undefined; + if (editedSearch !== undefined) { + state.searchQuery = editedSearch; state.contentIndex = 0; - } else if (state.searchFocused && key.kind === 'backspace') { - state.searchQuery = (state.searchQuery ?? '').slice(0, -1); + } else if (key.kind === 'text' && view === 'config' && state.focus !== 'tabs' && !state.searchFocused && key.value.toLowerCase() === 'a' && !state.searchQuery?.trim()) { + state.showAdvanced = !state.showAdvanced; state.contentIndex = 0; + } else if (key.kind === 'text' && view === 'config' && state.focus !== 'tabs' && !state.searchFocused && key.value.toLowerCase() === 'r' && row) { + if (settingsRowChanged(row, this.promptConfiguration)) this.applySettingsConfiguration(resetSettingsRow(row, this.promptConfiguration)); } else if (key.kind === 'text' && key.value === '/' && view === 'config') { state.searchFocused = true; state.focus = 'rows'; @@ -2400,7 +2425,7 @@ export class TerminalApp { * change. Blinks are skipped (not queued) while no welcome is present. */ private scheduleWelcomeBlink(): void { - if (this.stopped) return; + if (this.stopped || isReducedMotion()) return; this.welcomeBlinkTimer = setTimeout(() => { if (this.stopped) return; if (!this.output.hasWelcome || this.passthrough) { @@ -2582,7 +2607,8 @@ export class TerminalApp { const outputHeight = plan.transcript.height; const presenter = this.output.presenter; const interaction = {hoveredLineIndex: this.hoveredLineIndex, focusedLineIndex: this.focusedLineIndex, - focusedCommandIndex: this.focusedCommandIndex, focusedActivityId: this.focusedActivityId, now: Date.now()}; + focusedCommandIndex: this.focusedCommandIndex, focusedActivityId: this.focusedActivityId, + now: presentationNow().getTime()}; const visible = wrapped.slice(viewStart, viewStart + outputHeight).map(row => presenter.decorate(row, row.lineIndex === undefined ? undefined : this.output.lineTypes.get(row.lineIndex), interaction)); const sticky = this.stickyHeader(wrapped, viewStart); @@ -2676,8 +2702,9 @@ export class TerminalApp { if (!this.running) return ''; const elapsed = this.activityAnimationNow - this.running.startedAt; const isActive = (Date.now() - this.lastOutputTime) < 750; - const parts = liveActivityParts(this.running.command, elapsed); - return `${shimmerText(parts.phrase, elapsed, isActive)}${SECONDARY}${parts.duration}${RESET}`; + const animationElapsed = presentationAnimationElapsed(elapsed); + const parts = liveActivityParts(this.running.command, elapsed, animationElapsed); + return `${shimmerText(parts.phrase, animationElapsed, isReducedMotion() ? false : isActive)}${SECONDARY}${parts.duration}${RESET}`; } private jumpAffordance(columns: number): string { diff --git a/src/chroma/chroma.ts b/src/chroma/chroma.ts new file mode 100644 index 00000000..498a4515 --- /dev/null +++ b/src/chroma/chroma.ts @@ -0,0 +1,134 @@ +import {UI_COLORS} from '../ui/palette.js'; +import {colorEscape, type Rgb} from './escape.js'; + +/** + * Chroma decides which color a cell gets; UI code decides where cells exist. + * Three categories stay distinct on purpose: + * - status: success/failure/working meaning ("did it work?") + * - theme: presentation colors (text tiers, accent, selection) + * - identity: a thing's own brand color (e.g. a language). It is never a status. + * Only NMSh-owned output goes through here; PTY output and /copy never do. + */ +export type StatusRole = 'success' | 'failure' | 'working'; +export type ThemeRole = 'primary' | 'secondary' | 'subtle' | 'accent' | 'separator' | 'selection'; + +export type ColorRef = + | {kind: 'solid'; rgb: Rgb} + | {kind: 'status'; role: StatusRole} + | {kind: 'theme'; role: ThemeRole} + | {kind: 'identity'; rgb: Rgb}; + +export const solid = (rgb: Rgb): ColorRef => ({kind: 'solid', rgb}); +export const status = (role: StatusRole): ColorRef => ({kind: 'status', role}); +export const theme = (role: ThemeRole): ColorRef => ({kind: 'theme', role}); +/** A thing's identity color. Carries no success/warning/failure meaning. */ +export const identity = (rgb: Rgb): ColorRef => ({kind: 'identity', rgb}); + +/** The NMSh brand lavender; the default direction, not a hard-coded assumption of the system. */ +export const BRAND_LAVENDER: Rgb = {red: 0xA6, green: 0x7C, blue: 0xF3}; + +const STATUS_COLORS: Record = {success: UI_COLORS.success, failure: UI_COLORS.failure, working: UI_COLORS.workingBase}; +const THEME_COLORS: Record = { + primary: UI_COLORS.primary, secondary: UI_COLORS.secondary, subtle: UI_COLORS.subtle, + accent: UI_COLORS.accent, separator: UI_COLORS.separator, selection: UI_COLORS.selection, +}; + +export function resolveColor(color: ColorRef): Rgb { + switch (color.kind) { + case 'solid': + case 'identity': return color.rgb; + case 'status': return STATUS_COLORS[color.role]; + case 'theme': return THEME_COLORS[color.role]; + } +} + +/** Only status colors mean something about success or failure. */ +export function statusMeaning(color: ColorRef): StatusRole | undefined { + return color.kind === 'status' ? color.role : undefined; +} + +// ---- Curves ----------------------------------------------------------------- + +export type Curve = 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out'; + +/** Maps 0..1 to 0..1 with the chosen easing; input is clamped. */ +export function applyCurve(curve: Curve, amount: number): number { + const t = Math.max(0, Math.min(1, amount)); + switch (curve) { + case 'linear': return t; + case 'ease-in': return t * t; + case 'ease-out': return 1 - (1 - t) * (1 - t); + case 'ease-in-out': return t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2; + } +} + +// ---- Gradients -------------------------------------------------------------- + +export function mixRgb(from: Rgb, to: Rgb, amount: number): Rgb { + const t = Math.max(0, Math.min(1, amount)); + return { + red: Math.round(from.red + (to.red - from.red) * t), + green: Math.round(from.green + (to.green - from.green) * t), + blue: Math.round(from.blue + (to.blue - from.blue) * t), + }; +} + +export interface GradientStop { + /** Position 0..1. */ + at: number; + color: ColorRef; +} +export interface Gradient { + stops: readonly GradientStop[]; + /** Applied to the position before sampling, e.g. to bias toward one end. */ + curve?: Curve; +} + +/** A two-stop gradient from `from` to `to`. */ +export function linearGradient(from: ColorRef, to: ColorRef, curve?: Curve): Gradient { + return {stops: [{at: 0, color: from}, {at: 1, color: to}], curve}; +} + +/** Color at position 0..1. Stops may be given in any order; positions outside the stops clamp to the ends. */ +export function sampleGradient(gradient: Gradient, position: number): Rgb { + const stops = [...gradient.stops].sort((a, b) => a.at - b.at); + if (stops.length === 0) throw new Error('A gradient needs at least one stop'); + const t = applyCurve(gradient.curve ?? 'linear', position); + const first = stops[0]!; + const last = stops[stops.length - 1]!; + if (t <= first.at) return resolveColor(first.color); + if (t >= last.at) return resolveColor(last.color); + for (let index = 1; index < stops.length; index += 1) { + const upper = stops[index]!; + if (t <= upper.at) { + const lower = stops[index - 1]!; + const span = upper.at - lower.at; + return mixRgb(resolveColor(lower.color), resolveColor(upper.color), span === 0 ? 1 : (t - lower.at) / span); + } + } + return resolveColor(last.color); +} + +/** One color per cell across `count` cells, first cell at 0 and last at 1. */ +export function gradientCells(gradient: Gradient, count: number): Rgb[] { + return Array.from({length: count}, (_, index) => sampleGradient(gradient, count <= 1 ? 0 : index / (count - 1))); +} + +/** Perceptual-ish brightness 0..1 (Rec. 709 weights), for choosing readable text on a fill. */ +export function luminance(color: Rgb): number { + return (0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue) / 255; +} + +// ---- Painting --------------------------------------------------------------- + +export const foregroundOf = (color: ColorRef): string => colorEscape(38, resolveColor(color)); +export const backgroundOf = (color: ColorRef): string => colorEscape(48, resolveColor(color)); + +/** + * Text with one foreground color per glyph. Under no-color the result is the + * plain text, so meaning must never depend on the gradient. + */ +export function gradientText(glyphs: readonly string[], gradient: Gradient): string { + const colors = gradientCells(gradient, glyphs.length); + return glyphs.map((glyph, index) => `${colorEscape(38, colors[index]!)}${glyph}`).join(''); +} diff --git a/src/chroma/escape.ts b/src/chroma/escape.ts new file mode 100644 index 00000000..db8053fa --- /dev/null +++ b/src/chroma/escape.ts @@ -0,0 +1,38 @@ +import {colorLevel, type ColorLevel} from '../presentation/capabilities.js'; + +export interface Rgb { + red: number; + green: number; + blue: number; +} + +const CUBE_LEVELS = [0, 95, 135, 175, 215, 255] as const; + +function nearestCube(value: number): number { + let best = 0; + for (let index = 1; index < CUBE_LEVELS.length; index += 1) { + if (Math.abs(CUBE_LEVELS[index]! - value) < Math.abs(CUBE_LEVELS[best]! - value)) best = index; + } + return best; +} + +/** Nearest xterm 256-palette entry (6x6x6 cube or grayscale ramp) for a color. */ +export function rgbTo256(color: Rgb): number { + const r = nearestCube(color.red); + const g = nearestCube(color.green); + const b = nearestCube(color.blue); + const cube = {red: CUBE_LEVELS[r]!, green: CUBE_LEVELS[g]!, blue: CUBE_LEVELS[b]!}; + const average = Math.round((color.red + color.green + color.blue) / 3); + const grayStep = Math.max(0, Math.min(23, Math.round((average - 8) / 10))); + const grayValue = 8 + grayStep * 10; + const gray = {red: grayValue, green: grayValue, blue: grayValue}; + const distance = (a: Rgb) => (a.red - color.red) ** 2 + (a.green - color.green) ** 2 + (a.blue - color.blue) ** 2; + return distance(gray) < distance(cube) ? 232 + grayStep : 16 + 36 * r + 6 * g + b; +} + +/** SGR sequence for a foreground (38) or background (48) color at a capability level; empty when uncolored. */ +export function colorEscape(layer: 38 | 48, color: Rgb, level: ColorLevel = colorLevel()): string { + if (level === 'none') return ''; + if (level === 'ansi256') return `\u001B[${layer};5;${rgbTo256(color)}m`; + return `\u001B[${layer};2;${color.red};${color.green};${color.blue}m`; +} diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts new file mode 100644 index 00000000..cd64c7aa --- /dev/null +++ b/src/help/helpContent.ts @@ -0,0 +1,25 @@ +import {slashCommands} from '../commands/slashCommands.js'; +import {authoredMarkdown, type AuthoredMarkdown} from './markdown.js'; + +/** The /help page. Every input is NMSh source (command table, fixed guidance); nothing comes from the shell. */ +export function helpMarkdown(): AuthoredMarkdown { + const commands = slashCommands.map(item => `| \`${item.name}\` | ${item.description} |`).join('\n'); + return authoredMarkdown(`# NMSh help + +## Commands + +| Command | What it does | +| --- | --- | +${commands} + +## Tips + +- A large multiline paste is **one editable atom**; Enter submits its original text. Press Ctrl+O beside it to inspect or unwrap. +- Portable Select-All is Alt+A. +- VS Code Cmd+A keybinding JSON: + +\`\`\` +{ "key": "cmd+a", "command": "workbench.action.terminal.sendSequence", "args": { "text": "\\u001b[97;9u" }, "when": "terminalFocus" } +\`\`\` +`); +} diff --git a/src/help/markdown.ts b/src/help/markdown.ts new file mode 100644 index 00000000..6363fee6 --- /dev/null +++ b/src/help/markdown.ts @@ -0,0 +1,227 @@ +import {backgroundOf, foregroundOf, theme} from '../chroma/chroma.js'; +import {graphemes} from '../input/inputLayout.js'; +import {colorLevel} from '../presentation/capabilities.js'; +import {getCurrentGlyphMode} from '../ui/glyphs.js'; +import {displayWidth, truncateAnsi} from '../util/text.js'; + +/** + * Renders explicitly NMSh-authored Markdown (help, setup guidance, provider + * docs, release notes) into styled terminal rows. + * + * SAFETY BOUNDARY: this is never applied to shell output, PTY streams, + * transcript command output, archived output or `/copy` data. The input type is + * branded so a plain string cannot be passed by accident; the only way in is + * `authoredMarkdown()`, called on content that ships in NMSh's own source. + * A test pins the modules allowed to import this file. + */ +declare const AUTHORED: unique symbol; +export type AuthoredMarkdown = string & {readonly [AUTHORED]: true}; + +export function authoredMarkdown(source: string): AuthoredMarkdown { + return source as AuthoredMarkdown; +} + +export interface MarkdownOptions { + columns: number; + /** Emit OSC 8 hyperlinks; otherwise links read `text (url)`. */ + hyperlinks?: boolean; +} + +const RESET = '\u001B[0m'; +const BOLD = '\u001B[1m'; +const ITALIC = '\u001B[3m'; +const UNDERLINE = '\u001B[4m'; +const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/gu; + +/** Terminals known to implement OSC 8. `NMSH_HYPERLINKS=1|0` overrides detection. */ +export function supportsHyperlinks(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.NMSH_HYPERLINKS === '1') return true; + if (env.NMSH_HYPERLINKS === '0' || env.TERM === 'dumb') return false; + return ['ghostty', 'iTerm.app', 'WezTerm', 'vscode', 'Hyper'].includes(env.TERM_PROGRAM ?? '') || Boolean(env.KITTY_WINDOW_ID); +} + +// ---- Inline --------------------------------------------------------------- + +type Style = 'text' | 'code' | 'strong' | 'em' | 'link'; +interface Piece {text: string; style: Style; url?: string} + +const INLINE = /`([^`]+)`|\*\*([^*]+)\*\*|\*([^*\s][^*]*)\*|\[([^\]]+)\]\(([^)\s]+)\)/gu; + +function safeUrl(url: string): string | undefined { + return /^https?:\/\/[^\s\u0000-\u001F\u007F]+$/u.test(url) ? url : undefined; +} + +function parseInline(source: string): Piece[] { + const pieces: Piece[] = []; + let last = 0; + for (const match of source.matchAll(INLINE)) { + if (match.index! > last) pieces.push({text: source.slice(last, match.index), style: 'text'}); + if (match[1] !== undefined) pieces.push({text: match[1], style: 'code'}); + else if (match[2] !== undefined) pieces.push({text: match[2], style: 'strong'}); + else if (match[3] !== undefined) pieces.push({text: match[3], style: 'em'}); + else pieces.push({text: match[4]!, style: 'link', url: match[5]}); + last = match.index! + match[0].length; + } + if (last < source.length) pieces.push({text: source.slice(last), style: 'text'}); + return pieces; +} + +function styleFor(style: Style): string { + switch (style) { + case 'code': return `${backgroundOf(theme('selection'))}${foregroundOf(theme('primary'))}`; + case 'strong': return BOLD; + case 'em': return ITALIC; + case 'link': return `${UNDERLINE}${foregroundOf(theme('accent'))}`; + case 'text': return ''; + } +} + +/** Plain-terminal fallback for code so it is never distinguished by color alone. */ +function plainCode(text: string): string { + return colorLevel() === 'none' ? `\`${text}\`` : text; +} + +interface Word {text: string; style: Style; url?: string; /** Attached to the previous word with no space between (e.g. punctuation after a link). */ glue: boolean} + +function toWords(pieces: Piece[], hyperlinks: boolean): Word[] { + const words: Word[] = []; + let previousEndsSpace = true; + for (const piece of pieces) { + const url = piece.style === 'link' ? safeUrl(piece.url ?? '') : undefined; + const style: Style = piece.style === 'link' && !url ? 'text' : piece.style; + const parts = piece.style === 'code' ? [plainCode(piece.text)] : piece.text.split(/\s+/u).filter(Boolean); + const startsSpace = /^\s/u.test(piece.text); + parts.forEach((part, index) => words.push({text: part, style, url, glue: index === 0 && !startsSpace && !previousEndsSpace})); + if (parts.length) previousEndsSpace = piece.style !== 'code' && /\s$/u.test(piece.text); + if (piece.style === 'link' && url && !hyperlinks) { + words.push({text: `(${url})`, style: 'text', glue: false}); + previousEndsSpace = false; + } + } + return words; +} + +function paintWord(word: Word, hyperlinks: boolean): string { + const style = styleFor(word.style); + const body = word.style === 'link' && word.url && hyperlinks ? `\u001B]8;;${word.url}\u001B\\${word.text}\u001B]8;;\u001B\\` : word.text; + return style ? `${style}${body}${RESET}` : body; +} + +/** Word-wraps inline Markdown into rows no wider than `width`; every row carries its own resets. */ +function wrapInline(source: string, width: number, hyperlinks: boolean, firstIndent: string, restIndent: string): string[] { + const words = toWords(parseInline(source), hyperlinks); + const rows: string[] = []; + let line = firstIndent; + let used = displayWidth(firstIndent); + let empty = true; + const push = () => { rows.push(line); line = restIndent; used = displayWidth(restIndent); empty = true; }; + for (const word of words) { + const size = displayWidth(word.text); + const gap = word.glue ? 0 : 1; + if (!empty && used + gap + size > width) push(); + if (empty && used + size > width) { + // A single word wider than the row is hard-split rather than overflowing. + let chunk = ''; + for (const glyph of graphemes(word.text)) { + if (used + displayWidth(chunk + glyph) > width && chunk) { line += paintWord({...word, text: chunk}, hyperlinks); push(); chunk = ''; } + chunk += glyph; + } + line += paintWord({...word, text: chunk}, hyperlinks); + used += displayWidth(chunk); + empty = false; + continue; + } + if (!empty && !word.glue) { line += ' '; used += 1; } + line += paintWord(word, hyperlinks); + used += size; + empty = false; + } + if (!empty || rows.length === 0) rows.push(line); + return rows; +} + +// ---- Blocks --------------------------------------------------------------- + +function plainInline(source: string): string { + return parseInline(source).map(piece => piece.text).join(''); +} + +function splitRow(line: string): string[] { + return line.trim().replace(/^\|/u, '').replace(/\|$/u, '').split('|').map(cell => cell.trim()); +} + +function renderTable(rows: string[][], width: number, hyperlinks: boolean): string[] { + const [header, ...body] = rows; + const widths = header!.map((_, column) => Math.max(...rows.map(row => displayWidth(plainInline(row[column] ?? ''))))); + const total = widths.reduce((sum, value) => sum + value, 0) + 3 * (widths.length - 1) + 2; + if (total > width) { + // Too narrow for columns: each row becomes a labelled list. + return body.flatMap(row => wrapInline(row.map((cell, column) => (column === 0 ? `**${cell}**` : cell)).join(' — '), width, hyperlinks, ' ', ' ')); + } + const cellText = (cell: string, column: number, bold: boolean) => { + const rendered = wrapInline(bold ? `**${cell}**` : cell, widths[column]!, hyperlinks, '', '')[0] ?? ''; + return rendered + ' '.repeat(Math.max(0, widths[column]! - displayWidth(rendered))); + }; + const line = (row: string[], bold: boolean) => ` ${header!.map((_, column) => cellText(row[column] ?? '', column, bold)).join(' │ ')}` + .replace(/│/gu, getCurrentGlyphMode() === 'nerd' ? '│' : '|').trimEnd(); + const rule = ` ${widths.map(size => (getCurrentGlyphMode() === 'nerd' ? '─' : '-').repeat(size)).join(getCurrentGlyphMode() === 'nerd' ? '─┼─' : '-+-')}`; + return [line(header!, true), rule, ...body.map(row => line(row, false))]; +} + +/** Rows for `source`, each at most `columns` wide. Paragraphs wrap; code and tables never reflow. */ +export function renderMarkdown(source: AuthoredMarkdown, options: MarkdownOptions): string[] { + const width = Math.max(8, options.columns); + const hyperlinks = options.hyperlinks ?? supportsHyperlinks(); + const lines = source.replace(/\r\n?/gu, '\n').replace(CONTROL, match => (match === '\t' ? ' ' : '')).split('\n'); + const out: string[] = []; + const gap = () => { if (out.length && out[out.length - 1] !== '') out.push(''); }; + const bullet = getCurrentGlyphMode() === 'nerd' ? '•' : '-'; + let paragraph: string[] = []; + const flush = () => { + if (paragraph.length) out.push(...wrapInline(paragraph.join(' '), width, hyperlinks, '', '')); + paragraph = []; + }; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + const heading = /^(#{1,3})\s+(.*)$/u.exec(line); + const item = /^(\s*)([-*]|\d+\.)\s+(.*)$/u.exec(line); + if (/^```/u.test(line)) { + flush(); + const code: string[] = []; + for (index += 1; index < lines.length && !/^```/u.test(lines[index]!); index += 1) code.push(lines[index]!); + gap(); + out.push(...code.map(row => truncateAnsi(` ${foregroundOf(theme('secondary'))}${row}${RESET}`, width))); + out.push(''); + } else if (heading) { + flush(); + gap(); + const role = heading[1]!.length === 1 ? 'accent' : heading[1]!.length === 2 ? 'primary' : 'secondary'; + out.push(truncateAnsi(`${BOLD}${foregroundOf(theme(role))}${plainInline(heading[2]!)}${RESET}`, width)); + } else if (/^\s*\|/u.test(line) && /^\s*\|[\s:|-]+\|\s*$/u.test(lines[index + 1] ?? '')) { + flush(); + const table = [splitRow(line)]; + for (index += 2; index < lines.length && /^\s*\|/u.test(lines[index]!); index += 1) table.push(splitRow(lines[index]!)); + index -= 1; + gap(); + out.push(...renderTable(table, width, hyperlinks), ''); + } else if (item) { + flush(); + const marker = /\d/u.test(item[2]!) ? item[2]! : bullet; + const indent = `${item[1]!.replace(/\t/gu, ' ')}${marker} `; + out.push(...wrapInline(item[3]!, width, hyperlinks, indent, ' '.repeat(displayWidth(indent)))); + } else if (line.trim() === '') { + flush(); + gap(); + } else paragraph.push(line.trim()); + } + flush(); + while (out.length && out[out.length - 1] === '') out.pop(); + return out.map(row => truncateAnsi(row, width)); +} + +/** Rendered rows joined for the transcript's multi-line result slot. */ +export function renderMarkdownText(source: AuthoredMarkdown, options: MarkdownOptions): string { + return renderMarkdown(source, options).join('\n'); +} + diff --git a/src/input/SyntaxPanel.ts b/src/input/SyntaxPanel.ts index e66f3cb4..39a7879b 100644 --- a/src/input/SyntaxPanel.ts +++ b/src/input/SyntaxPanel.ts @@ -8,7 +8,7 @@ import { import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; import type {CommandType} from '../shell/SemanticService.js'; import type {Key} from '../terminal/keys.js'; -import {renderControls} from '../ui/controls.js'; +import {DRAFT_PANEL_ACTIONS, renderActionHelp} from '../ui/actions.js'; import {GLYPHS} from '../ui/glyphs.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; @@ -127,7 +127,7 @@ export function renderSyntaxPanel(state: SyntaxPanelState, columns: number, prom const preview = ['', `${PRIMARY}Preview${RESET} ${syntaxDraftChanged(state) ? `${ACCENT}unsaved preview` : `${SUBTLE}matches current`}${RESET}`, ...SYNTAX_PREVIEW_LINES.map(line => ` ${ACCENT}${GLYPHS.prompt}${RESET} ${renderSyntaxPreviewLine(line, draft, promptPalette)}`)]; if (state.message) preview.push(`${SECONDARY}${state.message}${RESET}`); - const controls = ['', renderControls([['↑↓', 'move'], ['←→', 'change'], ['Enter', 'save'], ['Esc', 'cancel']])]; + const controls = ['', renderActionHelp(DRAFT_PANEL_ACTIONS)]; const includeGallery = out.length + gallery.length + preview.length + controls.length <= rowsAvailable; return [...out, ...(includeGallery ? gallery : []), ...preview, ...controls].map(row => truncateAnsi(row, columns)); diff --git a/src/motion/motion.ts b/src/motion/motion.ts new file mode 100644 index 00000000..5b4110cc --- /dev/null +++ b/src/motion/motion.ts @@ -0,0 +1,78 @@ +import {applyCurve, type Curve} from '../chroma/chroma.js'; +import {isReducedMotion} from '../presentation/environment.js'; + +/** + * Restrained, pure motion: given elapsed time (from whichever clock the caller + * owns) a profile yields a 0..1 intensity for a cell. Chroma turns intensity + * into color; UI code decides where cells are. Nothing here changes width, + * schedules timers, or touches PTY output. + */ +export type MotionShape = + | 'sine' // symmetric breathing / traveling shimmer + | 'comet' // slow rise, fast fall + | 'tail' // fast rise, long tail + | 'pulse'; // quick rise, then settle to rest + +export type MotionSpread = + | 'uniform' // every cell moves together + | 'travel'; // the crest moves across cells + +export interface MotionProfile { + shape: MotionShape; + spread: MotionSpread; + /** Time for one full cycle (or the whole gesture when not repeating). */ + cycleMs: number; + /** Repeating profiles loop; the rest settle at 0 once the gesture ends. */ + repeat: boolean; + /** Cells per wavelength for `travel`. */ + wavelength?: number; + /** Shapes the finished waveform; linear when omitted. */ + curve?: Curve; +} + +export type MotionState = 'waiting' | 'processing' | 'streaming' | 'transition' | 'completion' | 'failure'; + +/** Which motion communicates which NMSh state. Reduced motion holds every one still. */ +export const MOTION_PROFILES: Readonly> = { + waiting: {shape: 'sine', spread: 'uniform', cycleMs: 3200, repeat: true}, + processing: {shape: 'sine', spread: 'travel', cycleMs: 1800, repeat: true, wavelength: 12}, + streaming: {shape: 'sine', spread: 'travel', cycleMs: 1200, repeat: true, wavelength: 12}, + transition: {shape: 'pulse', spread: 'uniform', cycleMs: 300, repeat: false, curve: 'ease-out'}, + completion: {shape: 'tail', spread: 'uniform', cycleMs: 700, repeat: false}, + failure: {shape: 'pulse', spread: 'uniform', cycleMs: 450, repeat: false}, +}; + +export function wrappedPhase(elapsedMs: number, cycleMs: number): number { + if (cycleMs <= 0) return 0; + return ((elapsedMs % cycleMs) + cycleMs) % cycleMs / cycleMs; +} + +/** Waveform value for a phase in [0,1). */ +function waveform(shape: MotionShape, phase: number): number { + switch (shape) { + case 'sine': return (1 + Math.cos(2 * Math.PI * phase)) / 2; + case 'comet': return phase < 0.8 ? phase / 0.8 : 1 - (phase - 0.8) / 0.2; + case 'tail': return phase < 0.15 ? phase / 0.15 : 1 - (phase - 0.15) / 0.85; + case 'pulse': return phase < 0.25 ? applyCurve('ease-out', phase / 0.25) : 1 - applyCurve('ease-in-out', (phase - 0.25) / 0.75); + } +} + +export interface SampleOptions { + /** Hold the profile still. Defaults to the user's reduced-motion setting. */ + reduced?: boolean; +} + +/** + * Intensity 0..1 of `cell` (its index along the text or bar) after `elapsedMs`. + * Reduced motion samples the profile at time zero for every call, so output is + * stable frame to frame. + */ +export function sampleMotion(profile: MotionProfile, elapsedMs: number, cell = 0, options: SampleOptions = {}): number { + const reduced = options.reduced ?? isReducedMotion(); + const elapsed = reduced ? 0 : elapsedMs; + if (!profile.repeat && !reduced && (elapsed < 0 || elapsed >= profile.cycleMs)) return 0; + const clock = profile.repeat ? wrappedPhase(elapsed, profile.cycleMs) : Math.max(0, Math.min(1, elapsed / profile.cycleMs)); + const shift = profile.spread === 'travel' ? cell / (profile.wavelength ?? 12) : 0; + const phase = ((clock - shift) % 1 + 1) % 1; + return applyCurve(profile.curve ?? 'linear', waveform(profile.shape, phase)); +} diff --git a/src/output/TranscriptPanel.ts b/src/output/TranscriptPanel.ts index a1154eba..92c4b7db 100644 --- a/src/output/TranscriptPanel.ts +++ b/src/output/TranscriptPanel.ts @@ -5,7 +5,7 @@ import { } from '../prompt/configuration.js'; import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; import type {Key} from '../terminal/keys.js'; -import {renderControls} from '../ui/controls.js'; +import {DRAFT_PANEL_ACTIONS, renderActionHelp} from '../ui/actions.js'; import {GLYPHS} from '../ui/glyphs.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; @@ -116,7 +116,7 @@ export function renderTranscriptPanel(state: TranscriptPanelState, columns: numb sampleRows.push(` ${SECONDARY}${GLYPHS.prompt} ${command}${RESET}`, ` ${SUBTLE}${output}${RESET}`); } if (state.message) sampleRows.push(`${SECONDARY}${state.message}${RESET}`); - const controls = ['', renderControls([['↑↓', 'move'], ['←→', 'change'], ['Enter', 'save'], ['Esc', 'cancel']])]; + const controls = ['', renderActionHelp(DRAFT_PANEL_ACTIONS)]; // Short terminals keep the editable rows, preview, and controls; the gallery goes first. const includeGallery = out.length + gallery.length + sampleRows.length + controls.length <= rowsAvailable; diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index 1639a2ea..583d29ac 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -1,6 +1,6 @@ import {type StyledLine} from './AnsiOutputParser.js'; import {wrapStyledLine, type WrappedRow} from './viewport.js'; -import {foreground, UI_COLORS} from '../ui/palette.js'; +import {background, foreground, UI_COLORS} from '../ui/palette.js'; import {GLYPHS} from '../ui/glyphs.js'; import {displayWidth, repeatToWidth, stripAnsi, truncateAnsi, truncateText} from '../util/text.js'; import {formatDuration} from '../status/commandTiming.js'; @@ -21,9 +21,9 @@ const SECONDARY = foreground(UI_COLORS.secondary); const SUBTLE = foreground(UI_COLORS.subtle); const RESET = '\u001B[0m'; /** Row surfaces: submitted command rows, hovered and focused disclosure rows. */ -const COMMAND_SURFACE = '\u001B[48;2;38;38;48m'; -const HOVER_SURFACE = '\u001B[48;2;45;45;55m'; -const FOCUS_SURFACE = '\u001B[48;2;60;60;80m'; +const COMMAND_SURFACE = background({red: 38, green: 38, blue: 48}); +const HOVER_SURFACE = background({red: 45, green: 45, blue: 55}); +const FOCUS_SURFACE = background({red: 60, green: 60, blue: 80}); /** Transcript presentation: Normal rows, or Chat with right-aligned command blocks. */ export type TranscriptLayout = 'normal' | 'chat'; @@ -470,7 +470,7 @@ export function renderHistoricalContext(context: HistoricalContextSnapshot, widt } function rgbStyle(foregroundColor?: Rgb, backgroundColor?: Rgb): string { - const fg = foregroundColor ? `\u001B[38;2;${foregroundColor.red};${foregroundColor.green};${foregroundColor.blue}m` : ''; - const bg = backgroundColor ? `\u001B[48;2;${backgroundColor.red};${backgroundColor.green};${backgroundColor.blue}m` : '\u001B[49m'; + const fg = foregroundColor ? foreground(foregroundColor) : ''; + const bg = backgroundColor ? background(backgroundColor) : '\u001B[49m'; return `${fg}${bg}`; } diff --git a/src/presentation/capabilities.ts b/src/presentation/capabilities.ts new file mode 100644 index 00000000..11647d51 --- /dev/null +++ b/src/presentation/capabilities.ts @@ -0,0 +1,16 @@ +/** + * Terminal color capability for NMSh-owned UI. Only explicit signals lower the + * level; without one NMSh keeps its truecolor behavior. Raw PTY output is never + * affected: this applies to colors NMSh itself emits. + */ +export type ColorLevel = 'none' | 'ansi256' | 'truecolor'; + +export function colorLevel(env: NodeJS.ProcessEnv = process.env): ColorLevel { + const override = env.NMSH_COLOR?.toLowerCase(); + if (override === '0' || override === 'none' || override === 'off') return 'none'; + if (override === '256') return 'ansi256'; + if (override === 'truecolor') return 'truecolor'; + if (env.NO_COLOR) return 'none'; + if (env.TERM === 'dumb') return 'none'; + return 'truecolor'; +} diff --git a/src/presentation/environment.ts b/src/presentation/environment.ts new file mode 100644 index 00000000..a42be686 --- /dev/null +++ b/src/presentation/environment.ts @@ -0,0 +1,31 @@ +/** + * Explicit clock helpers for NMSh-owned presentation. This seam must not be + * used for shell events, command durations, timers, or persisted timestamps. + */ +export function isDeterministicPresentation(): boolean { + return process.env.NMSH_DETERMINISTIC === '1'; +} + +/** Stable local wall time for display-only content; ordinary runs use real time. */ +export function presentationNow(): Date { + return isDeterministicPresentation() ? new Date(2026, 0, 1, 9, 41, 0) : new Date(); +} + +/** Keep the real event timestamp unless formatting it for deterministic presentation. */ +export function presentationCompletionTime(completedAt: Date): Date { + return isDeterministicPresentation() ? presentationNow() : completedAt; +} + +/** + * True when NMSh-owned decorative motion should stay still: the user asked for + * reduced motion (NMSH_REDUCED_MOTION=1), or presentation is deterministic. + * Measured durations and shell behavior are never affected. + */ +export function isReducedMotion(): boolean { + return process.env.NMSH_REDUCED_MOTION === '1' || isDeterministicPresentation(); +} + +/** Stable shimmer/activity phase when motion is reduced, without changing measured durations. */ +export function presentationAnimationElapsed(elapsedMs: number): number { + return isReducedMotion() ? 0 : elapsedMs; +} diff --git a/src/status/activity.ts b/src/status/activity.ts index 03a8488f..f9872cb4 100644 --- a/src/status/activity.ts +++ b/src/status/activity.ts @@ -9,13 +9,13 @@ export function activityGlyph(elapsedMs: number): string { return frames[Math.floor(Math.max(0, elapsedMs) / ACTIVITY_GLYPH_INTERVAL_MS) % frames.length]; } -export function liveActivityParts(command: string, elapsedMs: number): {phrase: string; duration: string} { +export function liveActivityParts(command: string, elapsedMs: number, animationElapsedMs = elapsedMs): {phrase: string; duration: string} { let flattenedCommand = command.trim().replace(/\r?\n/g, ' ⏎ '); if (flattenedCommand.length > 50) { flattenedCommand = flattenedCommand.slice(0, 49) + '…'; } return { - phrase: `${activityGlyph(elapsedMs)} Running ${flattenedCommand}`, + phrase: `${activityGlyph(animationElapsedMs)} Running ${flattenedCommand}`, duration: ` · ${formatDuration(elapsedMs)}`, }; } diff --git a/src/status/shimmer.ts b/src/status/shimmer.ts index 3c5e60f5..625ef859 100644 --- a/src/status/shimmer.ts +++ b/src/status/shimmer.ts @@ -1,41 +1,32 @@ import {graphemes} from '../input/inputLayout.js'; +import {mixRgb} from '../chroma/chroma.js'; +import {MOTION_PROFILES, sampleMotion, wrappedPhase} from '../motion/motion.js'; import {foreground, UI_COLORS, type RgbColor} from '../ui/palette.js'; /** Time for the wave to travel one wavelength; independent of text length. */ -export const SHIMMER_CYCLE_MS = 1800; +export const SHIMMER_CYCLE_MS = MOTION_PROFILES.processing.cycleMs; /** Recent output quickens the wave slightly instead of pulsing the whole line. */ -export const ACTIVE_SHIMMER_CYCLE_MS = 1200; +export const ACTIVE_SHIMMER_CYCLE_MS = MOTION_PROFILES.streaming.cycleMs; /** Glyphs per wavelength: neighbours differ by 1/12 of a cycle. */ -export const SHIMMER_WAVELENGTH = 12; +export const SHIMMER_WAVELENGTH = MOTION_PROFILES.processing.wavelength!; /** Luminance stays inside this band so the effect reads as a soft sheen. */ const SHIMMER_FLOOR = 0.12; const SHIMMER_CEILING = 0.9; -export function wrappedPhase(elapsedMs: number, cycleMs: number): number { - if (cycleMs <= 0) return 0; - return ((elapsedMs % cycleMs) + cycleMs) % cycleMs / cycleMs; -} +export {wrappedPhase}; /** * Per-glyph luminance (0 base … 1 peak) of a smooth wave travelling left to - * right. Each glyph is phase-shifted from its neighbour by 1/wavelength, and - * the crest advances wavelength/cycle glyphs per millisecond, so a 100 ms + * right: the `processing` profile, or `streaming` while output is arriving. + * The crest advances wavelength/cycle glyphs per millisecond, so a 100 ms * frame moves it well under one glyph whatever the text length. */ export function shimmerIntensity(elapsedMs: number, glyphIndex: number, _textLength: number, isActive: boolean): number { - const phase = wrappedPhase(elapsedMs, isActive ? ACTIVE_SHIMMER_CYCLE_MS : SHIMMER_CYCLE_MS); - const wave = (1 + Math.cos(2 * Math.PI * (glyphIndex / SHIMMER_WAVELENGTH - phase))) / 2; + const wave = sampleMotion(MOTION_PROFILES[isActive ? 'streaming' : 'processing'], elapsedMs, glyphIndex); return SHIMMER_FLOOR + (SHIMMER_CEILING - SHIMMER_FLOOR) * wave; } -export function interpolateRgb(from: RgbColor, to: RgbColor, amount: number): RgbColor { - const clamped = Math.max(0, Math.min(1, amount)); - return { - red: Math.round(from.red + (to.red - from.red) * clamped), - green: Math.round(from.green + (to.green - from.green) * clamped), - blue: Math.round(from.blue + (to.blue - from.blue) * clamped), - }; -} +export const interpolateRgb = mixRgb; export function shimmerText(text: string, elapsedMs: number, isActive: boolean): string { return shimmerTextWithColors(text, elapsedMs, isActive, UI_COLORS.workingBase, UI_COLORS.workingPeak); diff --git a/src/ui/CommandPalette.ts b/src/ui/CommandPalette.ts index 1aaf23fe..1d64afa1 100644 --- a/src/ui/CommandPalette.ts +++ b/src/ui/CommandPalette.ts @@ -3,7 +3,7 @@ import {NATIVE_PALETTE_IDS} from '../prompt/configuration.js'; import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; import {fuzzyMatch} from '../suggestions/NativeSuggestions.js'; import type {Key} from '../terminal/keys.js'; -import {renderControls} from './controls.js'; +import {renderActionHelp, resolveAction, type UiAction} from './actions.js'; import {foreground, UI_COLORS} from './palette.js'; import {SEARCH_MATCH, SETTINGS_ENTRIES, SETTINGS_ROWS, type SettingsDestination} from './SettingsPanel.js'; import {highlightMatches, truncateAnsi} from '../util/text.js'; @@ -109,10 +109,20 @@ export function filterPalette(state: PaletteState, recent: readonly string[] = [ return scored.sort((a, b) => b.score - a.score || recency(a.item) - recency(b.item)).map(entry => entry.item); } +/** Run needs a result to run; move needs more than nothing to move over. */ +export function paletteActions(resultCount: number): UiAction[] { + return [ + {id: 'search', label: 'search', keyLabel: 'type', kinds: []}, + {id: 'move', label: 'move', keyLabel: '↑↓', kinds: ['up', 'down'], enabled: resultCount > 1}, + {id: 'run', label: 'run', keyLabel: 'Enter', kinds: ['enter'], enabled: resultCount > 0}, + {id: 'close', label: 'close', keyLabel: 'Esc', kinds: ['escape']}, + ]; +} + /** Returns the chosen item on Enter; edits the query and selection otherwise. */ export function handlePaletteKey(key: Key, state: PaletteState, recent: readonly string[] = []): PaletteItem | 'changed' | undefined { const visible = filterPalette(state, recent); - if (key.kind === 'enter') return visible[Math.min(state.selectedIndex, visible.length - 1)]; + if (resolveAction(paletteActions(visible.length), key)?.id === 'run') return visible[Math.min(state.selectedIndex, visible.length - 1)]; if (key.kind === 'up') state.selectedIndex = visible.length ? (state.selectedIndex - 1 + visible.length) % visible.length : 0; else if (key.kind === 'down') state.selectedIndex = visible.length ? (state.selectedIndex + 1) % visible.length : 0; else if (key.kind === 'text' || key.kind === 'paste') { state.query += key.value.replace(/[\r\n]/gu, ''); state.selectedIndex = 0; state.viewportStart = 0; } @@ -142,6 +152,6 @@ export function renderPalette(state: PaletteState, columns: number, rowsAvailabl const label = state.query ? highlightMatches(item.label, state.query, active ? ACCENT : SECONDARY, SEARCH_MATCH) : item.label; rows.push(`${active ? `${ACCENT}›` : ' '} ${active ? ACCENT : SECONDARY}${label}${RESET} ${SUBTLE}${item.category} · ${item.detail}${RESET}`); }); - rows.push('', renderControls([['type', 'search'], ['↑↓', 'move'], ['Enter', 'run'], ['Esc', 'close']])); + rows.push('', renderActionHelp(paletteActions(visible.length))); return rows.map(row => truncateAnsi(row, columns)); } diff --git a/src/ui/LayoutPanel.ts b/src/ui/LayoutPanel.ts index a30fcd3c..80149814 100644 --- a/src/ui/LayoutPanel.ts +++ b/src/ui/LayoutPanel.ts @@ -11,7 +11,7 @@ import { import {completedActivity, liveActivityParts} from '../status/activity.js'; import type {Key} from '../terminal/keys.js'; import {truncateAnsi, repeatToWidth} from '../util/text.js'; -import {renderControls} from './controls.js'; +import {DRAFT_PANEL_ACTIONS, renderActionHelp} from './actions.js'; import {GLYPHS} from './glyphs.js'; import {foreground, UI_COLORS} from './palette.js'; @@ -159,7 +159,7 @@ export function renderLayoutPanel(state: LayoutPanelState, columns: number, rows const selected = index === state.selectedIndex; out.push(`${selected ? `${ACCENT}›` : ' '} ${selected ? ACCENT : SECONDARY}${labels[row]}${RESET}`); }); - const controls = ['', renderControls([['↑↓', 'move'], ['←→', 'change'], ['Enter', 'save'], ['Esc', 'cancel']])]; + const controls = ['', renderActionHelp(DRAFT_PANEL_ACTIONS)]; const header = ['', `${PRIMARY}Preview${RESET} ${layoutDraftChanged(state) ? `${ACCENT}unsaved preview` : `${SUBTLE}matches current`}${RESET} ${SUBTLE}sample content; nothing runs${RESET}`, ` ${SUBTLE}${POSITION_CAPTIONS[draft.composerPosition]}${RESET}`]; const message = state.message ? [`${SECONDARY}${state.message}${RESET}`] : []; diff --git a/src/ui/PanelShell.ts b/src/ui/PanelShell.ts index 5ee174b8..40fea4ca 100644 --- a/src/ui/PanelShell.ts +++ b/src/ui/PanelShell.ts @@ -1,6 +1,7 @@ import {background, foreground, UI_COLORS} from './palette.js'; -import {repeatToWidth, truncateAnsi, displayWidth} from '../util/text.js'; -import {GLYPHS} from './glyphs.js'; +import {truncateAnsi, displayWidth} from '../util/text.js'; +import {theme} from '../chroma/chroma.js'; +import {renderSurface} from './surface.js'; const RESET = '\u001B[0m'; const BOLD = '\u001B[1m'; @@ -51,5 +52,5 @@ export function renderTabStrip(tabs: readonly string[], selected: number, column /** Framing belongs to the live overlay, never to OutputBuffer or an archive. */ export function framePanel(rows: string[], columns: number): string[] { - return [`${foreground(UI_COLORS.separator)}${repeatToWidth(GLYPHS.separator, Math.max(1, columns))}${RESET}`, ...rows]; + return renderSurface(rows, columns, {frame: 'topLine', frameColor: theme('separator')}); } diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 7013209c..f6382250 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -14,9 +14,11 @@ import {providerLabel} from '../prompt/PromptPanel.js'; import {welcomeProvider} from '../output/WelcomeProviders.js'; import {PROMPT_STYLES, PROMPT_STYLE_LABELS} from '../prompt/powerline.js'; import {SUGGESTION_PROVIDERS} from '../suggestions/types.js'; +import {foregroundOf, status, theme} from '../chroma/chroma.js'; import {foreground, UI_COLORS} from './palette.js'; import {GLYPHS, getCurrentGlyphMode} from './glyphs.js'; import {framePanel, renderTabStrip} from './PanelShell.js'; +import {stepIndex, toggleValue} from './formControls.js'; import {displayWidth, highlightMatches, truncateAnsi} from '../util/text.js'; /** The three top-level views of the one shared panel behind /settings, /config, and /status. */ @@ -38,6 +40,8 @@ export interface SettingsPanelState { searchQuery?: string; /** Only `/` focuses search; while focused, typing edits the query. */ searchFocused?: boolean; + /** Config also lists advanced rows. */ + showAdvanced?: boolean; glyphStyle: GlyphStyle; onboarding: boolean; } @@ -63,6 +67,8 @@ interface SettingsRowBase { description: string; /** Search also matches the area a row belongs to. */ category: string; + /** Advanced rows stay out of the default Config list until the user asks for them (or searches). */ + level?: 'advanced'; } /** @@ -103,18 +109,18 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ control: 'child', destination: 'prompt', value: config => providerLabel(config.provider)}, {id: 'divider', label: 'History divider', description: 'Rule drawn above each past command', category: 'Transcript', control: 'boolean', get: config => config.transcript.divider, set: (config, divider) => withTranscript(config, {divider})}, - enumRow({id: 'dividerDensity', label: 'Divider density', description: 'Spacing around history dividers', category: 'Transcript', + enumRow({id: 'dividerDensity', level: 'advanced', label: 'Divider density', description: 'Spacing around history dividers', category: 'Transcript', values: DENSITIES, labels: ['Normal', 'Compact'], get: config => config.transcript.dividerDensity, set: (config, dividerDensity) => withTranscript(config, {dividerDensity})}), {id: 'historicalPrompt', label: 'Prompt snapshots', description: 'Show the prompt each past command ran under', category: 'Transcript', control: 'boolean', get: config => config.transcript.historicalPrompt, set: (config, historicalPrompt) => withTranscript(config, {historicalPrompt})}, - enumRow({id: 'historyColors', label: 'History colors', description: 'How past prompt snapshots are colored', category: 'Transcript', + enumRow({id: 'historyColors', level: 'advanced', label: 'History colors', description: 'How past prompt snapshots are colored', category: 'Transcript', values: COLOR_MODES, labels: ['Follow prompt', 'Theme', 'Grayscale'], get: config => config.transcript.historyColors, set: (config, historyColors) => withTranscript(config, {historyColors})}), {id: 'syntaxHighlighting', label: 'Syntax highlighting', description: 'Color commands while typing and in new history', category: 'Syntax', control: 'boolean', get: config => config.syntax.highlighting, set: (config, highlighting) => ({...config, syntax: {...config.syntax, highlighting}})}, - enumRow({id: 'syntaxColors', label: 'Syntax colors', description: 'Follow prompt theme, a chosen theme, or grayscale', category: 'Syntax', + enumRow({id: 'syntaxColors', level: 'advanced', label: 'Syntax colors', description: 'Follow prompt theme, a chosen theme, or grayscale', category: 'Syntax', values: COLOR_MODES, labels: ['Follow prompt', 'Theme', 'Grayscale'], get: config => config.syntax.colors, set: (config, colors) => ({...config, syntax: {...config.syntax, colors}})}), enumRow({id: 'promptStyle', label: 'Prompt style', description: 'NMSh Native look: Powerline, Soft, Minimal, or Outline', category: 'Prompt', @@ -126,7 +132,7 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'transcriptPresentation', label: 'Transcript presentation', description: 'Normal rows, or Chat with commands on the right', category: 'Layout', values: TRANSCRIPT_PRESENTATIONS, labels: TRANSCRIPT_PRESENTATIONS.map(presentation => TRANSCRIPT_PRESENTATION_LABELS[presentation]), get: config => config.transcriptPresentation, set: (config, transcriptPresentation) => ({...config, transcriptPresentation})}), - enumRow({id: 'outputFolding', label: 'Output folding', description: 'Off, Smart (long repetitive successes), or Always (every long block)', category: 'Transcript', + enumRow({id: 'outputFolding', level: 'advanced', label: 'Output folding', description: 'Off, Smart (long repetitive successes), or Always (every long block)', category: 'Transcript', values: OUTPUT_FOLDING_MODES, labels: ['Off', 'Smart', 'Always'], get: config => config.outputFolding, set: (config, outputFolding) => ({...config, outputFolding})}), enumRow({id: 'updateChecks', label: 'Update checks', description: 'Quietly check GitHub for new releases; /update checks on demand', category: 'Updates', @@ -135,7 +141,7 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'liveSessionStartup', label: 'Startup restore', description: 'Resume a detached live session at launch: Ask, Always, or Never (never ends none)', category: 'Sessions', values: LIVE_SESSION_STARTUP, labels: ['Ask', 'Always', 'Never'], get: config => config.liveSessionStartup, set: (config, liveSessionStartup) => ({...config, liveSessionStartup})}), - enumRow({id: 'liveSessionMultiple', label: 'Multiple detached sessions', description: 'At launch with several: ask which, or open all in new windows', category: 'Sessions', + enumRow({id: 'liveSessionMultiple', level: 'advanced', label: 'Multiple detached sessions', description: 'At launch with several: ask which, or open all in new windows', category: 'Sessions', values: LIVE_SESSION_MULTIPLE, labels: ['Ask which', 'Open all'], get: config => config.liveSessionMultiple, set: (config, liveSessionMultiple) => ({...config, liveSessionMultiple})}), {id: 'welcome', label: 'Welcome provider', description: 'What new sessions show first: Vespyr, Fastfetch, Neofetch, or None', category: 'Welcome', @@ -143,7 +149,7 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ {id: 'suggestions', label: 'Suggestions', description: 'Ghost-text prediction provider: NMSh Native, Deja, or None', category: 'Suggestions', control: 'child', destination: 'suggestions', value: config => SUGGESTION_PROVIDERS.find(provider => provider.id === config.suggestions)?.label ?? config.suggestions}, - {id: 'suggestionsOnEmpty', label: 'Empty-prompt prediction', description: 'Suggest the likely next command before typing', category: 'Suggestions', + {id: 'suggestionsOnEmpty', level: 'advanced', label: 'Empty-prompt prediction', description: 'Suggest the likely next command before typing', category: 'Suggestions', control: 'boolean', get: config => config.suggestionsOnEmpty, set: (config, suggestionsOnEmpty) => ({...config, suggestionsOnEmpty})}, ]; @@ -160,6 +166,9 @@ export const SETTINGS_ENTRIES: readonly SettingsRow[] = [ {id: 'layout', label: 'Layout', description: 'Preview and choose composer position and transcript presentation', category: 'Layout', control: 'child', destination: 'layout'}, ]; +/** 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; export function visibleSettingsRows(state: SettingsPanelState): SettingsRow[] { @@ -167,7 +176,7 @@ export function visibleSettingsRows(state: SettingsPanelState): SettingsRow[] { if (view === 'status') return []; if (view === 'settings') return [...SETTINGS_ENTRIES]; const query = state.searchQuery?.trim().toLowerCase(); - if (!query) return [...SETTINGS_ROWS]; + if (!query) return SETTINGS_ROWS.filter(row => state.showAdvanced || row.level !== 'advanced'); return SETTINGS_ROWS.filter(row => [row.label, row.description, row.category].some(text => text.toLowerCase().includes(query))); } @@ -185,9 +194,9 @@ export function isInlineEditable(row: SettingsRow | undefined): boolean { /** ←/→ on an enum or boolean row; undefined when the row has nothing to change inline. */ export function adjustSettingsRow(row: SettingsRow, config: PromptConfiguration, delta: -1 | 1): PromptConfiguration | undefined { - if (row.control === 'boolean') return row.set(config, !row.get(config)); + if (row.control === 'boolean') return row.set(config, toggleValue(row.get(config))); if (row.control !== 'enum') return undefined; - return row.select(config, (row.index(config) + delta + row.options.length) % row.options.length); + return row.select(config, stepIndex(row.options.length, row.index(config), delta)); } /** Enter/Space: toggle a boolean or step an enum forward. */ @@ -195,6 +204,25 @@ export function toggleSettingsRow(row: SettingsRow, config: PromptConfiguration) return adjustSettingsRow(row, config, 1); } +/** True when an inline-editable row differs from the shipped default. */ +export function settingsRowChanged(row: SettingsRow, config: PromptConfiguration): boolean { + if (row.control === 'enum') return row.index(config) !== row.index(DEFAULT_PROMPT_CONFIGURATION); + if (row.control === 'boolean') return row.get(config) !== row.get(DEFAULT_PROMPT_CONFIGURATION); + return false; +} + +/** The row's default display value, for "reset to ..." help. */ +export function settingsRowDefaultLabel(row: SettingsRow): string | undefined { + return settingsRowValue(row, DEFAULT_PROMPT_CONFIGURATION); +} + +/** Configuration with only this row reset to its default; undefined when the row has no inline value. */ +export function resetSettingsRow(row: SettingsRow, config: PromptConfiguration): PromptConfiguration | undefined { + if (row.control === 'enum') return row.select(config, row.index(DEFAULT_PROMPT_CONFIGURATION)); + if (row.control === 'boolean') return row.set(config, row.get(DEFAULT_PROMPT_CONFIGURATION)); + return undefined; +} + /** Enter on a row that opens something. */ export function settingsRowDestination(row: SettingsRow): SettingsDestination | undefined { return row.control === 'child' || row.control === 'action' ? row.destination : undefined; @@ -295,22 +323,24 @@ function renderRows(rows: readonly SettingsRow[], selected: number | undefined, return out; } -function footerText(state: SettingsPanelState, row: SettingsRow | undefined): string { +function footerText(state: SettingsPanelState, row: SettingsRow | undefined, changedDefault?: string): string { const view = settingsView(state); if (state.searchFocused) return '↑↓ results · Enter select · Esc clear'; if (view === 'status') return '←/→ to switch · ↑↓ to scroll · Esc to close'; if (state.focus === 'tabs') return '←/→ to switch · ↓ to select · Esc to close'; const search = view === 'config' ? ' · / to search' : ''; const escape = state.searchQuery?.trim() ? 'Esc to clear' : 'Esc to close'; - if (isInlineEditable(row)) return `Enter/Space to change${search} · ${escape}`; - if (row) return `Enter to open · ←/→ to switch${search} · ${escape}`; - return `←/→ to switch${search} · ${escape}`; + const advanced = view === 'config' && !state.searchQuery?.trim() ? ` · A ${state.showAdvanced ? 'hide' : 'show'} advanced` : ''; + const reset = changedDefault !== undefined ? ` · R reset to ${changedDefault}` : ''; + if (isInlineEditable(row)) return `Enter/Space to change${reset}${search}${advanced} · ${escape}`; + if (row) return `Enter to open · ←/→ to switch${search}${advanced} · ${escape}`; + return `←/→ to switch${search}${advanced} · ${escape}`; } function toneColor(tone: StatusItem['tone']): string { - return tone === 'success' ? foreground(UI_COLORS.success) - : tone === 'warning' ? foreground(UI_COLORS.failure) - : tone === 'muted' ? SUBTLE : PRIMARY; + return foregroundOf(tone === 'success' ? status('success') + : tone === 'warning' ? status('failure') + : theme(tone === 'muted' ? 'subtle' : 'primary')); } function statusLines(sections: StatusSections, columns: number): string[] { @@ -356,7 +386,8 @@ export function renderSettingsPanel(state: SettingsPanelState, columns: number, const rows = visibleSettingsRows(state); const selectedIndex = Math.min(state.contentIndex ?? 0, Math.max(0, rows.length - 1)); const selectedRow = tabsFocused ? undefined : rows[selectedIndex]; - const footer = ['', `${MARGIN}${SUBTLE}${footerText(state, selectedRow)}${RESET}`]; + const changedDefault = view === 'config' && selectedRow && settingsRowChanged(selectedRow, config) ? settingsRowDefaultLabel(selectedRow) : undefined; + const footer = ['', `${MARGIN}${SUBTLE}${footerText(state, selectedRow, changedDefault)}${RESET}`]; // Separator + header + footer; short terminals drop the footer first. const chrome = 1 + header.length + footer.length; const tight = maxRows - chrome < 3; @@ -379,7 +410,7 @@ export function renderSettingsPanel(state: SettingsPanelState, columns: number, const listBudget = Math.max(1, available - 3); if (rows.length) { body.push(...renderRows(rows, tabsFocused ? undefined : selectedIndex, columns, query, - row => settingsRowValue(row, config) ?? '', listBudget)); + row => `${settingsRowValue(row, config) ?? ''}${settingsRowChanged(row, config) ? ` ${CHANGED_MARK()}` : ''}`, listBudget)); if (selectedRow && listBudget - rows.length >= 2) { body.push('', `${MARGIN} ${highlightMatches(selectedRow.description, query, SUBTLE, SEARCH_MATCH)}${RESET}`); } diff --git a/src/ui/actions.ts b/src/ui/actions.ts new file mode 100644 index 00000000..b63957dd --- /dev/null +++ b/src/ui/actions.ts @@ -0,0 +1,45 @@ +import type {Key} from '../terminal/keys.js'; +import {renderControls} from './controls.js'; + +/** + * A panel action: one place that says what the action is called, which keys + * trigger it and whether it currently applies. Panel footers are derived from + * the actions that are enabled, so help cannot drift from the real bindings. + * Every action is keyboard-reachable; pointer input may only add shortcuts. + */ +export interface UiAction { + readonly id: string; + readonly label: string; + /** Key as shown in help, e.g. `↑↓`. */ + readonly keyLabel: string; + /** Key kinds (from the input parser) that trigger the action. Empty for display-only entries such as free typing. */ + readonly kinds: readonly Key['kind'][]; + /** Defaults to enabled; disabled actions are neither dispatched nor shown in help. */ + readonly enabled?: boolean; +} + +export function enabledActions(actions: readonly UiAction[]): UiAction[] { + return actions.filter(action => action.enabled !== false); +} + +/** The enabled action bound to this key, if any. */ +export function resolveAction(actions: readonly UiAction[], key: Key): UiAction | undefined { + return enabledActions(actions).find(action => action.kinds.includes(key.kind)); +} + +export function actionControls(actions: readonly UiAction[]): Array<[string, string]> { + return enabledActions(actions).map(action => [action.keyLabel, action.label]); +} + +/** Footer row for the actions currently available in a surface. */ +export function renderActionHelp(actions: readonly UiAction[]): string { + return renderControls(actionControls(actions)); +} + +/** Shared by the draft-and-save panels (layout, syntax, transcript): move between rows, change a value, save or cancel. */ +export const DRAFT_PANEL_ACTIONS: readonly UiAction[] = [ + {id: 'move', label: 'move', keyLabel: '↑↓', kinds: ['up', 'down']}, + {id: 'change', label: 'change', keyLabel: '←→', kinds: ['left', 'right']}, + {id: 'save', label: 'save', keyLabel: 'Enter', kinds: ['enter']}, + {id: 'cancel', label: 'cancel', keyLabel: 'Esc', kinds: ['escape']}, +]; diff --git a/src/ui/formControls.ts b/src/ui/formControls.ts new file mode 100644 index 00000000..794eeb45 --- /dev/null +++ b/src/ui/formControls.ts @@ -0,0 +1,144 @@ +import type {Key} from '../terminal/keys.js'; +import {foreground, UI_COLORS} from './palette.js'; +import {displayWidth, truncateAnsi} from '../util/text.js'; + +/** + * Terminal-native form controls. A control never stores or writes anything: + * key handlers return the value the user is proposing (or undefined when the + * key means nothing here) and the owning feature decides whether to persist it. + * Rendering has a plain path (`color: false`) where focus, changed state and + * errors are carried by text, not by color. + */ + +// ---- Behavior ------------------------------------------------------------- + +export function toggleValue(current: boolean): boolean { + return !current; +} + +/** Index after moving `delta` steps through `count` options, wrapping at both ends. */ +export function stepIndex(count: number, index: number, delta: number): number { + if (count <= 0) return 0; + return (((index + delta) % count) + count) % count; +} + +/** ←/→ (and Space/Enter as "next") on a select: the proposed option index. */ +export function handleSelectKey(key: Key, count: number, index: number): number | undefined { + if (key.kind === 'left') return stepIndex(count, index, -1); + if (key.kind === 'right' || key.kind === 'enter' || (key.kind === 'text' && key.value === ' ')) return stepIndex(count, index, 1); + return undefined; +} + +/** Multi-select proposal: `selected` with `index` flipped, kept in option order. */ +export function toggleMember(selected: readonly number[], index: number): number[] { + return selected.includes(index) ? selected.filter(value => value !== index) : [...selected, index].sort((a, b) => a - b); +} + +export interface MultiSelectState {cursor: number; selected: readonly number[]} + +/** ↑↓ move the cursor; Space flips the option under it. Returns the next state, or undefined for other keys. */ +export function handleMultiSelectKey(key: Key, count: number, state: MultiSelectState): MultiSelectState | undefined { + if (key.kind === 'up') return {...state, cursor: stepIndex(count, state.cursor, -1)}; + if (key.kind === 'down') return {...state, cursor: stepIndex(count, state.cursor, 1)}; + if (key.kind === 'text' && key.value === ' ') return {...state, selected: toggleMember(state.selected, state.cursor)}; + return undefined; +} + +/** Single-line text editing at the end of the value; the proposed text, or undefined for other keys. */ +export function editText(value: string, key: Key): string | undefined { + if (key.kind === 'text' || key.kind === 'paste') return value + key.value.replace(/[\r\n]/gu, ''); + if (key.kind === 'backspace') return [...value].slice(0, -1).join(''); + if (key.kind === 'deleteWord' || key.kind === 'deleteLineBefore') return ''; + return undefined; +} + +export interface ConfirmState {choice: 'yes' | 'no'} + +/** Confirmation starts on "no"; Enter accepts the highlighted choice, Esc always cancels. */ +export function createConfirm(): ConfirmState { + return {choice: 'no'}; +} + +export function handleConfirmKey(key: Key, state: ConfirmState): 'confirm' | 'cancel' | 'changed' | undefined { + if (key.kind === 'escape') return 'cancel'; + if (key.kind === 'enter') return state.choice === 'yes' ? 'confirm' : 'cancel'; + if (key.kind === 'left' || key.kind === 'right' || key.kind === 'up' || key.kind === 'down') { + state.choice = state.choice === 'yes' ? 'no' : 'yes'; + return 'changed'; + } + if (key.kind === 'text') { + const value = key.value.toLowerCase(); + if (value === 'y') { state.choice = 'yes'; return 'confirm'; } + if (value === 'n') { state.choice = 'no'; return 'cancel'; } + } + return undefined; +} + +// ---- Presentation --------------------------------------------------------- + +export interface ControlLook { + focused?: boolean; + /** False for a plain path with no escape sequences. */ + color?: boolean; +} + +const ACCENT = foreground(UI_COLORS.accent); +const SECONDARY = foreground(UI_COLORS.secondary); +const SUBTLE = foreground(UI_COLORS.subtle); +const ERROR = foreground(UI_COLORS.failure); +const INVERSE = '\u001B[7m'; +const RESET = '\u001B[0m'; + +function tint(text: string, look: ControlLook): string { + return look.color === false ? text : `${look.focused ? ACCENT : SECONDARY}${text}${RESET}`; +} + +export function renderToggle(value: boolean, look: ControlLook = {}): string { + return tint(`[${value ? 'x' : ' '}] ${value ? 'On' : 'Off'}`, look); +} + +export function renderSelect(optionLabel: string, look: ControlLook = {}): string { + return tint(`‹ ${optionLabel} ›`, look); +} + +export function renderMultiSelect(options: readonly string[], selected: readonly number[], cursor: number | undefined, look: ControlLook = {}): string { + return options.map((option, index) => { + const marked = `${cursor === index && look.focused ? '>' : ' '}[${selected.includes(index) ? 'x' : ' '}] ${option}`; + return tint(marked, {...look, focused: look.focused && cursor === index}); + }).join(' '); +} + +export function renderTextValue(value: string, placeholder: string, look: ControlLook = {}): string { + const cursor = look.focused ? (look.color === false ? '_' : `${INVERSE} ${RESET}`) : ''; + if (!value) return look.color === false ? `${cursor}${placeholder}` : `${cursor}${SUBTLE}${placeholder}${RESET}`; + return look.color === false ? `${value}${cursor}` : `${tint(value, look)}${cursor}`; +} + +export function renderConfirm(state: ConfirmState, look: ControlLook = {}): string { + const choice = (id: 'yes' | 'no', label: string) => (state.choice === id ? `[ ${label} ]` : ` ${label} `); + return tint(`${choice('yes', 'Yes')} ${choice('no', 'No')}`, look); +} + +export interface FieldFrame { + label: string; + /** Already rendered by one of the render* functions above. */ + control: string; + description?: string; + /** The proposal differs from the saved value. */ + changed?: boolean; + /** Set when the proposal is invalid; shown as text, not only color. */ + error?: string; + focused?: boolean; + color?: boolean; +} + +/** Label row, then description and error rows when present. Focus is a `>` marker; changed is `(changed)`. */ +export function renderField(field: FieldFrame, columns: number): string[] { + const plain = field.color === false; + const pointer = field.focused ? (plain ? '>' : `${ACCENT}›${RESET}`) : ' '; + const changed = field.changed ? (plain ? ' (changed)' : ` ${SUBTLE}(changed)${RESET}`) : ''; + const rows = [`${pointer} ${field.label} ${field.control}${changed}`]; + if (field.description) rows.push(plain ? ` ${field.description}` : ` ${SUBTLE}${field.description}${RESET}`); + if (field.error) rows.push(plain ? ` Error: ${field.error}` : ` ${ERROR}Error: ${field.error}${RESET}`); + return rows.map(row => (plain && displayWidth(row) > columns ? [...row].slice(0, columns).join('') : truncateAnsi(row, columns))); +} diff --git a/src/ui/palette.ts b/src/ui/palette.ts index dd521847..310770d9 100644 --- a/src/ui/palette.ts +++ b/src/ui/palette.ts @@ -1,3 +1,5 @@ +import {colorEscape} from '../chroma/escape.js'; + export interface RgbColor { red: number; green: number; @@ -26,10 +28,11 @@ export const UI_COLORS = { selection: {red: 88, green: 96, blue: 145}, } as const satisfies Record; +/** Color escapes follow the terminal capability: none, 256-color or truecolor (see presentation/capabilities). */ export function foreground(color: RgbColor): string { - return `\u001B[38;2;${color.red};${color.green};${color.blue}m`; + return colorEscape(38, color); } export function background(color: RgbColor): string { - return `\u001B[48;2;${color.red};${color.green};${color.blue}m`; + return colorEscape(48, color); } diff --git a/src/ui/surface.ts b/src/ui/surface.ts new file mode 100644 index 00000000..42de65ee --- /dev/null +++ b/src/ui/surface.ts @@ -0,0 +1,150 @@ +import { + backgroundOf, foregroundOf, gradientCells, resolveColor, sampleGradient, type ColorRef, type Gradient, +} from '../chroma/chroma.js'; +import {colorEscape} from '../chroma/escape.js'; +import {graphemes} from '../input/inputLayout.js'; +import {displayWidth, repeatToWidth, truncateAnsi} from '../util/text.js'; +import {getCurrentGlyphMode} from './glyphs.js'; + +/** + * Surface geometry: where a panel's cells are (frame, padding, inset, width, + * alignment). Which colors those cells get is Chroma's job; a surface only + * holds color references. Frame and fill are independent. + */ +export type FrameStyle = 'none' | 'topLine' | 'square' | 'rounded' | 'double' | 'heavy'; +/** A single color reference, or a left-to-right gradient. */ +export type Paint = ColorRef | Gradient; + +export interface SurfaceSpec { + frame?: FrameStyle; + frameColor?: Paint; + fill?: Paint; + /** Blank columns/rows between the frame and the content. */ + padding?: {x?: number; y?: number}; + /** Columns left of the surface. */ + inset?: number; + /** `fill` spans the available width; `content` hugs the widest row. */ + width?: 'fill' | 'content'; + /** Where a `content`-width surface sits in the available width. */ + align?: 'left' | 'center' | 'right'; +} + +const RESET = '\u001B[0m'; +const SGR = /^\u001B\[[0-9;]*m/u; + +interface Box {tl: string; tr: string; bl: string; br: string; h: string; v: string} +const BOXES: Record<'square' | 'rounded' | 'double' | 'heavy', Box> = { + square: {tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│'}, + rounded: {tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│'}, + double: {tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║'}, + heavy: {tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃'}, +}; +const SAFE_BOX: Box = {tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|'}; + +function isGradient(paint: Paint): paint is Gradient { + return 'stops' in paint; +} + +function boxFor(frame: 'square' | 'rounded' | 'double' | 'heavy'): Box { + return getCurrentGlyphMode() === 'nerd' ? BOXES[frame] : SAFE_BOX; +} + +/** Foreground escape for cell `index` of `count` across a frame edge. */ +function edgeColor(paint: Paint | undefined, index: number, count: number): string { + if (!paint) return ''; + if (!isGradient(paint)) return foregroundOf(paint); + return colorEscape(38, sampleGradient(paint, count <= 1 ? 0 : index / (count - 1))); +} + +/** Background escape for a column of a filled row. */ +function cellFill(fill: Paint, column: number, width: number): string { + return isGradient(fill) + ? colorEscape(48, gradientCells(fill, Math.max(1, width))[Math.min(column, Math.max(0, width - 1))]!) + : backgroundOf(fill); +} + +/** Paints `row` (already ANSI-styled) onto a fill, keeping the fill behind every glyph and restoring it after resets. */ +function fillRow(row: string, fill: Paint, width: number, startColumn: number): string { + let out = ''; + let column = startColumn; + let index = 0; + let plainRun = ''; + const flush = () => { + for (const glyph of graphemes(plainRun)) { + out += `${cellFill(fill, column, width)}${glyph}`; + column += displayWidth(glyph); + } + plainRun = ''; + }; + while (index < row.length) { + const match = row[index] === '\u001B' ? SGR.exec(row.slice(index)) : null; + if (match) { flush(); out += match[0]; index += match[0].length; continue; } + const codePoint = row.codePointAt(index)!; + const character = String.fromCodePoint(codePoint); + plainRun += character; + index += character.length; + } + flush(); + return `${out}${RESET}`; +} + +/** A run of `glyph` across `count` cells starting at edge position `from` of `width`; solid paints emit one escape. */ +function run(glyph: string, count: number, from: number, width: number, frameColor: Paint | undefined): string { + if (count <= 0) return ''; + if (frameColor && isGradient(frameColor)) { + return Array.from({length: count}, (_, offset) => `${edgeColor(frameColor, from + offset, width)}${glyph}`).join(''); + } + return `${edgeColor(frameColor, 0, 1)}${repeatToWidth(glyph, count)}`; +} + +function ruleRow(left: string, glyph: string, right: string, width: number, frameColor: Paint | undefined): string { + if (!left) return `${run(glyph, width, 0, width, frameColor)}${RESET}`; + return `${run(left, 1, 0, width, frameColor)}${run(glyph, width - 2, 1, width, frameColor)}${run(right, 1, width - 1, width, frameColor)}${RESET}`; +} + +/** + * Lays `rows` out as a surface within `columns`. Narrow widths degrade in a + * fixed order: padding first, then boxed frames fall back to a top line. + * With no fill, padding or side frame, rows pass through unchanged. + */ +export function renderSurface(rows: readonly string[], columns: number, spec: SurfaceSpec = {}): string[] { + const total = Math.max(1, columns); + const inset = Math.max(0, Math.min(spec.inset ?? 0, total - 1)); + const available = total - inset; + let frame: FrameStyle = spec.frame ?? 'none'; + let padX = Math.max(0, spec.padding?.x ?? 0); + const padY = Math.max(0, spec.padding?.y ?? 0); + const widest = Math.max(0, ...rows.map(row => displayWidth(row))); + const sides = () => (frame === 'square' || frame === 'rounded' || frame === 'double' || frame === 'heavy' ? 2 : 0); + + let width = spec.width === 'content' ? Math.min(available, widest + 2 * padX + sides()) : available; + while (width - sides() - 2 * padX < 1) { + if (padX > 0) padX -= 1; + else if (sides() > 0) frame = 'topLine'; + else break; + } + if (spec.width === 'content') width = Math.min(available, widest + 2 * padX + sides()); + const inner = Math.max(1, width - sides() - 2 * padX); + const align = spec.align ?? 'left'; + const slack = available - width; + const lead = ' '.repeat(inset + (align === 'center' ? Math.floor(slack / 2) : align === 'right' ? slack : 0)); + + const needsWidth = spec.fill !== undefined || sides() > 0; + const boxed = sides() > 0; + const box = boxed ? boxFor(frame as 'square') : undefined; + const body = [...Array(padY).fill(''), ...rows, ...Array(padY).fill('')].map((row, index) => { + const isBlank = index < padY || index >= padY + rows.length; + const content = truncateAnsi(isBlank ? '' : row, inner); + if (!needsWidth && padX === 0) return `${lead}${content}`; + const middle = `${' '.repeat(padX)}${content}${' '.repeat(padX + Math.max(0, inner - displayWidth(content)))}`; + const painted = spec.fill ? fillRow(middle, spec.fill, width, boxed ? 1 : 0) : middle; + if (!box) return `${lead}${painted}`; + return `${lead}${edgeColor(spec.frameColor, 0, width)}${box.v}${RESET}${painted}${edgeColor(spec.frameColor, width - 1, width)}${box.v}${RESET}`; + }); + + const topGlyph = getCurrentGlyphMode() === 'nerd' ? '─' : '-'; + const top = box ? [`${lead}${ruleRow(box.tl, box.h, box.tr, width, spec.frameColor)}`] + : frame === 'topLine' ? [`${lead}${ruleRow('', topGlyph, '', width, spec.frameColor)}`] : []; + const bottom = box ? [`${lead}${ruleRow(box.bl, box.h, box.br, width, spec.frameColor)}`] : []; + return [...top, ...body, ...bottom]; +} diff --git a/tests/accessibilityBaseline.test.ts b/tests/accessibilityBaseline.test.ts new file mode 100644 index 00000000..7d9b5e70 --- /dev/null +++ b/tests/accessibilityBaseline.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {colorLevel} from '../src/presentation/capabilities.js'; +import {isReducedMotion, presentationAnimationElapsed} from '../src/presentation/environment.js'; +import {background, foreground, UI_COLORS} from '../src/ui/palette.js'; +import {shimmerText} from '../src/status/shimmer.js'; +import {GLYPHS} from '../src/ui/glyphs.js'; + +function withEnv(patch: Record, run: () => T): T { + const saved = Object.fromEntries(Object.keys(patch).map(name => [name, process.env[name]])); + for (const [name, value] of Object.entries(patch)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + try { return run(); } finally { + for (const [name, value] of Object.entries(saved)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + } +} +const CLEAN = {NO_COLOR: undefined, NMSH_COLOR: undefined, NMSH_REDUCED_MOTION: undefined, NMSH_DETERMINISTIC: undefined, TERM: 'xterm-256color'}; + +test('color level only drops on explicit signals', () => { + assert.equal(colorLevel({TERM: 'xterm-256color'}), 'truecolor'); + assert.equal(colorLevel({}), 'truecolor'); + assert.equal(colorLevel({NO_COLOR: '1'}), 'none'); + assert.equal(colorLevel({NO_COLOR: ''}), 'truecolor'); + assert.equal(colorLevel({TERM: 'dumb'}), 'none'); + assert.equal(colorLevel({NO_COLOR: '1', NMSH_COLOR: 'truecolor'}), 'truecolor'); + assert.equal(colorLevel({NMSH_COLOR: 'none'}), 'none'); +}); + +test('palette helpers emit no color escapes under NO_COLOR', () => { + withEnv({...CLEAN}, () => assert.match(foreground(UI_COLORS.accent), /^\u001B\[38;2;/u)); + withEnv({...CLEAN, NO_COLOR: '1'}, () => { + assert.equal(foreground(UI_COLORS.accent), ''); + assert.equal(background(UI_COLORS.accent), ''); + assert.equal(shimmerText('Running ls', 500, false).includes('\u001B'), false); + }); +}); + +test('status cues survive without color', () => { + withEnv({NMSH_ICONS: 'safe'}, () => { + assert.notEqual(GLYPHS.success, GLYPHS.failure); + assert.match(GLYPHS.success + GLYPHS.failure, /^[\x20-\x7e]+$/u); + }); +}); + +test('reduced motion freezes the animation phase but not measured time', () => { + withEnv({...CLEAN}, () => { + assert.equal(isReducedMotion(), false); + assert.equal(presentationAnimationElapsed(4321), 4321); + }); + withEnv({...CLEAN, NMSH_REDUCED_MOTION: '1'}, () => { + assert.equal(isReducedMotion(), true); + assert.equal(presentationAnimationElapsed(4321), 0); + }); + withEnv({...CLEAN, NMSH_DETERMINISTIC: '1'}, () => assert.equal(isReducedMotion(), true)); +}); diff --git a/tests/authoredMarkdown.test.ts b/tests/authoredMarkdown.test.ts new file mode 100644 index 00000000..a981952e --- /dev/null +++ b/tests/authoredMarkdown.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import {readdirSync, readFileSync, statSync} from 'node:fs'; +import {join} from 'node:path'; +import test from 'node:test'; +import {helpMarkdown} from '../src/help/helpContent.js'; +import {authoredMarkdown, renderMarkdown, supportsHyperlinks} from '../src/help/markdown.js'; +import {slashCommands} from '../src/commands/slashCommands.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const render = (source: string, columns = 40, hyperlinks = false) => renderMarkdown(authoredMarkdown(source), {columns, hyperlinks}); +const plain = (rows: string[]) => rows.map(stripAnsi); + +test('headings, paragraphs and emphasis', () => { + const rows = plain(render('# Title\n\nSome **bold** and *italic* and `code` text.\n\n## Next\nmore')); + assert.deepEqual(rows, ['Title', '', 'Some bold and italic and code text.', '', 'Next', 'more']); +}); + +test('paragraphs wrap to the width and never exceed it', () => { + const rows = render('word '.repeat(40), 22); + assert.ok(rows.length > 5); + for (const row of rows) assert.ok(displayWidth(row) <= 22); + const long = render('x'.repeat(100), 15); + for (const row of long) assert.ok(displayWidth(row) <= 15); + assert.equal(plain(long).join(''), 'x'.repeat(100)); +}); + +test('lists keep hanging indents', () => { + const rows = plain(render('- first item that wraps around\n- second\n1. numbered', 16)); + assert.equal(rows[0], '• first item'); + assert.ok(rows[1]!.startsWith(' ')); + assert.ok(rows.includes('• second')); + assert.ok(rows.includes('1. numbered')); +}); + +test('code blocks are literal and not reflowed or interpreted', () => { + const rows = plain(render('```\n**not bold** [x](http://a.b)\n```', 60)); + assert.deepEqual(rows, [' **not bold** [x](http://a.b)']); +}); + +test('tables align columns and fall back to labelled rows when narrow', () => { + const source = '| Command | Meaning |\n| --- | --- |\n| `/a` | first |\n| `/bb` | second |'; + const wide = plain(render(source, 40)); + assert.equal(wide.length, 4); + assert.match(wide[0]!, /^ {2}Command │ Meaning$/u); + assert.match(wide[2]!, /^ {2}\/a {6}│ first$/u); + const narrow = render(source, 12); + for (const row of narrow) assert.ok(displayWidth(row) <= 12); + assert.ok(plain(narrow).join(' ').includes('/bb')); +}); + +test('links use OSC 8 only when enabled and only for http(s)', () => { + const on = render('See [docs](https://example.com/a).', 60, true).join(''); + assert.ok(on.includes('\u001B]8;;https://example.com/a\u001B\\')); + const off = plain(render('See [docs](https://example.com/a).', 60, false)); + assert.equal(off[0], 'See docs (https://example.com/a).'); + const unsafe = render('[x](javascript:alert) [y](https://a.b/\u001B]8;;)', 60, true).join(''); + assert.ok(!unsafe.includes('\u001B]8;;javascript')); + assert.ok(!/\u001B\]8;;https:\/\/a\.b\/\u001B/u.test(unsafe)); +}); + +test('control sequences in source cannot reach the terminal', () => { + const rows = render('hi \u001B[2J\u001B]0;title\u0007 there\u0000', 60); + assert.ok(!rows.join('').includes('\u001B[2J')); + assert.ok(!rows.join('').includes('\u0007')); +}); + +test('no-color path carries no color escapes and keeps code visible', () => { + const saved = process.env.NO_COLOR; + try { + process.env.NO_COLOR = '1'; + const rows = render('# H\n`code` and **b**', 40); + assert.ok(rows.every(row => !/\u001B\[(38|48);/u.test(row))); + assert.ok(plain(rows).join('\n').includes('`code`')); + } finally { + if (saved === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = saved; + } +}); + +test('hyperlink support detection', () => { + assert.equal(supportsHyperlinks({TERM_PROGRAM: 'ghostty'}), true); + assert.equal(supportsHyperlinks({TERM_PROGRAM: 'Apple_Terminal'}), false); + assert.equal(supportsHyperlinks({TERM_PROGRAM: 'Apple_Terminal', NMSH_HYPERLINKS: '1'}), true); + assert.equal(supportsHyperlinks({TERM_PROGRAM: 'ghostty', NMSH_HYPERLINKS: '0'}), false); +}); + +test('/help renders every command and fits narrow terminals', () => { + for (const columns of [20, 40, 100]) { + const rows = renderMarkdown(helpMarkdown(), {columns, hyperlinks: false}); + for (const row of rows) assert.ok(displayWidth(row) <= columns); + const text = plain(rows).join('\n'); + for (const command of slashCommands) assert.ok(text.includes(command.name.split(' ')[0]!), command.name); + assert.ok(text.includes('Alt+A')); + } +}); + +test('the Markdown renderer is imported only by NMSh-owned help code', () => { + const allowed = new Set(['src/help/helpContent.ts', 'src/app/TerminalApp.ts', 'src/help/markdown.ts']); + const offenders: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path); + else if (path.endsWith('.ts') && /help\/markdown\.js/u.test(readFileSync(path, 'utf8')) && !allowed.has(path)) offenders.push(path); + } + }; + walk('src'); + assert.deepEqual(offenders, []); + const app = readFileSync('src/app/TerminalApp.ts', 'utf8'); + assert.equal((app.match(/renderMarkdownText\(/gu) ?? []).length, 1, 'only /help renders authored Markdown'); +}); diff --git a/tests/chroma.test.ts b/tests/chroma.test.ts new file mode 100644 index 00000000..da715d02 --- /dev/null +++ b/tests/chroma.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyCurve, BRAND_LAVENDER, foregroundOf, gradientCells, gradientText, identity, linearGradient, luminance, resolveColor, sampleGradient, + solid, status, statusMeaning, theme, +} from '../src/chroma/chroma.js'; +import {colorEscape, rgbTo256} from '../src/chroma/escape.js'; +import {colorLevel} from '../src/presentation/capabilities.js'; +import {foreground, UI_COLORS} from '../src/ui/palette.js'; + +const red = {red: 255, green: 0, blue: 0}; +const blue = {red: 0, green: 0, blue: 255}; + +test('categories stay distinct: an identity color never carries status meaning', () => { + const language = identity(UI_COLORS.success); + assert.equal(statusMeaning(language), undefined); + assert.equal(statusMeaning(theme('accent')), undefined); + assert.equal(statusMeaning(solid(UI_COLORS.failure)), undefined); + assert.equal(statusMeaning(status('failure')), 'failure'); + assert.deepEqual(resolveColor(status('success')), UI_COLORS.success); + assert.deepEqual(resolveColor(theme('subtle')), UI_COLORS.subtle); +}); + +test('multi-stop gradients sample, clamp and accept unordered stops', () => { + const gradient = {stops: [{at: 1, color: solid(blue)}, {at: 0, color: solid(red)}, {at: 0.5, color: solid({red: 0, green: 255, blue: 0})}]}; + assert.deepEqual(sampleGradient(gradient, 0), red); + assert.deepEqual(sampleGradient(gradient, 0.5), {red: 0, green: 255, blue: 0}); + assert.deepEqual(sampleGradient(gradient, 1), blue); + assert.deepEqual(sampleGradient(gradient, 2), blue); + assert.deepEqual(sampleGradient(gradient, 0.25), {red: 128, green: 128, blue: 0}); + assert.deepEqual(gradientCells(linearGradient(solid(red), solid(blue)), 3)[1], {red: 128, green: 0, blue: 128}); + assert.deepEqual(gradientCells(linearGradient(solid(red), solid(blue)), 1), [red]); + assert.throws(() => sampleGradient({stops: []}, 0.5)); +}); + +test('curves are monotonic, bounded and hit both ends', () => { + for (const curve of ['linear', 'ease-in', 'ease-out', 'ease-in-out'] as const) { + assert.equal(applyCurve(curve, 0), 0); + assert.equal(applyCurve(curve, 1), 1); + let previous = -1; + for (let step = 0; step <= 20; step += 1) { + const value = applyCurve(curve, step / 20); + assert.ok(value >= previous && value >= 0 && value <= 1); + previous = value; + } + } + assert.ok(applyCurve('ease-in', 0.5) < 0.5 && applyCurve('ease-out', 0.5) > 0.5); + assert.equal(applyCurve('linear', 5), 1); +}); + +test('capability fallback: truecolor, 256-color and none', () => { + assert.equal(colorEscape(38, red, 'truecolor'), '\u001B[38;2;255;0;0m'); + assert.equal(colorEscape(48, red, 'ansi256'), '\u001B[48;5;196m'); + assert.equal(colorEscape(38, red, 'none'), ''); + assert.equal(rgbTo256({red: 0, green: 0, blue: 0}), 16); + assert.equal(rgbTo256({red: 255, green: 255, blue: 255}), 231); + const mid = rgbTo256({red: 128, green: 128, blue: 128}); + assert.ok(mid >= 232 && mid <= 255); + assert.equal(colorLevel({NMSH_COLOR: '256'}), 'ansi256'); +}); + +test('the palette follows the capability level and no-color yields plain gradient text', () => { + const saved = {NMSH_COLOR: process.env.NMSH_COLOR}; + try { + process.env.NMSH_COLOR = '256'; + assert.match(foreground(UI_COLORS.accent), /^\u001B\[38;5;\d+m$/u); + process.env.NMSH_COLOR = 'none'; + assert.equal(foregroundOf(theme('accent')), ''); + assert.equal(gradientText([...'abc'], linearGradient(solid(red), solid(blue))), 'abc'); + } finally { + if (saved.NMSH_COLOR === undefined) delete process.env.NMSH_COLOR; else process.env.NMSH_COLOR = saved.NMSH_COLOR; + } +}); + +test('brand lavender and luminance', () => { + assert.deepEqual(BRAND_LAVENDER, {red: 166, green: 124, blue: 243}); + assert.ok(luminance({red: 255, green: 255, blue: 255}) > luminance(BRAND_LAVENDER)); + assert.equal(luminance({red: 0, green: 0, blue: 0}), 0); +}); diff --git a/tests/commandPalette.test.ts b/tests/commandPalette.test.ts index 3f93701f..436f96d4 100644 --- a/tests/commandPalette.test.ts +++ b/tests/commandPalette.test.ts @@ -6,7 +6,7 @@ import {join} from 'node:path'; import {TerminalApp} from '../src/app/TerminalApp.js'; import {parseSlashCommand, slashCommands} from '../src/commands/slashCommands.js'; import {createPalette, filterPalette, handlePaletteKey, paletteItems, renderPalette} from '../src/ui/CommandPalette.js'; -import {SETTINGS_ENTRIES, SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; +import {SETTINGS_ENTRIES, SETTINGS_ROWS, visibleSettingsRows} from '../src/ui/SettingsPanel.js'; import {decodeKeys} from '../src/terminal/keys.js'; import {stripAnsi} from '../src/util/text.js'; @@ -77,7 +77,7 @@ test('the palette opens, runs only the chosen action, and never executes shell t app['handleKey']({kind: 'enter'}); await new Promise(resolve => setTimeout(resolve, 10)); assert.equal(app['settingsPanelState']?.view, 'config'); - assert.equal(SETTINGS_ROWS[app['settingsPanelState']!.contentIndex!]!.id, 'composerPosition'); + assert.equal(visibleSettingsRows(app['settingsPanelState']!)[app['settingsPanelState']!.contentIndex!]!.id, 'composerPosition'); app['settingsPanelState'] = undefined; app['handleKey']({kind: 'palette'}); diff --git a/tests/deterministicPresentation.test.ts b/tests/deterministicPresentation.test.ts new file mode 100644 index 00000000..ede0ab45 --- /dev/null +++ b/tests/deterministicPresentation.test.ts @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdir, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {presentationNow} from '../src/presentation/environment.js'; + +test('deterministic presentation keeps Vespyr in its stable open frame without a blink timer', () => { + const previous = process.env.NMSH_DETERMINISTIC; + process.env.NMSH_DETERMINISTIC = '1'; + let app: TerminalApp | undefined; + try { + app = new TerminalApp(); + assert.equal(app['welcomeBlinkTimer'], undefined); + assert.ok(app['output'].transcript().welcome); + assert.doesNotMatch(app['output'].wrapped(80)[1]!.plain, /▂/u, 'welcome stays in its open-eye frame'); + } finally { + app?.['stop'](0); + app?.['session'].kill(); + if (previous === undefined) delete process.env.NMSH_DETERMINISTIC; + else process.env.NMSH_DETERMINISTIC = previous; + } +}); + +test('deterministic presentation helpers leave user config bytes untouched', async () => { + const previousDeterministic = process.env.NMSH_DETERMINISTIC; + const previousConfigHome = process.env.XDG_CONFIG_HOME; + const configHome = await mkdtemp(join(tmpdir(), 'nmsh-deterministic-')); + const configDirectory = join(configHome, 'nmsh'); + const configPath = join(configDirectory, 'config.json'); + const configContents = '{"provider":"nmsh","onboardingComplete":true}\n'; + await mkdir(configDirectory, {recursive: true}); + await writeFile(configPath, configContents); + process.env.NMSH_DETERMINISTIC = '1'; + process.env.XDG_CONFIG_HOME = configHome; + try { + assert.ok(presentationNow() instanceof Date); + assert.equal(await readFile(configPath, 'utf8'), configContents); + } finally { + if (previousDeterministic === undefined) delete process.env.NMSH_DETERMINISTIC; + else process.env.NMSH_DETERMINISTIC = previousDeterministic; + if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousConfigHome; + await rm(configHome, {recursive: true, force: true}); + } +}); diff --git a/tests/formControls.test.ts b/tests/formControls.test.ts new file mode 100644 index 00000000..e2719e1e --- /dev/null +++ b/tests/formControls.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type {Key} from '../src/terminal/keys.js'; +import { + createConfirm, editText, handleConfirmKey, handleMultiSelectKey, handleSelectKey, renderConfirm, renderField, renderMultiSelect, + renderSelect, renderTextValue, renderToggle, stepIndex, toggleMember, toggleValue, +} from '../src/ui/formControls.js'; +import {adjustSettingsRow, SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; +import {DEFAULT_PROMPT_CONFIGURATION} from '../src/prompt/configuration.js'; + +const key = (kind: string, value?: string): Key => ({kind, value} as Key); +const text = (value: string) => key('text', value); +const hasEscape = (value: string) => value.includes('\u001B'); + +test('toggle and select report proposals without owning state', () => { + assert.equal(toggleValue(false), true); + assert.equal(stepIndex(3, 0, -1), 2); + assert.equal(stepIndex(3, 2, 1), 0); + assert.equal(stepIndex(0, 0, 1), 0); + assert.equal(handleSelectKey(key('left'), 3, 0), 2); + assert.equal(handleSelectKey(text(' '), 3, 0), 1); + assert.equal(handleSelectKey(key('up'), 3, 0), undefined); +}); + +test('multi-select keeps option order and moves with the keyboard only', () => { + assert.deepEqual(toggleMember([2], 0), [0, 2]); + assert.deepEqual(toggleMember([0, 2], 2), [0]); + let state = {cursor: 0, selected: [] as readonly number[]}; + state = handleMultiSelectKey(key('down'), 3, state)!; + state = handleMultiSelectKey(text(' '), 3, state)!; + assert.deepEqual(state, {cursor: 1, selected: [1]}); + assert.equal(handleMultiSelectKey(key('enter'), 3, state), undefined); +}); + +test('text field edits, pastes and clears', () => { + assert.equal(editText('ab', text('c')), 'abc'); + assert.equal(editText('ab', key('paste', 'x\ny')), 'abxy'); + assert.equal(editText('ab', key('backspace')), 'a'); + assert.equal(editText('ab', key('deleteWord')), ''); + assert.equal(editText('ab', key('up')), undefined); +}); + +test('confirmation defaults to no and Esc always cancels', () => { + const state = createConfirm(); + assert.equal(handleConfirmKey(key('enter'), state), 'cancel'); + assert.equal(handleConfirmKey(key('right'), state), 'changed'); + assert.equal(state.choice, 'yes'); + assert.equal(handleConfirmKey(key('enter'), state), 'confirm'); + assert.equal(handleConfirmKey(key('escape'), state), 'cancel'); + assert.equal(handleConfirmKey(text('y'), createConfirm()), 'confirm'); +}); + +test('plain rendering carries focus, changed state and errors in text', () => { + const plain = {color: false, focused: true}; + for (const rendered of [renderToggle(true, plain), renderSelect('Daily', plain), renderMultiSelect(['a', 'b'], [1], 0, plain), + renderTextValue('hi', 'Search', plain), renderTextValue('', 'Search', plain), renderConfirm(createConfirm(), plain)]) { + assert.equal(hasEscape(rendered), false, rendered); + } + assert.equal(renderToggle(false, plain), '[ ] Off'); + const rows = renderField({label: 'Name', control: renderTextValue('x', '', plain), description: 'Shown in the prompt', + changed: true, error: 'too short', focused: true, color: false}, 60); + assert.deepEqual(rows, ['> Name x_ (changed)', ' Shown in the prompt', ' Error: too short']); +}); + +test('styled rendering stays within narrow widths', () => { + const rows = renderField({label: 'A long setting label', control: renderSelect('A long option value'), description: 'x'.repeat(80), focused: true}, 20); + for (const row of rows) assert.ok([...row.replace(/\u001B\[[0-9;]*m/gu, '')].length <= 20); +}); + +test('settings rows use the shared toggle and select behavior', () => { + const toggle = SETTINGS_ROWS.find(row => row.control === 'boolean')!; + const before = toggle.control === 'boolean' && toggle.get(DEFAULT_PROMPT_CONFIGURATION); + const flipped = adjustSettingsRow(toggle, DEFAULT_PROMPT_CONFIGURATION, 1)!; + assert.equal(toggle.control === 'boolean' && toggle.get(flipped), !before); + const select = SETTINGS_ROWS.find(row => row.control === 'enum')!; + if (select.control !== 'enum') throw new Error('unreachable'); + const back = adjustSettingsRow(select, DEFAULT_PROMPT_CONFIGURATION, -1)!; + assert.equal(select.index(back), stepIndex(select.options.length, select.index(DEFAULT_PROMPT_CONFIGURATION), -1)); +}); diff --git a/tests/motion.test.ts b/tests/motion.test.ts new file mode 100644 index 00000000..57a7cdca --- /dev/null +++ b/tests/motion.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {MOTION_PROFILES, sampleMotion, wrappedPhase, type MotionProfile, type MotionState} from '../src/motion/motion.js'; +import {shimmerIntensity} from '../src/status/shimmer.js'; + +const STILL = {reduced: false}; + +test('every profile stays within 0..1 for any time and cell', () => { + for (const [state, profile] of Object.entries(MOTION_PROFILES) as Array<[MotionState, MotionProfile]>) { + for (let time = -500; time < 5000; time += 37) { + for (const cell of [0, 3, 11, 40]) { + const value = sampleMotion(profile, time, cell, STILL); + assert.ok(value >= 0 && value <= 1, `${state} t=${time} cell=${cell} -> ${value}`); + } + } + } +}); + +test('breathing is symmetric and uniform across cells', () => { + const waiting = MOTION_PROFILES.waiting; + const half = waiting.cycleMs / 2; + assert.equal(sampleMotion(waiting, 0, 0, STILL), 1); + assert.ok(Math.abs(sampleMotion(waiting, half, 0, STILL)) < 1e-9); + assert.ok(Math.abs(sampleMotion(waiting, 400, 0, STILL) - sampleMotion(waiting, waiting.cycleMs - 400, 0, STILL)) < 1e-9); + assert.equal(sampleMotion(waiting, 700, 0, STILL), sampleMotion(waiting, 700, 25, STILL)); +}); + +test('traveling shimmer moves its crest across cells over time', () => { + const crest = (time: number) => { + let best = 0; + for (let cell = 0; cell < 12; cell += 1) if (sampleMotion(MOTION_PROFILES.processing, time, cell, STILL) > sampleMotion(MOTION_PROFILES.processing, time, best, STILL)) best = cell; + return best; + }; + assert.ok(crest(0) !== crest(600)); +}); + +test('comet rises slowly and falls fast; tail rises fast and decays slowly', () => { + const comet: MotionProfile = {shape: 'comet', spread: 'uniform', cycleMs: 1000, repeat: true}; + const peak = sampleMotion(comet, 800, 0, STILL); + assert.equal(peak, 1); + assert.ok(sampleMotion(comet, 400, 0, STILL) < peak && sampleMotion(comet, 900, 0, STILL) < peak); + assert.ok(sampleMotion(comet, 900, 0, STILL) - sampleMotion(comet, 1000 - 1, 0, STILL) > 0.4, 'falls within the last 20%'); + const tail = MOTION_PROFILES.completion; + assert.equal(Math.round(sampleMotion(tail, 0.15 * tail.cycleMs, 0, STILL) * 1000), 1000); + assert.ok(sampleMotion(tail, 0.5 * tail.cycleMs, 0, STILL) > 0.5, 'long tail is still lit at half time'); +}); + +test('one-shot profiles settle at rest and do not repeat', () => { + for (const state of ['transition', 'completion', 'failure'] as const) { + const profile = MOTION_PROFILES[state]; + assert.equal(sampleMotion(profile, 0, 0, STILL), 0); + assert.equal(sampleMotion(profile, profile.cycleMs, 0, STILL), 0); + assert.equal(sampleMotion(profile, profile.cycleMs * 3.3, 0, STILL), 0); + assert.equal(sampleMotion(profile, -10, 0, STILL), 0); + } + const pulse = MOTION_PROFILES.failure; + assert.ok(sampleMotion(pulse, pulse.cycleMs * 0.25, 0, STILL) > 0.95); +}); + +test('reduced motion holds every profile still and is deterministic', () => { + for (const profile of Object.values(MOTION_PROFILES)) { + const first = sampleMotion(profile, 0, 4, {reduced: true}); + for (const time of [1, 250, 999, 12345]) assert.equal(sampleMotion(profile, time, 4, {reduced: true}), first); + } + const saved = process.env.NMSH_REDUCED_MOTION; + try { + process.env.NMSH_REDUCED_MOTION = '1'; + assert.equal(shimmerIntensity(777, 3, 10, false), shimmerIntensity(12345, 3, 10, false)); + } finally { + if (saved === undefined) delete process.env.NMSH_REDUCED_MOTION; else process.env.NMSH_REDUCED_MOTION = saved; + } +}); + +test('phase wraps and the shimmer is periodic', () => { + assert.equal(wrappedPhase(-250, 1000), 0.75); + assert.equal(shimmerIntensity(300, 2, 10, false), shimmerIntensity(300 + MOTION_PROFILES.processing.cycleMs, 2, 10, false)); +}); diff --git a/tests/presentationEnvironment.test.ts b/tests/presentationEnvironment.test.ts new file mode 100644 index 00000000..bb928c3c --- /dev/null +++ b/tests/presentationEnvironment.test.ts @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {presentationAnimationElapsed, presentationCompletionTime, presentationNow} from '../src/presentation/environment.js'; +import {completedActivity, liveActivityParts} from '../src/status/activity.js'; + +const deterministicValue = process.env.NMSH_DETERMINISTIC; + +function withDeterministicPresentation(enabled: boolean, run: () => T): T { + if (enabled) process.env.NMSH_DETERMINISTIC = '1'; + else delete process.env.NMSH_DETERMINISTIC; + try { + return run(); + } finally { + if (deterministicValue === undefined) delete process.env.NMSH_DETERMINISTIC; + else process.env.NMSH_DETERMINISTIC = deterministicValue; + } +} + +test('presentation clock and animation preserve ordinary runtime values by default', () => { + withDeterministicPresentation(false, () => { + const before = Date.now(); + const now = presentationNow().getTime(); + const after = Date.now(); + assert.ok(now >= before && now <= after); + assert.equal(presentationAnimationElapsed(731), 731); + + const shellCompletion = new Date(1_800_000_000_000); + assert.equal(presentationCompletionTime(shellCompletion), shellCompletion); + }); +}); + +test('deterministic presentation repeats timestamps and shimmer phase across renders', () => { + withDeterministicPresentation(true, () => { + const eventTimeA = new Date(1_800_000_000_000); + const eventTimeB = new Date(1_900_000_000_000); + const first = completedActivity('echo stable', 42, presentationCompletionTime(eventTimeA), 0, false); + const second = completedActivity('echo stable', 42, presentationCompletionTime(eventTimeB), 0, false); + + assert.deepEqual(first, second); + assert.equal(first.detail, ' · 09:41'); + assert.equal(presentationNow().getTime(), presentationNow().getTime()); + assert.equal(presentationAnimationElapsed(731), 0); + assert.equal(eventTimeA.getTime(), 1_800_000_000_000, 'shell event time remains untouched'); + + const shortRun = liveActivityParts('echo stable', 100, presentationAnimationElapsed(100)); + const longRun = liveActivityParts('echo stable', 5000, presentationAnimationElapsed(5000)); + assert.equal(shortRun.phrase, longRun.phrase, 'spinner phase stays fixed'); + assert.notEqual(shortRun.duration, longRun.duration, 'measured duration remains real'); + }); +}); + +test('deterministic environment does not leak after an assertion scope', () => { + const before = process.env.NMSH_DETERMINISTIC; + assert.throws(() => withDeterministicPresentation(true, () => { + assert.equal(presentationAnimationElapsed(12), 0); + throw new Error('exercise restoration'); + }), /exercise restoration/u); + assert.equal(process.env.NMSH_DETERMINISTIC, before); +}); diff --git a/tests/settingsControls.test.ts b/tests/settingsControls.test.ts index 61e930a8..3399b8d8 100644 --- a/tests/settingsControls.test.ts +++ b/tests/settingsControls.test.ts @@ -77,7 +77,7 @@ test('search field is bordered, inset, and shows focus with a caret and accent b }); test('Config rows are compact, single-line, with aligned values and a pointer', () => { - const rows = plain(renderSettingsPanel(config({contentIndex: 2}), 80, Infinity, {configuration: DEFAULT_PROMPT_CONFIGURATION})); + const rows = plain(renderSettingsPanel(config({contentIndex: 2, showAdvanced: true}), 80, Infinity, {configuration: DEFAULT_PROMPT_CONFIGURATION})); const list = rows.filter(row => /^ {2}[› ] (Glyph style|Prompt provider|History divider|Divider density|Prompt snapshots|History colors)/u.test(row)); assert.equal(list.length, 6); const first = rows.indexOf(list[0]!); @@ -86,7 +86,7 @@ test('Config rows are compact, single-line, with aligned values and a pointer', assert.equal(new Set(valueStarts).size, 1, `values align: ${list.join('|')}`); assert.match(list[2]!, /^ {2}› History divider\s+true$/u); assert.match(list[0]!, /^ {4}Glyph style\s+Nerd Font$/u, 'enum values are plain, not ‹ › wrapped'); - const styled = renderSettingsPanel(config({contentIndex: 2}), 80, Infinity, {configuration: DEFAULT_PROMPT_CONFIGURATION}); + const styled = renderSettingsPanel(config({contentIndex: 2, showAdvanced: true}), 80, Infinity, {configuration: DEFAULT_PROMPT_CONFIGURATION}); const accent = foreground(UI_COLORS.accent); const selected = styled.find(row => stripAnsi(row).includes('› History divider'))!; assert.ok(selected.includes(`\u001B[1m${accent}History divider`), 'selected label is bold accent'); @@ -109,8 +109,8 @@ test('search highlights matches restrainedly, distinct from the selected row', ( test('footer follows Claude-style phrasing for the focused control', () => { const footer = (state: SettingsPanelState) => stripAnsi(renderSettingsPanel(state, 120).at(-1)!).trim(); - assert.equal(footer(config()), 'Enter/Space to change · / to search · Esc to close'); - assert.equal(footer(config({contentIndex: 1})), 'Enter to open · ←/→ to switch · / to search · Esc to close'); + assert.equal(footer(config()), 'Enter/Space to change · / to search · A show advanced · Esc to close'); + assert.equal(footer(config({contentIndex: 1})), 'Enter to open · ←/→ to switch · / to search · A show advanced · Esc to close'); assert.equal(footer(config({searchFocused: true, searchQuery: 'x'})), '↑↓ results · Enter select · Esc clear'); assert.equal(footer(config({view: 'status'})), '←/→ to switch · ↑↓ to scroll · Esc to close'); assert.ok(!footer(config()).includes('Tab')); @@ -138,6 +138,7 @@ test('Left/Right change an enum inline and persist it; Enter changes too', () => test('Space and Enter toggle a real boolean; values stay shared with Status', () => withApp(async (app, path) => { app['openSettingsPanel']('config'); + app['settingsPanelState']!.showAdvanced = true; app['handleKey']({kind: 'down'}); app['handleKey']({kind: 'down'}); app['handleKey']({kind: 'text', value: ' '}); @@ -200,7 +201,7 @@ test('panel chrome never enters transcript, resume data, or copy payload', () => test('narrow widths keep values visible and never overflow', () => { for (const columns of [24, 32, 40]) { for (const [contentIndex, value] of [[0, 'Nerd Font'], [5, 'Follow prompt'], [2, 'true']] as const) { - const rows = renderSettingsPanel(config({contentIndex}), columns, 40); + const rows = renderSettingsPanel(config({contentIndex, showAdvanced: true}), columns, 40); for (const line of rows) assert.ok(displayWidth(line) <= columns, `${columns}: ${stripAnsi(line)}`); assert.ok(rows.some(line => stripAnsi(line).includes(value)), `${columns}: ${value}`); } diff --git a/tests/settingsV2.test.ts b/tests/settingsV2.test.ts new file mode 100644 index 00000000..ad81b311 --- /dev/null +++ b/tests/settingsV2.test.ts @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp, readFile, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import type {Key} from '../src/terminal/keys.js'; +import {decodeKeys} from '../src/terminal/keys.js'; +import {DEFAULT_PROMPT_CONFIGURATION} from '../src/prompt/configuration.js'; +import { + renderSettingsPanel, resetSettingsRow, SETTINGS_ROWS, settingsRowChanged, visibleSettingsRows, type SettingsPanelState, +} from '../src/ui/SettingsPanel.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import {getCurrentGlyphMode, setIconStyle} from '../src/ui/glyphs.js'; + +const config = (patch: Partial = {}): SettingsPanelState => + ({section: 'root', view: 'config', selectedIndex: 0, glyphStyle: 'nerd', onboarding: false, ...patch}); +const text = (value: string) => [...value].map(character => ({kind: 'text', value: character}) as Key); +const plain = (rows: string[]) => rows.map(stripAnsi); + +/** An app whose config writes land in a throwaway directory. */ +async function withApp(run: (app: TerminalApp, configPath: string) => Promise | void): Promise { + const directory = await mkdtemp(join(tmpdir(), 'nmsh-settings-')); + const previous = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = directory; + const app = new TerminalApp(); + try { + app['render'] = () => {}; + await run(app, join(directory, 'nmsh', 'config.json')); + } finally { + app['stop'](0); + app['session'].kill(); + setIconStyle('nerd'); + if (previous === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previous; + await rm(directory, {recursive: true, force: true}); + } +} + + +const row = (id: string) => SETTINGS_ROWS.find(item => item.id === id)!; + +test('default Config hides advanced rows; A reveals them; search always finds them', () => { + const simple = visibleSettingsRows(config()).map(item => item.id); + assert.ok(simple.includes('glyphStyle') && !simple.includes('dividerDensity')); + assert.ok(visibleSettingsRows(config({showAdvanced: true})).some(item => item.id === 'dividerDensity')); + assert.ok(visibleSettingsRows(config({searchQuery: 'density'})).some(item => item.id === 'dividerDensity')); + assert.equal(SETTINGS_ROWS.filter(item => item.level === 'advanced').length > 0, true); +}); + +test('changed-from-default is a text cue and reset targets one setting', () => { + const changed = {...DEFAULT_PROMPT_CONFIGURATION, updateChecks: 'weekly' as const, outputFolding: 'always' as const}; + assert.equal(settingsRowChanged(row('updateChecks'), DEFAULT_PROMPT_CONFIGURATION), false); + assert.equal(settingsRowChanged(row('updateChecks'), changed), true); + const reset = resetSettingsRow(row('updateChecks'), changed)!; + assert.equal(reset.updateChecks, DEFAULT_PROMPT_CONFIGURATION.updateChecks); + assert.equal(reset.outputFolding, 'always', 'other settings untouched'); + assert.equal(resetSettingsRow(row('provider'), changed), undefined); +}); + +test('rendering marks changed rows and offers reset only when it applies', () => { + const index = visibleSettingsRows(config()).findIndex(item => item.id === 'updateChecks'); + const changed = {...DEFAULT_PROMPT_CONFIGURATION, updateChecks: 'weekly' as const}; + const shown = plain(renderSettingsPanel(config({contentIndex: index}), 100, Infinity, {configuration: changed})); + assert.ok(shown.some(line => line.includes('Update checks') && line.includes('Weekly •'))); + assert.ok(shown.at(-1)!.includes('R reset to')); + const clean = plain(renderSettingsPanel(config({contentIndex: index}), 100, Infinity, {configuration: DEFAULT_PROMPT_CONFIGURATION})); + assert.ok(!clean.some(line => line.includes('•')) && !clean.at(-1)!.includes('R reset')); + for (const columns of [24, 40]) for (const line of renderSettingsPanel(config({contentIndex: index}), columns, Infinity, {configuration: changed})) assert.ok(displayWidth(line) <= columns); +}); + +test('R resets the focused setting, A toggles advanced, and position is remembered', () => withApp(app => { + app['openSettingsPanel']('config'); + app['applySettingsConfiguration']({...app['promptConfiguration'], updateChecks: 'weekly'}); + const index = visibleSettingsRows(app['settingsPanelState']!).findIndex(item => item.id === 'updateChecks'); + app['settingsPanelState']!.contentIndex = index; + app['handleKey']({kind: 'text', value: 'r'}); + assert.equal(app['promptConfiguration'].updateChecks, DEFAULT_PROMPT_CONFIGURATION.updateChecks); + app['handleKey']({kind: 'text', value: 'a'}); + assert.equal(app['settingsPanelState']!.showAdvanced, true); + assert.equal(app['settingsPanelState']!.contentIndex, 0); + app['settingsPanelState']!.contentIndex = 3; + app['handleKey']({kind: 'down'}); + app['settingsPanelState'] = undefined; + app['openSettingsPanel']('config'); + assert.equal(app['settingsPanelState']!.contentIndex, 4); + assert.equal(app['settingsPanelState']!.showAdvanced, true); +})); + +test('the palette can target an advanced row', () => withApp(app => { + app['focusConfigRow']('dividerDensity'); + const state = app['settingsPanelState']!; + assert.equal(visibleSettingsRows(state)[state.contentIndex!]!.id, 'dividerDensity'); +})); + +test('R does nothing while searching, so it can be typed', () => withApp(app => { + app['openSettingsPanel']('config'); + app['handleKey']({kind: 'text', value: '/'}); + app['handleKey']({kind: 'text', value: 'r'}); + assert.equal(app['settingsPanelState']!.searchQuery, 'r'); +})); diff --git a/tests/surface.test.ts b/tests/surface.test.ts new file mode 100644 index 00000000..0e72fcb4 --- /dev/null +++ b/tests/surface.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {linearGradient, solid, theme} from '../src/chroma/chroma.js'; +import {framePanel} from '../src/ui/PanelShell.js'; +import {renderSurface, type SurfaceSpec} from '../src/ui/surface.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const plain = (rows: string[]) => rows.map(stripAnsi); +const RED = solid({red: 200, green: 0, blue: 0}); +const BLUE = solid({red: 0, green: 0, blue: 200}); + +function withEnv(patch: Record, run: () => T): T { + const saved = Object.fromEntries(Object.keys(patch).map(name => [name, process.env[name]])); + for (const [name, value] of Object.entries(patch)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + try { return run(); } finally { + for (const [name, value] of Object.entries(saved)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + } +} + +test('every frame style renders with the same geometry', () => { + withEnv({NMSH_ICONS: 'nerd'}, () => { + assert.deepEqual(plain(renderSurface(['hi'], 6, {frame: 'square'})), ['┌────┐', '│hi │', '└────┘']); + assert.deepEqual(plain(renderSurface(['hi'], 6, {frame: 'rounded'})), ['╭────╮', '│hi │', '╰────╯']); + assert.deepEqual(plain(renderSurface(['hi'], 6, {frame: 'double'})), ['╔════╗', '║hi ║', '╚════╝']); + assert.deepEqual(plain(renderSurface(['hi'], 6, {frame: 'heavy'})), ['┏━━━━┓', '┃hi ┃', '┗━━━━┛']); + assert.deepEqual(plain(renderSurface(['hi'], 4, {frame: 'topLine'})), ['────', 'hi']); + assert.deepEqual(plain(renderSurface(['hi'], 4, {frame: 'none'})), ['hi']); + }); + withEnv({NMSH_ICONS: 'safe'}, () => { + assert.deepEqual(plain(renderSurface(['hi'], 6, {frame: 'double'})), ['+----+', '|hi |', '+----+']); + }); +}); + +test('frame and fill are independent: all four combinations are valid', () => { + const specs: SurfaceSpec[] = [ + {fill: theme('selection')}, + {frame: 'rounded'}, + {frame: 'rounded', fill: theme('selection')}, + {frame: 'square', fill: linearGradient(RED, BLUE), frameColor: linearGradient(BLUE, RED)}, + ]; + for (const spec of specs) { + const rows = renderSurface(['abc', 'de'], 10, spec); + for (const row of rows) assert.ok(displayWidth(row) <= 10, JSON.stringify(plain([row]))); + assert.ok(stripAnsi(rows.join('\n')).includes('abc')); + } + assert.ok(renderSurface(['x'], 5, {fill: theme('selection')})[0]!.includes('\u001B[48;')); + assert.ok(!renderSurface(['x'], 5, {frame: 'rounded'}).some(row => row.includes('\u001B[48;'))); +}); + +test('gradient fill gives different columns different backgrounds and survives inner resets', () => { + const row = renderSurface(['\u001B[1mab\u001B[0mcd'], 4, {fill: linearGradient(RED, BLUE)})[0]!; + const backgrounds = row.match(/\u001B\[48;2;\d+;\d+;\d+m/gu)!; + assert.equal(backgrounds.length, 4); + assert.equal(new Set(backgrounds).size, 4); + assert.equal(stripAnsi(row), 'abcd'); +}); + +test('padding, inset, width and alignment', () => { + assert.deepEqual(plain(renderSurface(['hi'], 12, {frame: 'square', padding: {x: 1, y: 1}, width: 'content'})), + ['┌────┐', '│ │', '│ hi │', '│ │', '└────┘']); + assert.deepEqual(plain(renderSurface(['hi'], 10, {inset: 2})), [' hi']); + assert.deepEqual(plain(renderSurface(['hi'], 10, {frame: 'square', width: 'content', align: 'right'})), [' ┌──┐', ' │hi│', ' └──┘']); + assert.deepEqual(plain(renderSurface(['hi'], 10, {frame: 'square', width: 'content', align: 'center'}))[1], ' │hi│'); +}); + +test('narrow terminals degrade instead of overflowing', () => { + for (const columns of [1, 2, 3, 4, 5, 8]) { + for (const frame of ['square', 'rounded', 'double', 'heavy', 'topLine', 'none'] as const) { + const rows = renderSurface(['hello world'], columns, {frame, padding: {x: 2, y: 1}, inset: 3, fill: theme('selection')}); + for (const row of rows) assert.ok(displayWidth(row) <= columns, `${frame}@${columns}: ${JSON.stringify(plain([row]))}`); + } + } + assert.deepEqual(plain(renderSurface(['hello'], 2, {frame: 'square'})), ['──', 'h…']); +}); + +test('no-color drops fills and frame colors but keeps geometry', () => { + withEnv({NO_COLOR: '1', NMSH_COLOR: undefined, NMSH_ICONS: 'nerd'}, () => { + const rows = renderSurface(['hi'], 6, {frame: 'rounded', fill: theme('selection'), frameColor: linearGradient(RED, BLUE)}); + assert.ok(rows.every(row => !/\u001B\[(38|48);/u.test(row))); + assert.equal(rows[0]!.replace(/\u001B\[0m/gu, ''), '╭────╮'); + }); +}); + +test('framePanel keeps its top line over the untouched rows', () => { + const rows = framePanel(['one', 'two'], 8); + assert.equal(rows.length, 3); + assert.equal(stripAnsi(rows[0]!), '────────'); + assert.deepEqual(rows.slice(1), ['one', 'two']); +}); diff --git a/tests/uiActions.test.ts b/tests/uiActions.test.ts new file mode 100644 index 00000000..49e6e7a8 --- /dev/null +++ b/tests/uiActions.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type {Key} from '../src/terminal/keys.js'; +import {actionControls, DRAFT_PANEL_ACTIONS, enabledActions, renderActionHelp, resolveAction, type UiAction} from '../src/ui/actions.js'; +import {createPalette, handlePaletteKey, paletteActions, renderPalette} from '../src/ui/CommandPalette.js'; +import {createLayoutPanel, renderLayoutPanel} from '../src/ui/LayoutPanel.js'; + +const strip = (text: string) => text.replace(/\u001B\[[0-9;]*m/gu, ''); +const key = (kind: string): Key => ({kind} as Key); + +test('disabled actions are neither resolved nor shown in help', () => { + const actions: UiAction[] = [ + {id: 'a', label: 'alpha', keyLabel: 'A', kinds: ['enter']}, + {id: 'b', label: 'beta', keyLabel: 'B', kinds: ['escape'], enabled: false}, + ]; + assert.deepEqual(enabledActions(actions).map(action => action.id), ['a']); + assert.equal(resolveAction(actions, key('enter'))?.id, 'a'); + assert.equal(resolveAction(actions, key('escape')), undefined); + assert.deepEqual(actionControls(actions), [['A', 'alpha']]); + assert.equal(strip(renderActionHelp(actions)), 'A alpha'); +}); + +test('draft panels keep complete keyboard operation with derived help', () => { + for (const kind of ['up', 'down', 'left', 'right', 'enter', 'escape']) { + assert.ok(resolveAction(DRAFT_PANEL_ACTIONS, key(kind)), `${kind} is bound`); + } + assert.equal(strip(renderActionHelp(DRAFT_PANEL_ACTIONS)), '↑↓ move · ←→ change · Enter save · Esc cancel'); +}); + +test('layout panel footer is generated from its actions, including at narrow widths', () => { + const state = createLayoutPanel({composerPosition: 'bottom', transcriptPresentation: 'flow'} as never); + const wide = renderLayoutPanel(state, 80, 30).map(strip); + assert.ok(wide.some(row => row.includes('↑↓ move · ←→ change · Enter save · Esc cancel'))); + for (const row of renderLayoutPanel(state, 24, 30).map(strip)) assert.ok([...row].length <= 24); +}); + +test('palette help reflects what is available', () => { + const state = createPalette([ + {id: 'one', label: 'One', category: 'c', detail: 'd'}, + {id: 'two', label: 'Two', category: 'c', detail: 'd'}, + ] as never); + const help = (rows: string[]) => strip(rows[rows.length - 1]!); + assert.match(help(renderPalette(state, 80, 20)), /↑↓ move · Enter run/u); + state.query = 'zzz-nothing'; + const empty = help(renderPalette(state, 80, 20)); + assert.doesNotMatch(empty, /Enter/u); + assert.doesNotMatch(empty, /↑↓/u); + assert.match(empty, /Esc close/u); + assert.equal(handlePaletteKey(key('enter'), state), undefined); + assert.deepEqual(enabledActions(paletteActions(1)).map(action => action.id), ['search', 'run', 'close']); +}); + +test('palette Enter still chooses the selected item', () => { + const state = createPalette([{id: 'one', label: 'One', category: 'c', detail: 'd'}] as never); + assert.equal((handlePaletteKey(key('enter'), state) as {id: string}).id, 'one'); +});