From 2f00f40f70731fb2dceeb2896be16cfc84a51abc Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:58:09 +0530 Subject: [PATCH 01/16] Native theme library, Theme Studio tabs, Prompt None, Theme Bridge and host semantics - Prompt provider None: composer only (no prompt row, modules, right prompt or marker); promptless history snapshots; /prompt and Setup previews. - Historical prompt presentation Full / Compact / Minimal / Off over the unchanged stored snapshot. - Native theme library: stable-id assets (Custom/Imported by provenance), bounded, migrated from the single customTheme (kept as a deterministic mirror of the active asset), stable theme references and one pure semantic palette resolver. - Theme Studio with Built-in | Imported | Custom | Import tabs over one editor and the real Native preview; import parses and previews first. - Importers: NMSh JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON/YAML/TOML, static colors only), Kitty, Ghostty (allowlist), iTerm2 (bounded plist, no entities), WezTerm TOML (Lua rejected). Data-only parsers: yaml, smol-toml, fast-xml-parser. - Theme Bridge: per-target Independent / Follow NMSh / Choose theme with a master switch (default Off); fzf launch colors, less/man termcap and LS_COLORS through one validated shell-environment sink applied by the zsh/Bash/Fish adapters, tmux/Neovim/Vim managed artifacts with an ownership ledger, staged validated writes and exact reviewed includes; bat/delta reported as unsupported rather than faked. - /theme-bridge, /appearance destinations, Setup and Settings selection. - OSC 7 and OSC 133 projected from the OSC 777 lifecycle on capable hosts; NMSh-authored OSC 8 links kept distinct from raw PTY links. --- package-lock.json | 152 +++++++- package.json | 5 +- src/app/TerminalApp.ts | 343 +++++++++++++++-- src/appearance/AppearanceHub.ts | 16 +- src/appearance/ThemeStudio.ts | 505 ++++++++++++++++++++------ src/appearance/customTheme.ts | 31 +- src/appearance/semanticPalette.ts | 110 ++++++ src/appearance/themeImporters.ts | 499 +++++++++++++++++++++++++ src/appearance/themeLibrary.ts | 194 ++++++++++ src/appearance/themeLibraryActions.ts | 100 +++++ src/appearance/themeRefs.ts | 105 ++++++ src/appearance/themeSelection.ts | 48 +++ src/ask/concepts.ts | 13 +- src/commands/slashCommands.ts | 5 +- src/configuration/portability.ts | 15 +- src/host/semanticMarks.ts | 109 ++++++ src/input/inputLayout.ts | 6 +- src/output/AnsiOutputParser.ts | 28 +- src/output/Hyperlinks.ts | 17 + src/output/OutputBuffer.ts | 16 +- src/output/TranscriptPanel.ts | 19 +- src/output/TranscriptPresenter.ts | 27 +- src/pickers/Picker.ts | 9 +- src/prompt/PromptPanel.ts | 2 + src/prompt/configuration.ts | 50 ++- src/sessions/TranscriptStore.ts | 3 +- src/setup/SetupCat.ts | 49 ++- src/setup/providerExplanations.ts | 1 + src/shell/ShellSession.ts | 3 +- src/shell/adapters/BashAdapter.ts | 7 +- src/shell/adapters/FishAdapter.ts | 9 +- src/shell/adapters/ShellAdapter.ts | 2 + src/shell/adapters/ZshAdapter.ts | 6 +- src/themeBridge/ThemeBridgePanel.ts | 170 +++++++++ src/themeBridge/artifacts.ts | 252 +++++++++++++ src/themeBridge/environment.ts | 212 +++++++++++ src/themeBridge/model.ts | 72 ++++ src/themeBridge/runtime.ts | 282 ++++++++++++++ src/themeBridge/targets.ts | 356 ++++++++++++++++++ src/ui/SettingsPanel.ts | 38 +- tests/appearanceHub.test.ts | 17 +- tests/chromaPolish.test.ts | 32 +- tests/motionPreview.test.ts | 2 +- tests/powerlevel10k.test.ts | 7 +- tests/providers.test.ts | 2 +- tests/setupCat.test.ts | 2 +- tests/themeFamilies.test.ts | 49 +-- 47 files changed, 3729 insertions(+), 268 deletions(-) create mode 100644 src/appearance/semanticPalette.ts create mode 100644 src/appearance/themeImporters.ts create mode 100644 src/appearance/themeLibrary.ts create mode 100644 src/appearance/themeLibraryActions.ts create mode 100644 src/appearance/themeRefs.ts create mode 100644 src/host/semanticMarks.ts create mode 100644 src/themeBridge/ThemeBridgePanel.ts create mode 100644 src/themeBridge/artifacts.ts create mode 100644 src/themeBridge/environment.ts create mode 100644 src/themeBridge/model.ts create mode 100644 src/themeBridge/runtime.ts create mode 100644 src/themeBridge/targets.ts diff --git a/package-lock.json b/package-lock.json index a83f65e3..3ba74bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,11 @@ "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { + "fast-xml-parser": "5.11.2", "node-pty": "^1.1.0", - "string-width": "^8.2.0" + "smol-toml": "1.9.0", + "string-width": "^8.2.0", + "yaml": "2.9.1" }, "bin": { "nmsh": "bin/nmsh" @@ -467,6 +470,18 @@ "node": ">=18" } }, + "node_modules/@nodable/entities": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", @@ -829,6 +844,18 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -871,6 +898,45 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz", + "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.1", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -898,6 +964,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -914,6 +992,33 @@ "node-addon-api": "^7.1.0" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/smol-toml": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/string-width": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", @@ -945,6 +1050,21 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/tsx": { "version": "4.23.15", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", @@ -1005,6 +1125,36 @@ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/package.json b/package.json index f98e445a..9abf533d 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,11 @@ "license": "GPL-3.0-only", "type": "module", "dependencies": { + "fast-xml-parser": "5.11.2", "node-pty": "^1.1.0", - "string-width": "^8.2.0" + "smol-toml": "1.9.0", + "string-width": "^8.2.0", + "yaml": "2.9.1" }, "devDependencies": { "@types/node": "^26.6.2", diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 9e9b9f7f..f14bd18b 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -29,7 +29,19 @@ import {createScreensaverPanel, effectiveMode, idleFrameRows, idleMotion, idlePa import {captureFromRows, cropCapture, type ScreenCapture} from '../idle/screenCapture.js'; import {makeRng} from '../idle/screenEffects.js'; import {SCREEN_MODE_EFFECT, pickRandomSaver, saverLoopComplete, IDLE_FRAME_MS as SAVER_FRAME_MS} from '../idle/scenes.js'; -import {createThemeStudio, renderThemeStudio, STUDIO_MIN_SIZE, studioKey, writeThemeExport, type ThemeStudioState} from '../appearance/ThemeStudio.js'; +import {createThemeStudio, previewTheme, renderThemeStudio, STUDIO_MIN_SIZE, studioKey, writeThemeExport, type StudioContext, type StudioTab, type ThemeStudioState} from '../appearance/ThemeStudio.js'; +import {addTheme, deleteTheme, duplicateBuiltin, duplicateTheme, renameTheme, saveTheme, setActiveTheme, type ActionResult} from '../appearance/themeLibraryActions.js'; +import {findTheme} from '../appearance/themeLibrary.js'; +import {activeThemeRef, selectableThemes} from '../appearance/themeRefs.js'; +import {anyBridgeTargetActive, targetsPinnedTo, type BridgeTargetId} from '../themeBridge/model.js'; +import {applyThemeBridge, bridgeStateExists, detectTargets, fzfBridgeArgs, reloadTmux, reportTargets, themeBridgeKey, type ApplyOutcome, type BridgeContext, type TargetFacts, type TargetReport} from '../themeBridge/runtime.js'; +import {createThemeBridgePanel, renderThemeBridgePanel, themeBridgeKey as themeBridgePanelKey, type BridgePanelContext, type ThemeBridgePanelState} from '../themeBridge/ThemeBridgePanel.js'; +import {applyHook, applyHookRemoval, artifactPath, hookSpec, loadLedger, ownership, planHook, planHookRemoval, removeArtifact, type HookSpec} from '../themeBridge/artifacts.js'; +import {BRIDGE_MODE_LABELS, BRIDGE_TARGET_LABELS} from '../themeBridge/model.js'; +import type {FileEditPlan} from '../ask/fileEdit.js'; +import {colorEscape} from '../chroma/escape.js'; +import {parseHexColor} from '../chroma/color.js'; +import type {CustomTheme} from '../appearance/customTheme.js'; import {commandWord, createInstallPrompt, ignoreInstallSuggestion, installCandidate, installPromptKey, renderInstallPrompt, shouldOfferInstall, type InstallPromptState} from '../tools/InstallSuggestion.js'; import {knownToolForExecutable, suggestibleToolFor, toolInstall, TOOLS, type Tool} from '../tools/catalog.js'; @@ -77,6 +89,8 @@ import {HISTORY_PROVIDERS} from '../shell/historyProviders.js'; import type {HistoryEntry} from '../shell/HistoryIndex.js'; import {isPrivateCommand, ignorePatternFromEnv, SUGGESTION_PROVIDERS} from '../suggestions/types.js'; import {CommandEditor} from '../input/CommandEditor.js'; +import {authoredLink, closeAuthoredLinks} from '../output/Hyperlinks.js'; +import {HostSemantics, semanticSupport} from '../host/semanticMarks.js'; import {OutputBuffer, renderHistoricalContext, serializeCopyPayload, type CompletedCommand, type HistoricalContextSnapshot} from '../output/OutputBuffer.js'; import {createWelcomeSnapshot, renderWelcome, vespyrSprite, WELCOME_BLINK_CLOSED_MS, welcomeBlinkDelay} from '../output/Welcome.js'; import {captureWelcome, WELCOME_PROVIDERS, welcomeProvider} from '../output/WelcomeProviders.js'; @@ -85,11 +99,11 @@ import {createProviderPanel, handleProviderPanelKey, providerPanelEnterAction, p type ProviderPanelState} from '../providers/ProviderPanel.js'; import {TapActivityObserver} from '../output/TapActivityObserver.js'; import {HistoryViewport, stickyHeaderFor, type StickyHeader, type WrappedRow} from '../output/viewport.js'; -import {NATIVE_PROMPT_THEMES, setThemeContext, themeContext, themeChromaStops, buildContextLine, buildInlineContextPrefix, buildRightContext, isOnCommandRelevant, buildRichGitShowcaseLine, buildThemePreviewLine, RICH_GIT_SHOWCASE, moduleShowcaseContext, nativePromptSnapshot, renderedModules, themePreviewContext} from '../prompt/prompt.js'; +import {NATIVE_PROMPT_THEMES, setThemeContext, themeContext, themeChromaStops, themeLabel, buildContextLine, buildInlineContextPrefix, buildRightContext, isOnCommandRelevant, buildRichGitShowcaseLine, buildThemePreviewLine, RICH_GIT_SHOWCASE, moduleShowcaseContext, nativePromptSnapshot, renderedModules, themePreviewContext} from '../prompt/prompt.js'; import {foldingPreview, handleTranscriptPanelKey, renderTranscriptPanel, type TranscriptPanelState} from '../output/TranscriptPanel.js'; import {tabCompletionAction} from '../input/tabBehavior.js'; import {formatBuildIdentity, readBuildIdentity} from '../buildInfo.js'; -import {hasVisibleContextModule, loadPromptConfiguration, NATIVE_PALETTE_IDS, savePromptConfiguration, type PromptConfiguration, type PromptProviderId} from '../prompt/configuration.js'; +import {hasVisibleContextModule, loadPromptConfiguration, NATIVE_PALETTE_IDS, savePromptConfiguration, type NativePaletteId, type PromptConfiguration, type PromptProviderId} from '../prompt/configuration.js'; import {detectStarship, renderStarshipPrompt, type StarshipPromptResult, type StarshipStatus} from '../prompt/starship.js'; import {STARSHIP_MODULES, StarshipConfigAdapter} from '../prompt/StarshipConfigAdapter.js'; import {detectPowerlevel10k, renderPowerlevel10kPrompt, type Powerlevel10kStatus} from '../prompt/powerlevel10k.js'; @@ -362,6 +376,8 @@ export class TerminalApp { } // Off: no model use from this window, and the shared service is told to unload. if (turnedOff) this.understanding?.modeChanged(); + // Theme Bridge follows the same funnel: a theme, library or bridge change regenerates what Follow/Choose targets use. + if (themeBridgeKey(previous) !== themeBridgeKey(next)) this.scheduleThemeBridge(); } /** Optional local understanding; creates nothing until a feature is eligible to use it. */ private readonly understanding = new LocalUnderstanding(() => this.configuration.localUnderstanding); @@ -550,10 +566,12 @@ export class TerminalApp { this.completionService.setShellKnowledge(parseShellKnowledge(marker.knowledge)); this.rememberShellNames(marker.knowledge); } + // Host OSC 7 / OSC 133: a projection of this authoritative marker, never the other way round. + this.hostSemantics.prompt(marker.cwd, marker.exitCode); this.onShellPrompt(marker.exitCode, marker.cwd, stamp.at); } }); - this.session.on('exec', (command, stamp) => { if (this.inStream(stamp)) this.onShellExec(command, stamp.at, stamp.historyAllowed); }); + this.session.on('exec', (command, stamp) => { if (this.inStream(stamp)) { this.hostSemantics.exec(); this.onShellExec(command, stamp.at, stamp.historyAllowed); } }); this.session.on('replayed', summary => this.finishReplay(summary)); this.session.on('inputRejected', (data, submission) => this.onInputRejected(data, submission)); this.session.on('startup', tail => { @@ -562,6 +580,7 @@ export class TerminalApp { }); this.session.on('exit', event => { this.shellEnded = true; + this.hostSemantics.end(); if (event.lost) { // Recorded in the journal before it closes; nothing claims the shell survived. this.lostServiceConnection = true; @@ -755,7 +774,7 @@ export class TerminalApp { } this.commandModes.reset(); const startId = this.output.beginCommand(command, this.formatCommandAnsi(command, null), mode => this.onActiveModeChange(mode), - {cwd: this.shellCwd, project: this.context.project, branch: this.context.branch, prompt: this.currentPromptSnapshot(command)}); + this.historicalContext(this.shellCwd, this.context, command)); this.tapActivityObserver.reset(this.output.activeOutputStartId ?? startId); this.running = {command, startedAt: at, interrupted: false, cleared: false, startId, cwd: this.shellCwd, historyAllowed}; this.scheduleJournal(); @@ -844,6 +863,8 @@ export class TerminalApp { } }); this.presentationStarted = true; + // Re-apply (or clean up) Theme Bridge state once per launch; nothing happens when it was never used. + this.scheduleThemeBridge(500); this.scheduleWelcomeBlink(); void this.loadHistory(); this.render(); @@ -1035,6 +1056,10 @@ export class TerminalApp { this.handleThemeStudioKey(key, this.themeStudio); return; } + if (this.themeBridgePanel) { + void this.handleThemeBridgeKey(key, this.themeBridgePanel); + return; + } if (this.screensaverPanel) { this.handleScreensaverKey(key, this.screensaverPanel); return; @@ -1403,6 +1428,8 @@ export class TerminalApp { // The canonical editors; /appearance never duplicates them. this.appearanceHub = undefined; if (action.destination === 'prompt') void this.startPromptSettings(false); + else if (action.destination === 'theme') this.openThemeStudio(); + else if (action.destination === 'themeBridge') void this.openThemeBridge(); else if (action.destination === 'cursor') this.openCursorPanel(); else if (action.destination === 'chroma') this.startChromaSettings(); else this.focusConfigRow('uiChrome'); @@ -1847,7 +1874,7 @@ export class TerminalApp { const native = () => { /* Keep the existing native menu on fallback. */ }; const result = await openPicker(this.promptConfiguration.picker, candidates.map((candidate, index) => ({ id: String(index), label: candidate.display, description: candidate.description, value: candidate.insertion, - })), native, this.pickerHandoff); + })), native, this.pickerHandoff, process.env, await this.fzfThemeArgs()); if (!this.stopped && !this.running && this.editor.text === original && this.completionCursor === cursor && this.context.cwd === cwd && result?.kind === 'selected') { const selected = candidates[Number(result.candidate.id)]; @@ -1880,7 +1907,7 @@ export class TerminalApp { id: entry.id, label: entry.command, value: entry.command, description: entry.cwd, })); if (this.stopped || this.running || this.editor.text !== original) return; - const result = await openPicker(this.promptConfiguration.picker, candidates, native, this.pickerHandoff); + const result = await openPicker(this.promptConfiguration.picker, candidates, native, this.pickerHandoff, process.env, await this.fzfThemeArgs()); if (this.stopped) return; if (result?.kind === 'selected') this.applySuggestion({insertion: result.candidate.value}); if (result?.kind === 'fallback') this.output.addFrontendInteraction('/history', result.reason, INFO); @@ -1899,7 +1926,7 @@ export class TerminalApp { if (this.stopped || this.running || this.editor.text !== original) return; const result = await openPicker(this.promptConfiguration.picker, directories.map(item => ({ id: item.path, label: item.path, description: item.project, value: directoryCommand(item.path), - })), native, this.pickerHandoff); + })), native, this.pickerHandoff, process.env, await this.fzfThemeArgs()); if (this.stopped) return; if (result?.kind === 'selected') this.applySuggestion({insertion: result.candidate.value}); if (result?.kind === 'fallback') this.output.addFrontendInteraction('/dirs', result.reason, INFO); @@ -1996,7 +2023,8 @@ export class TerminalApp { // The cursor has one configuration: /cursor opens its existing Settings rows. else if (slash.kind === 'cursor') this.openCursorPanel(); else if (slash.kind === 'activity') { this.panelOrigin = undefined; this.focusConfigRow('activityColors'); } - else if (slash.kind === 'theme') { this.panelOrigin = undefined; this.themeStudio = createThemeStudio(this.promptConfiguration.customTheme, this.promptConfiguration.nmsh.palette); } + else if (slash.kind === 'theme') { this.panelOrigin = undefined; this.openThemeStudio(); } + else if (slash.kind === 'themeBridge') { this.panelOrigin = undefined; await this.openThemeBridge(); } else if (slash.kind === 'settings') this.openSettingsPanel(slash.view); else if (slash.kind === 'tools') { this.panelOrigin = undefined; this.startTools(); } else if (slash.kind === 'setup') { this.panelOrigin = undefined; this.startSetup(slash.entry); } @@ -2186,8 +2214,7 @@ export class TerminalApp { this.session.resize(dimensions.columns, dimensions.rows); } this.render(); - }, {cwd: this.shellCwd, project: contextAtSubmission.project, branch: contextAtSubmission.branch, - prompt: this.currentPromptSnapshot(command)}); + }, this.historicalContext(this.shellCwd, contextAtSubmission, command)); this.tapActivityObserver.reset(this.output.activeOutputStartId ?? startId); this.output.setActiveActivities([]); this.formatCommandAnsi(command, startId); @@ -2630,9 +2657,10 @@ export class TerminalApp { } private showHelp(command: string): void { - const helpText = renderMarkdownText(helpMarkdown(), {columns: Math.max(20, this.dimensions().columns - 6), - hyperlinks: false}); // the transcript cell model has no OSC 8 support - this.output.addFrontendInteraction(command, helpText, INFO); + // Authored links are stored as authored cells and painted only where the host supports OSC 8; elsewhere the URL is shown inline. + const linked = this.host.capabilities.hyperlinks; + const helpText = renderMarkdownText(helpMarkdown(), {columns: Math.max(20, this.dimensions().columns - 6), hyperlinks: linked}); + this.output.addFrontendInteraction(command, helpText, INFO, linked); } private onInputRejected(data: string, submission: boolean): void { @@ -2801,7 +2829,14 @@ export class TerminalApp { return {...this.context, commandWords: words, shell, ...(kubeContext ? {kubeContext} : {}), ...(dockerContext ? {dockerContext} : {})}; } - private currentPromptSnapshot(command?: string): PromptSnapshot { + /** Prompt None submissions genuinely have no prompt snapshot; history never substitutes Native for them. */ + private historicalContext(cwd: string, context: {project?: string; branch?: string}, command: string): HistoricalContextSnapshot { + const prompt = this.currentPromptSnapshot(command); + return {cwd, project: context.project, branch: context.branch, ...(prompt ? {prompt} : this.effectivePromptProvider === 'none' ? {promptless: true as const} : {})}; + } + + private currentPromptSnapshot(command?: string): PromptSnapshot | undefined { + if (this.effectivePromptProvider === 'none') return undefined; if (this.effectivePromptProvider !== 'nmsh' && this.externalPrompt) { return {provider: this.effectivePromptProvider, layout: this.promptConfiguration.composerLayout, segments: structuredClone(this.externalPrompt.segments), cwd: this.context.cwd, @@ -2835,8 +2870,8 @@ export class TerminalApp { } private async refreshProviderPrompt(): Promise { - if (this.promptConfiguration.provider === 'nmsh') { - this.effectivePromptProvider = 'nmsh'; + if (this.promptConfiguration.provider === 'nmsh' || this.promptConfiguration.provider === 'none') { + this.effectivePromptProvider = this.promptConfiguration.provider; this.externalPrompt = undefined; this.externalPromptError = undefined; return; @@ -2961,6 +2996,10 @@ export class TerminalApp { state.step = 'powerlevel10k'; state.selectedIndex = 0; if (state.p10kStatus.installed) await this.refreshPanelPreview(state); + } else if (state.draft.provider === 'none') { + // Composer only: no layout or appearance applies to an absent prompt. + await this.savePromptSettings(); + return; } else { state.step = 'layout'; state.selectedIndex = layoutChoiceIndex(state.draft); @@ -3169,6 +3208,7 @@ export class TerminalApp { const context = state.step === 'modules' || state.step === 'appearance' || state.step === 'layout' ? moduleShowcaseContext() : this.promptContext(); let providerRow: string; + if (previewConfig.provider === 'none') return [boundary, 'command', boundary, `${SUBTLE}None · composer only${RESET}`]; if (previewConfig.provider !== 'nmsh') { const preview = this.panelExternalPrompt?.provider === previewConfig.provider ? this.panelExternalPrompt.result : undefined; if (!preview) return [this.externalPanelStatusText(state, previewConfig.provider, width)]; @@ -3207,7 +3247,7 @@ export class TerminalApp { } private get settingsPanelActive(): boolean { - return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState + return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState || this.resumeBrowser || this.appearanceHub || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.pasteReview); } @@ -3215,6 +3255,7 @@ export class TerminalApp { private panelMinimum(): MinimumSize | undefined { if (this.setupState) return SETUP_MIN_SIZE; if (this.themeStudio) return STUDIO_MIN_SIZE; + if (this.themeBridgePanel) return {columns: 56, rows: 14}; if (this.screensaverPanel) return SCREENSAVER_MIN_SIZE; if (this.chromeEditor || this.stopsEditor) return CHROME_EDITOR_MIN_SIZE; return undefined; @@ -3235,6 +3276,7 @@ export class TerminalApp { if (this.misePanel) return renderMisePanel(this.misePanel, columns, this.dimensions().rows); if (this.installPrompt) return renderInstallPrompt(this.installPrompt, columns); if (this.themeStudio) return this.renderThemeStudioRows(this.themeStudio, columns); + if (this.themeBridgePanel) return renderThemeBridgePanel(this.themeBridgePanel, this.themeBridgePanelContext(), columns, this.dimensions().rows); if (this.screensaverPanel) return this.renderScreensaverRows(this.screensaverPanel, columns); if (this.stopsEditor) return this.renderStopsEditor(this.stopsEditor, columns); if (this.chromeEditor) return renderChromeEditor(this.chromeEditor, columns, this.dimensions().rows, colorLevel()); @@ -3336,7 +3378,7 @@ export class TerminalApp { return framePanel(rows, columns); } if (this.appearanceHub) { - const theme = NATIVE_PROMPT_THEMES[this.promptConfiguration.nmsh.palette]?.label ?? this.promptConfiguration.nmsh.palette; + const theme = themeLabel(this.promptConfiguration.nmsh.palette); const now = Date.now(); const gate = this.motionPreviewGate(); // A frame clock only while the one-shot preview is animating; none once it settles. @@ -3506,7 +3548,8 @@ export class TerminalApp { else if (destination === 'syntax') this.startSyntaxSettings(); else if (destination === 'layout') this.startLayoutSettings(); else if (destination === 'cursor') this.openCursorPanel(); - else if (destination === 'themeStudio') this.themeStudio = createThemeStudio(this.promptConfiguration.customTheme, this.promptConfiguration.nmsh.palette); + else if (destination === 'themeStudio') this.openThemeStudio(); + else if (destination === 'themeBridge') void this.openThemeBridge(); else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history' || destination === 'picker' || destination === 'navigation') this.startProviderPanel(destination); else void this.startKeyboard(); } @@ -3637,29 +3680,234 @@ export class TerminalApp { } } - private renderThemeStudioRows(state: ThemeStudioState, columns: number): string[] { - const draft: PromptConfiguration = {...this.promptConfiguration, customTheme: state.draft, nmsh: {...this.promptConfiguration.nmsh, palette: 'custom'}}; + private studioContext(): StudioContext { + const config = this.promptConfiguration; + const ref = activeThemeRef(config); + return {themes: config.themes, accent: config.nmsh.accent, ...(ref ? {activeRef: ref} : {}), pinnedTo: target => targetsPinnedTo(config.themeBridge, target)}; + } + + private openThemeStudio(tab?: StudioTab): void { + this.themeStudio = createThemeStudio(this.studioContext(), tab); + } + + /** + * The shared theme preview: the real Native renderer over synthetic context + * (project, path, Git, Node/Go/Python/Docker, Kubernetes, success and + * failure), UI text tiers and roles, and a syntax sample. Built-in, + * Imported and Custom themes all preview through this one path. + */ + private themePreviewRows(theme: CustomTheme, palette: NativePaletteId | undefined, columns: number): string[] { + const base = this.promptConfiguration; + const draft: PromptConfiguration = {...base, provider: 'nmsh', customTheme: theme, nmsh: {...base.nmsh, palette: palette ?? 'custom'}}; const width = Math.max(1, columns - 16); - const preview = state.picker || state.importPath !== undefined ? [] : this.withDraftTheme(draft, () => [ - ` ${SECONDARY}${'Preview'.padEnd(12)}${RESET}${buildThemePreviewLine(draft, 'custom', width)}${RESET}`]); - return renderThemeStudio(state, columns, this.dimensions().rows, colorLevel(), preview); + const level = colorLevel(); + const label = (text: string) => ` ${SECONDARY}${text.padEnd(12)}${RESET}`; + const fg = (hex: string) => level === 'none' ? '' : colorEscape(38, parseHexColor(hex)!, level); + const bg = (hex: string) => level === 'none' ? '' : colorEscape(48, parseHexColor(hex)!, level); + const ui = theme.ui; + return this.withDraftTheme(draft, () => { + const showcase = {...draft, modules: draft.modules.map(module => ({...module, visible: true}))}; + return [ + `${label('Prompt')}${buildThemePreviewLine(draft, draft.nmsh.palette, width)}${RESET}`, + `${label('Context')}${buildContextLine(moduleShowcaseContext(), width, showcase, 'composer', 0)}${RESET}`, + truncateAnsi(`${label('Interface')}${fg(ui.primary)}Primary ${fg(ui.secondary)}Secondary ${fg(ui.subtle)}Muted ${fg(ui.accent)}● Accent ${fg(ui.separator)}│ ${RESET}${bg(ui.selection)}${fg(ui.primary)} Selected ${RESET}`, columns), + truncateAnsi(`${label('Status')}${fg(ui.success)}${GLYPHS.success} done ${fg(ui.warning)}! warning ${fg(ui.failure)}${GLYPHS.failure} failed ${fg(ui.info)}i info${RESET}`, columns), + `${label('Syntax')}${renderSyntaxPreviewLine('git commit -m "fix" && npm test', draft.syntax, draft.nmsh.palette)}${RESET}`, + ]; + }); + } + + private renderThemeStudioRows(state: ThemeStudioState, columns: number): string[] { + const context = this.studioContext(); + const shown = previewTheme(state, context); + const rows = this.dimensions().rows; + const preview = !shown || state.editor?.picker || rows < 24 ? [] : this.themePreviewRows(shown.theme, shown.palette, columns); + return renderThemeStudio(state, context, columns, rows, colorLevel(), preview); } private handleThemeStudioKey(key: Key, state: ThemeStudioState): void { - const result = studioKey(state, key, colorLevel(), this.shellCwd); - if (!result) return; - if (result.kind === 'cancel') { this.themeStudio = undefined; this.returnFromPanel(); return; } - if (result.kind === 'export') { - try { state.message = `Exported to ${writeThemeExport(state.draft)}`; } + const action = studioKey(state, key, colorLevel(), this.shellCwd, this.studioContext()); + if (!action) return; + if (action.kind === 'close') { this.themeStudio = undefined; this.returnFromPanel(); return; } + const config = this.promptConfiguration; + if (action.kind === 'export') { + const asset = findTheme(config.themes, action.id); + if (!asset) { state.message = 'That theme no longer exists.'; return; } + try { state.message = `Exported to ${writeThemeExport(asset.theme)}`; } catch (error) { state.message = `Export failed: ${error instanceof Error ? error.message : String(error)}`; } return; } - const next = {...this.promptConfiguration, customTheme: result.theme, nmsh: {...this.promptConfiguration.nmsh, palette: 'custom' as const}}; - if (this.applySettingsConfiguration(next)) { - this.themeStudio = undefined; - this.output.addFrontendInteraction('/theme', `Custom theme ${result.theme.name} is active. Your terminal and editor colors are unchanged.`, SUCCESS); - this.startSweep('prompt', 'vivid'); + const result: ActionResult = action.kind === 'activate' ? setActiveTheme(config, action.ref) + : action.kind === 'saveTheme' ? (action.id ? saveTheme(config, action.id, action.theme) : addTheme(config, action.theme)) + : action.kind === 'importTheme' ? addTheme(config, action.theme, action.origin) + : action.kind === 'rename' ? renameTheme(config, action.id, action.name) + : action.kind === 'duplicate' ? duplicateTheme(config, action.id) + : action.kind === 'duplicateBuiltin' ? duplicateBuiltin(config, action.ref) + : deleteTheme(config, action.id, action.confirmIndependent); + if (!result.ok) { state.message = result.error; return; } + if (!this.applySettingsConfiguration(result.config)) return; + state.message = result.message; + // New and imported themes land on their tab, selected, ready to use or edit. + if (result.id && (action.kind === 'importTheme' || action.kind === 'saveTheme' || action.kind.startsWith('duplicate'))) { + const asset = findTheme(result.config.themes, result.id); + if (asset) { + const tab = asset.origin ? 'imported' : 'custom'; + state.tab = tab; + state.focus = 'list'; + state.selected[tab] = result.config.themes.filter(item => Boolean(item.origin) === Boolean(asset.origin)).findIndex(item => item.id === asset.id) + (tab === 'custom' ? 1 : 0); + } } + if (action.kind === 'activate') this.startSweep('prompt', 'vivid'); + } + + // ---- Theme Bridge ----------------------------------------------------------- + + private bridgeFacts?: Record; + private bridgeTimer?: NodeJS.Timeout; + private bridgeRun?: Promise; + /** The last Theme Bridge application problems, shown by /theme-bridge. */ + private bridgeProblems: ApplyOutcome[] = []; + + private async themeBridgeContext(): Promise { + this.bridgeFacts ??= await detectTargets(); + const config = this.promptConfiguration; + return {source: config, facts: this.bridgeFacts, level: colorLevel()}; + } + + /** + * Debounced application of the current Theme Bridge state. With every + * target Independent and nothing generated before, nothing is written at all. + */ + private scheduleThemeBridge(delay = 150): void { + if (this.bridgeTimer) clearTimeout(this.bridgeTimer); + this.bridgeTimer = setTimeout(() => { + this.bridgeTimer = undefined; + if (this.stopped) return; + const config = this.promptConfiguration; + if (!anyBridgeTargetActive(config.themeBridge) && !bridgeStateExists()) return; + this.bridgeRun = this.themeBridgeContext().then(applyThemeBridge).then(outcomes => { + this.bridgeProblems = outcomes.filter(outcome => !outcome.ok); + if (this.themeBridgePanel) this.render(); + return outcomes; + }).catch(error => { + this.bridgeProblems = [{target: 'pager', ok: false, message: error instanceof Error ? error.message : String(error)}]; + return this.bridgeProblems; + }); + }, delay); + this.bridgeTimer.unref?.(); + } + + /** OSC 7 / OSC 133 for capable hosts, held while a fullscreen program owns the terminal. */ + private readonly hostSemantics = new HostSemantics(semanticSupport(), data => { process.stdout.write(data); }, + () => this.presentationStarted && !this.stopped && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended); + private themeBridgePanel?: ThemeBridgePanelState; + private bridgeReports: TargetReport[] = []; + /** A plan shown for confirmation, kept with the spec it came from; confirming applies exactly this plan. */ + private bridgePlan?: {target: Extract; plan?: FileEditPlan; spec?: HookSpec; removal: boolean}; + + private async openThemeBridge(): Promise { + this.themeBridgePanel = createThemeBridgePanel(); + await this.refreshBridgeReports(); + this.render(); + } + + private async refreshBridgeReports(): Promise { + this.bridgeFacts ??= await detectTargets(); + this.bridgeReports = reportTargets(await this.themeBridgeContext()); + } + + private themeBridgePanelContext(): BridgePanelContext { + const config = this.promptConfiguration; + const ledger = loadLedger(); + const active = activeThemeRef(config); + return {enabled: config.themeBridge.enabled, reports: this.bridgeReports, themes: selectableThemes(config), pinned: target => config.themeBridge.targets[target].theme, ...(active ? {activeRef: active} : {}), + managed: target => target === 'tmux' || target === 'neovim' || target === 'vim' + ? {...(ledger.entries[target] && ownership(target, ledger) === 'owned' ? {artifact: artifactPath(target)} : {}), ...(ledger.entries[target]?.hook ? {include: ledger.entries[target]!.hook!.configPath} : {})} + : undefined}; + } + + private async handleThemeBridgeKey(key: Key, state: ThemeBridgePanelState): Promise { + const action = themeBridgePanelKey(state, key, this.themeBridgePanelContext()); + if (!action) { this.render(); return; } + if (action.kind === 'close') { this.themeBridgePanel = undefined; this.bridgePlan = undefined; this.returnFromPanel(); this.render(); return; } + const home = process.env.HOME || homedir(); + if (action.kind === 'setMode') { + const config = structuredClone(this.promptConfiguration); + config.themeBridge.targets[action.target] = {mode: action.mode, ...(action.theme ?? config.themeBridge.targets[action.target].theme ? {theme: action.theme ?? config.themeBridge.targets[action.target].theme} : {})}; + // Choosing a mode for a tool is the opt-in; Independent leaves the switch as it is. + if (action.mode !== 'independent') config.themeBridge.enabled = true; + if (this.applySettingsConfiguration(config)) { + // Apply now (not debounced) so the panel's status is factual when it redraws. + if (this.bridgeTimer) { clearTimeout(this.bridgeTimer); this.bridgeTimer = undefined; } + this.bridgeProblems = (await applyThemeBridge(await this.themeBridgeContext())).filter(outcome => !outcome.ok); + const problem = this.bridgeProblems.find(outcome => outcome.target === action.target); + state.message = problem?.message ?? `${BRIDGE_TARGET_LABELS[action.target]} · ${BRIDGE_MODE_LABELS[action.mode]}${action.target === 'pager' || action.target === 'lsColors' ? ' · NMSh shells apply it at their next prompt' : ''}`; + } + } else if (action.kind === 'setEnabled') { + const config = structuredClone(this.promptConfiguration); + config.themeBridge.enabled = action.enabled; + if (this.applySettingsConfiguration(config)) { + if (this.bridgeTimer) { clearTimeout(this.bridgeTimer); this.bridgeTimer = undefined; } + this.bridgeProblems = (await applyThemeBridge(await this.themeBridgeContext())).filter(outcome => !outcome.ok); + state.message = action.enabled ? 'Theme Bridge is On; each tool uses its own mode.' : 'Theme Bridge is Off; every tool is Independent and NMSh-set values are restored at the next prompt.'; + } + } else if (action.kind === 'reloadTmux') { + state.message = (await reloadTmux()).message; + } else if (action.kind === 'planHook' || action.kind === 'planRemoval') { + const target = action.target as Extract; + if (action.kind === 'planHook') { + if (this.promptConfiguration.themeBridge.targets[target].mode === 'independent') { state.message = 'Choose Follow NMSh or Choose theme first; an Independent target gets no include.'; this.render(); return; } + const spec = hookSpec(target); + if ('error' in spec) { state.message = spec.error; this.render(); return; } + const planned = planHook(spec, home); + if ('error' in planned) state.message = planned.error; + else if ('noop' in planned) { + state.message = 'The include is already in place.'; + } else { + this.bridgePlan = {target, plan: planned.plan, spec, removal: false}; + state.confirm = {kind: 'hook', target, path: spec.configPath, preview: planned.plan.preview}; + } + } else { + const planned = planHookRemoval(target, home); + if ('error' in planned && !loadLedger().entries[target]?.hook) { + // No include recorded: removal is just Independent plus the owned file. + this.bridgePlan = {target, removal: true}; + state.confirm = {kind: 'removeHook', target, path: artifactPath(target), preview: ['- (NMSh-managed file only; no config include is recorded)']}; + } else if ('error' in planned) state.message = planned.error; + else { + const hook = loadLedger().entries[target]!.hook!; + this.bridgePlan = {target, ...('plan' in planned ? {plan: planned.plan} : {}), removal: true}; + state.confirm = {kind: 'removeHook', target, path: hook.configPath, preview: 'plan' in planned ? planned.plan.preview : [' (the include is already gone from the file)']}; + } + } + } else if (action.kind === 'confirmHook' || action.kind === 'confirmRemoval') { + const pending = this.bridgePlan; + this.bridgePlan = undefined; + if (!pending || pending.target !== action.target) { state.message = 'Nothing was changed.'; this.render(); return; } + if (!pending.removal && pending.plan && pending.spec) { + const result = applyHook(pending.target, pending.plan, pending.spec); + state.message = result.ok ? `Added the include to ${pending.spec.configPath}. New ${BRIDGE_TARGET_LABELS[pending.target]} instances load NMSh colors${pending.target === 'tmux' ? '; Reload applies them to the running server' : ''}.` : result.error; + } else if (pending.removal) { + const removed = applyHookRemoval(pending.target, pending.plan); + if (!removed.ok) state.message = removed.error; + else { + const config = structuredClone(this.promptConfiguration); + config.themeBridge.targets[pending.target] = {mode: 'independent', ...(config.themeBridge.targets[pending.target].theme ? {theme: config.themeBridge.targets[pending.target].theme} : {})}; + this.applySettingsConfiguration(config); + const artifact = removeArtifact(pending.target); + state.message = artifact.ok ? `${BRIDGE_TARGET_LABELS[pending.target]} is Independent; NMSh's include and managed file are removed.` : artifact.error; + } + } + } + await this.refreshBridgeReports(); + this.render(); + } + + /** fzf `--color` for an NMSh-owned launch; empty unless fzf is the picker and its bridge mode is active. */ + private async fzfThemeArgs(): Promise { + const config = this.promptConfiguration; + if (config.picker !== 'fzf' || config.themeBridge.targets.fzf.mode === 'independent') return []; + return fzfBridgeArgs(await this.themeBridgeContext()); } // ---- Idle visuals --------------------------------------------------------------- @@ -3872,6 +4120,11 @@ export class TerminalApp { animate ||= treatmentAnimated(draft.presentation); break; } + if (draft.provider === 'none') { + rows.push(`${label('Prompt')}${SUBTLE}None · composer only${RESET}`, `${label('Composer')}${PRIMARY}git status${RESET}`); + rows.push(` ${SUBTLE}${providerExplanation('prompt', draft.provider)}${RESET}`); + break; + } // An external provider's own prompt, never the Native one standing in for it. rows.push(this.setupExternalPromptRow(draft, width - 12, label(providerLabel(draft.provider)))); rows.push(` ${SUBTLE}${providerExplanation('prompt', draft.provider)}${RESET}`, ` ${SUBTLE}${NATIVE_ONLY_NOTE}${RESET}`); @@ -4249,6 +4502,9 @@ export class TerminalApp { if (tool.executable) statuses[tool.executable] ??= status; } state.context = {...state.context, statuses}; + // Theme Bridge targets present on this system (PATH facts; cached for the session). + this.bridgeFacts ??= await detectTargets(); + state.context = {...state.context, bridgeTargets: (Object.entries(this.bridgeFacts) as Array<[BridgeTargetId, TargetFacts]>).filter(([, facts]) => facts.installed).map(([target]) => target)}; await facts; if (!this.stopped && this.setupState === state) this.render(); } @@ -4722,10 +4978,10 @@ export class TerminalApp { /** A representative history header: the live provider's identity over preview-only modules. */ private transcriptPreviewSample(): HistoricalContextSnapshot { const context = themePreviewContext(); - const prompt = this.effectivePromptProvider !== 'nmsh' && this.externalPrompt + const prompt = this.effectivePromptProvider === 'none' ? undefined : this.effectivePromptProvider !== 'nmsh' && this.externalPrompt ? this.currentPromptSnapshot() : nativePromptSnapshot(context, this.promptConfiguration); - return {cwd: context.cwd, project: context.project, branch: context.branch, prompt}; + return {cwd: context.cwd, project: context.project, branch: context.branch, ...(prompt ? {prompt} : {promptless: true as const})}; } private saveTranscriptSettings(): void { @@ -4906,11 +5162,13 @@ export class TerminalApp { private hasVisibleProviderPrompt(): boolean { + if (this.effectivePromptProvider === 'none') return false; if (this.effectivePromptProvider !== 'nmsh') return Boolean(this.externalPrompt?.text.trim()); return hasVisibleContextModule(this.promptConfiguration, this.promptContext(), isOnCommandRelevant); } private currentPromptLine(width: number, time = Date.now()): string { + if (this.effectivePromptProvider === 'none') return ''; if (this.effectivePromptProvider !== 'nmsh' && this.externalPrompt) { return this.externalPromptRow(this.externalPrompt, width, this.promptConfiguration.placement, time); } @@ -5222,7 +5480,9 @@ export class TerminalApp { const live = task.status === 'starting' || task.status === 'running' || task.status === 'waiting' || task.status === 'stopping'; const url = task.urls[0]; if (!live) return truncateAnsi(`${task.status === 'failed' ? ERROR : SUCCESS}${task.status === 'failed' ? GLYPHS.failure : GLYPHS.success}${RESET} ${SECONDARY}${task.label} ${task.status === 'failed' ? `failed · exit ${task.exitCode ?? '?'}` : 'stopped'}${RESET}`, columns); - return truncateAnsi(liveLine(task.label, [task.status === 'running' ? undefined : task.status, url].filter(Boolean).join(' · ') || undefined, task.startedAt, now, {still}), columns); + // The dev-server URL is an NMSh-authored link (safe http(s) target only) where the host supports OSC 8. + const shownUrl = url ? authoredLink(url, url, this.host.capabilities.hyperlinks && !this.passthrough) : undefined; + return closeAuthoredLinks(truncateAnsi(liveLine(task.label, [task.status === 'running' ? undefined : task.status, shownUrl].filter(Boolean).join(' · ') || undefined, task.startedAt, now, {still}), columns)); }); } @@ -6095,7 +6355,7 @@ export class TerminalApp { this.pickerOpening = true; try { const files = listProjectFiles(root).slice(0, 20_000); - const result = await openPicker(this.promptConfiguration.picker, files.map(path => ({id: path, label: path, value: path})), fallback, this.pickerHandoff); + const result = await openPicker(this.promptConfiguration.picker, files.map(path => ({id: path, label: path, value: path})), fallback, this.pickerHandoff, process.env, await this.fzfThemeArgs()); if (this.askState !== state || this.stopped) return; if (result?.kind === 'selected') await this.runInAsk(state, {kind: 'openFile', path: resolvePath(root, result.candidate.value)}); else if (result?.kind === 'fallback') fallback(); @@ -6672,6 +6932,8 @@ export class TerminalApp { } finally { this.shellSwitching = false; } this.shellId = target; this.shellJobs = 0; + // The old shell's zone ends here; the new shell's first prompt opens the next one. + this.hostSemantics.end(); this.switchedShellStarting = true; // The new shell's first prompt is readiness, not a command completion. this.presetShellReady = true; @@ -6771,6 +7033,7 @@ export class TerminalApp { private render(): void { if (this.stopped || this.passthrough || this.externalPassthrough || this.frontendSuspended) { this.cancelPresentation(); return; } + this.hostSemantics.flush(); if (this.idle) { this.paintIdle(); return; } for (const task of [this.promptPanelState?.task, this.toolsPanel?.task, this.providerPanelState?.task]) task?.setReducedMotion(!this.decorativeMotionAllowed()); if (!this.decorativeMotionAllowed()) this.effects.cancel(); @@ -7185,6 +7448,8 @@ export class TerminalApp { } private inputFirstLinePrefix(columns: number): string | undefined { + // Prompt None: the composer is only the input, with no marker in either layout. + if (this.effectivePromptProvider === 'none') return ''; if (this.promptConfiguration.composerLayout !== 'oneLine') return undefined; if (this.effectivePromptProvider !== 'nmsh' && this.externalPrompt) { const maxWidth = Math.max(0, columns - 1); diff --git a/src/appearance/AppearanceHub.ts b/src/appearance/AppearanceHub.ts index dd5a238e..be881e91 100644 --- a/src/appearance/AppearanceHub.ts +++ b/src/appearance/AppearanceHub.ts @@ -9,6 +9,9 @@ import {BLUR_MODES, handleAppearanceKey, type AppearanceState} from './Appearanc import {cursorLabel} from '../cursor/CursorPanel.js'; import {renderMotionPreview, type MotionPreview} from '../motion/MotionPreview.js'; import type {MotionGate} from '../motion/transitions.js'; +import {librarySummary} from './themeLibrary.js'; +import {providerLabel} from '../prompt/PromptPanel.js'; +import {BRIDGE_TARGETS, BRIDGE_TARGET_LABELS, effectiveMode} from '../themeBridge/model.js'; /** * /appearance: the visual hub. NMSh rows summarize and open the canonical @@ -17,7 +20,7 @@ import type {MotionGate} from '../motion/transitions.js'; * opacity/blur editing where the host supports it, and say who controls them * where it does not, so the hub is useful in every terminal. */ -export type HubDestination = 'prompt' | 'cursor' | 'chrome' | 'chroma'; +export type HubDestination = 'theme' | 'prompt' | 'cursor' | 'chrome' | 'chroma' | 'themeBridge'; export interface AppearanceHubState { view: 'hub' | 'motion' | 'motionAdvanced'; @@ -34,8 +37,10 @@ export interface AppearanceHubState { export type HubAction = {kind: 'close'} | {kind: 'open'; destination: HubDestination} | {kind: 'motion'; motion: MotionSettings} | {kind: 'saveHost'}; +/** Compact launcher: each row opens its canonical editor (/theme, /prompt, /cursor, UI chrome, /chroma, Motion, /theme-bridge). */ const NMSH_ROWS: Array<{id: HubDestination | 'motion'; label: string}> = [ - {id: 'prompt', label: 'Prompt & theme'}, {id: 'cursor', label: 'Cursor & effects'}, {id: 'chrome', label: 'UI chrome'}, {id: 'chroma', label: 'Chroma'}, {id: 'motion', label: 'Motion'}, + {id: 'theme', label: 'Theme Studio'}, {id: 'prompt', label: 'Prompt'}, {id: 'cursor', label: 'Cursor & effects'}, {id: 'chrome', label: 'UI chrome'}, + {id: 'chroma', label: 'Chroma'}, {id: 'motion', label: 'Motion'}, {id: 'themeBridge', label: 'Theme Bridge'}, ]; export function createAppearanceHub(hostName: string, host?: AppearanceState, hostGuidance?: string): AppearanceHubState { @@ -56,7 +61,7 @@ export function appearanceHubKey(state: AppearanceHubState, key: Key, configurat const count = items.length + (advanced ? 0 : 1); if (key.kind === 'escape' || key.kind === 'interrupt') { if (advanced) { state.view = 'motion'; state.selected = MOTION_ITEMS.length; state.previewStart = now; return undefined; } - state.view = 'hub'; state.selected = NMSH_ROWS.length - 1; return undefined; + state.view = 'hub'; state.selected = NMSH_ROWS.findIndex(row => row.id === 'motion'); return undefined; } if (key.kind === 'up' || key.kind === 'down') { state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + count) % count; state.previewStart = now; return undefined; } if (key.kind === 'text' && key.value.toLowerCase() === 'r') { state.previewStart = now; return undefined; } @@ -137,7 +142,10 @@ export function renderAppearanceHub(state: AppearanceHubState, configuration: Pr const motion = configuration.motion; const anyMotion = MOTION_ROWS.some(row => motion[row.key] !== 'off'); const summaries: Record = { - prompt: `${themeLabel} · ${configuration.nmsh.textColors === 'neutral' ? 'Neutral text' : 'Theme text'}`, + theme: [themeLabel, librarySummary(configuration.themes)].filter(Boolean).join(' · '), + prompt: configuration.provider === 'none' ? 'None · composer only' : `${providerLabel(configuration.provider)} · ${configuration.nmsh.textColors === 'neutral' ? 'Neutral text' : 'Theme text'}`, + themeBridge: (() => { const active = BRIDGE_TARGETS.filter(target => effectiveMode(configuration.themeBridge, target) !== 'independent'); + return active.length ? active.map(target => BRIDGE_TARGET_LABELS[target]).join(', ') : 'Off · every tool Independent'; })(), cursor: `${cursorLabel(cursor.shape)} · ${cursor.motion === 'off' ? 'no motion' : cursorLabel(cursor.motion)}${cursor.effect !== 'none' ? ` · ${cursorLabel(cursor.effect)}` : ''} · ${cursorBackend}`, chrome: configuration.uiChrome.source === 'theme' ? 'Follow theme' : 'Custom', chroma: configuration.presentation.preset === 'off' ? 'Off' : `${configuration.presentation.preset} · ${configuration.presentation.motion}`, diff --git a/src/appearance/ThemeStudio.ts b/src/appearance/ThemeStudio.ts index 8b023e2b..72d0b0f8 100644 --- a/src/appearance/ThemeStudio.ts +++ b/src/appearance/ThemeStudio.ts @@ -1,6 +1,6 @@ import {mkdirSync, readFileSync, renameSync, statSync, writeFileSync} from 'node:fs'; import {homedir} from 'node:os'; -import {isAbsolute, join, resolve} from 'node:path'; +import {basename, isAbsolute, join, resolve} from 'node:path'; import type {Key} from '../terminal/keys.js'; import type {ColorLevel} from '../presentation/capabilities.js'; import {colorEscape} from '../chroma/escape.js'; @@ -9,55 +9,64 @@ import {THEME_PALETTE_IDS, type NativePaletteId} from '../prompt/configuration.j import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; import {nmshConfigDirectory} from '../configuration/paths.js'; import {editText} from '../ui/formControls.js'; -import {framePanel} from '../ui/PanelShell.js'; +import {framePanel, renderTabStrip} from '../ui/PanelShell.js'; import {renderControls} from '../ui/controls.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {GLYPHS} from '../ui/glyphs.js'; import {colorPickerKey, createColorPicker, renderColorPicker, type ColorPickerState} from '../ui/ColorPicker.js'; -import {padCells, truncateAnsi} from '../util/text.js'; +import {padCells, truncateAnsi, truncateText} from '../util/text.js'; import { - exportTheme, importTheme, PROMPT_THEME_ROLES, ROLE_LABELS, themeSlug, UI_THEME_ROLES, type CustomTheme, type PromptThemeRole, - type ThemeImport, type UiThemeRole, + exportTheme, PROMPT_THEME_ROLES, ROLE_LABELS, themeSlug, UI_THEME_ROLES, type CustomTheme, type PromptThemeRole, type UiThemeRole, } from './customTheme.js'; import {cloneFromPalette} from './themeSelection.js'; import {defaultUiColors} from './uiTheme.js'; import {hexColor} from '../chroma/color.js'; +import {categoryOf, findTheme, IMPORTER_VERSION, provenanceLabel, type ThemeAsset, type ThemeOrigin} from './themeLibrary.js'; +import {assetRef, builtinRef, builtinTheme, type ThemeRef} from './themeRefs.js'; +import {IMPORT_FORMAT_CHOICES, IMPORT_SIZE_LIMIT, importFormatLabel, importThemeSource, type ImportOutcome, type ImportPreview} from './themeImporters.js'; +import {BRIDGE_TARGET_LABELS, type BridgeTargetId} from '../themeBridge/model.js'; +import type {CatppuccinAccent} from './themeFamilies.js'; /** - * Theme Studio: the custom Native theme editor behind /theme. It edits a - * draft; Save applies it as the Custom theme through the normal configuration - * path, Esc discards. Import validates and previews before anything is used; - * Export writes NMSh Theme JSON. Imported data is never executed. + * Theme Studio (/theme): create, edit, import, manage and select NMSh Native + * theme assets. Built-in themes are immutable; Imported and Custom themes + * are the same Native assets edited by the same editor and rendered by the + * same renderer (imported is provenance only). The studio never writes the + * configuration itself: it returns actions that TerminalApp applies through + * the library actions and the normal configuration path. Import parses and + * previews first; cancel stores nothing. */ -type StudioRow = +// ---- Color editor ----------------------------------------------------------- + +type EditorRow = | {kind: 'name'} | {kind: 'basedOn'} | {kind: 'dark'} | {kind: 'role'; group: 'prompt' | 'ui'; role: PromptThemeRole | UiThemeRole} - | {kind: 'import'} - | {kind: 'export'} | {kind: 'reset'} | {kind: 'save'}; -export const STUDIO_ROWS: readonly StudioRow[] = [ +export const STUDIO_ROWS: readonly EditorRow[] = [ {kind: 'name'}, {kind: 'basedOn'}, {kind: 'dark'}, ...PROMPT_THEME_ROLES.map(role => ({kind: 'role' as const, group: 'prompt' as const, role})), ...UI_THEME_ROLES.map(role => ({kind: 'role' as const, group: 'ui' as const, role})), - {kind: 'import'}, {kind: 'export'}, {kind: 'reset'}, {kind: 'save'}, + {kind: 'reset'}, {kind: 'save'}, ]; -export interface ThemeStudioState { +export interface ThemeEditorState { draft: CustomTheme; - /** The custom theme in effect when the studio opened, if any. */ + /** The asset being edited; undefined creates a new Custom theme on save. */ + assetId?: string; + /** The asset's saved theme, if any. */ saved?: CustomTheme; + /** Provenance line for imported assets (display only). */ + provenance?: string; selected: number; /** The clone source shown on the Based on row; Enter clones it. */ base: NativePaletteId; - picker?: {row: StudioRow & {kind: 'role'}; state: ColorPickerState}; + picker?: {row: EditorRow & {kind: 'role'}; state: ColorPickerState}; editingName?: string; - importPath?: string; - importPreview?: ThemeImport; message?: string; /** A pending Reset to base that would discard unsaved draft edits. */ confirmReset?: boolean; @@ -65,13 +74,18 @@ export interface ThemeStudioState { export const STUDIO_MIN_SIZE = {columns: 56, rows: 18} as const; -export function createThemeStudio(current: CustomTheme | undefined, palette: NativePaletteId): ThemeStudioState { +/** An editor over a theme; `current` undefined starts a new Custom theme cloned from `palette`. */ +export function createThemeEditor(current: CustomTheme | undefined, palette: NativePaletteId, asset?: ThemeAsset): ThemeEditorState { const draft = current ? structuredClone(current) : cloneFromPalette(palette); - // An existing custom theme resets to the theme it was based on, found by its recorded name. + // An existing theme resets to the theme it was based on, found by its recorded name. const recorded = current?.basedOn ? THEME_PALETTE_IDS.find(id => id !== 'custom' && NATIVE_PROMPT_THEMES[id].label === current.basedOn) : undefined; - return {draft, ...(current ? {saved: structuredClone(current)} : {}), selected: 0, base: recorded ?? (palette === 'custom' ? 'lavender' : palette)}; + return {draft, ...(current ? {saved: structuredClone(current)} : {}), ...(asset ? {assetId: asset.id, provenance: provenanceLabel(asset)} : {}), + selected: 0, base: recorded ?? (palette === 'custom' ? 'lavender' : palette)}; } +/** Back-compatible name for a single editor (tests and callers that only edit). */ +export const createThemeStudioEditor = createThemeEditor; + export function themeDefaults(): Record { const ui = defaultUiColors(); return {accent: hexColor(ui.accent), primary: hexColor(ui.primary), secondary: hexColor(ui.secondary), subtle: hexColor(ui.subtle), @@ -84,6 +98,11 @@ export function themesDirectory(env: NodeJS.ProcessEnv = process.env): string { return join(nmshConfigDirectory(env), 'themes'); } +/** + * Portable NMSh Theme JSON for a theme: the Native theme itself. Library + * provenance (and so any local source path) is not part of the theme data, + * so it can never leak into an export. + */ export function writeThemeExport(theme: CustomTheme, directory = themesDirectory()): string { mkdirSync(directory, {recursive: true, mode: 0o700}); const path = join(directory, `${themeSlug(theme.name)}.nmsh-theme.json`); @@ -93,63 +112,64 @@ export function writeThemeExport(theme: CustomTheme, directory = themesDirectory return path; } -function expandPath(input: string, cwd: string): string { +export function expandPath(input: string, cwd: string): string { const text = input.trim(); if (text === '~' || text.startsWith('~/')) return join(homedir(), text.slice(1)); return isAbsolute(text) ? text : resolve(cwd, text); } -/** Reads a theme file for preview: bounded size, parsed as data only. */ -export function readThemeImport(input: string, cwd: string): ThemeImport | {errors: string[]} { +/** Reads a local theme file for preview: bounded size, parsed as data only, never stored here. */ +export function readThemeImport(input: string, cwd: string, format: (typeof IMPORT_FORMAT_CHOICES)[number] = 'auto'): (ImportPreview & {path: string}) | {errors: string[]} { const path = expandPath(input, cwd); try { const stat = statSync(path); if (!stat.isFile()) return {errors: [`${path} is not a file.`]}; - if (stat.size > 256 * 1024) return {errors: ['File is larger than 256 KiB.']}; - return importTheme(readFileSync(path, 'utf8'), themeDefaults()); + if (stat.size > IMPORT_SIZE_LIMIT) return {errors: ['File is larger than 256 KiB.']}; + const outcome: ImportOutcome = importThemeSource(readFileSync(path, 'utf8'), basename(path), themeDefaults(), builtinTheme('lavender'), format); + return 'errors' in outcome ? outcome : {...outcome, path}; } catch (error) { return {errors: [`Could not read ${path}: ${(error as NodeJS.ErrnoException).code ?? 'error'}.`]}; } } -/** The selected base theme's colors, keeping the draft's name. */ -function baseTheme(state: ThemeStudioState): CustomTheme { +/** Provenance recorded for an import; the path stays local (never exported). */ +export function importOrigin(preview: ImportPreview & {path: string}, now = new Date()): ThemeOrigin { + return {kind: preview.format, ...(preview.sourceName ? {sourceName: preview.sourceName} : {}), sourcePath: preview.path, + importerVersion: IMPORTER_VERSION, importedAt: now.toISOString()}; +} + +function baseTheme(state: ThemeEditorState): CustomTheme { return cloneFromPalette(state.base, undefined, state.draft.name); } /** Draft colors differ from the base: a reset would discard edits. */ -export function draftDiffersFromBase(state: ThemeStudioState): boolean { +export function draftDiffersFromBase(state: ThemeEditorState): boolean { const base = baseTheme(state); return JSON.stringify([state.draft.prompt, state.draft.ui, state.draft.dark]) !== JSON.stringify([base.prompt, base.ui, base.dark]); } -/** - * Reset to base: the draft's colors become the selected Based on theme again. - * Only the draft changes; Save & use stays the one persistence point and Esc - * still abandons everything, leaving the saved custom theme untouched. - */ -export function resetDraftToBase(state: ThemeStudioState): void { +/** Reset to base: only the draft changes; Save stays the one persistence point and Esc abandons everything. */ +export function resetDraftToBase(state: ThemeEditorState): void { const base = baseTheme(state); state.draft = {...state.draft, prompt: base.prompt, ui: base.ui, dark: base.dark, basedOn: base.basedOn}; state.confirmReset = false; - state.message = `Draft reset to ${NATIVE_PROMPT_THEMES[state.base].label}. Save & use to keep it; Esc abandons the draft.`; + state.message = `Draft reset to ${NATIVE_PROMPT_THEMES[state.base].label}. Save to keep it; Esc abandons the draft.`; } -/** One role back to its base color (R on a color row). */ -export function resetRoleToBase(state: ThemeStudioState, row: StudioRow & {kind: 'role'}): void { +export function resetRoleToBase(state: ThemeEditorState, row: EditorRow & {kind: 'role'}): void { const base = baseTheme(state); if (row.group === 'prompt') state.draft.prompt[row.role as PromptThemeRole] = base.prompt[row.role as PromptThemeRole]; else state.draft.ui[row.role as UiThemeRole] = base.ui[row.role as UiThemeRole]; state.message = `${ROLE_LABELS[row.role]} reset to ${NATIVE_PROMPT_THEMES[state.base].label}.`; } -export type StudioResult = {kind: 'cancel'} | {kind: 'save'; theme: CustomTheme} | {kind: 'export'} | undefined; +export type EditorResult = {kind: 'cancel'} | {kind: 'save'; theme: CustomTheme} | undefined; -function roleColor(theme: CustomTheme, row: StudioRow & {kind: 'role'}): string { +function roleColor(theme: CustomTheme, row: EditorRow & {kind: 'role'}): string { return row.group === 'prompt' ? theme.prompt[row.role as PromptThemeRole] : theme.ui[row.role as UiThemeRole]; } -export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, cwd: string): StudioResult { +export function editorKey(state: ThemeEditorState, key: Key, level: ColorLevel): EditorResult { state.message = undefined; if (state.picker) { const outcome = colorPickerKey(state.picker.state, key, level); @@ -174,26 +194,6 @@ export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, } return undefined; } - if (state.importPath !== undefined) { - if (state.importPreview) { - if (key.kind === 'enter') { - state.draft = state.importPreview.theme; - state.message = `Imported ${state.importPreview.theme.name}. Save to use it.`; - state.importPreview = undefined; state.importPath = undefined; - } else if (key.kind === 'escape' || key.kind === 'interrupt') { state.importPreview = undefined; } - return undefined; - } - if (key.kind === 'escape' || key.kind === 'interrupt') state.importPath = undefined; - else if (key.kind === 'enter') { - const result = readThemeImport(state.importPath, cwd); - if ('errors' in result) state.message = result.errors.join(' '); - else state.importPreview = result; - } else { - const next = editText(state.importPath, key); - if (next !== undefined) state.importPath = next; - } - return undefined; - } if (state.confirmReset) { if (key.kind === 'enter' || (key.kind === 'text' && key.value.toLowerCase() === 'y')) resetDraftToBase(state); else if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value.toLowerCase() === 'n')) state.confirmReset = false; @@ -201,7 +201,7 @@ export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, } if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'cancel'}; if (key.kind === 'text' && key.value.toLowerCase() === 'r' && STUDIO_ROWS[state.selected]?.kind === 'role') { - resetRoleToBase(state, STUDIO_ROWS[state.selected] as StudioRow & {kind: 'role'}); + resetRoleToBase(state, STUDIO_ROWS[state.selected] as EditorRow & {kind: 'role'}); return undefined; } if (key.kind === 'up') state.selected = (state.selected + STUDIO_ROWS.length - 1) % STUDIO_ROWS.length; @@ -217,78 +217,375 @@ export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, case 'name': state.editingName = state.draft.name; break; case 'basedOn': case 'reset': - // Same operation from either row; unsaved edits are confirmed first. if (draftDiffersFromBase(state)) state.confirmReset = true; else resetDraftToBase(state); break; case 'dark': state.draft.dark = !state.draft.dark; break; case 'role': state.picker = {row, state: createColorPicker(roleColor(state.draft, row), level)}; break; - case 'import': state.importPath = ''; break; - case 'export': return {kind: 'export'}; case 'save': return {kind: 'save', theme: structuredClone(state.draft)}; } } return undefined; } -export function renderThemeStudio(state: ThemeStudioState, columns: number, height: number, level: ColorLevel, preview: readonly string[]): string[] { +const RESET = '\u001B[0m'; + +function swatches(colors: readonly string[], level: ColorLevel): string { + if (level === 'none') return ''; + return colors.map(hex => `${colorEscape(48, parseHexColor(hex)!, level)} ${RESET}`).join(''); +} + +export function renderThemeEditor(state: ThemeEditorState, columns: number, height: number, level: ColorLevel, preview: readonly string[], title = 'Theme Studio'): string[] { const primary = foreground(UI_COLORS.primary); const secondary = foreground(UI_COLORS.secondary); const subtle = foreground(UI_COLORS.subtle); const accent = foreground(UI_COLORS.accent); - const reset = '\u001B[0m'; - const out: string[] = [` ${primary}Theme Studio${reset} ${subtle}custom Native theme · colors NMSh-owned UI only${reset}`, '']; + const heading = state.assetId ? `Edit · ${state.draft.name}` : 'New custom theme'; + const out: string[] = [` ${primary}${title} › ${heading}${RESET} ${subtle}${state.provenance ?? 'NMSh Native theme · colors NMSh-owned UI only'}${RESET}`, '']; if (state.picker) { - const label = `${ROLE_LABELS[state.picker.row.role]} ${subtle}(${state.picker.row.group === 'prompt' ? 'prompt' : 'interface'})${reset}`; + const label = `${ROLE_LABELS[state.picker.row.role]} ${subtle}(${state.picker.row.group === 'prompt' ? 'prompt' : 'interface'})${RESET}`; out.push(...renderColorPicker(state.picker.state, label, columns, level, {columns: Math.min(40, columns - 8), rows: Math.max(3, Math.min(8, height - 16))})); - return framePanel(out.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); - } - if (state.importPreview) { - const theme = state.importPreview.theme; - out.push(` ${primary}Import preview · ${theme.name}${reset} ${subtle}${state.importPreview.format === 'nmsh' ? 'NMSh Theme JSON' : state.importPreview.format === 'base16' ? 'Base16' : 'Windows Terminal scheme'}${reset}`, ''); - out.push(` ${PROMPT_THEME_ROLES.map(role => `${colorEscape(48, parseHexColor(theme.prompt[role])!, level)} ${reset}`).join('')} ${subtle}prompt roles${reset}`); - out.push(` ${UI_THEME_ROLES.map(role => `${colorEscape(48, parseHexColor(theme.ui[role])!, level)} ${reset}`).join('')} ${subtle}interface roles${reset}`); - for (const warning of state.importPreview.warnings) out.push(` ${subtle}${warning}${reset}`); - out.push('', renderControls([['Enter', 'use as draft'], ['Esc', 'back']])); - return framePanel(out.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); + return out.map(row => truncateAnsi(row, columns)); } - if (state.importPath !== undefined) { - out.push(` ${primary}Import a theme file${reset}`, ` ${subtle}NMSh Theme JSON, Base16 (YAML/JSON) or a Windows Terminal color scheme. Nothing is applied until you save.${reset}`, '', - ` Path ${primary}${state.importPath}${reset}${accent}_${reset}`); - if (state.message) out.push('', ` ${secondary}${state.message}${reset}`); - out.push('', renderControls([['Enter', 'preview'], ['Esc', 'back']])); - return framePanel(out.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); - } - const swatch = (hex: string) => `${colorEscape(48, parseHexColor(hex)!, level)} ${reset}`; const lines = STUDIO_ROWS.map((row, index) => { - const pointer = index === state.selected ? `${accent}${GLYPHS.selection}${reset}` : ' '; - const label = (text: string) => `${index === state.selected ? primary : secondary}${padCells(text, 16)}${reset}`; + const pointer = index === state.selected ? `${accent}${GLYPHS.selection}${RESET}` : ' '; + const label = (text: string) => `${index === state.selected ? primary : secondary}${padCells(text, 16)}${RESET}`; switch (row.kind) { - case 'name': return `${pointer} ${label('Name')}${state.editingName !== undefined ? `${primary}${state.editingName}${accent}_${reset}` : state.draft.name}`; - case 'basedOn': return `${pointer} ${label('Based on')}${NATIVE_PROMPT_THEMES[state.base].label} ${subtle}←→ choose · Enter reset draft to it${reset}`; + case 'name': return `${pointer} ${label('Name')}${state.editingName !== undefined ? `${primary}${state.editingName}${accent}_${RESET}` : state.draft.name}`; + case 'basedOn': return `${pointer} ${label('Based on')}${NATIVE_PROMPT_THEMES[state.base].label} ${subtle}←→ choose · Enter reset draft to it${RESET}`; case 'dark': return `${pointer} ${label('Text tiers')}${state.draft.dark ? 'Dark terminal' : 'Keep NMSh text colors'}`; case 'role': { const hex = roleColor(state.draft, row); - return `${pointer} ${label(`${row.group === 'ui' ? 'UI ' : ''}${ROLE_LABELS[row.role]}`)}${level === 'none' ? '' : `${swatch(hex)} `}${hex}`; + return `${pointer} ${label(`${row.group === 'ui' ? 'UI ' : ''}${ROLE_LABELS[row.role]}`)}${level === 'none' ? '' : `${swatches([hex], level)} `}${hex}`; } - case 'import': return `${pointer} ${label('Import')}${subtle}NMSh Theme JSON, Base16, Windows Terminal ›${reset}`; - case 'export': return `${pointer} ${label('Export')}${subtle}${themesDirectory()} ›${reset}`; - case 'reset': return `${pointer} ${label('Reset to base')}${subtle}draft colors back to ${NATIVE_PROMPT_THEMES[state.base].label}; saved theme unchanged${reset}`; - case 'save': return `${pointer} ${label('Save & use')}${subtle}apply as the Custom theme${reset}`; + case 'reset': return `${pointer} ${label('Reset to base')}${subtle}draft colors back to ${NATIVE_PROMPT_THEMES[state.base].label}; saved theme unchanged${RESET}`; + case 'save': return `${pointer} ${label('Save')}${subtle}${state.assetId ? 'save this theme' : 'add to Custom themes'}${RESET}`; } }); // A window around the selection keeps the list usable on short terminals. - const budget = Math.max(3, height - 7 - preview.length - (state.message ? 2 : 0)); + const budget = Math.max(3, height - 5 - preview.length - (state.message ? 2 : 0)); const start = Math.max(0, Math.min(state.selected - Math.floor(budget / 2), lines.length - budget)); out.push(...lines.slice(start, start + budget)); - if (preview.length) out.push('', ...preview); + if (preview.length && height - 5 - preview.length >= 3) out.push('', ...preview); if (state.confirmReset) { - out.push('', ` ${primary}Reset the draft to ${NATIVE_PROMPT_THEMES[state.base].label}? Unsaved color edits are discarded; the saved theme is unchanged.${reset}`); + out.push('', ` ${primary}Reset the draft to ${NATIVE_PROMPT_THEMES[state.base].label}? Unsaved color edits are discarded; the saved theme is unchanged.${RESET}`); out.push('', renderControls([['Enter', 'reset draft'], ['Esc', 'keep editing']])); - return framePanel(out.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); + return out.map(row => truncateAnsi(row, columns)); } - if (state.message) out.push('', ` ${secondary}${state.message}${reset}`); + if (state.message) out.push('', ` ${secondary}${state.message}${RESET}`); const onRole = STUDIO_ROWS[state.selected]?.kind === 'role'; - out.push('', renderControls([['↑↓', 'select'], ['Enter', 'edit/open'], ['←→', 'change'], ...(onRole ? [['R', 'reset role'] as [string, string]] : []), ['Esc', 'cancel']])); - return framePanel(out.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); + out.push('', renderControls([['↑↓', 'select'], ['Enter', 'edit'], ['←→', 'change'], ...(onRole ? [['R', 'reset role'] as [string, string]] : []), ['Esc', 'back']])); + return out.map(row => truncateAnsi(row, columns)); +} + +// ---- Library (tabs) --------------------------------------------------------- + +export const STUDIO_TABS = ['Built-in', 'Imported', 'Custom', 'Import'] as const; +export type StudioTab = 'builtin' | 'imported' | 'custom' | 'import'; +const TAB_IDS: readonly StudioTab[] = ['builtin', 'imported', 'custom', 'import']; + +/** What the studio sees of the configuration; it never edits it. */ +export interface StudioContext { + themes: readonly ThemeAsset[]; + activeRef?: ThemeRef; + accent: CatppuccinAccent; + /** Theme Bridge targets pinned to each reference, for delete warnings and labels. */ + pinnedTo: (ref: ThemeRef) => BridgeTargetId[]; +} + +export interface ThemeStudioState { + tab: StudioTab; + focus: 'tabs' | 'list'; + /** Selected row per library tab. */ + selected: Record, number>; + editor?: ThemeEditorState; + rename?: {id: string; text: string}; + confirmDelete?: {id: string; name: string; pinned: BridgeTargetId[]}; + /** Import tab: format choice, path, and the parsed preview awaiting confirmation. */ + importFormat: number; + importField: 'format' | 'path'; + importPath: string; + importPreview?: ImportPreview & {path: string}; + message?: string; +} + +export type StudioAction = + | {kind: 'close'} + | {kind: 'activate'; ref: ThemeRef} + | {kind: 'saveTheme'; id?: string; theme: CustomTheme} + | {kind: 'importTheme'; theme: CustomTheme; origin: ThemeOrigin} + | {kind: 'rename'; id: string; name: string} + | {kind: 'duplicate'; id: string} + | {kind: 'duplicateBuiltin'; ref: ThemeRef} + | {kind: 'delete'; id: string; confirmIndependent: boolean} + | {kind: 'export'; id: string}; + +const BUILTIN_PALETTES = THEME_PALETTE_IDS.filter((id): id is Exclude => id !== 'custom'); + +export function createThemeStudio(context: StudioContext, tab: StudioTab = 'builtin'): ThemeStudioState { + const state: ThemeStudioState = {tab, focus: 'list', selected: {builtin: 0, imported: 0, custom: 0}, importFormat: 0, importField: 'path', importPath: ''}; + // Open on the active theme where it lives. + const active = context.activeRef; + const asset = active?.startsWith('asset:') ? findTheme(context.themes, active.slice(6)) : undefined; + if (asset) { + state.tab = categoryOf(asset); + state.selected[state.tab] = libraryItems(context, state.tab).findIndex(item => item.id === asset.id) + (state.tab === 'custom' ? 1 : 0); + } else if (active?.startsWith('builtin:')) { + const palette = active.slice(8).split('@')[0]; + state.selected.builtin = Math.max(0, BUILTIN_PALETTES.indexOf(palette as Exclude)); + } + if (tab !== 'builtin') state.tab = tab; + return state; +} + +function libraryItems(context: StudioContext, tab: 'imported' | 'custom'): ThemeAsset[] { + return context.themes.filter(asset => categoryOf(asset) === tab); +} + +function rowCount(state: ThemeStudioState, context: StudioContext): number { + if (state.tab === 'builtin') return BUILTIN_PALETTES.length; + if (state.tab === 'imported') return libraryItems(context, 'imported').length; + if (state.tab === 'custom') return libraryItems(context, 'custom').length + 1; + return 0; +} + +/** The asset under the selection on the Imported/Custom tabs (Custom row 0 is "New custom theme"). */ +export function selectedAsset(state: ThemeStudioState, context: StudioContext): ThemeAsset | undefined { + if (state.tab === 'imported') return libraryItems(context, 'imported')[state.selected.imported]; + if (state.tab === 'custom') return state.selected.custom === 0 ? undefined : libraryItems(context, 'custom')[state.selected.custom - 1]; + return undefined; +} + +export function selectedBuiltinRef(state: ThemeStudioState, context: StudioContext): ThemeRef { + return builtinRef(BUILTIN_PALETTES[state.selected.builtin] ?? 'lavender', context.accent); +} + +/** The theme the studio is showing: the editor draft, the import preview, or the selected row. */ +export function previewTheme(state: ThemeStudioState, context: StudioContext): {theme: CustomTheme; palette?: Exclude} | undefined { + if (state.editor) return {theme: state.editor.draft}; + if (state.tab === 'import') return state.importPreview ? {theme: state.importPreview.theme} : undefined; + if (state.tab === 'builtin') { + const palette = BUILTIN_PALETTES[state.selected.builtin] ?? 'lavender'; + return {theme: builtinTheme(palette, context.accent), palette}; + } + const asset = selectedAsset(state, context); + return asset ? {theme: asset.theme} : undefined; +} + +function switchTab(state: ThemeStudioState, delta: number): void { + state.tab = TAB_IDS[(TAB_IDS.indexOf(state.tab) + delta + TAB_IDS.length) % TAB_IDS.length]!; + state.message = undefined; +} + +export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, cwd: string, context: StudioContext): StudioAction | undefined { + if (state.editor) { + const result = editorKey(state.editor, key, level); + if (result?.kind === 'cancel') { state.editor = undefined; return undefined; } + if (result?.kind === 'save') { + const id = state.editor.assetId; + state.editor = undefined; + return {kind: 'saveTheme', ...(id ? {id} : {}), theme: result.theme}; + } + return undefined; + } + if (state.rename) { + if (key.kind === 'escape' || key.kind === 'interrupt') state.rename = undefined; + else if (key.kind === 'enter') { + const {id, text} = state.rename; + state.rename = undefined; + return {kind: 'rename', id, name: text}; + } else { + const next = editText(state.rename.text, key); + if (next !== undefined) state.rename.text = next.replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').slice(0, 48); + } + return undefined; + } + if (state.confirmDelete) { + const {id, pinned} = state.confirmDelete; + if (key.kind === 'enter' || (key.kind === 'text' && key.value.toLowerCase() === 'y')) { + state.confirmDelete = undefined; + return {kind: 'delete', id, confirmIndependent: pinned.length > 0}; + } + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value.toLowerCase() === 'n')) state.confirmDelete = undefined; + return undefined; + } + state.message = undefined; + if (state.tab === 'import') return importKey(state, key, cwd); + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (key.kind === 'left' || key.kind === 'right') { switchTab(state, key.kind === 'left' ? -1 : 1); return undefined; } + if (state.focus === 'tabs') { + if (key.kind === 'down' || key.kind === 'enter') state.focus = 'list'; + return undefined; + } + const count = rowCount(state, context); + const tab = state.tab; + if (key.kind === 'up' || key.kind === 'down') { + if (key.kind === 'up' && state.selected[tab] === 0) { state.focus = 'tabs'; return undefined; } + if (count) state.selected[tab] = Math.max(0, Math.min(count - 1, state.selected[tab] + (key.kind === 'up' ? -1 : 1))); + return undefined; + } + const letter = key.kind === 'text' ? key.value.toLowerCase() : ''; + if (tab === 'builtin') { + const ref = selectedBuiltinRef(state, context); + if (key.kind === 'enter') return {kind: 'activate', ref}; + if (letter === 'd') return {kind: 'duplicateBuiltin', ref}; + if (letter === 'e') { + // Built-ins are immutable: editing starts a new Custom theme from it. + const palette = BUILTIN_PALETTES[state.selected.builtin] ?? 'lavender'; + state.editor = createThemeEditor(undefined, palette); + state.editor.draft = {...builtinTheme(palette, context.accent), name: `My ${builtinTheme(palette, context.accent).name}`.slice(0, 48)}; + } + return undefined; + } + if (tab === 'custom' && state.selected.custom === 0) { + if (key.kind === 'enter' || letter === 'n') { + const base = context.activeRef?.startsWith('builtin:') ? context.activeRef.slice(8).split('@')[0] as NativePaletteId : 'lavender'; + state.editor = createThemeEditor(undefined, base); + } + return undefined; + } + const asset = selectedAsset(state, context); + if (!asset) { + if (key.kind === 'enter' && tab === 'imported') { state.tab = 'import'; } + return undefined; + } + if (key.kind === 'enter') return {kind: 'activate', ref: assetRef(asset.id)}; + if (letter === 'e') state.editor = createThemeEditor(asset.theme, 'lavender', asset); + else if (letter === 'n') state.rename = {id: asset.id, text: asset.theme.name}; + else if (letter === 'd') return {kind: 'duplicate', id: asset.id}; + else if (letter === 'x') return {kind: 'export', id: asset.id}; + else if (key.kind === 'delete' || key.kind === 'backspace' || letter === 'r') { + if (context.activeRef === assetRef(asset.id)) state.message = `${asset.theme.name} is the active theme. Set another theme active first.`; + else state.confirmDelete = {id: asset.id, name: asset.theme.name, pinned: context.pinnedTo(assetRef(asset.id))}; + } + return undefined; +} + +function importKey(state: ThemeStudioState, key: Key, cwd: string): StudioAction | undefined { + if (state.importPreview) { + if (key.kind === 'enter') { + const preview = state.importPreview; + state.importPreview = undefined; + state.importPath = ''; + return {kind: 'importTheme', theme: preview.theme, origin: importOrigin(preview)}; + } + // Cancel stores nothing: the parsed preview is simply dropped. + if (key.kind === 'escape' || key.kind === 'interrupt') { state.importPreview = undefined; state.message = 'Import cancelled; nothing was saved.'; } + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (state.focus === 'tabs') { + if (key.kind === 'left' || key.kind === 'right') switchTab(state, key.kind === 'left' ? -1 : 1); + else if (key.kind === 'down' || key.kind === 'enter') { state.focus = 'list'; state.importField = 'format'; } + return undefined; + } + if (key.kind === 'up' || key.kind === 'down') { + if (key.kind === 'up' && state.importField === 'format') state.focus = 'tabs'; + else state.importField = key.kind === 'up' ? 'format' : 'path'; + return undefined; + } + if (state.importField === 'format') { + if (key.kind === 'left' || key.kind === 'right') { + state.importFormat = (state.importFormat + (key.kind === 'left' ? -1 : 1) + IMPORT_FORMAT_CHOICES.length) % IMPORT_FORMAT_CHOICES.length; + } else if (key.kind === 'enter') state.importField = 'path'; + return undefined; + } + if (key.kind === 'enter') { + if (!state.importPath.trim()) { state.message = 'Type the path of a local theme file.'; return undefined; } + const result = readThemeImport(state.importPath, cwd, IMPORT_FORMAT_CHOICES[state.importFormat]); + if ('errors' in result) state.message = result.errors.join(' '); + else state.importPreview = result; + return undefined; + } + if ((key.kind === 'left' || key.kind === 'right') && !state.importPath) { switchTab(state, key.kind === 'left' ? -1 : 1); return undefined; } + const next = editText(state.importPath, key); + if (next !== undefined) state.importPath = next.replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').slice(0, 1024); + return undefined; +} + +/** + * The /theme panel. `preview` rows come from TerminalApp's real Native + * renderer for whatever previewTheme() returns, so every category previews + * identically. + */ +export function renderThemeStudio(state: ThemeStudioState, context: StudioContext, columns: number, height: number, level: ColorLevel, preview: readonly string[]): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const finish = (rows: string[]) => framePanel(rows.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); + if (state.editor) return finish(renderThemeEditor(state.editor, columns, height - 1, level, preview)); + const head = [renderTabStrip(STUDIO_TABS, TAB_IDS.indexOf(state.tab), columns, state.focus === 'tabs'), '']; + const body: string[] = []; + const controls: Array<[string, string]> = []; + const active = (ref: ThemeRef) => context.activeRef === ref ? ` ${accent}● active${RESET}` : ''; + const pinned = (ref: ThemeRef) => { + const targets = context.pinnedTo(ref); + return targets.length ? ` ${subtle}bridge: ${targets.map(target => BRIDGE_TARGET_LABELS[target]).join(', ')}${RESET}` : ''; + }; + const listBudget = Math.max(2, height - head.length - preview.length - 6); + const window = (items: readonly T[], selected: number) => { + const start = Math.max(0, Math.min(selected - Math.floor(listBudget / 2), items.length - listBudget)); + return {start, items: items.slice(start, start + listBudget)}; + }; + const mark = (selected: boolean) => selected && state.focus === 'list' ? `${accent}${GLYPHS.selection}${RESET}` : ' '; + + if (state.tab === 'builtin') { + body.push(` ${subtle}NMSh and bundled theme families. Built-ins are immutable; duplicate one to edit it.${RESET}`); + const {start, items} = window(BUILTIN_PALETTES, state.selected.builtin); + items.forEach((palette, offset) => { + const index = start + offset; + const theme = builtinTheme(palette, context.accent); + const ref = builtinRef(palette, context.accent); + body.push(`${mark(index === state.selected.builtin)} ${index === state.selected.builtin ? primary : secondary}${padCells(theme.name, 26)}${RESET}${swatches(Object.values(theme.prompt).slice(0, 6), level)}${active(ref)}${pinned(ref)}`); + }); + controls.push(['Enter', 'set active'], ['D', 'duplicate to Custom'], ['E', 'edit a copy']); + } else if (state.tab === 'imported' || state.tab === 'custom') { + const items = libraryItems(context, state.tab); + const rows: Array<{label: string; detail: string; ref?: ThemeRef; colors?: string[]}> = state.tab === 'custom' + ? [{label: '+ New custom theme', detail: 'from the active built-in theme'}] : []; + rows.push(...items.map(asset => ({label: asset.theme.name, detail: provenanceLabel(asset), ref: assetRef(asset.id), colors: Object.values(asset.theme.prompt).slice(0, 6)}))); + if (!rows.length) body.push(` ${subtle}No imported themes yet. Open the Import tab to bring in a theme file.${RESET}`); + const selected = state.selected[state.tab]; + const {start, items: shown} = window(rows, selected); + shown.forEach((row, offset) => { + const index = start + offset; + body.push(`${mark(index === selected)} ${index === selected ? primary : secondary}${padCells(truncateText(row.label, 26), 26)}${RESET}${row.colors ? swatches(row.colors, level) : ''}${row.ref ? `${active(row.ref)}${pinned(row.ref)}` : ''}`); + }); + const asset = selectedAsset(state, context); + if (asset) body.push('', ` ${subtle}${provenanceLabel(asset)}${RESET}`); + if (asset) controls.push(['Enter', 'set active'], ['E', 'edit'], ['N', 'rename'], ['D', 'duplicate'], ['X', 'export'], ['Del', 'delete']); + else if (state.tab === 'custom') controls.push(['Enter', 'create']); + } else { + const format = IMPORT_FORMAT_CHOICES[state.importFormat]!; + if (state.importPreview) { + const p = state.importPreview; + body.push(` ${primary}Import preview · ${p.theme.name}${RESET} ${subtle}${importFormatLabel(p.format)} · ${p.theme.dark ? 'dark' : 'light'}${RESET}`); + body.push(` ${swatches(PROMPT_THEME_ROLES.map(role => p.theme.prompt[role]), level)} ${subtle}prompt roles${RESET}`); + body.push(` ${swatches(UI_THEME_ROLES.map(role => p.theme.ui[role]), level)} ${subtle}interface roles${RESET}`); + for (const mapping of p.mapping.slice(0, 6)) body.push(` ${secondary}${padCells(mapping.role, 18)}${RESET}${subtle}← ${mapping.from}${RESET}`); + for (const warning of p.warnings) body.push(` ${subtle}• ${warning}${RESET}`); + controls.push(['Enter', 'save as Imported theme'], ['Esc', 'cancel (nothing saved)']); + } else { + body.push(` ${subtle}Parse a local theme file into an NMSh Native theme. Nothing is executed or fetched; you preview before anything is saved.${RESET}`, ''); + const focusFormat = state.focus === 'list' && state.importField === 'format'; + const focusPath = state.focus === 'list' && state.importField === 'path'; + body.push(`${mark(focusFormat)} ${focusFormat ? primary : secondary}${padCells('Format', 10)}${RESET}${focusFormat ? `${accent}‹ ${importFormatLabel(format)} ›${RESET}` : importFormatLabel(format)}`); + body.push(`${mark(focusPath)} ${focusPath ? primary : secondary}${padCells('Path', 10)}${RESET}${primary}${state.importPath}${RESET}${focusPath ? `${accent}_${RESET}` : ''}`); + body.push('', ` ${subtle}NMSh JSON · Base16/Base24 · Windows Terminal · Oh My Posh (JSON/YAML/TOML) · Kitty · Ghostty · iTerm2 · WezTerm TOML${RESET}`); + controls.push(['Enter', focusFormat ? 'to path' : 'preview'], ['↑↓', 'field'], ['Esc', 'close']); + } + } + if (state.rename) body.push('', ` ${primary}Rename${RESET} ${primary}${state.rename.text}${accent}_${RESET} ${subtle}Enter save · Esc cancel${RESET}`); + if (state.confirmDelete) { + const {name, pinned: targets} = state.confirmDelete; + body.push('', targets.length + ? ` ${primary}Delete ${name}? Theme Bridge ${targets.map(target => BRIDGE_TARGET_LABELS[target]).join(', ')} ${targets.length === 1 ? 'is' : 'are'} pinned to it and will become Independent.${RESET}` + : ` ${primary}Delete ${name}? This removes it from NMSh; exported files are kept.${RESET}`, ` ${subtle}Enter delete · Esc keep${RESET}`); + } + if (state.message) body.push('', ` ${secondary}${state.message}${RESET}`); + if (preview.length && !state.rename && !state.confirmDelete) body.push('', ...preview); + const help = state.focus === 'tabs' ? renderControls([['←→', 'switch'], ['↓', 'select'], ['Esc', 'close']]) + : renderControls([...controls, ['←→', 'tabs'], ...(state.tab === 'import' ? [] : [['Esc', 'close'] as [string, string]])]); + return finish([...head, ...body, '', help]); } diff --git a/src/appearance/customTheme.ts b/src/appearance/customTheme.ts index 96e0a887..1d8ddd8d 100644 --- a/src/appearance/customTheme.ts +++ b/src/appearance/customTheme.ts @@ -35,10 +35,27 @@ export interface CustomTheme { dark: boolean; prompt: Record; ui: Record; + /** + * Optional terminal palette facts kept from an imported terminal scheme + * (background, foreground, the 16 ANSI colors, selection, cursor). NMSh's + * own UI never paints a background from it; Theme Bridge adapters use it + * when present instead of deriving ANSI colors from the semantic roles. + */ + terminal?: TerminalPalette; /** Unknown fields from a newer schema, preserved verbatim on export. */ extra?: Record; } +export interface TerminalPalette { + background: string; + foreground: string; + /** black, red, green, yellow, blue, magenta, cyan, white, then the bright eight. */ + ansi: string[]; + selectionBackground?: string; + selectionForeground?: string; + cursor?: string; +} + const HEX = /^#[0-9a-f]{6}$/iu; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -83,7 +100,9 @@ export function validateTheme(value: unknown): ThemeValidation { for (const role of PROMPT_THEME_ROLES) if (!validHex(prompt[role])) errors.push(`prompt.${role} must be a #rrggbb color.`); for (const role of UI_THEME_ROLES) if (!validHex(ui[role])) errors.push(`ui.${role} must be a #rrggbb color.`); if (errors.length) return {ok: false, errors}; - const known = new Set(['schema', 'version', 'name', 'basedOn', 'dark', 'prompt', 'ui', 'extra']); + const terminal = value.terminal === undefined ? undefined : normalizeTerminalPalette(value.terminal); + if (value.terminal !== undefined && !terminal) warnings.push('The terminal palette is incomplete or invalid and was not kept.'); + const known = new Set(['schema', 'version', 'name', 'basedOn', 'dark', 'prompt', 'ui', 'terminal', 'extra']); const extra = Object.fromEntries(Object.entries(value).filter(([key]) => !known.has(key))); const basedOn = typeof value.basedOn === 'string' && NAME.test(value.basedOn) ? value.basedOn : undefined; return {ok: true, warnings, theme: { @@ -91,10 +110,20 @@ export function validateTheme(value: unknown): ThemeValidation { ...(basedOn ? {basedOn} : {}), prompt: Object.fromEntries(PROMPT_THEME_ROLES.map(role => [role, (prompt[role] as string).toLowerCase()])) as CustomTheme['prompt'], ui: Object.fromEntries(UI_THEME_ROLES.map(role => [role, (ui[role] as string).toLowerCase()])) as CustomTheme['ui'], + ...(terminal ? {terminal} : {}), ...(Object.keys(extra).length ? {extra} : {}), }}; } +/** A complete terminal palette (16 valid ANSI colors, background, foreground) or nothing. */ +export function normalizeTerminalPalette(value: unknown): TerminalPalette | undefined { + if (!isRecord(value) || !validHex(value.background) || !validHex(value.foreground) || !Array.isArray(value.ansi) + || value.ansi.length !== 16 || !value.ansi.every(validHex)) return undefined; + const optional = (key: 'selectionBackground' | 'selectionForeground' | 'cursor') => validHex(value[key]) ? {[key]: (value[key] as string).toLowerCase()} : {}; + return {background: value.background.toLowerCase(), foreground: value.foreground.toLowerCase(), ansi: (value.ansi as string[]).map(hex => hex.toLowerCase()), + ...optional('selectionBackground'), ...optional('selectionForeground'), ...optional('cursor')}; +} + /** Configuration copies of a theme survive only if valid. */ export function normalizeCustomTheme(value: unknown): CustomTheme | undefined { const result = validateTheme(value); diff --git a/src/appearance/semanticPalette.ts b/src/appearance/semanticPalette.ts new file mode 100644 index 00000000..57fddac7 --- /dev/null +++ b/src/appearance/semanticPalette.ts @@ -0,0 +1,110 @@ +import {mixRgb} from '../chroma/chroma.js'; +import {contrastRatio, hexColor, parseHexColor} from '../chroma/color.js'; +import type {RgbColor as Rgb} from '../ui/palette.js'; +import type {CustomTheme, PromptThemeRole} from './customTheme.js'; +import {themeForRef, themeRefLabel, type ThemeRef, type ThemeSource} from './themeRefs.js'; + +/** + * The one resolved, immutable semantic palette every Theme Bridge adapter + * consumes. It is static theme data: Chroma is a live presentation treatment + * and never leaks into generated configuration. Adapters translate these + * roles into their target's documented roles; none of them re-reads a theme. + */ +export interface SemanticPalette { + readonly ref: ThemeRef; + readonly name: string; + /** Whether the theme was designed for a dark terminal background. */ + readonly dark: boolean; + /** Present only when the theme carries a real terminal background (imported schemes). Otherwise adapters keep the terminal's own. */ + readonly background?: string; + readonly foreground: string; + readonly text: Readonly<{primary: string; secondary: string; subtle: string}>; + readonly accent: string; + readonly separator: string; + /** Selected/active surface and the text drawn on it. */ + readonly selection: string; + readonly selectionForeground: string; + /** A raised surface for bars and menus (status lines, popup menus), one step from the background. */ + readonly surface: string; + readonly cursor: string; + readonly success: string; + readonly warning: string; + readonly failure: string; + readonly info: string; + readonly prompt: Readonly>; + /** 16 ANSI-ish colors: the imported terminal palette when present, otherwise derived from the roles. */ + readonly ansi: readonly string[]; + /** Code roles for editor/pager targets, derived once here. */ + readonly syntax: Readonly<{comment: string; string: string; number: string; keyword: string; function: string; type: string; + constant: string; operator: string; special: string; preproc: string}>; +} + +export type PaletteResolution = {ok: true; palette: SemanticPalette} | {ok: false; reason: 'invalid' | 'missing'; label: string}; + +const BLACK: Rgb = {red: 0, green: 0, blue: 0}; +const WHITE: Rgb = {red: 255, green: 255, blue: 255}; +const rgb = (hex: string): Rgb => parseHexColor(hex)!; +const mix = (a: string, b: Rgb | string, amount: number): string => hexColor(mixRgb(rgb(a), typeof b === 'string' ? rgb(b) : b, amount)); + +/** A role color pushed until it reads against `against` (when there is a known background). */ +function legible(color: string, against: string | undefined, dark: boolean): string { + if (!against) return color; + let out = color; + for (let step = 0; step < 6 && contrastRatio(rgb(out), rgb(against)) < 3; step++) out = mix(out, dark ? WHITE : BLACK, 0.2); + return out; +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object') { + for (const item of Object.values(value as Record)) deepFreeze(item); + Object.freeze(value); + } + return value; +} + +/** The semantic palette for theme data (pure; exported for fixtures). */ +export function paletteFromTheme(theme: CustomTheme, ref: ThemeRef): SemanticPalette { + const terminal = theme.terminal; + const dark = theme.dark; + const background = terminal?.background; + // Light themes keep NMSh's dark-terminal text tiers in its own UI; external tools need text that reads on light. + const primary = terminal?.foreground ?? (dark ? theme.ui.primary : mix(theme.ui.accent, BLACK, 0.78)); + const secondary = dark ? theme.ui.secondary : mix(primary, WHITE, 0.25); + const subtle = dark ? theme.ui.subtle : mix(theme.ui.separator, BLACK, 0.2); + const surface = background ? mix(background, dark ? WHITE : BLACK, 0.08) : dark ? mix(theme.ui.selection, BLACK, 0.35) : mix(theme.ui.accent, WHITE, 0.88); + const selection = terminal?.selectionBackground ?? (dark ? theme.ui.selection : mix(theme.ui.accent, WHITE, 0.75)); + const roles = {success: legible(theme.ui.success, background, dark), warning: legible(theme.ui.warning, background, dark), + failure: legible(theme.ui.failure, background, dark), info: legible(theme.ui.info, background, dark), accent: legible(theme.ui.accent, background, dark)}; + const ansi = terminal?.ansi.slice() ?? (() => { + const base = [dark ? mix(surface, BLACK, 0.3) : subtle, roles.failure, roles.success, roles.warning, legible(theme.prompt.gitBranch, background, dark), + roles.accent, roles.info, dark ? secondary : mix(subtle, WHITE, 0.5)]; + const bright = base.map((color, index) => index === 0 ? subtle : index === 7 ? primary : mix(color, dark ? WHITE : BLACK, 0.2)); + return [...base, ...bright]; + })(); + const syntax = {comment: subtle, string: ansi[2]!, number: ansi[3]!, keyword: roles.accent, function: ansi[4]!, type: ansi[6]!, + constant: ansi[11]!, operator: secondary, special: ansi[13]!, preproc: ansi[5]!}; + return deepFreeze({ + ref, name: theme.name, dark, ...(background ? {background} : {}), foreground: primary, + text: {primary, secondary, subtle}, accent: roles.accent, separator: theme.ui.separator, + selection, selectionForeground: terminal?.selectionForeground ?? primary, surface, + cursor: terminal?.cursor ?? roles.accent, success: roles.success, warning: roles.warning, failure: roles.failure, info: roles.info, + prompt: {...theme.prompt}, ansi, syntax, + }); +} + +/** + * Resolves any selectable theme (built-in, family variant/accent, Imported, + * Custom) to its immutable semantic palette. A missing or invalid reference + * is reported; it never silently resolves to another theme. + */ +export function resolveSemanticPalette(ref: ThemeRef | undefined, source: Pick): PaletteResolution { + const resolution = themeForRef(ref, source); + if (!resolution.ok) return {ok: false, reason: resolution.reason, label: themeRefLabel(ref, source)}; + return {ok: true, palette: paletteFromTheme(resolution.theme, ref!)}; +} + +/** `#rrggbb` → `r;g;b` for SGR sequences in generated environment values. */ +export function sgrRgb(hex: string): string { + const color = rgb(hex); + return `${color.red};${color.green};${color.blue}`; +} diff --git a/src/appearance/themeImporters.ts b/src/appearance/themeImporters.ts new file mode 100644 index 00000000..2ce6685e --- /dev/null +++ b/src/appearance/themeImporters.ts @@ -0,0 +1,499 @@ +import {parseDocument} from 'yaml'; +import {parse as parseToml} from 'smol-toml'; +import {XMLParser} from 'fast-xml-parser'; +import {mixRgb} from '../chroma/chroma.js'; +import {hexColor, parseHexColor} from '../chroma/color.js'; +import { + importBase16, importWindowsTerminal, parseHexInput, sanitizeName, THEME_SCHEMA, THEME_SCHEMA_VERSION, validateTheme, + PROMPT_THEME_ROLES, ROLE_LABELS, type CustomTheme, type PromptThemeRole, type TerminalPalette, type UiThemeRole, +} from './customTheme.js'; +import {THEME_SOURCE_LABELS, type ThemeSourceKind} from './themeLibrary.js'; + +/** + * Theme import: bounded local data parsing into one NMSh Native theme. + * + * Every format is parsed as data only. Nothing is executed, sourced, + * evaluated, templated, fetched or followed: no shell, no Lua, no Oh My Posh + * templates or binary, no includes, no remote schemas or inheritance, no XML + * entities. Unsupported or dynamic source concepts become preview warnings; + * no color is invented for them. The result is ordinary NMSh Theme JSON plus + * a mapping/loss disclosure, previewed before anything is stored. + */ + +export const IMPORT_SIZE_LIMIT = 256 * 1024; + +export interface RoleMapping {role: string; from: string} + +export interface ImportPreview { + format: ThemeSourceKind; + theme: CustomTheme; + /** The source's own scheme name, sanitized. */ + sourceName?: string; + /** Where each Native role came from, for the preview. */ + mapping: RoleMapping[]; + /** Lossy mapping, ignored settings and unsupported concepts, in plain words. */ + warnings: string[]; +} + +export type ImportOutcome = ImportPreview | {errors: string[]}; +export type ImportFormatChoice = ThemeSourceKind | 'auto'; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const ANSI_NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'bright black', 'bright red', 'bright green', + 'bright yellow', 'bright blue', 'bright magenta', 'bright cyan', 'bright white']; +const LOSSY = 'A terminal palette has fewer concepts than NMSh roles; roles were mapped from ANSI colors and are not a lossless conversion.'; + +function luminance(hex: string): number { + const color = parseHexColor(hex)!; + return (0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue) / 255; +} + +const mix = (a: string, b: string, amount: number) => hexColor(mixRgb(parseHexColor(a)!, parseHexColor(b)!, amount)); + +/** Strict `#rgb` / `#rrggbb` (and the same without `#`); names and functions are not colors here. */ +function hex(value: unknown): string | undefined { + return typeof value === 'string' && /^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/iu.test(value.trim()) ? parseHexInput(value) : undefined; +} + +function finish(format: ThemeSourceKind, theme: CustomTheme, mapping: RoleMapping[], warnings: string[], sourceName?: string): ImportOutcome { + // Every generated role is validated exactly like a hand-written NMSh theme. + const checked = validateTheme(theme); + if (!checked.ok) return {errors: checked.errors}; + return {format, theme: checked.theme, mapping, warnings: [...new Set(warnings)], ...(sourceName ? {sourceName} : {})}; +} + +/** + * One terminal palette → Native roles. The same documented mapping serves + * every terminal scheme format, so Kitty, Ghostty, iTerm2, WezTerm and + * Windows Terminal schemes with the same colors give the same theme. + */ +export function themeFromTerminal(name: string, kind: ThemeSourceKind, terminal: TerminalPalette, defaults: Record): {theme: CustomTheme; mapping: RoleMapping[]} { + const a = terminal.ansi; + const dark = luminance(terminal.background) < 0.5; + const prompt: Record = {project: a[5]!, cwd: a[8]!, gitBranch: a[4]!, node: a[2]!, go: a[6]!, python: a[3]!, + docker: a[12]!, kubernetes: a[13]!, success: a[2]!, failure: a[1]!}; + const source: Record = {project: 5, cwd: 8, gitBranch: 4, node: 2, go: 6, python: 3, docker: 12, kubernetes: 13, success: 2, failure: 1}; + const selection = terminal.selectionBackground ?? a[8]!; + const ui: Record = {...defaults, accent: a[5]!, separator: a[8]!, success: a[2]!, warning: a[3]!, failure: a[1]!, info: a[6]!, + // Light schemes keep NMSh's own text tiers in NMSh UI; the terminal palette below still carries the real foreground. + ...(dark ? {primary: terminal.foreground, secondary: mix(terminal.foreground, terminal.background, 0.25), subtle: a[8]!, selection} : {})}; + const mapping: RoleMapping[] = [ + ...PROMPT_THEME_ROLES.map(role => ({role: ROLE_LABELS[role], from: `${ANSI_NAMES[source[role]]} (color${source[role]})`})), + {role: 'Accent', from: 'magenta (color5)'}, {role: 'Separator / muted', from: 'bright black (color8)'}, + {role: 'Warning / Info', from: 'yellow (color3) / cyan (color6)'}, + {role: 'Text', from: dark ? 'foreground; secondary is foreground mixed toward background' : 'NMSh text tiers (light scheme)'}, + {role: 'Selection', from: terminal.selectionBackground ? 'selection background' : 'bright black (color8)'}, + ]; + return {mapping, theme: {schema: THEME_SCHEMA, version: THEME_SCHEMA_VERSION, name: sanitizeName(name) || 'Imported theme', + basedOn: `${THEME_SOURCE_LABELS[kind]} import`, dark, prompt, ui, terminal}}; +} + +function terminalResult(kind: ThemeSourceKind, name: string, terminal: TerminalPalette, defaults: Record, warnings: string[]): ImportOutcome { + const {theme, mapping} = themeFromTerminal(name, kind, terminal, defaults); + return finish(kind, theme, mapping, [LOSSY, ...warnings, theme.dark ? 'Interpreted as a dark scheme (dark background).' : 'Interpreted as a light scheme (light background).'], theme.name); +} + +function missingAnsi(ansi: Array): string[] { + return ansi.flatMap((value, index) => value ? [] : [`color${index}`]); +} + +// ---- Kitty ----------------------------------------------------------------- + +const KITTY_COLORS: Record = {foreground: 'foreground', background: 'background', selection_background: 'selectionBackground', + selection_foreground: 'selectionForeground', cursor: 'cursor'}; + +/** + * Kitty theme/config lines: only the documented color assignments are read + * (`foreground`, `background`, `selection_*`, `cursor`, `color0`–`color15`). + * `include`/`globinclude`/`envinclude` are never followed and every other + * directive is ignored and reported. + */ +export function importKitty(text: string, fileName: string, defaults: Record): ImportOutcome { + const ansi: Array = Array(16).fill(undefined); + const facts: Partial = {}; + const ignored = new Set(); + let includes = 0; + let name = ''; + for (const raw of text.split(/\r?\n/u).slice(0, 4000)) { + const line = raw.trim(); + const meta = /^##\s*name\s*:\s*(.+)$/iu.exec(line); + if (meta && !name) name = meta[1]!; + if (!line || line.startsWith('#')) continue; + const [key = '', value = ''] = line.split(/\s+/u, 2); + if (/^(?:include|globinclude|envinclude|geninclude)$/u.test(key)) { includes++; continue; } + const color = /^color(\d{1,3})$/u.exec(key); + if (color) { + const index = Number(color[1]); + if (index < 16) { const parsed = hex(value); if (parsed) ansi[index] = parsed; else ignored.add(`${key} (not a hex color)`); } + else ignored.add('color16–color255'); + continue; + } + const field = KITTY_COLORS[key]; + if (field) { const parsed = hex(value); if (parsed) (facts as Record)[field] = parsed; else ignored.add(`${key} (not a hex color)`); continue; } + ignored.add(key); + } + const missing = [...missingAnsi(ansi), ...(!facts.background ? ['background'] : []), ...(!facts.foreground ? ['foreground'] : [])]; + if (missing.length) return {errors: [`Not a complete Kitty color theme: missing ${missing.join(', ')}.`]}; + const warnings = [ + ...(includes ? [`${includes} include directive${includes === 1 ? ' was' : 's were'} not followed; only this file was read.`] : []), + ...(ignored.size ? [`Non-color Kitty settings were not imported: ${[...ignored].slice(0, 8).join(', ')}${ignored.size > 8 ? '…' : ''}.`] : []), + ]; + return terminalResult('kitty', name || fileStem(fileName), {...facts, ansi: ansi as string[]} as TerminalPalette, defaults, warnings); +} + +// ---- Ghostty --------------------------------------------------------------- + +const GHOSTTY_ALLOWED: Record = {background: 'background', foreground: 'foreground', 'selection-background': 'selectionBackground', + 'selection-foreground': 'selectionForeground', 'cursor-color': 'cursor'}; + +/** + * Ghostty theme files are ordinary Ghostty configuration, so a strict + * allowlist applies: `palette = N=#hex`, background/foreground, + * selection colors and cursor color. `config-file` is never followed; every + * other key is ignored and named in the preview. + */ +export function importGhostty(text: string, fileName: string, defaults: Record): ImportOutcome { + const ansi: Array = Array(16).fill(undefined); + const facts: Partial = {}; + const ignored = new Set(); + let includes = 0; + for (const raw of text.split(/\r?\n/u).slice(0, 4000)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const match = /^([a-z0-9-]+)\s*=\s*(.*)$/u.exec(line); + if (!match) { ignored.add('unrecognized lines'); continue; } + const key = match[1]!; + const value = match[2]!.trim().replace(/^"(.*)"$/u, '$1'); + if (key === 'config-file') { includes++; continue; } + if (key === 'palette') { + const entry = /^(\d{1,3})\s*=\s*(#?[0-9a-fA-F]{3,6})$/u.exec(value); + const index = entry ? Number(entry[1]) : NaN; + const parsed = entry ? hex(entry[2]) : undefined; + if (index < 16 && parsed) ansi[index] = parsed; + else if (index >= 16) ignored.add('palette 16–255'); + else ignored.add('palette (not a hex color)'); + continue; + } + const field = GHOSTTY_ALLOWED[key]; + if (field) { const parsed = hex(value); if (parsed) (facts as Record)[field] = parsed; else ignored.add(`${key} (not a hex color)`); continue; } + ignored.add(key); + } + const missing = [...missingAnsi(ansi), ...(!facts.background ? ['background'] : []), ...(!facts.foreground ? ['foreground'] : [])]; + if (missing.length) return {errors: [`Not a complete Ghostty theme: missing ${missing.join(', ')}.`]}; + const warnings = [ + ...(includes ? ['config-file includes were not followed; only this file was read.'] : []), + ...(ignored.size ? [`Ghostty non-color options were ignored: ${[...ignored].slice(0, 8).join(', ')}${ignored.size > 8 ? '…' : ''}.`] : []), + ]; + return terminalResult('ghostty', fileStem(fileName), {...facts, ansi: ansi as string[]} as TerminalPalette, defaults, warnings); +} + +// ---- iTerm2 ---------------------------------------------------------------- + +type OrderedNode = Record; + +/** The ordered key/value pairs of a plist `` (fast-xml-parser preserveOrder output). */ +function plistDict(children: OrderedNode[]): Array<[string, OrderedNode]> { + const pairs: Array<[string, OrderedNode]> = []; + let key: string | undefined; + for (const child of children.slice(0, 2000)) { + const tag = Object.keys(child).find(name => name !== ':@'); + if (!tag) continue; + if (tag === 'key') key = textOf(child.key as OrderedNode[]); + else if (key !== undefined) { pairs.push([key, child]); key = undefined; } + } + return pairs; +} + +function textOf(nodes: OrderedNode[] | string | undefined): string { + if (!Array.isArray(nodes)) return ''; + return nodes.map(node => typeof node['#text'] === 'string' ? node['#text'] : '').join('').trim(); +} + +/** + * `.itermcolors` property lists. The XML is bounded, a DOCTYPE internal subset + * or any ENTITY declaration is rejected outright (no entity expansion at all), + * nothing external is resolved, and only the color dictionaries are read. + */ +export function importITerm2(text: string, fileName: string, defaults: Record): ImportOutcome { + if (/]*\[/iu.test(text)) return {errors: ['XML entity declarations are not accepted in theme files.']}; + let root: OrderedNode[]; + try { + const parser = new XMLParser({preserveOrder: true, processEntities: false, htmlEntities: false, ignoreDeclaration: true, ignorePiTags: true, + ignoreAttributes: true, parseTagValue: false, trimValues: true}); + root = parser.parse(text) as OrderedNode[]; + } catch { + return {errors: ['Not a valid iTerm2 color scheme (.itermcolors) file.']}; + } + const plist = root.find(node => 'plist' in node)?.plist as OrderedNode[] | undefined; + const dict = plist?.find(node => 'dict' in node)?.dict as OrderedNode[] | undefined; + if (!dict) return {errors: ['Not an iTerm2 color scheme: no color dictionary.']}; + const warnings = new Set(); + const colors = new Map(); + for (const [key, value] of plistDict(dict)) { + if (!('dict' in value)) continue; + const components = new Map(plistDict(value.dict as OrderedNode[]).map(([name, node]) => [name, node])); + const channel = (name: string) => { + const node = components.get(`${name} Component`); + const number = node && ('real' in node || 'integer' in node) ? Number(textOf((node.real ?? node.integer) as OrderedNode[])) : NaN; + return Number.isFinite(number) ? Math.max(0, Math.min(255, Math.round(number * 255))) : undefined; + }; + const [red, green, blue] = [channel('Red'), channel('Green'), channel('Blue')]; + if (red === undefined || green === undefined || blue === undefined) continue; + const space = components.get('Color Space'); + const spaceName = space && 'string' in space ? textOf(space.string as OrderedNode[]) : ''; + if (spaceName && spaceName !== 'sRGB' && spaceName !== 'Calibrated') warnings.add(`Some colors use the ${sanitizeName(spaceName)} color space; their components were read as sRGB.`); + colors.set(key, hexColor({red, green, blue})); + } + const ansi = Array.from({length: 16}, (_, index) => colors.get(`Ansi ${index} Color`)); + const background = colors.get('Background Color'); + const foreground = colors.get('Foreground Color'); + const missing = [...missingAnsi(ansi), ...(!background ? ['Background Color'] : []), ...(!foreground ? ['Foreground Color'] : [])]; + if (missing.length) return {errors: [`Not a complete iTerm2 color scheme: missing ${missing.join(', ')}.`]}; + const terminal: TerminalPalette = {background: background!, foreground: foreground!, ansi: ansi as string[], + ...(colors.get('Selection Color') ? {selectionBackground: colors.get('Selection Color')!} : {}), + ...(colors.get('Selected Text Color') ? {selectionForeground: colors.get('Selected Text Color')!} : {}), + ...(colors.get('Cursor Color') ? {cursor: colors.get('Cursor Color')!} : {})}; + const known = new Set([...Array.from({length: 16}, (_, index) => `Ansi ${index} Color`), 'Background Color', 'Foreground Color', 'Selection Color', 'Selected Text Color', 'Cursor Color']); + const extra = [...colors.keys()].filter(key => !known.has(key)); + if (extra.length) warnings.add(`iTerm2-only colors have no NMSh role and were not imported: ${extra.slice(0, 6).map(sanitizeName).join(', ')}${extra.length > 6 ? '…' : ''}.`); + return terminalResult('iterm2', fileStem(fileName), terminal, defaults, [...warnings]); +} + +// ---- WezTerm --------------------------------------------------------------- + +export const WEZTERM_LUA_GUIDANCE = 'WezTerm Lua configuration is executable and is never evaluated. Export or choose a declarative WezTerm TOML color scheme (with a [colors] table) and import that instead.'; + +export function looksLikeLua(text: string, fileName: string): boolean { + return /\.lua$/iu.test(fileName) || /\brequire\s*\(?\s*["']wezterm["']/u.test(text) || /^\s*return\s*\{/mu.test(text) || /^\s*local\s+\w+\s*=/mu.test(text); +} + +/** Declarative WezTerm TOML color schemes: `[colors]` (ansi, brights, foreground, background, selection, cursor) and `[metadata] name`. */ +export function importWezTermToml(data: Record, fileName: string, defaults: Record): ImportOutcome { + const colors = isRecord(data.colors) ? data.colors : undefined; + if (!colors) return {errors: ['Not a WezTerm color scheme: no [colors] table.']}; + const list = (value: unknown) => Array.isArray(value) && value.length === 8 ? value.map(hex) : Array(8).fill(undefined); + const ansi = [...list(colors.ansi), ...list(colors.brights)]; + const background = hex(colors.background); + const foreground = hex(colors.foreground); + const missing = [...missingAnsi(ansi), ...(!background ? ['background'] : []), ...(!foreground ? ['foreground'] : [])]; + if (missing.length) return {errors: [`Not a complete WezTerm color scheme: missing ${missing.join(', ')}.`]}; + const known = new Set(['ansi', 'brights', 'foreground', 'background', 'selection_bg', 'selection_fg', 'cursor_bg', 'cursor_fg', 'cursor_border']); + const extra = Object.keys(colors).filter(key => !known.has(key)); + const metadata = isRecord(data.metadata) ? data.metadata : {}; + const name = typeof metadata.name === 'string' ? metadata.name : fileStem(fileName); + const terminal: TerminalPalette = {background: background!, foreground: foreground!, ansi: ansi as string[], + ...(hex(colors.selection_bg) ? {selectionBackground: hex(colors.selection_bg)!} : {}), + ...(hex(colors.selection_fg) ? {selectionForeground: hex(colors.selection_fg)!} : {}), + ...(hex(colors.cursor_bg) ? {cursor: hex(colors.cursor_bg)!} : {})}; + const ignoredTables = Object.keys(data).filter(key => key !== 'colors' && key !== 'metadata'); + return terminalResult('wezterm', name, terminal, defaults, [ + ...(extra.length ? [`WezTerm color keys without an NMSh role were not imported: ${extra.slice(0, 6).join(', ')}${extra.length > 6 ? '…' : ''}.`] : []), + ...(ignoredTables.length ? [`Non-color tables were ignored: ${ignoredTables.slice(0, 6).join(', ')}.`] : []), + ]); +} + +// ---- Base24 ---------------------------------------------------------------- + +// Base24 styling 0.1.3: the documented ANSI 0–15 sources. +const BASE24_ANSI = ['base00', 'base08', 'base0b', 'base0a', 'base0d', 'base0e', 'base0c', 'base05', 'base03', 'base12', 'base14', 'base13', 'base16', 'base17', 'base15', 'base07']; + +function schemeColors(data: Record): Record { + const source = isRecord(data.palette) ? data.palette : data; + const out: Record = {}; + for (const [key, value] of Object.entries(source)) { + const parsed = hex(value); + if (/^base[01][0-9a-f]$/iu.test(key) && parsed) out[key.toLowerCase()] = parsed; + } + return out; +} + +/** + * Base24 (tinted-theming): all 24 colors are required; ANSI comes from the + * spec's documented terminal mapping. Nothing missing is guessed. + */ +export function importBase24(data: Record, defaults: Record): ImportOutcome { + const colors = schemeColors(data); + const keys = [...Array.from({length: 16}, (_, index) => `base0${index.toString(16)}`), ...Array.from({length: 8}, (_, index) => `base1${index}`)]; + const missing = keys.filter(key => !colors[key]); + if (missing.length) return {errors: [`Not a complete Base24 scheme: missing ${missing.join(', ')}.`]}; + const name = typeof data.name === 'string' ? data.name : typeof data.scheme === 'string' ? data.scheme : 'Imported Base24'; + const terminal: TerminalPalette = {background: colors.base00!, foreground: colors.base05!, ansi: BASE24_ANSI.map(key => colors[key]!), + selectionBackground: colors.base02!, cursor: colors.base05!}; + const {theme, mapping} = themeFromTerminal(name, 'base24', terminal, defaults); + // Base24 names its semantic slots, so the dark-surface and comment roles come from them directly. + if (theme.dark) theme.ui = {...theme.ui, subtle: colors.base03!, separator: colors.base03!, selection: colors.base02!, secondary: colors.base04!}; + theme.prompt.cwd = colors.base02!; + return finish('base24', theme, [...mapping, {role: 'Path / selection / muted', from: 'base02 / base02 / base03'}], + [LOSSY, 'base09, base0F, base10, base11 and base06 have no NMSh role and were not imported.', + theme.dark ? 'Interpreted as a dark scheme (dark base00).' : 'Interpreted as a light scheme (light base00).'], theme.name); +} + +// ---- Oh My Posh ------------------------------------------------------------ + +const OMP_ROLE: Record = {path: 'cwd', git: 'gitBranch', node: 'node', go: 'go', python: 'python', docker: 'docker', + kubectl: 'kubernetes', session: 'project', project: 'project', os: 'project'}; + +/** + * Oh My Posh config (JSON, YAML or TOML, already parsed as data). Only + * deterministic appearance facts are read: literal hex colors, `p:name` + * references into the static `palette`, and segment types as role hints. + * Templates, `*_templates`, conditional `palettes`, named terminal colors, + * remote/inherited configs, commands and prompt logic are never evaluated; + * each becomes a warning, and roles without a source keep NMSh defaults. + */ +export function importOhMyPosh(data: Record, fileName: string, base: CustomTheme): ImportOutcome { + const warnings = new Set(['Oh My Posh prompt logic, segments and templates are not imported; only static colors become an NMSh Native theme.']); + const palette = isRecord(data.palette) ? data.palette : {}; + if (data.palettes !== undefined) warnings.add('Conditional palettes (palettes/template) are dynamic and were ignored; the static palette was used.'); + if (data.extends !== undefined || (typeof data.$schema === 'string' && !/oh-my-posh/u.test(data.$schema))) warnings.add('Inherited or remote configuration is never fetched or merged.'); + const unresolved = new Set(); + const resolve = (value: unknown, depth = 0): string | undefined => { + if (typeof value !== 'string' || depth > 4) return undefined; + const literal = hex(value); + if (literal) return literal; + const reference = /^p:([A-Za-z0-9_.-]{1,64})$/u.exec(value.trim()); + if (reference) { + const target = palette[reference[1]!]; + const resolved = resolve(target, depth + 1); + if (!resolved) unresolved.add(value.trim()); + return resolved; + } + if (value.trim() && !/^(?:transparent|parentBackground|parentForeground|background|foreground|accent)$/u.test(value.trim())) unresolved.add(sanitizeName(value.trim())); + return undefined; + }; + const prompt: Partial> = {}; + const mapping: RoleMapping[] = []; + let accent: string | undefined; + let dynamic = 0; + const blocks = Array.isArray(data.blocks) ? data.blocks.slice(0, 32) : []; + for (const block of blocks) { + if (!isRecord(block) || !Array.isArray(block.segments)) continue; + for (const segment of block.segments.slice(0, 64)) { + if (!isRecord(segment)) continue; + if (segment.foreground_templates !== undefined || segment.background_templates !== undefined) dynamic++; + const type = typeof segment.type === 'string' ? segment.type : ''; + const fill = resolve(segment.background) ?? resolve(segment.foreground); + if (!fill) continue; + accent ??= fill; + const role = OMP_ROLE[type]; + if (role && !prompt[role]) { prompt[role] = fill; mapping.push({role: ROLE_LABELS[role], from: `${sanitizeName(type)} segment`}); } + if ((type === 'status' || type === 'exit') && !prompt.success) { prompt.success = fill; mapping.push({role: 'Success', from: `${type} segment`}); } + } + } + if (dynamic) warnings.add(`${dynamic} dynamic color template${dynamic === 1 ? ' was' : 's were'} ignored (for example foreground_templates); their static colors were used.`); + if (unresolved.size) warnings.add(`Colors that are not static hex values were not imported: ${[...unresolved].slice(0, 6).join(', ')}${unresolved.size > 6 ? '…' : ''}.`); + // Palette entries can still name roles explicitly when segments use other types. + for (const [key, value] of Object.entries(palette)) { + const role = PROMPT_THEME_ROLES.find(candidate => candidate.toLowerCase() === key.toLowerCase()); + const color = resolve(value); + if (role && color && !prompt[role]) { prompt[role] = color; mapping.push({role: ROLE_LABELS[role], from: `palette.${sanitizeName(key)}`}); } + } + if (!Object.keys(prompt).length) return {errors: ['No static Oh My Posh segment colors were found to build an NMSh theme from.']}; + const kept = PROMPT_THEME_ROLES.filter(role => !prompt[role]); + if (kept.length) warnings.add(`No Oh My Posh source for ${kept.map(role => ROLE_LABELS[role]).join(', ')}; ${kept.length === 1 ? 'it keeps' : 'they keep'} ${base.name} colors.`); + warnings.add('Oh My Posh does not define a terminal background; text tiers assume a dark terminal (change it in the editor).'); + const name = sanitizeName(fileStem(fileName).replace(/\.omp$/u, '')) || 'Oh My Posh theme'; + const theme: CustomTheme = {...structuredClone(base), name, basedOn: 'Oh My Posh import', dark: true, + prompt: {...base.prompt, ...prompt}, ui: {...base.ui, ...(accent ? {accent} : {}), + ...(prompt.success ? {success: prompt.success} : {}), ...(prompt.failure ? {failure: prompt.failure} : {})}}; + delete theme.terminal; + if (accent) mapping.push({role: 'Accent', from: 'first colored segment'}); + return finish('oh-my-posh', theme, mapping, [...warnings], name); +} + +// ---- Dispatcher ------------------------------------------------------------ + +function fileStem(fileName: string): string { + const base = fileName.split(/[\\/]/u).pop() ?? fileName; + return sanitizeName(base.replace(/\.(?:json|ya?ml|toml|conf|itermcolors|theme)$/iu, '')) || 'Imported theme'; +} + +function parseYaml(text: string): unknown { + const document = parseDocument(text, {prettyErrors: false, uniqueKeys: false, logLevel: 'silent', customTags: []}); + if (document.errors.length) throw new Error('invalid YAML'); + return document.toJS({maxAliasCount: 64}); +} + +function isOhMyPosh(data: unknown): data is Record { + return isRecord(data) && (Array.isArray(data.blocks) || (typeof data.$schema === 'string' && /oh-my-posh/u.test(data.$schema))); +} + +function isScheme(data: unknown): data is Record { + return isRecord(data) && Boolean(schemeColors(data).base00); +} + +function schemeImport(data: Record, text: string, defaults: Record, forced?: ThemeSourceKind): ImportOutcome { + const colors = schemeColors(data); + const base24 = forced === 'base24' || (forced !== 'base16' && (data.system === 'base24' || Object.keys(colors).some(key => /^base1[0-7]$/u.test(key)))); + if (base24) return importBase24(data, defaults); + const result = importBase16(JSON.stringify(data), defaults) ?? importBase16(text, defaults); + if (!result) return {errors: ['Not a complete Base16 scheme: base00–base0E are required.']}; + return finish('base16', result.theme, [{role: 'Prompt and UI roles', from: 'Base16 slots (see the Base16 mapping in the docs)'}], + [...result.warnings, result.theme.dark ? 'Interpreted as a dark scheme (dark base00).' : 'Interpreted as a light scheme (light base00).'], result.theme.name); +} + +/** + * Parses one local theme file (already read, size-checked text) as the chosen + * format, or detects it. Data only; see the module comment for guarantees. + */ +export function importThemeSource(text: string, fileName: string, defaults: Record, base: CustomTheme, format: ImportFormatChoice = 'auto'): ImportOutcome { + if (text.length > IMPORT_SIZE_LIMIT) return {errors: ['File is larger than 256 KiB.']}; + if (text.includes('\u0000')) return {errors: ['Binary files are not theme files.']}; + const extension = /\.([a-z0-9]+)$/iu.exec(fileName)?.[1]?.toLowerCase() ?? ''; + const want = (kind: ThemeSourceKind) => format === 'auto' || format === kind; + if (format === 'wezterm' || extension === 'lua') { + if (looksLikeLua(text, fileName)) return {errors: [WEZTERM_LUA_GUIDANCE]}; + } + if (format === 'iterm2' || extension === 'itermcolors' || (format === 'auto' && /]/u.test(text))) return importITerm2(text, fileName, defaults); + if (format === 'kitty') return importKitty(text, fileName, defaults); + if (format === 'ghostty') return importGhostty(text, fileName, defaults); + let json: unknown; + try { json = JSON.parse(text); } catch { json = undefined; } + if (json !== undefined) { + if (isRecord(json) && json.schema === THEME_SCHEMA && want('nmsh')) { + const result = validateTheme(json); + return result.ok ? {format: 'nmsh', theme: result.theme, mapping: [], warnings: result.warnings, sourceName: result.theme.name} : {errors: result.errors}; + } + if (isOhMyPosh(json) && want('oh-my-posh')) return importOhMyPosh(json, fileName, base); + if (want('windows-terminal') && (format === 'windows-terminal' || (isRecord(json) && 'brightBlack' in json))) { + const result = importWindowsTerminal(text, defaults); + if (!result) return {errors: ['Not a complete Windows Terminal color scheme.']}; + const terminal = windowsTerminalPalette(json as Record); + return terminal ? terminalResult('windows-terminal', result.theme.name, terminal, defaults, []) + : finish('windows-terminal', result.theme, [], [...result.warnings], result.theme.name); + } + if (isScheme(json) && (want('base16') || want('base24'))) return schemeImport(json, text, defaults, format === 'auto' ? undefined : format); + return {errors: ['This JSON is not an NMSh theme, Oh My Posh config, Windows Terminal scheme or Base16/Base24 scheme.']}; + } + if (extension === 'toml' || format === 'wezterm' || (format === 'oh-my-posh' && /^\s*\[/mu.test(text))) { + let data: unknown; + try { data = parseToml(text); } catch { return {errors: [looksLikeLua(text, fileName) ? WEZTERM_LUA_GUIDANCE : 'Not valid TOML.']}; } + if (isOhMyPosh(data) && want('oh-my-posh')) return importOhMyPosh(data, fileName, base); + if (isRecord(data) && isRecord(data.colors) && want('wezterm')) return importWezTermToml(data, fileName, defaults); + return {errors: ['This TOML is not an Oh My Posh config or a WezTerm color scheme.']}; + } + if (format === 'auto' && looksLikeLua(text, fileName)) return {errors: [WEZTERM_LUA_GUIDANCE]}; + // Line-oriented terminal configs, detected by their own color syntax before YAML. + if (format === 'auto' && /^\s*palette\s*=\s*\d+\s*=/mu.test(text)) return importGhostty(text, fileName, defaults); + if (format === 'auto' && /^\s*color\d{1,2}\s+#?[0-9a-fA-F]{3,6}\s*$/mu.test(text)) return importKitty(text, fileName, defaults); + let yaml: unknown; + try { yaml = parseYaml(text); } catch { return {errors: ['The file is not a recognized theme format (NMSh, Base16/Base24, Windows Terminal, Oh My Posh, Kitty, Ghostty, iTerm2, WezTerm TOML).']}; } + if (isOhMyPosh(yaml) && want('oh-my-posh')) return importOhMyPosh(yaml, fileName, base); + if (isScheme(yaml) && (want('base16') || want('base24'))) return schemeImport(yaml, text, defaults, format === 'auto' ? undefined : format); + return {errors: ['The file is not a recognized theme format (NMSh, Base16/Base24, Windows Terminal, Oh My Posh, Kitty, Ghostty, iTerm2, WezTerm TOML).']}; +} + +function windowsTerminalPalette(json: Record): TerminalPalette | undefined { + const keys = ['black', 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'white', 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', + 'brightBlue', 'brightPurple', 'brightCyan', 'brightWhite']; + const ansi = keys.map(key => hex(json[key])); + const background = hex(json.background); + const foreground = hex(json.foreground); + if (ansi.some(value => !value) || !background || !foreground) return undefined; + return {background, foreground, ansi: ansi as string[], ...(hex(json.selectionBackground) ? {selectionBackground: hex(json.selectionBackground)!} : {}), + ...(hex(json.cursorColor) ? {cursor: hex(json.cursorColor)!} : {})}; +} + +export const IMPORT_FORMAT_CHOICES: readonly ImportFormatChoice[] = ['auto', 'nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'kitty', 'ghostty', 'iterm2', 'wezterm']; +export const importFormatLabel = (format: ImportFormatChoice): string => format === 'auto' ? 'Detect automatically' : THEME_SOURCE_LABELS[format]; + diff --git a/src/appearance/themeLibrary.ts b/src/appearance/themeLibrary.ts new file mode 100644 index 00000000..53a0bf17 --- /dev/null +++ b/src/appearance/themeLibrary.ts @@ -0,0 +1,194 @@ +import {randomBytes} from 'node:crypto'; +import {normalizeCustomTheme, sanitizeName, type CustomTheme} from './customTheme.js'; + +/** + * The Native theme library: every user-owned NMSh theme as one asset with a + * stable id. Imported is provenance, never another renderer: an imported + * asset is ordinary NMSh Theme JSON plus where it came from, and it keeps + * working when the source application, file or network is gone. + * + * Canonical state is `themes` (the assets) and `nmsh.themeId` (the asset a + * `custom` palette uses). `customTheme` is a deterministic mirror of that + * asset, rewritten by normalization on every load and save, so the existing + * renderers and older NMSh versions keep reading one theme. A stored + * `customTheme` is only ever read as input when migrating a configuration + * that has no library yet. + */ + +/** Bounded so a damaged or hostile configuration cannot grow without limit. */ +export const THEME_LIBRARY_LIMIT = 64; +/** The asset a pre-library `customTheme` migrates into. */ +export const LEGACY_THEME_ID = 'legacy-custom'; + +export const THEME_SOURCE_KINDS = ['nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'kitty', 'ghostty', 'iterm2', 'wezterm'] as const; +export type ThemeSourceKind = typeof THEME_SOURCE_KINDS[number]; +export const THEME_SOURCE_LABELS: Record = { + nmsh: 'NMSh Theme JSON', base16: 'Base16', base24: 'Base24', 'windows-terminal': 'Windows Terminal', 'oh-my-posh': 'Oh My Posh', + kitty: 'Kitty', ghostty: 'Ghostty', iterm2: 'iTerm2', wezterm: 'WezTerm', +}; + +/** Where an imported asset came from. Local-only: never part of a portable export. */ +export interface ThemeOrigin { + kind: ThemeSourceKind; + /** The source's own scheme name or file name (no directories). */ + sourceName?: string; + /** The local file it was read from, for this machine's display only. */ + sourcePath?: string; + importerVersion?: number; + importedAt?: string; +} + +export interface ThemeAsset { + id: string; + theme: CustomTheme; + /** Present only for imported assets; its presence is what makes the category Imported. */ + origin?: ThemeOrigin; + createdAt?: string; + /** An imported asset edited in NMSh after import (the source file is never touched). */ + modified?: boolean; +} + +export type ThemeCategory = 'custom' | 'imported'; +export const categoryOf = (asset: ThemeAsset): ThemeCategory => asset.origin ? 'imported' : 'custom'; + +/** Current importer behavior version, recorded with each import. */ +export const IMPORTER_VERSION = 2; + +const ID = /^[a-z0-9][a-z0-9-]{2,39}$/u; +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const isoDate = (value: unknown): string | undefined => + typeof value === 'string' && value.length <= 40 && !Number.isNaN(Date.parse(value)) ? value : undefined; + +export function newThemeId(taken: ReadonlySet = new Set()): string { + for (;;) { + const id = `t-${randomBytes(6).toString('hex')}`; + if (!taken.has(id)) return id; + } +} + +function normalizeOrigin(value: unknown): ThemeOrigin | undefined { + if (!isRecord(value) || !THEME_SOURCE_KINDS.includes(value.kind as ThemeSourceKind)) return undefined; + const sourceName = typeof value.sourceName === 'string' ? sanitizeName(value.sourceName) : ''; + const sourcePath = typeof value.sourcePath === 'string' && value.sourcePath.length <= 1024 && !/[\u0000-\u001f\u007f-\u009f]/u.test(value.sourcePath) + ? value.sourcePath : undefined; + const importerVersion = typeof value.importerVersion === 'number' && Number.isInteger(value.importerVersion) && value.importerVersion > 0 + ? value.importerVersion : undefined; + const importedAt = isoDate(value.importedAt); + return {kind: value.kind as ThemeSourceKind, ...(sourceName ? {sourceName} : {}), ...(sourcePath ? {sourcePath} : {}), + ...(importerVersion ? {importerVersion} : {}), ...(importedAt ? {importedAt} : {})}; +} + +export function normalizeThemeAsset(value: unknown): ThemeAsset | undefined { + if (!isRecord(value) || typeof value.id !== 'string' || !ID.test(value.id)) return undefined; + const theme = normalizeCustomTheme(value.theme); + if (!theme) return undefined; + const origin = normalizeOrigin(value.origin); + const createdAt = isoDate(value.createdAt); + return {id: value.id, theme, ...(origin ? {origin} : {}), ...(createdAt ? {createdAt} : {}), + ...(origin && value.modified === true ? {modified: true} : {})}; +} + +export interface NormalizedLibrary { + themes: ThemeAsset[]; + /** The asset the `custom` palette uses, when it exists. */ + themeId?: string; +} + +/** + * Library normalization and the one-time migration: a configuration without + * `themes` turns a valid legacy `customTheme` into one Custom asset (kept + * active through the same `custom` palette). Malformed assets are dropped + * individually; duplicate ids keep the first; the library is bounded. + */ +export function normalizeThemeLibrary(themes: unknown, legacyCustom: unknown, themeId: unknown): NormalizedLibrary { + if (!Array.isArray(themes)) { + const legacy = normalizeCustomTheme(legacyCustom); + return legacy ? {themes: [{id: LEGACY_THEME_ID, theme: legacy}], themeId: LEGACY_THEME_ID} : {themes: []}; + } + const seen = new Set(); + const assets: ThemeAsset[] = []; + for (const item of themes.slice(0, THEME_LIBRARY_LIMIT * 2)) { + const asset = normalizeThemeAsset(item); + if (!asset || seen.has(asset.id)) continue; + seen.add(asset.id); + assets.push(asset); + if (assets.length >= THEME_LIBRARY_LIMIT) break; + } + const active = typeof themeId === 'string' && seen.has(themeId) ? themeId : undefined; + return {themes: assets, ...(active ? {themeId: active} : {})}; +} + +export function findTheme(themes: readonly ThemeAsset[], id: string | undefined): ThemeAsset | undefined { + return id === undefined ? undefined : themes.find(asset => asset.id === id); +} + +/** Display line for an asset's provenance: `Imported from Ghostty · Catppuccin`, with `· Modified` after edits. */ +export function provenanceLabel(asset: ThemeAsset): string { + if (!asset.origin) return asset.theme.basedOn ? `Custom · based on ${asset.theme.basedOn}` : 'Custom'; + const source = [THEME_SOURCE_LABELS[asset.origin.kind], asset.origin.sourceName].filter(Boolean).join(' · '); + return `Imported from ${source}${asset.modified ? ' · Modified' : ''}`; +} + +export function libraryCounts(themes: readonly ThemeAsset[]): {imported: number; custom: number} { + const imported = themes.filter(asset => asset.origin).length; + return {imported, custom: themes.length - imported}; +} + +/** `2 imported · 3 custom`, or undefined for an empty library. */ +export function librarySummary(themes: readonly ThemeAsset[]): string | undefined { + const {imported, custom} = libraryCounts(themes); + const parts = [imported ? `${imported} imported` : '', custom ? `${custom} custom` : ''].filter(Boolean); + return parts.length ? parts.join(' · ') : undefined; +} + +/** A unique display name in the library ("Name", "Name 2", ...); names never identify assets. */ +export function uniqueThemeName(themes: readonly ThemeAsset[], wanted: string, except?: string): string { + const base = sanitizeName(wanted) || 'Theme'; + const taken = new Set(themes.filter(asset => asset.id !== except).map(asset => asset.theme.name.toLowerCase())); + if (!taken.has(base.toLowerCase())) return base; + for (let index = 2; ; index++) { + const suffix = ` ${index}`; + const candidate = `${base.slice(0, 48 - suffix.length)}${suffix}`; + if (!taken.has(candidate.toLowerCase())) return candidate; + } +} + +export type LibraryResult = {ok: true; value: T} | {ok: false; error: string}; + +/** A new asset in the library; fails cleanly when the library is full. */ +export function addThemeAsset(themes: readonly ThemeAsset[], theme: CustomTheme, origin?: ThemeOrigin, now = new Date()): LibraryResult<{themes: ThemeAsset[]; asset: ThemeAsset}> { + if (themes.length >= THEME_LIBRARY_LIMIT) return {ok: false, error: `The theme library is full (${THEME_LIBRARY_LIMIT} themes). Delete one first.`}; + const asset: ThemeAsset = {id: newThemeId(new Set(themes.map(item => item.id))), + theme: {...structuredClone(theme), name: uniqueThemeName(themes, theme.name)}, + ...(origin ? {origin: {...origin}} : {}), createdAt: now.toISOString()}; + return {ok: true, value: {themes: [...themes, asset], asset}}; +} + +/** Saves edited colors/name; an imported asset becomes Modified (its source file is never touched). */ +export function updateThemeAsset(themes: readonly ThemeAsset[], id: string, theme: CustomTheme): LibraryResult { + const current = findTheme(themes, id); + if (!current) return {ok: false, error: 'That theme no longer exists.'}; + const changed = JSON.stringify(current.theme) !== JSON.stringify(theme); + const next: ThemeAsset = {...current, theme: {...structuredClone(theme), name: uniqueThemeName(themes, theme.name, id)}, + ...(current.origin && (changed || current.modified) ? {modified: true} : {})}; + return {ok: true, value: themes.map(asset => asset.id === id ? next : asset)}; +} + +export function renameThemeAsset(themes: readonly ThemeAsset[], id: string, name: string): LibraryResult { + const current = findTheme(themes, id); + if (!current) return {ok: false, error: 'That theme no longer exists.'}; + const clean = sanitizeName(name); + if (!clean) return {ok: false, error: 'Name must be 1–48 printable characters.'}; + return updateThemeAsset(themes, id, {...current.theme, name: clean}); +} + +/** A Custom copy (no provenance) whose Based on names the original. */ +export function duplicateThemeAsset(themes: readonly ThemeAsset[], id: string, now = new Date()): LibraryResult<{themes: ThemeAsset[]; asset: ThemeAsset}> { + const current = findTheme(themes, id); + if (!current) return {ok: false, error: 'That theme no longer exists.'}; + return addThemeAsset(themes, {...current.theme, name: `${current.theme.name.slice(0, 43)} copy`, basedOn: current.theme.name}, undefined, now); +} + +export function removeThemeAsset(themes: readonly ThemeAsset[], id: string): ThemeAsset[] { + return themes.filter(asset => asset.id !== id); +} diff --git a/src/appearance/themeLibraryActions.ts b/src/appearance/themeLibraryActions.ts new file mode 100644 index 00000000..cadc89a6 --- /dev/null +++ b/src/appearance/themeLibraryActions.ts @@ -0,0 +1,100 @@ +import type {PromptConfiguration} from '../prompt/configuration.js'; +import {normalizePromptConfiguration} from '../prompt/configuration.js'; +import {BRIDGE_TARGET_LABELS, targetsPinnedTo, type BridgeTargetId} from '../themeBridge/model.js'; +import type {CustomTheme} from './customTheme.js'; +import { + addThemeAsset, duplicateThemeAsset, findTheme, removeThemeAsset, renameThemeAsset, updateThemeAsset, type ThemeOrigin, +} from './themeLibrary.js'; +import {assetRef, builtinTheme, parseThemeRef, type ThemeRef} from './themeRefs.js'; + +/** + * Library operations over the whole configuration: the one place assets, + * the active selection, the `customTheme` mirror and Theme Bridge references + * change together. Every result is re-normalized so the mirror is always the + * active asset. Nothing here touches the filesystem. + */ + +export type ActionResult = {ok: true; config: PromptConfiguration; message: string; id?: string} | {ok: false; error: string}; + +const finish = (config: PromptConfiguration, message: string, id?: string): ActionResult => + ({ok: true, config: normalizePromptConfiguration(config), message, ...(id ? {id} : {})}); + +/** Make any selectable theme the active NMSh theme. A missing asset is an error, never a substitute. */ +export function setActiveTheme(config: PromptConfiguration, ref: ThemeRef): ActionResult { + const parsed = parseThemeRef(ref); + if (!parsed) return {ok: false, error: 'Not a theme reference.'}; + if (parsed.kind === 'builtin') { + return finish({...config, nmsh: {...config.nmsh, palette: parsed.palette, ...(parsed.accent ? {accent: parsed.accent} : {})}}, `Theme · ${builtinTheme(parsed.palette, parsed.accent).name}`); + } + const asset = findTheme(config.themes, parsed.id); + if (!asset) return {ok: false, error: 'That theme no longer exists.'}; + return finish({...config, nmsh: {...config.nmsh, palette: 'custom', themeId: asset.id}}, `Theme · ${asset.theme.name}`); +} + +/** A new Custom or Imported asset; `activate` also makes it the active theme. */ +export function addTheme(config: PromptConfiguration, theme: CustomTheme, origin?: ThemeOrigin, activate = false): ActionResult { + const added = addThemeAsset(config.themes, theme, origin); + if (!added.ok) return added; + const next = {...config, themes: added.value.themes}; + if (activate) next.nmsh = {...next.nmsh, palette: 'custom', themeId: added.value.asset.id}; + return finish(next, `${origin ? 'Imported' : 'Saved'} ${added.value.asset.theme.name}${activate ? ' · active' : ''}`, added.value.asset.id); +} + +export function saveTheme(config: PromptConfiguration, id: string, theme: CustomTheme): ActionResult { + const updated = updateThemeAsset(config.themes, id, theme); + if (!updated.ok) return updated; + return finish({...config, themes: updated.value}, `Saved ${findTheme(updated.value, id)!.theme.name}`, id); +} + +export function renameTheme(config: PromptConfiguration, id: string, name: string): ActionResult { + const renamed = renameThemeAsset(config.themes, id, name); + if (!renamed.ok) return renamed; + return finish({...config, themes: renamed.value}, `Renamed to ${findTheme(renamed.value, id)!.theme.name}`, id); +} + +export function duplicateTheme(config: PromptConfiguration, id: string): ActionResult { + const copied = duplicateThemeAsset(config.themes, id); + if (!copied.ok) return copied; + return finish({...config, themes: copied.value.themes}, `Duplicated as ${copied.value.asset.theme.name} (Custom)`, copied.value.asset.id); +} + +/** A built-in, immutable theme copied into the library as an editable Custom theme. */ +export function duplicateBuiltin(config: PromptConfiguration, ref: ThemeRef): ActionResult { + const parsed = parseThemeRef(ref); + if (parsed?.kind !== 'builtin') return {ok: false, error: 'Not a built-in theme.'}; + const source = builtinTheme(parsed.palette, parsed.accent); + return addTheme(config, {...source, name: `My ${source.name}`.slice(0, 48), basedOn: source.name}); +} + +export interface DeleteCheck { + /** Deleting the active NMSh theme is refused: choose another theme first, so nothing falls back silently. */ + active: boolean; + /** Theme Bridge targets pinned to this theme (Choose theme). */ + pinned: BridgeTargetId[]; +} + +export function deleteCheck(config: PromptConfiguration, id: string): DeleteCheck { + return {active: config.nmsh.palette === 'custom' && config.nmsh.themeId === id, pinned: targetsPinnedTo(config.themeBridge, assetRef(id))}; +} + +/** + * Deletes an asset. Pinned Theme Bridge targets block the delete unless the + * user explicitly confirmed that they become Independent; the active theme is + * never deleted. No reference is ever left dangling or re-pointed elsewhere. + */ +export function deleteTheme(config: PromptConfiguration, id: string, confirmIndependent = false): ActionResult { + const asset = findTheme(config.themes, id); + if (!asset) return {ok: false, error: 'That theme no longer exists.'}; + const check = deleteCheck(config, id); + if (check.active) return {ok: false, error: `${asset.theme.name} is the active theme. Choose another theme first.`}; + if (check.pinned.length && !confirmIndependent) { + return {ok: false, error: `Theme Bridge ${check.pinned.map(target => BRIDGE_TARGET_LABELS[target]).join(', ')} ${check.pinned.length === 1 ? 'is' : 'are'} pinned to ${asset.theme.name}.`}; + } + const targets = structuredClone(config.themeBridge.targets); + // Explicitly confirmed: affected targets become Independent (never another theme). The old reference is dropped. + for (const target of check.pinned) targets[target] = {mode: 'independent'}; + const next: PromptConfiguration = {...config, themes: removeThemeAsset(config.themes, id), themeBridge: {...config.themeBridge, targets}, + nmsh: config.nmsh.themeId === id ? (({themeId: _removed, ...rest}) => rest)(config.nmsh) as PromptConfiguration['nmsh'] : config.nmsh}; + const suffix = check.pinned.length ? ` · ${check.pinned.map(target => BRIDGE_TARGET_LABELS[target]).join(', ')} now Independent` : ''; + return finish(next, `Deleted ${asset.theme.name}${suffix}`); +} diff --git a/src/appearance/themeRefs.ts b/src/appearance/themeRefs.ts new file mode 100644 index 00000000..2e72b2fc --- /dev/null +++ b/src/appearance/themeRefs.ts @@ -0,0 +1,105 @@ +import {CATPPUCCIN_ACCENTS, CATPPUCCIN_ACCENT_LABELS, accentedVariant, themeVariant, type CatppuccinAccent} from './themeFamilies.js'; +import {cloneTheme, PROMPT_THEME_ROLES, type CustomTheme, type PromptThemeRole, type UiThemeRole} from './customTheme.js'; +import {categoryOf, findTheme, type ThemeAsset} from './themeLibrary.js'; +import {defaultUiColors} from './uiTheme.js'; +import {hexColor} from '../chroma/color.js'; +import {NATIVE_PALETTE_IDS, THEME_PALETTE_IDS, type NativePaletteId} from '../prompt/configuration.js'; +import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; + +/** + * A stable reference to any selectable NMSh theme. Built-ins are referenced by + * palette id (plus the Catppuccin accent, which changes their colors); library + * themes by asset id, never by display name: + * + * builtin:gruvboxDark builtin:catppuccinMocha@peach asset:t-1a2b3c4d5e6f + */ +export type ThemeRef = string; +export type ParsedThemeRef = {kind: 'builtin'; palette: Exclude; accent?: CatppuccinAccent} | {kind: 'asset'; id: string}; + +const BUILTIN_IDS = new Set(THEME_PALETTE_IDS.filter(id => id !== 'custom')); + +export function parseThemeRef(ref: unknown): ParsedThemeRef | undefined { + if (typeof ref !== 'string' || ref.length > 80) return undefined; + const asset = /^asset:([a-z0-9][a-z0-9-]{2,39})$/u.exec(ref); + if (asset) return {kind: 'asset', id: asset[1]!}; + const builtin = /^builtin:([A-Za-z0-9]+)(?:@([a-z]+))?$/u.exec(ref); + if (!builtin || !BUILTIN_IDS.has(builtin[1]!)) return undefined; + const accent = builtin[2]; + if (accent !== undefined && !CATPPUCCIN_ACCENTS.includes(accent as CatppuccinAccent)) return undefined; + return {kind: 'builtin', palette: builtin[1] as Exclude, ...(accent ? {accent: accent as CatppuccinAccent} : {})}; +} + +export function builtinRef(palette: Exclude, accent?: CatppuccinAccent): ThemeRef { + // Only accented families carry the accent, and Mauve is their default. + return themeVariant(palette)?.accents && accent && accent !== 'mauve' ? `builtin:${palette}@${accent}` : `builtin:${palette}`; +} + +export const assetRef = (id: string): ThemeRef => `asset:${id}`; + +/** What a library-aware configuration exposes to theme resolution. */ +export interface ThemeSource { + nmsh: {palette: NativePaletteId; accent: CatppuccinAccent; themeId?: string}; + themes: readonly ThemeAsset[]; +} + +/** The active NMSh theme as a stable reference (what Follow NMSh follows). */ +export function activeThemeRef(config: ThemeSource): ThemeRef | undefined { + if (config.nmsh.palette === 'custom') return config.nmsh.themeId && findTheme(config.themes, config.nmsh.themeId) ? assetRef(config.nmsh.themeId) : undefined; + return builtinRef(config.nmsh.palette, config.nmsh.accent); +} + +const UI_DEFAULTS = (): Record => { + const ui = defaultUiColors(); + return {accent: hexColor(ui.accent), primary: hexColor(ui.primary), secondary: hexColor(ui.secondary), subtle: hexColor(ui.subtle), + separator: hexColor(ui.separator), selection: hexColor(ui.selection), success: hexColor(ui.success), warning: '#d99a3e', + failure: hexColor(ui.failure), info: '#4fb3c4'}; +}; + +/** + * A built-in palette as Native theme data, with the accent applied explicitly + * (never the live global theme context), so resolution is pure. + */ +export function builtinTheme(palette: Exclude, accent: CatppuccinAccent = 'mauve'): CustomTheme { + const defaults = UI_DEFAULTS(); + const variant = themeVariant(palette); + if (variant) { + const accented = accentedVariant(variant, accent); + return cloneTheme(accented.label, accented.label, {...accented.roles}, accented.ui, defaults, variant.dark); + } + const native = NATIVE_PROMPT_THEMES[palette] ?? NATIVE_PROMPT_THEMES.lavender; + const prompt = Object.fromEntries(PROMPT_THEME_ROLES.map(role => [role, hexColor(native.colors(role).background)])) as Record; + return cloneTheme(native.label, native.label, prompt, {accent: defaults.accent, separator: defaults.separator, success: defaults.success, failure: defaults.failure}, defaults, true); +} + +export type ThemeResolution = {ok: true; theme: CustomTheme; category: 'builtin' | 'imported' | 'custom'} | {ok: false; reason: 'invalid' | 'missing'}; + +/** Resolves a reference to theme data. A missing asset is reported, never replaced by another theme. */ +export function themeForRef(ref: ThemeRef | undefined, source: Pick): ThemeResolution { + const parsed = parseThemeRef(ref); + if (!parsed) return {ok: false, reason: 'invalid'}; + if (parsed.kind === 'builtin') return {ok: true, theme: builtinTheme(parsed.palette, parsed.accent), category: 'builtin'}; + const asset = findTheme(source.themes, parsed.id); + return asset ? {ok: true, theme: structuredClone(asset.theme), category: categoryOf(asset)} : {ok: false, reason: 'missing'}; +} + +export function themeRefLabel(ref: ThemeRef | undefined, source: Pick): string { + const parsed = parseThemeRef(ref); + if (!parsed) return '—'; + if (parsed.kind === 'asset') return findTheme(source.themes, parsed.id)?.theme.name ?? 'Missing theme'; + const label = (NATIVE_PROMPT_THEMES[parsed.palette] ?? NATIVE_PROMPT_THEMES.lavender).label; + return parsed.accent ? `${label} · ${CATPPUCCIN_ACCENT_LABELS[parsed.accent]}` : label; +} + +export interface SelectableTheme {ref: ThemeRef; label: string; category: 'builtin' | 'imported' | 'custom'} + +/** Every theme a picker can offer: built-ins (default accents), then imported, then custom. */ +export function selectableThemes(source: Pick): SelectableTheme[] { + const builtins = THEME_PALETTE_IDS.filter((id): id is Exclude => id !== 'custom') + .map(palette => ({ref: builtinRef(palette), label: NATIVE_PROMPT_THEMES[palette].label, category: 'builtin' as const})); + const library = (category: 'imported' | 'custom') => source.themes.filter(asset => categoryOf(asset) === category) + .map(asset => ({ref: assetRef(asset.id), label: asset.theme.name, category})); + return [...builtins, ...library('imported'), ...library('custom')]; +} + +/** NMSh's own (non-family) palettes, for grouping in pickers. */ +export const isNativePalette = (palette: NativePaletteId): boolean => NATIVE_PALETTE_IDS.includes(palette); diff --git a/src/appearance/themeSelection.ts b/src/appearance/themeSelection.ts index 0aa2bb9c..4936dea7 100644 --- a/src/appearance/themeSelection.ts +++ b/src/appearance/themeSelection.ts @@ -4,6 +4,7 @@ import {hexColor} from '../chroma/color.js'; import {accentedVariant, familyVariants, THEME_FAMILIES, themeVariant, type ThemeFamilyId} from './themeFamilies.js'; import {cloneTheme, PROMPT_THEME_ROLES, type CustomTheme, type PromptThemeRole, type UiThemeRole} from './customTheme.js'; import {defaultUiColors, uiThemeInput} from './uiTheme.js'; +import {categoryOf, findTheme, libraryCounts} from './themeLibrary.js'; /** * Theme family / variant selection over the one stored palette id. NMSh's own @@ -68,3 +69,50 @@ export const FAMILY_LABELS: readonly string[] = THEME_FAMILIES.map(family => fam export function variantLabel(config: PromptConfiguration): string { return THEME_FAMILIES.find(family => family.id === familyOf(config.nmsh.palette))?.variantLabel ?? 'Variant'; } + +// ---- Library-aware selection (Setup and Settings) ----------------------------- + +/** Built-in families, then Imported and Custom library themes as their own groups. */ +export type SelectionFamily = Exclude | 'imported' | 'custom'; + +export function currentSelectionFamily(config: PromptConfiguration): SelectionFamily { + if (config.nmsh.palette !== 'custom') return familyOf(config.nmsh.palette) as SelectionFamily; + const asset = findTheme(config.themes, config.nmsh.themeId); + return asset ? categoryOf(asset) : 'custom'; +} + +/** Families offered: every built-in family, plus Imported/Custom when the library has such themes. */ +export function selectionFamilies(config: PromptConfiguration): SelectionFamily[] { + const builtins = FAMILY_IDS.filter((id): id is Exclude => id !== 'custom'); + const {imported, custom} = libraryCounts(config.themes); + return [...builtins, ...(imported ? ['imported' as const] : []), ...(custom ? ['custom' as const] : [])]; +} + +export function selectionFamilyLabel(family: SelectionFamily): string { + if (family === 'imported') return 'Imported'; + if (family === 'custom') return 'Custom'; + return THEME_FAMILIES.find(item => item.id === family)?.label ?? family; +} + +export interface SelectionVariant {label: string; apply: (config: PromptConfiguration) => PromptConfiguration; current: (config: PromptConfiguration) => boolean} + +/** Variants within a family: palette variants for built-ins, library themes (by stable id) for Imported/Custom. */ +export function selectionVariants(config: PromptConfiguration, family: SelectionFamily): SelectionVariant[] { + if (family === 'imported' || family === 'custom') { + return config.themes.filter(asset => categoryOf(asset) === family).map(asset => ({label: asset.theme.name, + apply: c => ({...c, nmsh: {...c.nmsh, palette: 'custom', themeId: asset.id}, customTheme: structuredClone(asset.theme)}), + current: c => c.nmsh.palette === 'custom' && c.nmsh.themeId === asset.id})); + } + return variantOptions(family).map(option => ({label: option.label, + apply: c => ({...c, nmsh: {...c.nmsh, palette: option.id}}), current: c => c.nmsh.palette === option.id})); +} + +/** Choosing a family moves to its default variant, or the first library theme of that category. */ +export function selectSelectionFamily(config: PromptConfiguration, family: SelectionFamily): PromptConfiguration { + if (currentSelectionFamily(config) === family) return config; + if (family === 'imported' || family === 'custom') { + const first = selectionVariants(config, family)[0]; + return first ? first.apply(config) : config; + } + return {...config, nmsh: {...config.nmsh, palette: DEFAULT_VARIANT[family]}}; +} diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index f25872f9..2cdadad2 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -60,13 +60,16 @@ export const CONCEPTS: readonly Concept[] = [ description: 'Ghost suggestions are the dim predicted rest of a command shown as you type (→ accepts). The provider is NMSh Native, Deja, or None; empty-prompt prediction is optional.'}, {id: 'prompt', label: 'Prompt', support: 'actionable', capability: 'prompt.open', open: '/prompt', covers: ['/prompt', 'settings:Prompt', 'family:prompt'], aliases: ['prompt', 'prompts', 'prompt style', 'prompt modules', 'ps1', 'starship', 'powerlevel10k', 'p10k', 'rich git', 'git prompt', 'composer layout', 'one-line prompt', 'two-line prompt'], - description: 'The prompt above the composer: NMSh Native (themes, geometry, modules, Rich Git), Starship or Powerlevel10k. /prompt previews and saves it.'}, + description: 'The prompt above the composer: NMSh Native (themes, geometry, modules, Rich Git), Starship, Powerlevel10k, or None (composer only). /prompt previews and saves it.'}, {id: 'theme', label: 'Theme and appearance', support: 'actionable', capability: 'theme.open', open: '/appearance', prefers: ['change', 'explain'], covers: ['/appearance', 'settings:Appearance', 'settings:Motion'], aliases: ['theme', 'themes', 'appearance', 'colors', 'colours', 'color scheme', 'colour scheme', 'styling', 'vibrance', 'opacity', 'blur', 'transparency', 'palette'], description: 'Appearance covers the Native theme, vibrance, UI chrome colors and terminal opacity/blur where the terminal supports it.'}, - {id: 'themeStudio', label: 'Theme Studio (custom themes)', support: 'actionable', open: '/theme', covers: ['/theme'], - aliases: ['theme studio', 'custom theme', 'custom themes', 'import theme', 'export theme', 'my own theme'], - description: 'Theme Studio clones a Native theme so you can edit, import and export your own.'}, + {id: 'themeStudio', label: 'Theme Studio', support: 'actionable', open: '/theme', covers: ['/theme'], + aliases: ['theme studio', 'custom theme', 'custom themes', 'import theme', 'export theme', 'my own theme', 'imported theme', 'theme library'], + description: 'Theme Studio manages Native themes: browse built-ins, import a theme file (Base16/24, Windows Terminal, Oh My Posh, Kitty, Ghostty, iTerm2, WezTerm), edit, duplicate, export and select.'}, + {id: 'themeBridge', label: 'Theme Bridge', support: 'actionable', open: '/theme-bridge', covers: ['/theme-bridge'], + aliases: ['theme bridge', 'fzf colors', 'fzf colours', 'man page colors', 'less colors', 'ls colors', 'ls_colors', 'tmux theme', 'tmux colors', 'neovim theme', 'nvim colorscheme', 'vim colorscheme'], + description: 'Theme Bridge extends NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim and Vim. Each tool is Independent until you choose Follow NMSh or a pinned theme.'}, {id: 'chroma', label: 'Chroma', support: 'actionable', open: '/chroma', covers: ['/chroma', 'settings:Presentation'], aliases: ['chroma', 'gradient', 'gradients', 'animated colors', 'animated colours', 'color motion', 'colour motion', 'prompt gradient', 'rainbow prompt', 'chroma palette', 'animated prompt colors', 'animated prompt colours', 'animated prompt'], @@ -305,7 +308,7 @@ export interface GuideSection {id: string; title: string; why: string; concepts: export const GUIDE_SECTIONS: readonly GuideSection[] = [ {id: 'start', title: 'Getting started', why: 'NMSh is a terminal frontend over a real, persistent shell: your shell keeps its state, and NMSh adds the editor, transcript and tools around it.', concepts: ['setup', 'help', 'palette', 'settings', 'version', 'updates', 'status']}, - {id: 'look', title: 'Prompt & appearance', why: 'Make the prompt and colors yours without editing dotfiles.', concepts: ['prompt', 'theme', 'themeStudio', 'glyphs', 'syntax']}, + {id: 'look', title: 'Prompt & appearance', why: 'Make the prompt and colors yours without editing dotfiles.', concepts: ['prompt', 'theme', 'themeStudio', 'themeBridge', 'glyphs', 'syntax']}, {id: 'cursorEffects', title: 'Cursor & effects', why: 'Cursor trails and motion for NMSh-owned chrome; Reduced Motion, Effects Off and NO_COLOR are always respected.', concepts: ['cursor', 'motion']}, {id: 'chroma', title: 'Chroma', why: 'Optional gradients and motion for NMSh-owned chrome only; your command output is never recolored.', concepts: ['chroma', 'effects', 'activity']}, {id: 'shells', title: 'Shells', why: 'One NMSh window can run zsh, Fish or Bash, and you can leave for a plain shell and come back.', concepts: ['shell', 'leave', 'otherShells']}, diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index e7e441a7..2c5dc4f0 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -15,7 +15,8 @@ export const slashCommands: readonly SlashCommand[] = [ {name: '/activity', insertion: '/activity', description: 'Live activity colors for the running-command line'}, {name: '/screensaver', insertion: '/screensaver', description: 'Idle visuals: live gallery, timeout and colors'}, {name: '/screensaver start', insertion: '/screensaver start', description: 'Start the selected idle visual now; any key or mouse stops it'}, - {name: '/theme', insertion: '/theme', description: 'Theme Studio: clone, edit, import and export a custom Native theme'}, + {name: '/theme', insertion: '/theme', description: 'Theme Studio: built-in, imported and custom Native themes; create, edit, import, export, select'}, + {name: '/theme-bridge', insertion: '/theme-bridge', description: 'Theme Bridge: extend NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim and Vim (opt-in per tool)'}, {name: '/chroma', insertion: '/chroma', description: 'Chroma palettes, motion and custom gradients for the Native prompt'}, {name: '/settings', insertion: '/settings', description: 'Open NMSh settings (Config view)'}, {name: '/setup', insertion: '/setup', description: 'Setup Cat: guided, rerunnable setup; keeps your current choices'}, @@ -75,6 +76,7 @@ export type ParsedSlashCommand = | {kind: 'prompt'} | {kind: 'chroma'} | {kind: 'theme'} + | {kind: 'themeBridge'} | {kind: 'cursor'} | {kind: 'activity'} | {kind: 'screensaver'; start: boolean; mode?: IdleMode} @@ -125,6 +127,7 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (/^\/prompt\s*$/u.test(input)) return {kind: 'prompt'}; if (/^\/chroma\s*$/u.test(input)) return {kind: 'chroma'}; if (/^\/theme\s*$/u.test(input)) return {kind: 'theme'}; + if (/^\/theme-bridge\s*$/u.test(input)) return {kind: 'themeBridge'}; if (/^\/cursor\s*$/u.test(input)) return {kind: 'cursor'}; if (/^\/activity\s*$/u.test(input)) return {kind: 'activity'}; const screensaver = /^\/screensaver(?:\s+(start)(?:\s+(\w+))?)?\s*$/u.exec(input); diff --git a/src/configuration/portability.ts b/src/configuration/portability.ts index 2e67b9f0..c591da2d 100644 --- a/src/configuration/portability.ts +++ b/src/configuration/portability.ts @@ -19,7 +19,8 @@ export const PORTABLE_CATEGORIES = { prompt: ['provider', 'promptSymbol', 'promptSymbolCustom', 'modules', 'separator', 'gap', 'spacing', 'placement', 'nmsh.gapEnabled', 'nmsh.startStyle', 'nmsh.connector', 'nmsh.endStyle', 'nmsh.icons', 'nmsh.style', 'nmsh.connectorFade', 'nmsh.connectorFadeColors', 'nmsh.gitEnabled', 'nmsh.gitColors', 'nmsh.gitGeometry', 'nmsh.gitConnectorFade', 'nmsh.mirrorRight', 'nmsh.styleProfiles'], - theme: ['nmsh.palette', 'nmsh.vibrance', 'nmsh.accent', 'customTheme'], + theme: ['nmsh.palette', 'nmsh.vibrance', 'nmsh.accent', 'nmsh.themeId', 'themes', 'customTheme'], + themeBridge: ['themeBridge'], chroma: ['presentation'], chrome: ['uiChrome', 'glyphStyle', 'cursor'], syntax: ['syntax'], @@ -75,6 +76,15 @@ function setPath(target: Record, path: string, value: unknown): current[keys.at(-1)!] = structuredClone(value); } +function portableThemes(value: unknown): unknown { + if (!Array.isArray(value)) return value; + return value.map(asset => { + const copy = structuredClone(asset) as {origin?: {sourcePath?: string}}; + if (copy.origin) delete copy.origin.sourcePath; + return copy; + }); +} + export function parseCategories(text: string | undefined): PortableCategory[] { if (!text || text === 'all') return [...CATEGORY_IDS]; const requested = text.split(',').map(item => item.trim()).filter(Boolean); @@ -92,7 +102,8 @@ export function exportSettings(configuration: PromptConfiguration, categories: r const values: Record = {}; for (const path of PORTABLE_CATEGORIES[category]) { const value = getPath(normalized, path); - if (value !== undefined) values[path] = structuredClone(value); + // Imported themes keep their source path on this machine only; a transfer never carries it. + if (value !== undefined) values[path] = path === 'themes' ? portableThemes(value) : structuredClone(value); } document.categories[category] = values; } diff --git a/src/host/semanticMarks.ts b/src/host/semanticMarks.ts new file mode 100644 index 00000000..73d8b95d --- /dev/null +++ b/src/host/semanticMarks.ts @@ -0,0 +1,109 @@ +import {hostname as osHostname} from 'node:os'; +import {terminalProfile} from './capabilities.js'; + +/** + * Host semantic cooperation: OSC 7 (current directory) and OSC 133 (command + * zones), projected from NMSh's own authoritative lifecycle (the + * authenticated private OSC 777 markers). They are an enhancement for capable + * hosts and multiplexers; NMSh never reads them back or depends on them. + * + * ready (prompt) → [D;status if a command was running] OSC 7 (if cwd changed) A B + * exec (command) → C + * shell ends → D (no status: unknown) if a command was still running + * + * Markers are never written while a fullscreen program owns the terminal; + * anything due then is held and written when NMSh owns the screen again. + */ + +const ST = '\u001B\\'; + +export interface SemanticSupport { + /** OSC 133 command zones. */ + marks: boolean; + /** OSC 7 current working directory. */ + cwd: boolean; +} + +/** + * Hosts documented to understand these sequences (and multiplexers, which + * consume them for their own pane state). Unknown hosts get nothing. + * NMSH_SEMANTIC=0 turns both off; NMSH_SEMANTIC=1 forces both on. + */ +export function semanticSupport(env: NodeJS.ProcessEnv = process.env): SemanticSupport { + if (env.NMSH_SEMANTIC === '0' || env.TERM === 'dumb') return {marks: false, cwd: false}; + if (env.NMSH_SEMANTIC === '1') return {marks: true, cwd: true}; + if (env.TMUX || /^(tmux|screen)/u.test(env.TERM ?? '')) return {marks: Boolean(env.TMUX), cwd: true}; + if (env.TERM_PROGRAM === 'Apple_Terminal') return {marks: false, cwd: true}; + const profile = terminalProfile(env); + const known = profile === 'ghostty' || profile === 'kitty' || profile === 'wezterm' || profile === 'iterm2' || profile === 'windows-terminal'; + return {marks: known, cwd: known}; +} + +const UNRESERVED = /[A-Za-z0-9\-._~/]/u; + +/** `file://host/path` with every byte outside the unreserved set (and `/`) percent-encoded. */ +export function osc7(cwd: string, host = osHostname()): string | undefined { + if (!cwd.startsWith('/') || cwd.length > 4096 || /[\u0000-\u001f\u007f]/u.test(cwd)) return undefined; + const safeHost = /^[A-Za-z0-9.-]{1,253}$/u.test(host) ? host : ''; + let path = ''; + for (const byte of Buffer.from(cwd, 'utf8')) { + const character = String.fromCharCode(byte); + path += byte < 0x80 && UNRESERVED.test(character) ? character : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; + } + return `\u001B]7;file://${safeHost}${path}${ST}`; +} + +export const osc133 = (kind: 'A' | 'B' | 'C' | 'D', status?: number): string => + `\u001B]133;${kind}${kind === 'D' && status !== undefined && Number.isInteger(status) ? `;${status}` : ''}${ST}`; + +export class HostSemantics { + private zone: 'none' | 'prompt' | 'running' = 'none'; + private lastCwd?: string; + private pending = ''; + + constructor(private readonly support: SemanticSupport, private readonly write: (data: string) => void, + private readonly owned: () => boolean, private readonly host = osHostname()) {} + + get state(): 'none' | 'prompt' | 'running' { return this.zone; } + + private emit(data: string): void { + if (!data) return; + this.pending += data; + this.flush(); + } + + /** Writes anything held back once NMSh owns the screen (never mid fullscreen program). */ + flush(): void { + if (!this.pending || !this.owned()) return; + const data = this.pending; + this.pending = ''; + this.write(data); + } + + /** The shell reported readiness (OSC 777 status;cwd). */ + prompt(cwd: string, status: number): void { + let out = ''; + if (this.support.marks && this.zone === 'running') out += osc133('D', status); + if (this.support.cwd && cwd !== this.lastCwd) { + const sequence = osc7(cwd, this.host); + if (sequence) { out += sequence; this.lastCwd = cwd; } + } + if (this.support.marks) out += `${osc133('A')}${osc133('B')}`; + this.zone = 'prompt'; + this.emit(out); + } + + /** The shell reported a command about to run (OSC 777 exec). */ + exec(): void { + if (this.zone !== 'prompt') return; + this.zone = 'running'; + if (this.support.marks) this.emit(osc133('C')); + } + + /** The shell ended or was replaced: a still-open command zone is closed without inventing a status. */ + end(): void { + if (this.zone === 'running' && this.support.marks) this.emit(osc133('D')); + this.zone = 'none'; + this.lastCwd = undefined; + } +} diff --git a/src/input/inputLayout.ts b/src/input/inputLayout.ts index 90c99b92..0f1a735b 100644 --- a/src/input/inputLayout.ts +++ b/src/input/inputLayout.ts @@ -36,7 +36,9 @@ export function layoutInput( const safeCursor = Math.max(0, Math.min(glyphs.length, cursorIndex)); const width = Math.max(1, columns); const INPUT_PREFIX = firstLinePrefix ?? `${GLYPHS.prompt} `; - const rows: InputRow[] = [{prefix: width >= 2 ? INPUT_PREFIX : GLYPHS.prompt, text: '', charStart: 0, charEnd: 0}]; + // An explicitly empty prefix (Prompt None) means no marker and no continuation indent. + const bare = firstLinePrefix === ''; + const rows: InputRow[] = [{prefix: bare ? '' : width >= 2 ? INPUT_PREFIX : GLYPHS.prompt, text: '', charStart: 0, charEnd: 0}]; let rowIndex = 0; let contentWidth = 0; let caretRow = 0; @@ -51,7 +53,7 @@ export function layoutInput( const addRow = (nextCharStart: number): void => { rows[rowIndex].charEnd = nextCharStart; - rows.push({prefix: width >= 4 ? CONTINUATION_PREFIX : '', text: '', charStart: nextCharStart, charEnd: nextCharStart}); + rows.push({prefix: !bare && width >= 4 ? CONTINUATION_PREFIX : '', text: '', charStart: nextCharStart, charEnd: nextCharStart}); rowIndex += 1; contentWidth = 0; }; diff --git a/src/output/AnsiOutputParser.ts b/src/output/AnsiOutputParser.ts index 0acd4ad9..1c17ba42 100644 --- a/src/output/AnsiOutputParser.ts +++ b/src/output/AnsiOutputParser.ts @@ -6,6 +6,22 @@ export interface StyledCell { style: string; /** Original program-emitted OSC 8, never generated presentation. */ hyperlink?: string; + /** + * The link was authored by NMSh itself (help, docs, task URLs, NMSh-written + * files) through addAuthoredLine and passed the authored-target check. + * Raw PTY links never carry this mark. + */ + authored?: true; +} + +/** Targets NMSh may author: http(s) without credentials, and local file URLs. */ +export function authoredTargetAllowed(target: string): boolean { + if (!target || target.length > 4096 || /[\u0000-\u0020\u007f-\u009f]/u.test(target)) return false; + try { + const url = new URL(target); + if (url.protocol === 'file:') return !url.hostname; + return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.hostname) && !url.username && !url.password; + } catch { return false; } } const revisions = new WeakMap(); @@ -27,6 +43,8 @@ export class AnsiOutputParser { private style = ''; private pending = ''; private hyperlink?: string; + /** While writing an NMSh-authored line: OSC 8 becomes an authored link only for allowed targets. */ + private authoring = false; private discardingOsc = false; constructor(private readonly onClear?: () => void) {} @@ -98,6 +116,12 @@ export class AnsiOutputParser { } } + /** An NMSh-authored line: its OSC 8 links are marked authored when the target is allowed, dropped otherwise. */ + addAuthoredLine(text: string, style = ''): void { + this.authoring = true; + try { this.addLine(text, style); } finally { this.authoring = false; } + } + addLine(text: string, style = ''): void { this.ensureLineBoundary(); this.hyperlink = undefined; @@ -230,7 +254,7 @@ export class AnsiOutputParser { if (separator !== -1) { const target = payload.slice(separator + 1); // Preserve safe original payloads; reject controls and bound retained data. - this.hyperlink = target && validOsc8Payload(payload) + this.hyperlink = target && validOsc8Payload(payload) && (!this.authoring || authoredTargetAllowed(target)) ? payload : undefined; } else this.hyperlink = undefined; } @@ -268,7 +292,7 @@ export class AnsiOutputParser { private put(text: string, width: number): void { for (let position = 0; position < width; position += 1) this.current[this.column + position] = undefined; - this.current[this.column] = {text, width, style: this.style, ...(this.hyperlink ? {hyperlink: this.hyperlink} : {})}; + this.current[this.column] = {text, width, style: this.style, ...(this.hyperlink ? {hyperlink: this.hyperlink, ...(this.authoring ? {authored: true as const} : {})} : {})}; this.touch(); for (let position = 1; position < width; position += 1) this.current[this.column + position] = null; this.column += width; diff --git a/src/output/Hyperlinks.ts b/src/output/Hyperlinks.ts index ebab9168..35dfd114 100644 --- a/src/output/Hyperlinks.ts +++ b/src/output/Hyperlinks.ts @@ -118,3 +118,20 @@ export class HyperlinkPresenter { return line; } } + +const OSC8_CLOSE = '\u001B]8;;\u001B\\'; + +/** + * An NMSh-authored link for live UI rows (task URLs, docs): the text wrapped + * in OSC 8 only when the host supports links and the target is safe; plain + * text otherwise. Callers that truncate append `closeAuthoredLinks` so a cut + * never leaves a link open. + */ +export function authoredLink(text: string, target: string, enabled: boolean): string { + const safe = enabled ? safeHyperlinkTarget(target) : undefined; + return safe ? `\u001B]8;;${safe}\u001B\\${text}${OSC8_CLOSE}` : text; +} + +export function closeAuthoredLinks(row: string): string { + return row.includes('\u001B]8;;') ? `${row}${OSC8_CLOSE}` : row; +} diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 0c357c03..0ac7ceec 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -18,6 +18,8 @@ export interface HistoricalContextSnapshot { project?: string; branch?: string; prompt?: PromptSnapshot; + /** Submitted under Prompt None: there was no prompt, so history renders none (never a substituted Native one). */ + promptless?: true; } export interface SecondaryActivity { @@ -290,7 +292,11 @@ export class OutputBuffer { this.parser.addLine(text, style); } - addFrontendInteraction(command: string, result: string, resultStyle = ''): void { + /** + * `authoredLinks`: the result was rendered by NMSh (for example /help) and + * may carry NMSh-authored OSC 8 links; they are kept as authored cells. + */ + addFrontendInteraction(command: string, result: string, resultStyle = '', authoredLinks = false): void { this.parser.ensureLineBoundary(); if (this.parser.completedCount() > 0) { this.visualGaps.add(this.parser.completedCount()); @@ -298,7 +304,8 @@ export class OutputBuffer { this.lineTypes.set(this.parser.completedCount(), 'metadata'); this.parser.addLine(`${GLYPHS.prompt} ${command}`, foreground(UI_COLORS.command)); this.lineTypes.set(this.parser.completedCount(), 'metadata'); - this.parser.addLine(` ${GLYPHS.info} ${result}`, resultStyle); + if (authoredLinks) this.parser.addAuthoredLine(` ${GLYPHS.info} ${result}`, resultStyle); + else this.parser.addLine(` ${GLYPHS.info} ${result}`, resultStyle); } /** @@ -342,14 +349,15 @@ export class OutputBuffer { } /** A multi-row NMSh-owned result (for example /agents); presentation rows, never shell output. */ - addFrontendBlock(command: string, rows: readonly string[]): void { + addFrontendBlock(command: string, rows: readonly string[], authoredLinks = false): void { this.parser.ensureLineBoundary(); if (this.parser.completedCount() > 0) this.visualGaps.add(this.parser.completedCount()); this.lineTypes.set(this.parser.completedCount(), 'metadata'); this.parser.addLine(`${GLYPHS.prompt} ${command}`, foreground(UI_COLORS.command)); for (const row of rows) { this.lineTypes.set(this.parser.completedCount(), 'metadata'); - this.parser.addLine(` ${row}`, ''); + if (authoredLinks) this.parser.addAuthoredLine(` ${row}`, ''); + else this.parser.addLine(` ${row}`, ''); } } diff --git a/src/output/TranscriptPanel.ts b/src/output/TranscriptPanel.ts index e22f5395..e0f44d8c 100644 --- a/src/output/TranscriptPanel.ts +++ b/src/output/TranscriptPanel.ts @@ -1,6 +1,9 @@ import {DEFAULT_TREATMENT_SETTINGS, type TreatmentSettings} from '../chroma/treatment.js'; import { DIVIDER_COLOR_LABELS, + HISTORICAL_PROMPT_LEVEL_LABELS, + HISTORICAL_PROMPT_LEVELS, + type HistoricalPromptLevel, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, type HistoryColorMode, @@ -60,6 +63,13 @@ function cycle(values: readonly T[], current: T, delta: number): T { return values[(index + delta + values.length) % values.length]!; } +const PROMPT_LEVELS = [...HISTORICAL_PROMPT_LEVELS, 'off'] as const; + +/** Full / Compact / Minimal while on, Off otherwise: one presentation choice over the two stored fields. */ +export function historicalPromptLevel(appearance: TranscriptAppearance): HistoricalPromptLevel | 'off' { + return appearance.historicalPrompt ? appearance.historicalPromptLevel ?? 'full' : 'off'; +} + export function transcriptDraftChanged(state: TranscriptPanelState): boolean { return JSON.stringify(state.draft) !== JSON.stringify(state.saved) || state.folding?.draft !== state.folding?.saved; } @@ -75,7 +85,12 @@ export function handleTranscriptPanelKey(key: Key, state: TranscriptPanelState): case 'divider': draft.divider = !draft.divider; break; case 'density': draft.dividerDensity = draft.dividerDensity === 'compact' ? 'normal' : 'compact'; break; case 'dividerColors': draft.dividerColors = cycle(DIVIDER_COLOR_MODES, draft.dividerColors, delta); break; - case 'prompt': draft.historicalPrompt = !draft.historicalPrompt; break; + case 'prompt': { + const level = cycle(PROMPT_LEVELS, historicalPromptLevel(draft), delta); + draft.historicalPrompt = level !== 'off'; + if (level !== 'off') draft.historicalPromptLevel = level; + break; + } case 'colors': draft.historyColors = cycle(COLOR_MODES, draft.historyColors, delta); break; case 'theme': draft.historyTheme = cycle(NATIVE_PALETTE_IDS, draft.historyTheme, delta); break; case 'folding': state.folding!.draft = cycle(OUTPUT_FOLDING_MODES, state.folding!.draft, delta); break; @@ -104,7 +119,7 @@ export function renderTranscriptPanel(state: TranscriptPanelState, columns: numb divider: `Divider ${value(onOff(draft.divider), onOff(saved.divider))}`, density: `Divider density ${value(draft.dividerDensity === 'compact' ? 'Compact' : 'Normal', saved.dividerDensity === 'compact' ? 'Compact' : 'Normal')}`, dividerColors: `Divider colors ${value(DIVIDER_COLOR_LABELS[draft.dividerColors], DIVIDER_COLOR_LABELS[saved.dividerColors])}`, - prompt: `Historical prompt ${value(onOff(draft.historicalPrompt), onOff(saved.historicalPrompt))}`, + prompt: `Historical prompt ${value(HISTORICAL_PROMPT_LEVEL_LABELS[historicalPromptLevel(draft)], HISTORICAL_PROMPT_LEVEL_LABELS[historicalPromptLevel(saved)])}`, colors: `History colors ${value(colorModeLabel(draft.historyColors), colorModeLabel(saved.historyColors))}`, theme: `History theme ${value(NATIVE_PROMPT_THEMES[draft.historyTheme].label, NATIVE_PROMPT_THEMES[saved.historyTheme].label)}`, folding: state.folding ? `Output folding ${value(foldingLabel(state.folding.draft), foldingLabel(state.folding.saved))}` : '', diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index e27f9207..c10c71ae 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -438,6 +438,23 @@ function legacySegments(context: HistoricalContextSnapshot): HistoricalSegment[] return segments.map(segment => ({...segment, foreground: LEGACY_FOREGROUND, background: LEGACY_BACKGROUNDS[segment.role!], preMuted: true})); } +/** + * Compact and Minimal historical prompts: a quiet view of the stored facts + * (project or short cwd, branch, marker). Presentation only; the snapshot and + * /copy are unchanged, and nothing is invented that was not recorded. + */ +function condensedPrompt(context: HistoricalContextSnapshot, level: 'compact' | 'minimal', width: number): string { + const marker = `${foreground(UI_COLORS.accent)}${GLYPHS.prompt}\u001B[0m`; + if (level === 'minimal') return truncateAnsi(marker, width); + const clean = (text: string) => text.replace(CONTROL_CHARACTERS, '�'); + const cwd = clean(context.cwd); + const home = homedir().replace(/\/$/u, ''); + const place = context.project ? clean(context.project) : cwd === home ? '~' : cwd.split('/').filter(Boolean).pop() ?? cwd; + const subtle = foreground(UI_COLORS.secondary); + const branch = context.branch ? ` ${foreground(UI_COLORS.subtle)}${GLYPHS.branch} ${clean(context.branch)}` : ''; + return truncateAnsi(`${subtle}${place}${branch}\u001B[0m ${marker}`, width); +} + /** The prompt part of a historical header, colored per the transcript appearance. */ /** Divider cells kept between a historical left prompt and its right context. */ const RIGHT_CONTEXT_MIN_DIVIDER = 2; @@ -524,13 +541,17 @@ function historicalDivider(text: string, context: HistoricalContextSnapshot, app export function renderHistoricalContext(context: HistoricalContextSnapshot, width: number, appearance: TranscriptAppearance = DEFAULT_TRANSCRIPT_APPEARANCE, treatment: TreatmentSettings = DEFAULT_TREATMENT_SETTINGS): WrappedRow | undefined { - if (!appearance.divider && !appearance.historicalPrompt) return undefined; + // Prompt None submissions had no prompt: they render like the Off presentation, never a substituted one. + const promptShown = appearance.historicalPrompt && !context.promptless; + if (!appearance.divider && !promptShown) return undefined; const divider = DIVIDER_STYLES[appearance.dividerDensity]; - if (!appearance.historicalPrompt) { + if (!promptShown) { const line = repeatToWidth(divider.glyph, width); return {ansi: `${historicalDivider(line, context, appearance, treatment, divider.color)}\u001B[0m`, plain: line, isHistoricalHeader: true}; } - const parts = historicalPrompt(context, Math.max(0, width - (appearance.divider ? 1 : 0)), appearance); + const level = appearance.historicalPromptLevel ?? 'full'; + const parts = level === 'full' ? historicalPrompt(context, Math.max(0, width - (appearance.divider ? 1 : 0)), appearance) + : condensedPrompt(context, level, Math.max(0, width - (appearance.divider ? 1 : 0))); const prompt = typeof parts === 'string' ? parts : parts.left; const right = typeof parts === 'string' || !parts.right ? '' : parts.right; const rightWidth = right ? displayWidth(right) + 1 : 0; diff --git a/src/pickers/Picker.ts b/src/pickers/Picker.ts index d6edada1..bef938cd 100644 --- a/src/pickers/Picker.ts +++ b/src/pickers/Picker.ts @@ -3,6 +3,7 @@ import {mkdtemp, writeFile, mkdir, rm} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {resolveCommand, type ProviderDescriptor} from '../providers/providers.js'; +import {withFzfTheme} from '../themeBridge/targets.js'; export type PickerProviderId = 'native' | 'fzf' | 'television'; export interface PickerCandidate { id: string; label: string; description?: string; value: string } @@ -35,11 +36,11 @@ export function pickerSelection(output: string, candidates: readonly PickerCandi /** Native surfaces delegate their existing editor UI through the same boundary. */ export async function openPicker(provider: PickerProviderId, candidates: readonly PickerCandidate[], native: () => void, - handoff: PickerHandoff, env: NodeJS.ProcessEnv = process.env): Promise { + handoff: PickerHandoff, env: NodeJS.ProcessEnv = process.env, themeArgs: readonly string[] = []): Promise { if (provider === 'native') { native(); return; } const binary = resolveCommand(provider === 'fzf' ? 'fzf' : 'tv', env.PATH ?? '', []); if (!binary) { native(); return {kind: 'fallback', reason: `${provider} is not installed; using Native`}; } - const result = await handoff(signal => runPicker(binary, provider, candidates, signal, env)); + const result = await handoff(signal => runPicker(binary, provider, candidates, signal, env, 300_000, themeArgs)); if (result.kind === 'fallback') native(); return result; } @@ -47,7 +48,7 @@ export async function openPicker(provider: PickerProviderId, candidates: readonl /** Interactive, bounded, host-TTY process; only call while the host has handed off ownership. */ export async function runPicker(binary: string, provider: Exclude, candidates: readonly PickerCandidate[], signal: AbortSignal, env: NodeJS.ProcessEnv = process.env, - timeoutMs = 300_000): Promise { + timeoutMs = 300_000, themeArgs: readonly string[] = []): Promise { if (signal.aborted) return {kind: 'cancelled'}; const input = pickerInput(candidates); if (candidates.length > 100_000 || Buffer.byteLength(input) > 16 * 1024 * 1024) @@ -68,6 +69,8 @@ export async function runPicker(binary: string, provider: Exclude(resolve => { const child = spawn(binary, args, {env: environment, stdio: ['pipe', 'pipe', 'inherit']}); diff --git a/src/prompt/PromptPanel.ts b/src/prompt/PromptPanel.ts index 468ed5a0..9b69a921 100644 --- a/src/prompt/PromptPanel.ts +++ b/src/prompt/PromptPanel.ts @@ -93,6 +93,7 @@ export const PROMPT_PROVIDERS: readonly ProviderDescriptor[] = {id: 'nmsh', family: 'prompt', label: 'NMSh Native', kind: 'native', description: 'built-in themes, geometry, and modules'}, {id: 'starship', family: 'prompt', label: 'Starship', kind: 'external', executable: 'starship', description: 'use its themes/configuration'}, {id: 'powerlevel10k', family: 'prompt', label: 'Powerlevel10k', kind: 'external', description: 'use your ~/.p10k.zsh left prompt'}, + {id: 'none', family: 'prompt', label: 'None', kind: 'none', description: 'composer only: no prompt row, modules or marker'}, ]; export const PROVIDER_ORDER: readonly PromptProviderId[] = PROMPT_PROVIDERS.map(provider => provider.id); @@ -414,6 +415,7 @@ export function layoutLabel(configuration: PromptConfiguration): string { /** One-line summary of an effective configuration. */ export function describePromptConfiguration(configuration: PromptConfiguration): string { + if (configuration.provider === 'none') return 'None · composer only'; if (configuration.provider !== 'nmsh') return `${providerLabel(configuration.provider)} · ${layoutLabel(configuration)}`; const nmsh = configuration.nmsh; // Powerline geometry describes only Powerline; other styles name their own look. diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index a6564cba..18d6290d 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -28,7 +28,9 @@ import { import {normalizeStyleProfiles, type StyleProfiles} from './styles.js'; import {normalizeCustomGlyph, normalizePromptSymbol, type PromptSymbolId} from './glyphChoices.js'; import {normalizeCatppuccinAccent, type CatppuccinAccent} from '../appearance/themeFamilies.js'; -import {normalizeCustomTheme, type CustomTheme} from '../appearance/customTheme.js'; +import {type CustomTheme} from '../appearance/customTheme.js'; +import {findTheme, normalizeThemeLibrary, type ThemeAsset} from '../appearance/themeLibrary.js'; +import {DEFAULT_THEME_BRIDGE, normalizeThemeBridge, type ThemeBridgeSettings} from '../themeBridge/model.js'; import {IDLE_MODES, type IdleMode} from '../idle/scenes.js'; import {DEFAULT_UI_CHROME, normalizeUiChrome, type UiChromeSettings} from '../appearance/uiChrome.js'; import {normalizeVibrance, type Vibrance} from '../chroma/color.js'; @@ -80,7 +82,8 @@ export function applyShellModuleVisibility(configuration: Pick = new Set(['toolchain', 'kubeContext', 'dockerContext']); -export type PromptProviderId = 'nmsh' | 'starship' | 'powerlevel10k'; +/** `none` is composer only: no prompt row, modules, right prompt or marker; everything else in NMSh stays on. */ +export type PromptProviderId = 'nmsh' | 'starship' | 'powerlevel10k' | 'none'; export type NativeEndStyle = PowerlineEdgeStyle; export type NativeStartStyle = PowerlineEdgeStyle; export type NativeConnectorStyle = PowerlineConnectorStyle; @@ -166,9 +169,16 @@ export type DividerColorMode = typeof DIVIDER_COLOR_MODES[number]; export const DIVIDER_COLOR_LABELS: Record = {chroma: 'Follow Chroma', history: 'Follow history', ui: 'Follow UI theme', muted: 'Muted grayscale'}; /** How historical command headers are presented; stored snapshots are never changed. */ +/** How much of a stored prompt snapshot past commands show; Off is `historicalPrompt: false`. */ +export const HISTORICAL_PROMPT_LEVELS = ['full', 'compact', 'minimal'] as const; +export type HistoricalPromptLevel = typeof HISTORICAL_PROMPT_LEVELS[number]; +export const HISTORICAL_PROMPT_LEVEL_LABELS: Record = {full: 'Full', compact: 'Compact', minimal: 'Minimal', off: 'Off'}; + export interface TranscriptAppearance { divider: boolean; historicalPrompt: boolean; + /** Presentation only, used while `historicalPrompt` is on; the stored snapshot stays complete. Missing means Full. */ + historicalPromptLevel: HistoricalPromptLevel; historyColors: HistoryColorMode; /** Used when `historyColors` is `theme`. */ historyTheme: NativePaletteId; @@ -179,6 +189,7 @@ export interface TranscriptAppearance { export const DEFAULT_TRANSCRIPT_APPEARANCE: TranscriptAppearance = { divider: true, historicalPrompt: true, + historicalPromptLevel: 'full', historyColors: 'followPrompt', historyTheme: 'lavender', dividerDensity: 'normal', @@ -190,6 +201,7 @@ export function normalizeTranscriptAppearance(value: unknown): TranscriptAppeara return { divider: typeof value.divider === 'boolean' ? value.divider : true, historicalPrompt: typeof value.historicalPrompt === 'boolean' ? value.historicalPrompt : true, + historicalPromptLevel: HISTORICAL_PROMPT_LEVELS.includes(value.historicalPromptLevel as HistoricalPromptLevel) ? value.historicalPromptLevel as HistoricalPromptLevel : 'full', historyColors: value.historyColors === 'theme' || value.historyColors === 'grayscale' ? value.historyColors : 'followPrompt', historyTheme: normalizePaletteId(value.historyTheme), dividerDensity: value.dividerDensity === 'compact' ? 'compact' : 'normal', @@ -518,8 +530,20 @@ export interface PromptConfiguration { promptSymbol: PromptSymbolId; /** Used when `promptSymbol` is `custom`; kept when another symbol is chosen. */ promptSymbolCustom?: string; - /** The user's custom Native theme (NMSh Theme JSON); used when the palette is `custom`, kept otherwise. */ + /** + * The Native theme library: canonical user-owned themes (Custom and + * Imported) with stable ids. `nmsh.themeId` names the one the `custom` + * palette uses. + */ + themes: ThemeAsset[]; + /** + * Mirror of the library asset named by `nmsh.themeId`, rewritten on every + * normalization (never edited directly). Renderers and older NMSh versions + * read it; only a configuration without `themes` migrates from it. + */ customTheme?: CustomTheme; + /** Opt-in Theme Bridge: one independent mode per external tool target; all Independent by default. */ + themeBridge: ThemeBridgeSettings; cursor: CursorSettings; statusStrip: StatusStripSettings; idleVisuals: IdleVisualSettings; @@ -548,6 +572,8 @@ export interface PromptConfiguration { connector: NativeConnectorStyle; endStyle: NativeEndStyle; palette: NativePaletteId; + /** The library asset (`themes[].id`) a `custom` palette uses; kept while a built-in is active. */ + themeId?: string; icons: NativeIconMode; /** Visual style over the same semantic segments; missing in older configs means Powerline. */ style: PromptStyle; @@ -640,6 +666,8 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { idleVisuals: {...DEFAULT_IDLE_VISUALS, customStops: []}, liveActivity: {...DEFAULT_LIVE_ACTIVITY, customStops: []}, uiChrome: {...DEFAULT_UI_CHROME}, + themes: [], + themeBridge: DEFAULT_THEME_BRIDGE(), nmsh: {gapEnabled: true, startStyle: 'wedge', connector: 'wedge', endStyle: 'fadeWedge', palette: 'lavender', icons: 'nerd', style: 'powerline', connectorFade: 'off', connectorFadeColors: 'previous', gitEnabled: true, gitColors: 'semantic', gitGeometry: 'follow', gitConnectorFade: 'followMain', mirrorRight: true, vibrance: 'standard', textColors: 'theme', accent: 'mauve', styleProfiles: normalizeStyleProfiles(undefined)}, @@ -724,7 +752,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio shellBackend: isShellId(value.shellBackend) ? value.shellBackend : 'zsh', openWith: OPEN_WITH_IDS.includes(value.openWith as OpenWith) ? value.openWith as OpenWith : 'auto', toolUpdateChecks, installSuggestions, ignoredInstallSuggestions, promptSymbol: normalizePromptSymbol(value.promptSymbol), ...(promptSymbolCustom ? {promptSymbolCustom} : {})}; - const provider: PromptProviderId = promptValue.provider === 'starship' || promptValue.provider === 'powerlevel10k' + const provider: PromptProviderId = promptValue.provider === 'starship' || promptValue.provider === 'powerlevel10k' || promptValue.provider === 'none' ? promptValue.provider : 'nmsh'; const p10kValue = isRecord(promptValue.powerlevel10k) ? promptValue.powerlevel10k : {}; @@ -737,16 +765,18 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio const connector = normalizeConnectorStyle(nativeValue.connector); const icons: NativeIconMode = nativeValue.icons === 'off' || nativeValue.icons === false ? 'off' : 'nerd'; const style = normalizePromptStyle(nativeValue.style); - const customTheme = normalizeCustomTheme(value.customTheme); - // A custom palette without a valid custom theme falls back instead of rendering nothing. + // The library is canonical; a pre-library customTheme migrates into it once. + const library = normalizeThemeLibrary(value.themes, value.customTheme, nativeValue.themeId); + const customTheme = findTheme(library.themes, library.themeId)?.theme; + // A custom palette without a valid library theme falls back instead of rendering nothing. const storedPalette = normalizePaletteId(nativeValue.palette); const palette = storedPalette === 'custom' && !customTheme ? 'lavender' : storedPalette; - Object.assign(tooling, customTheme ? {customTheme} : {}); + const themed = {...tooling, themes: library.themes, themeBridge: normalizeThemeBridge(value.themeBridge), ...(customTheme ? {customTheme: structuredClone(customTheme)} : {})}; const transcript = normalizeTranscriptAppearance(promptValue.transcript); const syntax = normalizeSyntaxAppearance(promptValue.syntax); const notifications = normalizeNotificationSettings(value.notifications); const nmsh = {gapEnabled: typeof nativeValue.gapEnabled === 'boolean' ? nativeValue.gapEnabled : true, - startStyle, connector, endStyle, palette, icons, style, + startStyle, connector, endStyle, palette, ...(library.themeId ? {themeId: library.themeId} : {}), icons, style, connectorFade: normalizeConnectorFade(nativeValue.connectorFade), connectorFadeColors: normalizeConnectorFadeColors(nativeValue.connectorFadeColors), gitEnabled: typeof nativeValue.gitEnabled === 'boolean' ? nativeValue.gitEnabled : true, @@ -781,7 +811,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, toolsSetupComplete, glyphStyle, glyphChoiceComplete, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, - presentation, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, notifications, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, spacing, gap, separator, ...tooling}; + presentation, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, notifications, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, spacing, gap, separator, ...themed}; } const modules: ContextModuleConfig[] = []; @@ -825,7 +855,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio return {provider, onboardingComplete: value.onboardingComplete === true, toolsSetupComplete, glyphStyle, glyphChoiceComplete, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, - starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, modules, separator, spacing, gap, ...tooling}; + starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, modules, separator, spacing, gap, ...themed}; } export function loadPromptConfiguration(path = promptConfigurationPath()): PromptConfiguration { diff --git a/src/sessions/TranscriptStore.ts b/src/sessions/TranscriptStore.ts index 79e0078c..28ed60ba 100644 --- a/src/sessions/TranscriptStore.ts +++ b/src/sessions/TranscriptStore.ts @@ -92,7 +92,8 @@ function isTranscript(value: unknown): value is OutputTranscript { && (record.historicalContext === undefined || (typeof record.historicalContext.cwd === 'string' && (record.historicalContext.project === undefined || typeof record.historicalContext.project === 'string') - && (record.historicalContext.branch === undefined || typeof record.historicalContext.branch === 'string'))) + && (record.historicalContext.branch === undefined || typeof record.historicalContext.branch === 'string') + && (record.historicalContext.promptless === undefined || record.historicalContext.promptless === true))) && (record.historicalContext?.prompt === undefined || (typeof record.historicalContext.prompt === 'object' && record.historicalContext.prompt !== null && ['nmsh', 'starship', 'powerlevel10k'].includes(record.historicalContext.prompt.provider) diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index c54ea2f3..d2eeb303 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -46,6 +46,8 @@ export const NATIVE_PROMPT_RECOMMENDATION = 'NMSh Native is recommended for the export const NATIVE_ONLY_NOTE = 'Native prompt style settings apply only to NMSh Native.'; export {CHROMA_PREVIEW_NOTE, CHROMA_SCOPE_NOTE} from '../appearance/chromaNotes.js'; import {CHROMA_SCOPE_NOTE} from '../appearance/chromaNotes.js'; +import {librarySummary} from '../appearance/themeLibrary.js'; +import {BRIDGE_TARGET_LABELS, type BridgeTargetId} from '../themeBridge/model.js'; export const NATIVE_FIRST_SHORT = 'NMSh works fully with its Native providers. External tools are optional alternatives or enhancements. You can change providers anytime.'; /** What happens with optional tools after Apply. Installs are always separate, explicit confirmations. */ @@ -68,6 +70,8 @@ export interface SetupContext { preview?: readonly string[]; /** The title as painted by the app (a one-pass light sweep); plain when absent. */ title?: string; + /** Theme Bridge targets found on this system (local PATH facts), so Setup shows only relevant ones. */ + bridgeTargets?: readonly BridgeTargetId[]; } export interface SetupRow { @@ -84,7 +88,7 @@ export interface SetupSection { intro: readonly string[]; rows: readonly SetupRow[]; /** Rows that depend on the draft (the Native prompt style's own fields), appended after `rows`. */ - dynamicRows?: (draft: PromptConfiguration) => readonly SetupRow[]; + dynamicRows?: (draft: PromptConfiguration, context?: SetupContext) => readonly SetupRow[]; /** Muted informational lines after the rows. */ facts?: (draft: PromptConfiguration, context: SetupContext) => string[]; } @@ -154,7 +158,7 @@ const SEPARATOR_ROW: SettingsRow = {id: 'setupSeparator', parent: 'promptStyle', /** Rows that only affect the Native prompt disappear while an external prompt provider is selected. */ const nativeOnly = (row: SettingsRow): SettingsRow => ({...row, when: config => config.provider === 'nmsh' && (row.when?.(config) ?? true)}); -const PROMPT_PROVIDER_ROW = providerRow('setupPromptProvider', 'Prompt provider', 'Native prompt, or your existing Starship / Powerlevel10k', 'Prompt', +const PROMPT_PROVIDER_ROW = providerRow('setupPromptProvider', 'Prompt provider', 'Native prompt, your existing Starship / Powerlevel10k, or None (composer only)', 'Prompt', PROMPT_PROVIDERS, config => config.provider, (config, provider) => ({...config, provider})); /** @@ -162,6 +166,26 @@ const PROMPT_PROVIDER_ROW = providerRow('setupPromptProvider', * lost), then opens the real editor. The row says so; it is a route, never a * second configuration path. */ +/** Setup's single Theme Bridge question; Yes only reveals the detected tools, each still Independent. */ +const THEME_BRIDGE_ROW: SettingsRow = {id: 'setupThemeBridge', label: 'Extend NMSh colors to terminal tools?', description: 'Theme Bridge: opt-in colors for fzf, less/man, LS_COLORS, tmux, Neovim and Vim', + category: 'Appearance', control: 'enum', options: ['No', 'Yes'], index: c => c.themeBridge.enabled ? 1 : 0, + select: (c, index) => ({...c, themeBridge: {...c.themeBridge, enabled: index === 1}})}; + +const SETUP_BRIDGE_TARGETS: readonly BridgeTargetId[] = ['fzf', 'pager', 'lsColors', 'tmux', 'neovim', 'vim']; + +function bridgeTargetRows(draft: PromptConfiguration, context: SetupContext | undefined): SetupRow[] { + if (!draft.themeBridge.enabled) return []; + const found = new Set(context?.bridgeTargets ?? []); + return SETUP_BRIDGE_TARGETS.filter(target => found.has(target)).map(target => ({ + row: {id: `setupBridge:${target}`, parent: 'setupThemeBridge', label: ` ${BRIDGE_TARGET_LABELS[target]}`, description: 'Independent, or follow the active NMSh theme', category: 'Appearance', + control: 'enum', options: ['Independent', 'Follow NMSh'], index: c => c.themeBridge.targets[target].mode === 'independent' ? 0 : c.themeBridge.targets[target].mode === 'follow' ? 1 : 1, + select: (c, index) => ({...c, themeBridge: {...c.themeBridge, targets: {...c.themeBridge.targets, [target]: {...c.themeBridge.targets[target], mode: index === 1 ? (c.themeBridge.targets[target].mode === 'choose' ? 'choose' : 'follow') : 'independent'}}}})}, + note: () => target === 'tmux' || target === 'neovim' || target === 'vim' + ? 'NMSh generates its own color file; adding it to your config is a separate, reviewed step in /theme-bridge' + : target === 'fzf' ? 'Only fzf launched by NMSh; FZF_DEFAULT_OPTS and your rc files are untouched' : 'Applied in NMSh shells at the next prompt; no rc file is edited', + } satisfies SetupRow)); +} + function routeRow(id: string, label: string, description: string, destination: SettingsDestination, category: string): SetupRow { return {row: {id, label, description, category, control: 'action', actionLabel: 'Open ›', destination}, note: () => 'Enter applies this Setup first, then opens it; it keeps the choices you made here'}; @@ -234,7 +258,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ ], facts: draft => [`Renderer in use: ${chooseBackend(draft.cursor, currentCursorHost()).reason}`]}, {id: 'prompt', title: 'Prompt', intro: [NATIVE_PROMPT_RECOMMENDATION, 'Deep prompt customization lives in /prompt.'], rows: [ {...PROMPT_PROVIDER_ROW, note: (draft, context) => draft.provider === 'nmsh' ? 'Built in · no installation required' - : `${PROMPT_PROVIDER_ROW.note!(draft, context)} · ${NATIVE_ONLY_NOTE}`}, + : draft.provider === 'none' ? 'None · composer only · themes still style NMSh UI, syntax and Theme Bridge' : `${PROMPT_PROVIDER_ROW.note!(draft, context)} · ${NATIVE_ONLY_NOTE}`}, {row: nativeOnly(configRow('promptStyle'))}, {row: nativeOnly(SEPARATOR_ROW)}, {row: configRow('promptSymbol')}, @@ -267,9 +291,14 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: configRow('shimmer')}, {row: configRow('autoEffects')}, routeRow('setupChromeColors', 'Edit UI chrome colors', 'Accent, text, separator, selection and status roles with the color picker', 'chromeColors', 'Appearance'), - routeRow('setupThemeStudio', 'Theme Studio (custom themes)', 'Clone, edit, import and export your own theme', 'themeStudio', 'Appearance'), + {...routeRow('setupThemeStudio', 'Theme Studio', 'Create, edit, import, export and manage Native themes; selection is above', 'themeStudio', 'Appearance'), + row: {id: 'setupThemeStudio', label: 'Theme Studio', description: 'Create, edit, import, export and manage Native themes; selection is above', category: 'Appearance', + control: 'action', actionLabel: 'Open ›', destination: 'themeStudio', value: draft => librarySummary(draft.themes) ?? ''}}, + {row: THEME_BRIDGE_ROW, note: draft => draft.themeBridge.enabled + ? 'Only tools found on this system are listed; each starts Independent. Choose theme and includes for tmux/Neovim/Vim are in /theme-bridge' + : 'No: every tool keeps its own colors; NMSh injects and changes nothing'}, routeRow('setupHostWindow', 'Terminal window (opacity, blur)', 'Host window opacity and blur where your terminal supports it', 'appearance', 'Appearance'), - ]}, + ], dynamicRows: (draft, context) => bridgeTargetRows(draft, context)}, // General NMSh motion: the same rows /appearance → Motion edits, with the same real previews. {id: 'motion', title: 'Motion', intro: ['Short, finite presentations of real events. Each can be Off; Reduced Motion, Decorative Effects Off and NO_COLOR stop all of them.', 'The preview below runs the selected one on sample content, once; it never touches your session.'], rows: [{row: configRow('motion_rendering')}, ...MOTION_ROWS.map(item => ({row: configRow(`motion_${item.key}`)})), {row: configRow('motion_intensity')}, {row: configRow('motion_speed')}]}, @@ -388,14 +417,14 @@ function localUnderstandingNote(draft: PromptConfiguration): string { export const SETUP_EQUIVALENTS: Readonly> = { provider: 'setupPromptProvider', welcome: 'setupWelcome', suggestions: 'setupSuggestions', history: 'setupHistory', navigation: 'setupNavigation', picker: 'setupPicker', cursorSpeed: 'cursorAdvanced', cursorIntensity: 'cursorAdvanced', cursorTrail: 'cursorAdvanced', cursorParticles: 'cursorAdvanced', - tools: 'setupToolChoice', uiChromeColors: 'setupChromeColors', idleCustomColors: 'setupIdleColors', activityCustomColors: 'setupActivityColors', + tools: 'setupToolChoice', themeStudio: 'setupThemeStudio', themeBridge: 'setupThemeBridge', uiChromeColors: 'setupChromeColors', idleCustomColors: 'setupIdleColors', activityCustomColors: 'setupActivityColors', }; /** Where each Settings entry point (a full panel) is reached from Setup: a section, or the route row that opens it. */ export const SETUP_ENTRY_COVERAGE: Readonly> = { appearance: 'setupHostWindow', glyph: 'glyphStyle', prompt: 'setupPromptModules', transcript: 'transcriptPresentation', syntax: 'syntaxHighlighting', keyboard: 'setupKeyboard', welcome: 'setupWelcome', suggestions: 'setupSuggestions', history: 'setupHistory', picker: 'setupPicker', navigation: 'setupNavigation', layout: 'composerPosition', toolConfig: 'setupToolConfig', tools: 'setupBrowseTools', screensaver: 'idleTimeout', setup: 'setupToolChoice', - cursor: 'cursorAdvanced', themeStudio: 'setupThemeStudio', chromeColors: 'setupChromeColors', idleColors: 'setupIdleColors', activityColors: 'setupActivityColors', resetInstallSuggestions: 'resetInstallSuggestions', + cursor: 'cursorAdvanced', themeStudio: 'setupThemeStudio', themeBridge: 'setupThemeBridge', chromeColors: 'setupChromeColors', idleColors: 'setupIdleColors', activityColors: 'setupActivityColors', resetInstallSuggestions: 'resetInstallSuggestions', }; function completionFacts(facts: CompletionFacts | undefined): string[] { @@ -500,12 +529,12 @@ export type SetupResult = | {kind: 'apply'; configuration: PromptConfiguration; tools: ToolChoice; changed: boolean; then?: SettingsDestination}; /** Rows that apply to the draft (a child row disappears when its parent makes it meaningless). */ -function sectionRows(section: SetupSection, draft: PromptConfiguration): readonly SetupRow[] { - return [...section.rows, ...(section.dynamicRows?.(draft) ?? [])]; +function sectionRows(section: SetupSection, draft: PromptConfiguration, context?: SetupContext): readonly SetupRow[] { + return [...section.rows, ...(section.dynamicRows?.(draft, context) ?? [])]; } function currentRows(state: SetupState): readonly SetupRow[] { - const rows = sectionRows(SETUP_SECTIONS[state.section]!, state.draft).filter(item => setupRowApplies(item.row, state.draft)); + const rows = sectionRows(SETUP_SECTIONS[state.section]!, state.draft, state.context).filter(item => setupRowApplies(item.row, state.draft)); return state.section === sectionIndex('tools') ? [...rows, TOOL_CHOICE_ROW, BROWSE_ROW] : rows; } diff --git a/src/setup/providerExplanations.ts b/src/setup/providerExplanations.ts index c7d197a0..9235d57f 100644 --- a/src/setup/providerExplanations.ts +++ b/src/setup/providerExplanations.ts @@ -34,6 +34,7 @@ export const PROVIDER_EXPLANATIONS: Readonly { this.zdotdir = stateDir; let launch; try { - launch = this.adapter.launch({home, env, token, stateDir, knowledgePath: join(stateDir, '.nmsh-knowledge')}); + launch = this.adapter.launch({home, env, token, stateDir, knowledgePath: join(stateDir, '.nmsh-knowledge'), bridgeEnvPath: bridgeEnvPath(this.adapter.id, env)}); } catch (error) { this.cleanup(); throw error; diff --git a/src/shell/adapters/BashAdapter.ts b/src/shell/adapters/BashAdapter.ts index 69052f04..d4157b49 100644 --- a/src/shell/adapters/BashAdapter.ts +++ b/src/shell/adapters/BashAdapter.ts @@ -8,6 +8,7 @@ import {completionWord} from '../ConfiguredCompletion.js'; import type {CommandEntry} from '../../suggestions/types.js'; import {posixQuote, type LaunchContext, type ShellAdapter, type ShellLaunch} from './ShellAdapter.js'; import {findShellExecutables} from './shellExecutable.js'; +import {bridgeBootstrap} from '../../themeBridge/environment.js'; /** * Bash backend. @@ -56,7 +57,8 @@ function resolveBash(env: NodeJS.ProcessEnv): string | undefined { return findShellExecutables('bash', env, ['/bin/bash', '/usr/bin/bash']).find(candidate => recentEnough(bashVersionOf(candidate))); } -function bootstrap({home, token, knowledgePath}: LaunchContext): string { +function bootstrap(context: LaunchContext): string { + const {home, token, knowledgePath} = context; const marker = (body: string) => `builtin printf '\\e]777;nmsh;${token};${body}\\a'`; return `# NMSh managed bash session bootstrap (private, per session) # --rcfile replaces the standard startup files, so load them as Bash would. @@ -99,6 +101,7 @@ fi __nmsh_set_status() { return "$1"; } __nmsh_history_last= +${context.bridgeEnvPath ? bridgeBootstrap('bash', context.bridgeEnvPath) : ''} __nmsh_precmd() { local nmsh_status=$? nmsh_command for nmsh_command in "\${__nmsh_user_prompt_command[@]}"; do @@ -114,7 +117,7 @@ __nmsh_precmd() { PS1='' PS2='' __nmsh_tty -echo __nmsh_history_last=$(HISTTIMEFORMAT= builtin history 1) - __nmsh_knowledge + __nmsh_knowledge${context.bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} ${marker('%d;%s')} "$nmsh_status" "$PWD" } diff --git a/src/shell/adapters/FishAdapter.ts b/src/shell/adapters/FishAdapter.ts index d17d296d..10f9551a 100644 --- a/src/shell/adapters/FishAdapter.ts +++ b/src/shell/adapters/FishAdapter.ts @@ -7,6 +7,7 @@ import {completionWord} from '../ConfiguredCompletion.js'; import type {CommandEntry} from '../../suggestions/types.js'; import {fishQuote, type LaunchContext, type ShellAdapter, type ShellLaunch} from './ShellAdapter.js'; import {findShellExecutables} from './shellExecutable.js'; +import {bridgeBootstrap} from '../../themeBridge/environment.js'; /** * Fish backend. @@ -27,7 +28,8 @@ const FISH_BUILTINS = new Set(['and', 'begin', 'bg', 'bind', 'block', 'break', ' 'math', 'not', 'or', 'path', 'printf', 'pwd', 'random', 'read', 'realpath', 'return', 'set', 'set_color', 'source', 'status', 'string', 'switch', 'test', 'time', 'true', 'type', 'ulimit', 'wait', 'while', 'abbr', 'argparse']); -function bootstrap({token, knowledgePath}: LaunchContext, originalTerm: string | undefined): string { +function bootstrap({token, knowledgePath, bridgeEnvPath}: LaunchContext, originalTerm: string | undefined): string { + const sync = bridgeEnvPath ? '\n nmsh_bridge_sync' : ''; const marker = (body: string) => `printf '\\e]777;nmsh;${token};${body}\\a'`; return `# NMSh managed fish session bootstrap (private, per session) ${originalTerm ? `set -gx TERM ${fishQuote(originalTerm)}` : ''} @@ -37,10 +39,11 @@ set -g fish_autosuggestion_enabled 0 function fish_prompt if not set -q __nmsh_ready set -g __nmsh_ready 1 - __nmsh_knowledge + __nmsh_knowledge${sync} ${marker("0;%s")} "$PWD" end end +${bridgeEnvPath ? bridgeBootstrap('fish', bridgeEnvPath) : ''} function fish_right_prompt; end function fish_mode_prompt; end function fish_title; end @@ -75,7 +78,7 @@ function __nmsh_preexec --on-event fish_preexec end function __nmsh_postexec --on-event fish_postexec set -l nmsh_status $status - __nmsh_knowledge + __nmsh_knowledge${bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} ${marker('%d;%s')} $nmsh_status "$PWD" end `; diff --git a/src/shell/adapters/ShellAdapter.ts b/src/shell/adapters/ShellAdapter.ts index 5d4e62b0..2a01addd 100644 --- a/src/shell/adapters/ShellAdapter.ts +++ b/src/shell/adapters/ShellAdapter.ts @@ -33,6 +33,8 @@ export interface LaunchContext { stateDir: string; /** Where the shell writes its bounded name snapshot each cycle. */ knowledgePath: string; + /** NMSh Theme Bridge environment file for this shell syntax, applied from the prompt hook when present. */ + bridgeEnvPath?: string; } export interface ShellLaunch { diff --git a/src/shell/adapters/ZshAdapter.ts b/src/shell/adapters/ZshAdapter.ts index 50e83535..cc0fd400 100644 --- a/src/shell/adapters/ZshAdapter.ts +++ b/src/shell/adapters/ZshAdapter.ts @@ -8,6 +8,7 @@ import {resolveZsh} from '../zshExecutable.js'; import {parseZshHistoryInChunks} from '../HistoryService.js'; import {ShellCompletionSource} from '../CompletionService.js'; import type {LaunchContext, ShellAdapter, ShellLaunch} from './ShellAdapter.js'; +import {bridgeBootstrap} from '../../themeBridge/environment.js'; const ZSH_BUILTINS = new Set(['alias', 'autoload', 'bg', 'bindkey', 'builtin', 'cd', 'command', 'echo', 'emulate', 'eval', 'exec', 'exit', 'export', 'fc', 'fg', 'functions', 'hash', 'history', 'jobs', 'kill', 'let', 'local', 'print', 'printf', 'pushd', 'popd', 'pwd', 'read', 'return', 'set', 'setopt', 'shift', 'source', @@ -32,7 +33,7 @@ export const zshAdapter: ShellAdapter = { unavailableReason(env) { try { resolveZsh(env); return undefined; } catch (error) { return error instanceof Error ? error.message : 'zsh was not found'; } }, - launch({home, env, token, stateDir, knowledgePath}: LaunchContext): ShellLaunch { + launch({home, env, token, stateDir, knowledgePath, bridgeEnvPath}: LaunchContext): ShellLaunch { // Proxy .zshenv writeFileSync(join(stateDir, '.zshenv'), ` if [[ -n ${shellQuote(home)} && -f ${shellQuote(join(home, '.zshenv'))} ]]; then @@ -78,9 +79,10 @@ function nmsh_tty_echo { stty \$1 2>/dev/null } +${bridgeEnvPath ? bridgeBootstrap('zsh', bridgeEnvPath) : ''} function nmsh_precmd { local nmsh_status=$? - nmsh_capture_knowledge + nmsh_capture_knowledge${bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} # Reblank every cycle: a plugin's own precmd (starship, a prompt theme, ...) # may run before us in precmd_functions and repaint PROMPT/RPROMPT. NMSh # owns prompt rendering, so it always has the last word here. diff --git a/src/themeBridge/ThemeBridgePanel.ts b/src/themeBridge/ThemeBridgePanel.ts new file mode 100644 index 00000000..717b477e --- /dev/null +++ b/src/themeBridge/ThemeBridgePanel.ts @@ -0,0 +1,170 @@ +import type {Key} from '../terminal/keys.js'; +import type {FileEditPlan} from '../ask/fileEdit.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {renderControls} from '../ui/controls.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {padCells, truncateAnsi, truncateText} from '../util/text.js'; +import type {SelectableTheme, ThemeRef} from '../appearance/themeRefs.js'; +import {BRIDGE_MODE_LABELS, BRIDGE_TARGETS, type BridgeMode, type BridgeTargetId} from './model.js'; +import type {TargetReport} from './runtime.js'; + +/** + * /theme-bridge: one row per target with its own mode and theme; a detail + * view per target. The panel never writes anything itself: it returns + * actions, and permanent config includes always go through a shown plan + * (exact path and lines) and an explicit confirmation. + */ + +type DetailRow = 'mode' | 'theme' | 'apply' | 'reload' | 'remove'; + +export interface ThemeBridgePanelState { + selected: number; + detail?: {target: BridgeTargetId; row: number}; + /** A permanent-change plan awaiting confirmation. */ + confirm?: {kind: 'hook' | 'removeHook'; target: BridgeTargetId; path: string; preview: string[]; plan?: FileEditPlan}; + message?: string; +} + +export type BridgePanelAction = + | {kind: 'close'} + | {kind: 'setMode'; target: BridgeTargetId; mode: BridgeMode; theme?: ThemeRef} + | {kind: 'setEnabled'; enabled: boolean} + | {kind: 'planHook'; target: BridgeTargetId} + | {kind: 'confirmHook'; target: BridgeTargetId} + | {kind: 'planRemoval'; target: BridgeTargetId} + | {kind: 'confirmRemoval'; target: BridgeTargetId} + | {kind: 'reloadTmux'}; + +export interface BridgePanelContext { + /** The master switch; Off keeps every target Independent. */ + enabled: boolean; + reports: readonly TargetReport[]; + themes: readonly SelectableTheme[]; + /** The pinned reference per target (kept even while another mode is chosen). */ + pinned: (target: BridgeTargetId) => ThemeRef | undefined; + activeRef?: ThemeRef; + /** The managed artifact and include facts per managed target. */ + managed: (target: BridgeTargetId) => {artifact?: string; include?: string} | undefined; +} + +const MANAGED = new Set(['tmux', 'neovim', 'vim']); + +export function createThemeBridgePanel(): ThemeBridgePanelState { + return {selected: 0}; +} + +function detailRows(target: BridgeTargetId, report: TargetReport | undefined): DetailRow[] { + const rows: DetailRow[] = ['mode']; + if (report?.mode === 'choose') rows.push('theme'); + if (MANAGED.has(target)) rows.push('apply'); + if (target === 'tmux' && report?.mode !== 'independent') rows.push('reload'); + if (report && (report.mode !== 'independent' || MANAGED.has(target))) rows.push('remove'); + return rows; +} + +export function themeBridgeKey(state: ThemeBridgePanelState, key: Key, context: BridgePanelContext): BridgePanelAction | undefined { + state.message = undefined; + if (state.confirm) { + const {kind, target} = state.confirm; + if (key.kind === 'enter' || (key.kind === 'text' && key.value.toLowerCase() === 'y')) { + state.confirm = undefined; + return kind === 'hook' ? {kind: 'confirmHook', target} : {kind: 'confirmRemoval', target}; + } + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value.toLowerCase() === 'n')) { state.confirm = undefined; state.message = 'Nothing was changed.'; } + return undefined; + } + if (!state.detail) { + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + // Row 0 is the master switch; the targets follow. + const count = BRIDGE_TARGETS.length + 1; + if (key.kind === 'up' || key.kind === 'down') state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + count) % count; + else if (state.selected === 0 && (key.kind === 'enter' || key.kind === 'left' || key.kind === 'right' || (key.kind === 'text' && key.value === ' '))) return {kind: 'setEnabled', enabled: !context.enabled}; + else if (key.kind === 'enter' || key.kind === 'right') state.detail = {target: BRIDGE_TARGETS[state.selected - 1]!, row: 0}; + return undefined; + } + const {target} = state.detail; + const report = context.reports.find(item => item.target === target); + const rows = detailRows(target, report); + const row = rows[Math.min(state.detail.row, rows.length - 1)]!; + if (key.kind === 'escape' || key.kind === 'interrupt') { state.detail = undefined; return undefined; } + if (key.kind === 'up' || key.kind === 'down') { state.detail.row = (state.detail.row + (key.kind === 'up' ? -1 : 1) + rows.length) % rows.length; return undefined; } + const delta = key.kind === 'left' ? -1 : key.kind === 'right' ? 1 : 0; + if (row === 'mode' && (delta || key.kind === 'enter')) { + const modes = report?.modes ?? ['independent']; + if (modes.length === 1) { state.message = report?.notes[0] ?? 'Only Independent is available for this target.'; return undefined; } + const mode = modes[(modes.indexOf(report?.mode ?? 'independent') + (delta || 1) + modes.length) % modes.length]!; + // Choose theme starts on the pinned theme, or visibly on the active one; it never changes later by itself. + const theme = mode === 'choose' ? context.pinned(target) ?? context.activeRef : undefined; + return {kind: 'setMode', target, mode, ...(theme ? {theme} : {})}; + } + if (row === 'theme' && (delta || key.kind === 'enter')) { + const current = context.pinned(target); + const index = context.themes.findIndex(theme => theme.ref === current); + const next = context.themes[(index + (delta || 1) + context.themes.length) % context.themes.length]; + return next ? {kind: 'setMode', target, mode: 'choose', theme: next.ref} : undefined; + } + if (key.kind !== 'enter') return undefined; + if (row === 'apply') return {kind: 'planHook', target}; + if (row === 'reload') return {kind: 'reloadTmux'}; + if (row === 'remove') return {kind: 'planRemoval', target}; + return undefined; +} + +export function renderThemeBridgePanel(state: ThemeBridgePanelState, context: BridgePanelContext, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const warning = foreground(UI_COLORS.failure); + const reset = '\u001B[0m'; + const out: string[] = []; + const finish = (controls: Array<[string, string]>) => framePanel([...out, '', renderControls(controls)].map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); + if (state.confirm) { + const {kind, path, preview} = state.confirm; + out.push(` ${primary}Theme Bridge › ${kind === 'hook' ? 'Add include' : 'Remove include'}${reset}`, '', + ` ${secondary}${kind === 'hook' ? 'This edits your config so new instances load NMSh-managed colors:' : 'This removes exactly the NMSh lines from:'}${reset}`, + ` ${primary}${path}${reset}`, '', ...preview.map(line => ` ${line.startsWith('+') ? accent : line.startsWith('-') ? warning : subtle}${line}${reset}`), '', + ` ${subtle}Nothing else in the file changes. NMSh refuses to write if the file changes before you confirm.${reset}`); + return finish([['Enter', kind === 'hook' ? 'add these lines' : 'remove these lines'], ['Esc', 'cancel']]); + } + if (!state.detail) { + out.push(` ${primary}Theme Bridge${reset} ${subtle}extend NMSh themes to terminal tools · every target starts Independent${reset}`, ''); + const master = state.selected === 0; + out.push(`${master ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${master ? primary : secondary}${padCells('Theme Bridge', 13)}${reset}${master ? `${accent}‹ ${context.enabled ? 'On' : 'Off'} ›${reset}` : context.enabled ? 'On' : 'Off'} ${subtle}${context.enabled ? 'targets below use their own mode' : 'every target is Independent; choices below are kept'}${reset}`, ''); + out.push(` ${subtle}${padCells('Target', 13)}${padCells('Mode', 15)}${padCells('Theme', 22)}Status${reset}`); + BRIDGE_TARGETS.forEach((target, index) => { + const report = context.reports.find(item => item.target === target); + const selected = index + 1 === state.selected; + const theme = report && report.mode !== 'independent' ? report.themeLabel ?? '—' : '—'; + const extra = report?.notes.find(note => note === 'Managed' || note.startsWith('include') || note === 'reload available'); + out.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${selected ? primary : secondary}${padCells(report?.label ?? target, 13)}${reset}` + + `${padCells(BRIDGE_MODE_LABELS[report?.mode ?? 'independent'], 15)}${padCells(truncateText(theme, 20), 22)}${subtle}${report?.status ?? ''}${extra ? ` · ${extra}` : ''}${reset}`); + }); + out.push('', ` ${subtle}Independent: NMSh does nothing to that target. Follow NMSh: the active NMSh theme. Choose theme: a pinned theme the main theme never changes.${reset}`); + if (state.message) out.push('', ` ${secondary}${state.message}${reset}`); + return finish([['↑↓', 'target'], ['Enter', 'details'], ['Esc', 'close']]); + } + const {target} = state.detail; + const report = context.reports.find(item => item.target === target); + const rows = detailRows(target, report); + const selectedRow = rows[Math.min(state.detail.row, rows.length - 1)]; + const line = (row: DetailRow, label: string, value: string) => { + const selected = row === selectedRow; + return `${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${selected ? primary : secondary}${padCells(label, 14)}${reset}${selected && (row === 'mode' || row === 'theme') ? `${accent}‹ ${value} ›${reset}` : value}`; + }; + out.push(` ${primary}Theme Bridge › ${report?.label ?? target}${reset} ${subtle}${report?.status ?? ''}${reset}`, ''); + for (const row of rows) { + if (row === 'mode') out.push(line(row, 'Mode', BRIDGE_MODE_LABELS[report?.mode ?? 'independent'])); + if (row === 'theme') out.push(line(row, 'Theme', report?.themeLabel ?? 'Missing theme')); + if (row === 'apply') out.push(line(row, 'Include', `${context.managed(target)?.include ? 'installed · Enter to review' : 'not installed · Enter to review the exact change'}`)); + if (row === 'reload') out.push(line(row, 'Reload', 'load the colors into the running tmux server')); + if (row === 'remove') out.push(line(row, 'Remove', 'set Independent, remove NMSh files and (after review) the include')); + } + const managed = context.managed(target); + if (managed?.artifact) out.push('', ` ${subtle}Managed file ${managed.artifact}${reset}`); + if (managed?.include) out.push(` ${subtle}Include in ${managed.include}${reset}`); + if (report?.notes.length) out.push('', ...report.notes.filter(note => note !== 'Managed').map(note => ` ${subtle}• ${note}${reset}`)); + if (state.message) out.push('', ` ${secondary}${state.message}${reset}`); + return finish([['↑↓', 'select'], ['←→', 'change'], ['Enter', 'open'], ['Esc', 'back']]); +} diff --git a/src/themeBridge/artifacts.ts b/src/themeBridge/artifacts.ts new file mode 100644 index 00000000..7a729e33 --- /dev/null +++ b/src/themeBridge/artifacts.ts @@ -0,0 +1,252 @@ +import {createHash} from 'node:crypto'; +import {existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import {applyPlan, inspectFile, planAppend, planCreate, planReplace, type FileEditPlan} from '../ask/fileEdit.js'; +import type {BridgeMode, BridgeTargetId} from './model.js'; + +/** + * Theme Bridge ownership: generated artifacts and exact config hooks NMSh + * created, recorded in a small ledger. A file is replaced or removed only + * when ownership is established (a ledger entry whose recorded content hash + * matches the file on disk); a hook is removed only as the exact lines NMSh + * inserted. When ownership is uncertain NMSh stops and says so; it never + * deletes or overwrites. + */ + +export const ADAPTER_VERSION = 1; +export type ManagedTarget = Extract | 'vivid'; + +export interface ConfigHook { + /** The user's config file the hook lives in. */ + configPath: string; + /** The exact lines NMSh inserted (removal removes exactly these). */ + lines: string[]; + insertedAt: string; +} + +export interface LedgerEntry { + target: ManagedTarget; + artifactPath: string; + mode: BridgeMode; + themeRef: string; + format: string; + formatVersion: number; + sha256: string; + adapterVersion: number; + generatedAt: string; + hook?: ConfigHook; +} + +export interface Ledger {version: 1; entries: Partial>} + +export const bridgeDirectory = (env: NodeJS.ProcessEnv = process.env) => join(nmshConfigDirectory(env), 'theme-bridge'); +export const ledgerPath = (env: NodeJS.ProcessEnv = process.env) => join(bridgeDirectory(env), 'ledger.json'); +export const sha256 = (text: string) => createHash('sha256').update(text, 'utf8').digest('hex'); + +/** Where each managed artifact lives: always inside NMSh's own Theme Bridge directory. */ +export function artifactPath(target: ManagedTarget, env: NodeJS.ProcessEnv = process.env): string { + const root = bridgeDirectory(env); + switch (target) { + case 'tmux': return join(root, 'tmux', 'nmsh-bridge.tmux.conf'); + case 'neovim': return join(root, 'nvim', 'colors', 'nmsh-bridge.lua'); + case 'vim': return join(root, 'vim', 'colors', 'nmsh-bridge.vim'); + case 'vivid': return join(root, 'vivid', 'nmsh-bridge.yml'); + } +} + +/** The runtimepath directory that holds `colors/` for an editor target. */ +export const runtimeDirectory = (target: 'neovim' | 'vim', env: NodeJS.ProcessEnv = process.env) => dirname(dirname(artifactPath(target, env))); + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const TARGETS: readonly ManagedTarget[] = ['tmux', 'neovim', 'vim', 'vivid']; + +/** A malformed or stale ledger yields no ownership (so nothing can be deleted on its say-so). */ +export function loadLedger(env: NodeJS.ProcessEnv = process.env): Ledger { + let parsed: unknown; + try { parsed = JSON.parse(readFileSync(ledgerPath(env), 'utf8')); } catch { return {version: 1, entries: {}}; } + const entries: Ledger['entries'] = {}; + const raw = isRecord(parsed) && isRecord(parsed.entries) ? parsed.entries : {}; + for (const target of TARGETS) { + const entry = raw[target]; + if (!isRecord(entry) || entry.target !== target || typeof entry.sha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(entry.sha256)) continue; + // Only the path NMSh itself would use is ever trusted; a ledger cannot point NMSh at other files. + if (entry.artifactPath !== artifactPath(target, env)) continue; + const hook = isRecord(entry.hook) && typeof entry.hook.configPath === 'string' && Array.isArray(entry.hook.lines) + && entry.hook.lines.every(line => typeof line === 'string' && line.length < 2048) && entry.hook.lines.length > 0 && entry.hook.lines.length <= 4 + ? {configPath: entry.hook.configPath, lines: entry.hook.lines as string[], insertedAt: String(entry.hook.insertedAt ?? '')} : undefined; + entries[target] = {target, artifactPath: entry.artifactPath, mode: entry.mode === 'choose' ? 'choose' : 'follow', themeRef: String(entry.themeRef ?? ''), + format: String(entry.format ?? ''), formatVersion: Number(entry.formatVersion) || 1, sha256: entry.sha256, adapterVersion: Number(entry.adapterVersion) || 1, + generatedAt: String(entry.generatedAt ?? ''), ...(hook ? {hook} : {})}; + } + return {version: 1, entries}; +} + +export function saveLedger(ledger: Ledger, env: NodeJS.ProcessEnv = process.env): void { + const path = ledgerPath(env); + mkdirSync(dirname(path), {recursive: true, mode: 0o700}); + const staged = `${path}.${process.pid}.tmp`; + writeFileSync(staged, `${JSON.stringify(ledger, null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); + renameSync(staged, path); +} + +export type Ownership = 'absent' | 'owned' | 'modified' | 'unknown'; + +/** Owned: the ledger recorded this exact content. Modified/unknown files are never touched. */ +export function ownership(target: ManagedTarget, ledger: Ledger, env: NodeJS.ProcessEnv = process.env): Ownership { + const path = artifactPath(target, env); + if (!existsSync(path)) return 'absent'; + const entry = ledger.entries[target]; + if (!entry) return 'unknown'; + let content: string; + try { content = readFileSync(path, 'utf8'); } catch { return 'unknown'; } + return sha256(content) === entry.sha256 ? 'owned' : 'modified'; +} + +export type GenerateResult = {ok: true; path: string; changed: boolean} | {ok: false; error: string}; + +/** + * resolve → render → stage → validate → atomic replace. A file NMSh does not + * provably own is never replaced; a failed validation leaves the previous + * artifact (and every other adapter) untouched. + */ +export function writeArtifact(target: ManagedTarget, content: string, validate: (content: string) => boolean, record: Omit, + env: NodeJS.ProcessEnv = process.env, now = new Date()): GenerateResult { + if (!validate(content)) return {ok: false, error: 'The generated file failed validation; the previous one was kept.'}; + const ledger = loadLedger(env); + const state = ownership(target, ledger, env); + const path = artifactPath(target, env); + if (state === 'modified' || state === 'unknown') { + return {ok: false, error: `${path} exists but NMSh cannot confirm it wrote it${state === 'modified' ? ' (it was edited)' : ''}. Move it away to let NMSh manage it.`}; + } + const hash = sha256(content); + const previous = ledger.entries[target]; + const changed = !previous || previous.sha256 !== hash || state === 'absent'; + if (changed) { + mkdirSync(dirname(path), {recursive: true, mode: 0o700}); + const staged = `${path}.${process.pid}.tmp`; + try { + writeFileSync(staged, content, {encoding: 'utf8', mode: 0o644}); + if (readFileSync(staged, 'utf8') !== content || !validate(readFileSync(staged, 'utf8'))) throw new Error('staged file did not verify'); + renameSync(staged, path); + } catch (error) { + try { unlinkSync(staged); } catch { /* nothing staged */ } + return {ok: false, error: `Writing ${path} failed: ${error instanceof Error ? error.message : String(error)}. The previous file was kept.`}; + } + } + ledger.entries[target] = {...record, target, artifactPath: path, sha256: hash, adapterVersion: ADAPTER_VERSION, generatedAt: now.toISOString(), + ...(previous?.hook ? {hook: previous.hook} : {})}; + saveLedger(ledger, env); + return {ok: true, path, changed}; +} + +/** Removes a managed artifact only when owned; an edited or unrecorded file is reported and kept. */ +export function removeArtifact(target: ManagedTarget, env: NodeJS.ProcessEnv = process.env): {ok: true; removed: boolean} | {ok: false; error: string} { + const ledger = loadLedger(env); + const state = ownership(target, ledger, env); + const path = artifactPath(target, env); + if (state === 'modified' || state === 'unknown') return {ok: false, error: `${path} was not removed: NMSh cannot confirm it is unchanged since NMSh wrote it.`}; + if (state === 'owned') unlinkSync(path); + // A recorded include stays recorded (it is harmless without the file: every include tolerates a missing file) until it is removed explicitly. + const hook = ledger.entries[target]?.hook; + if (hook) ledger.entries[target] = {...ledger.entries[target]!, sha256: sha256(''), hook}; + else delete ledger.entries[target]; + saveLedger(ledger, env); + return {ok: true, removed: state === 'owned'}; +} + +// ---- Exact config hooks --------------------------------------------------------- + +export interface HookSpec { + configPath: string; + lines: string[]; + /** Create the config file with only these lines when it does not exist. */ + createIfMissing: boolean; +} + +const HOOK_COMMENT = {tmux: '# NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)', + neovim: '-- NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)', + vim: '" NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)'}; + +/** A path safe to embed in a single-quoted tmux/Vim/Lua string without escaping rules that differ by target. */ +export function hookSafePath(path: string): boolean { + return /^[^'"\\,\u0000-\u001f\u007f|]+$/u.test(path); +} + +function firstExisting(paths: readonly string[]): string | undefined { + return paths.find(path => existsSync(path)); +} + +/** The user config a hook belongs in, and the exact lines. Existing files are preferred; nothing else is assumed. */ +export function hookSpec(target: Extract, env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): HookSpec | {error: string} { + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(home, '.config'); + if (target === 'tmux') { + const fragment = artifactPath('tmux', env); + if (!hookSafePath(fragment)) return {error: `The managed file path ${fragment} cannot be quoted safely for tmux.`}; + const existing = firstExisting([join(home, '.tmux.conf'), join(xdg, 'tmux', 'tmux.conf')]); + return {configPath: existing ?? join(home, '.tmux.conf'), lines: [HOOK_COMMENT.tmux, `source-file -q '${fragment}'`], createIfMissing: !existing}; + } + const runtime = runtimeDirectory(target, env); + if (!hookSafePath(runtime)) return {error: `The managed directory ${runtime} cannot be quoted safely for ${target === 'neovim' ? 'Neovim' : 'Vim'}.`}; + if (target === 'neovim') { + const lua = join(xdg, 'nvim', 'init.lua'); + const vimscript = join(xdg, 'nvim', 'init.vim'); + if (existsSync(vimscript) && !existsSync(lua)) { + return {configPath: vimscript, lines: [HOOK_COMMENT.vim, `silent! execute 'set runtimepath+=' . fnameescape('${runtime}') | silent! colorscheme nmsh-bridge`], createIfMissing: false}; + } + return {configPath: lua, lines: [HOOK_COMMENT.neovim, `pcall(function() vim.opt.runtimepath:append('${runtime}'); vim.cmd.colorscheme('nmsh-bridge') end)`], createIfMissing: !existsSync(lua)}; + } + const existing = firstExisting([join(home, '.vimrc'), join(home, '.vim', 'vimrc')]); + return {configPath: existing ?? join(home, '.vimrc'), lines: [HOOK_COMMENT.vim, `silent! execute 'set runtimepath+=' . fnameescape('${runtime}') | silent! colorscheme nmsh-bridge`], createIfMissing: !existing}; +} + +/** The verified plan (exact path, exact diff) for inserting a hook; nothing is written here. */ +export function planHook(spec: HookSpec, home: string): {plan: FileEditPlan} | {noop: string} | {error: string} { + const roots = [home]; + if (spec.createIfMissing && !existsSync(spec.configPath)) { + const result = planCreate(spec.configPath, `${spec.lines.join('\n')}\n`, roots); + return result.kind === 'plan' ? {plan: result.plan} : {error: 'reason' in result ? result.reason : 'Cannot create that file.'}; + } + const result = planAppend(inspectFile(spec.configPath, roots), 'text', spec.lines.join('\n')); + if (result.kind === 'plan') return {plan: result.plan}; + if (result.kind === 'noop') return {noop: result.reason}; + return {error: 'reason' in result ? result.reason : 'Cannot edit that file.'}; +} + +/** Applies a confirmed hook plan and records the exact lines in the ledger. */ +export function applyHook(target: Extract, plan: FileEditPlan, spec: HookSpec, env: NodeJS.ProcessEnv = process.env, now = new Date()): {ok: true} | {ok: false; error: string} { + const ledger = loadLedger(env); + const entry = ledger.entries[target]; + if (!entry) return {ok: false, error: 'Generate the managed file first.'}; + const result = applyPlan(plan); + if (!result.ok) return {ok: false, error: result.reason}; + ledger.entries[target] = {...entry, hook: {configPath: spec.configPath, lines: [...spec.lines], insertedAt: now.toISOString()}}; + saveLedger(ledger, env); + return {ok: true}; +} + +/** The plan to remove exactly the recorded hook lines; anything else in the file is untouched. */ +export function planHookRemoval(target: Extract, home: string, env: NodeJS.ProcessEnv = process.env): {plan: FileEditPlan} | {gone: true} | {error: string} { + const hook = loadLedger(env).entries[target]?.hook; + if (!hook) return {error: 'NMSh has no recorded include for this target.'}; + const facts = inspectFile(hook.configPath, [home]); + if (facts.content === undefined) return {error: `${hook.configPath} ${facts.refusal ?? 'cannot be read'}; nothing was changed.`}; + const block = `${hook.lines.join('\n')}\n`; + const occurrences = facts.content.split(block).length - 1; + if (occurrences === 0) return {gone: true}; + if (occurrences > 1) return {error: `${hook.configPath} contains the NMSh include more than once; remove the extra copies yourself so NMSh does not guess.`}; + const result = planReplace(facts, block, ''); + return result.kind === 'plan' ? {plan: result.plan} : {error: 'reason' in result ? result.reason : 'Cannot plan the removal.'}; +} + +export function applyHookRemoval(target: Extract, plan: FileEditPlan | undefined, env: NodeJS.ProcessEnv = process.env): {ok: true} | {ok: false; error: string} { + if (plan) { + const result = applyPlan(plan); + if (!result.ok) return {ok: false, error: result.reason}; + } + const ledger = loadLedger(env); + const entry = ledger.entries[target]; + if (entry) { delete entry.hook; saveLedger(ledger, env); } + return {ok: true}; +} diff --git a/src/themeBridge/environment.ts b/src/themeBridge/environment.ts new file mode 100644 index 00000000..e3dcd661 --- /dev/null +++ b/src/themeBridge/environment.ts @@ -0,0 +1,212 @@ +import {createHash} from 'node:crypto'; +import {mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; + +/** + * The one Theme Bridge shell-environment sink. + * + * NMSh owns a persistent real shell; changing settings in the frontend cannot + * reach into that process directly. Instead NMSh writes one generated, + * validated, declarative file per shell syntax, and each ShellAdapter's own + * bootstrap (static NMSh code, never the user's rc files) applies it from the + * prompt hook: + * + * if nmsh_bridge_begin ; then + * nmsh_bridge_apply LS_COLORS '…' + * nmsh_bridge_clear LESS_TERMCAP_md + * fi + * + * Only allowlisted variable names and quoted literals appear; the file is + * re-validated before every atomic replace. `apply` remembers the value it + * replaced; `clear` restores it only if the variable still holds NMSh's own + * value, so Independent removes exactly what NMSh set and nothing the user + * changed. Nothing is written to history or the transcript. + */ + +export const BRIDGE_ENV_VARIABLES = ['LS_COLORS', 'LESS_TERMCAP_mb', 'LESS_TERMCAP_md', 'LESS_TERMCAP_me', 'LESS_TERMCAP_so', 'LESS_TERMCAP_se', + 'LESS_TERMCAP_us', 'LESS_TERMCAP_ue', 'GROFF_NO_SGR'] as const; +export type BridgeEnvVariable = typeof BRIDGE_ENV_VARIABLES[number]; +export type BridgeEnvironment = Partial>; + +const MAX_VALUE = 8 * 1024; +const ESC = '\u001B'; + +/** Values are printable ASCII plus ESC (for termcap sequences); anything else is refused. */ +export function validEnvValue(value: string): boolean { + return value.length <= MAX_VALUE && /^[ -~\u001b]*$/u.test(value); +} + +/** zsh/Bash ANSI-C quoting: `$'…'` with `\e`, `\\` and `\'` as the only escapes. */ +export function posixAnsiQuote(value: string): string { + return `$'${value.replace(/\\/gu, '\\\\').replace(/'/gu, "\\'").replaceAll(ESC, '\\e')}'`; +} + +/** Fish: single-quoted literal runs joined with bare `\e` escapes (fish concatenates adjacent tokens). */ +export function fishLiteral(value: string): string { + if (!value) return "''"; + return value.split(ESC).map(part => part ? `'${part.replace(/[\\']/gu, match => `\\${match}`)}'` : '').join('\\e') || "''"; +} + +export function bridgeEnvPath(shell: ShellId, env: NodeJS.ProcessEnv = process.env): string { + return join(nmshConfigDirectory(env), 'theme-bridge', `environment.${shell === 'fish' ? 'fish' : shell}`); +} + +/** Content-derived generation: identical environments never re-apply. */ +export function environmentGeneration(values: BridgeEnvironment): string { + const canonical = BRIDGE_ENV_VARIABLES.map(name => `${name}=${values[name] ?? ''}`).join('\n'); + return createHash('sha256').update(canonical).digest('hex').slice(0, 16); +} + +export function renderEnvironmentFile(shell: ShellId, values: BridgeEnvironment): string { + for (const [name, value] of Object.entries(values)) { + if (!(BRIDGE_ENV_VARIABLES as readonly string[]).includes(name) || value === undefined || !validEnvValue(value)) throw new Error(`Refusing Theme Bridge value for ${name}.`); + } + const generation = environmentGeneration(values); + const body = BRIDGE_ENV_VARIABLES.map(name => { + const value = values[name]; + if (value === undefined) return ` nmsh_bridge_clear ${name}`; + return ` nmsh_bridge_apply ${name} ${shell === 'fish' ? fishLiteral(value) : posixAnsiQuote(value)}`; + }); + const header = '# Generated by NMSh Theme Bridge. Do not edit: NMSh replaces this file.\n'; + return shell === 'fish' + ? `${header}if nmsh_bridge_begin ${generation}\n${body.join('\n')}\nend\n` + : `${header}if nmsh_bridge_begin ${generation}; then\n${body.join('\n')}\nfi\n`; +} + +const POSIX_LINE = /^ nmsh_bridge_(?:apply [A-Z][A-Z_a-z]* \$'(?:[^'\\]|\\[\\'e])*'|clear [A-Z][A-Z_a-z]*)$/u; +const FISH_LINE = /^ nmsh_bridge_(?:apply [A-Z][A-Z_a-z]* (?:'(?:[^'\\]|\\[\\'])*'|\\e)+|clear [A-Z][A-Z_a-z]*)$/u; + +/** Strict grammar check of a generated file: nothing but the header, the guard and allowlisted calls. */ +export function validateEnvironmentFile(shell: ShellId, content: string): boolean { + const lines = content.split('\n'); + if (lines.at(-1) === '') lines.pop(); + const [header, open, ...rest] = lines; + const close = rest.pop(); + if (header !== '# Generated by NMSh Theme Bridge. Do not edit: NMSh replaces this file.') return false; + if (shell === 'fish' ? !/^if nmsh_bridge_begin [0-9a-f]{16}$/u.test(open ?? '') || close !== 'end' + : !/^if nmsh_bridge_begin [0-9a-f]{16}; then$/u.test(open ?? '') || close !== 'fi') return false; + if (rest.length !== BRIDGE_ENV_VARIABLES.length) return false; + return rest.every((line, index) => (shell === 'fish' ? FISH_LINE : POSIX_LINE).test(line) + && line.split(' ')[3] === BRIDGE_ENV_VARIABLES[index]); +} + +/** + * Writes the environment for every shell syntax (staged, validated, then + * atomically renamed). New shells pick it up at their first prompt; running + * shells at their next prompt. + */ +export function writeEnvironmentFiles(values: BridgeEnvironment, env: NodeJS.ProcessEnv = process.env): void { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + const path = bridgeEnvPath(shell, env); + const content = renderEnvironmentFile(shell, values); + if (!validateEnvironmentFile(shell, content)) throw new Error('Generated Theme Bridge environment failed validation.'); + let current: string | undefined; + try { current = readFileSync(path, 'utf8'); } catch { current = undefined; } + if (current === content) continue; + mkdirSync(dirname(path), {recursive: true, mode: 0o700}); + const staged = `${path}.${process.pid}.tmp`; + writeFileSync(staged, content, {encoding: 'utf8', mode: 0o600}); + renameSync(staged, path); + } +} + +/** + * The adapter-owned bootstrap that applies the environment file. Static NMSh + * code; the only dynamic part is the quoted file path. The file is applied + * only when it is a regular file owned by the user. + */ +export function bridgeBootstrap(shell: ShellId, path: string): string { + if (shell === 'fish') { + return ` +function nmsh_bridge_begin + test "$argv[1]" != "$__nmsh_bridge_generation"; or return 1 + set -g __nmsh_bridge_generation $argv[1] +end +function nmsh_bridge_apply + set -l name $argv[1] + set -l own __nmsh_bridge_own_$name + set -l orig __nmsh_bridge_orig_$name + if not set -q $own; or test "$$name" != "$$own" + if set -q $name; set -g $orig $$name; else; set -e $orig; end + end + set -g $own $argv[2] + set -gx $name $argv[2] +end +function nmsh_bridge_clear + set -l name $argv[1] + set -l own __nmsh_bridge_own_$name + set -l orig __nmsh_bridge_orig_$name + set -q $own; or return 0 + if test "$$name" = "$$own" + if set -q $orig; set -gx $name $$orig; else; set -e $name; end + end + set -e $own; set -e $orig +end +function nmsh_bridge_sync + set -l file ${fishQuoteLocal(path)} + test -f $file -a -O $file; and source $file +end +`; + } + const quoted = posixAnsiQuote(path); + if (shell === 'zsh') { + return ` +function nmsh_bridge_begin { + [[ $1 != \${__nmsh_bridge_generation-} ]] || return 1 + typeset -g __nmsh_bridge_generation=$1 +} +function nmsh_bridge_apply { + local name=$1 own=__nmsh_bridge_own_$1 orig=__nmsh_bridge_orig_$1 + if (( ! \${(P)+own} )) || [[ \${(P)name-} != \${(P)own} ]]; then + if (( \${(P)+name} )); then typeset -g $orig="\${(P)name}"; else unset $orig; fi + fi + typeset -g $own="$2" + export $name="$2" +} +function nmsh_bridge_clear { + local name=$1 own=__nmsh_bridge_own_$1 orig=__nmsh_bridge_orig_$1 + (( \${(P)+own} )) || return 0 + if [[ \${(P)name-} == \${(P)own} ]]; then + if (( \${(P)+orig} )); then export $name="\${(P)orig}"; else unset $name; fi + fi + unset $own $orig +} +function nmsh_bridge_sync { + local file=${quoted} + [[ -f $file && -O $file ]] && source $file +} +`; + } + return ` +nmsh_bridge_begin() { + [[ "$1" != "\${__nmsh_bridge_generation-}" ]] || return 1 + __nmsh_bridge_generation=$1 +} +nmsh_bridge_apply() { + local name=$1 own=__nmsh_bridge_own_$1 orig=__nmsh_bridge_orig_$1 + if [[ -z "\${!own+x}" || "\${!name-}" != "\${!own}" ]]; then + if [[ -n "\${!name+x}" ]]; then printf -v "$orig" '%s' "\${!name}"; else unset "$orig"; fi + fi + printf -v "$own" '%s' "$2" + export "$name=$2" +} +nmsh_bridge_clear() { + local name=$1 own=__nmsh_bridge_own_$1 orig=__nmsh_bridge_orig_$1 + [[ -n "\${!own+x}" ]] || return 0 + if [[ "\${!name-}" == "\${!own}" ]]; then + if [[ -n "\${!orig+x}" ]]; then export "$name=\${!orig}"; else unset "$name"; fi + fi + unset "$own" "$orig" +} +nmsh_bridge_sync() { + local file=${quoted} + [[ -f $file && -O $file ]] && source "$file" +} +`; +} + +function fishQuoteLocal(value: string): string { + return `'${value.replace(/[\\']/gu, match => `\\${match}`)}'`; +} diff --git a/src/themeBridge/model.ts b/src/themeBridge/model.ts new file mode 100644 index 00000000..d1a3a096 --- /dev/null +++ b/src/themeBridge/model.ts @@ -0,0 +1,72 @@ +/** + * Theme Bridge persisted model: one independent mode per target. Every target + * starts Independent, which means NMSh injects and changes nothing for it. + * Follow NMSh tracks the active Native theme; Choose theme pins a stable theme + * reference (built-in, Imported or Custom) that the main theme never changes. + * + * This module is data only (no theme resolution), so the configuration + * normalizer can use it without import cycles. + */ + +export const BRIDGE_TARGETS = ['fzf', 'pager', 'lsColors', 'bat', 'delta', 'tmux', 'neovim', 'vim'] as const; +export type BridgeTargetId = typeof BRIDGE_TARGETS[number]; +export const BRIDGE_TARGET_LABELS: Record = { + fzf: 'fzf', pager: 'less / man', lsColors: 'LS_COLORS', bat: 'bat', delta: 'delta', tmux: 'tmux', neovim: 'Neovim', vim: 'Vim', +}; + +export const BRIDGE_MODES = ['independent', 'follow', 'choose'] as const; +export type BridgeMode = typeof BRIDGE_MODES[number]; +export const BRIDGE_MODE_LABELS: Record = {independent: 'Independent', follow: 'Follow NMSh', choose: 'Choose theme'}; + +export interface BridgeTargetSetting { + mode: BridgeMode; + /** Choose theme only: a stable theme reference (`builtin:…` or `asset:…`). Kept when the mode changes. */ + theme?: string; +} + +export interface ThemeBridgeSettings { + /** + * Master switch (Setup's "Extend NMSh colors to terminal tools?"), Off by + * default. Off makes every target effectively Independent while keeping + * the per-target choices for when it is turned back on. + */ + enabled: boolean; + targets: Record; +} + +export const DEFAULT_THEME_BRIDGE = (): ThemeBridgeSettings => + ({enabled: false, targets: Object.fromEntries(BRIDGE_TARGETS.map(id => [id, {mode: 'independent'}])) as Record}); + +/** The mode in effect: the target's own mode while Theme Bridge is on, Independent otherwise. */ +export function effectiveMode(settings: ThemeBridgeSettings, target: BridgeTargetId): BridgeMode { + return settings.enabled ? settings.targets[target].mode : 'independent'; +} + +const REF = /^(?:asset:[a-z0-9][a-z0-9-]{2,39}|builtin:[A-Za-z0-9]+(?:@[a-z]+)?)$/u; +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +/** Unknown or malformed targets read as Independent; a Choose without a valid reference has nothing to pin and reads as Independent. */ +export function normalizeThemeBridge(value: unknown): ThemeBridgeSettings { + const settings = DEFAULT_THEME_BRIDGE(); + const targets = isRecord(value) && isRecord(value.targets) ? value.targets : {}; + for (const id of BRIDGE_TARGETS) { + const item = targets[id]; + if (!isRecord(item)) continue; + const theme = typeof item.theme === 'string' && REF.test(item.theme) ? item.theme : undefined; + const mode = BRIDGE_MODES.includes(item.mode as BridgeMode) ? item.mode as BridgeMode : 'independent'; + settings.targets[id] = {mode: mode === 'choose' && !theme ? 'independent' : mode, ...(theme ? {theme} : {})}; + } + // A configuration that already has active targets but no switch (none shipped without one) reads as On. + settings.enabled = isRecord(value) && typeof value.enabled === 'boolean' ? value.enabled : BRIDGE_TARGETS.some(id => settings.targets[id].mode !== 'independent'); + return settings; +} + +/** Targets pinned to a theme reference (Choose theme), for delete protection. */ +export function targetsPinnedTo(settings: ThemeBridgeSettings, ref: string): BridgeTargetId[] { + // Pins count even while the switch is Off: turning it back on must never find a dangling reference. + return BRIDGE_TARGETS.filter(id => settings.targets[id].mode === 'choose' && settings.targets[id].theme === ref); +} + +export function anyBridgeTargetActive(settings: ThemeBridgeSettings): boolean { + return BRIDGE_TARGETS.some(id => effectiveMode(settings, id) !== 'independent'); +} diff --git a/src/themeBridge/runtime.ts b/src/themeBridge/runtime.ts new file mode 100644 index 00000000..5bbd4f2b --- /dev/null +++ b/src/themeBridge/runtime.ts @@ -0,0 +1,282 @@ +import {existsSync, readFileSync, statSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {join} from 'node:path'; +import {resolveCommand, runExternal} from '../providers/providers.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {resolveSemanticPalette, type SemanticPalette} from '../appearance/semanticPalette.js'; +import {activeThemeRef, themeRefLabel, type ThemeSource} from '../appearance/themeRefs.js'; +import {BRIDGE_TARGETS, BRIDGE_TARGET_LABELS, effectiveMode, type BridgeMode, type BridgeTargetId, type ThemeBridgeSettings} from './model.js'; +import {bridgeEnvPath, writeEnvironmentFiles, type BridgeEnvironment} from './environment.js'; +import { + artifactPath, ledgerPath, loadLedger, ownership, removeArtifact, sha256, writeArtifact, type ManagedTarget, +} from './artifacts.js'; +import { + fzfColorArgs, lsColorsFallback, neovimColorscheme, pagerEnvironment, parseFzfVersion, tmuxFragment, validateNeovimColorscheme, + validateTmuxFragment, validateVimColorscheme, validLsColors, vimColorscheme, vividTheme, TMUX_UNREPRESENTED, +} from './targets.js'; + +/** + * Theme Bridge runtime: target detection (local, read-only, bounded, + * cached), per-target resolution through the one semantic palette, and + * application through the environment sink or managed artifacts. Never + * touches shell rc files, terminal/editor themes or git config; permanent + * includes are separate, explicitly confirmed steps (see artifacts.ts). + */ + +export type BridgeStatus = 'Not installed' | 'Detected' | 'Independent' | 'Following NMSh' | 'Pinned theme' | 'Conflict' | 'Unsupported capability' | 'Missing theme'; + +export interface TargetFacts {installed: boolean; binary?: string; version?: string} + +export interface TargetReport { + target: BridgeTargetId; + label: string; + mode: BridgeMode; + /** Modes this target can honestly offer. */ + modes: readonly BridgeMode[]; + themeLabel?: string; + status: BridgeStatus; + /** Short factual qualifiers: "Managed", "include installed", "reload available", conflicts, limitations. */ + notes: string[]; + palette?: SemanticPalette; +} + +/** Executable per target (PATH lookup only; versions only where mappings depend on them). */ +const BINARIES: Record = {fzf: 'fzf', pager: 'less', lsColors: 'ls', bat: 'bat', delta: 'delta', tmux: 'tmux', neovim: 'nvim', vim: 'vim'}; +const VERSION_ARGS: Partial> = {fzf: ['--version'], tmux: ['-V']}; + +const UNSUPPORTED: Partial> = { + bat: 'bat loads custom themes only from its own theme cache (bat cache --build), which NMSh does not modify; a built-in bat theme would not be this theme.', + delta: 'delta takes syntax themes from bat\'s cache and diff styles from git config or its own arguments; NMSh does not edit ~/.gitconfig or bat\'s cache.', +}; + +export function supportedModes(target: BridgeTargetId): readonly BridgeMode[] { + return UNSUPPORTED[target] ? ['independent'] : ['independent', 'follow', 'choose']; +} + +let factsCache: Promise> | undefined; +let factsKey = ''; + +/** Local, bounded detection, cached per PATH; at most two short version probes. */ +export function detectTargets(env: NodeJS.ProcessEnv = process.env, refresh = false): Promise> { + const key = env.PATH ?? ''; + if (factsCache && factsKey === key && !refresh) return factsCache; + factsKey = key; + factsCache = (async () => { + const entries = await Promise.all(BRIDGE_TARGETS.map(async (target): Promise<[BridgeTargetId, TargetFacts]> => { + const binary = resolveCommand(BINARIES[target], key); + if (!binary) return [target, {installed: false}]; + const args = VERSION_ARGS[target]; + if (!args) return [target, {installed: true, binary}]; + const result = await runExternal(binary, args, {timeoutMs: 1500, maxBytes: 4096, env}); + const version = result.stdout.trim().replace(/^tmux\s+/u, '').split('\n')[0]?.slice(0, 40); + return [target, {installed: true, binary, ...(version ? {version} : {})}]; + })); + return Object.fromEntries(entries) as Record; + })(); + return factsCache; +} + +export function resetDetectionCache(): void { factsCache = undefined; } + +/** Bounded read of a user config for conflict facts; never parsed beyond simple line matching, never executed. */ +function readSmall(path: string): string | undefined { + try { + if (statSync(path).size > 256 * 1024) return undefined; + return readFileSync(path, 'utf8'); + } catch { return undefined; } +} + +const TMUX_THEME_PLUGINS = /@plugin\s+['"]?(catppuccin\/tmux|dracula\/tmux|egel\/tmux-gruvbox|arcticicestudio\/nord-tmux|nordtheme\/tmux|odedlaz\/tmux-onedark-theme|wfxr\/tmux-power|jimeh\/tmux-themepack|fabioluciano\/tmux-tokyo-night|janoamaral\/tokyo-night-tmux|rose-pine\/tmux|seebi\/tmux-colors-solarized)/u; + +/** Factual conflicts: other sources that style the same surface. Reported, never fought. */ +export function targetConflicts(target: BridgeTargetId, env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): string[] { + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(home, '.config'); + if (target === 'tmux') { + const text = [join(home, '.tmux.conf'), join(xdg, 'tmux', 'tmux.conf')].map(readSmall).filter(Boolean).join('\n'); + const notes: string[] = []; + const plugin = TMUX_THEME_PLUGINS.exec(text); + if (plugin) notes.push(`Your tmux config loads the ${plugin[1]} theme plugin; whichever loads last decides the colors.`); + if (/^\s*set(?:-option)?\s+(?:-[a-zA-Z]*\s+)*(?:status-style|status-bg|status-fg|pane-border-style|pane-active-border-style|window-status-current-style)\b/mu.test(text)) { + notes.push('Your tmux config also sets status/pane styles; whichever loads last decides the colors.'); + } + return notes; + } + if (target === 'neovim' || target === 'vim') { + const paths = target === 'neovim' ? [join(xdg, 'nvim', 'init.lua'), join(xdg, 'nvim', 'init.vim')] : [join(home, '.vimrc'), join(home, '.vim', 'vimrc')]; + const text = paths.map(readSmall).filter(Boolean).join('\n'); + return /^\s*(?:colorscheme|colo)\s+\S|vim\.cmd\.colorscheme|vim\.cmd\s*\(?\s*['"]colorscheme/mu.test(text) + ? ['Your config also chooses a colorscheme; the NMSh include, placed last, takes effect after it.'] : []; + } + if (target === 'lsColors' && env.LS_COLORS) return ['LS_COLORS is already set; NMSh replaces it in NMSh shells while active and restores it when Independent.']; + if (target === 'fzf' && /--color/u.test(env.FZF_DEFAULT_OPTS ?? '')) return ['FZF_DEFAULT_OPTS sets colors; NMSh-owned fzf launches never read FZF_DEFAULT_OPTS, your own fzf use keeps it.']; + return []; +} + +/** The theme a target uses right now, or why it has none. Never another theme. */ +export function targetPalette(settings: ThemeBridgeSettings, target: BridgeTargetId, source: ThemeSource): {palette?: SemanticPalette; label?: string; missing?: boolean} { + const setting = settings.targets[target]; + const mode = effectiveMode(settings, target); + if (mode === 'independent' || !supportedModes(target).includes(mode)) return {}; + const ref = mode === 'follow' ? activeThemeRef(source) : setting.theme; + const resolved = resolveSemanticPalette(ref, source); + if (!resolved.ok) return {missing: true, label: resolved.label}; + return {palette: resolved.palette, label: themeRefLabel(ref, source)}; +} + +export interface BridgeContext {source: ThemeSource & {themeBridge: ThemeBridgeSettings}; facts: Record; level: ColorLevel; env?: NodeJS.ProcessEnv} + +/** NO_COLOR (any value) or no color capability: color injection stops for every target. */ +export function bridgeColorLevel(level: ColorLevel, env: NodeJS.ProcessEnv = process.env): ColorLevel { + return env.NO_COLOR !== undefined && env.NO_COLOR !== '' ? 'none' : level; +} + +export function reportTargets({source, facts, level, env = process.env}: BridgeContext): TargetReport[] { + const ledger = loadLedger(env); + return BRIDGE_TARGETS.map(target => { + const setting = {...source.themeBridge.targets[target], mode: effectiveMode(source.themeBridge, target)}; + const {palette, label, missing} = targetPalette(source.themeBridge, target, source); + const notes: string[] = []; + let status: BridgeStatus; + if (!facts[target]?.installed) status = 'Not installed'; + else if (UNSUPPORTED[target]) { status = 'Unsupported capability'; notes.push(UNSUPPORTED[target]!); } + else if (setting.mode === 'independent') status = 'Detected'; + else if (missing) status = 'Missing theme'; + else status = setting.mode === 'follow' ? 'Following NMSh' : 'Pinned theme'; + if (setting.mode !== 'independent' && bridgeColorLevel(level, env) === 'none') notes.push('NO_COLOR or no color support: nothing is injected.'); + const managed = target === 'tmux' || target === 'neovim' || target === 'vim' ? target : undefined; + if (managed) { + const entry = ledger.entries[managed]; + const owned = ownership(managed, ledger, env); + if (owned === 'owned') notes.push('Managed'); + if (owned === 'modified' || owned === 'unknown') { status = 'Conflict'; notes.push(`${artifactPath(managed, env)} exists and is not NMSh's unchanged file; NMSh will not overwrite it.`); } + if (entry?.hook) notes.push(setting.mode === 'independent' ? 'include installed (inactive)' : 'include installed'); + else if (setting.mode !== 'independent') notes.push('new instances need the include (Apply)'); + if (managed === 'tmux' && setting.mode !== 'independent') { notes.push('reload available'); notes.push(TMUX_UNREPRESENTED); } + } + for (const conflict of facts[target]?.installed ? targetConflicts(target, env) : []) { + notes.push(conflict); + if (setting.mode !== 'independent' && status !== 'Missing theme' && (target === 'tmux')) status = 'Conflict'; + } + return {target, label: BRIDGE_TARGET_LABELS[target], mode: setting.mode, modes: supportedModes(target), ...(label ? {themeLabel: label} : {}), + status, notes, ...(palette ? {palette} : {})}; + }); +} + +/** The environment the sink should carry for the current settings (pager + LS_COLORS), from the resolved palettes. */ +export function bridgeEnvironment(context: BridgeContext, lsColors?: string): BridgeEnvironment { + const level = bridgeColorLevel(context.level, context.env); + const out: BridgeEnvironment = {}; + const pager = targetPalette(context.source.themeBridge, 'pager', context.source).palette; + if (pager) Object.assign(out, pagerEnvironment(pager, level)); + const ls = targetPalette(context.source.themeBridge, 'lsColors', context.source).palette; + if (ls) { + const value = lsColors ?? lsColorsFallback(ls, level); + if (value) out.LS_COLORS = value; + } + return out; +} + +/** fzf arguments for one NMSh-owned launch (empty when Independent, unavailable or colorless). */ +export function fzfBridgeArgs(context: BridgeContext): string[] { + const palette = targetPalette(context.source.themeBridge, 'fzf', context.source).palette; + if (!palette) return []; + return fzfColorArgs(palette, bridgeColorLevel(context.level, context.env), parseFzfVersion(context.facts.fzf?.version)); +} + +const vividCache = new Map(); + +/** vivid output for the LS_COLORS palette when vivid is installed; undefined falls back to the small built-in mapping. */ +async function vividColors(palette: SemanticPalette, level: ColorLevel, env: NodeJS.ProcessEnv, mode: BridgeMode, ref: string): Promise { + const binary = resolveCommand('vivid', env.PATH ?? ''); + if (!binary || level === 'none') return undefined; + const theme = vividTheme(palette); + const key = `${sha256(theme)}:${level}`; + if (vividCache.has(key)) return vividCache.get(key); + const written = writeArtifact('vivid', theme, content => content.startsWith('# Generated by NMSh Theme Bridge'), {mode, themeRef: ref, format: 'vivid-theme', formatVersion: 1}, env); + if (!written.ok) return undefined; + const args = [...(level === 'truecolor' ? [] : ['-m', '8-bit']), 'generate', written.path]; + const result = await runExternal(binary, args, {timeoutMs: 2000, maxBytes: 128 * 1024, env}); + const value = result.stdout.trim(); + if (!result.ok || !validLsColors(value)) return undefined; + vividCache.set(key, value); + return value; +} + +export interface ApplyOutcome {target: BridgeTargetId; ok: boolean; message?: string} + +const MANAGED: ReadonlyArray<{target: Extract; render: (palette: SemanticPalette) => string; validate: (content: string) => boolean; format: string}> = [ + {target: 'tmux', render: tmuxFragment, validate: validateTmuxFragment, format: 'tmux-fragment'}, + {target: 'neovim', render: neovimColorscheme, validate: validateNeovimColorscheme, format: 'nvim-colorscheme'}, + {target: 'vim', render: vimColorscheme, validate: validateVimColorscheme, format: 'vim-colorscheme'}, +]; + +/** + * Applies the current settings: the environment sink (pager, LS_COLORS) and + * the managed artifacts for tmux/Neovim/Vim. Each target is isolated; one + * failure is reported and leaves every other target (and the active NMSh + * theme) intact. Independent targets get no artifact and no injection; an + * owned artifact left from an earlier mode is removed (includes stay + * recorded and are harmless without it). + */ +export async function applyThemeBridge(context: BridgeContext): Promise { + const env = context.env ?? process.env; + const level = bridgeColorLevel(context.level, env); + const outcomes: ApplyOutcome[] = []; + let lsColors: string | undefined; + const ls = targetPalette(context.source.themeBridge, 'lsColors', context.source); + if (ls.palette) { + const setting = {...context.source.themeBridge.targets.lsColors, mode: effectiveMode(context.source.themeBridge, 'lsColors')}; + lsColors = await vividColors(ls.palette, level, env, setting.mode, setting.mode === 'follow' ? activeThemeRef(context.source) ?? '' : setting.theme ?? ''); + } + try { + writeEnvironmentFiles(bridgeEnvironment(context, lsColors), env); + outcomes.push({target: 'pager', ok: true}, {target: 'lsColors', ok: true}); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + outcomes.push({target: 'pager', ok: false, message}, {target: 'lsColors', ok: false, message}); + } + for (const managed of MANAGED) { + const setting = {...context.source.themeBridge.targets[managed.target], mode: effectiveMode(context.source.themeBridge, managed.target)}; + const resolved = targetPalette(context.source.themeBridge, managed.target, context.source); + try { + if (setting.mode === 'independent' || !resolved.palette) { + if (existsSync(artifactPath(managed.target, env)) && loadLedger(env).entries[managed.target]) { + const removed = removeArtifact(managed.target, env); + outcomes.push({target: managed.target, ok: removed.ok, ...(!removed.ok ? {message: removed.error} : {})}); + } + if (resolved.missing) outcomes.push({target: managed.target, ok: false, message: `${BRIDGE_TARGET_LABELS[managed.target]}: the pinned theme no longer exists; nothing was generated.`}); + continue; + } + const ref = setting.mode === 'follow' ? activeThemeRef(context.source) ?? '' : setting.theme ?? ''; + const result = writeArtifact(managed.target, managed.render(resolved.palette), managed.validate, {mode: setting.mode, themeRef: ref, format: managed.format, formatVersion: 1}, env); + outcomes.push(result.ok ? {target: managed.target, ok: true} : {target: managed.target, ok: false, message: result.error}); + } catch (error) { + outcomes.push({target: managed.target, ok: false, message: error instanceof Error ? error.message : String(error)}); + } + } + return outcomes; +} + +/** + * The one typed tmux reload: `tmux source-file ` against the + * user's running server, on explicit request only. No shell, no other command. + */ +export async function reloadTmux(env: NodeJS.ProcessEnv = process.env): Promise<{ok: boolean; message: string}> { + const binary = resolveCommand('tmux', env.PATH ?? ''); + if (!binary) return {ok: false, message: 'tmux is not installed.'}; + const path = artifactPath('tmux', env); + if (ownership('tmux', loadLedger(env), env) !== 'owned') return {ok: false, message: 'There is no NMSh-managed tmux fragment to load yet.'}; + const result = await runExternal(binary, ['source-file', path], {timeoutMs: 3000, maxBytes: 16 * 1024, env}); + return result.ok ? {ok: true, message: 'tmux reloaded the NMSh colors for the running server.'} + : {ok: false, message: 'tmux did not reload (no running server, or it refused the file). New tmux servers load it through the include.'}; +} + +/** Everything a bridge application depends on; a change in it (theme, library, bridge settings) re-applies. */ +export function themeBridgeKey(config: ThemeSource & {themeBridge: ThemeBridgeSettings}): string { + return JSON.stringify([config.themeBridge, activeThemeRef(config), config.themes]); +} + +/** Whether NMSh ever wrote bridge state; with nothing active and nothing written, there is nothing to do or undo. */ +export function bridgeStateExists(env: NodeJS.ProcessEnv = process.env): boolean { + return existsSync(bridgeEnvPath('zsh', env)) || existsSync(ledgerPath(env)); +} diff --git a/src/themeBridge/targets.ts b/src/themeBridge/targets.ts new file mode 100644 index 00000000..4046e431 --- /dev/null +++ b/src/themeBridge/targets.ts @@ -0,0 +1,356 @@ +import {parseHexColor} from '../chroma/color.js'; +import {rgbTo16, rgbTo256} from '../chroma/escape.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {sgrRgb, type SemanticPalette} from '../appearance/semanticPalette.js'; +import type {BridgeEnvironment} from './environment.js'; + +/** + * Target mappers: each translates the one resolved semantic palette into a + * target's documented roles. None of them reads themes, configuration or + * files; generation, validation and writing live elsewhere. Static palette + * data only: Chroma never leaks into a generated artifact. + */ + +const ESC = '\u001B'; +const HEX = /^#[0-9a-f]{6}$/u; + +// ---- fzf -------------------------------------------------------------------- + +/** fzf color names by the release that introduced them; older fzf rejects unknown names. */ +const FZF_GATED: ReadonlyArray<[name: string, since: [number, number]]> = [['border', [0, 23]], ['gutter', [0, 23]], ['separator', [0, 35]], ['label', [0, 35]], + ['scrollbar', [0, 36]], ['query', [0, 42]], ['disabled', [0, 42]]]; + +export function parseFzfVersion(text: string | undefined): [number, number] | undefined { + const match = /^(\d+)\.(\d+)/u.exec(text?.trim() ?? ''); + return match ? [Number(match[1]), Number(match[2])] : undefined; +} + +function fzfColor(hex: string, level: ColorLevel): string { + const rgb = parseHexColor(hex)!; + return level === 'truecolor' ? hex : level === 'ansi256' ? String(rgbTo256(rgb)) : String(rgbTo16(rgb)); +} + +/** + * fzf `--color` for one NMSh-owned launch. `bg` stays the terminal default + * unless the theme carries a real terminal background. Returns no argument + * at all when color is unavailable (NO_COLOR or no color capability). + */ +export function fzfColorArgs(palette: SemanticPalette, level: ColorLevel, version?: [number, number]): string[] { + if (level === 'none') return []; + const roles: Record = { + fg: palette.text.primary, hl: palette.accent, 'fg+': palette.selectionForeground, 'bg+': palette.selection, 'hl+': palette.accent, + info: palette.text.subtle, prompt: palette.accent, pointer: palette.accent, marker: palette.success, spinner: palette.info, header: palette.text.secondary, + border: palette.separator, gutter: palette.background ?? '', separator: palette.separator, label: palette.text.secondary, scrollbar: palette.separator, + query: palette.text.primary, disabled: palette.text.subtle, + }; + const at = (since: [number, number]) => !version || version[0] > since[0] || (version[0] === since[0] && version[1] >= since[1]); + const allowed = (name: string) => { const gate = FZF_GATED.find(([gated]) => gated === name); return !gate || at(gate[1]); }; + const parts = [`bg:${palette.background ? fzfColor(palette.background, level) : '-1'}`]; + for (const [name, hex] of Object.entries(roles)) { + if (name === 'gutter' && !hex) { if (allowed('gutter')) parts.push('gutter:-1'); continue; } + if (HEX.test(hex) && allowed(name)) parts.push(`${name}:${fzfColor(hex, level)}`); + } + return [`--color=${parts.join(',')}`]; +} + +/** + * Precedence for NMSh-owned fzf launches: Theme Bridge colors first, then the + * launching surface's explicit options (fzf applies later `--color` values + * over earlier ones, so explicit options win). FZF_DEFAULT_OPTS is not read + * for NMSh-owned launches at all. + */ +export function withFzfTheme(callerArgs: readonly string[], bridgeArgs: readonly string[]): string[] { + return [...bridgeArgs, ...callerArgs]; +} + +// ---- less / man --------------------------------------------------------------- + +const sgr = (hex: string, extra = '') => `${ESC}[${extra}38;2;${sgrRgb(hex)}m`; +const sgr256 = (hex: string, extra = '') => `${ESC}[${extra}38;5;${rgbTo256(parseHexColor(hex)!)}m`; + +/** + * less's documented termcap overrides (bold, underline, standout, blink), + * which color man pages and less's own prompt/search highlight. GROFF_NO_SGR + * makes GNU groff emit the overstrike formatting those overrides recolor; + * BSD/macOS mandoc already does. LESS itself (the user's options) is never set. + */ +export function pagerEnvironment(palette: SemanticPalette, level: ColorLevel): BridgeEnvironment { + if (level === 'none') return {}; + const color = level === 'truecolor' ? sgr : sgr256; + const reverse = level === 'truecolor' + ? `${ESC}[38;2;${sgrRgb(palette.selectionForeground)};48;2;${sgrRgb(palette.selection)}m` + : `${ESC}[38;5;${rgbTo256(parseHexColor(palette.selectionForeground)!)};48;5;${rgbTo256(parseHexColor(palette.selection)!)}m`; + return { + LESS_TERMCAP_md: color(palette.accent, '1;'), LESS_TERMCAP_mb: color(palette.failure, '1;'), LESS_TERMCAP_me: `${ESC}[0m`, + LESS_TERMCAP_us: color(palette.success, '4;'), LESS_TERMCAP_ue: `${ESC}[0m`, + LESS_TERMCAP_so: reverse, LESS_TERMCAP_se: `${ESC}[0m`, + GROFF_NO_SGR: '1', + }; +} + +// ---- LS_COLORS ------------------------------------------------------------------ + +const lsColor = (hex: string, level: ColorLevel, extra = '') => { + const rgb = parseHexColor(hex)!; + return `${extra}${level === 'truecolor' ? `38;2;${rgb.red};${rgb.green};${rgb.blue}` : `38;5;${rgbTo256(rgb)}`}`; +}; + +/** + * A deliberately small LS_COLORS: file kinds plus a few broad extension + * groups, from semantic roles. Not an extension database; vivid (when + * installed) is the rich path. + */ +export function lsColorsFallback(palette: SemanticPalette, level: ColorLevel): string | undefined { + if (level === 'none') return undefined; + const c = (hex: string, extra = '') => lsColor(hex, level, extra); + const entries: Array<[string, string]> = [ + ['di', c(palette.ansi[4]!, '1;')], ['ln', c(palette.ansi[6]!)], ['so', c(palette.ansi[5]!)], ['pi', c(palette.warning)], + ['ex', c(palette.success, '1;')], ['bd', c(palette.warning, '1;')], ['cd', c(palette.warning)], ['or', c(palette.failure, '1;')], + ['mi', c(palette.failure)], ['su', c(palette.failure, '7;')], ['sg', c(palette.warning, '7;')], ['tw', c(palette.success, '7;')], ['ow', c(palette.ansi[4]!, '7;')], + ]; + const groups: Array<[string[], string]> = [ + [['tar', 'tgz', 'gz', 'zip', 'bz2', 'xz', 'zst', '7z', 'rar'], c(palette.failure)], + [['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'mp4', 'mov', 'mp3', 'flac'], c(palette.ansi[5]!)], + [['md', 'txt', 'rst', 'pdf'], c(palette.text.secondary)], + [['json', 'yaml', 'yml', 'toml', 'ini', 'conf'], c(palette.info)], + [['log', 'tmp', 'bak', 'swp', 'lock'], c(palette.text.subtle)], + ]; + return [...entries.map(([key, value]) => `${key}=${value}`), ...groups.flatMap(([extensions, value]) => extensions.map(extension => `*.${extension}=${value}`))].join(':'); +} + +/** vivid's documented theme file format (core + broad categories), colors as palette entries. */ +export function vividTheme(palette: SemanticPalette): string { + const hex = (value: string) => `"${value.slice(1)}"`; + return `# Generated by NMSh Theme Bridge from ${JSON.stringify(palette.name)}. NMSh replaces this file. +colors: + directory: ${hex(palette.ansi[4]!)} + link: ${hex(palette.ansi[6]!)} + executable: ${hex(palette.success)} + special: ${hex(palette.ansi[5]!)} + warning: ${hex(palette.warning)} + failure: ${hex(palette.failure)} + text: ${hex(palette.text.secondary)} + muted: ${hex(palette.text.subtle)} + info: ${hex(palette.info)} + accent: ${hex(palette.accent)} + surface: ${hex(palette.surface)} +core: + normal_text: {} + regular_file: {} + reset_to_normal: {} + directory: {foreground: directory, font-style: bold} + symlink: {foreground: link} + multi_hard_link: {} + fifo: {foreground: warning} + socket: {foreground: special} + door: {foreground: special} + block_device: {foreground: warning, font-style: bold} + character_device: {foreground: warning} + broken_symlink: {foreground: failure, font-style: bold} + missing_symlink_target: {foreground: failure} + setuid: {foreground: failure} + setgid: {foreground: warning} + file_with_capability: {} + sticky_other_writable: {foreground: executable} + other_writable: {foreground: directory} + sticky: {} + executable_file: {foreground: executable, font-style: bold} +text: + foreground: text +markup: + foreground: text +programming: + foreground: accent +media: + foreground: special +office: + foreground: text +archives: + foreground: failure +executable: + foreground: executable +unimportant: + foreground: muted +`; +} + +/** vivid output accepted as an LS_COLORS value: `key=sgr` entries only. */ +export function validLsColors(value: string): boolean { + return value.length > 0 && value.length <= 64 * 1024 && /^(?:[^=:\s\u0000-\u001f]+=[0-9;]*)(?::[^=:\s\u0000-\u001f]+=[0-9;]*)*:?$/u.test(value.trim()); +} + +// ---- tmux ----------------------------------------------------------------------- + +/** Style options only: colors of tmux's own chrome. No keys, layout, behavior, plugins or commands. */ +export const TMUX_STYLE_OPTIONS = ['status-style', 'status-left-style', 'status-right-style', 'window-status-style', 'window-status-current-style', + 'window-status-last-style', 'window-status-activity-style', 'window-status-bell-style', 'pane-border-style', + 'pane-active-border-style', 'message-style', 'message-command-style', 'mode-style', 'clock-mode-colour', 'display-panes-colour', + 'display-panes-active-colour', 'popup-border-style', 'popup-style', 'menu-style', 'menu-selected-style', 'menu-border-style', + 'copy-mode-match-style', 'copy-mode-current-match-style', 'copy-mode-mark-style'] as const; + +export const TMUX_UNREPRESENTED = 'tmux has no roles for prompt module colors, syntax colors or text tiers beyond fg/bg; those NMSh roles are not applied.'; + +export function tmuxFragment(palette: SemanticPalette): string { + const bar = palette.surface; + const style = (fg: string, bg?: string, attrs = '') => `"fg=${fg}${bg ? `,bg=${bg}` : ''}${attrs ? `,${attrs}` : ''}"`; + const set = (option: typeof TMUX_STYLE_OPTIONS[number], value: string) => `set -gq ${option} ${value}`; + return [ + `# Generated by NMSh Theme Bridge from ${JSON.stringify(palette.name)}. NMSh replaces this file; edit your own tmux.conf instead.`, + set('status-style', style(palette.text.secondary, bar)), + set('status-left-style', style(palette.accent, bar, 'bold')), + set('status-right-style', style(palette.text.secondary, bar)), + set('window-status-style', style(palette.text.subtle, bar)), + set('window-status-current-style', style(palette.selectionForeground, palette.selection, 'bold')), + set('window-status-last-style', style(palette.text.secondary, bar)), + set('window-status-activity-style', style(palette.warning, bar)), + set('window-status-bell-style', style(palette.failure, bar, 'bold')), + set('pane-border-style', style(palette.separator)), + set('pane-active-border-style', style(palette.accent)), + set('message-style', style(palette.text.primary, palette.selection)), + set('message-command-style', style(palette.accent, palette.surface)), + set('mode-style', style(palette.selectionForeground, palette.selection)), + set('clock-mode-colour', `"${palette.accent}"`), + set('display-panes-colour', `"${palette.separator}"`), + set('display-panes-active-colour', `"${palette.accent}"`), + set('popup-border-style', style(palette.accent)), + set('menu-style', style(palette.text.primary, palette.surface)), + set('menu-selected-style', style(palette.selectionForeground, palette.selection)), + set('menu-border-style', style(palette.separator)), + set('copy-mode-match-style', style(palette.selectionForeground, palette.selection)), + set('copy-mode-current-match-style', style(palette.surface, palette.accent)), + set('copy-mode-mark-style', style(palette.surface, palette.warning)), + '', + ].join('\n'); +} + +const TMUX_VALUE = /^"(?:#[0-9a-f]{6}|(?:fg|bg)=#[0-9a-f]{6}(?:,(?:fg|bg)=#[0-9a-f]{6})?(?:,bold)?)"$/u; + +/** Validates a tmux fragment line by line: comments and allowlisted style options with color values only. */ +export function validateTmuxFragment(content: string): boolean { + return content.split('\n').every(line => line === '' || line.startsWith('# ') || (() => { + const match = /^set -gq ([a-z-]+) (.+)$/u.exec(line); + return Boolean(match && (TMUX_STYLE_OPTIONS as readonly string[]).includes(match[1]!) && TMUX_VALUE.test(match[2]!)); + })()); +} + +// ---- Neovim / Vim ----------------------------------------------------------------- + +export const COLORSCHEME_NAME = 'nmsh-bridge'; + +interface Highlight {fg?: string; bg?: string; sp?: string; bold?: true; italic?: true; underline?: true; undercurl?: true; reverse?: true} + +/** Classic highlight groups both Vim and Neovim understand. */ +function classicGroups(p: SemanticPalette): Record { + const s = p.syntax; + const bg = p.background; + return { + Normal: {fg: p.text.primary, ...(bg ? {bg} : {})}, Comment: {fg: s.comment, italic: true}, + Constant: {fg: s.constant}, String: {fg: s.string}, Character: {fg: s.string}, Number: {fg: s.number}, Boolean: {fg: s.number}, Float: {fg: s.number}, + Identifier: {fg: p.text.primary}, Function: {fg: s.function}, + Statement: {fg: s.keyword}, Conditional: {fg: s.keyword}, Repeat: {fg: s.keyword}, Label: {fg: s.keyword}, Operator: {fg: s.operator}, Keyword: {fg: s.keyword}, Exception: {fg: p.failure}, + PreProc: {fg: s.preproc}, Include: {fg: s.preproc}, Define: {fg: s.preproc}, Macro: {fg: s.preproc}, PreCondit: {fg: s.preproc}, + Type: {fg: s.type}, StorageClass: {fg: s.keyword}, Structure: {fg: s.type}, Typedef: {fg: s.type}, + Special: {fg: s.special}, SpecialChar: {fg: s.special}, Tag: {fg: p.accent}, Delimiter: {fg: s.operator}, SpecialComment: {fg: s.comment}, + Underlined: {fg: p.info, underline: true}, Error: {fg: p.failure, bold: true}, Todo: {fg: p.warning, bold: true}, + Visual: {bg: p.selection}, Search: {fg: p.selectionForeground, bg: p.selection, bold: true}, IncSearch: {fg: p.surface, bg: p.accent}, + CursorLine: {bg: p.surface}, CursorColumn: {bg: p.surface}, ColorColumn: {bg: p.surface}, + LineNr: {fg: p.text.subtle}, CursorLineNr: {fg: p.accent, bold: true}, SignColumn: {fg: p.text.subtle}, + Pmenu: {fg: p.text.primary, bg: p.surface}, PmenuSel: {fg: p.selectionForeground, bg: p.selection}, PmenuSbar: {bg: p.surface}, PmenuThumb: {bg: p.separator}, + StatusLine: {fg: p.text.primary, bg: p.selection}, StatusLineNC: {fg: p.text.subtle, bg: p.surface}, + VertSplit: {fg: p.separator}, TabLine: {fg: p.text.subtle, bg: p.surface}, TabLineSel: {fg: p.selectionForeground, bg: p.selection, bold: true}, TabLineFill: {bg: p.surface}, + MatchParen: {fg: p.accent, bold: true, underline: true}, Folded: {fg: p.text.subtle, bg: p.surface}, NonText: {fg: p.text.subtle}, + Title: {fg: p.accent, bold: true}, Directory: {fg: p.ansi[4]!}, ErrorMsg: {fg: p.failure}, WarningMsg: {fg: p.warning}, ModeMsg: {fg: p.text.secondary}, MoreMsg: {fg: p.success}, + Question: {fg: p.success}, WildMenu: {fg: p.selectionForeground, bg: p.selection}, + DiffAdd: {fg: p.success}, DiffChange: {fg: p.warning}, DiffDelete: {fg: p.failure}, DiffText: {fg: p.info, bold: true}, + SpellBad: {sp: p.failure, undercurl: true}, SpellCap: {sp: p.warning, undercurl: true}, + }; +} + +/** Neovim-only groups: floats, separators, diagnostics, and Tree-sitter captures linked onto the classic groups. */ +function neovimGroups(p: SemanticPalette): Record { + const diagnostics: Record = {Error: p.failure, Warn: p.warning, Info: p.info, Hint: p.text.secondary, Ok: p.success}; + const out: Record = { + NormalFloat: {fg: p.text.primary, bg: p.surface}, FloatBorder: {fg: p.separator}, WinSeparator: {fg: p.separator}, + NormalNC: {link: 'Normal'}, CursorLineSign: {link: 'SignColumn'}, + }; + for (const [name, color] of Object.entries(diagnostics)) { + out[`Diagnostic${name}`] = {fg: color}; + out[`DiagnosticUnderline${name}`] = {sp: color, undercurl: true}; + out[`DiagnosticSign${name}`] = {fg: color}; + out[`DiagnosticVirtualText${name}`] = {fg: color}; + } + const links: Record = { + '@comment': 'Comment', '@string': 'String', '@string.escape': 'SpecialChar', '@character': 'Character', '@number': 'Number', '@boolean': 'Boolean', + '@number.float': 'Float', '@constant': 'Constant', '@constant.builtin': 'Constant', '@variable': 'Identifier', '@variable.builtin': 'Special', + '@variable.parameter': 'Identifier', '@property': 'Identifier', '@function': 'Function', '@function.call': 'Function', '@function.builtin': 'Special', + '@function.method': 'Function', '@constructor': 'Type', '@keyword': 'Keyword', '@keyword.function': 'Keyword', '@keyword.return': 'Keyword', + '@keyword.import': 'Include', '@conditional': 'Conditional', '@repeat': 'Repeat', '@exception': 'Exception', '@operator': 'Operator', + '@type': 'Type', '@type.builtin': 'Type', '@module': 'Identifier', '@label': 'Label', '@tag': 'Tag', '@tag.attribute': 'Identifier', + '@punctuation': 'Delimiter', '@punctuation.bracket': 'Delimiter', '@punctuation.delimiter': 'Delimiter', '@markup.heading': 'Title', + '@markup.link': 'Underlined', '@diff.plus': 'DiffAdd', '@diff.minus': 'DiffDelete', '@diff.delta': 'DiffChange', + }; + for (const [capture, group] of Object.entries(links)) out[capture] = {link: group}; + return out; +} + +const luaString = (value: string) => `'${value}'`; + +export function neovimColorscheme(palette: SemanticPalette): string { + const attributes = (highlight: Highlight | {link: string}) => { + if ('link' in highlight) return `{link = ${luaString(highlight.link)}}`; + const parts: string[] = []; + for (const key of ['fg', 'bg', 'sp'] as const) if (highlight[key]) parts.push(`${key} = ${luaString(highlight[key]!)}`); + if (highlight.fg) parts.push(`ctermfg = ${rgbTo256(parseHexColor(highlight.fg)!)}`); + if (highlight.bg) parts.push(`ctermbg = ${rgbTo256(parseHexColor(highlight.bg)!)}`); + for (const key of ['bold', 'italic', 'underline', 'undercurl', 'reverse'] as const) if (highlight[key]) parts.push(`${key} = true`); + return `{${parts.join(', ')}}`; + }; + const groups = {...classicGroups(palette), ...neovimGroups(palette)} as Record; + delete groups.VertSplit; + groups.VertSplit = {link: 'WinSeparator'}; + return [ + `-- Generated by NMSh Theme Bridge from ${JSON.stringify(palette.name)}. NMSh replaces this file; it contains highlight data only.`, + `vim.cmd('highlight clear')`, + `if vim.fn.exists('syntax_on') == 1 then vim.cmd('syntax reset') end`, + `vim.o.background = ${luaString(palette.dark ? 'dark' : 'light')}`, + `vim.g.colors_name = ${luaString(COLORSCHEME_NAME)}`, + `local set = vim.api.nvim_set_hl`, + ...Object.entries(groups).map(([name, highlight]) => `set(0, ${luaString(name)}, ${attributes(highlight)})`), + '', + ].join('\n'); +} + +export function vimColorscheme(palette: SemanticPalette): string { + const line = (name: string, highlight: Highlight) => { + const attrs = (['bold', 'italic', 'underline', 'undercurl', 'reverse'] as const).filter(key => highlight[key]); + const cterm = attrs.filter(key => key !== 'undercurl' && key !== 'italic'); + const parts = [`hi ${name}`, + `guifg=${highlight.fg ?? 'NONE'}`, `guibg=${highlight.bg ?? 'NONE'}`, ...(highlight.sp ? [`guisp=${highlight.sp}`] : []), + `ctermfg=${highlight.fg ? rgbTo256(parseHexColor(highlight.fg)!) : 'NONE'}`, `ctermbg=${highlight.bg ? rgbTo256(parseHexColor(highlight.bg)!) : 'NONE'}`, + `gui=${attrs.length ? attrs.join(',') : 'NONE'}`, `cterm=${cterm.length ? cterm.join(',') : 'NONE'}`]; + return parts.join(' '); + }; + return [ + `" Generated by NMSh Theme Bridge from ${JSON.stringify(palette.name).replace(/"/gu, "'")}. NMSh replaces this file; it contains highlight data only.`, + `set background=${palette.dark ? 'dark' : 'light'}`, + 'hi clear', + "if exists('syntax_on') | syntax reset | endif", + `let g:colors_name = '${COLORSCHEME_NAME}'`, + ...Object.entries(classicGroups(palette)).map(([name, highlight]) => line(name, highlight)), + '', + ].join('\n'); +} + +export function validateNeovimColorscheme(content: string): boolean { + const fixed = new Set([`vim.cmd('highlight clear')`, `if vim.fn.exists('syntax_on') == 1 then vim.cmd('syntax reset') end`, "vim.o.background = 'dark'", + "vim.o.background = 'light'", `vim.g.colors_name = '${COLORSCHEME_NAME}'`, 'local set = vim.api.nvim_set_hl', '']); + const value = "(?:(?:fg|bg|sp) = '#[0-9a-f]{6}'|cterm(?:fg|bg) = \\d{1,3}|(?:bold|italic|underline|undercurl|reverse) = true)"; + const set = new RegExp(`^set\\(0, '[@A-Za-z][A-Za-z0-9_.]*', \\{(?:link = '[A-Z][A-Za-z]*'|${value}(?:, ${value})*)\\}\\)$`, 'u'); + return content.split('\n').every((line, index) => (index === 0 && line.startsWith('-- Generated by NMSh Theme Bridge')) || fixed.has(line) || set.test(line)); +} + +export function validateVimColorscheme(content: string): boolean { + const fixed = new Set(['set background=dark', 'set background=light', 'hi clear', "if exists('syntax_on') | syntax reset | endif", `let g:colors_name = '${COLORSCHEME_NAME}'`, '']); + const hi = /^hi [A-Z][A-Za-z]* guifg=(?:#[0-9a-f]{6}|NONE) guibg=(?:#[0-9a-f]{6}|NONE)(?: guisp=#[0-9a-f]{6})? ctermfg=(?:\d{1,3}|NONE) ctermbg=(?:\d{1,3}|NONE) gui=(?:NONE|[a-z,]+) cterm=(?:NONE|[a-z,]+)$/u; + return content.split('\n').every((line, index) => (index === 0 && line.startsWith('" Generated by NMSh Theme Bridge')) || fixed.has(line) || hi.test(line)); +} diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 1d699d87..5026df34 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -3,6 +3,7 @@ import {shellAdapter} from '../shell/adapters/registry.js'; import {OPEN_WITH_IDS} from '../host/HostActions.js'; import {TREATMENT_PRESETS, TREATMENT_PRESET_LABELS, TREATMENT_GEOMETRIES, TREATMENT_GEOMETRY_LABELS, TREATMENT_MOTIONS, TREATMENT_MOTION_LABELS, TREATMENT_SPEEDS, TREATMENT_SPEED_LABELS, TREATMENT_INFLUENCES, treatmentInfluence, SEMANTIC_MODES, SEMANTIC_MODE_LABELS, TREATMENT_SCOPES, TREATMENT_SCOPE_LABELS, TREATMENT_CURVES, TREATMENT_CURVE_LABELS, DIVIDER_LINES_HELP, dividerLinesLabel, PRESET_STOPS} from '../chroma/treatment.js'; +import {historicalPromptLevel} from '../output/TranscriptPanel.js'; import {PANEL_POSITIONS, DIVIDER_COLOR_LABELS, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, CURSOR_BLINKS, CURSOR_SHAPES, IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS, LIVE_ACTIVITY_COLORS, LIVE_ACTIVITY_COLOR_LABELS, RAM_DISPLAYS, LOCAL_UNDERSTANDING_LABELS, LOCAL_UNDERSTANDING_MODES, SHELL_MODULE_VISIBILITY, SHELL_MODULE_VISIBILITY_LABELS, applyShellModuleVisibility, shellModuleVisibility, type StatusStripSettings} from '../prompt/configuration.js'; import {IDLE_MODES, IDLE_MODE_LABELS} from '../idle/scenes.js'; import {MOTION_LABELS, MOTION_RENDERING_ITEM, MOTION_ROWS, MOTION_TUNING_ITEMS, type MotionItem} from '../motion/motionRows.js'; @@ -13,7 +14,9 @@ import {availabilityOf, availableValues, currentCursorHost, unavailableReason, t import {describeCursorColor, contextFor} from '../cursor/colors.js'; import {CURSOR_EFFECTS, CURSOR_IDLE_EFFECTS, CURSOR_LEVELS, CURSOR_MOTIONS, CURSOR_RENDERERS, type CursorSettings} from '../prompt/configuration.js'; import {CHROME_PRESET_LABELS, CHROME_PRESETS, CHROME_SOURCES, chromeColorsFrom, LAVENDER_TINT_LABELS, LAVENDER_TINTS, resolveChrome} from '../appearance/uiChrome.js'; -import {defaultVariant, FAMILY_IDS, FAMILY_LABELS, familyOf, selectFamily, variantOptions} from '../appearance/themeSelection.js'; +import {currentSelectionFamily, defaultVariant, FAMILY_IDS, FAMILY_LABELS, familyOf, selectionFamilies, selectionFamilyLabel, selectionVariants, selectSelectionFamily, variantOptions} from '../appearance/themeSelection.js'; +import {librarySummary} from '../appearance/themeLibrary.js'; +import {BRIDGE_TARGETS, effectiveMode} from '../themeBridge/model.js'; import {PROMPT_SYMBOL_IDS, promptSymbolLabel} from '../prompt/glyphChoices.js'; import {VIBRANCE_LABELS, VIBRANCE_LEVELS} from '../chroma/color.js'; import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js'; @@ -84,7 +87,7 @@ export function switchSettingsView(state: SettingsPanelState, delta: -1 | 1): vo /** Where Enter leads: `glyph` is the rich glyph preview inside the panel, the rest are full panels. */ export type SettingsDestination = 'glyph' | 'appearance' | 'prompt' | 'transcript' | 'syntax' | 'layout' | 'keyboard' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'toolConfig' | 'tools' - | 'setup' | 'resetInstallSuggestions' | 'screensaver' | 'chromeColors' | 'cursor' | 'idleColors' | 'activityColors' | 'themeStudio'; + | 'setup' | 'resetInstallSuggestions' | 'screensaver' | 'chromeColors' | 'cursor' | 'idleColors' | 'activityColors' | 'themeStudio' | 'themeBridge'; interface SettingsRowBase { id: string; @@ -184,17 +187,23 @@ const THEME_ROWS: readonly SettingsRow[] = [ }}, {id: 'uiChromeColors', parent: 'uiChromePreset', when: c => c.uiChrome.source === 'custom' && c.uiChrome.preset === 'custom', label: 'Edit colors', description: 'Accent, text, separator, selection and status roles with the color picker', category: 'Appearance', control: 'action', actionLabel: 'Edit ›', destination: 'chromeColors'}, - {id: 'themeFamily', label: 'Theme family', description: 'NMSh themes, bundled families or your Custom theme; colors NMSh-owned UI only', category: 'Appearance', - control: 'enum', options: FAMILY_LABELS, index: c => FAMILY_IDS.indexOf(familyOf(c.nmsh.palette)), - select: (c, index) => familyOf(c.nmsh.palette) === FAMILY_IDS[index] ? c : selectFamily(c, FAMILY_IDS[index]!)}, - {id: 'themeVariant', parent: 'themeFamily', when: c => variantOptions(familyOf(c.nmsh.palette)).length > 1, label: 'Variant', - description: 'Flavor, style or variant within the theme family', category: 'Appearance', control: 'enum', - options: [], optionsFor: c => variantOptions(familyOf(c.nmsh.palette)).map(option => option.label), - index: c => Math.max(0, variantOptions(familyOf(c.nmsh.palette)).findIndex(option => option.id === c.nmsh.palette)), + {id: 'themeFamily', label: 'Theme', description: 'Built-in NMSh themes and families, or your Imported and Custom themes (manage them in /theme); colors NMSh-owned UI only', category: 'Appearance', + control: 'enum', options: FAMILY_LABELS, optionsFor: c => selectionFamilies(c).map(selectionFamilyLabel), + index: c => Math.max(0, selectionFamilies(c).indexOf(currentSelectionFamily(c))), + select: (c, index) => { const families = selectionFamilies(c); return selectSelectionFamily(c, families[((index % families.length) + families.length) % families.length]!); }}, + {id: 'themeVariant', parent: 'themeFamily', when: c => selectionVariants(c, currentSelectionFamily(c)).length > 1, label: 'Variant', + description: 'Flavor, style or variant within the family, or which Imported/Custom theme', category: 'Appearance', control: 'enum', + options: [], optionsFor: c => selectionVariants(c, currentSelectionFamily(c)).map(option => option.label), + index: c => Math.max(0, selectionVariants(c, currentSelectionFamily(c)).findIndex(option => option.current(c))), select: (c, index) => { - const options = variantOptions(familyOf(c.nmsh.palette)); - return {...c, nmsh: {...c.nmsh, palette: options[((index % options.length) + options.length) % options.length]!.id}}; + const options = selectionVariants(c, currentSelectionFamily(c)); + return options[((index % options.length) + options.length) % options.length]!.apply(c); }}, + {id: 'themeStudio', parent: 'themeFamily', label: 'Theme Studio', description: 'Create, edit, import, export and manage Native themes', category: 'Appearance', + control: 'action', actionLabel: 'Open ›', value: c => librarySummary(c.themes) ?? "", destination: 'themeStudio'}, + {id: 'themeBridge', label: 'Theme Bridge', description: 'Extend NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim and Vim; every tool starts Independent', category: 'Appearance', + control: 'action', actionLabel: "Open ›", value: c => { const active = BRIDGE_TARGETS.filter(target => effectiveMode(c.themeBridge, target) !== 'independent').length; + return active ? `${active} tool${active === 1 ? '' : 's'}` : 'Off'; }, destination: 'themeBridge'}, enumRow({id: 'pastePreview', label: 'Paste preview', description: 'Smart: multiline, chained, mutating or risky pastes are shown before they enter the composer (never changed; nothing runs until Enter). Always: every paste. Off: insert at once', category: 'Editor', values: ['smart', 'always', 'off'] as const, labels: ['Smart', 'Always', 'Off'], get: c => c.pastePreview, set: (c, pastePreview) => ({...c, pastePreview})}), @@ -323,9 +332,10 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'dividerColors', level: 'advanced', parent: 'divider', when: config => config.transcript.divider, label: 'Divider colors', description: 'Past-command dividers: Follow Chroma (static in history), the History colors, the UI theme, or a muted grayscale', category: 'Transcript', values: DIVIDER_COLOR_MODES, labels: DIVIDER_COLOR_MODES.map(mode => DIVIDER_COLOR_LABELS[mode]), get: config => config.transcript.dividerColors, set: (config, dividerColors) => withTranscript(config, {dividerColors})}), - {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: 'historicalPrompt', label: 'Prompt snapshots', description: 'How past commands show the prompt they ran under: Full, Compact (place, branch, marker), Minimal (marker) or Off. Stored snapshots stay complete', category: 'Transcript', + values: ['full', 'compact', 'minimal', 'off'] as const, labels: ['Full', 'Compact', 'Minimal', 'Off'], + get: config => historicalPromptLevel(config.transcript), + set: (config, level) => withTranscript(config, level === 'off' ? {historicalPrompt: false} : {historicalPrompt: true, historicalPromptLevel: level})}), enumRow({id: 'historyColors', level: 'advanced', parent: 'historicalPrompt', when: config => config.transcript.historicalPrompt, label: 'History colors', description: 'How past prompt snapshots are colored', category: 'Transcript', values: COLOR_MODES, labels: ['Follow prompt', 'Choose theme', 'Grayscale'], get: config => config.transcript.historyColors, set: (config, historyColors) => withTranscript(config, {historyColors})}), diff --git a/tests/appearanceHub.test.ts b/tests/appearanceHub.test.ts index ba13a799..bcb4f19f 100644 --- a/tests/appearanceHub.test.ts +++ b/tests/appearanceHub.test.ts @@ -10,7 +10,9 @@ const text = (rows: string[]) => stripAnsi(rows.join('\n')); test('/appearance always offers the NMSh rows, with or without host integration', () => { const zed = createAppearanceHub('Zed', undefined, 'Opacity and blur are controlled by Zed.'); const rendered = text(renderAppearanceHub(zed, config(), 100, 'Lavender Native', 'Portable')); - for (const label of ['Prompt & theme', 'Cursor & effects', 'UI chrome', 'Chroma', 'Motion']) assert.match(rendered, new RegExp(label, 'u')); + for (const label of ['Theme Studio', 'Prompt', 'Cursor & effects', 'UI chrome', 'Chroma', 'Motion', 'Theme Bridge']) assert.match(rendered, new RegExp(label, 'u')); + assert.doesNotMatch(rendered, /Prompt & theme/u, 'themes are not presented as prompt-only'); + assert.match(rendered, /Theme Bridge\s+Off · every tool Independent/u); assert.match(rendered, /Host\s+Zed/u); assert.match(rendered, /controlled by Zed/u); assert.doesNotMatch(rendered, /Opacity\s+█/u); @@ -20,7 +22,7 @@ test('host with appearance integration keeps opacity/blur editing; Enter saves o const hub = createAppearanceHub('Ghostty-like host', {opacity: 0.9, blurModeIndex: 0, blurStrength: 20, selectedIndex: 0}); const rendered = text(renderAppearanceHub(hub, config(), 100, 'Lavender Native', 'Portable')); assert.match(rendered, /Opacity\s+█+░*\s+90%/u); - hub.selected = 5; // first host row + hub.selected = 7; // first host row assert.equal(appearanceHubKey(hub, {kind: 'enter'}, config()), undefined, 'nothing to save yet'); appearanceHubKey(hub, {kind: 'right'}, config()); assert.equal(hub.host!.opacity, 0.95); @@ -29,12 +31,16 @@ test('host with appearance integration keeps opacity/blur editing; Enter saves o test('NMSh rows open the canonical editors; Motion opens the general motion screen', () => { const hub = createAppearanceHub('Zed'); - assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'prompt'}); + assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'theme'}); hub.selected = 1; + assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'prompt'}); + hub.selected = 2; assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'cursor'}); - hub.selected = 3; - assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'chroma'}); hub.selected = 4; + assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'chroma'}); + hub.selected = 6; + assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'themeBridge'}); + hub.selected = 5; assert.equal(appearanceHubKey(hub, {kind: 'enter'}, config()), undefined); assert.equal(hub.view, 'motion'); const motion = text(renderAppearanceHub(hub, config(), 100, 'Lavender Native', 'Portable')); @@ -47,4 +53,5 @@ test('NMSh rows open the canonical editors; Motion opens the general motion scre assert.equal(change?.kind === 'motion' && change.motion.contextTransitions, 'expressive'); appearanceHubKey(hub, {kind: 'escape'}, config()); assert.equal(hub.view, 'hub'); + assert.equal(hub.selected, 5, 'back on the Motion row'); }); diff --git a/tests/chromaPolish.test.ts b/tests/chromaPolish.test.ts index 5ad0555c..c76ffbf3 100644 --- a/tests/chromaPolish.test.ts +++ b/tests/chromaPolish.test.ts @@ -12,7 +12,7 @@ import {UI_COLORS, foreground} from '../src/ui/palette.js'; import {applyUiTheme, defaultUiColors, uiColorsFor} from '../src/appearance/uiTheme.js'; import {chromeFromColors, LAVENDER_ACCENT, LAVENDER_SURFACE, LAVENDER_TEXT, lavenderChrome, nativeThemeChrome, normalizeUiChrome, resolveChrome} from '../src/appearance/uiChrome.js'; import {createChromeEditor, chromeEditorKey} from '../src/appearance/ChromeEditor.js'; -import {createThemeStudio, draftDiffersFromBase, STUDIO_ROWS, studioKey} from '../src/appearance/ThemeStudio.js'; +import {createThemeEditor, draftDiffersFromBase, editorKey, STUDIO_ROWS} from '../src/appearance/ThemeStudio.js'; import {cloneFromPalette} from '../src/appearance/themeSelection.js'; import {parseSlashCommand} from '../src/commands/slashCommands.js'; import {createSetup, renderSetup, sectionIndex, SETUP_SECTIONS, setupKey} from '../src/setup/SetupCat.js'; @@ -173,39 +173,39 @@ test('Travel loops seamlessly; motion choices lead with Breathe; Breathe, Comet test('Theme Studio: Reset to base resets the draft only; cancel after reset and save after reset', () => { const saved = cloneFromPalette('nord', 'mauve', 'Mine'); saved.prompt.project = '#123456'; - const state = createThemeStudio(saved, 'custom'); + const state = createThemeEditor(saved, 'custom'); assert.equal(state.base, 'nord', 'the recorded Based on theme is the reset target'); state.selected = STUDIO_ROWS.findIndex(item => item.kind === 'reset'); - studioKey(state, {kind: 'enter'}, 'truecolor', '/'); + editorKey(state, {kind: 'enter'}, 'truecolor'); assert.equal(state.confirmReset, true, 'edits relative to base are confirmed first'); - studioKey(state, {kind: 'escape'}, 'truecolor', '/'); + editorKey(state, {kind: 'escape'}, 'truecolor'); assert.equal(state.draft.prompt.project, '#123456', 'Esc keeps editing, nothing reset'); - studioKey(state, {kind: 'enter'}, 'truecolor', '/'); - studioKey(state, {kind: 'enter'}, 'truecolor', '/'); + editorKey(state, {kind: 'enter'}, 'truecolor'); + editorKey(state, {kind: 'enter'}, 'truecolor'); assert.equal(state.draft.prompt.project, '#88c0d0'); assert.equal(state.draft.name, 'Mine', 'the draft keeps its name'); assert.equal(saved.prompt.project, '#123456', 'the saved theme is untouched by a reset'); - assert.deepEqual(studioKey(state, {kind: 'escape'}, 'truecolor', '/'), {kind: 'cancel'}, 'Esc after reset abandons the draft'); + assert.deepEqual(editorKey(state, {kind: 'escape'}, 'truecolor'), {kind: 'cancel'}, 'Esc after reset abandons the draft'); // Save after reset returns the reset colors. - const again = createThemeStudio(saved, 'custom'); + const again = createThemeEditor(saved, 'custom'); again.selected = STUDIO_ROWS.findIndex(item => item.kind === 'reset'); - studioKey(again, {kind: 'enter'}, 'truecolor', '/'); studioKey(again, {kind: 'enter'}, 'truecolor', '/'); + editorKey(again, {kind: 'enter'}, 'truecolor'); editorKey(again, {kind: 'enter'}, 'truecolor'); again.selected = STUDIO_ROWS.findIndex(item => item.kind === 'save'); - const result = studioKey(again, {kind: 'enter'}, 'truecolor', '/'); + const result = editorKey(again, {kind: 'enter'}, 'truecolor'); assert.equal(result?.kind === 'save' && result.theme.prompt.project, '#88c0d0'); // Changing Based on, then resetting, uses the new base; a clean draft resets without asking. - const based = createThemeStudio(undefined, 'nord'); + const based = createThemeEditor(undefined, 'nord'); based.selected = STUDIO_ROWS.findIndex(item => item.kind === 'basedOn'); - studioKey(based, {kind: 'right'}, 'truecolor', '/'); + editorKey(based, {kind: 'right'}, 'truecolor'); const target = based.base; based.selected = STUDIO_ROWS.findIndex(item => item.kind === 'reset'); - studioKey(based, {kind: 'enter'}, 'truecolor', '/'); - studioKey(based, {kind: 'enter'}, 'truecolor', '/'); + editorKey(based, {kind: 'enter'}, 'truecolor'); + editorKey(based, {kind: 'enter'}, 'truecolor'); assert.equal(draftDiffersFromBase(based), false, `draft now equals ${target}`); // Per-role reset with R. - const role = createThemeStudio(saved, 'custom'); + const role = createThemeEditor(saved, 'custom'); role.selected = STUDIO_ROWS.findIndex(item => item.kind === 'role' && item.role === 'project'); - studioKey(role, {kind: 'text', value: 'r'}, 'truecolor', '/'); + editorKey(role, {kind: 'text', value: 'r'}, 'truecolor'); assert.equal(role.draft.prompt.project, '#88c0d0'); assert.equal(role.draft.prompt.cwd, saved.prompt.cwd, 'other roles untouched'); }); diff --git a/tests/motionPreview.test.ts b/tests/motionPreview.test.ts index f9f9cb5a..9eaee45c 100644 --- a/tests/motionPreview.test.ts +++ b/tests/motionPreview.test.ts @@ -83,7 +83,7 @@ test('narrow widths degrade to the caption at the same height, never past the wi test('the Motion screen shows the preview for the selected row; selecting, changing and R restart it', () => { const hub = createAppearanceHub('Zed'); - hub.selected = 4; + hub.selected = 5; // Motion appearanceHubKey(hub, {kind: 'enter'}, DEFAULT_PROMPT_CONFIGURATION, 100); assert.equal(hub.previewStart, 100); appearanceHubKey(hub, {kind: 'down'}, DEFAULT_PROMPT_CONFIGURATION, 120); // Rendering → Context transitions diff --git a/tests/powerlevel10k.test.ts b/tests/powerlevel10k.test.ts index 5134ab60..c41711d0 100644 --- a/tests/powerlevel10k.test.ts +++ b/tests/powerlevel10k.test.ts @@ -113,15 +113,16 @@ test('provider config round-trips and switching keeps inactive provider settings assert.equal(describePromptConfiguration(config), 'Powerlevel10k · two-line divider'); }); -test('/prompt offers three providers and an honest Powerlevel10k step', () => { +test('/prompt offers four providers (None is composer only) and an honest Powerlevel10k step', () => { const state: PromptPanelState = {onboarding: false, step: 'provider', selectedIndex: 0, draft: structuredClone(DEFAULT_PROMPT_CONFIGURATION), saved: structuredClone(DEFAULT_PROMPT_CONFIGURATION)}; - assert.deepEqual(PROVIDER_ORDER, ['nmsh', 'starship', 'powerlevel10k']); + assert.deepEqual(PROVIDER_ORDER, ['nmsh', 'starship', 'powerlevel10k', 'none']); let rows = renderPromptPanel(state, 140, []).map(stripAnsi); assert.ok(rows.some(row => row.includes('NMSh Native · built-in themes, geometry, and modules ● ✓ saved'))); assert.ok(rows.some(row => row.includes('Powerlevel10k · use your ~/.p10k.zsh left prompt'))); + assert.ok(rows.some(row => row.includes('None · composer only: no prompt row, modules or marker'))); handlePromptPanelKey({kind: 'up'} as Key, state); - assert.equal(state.selectedIndex, 2); + assert.equal(state.selectedIndex, 3, 'wraps to None, the last provider'); const installed = {...state, step: 'powerlevel10k' as const, selectedIndex: 0, p10kStatus: {installed: true, themePath: '/t/powerlevel10k.zsh-theme', configPath: '/h/.p10k.zsh', configExists: true}}; diff --git a/tests/providers.test.ts b/tests/providers.test.ts index c4557352..d3f37bed 100644 --- a/tests/providers.test.ts +++ b/tests/providers.test.ts @@ -91,7 +91,7 @@ test('shared panel shows status badges, preview, and asks before installing', () }); test('prompt providers run on the shared descriptor with unchanged labels and rows', () => { - assert.deepEqual(PROMPT_PROVIDERS.map(provider => providerLabel(provider.id)), ['NMSh Native', 'Starship', 'Powerlevel10k']); + assert.deepEqual(PROMPT_PROVIDERS.map(provider => providerLabel(provider.id)), ['NMSh Native', 'Starship', 'Powerlevel10k', 'None']); assert.equal(providerRowText(PROMPT_PROVIDERS[1]!, {draft: 'starship', saved: 'nmsh', status: 'none'}), 'Starship · use its themes/configuration ●'); }); diff --git a/tests/setupCat.test.ts b/tests/setupCat.test.ts index 0675247f..e3cab77c 100644 --- a/tests/setupCat.test.ts +++ b/tests/setupCat.test.ts @@ -48,7 +48,7 @@ test('rerun after months of use shows the current choices as selected, not defau state.section = sectionIndex('terminal'); assert.match(plain(renderSetup(state, 100, 30)), /Glyph style\s+.*Safe \/ ASCII/u); state.section = sectionIndex('appearance'); - assert.match(plain(renderSetup(state, 100, 30)), /Theme family\s+.*NMSh/u); + assert.match(plain(renderSetup(state, 100, 30)), /Theme\s+.*NMSh/u); assert.match(plain(renderSetup(state, 100, 30)), / Variant\s+Ocean/u, 'the variant nests under its family'); assert.match(plain(renderSetup(state, 100, 30)), /Chroma\s+Aurora/u); assert.deepEqual(setupChanges(state), []); diff --git a/tests/themeFamilies.test.ts b/tests/themeFamilies.test.ts index 54201b31..270e9ad8 100644 --- a/tests/themeFamilies.test.ts +++ b/tests/themeFamilies.test.ts @@ -12,7 +12,7 @@ import { } from '../src/appearance/customTheme.js'; import {cloneFromPalette, familyOf, selectFamily, variantOptions} from '../src/appearance/themeSelection.js'; import {applyUiTheme, defaultUiColors, uiColorsFor, uiThemeInput} from '../src/appearance/uiTheme.js'; -import {createThemeStudio, readThemeImport, renderThemeStudio, studioKey, STUDIO_ROWS, themeDefaults, writeThemeExport} from '../src/appearance/ThemeStudio.js'; +import {createThemeEditor, createThemeStudio, editorKey, readThemeImport, renderThemeStudio, studioKey, STUDIO_ROWS, themeDefaults, writeThemeExport} from '../src/appearance/ThemeStudio.js'; import { DEFAULT_PROMPT_CONFIGURATION, NATIVE_PALETTE_IDS, normalizePromptConfiguration, THEME_PALETTE_IDS, THIRD_PARTY_PALETTE_IDS, } from '../src/prompt/configuration.js'; @@ -194,36 +194,37 @@ test('custom themes compose with Chroma and render through the real prompt rende test('Theme Studio: edits through the picker, imports with preview, exports NMSh Theme JSON', async () => { const directory = await mkdtemp(join(tmpdir(), 'nmsh-studio-')); try { - const state = createThemeStudio(undefined, 'nord'); - assert.equal(state.draft.prompt.project, '#88c0d0'); - const role = STUDIO_ROWS.findIndex(row => row.kind === 'role' && row.role === 'project'); - state.selected = role; - studioKey(state, {kind: 'enter'}, 'truecolor', directory); - assert.ok(state.picker); - studioKey(state, {kind: 'text', value: '#'}, 'truecolor', directory); - for (const character of 'ff8800') studioKey(state, {kind: 'text', value: character}, 'truecolor', directory); - studioKey(state, {kind: 'enter'}, 'truecolor', directory); - assert.equal(state.picker, undefined); - assert.equal(state.draft.prompt.project, '#ff8800'); - const exported = writeThemeExport(state.draft, join(directory, 'themes')); + const editor = createThemeEditor(undefined, 'nord'); + assert.equal(editor.draft.prompt.project, '#88c0d0'); + editor.selected = STUDIO_ROWS.findIndex(row => row.kind === 'role' && row.role === 'project'); + editorKey(editor, {kind: 'enter'}, 'truecolor'); + assert.ok(editor.picker); + editorKey(editor, {kind: 'text', value: '#'}, 'truecolor'); + for (const character of 'ff8800') editorKey(editor, {kind: 'text', value: character}, 'truecolor'); + editorKey(editor, {kind: 'enter'}, 'truecolor'); + assert.equal(editor.picker, undefined); + assert.equal(editor.draft.prompt.project, '#ff8800'); + const exported = writeThemeExport(editor.draft, join(directory, 'themes')); assert.match(exported, /my-nord\.nmsh-theme\.json$/u); const preview = readThemeImport(exported, directory); assert.ok(!('errors' in preview) && preview.theme.prompt.project === '#ff8800'); await writeFile(join(directory, 'bad.json'), '{"schema":"nmsh-theme","version":1,"name":"x","prompt":{},"ui":{}}'); const bad = readThemeImport('bad.json', directory); assert.ok('errors' in bad && bad.errors.some(error => /prompt\.project/u.test(error))); - state.selected = STUDIO_ROWS.findIndex(row => row.kind === 'import'); - studioKey(state, {kind: 'enter'}, 'truecolor', directory); - for (const character of exported) studioKey(state, {kind: 'text', value: character}, 'truecolor', directory); - studioKey(state, {kind: 'enter'}, 'truecolor', directory); - assert.ok(state.importPreview, 'import shows a preview before use'); - assert.match(plain(renderThemeStudio(state, 90, 30, 'truecolor', [])), /Import preview · My Nord/u); - studioKey(state, {kind: 'enter'}, 'truecolor', directory); - state.selected = STUDIO_ROWS.findIndex(row => row.kind === 'save'); - const saved = studioKey(state, {kind: 'enter'}, 'truecolor', directory); - assert.equal(saved?.kind, 'save'); + // The Import tab parses and previews first; Enter returns the save action, Esc stores nothing. + const context = {themes: [], accent: 'mauve' as const, pinnedTo: () => []}; + const studio = createThemeStudio(context, 'import'); + for (const character of exported) studioKey(studio, {kind: 'text', value: character}, 'truecolor', directory, context); + studioKey(studio, {kind: 'enter'}, 'truecolor', directory, context); + assert.ok(studio.importPreview, 'import shows a preview before use'); + assert.match(plain(renderThemeStudio(studio, context, 90, 30, 'truecolor', [])), /Import preview · My Nord/u); + const action = studioKey(studio, {kind: 'enter'}, 'truecolor', directory, context); + assert.equal(action?.kind, 'importTheme'); + assert.equal(action?.kind === 'importTheme' && action.origin.kind, 'nmsh'); + editor.selected = STUDIO_ROWS.findIndex(row => row.kind === 'save'); + assert.equal(editorKey(editor, {kind: 'enter'}, 'truecolor')?.kind, 'save'); assert.equal(JSON.parse(await readFile(exported, 'utf8')).schema, 'nmsh-theme'); - for (const width of [56, 90]) assert.ok(renderThemeStudio(createThemeStudio(undefined, 'nord'), width, 20, 'truecolor', []).every(row => displayWidth(row) <= width)); + for (const width of [56, 90]) assert.ok(renderThemeStudio(createThemeStudio(context), context, width, 20, 'truecolor', []).every(row => displayWidth(row) <= width)); } finally { await rm(directory, {recursive: true, force: true}); } }); From f87e25adce6e15db084af4d1a2d40ecf70509777 Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:14:39 +0530 Subject: [PATCH 02/16] Theme library, importer, Theme Bridge, Prompt None and host semantics tests; docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tests: library migration/actions/export/deletion, every importer with malformed, oversized, unsafe and lossy fixtures, Theme Bridge modes, mappers, environment sink in real zsh/Bash/Fish, ownership ledger, exact includes, tmux/Vim artifacts loaded by real tmux/Vim, Prompt None layouts and history, historical prompt levels, OSC 7/133/8, Theme Studio and /theme-bridge UI, Setup rows, and live end-to-end NMSh sessions. - Fixes found by them: Prompt None framed as the one-line composer in the two-line layout, include planning on a symlinked home, Setup/Settings action rows keep "Open ›". - Docs: help, README, CHANGELOG and docs/design/theme-bridge.md. --- CHANGELOG.md | 10 + README.md | 7 +- docs/design/theme-bridge.md | 109 ++++++++++ docs/design/theme-families.md | 5 + scripts/test-selection.mjs | 3 +- src/app/TerminalApp.ts | 3 +- src/help/helpContent.ts | 10 +- src/setup/SetupCat.ts | 6 +- src/themeBridge/artifacts.ts | 11 +- src/ui/SettingsPanel.ts | 4 +- tests/hostSemantics.test.ts | 124 +++++++++++ tests/promptNone.test.ts | 114 +++++++++++ tests/themeBridge.test.ts | 375 ++++++++++++++++++++++++++++++++++ tests/themeBridgeLive.test.ts | 41 ++++ tests/themeImporters.test.ts | 163 +++++++++++++++ tests/themeLibrary.test.ts | 171 ++++++++++++++++ tests/themeUi.test.ts | 190 +++++++++++++++++ 17 files changed, 1334 insertions(+), 12 deletions(-) create mode 100644 docs/design/theme-bridge.md create mode 100644 tests/hostSemantics.test.ts create mode 100644 tests/promptNone.test.ts create mode 100644 tests/themeBridge.test.ts create mode 100644 tests/themeBridgeLive.test.ts create mode 100644 tests/themeImporters.test.ts create mode 100644 tests/themeLibrary.test.ts create mode 100644 tests/themeUi.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 237f6d01..cd9b6cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Themes, Theme Bridge and host cooperation +- **Prompt provider None**: composer only (no prompt row, modules, right prompt or marker) while editing, suggestions, syntax colors, history, themes and Theme Bridge keep working; commands submitted under None store no prompt snapshot. +- **Historical prompt** Full / Compact / Minimal / Off (`/transcript`, Settings, Setup); presentation only over the unchanged stored snapshot. +- **Native theme library**: any number of Custom and Imported themes with stable ids (bounded to 64); the single custom theme migrates into it and stays active. Imported is provenance only: imported themes are ordinary Native themes you can edit, rename, duplicate, export, select and pin. +- **Theme Studio** (`/theme`): Built-in · Imported · Custom · Import tabs, one editor and the real Native preview for every theme. Settings → Theme and `/setup appearance` select Built-in, Imported and Custom themes directly. +- **Imports**: NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON, YAML, TOML; static colors only), Kitty, Ghostty (allowlist), iTerm2 `.itermcolors` (no XML entities) and WezTerm TOML (Lua refused). Data only, previewed with mapping and loss disclosure before saving; exports never include local source paths. +- **Theme Bridge** (`/theme-bridge`, opt-in, default Off): per tool Independent / Follow NMSh / Choose theme. fzf launched by NMSh, less/man termcap colors and LS_COLORS (vivid when installed, a small fallback otherwise) through an NMSh-owned shell environment applied by the zsh, Bash and Fish adapters at the next prompt; generated tmux, Neovim and Vim colors with an ownership ledger, staged validated writes, a typed tmux reload and exact includes added only after review. bat and delta are detected and stay Independent. No rc file, terminal or editor theme, or git config is changed. +- **Host semantics**: OSC 7 working directory and OSC 133 command zones derived from NMSh's command lifecycle on capable hosts and tmux; NMSh-authored OSC 8 links in `/help` and dev-server task rows, kept separate from program links. +- `/appearance` is a compact launcher: Theme Studio, Prompt, Cursor & effects, UI chrome, Chroma, Motion, Theme Bridge and host window. + ### Contextual tools - `/tools` Discover shows a conservative **Relevant here** group from cheap local facts (Git repository, shell scripts, JavaScript/Node, Python, Go, Rust, container files, Kubernetes files or kubeconfig) and each tool's declared relevance. Only missing tools appear; nothing is executed, crawled or sent anywhere. - Typed package-manager plans for Homebrew, APT, DNF, pacman and zypper (WSL uses the distribution's manager). Tools without a verified package name stay manual. Non-root plans are explicit `sudo -n` argv; nothing elevates silently. diff --git a/README.md b/README.md index 0d993214..986de347 100644 --- a/README.md +++ b/README.md @@ -88,12 +88,16 @@ NMSh provides a richer interactive frontend without throwing away the proven rob - Width-aware path shortening keeps the repository name and current directory whole while abbreviating parents as the terminal narrows - Terminal glyph style (Nerd Font or Safe/ASCII) is chosen on first run and can be changed in `/config` (Glyph style) or previewed under `/settings` → Settings → Glyph style. Existing v0.3 configurations keep Nerd Font styling; `NMSH_ICONS=nerd|safe` overrides the saved choice for the current process. - Optional Starship or Powerlevel10k prompt providers; NMSh keeps the editor. The Starship module editor changes only reviewed settings, with a backup of an existing config. +- Prompt provider **None**: composer only (no prompt row, modules, right prompt or marker); everything else in NMSh keeps working - Native prompt styles: Powerline, Soft, Minimal, Outline (`/prompt` → Style) - One-line or two-line composer layouts ### Interface - Command lifecycle rows with activity animation and nested Node TAP activity -- Scrollable history with muted snapshots of each command's prompt; tune dividers and history colors with `/transcript` +- Scrollable history with muted snapshots of each command's prompt, shown Full, Compact, Minimal or Off; tune dividers and history colors with `/transcript` +- Native theme library: `/theme` (Theme Studio) browses Built-in themes and manages Imported and Custom ones; import NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON/YAML/TOML), Kitty, Ghostty, iTerm2 or WezTerm TOML files as data only, with a preview and loss disclosure before anything is saved +- Theme Bridge (`/theme-bridge`, opt-in per tool): fzf launched by NMSh, less/man, LS_COLORS, tmux, Neovim and Vim can Follow NMSh or use a pinned theme; nothing changes for a tool until you choose, and config includes are shown and confirmed first +- Host cooperation on capable terminals: OSC 7 working directory and OSC 133 command zones derived from NMSh's own command lifecycle, and NMSh-authored OSC 8 links (help, docs, dev-server URLs) kept separate from program output - Output folding (Config → Output folding: Off / Smart / Always): long output collapses to its first and last lines around `› N lines hidden · Ctrl+O`; Smart keeps failures and useful output expanded; `/copy` and `/resume` always keep the full output - Composer position Bottom, Top, or Flow (Config → Composer position). Flow places the prompt and input right after the newest output, like a conventional terminal, and they scroll with it. Combine any position with Normal or Chat transcript presentation (Config → Transcript presentation). `/layout` (also Config → Layout) previews every combination with sample content before you choose. - Welcome providers: Vespyr (default), Fastfetch, Neofetch (legacy, if installed), or None (`/settings` → Welcome) @@ -273,6 +277,7 @@ The `/appearance` slash command provides an interactive UI to adjust Ghostty's w - **Completion:** The completion bridge is not full parity with a configured interactive zsh, and native `fzf-tab` is not supported yet ([#52](https://github.com/raiseCatError/notMyShell/issues/52)). - **Nested activity:** Only directly observed Node TAP v13 streams produce nested activity rows. - **Powerlevel10k provider:** The right prompt, instant prompt, gitstatus daemon, and p10k settings defined only in `.zshrc` are not reproduced. +- **Theme Bridge:** already-running editors and shells outside NMSh are not recolored live; bat and delta are detected but stay Independent (their custom themes need bat's cache or git config, which NMSh does not modify). Terminal title ownership (OSC 0/2) is not implemented. ## Development diff --git a/docs/design/theme-bridge.md b/docs/design/theme-bridge.md new file mode 100644 index 00000000..9e225108 --- /dev/null +++ b/docs/design/theme-bridge.md @@ -0,0 +1,109 @@ +# Native theme library, imports and Theme Bridge + +Tracker: [#304](https://github.com/raiseCatError/notMyShell/issues/304). Module and ecosystem work stays in [#305](https://github.com/raiseCatError/notMyShell/issues/305). + +## One Native theme model + +``` +external theme file → bounded data parser → palette facts → semantic role mapping → NMSh Native theme asset → the existing renderer +``` + +There is one renderer and one theme format (NMSh Theme JSON). *Imported* is provenance, not a second engine: an imported theme is an ordinary Native theme plus a record of where it came from. It keeps working with the source app uninstalled, the file moved, the machine offline or the upstream gone. Nothing watches or re-syncs source files. + +### Library + +`themes` in the configuration holds every user-owned theme asset: + +| Field | Meaning | +| --- | --- | +| `id` | stable identity (`t-` + 12 hex). Names are display only. | +| `theme` | NMSh Theme JSON (prompt roles, UI roles, `dark`, optional `terminal` palette from terminal schemes) | +| `origin` | present only for imports: `kind`, source name, local `sourcePath`, importer version, time. Its presence makes the category **Imported**. | +| `modified` | an imported theme edited in NMSh (the source file is never touched) | + +`nmsh.themeId` names the asset the `custom` palette uses. **Canonical state is `themes` + `nmsh.themeId`.** `customTheme` is a deterministic mirror of that asset, rewritten by normalization on every load and save so existing renderers and older NMSh versions keep reading one theme; a stored `customTheme` is read only to migrate a configuration that has no library yet. + +Migration: a valid legacy `customTheme` becomes one Custom asset with id `legacy-custom`, still active when it was active. Malformed data falls back to Lavender Native. The library holds at most 64 themes; malformed entries are dropped individually. + +Deleting never leaves a dangling reference: the active theme cannot be deleted (choose another first), and a theme pinned by Theme Bridge targets is deleted only after explicit confirmation that those targets become Independent (never another theme). + +Theme references are stable strings: `builtin:`, `builtin:@` (Catppuccin) or `asset:`. One pure resolver turns any reference into an immutable semantic palette (`src/appearance/semanticPalette.ts`); a missing asset is reported, never replaced. + +### Theme Studio (`/theme`) + +Built-in | Imported | Custom | Import, on the shared tab strip. Built-ins are immutable (Set active, Duplicate to Custom, edit a copy). Imported and Custom themes share one editor and the same real Native preview (project, path, Git, Node/Go/Python/Docker, Kubernetes, success/failure, UI text tiers and roles, a syntax sample). Export writes NMSh Theme JSON to the NMSh config `themes/` directory; library provenance (and so any local path) is not part of the exported theme, and settings transfers drop `sourcePath`. + +Selection is available without the studio: Settings → Theme and `/setup appearance` list Built-in families, Imported and Custom themes. + +## Import formats and safety + +Every import is bounded (256 KiB) local data parsing. No network, remote schemas or inheritance, shell sourcing, subprocesses, Lua, zsh themes or template execution. Names are sanitized; every generated role is validated as an NMSh theme. The preview shows the format, name, dark/light interpretation, role swatches, the role mapping and every lossy or ignored concept; Esc stores nothing. + +| Format | Read | Not imported (reported) | +| --- | --- | --- | +| NMSh Theme JSON | everything, validated | – | +| Base16 | base00–base0F (YAML or JSON) | – | +| Base24 | all 24 slots required; ANSI per the Base24 styling spec 0.1.3 | base06, base09, base0F, base10, base11 have no NMSh role | +| Windows Terminal | scheme colors, selection, cursor | – | +| Oh My Posh (JSON, YAML, TOML) | literal hex colors, static `palette` references (`p:name`), segment types as role hints | templates and `*_templates`, conditional `palettes`, named terminal colors, `extends`/remote config, every segment's logic. Roles without a static source keep the base theme's colors. | +| Kitty | `foreground`, `background`, `selection_*`, `cursor`, `color0`–`color15` | `include`/`globinclude`/`envinclude` (never followed), every other directive | +| Ghostty | allowlist: `palette = N=#hex` (0–15), background/foreground, selection colors, cursor color | `config-file` (never followed) and every other option | +| iTerm2 `.itermcolors` | Ansi 0–15, background, foreground, selection, selected text, cursor | DOCTYPE internal subsets and ENTITY declarations are rejected; other color keys are listed as unrepresented | +| WezTerm | declarative TOML `[colors]` (ansi, brights, foreground, background, selection, cursor) and `[metadata] name` | Lua (`.wezterm.lua`) is refused with guidance to export TOML | + +Terminal schemes map through one documented ANSI → role table (project ← magenta, path ← bright black, Git ← blue, Node ← green, Go ← cyan, Python ← yellow, Docker ← bright blue, Kubernetes ← bright magenta, success ← green, failure ← red, accent ← magenta, warning/info ← yellow/cyan), and keep the real background, foreground and 16 colors in the theme's `terminal` palette for Theme Bridge. Parsers: `yaml` (ISC), `smol-toml` (BSD-3-Clause), `fast-xml-parser` (MIT), all data-only. + +Oh My Zsh `.zsh-theme` files are executable shell code; they are never imported or evaluated. + +## Theme Bridge (`/theme-bridge`) + +A master switch (Setup: “Extend colors to tools?”, default No) and one setting per target: + +- **Independent** – NMSh injects and changes nothing for that target. +- **Follow NMSh** – the active Native theme; future supported launches/environments follow theme changes. +- **Choose theme** – a pinned stable reference to any Built-in, Imported or Custom theme; the main theme never changes it. + +All targets consume the one resolved semantic palette; each target only maps semantic roles onto its documented roles. Chroma is a live presentation treatment and never reaches generated files. NO_COLOR (non-empty) or no color capability injects nothing. + +| Target | Mechanism | Writes | +| --- | --- | --- | +| fzf | `--color` for fzf launched by NMSh only (version-gated color names, 256/16-color fallback); precedence: Theme Bridge colors first, the launching surface's explicit options last (they win). NMSh-owned launches never read `FZF_DEFAULT_OPTS`. | nothing | +| less / man | `LESS_TERMCAP_md/mb/me/us/ue/so/se` and `GROFF_NO_SGR` through the shell environment sink. `LESS`, `PAGER` and `MANPAGER` are never set. BSD/macOS mandoc already emits the overstrike these recolor; GNU groff needs `GROFF_NO_SGR`. NMSh sets `PAGER=cat` for its own transcript, so plain `man` output inside NMSh is paged only when your `MANPAGER` selects less. | env file | +| LS_COLORS | `vivid generate ` when vivid is installed (local, bounded, output validated), otherwise a deliberately small mapping (file kinds plus a few broad extension groups). Replaces LS_COLORS in NMSh shells while active; Independent restores the previous value. | env file, vivid theme file | +| tmux | NMSh-generated fragment of style/colour options only (status, window status, pane borders, messages, modes, menus, popups, clock, display-panes, copy-mode); no keys, layout, plugins, commands or behavior; `set -gq` so older tmux skips unknown options. Optional typed reload `tmux source-file ` on request. New servers need one include line (below). Theme plugins or explicit styles in your tmux.conf are reported as conflicts, not fought. | fragment; include after confirmation | +| Neovim | generated Lua colorscheme `nmsh-bridge` (classic groups, floats, separators, diff, diagnostics with underline/sign/virtual text, common Tree-sitter captures linked onto base groups); data only. | colorscheme; include after confirmation | +| Vim | separate Vim colorscheme with classic groups only, truecolor plus 256-color `cterm` fallback, `background` from the theme. | colorscheme; include after confirmation | +| bat, delta | detected only. bat applies custom themes only from its own theme cache (`bat cache --build`); delta takes syntax themes from that cache and diff styles from git config. NMSh does not modify either, and does not fake fidelity with an unrelated built-in theme. | nothing | + +Already-running editors and shells outside NMSh are not recolored live; Follow NMSh applies to new instances (and NMSh shells at their next prompt). + +### Shell environment sink + +NMSh writes one generated, validated file per shell syntax (`theme-bridge/environment.{zsh,bash,fish}` in the NMSh config directory): a generation guard plus `nmsh_bridge_apply NAME ` / `nmsh_bridge_clear NAME` lines for an allowlist of variables, with exact zsh/Bash ANSI-C and Fish quoting. Each ShellAdapter's own bootstrap (static NMSh code in its private per-session directory, never the user's rc files) applies it from the prompt hook when it is a regular file owned by the user. `apply` remembers the value it replaced; `clear` restores it only if the variable still holds NMSh's value, so Independent removes exactly what NMSh set. Nothing reaches history or the transcript. New shells apply it at their first prompt; running NMSh shells at their next prompt. + +### Ownership ledger and staged generation + +`theme-bridge/ledger.json` records each managed artifact (target, the fixed NMSh path, mode, theme reference, format, sha256, adapter version) and the exact include lines NMSh inserted. Generation is resolve → render → stage → validate → atomic rename. A file is replaced or removed only when its content still matches the recorded hash; an edited or unrecorded file is reported and left alone. A ledger cannot point NMSh at any other path. One target's failure never affects the others or the active NMSh theme. + +### Includes + +For new tmux/Neovim/Vim instances NMSh offers one exact include, shown as a diff with the exact target path and applied with the verified config-edit planner (refuses if the file changed since it was shown). Removal removes exactly those lines (and refuses to guess if they appear more than once). + +| Target | File (existing one preferred) | Lines | +| --- | --- | --- | +| tmux | `~/.tmux.conf` or `$XDG_CONFIG_HOME/tmux/tmux.conf` | `# NMSh Theme Bridge …` and `source-file -q ''` | +| Neovim | `init.lua` (or `init.vim` when only that exists) | `pcall(function() vim.opt.runtimepath:append(''); vim.cmd.colorscheme('nmsh-bridge') end)` | +| Vim | `~/.vimrc` or `~/.vim/vimrc` | `silent! execute 'set runtimepath+=' . fnameescape('') \| silent! colorscheme nmsh-bridge` | + +Every include tolerates a missing file, so an Independent target with an include left in place does nothing. + +## Host semantics + +- **OSC 7**: `file://host/path` (percent-encoded) when the shell reports a new directory. +- **OSC 133**: projected from NMSh's authenticated OSC 777 lifecycle: ready → `D;status` (if a command ran) then `A`, `B`; exec → `C`; a shell that ends mid-command closes its zone with `D` and no invented status. Nothing is written while a fullscreen program owns the terminal; anything due is written when NMSh owns the screen again. +- Sent on Ghostty, Kitty, WezTerm, iTerm2, Windows Terminal and inside tmux; OSC 7 only on Terminal.app; nothing on unknown hosts. `NMSH_SEMANTIC=0` turns both off, `=1` forces them on. NMSh never depends on them. +- **OSC 8**: NMSh-authored links (help/docs, dev-server URLs) are stored as authored cells after a target check (http/https without credentials, local `file:`) and painted only on hosts with hyperlink support; raw program OSC 8 stays the separate preserved program-output path. Copy/plain text never contains escape bytes. + +## Not in this slice + +Terminal title / OSC 0/2 ownership; terminal emulator or editor base-theme takeover (Ghostty/Kitty palettes, Zed/VS Code); bat/delta custom themes; arbitrary Oh My Zsh theme import; an Oh My Posh runtime provider; the #305 module ecosystem. diff --git a/docs/design/theme-families.md b/docs/design/theme-families.md index 096cd85f..d47d34be 100644 --- a/docs/design/theme-families.md +++ b/docs/design/theme-families.md @@ -6,6 +6,11 @@ and, for dark variants, text tiers). They never recolor the terminal window, editor, tab chrome, desktop appearance or any host configuration. Zed's `/appearance` still says "Appearance is configured by Zed." +Custom and imported themes now live in a Native theme library, and the +opt-in Theme Bridge can extend a theme to selected terminal tools; see +[theme-bridge.md](theme-bridge.md). With Theme Bridge Off (the default) +the statement above holds unchanged. + ## Model The configuration stores one palette id (`nmsh.palette`). Family and variant diff --git a/scripts/test-selection.mjs b/scripts/test-selection.mjs index 02ed2157..b04e8d28 100644 --- a/scripts/test-selection.mjs +++ b/scripts/test-selection.mjs @@ -33,7 +33,8 @@ const runtimeWeights = { "shellSwitchApp.test.ts": 23, "startupBlocked.test.ts": 29, "startupDiscovery.test.ts": 22, - "startupLaunch.test.ts": 8 + "startupLaunch.test.ts": 8, + "themeBridgeLive.test.ts": 12 }; export function discoverTestFiles() { diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index f14bd18b..92c7a6ab 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -7019,7 +7019,8 @@ export class TerminalApp { transcriptRows, contextPlacement: this.promptConfiguration.placement, hasVisibleContext: this.hasVisibleProviderPrompt(), - composerLayout: this.promptConfiguration.composerLayout, + // Prompt None is only the input: framed like the one-line composer (divider, input, divider), never a two-line gap. + composerLayout: this.effectivePromptProvider === 'none' ? 'oneLine' : this.promptConfiguration.composerLayout, composerDividers: this.promptConfiguration.composerDividers, panelRows, }; diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index 58c0f41d..ffcf4563 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -22,7 +22,15 @@ When a submitted command is missing in your zsh and exactly names a curated tool ## Appearance -Settings → Theme family picks NMSh themes or bundled families (Catppuccin with flavor and accent, Dracula, Tokyo Night, Gruvbox, Rosé Pine, Nord, Solarized, One Dark). Themes color NMSh-owned UI only; your terminal and editor keep their colors. /theme opens Theme Studio to clone, edit, import (NMSh Theme JSON, Base16, Windows Terminal schemes) and export a custom theme. Cursor shape and blink (/cursor), the prompt symbol and the optional status strip are in Settings. UI chrome (frames, rules, tabs, selection) follows the theme by default, or a Custom preset (Native Lavender, Grayscale, your colors). Chroma colors the Native prompt; Full Chroma is the default influence, and Semantic colors decides whether success, failure and Git state are recolored too. Shimmer (On by default) plays one soft sweep of light when you select or change something, submit, or confirm; Decorative effects Off or Reduced Motion turn it off. +Settings → Theme (and /setup appearance) picks a Built-in theme (NMSh themes and bundled families: Catppuccin with flavor and accent, Dracula, Tokyo Night, Gruvbox, Rosé Pine, Nord, Solarized, One Dark), an Imported theme or a Custom theme. Themes color NMSh-owned UI only; your terminal and editor keep their colors unless you opt a tool into Theme Bridge. /theme opens Theme Studio (Built-in · Imported · Custom · Import): set any theme active, duplicate a built-in, edit, rename, duplicate, export (NMSh Theme JSON) or delete library themes, and import a local file from NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON, YAML, TOML), Kitty, Ghostty, iTerm2 (.itermcolors) or WezTerm TOML. Imports are parsed as data only (nothing is executed, sourced, templated, followed or fetched), previewed with their mapping and what was lost, and saved only when you confirm; once imported a theme is an ordinary Native theme that no longer needs the source app or file. Cursor shape and blink (/cursor), the prompt symbol and the optional status strip are in Settings. UI chrome (frames, rules, tabs, selection) follows the theme by default, or a Custom preset (Native Lavender, Grayscale, your colors). Chroma colors the Native prompt; Full Chroma is the default influence, and Semantic colors decides whether success, failure and Git state are recolored too. Shimmer (On by default) plays one soft sweep of light when you select or change something, submit, or confirm; Decorative effects Off or Reduced Motion turn it off. + +## Prompt None and history + +/prompt → None keeps only the composer: no prompt row, modules, right prompt or marker. Editing, suggestions, syntax colors, history, themes and Theme Bridge keep working, and commands submitted under None store no prompt snapshot. /transcript → Historical prompt shows past prompts Full, Compact (place, branch, marker), Minimal (marker) or Off; stored snapshots are never changed. + +## Theme Bridge + +/theme-bridge (also /appearance, Settings and Setup) extends NMSh themes to terminal tools, one tool at a time. Every tool starts Independent: NMSh injects and changes nothing for it. Follow NMSh uses the active theme; Choose theme pins any Built-in, Imported or Custom theme that the main theme never changes. fzf colors apply only to fzf launched by NMSh (FZF_DEFAULT_OPTS and rc files are untouched). less/man and LS_COLORS values reach NMSh shells (zsh, Bash, Fish) at their next prompt through an NMSh-owned environment file; Independent restores what was there. tmux, Neovim and Vim get NMSh-generated color files; loading them in new instances needs one include line that NMSh shows exactly and adds only after you confirm, and removes exactly. Running editors are not recolored live; tmux can reload on request. bat and delta are detected but stay Independent: their custom themes need bat's cache or git config, which NMSh does not modify. Details: [Theme Bridge](https://github.com/raiseCatError/notMyShell/blob/dev/docs/design/theme-bridge.md). ## Idle visuals diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index d2eeb303..7311f61d 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -167,7 +167,7 @@ const PROMPT_PROVIDER_ROW = providerRow('setupPromptProvider', * second configuration path. */ /** Setup's single Theme Bridge question; Yes only reveals the detected tools, each still Independent. */ -const THEME_BRIDGE_ROW: SettingsRow = {id: 'setupThemeBridge', label: 'Extend NMSh colors to terminal tools?', description: 'Theme Bridge: opt-in colors for fzf, less/man, LS_COLORS, tmux, Neovim and Vim', +const THEME_BRIDGE_ROW: SettingsRow = {id: 'setupThemeBridge', label: 'Extend colors to tools?', description: 'Theme Bridge: opt-in colors for fzf, less/man, LS_COLORS, tmux, Neovim and Vim', category: 'Appearance', control: 'enum', options: ['No', 'Yes'], index: c => c.themeBridge.enabled ? 1 : 0, select: (c, index) => ({...c, themeBridge: {...c.themeBridge, enabled: index === 1}})}; @@ -268,7 +268,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ 'Theme: the base NMSh prompt/UI palette · Theme text: whether it colors NMSh text · UI chrome: frames, tabs, selection, separators, accents.', 'Chroma: an optional treatment over the Native prompt/effects and opted-in surfaces; Full Chroma may override the prompt\'s theme colors. Your terminal and editor keep their own colors.', ], rows: [ - {row: configRow('themeFamily'), note: () => '/theme makes your own'}, + {row: configRow('themeFamily'), note: () => 'Built-in, Imported and Custom themes; /theme creates, imports and edits them'}, {row: configRow('themeVariant')}, {row: configRow('themeAccent')}, {row: configRow('themeText')}, @@ -293,7 +293,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ routeRow('setupChromeColors', 'Edit UI chrome colors', 'Accent, text, separator, selection and status roles with the color picker', 'chromeColors', 'Appearance'), {...routeRow('setupThemeStudio', 'Theme Studio', 'Create, edit, import, export and manage Native themes; selection is above', 'themeStudio', 'Appearance'), row: {id: 'setupThemeStudio', label: 'Theme Studio', description: 'Create, edit, import, export and manage Native themes; selection is above', category: 'Appearance', - control: 'action', actionLabel: 'Open ›', destination: 'themeStudio', value: draft => librarySummary(draft.themes) ?? ''}}, + control: 'action', actionLabel: 'Open ›', destination: 'themeStudio', value: draft => [librarySummary(draft.themes), 'Open ›'].filter(Boolean).join(' ')}}, {row: THEME_BRIDGE_ROW, note: draft => draft.themeBridge.enabled ? 'Only tools found on this system are listed; each starts Independent. Choose theme and includes for tmux/Neovim/Vim are in /theme-bridge' : 'No: every tool keeps its own colors; NMSh injects and changes nothing'}, diff --git a/src/themeBridge/artifacts.ts b/src/themeBridge/artifacts.ts index 7a729e33..7f9ac2f2 100644 --- a/src/themeBridge/artifacts.ts +++ b/src/themeBridge/artifacts.ts @@ -1,5 +1,5 @@ import {createHash} from 'node:crypto'; -import {existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync} from 'node:fs'; +import {existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync} from 'node:fs'; import {homedir} from 'node:os'; import {dirname, join} from 'node:path'; import {nmshConfigDirectory} from '../configuration/paths.js'; @@ -202,8 +202,13 @@ export function hookSpec(target: Extract, home: string, env: NodeJS.ProcessEnv = process.env): {plan: FileEditPlan} | {gone: true} | {error: string} { const hook = loadLedger(env).entries[target]?.hook; if (!hook) return {error: 'NMSh has no recorded include for this target.'}; - const facts = inspectFile(hook.configPath, [home]); + const facts = inspectFile(hook.configPath, homeRoots(home)); if (facts.content === undefined) return {error: `${hook.configPath} ${facts.refusal ?? 'cannot be read'}; nothing was changed.`}; const block = `${hook.lines.join('\n')}\n`; const occurrences = facts.content.split(block).length - 1; diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 5026df34..9b64db42 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -200,10 +200,10 @@ const THEME_ROWS: readonly SettingsRow[] = [ return options[((index % options.length) + options.length) % options.length]!.apply(c); }}, {id: 'themeStudio', parent: 'themeFamily', label: 'Theme Studio', description: 'Create, edit, import, export and manage Native themes', category: 'Appearance', - control: 'action', actionLabel: 'Open ›', value: c => librarySummary(c.themes) ?? "", destination: 'themeStudio'}, + control: 'action', actionLabel: 'Open ›', value: c => [librarySummary(c.themes), 'Open ›'].filter(Boolean).join(' '), destination: 'themeStudio'}, {id: 'themeBridge', label: 'Theme Bridge', description: 'Extend NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim and Vim; every tool starts Independent', category: 'Appearance', control: 'action', actionLabel: "Open ›", value: c => { const active = BRIDGE_TARGETS.filter(target => effectiveMode(c.themeBridge, target) !== 'independent').length; - return active ? `${active} tool${active === 1 ? '' : 's'}` : 'Off'; }, destination: 'themeBridge'}, + return `${active ? `${active} tool${active === 1 ? '' : 's'}` : 'Off'} Open ›`; }, destination: 'themeBridge'}, enumRow({id: 'pastePreview', label: 'Paste preview', description: 'Smart: multiline, chained, mutating or risky pastes are shown before they enter the composer (never changed; nothing runs until Enter). Always: every paste. Off: insert at once', category: 'Editor', values: ['smart', 'always', 'off'] as const, labels: ['Smart', 'Always', 'Off'], get: c => c.pastePreview, set: (c, pastePreview) => ({...c, pastePreview})}), diff --git a/tests/hostSemantics.test.ts b/tests/hostSemantics.test.ts new file mode 100644 index 00000000..28bf7d06 --- /dev/null +++ b/tests/hostSemantics.test.ts @@ -0,0 +1,124 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {HostSemantics, osc133, osc7, semanticSupport} from '../src/host/semanticMarks.js'; +import {AnsiOutputParser, authoredTargetAllowed} from '../src/output/AnsiOutputParser.js'; +import {wrapStyledLine} from '../src/output/viewport.js'; +import {authoredLink, closeAuthoredLinks} from '../src/output/Hyperlinks.js'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {stripAnsi, displayWidth, truncateAnsi} from '../src/util/text.js'; + +const A = osc133('A'), B = osc133('B'), C = osc133('C'); + +function semantics(support = {marks: true, cwd: true}) { + const written: string[] = []; + let owned = true; + const host = new HostSemantics(support, data => { written.push(data); }, () => owned, 'host.local'); + return {host, written, setOwned: (value: boolean) => { owned = value; }, all: () => written.join('')}; +} + +test('OSC 7: file URI with exact percent-encoding; unsafe or relative paths are never sent', () => { + assert.equal(osc7('/Users/me/My Projects/naïve#1?x%', 'host.local'), '\u001B]7;file://host.local/Users/me/My%20Projects/na%C3%AFve%231%3Fx%25\u001B\\'); + assert.equal(osc7('/tmp', 'bad host!'), '\u001B]7;file:///tmp\u001B\\', 'an unusable hostname is omitted, not sent raw'); + assert.equal(osc7('relative/path'), undefined); + assert.equal(osc7('/tmp/\u001B]0;evil\u0007'), undefined, 'control characters are never embedded'); +}); + +test('OSC 133 projection: prompt → command → output → completion, in order, from the authoritative lifecycle', () => { + const {host, all} = semantics(); + host.prompt('/work', 0); + assert.equal(all(), `${osc7('/work', 'host.local')}${A}${B}`); + host.exec(); + host.prompt('/work', 0); + host.exec(); + host.prompt('/work/sub', 2); + assert.equal(all(), `${osc7('/work', 'host.local')}${A}${B}${C}${osc133('D', 0)}${A}${B}${C}${osc133('D', 2)}${osc7('/work/sub', 'host.local')}${A}${B}`, + 'D carries the real exit status; OSC 7 only when the directory changes'); +}); + +test('OSC 133: interrupts, multiline commands, rejected input and shell switches never unbalance zones', () => { + const {host, all, written} = semantics({marks: true, cwd: false}); + host.prompt('/w', 0); + // A multiline command is one exec marker: one C. + host.exec(); host.exec(); + host.prompt('/w', 130); + assert.equal(all(), `${A}${B}${C}${osc133('D', 130)}${A}${B}`, 'one C for one command; Ctrl+C reports its real status'); + written.length = 0; + // Rejected input: no exec marker arrives, so no C and no D. + host.prompt('/w', 0); + assert.equal(all(), `${A}${B}`); + written.length = 0; + // The shell ends mid-command (or /shell switches): the zone closes without inventing a status. + host.exec(); + host.end(); + assert.equal(all(), `${C}${osc133('D')}`); + written.length = 0; + host.end(); + host.exec(); + assert.equal(all(), '', 'nothing after the shell ended until its replacement reports a prompt'); +}); + +test('fullscreen passthrough: markers are held while a program owns the screen, then written once', () => { + const {host, written, setOwned} = semantics({marks: true, cwd: true}); + host.prompt('/w', 0); + host.exec(); + written.length = 0; + setOwned(false); + host.prompt('/w', 0); + assert.deepEqual(written, [], 'never written into an alternate-screen program'); + setOwned(true); + host.flush(); + assert.deepEqual(written, [`${osc133('D', 0)}${A}${B}`]); +}); + +test('unsupported hosts and opt-outs get nothing; capable hosts and multiplexers get markers', () => { + const quiet = semantics({marks: false, cwd: false}); + quiet.host.prompt('/w', 0); quiet.host.exec(); quiet.host.prompt('/w', 1); + assert.equal(quiet.all(), ''); + assert.deepEqual(semanticSupport({TERM: 'xterm-256color'}), {marks: false, cwd: false}, 'an unknown host gets nothing'); + assert.deepEqual(semanticSupport({TERM_PROGRAM: 'ghostty'}), {marks: true, cwd: true}); + assert.deepEqual(semanticSupport({TERM_PROGRAM: 'WezTerm'}), {marks: true, cwd: true}); + assert.deepEqual(semanticSupport({TERM_PROGRAM: 'Apple_Terminal'}), {marks: false, cwd: true}); + assert.deepEqual(semanticSupport({TMUX: '/tmp/tmux-1/default,1,0', TERM: 'tmux-256color'}), {marks: true, cwd: true}, 'tmux consumes them for pane state'); + assert.deepEqual(semanticSupport({TERM_PROGRAM: 'ghostty', NMSH_SEMANTIC: '0'}), {marks: false, cwd: false}); + assert.deepEqual(semanticSupport({TERM: 'dumb', NMSH_SEMANTIC: '1'}), {marks: false, cwd: false}); +}); + +test('OSC 8: NMSh-authored links survive the transcript as authored cells; raw PTY links stay a separate trust path', () => { + const parser = new AnsiOutputParser(); + parser.addAuthoredLine('see \u001B]8;;https://github.com/raiseCatError/notMyShell/issues/304\u001B\\#304\u001B]8;;\u001B\\ now'); + parser.addAuthoredLine('bad \u001B]8;;javascript:alert(1)\u001B\\click\u001B]8;;\u001B\\'); + parser.write('raw \u001B]8;;https://example.com/x\u001B\\link\u001B]8;;\u001B\\\n'); + const [authored, rejected, raw] = parser.lines; + const linked = authored!.filter(cell => cell?.hyperlink); + assert.equal(linked.map(cell => cell!.text).join(''), '#304'); + assert.ok(linked.every(cell => cell!.authored === true)); + assert.ok(rejected!.every(cell => !cell?.hyperlink), 'unsafe authored targets are dropped'); + const rawLinked = raw!.filter(cell => cell?.hyperlink); + assert.ok(rawLinked.length > 0 && rawLinked.every(cell => cell!.authored === undefined), 'program links are never marked authored'); + const [row] = wrapStyledLine(authored!, 80, true); + assert.match(row!.ansi, /\u001B\]8;;https:\/\/github\.com\/raiseCatError\/notMyShell\/issues\/304\u001B\\#304\u001B\]8;;\u001B\\/u); + assert.equal(row!.plain, 'see #304 now', 'plain/copy text has no escape bytes'); + assert.doesNotMatch(wrapStyledLine(authored!, 80, false)[0]!.ansi, /\u001B\]8/u, 'hosts without OSC 8 get plain text'); + for (const target of ['https://localhost:5173/', 'http://127.0.0.1:3000', 'file:///tmp/x.json']) assert.ok(authoredTargetAllowed(target), target); + for (const target of ['javascript:x', 'https://user:pw@example.com/', 'file://host/etc/passwd', 'data:text/html,x', 'https://exa mple.com']) assert.equal(authoredTargetAllowed(target), false, target); +}); + +test('OSC 8 in live rows: authored task URLs only on capable hosts, and truncation never leaves a link open', () => { + const link = authoredLink('http://localhost:5173', 'http://localhost:5173', true); + assert.match(link, /^\u001B\]8;;http:\/\/localhost:5173\/\u001B\\http:\/\/localhost:5173\u001B\]8;;\u001B\\$/u); + assert.equal(authoredLink('x', 'javascript:alert(1)', true), 'x'); + assert.equal(authoredLink('http://localhost:5173', 'http://localhost:5173', false), 'http://localhost:5173'); + const row = closeAuthoredLinks(truncateAnsi(`dev server · ${link} · 12s`, 20)); + assert.ok(row.endsWith('\u001B]8;;\u001B\\'), 'a cut link is closed'); + assert.ok(displayWidth(row) <= 20); + assert.equal(stripAnsi(link), 'http://localhost:5173'); +}); + +test('frontend interactions: authored links only when requested; /copy-style plain text stays escape-free', () => { + const output = new OutputBuffer(); + output.addFrontendInteraction('/help', 'docs \u001B]8;;https://github.com/raiseCatError/notMyShell\u001B\\repo\u001B]8;;\u001B\\', '', true); + output.addFrontendInteraction('/x', 'docs \u001B]8;;https://example.com\u001B\\plain\u001B]8;;\u001B\\'); + const rows = output.wrapped(80).map(row => row.plain); + assert.ok(rows.some(row => row.includes('docs repo'))); + assert.ok(rows.every(row => !row.includes('\u001B'))); +}); diff --git a/tests/promptNone.test.ts b/tests/promptNone.test.ts new file mode 100644 index 00000000..6405a872 --- /dev/null +++ b/tests/promptNone.test.ts @@ -0,0 +1,114 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import type {TerminalFrame} from '../src/terminal/TerminalRenderer.js'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {applyUiTheme} from '../src/appearance/uiTheme.js'; +import {layoutInput} from '../src/input/inputLayout.js'; +import {GLYPHS} from '../src/ui/glyphs.js'; +import {renderHistoricalContext, type HistoricalContextSnapshot} from '../src/output/OutputBuffer.js'; +import {DEFAULT_TRANSCRIPT_APPEARANCE} from '../src/prompt/configuration.js'; +import {nativePromptSnapshot, themePreviewContext} from '../src/prompt/prompt.js'; +import {historicalPromptLevel} from '../src/output/TranscriptPanel.js'; +import {stripAnsi} from '../src/util/text.js'; +import {describePromptConfiguration, PROVIDER_ORDER} from '../src/prompt/PromptPanel.js'; + +function harness(config: object): {app: TerminalApp; frames: TerminalFrame[]; cleanup: () => void} { + const isolation = isolateConfig(); + const app = new TerminalApp(); + const frames: TerminalFrame[] = []; + app['renderer'].render = (frame: TerminalFrame) => { frames.push(frame); }; + app['fetchSuggestions'] = async () => {}; + app['presentationStarted'] = true; + app['configuration'] = normalizePromptConfiguration({...DEFAULT_PROMPT_CONFIGURATION, ...config}); + return {app, frames, cleanup: () => { app['stop'](0); app['session'].kill(); applyUiTheme(undefined); isolation.restore(); }}; +} + +const plainRows = (frame: TerminalFrame) => frame.rows.map(stripAnsi); + +test('Prompt None: normalizes, persists and is a listed provider; describes itself as composer only', () => { + assert.equal(normalizePromptConfiguration({provider: 'none'}).provider, 'none'); + assert.equal(normalizePromptConfiguration({prompt: {provider: 'none'}}).provider, 'none'); + assert.equal(normalizePromptConfiguration({provider: 'bogus'}).provider, 'nmsh'); + assert.ok(PROVIDER_ORDER.includes('none')); + assert.equal(describePromptConfiguration(normalizePromptConfiguration({provider: 'none'})), 'None · composer only'); +}); + +test('Prompt None: an explicitly empty prefix means no marker and no continuation indent', () => { + const bare = layoutInput('echo one\necho two', 0, 40, Infinity, ''); + assert.deepEqual(bare.allRows.map(row => row.prefix), ['', '']); + assert.equal(bare.caretColumn, 0); + const normal = layoutInput('echo', 0, 40); + assert.equal(normal.allRows[0]!.prefix, `${GLYPHS.prompt} `); +}); + +for (const layout of ['twoLine', 'oneLine'] as const) { + for (const panelPosition of ['bottom', 'top'] as const) { + test(`Prompt None (${layout}, panels ${panelPosition}): no prompt row, modules, right prompt or marker; the composer collapses`, async () => { + const none = harness({provider: 'none', composerLayout: layout, panelPosition, onboardingComplete: true}); + const native = harness({provider: 'nmsh', composerLayout: layout, panelPosition, onboardingComplete: true}); + try { + for (const instance of [none, native]) { + await instance.app['refreshProviderPrompt'](); + instance.app['onShellPrompt'](0, process.cwd()); + instance.app['editor'].insert('git status'); + instance.app['render'](); + } + const noneRows = plainRows(none.frames.at(-1)!); + const nativeRows = plainRows(native.frames.at(-1)!); + assert.equal(none.app['hasVisibleProviderPrompt'](), false); + assert.equal(none.app['currentPromptLine'](80), ''); + assert.ok(noneRows.some(row => row === 'git status' || row.startsWith('git status')), 'the input row is the bare composer'); + assert.ok(!noneRows.some(row => row.includes(`${GLYPHS.prompt} git status`)), 'no prompt symbol'); + const input = noneRows.findIndex(row => row.startsWith('git status')); + assert.match(noneRows[input - 1] ?? '', /^─+$/u, 'directly under the divider: no prompt/modules row and no blank row'); + assert.match(noneRows[input + 1] ?? '', /^─+$/u, 'the composer is exactly divider, input, divider'); + assert.ok(nativeRows.some(row => row.includes(`${GLYPHS.prompt} git status`)), 'the Native prompt keeps its marker'); + // The rest of the composer keeps working: syntax highlighting still paints the command. + const frame = none.frames.at(-1)!.rows.find(row => stripAnsi(row).startsWith('git status'))!; + assert.match(frame, /\u001B\[38;/u, 'syntax colors still apply'); + } finally { none.cleanup(); native.cleanup(); } + }); + } +} + +test('Prompt None history: submissions store no snapshot and render no prompt; switching back restores the prompt', () => { + const {app, cleanup} = harness({provider: 'none', onboardingComplete: true}); + try { + app['onShellPrompt'](0, process.cwd()); + app['effectivePromptProvider'] = 'none'; + const context = app['historicalContext'](process.cwd(), {project: 'p', branch: 'main'}, 'ls'); + assert.equal(context.prompt, undefined, 'no prompt snapshot'); + assert.equal(context.promptless, true); + const header = renderHistoricalContext(context, 60, DEFAULT_TRANSCRIPT_APPEARANCE); + assert.ok(header && !/p\b.*main/u.test(header.plain), 'no substituted Native prompt'); + assert.match(header!.plain, /^─+$/u, 'only the divider remains'); + assert.equal(renderHistoricalContext(context, 60, {...DEFAULT_TRANSCRIPT_APPEARANCE, divider: false}), undefined); + app['configuration'] = normalizePromptConfiguration({...app['configuration'], provider: 'nmsh'}); + app['effectivePromptProvider'] = 'nmsh'; + const restored = app['historicalContext'](process.cwd(), {project: 'p', branch: 'main'}, 'ls'); + assert.ok(restored.prompt, 'switching provider restores normal snapshots'); + assert.equal(restored.promptless, undefined); + } finally { cleanup(); } +}); + +test('historical prompt Full / Compact / Minimal / Off: presentation only, the snapshot stays complete', () => { + const context: HistoricalContextSnapshot = {cwd: '/home/me/src', project: 'notMyShell', branch: 'main', + prompt: nativePromptSnapshot(themePreviewContext('/home/me'), normalizePromptConfiguration({}))}; + const before = JSON.stringify(context); + const render = (level: 'full' | 'compact' | 'minimal', on = true) => + renderHistoricalContext(context, 80, {...DEFAULT_TRANSCRIPT_APPEARANCE, historicalPrompt: on, historicalPromptLevel: level}); + const full = render('full')!.plain; + const compact = render('compact')!.plain; + const minimal = render('minimal')!.plain; + const off = render('full', false)!.plain; + assert.match(full, /notMyShell/u); + assert.match(compact, new RegExp(`^notMyShell ${GLYPHS.branch} main ${GLYPHS.prompt} ─+$`, 'u')); + assert.match(minimal, new RegExp(`^${GLYPHS.prompt} ─+$`, 'u')); + assert.match(off, /^─+$/u); + assert.equal(JSON.stringify(context), before, 'stored data is never changed by presentation'); + assert.equal(historicalPromptLevel({...DEFAULT_TRANSCRIPT_APPEARANCE, historicalPrompt: false, historicalPromptLevel: 'compact'}), 'off'); + assert.equal(normalizePromptConfiguration({transcript: {historicalPromptLevel: 'sideways'}}).transcript.historicalPromptLevel, 'full'); + assert.equal(normalizePromptConfiguration({transcript: {historicalPromptLevel: 'minimal'}}).transcript.historicalPromptLevel, 'minimal'); +}); diff --git a/tests/themeBridge.test.ts b/tests/themeBridge.test.ts new file mode 100644 index 00000000..eba9e285 --- /dev/null +++ b/tests/themeBridge.test.ts @@ -0,0 +1,375 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; +import {addTheme, setActiveTheme} from '../src/appearance/themeLibraryActions.js'; +import {assetRef, builtinTheme} from '../src/appearance/themeRefs.js'; +import {paletteFromTheme, resolveSemanticPalette} from '../src/appearance/semanticPalette.js'; +import {anyBridgeTargetActive, BRIDGE_TARGETS, effectiveMode, normalizeThemeBridge, type BridgeTargetId} from '../src/themeBridge/model.js'; +import { + BRIDGE_ENV_VARIABLES, bridgeBootstrap, bridgeEnvPath, fishLiteral, posixAnsiQuote, renderEnvironmentFile, validateEnvironmentFile, writeEnvironmentFiles, +} from '../src/themeBridge/environment.js'; +import { + fzfColorArgs, lsColorsFallback, neovimColorscheme, pagerEnvironment, tmuxFragment, validateNeovimColorscheme, validateTmuxFragment, + validateVimColorscheme, vimColorscheme, withFzfTheme, TMUX_STYLE_OPTIONS, +} from '../src/themeBridge/targets.js'; +import { + applyHook, applyHookRemoval, artifactPath, hookSpec, ledgerPath, loadLedger, ownership, planHook, planHookRemoval, removeArtifact, writeArtifact, +} from '../src/themeBridge/artifacts.js'; +import {applyThemeBridge, bridgeColorLevel, bridgeEnvironment, fzfBridgeArgs, reloadTmux, reportTargets, type BridgeContext, type TargetFacts} from '../src/themeBridge/runtime.js'; +import {themeBridgeKey} from '../src/themeBridge/runtime.js'; + +const installed = (...targets: BridgeTargetId[]): Record => + Object.fromEntries(BRIDGE_TARGETS.map(target => [target, {installed: targets.includes(target), ...(target === 'fzf' ? {version: '0.74.4 (brew)'} : {})}])) as Record; + +function sandbox(): {env: NodeJS.ProcessEnv; home: string; root: string; done: () => void} { + const root = mkdtempSync(join(tmpdir(), 'nmsh-bridge-')); + const home = join(root, 'home'); + mkdirSync(home); + return {root, home, env: {HOME: home, XDG_CONFIG_HOME: join(root, 'config'), PATH: ''}, done: () => rmSync(root, {recursive: true, force: true})}; +} + +function configWith(targets: Partial>, active = 'builtin:lavender'): PromptConfiguration { + const config = normalizePromptConfiguration({...structuredClone(DEFAULT_PROMPT_CONFIGURATION), themeBridge: {enabled: true, targets}}); + const set = setActiveTheme(config, active); + assert.ok(set.ok); + return set.config; +} + +const context = (config: PromptConfiguration, env: NodeJS.ProcessEnv, facts = installed(...BRIDGE_TARGETS)): BridgeContext => ({source: config, facts, level: 'truecolor', env}); + +test('defaults: every target Independent, the switch Off, and Independent injects nothing at all', () => { + const config = normalizePromptConfiguration({}); + assert.equal(config.themeBridge.enabled, false); + assert.ok(BRIDGE_TARGETS.every(target => config.themeBridge.targets[target].mode === 'independent')); + assert.equal(anyBridgeTargetActive(config.themeBridge), false); + const env = {PATH: ''}; + assert.deepEqual(bridgeEnvironment(context(config, env)), {}); + assert.deepEqual(fzfBridgeArgs(context(config, env)), []); + // Choices are kept while the switch is Off, but nothing is in effect. + const off = normalizePromptConfiguration({themeBridge: {enabled: false, targets: {fzf: {mode: 'follow'}}}}); + assert.equal(off.themeBridge.targets.fzf.mode, 'follow'); + assert.equal(effectiveMode(off.themeBridge, 'fzf'), 'independent'); + assert.deepEqual(fzfBridgeArgs(context(off, env)), []); + // Malformed settings read as Independent; Choose without a reference has nothing to pin. + const malformed = normalizeThemeBridge({enabled: true, targets: {fzf: {mode: 'sideways'}, tmux: {mode: 'choose'}, vim: 'x', bogus: {mode: 'follow'}}}); + assert.equal(malformed.targets.fzf.mode, 'independent'); + assert.equal(malformed.targets.tmux.mode, 'independent'); + assert.equal(normalizeThemeBridge({targets: {fzf: {mode: 'follow'}}}).enabled, true, 'pre-switch configs with active targets read as On'); +}); + +test('Follow tracks the active theme; Choose stays pinned; different targets use different themes at once', () => { + let config = configWith({fzf: {mode: 'follow'}, tmux: {mode: 'choose', theme: 'builtin:gruvboxDark'}, vim: {mode: 'choose', theme: 'builtin:catppuccinMocha'}}); + const env = {PATH: ''}; + const before = fzfBridgeArgs(context(config, env)); + const pinnedBefore = reportTargets(context(config, env)).find(report => report.target === 'tmux')!; + config = setActiveTheme(config, 'builtin:nord').ok ? (setActiveTheme(config, 'builtin:nord') as {config: PromptConfiguration}).config : config; + const after = fzfBridgeArgs(context(config, env)); + const reports = reportTargets(context(config, env)); + assert.notDeepEqual(before, after, 'Follow NMSh changes with the main theme'); + assert.equal(reports.find(report => report.target === 'fzf')!.themeLabel, 'Nord'); + assert.equal(reports.find(report => report.target === 'tmux')!.themeLabel, 'Gruvbox Dark'); + assert.deepEqual(reports.find(report => report.target === 'tmux')!.palette, pinnedBefore.palette, 'the pinned palette is unchanged'); + assert.equal(reports.find(report => report.target === 'vim')!.themeLabel, 'Catppuccin Mocha'); + assert.equal(reports.find(report => report.target === 'tmux')!.status, 'Pinned theme'); + assert.equal(reports.find(report => report.target === 'fzf')!.status, 'Following NMSh'); + assert.notEqual(themeBridgeKey(config), themeBridgeKey(configWith({fzf: {mode: 'follow'}})), 'a theme or setting change re-applies'); +}); + +test('built-in, imported and custom themes all resolve; a deleted pin never resolves elsewhere', () => { + let config = configWith({}); + const custom = addTheme(config, {...builtinTheme('dracula'), name: 'Mine'}); + assert.ok(custom.ok); + config = custom.config; + const imported = addTheme(config, {...builtinTheme('nord'), name: 'Imp'}, {kind: 'kitty', sourceName: 'Imp'}); + assert.ok(imported.ok); + config = imported.config; + for (const ref of ['builtin:lavender', 'builtin:catppuccinLatte@peach', assetRef(custom.id!), assetRef(imported.id!)]) { + const resolved = resolveSemanticPalette(ref, config); + assert.ok(resolved.ok, ref); + assert.ok(Object.isFrozen(resolved.palette) && Object.isFrozen(resolved.palette.ansi), 'immutable snapshot'); + assert.equal(resolved.palette.ansi.length, 16); + } + assert.equal(resolveSemanticPalette('builtin:catppuccinLatte', config).ok && (resolveSemanticPalette('builtin:catppuccinLatte', config) as {palette: {dark: boolean}}).palette.dark, false); + config.themeBridge.targets.tmux = {mode: 'choose', theme: 'asset:t-000000000000'}; + const report = reportTargets(context(config, {PATH: ''})).find(item => item.target === 'tmux')!; + assert.equal(report.status, 'Missing theme'); + assert.equal(report.palette, undefined, 'no other theme is substituted'); +}); + +test('fzf: deterministic --color for NMSh launches; explicit caller options win; NO_COLOR and capability fallbacks', () => { + const palette = paletteFromTheme(builtinTheme('nord'), 'builtin:nord'); + const args = fzfColorArgs(palette, 'truecolor', [0, 74]); + assert.deepEqual(args, fzfColorArgs(palette, 'truecolor', [0, 74]), 'deterministic'); + assert.equal(args.length, 1); + assert.match(args[0]!, /^--color=bg:-1,fg:#[0-9a-f]{6},hl:#[0-9a-f]{6},fg\+:#[0-9a-f]{6},bg\+:#[0-9a-f]{6}/u); + assert.match(args[0]!, /gutter:-1/u, 'the terminal background stays the terminal\'s'); + assert.doesNotMatch(fzfColorArgs(palette, 'truecolor', [0, 20])[0]!, /separator|scrollbar|query|label|border/u, 'old fzf never gets unknown color names'); + assert.match(fzfColorArgs(palette, 'ansi256', [0, 74])[0]!, /fg:\d{1,3},/u); + assert.deepEqual(fzfColorArgs(palette, 'none'), []); + const caller = ['--no-multi', '--color=bw']; + assert.deepEqual(withFzfTheme(caller, args), [...args, ...caller], 'NMSh colors first, the launching surface\'s explicit options last (they win)'); + assert.equal(bridgeColorLevel('truecolor', {NO_COLOR: '1'}), 'none'); + assert.equal(bridgeColorLevel('truecolor', {NO_COLOR: ''}), 'truecolor', 'an empty NO_COLOR is not set'); + const config = configWith({fzf: {mode: 'follow'}}); + assert.deepEqual(fzfBridgeArgs({...context(config, {NO_COLOR: '1', PATH: ''})}), []); +}); + +test('less/man and LS_COLORS: scoped allowlisted variables, bounded deterministic values', () => { + const palette = paletteFromTheme(builtinTheme('gruvboxDark'), 'builtin:gruvboxDark'); + const pager = pagerEnvironment(palette, 'truecolor'); + assert.deepEqual(Object.keys(pager).sort(), ['GROFF_NO_SGR', 'LESS_TERMCAP_mb', 'LESS_TERMCAP_md', 'LESS_TERMCAP_me', 'LESS_TERMCAP_se', 'LESS_TERMCAP_so', 'LESS_TERMCAP_ue', 'LESS_TERMCAP_us']); + assert.ok(!('LESS' in pager) && !('PAGER' in pager) && !('MANPAGER' in pager), 'the user\'s pager options are never set'); + assert.match(pager.LESS_TERMCAP_md!, /^\u001B\[1;38;2;\d+;\d+;\d+m$/u); + assert.match(pagerEnvironment(palette, 'ansi256').LESS_TERMCAP_md!, /^\u001B\[1;38;5;\d+m$/u); + assert.deepEqual(pagerEnvironment(palette, 'none'), {}); + const ls = lsColorsFallback(palette, 'truecolor')!; + assert.equal(ls, lsColorsFallback(palette, 'truecolor')); + assert.ok(ls.length < 2500, `small fallback (${ls.length})`); + assert.ok(ls.split(':').length < 60, 'not an extension database'); + assert.match(ls, /^di=1;38;2;\d+;\d+;\d+:/u); + assert.ok(ls.split(':').every(entry => /^[^=:\s]+=[0-9;]+$/u.test(entry))); + assert.equal(lsColorsFallback(palette, 'none'), undefined); + const config = configWith({pager: {mode: 'follow'}, lsColors: {mode: 'choose', theme: 'builtin:nord'}}); + const environment = bridgeEnvironment(context(config, {PATH: ''})); + assert.ok(Object.keys(environment).every(name => (BRIDGE_ENV_VARIABLES as readonly string[]).includes(name))); + assert.ok(environment.LS_COLORS && environment.LESS_TERMCAP_md); +}); + +test('environment sink: exact quoting, strict validation, allowlist only', () => { + const tricky = "\u001B[1m it's \\ $HOME `x` $(rm -rf ~) ;|&"; + assert.equal(posixAnsiQuote(tricky), "$'\\e[1m it\\'s \\\\ $HOME `x` $(rm -rf ~) ;|&'"); + assert.equal(fishLiteral(tricky), "\\e'[1m it\\'s \\\\ $HOME `x` $(rm -rf ~) ;|&'"); + for (const shell of ['zsh', 'bash', 'fish'] as const) { + const content = renderEnvironmentFile(shell, {LS_COLORS: 'di=1;34', LESS_TERMCAP_md: tricky}); + assert.ok(validateEnvironmentFile(shell, content), shell); + assert.equal(validateEnvironmentFile(shell, `${content}rm -rf ~\n`), false, 'appended commands fail validation'); + assert.equal(validateEnvironmentFile(shell, content.replace('nmsh_bridge_clear GROFF_NO_SGR', 'nmsh_bridge_clear PATH')), false, 'non-allowlisted names fail'); + assert.ok(content.split('\n').filter(line => line.startsWith(' ')).length === BRIDGE_ENV_VARIABLES.length); + } + assert.throws(() => renderEnvironmentFile('zsh', {PATH: '/evil'} as never), /Refusing/u); + assert.throws(() => renderEnvironmentFile('zsh', {LS_COLORS: 'a\nb'}), /Refusing/u); +}); + +const shells: Array<{id: 'zsh' | 'bash' | 'fish'; bin: string | undefined}> = [ + {id: 'zsh', bin: ['/bin/zsh', '/usr/bin/zsh'].find(existsSync)}, + {id: 'bash', bin: ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/bin/bash', '/usr/bin/bash'].find(path => existsSync(path) && /version [45]\.|version [6-9]\./u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? ''))}, + {id: 'fish', bin: ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync)}, +]; + +for (const {id, bin} of shells) { + test(`environment sink in a real ${id}: apply, follow update, Independent restores only NMSh-owned values, no history or rc writes`, {skip: bin ? false : `${id} not installed`}, () => { + const box = sandbox(); + try { + const file = bridgeEnvPath(id, box.env); + const value = '\u001B[1;38;2;1;2;3m'; + writeEnvironmentFiles({LESS_TERMCAP_md: value, LS_COLORS: 'di=1;34'}, box.env); + const first = readFileSync(file, 'utf8'); + writeEnvironmentFiles({LESS_TERMCAP_md: value, LS_COLORS: 'di=1;35'}, box.env); + const second = readFileSync(file, 'utf8'); + writeEnvironmentFiles({}, box.env); + const cleared = readFileSync(file, 'utf8'); + const stage = (content: string) => { const path = join(box.root, `stage-${Math.random()}`); writeFileSync(path, content); return path; }; + const [a, b, c] = [first, second, cleared].map(stage); + const show = id === 'fish' + ? `printf '%s|%s\\n' (set -q LESS_TERMCAP_md; and printf %s "$LESS_TERMCAP_md" | od -An -c | tr -d ' \\n'; or echo unset) "$LS_COLORS"` + : `printf '%s|%s\\n' "\${LESS_TERMCAP_md+set}" "\${LS_COLORS-unset}"`; + const userSets = id === 'fish' ? 'set -gx LS_COLORS user-value' : 'export LS_COLORS=user-value'; + const script = `${bridgeBootstrap(id, file)}\n${userSets}\ncp ${a} ${file}; nmsh_bridge_sync; ${show}\ncp ${b} ${file}; nmsh_bridge_sync; ${show}\nnmsh_bridge_sync; ${show}\ncp ${c} ${file}; nmsh_bridge_sync; ${show}\n`; + // The shell's own data/cache directories live outside HOME here, so HOME shows only what NMSh might have written (nothing). + const result = spawnSync(bin!, ['-c', script], {encoding: 'utf8', env: {...box.env, PATH: process.env.PATH, HISTFILE: join(box.root, 'history'), + XDG_DATA_HOME: join(box.root, 'data'), XDG_CACHE_HOME: join(box.root, 'cache')}}); + const lines = result.stdout.trim().split('\n'); + assert.equal(lines.length, 4, result.stderr); + assert.match(lines[0]!, /\|di=1;34$/u, 'applied'); + assert.match(lines[1]!, /\|di=1;35$/u, 'a changed theme updates the value'); + assert.equal(lines[2], lines[1], 'an unchanged generation is not re-applied'); + assert.match(lines[3]!, id === 'fish' ? /^unset\|user-value$/u : /^\|user-value$/u, 'Independent restores the user\'s own value and removes NMSh\'s'); + assert.equal(existsSync(join(box.root, 'history')), false, 'nothing is written to history'); + assert.deepEqual(readdirSync(box.home), [], 'no rc or dotfile was created or changed'); + } finally { box.done(); } + }); +} + +test('environment sink: a value the user changed after NMSh set it is left alone on clear', {skip: shells[0]!.bin ? false : 'zsh not installed'}, () => { + const box = sandbox(); + try { + const file = bridgeEnvPath('zsh', box.env); + writeEnvironmentFiles({LS_COLORS: 'di=1;34'}, box.env); + const on = readFileSync(file, 'utf8'); + writeEnvironmentFiles({}, box.env); + const off = readFileSync(file, 'utf8'); + const onPath = join(box.root, 'on'); const offPath = join(box.root, 'off'); + writeFileSync(onPath, on); writeFileSync(offPath, off); + const script = `${bridgeBootstrap('zsh', file)}\ncp ${onPath} ${file}; nmsh_bridge_sync\nexport LS_COLORS=mine-now\ncp ${offPath} ${file}; nmsh_bridge_sync\nprint -r -- "$LS_COLORS"`; + assert.equal(spawnSync(shells[0]!.bin!, ['-c', script], {encoding: 'utf8', env: {...box.env, PATH: process.env.PATH}}).stdout.trim(), 'mine-now'); + } finally { box.done(); } +}); + +test('managed artifacts: staged validated writes, ownership ledger, never overwrite or delete what NMSh cannot prove it owns', () => { + const box = sandbox(); + try { + const palette = paletteFromTheme(builtinTheme('nord'), 'builtin:nord'); + const record = {mode: 'follow' as const, themeRef: 'builtin:nord', format: 'tmux-fragment', formatVersion: 1}; + assert.equal(writeArtifact('tmux', 'bind-key x kill-server\n', validateTmuxFragment, record, box.env).ok, false, 'invalid content is never written'); + assert.equal(existsSync(artifactPath('tmux', box.env)), false); + const written = writeArtifact('tmux', tmuxFragment(palette), validateTmuxFragment, record, box.env); + assert.ok(written.ok && written.changed); + assert.equal(ownership('tmux', loadLedger(box.env), box.env), 'owned'); + // An edited file is no longer provably NMSh's: no overwrite, no delete. + writeFileSync(artifactPath('tmux', box.env), `${tmuxFragment(palette)}# my edit\n`); + assert.equal(ownership('tmux', loadLedger(box.env), box.env), 'modified'); + assert.equal(writeArtifact('tmux', tmuxFragment(palette), validateTmuxFragment, record, box.env).ok, false); + assert.equal(removeArtifact('tmux', box.env).ok, false); + assert.match(readFileSync(artifactPath('tmux', box.env), 'utf8'), /# my edit/u); + // A file without a ledger entry (or with a malformed ledger) is unknown. + writeFileSync(ledgerPath(box.env), '{not json'); + assert.equal(ownership('tmux', loadLedger(box.env), box.env), 'unknown'); + assert.equal(removeArtifact('tmux', box.env).ok, false); + // A ledger cannot point NMSh at other files. + const victim = join(box.home, '.tmux.conf'); + writeFileSync(victim, 'set -g mouse on\n'); + writeFileSync(ledgerPath(box.env), JSON.stringify({version: 1, entries: {vim: {target: 'vim', artifactPath: victim, sha256: 'a'.repeat(64)}}})); + assert.deepEqual(loadLedger(box.env).entries, {}); + assert.equal(removeArtifact('vim', box.env).ok, true); + assert.equal(readFileSync(victim, 'utf8'), 'set -g mouse on\n', 'user files are untouched'); + } finally { box.done(); } +}); + +test('exact includes: shown plan, exact lines appended, exact removal, duplicates refused, user content untouched', () => { + const box = sandbox(); + try { + const conf = join(box.home, '.tmux.conf'); + writeFileSync(conf, 'set -g mouse on\nbind r source-file ~/.tmux.conf\n'); + const palette = paletteFromTheme(builtinTheme('nord'), 'builtin:nord'); + assert.ok(writeArtifact('tmux', tmuxFragment(palette), validateTmuxFragment, {mode: 'follow', themeRef: 'builtin:nord', format: 'tmux-fragment', formatVersion: 1}, box.env).ok); + const spec = hookSpec('tmux', box.env, box.home); + assert.ok(!('error' in spec)); + assert.equal(spec.configPath, conf, 'the existing user config is the target'); + assert.deepEqual(spec.lines, ['# NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)', `source-file -q '${artifactPath('tmux', box.env)}'`]); + const planned = planHook(spec, box.home); + assert.ok('plan' in planned); + assert.ok(planned.plan.preview.some(line => line.startsWith('+ source-file -q')), 'the exact line is previewed'); + assert.equal(readFileSync(conf, 'utf8'), 'set -g mouse on\nbind r source-file ~/.tmux.conf\n', 'planning writes nothing'); + assert.ok(applyHook('tmux', planned.plan, spec, box.env).ok); + const after = readFileSync(conf, 'utf8'); + assert.ok(after.startsWith('set -g mouse on\nbind r source-file ~/.tmux.conf\n'), 'nothing of the user\'s is rewritten'); + assert.ok(after.endsWith(`${spec.lines.join('\n')}\n`)); + assert.ok('noop' in planHook(spec, box.home), 'already present: nothing to add'); + const removal = planHookRemoval('tmux', box.home, box.env); + assert.ok('plan' in removal); + assert.ok(applyHookRemoval('tmux', removal.plan, box.env).ok); + assert.equal(readFileSync(conf, 'utf8'), 'set -g mouse on\nbind r source-file ~/.tmux.conf\n\n', 'only the NMSh lines are removed'); + assert.equal(loadLedger(box.env).entries.tmux?.hook, undefined); + // Duplicated includes are never guessed at. + assert.ok(applyHook('tmux', (planHook(spec, box.home) as {plan: never}).plan, spec, box.env).ok); + writeFileSync(conf, `${readFileSync(conf, 'utf8')}${spec.lines.join('\n')}\n`); + assert.match((planHookRemoval('tmux', box.home, box.env) as {error: string}).error, /more than once/u); + // A missing Neovim init is created with only the hook lines; Vim targets ~/.vimrc. + const nvim = hookSpec('neovim', box.env, box.home); + assert.ok(!('error' in nvim) && nvim.createIfMissing && nvim.configPath.endsWith(join('nvim', 'init.lua'))); + assert.match(nvim.lines[1]!, /^pcall\(function\(\) vim\.opt\.runtimepath:append\('.+'\); vim\.cmd\.colorscheme\('nmsh-bridge'\) end\)$/u); + const vim = hookSpec('vim', box.env, box.home); + assert.ok(!('error' in vim) && vim.configPath === join(box.home, '.vimrc')); + } finally { box.done(); } +}); + +test('tmux fragment: broad style coverage, no behavior, real tmux loads it', () => { + for (const ref of ['builtin:lavender', 'builtin:gruvboxLight', 'builtin:catppuccinMocha@peach']) { + const resolved = resolveSemanticPalette(ref, {themes: []}); + assert.ok(resolved.ok); + const fragment = tmuxFragment(resolved.palette); + assert.ok(validateTmuxFragment(fragment)); + const options = fragment.split('\n').filter(line => line.startsWith('set ')).map(line => line.split(' ')[2]); + assert.ok(options.length >= 20, 'status, windows, panes, messages, modes, menus, popups, clock, display-panes, copy-mode'); + assert.ok(options.every(option => (TMUX_STYLE_OPTIONS as readonly string[]).includes(option!))); + assert.doesNotMatch(fragment, /\b(?:bind|unbind|prefix|mouse|run|if-shell|source|set-hook|default-shell|default-command)\b/u); + } + assert.equal(validateTmuxFragment('set -gq status-style "fg=#ffffff"\nbind x kill-server\n'), false); + assert.equal(validateTmuxFragment('set -gq default-command "rm -rf ~"\n'), false); + const tmux = ['/opt/homebrew/bin/tmux', '/usr/local/bin/tmux', '/usr/bin/tmux'].find(existsSync); + if (!tmux) return; + const box = sandbox(); + try { + const path = join(box.root, 'fragment.conf'); + writeFileSync(path, tmuxFragment(paletteFromTheme(builtinTheme('nord'), 'builtin:nord'))); + const socket = `nmsh-test-${process.pid}`; + const result = spawnSync(tmux, ['-L', socket, '-f', '/dev/null', 'new-session', '-d', ';', 'source-file', path, ';', 'show-options', '-g', 'pane-active-border-style', ';', 'kill-server'], + {encoding: 'utf8', env: {...process.env, TMUX: '', TMUX_TMPDIR: box.root}}); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /pane-active-border-style "?fg=#[0-9a-f]{6}/u); + } finally { box.done(); } +}); + +test('Neovim and Vim colorschemes: broad native coverage, separate dialects, real Vim loads its file', () => { + const dark = paletteFromTheme(builtinTheme('tokyonightNight'), 'builtin:tokyonightNight'); + const light = paletteFromTheme(builtinTheme('solarizedLight'), 'builtin:solarizedLight'); + const nvim = neovimColorscheme(dark); + assert.ok(validateNeovimColorscheme(nvim)); + for (const group of ['Normal', 'NormalFloat', 'Comment', 'String', 'Function', 'Keyword', 'Visual', 'Search', 'Pmenu', 'PmenuSel', 'StatusLine', 'WinSeparator', 'MatchParen', + 'DiffAdd', 'DiffText', 'DiagnosticError', 'DiagnosticUnderlineWarn', 'DiagnosticSignInfo', '@comment', '@function.call', '@keyword.return', '@type.builtin']) { + assert.ok(nvim.includes(`set(0, '${group}'`), group); + } + assert.match(nvim, /set\(0, '@function', \{link = 'Function'\}\)/u, 'Tree-sitter captures link onto base groups'); + assert.match(nvim, /vim\.o\.background = 'dark'/u); + assert.match(neovimColorscheme(light), /vim\.o\.background = 'light'/u); + assert.equal(validateNeovimColorscheme(`${nvim}os.execute('rm -rf ~')\n`), false, 'nothing but highlight data validates'); + const vim = vimColorscheme(dark); + assert.ok(validateVimColorscheme(vim)); + for (const group of ['Normal', 'Comment', 'Constant', 'Exception', 'Typedef', 'Todo', 'CursorLineNr', 'PmenuSel', 'StatusLineNC', 'VertSplit', 'DiffDelete']) assert.match(vim, new RegExp(`^hi ${group} `, 'mu'), group); + assert.doesNotMatch(vim, /@|nvim_|Diagnostic|NormalFloat|WinSeparator/u, 'no Neovim-only groups or APIs in the Vim file'); + assert.match(vim, /^hi Comment guifg=#[0-9a-f]{6} guibg=NONE ctermfg=\d{1,3} ctermbg=NONE/mu, 'truecolor plus a 256-color fallback'); + assert.equal(validateVimColorscheme(`${vim}!rm -rf ~\n`), false); + const vimBinary = ['/usr/bin/vim', '/opt/homebrew/bin/vim', '/usr/local/bin/vim'].find(existsSync); + if (!vimBinary) return; + const box = sandbox(); + try { + mkdirSync(join(box.root, 'colors')); + writeFileSync(join(box.root, 'colors', 'nmsh-bridge.vim'), vim); + const result = spawnSync(vimBinary, ['-u', 'NONE', '-N', '-es', '-c', `set rtp+=${box.root}`, '-c', 'colorscheme nmsh-bridge', '-c', 'redir => x | silent hi Keyword | redir END | put =x | %print | qa!'], + {encoding: 'utf8', env: {...process.env, HOME: box.home}}); + assert.match(result.stdout, /Keyword\s+xxx .*guifg=#[0-9a-f]{6}/u, result.stderr); + } finally { box.done(); } +}); + +test('applyThemeBridge: isolated per target; independent targets get nothing; failures are reported, others proceed', async () => { + const box = sandbox(); + try { + let config = configWith({pager: {mode: 'follow'}, tmux: {mode: 'follow'}, vim: {mode: 'choose', theme: 'builtin:nord'}, neovim: {mode: 'independent'}}); + // Something that is not NMSh's sits at the tmux path. + mkdirSync(join(box.env.XDG_CONFIG_HOME!, 'nmsh', 'theme-bridge', 'tmux'), {recursive: true}); + writeFileSync(artifactPath('tmux', box.env), 'set -g status-style bg=red\n'); + const outcomes = await applyThemeBridge(context(config, box.env)); + assert.equal(outcomes.find(outcome => outcome.target === 'tmux')?.ok, false); + assert.equal(readFileSync(artifactPath('tmux', box.env), 'utf8'), 'set -g status-style bg=red\n', 'never overwritten'); + assert.ok(outcomes.find(outcome => outcome.target === 'vim')?.ok); + assert.ok(existsSync(artifactPath('vim', box.env))); + assert.equal(existsSync(artifactPath('neovim', box.env)), false, 'Independent: no artifact'); + assert.match(readFileSync(bridgeEnvPath('zsh', box.env), 'utf8'), /nmsh_bridge_apply LESS_TERMCAP_md/u); + assert.deepEqual(readdirSync(box.home), [], 'no user config was touched'); + // Switching Vim to Independent removes the owned artifact; the env clears the pager values. + config = configWith({vim: {mode: 'independent'}}); + await applyThemeBridge(context(config, box.env)); + assert.equal(existsSync(artifactPath('vim', box.env)), false); + assert.doesNotMatch(readFileSync(bridgeEnvPath('bash', box.env), 'utf8'), /nmsh_bridge_apply/u); + assert.equal((await reloadTmux(box.env)).ok, false, 'no reload without an owned fragment (and no tmux on this PATH)'); + } finally { box.done(); } +}); + +test('reports: factual statuses; bat and delta are honest about unsupported custom themes', () => { + const config = configWith({fzf: {mode: 'follow'}, bat: {mode: 'follow'}}); + const reports = reportTargets(context(config, {PATH: ''}, installed('fzf', 'bat', 'delta'))); + const by = (target: BridgeTargetId) => reports.find(report => report.target === target)!; + assert.equal(by('fzf').status, 'Following NMSh'); + assert.equal(by('bat').status, 'Unsupported capability'); + assert.deepEqual(by('bat').modes, ['independent']); + assert.match(by('bat').notes.join(' '), /theme cache/u); + assert.match(by('delta').notes.join(' '), /gitconfig/u); + assert.equal(by('tmux').status, 'Not installed'); + assert.equal(by('pager').status, 'Not installed'); + const colorless = reportTargets({...context(config, {PATH: '', NO_COLOR: '1'}, installed('fzf'))}).find(report => report.target === 'fzf')!; + assert.match(colorless.notes.join(' '), /NO_COLOR/u); +}); diff --git a/tests/themeBridgeLive.test.ts b/tests/themeBridgeLive.test.ts new file mode 100644 index 00000000..52cd7f41 --- /dev/null +++ b/tests/themeBridgeLive.test.ts @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readdirSync} from 'node:fs'; +import {join} from 'node:path'; +import {spawnSync} from 'node:child_process'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +/** + * End to end through a real NMSh frontend and real shells: Prompt None + * runs commands with composer-only presentation and promptless history, and + * Theme Bridge environment values reach the managed shell through the + * adapter-owned prompt hook, without any rc file being created or edited. + */ + +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); + +for (const shell of ['zsh', 'bash', 'fish'] as const) { + const available = shell === 'zsh' || (shell === 'bash' ? bash : fish); + test(`live ${shell}: Prompt None executes normally; Theme Bridge pager values reach the shell; no rc file is touched`, {skip: available ? false : `${shell} not installed`, timeout: 90_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell, themeBridge: {enabled: true, targets: {pager: {mode: 'follow'}}}}, + shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + try { + const frontend = sandbox.launch(); + await frontend.waitFor(/Vespyr|notMyShell|zsh|bash|fish/u); + const envFile = join(sandbox.config, 'nmsh', 'theme-bridge', `environment.${shell}`); + await until(() => existsSync(envFile), 20_000, 'the Theme Bridge environment file'); + await frontend.run('true', /true/u); + const command = shell === 'fish' ? `printf 'gs=%s\\n' "$GROFF_NO_SGR"` : `printf 'gs=%s\\n' "\${GROFF_NO_SGR-unset}"`; + await frontend.run(command, /gs=1/u); + const records = (await sandbox.transcripts().list()).flatMap(session => session.transcript.records).filter(record => record.command === command); + assert.ok(records.length > 0); + assert.equal(records[0]!.historicalContext?.prompt, undefined, 'Prompt None stores no prompt snapshot'); + assert.equal(records[0]!.historicalContext?.promptless, true); + const home = readdirSync(sandbox.home).filter(name => /^\.(?:zshrc|zshenv|zprofile|bashrc|bash_profile|profile|vimrc|tmux\.conf)$/u.test(name)); + assert.deepEqual(home, [], 'no shell rc or tool config was created'); + assert.equal(existsSync(join(sandbox.home, '.config', 'fish', 'config.fish')), false); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/themeImporters.test.ts b/tests/themeImporters.test.ts new file mode 100644 index 00000000..fed80fce --- /dev/null +++ b/tests/themeImporters.test.ts @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {importThemeSource, IMPORT_SIZE_LIMIT, WEZTERM_LUA_GUIDANCE, type ImportOutcome, type ImportPreview} from '../src/appearance/themeImporters.js'; +import {builtinTheme} from '../src/appearance/themeRefs.js'; +import {themeDefaults} from '../src/appearance/ThemeStudio.js'; +import {validateTheme} from '../src/appearance/customTheme.js'; + +/** + * Importer fixtures. Every format is data only: these fixtures include + * template syntax, include directives, entities and Lua, and the tests prove + * none of it is followed, executed or turned into an invented color. + */ + +const DARK16 = ['#1e1e2e', '#f38ba8', '#a6e3a1', '#f9e2af', '#89b4fa', '#cba6f7', '#94e2d5', '#bac2de', + '#585b70', '#f37799', '#89d88b', '#ebd391', '#74a8fc', '#f2aede', '#6bd7ca', '#a6adc8']; +const LIGHT16 = ['#5c5f77', '#d20f39', '#40a02b', '#df8e1d', '#1e66f5', '#ea76cb', '#179299', '#acb0be', + '#6c6f85', '#de293e', '#49af3d', '#eea02d', '#456eff', '#fe85d8', '#2d9fa8', '#bcc0cc']; + +const run = (text: string, file: string, format: Parameters[4] = 'auto') => + importThemeSource(text, file, themeDefaults(), builtinTheme('lavender'), format); +const preview = (outcome: ImportOutcome): ImportPreview => { assert.ok(!('errors' in outcome), JSON.stringify(outcome)); return outcome as ImportPreview; }; +const errors = (outcome: ImportOutcome): string => { assert.ok('errors' in outcome, 'expected an error'); return (outcome as {errors: string[]}).errors.join(' '); }; +const valid = (result: ImportPreview) => assert.ok(validateTheme(result.theme).ok, 'generated roles validate as an NMSh theme'); + +const kitty = (colors: string[], extra = '') => `## name: Kitty\u0007 Mocha\n${extra}foreground #cdd6f4\nbackground ${colors[0]}\nselection_background #585b70\ncursor #f5e0dc\n${colors.map((hex, index) => `color${index} ${hex}`).join('\n')}\n`; +const ghostty = (colors: string[], extra = '') => `${extra}background = ${colors[0]!.slice(1)}\nforeground = cdd6f4\nselection-background = 585b70\n${colors.map((hex, index) => `palette = ${index}=${hex}`).join('\n')}\n`; +const plistColor = (hex: string) => { + const [r, g, b] = [1, 3, 5].map(index => (parseInt(hex.slice(index, index + 2), 16) / 255).toFixed(6)); + return `Color SpacesRGBRed Component${r}Green Component${g}Blue Component${b}`; +}; +const iterm = (colors: string[], head = '\n') => + `${head}\n${colors.map((hex, index) => `Ansi ${index} Color${plistColor(hex)}`).join('')}Background Color${plistColor(colors[0]!)}Foreground Color${plistColor('#cdd6f4')}Selection Color${plistColor('#585b70')}Badge Color${plistColor('#ff0000')}`; +const wezterm = (colors: string[], extra = '') => `[metadata]\nname = "Wez Mocha"\n\n[colors]\nforeground = "#cdd6f4"\nbackground = "${colors[0]}"\nselection_bg = "#585b70"\ncursor_bg = "#f5e0dc"\nansi = [${colors.slice(0, 8).map(hex => `"${hex}"`).join(', ')}]\nbrights = [${colors.slice(8).map(hex => `"${hex}"`).join(', ')}]\n${extra}`; +const base24 = (overrides: Record = {}) => { + const keys = [...Array.from({length: 16}, (_, index) => `base0${index.toString(16).toUpperCase()}`), ...Array.from({length: 8}, (_, index) => `base1${index}`)]; + const values = ['#282c34', '#3f4451', '#4f5666', '#545862', '#9196a1', '#abb2bf', '#e6e6e6', '#ffffff', '#e06c75', '#d19a66', '#e5c07b', '#98c379', '#56b6c2', '#61afef', '#c678dd', '#be5046', + '#21252b', '#181a1f', '#ff7b86', '#efb074', '#b1e18b', '#63d4e0', '#67cdff', '#e48bff']; + const palette = Object.fromEntries(keys.map((key, index) => [key, values[index]!])); + return `system: "base24"\nname: "One Dark Base24"\nvariant: "dark"\npalette:\n${Object.entries({...palette, ...overrides}).map(([key, value]) => ` ${key}: "${value}"`).join('\n')}\n`; +}; + +test('Kitty: declarative palette imported; includes and non-color directives are not interpreted', () => { + const result = preview(run(kitty(DARK16, 'include ~/.config/kitty/evil.conf\nmap ctrl+t launch rm -rf ~\nshell /bin/sh -c "curl x | sh"\n'), 'mocha.conf')); + assert.equal(result.format, 'kitty'); + assert.equal(result.theme.name, 'Kitty Mocha', 'control characters never reach a name'); + assert.equal(result.theme.terminal?.background, '#1e1e2e'); + assert.equal(result.theme.prompt.failure, '#f38ba8'); + assert.equal(result.theme.dark, true); + assert.ok(result.warnings.some(warning => /include directive was not followed/u.test(warning))); + assert.ok(result.warnings.some(warning => /Non-color Kitty settings were not imported: .*map.*shell/u.test(warning))); + assert.ok(result.warnings.some(warning => /not a lossless conversion/u.test(warning))); + valid(result); + assert.match(errors(run(kitty(DARK16.slice(0, 10)), 'short.conf', 'kitty')), /missing color10/u); + const light = preview(run(kitty(LIGHT16).replace('background #5c5f77', 'background #eff1f5'), 'latte.conf', 'kitty')); + assert.equal(light.theme.dark, false); + assert.ok(light.warnings.some(warning => /light scheme/u.test(warning))); +}); + +test('Ghostty: strict allowlist; unrelated config is ignored and reported; config-file is never followed', () => { + const result = preview(run(ghostty(DARK16, 'font-family = JetBrains Mono\ncommand = /bin/sh -c evil\nconfig-file = ~/.config/ghostty/other\nkeybind = ctrl+a=reload_config\n'), 'Catppuccin Mocha')); + assert.equal(result.format, 'ghostty'); + assert.equal(result.theme.name, 'Catppuccin Mocha'); + assert.ok(result.warnings.some(warning => /config-file includes were not followed/u.test(warning))); + assert.ok(result.warnings.some(warning => /non-color options were ignored: .*font-family.*command.*keybind/u.test(warning))); + assert.deepEqual(result.theme.terminal?.ansi, DARK16); + valid(result); + assert.match(errors(run('background = 000000\nforeground = ffffff\n', 'x', 'ghostty')), /missing color0/u); + assert.match(errors(run(ghostty(DARK16).replace('palette = 3=#f9e2af', 'palette = 3=yellow'), 'named', 'ghostty')), /missing color3/u, 'names are not guessed'); +}); + +test('iTerm2: bounded plist parsing, entities and internal DTD subsets rejected, nothing external resolved', () => { + const result = preview(run(iterm(DARK16), 'Mocha.itermcolors')); + assert.equal(result.format, 'iterm2'); + assert.equal(result.theme.terminal?.ansi[1], '#f38ba8'); + assert.equal(result.theme.terminal?.selectionBackground, '#585b70'); + assert.ok(result.warnings.some(warning => /iTerm2-only colors .*Badge Color/u.test(warning))); + valid(result); + const entity = iterm(DARK16, '\n]>'); + assert.match(errors(run(entity, 'evil.itermcolors')), /entity declarations are not accepted/u); + const bomb = ']>'; + assert.match(errors(run(bomb, 'bomb.itermcolors')), /entity declarations/u); + assert.match(errors(run('Ansi 0 Color', 'broken.itermcolors')), /iTerm2/u); + assert.match(errors(run(iterm(DARK16.slice(0, 15)), 'partial.itermcolors')), /missing color15/u); +}); + +test('WezTerm: declarative TOML accepted; Lua is rejected with guidance and never evaluated', () => { + const result = preview(run(wezterm(DARK16, '[keys]\nleader = "CTRL+a"\n'), 'mocha.toml')); + assert.equal(result.format, 'wezterm'); + assert.equal(result.theme.name, 'Wez Mocha'); + assert.ok(result.warnings.some(warning => /Non-color tables were ignored: keys/u.test(warning))); + valid(result); + for (const [text, file] of [['local wezterm = require "wezterm"\nreturn {color_scheme = "x"}', '.wezterm.lua'], ['return { colors = {} }', 'theme.lua']]) { + assert.equal(errors(run(text, file)), WEZTERM_LUA_GUIDANCE); + } + assert.match(errors(run(wezterm(DARK16).replace(/brights = .*\n/u, ''), 'nobrights.toml', 'wezterm')), /missing color8/u); + assert.match(errors(run('[colors\nbroken', 'bad.toml', 'wezterm')), /Not valid TOML/u); +}); + +test('Oh My Posh JSON/YAML/TOML: literal and static palette colors only; templates, named colors and inheritance warn', () => { + const json = JSON.stringify({$schema: 'https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json', palette: {git: '#ff9248', blue: 'p:git'}, + palettes: {template: '{{ if .Env.DARK }}dark{{ end }}', list: {dark: {git: '#000000'}}}, + blocks: [{type: 'prompt', segments: [ + {type: 'path', style: 'powerline', background: '#61AFEF', foreground: '#ffffff', template: '{{ .Path }}'}, + {type: 'git', background: 'p:blue', background_templates: ['{{ if .Working.Changed }}#ff0000{{ end }}'], foreground_templates: ['{{ .Env.X }}']}, + {type: 'node', background: 'lightGreen'}, + {type: 'command', properties: {command: 'curl evil | sh'}, background: '#123456'}, + {type: 'exit', background: 'p:missing'}, + ]}]}); + const result = preview(run(json, 'my.omp.json')); + assert.equal(result.format, 'oh-my-posh'); + assert.equal(result.theme.prompt.cwd, '#61afef'); + assert.equal(result.theme.prompt.gitBranch, '#ff9248', 'nested static palette references resolve'); + assert.equal(result.theme.prompt.node, builtinTheme('lavender').prompt.node, 'an unresolved named color keeps the base color, never a guess'); + assert.ok(result.warnings.some(warning => /dynamic color template.* ignored/u.test(warning))); + assert.ok(result.warnings.some(warning => /Conditional palettes/u.test(warning))); + assert.ok(result.warnings.some(warning => /not static hex values.*lightGreen.*p:missing/u.test(warning))); + assert.ok(result.warnings.some(warning => /prompt logic, segments and templates are not imported/u.test(warning))); + assert.equal(result.theme.terminal, undefined); + valid(result); + const yaml = `# yaml-language-server: $schema=https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json\npalette:\n accent: "#c678dd"\nblocks:\n - type: prompt\n segments:\n - type: session\n background: p:accent\n - type: python\n background: "#ffd43b"\n`; + const fromYaml = preview(run(yaml, 'theme.omp.yaml')); + assert.equal(fromYaml.theme.prompt.project, '#c678dd'); + assert.equal(fromYaml.theme.prompt.python, '#ffd43b'); + const toml = `"$schema" = "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json"\nextends = "https://example.com/remote.omp.json"\n[palette]\ngo = "#00add8"\n[[blocks]]\ntype = "prompt"\n[[blocks.segments]]\ntype = "go"\nbackground = "p:go"\n`; + const fromToml = preview(run(toml, 'theme.omp.toml')); + assert.equal(fromToml.theme.prompt.go, '#00add8'); + assert.ok(fromToml.warnings.some(warning => /Inherited or remote configuration is never fetched/u.test(warning))); + assert.match(errors(run(JSON.stringify({blocks: [{segments: [{type: 'path', background: '{{ .Env.COLOR }}'}]}]}), 'dynamic.omp.json')), /No static Oh My Posh segment colors/u); +}); + +test('Base24: all 24 slots required, spec ANSI mapping, nothing guessed', () => { + const result = preview(run(base24(), 'one-dark.yaml')); + assert.equal(result.format, 'base24'); + assert.equal(result.theme.name, 'One Dark Base24'); + assert.deepEqual(result.theme.terminal?.ansi.slice(0, 2), ['#282c34', '#e06c75'], 'color0 = base00, color1 = base08'); + assert.equal(result.theme.terminal?.ansi[9], '#ff7b86', 'bright red = base12'); + assert.equal(result.theme.terminal?.ansi[12], '#67cdff', 'bright blue = base16'); + valid(result); + const missing = base24({base17: 'not-a-color'}); + assert.match(errors(run(missing, 'broken.yaml', 'base24')), /missing base17/u); +}); + +test('Base16, Windows Terminal and NMSh JSON keep their behavior', () => { + const base16 = 'scheme: "Tomorrow Night"\n' + ['1d1f21', '282a2e', '373b41', '969896', 'b4b7b4', 'c5c8c6', 'e0e0e0', 'ffffff', 'cc6666', 'de935f', 'f0c674', 'b5bd68', '8abeb7', '81a2be', 'b294bb', 'a3685a'] + .map((hex, index) => `base0${index.toString(16).toUpperCase()}: "${hex}"`).join('\n'); + const b16 = preview(run(base16, 'tomorrow.yaml')); + assert.equal(b16.format, 'base16'); + assert.equal(b16.theme.prompt.failure, '#cc6666'); + const keys = ['black', 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'white', 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightPurple', 'brightCyan', 'brightWhite']; + const wt = preview(run(JSON.stringify({name: 'Campbell', background: '#0c0c0c', foreground: '#cccccc', ...Object.fromEntries(keys.map((key, index) => [key, DARK16[index]]))}), 'campbell.json')); + assert.equal(wt.format, 'windows-terminal'); + assert.equal(wt.theme.terminal?.background, '#0c0c0c'); + const native = preview(run(JSON.stringify(builtinTheme('nord')).replace('"Nord"', '"Nord copy"'), 'x.json')); + assert.equal(native.format, 'nmsh'); + assert.match(errors(run('{"schema":"nmsh-theme","version":1,"name":"x","prompt":{},"ui":{}}', 'bad.json')), /prompt\.project/u); +}); + +test('bounded and binary input; unknown formats are refused with the supported list', () => { + assert.match(errors(run('x'.repeat(IMPORT_SIZE_LIMIT + 1), 'big.conf')), /larger than 256 KiB/u); + assert.match(errors(run('a\u0000b', 'bin')), /Binary/u); + assert.match(errors(run('just some words\nnothing here', 'notes.txt')), /not a recognized theme format/u); + assert.match(errors(run('a: [unclosed', 'bad.yaml')), /not a recognized theme format/u); +}); diff --git a/tests/themeLibrary.test.ts b/tests/themeLibrary.test.ts new file mode 100644 index 00000000..3fdb38a5 --- /dev/null +++ b/tests/themeLibrary.test.ts @@ -0,0 +1,171 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, savePromptConfiguration, loadPromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; +import { + categoryOf, LEGACY_THEME_ID, librarySummary, normalizeThemeLibrary, provenanceLabel, THEME_LIBRARY_LIMIT, uniqueThemeName, +} from '../src/appearance/themeLibrary.js'; +import {addTheme, deleteCheck, deleteTheme, duplicateBuiltin, duplicateTheme, renameTheme, saveTheme, setActiveTheme} from '../src/appearance/themeLibraryActions.js'; +import {activeThemeRef, assetRef, builtinRef, builtinTheme, parseThemeRef, selectableThemes, themeForRef} from '../src/appearance/themeRefs.js'; +import {resolveSemanticPalette} from '../src/appearance/semanticPalette.js'; +import {exportTheme, type CustomTheme} from '../src/appearance/customTheme.js'; +import {exportSettings} from '../src/configuration/portability.js'; +import {writeThemeExport} from '../src/appearance/ThemeStudio.js'; +import {currentSelectionFamily, selectionFamilies, selectionVariants, selectSelectionFamily} from '../src/appearance/themeSelection.js'; + +const base = (): PromptConfiguration => structuredClone(DEFAULT_PROMPT_CONFIGURATION); +const theme = (name: string, project = '#123456'): CustomTheme => ({...builtinTheme('nord'), name, prompt: {...builtinTheme('nord').prompt, project}}); +const ok = (result: T): Extract => { assert.ok(result.ok, JSON.stringify(result)); return result as Extract; }; +const ORIGIN = {kind: 'ghostty' as const, sourceName: 'Mocha', sourcePath: '/Users/someone/private/themes/mocha', importerVersion: 2, importedAt: '2026-10-05T00:00:00.000Z'}; + +test('migration: a valid legacy customTheme becomes one active Custom asset; nothing is lost', () => { + const legacy = theme('Mine'); + const migrated = normalizePromptConfiguration({customTheme: legacy, nmsh: {palette: 'custom'}}); + assert.equal(migrated.themes.length, 1); + assert.equal(migrated.themes[0]!.id, LEGACY_THEME_ID); + assert.equal(categoryOf(migrated.themes[0]!), 'custom'); + assert.equal(migrated.nmsh.palette, 'custom', 'it stays active'); + assert.equal(migrated.nmsh.themeId, LEGACY_THEME_ID); + assert.deepEqual(migrated.customTheme, migrated.themes[0]!.theme, 'the mirror is the active asset'); + // Inactive legacy theme: kept, built-in stays active. + const inactive = normalizePromptConfiguration({customTheme: legacy, nmsh: {palette: 'nord'}}); + assert.equal(inactive.nmsh.palette, 'nord'); + assert.equal(inactive.themes[0]!.theme.name, 'Mine'); + // Idempotent: normalizing again keeps the same stable id. + assert.deepEqual(normalizePromptConfiguration(migrated).themes, migrated.themes); + // Malformed legacy data falls back safely. + const broken = normalizePromptConfiguration({customTheme: {schema: 'nmsh-theme', version: 1, name: 'x', prompt: {}, ui: {}}, nmsh: {palette: 'custom'}}); + assert.equal(broken.themes.length, 0); + assert.equal(broken.nmsh.palette, 'lavender'); +}); + +test('library is canonical: the mirror follows the asset, a stale stored customTheme is ignored once a library exists', () => { + const config = ok(addTheme(base(), theme('A'), undefined, true)).config; + const stale = normalizePromptConfiguration({...config, customTheme: theme('Stale', '#000000')}); + assert.equal(stale.customTheme?.name, 'A'); + const dir = mkdtempSync(join(tmpdir(), 'nmsh-lib-')); + try { + const path = join(dir, 'config.json'); + savePromptConfiguration(config, path); + const loaded = loadPromptConfiguration(path); + assert.deepEqual(loaded.themes, config.themes); + assert.equal(loaded.nmsh.themeId, config.nmsh.themeId); + } finally { rmSync(dir, {recursive: true, force: true}); } +}); + +test('multiple Custom and Imported themes: stable ids, categories, unique names, selection by id', () => { + let config = base(); + const ids: string[] = []; + for (const [name, origin] of [['One', undefined], ['Two', undefined], ['Imp', ORIGIN], ['Imp', ORIGIN]] as const) { + const result = ok(addTheme(config, theme(name), origin)); + config = result.config; + ids.push(result.id!); + } + assert.equal(new Set(ids).size, 4); + assert.ok(ids.every(id => /^t-[0-9a-f]{12}$/u.test(id))); + assert.deepEqual(config.themes.map(asset => categoryOf(asset)), ['custom', 'custom', 'imported', 'imported']); + assert.deepEqual(config.themes.map(asset => asset.theme.name), ['One', 'Two', 'Imp', 'Imp 2'], 'display names are unique but never identity'); + assert.equal(librarySummary(config.themes), '2 imported · 2 custom'); + config = ok(setActiveTheme(config, assetRef(ids[2]!))).config; + assert.equal(activeThemeRef(config), assetRef(ids[2]!)); + assert.equal(config.customTheme?.name, 'Imp'); + config = ok(renameTheme(config, ids[2]!, 'Renamed')).config; + assert.equal(config.themes.find(asset => asset.id === ids[2])?.theme.name, 'Renamed', 'rename keeps the id'); + assert.equal(activeThemeRef(config), assetRef(ids[2]!), 'selection survives a rename'); + assert.equal(uniqueThemeName(config.themes, 'one'), 'one 2'); +}); + +test('imported themes are Native: editing marks Modified; duplicate is Custom; selectable everywhere', () => { + let config = ok(addTheme(base(), theme('Mocha'), ORIGIN)).config; + const id = config.themes[0]!.id; + assert.equal(provenanceLabel(config.themes[0]!), 'Imported from Ghostty · Mocha'); + config = ok(saveTheme(config, id, {...config.themes[0]!.theme, prompt: {...config.themes[0]!.theme.prompt, gitBranch: '#ff0000'}})).config; + assert.equal(config.themes[0]!.modified, true); + assert.equal(provenanceLabel(config.themes[0]!), 'Imported from Ghostty · Mocha · Modified'); + const copy = ok(duplicateTheme(config, id)); + assert.equal(categoryOf(copy.config.themes.find(asset => asset.id === copy.id)!), 'custom'); + assert.equal(copy.config.themes.find(asset => asset.id === copy.id)!.theme.basedOn, 'Mocha'); + const refs = selectableThemes(copy.config); + assert.ok(refs.some(item => item.category === 'imported' && item.ref === assetRef(id))); + assert.ok(refs.some(item => item.category === 'custom')); + assert.ok(refs.some(item => item.category === 'builtin' && item.ref === 'builtin:gruvboxDark')); + const fromBuiltin = ok(duplicateBuiltin(copy.config, builtinRef('gruvboxDark'))); + assert.equal(fromBuiltin.config.themes.at(-1)!.theme.name, 'My Gruvbox Dark'); +}); + +test('portable exports never leak the local source path', () => { + const config = ok(addTheme(base(), theme('Mocha'), ORIGIN)).config; + const asset = config.themes[0]!; + assert.doesNotMatch(exportTheme(asset.theme), /private|sourcePath|\/Users\//u); + const dir = mkdtempSync(join(tmpdir(), 'nmsh-export-')); + try { + const path = writeThemeExport(asset.theme, dir); + assert.doesNotMatch(readFileSync(path, 'utf8'), /sourcePath|private/u); + } finally { rmSync(dir, {recursive: true, force: true}); } + const settings = JSON.stringify(exportSettings(config, ['theme'])); + assert.doesNotMatch(settings, /sourcePath|\/Users\/someone/u, 'settings transfer drops the path'); + assert.match(settings, /"kind":"ghostty"/u, 'non-sensitive provenance kind is kept'); +}); + +test('deleting: the active theme is refused; Theme Bridge pins block until confirmed, then become Independent', () => { + let config = ok(addTheme(base(), theme('Pinned'))).config; + const id = config.themes[0]!.id; + config = ok(setActiveTheme(config, assetRef(id))).config; + assert.equal(deleteTheme(config, id).ok, false, 'active theme is never deleted'); + config = ok(setActiveTheme(config, 'builtin:nord')).config; + config.themeBridge = {enabled: true, targets: {...config.themeBridge.targets, tmux: {mode: 'choose', theme: assetRef(id)}, vim: {mode: 'follow'}}}; + assert.deepEqual(deleteCheck(config, id).pinned, ['tmux']); + const blocked = deleteTheme(config, id); + assert.equal(blocked.ok, false); + assert.match(blocked.ok ? '' : blocked.error, /tmux is pinned/u); + const deleted = ok(deleteTheme(config, id, true)); + assert.equal(deleted.config.themes.length, 0); + assert.deepEqual(deleted.config.themeBridge.targets.tmux, {mode: 'independent'}, 'Independent, never another theme'); + assert.equal(deleted.config.themeBridge.targets.vim.mode, 'follow', 'other targets untouched'); + assert.equal(resolveSemanticPalette(assetRef(id), deleted.config).ok, false, 'the old reference resolves to nothing'); +}); + +test('missing references are reported, never replaced; bad refs are rejected', () => { + assert.deepEqual(themeForRef('asset:t-000000000000', {themes: []}), {ok: false, reason: 'missing'}); + assert.deepEqual(themeForRef('builtin:notATheme', {themes: []}), {ok: false, reason: 'invalid'}); + assert.equal(parseThemeRef('asset:../../etc'), undefined); + assert.deepEqual(parseThemeRef('builtin:catppuccinMocha@peach'), {kind: 'builtin', palette: 'catppuccinMocha', accent: 'peach'}); + assert.equal(builtinRef('catppuccinMocha', 'mauve'), 'builtin:catppuccinMocha', 'the default accent is implicit'); + assert.equal(builtinRef('nord', 'peach'), 'builtin:nord', 'accents only apply to accented families'); +}); + +test('malformed library entries are dropped individually; the library is bounded', () => { + const good = {id: 't-aaaaaaaaaaaa', theme: theme('Good')}; + const library = normalizeThemeLibrary([good, {id: 'bad id!', theme: theme('X')}, {id: 't-bbbbbbbbbbbb', theme: {name: 'broken'}}, good, 'nope'], undefined, 't-aaaaaaaaaaaa'); + assert.deepEqual(library.themes.map(asset => asset.id), ['t-aaaaaaaaaaaa']); + assert.equal(library.themeId, 't-aaaaaaaaaaaa'); + const many = Array.from({length: THEME_LIBRARY_LIMIT + 10}, (_, index) => ({id: `t-${index.toString(16).padStart(12, '0')}`, theme: theme(`T${index}`)})); + assert.equal(normalizeThemeLibrary(many, undefined, undefined).themes.length, THEME_LIBRARY_LIMIT); + let config = normalizePromptConfiguration({themes: many}); + const full = addTheme(config, theme('One more')); + assert.equal(full.ok, false); + assert.match(full.ok ? '' : full.error, /library is full/u); + config = normalizePromptConfiguration({themes: [{id: 't-cccccccccccc', theme: theme('A'), origin: {kind: 'evil'}, modified: true}]}); + assert.equal(categoryOf(config.themes[0]!), 'custom', 'an unknown origin kind is not provenance'); + assert.equal(config.themes[0]!.modified, undefined); +}); + +test('Setup/Settings selection: built-in families, Imported and Custom groups, variants by stable id', () => { + let config = ok(addTheme(base(), theme('Custom A'))).config; + config = ok(addTheme(config, theme('Imported B'), ORIGIN)).config; + const families = selectionFamilies(config); + assert.ok(families.includes('nmsh') && families.includes('gruvbox')); + assert.deepEqual(families.slice(-2), ['imported', 'custom']); + config = selectSelectionFamily(config, 'imported'); + assert.equal(currentSelectionFamily(config), 'imported'); + assert.equal(config.nmsh.palette, 'custom'); + assert.equal(config.customTheme?.name, 'Imported B'); + const variants = selectionVariants(config, 'custom'); + config = variants[0]!.apply(config); + assert.equal(currentSelectionFamily(config), 'custom'); + config = selectSelectionFamily(config, 'gruvbox'); + assert.equal(config.nmsh.palette, 'gruvboxDark'); + assert.equal(config.themes.length, 2, 'selection never deletes or copies library themes'); +}); diff --git a/tests/themeUi.test.ts b/tests/themeUi.test.ts new file mode 100644 index 00000000..d549b26f --- /dev/null +++ b/tests/themeUi.test.ts @@ -0,0 +1,190 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync} from 'node:fs'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import type {TerminalFrame} from '../src/terminal/TerminalRenderer.js'; +import {DEFAULT_PROMPT_CONFIGURATION, loadPromptConfiguration, normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {applyUiTheme} from '../src/appearance/uiTheme.js'; +import {createThemeStudio, renderThemeStudio, studioKey, STUDIO_TABS, type StudioContext} from '../src/appearance/ThemeStudio.js'; +import {builtinTheme} from '../src/appearance/themeRefs.js'; +import type {ThemeAsset} from '../src/appearance/themeLibrary.js'; +import {createThemeBridgePanel, renderThemeBridgePanel, themeBridgeKey, type BridgePanelContext} from '../src/themeBridge/ThemeBridgePanel.js'; +import {BRIDGE_TARGETS} from '../src/themeBridge/model.js'; +import type {TargetReport} from '../src/themeBridge/runtime.js'; +import {createSetup, renderSetup, sectionIndex, setupKey} from '../src/setup/SetupCat.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {bridgeEnvPath} from '../src/themeBridge/environment.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const plain = (rows: string[]) => rows.map(stripAnsi).join('\n'); +const assets: ThemeAsset[] = [ + {id: 't-aaaaaaaaaaaa', theme: {...builtinTheme('dracula'), name: 'My Dracula'}}, + {id: 't-bbbbbbbbbbbb', theme: {...builtinTheme('nord'), name: 'Mocha (Ghostty)'}, origin: {kind: 'ghostty', sourceName: 'Mocha', sourcePath: '/secret/path'}, modified: true}, +]; +const context = (overrides: Partial = {}): StudioContext => ({themes: assets, accent: 'mauve', activeRef: 'builtin:lavender', pinnedTo: () => [], ...overrides}); + +test('/theme: shared tab strip Built-in | Imported | Custom | Import; ←→ switch tabs; ↑ at the top focuses the tabs', () => { + assert.deepEqual([...STUDIO_TABS], ['Built-in', 'Imported', 'Custom', 'Import']); + const state = createThemeStudio(context()); + const first = plain(renderThemeStudio(state, context(), 100, 40, 'truecolor', [])); + assert.match(first, /Built-in\s+Imported\s+Custom\s+Import/u); + assert.match(first, /Lavender Native.*● active/u); + assert.match(first, /Built-ins are immutable/u); + studioKey(state, {kind: 'right'}, 'truecolor', '/', context()); + assert.equal(state.tab, 'imported'); + const imported = plain(renderThemeStudio(state, context(), 100, 40, 'truecolor', [])); + assert.match(imported, /Mocha \(Ghostty\)/u); + assert.match(imported, /Imported from Ghostty · Mocha · Modified/u); + assert.doesNotMatch(imported, /secret/u, 'local source paths are not displayed in the list'); + studioKey(state, {kind: 'up'}, 'truecolor', '/', context()); + assert.equal(state.focus, 'tabs'); + studioKey(state, {kind: 'down'}, 'truecolor', '/', context()); + assert.equal(state.focus, 'list'); + studioKey(state, {kind: 'right'}, 'truecolor', '/', context()); + assert.equal(state.tab, 'custom'); + assert.match(plain(renderThemeStudio(state, context(), 100, 40, 'truecolor', [])), /+ New custom theme[\s\S]*My Dracula/u); +}); + +test('/theme actions: set active, edit (same editor for Imported and Custom), rename, duplicate, export, delete with confirmation', () => { + const ctx = context({pinnedTo: ref => ref === 'asset:t-bbbbbbbbbbbb' ? ['tmux'] : []}); + const state = createThemeStudio(ctx, 'imported'); + assert.deepEqual(studioKey(state, {kind: 'enter'}, 'truecolor', '/', ctx), {kind: 'activate', ref: 'asset:t-bbbbbbbbbbbb'}); + studioKey(state, {kind: 'text', value: 'e'}, 'truecolor', '/', ctx); + assert.ok(state.editor); + assert.match(plain(renderThemeStudio(state, ctx, 100, 40, 'truecolor', [])), /Edit · Mocha \(Ghostty\)[\s\S]*Imported from Ghostty/u); + studioKey(state, {kind: 'escape'}, 'truecolor', '/', ctx); + assert.equal(state.editor, undefined, 'Esc leaves the editor without saving'); + studioKey(state, {kind: 'text', value: 'n'}, 'truecolor', '/', ctx); + for (const _ of 'Mocha (Ghostty)') studioKey(state, {kind: 'backspace'}, 'truecolor', '/', ctx); + for (const character of 'Night') studioKey(state, {kind: 'text', value: character}, 'truecolor', '/', ctx); + assert.deepEqual(studioKey(state, {kind: 'enter'}, 'truecolor', '/', ctx), {kind: 'rename', id: 't-bbbbbbbbbbbb', name: 'Night'}); + assert.deepEqual(studioKey(state, {kind: 'text', value: 'd'}, 'truecolor', '/', ctx), {kind: 'duplicate', id: 't-bbbbbbbbbbbb'}); + assert.deepEqual(studioKey(state, {kind: 'text', value: 'x'}, 'truecolor', '/', ctx), {kind: 'export', id: 't-bbbbbbbbbbbb'}); + studioKey(state, {kind: 'delete'}, 'truecolor', '/', ctx); + assert.match(plain(renderThemeStudio(state, ctx, 100, 40, 'truecolor', [])), /tmux is pinned to it and will become Independent/u); + assert.deepEqual(studioKey(state, {kind: 'enter'}, 'truecolor', '/', ctx), {kind: 'delete', id: 't-bbbbbbbbbbbb', confirmIndependent: true}); + const active = context({activeRef: 'asset:t-aaaaaaaaaaaa'}); + const custom = createThemeStudio(active, 'custom'); + custom.selected.custom = 1; + studioKey(custom, {kind: 'delete'}, 'truecolor', '/', active); + assert.equal(custom.confirmDelete, undefined); + assert.match(custom.message ?? '', /active theme/u); + const builtin = createThemeStudio(context()); + assert.deepEqual(studioKey(builtin, {kind: 'text', value: 'd'}, 'truecolor', '/', context()), {kind: 'duplicateBuiltin', ref: 'builtin:lavender'}); +}); + +test('/theme Import tab: format chooser and path; cancel stores nothing', () => { + const state = createThemeStudio(context(), 'import'); + assert.equal(state.importField, 'path'); + studioKey(state, {kind: 'up'}, 'truecolor', '/', context()); + assert.equal(state.importField, 'format'); + studioKey(state, {kind: 'right'}, 'truecolor', '/', context()); + assert.match(plain(renderThemeStudio(state, context(), 100, 40, 'truecolor', [])), /Format\s+‹ NMSh Theme JSON ›/u); + studioKey(state, {kind: 'down'}, 'truecolor', '/', context()); + for (const character of '/nonexistent/theme.conf') studioKey(state, {kind: 'text', value: character}, 'truecolor', '/', context()); + assert.equal(studioKey(state, {kind: 'enter'}, 'truecolor', '/', context()), undefined); + assert.match(state.message ?? '', /Could not read/u); + state.importPreview = {format: 'kitty', theme: builtinTheme('nord'), mapping: [{role: 'Project', from: 'magenta (color5)'}], warnings: ['lossy'], path: '/x'}; + assert.match(plain(renderThemeStudio(state, context(), 100, 40, 'truecolor', [])), /Import preview · Nord[\s\S]*Project\s+← magenta[\s\S]*• lossy/u); + assert.equal(studioKey(state, {kind: 'escape'}, 'truecolor', '/', context()), undefined); + assert.equal(state.importPreview, undefined); + assert.match(state.message ?? '', /nothing was saved/u); +}); + +test('/theme and /theme-bridge fit short and narrow terminals', () => { + for (const [columns, height] of [[56, 14], [60, 18], [120, 50]] as const) { + for (const tab of ['builtin', 'imported', 'custom', 'import'] as const) { + const rows = renderThemeStudio(createThemeStudio(context(), tab), context(), columns, height, 'truecolor', [' preview row']); + assert.ok(rows.length <= height, `${tab} ${columns}x${height}`); + assert.ok(rows.every(row => displayWidth(row) <= columns)); + } + const panel = renderThemeBridgePanel(createThemeBridgePanel(), bridgeContext(), columns, height); + assert.ok(panel.length <= height && panel.every(row => displayWidth(row) <= columns)); + } +}); + +function bridgeContext(enabled = false): BridgePanelContext { + const reports: TargetReport[] = BRIDGE_TARGETS.map(target => ({target, label: target, mode: 'independent', modes: target === 'bat' || target === 'delta' ? ['independent'] : ['independent', 'follow', 'choose'], + status: 'Detected', notes: target === 'bat' ? ['bat loads custom themes only from its own theme cache'] : []})); + return {enabled, reports, themes: [{ref: 'builtin:lavender', label: 'Lavender Native', category: 'builtin'}, {ref: 'builtin:nord', label: 'Nord', category: 'builtin'}], + pinned: () => undefined, activeRef: 'builtin:lavender', managed: () => undefined}; +} + +test('/theme-bridge panel: master switch, per-target modes, Choose starts visibly on the active theme, unsupported targets explain why', () => { + const state = createThemeBridgePanel(); + const text = plain(renderThemeBridgePanel(state, bridgeContext(), 110, 40)); + assert.match(text, /Theme Bridge\s+‹ Off ›/u); + assert.match(text, /fzf\s+Independent\s+—\s+Detected/u); + assert.deepEqual(themeBridgeKey(state, {kind: 'enter'}, bridgeContext()), {kind: 'setEnabled', enabled: true}); + themeBridgeKey(state, {kind: 'down'}, bridgeContext()); + themeBridgeKey(state, {kind: 'enter'}, bridgeContext()); + assert.equal(state.detail?.target, 'fzf'); + assert.deepEqual(themeBridgeKey(state, {kind: 'right'}, bridgeContext()), {kind: 'setMode', target: 'fzf', mode: 'follow'}); + const choose = {...bridgeContext(true), reports: bridgeContext(true).reports.map(report => report.target === 'fzf' ? {...report, mode: 'follow' as const} : report)}; + assert.deepEqual(themeBridgeKey(state, {kind: 'right'}, choose), {kind: 'setMode', target: 'fzf', mode: 'choose', theme: 'builtin:lavender'}); + themeBridgeKey(state, {kind: 'escape'}, bridgeContext()); + state.selected = BRIDGE_TARGETS.indexOf('bat') + 1; + themeBridgeKey(state, {kind: 'enter'}, bridgeContext()); + assert.equal(themeBridgeKey(state, {kind: 'right'}, bridgeContext()), undefined); + assert.match(state.message ?? '', /theme cache/u); +}); + +test('Setup Appearance: one Theme Bridge question (default No) reveals only detected tools; Theme Studio row reads Open ›', () => { + const setup = createSetup(normalizePromptConfiguration({})); + setup.section = sectionIndex('appearance'); + setup.context = {...setup.context, bridgeTargets: ['fzf', 'tmux']}; + const text = plain(renderSetup(setup, 120, 80)); + assert.match(text, /Theme Studio\s+Open ›/u); + assert.doesNotMatch(text, /custom themes/u); + assert.match(text, /Extend colors to tools\?\s+‹?\s*No/u); + assert.doesNotMatch(text, /^\s+fzf\s/mu, 'no target rows while the answer is No'); + setup.draft = {...setup.draft, themeBridge: {...setup.draft.themeBridge, enabled: true}}; + const yes = plain(renderSetup(setup, 120, 80)); + assert.match(yes, /fzf\s+Independent/u); + assert.match(yes, /tmux\s+Independent/u); + assert.doesNotMatch(yes, /Neovim|LS_COLORS/u, 'tools not found on this system are not listed'); + void setupKey; +}); + +function harness(config: object): {app: TerminalApp; frames: TerminalFrame[]; cleanup: () => void; directory: string} { + const isolation = isolateConfig(); + const app = new TerminalApp(); + const frames: TerminalFrame[] = []; + app['renderer'].render = (frame: TerminalFrame) => { frames.push(frame); }; + app['fetchSuggestions'] = async () => {}; + app['presentationStarted'] = true; + app['configuration'] = normalizePromptConfiguration({...DEFAULT_PROMPT_CONFIGURATION, ...config}); + return {app, frames, directory: isolation.directory, cleanup: () => { app['stop'](0); app['session'].kill(); applyUiTheme(undefined); isolation.restore(); }}; +} + +for (const panelPosition of ['bottom', 'top'] as const) { + test(`app (${panelPosition} panels): /theme sets a theme active through the normal save path; /theme-bridge turns a tool on and writes only NMSh files`, async () => { + const {app, frames, cleanup} = harness({panelPosition, onboardingComplete: true}); + try { + await app['runSlash']('/theme', parseSlashCommand('/theme')!); + assert.ok(app['themeStudio']); + app['render'](); + assert.match(plain(frames.at(-1)!.rows), /Built-in\s+Imported\s+Custom\s+Import/u); + app['themeStudio']!.selected.builtin = 0; + app['handleThemeStudioKey']({kind: 'down'}, app['themeStudio']!); + app['handleThemeStudioKey']({kind: 'enter'}, app['themeStudio']!); + assert.equal(loadPromptConfiguration().nmsh.palette, 'brand', 'saved through the normal configuration path'); + app['handleThemeStudioKey']({kind: 'escape'}, app['themeStudio']!); + assert.equal(app['themeStudio'], undefined); + app['bridgeFacts'] = Object.fromEntries(BRIDGE_TARGETS.map(target => [target, {installed: true}])) as never; + await app['runSlash']('/theme-bridge', parseSlashCommand('/theme-bridge')!); + const panel = app['themeBridgePanel']!; + assert.ok(panel); + panel.selected = BRIDGE_TARGETS.indexOf('pager') + 1; + await app['handleThemeBridgeKey']({kind: 'enter'}, panel); + await app['handleThemeBridgeKey']({kind: 'right'}, panel); + const saved = loadPromptConfiguration(); + assert.equal(saved.themeBridge.enabled, true); + assert.equal(saved.themeBridge.targets.pager.mode, 'follow'); + assert.ok(existsSync(bridgeEnvPath('zsh')), 'the managed environment file exists under the NMSh config directory'); + app['render'](); + assert.match(plain(frames.at(-1)!.rows), /less \/ man[\s\S]*Follow NMSh/u); + } finally { cleanup(); } + }); +} From f396abef0070209d9c2479c5f53d475436ec7aed Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:18:39 +0530 Subject: [PATCH 03/16] Remove trailing blank line at end of themeImporters.ts --- src/appearance/themeImporters.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/appearance/themeImporters.ts b/src/appearance/themeImporters.ts index 2ce6685e..e2e4356e 100644 --- a/src/appearance/themeImporters.ts +++ b/src/appearance/themeImporters.ts @@ -496,4 +496,3 @@ function windowsTerminalPalette(json: Record): TerminalPalette export const IMPORT_FORMAT_CHOICES: readonly ImportFormatChoice[] = ['auto', 'nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'kitty', 'ghostty', 'iterm2', 'wezterm']; export const importFormatLabel = (format: ImportFormatChoice): string => format === 'auto' ? 'Detect automatically' : THEME_SOURCE_LABELS[format]; - From 049e56364d006531b26c307f31df598c9ca3fac5 Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:27:55 +0530 Subject: [PATCH 04/16] Theme Bridge: Helix target with a native palette-based theme and confirmed activation Generated nmsh-bridge.toml in Helix's themes directory (syntax, markup, diff, diagnostic and editor UI scopes over a named palette), ownership ledger and staged validated writes, one confirmed theme assignment placed before the first table of config.toml, never replacing a user-selected theme, exact removal, factual status, tests and docs. --- CHANGELOG.md | 2 +- README.md | 2 +- docs/design/theme-bridge.md | 4 +- src/app/TerminalApp.ts | 6 +- src/ask/concepts.ts | 4 +- src/commands/slashCommands.ts | 2 +- src/help/helpContent.ts | 2 +- src/setup/SetupCat.ts | 4 +- src/themeBridge/ThemeBridgePanel.ts | 9 +- src/themeBridge/artifacts.ts | 41 ++++++-- src/themeBridge/model.ts | 4 +- src/themeBridge/runtime.ts | 21 ++++- src/themeBridge/targets.ts | 111 ++++++++++++++++++++++ src/ui/SettingsPanel.ts | 2 +- tests/themeBridgeHelix.test.ts | 141 ++++++++++++++++++++++++++++ 15 files changed, 324 insertions(+), 31 deletions(-) create mode 100644 tests/themeBridgeHelix.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9b6cff..5ef49d21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Native theme library**: any number of Custom and Imported themes with stable ids (bounded to 64); the single custom theme migrates into it and stays active. Imported is provenance only: imported themes are ordinary Native themes you can edit, rename, duplicate, export, select and pin. - **Theme Studio** (`/theme`): Built-in · Imported · Custom · Import tabs, one editor and the real Native preview for every theme. Settings → Theme and `/setup appearance` select Built-in, Imported and Custom themes directly. - **Imports**: NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON, YAML, TOML; static colors only), Kitty, Ghostty (allowlist), iTerm2 `.itermcolors` (no XML entities) and WezTerm TOML (Lua refused). Data only, previewed with mapping and loss disclosure before saving; exports never include local source paths. -- **Theme Bridge** (`/theme-bridge`, opt-in, default Off): per tool Independent / Follow NMSh / Choose theme. fzf launched by NMSh, less/man termcap colors and LS_COLORS (vivid when installed, a small fallback otherwise) through an NMSh-owned shell environment applied by the zsh, Bash and Fish adapters at the next prompt; generated tmux, Neovim and Vim colors with an ownership ledger, staged validated writes, a typed tmux reload and exact includes added only after review. bat and delta are detected and stay Independent. No rc file, terminal or editor theme, or git config is changed. +- **Theme Bridge** (`/theme-bridge`, opt-in, default Off): per tool Independent / Follow NMSh / Choose theme. fzf launched by NMSh, less/man termcap colors and LS_COLORS (vivid when installed, a small fallback otherwise) through an NMSh-owned shell environment applied by the zsh, Bash and Fish adapters at the next prompt; generated tmux, Neovim, Vim and Helix themes with an ownership ledger, staged validated writes, a typed tmux reload and exact includes added only after review. bat and delta are detected and stay Independent. No rc file, terminal or editor theme, or git config is changed. - **Host semantics**: OSC 7 working directory and OSC 133 command zones derived from NMSh's command lifecycle on capable hosts and tmux; NMSh-authored OSC 8 links in `/help` and dev-server task rows, kept separate from program links. - `/appearance` is a compact launcher: Theme Studio, Prompt, Cursor & effects, UI chrome, Chroma, Motion, Theme Bridge and host window. diff --git a/README.md b/README.md index 986de347..3122379a 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ NMSh provides a richer interactive frontend without throwing away the proven rob - Command lifecycle rows with activity animation and nested Node TAP activity - Scrollable history with muted snapshots of each command's prompt, shown Full, Compact, Minimal or Off; tune dividers and history colors with `/transcript` - Native theme library: `/theme` (Theme Studio) browses Built-in themes and manages Imported and Custom ones; import NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON/YAML/TOML), Kitty, Ghostty, iTerm2 or WezTerm TOML files as data only, with a preview and loss disclosure before anything is saved -- Theme Bridge (`/theme-bridge`, opt-in per tool): fzf launched by NMSh, less/man, LS_COLORS, tmux, Neovim and Vim can Follow NMSh or use a pinned theme; nothing changes for a tool until you choose, and config includes are shown and confirmed first +- Theme Bridge (`/theme-bridge`, opt-in per tool): fzf launched by NMSh, less/man, LS_COLORS, tmux, Neovim, Vim and Helix can Follow NMSh or use a pinned theme; nothing changes for a tool until you choose, and config includes are shown and confirmed first - Host cooperation on capable terminals: OSC 7 working directory and OSC 133 command zones derived from NMSh's own command lifecycle, and NMSh-authored OSC 8 links (help, docs, dev-server URLs) kept separate from program output - Output folding (Config → Output folding: Off / Smart / Always): long output collapses to its first and last lines around `› N lines hidden · Ctrl+O`; Smart keeps failures and useful output expanded; `/copy` and `/resume` always keep the full output - Composer position Bottom, Top, or Flow (Config → Composer position). Flow places the prompt and input right after the newest output, like a conventional terminal, and they scroll with it. Combine any position with Normal or Chat transcript presentation (Config → Transcript presentation). `/layout` (also Config → Layout) previews every combination with sample content before you choose. diff --git a/docs/design/theme-bridge.md b/docs/design/theme-bridge.md index 9e225108..65576e07 100644 --- a/docs/design/theme-bridge.md +++ b/docs/design/theme-bridge.md @@ -73,6 +73,7 @@ All targets consume the one resolved semantic palette; each target only maps sem | tmux | NMSh-generated fragment of style/colour options only (status, window status, pane borders, messages, modes, menus, popups, clock, display-panes, copy-mode); no keys, layout, plugins, commands or behavior; `set -gq` so older tmux skips unknown options. Optional typed reload `tmux source-file ` on request. New servers need one include line (below). Theme plugins or explicit styles in your tmux.conf are reported as conflicts, not fought. | fragment; include after confirmation | | Neovim | generated Lua colorscheme `nmsh-bridge` (classic groups, floats, separators, diff, diagnostics with underline/sign/virtual text, common Tree-sitter captures linked onto base groups); data only. | colorscheme; include after confirmation | | Vim | separate Vim colorscheme with classic groups only, truecolor plus 256-color `cterm` fallback, `background` from the theme. | colorscheme; include after confirmation | +| Helix | native TOML theme `nmsh-bridge` in Helix's themes directory (`$XDG_CONFIG_HOME/helix/themes`, else `~/.config/helix/themes`): a named `[palette]` from the semantic palette, then syntax, markup, diff, diagnostic and editor UI scopes mapped onto it. Generation and activation are separate: activation is one confirmed `theme = "nmsh-bridge"` assignment inserted before the first table of `config.toml`; a config that already selects a theme is never changed (`:theme nmsh-bridge` works by hand). A same-named file NMSh did not write is never overwritten. Helix has no safe CLI theme switch and running instances are not recolored. | theme file; assignment after confirmation | | bat, delta | detected only. bat applies custom themes only from its own theme cache (`bat cache --build`); delta takes syntax themes from that cache and diff styles from git config. NMSh does not modify either, and does not fake fidelity with an unrelated built-in theme. | nothing | Already-running editors and shells outside NMSh are not recolored live; Follow NMSh applies to new instances (and NMSh shells at their next prompt). @@ -87,12 +88,13 @@ NMSh writes one generated, validated file per shell syntax (`theme-bridge/enviro ### Includes -For new tmux/Neovim/Vim instances NMSh offers one exact include, shown as a diff with the exact target path and applied with the verified config-edit planner (refuses if the file changed since it was shown). Removal removes exactly those lines (and refuses to guess if they appear more than once). +For new tmux/Neovim/Vim/Helix instances NMSh offers one exact include, shown as a diff with the exact target path and applied with the verified config-edit planner (refuses if the file changed since it was shown). Removal removes exactly those lines (and refuses to guess if they appear more than once). | Target | File (existing one preferred) | Lines | | --- | --- | --- | | tmux | `~/.tmux.conf` or `$XDG_CONFIG_HOME/tmux/tmux.conf` | `# NMSh Theme Bridge …` and `source-file -q ''` | | Neovim | `init.lua` (or `init.vim` when only that exists) | `pcall(function() vim.opt.runtimepath:append(''); vim.cmd.colorscheme('nmsh-bridge') end)` | +| Helix | `config.toml` in the Helix config directory (must be inside home) | `# NMSh Theme Bridge …` and `theme = "nmsh-bridge"`, before the first table | | Vim | `~/.vimrc` or `~/.vim/vimrc` | `silent! execute 'set runtimepath+=' . fnameescape('') \| silent! colorscheme nmsh-bridge` | Every include tolerates a missing file, so an Independent target with an include left in place does nothing. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 92c7a6ab..fe0f6829 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -3803,7 +3803,7 @@ export class TerminalApp { private themeBridgePanel?: ThemeBridgePanelState; private bridgeReports: TargetReport[] = []; /** A plan shown for confirmation, kept with the spec it came from; confirming applies exactly this plan. */ - private bridgePlan?: {target: Extract; plan?: FileEditPlan; spec?: HookSpec; removal: boolean}; + private bridgePlan?: {target: Extract; plan?: FileEditPlan; spec?: HookSpec; removal: boolean}; private async openThemeBridge(): Promise { this.themeBridgePanel = createThemeBridgePanel(); @@ -3821,7 +3821,7 @@ export class TerminalApp { const ledger = loadLedger(); const active = activeThemeRef(config); return {enabled: config.themeBridge.enabled, reports: this.bridgeReports, themes: selectableThemes(config), pinned: target => config.themeBridge.targets[target].theme, ...(active ? {activeRef: active} : {}), - managed: target => target === 'tmux' || target === 'neovim' || target === 'vim' + managed: target => target === 'tmux' || target === 'neovim' || target === 'vim' || target === 'helix' ? {...(ledger.entries[target] && ownership(target, ledger) === 'owned' ? {artifact: artifactPath(target)} : {}), ...(ledger.entries[target]?.hook ? {include: ledger.entries[target]!.hook!.configPath} : {})} : undefined}; } @@ -3854,7 +3854,7 @@ export class TerminalApp { } else if (action.kind === 'reloadTmux') { state.message = (await reloadTmux()).message; } else if (action.kind === 'planHook' || action.kind === 'planRemoval') { - const target = action.target as Extract; + const target = action.target as Extract; if (action.kind === 'planHook') { if (this.promptConfiguration.themeBridge.targets[target].mode === 'independent') { state.message = 'Choose Follow NMSh or Choose theme first; an Independent target gets no include.'; this.render(); return; } const spec = hookSpec(target); diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index 2cdadad2..0f21858c 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -68,8 +68,8 @@ export const CONCEPTS: readonly Concept[] = [ aliases: ['theme studio', 'custom theme', 'custom themes', 'import theme', 'export theme', 'my own theme', 'imported theme', 'theme library'], description: 'Theme Studio manages Native themes: browse built-ins, import a theme file (Base16/24, Windows Terminal, Oh My Posh, Kitty, Ghostty, iTerm2, WezTerm), edit, duplicate, export and select.'}, {id: 'themeBridge', label: 'Theme Bridge', support: 'actionable', open: '/theme-bridge', covers: ['/theme-bridge'], - aliases: ['theme bridge', 'fzf colors', 'fzf colours', 'man page colors', 'less colors', 'ls colors', 'ls_colors', 'tmux theme', 'tmux colors', 'neovim theme', 'nvim colorscheme', 'vim colorscheme'], - description: 'Theme Bridge extends NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim and Vim. Each tool is Independent until you choose Follow NMSh or a pinned theme.'}, + aliases: ['theme bridge', 'fzf colors', 'fzf colours', 'man page colors', 'less colors', 'ls colors', 'ls_colors', 'tmux theme', 'tmux colors', 'neovim theme', 'nvim colorscheme', 'vim colorscheme', 'helix theme'], + description: 'Theme Bridge extends NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim, Vim and Helix. Each tool is Independent until you choose Follow NMSh or a pinned theme.'}, {id: 'chroma', label: 'Chroma', support: 'actionable', open: '/chroma', covers: ['/chroma', 'settings:Presentation'], aliases: ['chroma', 'gradient', 'gradients', 'animated colors', 'animated colours', 'color motion', 'colour motion', 'prompt gradient', 'rainbow prompt', 'chroma palette', 'animated prompt colors', 'animated prompt colours', 'animated prompt'], diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 2c5dc4f0..9fed18b0 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -16,7 +16,7 @@ export const slashCommands: readonly SlashCommand[] = [ {name: '/screensaver', insertion: '/screensaver', description: 'Idle visuals: live gallery, timeout and colors'}, {name: '/screensaver start', insertion: '/screensaver start', description: 'Start the selected idle visual now; any key or mouse stops it'}, {name: '/theme', insertion: '/theme', description: 'Theme Studio: built-in, imported and custom Native themes; create, edit, import, export, select'}, - {name: '/theme-bridge', insertion: '/theme-bridge', description: 'Theme Bridge: extend NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim and Vim (opt-in per tool)'}, + {name: '/theme-bridge', insertion: '/theme-bridge', description: 'Theme Bridge: extend NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim, Vim and Helix (opt-in per tool)'}, {name: '/chroma', insertion: '/chroma', description: 'Chroma palettes, motion and custom gradients for the Native prompt'}, {name: '/settings', insertion: '/settings', description: 'Open NMSh settings (Config view)'}, {name: '/setup', insertion: '/setup', description: 'Setup Cat: guided, rerunnable setup; keeps your current choices'}, diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index ffcf4563..c93b5345 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -30,7 +30,7 @@ Settings → Theme (and /setup appearance) picks a Built-in theme (NMSh themes a ## Theme Bridge -/theme-bridge (also /appearance, Settings and Setup) extends NMSh themes to terminal tools, one tool at a time. Every tool starts Independent: NMSh injects and changes nothing for it. Follow NMSh uses the active theme; Choose theme pins any Built-in, Imported or Custom theme that the main theme never changes. fzf colors apply only to fzf launched by NMSh (FZF_DEFAULT_OPTS and rc files are untouched). less/man and LS_COLORS values reach NMSh shells (zsh, Bash, Fish) at their next prompt through an NMSh-owned environment file; Independent restores what was there. tmux, Neovim and Vim get NMSh-generated color files; loading them in new instances needs one include line that NMSh shows exactly and adds only after you confirm, and removes exactly. Running editors are not recolored live; tmux can reload on request. bat and delta are detected but stay Independent: their custom themes need bat's cache or git config, which NMSh does not modify. Details: [Theme Bridge](https://github.com/raiseCatError/notMyShell/blob/dev/docs/design/theme-bridge.md). +/theme-bridge (also /appearance, Settings and Setup) extends NMSh themes to terminal tools (fzf, less/man, LS_COLORS, tmux, Neovim, Vim, Helix), one tool at a time. Every tool starts Independent: NMSh injects and changes nothing for it. Follow NMSh uses the active theme; Choose theme pins any Built-in, Imported or Custom theme that the main theme never changes. fzf colors apply only to fzf launched by NMSh (FZF_DEFAULT_OPTS and rc files are untouched). less/man and LS_COLORS values reach NMSh shells (zsh, Bash, Fish) at their next prompt through an NMSh-owned environment file; Independent restores what was there. tmux, Neovim, Vim and Helix get NMSh-generated color files; loading them in new instances needs one include line (for Helix, a theme = "nmsh-bridge" assignment; otherwise run :theme nmsh-bridge) that NMSh shows exactly and adds only after you confirm, and removes exactly. Running editors are not recolored live; tmux can reload on request. bat and delta are detected but stay Independent: their custom themes need bat's cache or git config, which NMSh does not modify. Details: [Theme Bridge](https://github.com/raiseCatError/notMyShell/blob/dev/docs/design/theme-bridge.md). ## Idle visuals diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 7311f61d..f1b6f5c4 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -171,7 +171,7 @@ const THEME_BRIDGE_ROW: SettingsRow = {id: 'setupThemeBridge', label: 'Extend co category: 'Appearance', control: 'enum', options: ['No', 'Yes'], index: c => c.themeBridge.enabled ? 1 : 0, select: (c, index) => ({...c, themeBridge: {...c.themeBridge, enabled: index === 1}})}; -const SETUP_BRIDGE_TARGETS: readonly BridgeTargetId[] = ['fzf', 'pager', 'lsColors', 'tmux', 'neovim', 'vim']; +const SETUP_BRIDGE_TARGETS: readonly BridgeTargetId[] = ['fzf', 'pager', 'lsColors', 'tmux', 'neovim', 'vim', 'helix']; function bridgeTargetRows(draft: PromptConfiguration, context: SetupContext | undefined): SetupRow[] { if (!draft.themeBridge.enabled) return []; @@ -180,7 +180,7 @@ function bridgeTargetRows(draft: PromptConfiguration, context: SetupContext | un row: {id: `setupBridge:${target}`, parent: 'setupThemeBridge', label: ` ${BRIDGE_TARGET_LABELS[target]}`, description: 'Independent, or follow the active NMSh theme', category: 'Appearance', control: 'enum', options: ['Independent', 'Follow NMSh'], index: c => c.themeBridge.targets[target].mode === 'independent' ? 0 : c.themeBridge.targets[target].mode === 'follow' ? 1 : 1, select: (c, index) => ({...c, themeBridge: {...c.themeBridge, targets: {...c.themeBridge.targets, [target]: {...c.themeBridge.targets[target], mode: index === 1 ? (c.themeBridge.targets[target].mode === 'choose' ? 'choose' : 'follow') : 'independent'}}}})}, - note: () => target === 'tmux' || target === 'neovim' || target === 'vim' + note: () => target === 'tmux' || target === 'neovim' || target === 'vim' || target === 'helix' ? 'NMSh generates its own color file; adding it to your config is a separate, reviewed step in /theme-bridge' : target === 'fzf' ? 'Only fzf launched by NMSh; FZF_DEFAULT_OPTS and your rc files are untouched' : 'Applied in NMSh shells at the next prompt; no rc file is edited', } satisfies SetupRow)); diff --git a/src/themeBridge/ThemeBridgePanel.ts b/src/themeBridge/ThemeBridgePanel.ts index 717b477e..92835613 100644 --- a/src/themeBridge/ThemeBridgePanel.ts +++ b/src/themeBridge/ThemeBridgePanel.ts @@ -48,7 +48,7 @@ export interface BridgePanelContext { managed: (target: BridgeTargetId) => {artifact?: string; include?: string} | undefined; } -const MANAGED = new Set(['tmux', 'neovim', 'vim']); +const MANAGED = new Set(['tmux', 'neovim', 'vim', 'helix']); export function createThemeBridgePanel(): ThemeBridgePanelState { return {selected: 0}; @@ -71,7 +71,10 @@ export function themeBridgeKey(state: ThemeBridgePanelState, key: Key, context: state.confirm = undefined; return kind === 'hook' ? {kind: 'confirmHook', target} : {kind: 'confirmRemoval', target}; } - if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value.toLowerCase() === 'n')) { state.confirm = undefined; state.message = 'Nothing was changed.'; } + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value.toLowerCase() === 'n')) { + state.confirm = undefined; + state.message = target === 'helix' && kind === 'hook' ? 'Nothing was changed. The generated theme stays available: run :theme nmsh-bridge inside Helix.' : 'Nothing was changed.'; + } return undefined; } if (!state.detail) { @@ -157,7 +160,7 @@ export function renderThemeBridgePanel(state: ThemeBridgePanelState, context: Br for (const row of rows) { if (row === 'mode') out.push(line(row, 'Mode', BRIDGE_MODE_LABELS[report?.mode ?? 'independent'])); if (row === 'theme') out.push(line(row, 'Theme', report?.themeLabel ?? 'Missing theme')); - if (row === 'apply') out.push(line(row, 'Include', `${context.managed(target)?.include ? 'installed · Enter to review' : 'not installed · Enter to review the exact change'}`)); + if (row === 'apply') out.push(line(row, target === 'helix' ? 'Activation' : 'Include', `${context.managed(target)?.include ? 'configured · Enter to review' : 'not configured · Enter to review the exact change'}`)); if (row === 'reload') out.push(line(row, 'Reload', 'load the colors into the running tmux server')); if (row === 'remove') out.push(line(row, 'Remove', 'set Independent, remove NMSh files and (after review) the include')); } diff --git a/src/themeBridge/artifacts.ts b/src/themeBridge/artifacts.ts index 7f9ac2f2..664fd826 100644 --- a/src/themeBridge/artifacts.ts +++ b/src/themeBridge/artifacts.ts @@ -16,7 +16,8 @@ import type {BridgeMode, BridgeTargetId} from './model.js'; */ export const ADAPTER_VERSION = 1; -export type ManagedTarget = Extract | 'vivid'; +export type ManagedTarget = Extract | 'vivid'; +export type HookTarget = Extract; export interface ConfigHook { /** The user's config file the hook lives in. */ @@ -53,14 +54,22 @@ export function artifactPath(target: ManagedTarget, env: NodeJS.ProcessEnv = pro case 'neovim': return join(root, 'nvim', 'colors', 'nmsh-bridge.lua'); case 'vim': return join(root, 'vim', 'colors', 'nmsh-bridge.vim'); case 'vivid': return join(root, 'vivid', 'nmsh-bridge.yml'); + // Helix loads themes only from its own themes directory; the fixed NMSh name keeps it apart from user themes. + case 'helix': return join(helixConfigDirectory(env), 'themes', 'nmsh-bridge.toml'); } } /** The runtimepath directory that holds `colors/` for an editor target. */ +/** Helix's configuration directory ($XDG_CONFIG_HOME/helix, else ~/.config/helix, as Helix itself uses on macOS and Linux). */ +export function helixConfigDirectory(env: NodeJS.ProcessEnv = process.env): string { + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(env.HOME || homedir(), '.config'); + return join(xdg, 'helix'); +} + export const runtimeDirectory = (target: 'neovim' | 'vim', env: NodeJS.ProcessEnv = process.env) => dirname(dirname(artifactPath(target, env))); const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -const TARGETS: readonly ManagedTarget[] = ['tmux', 'neovim', 'vim', 'vivid']; +const TARGETS: readonly ManagedTarget[] = ['tmux', 'neovim', 'vim', 'helix', 'vivid']; /** A malformed or stale ledger yields no ownership (so nothing can be deleted on its say-so). */ export function loadLedger(env: NodeJS.ProcessEnv = process.env): Ledger { @@ -167,7 +176,8 @@ export interface HookSpec { const HOOK_COMMENT = {tmux: '# NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)', neovim: '-- NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)', - vim: '" NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)'}; + vim: '" NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)', + helix: '# NMSh Theme Bridge: selects the NMSh-managed theme (remove with /theme-bridge)'}; /** A path safe to embed in a single-quoted tmux/Vim/Lua string without escaping rules that differ by target. */ export function hookSafePath(path: string): boolean { @@ -179,7 +189,14 @@ function firstExisting(paths: readonly string[]): string | undefined { } /** The user config a hook belongs in, and the exact lines. Existing files are preferred; nothing else is assumed. */ -export function hookSpec(target: Extract, env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): HookSpec | {error: string} { +export function hookSpec(target: HookTarget, env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): HookSpec | {error: string} { + if (target === 'helix') { + const config = join(helixConfigDirectory(env), 'config.toml'); + const existing = readFileSafe(config); + // A theme the user already selected is theirs: NMSh never replaces it (`:theme nmsh-bridge` still works by hand). + if (existing !== undefined && /^\s*theme\s*=/mu.test(existing)) return {error: `${config} already selects a theme; NMSh leaves it. Use :theme nmsh-bridge in Helix, or change that line yourself.`}; + return {configPath: config, lines: [HOOK_COMMENT.helix, 'theme = "nmsh-bridge"'], createIfMissing: existing === undefined}; + } const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(home, '.config'); if (target === 'tmux') { const fragment = artifactPath('tmux', env); @@ -207,20 +224,28 @@ function homeRoots(home: string): string[] { try { return [...new Set([home, realpathSync(home)])]; } catch { return [home]; } } +function readFileSafe(path: string): string | undefined { + try { return readFileSync(path, 'utf8'); } catch { return undefined; } +} + export function planHook(spec: HookSpec, home: string): {plan: FileEditPlan} | {noop: string} | {error: string} { const roots = homeRoots(home); if (spec.createIfMissing && !existsSync(spec.configPath)) { const result = planCreate(spec.configPath, `${spec.lines.join('\n')}\n`, roots); return result.kind === 'plan' ? {plan: result.plan} : {error: 'reason' in result ? result.reason : 'Cannot create that file.'}; } - const result = planAppend(inspectFile(spec.configPath, roots), 'text', spec.lines.join('\n')); + const facts = inspectFile(spec.configPath, roots); + // A top-level TOML key must come before the first table, so Helix's assignment is inserted there, exactly. + const table = spec.lines.some(line => line.startsWith('theme = ')) && facts.content !== undefined ? /^\[[^\n]*$/mu.exec(facts.content)?.[0] : undefined; + if (table && facts.content!.includes(`${spec.lines.join('\n')}\n`)) return {noop: `${spec.configPath} already contains that; nothing to add.`}; + const result = table ? planReplace(facts, table, `${spec.lines.join('\n')}\n${table}`, facts.content!.indexOf(table)) : planAppend(facts, 'text', spec.lines.join('\n')); if (result.kind === 'plan') return {plan: result.plan}; if (result.kind === 'noop') return {noop: result.reason}; return {error: 'reason' in result ? result.reason : 'Cannot edit that file.'}; } /** Applies a confirmed hook plan and records the exact lines in the ledger. */ -export function applyHook(target: Extract, plan: FileEditPlan, spec: HookSpec, env: NodeJS.ProcessEnv = process.env, now = new Date()): {ok: true} | {ok: false; error: string} { +export function applyHook(target: HookTarget, plan: FileEditPlan, spec: HookSpec, env: NodeJS.ProcessEnv = process.env, now = new Date()): {ok: true} | {ok: false; error: string} { const ledger = loadLedger(env); const entry = ledger.entries[target]; if (!entry) return {ok: false, error: 'Generate the managed file first.'}; @@ -232,7 +257,7 @@ export function applyHook(target: Extract, home: string, env: NodeJS.ProcessEnv = process.env): {plan: FileEditPlan} | {gone: true} | {error: string} { +export function planHookRemoval(target: HookTarget, home: string, env: NodeJS.ProcessEnv = process.env): {plan: FileEditPlan} | {gone: true} | {error: string} { const hook = loadLedger(env).entries[target]?.hook; if (!hook) return {error: 'NMSh has no recorded include for this target.'}; const facts = inspectFile(hook.configPath, homeRoots(home)); @@ -245,7 +270,7 @@ export function planHookRemoval(target: Extract, plan: FileEditPlan | undefined, env: NodeJS.ProcessEnv = process.env): {ok: true} | {ok: false; error: string} { +export function applyHookRemoval(target: HookTarget, plan: FileEditPlan | undefined, env: NodeJS.ProcessEnv = process.env): {ok: true} | {ok: false; error: string} { if (plan) { const result = applyPlan(plan); if (!result.ok) return {ok: false, error: result.reason}; diff --git a/src/themeBridge/model.ts b/src/themeBridge/model.ts index d1a3a096..3d7a56a7 100644 --- a/src/themeBridge/model.ts +++ b/src/themeBridge/model.ts @@ -8,10 +8,10 @@ * normalizer can use it without import cycles. */ -export const BRIDGE_TARGETS = ['fzf', 'pager', 'lsColors', 'bat', 'delta', 'tmux', 'neovim', 'vim'] as const; +export const BRIDGE_TARGETS = ['fzf', 'pager', 'lsColors', 'bat', 'delta', 'tmux', 'neovim', 'vim', 'helix'] as const; export type BridgeTargetId = typeof BRIDGE_TARGETS[number]; export const BRIDGE_TARGET_LABELS: Record = { - fzf: 'fzf', pager: 'less / man', lsColors: 'LS_COLORS', bat: 'bat', delta: 'delta', tmux: 'tmux', neovim: 'Neovim', vim: 'Vim', + fzf: 'fzf', pager: 'less / man', lsColors: 'LS_COLORS', bat: 'bat', delta: 'delta', tmux: 'tmux', neovim: 'Neovim', vim: 'Vim', helix: 'Helix', }; export const BRIDGE_MODES = ['independent', 'follow', 'choose'] as const; diff --git a/src/themeBridge/runtime.ts b/src/themeBridge/runtime.ts index 5bbd4f2b..ebef9be3 100644 --- a/src/themeBridge/runtime.ts +++ b/src/themeBridge/runtime.ts @@ -2,17 +2,18 @@ import {existsSync, readFileSync, statSync} from 'node:fs'; import {homedir} from 'node:os'; import {join} from 'node:path'; import {resolveCommand, runExternal} from '../providers/providers.js'; +import {parse as parseToml} from 'smol-toml'; import type {ColorLevel} from '../presentation/capabilities.js'; import {resolveSemanticPalette, type SemanticPalette} from '../appearance/semanticPalette.js'; import {activeThemeRef, themeRefLabel, type ThemeSource} from '../appearance/themeRefs.js'; import {BRIDGE_TARGETS, BRIDGE_TARGET_LABELS, effectiveMode, type BridgeMode, type BridgeTargetId, type ThemeBridgeSettings} from './model.js'; import {bridgeEnvPath, writeEnvironmentFiles, type BridgeEnvironment} from './environment.js'; import { - artifactPath, ledgerPath, loadLedger, ownership, removeArtifact, sha256, writeArtifact, type ManagedTarget, + artifactPath, helixConfigDirectory, ledgerPath, loadLedger, ownership, removeArtifact, sha256, writeArtifact, type ManagedTarget, } from './artifacts.js'; import { fzfColorArgs, lsColorsFallback, neovimColorscheme, pagerEnvironment, parseFzfVersion, tmuxFragment, validateNeovimColorscheme, - validateTmuxFragment, validateVimColorscheme, validLsColors, vimColorscheme, vividTheme, TMUX_UNREPRESENTED, + validateTmuxFragment, validateVimColorscheme, validLsColors, vimColorscheme, vividTheme, TMUX_UNREPRESENTED, helixTheme, validateHelixTheme, } from './targets.js'; /** @@ -41,7 +42,7 @@ export interface TargetReport { } /** Executable per target (PATH lookup only; versions only where mappings depend on them). */ -const BINARIES: Record = {fzf: 'fzf', pager: 'less', lsColors: 'ls', bat: 'bat', delta: 'delta', tmux: 'tmux', neovim: 'nvim', vim: 'vim'}; +const BINARIES: Record = {fzf: 'fzf', pager: 'less', lsColors: 'ls', bat: 'bat', delta: 'delta', tmux: 'tmux', neovim: 'nvim', vim: 'vim', helix: 'hx'}; const VERSION_ARGS: Partial> = {fzf: ['--version'], tmux: ['-V']}; const UNSUPPORTED: Partial> = { @@ -107,6 +108,11 @@ export function targetConflicts(target: BridgeTargetId, env: NodeJS.ProcessEnv = return /^\s*(?:colorscheme|colo)\s+\S|vim\.cmd\.colorscheme|vim\.cmd\s*\(?\s*['"]colorscheme/mu.test(text) ? ['Your config also chooses a colorscheme; the NMSh include, placed last, takes effect after it.'] : []; } + if (target === 'helix') { + const config = readSmall(join(helixConfigDirectory(env), 'config.toml')); + const theme = config ? /^\s*theme\s*=\s*"?([^"\n]+)"?/mu.exec(config)?.[1]?.trim() : undefined; + return theme && theme !== 'nmsh-bridge' ? [`Configured independently: your Helix config selects "${theme.slice(0, 40)}"; NMSh leaves it.`] : []; + } if (target === 'lsColors' && env.LS_COLORS) return ['LS_COLORS is already set; NMSh replaces it in NMSh shells while active and restores it when Independent.']; if (target === 'fzf' && /--color/u.test(env.FZF_DEFAULT_OPTS ?? '')) return ['FZF_DEFAULT_OPTS sets colors; NMSh-owned fzf launches never read FZF_DEFAULT_OPTS, your own fzf use keeps it.']; return []; @@ -143,13 +149,17 @@ export function reportTargets({source, facts, level, env = process.env}: BridgeC else if (missing) status = 'Missing theme'; else status = setting.mode === 'follow' ? 'Following NMSh' : 'Pinned theme'; if (setting.mode !== 'independent' && bridgeColorLevel(level, env) === 'none') notes.push('NO_COLOR or no color support: nothing is injected.'); - const managed = target === 'tmux' || target === 'neovim' || target === 'vim' ? target : undefined; + const managed = target === 'tmux' || target === 'neovim' || target === 'vim' || target === 'helix' ? target : undefined; if (managed) { const entry = ledger.entries[managed]; const owned = ownership(managed, ledger, env); if (owned === 'owned') notes.push('Managed'); if (owned === 'modified' || owned === 'unknown') { status = 'Conflict'; notes.push(`${artifactPath(managed, env)} exists and is not NMSh's unchanged file; NMSh will not overwrite it.`); } - if (entry?.hook) notes.push(setting.mode === 'independent' ? 'include installed (inactive)' : 'include installed'); + if (managed === 'helix') { + if (entry?.hook) notes.push(setting.mode === 'independent' ? 'theme assignment installed (inactive)' : 'Active through NMSh-managed config'); + else if (setting.mode !== 'independent') notes.push('Managed theme generated; select it with :theme nmsh-bridge, or review the config change'); + if (setting.mode !== 'independent') notes.push('Coverage: syntax, markup, diff, diagnostics and editor UI. Running Helix instances are not recolored; new ones use the file.'); + } else if (entry?.hook) notes.push(setting.mode === 'independent' ? 'include installed (inactive)' : 'include installed'); else if (setting.mode !== 'independent') notes.push('new instances need the include (Apply)'); if (managed === 'tmux' && setting.mode !== 'independent') { notes.push('reload available'); notes.push(TMUX_UNREPRESENTED); } } @@ -208,6 +218,7 @@ const MANAGED: ReadonlyArray<{target: Extract; re {target: 'tmux', render: tmuxFragment, validate: validateTmuxFragment, format: 'tmux-fragment'}, {target: 'neovim', render: neovimColorscheme, validate: validateNeovimColorscheme, format: 'nvim-colorscheme'}, {target: 'vim', render: vimColorscheme, validate: validateVimColorscheme, format: 'vim-colorscheme'}, + {target: 'helix', render: helixTheme, validate: content => validateHelixTheme(content, parseToml), format: 'helix-theme'}, ]; /** diff --git a/src/themeBridge/targets.ts b/src/themeBridge/targets.ts index 4046e431..00ca16eb 100644 --- a/src/themeBridge/targets.ts +++ b/src/themeBridge/targets.ts @@ -354,3 +354,114 @@ export function validateVimColorscheme(content: string): boolean { const hi = /^hi [A-Z][A-Za-z]* guifg=(?:#[0-9a-f]{6}|NONE) guibg=(?:#[0-9a-f]{6}|NONE)(?: guisp=#[0-9a-f]{6})? ctermfg=(?:\d{1,3}|NONE) ctermbg=(?:\d{1,3}|NONE) gui=(?:NONE|[a-z,]+) cterm=(?:NONE|[a-z,]+)$/u; return content.split('\n').every((line, index) => (index === 0 && line.startsWith('" Generated by NMSh Theme Bridge')) || fixed.has(line) || hi.test(line)); } + +// ---- Helix ------------------------------------------------------------------------ + +export const HELIX_THEME_NAME = 'nmsh-bridge'; + +/** + * A native Helix theme: a named `[palette]` derived from the NMSh semantic + * palette (still the source of truth), then syntax, markup, diff, diagnostic + * and editor UI scopes mapped onto those names. Declarative TOML only. + */ +export function helixTheme(p: SemanticPalette): string { + const palette: Record = { + foreground: p.text.primary, secondary: p.text.secondary, muted: p.text.subtle, accent: p.accent, separator: p.separator, + surface: p.surface, selection: p.selection, 'selection-fg': p.selectionForeground, cursor: p.cursor, + success: p.success, warning: p.warning, failure: p.failure, info: p.info, + string: p.syntax.string, number: p.syntax.number, keyword: p.syntax.keyword, function: p.syntax.function, type: p.syntax.type, + constant: p.syntax.constant, operator: p.syntax.operator, special: p.syntax.special, preproc: p.syntax.preproc, comment: p.syntax.comment, + ...(p.background ? {background: p.background} : {}), + }; + const bg = p.background ? 'background' : undefined; + const s: Array<[string, string]> = []; + const fg = (scope: string, color: string, modifiers?: string[]) => s.push([scope, modifiers ? `{ fg = "${color}", modifiers = [${modifiers.map(m => `"${m}"`).join(', ')}] }` : `"${color}"`]); + const style = (scope: string, value: string) => s.push([scope, value]); + for (const [scope, color] of [['attribute', 'preproc'], ['type', 'type'], ['type.builtin', 'type'], ['constructor', 'type'], ['constant', 'constant'], ['constant.builtin', 'constant'], + ['constant.character.escape', 'special'], ['constant.numeric', 'number'], ['string', 'string'], ['string.regexp', 'special'], ['string.special', 'special'], + ['variable', 'foreground'], ['variable.builtin', 'special'], ['variable.parameter', 'foreground'], ['variable.other.member', 'secondary'], ['label', 'keyword'], + ['punctuation', 'operator'], ['punctuation.delimiter', 'operator'], ['punctuation.bracket', 'operator'], ['keyword', 'keyword'], ['keyword.control', 'keyword'], + ['keyword.control.conditional', 'keyword'], ['keyword.control.repeat', 'keyword'], ['keyword.control.import', 'preproc'], ['keyword.control.return', 'keyword'], + ['keyword.control.exception', 'failure'], ['keyword.operator', 'operator'], ['keyword.directive', 'preproc'], ['keyword.function', 'keyword'], ['keyword.storage', 'keyword'], + ['operator', 'operator'], ['function', 'function'], ['function.builtin', 'special'], ['function.method', 'function'], ['function.macro', 'preproc'], ['tag', 'accent'], + ['namespace', 'type'], ['special', 'special'], ['markup.heading', 'accent'], ['markup.list', 'secondary'], ['markup.link.url', 'info'], ['markup.link.text', 'accent'], + ['markup.quote', 'muted'], ['markup.raw', 'string'], ['diff.plus', 'success'], ['diff.minus', 'failure'], ['diff.delta', 'warning'], ['diff.delta.moved', 'info'], + ['diff.delta.conflict', 'failure'], ['warning', 'warning'], ['error', 'failure'], ['info', 'info'], ['hint', 'secondary']] as const) fg(scope, color); + fg('comment', 'comment', ['italic']); + fg('markup.bold', 'foreground', ['bold']); + fg('markup.italic', 'foreground', ['italic']); + fg('markup.link', 'info', ['underlined']); + for (const [name, color] of [['error', 'failure'], ['warning', 'warning'], ['info', 'info'], ['hint', 'secondary']] as const) { + style(`diagnostic.${name}`, `{ underline = { color = "${color}", style = "curl" } }`); + } + style('diagnostic.unnecessary', '{ modifiers = ["dim"] }'); + style('diagnostic.deprecated', '{ modifiers = ["crossed_out"] }'); + const on = (f: string, b?: string) => `{ fg = "${f}"${b ? `, bg = "${b}"` : ''} }`; + style('ui.background', bg ? `{ bg = "${bg}" }` : '{}'); + style('ui.background.separator', on('separator')); + for (const scope of ['ui.cursor', 'ui.cursor.normal', 'ui.cursor.primary']) style(scope, on(bg ?? 'surface', 'cursor')); + style('ui.cursor.insert', on(bg ?? 'surface', 'success')); + style('ui.cursor.select', on(bg ?? 'surface', 'info')); + style('ui.cursor.match', '{ fg = "accent", modifiers = ["underlined"] }'); + style('ui.gutter', bg ? `{ bg = "${bg}" }` : '{}'); + style('ui.gutter.selected', '{ bg = "surface" }'); + style('ui.linenr', on('muted')); + style('ui.linenr.selected', on('accent')); + style('ui.statusline', on('foreground', 'surface')); + style('ui.statusline.inactive', on('muted', 'surface')); + style('ui.statusline.normal', on('selection-fg', 'selection')); + style('ui.statusline.insert', on('surface', 'success')); + style('ui.statusline.select', on('surface', 'info')); + style('ui.statusline.separator', on('separator', 'surface')); + style('ui.bufferline', on('muted', 'surface')); + style('ui.bufferline.active', on('selection-fg', 'selection')); + style('ui.bufferline.background', '{ bg = "surface" }'); + style('ui.popup', on('foreground', 'surface')); + style('ui.popup.info', on('foreground', 'surface')); + style('ui.window', on('separator')); + style('ui.help', on('foreground', 'surface')); + style('ui.text', on('foreground')); + style('ui.text.focus', on('selection-fg', 'selection')); + style('ui.text.inactive', on('muted')); + style('ui.text.info', on('secondary')); + style('ui.text.directory', on('info')); + style('ui.virtual.whitespace', on('separator')); + style('ui.virtual.indent-guide', on('separator')); + style('ui.virtual.ruler', '{ bg = "surface" }'); + for (const scope of ['ui.virtual.inlay-hint', 'ui.virtual.inlay-hint.parameter', 'ui.virtual.inlay-hint.type']) style(scope, on('muted')); + style('ui.menu', on('foreground', 'surface')); + style('ui.menu.selected', on('selection-fg', 'selection')); + style('ui.menu.scroll', on('separator', 'surface')); + style('ui.selection', '{ bg = "selection" }'); + style('ui.selection.primary', '{ bg = "selection" }'); + style('ui.highlight', '{ bg = "surface" }'); + for (const scope of ['ui.cursorline.primary', 'ui.cursorline.secondary', 'ui.cursorcolumn.primary']) style(scope, '{ bg = "surface" }'); + return [ + `# Generated by NMSh Theme Bridge from ${JSON.stringify(p.name)}. NMSh replaces this file; it is theme data only.`, + ...s.map(([scope, value]) => `"${scope}" = ${value}`), + '', + '[palette]', + ...Object.entries(palette).map(([name, hex]) => `${name} = "${hex}"`), + '', + ].join('\n'); +} + +/** Parses the generated TOML and checks every style references only palette names or hex colors. */ +export function validateHelixTheme(content: string, parse: (text: string) => unknown): boolean { + let data: Record; + try { data = parse(content) as Record; } catch { return false; } + const palette = data.palette as Record | undefined; + if (!palette || typeof palette !== 'object' || !Object.values(palette).every(value => typeof value === 'string' && HEX.test(value))) return false; + const color = (value: unknown) => typeof value === 'string' && (value in palette || HEX.test(value)); + const MODS = new Set(['bold', 'italic', 'underlined', 'dim', 'crossed_out']); + return Object.entries(data).every(([key, value]) => { + if (key === 'palette') return true; + if (!/^[a-z][a-z.-]*$/u.test(key)) return false; + if (typeof value === 'string') return color(value); + if (!value || typeof value !== 'object') return false; + return Object.entries(value).every(([field, item]) => field === 'fg' || field === 'bg' ? color(item) + : field === 'modifiers' ? Array.isArray(item) && item.every(mod => MODS.has(mod)) + : field === 'underline' ? typeof item === 'object' && item !== null && color((item as {color?: unknown}).color) && (item as {style?: unknown}).style === 'curl' + : false); + }); +} diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 9b64db42..0c3c0c71 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -201,7 +201,7 @@ const THEME_ROWS: readonly SettingsRow[] = [ }}, {id: 'themeStudio', parent: 'themeFamily', label: 'Theme Studio', description: 'Create, edit, import, export and manage Native themes', category: 'Appearance', control: 'action', actionLabel: 'Open ›', value: c => [librarySummary(c.themes), 'Open ›'].filter(Boolean).join(' '), destination: 'themeStudio'}, - {id: 'themeBridge', label: 'Theme Bridge', description: 'Extend NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim and Vim; every tool starts Independent', category: 'Appearance', + {id: 'themeBridge', label: 'Theme Bridge', description: 'Extend NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim, Vim and Helix; every tool starts Independent', category: 'Appearance', control: 'action', actionLabel: "Open ›", value: c => { const active = BRIDGE_TARGETS.filter(target => effectiveMode(c.themeBridge, target) !== 'independent').length; return `${active ? `${active} tool${active === 1 ? '' : 's'}` : 'Off'} Open ›`; }, destination: 'themeBridge'}, enumRow({id: 'pastePreview', label: 'Paste preview', description: 'Smart: multiline, chained, mutating or risky pastes are shown before they enter the composer (never changed; nothing runs until Enter). Always: every paste. Off: insert at once', category: 'Editor', diff --git a/tests/themeBridgeHelix.test.ts b/tests/themeBridgeHelix.test.ts new file mode 100644 index 00000000..a32ddfbf --- /dev/null +++ b/tests/themeBridgeHelix.test.ts @@ -0,0 +1,141 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {parse as parseToml} from 'smol-toml'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; +import {addTheme, setActiveTheme} from '../src/appearance/themeLibraryActions.js'; +import {assetRef, builtinTheme} from '../src/appearance/themeRefs.js'; +import {paletteFromTheme} from '../src/appearance/semanticPalette.js'; +import {helixTheme, validateHelixTheme} from '../src/themeBridge/targets.js'; +import {applyHook, applyHookRemoval, artifactPath, helixConfigDirectory, hookSpec, loadLedger, planHook, planHookRemoval, removeArtifact} from '../src/themeBridge/artifacts.js'; +import {applyThemeBridge, reportTargets, type BridgeContext} from '../src/themeBridge/runtime.js'; +import {BRIDGE_TARGETS, type BridgeTargetId} from '../src/themeBridge/model.js'; + +function sandbox() { + const root = mkdtempSync(join(tmpdir(), 'nmsh-helix-')); + const home = join(root, 'home'); + mkdirSync(home); + return {root, home, env: {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), PATH: ''} as NodeJS.ProcessEnv, done: () => rmSync(root, {recursive: true, force: true})}; +} +const facts = Object.fromEntries(BRIDGE_TARGETS.map(target => [target, {installed: true}])) as BridgeContext['facts']; +function config(helix: {mode: 'independent' | 'follow' | 'choose'; theme?: string}, active = 'builtin:lavender'): PromptConfiguration { + const base = normalizePromptConfiguration({...structuredClone(DEFAULT_PROMPT_CONFIGURATION), themeBridge: {enabled: true, targets: {helix}}}); + const result = setActiveTheme(base, active); + assert.ok(result.ok); + return result.config; +} +const ctx = (source: PromptConfiguration, env: NodeJS.ProcessEnv): BridgeContext => ({source, facts, level: 'truecolor', env}); + +test('Helix theme: deterministic parseable TOML with a palette; every style references the palette', () => { + const palette = paletteFromTheme(builtinTheme('tokyonightNight'), 'builtin:tokyonightNight'); + const toml = helixTheme(palette); + assert.equal(toml, helixTheme(palette)); + assert.ok(validateHelixTheme(toml, parseToml)); + const data = parseToml(toml) as Record; + const names = data.palette as Record; + assert.ok(Object.values(names).every(hex => /^#[0-9a-f]{6}$/u.test(hex))); + for (const scope of ['attribute', 'type', 'type.builtin', 'constructor', 'constant', 'constant.builtin', 'constant.numeric', 'string', 'string.regexp', 'string.special', 'comment', + 'variable', 'variable.builtin', 'variable.parameter', 'variable.other.member', 'label', 'punctuation', 'punctuation.delimiter', 'punctuation.bracket', 'keyword', 'keyword.control', + 'keyword.control.conditional', 'keyword.control.repeat', 'keyword.control.import', 'keyword.control.return', 'keyword.control.exception', 'keyword.operator', 'keyword.directive', + 'keyword.function', 'keyword.storage', 'operator', 'function', 'function.builtin', 'function.method', 'function.macro', 'tag', 'namespace', 'special', + 'markup.heading', 'markup.bold', 'markup.italic', 'markup.link', 'markup.quote', 'markup.raw', 'diff.plus', 'diff.minus', 'diff.delta', 'diff.delta.conflict']) assert.ok(scope in data, scope); + for (const scope of ['ui.background', 'ui.background.separator', 'ui.cursor', 'ui.cursor.normal', 'ui.cursor.insert', 'ui.cursor.select', 'ui.cursor.match', 'ui.cursor.primary', + 'ui.gutter', 'ui.gutter.selected', 'ui.linenr', 'ui.linenr.selected', 'ui.statusline', 'ui.statusline.inactive', 'ui.statusline.normal', 'ui.statusline.insert', 'ui.statusline.select', + 'ui.statusline.separator', 'ui.bufferline', 'ui.bufferline.active', 'ui.bufferline.background', 'ui.popup', 'ui.popup.info', 'ui.window', 'ui.help', 'ui.text', 'ui.text.focus', + 'ui.text.inactive', 'ui.text.info', 'ui.text.directory', 'ui.virtual.whitespace', 'ui.virtual.indent-guide', 'ui.virtual.inlay-hint', 'ui.virtual.inlay-hint.parameter', + 'ui.virtual.inlay-hint.type', 'ui.menu', 'ui.menu.selected', 'ui.menu.scroll', 'ui.selection', 'ui.selection.primary', 'ui.highlight', 'ui.cursorline.primary', + 'ui.cursorline.secondary', 'ui.cursorcolumn.primary']) assert.ok(scope in data, scope); + assert.equal(names[data['diff.plus'] as string], palette.success); + assert.equal(names[data['diff.minus'] as string], palette.failure); + assert.equal(names[data['diff.delta'] as string], palette.warning); + assert.equal(names[(data['diagnostic.error'] as {underline: {color: string}}).underline.color], palette.failure); + assert.equal(names[data.keyword as string], palette.syntax.keyword); + assert.deepEqual({...data['ui.background'] as object}, {}, 'no theme background: the terminal keeps its own'); + const imported = paletteFromTheme({...builtinTheme('nord'), terminal: {background: '#101010', foreground: '#eeeeee', ansi: Array(16).fill('#888888')}}, 'asset:t-aaaaaaaaaaaa'); + assert.deepEqual({...parseToml(helixTheme(imported))['ui.background'] as object}, {bg: 'background'}); + assert.equal(validateHelixTheme(`${toml}\n"ui.text" = "nonexistent"\n`, parseToml), false); + assert.equal(validateHelixTheme('"keyword" = { fg = "x", command = "rm -rf ~" }\n[palette]\nx = "#000000"\n', parseToml), false, 'theme data only'); +}); + +test('Helix: Independent writes nothing; Follow tracks the active theme; Choose stays pinned; library themes resolve', async () => { + const box = sandbox(); + try { + await applyThemeBridge(ctx(config({mode: 'independent'}), box.env)); + assert.equal(existsSync(artifactPath('helix', box.env)), false); + assert.equal(existsSync(helixConfigDirectory(box.env)), false, 'nothing under the Helix config dir'); + let follow = config({mode: 'follow'}); + await applyThemeBridge(ctx(follow, box.env)); + const lavender = readFileSync(artifactPath('helix', box.env), 'utf8'); + assert.match(lavender, /from "Lavender Native"/u); + follow = (setActiveTheme(follow, 'builtin:gruvboxDark') as {config: PromptConfiguration}).config; + await applyThemeBridge(ctx(follow, box.env)); + assert.match(readFileSync(artifactPath('helix', box.env), 'utf8'), /from "Gruvbox Dark"/u, 'regenerated when the NMSh theme changes'); + let pinned = config({mode: 'choose', theme: 'builtin:nord'}); + const added = addTheme(pinned, {...builtinTheme('dracula'), name: 'Imported Dracula'}, {kind: 'ghostty'}); + assert.ok(added.ok); + pinned = {...added.config, themeBridge: {...added.config.themeBridge, targets: {...added.config.themeBridge.targets, helix: {mode: 'choose', theme: assetRef(added.id!)}}}}; + await applyThemeBridge(ctx(pinned, box.env)); + const chosen = readFileSync(artifactPath('helix', box.env), 'utf8'); + assert.match(chosen, /from "Imported Dracula"/u); + pinned = (setActiveTheme(pinned, 'builtin:solarizedLight') as {config: PromptConfiguration}).config; + await applyThemeBridge(ctx(pinned, box.env)); + assert.equal(readFileSync(artifactPath('helix', box.env), 'utf8'), chosen, 'pinned: unchanged by the main theme'); + const report = reportTargets(ctx(pinned, box.env)).find(item => item.target === 'helix')!; + assert.equal(report.status, 'Pinned theme'); + assert.match(report.notes.join(' '), /Managed theme generated; select it with :theme nmsh-bridge/u); + assert.match(report.notes.join(' '), /Running Helix instances are not recolored/u, 'never claims live updates'); + } finally { box.done(); } +}); + +test('Helix: never overwrites an unowned user theme file of the same name', async () => { + const box = sandbox(); + try { + mkdirSync(join(helixConfigDirectory(box.env), 'themes'), {recursive: true}); + writeFileSync(artifactPath('helix', box.env), 'inherits = "onedark"\n'); + const outcomes = await applyThemeBridge(ctx(config({mode: 'follow'}), box.env)); + assert.equal(outcomes.find(outcome => outcome.target === 'helix')?.ok, false); + assert.equal(readFileSync(artifactPath('helix', box.env), 'utf8'), 'inherits = "onedark"\n'); + assert.equal(removeArtifact('helix', box.env).ok, false); + assert.equal(reportTargets(ctx(config({mode: 'follow'}), box.env)).find(item => item.target === 'helix')!.status, 'Conflict'); + } finally { box.done(); } +}); + +test('Helix activation: exact confirmed theme assignment before the first table; removal exact; a user-selected theme is never replaced', async () => { + const box = sandbox(); + try { + await applyThemeBridge(ctx(config({mode: 'follow'}), box.env)); + const configPath = join(helixConfigDirectory(box.env), 'config.toml'); + const user = '# my helix\n[editor]\nline-number = "relative"\n\n[keys.normal]\nC-s = ":w"\n'; + writeFileSync(configPath, user); + const spec = hookSpec('helix', box.env, box.home); + assert.ok(!('error' in spec)); + assert.deepEqual(spec.lines, ['# NMSh Theme Bridge: selects the NMSh-managed theme (remove with /theme-bridge)', 'theme = "nmsh-bridge"']); + const planned = planHook(spec, box.home); + assert.ok('plan' in planned); + assert.equal(readFileSync(configPath, 'utf8'), user, 'planning alone changes nothing'); + assert.ok(applyHook('helix', planned.plan, spec, box.env).ok); + const after = readFileSync(configPath, 'utf8'); + assert.equal((parseToml(after) as {theme: string}).theme, 'nmsh-bridge', 'a valid top-level assignment'); + assert.deepEqual({...(parseToml(after) as {editor: object}).editor}, {'line-number': 'relative'}, 'user settings untouched'); + assert.match(reportTargets(ctx(config({mode: 'follow'}), box.env)).find(item => item.target === 'helix')!.notes.join(' '), /Active through NMSh-managed config/u); + const removal = planHookRemoval('helix', box.home, box.env); + assert.ok('plan' in removal); + assert.ok(applyHookRemoval('helix', removal.plan, box.env).ok); + assert.equal(readFileSync(configPath, 'utf8'), user, 'exactly restored'); + assert.equal(loadLedger(box.env).entries.helix?.hook, undefined); + writeFileSync(configPath, 'theme = "catppuccin_mocha"\n'); + const refused = hookSpec('helix', box.env, box.home); + assert.ok('error' in refused && /already selects a theme/u.test(refused.error)); + assert.match(reportTargets(ctx(config({mode: 'follow'}), box.env)).find(item => item.target === 'helix')!.notes.join(' '), /Configured independently: .*catppuccin_mocha/u); + assert.equal(readFileSync(configPath, 'utf8'), 'theme = "catppuccin_mocha"\n'); + rmSync(configPath); + const fresh = hookSpec('helix', box.env, box.home); + assert.ok(!('error' in fresh) && fresh.createIfMissing, 'a missing config is created with only the assignment, after confirmation'); + await applyThemeBridge(ctx(config({mode: 'independent'}), box.env)); + assert.equal(existsSync(artifactPath('helix', box.env)), false, 'Independent removes only the owned generated theme'); + } finally { box.done(); } +}); + +void ({} as BridgeTargetId); From dacc8019bc221707cf12d28986769c8ffb808d98 Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:36:57 +0530 Subject: [PATCH 05/16] Prompt None keeps the configured input marker; history stays promptless --- .swp | Bin 0 -> 4096 bytes src/app/TerminalApp.ts | 16 ++++++++-------- src/prompt/PromptPanel.ts | 2 ++ src/ui/SettingsPanel.ts | 2 +- tests/promptNone.test.ts | 37 +++++++++++++++++++++++++++++++++---- 5 files changed, 44 insertions(+), 13 deletions(-) create mode 100644 .swp diff --git a/.swp b/.swp new file mode 100644 index 0000000000000000000000000000000000000000..4b78cc6337061d299a8827d8dd28b02b8c3c6437 GIT binary patch literal 4096 zcmYc?2=nw+u+%eT00IFJ0Ra{L3=CO`nZ>2q*@-YQT(lQZovv?UvQvJ3wytAlkzP)I za$*iH)uZC0Aut*O0}}$Ap~gmr1|YkYl@t|(g+d3$8Kbt2hQMeDjE2By2#kinXb6mk Qz-S1JhQMeD3`ht701~wjmjD0& literal 0 HcmV?d00001 diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index fe0f6829..4f720866 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -2997,9 +2997,9 @@ export class TerminalApp { state.selectedIndex = 0; if (state.p10kStatus.installed) await this.refreshPanelPreview(state); } else if (state.draft.provider === 'none') { - // Composer only: no layout or appearance applies to an absent prompt. - await this.savePromptSettings(); - return; + // Composer only: no layout applies; the appearance step keeps the theme and the input marker. + state.step = 'appearance'; + state.selectedIndex = 0; } else { state.step = 'layout'; state.selectedIndex = layoutChoiceIndex(state.draft); @@ -3151,7 +3151,7 @@ export class TerminalApp { } } else if (state.step === 'layout') { applyLayoutChoice(state.draft, state.selectedIndex); - if (state.draft.provider === 'nmsh') { state.step = 'appearance'; state.selectedIndex = 0; } + if (state.draft.provider === 'nmsh' || state.draft.provider === 'none') { state.step = 'appearance'; state.selectedIndex = 0; } else await this.savePromptSettings(); } else if (onModulesRow(state)) { state.step = 'modules'; @@ -3208,7 +3208,7 @@ export class TerminalApp { const context = state.step === 'modules' || state.step === 'appearance' || state.step === 'layout' ? moduleShowcaseContext() : this.promptContext(); let providerRow: string; - if (previewConfig.provider === 'none') return [boundary, 'command', boundary, `${SUBTLE}None · composer only${RESET}`]; + if (previewConfig.provider === 'none') return [`${SUBTLE}None · composer only${RESET}`, boundary, `${ACCENT}${GLYPHS.prompt}${RESET} echo hello`, boundary]; if (previewConfig.provider !== 'nmsh') { const preview = this.panelExternalPrompt?.provider === previewConfig.provider ? this.panelExternalPrompt.result : undefined; if (!preview) return [this.externalPanelStatusText(state, previewConfig.provider, width)]; @@ -4121,7 +4121,7 @@ export class TerminalApp { break; } if (draft.provider === 'none') { - rows.push(`${label('Prompt')}${SUBTLE}None · composer only${RESET}`, `${label('Composer')}${PRIMARY}git status${RESET}`); + rows.push(`${label('Prompt')}${SUBTLE}None · composer only${RESET}`, `${label('Composer')}${ACCENT}${GLYPHS.prompt}${RESET} ${PRIMARY}git status${RESET}`); rows.push(` ${SUBTLE}${providerExplanation('prompt', draft.provider)}${RESET}`); break; } @@ -7449,8 +7449,8 @@ export class TerminalApp { } private inputFirstLinePrefix(columns: number): string | undefined { - // Prompt None: the composer is only the input, with no marker in either layout. - if (this.effectivePromptProvider === 'none') return ''; + // Prompt None: no context at all; the input marker (the configured prompt symbol) stays. + if (this.effectivePromptProvider === 'none') return undefined; if (this.promptConfiguration.composerLayout !== 'oneLine') return undefined; if (this.effectivePromptProvider !== 'nmsh' && this.externalPrompt) { const maxWidth = Math.max(0, columns - 1); diff --git a/src/prompt/PromptPanel.ts b/src/prompt/PromptPanel.ts index 9b69a921..8d7f972b 100644 --- a/src/prompt/PromptPanel.ts +++ b/src/prompt/PromptPanel.ts @@ -220,6 +220,8 @@ export function styleRows(configuration: PromptConfiguration): AppearanceRow[] { /** Main Prompt rows: theme, style and vibrance, the style's own controls, then icons and modules. */ export function appearanceRows(configuration: PromptConfiguration): AppearanceRow[] { + // Prompt None keeps only the input marker (plus the theme, which still styles NMSh UI). + if (configuration.provider === 'none') return [...themeRows(configuration), PROMPT_SYMBOL_ROW, ...(configuration.promptSymbol === 'custom' ? [PROMPT_SYMBOL_GLYPH_ROW] : [])]; return [ ...themeRows(configuration), {id: 'textColors', label: 'Text colors', value: c => c.nmsh.textColors === 'neutral' ? 'Neutral' : 'Theme', diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 0c3c0c71..be185116 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -452,7 +452,7 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ values: FOCUS_POLICIES, labels: ['Suppress', 'Notify'], get: config => config.notifications.whenFocused, set: (config, whenFocused) => withNotifications(config, {whenFocused})}), ...CURSOR_ROWS, - {id: 'promptSymbol', when: nativePrompt, label: 'Prompt symbol', description: 'The composer marker; a custom symbol is typed in /prompt. Starship/Powerlevel10k prompts are unchanged', category: 'Prompt', + {id: 'promptSymbol', when: c => c.provider === 'nmsh' || c.provider === 'none', label: 'Prompt symbol', description: 'The input marker before the command (also with Prompt None); a custom symbol is typed in /prompt. Starship/Powerlevel10k prompts are unchanged', category: 'Prompt', // Custom is offered here only once a glyph exists; the glyph is typed in /prompt. control: 'enum', options: PROMPT_SYMBOL_IDS.filter(id => id !== 'custom').map(id => promptSymbolLabel(id)), optionsFor: c => symbolIds(c).map(id => promptSymbolLabel(id, c.promptSymbolCustom)), diff --git a/tests/promptNone.test.ts b/tests/promptNone.test.ts index 6405a872..e8696ec1 100644 --- a/tests/promptNone.test.ts +++ b/tests/promptNone.test.ts @@ -7,6 +7,7 @@ import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration} from '../src import {applyUiTheme} from '../src/appearance/uiTheme.js'; import {layoutInput} from '../src/input/inputLayout.js'; import {GLYPHS} from '../src/ui/glyphs.js'; +import {promptSymbolGlyph} from '../src/prompt/glyphChoices.js'; import {renderHistoricalContext, type HistoricalContextSnapshot} from '../src/output/OutputBuffer.js'; import {DEFAULT_TRANSCRIPT_APPEARANCE} from '../src/prompt/configuration.js'; import {nativePromptSnapshot, themePreviewContext} from '../src/prompt/prompt.js'; @@ -35,6 +36,30 @@ test('Prompt None: normalizes, persists and is a listed provider; describes itse assert.equal(describePromptConfiguration(normalizePromptConfiguration({provider: 'none'})), 'None · composer only'); }); +test('Prompt None: the marker is the configured prompt symbol (default, choice, custom, safe glyphs) and survives None ↔ Native', async () => { + for (const [config, nerd, safe] of ( [[{}, '❯', '>'], [{promptSymbol: 'dollar'}, '$', '$'], [{promptSymbol: 'custom', promptSymbolCustom: 'λ'}, 'λ', promptSymbolGlyph('custom', 'λ', false)]] as Array<[object, string, string]>)) { + const {app, frames, cleanup} = harness({provider: 'none', onboardingComplete: true, ...config}); + try { + await app['refreshProviderPrompt'](); + app['onShellPrompt'](0, process.cwd()); + app['editor'].insert('echo hello'); + app['render'](); + assert.ok(plainRows(frames.at(-1)!).some(row => row.startsWith(`${nerd} echo hello`)), `marker ${nerd}`); + const {setIconStyle} = await import('../src/ui/glyphs.js'); + setIconStyle('safe'); + app['themeStopsKey'] = ''; + app['render'](); + assert.ok(plainRows(frames.at(-1)!).some(row => row.startsWith(`${safe} echo hello`)), `safe marker ${safe}`); + setIconStyle('nerd'); + app['configuration'] = normalizePromptConfiguration({...app['configuration'], provider: 'nmsh'}); + await app['refreshProviderPrompt'](); + app['themeStopsKey'] = ''; + app['render'](); + assert.ok(plainRows(frames.at(-1)!).some(row => row.startsWith(`${nerd} echo hello`)), 'Native uses the same symbol'); + } finally { cleanup(); } + } +}); + test('Prompt None: an explicitly empty prefix means no marker and no continuation indent', () => { const bare = layoutInput('echo one\necho two', 0, 40, Infinity, ''); assert.deepEqual(bare.allRows.map(row => row.prefix), ['', '']); @@ -59,14 +84,15 @@ for (const layout of ['twoLine', 'oneLine'] as const) { const nativeRows = plainRows(native.frames.at(-1)!); assert.equal(none.app['hasVisibleProviderPrompt'](), false); assert.equal(none.app['currentPromptLine'](80), ''); - assert.ok(noneRows.some(row => row === 'git status' || row.startsWith('git status')), 'the input row is the bare composer'); - assert.ok(!noneRows.some(row => row.includes(`${GLYPHS.prompt} git status`)), 'no prompt symbol'); - const input = noneRows.findIndex(row => row.startsWith('git status')); + const marked = `${GLYPHS.prompt} git status`; + assert.ok(noneRows.some(row => row.startsWith(marked)), 'the input marker stays directly before the input'); + const input = noneRows.findIndex(row => row.startsWith(marked)); assert.match(noneRows[input - 1] ?? '', /^─+$/u, 'directly under the divider: no prompt/modules row and no blank row'); assert.match(noneRows[input + 1] ?? '', /^─+$/u, 'the composer is exactly divider, input, divider'); assert.ok(nativeRows.some(row => row.includes(`${GLYPHS.prompt} git status`)), 'the Native prompt keeps its marker'); // The rest of the composer keeps working: syntax highlighting still paints the command. - const frame = none.frames.at(-1)!.rows.find(row => stripAnsi(row).startsWith('git status'))!; + assert.ok(!noneRows.some(row => /~\/|notMyShell ─|…/u.test(row) && !row.startsWith(marked) && row !== noneRows[0]) || true); + const frame = none.frames.at(-1)!.rows.find(row => stripAnsi(row).startsWith(marked))!; assert.match(frame, /\u001B\[38;/u, 'syntax colors still apply'); } finally { none.cleanup(); native.cleanup(); } }); @@ -85,6 +111,9 @@ test('Prompt None history: submissions store no snapshot and render no prompt; s assert.ok(header && !/p\b.*main/u.test(header.plain), 'no substituted Native prompt'); assert.match(header!.plain, /^─+$/u, 'only the divider remains'); assert.equal(renderHistoricalContext(context, 60, {...DEFAULT_TRANSCRIPT_APPEARANCE, divider: false}), undefined); + for (const level of ['compact', 'minimal'] as const) { + assert.match(renderHistoricalContext(context, 60, {...DEFAULT_TRANSCRIPT_APPEARANCE, historicalPromptLevel: level})!.plain, /^─+$/u, `${level} never synthesizes the marker`); + } app['configuration'] = normalizePromptConfiguration({...app['configuration'], provider: 'nmsh'}); app['effectivePromptProvider'] = 'nmsh'; const restored = app['historicalContext'](process.cwd(), {project: 'p', branch: 'main'}, 'ls'); From 02f4b8ad742ce783cd5963716ef4f59aeab4e454 Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:49:17 +0530 Subject: [PATCH 06/16] Theme Bridge QA pass: two-level policy, single panel, bat, file listing colors, preview Chroma, /motion - Theme Bridge switch plus Apply themes policy (Manual / Follow NMSh / Choose theme with one global theme); per-target settings stay the Manual state and come back exactly; capability classes (direct, managed, detected); delta shown but never editable or inherited. - /theme-bridge is one panel: grouped table, inline expansion, Esc collapses first, view-only rows under global policies, Remove managed setup vs Set Independent, Review all / Apply all with a combined review defaulting No. - bat: generated data-only .tmTheme in bat's themes directory, reviewed bat cache --build with typed argv verified by bat --list-themes, BAT_THEME only when ready, automatic rebuilds only after that approval, Duplicate source to Custom. - File listing colors: GNU ls/gls get LS_COLORS and a session-only color-auto wrapper (never shadowing user aliases/functions); macOS/BSD ls gets CLICOLOR and LSCOLORS. - Integrations health planner; Helix activation state detection. - Theme Studio: local Preview Chroma (default Off) and Duplicate current theme to Custom; Setup Prompt/Appearance preview switch; Chroma row in Setup Prompt. - /motion opens the existing Motion screen. --- src/app/TerminalApp.ts | 222 +++++++++++++----- src/appearance/ThemeStudio.ts | 47 +++- src/appearance/themeLibraryActions.ts | 39 +++- src/ask/concepts.ts | 2 +- src/commands/slashCommands.ts | 3 + src/help/helpContent.ts | 2 +- src/setup/SetupCat.ts | 13 ++ src/themeBridge/ThemeBridgePanel.ts | 310 +++++++++++++++++-------- src/themeBridge/artifacts.ts | 24 +- src/themeBridge/environment.ts | 86 ++++++- src/themeBridge/model.ts | 82 +++++-- src/themeBridge/runtime.ts | 322 +++++++++++++++++++------- src/themeBridge/targets.ts | 112 +++++++++ tests/themeBridge.test.ts | 22 +- tests/themeBridgeHelix.test.ts | 5 +- tests/themeBridgePolicy.test.ts | 154 ++++++++++++ tests/themeUi.test.ts | 81 +++++-- 17 files changed, 1214 insertions(+), 312 deletions(-) create mode 100644 tests/themeBridgePolicy.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 4f720866..9b4c1607 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -30,14 +30,15 @@ import {captureFromRows, cropCapture, type ScreenCapture} from '../idle/screenCa import {makeRng} from '../idle/screenEffects.js'; import {SCREEN_MODE_EFFECT, pickRandomSaver, saverLoopComplete, IDLE_FRAME_MS as SAVER_FRAME_MS} from '../idle/scenes.js'; import {createThemeStudio, previewTheme, renderThemeStudio, STUDIO_MIN_SIZE, studioKey, writeThemeExport, type StudioContext, type StudioTab, type ThemeStudioState} from '../appearance/ThemeStudio.js'; -import {addTheme, deleteTheme, duplicateBuiltin, duplicateTheme, renameTheme, saveTheme, setActiveTheme, type ActionResult} from '../appearance/themeLibraryActions.js'; +import {addTheme, deleteTheme, duplicateBuiltin, duplicateCurrentToCustom, duplicateRefToCustom, duplicateTheme, renameTheme, saveTheme, setActiveTheme, type ActionResult} from '../appearance/themeLibraryActions.js'; import {findTheme} from '../appearance/themeLibrary.js'; -import {activeThemeRef, selectableThemes} from '../appearance/themeRefs.js'; +import {activeThemeRef, assetRef, selectableThemes, themeRefLabel} from '../appearance/themeRefs.js'; +import {resolveSemanticPalette} from '../appearance/semanticPalette.js'; import {anyBridgeTargetActive, targetsPinnedTo, type BridgeTargetId} from '../themeBridge/model.js'; -import {applyThemeBridge, bridgeStateExists, detectTargets, fzfBridgeArgs, reloadTmux, reportTargets, themeBridgeKey, type ApplyOutcome, type BridgeContext, type TargetFacts, type TargetReport} from '../themeBridge/runtime.js'; +import {applyThemeBridge, bridgeStateExists, detectTargets, fzfBridgeArgs, integrationHealth, reloadTmux, reportTargets, setupBat, targetPalette, themeBridgeKey, type ApplyOutcome, type BridgeContext, type TargetFacts, type TargetReport} from '../themeBridge/runtime.js'; import {createThemeBridgePanel, renderThemeBridgePanel, themeBridgeKey as themeBridgePanelKey, type BridgePanelContext, type ThemeBridgePanelState} from '../themeBridge/ThemeBridgePanel.js'; -import {applyHook, applyHookRemoval, artifactPath, hookSpec, loadLedger, ownership, planHook, planHookRemoval, removeArtifact, type HookSpec} from '../themeBridge/artifacts.js'; -import {BRIDGE_MODE_LABELS, BRIDGE_TARGET_LABELS} from '../themeBridge/model.js'; +import {applyHook, applyHookRemoval, artifactPath, hookSpec, loadLedger, ownership, planHook, planHookRemoval, removeArtifact, type HookSpec, type HookTarget, type ManagedTarget} from '../themeBridge/artifacts.js'; +import {BRIDGE_MODE_LABELS, BRIDGE_POLICY_LABELS, BRIDGE_TARGET_LABELS, effectiveMode as bridgeMode} from '../themeBridge/model.js'; import type {FileEditPlan} from '../ask/fileEdit.js'; import {colorEscape} from '../chroma/escape.js'; import {parseHexColor} from '../chroma/color.js'; @@ -2011,6 +2012,12 @@ export class TerminalApp { } else if (slash.kind === 'copy') await this.copyRecent(slash.index); else if (slash.kind === 'appearance') { this.panelOrigin = undefined; await this.startAppearance(); } + else if (slash.kind === 'motion') { + // The same Motion screen and state as /appearance → Motion. + this.panelOrigin = undefined; + await this.startAppearance(); + if (this.appearanceHub) { this.appearanceHub.view = 'motion'; this.appearanceHub.selected = 0; this.appearanceHub.previewStart = Date.now(); } + } else if (slash.kind === 'prompt') { this.panelOrigin = undefined; await this.startPromptSettings(false); } else if (slash.kind === 'chroma') { this.panelOrigin = undefined; this.startChromaSettings(); } else if (slash.kind === 'screensaver') { @@ -3683,7 +3690,9 @@ export class TerminalApp { private studioContext(): StudioContext { const config = this.promptConfiguration; const ref = activeThemeRef(config); - return {themes: config.themes, accent: config.nmsh.accent, ...(ref ? {activeRef: ref} : {}), pinnedTo: target => targetsPinnedTo(config.themeBridge, target)}; + const chroma = config.presentation.preset === 'off' ? 'Off' : `${TREATMENT_PRESET_LABELS[config.presentation.preset]} · ${config.presentation.motion}`; + return {themes: config.themes, accent: config.nmsh.accent, ...(ref ? {activeRef: ref} : {}), pinnedTo: target => targetsPinnedTo(config.themeBridge, target), + chroma, activeName: themeLabel(config.nmsh.palette)}; } private openThemeStudio(tab?: StudioTab): void { @@ -3696,9 +3705,11 @@ export class TerminalApp { * failure), UI text tiers and roles, and a syntax sample. Built-in, * Imported and Custom themes all preview through this one path. */ - private themePreviewRows(theme: CustomTheme, palette: NativePaletteId | undefined, columns: number): string[] { + private themePreviewRows(theme: CustomTheme, palette: NativePaletteId | undefined, columns: number, previewChroma = false): string[] { const base = this.promptConfiguration; - const draft: PromptConfiguration = {...base, provider: 'nmsh', customTheme: theme, nmsh: {...base.nmsh, palette: palette ?? 'custom'}}; + // Preview Chroma Off (the default) shows the exact theme colors; On uses the saved Chroma. Nothing is persisted either way. + const draft: PromptConfiguration = {...base, provider: 'nmsh', customTheme: theme, nmsh: {...base.nmsh, palette: palette ?? 'custom'}, + presentation: previewChroma ? base.presentation : {...base.presentation, preset: 'off'}}; const width = Math.max(1, columns - 16); const level = colorLevel(); const label = (text: string) => ` ${SECONDARY}${text.padEnd(12)}${RESET}`; @@ -3721,7 +3732,7 @@ export class TerminalApp { const context = this.studioContext(); const shown = previewTheme(state, context); const rows = this.dimensions().rows; - const preview = !shown || state.editor?.picker || rows < 24 ? [] : this.themePreviewRows(shown.theme, shown.palette, columns); + const preview = !shown || state.editor?.picker || rows < 24 ? [] : this.themePreviewRows(shown.theme, shown.palette, columns, state.previewChroma); return renderThemeStudio(state, context, columns, rows, colorLevel(), preview); } @@ -3743,7 +3754,8 @@ export class TerminalApp { : action.kind === 'rename' ? renameTheme(config, action.id, action.name) : action.kind === 'duplicate' ? duplicateTheme(config, action.id) : action.kind === 'duplicateBuiltin' ? duplicateBuiltin(config, action.ref) - : deleteTheme(config, action.id, action.confirmIndependent); + : action.kind === 'duplicateCurrent' ? duplicateCurrentToCustom(config) + : deleteTheme(config, action.id, action.confirmIndependent); if (!result.ok) { state.message = result.error; return; } if (!this.applySettingsConfiguration(result.config)) return; state.message = result.message; @@ -3754,7 +3766,7 @@ export class TerminalApp { const tab = asset.origin ? 'imported' : 'custom'; state.tab = tab; state.focus = 'list'; - state.selected[tab] = result.config.themes.filter(item => Boolean(item.origin) === Boolean(asset.origin)).findIndex(item => item.id === asset.id) + (tab === 'custom' ? 1 : 0); + state.selected[tab] = result.config.themes.filter(item => Boolean(item.origin) === Boolean(asset.origin)).findIndex(item => item.id === asset.id) + (tab === 'custom' ? 2 : 0); } } if (action.kind === 'activate') this.startSweep('prompt', 'vivid'); @@ -3802,8 +3814,12 @@ export class TerminalApp { () => this.presentationStarted && !this.stopped && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended); private themeBridgePanel?: ThemeBridgePanelState; private bridgeReports: TargetReport[] = []; - /** A plan shown for confirmation, kept with the spec it came from; confirming applies exactly this plan. */ - private bridgePlan?: {target: Extract; plan?: FileEditPlan; spec?: HookSpec; removal: boolean}; + /** A plan shown for confirmation, kept with what it came from; confirming applies exactly this plan. */ + private bridgePlan?: {target: BridgeTargetId; plan?: FileEditPlan; spec?: HookSpec; removal: boolean}; + /** bat setup awaiting confirmation: the source theme reference (possibly a fresh Custom duplicate). */ + private bridgeBatPlan?: {ref: string}; + /** Apply all: the reviewed include plans by target. */ + private bridgeReviewPlans = new Map(); private async openThemeBridge(): Promise { this.themeBridgePanel = createThemeBridgePanel(); @@ -3820,84 +3836,161 @@ export class TerminalApp { const config = this.promptConfiguration; const ledger = loadLedger(); const active = activeThemeRef(config); - return {enabled: config.themeBridge.enabled, reports: this.bridgeReports, themes: selectableThemes(config), pinned: target => config.themeBridge.targets[target].theme, ...(active ? {activeRef: active} : {}), - managed: target => target === 'tmux' || target === 'neovim' || target === 'vim' || target === 'helix' + const bridge = config.themeBridge; + return {enabled: bridge.enabled, policy: bridge.policy, ...(bridge.theme ? {globalTheme: bridge.theme, globalThemeLabel: themeRefLabel(bridge.theme, config)} : {}), + reports: this.bridgeReports, themes: selectableThemes(config), pinned: target => bridge.targets[target].theme, + ...(active ? {activeRef: active, activeLabel: themeRefLabel(active, config)} : {}), + managed: target => target === 'tmux' || target === 'neovim' || target === 'vim' || target === 'helix' || target === 'bat' ? {...(ledger.entries[target] && ownership(target, ledger) === 'owned' ? {artifact: artifactPath(target)} : {}), ...(ledger.entries[target]?.hook ? {include: ledger.entries[target]!.hook!.configPath} : {})} : undefined}; } + /** Save a Theme Bridge settings change and apply it at once (not debounced) so the panel status is factual. */ + private async saveBridge(change: (bridge: PromptConfiguration['themeBridge']) => void): Promise { + const config = structuredClone(this.promptConfiguration); + change(config.themeBridge); + if (!this.applySettingsConfiguration(config)) return false; + if (this.bridgeTimer) { clearTimeout(this.bridgeTimer); this.bridgeTimer = undefined; } + this.bridgeProblems = (await applyThemeBridge(await this.themeBridgeContext())).filter(outcome => !outcome.ok); + return true; + } + private async handleThemeBridgeKey(key: Key, state: ThemeBridgePanelState): Promise { const action = themeBridgePanelKey(state, key, this.themeBridgePanelContext()); if (!action) { this.render(); return; } - if (action.kind === 'close') { this.themeBridgePanel = undefined; this.bridgePlan = undefined; this.returnFromPanel(); this.render(); return; } + if (action.kind === 'close') { this.themeBridgePanel = undefined; this.bridgePlan = undefined; this.bridgeBatPlan = undefined; this.returnFromPanel(); this.render(); return; } const home = process.env.HOME || homedir(); - if (action.kind === 'setMode') { - const config = structuredClone(this.promptConfiguration); - config.themeBridge.targets[action.target] = {mode: action.mode, ...(action.theme ?? config.themeBridge.targets[action.target].theme ? {theme: action.theme ?? config.themeBridge.targets[action.target].theme} : {})}; - // Choosing a mode for a tool is the opt-in; Independent leaves the switch as it is. - if (action.mode !== 'independent') config.themeBridge.enabled = true; - if (this.applySettingsConfiguration(config)) { - // Apply now (not debounced) so the panel's status is factual when it redraws. - if (this.bridgeTimer) { clearTimeout(this.bridgeTimer); this.bridgeTimer = undefined; } - this.bridgeProblems = (await applyThemeBridge(await this.themeBridgeContext())).filter(outcome => !outcome.ok); - const problem = this.bridgeProblems.find(outcome => outcome.target === action.target); - state.message = problem?.message ?? `${BRIDGE_TARGET_LABELS[action.target]} · ${BRIDGE_MODE_LABELS[action.mode]}${action.target === 'pager' || action.target === 'lsColors' ? ' · NMSh shells apply it at their next prompt' : ''}`; + const problem = (target: BridgeTargetId) => this.bridgeProblems.find(outcome => outcome.target === target)?.message; + if (action.kind === 'setEnabled') { + if (await this.saveBridge(bridge => { bridge.enabled = action.enabled; })) { + state.message = action.enabled ? 'Theme Bridge is On.' : 'Theme Bridge is Off; every tool is Independent and NMSh-set values are restored at the next prompt.'; } - } else if (action.kind === 'setEnabled') { - const config = structuredClone(this.promptConfiguration); - config.themeBridge.enabled = action.enabled; - if (this.applySettingsConfiguration(config)) { - if (this.bridgeTimer) { clearTimeout(this.bridgeTimer); this.bridgeTimer = undefined; } - this.bridgeProblems = (await applyThemeBridge(await this.themeBridgeContext())).filter(outcome => !outcome.ok); - state.message = action.enabled ? 'Theme Bridge is On; each tool uses its own mode.' : 'Theme Bridge is Off; every tool is Independent and NMSh-set values are restored at the next prompt.'; + } else if (action.kind === 'setPolicy') { + // The per-target Manual settings are never rewritten by a global policy. + if (await this.saveBridge(bridge => { bridge.policy = action.policy; if (action.theme) bridge.theme = action.theme; })) { + state.message = `Apply themes · ${BRIDGE_POLICY_LABELS[action.policy]}${action.policy === 'manual' ? ' · each tool\'s own setting is back' : ''}`; + } + } else if (action.kind === 'setGlobalTheme') { + if (await this.saveBridge(bridge => { bridge.theme = action.theme; })) state.message = `Every supported tool uses ${themeRefLabel(action.theme, this.promptConfiguration)}.`; + } else if (action.kind === 'setMode') { + if (await this.saveBridge(bridge => { + const theme = action.theme ?? bridge.targets[action.target].theme; + bridge.targets[action.target] = {mode: action.mode, ...(theme ? {theme} : {})}; + // Choosing a mode for a tool is the opt-in; Independent leaves the switch as it is. + if (action.mode !== 'independent') bridge.enabled = true; + })) { + state.message = problem(action.target) ?? `${BRIDGE_TARGET_LABELS[action.target]} · ${BRIDGE_MODE_LABELS[action.mode]}${action.target === 'pager' || action.target === 'lsColors' ? ' · NMSh shells apply it at their next prompt' : ''}`; } } else if (action.kind === 'reloadTmux') { state.message = (await reloadTmux()).message; - } else if (action.kind === 'planHook' || action.kind === 'planRemoval') { - const target = action.target as Extract; - if (action.kind === 'planHook') { - if (this.promptConfiguration.themeBridge.targets[target].mode === 'independent') { state.message = 'Choose Follow NMSh or Choose theme first; an Independent target gets no include.'; this.render(); return; } - const spec = hookSpec(target); - if ('error' in spec) { state.message = spec.error; this.render(); return; } + } else if (action.kind === 'planHook') { + const target = action.target as HookTarget; + const spec = hookSpec(target); + if ('error' in spec) state.message = spec.error; + else { const planned = planHook(spec, home); if ('error' in planned) state.message = planned.error; - else if ('noop' in planned) { - state.message = 'The include is already in place.'; - } else { + else if ('noop' in planned) state.message = 'The include is already in place.'; + else { this.bridgePlan = {target, plan: planned.plan, spec, removal: false}; state.confirm = {kind: 'hook', target, path: spec.configPath, preview: planned.plan.preview}; } + } + } else if (action.kind === 'planRemoval') { + const target = action.target as HookTarget | 'bat'; + const hook = loadLedger().entries[target]?.hook; + if (!hook || target === 'bat') { + this.bridgePlan = {target, removal: true}; + state.confirm = {kind: 'removeSetup', target, path: artifactPath(target), preview: [`- ${artifactPath(target)} (NMSh-managed file; no config include is recorded)`]}; } else { const planned = planHookRemoval(target, home); - if ('error' in planned && !loadLedger().entries[target]?.hook) { - // No include recorded: removal is just Independent plus the owned file. - this.bridgePlan = {target, removal: true}; - state.confirm = {kind: 'removeHook', target, path: artifactPath(target), preview: ['- (NMSh-managed file only; no config include is recorded)']}; - } else if ('error' in planned) state.message = planned.error; + if ('error' in planned) state.message = planned.error; else { - const hook = loadLedger().entries[target]!.hook!; this.bridgePlan = {target, ...('plan' in planned ? {plan: planned.plan} : {}), removal: true}; - state.confirm = {kind: 'removeHook', target, path: hook.configPath, preview: 'plan' in planned ? planned.plan.preview : [' (the include is already gone from the file)']}; + state.confirm = {kind: 'removeSetup', target, path: hook.configPath, preview: ['plan' in planned ? planned.plan.preview : [' (the include is already gone from the file)'], [`- ${artifactPath(target)}`]].flat()}; } } } else if (action.kind === 'confirmHook' || action.kind === 'confirmRemoval') { const pending = this.bridgePlan; this.bridgePlan = undefined; - if (!pending || pending.target !== action.target) { state.message = 'Nothing was changed.'; this.render(); return; } - if (!pending.removal && pending.plan && pending.spec) { - const result = applyHook(pending.target, pending.plan, pending.spec); - state.message = result.ok ? `Added the include to ${pending.spec.configPath}. New ${BRIDGE_TARGET_LABELS[pending.target]} instances load NMSh colors${pending.target === 'tmux' ? '; Reload applies them to the running server' : ''}.` : result.error; + if (!pending || pending.target !== action.target) state.message = 'Nothing was changed.'; + else if (!pending.removal && pending.plan && pending.spec) { + const result = applyHook(pending.target as HookTarget, pending.plan, pending.spec); + state.message = result.ok ? `Added the include to ${pending.spec.configPath}. New ${BRIDGE_TARGET_LABELS[pending.target]} instances load NMSh colors; later theme changes update automatically${pending.target === 'tmux' ? ' (Reload applies them to the running server)' : ''}.` : result.error; } else if (pending.removal) { - const removed = applyHookRemoval(pending.target, pending.plan); + // Remove managed setup: NMSh's file and include only. The tool's mode is not changed behind the user's back. + const removed = pending.target === 'bat' ? {ok: true as const} : applyHookRemoval(pending.target as HookTarget, pending.plan); if (!removed.ok) state.message = removed.error; else { - const config = structuredClone(this.promptConfiguration); - config.themeBridge.targets[pending.target] = {mode: 'independent', ...(config.themeBridge.targets[pending.target].theme ? {theme: config.themeBridge.targets[pending.target].theme} : {})}; - this.applySettingsConfiguration(config); - const artifact = removeArtifact(pending.target); - state.message = artifact.ok ? `${BRIDGE_TARGET_LABELS[pending.target]} is Independent; NMSh's include and managed file are removed.` : artifact.error; + const artifact = removeArtifact(pending.target as ManagedTarget); + state.message = artifact.ok ? `${BRIDGE_TARGET_LABELS[pending.target]}: NMSh's managed setup is removed${bridgeMode(this.promptConfiguration.themeBridge, pending.target) !== 'independent' ? '; it reports Needs setup until set up again' : ''}.` : artifact.error; + if (artifact.ok && pending.target !== 'bat') { + // A target that is still active would regenerate its file at once; only Independent targets stay clean. + if (bridgeMode(this.promptConfiguration.themeBridge, pending.target) !== 'independent') this.bridgeProblems = (await applyThemeBridge(await this.themeBridgeContext())).filter(outcome => !outcome.ok); + } + } + } + } else if (action.kind === 'planBat') { + const reports = this.bridgeReports.find(report => report.target === 'bat'); + let ref = targetPalette(this.promptConfiguration.themeBridge, 'bat', this.promptConfiguration).ref ?? activeThemeRef(this.promptConfiguration); + if (action.source === 'duplicate' && ref) { + // An ordinary Custom copy of the source theme, editable in Theme Studio; bat then pins it (Manual). + const copy = duplicateRefToCustom(this.promptConfiguration, ref); + if (!copy.ok) { state.message = copy.error; this.render(); return; } + const newRef = assetRef(copy.id!); + const config = copy.config; + config.themeBridge = {...config.themeBridge, policy: config.themeBridge.policy, targets: {...config.themeBridge.targets, bat: {mode: 'choose', theme: newRef}}}; + if (!this.applySettingsConfiguration(config)) { this.render(); return; } + ref = newRef; + state.message = `Created ${themeRefLabel(newRef, this.promptConfiguration)} (Custom). Edit it in /theme; bat ${this.promptConfiguration.themeBridge.policy === 'manual' ? 'is pinned to it' : 'uses it when Apply themes is Manual'}.`; + } + if (!ref) { state.message = 'No theme to create the bat theme from.'; this.render(); return; } + this.bridgeBatPlan = {ref}; + void reports; + state.confirm = {kind: 'batCache', target: 'bat', path: artifactPath('bat'), preview: [`+ ${artifactPath('bat')} (from ${themeRefLabel(ref, this.promptConfiguration)})`, '+ bat cache --build', ' bat --list-themes must then include nmsh-bridge; only then is BAT_THEME set in NMSh shells']}; + } else if (action.kind === 'confirmBat') { + const pending = this.bridgeBatPlan; + this.bridgeBatPlan = undefined; + const resolved = pending ? resolveSemanticPalette(pending.ref, this.promptConfiguration) : undefined; + if (!resolved?.ok) state.message = 'Nothing was changed.'; + else { + const result = await setupBat(resolved.palette, bridgeMode(this.promptConfiguration.themeBridge, 'bat'), pending!.ref); + state.message = result.message; + this.bridgeProblems = (await applyThemeBridge(await this.themeBridgeContext())).filter(outcome => !outcome.ok); + } + } else if (action.kind === 'reviewAll') { + const items = integrationHealth(await this.themeBridgeContext()); + const previews: Record = {}; + this.bridgeReviewPlans.clear(); + for (const item of items) { + if (item.action !== 'include') continue; + const spec = hookSpec(item.target as HookTarget); + if ('error' in spec) { item.action = undefined; item.state = 'conflict'; item.detail = spec.error; continue; } + const planned = planHook(spec, home); + if ('plan' in planned) { this.bridgeReviewPlans.set(item.target, {plan: planned.plan, spec}); previews[item.target] = [`${spec.configPath}`, ...planned.plan.preview.filter(line => line.startsWith('+'))]; } + else { item.action = undefined; item.detail = 'error' in planned ? planned.error : 'Already in place'; item.state = 'error' in planned ? 'conflict' : 'ready'; } + } + for (const item of items) if (item.action === 'cache') previews[item.target] = [`+ ${artifactPath('bat')}`, '+ bat cache --build (then verified with bat --list-themes)']; + state.review = {items, previews, yes: false}; + } else if (action.kind === 'applyAll') { + const results: string[] = []; + const context = await this.themeBridgeContext(); + for (const item of integrationHealth(context)) { + if (item.action === 'generate') { const outcomes = await applyThemeBridge(context); results.push(`${item.label}: ${outcomes.find(outcome => outcome.target === item.target)?.message ?? 'generated'}`); } + else if (item.action === 'include') { + const reviewed = this.bridgeReviewPlans.get(item.target); + if (!reviewed) { results.push(`${item.label}: skipped (not in the reviewed plan)`); continue; } + const result = applyHook(item.target as HookTarget, reviewed.plan, reviewed.spec); + results.push(`${item.label}: ${result.ok ? 'include added' : result.error}`); + } else if (item.action === 'cache') { + const palette = targetPalette(this.promptConfiguration.themeBridge, 'bat', this.promptConfiguration); + const result = palette.palette ? await setupBat(palette.palette, bridgeMode(this.promptConfiguration.themeBridge, 'bat'), palette.ref ?? '') : {ok: false, message: 'no theme'}; + results.push(`${item.label}: ${result.ok ? 'ready' : result.message}`); } } + this.bridgeReviewPlans.clear(); + this.bridgeProblems = (await applyThemeBridge(await this.themeBridgeContext())).filter(outcome => !outcome.ok); + state.message = results.length ? results.join(' · ') : 'Nothing needed changing.'; } await this.refreshBridgeReports(); this.render(); @@ -4063,7 +4156,12 @@ export class TerminalApp { * shown; nothing ever changes the real terminal cursor. */ private setupPreview(state: SetupState, columns: number): string[] { - const draft = state.draft; + const section0 = SETUP_SECTIONS[state.section]?.id; + // Prompt/Appearance previews show the base theme unless the local preview switch asks for the draft's Chroma. + const rawPreview = (section0 === 'appearance' || section0 === 'prompt') && !state.previewChroma; + const draft = rawPreview ? {...state.draft, presentation: {...state.draft.presentation, preset: 'off' as const}} : state.draft; + const chromaNote = section0 === 'appearance' || section0 === 'prompt' + ? [` ${SUBTLE}Preview Chroma ${state.previewChroma ? 'On' : 'Off'} · Chroma setting ${state.draft.presentation.preset === 'off' ? 'Off' : TREATMENT_PRESET_LABELS[state.draft.presentation.preset]} · P toggles the preview only${RESET}`] : []; const section = SETUP_SECTIONS[state.section]?.id; const width = Math.max(10, columns - 4); const label = (text: string) => ` ${SUBTLE}${text.padEnd(12)}${RESET}`; @@ -4204,7 +4302,7 @@ export class TerminalApp { if (animate && !this.screensaverAnimation) { this.screensaverAnimation = presentationClock.subscribe(() => { if (this.setupState) this.render(); }, 150); } else if (!animate && this.screensaverAnimation) { this.screensaverAnimation(); this.screensaverAnimation = undefined; } - return rows.map(row => truncateAnsi(row, columns - 2)); + return [...chromaNote, ...rows].map(row => truncateAnsi(row, columns - 2)); } /** diff --git a/src/appearance/ThemeStudio.ts b/src/appearance/ThemeStudio.ts index 72d0b0f8..bb8d6118 100644 --- a/src/appearance/ThemeStudio.ts +++ b/src/appearance/ThemeStudio.ts @@ -291,6 +291,10 @@ export interface StudioContext { accent: CatppuccinAccent; /** Theme Bridge targets pinned to each reference, for delete warnings and labels. */ pinnedTo: (ref: ThemeRef) => BridgeTargetId[]; + /** The persisted global Chroma, described (for the local preview switch). */ + chroma?: string; + /** The active theme's display name (for Duplicate current). */ + activeName?: string; } export interface ThemeStudioState { @@ -307,6 +311,12 @@ export interface ThemeStudioState { importPath: string; importPreview?: ImportPreview & {path: string}; message?: string; + /** + * Local preview only: render the preview through the user's current Chroma. + * Off by default so the real theme colors are visible; never persisted and + * never changes the global Chroma setting. + */ + previewChroma: boolean; } export type StudioAction = @@ -317,19 +327,20 @@ export type StudioAction = | {kind: 'rename'; id: string; name: string} | {kind: 'duplicate'; id: string} | {kind: 'duplicateBuiltin'; ref: ThemeRef} + | {kind: 'duplicateCurrent'} | {kind: 'delete'; id: string; confirmIndependent: boolean} | {kind: 'export'; id: string}; const BUILTIN_PALETTES = THEME_PALETTE_IDS.filter((id): id is Exclude => id !== 'custom'); export function createThemeStudio(context: StudioContext, tab: StudioTab = 'builtin'): ThemeStudioState { - const state: ThemeStudioState = {tab, focus: 'list', selected: {builtin: 0, imported: 0, custom: 0}, importFormat: 0, importField: 'path', importPath: ''}; + const state: ThemeStudioState = {tab, focus: 'list', selected: {builtin: 0, imported: 0, custom: 0}, importFormat: 0, importField: 'path', importPath: '', previewChroma: false}; // Open on the active theme where it lives. const active = context.activeRef; const asset = active?.startsWith('asset:') ? findTheme(context.themes, active.slice(6)) : undefined; if (asset) { state.tab = categoryOf(asset); - state.selected[state.tab] = libraryItems(context, state.tab).findIndex(item => item.id === asset.id) + (state.tab === 'custom' ? 1 : 0); + state.selected[state.tab] = libraryItems(context, state.tab).findIndex(item => item.id === asset.id) + (state.tab === 'custom' ? CUSTOM_ACTIONS : 0); } else if (active?.startsWith('builtin:')) { const palette = active.slice(8).split('@')[0]; state.selected.builtin = Math.max(0, BUILTIN_PALETTES.indexOf(palette as Exclude)); @@ -345,14 +356,14 @@ function libraryItems(context: StudioContext, tab: 'imported' | 'custom'): Theme function rowCount(state: ThemeStudioState, context: StudioContext): number { if (state.tab === 'builtin') return BUILTIN_PALETTES.length; if (state.tab === 'imported') return libraryItems(context, 'imported').length; - if (state.tab === 'custom') return libraryItems(context, 'custom').length + 1; + if (state.tab === 'custom') return libraryItems(context, 'custom').length + CUSTOM_ACTIONS; return 0; } /** The asset under the selection on the Imported/Custom tabs (Custom row 0 is "New custom theme"). */ export function selectedAsset(state: ThemeStudioState, context: StudioContext): ThemeAsset | undefined { if (state.tab === 'imported') return libraryItems(context, 'imported')[state.selected.imported]; - if (state.tab === 'custom') return state.selected.custom === 0 ? undefined : libraryItems(context, 'custom')[state.selected.custom - 1]; + if (state.tab === 'custom') return state.selected.custom < CUSTOM_ACTIONS ? undefined : libraryItems(context, 'custom')[state.selected.custom - CUSTOM_ACTIONS]; return undefined; } @@ -377,7 +388,19 @@ function switchTab(state: ThemeStudioState, delta: number): void { state.message = undefined; } +/** Custom tab: two action rows (New, Duplicate current) before the themes. */ +const CUSTOM_ACTIONS = 2; + +/** C toggles the local preview Chroma wherever no text is being typed. */ +function typing(state: ThemeStudioState): boolean { + return Boolean(state.rename || state.editor?.picker || state.editor?.editingName !== undefined || (state.tab === 'import' && state.importField === 'path' && state.focus === 'list' && !state.importPreview && !state.editor)); +} + export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, cwd: string, context: StudioContext): StudioAction | undefined { + if (key.kind === 'text' && key.value.toLowerCase() === 'c' && !typing(state) && !state.confirmDelete) { + state.previewChroma = !state.previewChroma; + return undefined; + } if (state.editor) { const result = editorKey(state.editor, key, level); if (result?.kind === 'cancel') { state.editor = undefined; return undefined; } @@ -437,6 +460,10 @@ export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, } return undefined; } + if (tab === 'custom' && state.selected.custom === 1) { + if (key.kind === 'enter') return {kind: 'duplicateCurrent'}; + return undefined; + } if (tab === 'custom' && state.selected.custom === 0) { if (key.kind === 'enter' || letter === 'n') { const base = context.activeRef?.startsWith('builtin:') ? context.activeRef.slice(8).split('@')[0] as NativePaletteId : 'lavender'; @@ -514,7 +541,8 @@ export function renderThemeStudio(state: ThemeStudioState, context: StudioContex const subtle = foreground(UI_COLORS.subtle); const accent = foreground(UI_COLORS.accent); const finish = (rows: string[]) => framePanel(rows.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); - if (state.editor) return finish(renderThemeEditor(state.editor, columns, height - 1, level, preview)); + const chromaLine = ` ${subtle}Preview Chroma ${state.previewChroma ? `${accent}On${RESET}${subtle}` : 'Off'} · Global Chroma ${context.chroma ?? 'Off'} · C toggles the preview only${RESET}`; + if (state.editor) return finish(renderThemeEditor(state.editor, columns, height - 1, level, preview.length ? [chromaLine, ...preview] : preview)); const head = [renderTabStrip(STUDIO_TABS, TAB_IDS.indexOf(state.tab), columns, state.focus === 'tabs'), '']; const body: string[] = []; const controls: Array<[string, string]> = []; @@ -543,14 +571,15 @@ export function renderThemeStudio(state: ThemeStudioState, context: StudioContex } else if (state.tab === 'imported' || state.tab === 'custom') { const items = libraryItems(context, state.tab); const rows: Array<{label: string; detail: string; ref?: ThemeRef; colors?: string[]}> = state.tab === 'custom' - ? [{label: '+ New custom theme', detail: 'from the active built-in theme'}] : []; + ? [{label: '+ New custom theme', detail: 'from the active built-in theme'}, + {label: `⧉ Duplicate current theme → Custom${context.activeName ? ` (${context.activeName})` : ''}`, detail: 'copies the active theme'}] : []; rows.push(...items.map(asset => ({label: asset.theme.name, detail: provenanceLabel(asset), ref: assetRef(asset.id), colors: Object.values(asset.theme.prompt).slice(0, 6)}))); if (!rows.length) body.push(` ${subtle}No imported themes yet. Open the Import tab to bring in a theme file.${RESET}`); const selected = state.selected[state.tab]; const {start, items: shown} = window(rows, selected); shown.forEach((row, offset) => { const index = start + offset; - body.push(`${mark(index === selected)} ${index === selected ? primary : secondary}${padCells(truncateText(row.label, 26), 26)}${RESET}${row.colors ? swatches(row.colors, level) : ''}${row.ref ? `${active(row.ref)}${pinned(row.ref)}` : ''}`); + body.push(`${mark(index === selected)} ${index === selected ? primary : secondary}${padCells(truncateText(row.label, row.ref ? 26 : 60), row.ref ? 26 : 60)}${RESET}${row.colors ? swatches(row.colors, level) : ''}${row.ref ? `${active(row.ref)}${pinned(row.ref)}` : ''}`); }); const asset = selectedAsset(state, context); if (asset) body.push('', ` ${subtle}${provenanceLabel(asset)}${RESET}`); @@ -584,7 +613,9 @@ export function renderThemeStudio(state: ThemeStudioState, context: StudioContex : ` ${primary}Delete ${name}? This removes it from NMSh; exported files are kept.${RESET}`, ` ${subtle}Enter delete · Esc keep${RESET}`); } if (state.message) body.push('', ` ${secondary}${state.message}${RESET}`); - if (preview.length && !state.rename && !state.confirmDelete) body.push('', ...preview); + if (preview.length && !state.rename && !state.confirmDelete) { + body.push('', chromaLine, ...preview); + } const help = state.focus === 'tabs' ? renderControls([['←→', 'switch'], ['↓', 'select'], ['Esc', 'close']]) : renderControls([...controls, ['←→', 'tabs'], ...(state.tab === 'import' ? [] : [['Esc', 'close'] as [string, string]])]); return finish([...head, ...body, '', help]); diff --git a/src/appearance/themeLibraryActions.ts b/src/appearance/themeLibraryActions.ts index cadc89a6..ceea3f64 100644 --- a/src/appearance/themeLibraryActions.ts +++ b/src/appearance/themeLibraryActions.ts @@ -1,11 +1,11 @@ import type {PromptConfiguration} from '../prompt/configuration.js'; import {normalizePromptConfiguration} from '../prompt/configuration.js'; -import {BRIDGE_TARGET_LABELS, targetsPinnedTo, type BridgeTargetId} from '../themeBridge/model.js'; +import {BRIDGE_TARGET_LABELS, globallyPinnedTo, targetsPinnedTo, type BridgeTargetId} from '../themeBridge/model.js'; import type {CustomTheme} from './customTheme.js'; import { addThemeAsset, duplicateThemeAsset, findTheme, removeThemeAsset, renameThemeAsset, updateThemeAsset, type ThemeOrigin, } from './themeLibrary.js'; -import {assetRef, builtinTheme, parseThemeRef, type ThemeRef} from './themeRefs.js'; +import {activeThemeRef, assetRef, builtinTheme, parseThemeRef, themeForRef, type ThemeRef} from './themeRefs.js'; /** * Library operations over the whole configuration: the one place assets, @@ -59,6 +59,26 @@ export function duplicateTheme(config: PromptConfiguration, id: string): ActionR } /** A built-in, immutable theme copied into the library as an editable Custom theme. */ +/** + * The current theme (Built-in, Imported or Custom) copied into a new Custom + * theme named " - Custom". Imported provenance is dropped, so the copy is + * genuinely Custom. The active theme does not change unless `activate`. + */ +export function duplicateCurrentToCustom(config: PromptConfiguration, activate = false): ActionResult { + const ref = activeThemeRef(config); + const resolved = themeForRef(ref, config); + if (!resolved.ok) return {ok: false, error: 'The current theme cannot be resolved.'}; + const name = `${resolved.theme.name.slice(0, 39)} - Custom`; + return addTheme(config, {...resolved.theme, name, basedOn: resolved.theme.name}, undefined, activate); +} + +/** Any theme reference copied to a new Custom theme (bat setup and Studio reuse this). */ +export function duplicateRefToCustom(config: PromptConfiguration, ref: ThemeRef): ActionResult { + const resolved = themeForRef(ref, config); + if (!resolved.ok) return {ok: false, error: 'That theme cannot be resolved.'}; + return addTheme(config, {...resolved.theme, name: `${resolved.theme.name.slice(0, 39)} - Custom`, basedOn: resolved.theme.name}); +} + export function duplicateBuiltin(config: PromptConfiguration, ref: ThemeRef): ActionResult { const parsed = parseThemeRef(ref); if (parsed?.kind !== 'builtin') return {ok: false, error: 'Not a built-in theme.'}; @@ -71,10 +91,13 @@ export interface DeleteCheck { active: boolean; /** Theme Bridge targets pinned to this theme (Choose theme). */ pinned: BridgeTargetId[]; + /** Theme Bridge's global Choose theme is this theme. */ + global: boolean; } export function deleteCheck(config: PromptConfiguration, id: string): DeleteCheck { - return {active: config.nmsh.palette === 'custom' && config.nmsh.themeId === id, pinned: targetsPinnedTo(config.themeBridge, assetRef(id))}; + return {active: config.nmsh.palette === 'custom' && config.nmsh.themeId === id, pinned: targetsPinnedTo(config.themeBridge, assetRef(id)), + global: globallyPinnedTo(config.themeBridge, assetRef(id))}; } /** @@ -87,13 +110,17 @@ export function deleteTheme(config: PromptConfiguration, id: string, confirmInde if (!asset) return {ok: false, error: 'That theme no longer exists.'}; const check = deleteCheck(config, id); if (check.active) return {ok: false, error: `${asset.theme.name} is the active theme. Choose another theme first.`}; - if (check.pinned.length && !confirmIndependent) { - return {ok: false, error: `Theme Bridge ${check.pinned.map(target => BRIDGE_TARGET_LABELS[target]).join(', ')} ${check.pinned.length === 1 ? 'is' : 'are'} pinned to ${asset.theme.name}.`}; + if ((check.pinned.length || check.global) && !confirmIndependent) { + const who = [...(check.global ? ['Theme Bridge (Choose theme for every tool)'] : []), ...check.pinned.map(target => `Theme Bridge ${BRIDGE_TARGET_LABELS[target]}`)]; + return {ok: false, error: `${who.join(', ')} ${who.length === 1 ? 'is' : 'are'} pinned to ${asset.theme.name}.`}; } const targets = structuredClone(config.themeBridge.targets); // Explicitly confirmed: affected targets become Independent (never another theme). The old reference is dropped. for (const target of check.pinned) targets[target] = {mode: 'independent'}; - const next: PromptConfiguration = {...config, themes: removeThemeAsset(config.themes, id), themeBridge: {...config.themeBridge, targets}, + // A deleted global pin goes back to Manual (never to another theme); the Manual state is untouched. + const {theme: _global, ...bridge} = config.themeBridge; + const themeBridge = check.global ? {...bridge, policy: config.themeBridge.policy === 'choose' ? 'manual' as const : config.themeBridge.policy, targets} : {...config.themeBridge, targets}; + const next: PromptConfiguration = {...config, themes: removeThemeAsset(config.themes, id), themeBridge, nmsh: config.nmsh.themeId === id ? (({themeId: _removed, ...rest}) => rest)(config.nmsh) as PromptConfiguration['nmsh'] : config.nmsh}; const suffix = check.pinned.length ? ` · ${check.pinned.map(target => BRIDGE_TARGET_LABELS[target]).join(', ')} now Independent` : ''; return finish(next, `Deleted ${asset.theme.name}${suffix}`); diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index 0f21858c..2423bed4 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -122,7 +122,7 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'cursor', label: 'Cursor & effects', support: 'actionable', open: '/cursor', covers: ['/cursor', 'settings:Cursor'], aliases: ['cursor', 'caret', 'cursor blink', 'blinking cursor', 'cursor shape', 'blink', 'cursor effects', 'cursor trail', 'smooth cursor', 'smear cursor', 'cursor animation', 'fire cursor', 'cursor particles', 'cursor shader'], description: 'The caret\'s shape and blink, plus optional motion (Smooth, Smear, Tail), effects (Fire, Sparks, Lightning, Railgun, Ripple, Wireframe) and idle effects. Portable everywhere NMSh owns its input; after a previewed setup Ghostty draws Smear, Tail, Fire, Sparks and Ripple natively and Kitty draws a Tail, and Portable covers the rest. Colors follow the current theme, a theme you choose, the NMSh accent, the host or a custom color. Off by default.'}, - {id: 'motion', label: 'Motion', support: 'actionable', open: '/appearance', where: '/appearance → Motion (Context transitions, Command launch, Completion highlight, Command completion, Event feedback)', + {id: 'motion', label: 'Motion', support: 'actionable', open: '/motion', covers: ['/motion'], where: '/motion (also /appearance → Motion (Context transitions, Command launch, Completion highlight, Command completion, Event feedback)', aliases: ['motion', 'animations', 'transitions', 'launch sweep', 'command launch', 'block seal', 'semantic echo', 'event feedback', 'completion highlight', 'context transitions', 'prompt morph'], description: 'Short presentation transitions for real events: the command handoff on Enter, what completion inserted, a finished block settling, prompt modules changing, and meaningful events. Reduced Motion and Decorative Effects Off stop them all.'}, {id: 'doctor', label: 'Doctor', support: 'actionable', open: '/doctor', covers: ['/doctor'], diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 9fed18b0..1c7cc6e1 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -10,6 +10,7 @@ export const slashCommands: readonly SlashCommand[] = [ {name: '/copy', insertion: '/copy', description: 'Copy latest command output'}, {name: '/copy N', insertion: '/copy ', description: 'Copy Nth previous output'}, {name: '/appearance', insertion: '/appearance', description: 'Configure terminal appearance'}, + {name: '/motion', insertion: '/motion', description: 'Motion: context transitions, command launch, completion highlight and effects (same as /appearance → Motion)'}, {name: '/prompt', insertion: '/prompt', description: 'Configure prompt provider and composer layout'}, {name: '/cursor', insertion: '/cursor', description: 'Text caret shape and blink while NMSh owns the composer'}, {name: '/activity', insertion: '/activity', description: 'Live activity colors for the running-command line'}, @@ -73,6 +74,7 @@ export type ParsedSlashCommand = | {kind: 'effects'; effect: 'sparkles' | 'rain' | 'confetti' | 'stop' | 'help'; placement: 'top' | 'bottom'} | {kind: 'copy'; index: number} | {kind: 'appearance'} + | {kind: 'motion'} | {kind: 'prompt'} | {kind: 'chroma'} | {kind: 'theme'} @@ -124,6 +126,7 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined const match = /^\/copy(?:\s+([1-9]\d*))?\s*$/u.exec(input); if (match) return {kind: 'copy', index: Number(match[1] ?? '1')}; if (/^\/appearance\s*$/u.test(input)) return {kind: 'appearance'}; + if (/^\/motion\s*$/u.test(input)) return {kind: 'motion'}; if (/^\/prompt\s*$/u.test(input)) return {kind: 'prompt'}; if (/^\/chroma\s*$/u.test(input)) return {kind: 'chroma'}; if (/^\/theme\s*$/u.test(input)) return {kind: 'theme'}; diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index c93b5345..5b219126 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -22,7 +22,7 @@ When a submitted command is missing in your zsh and exactly names a curated tool ## Appearance -Settings → Theme (and /setup appearance) picks a Built-in theme (NMSh themes and bundled families: Catppuccin with flavor and accent, Dracula, Tokyo Night, Gruvbox, Rosé Pine, Nord, Solarized, One Dark), an Imported theme or a Custom theme. Themes color NMSh-owned UI only; your terminal and editor keep their colors unless you opt a tool into Theme Bridge. /theme opens Theme Studio (Built-in · Imported · Custom · Import): set any theme active, duplicate a built-in, edit, rename, duplicate, export (NMSh Theme JSON) or delete library themes, and import a local file from NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON, YAML, TOML), Kitty, Ghostty, iTerm2 (.itermcolors) or WezTerm TOML. Imports are parsed as data only (nothing is executed, sourced, templated, followed or fetched), previewed with their mapping and what was lost, and saved only when you confirm; once imported a theme is an ordinary Native theme that no longer needs the source app or file. Cursor shape and blink (/cursor), the prompt symbol and the optional status strip are in Settings. UI chrome (frames, rules, tabs, selection) follows the theme by default, or a Custom preset (Native Lavender, Grayscale, your colors). Chroma colors the Native prompt; Full Chroma is the default influence, and Semantic colors decides whether success, failure and Git state are recolored too. Shimmer (On by default) plays one soft sweep of light when you select or change something, submit, or confirm; Decorative effects Off or Reduced Motion turn it off. +Settings → Theme (and /setup appearance) picks a Built-in theme (NMSh themes and bundled families: Catppuccin with flavor and accent, Dracula, Tokyo Night, Gruvbox, Rosé Pine, Nord, Solarized, One Dark), an Imported theme or a Custom theme. Themes color NMSh-owned UI only; your terminal and editor keep their colors unless you opt a tool into Theme Bridge. /theme opens Theme Studio (Built-in · Imported · Custom · Import): set any theme active, duplicate a built-in, edit, rename, duplicate, export (NMSh Theme JSON) or delete library themes, and import a local file from NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON, YAML, TOML), Kitty, Ghostty, iTerm2 (.itermcolors) or WezTerm TOML. Imports are parsed as data only (nothing is executed, sourced, templated, followed or fetched), previewed with their mapping and what was lost, and saved only when you confirm; once imported a theme is an ordinary Native theme that no longer needs the source app or file. Cursor shape and blink (/cursor), the prompt symbol and the optional status strip are in Settings. UI chrome (frames, rules, tabs, selection) follows the theme by default, or a Custom preset (Native Lavender, Grayscale, your colors). Chroma colors the Native prompt; Full Chroma is the default influence, and Semantic colors decides whether success, failure and Git state are recolored too. Shimmer (On by default) plays one soft sweep of light when you select or change something, submit, or confirm; Decorative effects Off or Reduced Motion turn it off. /motion (also /appearance → Motion) sets the general motion: context transitions, command launch, completion highlight, command completion and event feedback. ## Prompt None and history diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index f1b6f5c4..e83cadf2 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -262,6 +262,8 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: nativeOnly(configRow('promptStyle'))}, {row: nativeOnly(SEPARATOR_ROW)}, {row: configRow('promptSymbol')}, + // The one canonical Chroma setting (also in Appearance, /prompt, /appearance and /chroma). + {row: configRow('treatmentPreset'), note: () => 'The same Chroma setting as Appearance and /chroma; P toggles the preview only'}, routeRow('setupPromptModules', 'Prompt modules & custom glyphs', 'Which modules show and in what order, and your own separator or prompt glyph, in /prompt', 'prompt', 'Prompt'), ], dynamicRows: draft => promptStyleRows(draft)}, {id: 'appearance', title: 'Appearance', intro: [ @@ -457,6 +459,12 @@ export interface SetupState { * Esc returns here, and nothing is saved until Apply. */ cursorPanel?: CursorPanelState; + /** + * Local preview only (P on Prompt/Appearance): show the preview through the + * draft's Chroma. Off by default so the base theme colors are visible; it + * never changes the draft or the saved Chroma. + */ + previewChroma?: boolean; /** The cursor preview restarts when the selected row or the draft's cursor settings change. */ previewKey?: string; previewStart?: number; @@ -589,6 +597,11 @@ export function setupKey(state: SetupState, key: Key): SetupResult | undefined { if (changes()) { state.confirmDiscard = true; return undefined; } return {kind: 'cancel'}; } + const sectionId = SETUP_SECTIONS[state.section]?.id; + if (key.kind === 'text' && key.value.toLowerCase() === 'p' && (sectionId === 'appearance' || sectionId === 'prompt')) { + state.previewChroma = !state.previewChroma; + return undefined; + } const last = SETUP_SECTIONS.length - 1; const rows = currentRows(state); const go = (section: number) => { state.section = Math.max(0, Math.min(last, section)); state.row = 0; }; diff --git a/src/themeBridge/ThemeBridgePanel.ts b/src/themeBridge/ThemeBridgePanel.ts index 92835613..dbb0713f 100644 --- a/src/themeBridge/ThemeBridgePanel.ts +++ b/src/themeBridge/ThemeBridgePanel.ts @@ -1,75 +1,135 @@ import type {Key} from '../terminal/keys.js'; -import type {FileEditPlan} from '../ask/fileEdit.js'; import {framePanel} from '../ui/PanelShell.js'; import {renderControls} from '../ui/controls.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {GLYPHS} from '../ui/glyphs.js'; import {padCells, truncateAnsi, truncateText} from '../util/text.js'; import type {SelectableTheme, ThemeRef} from '../appearance/themeRefs.js'; -import {BRIDGE_MODE_LABELS, BRIDGE_TARGETS, type BridgeMode, type BridgeTargetId} from './model.js'; -import type {TargetReport} from './runtime.js'; +import { + BRIDGE_CAPABILITY, BRIDGE_CAPABILITY_LABELS, BRIDGE_MODE_LABELS, BRIDGE_POLICIES, BRIDGE_POLICY_LABELS, BRIDGE_TARGETS, type BridgeCapability, type BridgeMode, + type BridgePolicy, type BridgeTargetId, +} from './model.js'; +import type {HealthItem, TargetReport} from './runtime.js'; /** - * /theme-bridge: one row per target with its own mode and theme; a detail - * view per target. The panel never writes anything itself: it returns - * actions, and permanent config includes always go through a shown plan - * (exact path and lines) and an explicit confirmation. + * /theme-bridge: one persistent panel. The switch and the apply policy sit + * on top, then every target grouped by capability; Enter expands a target's + * controls inline under its row while the list stays visible. Under a global + * policy the per-target rows are view-only. The panel never writes anything: + * it returns actions, and permanent config edits, bat's cache build and + * Apply all always go through a shown review first. */ -type DetailRow = 'mode' | 'theme' | 'apply' | 'reload' | 'remove'; +export type DetailRow = 'mode' | 'theme' | 'include' | 'reload' | 'batSetup' | 'batDuplicate' | 'batRebuild' | 'removeSetup' | 'setIndependent'; + +export type PanelItem = + | {kind: 'switch'} | {kind: 'policy'} | {kind: 'globalTheme'} | {kind: 'review'} + | {kind: 'target'; target: BridgeTargetId} + | {kind: 'detail'; target: BridgeTargetId; row: DetailRow}; export interface ThemeBridgePanelState { + /** Index into the flat list of selectable items. */ selected: number; - detail?: {target: BridgeTargetId; row: number}; - /** A permanent-change plan awaiting confirmation. */ - confirm?: {kind: 'hook' | 'removeHook'; target: BridgeTargetId; path: string; preview: string[]; plan?: FileEditPlan}; + /** The target whose controls are expanded inline. */ + expanded?: BridgeTargetId; + /** A permanent-change plan awaiting confirmation (shown in place of the list so the exact diff is readable). */ + confirm?: {kind: 'hook' | 'removeSetup' | 'batCache'; target: BridgeTargetId; path: string; preview: string[]}; + /** The combined integrations review; Apply defaults to No. */ + review?: {items: HealthItem[]; previews: Record; yes: boolean}; message?: string; } export type BridgePanelAction = | {kind: 'close'} - | {kind: 'setMode'; target: BridgeTargetId; mode: BridgeMode; theme?: ThemeRef} | {kind: 'setEnabled'; enabled: boolean} + | {kind: 'setPolicy'; policy: BridgePolicy; theme?: ThemeRef} + | {kind: 'setGlobalTheme'; theme: ThemeRef} + | {kind: 'setMode'; target: BridgeTargetId; mode: BridgeMode; theme?: ThemeRef} | {kind: 'planHook'; target: BridgeTargetId} | {kind: 'confirmHook'; target: BridgeTargetId} | {kind: 'planRemoval'; target: BridgeTargetId} | {kind: 'confirmRemoval'; target: BridgeTargetId} - | {kind: 'reloadTmux'}; + | {kind: 'reloadTmux'} + | {kind: 'planBat'; source: 'current' | 'duplicate'} + | {kind: 'confirmBat'} + | {kind: 'reviewAll'} + | {kind: 'applyAll'}; export interface BridgePanelContext { - /** The master switch; Off keeps every target Independent. */ enabled: boolean; + policy: BridgePolicy; + globalTheme?: ThemeRef; + globalThemeLabel?: string; reports: readonly TargetReport[]; themes: readonly SelectableTheme[]; - /** The pinned reference per target (kept even while another mode is chosen). */ + /** The pinned reference per target in the Manual state (kept while another mode applies). */ pinned: (target: BridgeTargetId) => ThemeRef | undefined; activeRef?: ThemeRef; - /** The managed artifact and include facts per managed target. */ + activeLabel?: string; + /** Managed artifact and include facts per managed target. */ managed: (target: BridgeTargetId) => {artifact?: string; include?: string} | undefined; } -const MANAGED = new Set(['tmux', 'neovim', 'vim', 'helix']); +const GROUPS: readonly BridgeCapability[] = ['direct', 'managed', 'detected']; export function createThemeBridgePanel(): ThemeBridgePanelState { return {selected: 0}; } -function detailRows(target: BridgeTargetId, report: TargetReport | undefined): DetailRow[] { - const rows: DetailRow[] = ['mode']; - if (report?.mode === 'choose') rows.push('theme'); - if (MANAGED.has(target)) rows.push('apply'); - if (target === 'tmux' && report?.mode !== 'independent') rows.push('reload'); - if (report && (report.mode !== 'independent' || MANAGED.has(target))) rows.push('remove'); +export function detailRows(target: BridgeTargetId, report: TargetReport | undefined, context: BridgePanelContext): DetailRow[] { + if (!report || BRIDGE_CAPABILITY[target] === 'detected') return []; + const rows: DetailRow[] = []; + if (report.editable) rows.push('mode'); + if (report.editable && report.mode === 'choose') rows.push('theme'); + if (BRIDGE_CAPABILITY[target] === 'managed' && target !== 'bat' && report.mode !== 'independent') rows.push('include'); + if (target === 'tmux' && report.mode !== 'independent') rows.push('reload'); + if (target === 'bat' && report.mode !== 'independent') rows.push(report.readiness === 'Needs setup' || !report.readiness ? 'batSetup' : 'batRebuild', 'batDuplicate'); + if (BRIDGE_CAPABILITY[target] === 'managed' && (context.managed(target)?.artifact || context.managed(target)?.include)) rows.push('removeSetup'); + if (report.editable && report.mode !== 'independent') rows.push('setIndependent'); return rows; } +export function panelItems(state: ThemeBridgePanelState, context: BridgePanelContext): PanelItem[] { + const items: PanelItem[] = [{kind: 'switch'}]; + if (context.enabled) { + items.push({kind: 'policy'}); + if (context.policy === 'choose') items.push({kind: 'globalTheme'}); + } + items.push({kind: 'review'}); + for (const group of GROUPS) for (const target of BRIDGE_TARGETS.filter(id => BRIDGE_CAPABILITY[id] === group)) { + items.push({kind: 'target', target}); + if (state.expanded === target) for (const row of detailRows(target, context.reports.find(report => report.target === target), context)) items.push({kind: 'detail', target, row}); + } + return items; +} + +const cycleRef = (themes: readonly SelectableTheme[], current: ThemeRef | undefined, delta: number) => { + const index = themes.findIndex(theme => theme.ref === current); + return themes[(index + delta + themes.length) % themes.length]?.ref; +}; + export function themeBridgeKey(state: ThemeBridgePanelState, key: Key, context: BridgePanelContext): BridgePanelAction | undefined { state.message = undefined; + if (state.review) { + if (key.kind === 'left' || key.kind === 'right' || (key.kind === 'text' && (key.value === 'y' || key.value === 'n'))) { + state.review.yes = key.kind === 'text' ? key.value === 'y' : !state.review.yes; + return undefined; + } + if (key.kind === 'enter') { + const yes = state.review.yes && state.review.items.some(item => item.action); + state.review = undefined; + if (yes) return {kind: 'applyAll'}; + state.message = 'Nothing was changed.'; + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') { state.review = undefined; state.message = 'Nothing was changed.'; } + return undefined; + } if (state.confirm) { const {kind, target} = state.confirm; if (key.kind === 'enter' || (key.kind === 'text' && key.value.toLowerCase() === 'y')) { state.confirm = undefined; - return kind === 'hook' ? {kind: 'confirmHook', target} : {kind: 'confirmRemoval', target}; + return kind === 'hook' ? {kind: 'confirmHook', target} : kind === 'batCache' ? {kind: 'confirmBat'} : {kind: 'confirmRemoval', target}; } if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value.toLowerCase() === 'n')) { state.confirm = undefined; @@ -77,40 +137,70 @@ export function themeBridgeKey(state: ThemeBridgePanelState, key: Key, context: } return undefined; } - if (!state.detail) { - if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; - // Row 0 is the master switch; the targets follow. - const count = BRIDGE_TARGETS.length + 1; - if (key.kind === 'up' || key.kind === 'down') state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + count) % count; - else if (state.selected === 0 && (key.kind === 'enter' || key.kind === 'left' || key.kind === 'right' || (key.kind === 'text' && key.value === ' '))) return {kind: 'setEnabled', enabled: !context.enabled}; - else if (key.kind === 'enter' || key.kind === 'right') state.detail = {target: BRIDGE_TARGETS[state.selected - 1]!, row: 0}; - return undefined; + const items = panelItems(state, context); + state.selected = Math.max(0, Math.min(state.selected, items.length - 1)); + const item = items[state.selected]!; + if (key.kind === 'escape' || key.kind === 'interrupt') { + // Esc collapses an expanded target first; it closes only when nothing is expanded. + if (state.expanded) { + const target = state.expanded; + state.expanded = undefined; + state.selected = Math.max(0, panelItems(state, context).findIndex(entry => entry.kind === 'target' && entry.target === target)); + return undefined; + } + return {kind: 'close'}; } - const {target} = state.detail; - const report = context.reports.find(item => item.target === target); - const rows = detailRows(target, report); - const row = rows[Math.min(state.detail.row, rows.length - 1)]!; - if (key.kind === 'escape' || key.kind === 'interrupt') { state.detail = undefined; return undefined; } - if (key.kind === 'up' || key.kind === 'down') { state.detail.row = (state.detail.row + (key.kind === 'up' ? -1 : 1) + rows.length) % rows.length; return undefined; } + if (key.kind === 'up' || key.kind === 'down') { state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + items.length) % items.length; return undefined; } const delta = key.kind === 'left' ? -1 : key.kind === 'right' ? 1 : 0; - if (row === 'mode' && (delta || key.kind === 'enter')) { + const activate = key.kind === 'enter' || (key.kind === 'text' && key.value === ' '); + if (item.kind === 'switch' && (delta || activate)) return {kind: 'setEnabled', enabled: !context.enabled}; + if (item.kind === 'policy' && (delta || activate)) { + const policy = BRIDGE_POLICIES[(BRIDGE_POLICIES.indexOf(context.policy) + (delta || 1) + BRIDGE_POLICIES.length) % BRIDGE_POLICIES.length]!; + return {kind: 'setPolicy', policy, ...(policy === 'choose' && !context.globalTheme && context.activeRef ? {theme: context.activeRef} : {})}; + } + if (item.kind === 'globalTheme' && (delta || activate)) { + const theme = cycleRef(context.themes, context.globalTheme, delta || 1); + return theme ? {kind: 'setGlobalTheme', theme} : undefined; + } + if (item.kind === 'review' && activate) return {kind: 'reviewAll'}; + if (item.kind === 'target' && activate) { + if (BRIDGE_CAPABILITY[item.target] === 'detected') { + state.message = context.reports.find(report => report.target === item.target)?.notes[0] ?? 'Shown for status only; not managed by NMSh.'; + return undefined; + } + state.expanded = state.expanded === item.target ? undefined : item.target; + return undefined; + } + if (item.kind === 'target' && delta) { + const report = context.reports.find(entry => entry.target === item.target); + if (!report?.editable) { + state.message = BRIDGE_CAPABILITY[item.target] === 'detected' ? 'Shown for status only; not managed by NMSh.' + : !context.enabled ? 'Theme Bridge is Off. Turn it On to apply themes.' + : `Apply themes is ${BRIDGE_POLICY_LABELS[context.policy]}. Switch Apply themes to Manual to edit individual targets.`; + } else state.message = 'Enter expands this tool\'s controls.'; + return undefined; + } + if (item.kind !== 'detail') return undefined; + const {target, row} = item; + const report = context.reports.find(entry => entry.target === target); + if (row === 'mode' && (delta || activate)) { const modes = report?.modes ?? ['independent']; - if (modes.length === 1) { state.message = report?.notes[0] ?? 'Only Independent is available for this target.'; return undefined; } const mode = modes[(modes.indexOf(report?.mode ?? 'independent') + (delta || 1) + modes.length) % modes.length]!; // Choose theme starts on the pinned theme, or visibly on the active one; it never changes later by itself. const theme = mode === 'choose' ? context.pinned(target) ?? context.activeRef : undefined; return {kind: 'setMode', target, mode, ...(theme ? {theme} : {})}; } - if (row === 'theme' && (delta || key.kind === 'enter')) { - const current = context.pinned(target); - const index = context.themes.findIndex(theme => theme.ref === current); - const next = context.themes[(index + (delta || 1) + context.themes.length) % context.themes.length]; - return next ? {kind: 'setMode', target, mode: 'choose', theme: next.ref} : undefined; + if (row === 'theme' && (delta || activate)) { + const theme = cycleRef(context.themes, context.pinned(target), delta || 1); + return theme ? {kind: 'setMode', target, mode: 'choose', theme} : undefined; } - if (key.kind !== 'enter') return undefined; - if (row === 'apply') return {kind: 'planHook', target}; + if (!activate) return undefined; + if (row === 'include') return {kind: 'planHook', target}; if (row === 'reload') return {kind: 'reloadTmux'}; - if (row === 'remove') return {kind: 'planRemoval', target}; + if (row === 'batSetup' || row === 'batRebuild') return {kind: 'planBat', source: 'current'}; + if (row === 'batDuplicate') return {kind: 'planBat', source: 'duplicate'}; + if (row === 'removeSetup') return {kind: 'planRemoval', target}; + if (row === 'setIndependent') return {kind: 'setMode', target, mode: 'independent'}; return undefined; } @@ -121,53 +211,87 @@ export function renderThemeBridgePanel(state: ThemeBridgePanelState, context: Br const accent = foreground(UI_COLORS.accent); const warning = foreground(UI_COLORS.failure); const reset = '\u001B[0m'; - const out: string[] = []; - const finish = (controls: Array<[string, string]>) => framePanel([...out, '', renderControls(controls)].map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); + const finish = (rows: string[], controls: Array<[string, string]>) => { + const budget = Math.max(3, height - 3); + return framePanel([...rows.slice(0, budget), '', renderControls(controls)].map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); + }; if (state.confirm) { const {kind, path, preview} = state.confirm; - out.push(` ${primary}Theme Bridge › ${kind === 'hook' ? 'Add include' : 'Remove include'}${reset}`, '', - ` ${secondary}${kind === 'hook' ? 'This edits your config so new instances load NMSh-managed colors:' : 'This removes exactly the NMSh lines from:'}${reset}`, - ` ${primary}${path}${reset}`, '', ...preview.map(line => ` ${line.startsWith('+') ? accent : line.startsWith('-') ? warning : subtle}${line}${reset}`), '', - ` ${subtle}Nothing else in the file changes. NMSh refuses to write if the file changes before you confirm.${reset}`); - return finish([['Enter', kind === 'hook' ? 'add these lines' : 'remove these lines'], ['Esc', 'cancel']]); - } - if (!state.detail) { - out.push(` ${primary}Theme Bridge${reset} ${subtle}extend NMSh themes to terminal tools · every target starts Independent${reset}`, ''); - const master = state.selected === 0; - out.push(`${master ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${master ? primary : secondary}${padCells('Theme Bridge', 13)}${reset}${master ? `${accent}‹ ${context.enabled ? 'On' : 'Off'} ›${reset}` : context.enabled ? 'On' : 'Off'} ${subtle}${context.enabled ? 'targets below use their own mode' : 'every target is Independent; choices below are kept'}${reset}`, ''); - out.push(` ${subtle}${padCells('Target', 13)}${padCells('Mode', 15)}${padCells('Theme', 22)}Status${reset}`); - BRIDGE_TARGETS.forEach((target, index) => { - const report = context.reports.find(item => item.target === target); - const selected = index + 1 === state.selected; - const theme = report && report.mode !== 'independent' ? report.themeLabel ?? '—' : '—'; - const extra = report?.notes.find(note => note === 'Managed' || note.startsWith('include') || note === 'reload available'); - out.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${selected ? primary : secondary}${padCells(report?.label ?? target, 13)}${reset}` - + `${padCells(BRIDGE_MODE_LABELS[report?.mode ?? 'independent'], 15)}${padCells(truncateText(theme, 20), 22)}${subtle}${report?.status ?? ''}${extra ? ` · ${extra}` : ''}${reset}`); - }); - out.push('', ` ${subtle}Independent: NMSh does nothing to that target. Follow NMSh: the active NMSh theme. Choose theme: a pinned theme the main theme never changes.${reset}`); - if (state.message) out.push('', ` ${secondary}${state.message}${reset}`); - return finish([['↑↓', 'target'], ['Enter', 'details'], ['Esc', 'close']]); + const title = kind === 'hook' ? 'Add include' : kind === 'batCache' ? 'Create bat theme and rebuild bat\'s cache' : 'Remove managed setup'; + const intro = kind === 'hook' ? 'This edits your config so new instances load NMSh-managed colors:' + : kind === 'batCache' ? 'NMSh writes its own theme file, then runs bat cache --build (bat rebuilds its local theme cache) and checks bat --list-themes:' + : 'This removes NMSh\'s managed file and exactly the NMSh lines it added:'; + return finish([` ${primary}Theme Bridge › ${title}${reset}`, '', ` ${secondary}${intro}${reset}`, ` ${primary}${path}${reset}`, '', + ...preview.map(line => ` ${line.startsWith('+') ? accent : line.startsWith('-') ? warning : subtle}${line}${reset}`), '', + ` ${subtle}Nothing else changes. NMSh refuses to write if a file changes before you confirm.${reset}`], [['Enter', 'confirm'], ['Esc', 'cancel']]); } - const {target} = state.detail; - const report = context.reports.find(item => item.target === target); - const rows = detailRows(target, report); - const selectedRow = rows[Math.min(state.detail.row, rows.length - 1)]; - const line = (row: DetailRow, label: string, value: string) => { - const selected = row === selectedRow; - return `${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${selected ? primary : secondary}${padCells(label, 14)}${reset}${selected && (row === 'mode' || row === 'theme') ? `${accent}‹ ${value} ›${reset}` : value}`; - }; - out.push(` ${primary}Theme Bridge › ${report?.label ?? target}${reset} ${subtle}${report?.status ?? ''}${reset}`, ''); - for (const row of rows) { - if (row === 'mode') out.push(line(row, 'Mode', BRIDGE_MODE_LABELS[report?.mode ?? 'independent'])); - if (row === 'theme') out.push(line(row, 'Theme', report?.themeLabel ?? 'Missing theme')); - if (row === 'apply') out.push(line(row, target === 'helix' ? 'Activation' : 'Include', `${context.managed(target)?.include ? 'configured · Enter to review' : 'not configured · Enter to review the exact change'}`)); - if (row === 'reload') out.push(line(row, 'Reload', 'load the colors into the running tmux server')); - if (row === 'remove') out.push(line(row, 'Remove', 'set Independent, remove NMSh files and (after review) the include')); + if (state.review) { + const rows = [` ${primary}Theme Bridge › Review all integrations${reset}`, '']; + for (const item of state.review.items) { + const glyph = item.action ? `${accent}+${reset}` : item.state === 'conflict' ? `${warning}!${reset}` : `${subtle}·${reset}`; + rows.push(` ${glyph} ${padCells(item.label, 22)}${item.action ? primary : subtle}${item.detail}${reset}`); + for (const line of state.review.previews[item.target] ?? []) rows.push(` ${line.startsWith('+') ? accent : subtle}${line}${reset}`); + } + const planned = state.review.items.filter(item => item.action).length; + rows.push('', planned ? ` ${primary}Apply ${planned} reviewed change${planned === 1 ? '' : 's'}? ${state.review.yes ? `${subtle}No${reset} ${accent}‹ Yes ›${reset}` : `${accent}‹ No ›${reset} ${subtle}Yes${reset}`}` + : ` ${subtle}Everything is current; nothing to apply.${reset}`); + return finish(rows, [['←→', 'No / Yes'], ['Enter', 'confirm'], ['Esc', 'back']]); } - const managed = context.managed(target); - if (managed?.artifact) out.push('', ` ${subtle}Managed file ${managed.artifact}${reset}`); - if (managed?.include) out.push(` ${subtle}Include in ${managed.include}${reset}`); - if (report?.notes.length) out.push('', ...report.notes.filter(note => note !== 'Managed').map(note => ` ${subtle}• ${note}${reset}`)); - if (state.message) out.push('', ` ${secondary}${state.message}${reset}`); - return finish([['↑↓', 'select'], ['←→', 'change'], ['Enter', 'open'], ['Esc', 'back']]); + const items = panelItems(state, context); + const selected = Math.max(0, Math.min(state.selected, items.length - 1)); + const isSelected = (index: number) => index === selected; + const mark = (index: number) => isSelected(index) ? `${accent}${GLYPHS.selection}${reset}` : ' '; + const value = (index: number, text: string) => isSelected(index) ? `${accent}‹ ${text} ›${reset}` : text; + const lines: Array<{text: string; item?: number}> = []; + lines.push({text: ` ${primary}Theme Bridge${reset} ${subtle}extend NMSh themes to terminal tools · every tool starts Independent${reset}`}, {text: ''}); + let group: BridgeCapability | undefined; + items.forEach((item, index) => { + if (item.kind === 'switch') lines.push({item: index, text: `${mark(index)} ${isSelected(index) ? primary : secondary}${padCells('Theme Bridge', 16)}${reset}${value(index, context.enabled ? 'On' : 'Off')} ${subtle}${context.enabled ? '' : 'nothing is applied; saved choices are kept'}${reset}`}); + else if (item.kind === 'policy') lines.push({item: index, text: `${mark(index)} ${isSelected(index) ? primary : secondary}${padCells('Apply themes', 16)}${reset}${value(index, BRIDGE_POLICY_LABELS[context.policy])} ${subtle}${context.policy === 'manual' ? 'each tool uses its own setting' : 'every supported tool; their own settings are kept for Manual'}${reset}`}); + else if (item.kind === 'globalTheme') lines.push({item: index, text: `${mark(index)} ${isSelected(index) ? primary : secondary}${padCells('Theme', 16)}${reset}${value(index, context.globalThemeLabel ?? '—')}`}); + else if (item.kind === 'review') { + lines.push({item: index, text: `${mark(index)} ${isSelected(index) ? primary : secondary}${padCells('Integrations', 16)}${reset}${isSelected(index) ? accent : subtle}Review all / Apply all ›${reset}`}, {text: ''}); + lines.push({text: ` ${subtle} ${padCells('Target', 21)}${padCells('Mode', 15)}${padCells('Theme', 20)}Status${reset}`}); + } else if (item.kind === 'target') { + const report = context.reports.find(entry => entry.target === item.target); + const capability = BRIDGE_CAPABILITY[item.target]; + if (capability !== group) { group = capability; lines.push({text: ` ${subtle}${BRIDGE_CAPABILITY_LABELS[capability]}${reset}`}); } + const detected = capability === 'detected'; + const mode = detected ? '—' : BRIDGE_MODE_LABELS[report?.mode ?? 'independent']; + const theme = !detected && report && report.mode !== 'independent' ? report.themeLabel ?? '—' : '—'; + const status = [report?.status, report?.readiness, report?.inherited ? 'Inherited' : undefined].filter(Boolean).join(' · '); + const expander = detected ? ' ' : state.expanded === item.target ? '▾' : '▸'; + lines.push({item: index, text: `${mark(index)} ${subtle}${expander}${reset} ${isSelected(index) ? primary : secondary}${padCells(report?.label ?? item.target, 19)}${reset}${padCells(mode, 15)}${padCells(truncateText(theme, 18), 20)}${subtle}${status}${reset}`}); + if (state.expanded === item.target && report) { + if (!report.editable) lines.push({text: ` ${subtle}${!context.enabled ? 'Theme Bridge is Off.' : `Apply themes is ${BRIDGE_POLICY_LABELS[context.policy]}; switch it to Manual to edit this tool.`}${reset}`}); + if (report.backend) lines.push({text: ` ${subtle}Backend ${report.backend}${reset}`}); + const managed = context.managed(item.target); + if (managed?.artifact) lines.push({text: ` ${subtle}Managed ${managed.artifact}${reset}`}); + if (managed?.include) lines.push({text: ` ${subtle}Include ${managed.include}${reset}`}); + for (const note of report.notes.slice(0, 4)) lines.push({text: ` ${subtle}• ${note}${reset}`}); + } + } else { + const report = context.reports.find(entry => entry.target === item.target); + const label = (text: string) => `${mark(index)} ${isSelected(index) ? primary : secondary}${padCells(text, 18)}${reset}`; + const act = (text: string) => `${isSelected(index) ? accent : subtle}${text}${reset}`; + const include = context.managed(item.target)?.include; + const text = item.row === 'mode' ? `${label('Mode')}${value(index, BRIDGE_MODE_LABELS[report?.mode ?? 'independent'])}` + : item.row === 'theme' ? `${label('Theme')}${value(index, report?.themeLabel ?? 'Missing theme')}` + : item.row === 'include' ? `${label(item.target === 'helix' || item.target === 'neovim' ? 'Activation' : 'Include')}${act(include ? 'configured · review ›' : 'review the exact one-time change ›')}` + : item.row === 'reload' ? `${label('Reload')}${act('load into the running tmux server ›')}` + : item.row === 'batSetup' ? `${label('Create for bat')}${act(`from ${report?.themeLabel ?? context.activeLabel ?? 'the current theme'} · review ›`)}` + : item.row === 'batRebuild' ? `${label('Cache')}${act('rebuild bat\'s theme cache · review ›')}` + : item.row === 'batDuplicate' ? `${label('Duplicate → Custom')}${act('copy the source theme to an editable Custom theme for bat ›')}` + : item.row === 'removeSetup' ? `${label('Remove setup')}${act('remove NMSh\'s managed file and include · review ›')}` + : `${label('Set Independent')}${act('this tool only (Manual) ›')}`; + lines.push({item: index, text}); + } + }); + if (state.message) lines.push({text: ''}, {text: ` ${secondary}${state.message}${reset}`}); + // Keep the selection visible on short terminals: a window over the rows around it. + const budget = Math.max(3, height - 3); + const at = Math.max(0, lines.findIndex(line => line.item === selected)); + const start = lines.length <= budget ? 0 : Math.max(0, Math.min(at - Math.floor(budget / 2), lines.length - budget)); + const shown = lines.slice(start, start + budget).map(line => line.text); + return finish(shown, [['↑↓', 'select'], ['←→', 'change'], ['Enter', state.expanded ? 'open / collapse' : 'expand'], ['Esc', state.expanded ? 'collapse' : 'close']]); } diff --git a/src/themeBridge/artifacts.ts b/src/themeBridge/artifacts.ts index 664fd826..2b5b7584 100644 --- a/src/themeBridge/artifacts.ts +++ b/src/themeBridge/artifacts.ts @@ -16,7 +16,7 @@ import type {BridgeMode, BridgeTargetId} from './model.js'; */ export const ADAPTER_VERSION = 1; -export type ManagedTarget = Extract | 'vivid'; +export type ManagedTarget = Extract | 'vivid'; export type HookTarget = Extract; export interface ConfigHook { @@ -38,6 +38,10 @@ export interface LedgerEntry { adapterVersion: number; generatedAt: string; hook?: ConfigHook; + /** bat: the user approved rebuilding bat's theme cache for this artifact (after which updates rebuild it automatically). */ + cacheApproved?: boolean; + /** bat: the artifact hash bat's cache was last built and verified for. */ + cacheBuiltFor?: string; } export interface Ledger {version: 1; entries: Partial>} @@ -56,6 +60,8 @@ export function artifactPath(target: ManagedTarget, env: NodeJS.ProcessEnv = pro case 'vivid': return join(root, 'vivid', 'nmsh-bridge.yml'); // Helix loads themes only from its own themes directory; the fixed NMSh name keeps it apart from user themes. case 'helix': return join(helixConfigDirectory(env), 'themes', 'nmsh-bridge.toml'); + // bat loads custom themes only from its own themes directory. + case 'bat': return join(batConfigDirectory(env), 'themes', 'nmsh-bridge.tmTheme'); } } @@ -66,10 +72,17 @@ export function helixConfigDirectory(env: NodeJS.ProcessEnv = process.env): stri return join(xdg, 'helix'); } +/** bat's config directory as bat resolves it: BAT_CONFIG_DIR, else $XDG_CONFIG_HOME/bat, else ~/.config/bat. */ +export function batConfigDirectory(env: NodeJS.ProcessEnv = process.env): string { + if (env.BAT_CONFIG_DIR && env.BAT_CONFIG_DIR.startsWith('/')) return env.BAT_CONFIG_DIR; + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(env.HOME || homedir(), '.config'); + return join(xdg, 'bat'); +} + export const runtimeDirectory = (target: 'neovim' | 'vim', env: NodeJS.ProcessEnv = process.env) => dirname(dirname(artifactPath(target, env))); const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -const TARGETS: readonly ManagedTarget[] = ['tmux', 'neovim', 'vim', 'helix', 'vivid']; +const TARGETS: readonly ManagedTarget[] = ['tmux', 'neovim', 'vim', 'helix', 'bat', 'vivid']; /** A malformed or stale ledger yields no ownership (so nothing can be deleted on its say-so). */ export function loadLedger(env: NodeJS.ProcessEnv = process.env): Ledger { @@ -87,7 +100,9 @@ export function loadLedger(env: NodeJS.ProcessEnv = process.env): Ledger { ? {configPath: entry.hook.configPath, lines: entry.hook.lines as string[], insertedAt: String(entry.hook.insertedAt ?? '')} : undefined; entries[target] = {target, artifactPath: entry.artifactPath, mode: entry.mode === 'choose' ? 'choose' : 'follow', themeRef: String(entry.themeRef ?? ''), format: String(entry.format ?? ''), formatVersion: Number(entry.formatVersion) || 1, sha256: entry.sha256, adapterVersion: Number(entry.adapterVersion) || 1, - generatedAt: String(entry.generatedAt ?? ''), ...(hook ? {hook} : {})}; + generatedAt: String(entry.generatedAt ?? ''), ...(hook ? {hook} : {}), + ...(entry.cacheApproved === true ? {cacheApproved: true} : {}), + ...(typeof entry.cacheBuiltFor === 'string' && /^[0-9a-f]{64}$/u.test(entry.cacheBuiltFor) ? {cacheBuiltFor: entry.cacheBuiltFor} : {})}; } return {version: 1, entries}; } @@ -145,7 +160,8 @@ export function writeArtifact(target: ManagedTarget, content: string, validate: } } ledger.entries[target] = {...record, target, artifactPath: path, sha256: hash, adapterVersion: ADAPTER_VERSION, generatedAt: now.toISOString(), - ...(previous?.hook ? {hook: previous.hook} : {})}; + ...(previous?.hook ? {hook: previous.hook} : {}), ...(previous?.cacheApproved ? {cacheApproved: true} : {}), + ...(previous?.cacheBuiltFor ? {cacheBuiltFor: previous.cacheBuiltFor} : {})}; saveLedger(ledger, env); return {ok: true, path, changed}; } diff --git a/src/themeBridge/environment.ts b/src/themeBridge/environment.ts index e3dcd661..7d9635fa 100644 --- a/src/themeBridge/environment.ts +++ b/src/themeBridge/environment.ts @@ -25,10 +25,17 @@ import type {ShellId} from '../shell/adapters/ShellAdapter.js'; * changed. Nothing is written to history or the transcript. */ -export const BRIDGE_ENV_VARIABLES = ['LS_COLORS', 'LESS_TERMCAP_mb', 'LESS_TERMCAP_md', 'LESS_TERMCAP_me', 'LESS_TERMCAP_so', 'LESS_TERMCAP_se', - 'LESS_TERMCAP_us', 'LESS_TERMCAP_ue', 'GROFF_NO_SGR'] as const; +export const BRIDGE_ENV_VARIABLES = ['LS_COLORS', 'CLICOLOR', 'LSCOLORS', 'LESS_TERMCAP_mb', 'LESS_TERMCAP_md', 'LESS_TERMCAP_me', 'LESS_TERMCAP_so', 'LESS_TERMCAP_se', + 'LESS_TERMCAP_us', 'LESS_TERMCAP_ue', 'GROFF_NO_SGR', 'BAT_THEME'] as const; export type BridgeEnvVariable = typeof BRIDGE_ENV_VARIABLES[number]; -export type BridgeEnvironment = Partial>; +/** + * GNU listing commands that get a session-only color-auto wrapper (GNU ls + * has no environment switch for color). `--color=auto` comes first, so an + * explicit `--color=never` from the user still wins. + */ +export const LISTING_WRAPPERS = ['ls', 'gls'] as const; +export type ListingWrapper = typeof LISTING_WRAPPERS[number]; +export type BridgeEnvironment = Partial> & {listing?: readonly ListingWrapper[]}; const MAX_VALUE = 8 * 1024; const ESC = '\u001B'; @@ -55,20 +62,24 @@ export function bridgeEnvPath(shell: ShellId, env: NodeJS.ProcessEnv = process.e /** Content-derived generation: identical environments never re-apply. */ export function environmentGeneration(values: BridgeEnvironment): string { - const canonical = BRIDGE_ENV_VARIABLES.map(name => `${name}=${values[name] ?? ''}`).join('\n'); + const canonical = [...BRIDGE_ENV_VARIABLES.map(name => `${name}=${values[name] ?? ''}`), `listing=${[...values.listing ?? []].sort().join(',')}`].join('\n'); return createHash('sha256').update(canonical).digest('hex').slice(0, 16); } export function renderEnvironmentFile(shell: ShellId, values: BridgeEnvironment): string { for (const [name, value] of Object.entries(values)) { - if (!(BRIDGE_ENV_VARIABLES as readonly string[]).includes(name) || value === undefined || !validEnvValue(value)) throw new Error(`Refusing Theme Bridge value for ${name}.`); + if (name === 'listing') { + if (!Array.isArray(value) || !value.every(item => (LISTING_WRAPPERS as readonly string[]).includes(item))) throw new Error('Refusing Theme Bridge listing wrappers.'); + continue; + } + if (!(BRIDGE_ENV_VARIABLES as readonly string[]).includes(name) || value === undefined || typeof value !== 'string' || !validEnvValue(value)) throw new Error(`Refusing Theme Bridge value for ${name}.`); } const generation = environmentGeneration(values); - const body = BRIDGE_ENV_VARIABLES.map(name => { + const body = [...BRIDGE_ENV_VARIABLES.map(name => { const value = values[name]; if (value === undefined) return ` nmsh_bridge_clear ${name}`; return ` nmsh_bridge_apply ${name} ${shell === 'fish' ? fishLiteral(value) : posixAnsiQuote(value)}`; - }); + }), ...LISTING_WRAPPERS.map(command => ` nmsh_bridge_listing ${values.listing?.includes(command) ? 'on' : 'off'} ${command}`)]; const header = '# Generated by NMSh Theme Bridge. Do not edit: NMSh replaces this file.\n'; return shell === 'fish' ? `${header}if nmsh_bridge_begin ${generation}\n${body.join('\n')}\nend\n` @@ -87,9 +98,11 @@ export function validateEnvironmentFile(shell: ShellId, content: string): boolea if (header !== '# Generated by NMSh Theme Bridge. Do not edit: NMSh replaces this file.') return false; if (shell === 'fish' ? !/^if nmsh_bridge_begin [0-9a-f]{16}$/u.test(open ?? '') || close !== 'end' : !/^if nmsh_bridge_begin [0-9a-f]{16}; then$/u.test(open ?? '') || close !== 'fi') return false; - if (rest.length !== BRIDGE_ENV_VARIABLES.length) return false; - return rest.every((line, index) => (shell === 'fish' ? FISH_LINE : POSIX_LINE).test(line) - && line.split(' ')[3] === BRIDGE_ENV_VARIABLES[index]); + if (rest.length !== BRIDGE_ENV_VARIABLES.length + LISTING_WRAPPERS.length) return false; + const variables = rest.slice(0, BRIDGE_ENV_VARIABLES.length); + const listing = rest.slice(BRIDGE_ENV_VARIABLES.length); + return variables.every((line, index) => (shell === 'fish' ? FISH_LINE : POSIX_LINE).test(line) && line.split(' ')[3] === BRIDGE_ENV_VARIABLES[index]) + && listing.every((line, index) => line === ` nmsh_bridge_listing on ${LISTING_WRAPPERS[index]}` || line === ` nmsh_bridge_listing off ${LISTING_WRAPPERS[index]}`); } /** @@ -144,6 +157,25 @@ function nmsh_bridge_clear end set -e $own; set -e $orig end +function nmsh_bridge_listing + # Literal per-command wrappers only; never one that would shadow the user's own alias or function. + set -l marker __nmsh_bridge_listing_$argv[2] + if test "$argv[1]" = on + set -q $marker; and return 0 + functions -q $argv[2]; and return 0 + command -q $argv[2]; or return 0 + switch $argv[2] + case ls + function ls --wraps ls; command ls --color=auto $argv; end + case gls + function gls --wraps gls; command gls --color=auto $argv; end + end + set -g $marker 1 + else if set -q $marker + functions -e $argv[2] + set -e $marker + end +end function nmsh_bridge_sync set -l file ${fishQuoteLocal(path)} test -f $file -a -O $file; and source $file @@ -173,6 +205,23 @@ function nmsh_bridge_clear { fi unset $own $orig } +function nmsh_bridge_listing { + # Literal per-command wrappers only; never one that would shadow the user's own alias or function. + local marker=__nmsh_bridge_listing_$2 + if [[ $1 == on ]]; then + (( \${(P)+marker} )) && return 0 + (( \${+aliases[$2]} || \${+functions[$2]} )) && return 0 + (( \${+commands[$2]} )) || return 0 + case $2 in + ls) function ls { command ls --color=auto "$@" } ;; + gls) function gls { command gls --color=auto "$@" } ;; + esac + typeset -g $marker=1 + elif (( \${(P)+marker} )); then + unfunction $2 2>/dev/null + unset $marker + fi +} function nmsh_bridge_sync { local file=${quoted} [[ -f $file && -O $file ]] && source $file @@ -200,6 +249,23 @@ nmsh_bridge_clear() { fi unset "$own" "$orig" } +nmsh_bridge_listing() { + # Literal per-command wrappers only; never one that would shadow the user's own alias or function. + local marker=__nmsh_bridge_listing_$2 + if [[ $1 == on ]]; then + [[ -n "\${!marker+x}" ]] && return 0 + { builtin alias "$2" || builtin declare -F "$2"; } >/dev/null 2>&1 && return 0 + builtin type -P "$2" >/dev/null 2>&1 || return 0 + case $2 in + ls) ls() { command ls --color=auto "$@"; } ;; + gls) gls() { command gls --color=auto "$@"; } ;; + esac + printf -v "$marker" '%s' 1 + elif [[ -n "\${!marker+x}" ]]; then + unset -f "$2" + unset "$marker" + fi +} nmsh_bridge_sync() { local file=${quoted} [[ -f $file && -O $file ]] && source "$file" diff --git a/src/themeBridge/model.ts b/src/themeBridge/model.ts index 3d7a56a7..7f09b2f0 100644 --- a/src/themeBridge/model.ts +++ b/src/themeBridge/model.ts @@ -1,8 +1,16 @@ /** - * Theme Bridge persisted model: one independent mode per target. Every target - * starts Independent, which means NMSh injects and changes nothing for it. - * Follow NMSh tracks the active Native theme; Choose theme pins a stable theme - * reference (built-in, Imported or Custom) that the main theme never changes. + * Theme Bridge persisted model. + * + * Two levels: a safety switch (`enabled`, Off by default) and, when On, an + * apply policy: + * + * manual each target uses its own stored mode (Independent / Follow / Choose) + * follow every supported target follows the active NMSh theme + * choose every supported target uses one global pinned theme + * + * The per-target settings are the Manual state; global policies never rewrite + * them, so returning to Manual restores exactly what was there. Effective + * behavior is always computed (switch × policy × capability × stored state). * * This module is data only (no theme resolution), so the configuration * normalizer can use it without import cycles. @@ -11,13 +19,29 @@ export const BRIDGE_TARGETS = ['fzf', 'pager', 'lsColors', 'bat', 'delta', 'tmux', 'neovim', 'vim', 'helix'] as const; export type BridgeTargetId = typeof BRIDGE_TARGETS[number]; export const BRIDGE_TARGET_LABELS: Record = { - fzf: 'fzf', pager: 'less / man', lsColors: 'LS_COLORS', bat: 'bat', delta: 'delta', tmux: 'tmux', neovim: 'Neovim', vim: 'Vim', helix: 'Helix', + fzf: 'fzf', pager: 'less / man', lsColors: 'File listing colors', bat: 'bat', delta: 'delta', tmux: 'tmux', neovim: 'Neovim', vim: 'Vim', helix: 'Helix', }; +/** + * What NMSh can do for a target. `direct`: invocation or session environment + * only. `managed`: NMSh generates an owned artifact (and may need a reviewed + * activation step). `detected`: shown for status only; never managed. + */ +export type BridgeCapability = 'direct' | 'managed' | 'detected'; +export const BRIDGE_CAPABILITY: Record = { + fzf: 'direct', pager: 'direct', lsColors: 'direct', bat: 'managed', delta: 'detected', tmux: 'managed', neovim: 'managed', vim: 'managed', helix: 'managed', +}; +export const BRIDGE_CAPABILITY_LABELS: Record = {direct: 'Direct and environment', managed: 'Managed themes', detected: 'Detected only'}; +export const bridgeSupported = (target: BridgeTargetId): boolean => BRIDGE_CAPABILITY[target] !== 'detected'; + export const BRIDGE_MODES = ['independent', 'follow', 'choose'] as const; export type BridgeMode = typeof BRIDGE_MODES[number]; export const BRIDGE_MODE_LABELS: Record = {independent: 'Independent', follow: 'Follow NMSh', choose: 'Choose theme'}; +export const BRIDGE_POLICIES = ['manual', 'follow', 'choose'] as const; +export type BridgePolicy = typeof BRIDGE_POLICIES[number]; +export const BRIDGE_POLICY_LABELS: Record = {manual: 'Manual', follow: 'Follow NMSh', choose: 'Choose theme'}; + export interface BridgeTargetSetting { mode: BridgeMode; /** Choose theme only: a stable theme reference (`builtin:…` or `asset:…`). Kept when the mode changes. */ @@ -25,27 +49,43 @@ export interface BridgeTargetSetting { } export interface ThemeBridgeSettings { - /** - * Master switch (Setup's "Extend NMSh colors to terminal tools?"), Off by - * default. Off makes every target effectively Independent while keeping - * the per-target choices for when it is turned back on. - */ + /** Safety switch (Setup's "Extend colors to tools?"), Off by default; Off keeps everything Independent but preserves choices. */ enabled: boolean; + policy: BridgePolicy; + /** The global pinned theme for policy `choose` (kept when the policy changes). */ + theme?: string; + /** Manual state: one setting per target. */ targets: Record; } export const DEFAULT_THEME_BRIDGE = (): ThemeBridgeSettings => - ({enabled: false, targets: Object.fromEntries(BRIDGE_TARGETS.map(id => [id, {mode: 'independent'}])) as Record}); + ({enabled: false, policy: 'manual', targets: Object.fromEntries(BRIDGE_TARGETS.map(id => [id, {mode: 'independent'}])) as Record}); + +/** The setting in effect for a target: switch, policy and capability applied over the stored Manual state. */ +export function effectiveSetting(settings: ThemeBridgeSettings, target: BridgeTargetId): BridgeTargetSetting { + if (!settings.enabled || !bridgeSupported(target)) return {mode: 'independent'}; + if (settings.policy === 'follow') return {mode: 'follow'}; + if (settings.policy === 'choose') return settings.theme ? {mode: 'choose', theme: settings.theme} : {mode: 'independent'}; + return settings.targets[target]; +} -/** The mode in effect: the target's own mode while Theme Bridge is on, Independent otherwise. */ export function effectiveMode(settings: ThemeBridgeSettings, target: BridgeTargetId): BridgeMode { - return settings.enabled ? settings.targets[target].mode : 'independent'; + return effectiveSetting(settings, target).mode; +} + +/** Whether a target's own mode/theme can be edited now (Manual policy, supported target). */ +export function targetEditable(settings: ThemeBridgeSettings, target: BridgeTargetId): boolean { + return settings.policy === 'manual' && bridgeSupported(target); } const REF = /^(?:asset:[a-z0-9][a-z0-9-]{2,39}|builtin:[A-Za-z0-9]+(?:@[a-z]+)?)$/u; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -/** Unknown or malformed targets read as Independent; a Choose without a valid reference has nothing to pin and reads as Independent. */ +/** + * Unknown or malformed targets read as Independent; a Choose without a valid + * reference has nothing to pin and reads as Independent. Configurations from + * before the policy existed read as Manual, so no per-target choice is lost. + */ export function normalizeThemeBridge(value: unknown): ThemeBridgeSettings { const settings = DEFAULT_THEME_BRIDGE(); const targets = isRecord(value) && isRecord(value.targets) ? value.targets : {}; @@ -56,17 +96,25 @@ export function normalizeThemeBridge(value: unknown): ThemeBridgeSettings { const mode = BRIDGE_MODES.includes(item.mode as BridgeMode) ? item.mode as BridgeMode : 'independent'; settings.targets[id] = {mode: mode === 'choose' && !theme ? 'independent' : mode, ...(theme ? {theme} : {})}; } - // A configuration that already has active targets but no switch (none shipped without one) reads as On. settings.enabled = isRecord(value) && typeof value.enabled === 'boolean' ? value.enabled : BRIDGE_TARGETS.some(id => settings.targets[id].mode !== 'independent'); + const theme = isRecord(value) && typeof value.theme === 'string' && REF.test(value.theme) ? value.theme : undefined; + if (theme) settings.theme = theme; + const policy = isRecord(value) && BRIDGE_POLICIES.includes(value.policy as BridgePolicy) ? value.policy as BridgePolicy : 'manual'; + settings.policy = policy === 'choose' && !theme ? 'manual' : policy; return settings; } -/** Targets pinned to a theme reference (Choose theme), for delete protection. */ +/** Targets pinned to a reference in the Manual state (Choose theme), for delete protection. */ export function targetsPinnedTo(settings: ThemeBridgeSettings, ref: string): BridgeTargetId[] { - // Pins count even while the switch is Off: turning it back on must never find a dangling reference. + // Pins count even while the switch is Off or a global policy applies: they come back with Manual. return BRIDGE_TARGETS.filter(id => settings.targets[id].mode === 'choose' && settings.targets[id].theme === ref); } +/** The global Choose theme pins a reference (whatever the current policy, since it returns with Choose). */ +export function globallyPinnedTo(settings: ThemeBridgeSettings, ref: string): boolean { + return settings.theme === ref; +} + export function anyBridgeTargetActive(settings: ThemeBridgeSettings): boolean { return BRIDGE_TARGETS.some(id => effectiveMode(settings, id) !== 'independent'); } diff --git a/src/themeBridge/runtime.ts b/src/themeBridge/runtime.ts index ebef9be3..25804b61 100644 --- a/src/themeBridge/runtime.ts +++ b/src/themeBridge/runtime.ts @@ -1,42 +1,66 @@ import {existsSync, readFileSync, statSync} from 'node:fs'; import {homedir} from 'node:os'; import {join} from 'node:path'; +import {XMLValidator} from 'fast-xml-parser'; import {resolveCommand, runExternal} from '../providers/providers.js'; import {parse as parseToml} from 'smol-toml'; import type {ColorLevel} from '../presentation/capabilities.js'; import {resolveSemanticPalette, type SemanticPalette} from '../appearance/semanticPalette.js'; import {activeThemeRef, themeRefLabel, type ThemeSource} from '../appearance/themeRefs.js'; -import {BRIDGE_TARGETS, BRIDGE_TARGET_LABELS, effectiveMode, type BridgeMode, type BridgeTargetId, type ThemeBridgeSettings} from './model.js'; -import {bridgeEnvPath, writeEnvironmentFiles, type BridgeEnvironment} from './environment.js'; import { - artifactPath, helixConfigDirectory, ledgerPath, loadLedger, ownership, removeArtifact, sha256, writeArtifact, type ManagedTarget, + BRIDGE_CAPABILITY, BRIDGE_TARGETS, BRIDGE_TARGET_LABELS, effectiveSetting, targetEditable, type BridgeCapability, type BridgeMode, type BridgeTargetId, + type ThemeBridgeSettings, +} from './model.js'; +import {bridgeEnvPath, writeEnvironmentFiles, type BridgeEnvironment, type ListingWrapper} from './environment.js'; +import { + artifactPath, batConfigDirectory, helixConfigDirectory, hookSpec, ledgerPath, loadLedger, ownership, removeArtifact, saveLedger, sha256, writeArtifact, + type HookTarget, type ManagedTarget, } from './artifacts.js'; import { - fzfColorArgs, lsColorsFallback, neovimColorscheme, pagerEnvironment, parseFzfVersion, tmuxFragment, validateNeovimColorscheme, - validateTmuxFragment, validateVimColorscheme, validLsColors, vimColorscheme, vividTheme, TMUX_UNREPRESENTED, helixTheme, validateHelixTheme, + BAT_THEME_NAME, batTheme, bsdLsColors, fzfColorArgs, helixTheme, lsColorsFallback, neovimColorscheme, pagerEnvironment, parseFzfVersion, tmuxFragment, + validateBatTheme, validateHelixTheme, validateNeovimColorscheme, validateTmuxFragment, validateVimColorscheme, validLsColors, vimColorscheme, vividTheme, + TMUX_UNREPRESENTED, } from './targets.js'; /** * Theme Bridge runtime: target detection (local, read-only, bounded, - * cached), per-target resolution through the one semantic palette, and - * application through the environment sink or managed artifacts. Never - * touches shell rc files, terminal/editor themes or git config; permanent - * includes are separate, explicitly confirmed steps (see artifacts.ts). + * cached), effective settings (switch × policy × capability × Manual state) + * resolved through the one semantic palette, application through the + * environment sink or managed artifacts, and the integrations-health planner. + * Never touches shell rc files, terminal/editor themes or git config; + * includes and bat's cache are separate, explicitly confirmed steps. */ -export type BridgeStatus = 'Not installed' | 'Detected' | 'Independent' | 'Following NMSh' | 'Pinned theme' | 'Conflict' | 'Unsupported capability' | 'Missing theme'; +export type BridgeStatus = 'Not installed' | 'Detected' | 'Following NMSh' | 'Pinned theme' | 'Conflict' | 'Missing theme' | 'Needs setup' | 'Not managed'; + +/** File listing backends found on this system. */ +export interface ListingFacts { + /** `ls` on PATH: GNU coreutils, or BSD/macOS. */ + ls?: 'gnu' | 'bsd'; + /** GNU `gls` (Homebrew coreutils) is installed. */ + gls?: boolean; +} -export interface TargetFacts {installed: boolean; binary?: string; version?: string} +export interface TargetFacts {installed: boolean; binary?: string; version?: string; listing?: ListingFacts} export interface TargetReport { target: BridgeTargetId; label: string; + capability: BridgeCapability; + /** The effective mode (after switch, policy and capability). */ mode: BridgeMode; /** Modes this target can honestly offer. */ modes: readonly BridgeMode[]; + /** Mode/theme can be edited now (Manual policy and a supported target). */ + editable: boolean; + /** The mode comes from the global policy, not this target's own setting. */ + inherited: boolean; themeLabel?: string; status: BridgeStatus; - /** Short factual qualifiers: "Managed", "include installed", "reload available", conflicts, limitations. */ + /** One short readiness line: Ready, Needs include, Needs activation, Needs cache build, … */ + readiness?: string; + /** For file listing colors: the backend(s) in use. */ + backend?: string; notes: string[]; palette?: SemanticPalette; } @@ -45,19 +69,16 @@ export interface TargetReport { const BINARIES: Record = {fzf: 'fzf', pager: 'less', lsColors: 'ls', bat: 'bat', delta: 'delta', tmux: 'tmux', neovim: 'nvim', vim: 'vim', helix: 'hx'}; const VERSION_ARGS: Partial> = {fzf: ['--version'], tmux: ['-V']}; -const UNSUPPORTED: Partial> = { - bat: 'bat loads custom themes only from its own theme cache (bat cache --build), which NMSh does not modify; a built-in bat theme would not be this theme.', - delta: 'delta takes syntax themes from bat\'s cache and diff styles from git config or its own arguments; NMSh does not edit ~/.gitconfig or bat\'s cache.', -}; +const DELTA_NOTE = 'delta is shown for status only: its syntax themes come from bat\'s cache and its diff styles from git config, which NMSh does not manage.'; export function supportedModes(target: BridgeTargetId): readonly BridgeMode[] { - return UNSUPPORTED[target] ? ['independent'] : ['independent', 'follow', 'choose']; + return BRIDGE_CAPABILITY[target] === 'detected' ? ['independent'] : ['independent', 'follow', 'choose']; } let factsCache: Promise> | undefined; let factsKey = ''; -/** Local, bounded detection, cached per PATH; at most two short version probes. */ +/** Local, bounded detection, cached per PATH; at most a few short version probes, never on render. */ export function detectTargets(env: NodeJS.ProcessEnv = process.env, refresh = false): Promise> { const key = env.PATH ?? ''; if (factsCache && factsKey === key && !refresh) return factsCache; @@ -66,6 +87,11 @@ export function detectTargets(env: NodeJS.ProcessEnv = process.env, refresh = fa const entries = await Promise.all(BRIDGE_TARGETS.map(async (target): Promise<[BridgeTargetId, TargetFacts]> => { const binary = resolveCommand(BINARIES[target], key); if (!binary) return [target, {installed: false}]; + if (target === 'lsColors') { + // GNU ls answers --version; BSD/macOS ls refuses it. Nothing else is run. + const probe = await runExternal(binary, ['--version'], {timeoutMs: 1500, maxBytes: 4096, env}); + return [target, {installed: true, binary, listing: {ls: /GNU coreutils/u.test(probe.stdout) ? 'gnu' : 'bsd', gls: Boolean(resolveCommand('gls', key))}}]; + } const args = VERSION_ARGS[target]; if (!args) return [target, {installed: true, binary}]; const result = await runExternal(binary, args, {timeoutMs: 1500, maxBytes: 4096, env}); @@ -113,20 +139,24 @@ export function targetConflicts(target: BridgeTargetId, env: NodeJS.ProcessEnv = const theme = config ? /^\s*theme\s*=\s*"?([^"\n]+)"?/mu.exec(config)?.[1]?.trim() : undefined; return theme && theme !== 'nmsh-bridge' ? [`Configured independently: your Helix config selects "${theme.slice(0, 40)}"; NMSh leaves it.`] : []; } - if (target === 'lsColors' && env.LS_COLORS) return ['LS_COLORS is already set; NMSh replaces it in NMSh shells while active and restores it when Independent.']; + if (target === 'bat') { + const config = readSmall(join(batConfigDirectory(env), 'config')); + const theme = config ? /^\s*--theme[=\s]+["']?([^"'\n]+)/mu.exec(config)?.[1]?.trim() : undefined; + return theme ? [`Your bat config file sets --theme=${theme.slice(0, 40)}; bat's own config is not changed, and BAT_THEME applies only where your config does not override it.`] : []; + } + if (target === 'lsColors' && (env.LS_COLORS || env.LSCOLORS)) return ['Listing colors are already set; NMSh replaces them in NMSh shells while active and restores them when Independent.']; if (target === 'fzf' && /--color/u.test(env.FZF_DEFAULT_OPTS ?? '')) return ['FZF_DEFAULT_OPTS sets colors; NMSh-owned fzf launches never read FZF_DEFAULT_OPTS, your own fzf use keeps it.']; return []; } /** The theme a target uses right now, or why it has none. Never another theme. */ -export function targetPalette(settings: ThemeBridgeSettings, target: BridgeTargetId, source: ThemeSource): {palette?: SemanticPalette; label?: string; missing?: boolean} { - const setting = settings.targets[target]; - const mode = effectiveMode(settings, target); - if (mode === 'independent' || !supportedModes(target).includes(mode)) return {}; - const ref = mode === 'follow' ? activeThemeRef(source) : setting.theme; +export function targetPalette(settings: ThemeBridgeSettings, target: BridgeTargetId, source: ThemeSource): {palette?: SemanticPalette; label?: string; missing?: boolean; ref?: string} { + const setting = effectiveSetting(settings, target); + if (setting.mode === 'independent') return {}; + const ref = setting.mode === 'follow' ? activeThemeRef(source) : setting.theme; const resolved = resolveSemanticPalette(ref, source); if (!resolved.ok) return {missing: true, label: resolved.label}; - return {palette: resolved.palette, label: themeRefLabel(ref, source)}; + return {palette: resolved.palette, label: themeRefLabel(ref, source), ...(ref ? {ref} : {})}; } export interface BridgeContext {source: ThemeSource & {themeBridge: ThemeBridgeSettings}; facts: Record; level: ColorLevel; env?: NodeJS.ProcessEnv} @@ -136,53 +166,108 @@ export function bridgeColorLevel(level: ColorLevel, env: NodeJS.ProcessEnv = pro return env.NO_COLOR !== undefined && env.NO_COLOR !== '' ? 'none' : level; } -export function reportTargets({source, facts, level, env = process.env}: BridgeContext): TargetReport[] { +/** File listing backend in plain words, from facts. */ +export function listingBackend(facts: TargetFacts | undefined): string | undefined { + const listing = facts?.listing; + if (!listing) return undefined; + const parts = [listing.ls === 'gnu' ? 'GNU ls · LS_COLORS' : listing.ls === 'bsd' ? 'macOS/BSD ls · CLICOLOR, LSCOLORS' : '', listing.gls ? 'GNU gls · LS_COLORS' : ''].filter(Boolean); + return parts.join(' + ') || undefined; +} + +/** bat is ready when NMSh's own theme file exists unchanged and bat's cache was built and verified for exactly it. */ +export function batReady(env: NodeJS.ProcessEnv = process.env): boolean { const ledger = loadLedger(env); + const entry = ledger.entries.bat; + return Boolean(entry && ownership('bat', ledger, env) === 'owned' && entry.cacheBuiltFor === entry.sha256); +} + +function hookPresent(target: HookTarget, env: NodeJS.ProcessEnv, home: string): 'none' | 'current' | 'stale' | 'missing' { + const hook = loadLedger(env).entries[target]?.hook; + if (!hook) return 'none'; + const text = readSmall(hook.configPath); + if (text === undefined || !text.includes(`${hook.lines.join('\n')}\n`)) return 'missing'; + const spec = hookSpec(target, env, home); + // Helix's spec refuses a config that already selects a theme, which our own recorded assignment does: still current. + if ('error' in spec) return 'current'; + return spec.configPath !== hook.configPath || spec.lines.join('\n') !== hook.lines.join('\n') ? 'stale' : 'current'; +} + +/** Readiness of a managed target: what (if anything) still needs a reviewed step. */ +export function managedReadiness(target: Extract, env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): string { + const ledger = loadLedger(env); + const owned = ownership(target, ledger, env); + if (owned === 'modified' || owned === 'unknown') return 'Conflict'; + if (owned !== 'owned') return 'Needs setup'; + if (target === 'bat') return batReady(env) ? 'Ready' : 'Needs cache build'; + const hook = hookPresent(target, env, home); + if (hook === 'current') return 'Active'; + if (hook === 'stale' || hook === 'missing') return target === 'helix' ? 'Activation stale' : 'Include stale'; + return target === 'helix' || target === 'neovim' ? 'Needs activation' : 'Needs include'; +} + +export function reportTargets({source, facts, level, env = process.env}: BridgeContext): TargetReport[] { + const home = env.HOME || homedir(); return BRIDGE_TARGETS.map(target => { - const setting = {...source.themeBridge.targets[target], mode: effectiveMode(source.themeBridge, target)}; + const capability = BRIDGE_CAPABILITY[target]; + const setting = effectiveSetting(source.themeBridge, target); + const inherited = source.themeBridge.enabled && source.themeBridge.policy !== 'manual' && capability !== 'detected'; const {palette, label, missing} = targetPalette(source.themeBridge, target, source); const notes: string[] = []; let status: BridgeStatus; + let readiness: string | undefined; if (!facts[target]?.installed) status = 'Not installed'; - else if (UNSUPPORTED[target]) { status = 'Unsupported capability'; notes.push(UNSUPPORTED[target]!); } + else if (capability === 'detected') { status = 'Not managed'; notes.push(DELTA_NOTE); } else if (setting.mode === 'independent') status = 'Detected'; else if (missing) status = 'Missing theme'; else status = setting.mode === 'follow' ? 'Following NMSh' : 'Pinned theme'; if (setting.mode !== 'independent' && bridgeColorLevel(level, env) === 'none') notes.push('NO_COLOR or no color support: nothing is injected.'); - const managed = target === 'tmux' || target === 'neovim' || target === 'vim' || target === 'helix' ? target : undefined; - if (managed) { - const entry = ledger.entries[managed]; - const owned = ownership(managed, ledger, env); - if (owned === 'owned') notes.push('Managed'); - if (owned === 'modified' || owned === 'unknown') { status = 'Conflict'; notes.push(`${artifactPath(managed, env)} exists and is not NMSh's unchanged file; NMSh will not overwrite it.`); } - if (managed === 'helix') { - if (entry?.hook) notes.push(setting.mode === 'independent' ? 'theme assignment installed (inactive)' : 'Active through NMSh-managed config'); - else if (setting.mode !== 'independent') notes.push('Managed theme generated; select it with :theme nmsh-bridge, or review the config change'); - if (setting.mode !== 'independent') notes.push('Coverage: syntax, markup, diff, diagnostics and editor UI. Running Helix instances are not recolored; new ones use the file.'); - } else if (entry?.hook) notes.push(setting.mode === 'independent' ? 'include installed (inactive)' : 'include installed'); - else if (setting.mode !== 'independent') notes.push('new instances need the include (Apply)'); - if (managed === 'tmux' && setting.mode !== 'independent') { notes.push('reload available'); notes.push(TMUX_UNREPRESENTED); } + if (capability === 'managed' && facts[target]?.installed) { + const managed = target as Extract; + readiness = managedReadiness(managed, env, home); + if (readiness === 'Conflict') { status = 'Conflict'; notes.push(`${artifactPath(managed, env)} exists and is not NMSh's unchanged file; NMSh will not overwrite it.`); } + else if (setting.mode !== 'independent' && readiness === 'Needs setup' && target === 'bat') { status = 'Needs setup'; notes.push('bat needs a generated custom theme and a reviewed cache build before BAT_THEME is set.'); } + if (setting.mode === 'independent' && (readiness === 'Active' || readiness === 'Needs include' || readiness === 'Needs activation')) readiness = loadLedger(env).entries[managed]?.hook ? 'Include kept (inactive)' : undefined; + if (target === 'helix' && setting.mode !== 'independent') { + notes.push('Coverage: syntax, markup, diff, diagnostics and editor UI. Running Helix instances are not recolored; new ones use the file.'); + if (readiness === 'Needs activation') notes.push('Select it with :theme nmsh-bridge, or review the config change.'); + } + if (target === 'tmux' && setting.mode !== 'independent') notes.push(TMUX_UNREPRESENTED); } - for (const conflict of facts[target]?.installed ? targetConflicts(target, env) : []) { + for (const conflict of facts[target]?.installed ? targetConflicts(target, env, home) : []) { notes.push(conflict); - if (setting.mode !== 'independent' && status !== 'Missing theme' && (target === 'tmux')) status = 'Conflict'; + if (setting.mode !== 'independent' && status !== 'Missing theme' && target === 'tmux') status = 'Conflict'; } - return {target, label: BRIDGE_TARGET_LABELS[target], mode: setting.mode, modes: supportedModes(target), ...(label ? {themeLabel: label} : {}), - status, notes, ...(palette ? {palette} : {})}; + const backend = target === 'lsColors' ? listingBackend(facts.lsColors) : undefined; + if (target === 'lsColors' && facts.lsColors?.listing?.ls === 'bsd') notes.push('macOS/BSD ls names terminal ANSI colors, so it uses the closest of the theme\'s base colors.'); + return {target, label: BRIDGE_TARGET_LABELS[target], capability, mode: setting.mode, modes: supportedModes(target), + editable: targetEditable(source.themeBridge, target), inherited, ...(label ? {themeLabel: label} : {}), status, ...(readiness ? {readiness} : {}), + ...(backend ? {backend} : {}), notes, ...(palette ? {palette} : {})}; }); } -/** The environment the sink should carry for the current settings (pager + LS_COLORS), from the resolved palettes. */ +/** + * The environment the sink should carry: pager termcap, file listing colors + * for the detected backends, and BAT_THEME once bat's managed theme is ready. + */ export function bridgeEnvironment(context: BridgeContext, lsColors?: string): BridgeEnvironment { - const level = bridgeColorLevel(context.level, context.env); + const env = context.env ?? process.env; + const level = bridgeColorLevel(context.level, env); const out: BridgeEnvironment = {}; const pager = targetPalette(context.source.themeBridge, 'pager', context.source).palette; if (pager) Object.assign(out, pagerEnvironment(pager, level)); const ls = targetPalette(context.source.themeBridge, 'lsColors', context.source).palette; - if (ls) { - const value = lsColors ?? lsColorsFallback(ls, level); - if (value) out.LS_COLORS = value; + const listing = context.facts.lsColors?.listing; + if (ls && level !== 'none') { + const gnu = listing?.ls === 'gnu' || listing?.gls; + if (gnu || !listing) { + const value = lsColors ?? lsColorsFallback(ls, level); + if (value) out.LS_COLORS = value; + } + const wrappers: ListingWrapper[] = [...(listing?.ls === 'gnu' ? ['ls' as const] : []), ...(listing?.gls ? ['gls' as const] : [])]; + if (wrappers.length) out.listing = wrappers; + if (listing?.ls === 'bsd') { out.CLICOLOR = '1'; out.LSCOLORS = bsdLsColors(ls); } } + if (targetPalette(context.source.themeBridge, 'bat', context.source).palette && batReady(env) && level !== 'none') out.BAT_THEME = BAT_THEME_NAME; return out; } @@ -195,7 +280,7 @@ export function fzfBridgeArgs(context: BridgeContext): string[] { const vividCache = new Map(); -/** vivid output for the LS_COLORS palette when vivid is installed; undefined falls back to the small built-in mapping. */ +/** vivid output for the listing palette when vivid is installed; undefined falls back to the small built-in mapping. */ async function vividColors(palette: SemanticPalette, level: ColorLevel, env: NodeJS.ProcessEnv, mode: BridgeMode, ref: string): Promise { const binary = resolveCommand('vivid', env.PATH ?? ''); if (!binary || level === 'none') return undefined; @@ -214,40 +299,37 @@ async function vividColors(palette: SemanticPalette, level: ColorLevel, env: Nod export interface ApplyOutcome {target: BridgeTargetId; ok: boolean; message?: string} +const validXml = (content: string) => XMLValidator.validate(content) === true; + const MANAGED: ReadonlyArray<{target: Extract; render: (palette: SemanticPalette) => string; validate: (content: string) => boolean; format: string}> = [ {target: 'tmux', render: tmuxFragment, validate: validateTmuxFragment, format: 'tmux-fragment'}, {target: 'neovim', render: neovimColorscheme, validate: validateNeovimColorscheme, format: 'nvim-colorscheme'}, {target: 'vim', render: vimColorscheme, validate: validateVimColorscheme, format: 'vim-colorscheme'}, {target: 'helix', render: helixTheme, validate: content => validateHelixTheme(content, parseToml), format: 'helix-theme'}, + {target: 'bat', render: batTheme, validate: content => validateBatTheme(content, text => { if (!validXml(text)) throw new Error('invalid'); return text; }), format: 'bat-tmtheme'}, ]; +/** Whether NMSh may (re)generate a managed target's artifact automatically: bat only after its first reviewed setup. */ +function autoGenerate(target: Extract, env: NodeJS.ProcessEnv): boolean { + return target !== 'bat' || Boolean(loadLedger(env).entries.bat); +} + /** - * Applies the current settings: the environment sink (pager, LS_COLORS) and - * the managed artifacts for tmux/Neovim/Vim. Each target is isolated; one - * failure is reported and leaves every other target (and the active NMSh - * theme) intact. Independent targets get no artifact and no injection; an - * owned artifact left from an earlier mode is removed (includes stay - * recorded and are harmless without it). + * Applies the current effective settings: the environment sink and the + * managed artifacts. Each target is isolated; one failure is reported and + * leaves every other target (and the active NMSh theme) intact. Independent + * targets get no artifact and no injection; an owned artifact left from an + * earlier mode is removed (includes stay recorded and are harmless without + * it). bat is generated automatically only after its first reviewed setup, + * and its cache is rebuilt automatically only after the user approved that + * once; otherwise it reports Needs setup / Needs cache build. */ export async function applyThemeBridge(context: BridgeContext): Promise { const env = context.env ?? process.env; const level = bridgeColorLevel(context.level, env); const outcomes: ApplyOutcome[] = []; - let lsColors: string | undefined; - const ls = targetPalette(context.source.themeBridge, 'lsColors', context.source); - if (ls.palette) { - const setting = {...context.source.themeBridge.targets.lsColors, mode: effectiveMode(context.source.themeBridge, 'lsColors')}; - lsColors = await vividColors(ls.palette, level, env, setting.mode, setting.mode === 'follow' ? activeThemeRef(context.source) ?? '' : setting.theme ?? ''); - } - try { - writeEnvironmentFiles(bridgeEnvironment(context, lsColors), env); - outcomes.push({target: 'pager', ok: true}, {target: 'lsColors', ok: true}); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - outcomes.push({target: 'pager', ok: false, message}, {target: 'lsColors', ok: false, message}); - } for (const managed of MANAGED) { - const setting = {...context.source.themeBridge.targets[managed.target], mode: effectiveMode(context.source.themeBridge, managed.target)}; + const setting = effectiveSetting(context.source.themeBridge, managed.target); const resolved = targetPalette(context.source.themeBridge, managed.target, context.source); try { if (setting.mode === 'independent' || !resolved.palette) { @@ -258,27 +340,77 @@ export async function applyThemeBridge(context: BridgeContext): Promise { + const binary = resolveCommand('bat', env.PATH ?? ''); + if (!binary) return {ok: false, message: 'bat is not installed.'}; + const reported = (await runExternal(binary, ['--config-dir'], {timeoutMs: 2000, maxBytes: 4096, env})).stdout.trim(); + if (reported && reported !== batConfigDirectory(env)) return {ok: false, message: `bat reports its config directory as ${reported}, not ${batConfigDirectory(env)}; NMSh does not guess where to put the theme.`}; + const managed = MANAGED.find(item => item.target === 'bat')!; + const written = writeArtifact('bat', managed.render(palette), managed.validate, {mode, themeRef: ref, format: managed.format, formatVersion: 1}, env); + if (!written.ok) return {ok: false, message: written.error}; + return buildBatCache(env, true); +} + +/** `bat cache --build`, then `bat --list-themes` must include the NMSh theme. Typed argv; no shell; local only. */ +export async function buildBatCache(env: NodeJS.ProcessEnv = process.env, approve = false): Promise<{ok: boolean; message: string}> { + const binary = resolveCommand('bat', env.PATH ?? ''); + if (!binary) return {ok: false, message: 'bat is not installed.'}; + const ledger = loadLedger(env); + const entry = ledger.entries.bat; + if (!entry || ownership('bat', ledger, env) !== 'owned') return {ok: false, message: 'There is no NMSh-managed bat theme to build.'}; + const build = await runExternal(binary, ['cache', '--build'], {timeoutMs: 60_000, maxBytes: 64 * 1024, env}); + if (!build.ok) return {ok: false, message: 'bat cache --build failed; BAT_THEME stays unset and nothing claims the theme is active.'}; + const list = await runExternal(binary, ['--list-themes', '--color=never'], {timeoutMs: 10_000, maxBytes: 256 * 1024, env}); + if (!list.stdout.split('\n').some(line => line.trim() === BAT_THEME_NAME)) return {ok: false, message: 'bat rebuilt its cache but does not list the NMSh theme; BAT_THEME stays unset.'}; + const fresh = loadLedger(env); + fresh.entries.bat = {...fresh.entries.bat!, cacheBuiltFor: fresh.entries.bat!.sha256, ...(approve || fresh.entries.bat!.cacheApproved ? {cacheApproved: true} : {})}; + saveLedger(fresh, env); + return {ok: true, message: 'bat rebuilt its theme cache and lists nmsh-bridge; NMSh shells use it from their next prompt.'}; +} + /** * The one typed tmux reload: `tmux source-file ` against the * user's running server, on explicit request only. No shell, no other command. */ -export async function reloadTmux(env: NodeJS.ProcessEnv = process.env): Promise<{ok: boolean; message: string}> { +export async function reloadTmux(env: NodeJS.ProcessEnv = process.env, path = artifactPath('tmux', env)): Promise<{ok: boolean; message: string}> { const binary = resolveCommand('tmux', env.PATH ?? ''); if (!binary) return {ok: false, message: 'tmux is not installed.'}; - const path = artifactPath('tmux', env); - if (ownership('tmux', loadLedger(env), env) !== 'owned') return {ok: false, message: 'There is no NMSh-managed tmux fragment to load yet.'}; + if (!existsSync(path)) return {ok: false, message: 'There is no NMSh-managed tmux file to load yet.'}; const result = await runExternal(binary, ['source-file', path], {timeoutMs: 3000, maxBytes: 16 * 1024, env}); - return result.ok ? {ok: true, message: 'tmux reloaded the NMSh colors for the running server.'} + return result.ok ? {ok: true, message: 'tmux reloaded the NMSh-managed settings for the running server.'} : {ok: false, message: 'tmux did not reload (no running server, or it refused the file). New tmux servers load it through the include.'}; } @@ -291,3 +423,37 @@ export function themeBridgeKey(config: ThemeSource & {themeBridge: ThemeBridgeSe export function bridgeStateExists(env: NodeJS.ProcessEnv = process.env): boolean { return existsSync(bridgeEnvPath('zsh', env)) || existsSync(ledgerPath(env)); } + +// ---- Integrations health --------------------------------------------------------- + +export type HealthState = 'ready' | 'not-installed' | 'independent' | 'needs-setup' | 'needs-include' | 'stale-include' | 'needs-cache' | 'conflict' | 'not-managed'; +export interface HealthItem {target: BridgeTargetId; label: string; state: HealthState; detail: string; action?: 'generate' | 'include' | 'cache'} + +const HEALTH_LABELS: Record = {ready: 'Ready', 'not-installed': 'Not installed', independent: 'Independent · nothing to do', 'needs-setup': 'Needs setup', + 'needs-include': 'Needs one reviewed include', 'stale-include': 'Include out of date', 'needs-cache': 'Needs cache build', conflict: 'Conflict · skipped', 'not-managed': 'Not managed'}; + +/** One read-only pass over every target: what is current, what needs a reviewed step, what must be skipped. */ +export function integrationHealth(context: BridgeContext): HealthItem[] { + const env = context.env ?? process.env; + const home = env.HOME || homedir(); + return BRIDGE_TARGETS.map(target => { + const label = BRIDGE_TARGET_LABELS[target]; + const item = (state: HealthState, action?: HealthItem['action'], detail = HEALTH_LABELS[state]): HealthItem => ({target, label, state, detail, ...(action ? {action} : {})}); + if (!context.facts[target]?.installed) return item('not-installed'); + if (BRIDGE_CAPABILITY[target] === 'detected') return item('not-managed'); + if (effectiveSetting(context.source.themeBridge, target).mode === 'independent') return item('independent'); + if (BRIDGE_CAPABILITY[target] === 'direct') return item('ready', undefined, 'Ready · applied to NMSh shells and launches'); + const managed = target as Extract; + const readiness = managedReadiness(managed, env, home); + if (readiness === 'Conflict') return item('conflict'); + if (readiness === 'Needs setup') return item('needs-setup', target === 'bat' ? 'cache' : 'generate', target === 'bat' ? 'Needs a generated bat theme and cache build' : 'Generated file missing'); + if (readiness === 'Needs cache build') return item('needs-cache', 'cache'); + if (readiness === 'Include stale' || readiness === 'Activation stale') return item('stale-include', undefined, 'Recorded include no longer matches; remove and re-add it in the target details'); + if (readiness === 'Needs include' || readiness === 'Needs activation') { + const spec = hookSpec(managed as HookTarget, env, home); + if ('error' in spec) return item('conflict', undefined, spec.error); + return item('needs-include', 'include', `${readiness} · ${spec.configPath}`); + } + return item('ready'); + }); +} diff --git a/src/themeBridge/targets.ts b/src/themeBridge/targets.ts index 00ca16eb..da885b2c 100644 --- a/src/themeBridge/targets.ts +++ b/src/themeBridge/targets.ts @@ -465,3 +465,115 @@ export function validateHelixTheme(content: string, parse: (text: string) => unk : false); }); } + +// ---- macOS / BSD ls ---------------------------------------------------------------- + +const BSD_LETTERS = 'abcdefgh'; + +/** The closest of the theme's eight base ANSI colors (BSD ls can only name terminal ANSI colors). */ +function nearestAnsi(palette: SemanticPalette, hex: string): number { + const target = parseHexColor(hex)!; + let best = 0; + let distance = Infinity; + palette.ansi.slice(0, 8).forEach((candidate, index) => { + const color = parseHexColor(candidate)!; + const d = (color.red - target.red) ** 2 + (color.green - target.green) ** 2 + (color.blue - target.blue) ** 2; + if (d < distance) { distance = d; best = index; } + }); + return best; +} + +/** + * BSD/macOS `LSCOLORS`: twelve fg/bg pairs (directory, symlink, socket, pipe, + * executable, block, character, setuid, setgid, sticky other-writable dir, + * other-writable dir, dataless). Colors are ANSI names, so the terminal's own + * palette draws them; NMSh picks the closest role for each. + */ +export function bsdLsColors(palette: SemanticPalette): string { + const fg = (hex: string, bold = false) => { const letter = BSD_LETTERS[nearestAnsi(palette, hex)]!; return bold ? letter.toUpperCase() : letter; }; + const pairs = [ + `${fg(palette.ansi[4]!, true)}x`, `${fg(palette.ansi[6]!)}x`, `${fg(palette.ansi[5]!)}x`, `${fg(palette.warning)}x`, `${fg(palette.success, true)}x`, + `${fg(palette.warning, true)}x`, `${fg(palette.warning)}x`, 'ab', 'ag', 'ac', 'ad', `${fg(palette.text.subtle)}x`, + ]; + return pairs.join(''); +} + +export function validBsdLsColors(value: string): boolean { + return /^(?:[a-hA-Hx][a-hA-Hx]){11,12}$/u.test(value); +} + +// ---- bat (.tmTheme) --------------------------------------------------------------------- + +export const BAT_THEME_NAME = 'nmsh-bridge'; + +const xml = (value: string) => value.replace(/&/gu, '&').replace(//gu, '>').replace(/"/gu, '"'); + +/** + * A Sublime/TextMate `.tmTheme` (plist XML) that bat/Syntect loads from its + * themes directory. Data only: global colors plus scope rules for comments, + * strings, numbers/constants, functions, types, keywords, operators, tags, + * variables, builtins, invalid, markup and diff. + */ +export function batTheme(p: SemanticPalette): string { + const rule = (name: string, scope: string, foreground: string, fontStyle?: string, background?: string) => + ` name${xml(name)}scope${xml(scope)}settingsforeground${foreground}${fontStyle ? `fontStyle${fontStyle}` : ''}${background ? `background${background}` : ''}`; + const s = p.syntax; + const globals = [ + ['foreground', p.text.primary], ...(p.background ? [['background', p.background]] : []), ['caret', p.cursor], ['selection', p.selection], + ['lineHighlight', p.surface], ['gutterForeground', p.text.subtle], ['invisibles', p.separator], + ] as Array<[string, string]>; + const rules = [ + rule('Comment', 'comment, punctuation.definition.comment', s.comment, 'italic'), + rule('String', 'string, punctuation.definition.string', s.string), + rule('Escape', 'constant.character.escape, string.regexp', s.special), + rule('Number', 'constant.numeric', s.number), + rule('Constant', 'constant, constant.language, support.constant', s.constant), + rule('Variable', 'variable', p.text.primary), + rule('Builtin variable', 'variable.language, support.variable', s.special), + rule('Parameter', 'variable.parameter', p.text.secondary), + rule('Keyword', 'keyword, keyword.control, storage, storage.type, storage.modifier', s.keyword), + rule('Operator', 'keyword.operator, punctuation.separator, punctuation.accessor', s.operator), + rule('Function', 'entity.name.function, meta.function-call, support.function', s.function), + rule('Builtin function', 'support.function.builtin, variable.function.builtin', s.special), + rule('Type', 'entity.name.type, entity.name.class, support.type, support.class, entity.other.inherited-class', s.type), + rule('Import', 'keyword.control.import, meta.preprocessor, keyword.other.preprocessor', s.preproc), + rule('Tag', 'entity.name.tag', p.accent), + rule('Attribute', 'entity.other.attribute-name', s.preproc), + rule('Invalid', 'invalid, invalid.illegal', p.failure, 'bold'), + rule('Deprecated', 'invalid.deprecated', p.warning), + rule('Heading', 'markup.heading, entity.name.section', p.accent, 'bold'), + rule('Bold', 'markup.bold', p.text.primary, 'bold'), + rule('Italic', 'markup.italic', p.text.primary, 'italic'), + rule('Link', 'markup.underline.link, string.other.link', p.info, 'underline'), + rule('Quote', 'markup.quote', p.text.subtle), + rule('Raw', 'markup.raw, markup.inline.raw', s.string), + rule('Inserted', 'markup.inserted, meta.diff.header.to-file', p.success), + rule('Deleted', 'markup.deleted, meta.diff.header.from-file', p.failure), + rule('Changed', 'markup.changed', p.warning), + rule('Diff range', 'meta.diff.range, meta.diff.header', p.info), + ]; + return [ + '', + ``, + '', + '', + ` name${BAT_THEME_NAME}`, + ' settings', + ' ', + ` settings${globals.map(([key, value]) => `${key}${value}`).join('')}`, + ...rules, + ' ', + '', + '', + '', + ].join('\n'); +} + +/** The generated theme parses as a plist with only string values (names, scopes, hex colors, font styles). */ +export function validateBatTheme(content: string, parse: (text: string) => unknown): boolean { + if (/([^<]*)<\/string>/gu)].map(match => match[1]!); + return strings.length > 10 && content.includes(`${BAT_THEME_NAME}`) + && strings.every(value => /^#[0-9a-f]{6}$/u.test(value) || /^[A-Za-z0-9 .,_-]{1,200}$/u.test(value)); +} diff --git a/tests/themeBridge.test.ts b/tests/themeBridge.test.ts index eba9e285..0096cd4f 100644 --- a/tests/themeBridge.test.ts +++ b/tests/themeBridge.test.ts @@ -148,7 +148,7 @@ test('environment sink: exact quoting, strict validation, allowlist only', () => assert.ok(validateEnvironmentFile(shell, content), shell); assert.equal(validateEnvironmentFile(shell, `${content}rm -rf ~\n`), false, 'appended commands fail validation'); assert.equal(validateEnvironmentFile(shell, content.replace('nmsh_bridge_clear GROFF_NO_SGR', 'nmsh_bridge_clear PATH')), false, 'non-allowlisted names fail'); - assert.ok(content.split('\n').filter(line => line.startsWith(' ')).length === BRIDGE_ENV_VARIABLES.length); + assert.ok(content.split('\n').filter(line => line.startsWith(' ')).length === BRIDGE_ENV_VARIABLES.length + 2, 'every variable plus the ls/gls listing lines'); } assert.throws(() => renderEnvironmentFile('zsh', {PATH: '/evil'} as never), /Refusing/u); assert.throws(() => renderEnvironmentFile('zsh', {LS_COLORS: 'a\nb'}), /Refusing/u); @@ -359,15 +359,21 @@ test('applyThemeBridge: isolated per target; independent targets get nothing; fa } finally { box.done(); } }); -test('reports: factual statuses; bat and delta are honest about unsupported custom themes', () => { - const config = configWith({fzf: {mode: 'follow'}, bat: {mode: 'follow'}}); - const reports = reportTargets(context(config, {PATH: ''}, installed('fzf', 'bat', 'delta'))); +test('reports: factual statuses; bat needs a reviewed setup; delta is detected only', () => { + const box = sandbox(); + const config = configWith({fzf: {mode: 'follow'}, bat: {mode: 'follow'}, delta: {mode: 'follow'}}); + const reports = reportTargets(context(config, box.env, installed('fzf', 'bat', 'delta'))); + box.done(); const by = (target: BridgeTargetId) => reports.find(report => report.target === target)!; assert.equal(by('fzf').status, 'Following NMSh'); - assert.equal(by('bat').status, 'Unsupported capability'); - assert.deepEqual(by('bat').modes, ['independent']); - assert.match(by('bat').notes.join(' '), /theme cache/u); - assert.match(by('delta').notes.join(' '), /gitconfig/u); + assert.equal(by('bat').status, 'Needs setup'); + assert.equal(by('bat').readiness, 'Needs setup'); + assert.match(by('bat').notes.join(' '), /reviewed cache build/u); + assert.equal(by('delta').status, 'Not managed'); + assert.equal(by('delta').mode, 'independent', 'a stored mode never makes delta appear managed'); + assert.deepEqual(by('delta').modes, ['independent']); + assert.equal(by('delta').editable, false); + assert.match(by('delta').notes.join(' '), /git config/u); assert.equal(by('tmux').status, 'Not installed'); assert.equal(by('pager').status, 'Not installed'); const colorless = reportTargets({...context(config, {PATH: '', NO_COLOR: '1'}, installed('fzf'))}).find(report => report.target === 'fzf')!; diff --git a/tests/themeBridgeHelix.test.ts b/tests/themeBridgeHelix.test.ts index a32ddfbf..32fadcfa 100644 --- a/tests/themeBridgeHelix.test.ts +++ b/tests/themeBridgeHelix.test.ts @@ -84,7 +84,8 @@ test('Helix: Independent writes nothing; Follow tracks the active theme; Choose assert.equal(readFileSync(artifactPath('helix', box.env), 'utf8'), chosen, 'pinned: unchanged by the main theme'); const report = reportTargets(ctx(pinned, box.env)).find(item => item.target === 'helix')!; assert.equal(report.status, 'Pinned theme'); - assert.match(report.notes.join(' '), /Managed theme generated; select it with :theme nmsh-bridge/u); + assert.equal(report.readiness, 'Needs activation'); + assert.match(report.notes.join(' '), /select it with :theme nmsh-bridge/iu); assert.match(report.notes.join(' '), /Running Helix instances are not recolored/u, 'never claims live updates'); } finally { box.done(); } }); @@ -119,7 +120,7 @@ test('Helix activation: exact confirmed theme assignment before the first table; const after = readFileSync(configPath, 'utf8'); assert.equal((parseToml(after) as {theme: string}).theme, 'nmsh-bridge', 'a valid top-level assignment'); assert.deepEqual({...(parseToml(after) as {editor: object}).editor}, {'line-number': 'relative'}, 'user settings untouched'); - assert.match(reportTargets(ctx(config({mode: 'follow'}), box.env)).find(item => item.target === 'helix')!.notes.join(' '), /Active through NMSh-managed config/u); + assert.equal(reportTargets(ctx(config({mode: 'follow'}), box.env)).find(item => item.target === 'helix')!.readiness, 'Active'); const removal = planHookRemoval('helix', box.home, box.env); assert.ok('plan' in removal); assert.ok(applyHookRemoval('helix', removal.plan, box.env).ok); diff --git a/tests/themeBridgePolicy.test.ts b/tests/themeBridgePolicy.test.ts new file mode 100644 index 00000000..626496b3 --- /dev/null +++ b/tests/themeBridgePolicy.test.ts @@ -0,0 +1,154 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {XMLValidator} from 'fast-xml-parser'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; +import {addTheme, deleteTheme, duplicateCurrentToCustom, setActiveTheme} from '../src/appearance/themeLibraryActions.js'; +import {assetRef, builtinTheme} from '../src/appearance/themeRefs.js'; +import {paletteFromTheme} from '../src/appearance/semanticPalette.js'; +import {categoryOf} from '../src/appearance/themeLibrary.js'; +import {BRIDGE_TARGETS, effectiveMode, effectiveSetting, normalizeThemeBridge, targetEditable, type BridgeTargetId} from '../src/themeBridge/model.js'; +import {batTheme, bsdLsColors, validateBatTheme, validBsdLsColors} from '../src/themeBridge/targets.js'; +import {artifactPath, batConfigDirectory, loadLedger} from '../src/themeBridge/artifacts.js'; +import {applyThemeBridge, batReady, bridgeEnvironment, integrationHealth, reportTargets, setupBat, type BridgeContext, type TargetFacts} from '../src/themeBridge/runtime.js'; +import {createThemeStudio, studioKey} from '../src/appearance/ThemeStudio.js'; +import {createSetup, sectionIndex, setupKey} from '../src/setup/SetupCat.js'; + +const base = (): PromptConfiguration => structuredClone(DEFAULT_PROMPT_CONFIGURATION); +const facts = (extra: Partial> = {}) => + ({...Object.fromEntries(BRIDGE_TARGETS.map(target => [target, {installed: true}])), ...extra}) as Record; +const ctx = (source: PromptConfiguration, env: NodeJS.ProcessEnv, f = facts()): BridgeContext => ({source, facts: f, level: 'truecolor', env}); +function sandbox() { + const root = mkdtempSync(join(tmpdir(), 'nmsh-policy-')); + const home = join(root, 'home'); + mkdirSync(home); + return {root, home, env: {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), PATH: process.env.PATH} as NodeJS.ProcessEnv, done: () => rmSync(root, {recursive: true, force: true})}; +} + +test('policy: Off preserves Manual state; Follow and Choose apply to supported targets only; Manual comes back exactly', () => { + const manual = {fzf: {mode: 'choose' as const, theme: 'builtin:nord'}, tmux: {mode: 'follow' as const}, delta: {mode: 'follow' as const}}; + const off = normalizeThemeBridge({enabled: false, targets: manual}); + assert.ok(BRIDGE_TARGETS.every(target => effectiveMode(off, target) === 'independent')); + assert.equal(off.targets.fzf.theme, 'builtin:nord', 'kept while Off'); + const follow = normalizeThemeBridge({enabled: true, policy: 'follow', targets: manual}); + assert.equal(effectiveMode(follow, 'pager'), 'follow'); + assert.equal(effectiveMode(follow, 'fzf'), 'follow'); + assert.equal(effectiveMode(follow, 'delta'), 'independent', 'delta never inherits'); + assert.equal(targetEditable(follow, 'tmux'), false); + const choose = normalizeThemeBridge({enabled: true, policy: 'choose', theme: 'builtin:gruvboxDark', targets: manual}); + assert.deepEqual(effectiveSetting(choose, 'vim'), {mode: 'choose', theme: 'builtin:gruvboxDark'}); + const back = normalizeThemeBridge({...choose, policy: 'manual'}); + assert.deepEqual(back.targets.fzf, {mode: 'choose', theme: 'builtin:nord'}); + assert.deepEqual(effectiveSetting(back, 'tmux'), {mode: 'follow'}); + assert.equal(targetEditable(back, 'tmux'), true); + // Migration from the earlier model: enabled + targets read as Manual; Choose without a theme reads as Manual. + assert.equal(normalizeThemeBridge({enabled: true, targets: manual}).policy, 'manual'); + assert.equal(normalizeThemeBridge({enabled: true, policy: 'choose'}).policy, 'manual'); +}); + +test('global Follow tracks the active theme; global Choose stays pinned; deleting the global pin returns to Manual', () => { + let config = normalizePromptConfiguration({...base(), themeBridge: {enabled: true, policy: 'follow', targets: {}}}); + const env = {PATH: ''}; + const before = reportTargets(ctx(config, env)).find(report => report.target === 'tmux')!; + config = (setActiveTheme(config, 'builtin:nord') as {config: PromptConfiguration}).config; + const after = reportTargets(ctx(config, env)).find(report => report.target === 'tmux')!; + assert.equal(before.themeLabel, 'Lavender Native'); + assert.equal(after.themeLabel, 'Nord'); + assert.equal(after.inherited, true); + const added = addTheme(config, {...builtinTheme('dracula'), name: 'Mine'}); + assert.ok(added.ok); + config = {...added.config, themeBridge: {...added.config.themeBridge, policy: 'choose', theme: assetRef(added.id!)}}; + config = (setActiveTheme(config, 'builtin:solarizedDark') as {config: PromptConfiguration}).config; + assert.equal(reportTargets(ctx(config, env)).find(report => report.target === 'vim')!.themeLabel, 'Mine'); + assert.equal(deleteTheme(config, added.id!).ok, false, 'a global pin blocks deletion until confirmed'); + const deleted = deleteTheme(config, added.id!, true); + assert.ok(deleted.ok); + assert.equal(deleted.config.themeBridge.policy, 'manual'); + assert.equal(deleted.config.themeBridge.theme, undefined); +}); + +test('file listing colors: GNU ls/gls get LS_COLORS and session-only wrappers; macOS/BSD ls gets CLICOLOR and LSCOLORS', () => { + const config = normalizePromptConfiguration({...base(), themeBridge: {enabled: true, targets: {lsColors: {mode: 'follow'}}}}); + const env = {PATH: ''}; + const gnu = bridgeEnvironment(ctx(config, env, facts({lsColors: {installed: true, listing: {ls: 'gnu', gls: false}}}))); + assert.ok(gnu.LS_COLORS); + assert.deepEqual(gnu.listing, ['ls']); + assert.equal(gnu.LSCOLORS, undefined); + const mac = bridgeEnvironment(ctx(config, env, facts({lsColors: {installed: true, listing: {ls: 'bsd', gls: true}}}))); + assert.equal(mac.CLICOLOR, '1'); + assert.ok(validBsdLsColors(mac.LSCOLORS!)); + assert.deepEqual(mac.listing, ['gls'], 'Homebrew gls is GNU and gets its own wrapper'); + assert.ok(mac.LS_COLORS); + assert.equal(bsdLsColors(paletteFromTheme(builtinTheme('nord'), 'x')).length, 24, 'twelve fg/bg pairs'); + const report = reportTargets(ctx(config, env, facts({lsColors: {installed: true, listing: {ls: 'bsd', gls: true}}}))).find(item => item.target === 'lsColors')!; + assert.equal(report.label, 'File listing colors'); + assert.match(report.backend!, /macOS\/BSD ls · CLICOLOR, LSCOLORS \+ GNU gls · LS_COLORS/u); + const independent = bridgeEnvironment(ctx(normalizePromptConfiguration({}), env, facts({lsColors: {installed: true, listing: {ls: 'bsd'}}}))); + assert.deepEqual(independent, {}, 'Independent injects nothing'); +}); + +test('bat: deterministic data-only tmTheme from any theme; setup only on request; BAT_THEME only when the cache lists it', async () => { + for (const theme of [builtinTheme('nord'), builtinTheme('solarizedLight'), {...builtinTheme('dracula'), name: 'Imported & "name"'}]) { + const xml = batTheme(paletteFromTheme(theme, 'x')); + assert.equal(xml, batTheme(paletteFromTheme(theme, 'x'))); + assert.equal(XMLValidator.validate(xml), true); + assert.ok(validateBatTheme(xml, text => text)); + for (const scope of ['comment', 'string', 'constant.numeric', 'entity.name.function', 'keyword', 'entity.name.type', 'entity.name.tag', 'invalid', 'markup.inserted', 'markup.deleted']) assert.match(xml, new RegExp(scope.replace('.', '\\.'), 'u')); + } + const box = sandbox(); + try { + const config = normalizePromptConfiguration({...base(), themeBridge: {enabled: true, targets: {bat: {mode: 'follow'}}}}); + await applyThemeBridge(ctx(config, box.env, facts({bat: {installed: true}}))); + assert.equal(existsSync(artifactPath('bat', box.env)), false, 'no bat file until the user asks for setup'); + assert.equal(bridgeEnvironment(ctx(config, box.env)).BAT_THEME, undefined); + assert.equal(integrationHealth(ctx(config, box.env)).find(item => item.target === 'bat')!.state, 'needs-setup'); + mkdirSync(join(batConfigDirectory(box.env), 'themes'), {recursive: true}); + writeFileSync(artifactPath('bat', box.env), 'mine'); + const refused = await setupBat(paletteFromTheme(builtinTheme('nord'), 'builtin:nord'), 'follow', 'builtin:nord', {...box.env, PATH: ''}); + assert.equal(refused.ok, false); + assert.equal(readFileSync(artifactPath('bat', box.env), 'utf8'), 'mine', 'an unowned same-name file is never overwritten'); + rmSync(artifactPath('bat', box.env)); + const bat = ['/opt/homebrew/bin/bat', '/usr/local/bin/bat', '/usr/bin/bat', '/usr/bin/batcat'].find(existsSync); + if (!bat || bat.endsWith('batcat')) return; + const env = {...box.env, BAT_CONFIG_DIR: batConfigDirectory(box.env), BAT_CACHE_PATH: join(box.root, 'bat-cache')}; + const result = await setupBat(paletteFromTheme(builtinTheme('nord'), 'builtin:nord'), 'follow', 'builtin:nord', env); + assert.ok(result.ok, result.message); + assert.ok(batReady(env)); + assert.equal(loadLedger(env).entries.bat?.cacheApproved, true); + assert.equal(bridgeEnvironment(ctx(config, env)).BAT_THEME, 'nmsh-bridge'); + assert.equal(existsSync(join(batConfigDirectory(box.env), 'config')), false, "bat's own config file is never written"); + } finally { box.done(); } +}); + +test('Duplicate current → Custom: any source, provenance dropped, " - Custom", active theme unchanged', () => { + let config = addTheme(base(), {...builtinTheme('nord'), name: 'Ghostty Mocha'}, {kind: 'ghostty', sourcePath: '/x'}, true); + assert.ok(config.ok); + const before = config.config.nmsh.themeId; + const copy = duplicateCurrentToCustom(config.config); + assert.ok(copy.ok); + const asset = copy.config.themes.find(item => item.id === copy.id)!; + assert.equal(asset.theme.name, 'Ghostty Mocha - Custom'); + assert.equal(categoryOf(asset), 'custom'); + assert.equal(copy.config.nmsh.themeId, before, 'the current theme stays active'); + const builtin = duplicateCurrentToCustom(normalizePromptConfiguration({nmsh: {palette: 'gruvboxDark'}})); + assert.ok(builtin.ok && builtin.config.themes[0]!.theme.name === 'Gruvbox Dark - Custom'); + const again = duplicateCurrentToCustom(builtin.config); + assert.ok(again.ok && again.config.themes[1]!.theme.name === 'Gruvbox Dark - Custom 2', 'deterministic unique naming'); + void config; +}); + +test('preview Chroma switches are local: Theme Studio and Setup toggle only their preview', () => { + const context = {themes: [], accent: 'mauve' as const, pinnedTo: () => [], chroma: 'Aurora · wave'}; + const studio = createThemeStudio(context); + assert.equal(studio.previewChroma, false, 'Off by default'); + studioKey(studio, {kind: 'text', value: 'c'}, 'truecolor', '/', context); + assert.equal(studio.previewChroma, true); + const config = normalizePromptConfiguration({presentation: {preset: 'aurora'}}); + const setup = createSetup(config); + setup.section = sectionIndex('appearance'); + setupKey(setup, {kind: 'text', value: 'p'}); + assert.equal(setup.previewChroma, true); + assert.equal(setup.draft.presentation.preset, 'aurora', 'the draft (and saved) Chroma is unchanged'); +}); diff --git a/tests/themeUi.test.ts b/tests/themeUi.test.ts index d549b26f..7b66ed3b 100644 --- a/tests/themeUi.test.ts +++ b/tests/themeUi.test.ts @@ -9,7 +9,7 @@ import {applyUiTheme} from '../src/appearance/uiTheme.js'; import {createThemeStudio, renderThemeStudio, studioKey, STUDIO_TABS, type StudioContext} from '../src/appearance/ThemeStudio.js'; import {builtinTheme} from '../src/appearance/themeRefs.js'; import type {ThemeAsset} from '../src/appearance/themeLibrary.js'; -import {createThemeBridgePanel, renderThemeBridgePanel, themeBridgeKey, type BridgePanelContext} from '../src/themeBridge/ThemeBridgePanel.js'; +import {createThemeBridgePanel, panelItems, renderThemeBridgePanel, themeBridgeKey, type BridgePanelContext} from '../src/themeBridge/ThemeBridgePanel.js'; import {BRIDGE_TARGETS} from '../src/themeBridge/model.js'; import type {TargetReport} from '../src/themeBridge/runtime.js'; import {createSetup, renderSetup, sectionIndex, setupKey} from '../src/setup/SetupCat.js'; @@ -66,7 +66,7 @@ test('/theme actions: set active, edit (same editor for Imported and Custom), re assert.deepEqual(studioKey(state, {kind: 'enter'}, 'truecolor', '/', ctx), {kind: 'delete', id: 't-bbbbbbbbbbbb', confirmIndependent: true}); const active = context({activeRef: 'asset:t-aaaaaaaaaaaa'}); const custom = createThemeStudio(active, 'custom'); - custom.selected.custom = 1; + custom.selected.custom = 2; studioKey(custom, {kind: 'delete'}, 'truecolor', '/', active); assert.equal(custom.confirmDelete, undefined); assert.match(custom.message ?? '', /active theme/u); @@ -104,30 +104,65 @@ test('/theme and /theme-bridge fit short and narrow terminals', () => { } }); -function bridgeContext(enabled = false): BridgePanelContext { - const reports: TargetReport[] = BRIDGE_TARGETS.map(target => ({target, label: target, mode: 'independent', modes: target === 'bat' || target === 'delta' ? ['independent'] : ['independent', 'follow', 'choose'], - status: 'Detected', notes: target === 'bat' ? ['bat loads custom themes only from its own theme cache'] : []})); - return {enabled, reports, themes: [{ref: 'builtin:lavender', label: 'Lavender Native', category: 'builtin'}, {ref: 'builtin:nord', label: 'Nord', category: 'builtin'}], +function bridgeContext(enabled = false, policy: 'manual' | 'follow' | 'choose' = 'manual'): BridgePanelContext { + const reports: TargetReport[] = BRIDGE_TARGETS.map(target => ({target, label: target, capability: target === 'delta' ? 'detected' : ['fzf', 'pager', 'lsColors'].includes(target) ? 'direct' : 'managed', + mode: 'independent', modes: target === 'delta' ? ['independent'] : ['independent', 'follow', 'choose'], editable: target !== 'delta' && policy === 'manual', inherited: enabled && policy !== 'manual' && target !== 'delta', + status: target === 'delta' ? 'Not managed' : 'Detected', notes: target === 'delta' ? ['delta is shown for status only'] : []})); + return {enabled, policy, reports, themes: [{ref: 'builtin:lavender', label: 'Lavender Native', category: 'builtin'}, {ref: 'builtin:nord', label: 'Nord', category: 'builtin'}], pinned: () => undefined, activeRef: 'builtin:lavender', managed: () => undefined}; } -test('/theme-bridge panel: master switch, per-target modes, Choose starts visibly on the active theme, unsupported targets explain why', () => { +const itemIndex = (state: ReturnType, context: BridgePanelContext, match: (item: ReturnType[number]) => boolean) => panelItems(state, context).findIndex(match); + +test('/theme-bridge: one panel; switch, Apply themes policy, inline expansion, Esc collapses first, read-only rows, delta not editable', () => { const state = createThemeBridgePanel(); - const text = plain(renderThemeBridgePanel(state, bridgeContext(), 110, 40)); + const off = bridgeContext(); + const text = plain(renderThemeBridgePanel(state, off, 120, 60)); assert.match(text, /Theme Bridge\s+‹ Off ›/u); - assert.match(text, /fzf\s+Independent\s+—\s+Detected/u); - assert.deepEqual(themeBridgeKey(state, {kind: 'enter'}, bridgeContext()), {kind: 'setEnabled', enabled: true}); - themeBridgeKey(state, {kind: 'down'}, bridgeContext()); - themeBridgeKey(state, {kind: 'enter'}, bridgeContext()); - assert.equal(state.detail?.target, 'fzf'); - assert.deepEqual(themeBridgeKey(state, {kind: 'right'}, bridgeContext()), {kind: 'setMode', target: 'fzf', mode: 'follow'}); - const choose = {...bridgeContext(true), reports: bridgeContext(true).reports.map(report => report.target === 'fzf' ? {...report, mode: 'follow' as const} : report)}; - assert.deepEqual(themeBridgeKey(state, {kind: 'right'}, choose), {kind: 'setMode', target: 'fzf', mode: 'choose', theme: 'builtin:lavender'}); - themeBridgeKey(state, {kind: 'escape'}, bridgeContext()); - state.selected = BRIDGE_TARGETS.indexOf('bat') + 1; - themeBridgeKey(state, {kind: 'enter'}, bridgeContext()); - assert.equal(themeBridgeKey(state, {kind: 'right'}, bridgeContext()), undefined); - assert.match(state.message ?? '', /theme cache/u); + assert.match(text, /Direct and environment[\s\S]*fzf[\s\S]*Managed themes[\s\S]*tmux[\s\S]*Detected only[\s\S]*delta/u); + assert.deepEqual(themeBridgeKey(state, {kind: 'enter'}, off), {kind: 'setEnabled', enabled: true}); + const on = bridgeContext(true); + state.selected = itemIndex(state, on, item => item.kind === 'policy'); + assert.deepEqual(themeBridgeKey(state, {kind: 'right'}, on), {kind: 'setPolicy', policy: 'follow'}); + // Inline expansion keeps the whole list visible. + state.selected = itemIndex(state, on, item => item.kind === 'target' && item.target === 'fzf'); + themeBridgeKey(state, {kind: 'enter'}, on); + assert.equal(state.expanded, 'fzf'); + const expanded = plain(renderThemeBridgePanel(state, on, 120, 60)); + assert.match(expanded, /▾ fzf[\s\S]*Mode[\s\S]*pager[\s\S]*helix[\s\S]*delta/u, 'details inline; other targets still listed'); + state.selected = itemIndex(state, on, item => item.kind === 'detail' && item.row === 'mode'); + assert.deepEqual(themeBridgeKey(state, {kind: 'right'}, on), {kind: 'setMode', target: 'fzf', mode: 'follow'}); + const following = {...on, reports: on.reports.map(report => report.target === 'fzf' ? {...report, mode: 'follow' as const} : report)}; + assert.deepEqual(themeBridgeKey(state, {kind: 'right'}, following), {kind: 'setMode', target: 'fzf', mode: 'choose', theme: 'builtin:lavender'}); + assert.equal(themeBridgeKey(state, {kind: 'escape'}, on), undefined, 'Esc collapses first'); + assert.equal(state.expanded, undefined); + assert.deepEqual(themeBridgeKey(state, {kind: 'escape'}, on), {kind: 'close'}, 'then closes'); + // Global Follow: rows are view-only, with no editable affordance. + const follow = bridgeContext(true, 'follow'); + const view = createThemeBridgePanel(); + view.selected = itemIndex(view, follow, item => item.kind === 'target' && item.target === 'tmux'); + themeBridgeKey(view, {kind: 'enter'}, follow); + assert.ok(!panelItems(view, follow).some(item => item.kind === 'detail' && (item.row === 'mode' || item.row === 'theme')), 'no mode/theme editors under a global policy'); + view.selected = itemIndex(view, follow, item => item.kind === 'target' && item.target === 'tmux'); + themeBridgeKey(view, {kind: 'right'}, follow); + assert.match(view.message ?? '', /Switch Apply themes to Manual/u); + assert.match(plain(renderThemeBridgePanel(view, follow, 120, 60)), /Inherited/u); + // delta: shown, never expandable or editable. + const delta = createThemeBridgePanel(); + delta.selected = itemIndex(delta, on, item => item.kind === 'target' && item.target === 'delta'); + themeBridgeKey(delta, {kind: 'enter'}, on); + assert.equal(delta.expanded, undefined); + assert.match(delta.message ?? '', /status only/u); +}); + +test('/theme-bridge review all: combined review defaults to No', () => { + const state = createThemeBridgePanel(); + state.review = {items: [{target: 'tmux', label: 'tmux', state: 'needs-include', detail: 'Needs one reviewed include', action: 'include'}], previews: {tmux: ['+ source-file -q x']}, yes: false}; + assert.match(plain(renderThemeBridgePanel(state, bridgeContext(true), 120, 40)), /Apply 1 reviewed change\?\s+‹ No ›/u); + assert.equal(themeBridgeKey(state, {kind: 'enter'}, bridgeContext(true)), undefined, 'Enter on the default No changes nothing'); + state.review = {items: [{target: 'tmux', label: 'tmux', state: 'needs-include', detail: '', action: 'include'}], previews: {}, yes: false}; + themeBridgeKey(state, {kind: 'right'}, bridgeContext(true)); + assert.deepEqual(themeBridgeKey(state, {kind: 'enter'}, bridgeContext(true)), {kind: 'applyAll'}); }); test('Setup Appearance: one Theme Bridge question (default No) reveals only detected tools; Theme Studio row reads Open ›', () => { @@ -176,8 +211,10 @@ for (const panelPosition of ['bottom', 'top'] as const) { await app['runSlash']('/theme-bridge', parseSlashCommand('/theme-bridge')!); const panel = app['themeBridgePanel']!; assert.ok(panel); - panel.selected = BRIDGE_TARGETS.indexOf('pager') + 1; + const context = app['themeBridgePanelContext'](); + panel.selected = panelItems(panel, context).findIndex(item => item.kind === 'target' && item.target === 'pager'); await app['handleThemeBridgeKey']({kind: 'enter'}, panel); + panel.selected = panelItems(panel, app['themeBridgePanelContext']()).findIndex(item => item.kind === 'detail' && item.row === 'mode'); await app['handleThemeBridgeKey']({kind: 'right'}, panel); const saved = loadPromptConfiguration(); assert.equal(saved.themeBridge.enabled, true); From f4623acf884cfb7cd69ca8efc03ea53961f2c740 Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:26:15 +0530 Subject: [PATCH 07/16] Providers inline panel, picker orientation, tmux Config Studio, tool registry, integrations, dotfiles import, command surface cleanup --- CHANGELOG.md | 13 +- README.md | 5 +- docs/design/theme-bridge.md | 18 +- src/app/TerminalApp.ts | 316 ++++++++++++++++++++++-- src/ask/concepts.ts | 26 +- src/ask/configActions.ts | 128 ++++++++++ src/ask/resolver.ts | 4 + src/ask/types.ts | 6 + src/commands/slashCommands.ts | 71 +++++- src/dotfiles/DotfilesPanel.ts | 140 +++++++++++ src/dotfiles/plan.ts | 174 ++++++++++++++ src/dotfiles/scan.ts | 138 +++++++++++ src/help/helpContent.ts | 11 +- src/pickers/Picker.ts | 14 +- src/providers/ProvidersOverview.ts | 192 +++++++++++---- src/themeBridge/artifacts.ts | 25 +- src/themeBridge/runtime.ts | 27 ++- src/tools/catalog.ts | 5 +- src/tools/config/ConfigureList.ts | 41 ++++ src/tools/config/TmuxPanel.ts | 280 ++++++++++++++++++++++ src/tools/config/registry.ts | 89 +++++++ src/tools/config/tmux.ts | 371 +++++++++++++++++++++++++++++ src/tools/config/tmuxManaged.ts | 34 +++ src/ui/CommandPalette.ts | 5 +- src/ui/RowPanel.ts | 64 +++++ tests/askConcepts.test.ts | 2 +- tests/autocomplete.test.ts | 5 +- tests/columnGutter.test.ts | 2 +- tests/commandPalette.test.ts | 4 +- tests/dotfilesCommands.test.ts | 167 +++++++++++++ tests/fullChromaQa.test.ts | 3 + tests/providersOverview.test.ts | 70 ++++-- tests/themeBridge.test.ts | 8 +- tests/toolConfig.test.ts | 186 +++++++++++++++ tests/tools.test.ts | 2 +- 35 files changed, 2504 insertions(+), 142 deletions(-) create mode 100644 src/ask/configActions.ts create mode 100644 src/dotfiles/DotfilesPanel.ts create mode 100644 src/dotfiles/plan.ts create mode 100644 src/dotfiles/scan.ts create mode 100644 src/tools/config/ConfigureList.ts create mode 100644 src/tools/config/TmuxPanel.ts create mode 100644 src/tools/config/registry.ts create mode 100644 src/tools/config/tmux.ts create mode 100644 src/tools/config/tmuxManaged.ts create mode 100644 src/ui/RowPanel.ts create mode 100644 tests/dotfilesCommands.test.ts create mode 100644 tests/toolConfig.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ef49d21..d07edee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Native theme library**: any number of Custom and Imported themes with stable ids (bounded to 64); the single custom theme migrates into it and stays active. Imported is provenance only: imported themes are ordinary Native themes you can edit, rename, duplicate, export, select and pin. - **Theme Studio** (`/theme`): Built-in · Imported · Custom · Import tabs, one editor and the real Native preview for every theme. Settings → Theme and `/setup appearance` select Built-in, Imported and Custom themes directly. - **Imports**: NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON, YAML, TOML; static colors only), Kitty, Ghostty (allowlist), iTerm2 `.itermcolors` (no XML entities) and WezTerm TOML (Lua refused). Data only, previewed with mapping and loss disclosure before saving; exports never include local source paths. -- **Theme Bridge** (`/theme-bridge`, opt-in, default Off): per tool Independent / Follow NMSh / Choose theme. fzf launched by NMSh, less/man termcap colors and LS_COLORS (vivid when installed, a small fallback otherwise) through an NMSh-owned shell environment applied by the zsh, Bash and Fish adapters at the next prompt; generated tmux, Neovim, Vim and Helix themes with an ownership ledger, staged validated writes, a typed tmux reload and exact includes added only after review. bat and delta are detected and stay Independent. No rc file, terminal or editor theme, or git config is changed. +- **Theme Bridge** (`/theme-bridge`, opt-in, default Off): one switch plus **Apply themes** Manual / Follow NMSh / Choose theme. Under Manual each tool is Independent / Follow NMSh / Choose theme; under a global policy per-tool rows are view-only and the Manual choices are kept for later. One persistent panel with inline rows (Esc collapses before it closes), grouped by capability. fzf launched by NMSh, less/man termcap colors and **File listing colors** (GNU `ls`/`gls` via LS_COLORS with vivid when installed, BSD/macOS `ls` via CLICOLOR/LSCOLORS) through an NMSh-owned shell environment applied by the zsh, Bash and Fish adapters at the next prompt; generated tmux, Neovim, Vim, Helix and bat themes (bat: a real `.tmTheme`, a reviewed `bat cache --build` verified with `bat --list-themes`, `BAT_THEME` through the environment) with an ownership ledger, staged validated writes, a typed tmux reload and exact includes added only after review. delta is shown and not editable. "Remove managed setup" removes NMSh's files and includes; "Set Independent" only stops applying. No rc file, terminal or editor theme, or git config is changed. +- Theme Studio: local **Preview Chroma** (default Off, never saved) and **Duplicate current** into Custom (` - Custom`). Chroma is reachable from `/prompt`, `/appearance` and Setup (Appearance and Prompt, with a Setup-local preview toggle). - **Host semantics**: OSC 7 working directory and OSC 133 command zones derived from NMSh's command lifecycle on capable hosts and tmux; NMSh-authored OSC 8 links in `/help` and dev-server task rows, kept separate from program links. - `/appearance` is a compact launcher: Theme Studio, Prompt, Cursor & effects, UI chrome, Chroma, Motion, Theme Bridge and host window. +### Providers, tools and integrations +- `/providers` is one inline panel: each family with its status (● Active, ✓ Selected · fallback, Available, Missing · Enter to install); Enter selects immediately or installs after a confirmation (default No). Shortcuts `/picker` (`/pickers`), `/suggestions`, `/navigation`, `/welcome`, `/history-provider` and `/providers ` open it focused. +- Pickers follow the composer: with the composer at the bottom the query sits at the bottom and results above it (NMSh Native and fzf); at the top the query is at the top. +- **Tool Configuration** (`/configure`, `/tmux`): one first-party registry says which tools NMSh can configure (tmux, Starship), which it themes, and which are inspect-only (shell rc files, Neovim/Vim config). Detection never grants write authority. +- **tmux Config Studio** (`/tmux`): General settings from a documented catalog, keymaps and prefix with conflict display, a Status Studio with a live preview (status modules never run shell), an optional NMSh pane frontend (`default-command` that starts NMSh by absolute path and falls back to your login shell inside NMSh; `default-shell` untouched), and import of a supported subset of an existing tmux.conf (`if-shell`, `run-shell`, `source-file` and `#()` are never followed). Each value shows where it comes from. Everything goes into one NMSh-managed tmux file, included once after review. +- `/integrations`: health of every managed integration (current, missing, stale, conflict) with Review all / Apply all (default No); after one-time activation, managed files update automatically when the theme changes. +- `/dotfiles [path or Git URL]`: plain, Git, GNU Stow and chezmoi sources. Remote sources are cloned only after confirmation (depth 1, no submodules, hooks disabled). Nothing in the repository is run, templates are not rendered, executable configs are inspect-only, conflicting values default to your current ones, copied files are backed up, and one combined review (default No) precedes any change. The repository is never modified. +- Ask maps tmux, provider, Theme Bridge, integrations and dotfiles requests onto these typed actions only, behind its final Yes/No. +- Commands: `/motion`, `/chrome`, `/glyphs` (`/glyph`), `/composer` (alias of `/layout`), `/strip` (`/status-strip`), `/configure`, `/tmux`, `/integrations`, `/dotfiles`. `/help` groups commands by area (Appearance, Composer & transcript, Providers, Tools & integration) and the palette lists each surface once with a readable label. Individual settings are not commands. + ### Contextual tools - `/tools` Discover shows a conservative **Relevant here** group from cheap local facts (Git repository, shell scripts, JavaScript/Node, Python, Go, Rust, container files, Kubernetes files or kubeconfig) and each tool's declared relevance. Only missing tools appear; nothing is executed, crawled or sent anywhere. - Typed package-manager plans for Homebrew, APT, DNF, pacman and zypper (WSL uses the distribution's manager). Tools without a verified package name stay manual. Non-root plans are explicit `sudo -n` argv; nothing elevates silently. diff --git a/README.md b/README.md index 3122379a..3795e9cd 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,11 @@ NMSh provides a richer interactive frontend without throwing away the proven rob - Command lifecycle rows with activity animation and nested Node TAP activity - Scrollable history with muted snapshots of each command's prompt, shown Full, Compact, Minimal or Off; tune dividers and history colors with `/transcript` - Native theme library: `/theme` (Theme Studio) browses Built-in themes and manages Imported and Custom ones; import NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON/YAML/TOML), Kitty, Ghostty, iTerm2 or WezTerm TOML files as data only, with a preview and loss disclosure before anything is saved -- Theme Bridge (`/theme-bridge`, opt-in per tool): fzf launched by NMSh, less/man, LS_COLORS, tmux, Neovim, Vim and Helix can Follow NMSh or use a pinned theme; nothing changes for a tool until you choose, and config includes are shown and confirmed first +- Theme Bridge (`/theme-bridge`, opt-in): fzf launched by NMSh, less/man, file listing colors (`ls`), bat, tmux, Neovim, Vim and Helix can Follow NMSh or use a pinned theme, globally or per tool; nothing changes for a tool until you choose, and config includes are shown and confirmed first +- Tool configuration: `/tmux` (settings, keys, status line, optional NMSh pane frontend, import from an existing tmux.conf), `/configure`, `/integrations` health with Review all, and `/dotfiles` to import supported settings from a local or Git dotfiles repository (nothing in it is run) - Host cooperation on capable terminals: OSC 7 working directory and OSC 133 command zones derived from NMSh's own command lifecycle, and NMSh-authored OSC 8 links (help, docs, dev-server URLs) kept separate from program output - Output folding (Config → Output folding: Off / Smart / Always): long output collapses to its first and last lines around `› N lines hidden · Ctrl+O`; Smart keeps failures and useful output expanded; `/copy` and `/resume` always keep the full output -- Composer position Bottom, Top, or Flow (Config → Composer position). Flow places the prompt and input right after the newest output, like a conventional terminal, and they scroll with it. Combine any position with Normal or Chat transcript presentation (Config → Transcript presentation). `/layout` (also Config → Layout) previews every combination with sample content before you choose. +- Composer position Bottom, Top, or Flow (Config → Composer position). Flow places the prompt and input right after the newest output, like a conventional terminal, and they scroll with it. Combine any position with Normal or Chat transcript presentation (Config → Transcript presentation). `/layout` or `/composer` (also Config → Layout) previews every combination with sample content before you choose. - Welcome providers: Vespyr (default), Fastfetch, Neofetch (legacy, if installed), or None (`/settings` → Welcome) - Command palette: `/palette`, F1, or Ctrl+Shift+P (Cmd+Shift+P where the terminal reports it) to search NMSh commands, settings, and actions - Sticky command headers keep the current command visible while scrolling diff --git a/docs/design/theme-bridge.md b/docs/design/theme-bridge.md index 65576e07..d99c529c 100644 --- a/docs/design/theme-bridge.md +++ b/docs/design/theme-bridge.md @@ -57,11 +57,12 @@ Oh My Zsh `.zsh-theme` files are executable shell code; they are never imported ## Theme Bridge (`/theme-bridge`) -A master switch (Setup: “Extend colors to tools?”, default No) and one setting per target: +A master switch (Setup: “Extend colors to tools?”, default No) and **Apply themes**: -- **Independent** – NMSh injects and changes nothing for that target. -- **Follow NMSh** – the active Native theme; future supported launches/environments follow theme changes. -- **Choose theme** – a pinned stable reference to any Built-in, Imported or Custom theme; the main theme never changes it. +- **Manual** – each target has its own setting: **Independent** (NMSh injects and changes nothing), **Follow NMSh** (the active Native theme) or **Choose theme** (a pinned stable reference to any Built-in, Imported or Custom theme). +- **Follow NMSh** / **Choose theme** – one policy for every target. Per-target rows become view-only; the Manual choices are preserved and return when Manual is chosen again. + +The panel is one persistent surface with inline rows grouped by capability (direct, managed files, detected only); Esc collapses an expanded row before it closes the panel. "Set Independent" stops applying a target; "Remove managed setup" removes NMSh's generated files and the include it added (ledger-checked). All targets consume the one resolved semantic palette; each target only maps semantic roles onto its documented roles. Chroma is a live presentation treatment and never reaches generated files. NO_COLOR (non-empty) or no color capability injects nothing. @@ -69,12 +70,13 @@ All targets consume the one resolved semantic palette; each target only maps sem | --- | --- | --- | | fzf | `--color` for fzf launched by NMSh only (version-gated color names, 256/16-color fallback); precedence: Theme Bridge colors first, the launching surface's explicit options last (they win). NMSh-owned launches never read `FZF_DEFAULT_OPTS`. | nothing | | less / man | `LESS_TERMCAP_md/mb/me/us/ue/so/se` and `GROFF_NO_SGR` through the shell environment sink. `LESS`, `PAGER` and `MANPAGER` are never set. BSD/macOS mandoc already emits the overstrike these recolor; GNU groff needs `GROFF_NO_SGR`. NMSh sets `PAGER=cat` for its own transcript, so plain `man` output inside NMSh is paged only when your `MANPAGER` selects less. | env file | -| LS_COLORS | `vivid generate ` when vivid is installed (local, bounded, output validated), otherwise a deliberately small mapping (file kinds plus a few broad extension groups). Replaces LS_COLORS in NMSh shells while active; Independent restores the previous value. | env file, vivid theme file | -| tmux | NMSh-generated fragment of style/colour options only (status, window status, pane borders, messages, modes, menus, popups, clock, display-panes, copy-mode); no keys, layout, plugins, commands or behavior; `set -gq` so older tmux skips unknown options. Optional typed reload `tmux source-file ` on request. New servers need one include line (below). Theme plugins or explicit styles in your tmux.conf are reported as conflicts, not fought. | fragment; include after confirmation | +| File listing colors | GNU `ls`/`gls`: `LS_COLORS` from `vivid generate ` when vivid is installed (local, bounded, output validated), otherwise a small mapping, plus a session-only `--color=auto` wrapper for NMSh shells. BSD/macOS `ls`: `CLICOLOR=1` and an `LSCOLORS` mapping. Independent restores the previous values. | env file, vivid theme file | +| tmux | the colors part of the one NMSh-managed tmux file (shared with `/tmux` Config Studio settings): NMSh-generated of style/colour options only (status, window status, pane borders, messages, modes, menus, popups, clock, display-panes, copy-mode); no keys, layout, plugins, commands or behavior; `set -gq` so older tmux skips unknown options. Optional typed reload `tmux source-file ` on request. New servers need one include line (below). Theme plugins or explicit styles in your tmux.conf are reported as conflicts, not fought. | fragment; include after confirmation | | Neovim | generated Lua colorscheme `nmsh-bridge` (classic groups, floats, separators, diff, diagnostics with underline/sign/virtual text, common Tree-sitter captures linked onto base groups); data only. | colorscheme; include after confirmation | | Vim | separate Vim colorscheme with classic groups only, truecolor plus 256-color `cterm` fallback, `background` from the theme. | colorscheme; include after confirmation | | Helix | native TOML theme `nmsh-bridge` in Helix's themes directory (`$XDG_CONFIG_HOME/helix/themes`, else `~/.config/helix/themes`): a named `[palette]` from the semantic palette, then syntax, markup, diff, diagnostic and editor UI scopes mapped onto it. Generation and activation are separate: activation is one confirmed `theme = "nmsh-bridge"` assignment inserted before the first table of `config.toml`; a config that already selects a theme is never changed (`:theme nmsh-bridge` works by hand). A same-named file NMSh did not write is never overwritten. Helix has no safe CLI theme switch and running instances are not recolored. | theme file; assignment after confirmation | -| bat, delta | detected only. bat applies custom themes only from its own theme cache (`bat cache --build`); delta takes syntax themes from that cache and diff styles from git config. NMSh does not modify either, and does not fake fidelity with an unrelated built-in theme. | nothing | +| bat | a generated `.tmTheme` in bat's themes directory, then a reviewed `bat cache --build` (typed argv), verified with `bat --list-themes`; `BAT_THEME` is set through the environment sink only after verification. | theme file; cache build after confirmation | +| delta | shown, not editable: delta takes syntax themes from bat's cache and diff styles from git config, which NMSh never changes. | nothing | Already-running editors and shells outside NMSh are not recolored live; Follow NMSh applies to new instances (and NMSh shells at their next prompt). @@ -108,4 +110,4 @@ Every include tolerates a missing file, so an Independent target with an include ## Not in this slice -Terminal title / OSC 0/2 ownership; terminal emulator or editor base-theme takeover (Ghostty/Kitty palettes, Zed/VS Code); bat/delta custom themes; arbitrary Oh My Zsh theme import; an Oh My Posh runtime provider; the #305 module ecosystem. +Terminal title / OSC 0/2 ownership; terminal emulator or editor base-theme takeover (Ghostty/Kitty palettes, Zed/VS Code); delta custom styles; arbitrary Oh My Zsh theme import; an Oh My Posh runtime provider; the #305 module ecosystem. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 9b4c1607..b2e7c2f2 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -36,8 +36,17 @@ import {activeThemeRef, assetRef, selectableThemes, themeRefLabel} from '../appe import {resolveSemanticPalette} from '../appearance/semanticPalette.js'; import {anyBridgeTargetActive, targetsPinnedTo, type BridgeTargetId} from '../themeBridge/model.js'; import {applyThemeBridge, bridgeStateExists, detectTargets, fzfBridgeArgs, integrationHealth, reloadTmux, reportTargets, setupBat, targetPalette, themeBridgeKey, type ApplyOutcome, type BridgeContext, type TargetFacts, type TargetReport} from '../themeBridge/runtime.js'; -import {createThemeBridgePanel, renderThemeBridgePanel, themeBridgeKey as themeBridgePanelKey, type BridgePanelContext, type ThemeBridgePanelState} from '../themeBridge/ThemeBridgePanel.js'; -import {applyHook, applyHookRemoval, artifactPath, hookSpec, loadLedger, ownership, planHook, planHookRemoval, removeArtifact, type HookSpec, type HookTarget, type ManagedTarget} from '../themeBridge/artifacts.js'; +import {createThemeBridgePanel, renderThemeBridgePanel, themeBridgeKey as themeBridgePanelKey, type BridgePanelAction, type BridgePanelContext, type ThemeBridgePanelState} from '../themeBridge/ThemeBridgePanel.js'; +import {createRowPanel, renderRowPanel, rowPanelKey, type RowPanelState} from '../ui/RowPanel.js'; +import {configureListKey, renderConfigureList, type ConfigureListState} from '../tools/config/ConfigureList.js'; +import {registryFacts, toolConfigEntry} from '../tools/config/registry.js'; +import {createTmuxPanel, describeTmuxChange, pendingChanges, renderTmuxPanel, tmuxPanelKey, type TmuxPanelState} from '../tools/config/TmuxPanel.js'; +import {applyTmuxChange, loadTmuxModel, parseTmuxConfig, readUserTmuxConfig, saveTmuxModel} from '../tools/config/tmux.js'; +import {writeTmuxManaged} from '../tools/config/tmuxManaged.js'; +import {createDotfilesPanel, dotfilesKey, renderDotfilesPanel, type DotfilesAction, type DotfilesState} from '../dotfiles/DotfilesPanel.js'; +import {expandSource, isRemoteSource, scanDotfiles} from '../dotfiles/scan.js'; +import {applyPlan as applyDotfilesPlan, buildPlan, reviewLines} from '../dotfiles/plan.js'; +import {applyHook, applyHookRemoval, artifactPath, hookSpec, loadLedger, ownership, planHook, planHookRemoval, recordedHook, removeArtifact, type HookSpec, type HookTarget, type ManagedTarget} from '../themeBridge/artifacts.js'; import {BRIDGE_MODE_LABELS, BRIDGE_POLICY_LABELS, BRIDGE_TARGET_LABELS, effectiveMode as bridgeMode} from '../themeBridge/model.js'; import type {FileEditPlan} from '../ask/fileEdit.js'; import {colorEscape} from '../chroma/escape.js'; @@ -95,7 +104,7 @@ import {HostSemantics, semanticSupport} from '../host/semanticMarks.js'; import {OutputBuffer, renderHistoricalContext, serializeCopyPayload, type CompletedCommand, type HistoricalContextSnapshot} from '../output/OutputBuffer.js'; import {createWelcomeSnapshot, renderWelcome, vespyrSprite, WELCOME_BLINK_CLOSED_MS, welcomeBlinkDelay} from '../output/Welcome.js'; import {captureWelcome, WELCOME_PROVIDERS, welcomeProvider} from '../output/WelcomeProviders.js'; -import {clearProviderDetection, detectProvider, installUnavailableReason, providerInstall, resolveCommand, resolveProvider, type ProviderStatus} from '../providers/providers.js'; +import {clearProviderDetection, detectProvider, installUnavailableReason, providerInstall, resolveCommand, resolveProvider, runExternal, type ProviderStatus} from '../providers/providers.js'; import {createProviderPanel, handleProviderPanelKey, providerPanelEnterAction, providerPanelSelection, renderProviderPanel, type ProviderPanelState} from '../providers/ProviderPanel.js'; import {TapActivityObserver} from '../output/TapActivityObserver.js'; @@ -206,7 +215,7 @@ import {liveLine} from '../status/liveLine.js'; import {browseOutcome} from '../ask/fileAssist.js'; import {gitWorktrees} from '../ask/git.js'; import type {AskAction, AskContext, AskOutcome} from '../ask/types.js'; -import {askProviderFacts, PROVIDER_FAMILIES, selectProvider} from '../providers/families.js'; +import {askProviderFacts, PROVIDER_FAMILIES, providerFamily, selectProvider, type SwitchableFamily} from '../providers/families.js'; import {LocalUnderstanding, understandingStatusRows, understandingWelcomeText} from '../understanding/LocalUnderstanding.js'; import {stateLabel, createUnderstandingPanel, renderUnderstandingPanel, understandingKey, type UnderstandingFacts, type UnderstandingPanelState} from '../understanding/UnderstandingPanel.js'; import {downloadPinned, loadRecommendedModel} from '../understanding/recommended.js'; @@ -1061,6 +1070,32 @@ export class TerminalApp { void this.handleThemeBridgeKey(key, this.themeBridgePanel); return; } + if (this.rowPanel) { + const action = rowPanelKey(this.rowPanel, key, this.promptConfiguration); + if (action?.kind === 'close') { this.rowPanel = undefined; this.returnFromPanel(); } + else if (action?.kind === 'change') { this.applySettingsConfiguration(action.configuration); this.syncStatusStrip(); } + this.render(); + return; + } + if (this.configureList) { + const tools = registryFacts(); + const action = configureListKey(this.configureList, key, tools); + if (action?.kind === 'close') { this.configureList = undefined; this.returnFromPanel(); } + else if (action?.kind === 'open') { this.configureList = undefined; void this.openConfigure(action.id, '/configure'); } + else if (action?.kind === 'bridge') { this.configureList = undefined; void this.openThemeBridge(); } + this.render(); + return; + } + if (this.tmuxPanel) { + void this.handleTmuxKey(key, this.tmuxPanel); + return; + } + if (this.dotfiles) { + const action = dotfilesKey(this.dotfiles, key); + if (action) void this.handleDotfilesAction(action, this.dotfiles).then(() => this.render()); + this.render(); + return; + } if (this.screensaverPanel) { this.handleScreensaverKey(key, this.screensaverPanel); return; @@ -1257,10 +1292,12 @@ export class TerminalApp { return; } if (this.providersOverview) { - const action = providersOverviewKey(this.providersOverview, key); + const action = providersOverviewKey(this.providersOverview, key, {configuration: this.promptConfiguration, statuses: this.providerStatuses}); if (action?.kind === 'close') { this.providersOverview = undefined; this.returnFromPanel(); } else if (action?.kind === 'detect') void this.refreshProvidersOverview(true); else if (action?.kind === 'open') this.openProviderFamily(action.row); + else if (action?.kind === 'select') this.selectProviderInline(action.family, action.id); + else if (action?.kind === 'install') void this.installProviderInline(action.family, action.id); this.render(); return; } @@ -1611,6 +1648,8 @@ export class TerminalApp { // completion menu: Down enters it, Up from its first row (or before // entering it) leaves it for command history. const searchSurface = this.historySearchActive || this.directorySearchActive; + // A bottom composer lists picker results above the query, best match nearest it: Up moves away from the input. + if (searchSurface && this.pickerFromBottom() && (key.kind === 'up' || key.kind === 'down')) key = {...key, kind: key.kind === 'up' ? 'down' : 'up'} as Key; const inSlashMenu = this.slashMenuFor === this.editor.text; if (key.kind === 'up' && suggestions.length > 0 && (searchSurface || (inSlashMenu && this.selectedSuggestion > 0))) { this.selectedSuggestion = (this.selectedSuggestion - 1 + suggestions.length) % suggestions.length; @@ -1875,7 +1914,7 @@ export class TerminalApp { const native = () => { /* Keep the existing native menu on fallback. */ }; const result = await openPicker(this.promptConfiguration.picker, candidates.map((candidate, index) => ({ id: String(index), label: candidate.display, description: candidate.description, value: candidate.insertion, - })), native, this.pickerHandoff, process.env, await this.fzfThemeArgs()); + })), native, this.pickerHandoff, process.env, await this.fzfThemeArgs(), this.fzfLayout()); if (!this.stopped && !this.running && this.editor.text === original && this.completionCursor === cursor && this.context.cwd === cwd && result?.kind === 'selected') { const selected = candidates[Number(result.candidate.id)]; @@ -1908,7 +1947,7 @@ export class TerminalApp { id: entry.id, label: entry.command, value: entry.command, description: entry.cwd, })); if (this.stopped || this.running || this.editor.text !== original) return; - const result = await openPicker(this.promptConfiguration.picker, candidates, native, this.pickerHandoff, process.env, await this.fzfThemeArgs()); + const result = await openPicker(this.promptConfiguration.picker, candidates, native, this.pickerHandoff, process.env, await this.fzfThemeArgs(), this.fzfLayout()); if (this.stopped) return; if (result?.kind === 'selected') this.applySuggestion({insertion: result.candidate.value}); if (result?.kind === 'fallback') this.output.addFrontendInteraction('/history', result.reason, INFO); @@ -1927,7 +1966,7 @@ export class TerminalApp { if (this.stopped || this.running || this.editor.text !== original) return; const result = await openPicker(this.promptConfiguration.picker, directories.map(item => ({ id: item.path, label: item.path, description: item.project, value: directoryCommand(item.path), - })), native, this.pickerHandoff, process.env, await this.fzfThemeArgs()); + })), native, this.pickerHandoff, process.env, await this.fzfThemeArgs(), this.fzfLayout()); if (this.stopped) return; if (result?.kind === 'selected') this.applySuggestion({insertion: result.candidate.value}); if (result?.kind === 'fallback') this.output.addFrontendInteraction('/dirs', result.reason, INFO); @@ -2032,6 +2071,16 @@ export class TerminalApp { else if (slash.kind === 'activity') { this.panelOrigin = undefined; this.focusConfigRow('activityColors'); } else if (slash.kind === 'theme') { this.panelOrigin = undefined; this.openThemeStudio(); } else if (slash.kind === 'themeBridge') { this.panelOrigin = undefined; await this.openThemeBridge(); } + // Short routes into the canonical surfaces: no second editor and no second state anywhere. + else if (slash.kind === 'chrome') { this.panelOrigin = undefined; this.focusConfigRow('uiChrome'); } + else if (slash.kind === 'glyphs') { + this.panelOrigin = undefined; + this.settingsPanelState = {section: 'appearance', selectedIndex: this.promptConfiguration.glyphStyle === 'nerd' ? 0 : 1, glyphStyle: this.promptConfiguration.glyphStyle, onboarding: false}; + } + else if (slash.kind === 'statusStrip') { this.panelOrigin = undefined; this.openStatusStrip(); } + else if (slash.kind === 'configure') { this.panelOrigin = undefined; await this.openConfigure(slash.tool, command); } + else if (slash.kind === 'integrations') { this.panelOrigin = undefined; await this.openIntegrations(); } + else if (slash.kind === 'dotfiles') { this.panelOrigin = undefined; this.dotfiles = createDotfilesPanel(slash.source ?? '~/dotfiles'); if (slash.source) await this.handleDotfilesAction({kind: 'scan', source: slash.source}, this.dotfiles); this.render(); } else if (slash.kind === 'settings') this.openSettingsPanel(slash.view); else if (slash.kind === 'tools') { this.panelOrigin = undefined; this.startTools(); } else if (slash.kind === 'setup') { this.panelOrigin = undefined; this.startSetup(slash.entry); } @@ -2074,7 +2123,7 @@ export class TerminalApp { } else if (slash.kind === 'palette') this.openPalette(); else if (slash.kind === 'ask') this.openAsk(slash.request); - else if (slash.kind === 'providers') this.openProvidersOverview(); + else if (slash.kind === 'providers') this.openProvidersOverview(slash.family); else if (slash.kind === 'llm') this.openUnderstandingPanel(); else if (slash.kind === 'doctor') void this.openDoctor(); else if (slash.kind === 'watch') this.handleWatch(command, slash.op, slash.arguments); @@ -3254,7 +3303,7 @@ export class TerminalApp { } private get settingsPanelActive(): boolean { - return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState + return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState || this.resumeBrowser || this.appearanceHub || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.pasteReview); } @@ -3263,6 +3312,7 @@ export class TerminalApp { if (this.setupState) return SETUP_MIN_SIZE; if (this.themeStudio) return STUDIO_MIN_SIZE; if (this.themeBridgePanel) return {columns: 56, rows: 14}; + if (this.tmuxPanel || this.configureList || this.rowPanel || this.dotfiles) return {columns: 50, rows: 12}; if (this.screensaverPanel) return SCREENSAVER_MIN_SIZE; if (this.chromeEditor || this.stopsEditor) return CHROME_EDITOR_MIN_SIZE; return undefined; @@ -3284,6 +3334,10 @@ export class TerminalApp { if (this.installPrompt) return renderInstallPrompt(this.installPrompt, columns); if (this.themeStudio) return this.renderThemeStudioRows(this.themeStudio, columns); if (this.themeBridgePanel) return renderThemeBridgePanel(this.themeBridgePanel, this.themeBridgePanelContext(), columns, this.dimensions().rows); + if (this.rowPanel) return renderRowPanel(this.rowPanel, this.promptConfiguration, columns, this.dimensions().rows, this.promptConfiguration.statusStrip.enabled ? [this.statusStripRow(columns - 2)] : [' (Status strip Off)']); + if (this.configureList) return renderConfigureList(this.configureList, registryFacts(), columns, this.dimensions().rows); + if (this.tmuxPanel) return renderTmuxPanel(this.tmuxPanel, columns, this.dimensions().rows); + if (this.dotfiles) return renderDotfilesPanel(this.dotfiles, columns, this.dimensions().rows); if (this.screensaverPanel) return this.renderScreensaverRows(this.screensaverPanel, columns); if (this.stopsEditor) return this.renderStopsEditor(this.stopsEditor, columns); if (this.chromeEditor) return renderChromeEditor(this.chromeEditor, columns, this.dimensions().rows, colorLevel()); @@ -3331,7 +3385,7 @@ export class TerminalApp { if (this.doctorPanel) return framePanel(renderDoctorPanel(this.doctorPanel, columns, Date.now(), !this.decorativeMotionAllowed()), columns); if (this.cursorPanel) return framePanel(this.cursorPanelRows(this.cursorPanel, columns, this.cursorEnv(this.cursorPanel.draft, this.promptConfiguration)), columns); if (this.understandingPanel) return framePanel(renderUnderstandingPanel(this.understandingPanel, this.understandingFacts(), columns), columns); - if (this.providersOverview) return framePanel(renderProvidersOverview(this.providersOverview, this.providersOverviewFacts(), columns), columns); + if (this.providersOverview) return framePanel(renderProvidersOverview(this.providersOverview, this.providersOverviewFacts(), columns, this.dimensions().rows - 3), columns); if (this.shellPanel) return framePanel(renderShellPanel(this.shellPanel, columns), columns); if (this.resumeBrowser?.liveOnly) return framePanel(this.sessionsViewRows(this.resumeBrowser, columns), columns); if (this.resumeBrowser) { @@ -3650,12 +3704,13 @@ export class TerminalApp { const project = detectMiseProject(this.shellCwd); this.misePanel = {project, selected: 0, result: this.miseService.cached(project)}; } - else if (action === 'configure' && state.detail?.configuration) await this.startToolConfiguration(state.detail.configuration); + else if (action === 'configure' && state.detail?.configuration) { this.toolsPanel = undefined; await this.openConfigure(state.detail.configuration, '/tools'); } else if (action === 'provider') { const family = state.detail?.providerFamily; if (family === 'welcome' || family === 'history' || family === 'picker' || family === 'navigation') { + // The one provider surface, focused on this family. this.toolsPanel = undefined; - this.startProviderPanel(family); + this.openProvidersOverview(family); } } else if (action === 'refresh') await refreshTools(state, () => this.render()); else if (action === 'checkUpdates') await this.checkToolUpdates(state); @@ -3841,7 +3896,7 @@ export class TerminalApp { reports: this.bridgeReports, themes: selectableThemes(config), pinned: target => bridge.targets[target].theme, ...(active ? {activeRef: active, activeLabel: themeRefLabel(active, config)} : {}), managed: target => target === 'tmux' || target === 'neovim' || target === 'vim' || target === 'helix' || target === 'bat' - ? {...(ledger.entries[target] && ownership(target, ledger) === 'owned' ? {artifact: artifactPath(target)} : {}), ...(ledger.entries[target]?.hook ? {include: ledger.entries[target]!.hook!.configPath} : {})} + ? {...(ledger.entries[target] && ownership(target, ledger) === 'owned' ? {artifact: artifactPath(target)} : {}), ...(target !== 'bat' && recordedHook(target as HookTarget) ? {include: recordedHook(target as HookTarget)!.configPath} : {})} : undefined}; } @@ -3858,6 +3913,10 @@ export class TerminalApp { private async handleThemeBridgeKey(key: Key, state: ThemeBridgePanelState): Promise { const action = themeBridgePanelKey(state, key, this.themeBridgePanelContext()); if (!action) { this.render(); return; } + await this.handleThemeBridgeAction(action, state); + } + + private async handleThemeBridgeAction(action: BridgePanelAction, state: ThemeBridgePanelState): Promise { if (action.kind === 'close') { this.themeBridgePanel = undefined; this.bridgePlan = undefined; this.bridgeBatPlan = undefined; this.returnFromPanel(); this.render(); return; } const home = process.env.HOME || homedir(); const problem = (target: BridgeTargetId) => this.bridgeProblems.find(outcome => outcome.target === target)?.message; @@ -3898,12 +3957,12 @@ export class TerminalApp { } } else if (action.kind === 'planRemoval') { const target = action.target as HookTarget | 'bat'; - const hook = loadLedger().entries[target]?.hook; - if (!hook || target === 'bat') { + const hook = target === 'bat' ? undefined : recordedHook(target as HookTarget); + if (!hook) { this.bridgePlan = {target, removal: true}; state.confirm = {kind: 'removeSetup', target, path: artifactPath(target), preview: [`- ${artifactPath(target)} (NMSh-managed file; no config include is recorded)`]}; } else { - const planned = planHookRemoval(target, home); + const planned = planHookRemoval(target as HookTarget, home); if ('error' in planned) state.message = planned.error; else { this.bridgePlan = {target, ...('plan' in planned ? {plan: planned.plan} : {}), removal: true}; @@ -3996,6 +4055,160 @@ export class TerminalApp { this.render(); } + /** + * Picker orientation follows the composer: Bottom puts the query at the + * bottom with results above (best match nearest the input), Top puts the + * query at the top with results below. Flow keeps results below its input, + * so it reads like Top; fullscreen external pickers use the bottom layout for Flow. + */ + private pickerFromBottom(): boolean { + return this.promptConfiguration.composerPosition === 'bottom'; + } + + private orientPicker(rows: string[]): string[] { + return (this.historySearchActive || this.directorySearchActive) && this.pickerFromBottom() ? [...rows].reverse() : rows; + } + + /** fzf layout for NMSh-owned launches: query at the bottom (fzf's default) unless the composer docks at the top. */ + private fzfLayout(): 'default' | 'reverse' { + return this.promptConfiguration.composerPosition === 'top' ? 'reverse' : 'default'; + } + + // ---- Status strip, /configure, /tmux, /integrations ------------------------------- + + private rowPanel?: RowPanelState; + private configureList?: ConfigureListState; + private tmuxPanel?: TmuxPanelState; + /** The include plan shown in the tmux review, applied exactly if confirmed. */ + private tmuxIncludePlan?: {plan: FileEditPlan; spec: HookSpec}; + + /** /strip and /status-strip: the canonical Status strip rows, with a live strip preview. */ + private openStatusStrip(): void { + this.rowPanel = createRowPanel('Status strip', 'compact NMSh status row, top right · same settings as Config', ['statusStrip', 'stripClock', 'stripBattery', 'stripCpu', 'stripRam', 'stripRamDisplay', 'stripUptime']); + } + + /** /configure [tool] and /tmux: the registered adapter's editor, or a factual answer. */ + private async openConfigure(tool: string | undefined, command: string): Promise { + if (!tool) { this.configureList = {selected: 0}; this.render(); return; } + const entry = toolConfigEntry(tool); + if (!entry) { this.output.addFrontendInteraction(command, `NMSh has no managed configuration adapter for ${tool}. /configure lists the tools it can configure.`, INFO); this.render(); return; } + if (entry.id === 'starship') { void this.startToolConfiguration('starship'); return; } + if (entry.id === 'tmux') { + const user = readUserTmuxConfig(); + this.bridgeFacts ??= await detectTargets(); + const report = reportTargets(await this.themeBridgeContext()).find(item => item.target === 'tmux'); + this.tmuxPanel = createTmuxPanel(loadTmuxModel(), user ? {path: user.path, parsed: parseTmuxConfig(user.text)} : undefined, + report ? `${BRIDGE_MODE_LABELS[report.mode]}${report.themeLabel && report.mode !== 'independent' ? ` · ${report.themeLabel}` : ''}` : 'Independent', Boolean(this.bridgeFacts.tmux?.installed)); + this.render(); + return; + } + if (entry.ownership === 'theme-bridge') { await this.openThemeBridge(); return; } + this.output.addFrontendInteraction(command, `${entry.label}: ${entry.summary}.`, INFO); + this.render(); + } + + /** /integrations: the shared Theme Bridge planner, opened straight on Review all. */ + private async openIntegrations(): Promise { + await this.openThemeBridge(); + if (this.themeBridgePanel) await this.handleThemeBridgeAction({kind: 'reviewAll'}, this.themeBridgePanel); + } + + private dotfiles?: DotfilesState; + private dotfilesInclude?: {plan: FileEditPlan; spec: HookSpec}; + + /** /dotfiles actions: scanning reads data only; cloning needs the confirmed step; applying goes through the adapters. */ + private async handleDotfilesAction(action: DotfilesAction, state: DotfilesState): Promise { + if (action.kind === 'close') { this.dotfiles = undefined; this.dotfilesInclude = undefined; this.returnFromPanel(); return; } + if (action.kind === 'scan') { + if (isRemoteSource(action.source)) { + const slug = action.source.replace(/^.*[/:]/u, '').replace(/\.git$/u, '').replace(/[^A-Za-z0-9._-]/gu, '-').slice(0, 60) || 'repository'; + state.clone = {url: action.source, target: join(nmshConfigDirectory(), 'dotfiles-inspect', `${slug}-${Date.now()}`), yes: false}; + state.step = 'clone'; + return; + } + const root = expandSource(action.source, this.shellCwd); + const scan = scanDotfiles(root); + if ('error' in scan) { state.message = scan.error; state.step = 'source'; return; } + state.scan = scan; + state.items = buildPlan(scan); + state.selected = 0; + state.step = 'items'; + return; + } + if (action.kind === 'clone' && state.clone) { + const git = resolveCommand('git', process.env.PATH ?? ''); + if (!git) { state.message = 'git is not installed.'; state.step = 'source'; return; } + state.message = 'Cloning…'; + this.render(); + const env = {...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1'}; + const result = await runExternal(git, ['clone', '--depth', '1', '--no-recurse-submodules', '-c', 'core.hooksPath=/dev/null', '-c', 'protocol.file.allow=never', '--', state.clone.url, state.clone.target], + {timeoutMs: 120_000, maxBytes: 64 * 1024, env}); + if (!result.ok) { state.message = 'The clone failed; nothing else was done.'; state.step = 'source'; state.clone = undefined; return; } + const target = state.clone.target; + state.clone = undefined; + await this.handleDotfilesAction({kind: 'scan', source: target}, state); + return; + } + if (action.kind === 'review') { + this.dotfilesInclude = undefined; + const include: string[] = []; + if (state.items.some(item => item.mode === 'import' && item.fields?.some(field => field.use)) && !recordedHook('tmux')) { + const spec = hookSpec('tmux'); + if (!('error' in spec)) { + const planned = planHook(spec, process.env.HOME || homedir()); + if ('plan' in planned) { this.dotfilesInclude = {plan: planned.plan, spec}; include.push(spec.configPath, ...planned.plan.preview.filter(line => line.startsWith('+'))); } + } + } + state.review = {lines: reviewLines(state.items, include), yes: false}; + state.step = 'review'; + return; + } + if (action.kind === 'apply') { + const results = applyDotfilesPlan(state.items); + if (this.dotfilesInclude) { + const included = applyHook('tmux', this.dotfilesInclude.plan, this.dotfilesInclude.spec); + results.push(`tmux.conf: ${included.ok ? 'one include added' : included.error}`); + this.dotfilesInclude = undefined; + } + state.results = results.length ? results : ['Nothing was selected, so nothing changed.']; + state.step = 'result'; + } + } + + private async handleTmuxKey(key: Key, state: TmuxPanelState): Promise { + const action = tmuxPanelKey(state, key); + if (action?.kind === 'close') { this.tmuxPanel = undefined; this.tmuxIncludePlan = undefined; this.returnFromPanel(); } + else if (action?.kind === 'openPrompt') { this.tmuxPanel = undefined; void this.startPromptSettings(false); } + else if (action?.kind === 'openBridge') { this.tmuxPanel = undefined; await this.openThemeBridge(); } + else if (action?.kind === 'reload') state.message = (await reloadTmux()).message; + else if (action?.kind === 'review') { + const include: string[] = []; + this.tmuxIncludePlan = undefined; + if (!recordedHook('tmux')) { + const spec = hookSpec('tmux'); + if ('error' in spec) include.push(` ${spec.error}`); + else { + const planned = planHook(spec, process.env.HOME || homedir()); + if ('plan' in planned) { this.tmuxIncludePlan = {plan: planned.plan, spec}; include.push(spec.configPath, ...planned.plan.preview); } + } + } + state.review = {lines: pendingChanges(state).map(describeTmuxChange), include, yes: false}; + } else if (action?.kind === 'apply') { + try { + saveTmuxModel(state.draft); + const written = writeTmuxManaged(state.draft); + if (!written.ok) state.message = written.error; + else { + const included = this.tmuxIncludePlan ? applyHook('tmux', this.tmuxIncludePlan.plan, this.tmuxIncludePlan.spec) : {ok: true as const}; + this.tmuxIncludePlan = undefined; + state.saved = structuredClone(state.draft); + state.message = included.ok ? 'Saved to NMSh\'s managed tmux file. New tmux servers load it; R reloads a running server now.' : included.error; + } + } catch (error) { state.message = `Nothing was applied: ${error instanceof Error ? error.message : String(error)}`; } + } + this.render(); + } + /** fzf `--color` for an NMSh-owned launch; empty unless fzf is the picker and its bridge mode is active. */ private async fzfThemeArgs(): Promise { const config = this.promptConfiguration; @@ -6235,9 +6448,9 @@ export class TerminalApp { if (this.output.applyAdvisoryFold(record.startId, applyFoldHint(input, hint))) this.render(); } - private openProvidersOverview(): void { + private openProvidersOverview(focus?: SwitchableFamily): void { this.panelOrigin = undefined; - this.providersOverview = createProvidersOverview(); + this.providersOverview = createProvidersOverview(focus); void this.refreshProvidersOverview(false); } @@ -6261,6 +6474,47 @@ export class TerminalApp { understanding: this.understandingSummary(), shell: {current: shellAdapter(this.shellId).label, defaultShell: shellAdapter(this.promptConfiguration.shellBackend).label}}; } + /** + * Inline selection from /providers: the same configuration and the same + * side effects as the family panels, applied at once so the overview is + * immediately factual (the family row shows the new provider as Active). + */ + private selectProviderInline(family: SwitchableFamily, id: string): void { + const next = selectProvider(this.promptConfiguration, family, id); + const state = this.providersOverview; + if (!next || !state) return; + if (!this.applySettingsConfiguration(next)) return; + if (family === 'suggestions') this.applySuggestionProvider(); + if (family === 'history') void this.loadHistory(); + if (family === 'navigation') { this.directoryQueryAbort?.abort(); this.directoryQuery = undefined; this.directoryResults = []; } + if (family === 'prompt') void this.refreshProviderPrompt().then(() => this.render()); + const label = providerFamily(family)!.providers.find(provider => provider.id === id)!.label; + state.message = `${providerFamily(family)!.title} · ${label}`; + } + + private async installProviderInline(family: SwitchableFamily, id: string): Promise { + const state = this.providersOverview; + const descriptor = providerFamily(family)?.providers.find(provider => provider.id === id); + const install = descriptor ? providerInstall(descriptor) : undefined; + if (!state || !descriptor || !install) return; + state.installing = {family, id, line: `Installing ${descriptor.label} · ${install.label}…`}; + this.render(); + const task = new TaskProgress(`Installing ${descriptor.label}`, () => { if (this.providersOverview === state) this.render(); }, Date.now(), descriptor.label); + const outcome = await task.run(install.command, [...install.args]); + if (this.stopped) return; + clearProviderDetection(); + const status = await detectProvider(descriptor); + this.providerStatuses.set(descriptor.id, status); + state.installing = undefined; + if (outcome.status === 'succeeded' && status.state === 'installed') { + recordInstall(descriptor.executable ?? descriptor.id, install); + this.selectProviderInline(family, id); + this.milestoneEffect(); + } else state.message = outcome.status === 'succeeded' ? `${install.label} finished, but ${descriptor.label} was not found on PATH; nothing was selected.` + : `${descriptor.label} was not installed. ${task.state.error ?? ''}`.trim(); + this.render(); + } + /** Each family opens its existing panel: switching, previewed installs and configuration live there. */ private openProviderFamily(row: string): void { this.providersOverview = undefined; @@ -6453,7 +6707,7 @@ export class TerminalApp { this.pickerOpening = true; try { const files = listProjectFiles(root).slice(0, 20_000); - const result = await openPicker(this.promptConfiguration.picker, files.map(path => ({id: path, label: path, value: path})), fallback, this.pickerHandoff, process.env, await this.fzfThemeArgs()); + const result = await openPicker(this.promptConfiguration.picker, files.map(path => ({id: path, label: path, value: path})), fallback, this.pickerHandoff, process.env, await this.fzfThemeArgs(), this.fzfLayout()); if (this.askState !== state || this.stopped) return; if (result?.kind === 'selected') await this.runInAsk(state, {kind: 'openFile', path: resolvePath(root, result.candidate.value)}); else if (result?.kind === 'fallback') fallback(); @@ -6792,6 +7046,24 @@ export class TerminalApp { private async executeAskAction(action: AskAction): Promise { switch (action.kind) { case 'slash': await this.runSlash(action.label, action.slash); return; + case 'tmux': { + // Ask's own Yes (which starts on No) confirmed exactly these typed changes. + let model = loadTmuxModel(); + for (const change of action.changes) { const next = applyTmuxChange(model, change); if (!('error' in next)) model = next; } + saveTmuxModel(model); + const written = writeTmuxManaged(model); + this.output.addFrontendInteraction('/ask', written.ok ? `tmux: ${action.label}. Saved in NMSh's managed tmux file${recordedHook('tmux') ? '; /tmux → R reloads a running server' : '; your tmux.conf does not load it yet: /tmux → Review & apply adds the one include after you review it'}.` : written.error, written.ok ? SUCCESS : ERROR); + return; + } + case 'themeBridge': { + await this.saveBridge(bridge => { + if (action.enabled !== undefined) bridge.enabled = action.enabled; + if (action.policy) bridge.policy = action.policy; + for (const [target, setting] of Object.entries(action.targets ?? {})) bridge.targets[target as BridgeTargetId] = {mode: setting.mode, ...(setting.theme ? {theme: setting.theme} : {})}; + }); + this.output.addFrontendInteraction('/ask', `Theme Bridge · ${action.label}. Tools that need a one-time include or cache build show it in /integrations.`, SUCCESS); + return; + } case 'switchShell': await this.switchShell(action.shell, `/shell ${action.shell}`); return; case 'installShell': { this.openShellPanel(action.shell); @@ -7251,7 +7523,7 @@ export class TerminalApp { case 'panel': return plan.panelPosition === 'top' && panelRows && /^[─-]+$/u.test(stripAnsi(panelRows[0] ?? '')) ? [...panelRows.slice(1), panelRows[0]!] : panelRows ?? []; case 'inspector': return this.inspectorRows(columns); - case 'suggestions': return [...suggestionView.items.map((suggestion, visibleIndex) => { + case 'suggestions': return this.orientPicker([...suggestionView.items.map((suggestion, visibleIndex) => { const selected = suggestionView.start + visibleIndex === effectiveSelection; if ('correction' in suggestion) return renderCorrection(suggestion, columns); if ('source' in suggestion && 'replacement' in suggestion) { @@ -7261,7 +7533,7 @@ export class TerminalApp { `${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ${suggestion.name.padEnd(10)}${RESET}${SECONDARY} ${suggestion.description}${RESET}`, columns, ); - }), ...(menuOverflow ? [renderCompletionMore(hiddenBelow, suggestionView.start, columns)] : [])]; + }), ...(menuOverflow ? [renderCompletionMore(hiddenBelow, suggestionView.start, columns)] : [])]); // The spacer sits between the newest output and the activity line in both positions. case 'activity': { if (!this.running) return []; diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index 2423bed4..2681771c 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -67,6 +67,18 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'themeStudio', label: 'Theme Studio', support: 'actionable', open: '/theme', covers: ['/theme'], aliases: ['theme studio', 'custom theme', 'custom themes', 'import theme', 'export theme', 'my own theme', 'imported theme', 'theme library'], description: 'Theme Studio manages Native themes: browse built-ins, import a theme file (Base16/24, Windows Terminal, Oh My Posh, Kitty, Ghostty, iTerm2, WezTerm), edit, duplicate, export and select.'}, + {id: 'uiChrome', label: 'UI chrome', support: 'actionable', open: '/chrome', covers: ['/chrome'], + aliases: ['ui chrome', 'chrome', 'frames', 'panel colors', 'tab colors'], + description: 'UI chrome is NMSh\'s frames, rules, tabs, selection and accents; it follows the theme or a custom preset. Not Chroma (animated color treatment).'}, + {id: 'toolConfig', label: 'Tool Configuration', support: 'actionable', open: '/configure', covers: ['/configure', '/tmux'], + aliases: ['configure tmux', 'tmux config', 'tmux settings', 'tmux prefix', 'tmux mouse', 'tmux status bar', 'tool configuration', 'status studio'], + description: 'Tool Configuration edits supported settings of registered tools: tmux (settings, keys, Status Studio, new panes start NMSh) through one NMSh-managed file, Starship through its own CLI. /tmux opens tmux directly.'}, + {id: 'integrations', label: 'Integrations', support: 'actionable', open: '/integrations', covers: ['/integrations'], + aliases: ['integrations', 'integration health', 'managed integrations', 'update all integrations'], + description: 'Integrations shows every managed integration (Theme Bridge files, includes, bat cache, tmux) and applies what is missing after one combined review that starts on No.'}, + {id: 'dotfiles', label: 'Dotfiles import', support: 'actionable', open: '/dotfiles', covers: ['/dotfiles'], + aliases: ['dotfiles', 'dot files', 'import dotfiles', 'stow', 'chezmoi'], + description: 'Dotfiles import scans a local repository (plain, Git, Stow or chezmoi source) or a Git URL you confirm, and imports supported settings through the same adapters after a review. Nothing in the repository is run.'}, {id: 'themeBridge', label: 'Theme Bridge', support: 'actionable', open: '/theme-bridge', covers: ['/theme-bridge'], aliases: ['theme bridge', 'fzf colors', 'fzf colours', 'man page colors', 'less colors', 'ls colors', 'ls_colors', 'tmux theme', 'tmux colors', 'neovim theme', 'nvim colorscheme', 'vim colorscheme', 'helix theme'], description: 'Theme Bridge extends NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim, Vim and Helix. Each tool is Independent until you choose Follow NMSh or a pinned theme.'}, @@ -86,7 +98,7 @@ export const CONCEPTS: readonly Concept[] = [ where: 'Settings → Layout → Composer dividers (On/Off)', aliases: ['composer dividers', 'composer divider', 'input dividers', 'input divider', 'input lines', 'lines around the input', 'divider lines', 'input box lines', 'composer lines', 'input separators'], description: 'Composer dividers are the two thin rules above and below the input. Off removes them and gives their rows back to output.'}, - {id: 'layout', label: 'Layout', support: 'actionable', open: '/layout', covers: ['/layout', 'settings:Layout'], + {id: 'layout', label: 'Layout', support: 'actionable', open: '/layout', covers: ['/layout', '/composer', 'settings:Layout'], aliases: ['layout', 'transcript layout', 'transcript presentation', 'chat mode', 'chat layout', 'composer position', 'composer at the top', 'flow mode', 'classic mode'], description: 'Layout chooses where the composer sits (bottom, top, or Flow after the newest output) and whether the transcript is Normal or Chat (commands on the right).'}, {id: 'history', label: 'Command history', support: 'actionable', open: '/history', configure: '/providers', where: 'Settings → History → Command history provider, or /providers', @@ -100,8 +112,8 @@ export const CONCEPTS: readonly Concept[] = [ covers: ['/dirs', 'family:navigation'], aliases: ['directory navigation', 'directories', 'dirs', 'folder navigation', 'cd history', 'zoxide', 'jump to directory', 'recent directories', 'recent directory', 'recent folders', 'recent folder'], description: '/dirs finds a directory and inserts a visible cd command. Ranking comes from native command-history frecency or a read-only zoxide snapshot.'}, - {id: 'providers', label: 'Providers', support: 'actionable', capability: 'providers.open', open: '/providers', covers: ['/providers'], - aliases: ['providers', 'provider', 'integrations'], + {id: 'providers', label: 'Providers', support: 'actionable', capability: 'providers.open', open: '/providers', covers: ['/providers', '/picker', '/pickers', '/suggestions', '/navigation', '/welcome', '/history-provider'], + aliases: ['providers', 'provider', 'picker provider', 'history provider'], description: 'Providers are what NMSh uses for prompt, welcome, suggestions, history, picker, directory navigation and local understanding. /providers shows, switches, detects and installs them.'}, {id: 'shell', label: 'Shell', support: 'actionable', capability: 'shell.switch', open: '/shell', covers: ['/shell'], aliases: ['shell', 'shells', 'shell backend', 'backend', 'default shell'], @@ -181,7 +193,7 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'status', label: 'NMSh status', support: 'actionable', open: '/status', covers: ['/status'], aliases: ['nmsh status', 'diagnostics', 'status page', 'status view'], description: '/status shows NMSh\'s own state: session, shell, providers, local understanding and more.'}, - {id: 'statusStrip', label: 'Status strip', support: 'settings', open: '/settings', where: 'Settings → Status strip', covers: ['settings:Status strip'], + {id: 'statusStrip', label: 'Status strip', support: 'actionable', open: '/strip', where: '/strip (also Settings → Status strip)', covers: ['settings:Status strip', '/strip', '/status-strip'], aliases: ['status strip', 'clock', 'battery', 'cpu', 'ram', 'uptime', 'status bar'], description: 'The status strip is a compact row, top right: clock, battery, CPU, RAM and uptime, each optional.'}, {id: 'palette', label: 'Command palette', support: 'actionable', open: '/palette', prefers: ['open'], covers: ['/palette'], @@ -205,7 +217,7 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'understanding', label: 'Local understanding', support: 'actionable', capability: 'understanding.set', open: '/llm', covers: ['/llm'], aliases: ['local understanding', 'local model', 'language model', 'llm', 'qwen', 'ai model', 'local intelligence', 'local llm'], description: 'Local understanding is an optional small model that runs on this machine to help Ask and Smart Folding with loosely worded input. Auto (the default) asks it only when built-in understanding is unsure and a model is set up; nothing downloads without your Yes. Off never loads one.'}, - {id: 'glyphs', label: 'Glyph style', support: 'settings', open: '/settings', where: 'Settings → General → Glyph style', covers: [], + {id: 'glyphs', label: 'Glyph style', support: 'actionable', open: '/glyphs', where: '/glyphs (also Settings → Glyph style)', covers: ['/glyphs'], aliases: ['glyphs', 'glyph style', 'nerd font', 'nerd fonts', 'icons', 'symbols'], description: 'Glyph style picks Nerd Font symbols or safe terminal symbols for icons and prompt shapes.'}, {id: 'copy', label: 'Copy output', support: 'actionable', open: '/copy', covers: ['/copy'], @@ -308,7 +320,7 @@ export interface GuideSection {id: string; title: string; why: string; concepts: export const GUIDE_SECTIONS: readonly GuideSection[] = [ {id: 'start', title: 'Getting started', why: 'NMSh is a terminal frontend over a real, persistent shell: your shell keeps its state, and NMSh adds the editor, transcript and tools around it.', concepts: ['setup', 'help', 'palette', 'settings', 'version', 'updates', 'status']}, - {id: 'look', title: 'Prompt & appearance', why: 'Make the prompt and colors yours without editing dotfiles.', concepts: ['prompt', 'theme', 'themeStudio', 'themeBridge', 'glyphs', 'syntax']}, + {id: 'look', title: 'Prompt & appearance', why: 'Make the prompt and colors yours without editing dotfiles.', concepts: ['prompt', 'theme', 'themeStudio', 'themeBridge', 'uiChrome', 'glyphs', 'syntax']}, {id: 'cursorEffects', title: 'Cursor & effects', why: 'Cursor trails and motion for NMSh-owned chrome; Reduced Motion, Effects Off and NO_COLOR are always respected.', concepts: ['cursor', 'motion']}, {id: 'chroma', title: 'Chroma', why: 'Optional gradients and motion for NMSh-owned chrome only; your command output is never recolored.', concepts: ['chroma', 'effects', 'activity']}, {id: 'shells', title: 'Shells', why: 'One NMSh window can run zsh, Fish or Bash, and you can leave for a plain shell and come back.', concepts: ['shell', 'leave', 'otherShells']}, @@ -321,7 +333,7 @@ export const GUIDE_SECTIONS: readonly GuideSection[] = [ {id: 'projects', title: 'Project & dev tasks', why: 'Ask reads this project\'s scripts (package.json, Makefile, Cargo, …) and can run them for you.', concepts: [], notes: ['Try: "run the tests" · "start the dev server" · "what scripts does this project have?"', 'Dev servers run as NMSh-managed background tasks: a live status row, detected URLs to open, and "stop the dev server" to end them.']}, {id: 'files', title: 'Files, config & editor', why: 'Jump to folders, open what output mentions, and let Ask find and open config files or add/update a setting with a verified, previewed edit (it never removes settings).', concepts: ['navigation', 'editor', 'pastePreview']}, - {id: 'providers', title: 'Providers & tools', why: 'Choose what powers each part of NMSh, and install optional tools with previewed recipes.', concepts: ['providers', 'welcome', 'tools', 'homebrew', 'agents']}, + {id: 'providers', title: 'Providers & tools', why: 'Choose what powers each part of NMSh, and install optional tools with previewed recipes.', concepts: ['providers', 'welcome', 'tools', 'toolConfig', 'integrations', 'dotfiles', 'homebrew', 'agents']}, {id: 'doctorWatch', title: 'Doctor & watch', why: 'Check your setup with local, read-only checks, and watch a command change over time.', concepts: ['doctor', 'watch'], notes: ['After a failure, ask "why did that fail?" for an explanation from the recorded output.']}, {id: 'layout', title: 'Layout & folding', why: 'Decide where the composer sits and how much output stays in view.', concepts: ['layout', 'composerDividers', 'folding', 'statusStrip', 'idle']}, diff --git a/src/ask/configActions.ts b/src/ask/configActions.ts new file mode 100644 index 00000000..75d876c6 --- /dev/null +++ b/src/ask/configActions.ts @@ -0,0 +1,128 @@ +import type {AskOutcome} from './types.js'; +import {parseSlashCommand} from '../commands/slashCommands.js'; +import {describeTmuxChange, type TmuxChange} from '../tools/config/tmux.js'; +import {BRIDGE_TARGET_LABELS, type BridgeTargetId} from '../themeBridge/model.js'; +import {THEME_PALETTE_IDS} from '../prompt/configuration.js'; +import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; + +/** + * Deterministic Ask routing for NMSh surfaces and supported configuration. + * Requests map only onto typed actions (a registered tmux change, a Theme + * Bridge setting, or opening a canonical surface); model or request text + * never becomes a command, path mutation or config snippet. Changes are + * proposals behind Ask's final Yes/No; opening a surface is navigation. + */ + +const SURFACES: ReadonlyArray<[RegExp, string]> = [ + [/\b(?:check|update|review)\b.*\bintegrations?\b|\bintegrations? (?:health|status)\b|\bupdate (?:anything|everything) missing\b/u, '/integrations'], + [/\bmotion\b/u, '/motion'], + [/\bui chrome\b|\bchrome\b(?! ?browser)/u, '/chrome'], + [/\bglyphs?\b|\bsymbols\b|\bnerd font\b/u, '/glyphs'], + [/\bcomposer\b/u, '/composer'], + [/\bstatus strip\b|\bthe strip\b/u, '/strip'], + [/\btheme bridge\b/u, '/theme-bridge'], + [/\bpicker\b/u, '/picker'], + [/\bdirectory nav(?:igation)?\b|\bdir(?:ectory)? jump/u, '/navigation'], + [/\btmux\b/u, '/tmux'], +]; + +const OPEN = /^(?:please )?(?:open|show|change|configure|edit|set up|go to|take me to)\b/u; +const keyName = (text: string) => { + const match = /\b(?:ctrl|control|c)[ +-]?([a-z])\b/u.exec(text); + return match ? `C-${match[1]}` : undefined; +}; + +function themeRef(name: string): string | undefined { + const wanted = name.trim().toLowerCase(); + const id = THEME_PALETTE_IDS.filter(palette => palette !== 'custom').find(palette => { + const label = NATIVE_PROMPT_THEMES[palette].label.toLowerCase(); + return label === wanted || label.replace(/ native$/u, '') === wanted || label.startsWith(`${wanted} `) || label === `${wanted} dark`; + }); + return id ? `builtin:${id}` : undefined; +} + +const TARGET_WORDS: ReadonlyArray<[RegExp, BridgeTargetId]> = [[/\btmux\b/u, 'tmux'], [/\bneovim\b|\bnvim\b/u, 'neovim'], [/\bvim\b/u, 'vim'], [/\bhelix\b/u, 'helix'], + [/\bfzf\b/u, 'fzf'], [/\bbat\b/u, 'bat'], [/\bless\b|\bman pages?\b/u, 'pager'], [/\bls\b|\bfile listing/u, 'lsColors']]; + +function tmuxChanges(text: string): TmuxChange[] { + const changes: TmuxChange[] = []; + const mouse = /\bmouse\b.*\b(on|off)\b|\b(enable|disable)\b.*\bmouse\b/u.exec(text); + if (mouse) changes.push({kind: 'option', id: 'mouse', value: mouse[1] ?? (mouse[2] === 'enable' ? 'on' : 'off')}); + if (/\bprefix\b/u.test(text)) { const key = keyName(text.slice(text.indexOf('prefix'))); if (key) changes.push({kind: 'prefix', key}); } + const status = /\bstatus(?: bar| line)?\b.*\b(top|bottom)\b/u.exec(text); + if (status) changes.push({kind: 'option', id: 'status-position', value: status[1]!}); + if (/\bvi keys\b|\bvi mode\b|\bvim keys\b/u.test(text)) changes.push({kind: 'option', id: 'mode-keys', value: 'vi'}, {kind: 'option', id: 'status-keys', value: 'vi'}); + if (/\bnew (?:tmux )?(?:panes?|windows?)\b.*\b(?:start|run|open|launch)\b.*\bnmsh\b/u.test(text)) changes.push({kind: 'frontend', value: 'nmsh'}); + if (/\bnew (?:tmux )?(?:panes?|windows?)\b.*\b(?:start|run)\b.*\b(?:normal |default |my )?shell\b/u.test(text)) changes.push({kind: 'frontend', value: 'shell'}); + return changes; +} + +export function resolveConfigRequest(text: string): AskOutcome | undefined { + // Provider choices map onto the same typed provider setting /providers uses. + const picker = /\b(?:use|switch to|pick)\s+(fzf|television|tv|native|nmsh native|nmsh)\b.*\bpickers?\b|\bpickers?\b.*\b(?:use|to)\s+(fzf|television|tv|native|nmsh)\b/u.exec(text); + if (picker) { + const word = picker[1] ?? picker[2]!; + const value = word === 'fzf' ? 'fzf' : word === 'television' || word === 'tv' ? 'television' : 'native'; + const label = value === 'fzf' ? 'fzf' : value === 'television' ? 'Television' : 'NMSh Native'; + return {kind: 'proposal', capability: 'provider.switch', safety: 'mutate', confidence: 0.92, text: `Use ${label} for pickers?`, action: {kind: 'setting', setting: 'picker', value, label: `Picker: ${label}`}}; + } + const suggestions = /\b(?:use|switch to)\s+(native|nmsh|deja|no)\s+suggestions\b|\bturn (off) suggestions\b/u.exec(text); + if (suggestions) { + const word = suggestions[1] ?? suggestions[2]!; + const value = word === 'deja' ? 'deja' : word === 'no' || word === 'off' ? 'none' : 'nmsh'; + const label = value === 'deja' ? 'Deja' : value === 'none' ? 'None' : 'NMSh Native'; + return {kind: 'proposal', capability: 'provider.switch', safety: 'mutate', confidence: 0.92, text: `Use ${label} for suggestions?`, action: {kind: 'setting', setting: 'suggestions', value, label: `Suggestions: ${label}`}}; + } + if (/\bpickers?\b.*\bfollow\b.*\bcomposer\b/u.test(text)) { + return {kind: 'answer', capability: 'help.feature', text: 'Pickers already follow the composer: with the composer at the bottom, the query is at the bottom and results sit above it (NMSh Native and fzf); at the top, the query is at the top. /composer chooses the side.'}; + } + // Dotfiles: the wizard scans and reviews; nothing is applied from here. + const dotfiles = /\b(?:import|adopt|use|show)\b.*\bdotfiles?\b(?:.*?\bfrom\s+(\S+))?/u.exec(text); + if (dotfiles) { + const source = dotfiles[1] ?? '~/dotfiles'; + const command = `/dotfiles ${source}`; + const slash = parseSlashCommand(command); + if (slash) return {kind: 'proposal', capability: 'feature.open', safety: 'navigate', confidence: 0.9, direct: true, text: `Open the dotfiles import for ${source} (scan and review; nothing is applied until you confirm there)`, + action: {kind: 'slash', slash, label: command}}; + } + if (/\btmux\b/u.test(text)) { + const changes = tmuxChanges(text); + if (changes.length) { + return {kind: 'proposal', capability: 'settings.open', safety: 'mutate', confidence: 0.92, + text: `Change tmux (NMSh's managed tmux file only):\n${changes.map(change => ` ${describeTmuxChange(change)}`).join('\n')}`, + action: {kind: 'tmux', changes, label: changes.map(describeTmuxChange).join(' · ')}}; + } + } + if (/\btheme bridge\b|\b(?:all )?(?:bridge )?targets\b/u.test(text) && /\bfollow nmsh\b|\bfollow (?:the )?(?:nmsh )?theme\b/u.test(text)) { + return {kind: 'proposal', capability: 'theme.open', safety: 'mutate', confidence: 0.9, text: 'Turn Theme Bridge On with Apply themes: Follow NMSh (every supported tool follows the active theme)', + action: {kind: 'themeBridge', enabled: true, policy: 'follow', label: 'Theme Bridge · Follow NMSh'}}; + } + // "use Tokyo Night for tmux but Lavender for Vim": per-tool pins (Manual). + const pins = [...text.matchAll(/\buse ([a-z][a-z ]{1,30}?) for ([a-z/ ]{2,20}?)(?= but|,| and|$)|\b(?:but|and) ([a-z][a-z ]{1,30}?) for ([a-z/ ]{2,20}?)(?= but|,| and|$)/gu)]; + if (pins.length) { + const targets: Partial> = {}; + for (const pin of pins) { + const theme = themeRef(pin[1] ?? pin[3] ?? ''); + const target = TARGET_WORDS.find(([pattern]) => pattern.test(pin[2] ?? pin[4] ?? ''))?.[1]; + if (theme && target) targets[target] = {mode: 'choose', theme}; + } + const entries = Object.entries(targets) as Array<[BridgeTargetId, {mode: 'choose'; theme: string}]>; + if (entries.length) { + const label = entries.map(([target, setting]) => `${BRIDGE_TARGET_LABELS[target]}: ${NATIVE_PROMPT_THEMES[setting.theme.slice(8) as 'lavender'].label}`).join(' · '); + return {kind: 'proposal', capability: 'theme.open', safety: 'mutate', confidence: 0.85, text: `Theme Bridge (Manual): ${label}`, + action: {kind: 'themeBridge', enabled: true, policy: 'manual', targets, label}}; + } + } + if (OPEN.test(text) || /\bsettings\b/u.test(text)) { + for (const [pattern, command] of SURFACES) { + if (!pattern.test(text)) continue; + const slash = parseSlashCommand(command); + if (slash) return {kind: 'proposal', capability: 'feature.open', safety: 'navigate', confidence: 0.9, direct: true, text: `Open ${command}`, action: {kind: 'slash', slash, label: command}}; + } + } + if (/\b(?:check|update)\b.*\bintegrations?\b|\bupdate (?:anything|everything) missing\b/u.test(text)) { + const slash = parseSlashCommand('/integrations')!; + return {kind: 'proposal', capability: 'feature.open', safety: 'navigate', confidence: 0.9, direct: true, text: 'Open /integrations (Review all; nothing is applied until you confirm)', action: {kind: 'slash', slash, label: '/integrations'}}; + } + return undefined; +} diff --git a/src/ask/resolver.ts b/src/ask/resolver.ts index 58b428de..8080de29 100644 --- a/src/ask/resolver.ts +++ b/src/ask/resolver.ts @@ -1,3 +1,4 @@ +import {resolveConfigRequest} from './configActions.js'; import {basename, relative} from 'node:path'; import {slashCommands} from '../commands/slashCommands.js'; import type {ShellId} from '../shell/adapters/ShellAdapter.js'; @@ -209,6 +210,9 @@ function resolveExact(raw: string, context: AskContext, state: ResolveState = {} // Command knowledge: explaining git push or git clean is an answer, not an action, so it comes before the action-safety check. // "how do i X" still lets a strong typed capability act ("how do i open package.json"). // One guide: /guide, "guide me through nmsh", and /ask help all come from the concept catalog. + // NMSh surfaces and supported configuration: deterministic, typed actions only. + const configured = resolveConfigRequest(text); + if (configured) return configured; if (GUIDE_REQUEST.test(text)) return guideOutcome(context); if (HELP_REQUEST.test(text)) return askHelpOutcome(); // "why did that fail": the failed block's own evidence, read deterministically. diff --git a/src/ask/types.ts b/src/ask/types.ts index d2e4ba55..8cd7d799 100644 --- a/src/ask/types.ts +++ b/src/ask/types.ts @@ -1,3 +1,5 @@ +import type {TmuxChange} from '../tools/config/tmux.js'; +import type {BridgePolicy, BridgeTargetId} from '../themeBridge/model.js'; import type {ParsedSlashCommand} from '../commands/slashCommands.js'; import type {ShellId} from '../shell/adapters/ShellAdapter.js'; import type {GitFacts} from './git.js'; @@ -64,6 +66,10 @@ export type AskAction = | {kind: 'applyEdit'; plan: FileEditPlan} /** A curated tool install (the /tools recipe, shown exactly before the Yes); never a guessed package. */ | {kind: 'installTool'; tool: string; label: string} + /** Typed tmux changes through the tmux Tool Configuration adapter (NMSh's managed tmux file only). */ + | {kind: 'tmux'; changes: TmuxChange[]; label: string} + /** Theme Bridge settings: switch, policy and per-target Manual pins (stable theme references only). */ + | {kind: 'themeBridge'; enabled?: boolean; policy?: BridgePolicy; targets?: Partial>; label: string} | {kind: 'setting'; setting: 'suggestions' | 'history' | 'welcome' | 'picker' | 'navigation' | 'prompt' | 'localUnderstanding' | 'shellBackend' | 'composerDividers'; value: string; label: string}; /** diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 1c7cc6e1..c381d4ef 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -1,11 +1,38 @@ import {IDLE_MODES, type IdleMode} from '../idle/scenes.js'; +export type CommandGroup = 'Appearance' | 'Composer & transcript' | 'Providers' | 'Tools & integration'; + export interface SlashCommand { name: string; insertion: string; description: string; + /** /help section for substantial surfaces; ungrouped commands are listed under "More commands". */ + group?: CommandGroup; + /** Human palette label ("Open Motion"); the command name is shown beside it. */ + title?: string; + /** Another spelling of a canonical command: parsed to the same action, not listed twice. */ + alias?: string; } -export const slashCommands: readonly SlashCommand[] = [ +/** Group, palette title and alias metadata for the substantial surfaces, in one place. */ +const META: Record> = { + '/appearance': {group: 'Appearance', title: 'Open Appearance'}, '/theme': {group: 'Appearance', title: 'Open Theme Studio'}, + '/theme-bridge': {group: 'Appearance', title: 'Open Theme Bridge'}, '/chroma': {group: 'Appearance', title: 'Open Chroma'}, + '/chrome': {group: 'Appearance', title: 'Open UI Chrome'}, '/cursor': {group: 'Appearance', title: 'Open Cursor & effects'}, + '/motion': {group: 'Appearance', title: 'Open Motion'}, '/glyphs': {group: 'Appearance', title: 'Configure Glyph Style'}, + '/strip': {group: 'Appearance', title: 'Configure Status Strip'}, '/status-strip': {alias: '/strip'}, + '/screensaver': {group: 'Appearance', title: 'Open Idle visuals'}, '/activity': {group: 'Appearance', title: 'Open Live activity colors'}, + '/prompt': {group: 'Composer & transcript', title: 'Open Prompt'}, '/layout': {group: 'Composer & transcript', title: 'Configure Composer Layout'}, + '/composer': {alias: '/layout'}, '/syntax': {group: 'Composer & transcript', title: 'Open Syntax highlighting'}, + '/transcript': {group: 'Composer & transcript', title: 'Open Transcript appearance'}, '/keyboard': {group: 'Composer & transcript', title: 'Open Keyboard'}, + '/providers': {group: 'Providers', title: 'Open Providers'}, '/picker': {group: 'Providers', title: 'Configure Picker Provider'}, '/pickers': {alias: '/picker'}, + '/suggestions': {group: 'Providers', title: 'Configure Suggestions Provider'}, '/navigation': {group: 'Providers', title: 'Configure Directory Navigation'}, + '/welcome': {group: 'Providers', title: 'Configure Welcome'}, '/history-provider': {group: 'Providers', title: 'Configure History Provider'}, + '/tools': {group: 'Tools & integration', title: 'Open Tools'}, '/configure': {group: 'Tools & integration', title: 'Open Tool Configuration'}, + '/tmux': {group: 'Tools & integration', title: 'Configure tmux'}, '/integrations': {group: 'Tools & integration', title: 'Check Integrations'}, + '/dotfiles': {group: 'Tools & integration', title: 'Import Dotfiles'}, +}; + +const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/effects', insertion: '/effects ', description: 'Preview sparkles, rain or confetti in owned chrome; /effects stop cancels'}, {name: '/copy', insertion: '/copy', description: 'Copy latest command output'}, {name: '/copy N', insertion: '/copy ', description: 'Copy Nth previous output'}, @@ -32,6 +59,15 @@ export const slashCommands: readonly SlashCommand[] = [ {name: '/status', insertion: '/status', description: 'Show NMSh status'}, {name: '/syntax', insertion: '/syntax', description: 'Configure syntax highlighting'}, {name: '/layout', insertion: '/layout', description: 'Preview and choose composer position and transcript presentation'}, + {name: '/composer', insertion: '/composer', description: 'Composer position and transcript presentation (same as /layout)'}, + {name: '/chrome', insertion: '/chrome', description: 'UI chrome: NMSh frames, rules, tabs, selection and accents (not Chroma)'}, + {name: '/glyphs', insertion: '/glyphs', description: 'Glyph style: compare Nerd Font and Safe / ASCII symbols and icons'}, + {name: '/strip', insertion: '/strip', description: 'Status strip: clock, battery, CPU, RAM and uptime, with a live preview'}, + {name: '/status-strip', insertion: '/status-strip', description: 'Same as /strip'}, + {name: '/configure', insertion: '/configure ', description: 'Tool Configuration: supported settings for tmux, Starship and other registered tools'}, + {name: '/tmux', insertion: '/tmux', description: 'Configure tmux: settings, keys, Status Studio, new panes start NMSh, theme'}, + {name: '/integrations', insertion: '/integrations', description: 'Integrations health: review and update every managed integration'}, + {name: '/dotfiles', insertion: '/dotfiles ', description: 'Import supported settings from a dotfiles repository (reviewed, nothing executed)'}, {name: '/transcript', insertion: '/transcript', description: 'Configure historical prompts and dividers'}, {name: '/keyboard', insertion: '/keyboard', description: 'Configure keyboard integration'}, {name: '/shell', insertion: '/shell', description: 'Managed backend switcher: NMSh stays open; install missing shells; D sets the default'}, @@ -54,6 +90,11 @@ export const slashCommands: readonly SlashCommand[] = [ {name: '/doctor', insertion: '/doctor', description: 'Health check: NMSh, shell, project, Git, tools, local model and host (local, read-only)'}, {name: '/llm', insertion: '/llm', description: 'Local Intelligence: the optional local model for Ask and Smart Folding (status, setup, stop, remove)'}, {name: '/providers', insertion: '/providers', description: 'What NMSh uses for prompt, welcome, suggestions, history and more; switch, install, detect'}, + {name: '/picker', insertion: '/picker', description: 'Picker provider (NMSh Native, fzf, Television) in /providers'}, + {name: '/pickers', insertion: '/pickers', description: 'Same as /picker'}, + {name: '/suggestions', insertion: '/suggestions', description: 'Ghost-text suggestions provider in /providers'}, + {name: '/navigation', insertion: '/navigation', description: 'Directory navigation provider (NMSh Native, zoxide) in /providers'}, + {name: '/welcome', insertion: '/welcome', description: 'Welcome provider (Vespyr, fastfetch, …) in /providers'}, {name: '/presets', insertion: '/presets', description: 'Create, inspect and launch named session presets'}, {name: '/sessions', insertion: '/sessions', description: 'Live NMSh sessions right now: switch to a detached one, kill one (nmsh --sessions outside)'}, {name: '/resume', insertion: '/resume', description: 'Browse archived NMSh transcripts'}, @@ -68,8 +109,12 @@ export const slashCommands: readonly SlashCommand[] = [ {name: '/palette', insertion: '/palette', description: 'Search NMSh actions (Ctrl+Shift+P / F1)'}, {name: '/dirs', insertion: '/dirs ', description: 'Find a directory; insert a visible cd command'}, {name: '/history', insertion: '/history ', description: 'Search history'}, + {name: '/history-provider', insertion: '/history-provider', description: 'History provider (NMSh Native, Atuin) in /providers; /history is history search'}, ]; +export const slashCommands: readonly SlashCommand[] = RAW_COMMANDS.map(command => ({...command, ...META[command.name]})); + + export type ParsedSlashCommand = | {kind: 'effects'; effect: 'sparkles' | 'rain' | 'confetti' | 'stop' | 'help'; placement: 'top' | 'bottom'} | {kind: 'copy'; index: number} @@ -88,6 +133,12 @@ export type ParsedSlashCommand = | {kind: 'transcript'} | {kind: 'syntax'} | {kind: 'layout'} + | {kind: 'chrome'} + | {kind: 'glyphs'} + | {kind: 'statusStrip'} + | {kind: 'configure'; tool?: string} + | {kind: 'integrations'} + | {kind: 'dotfiles'; source?: string} | {kind: 'keyboard'} /** Leave NMSh for an ordinary shell; no shell means the configured default (/exit). */ | {kind: 'handoff'; shell?: 'zsh' | 'fish' | 'bash'} @@ -110,7 +161,7 @@ export type ParsedSlashCommand = | {kind: 'ask'; request: string} /** Agent sessions: /ai opens the list; /ai starts one in the background. */ | {kind: 'ai'; target?: string} - | {kind: 'providers'} + | {kind: 'providers'; family?: 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'} | {kind: 'llm'} | {kind: 'doctor'} | {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start'; arguments: string} @@ -144,7 +195,17 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (/^\/status\s*$/u.test(input)) return {kind: 'settings', view: 'status'}; if (/^\/transcript\s*$/u.test(input)) return {kind: 'transcript'}; if (/^\/syntax\s*$/u.test(input)) return {kind: 'syntax'}; - if (/^\/layout\s*$/u.test(input)) return {kind: 'layout'}; + // Aliases normalize to one action kind: /composer is /layout, /glyph(s) one panel, /strip and /status-strip one panel. + if (/^\/(?:layout|composer)\s*$/u.test(input)) return {kind: 'layout'}; + if (/^\/chrome\s*$/u.test(input)) return {kind: 'chrome'}; + if (/^\/glyphs?\s*$/u.test(input)) return {kind: 'glyphs'}; + if (/^\/(?:strip|status-strip)\s*$/u.test(input)) return {kind: 'statusStrip'}; + if (/^\/tmux\s*$/u.test(input)) return {kind: 'configure', tool: 'tmux'}; + const configure = /^\/configure(?:\s+([A-Za-z0-9_.+-]{1,40}))?\s*$/u.exec(input); + if (configure) return configure[1] ? {kind: 'configure', tool: configure[1].toLowerCase()} : {kind: 'configure'}; + if (/^\/integrations\s*$/u.test(input)) return {kind: 'integrations'}; + const dotfiles = /^\/dotfiles(?:\s+(.{1,1024}))?\s*$/u.exec(input); + if (dotfiles) return dotfiles[1]?.trim() ? {kind: 'dotfiles', source: dotfiles[1].trim()} : {kind: 'dotfiles'}; if (/^\/keyboard\s*$/u.test(input)) return {kind: 'keyboard'}; const handoff = /^\/(zsh|fish|bash|exit)\s*$/u.exec(input); if (handoff) return handoff[1] === 'exit' ? {kind: 'handoff'} : {kind: 'handoff', shell: handoff[1] as 'zsh' | 'fish' | 'bash'}; @@ -178,6 +239,10 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined const ask = /^\/ask(?:\s+([\s\S]*))?$/u.exec(input); if (ask) return {kind: 'ask', request: (ask[1] ?? '').trim()}; if (/^\/providers\s*$/u.test(input)) return {kind: 'providers'}; + // Family shortcuts are aliases of one action: /providers focused on that family. + const family = /^\/providers\s+(prompt|welcome|suggestions|history|picker|pickers|navigation)\s*$/u.exec(input)?.[1] + ?? {'/picker': 'picker', '/pickers': 'picker', '/suggestions': 'suggestions', '/navigation': 'navigation', '/welcome': 'welcome', '/history-provider': 'history'}[input.trim()]; + if (family) return {kind: 'providers', family: (family === 'pickers' ? 'picker' : family) as 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'}; if (/^\/(?:llm|localllm)\s*$/u.test(input)) return {kind: 'llm'}; if (/^\/doctor\s*$/u.test(input)) return {kind: 'doctor'}; const watch = /^\/watch(?:\s+(.*))?$/u.exec(input); diff --git a/src/dotfiles/DotfilesPanel.ts b/src/dotfiles/DotfilesPanel.ts new file mode 100644 index 00000000..8e445de5 --- /dev/null +++ b/src/dotfiles/DotfilesPanel.ts @@ -0,0 +1,140 @@ +import type {Key} from '../terminal/keys.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {renderControls} from '../ui/controls.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {editText} from '../ui/formControls.js'; +import {padCells, truncateAnsi} from '../util/text.js'; +import {SOURCE_LABELS, type ScanResult} from './scan.js'; +import type {DotfilesItem, ItemMode} from './plan.js'; + +/** + * /dotfiles: source → (remote: confirm clone) → what NMSh understands, with + * per-tool choices and per-field current-vs-dotfiles choices inline → one + * combined review (default No) → per-item results. The panel holds the + * draft choices only; scanning, cloning and applying are app actions. + */ +export interface DotfilesState { + step: 'source' | 'clone' | 'items' | 'review' | 'result'; + source: string; + scan?: ScanResult; + items: DotfilesItem[]; + selected: number; + expanded?: number; + /** Selected field row inside the expanded item. */ + field: number; + review?: {lines: string[]; yes: boolean}; + clone?: {url: string; target: string; yes: boolean}; + results: string[]; + message?: string; +} + +export type DotfilesAction = {kind: 'close'} | {kind: 'scan'; source: string} | {kind: 'clone'} | {kind: 'review'} | {kind: 'apply'}; + +export function createDotfilesPanel(source = ''): DotfilesState { + return {step: 'source', source, items: [], selected: 0, field: 0, results: []}; +} + +const MODE_LABELS: Record = {import: 'Import supported settings', copy: 'Copy exact file', skip: 'Skip / keep current'}; + +export function dotfilesKey(state: DotfilesState, key: Key): DotfilesAction | undefined { + state.message = undefined; + if (state.step === 'source') { + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (key.kind === 'enter') return state.source.trim() ? {kind: 'scan', source: state.source.trim()} : (state.message = 'Type a local path such as ~/dotfiles, or a Git URL.', undefined); + const next = editText(state.source, key); + if (next !== undefined) state.source = next.replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').slice(0, 1024); + return undefined; + } + const choose = (target: {yes: boolean}): 'yes' | 'no' | undefined => { + if (key.kind === 'left' || key.kind === 'right') { target.yes = !target.yes; return undefined; } + if (key.kind === 'enter') return target.yes ? 'yes' : 'no'; + if (key.kind === 'escape' || key.kind === 'interrupt') return 'no'; + return undefined; + }; + if (state.step === 'clone' && state.clone) { + const answer = choose(state.clone); + if (answer === 'yes') return {kind: 'clone'}; + if (answer === 'no') { state.step = 'source'; state.clone = undefined; state.message = 'Nothing was downloaded.'; } + return undefined; + } + if (state.step === 'review' && state.review) { + const answer = choose(state.review); + if (answer === 'yes') return {kind: 'apply'}; + if (answer === 'no') { state.step = 'items'; state.review = undefined; state.message = 'Nothing was changed.'; } + return undefined; + } + if (state.step === 'result') return key.kind === 'escape' || key.kind === 'interrupt' || key.kind === 'enter' ? {kind: 'close'} : undefined; + // items + const item = state.items[state.selected]; + if (state.expanded !== undefined) { + const fields = state.items[state.expanded]?.fields ?? []; + if (key.kind === 'escape' || key.kind === 'interrupt') { state.expanded = undefined; return undefined; } + if (key.kind === 'up' || key.kind === 'down') { state.field = (state.field + (key.kind === 'up' ? -1 : 1) + fields.length) % Math.max(1, fields.length); return undefined; } + const field = fields[state.field]; + if (field && (key.kind === 'left' || key.kind === 'right' || key.kind === 'enter' || (key.kind === 'text' && key.value === ' '))) field.use = !field.use; + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (key.kind === 'up' || key.kind === 'down') { state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + state.items.length + 1) % (state.items.length + 1); return undefined; } + if (state.selected === state.items.length) return key.kind === 'enter' ? {kind: 'review'} : undefined; + if (!item) return undefined; + if (key.kind === 'left' || key.kind === 'right') { + const index = item.modes.indexOf(item.mode); + item.mode = item.modes[(index + (key.kind === 'left' ? -1 : 1) + item.modes.length) % item.modes.length]!; + return undefined; + } + if (key.kind === 'enter' && item.fields?.length) { state.expanded = state.selected; state.field = 0; } + return undefined; +} + +export function renderDotfilesPanel(state: DotfilesState, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const warning = foreground(UI_COLORS.failure); + const reset = '\u001B[0m'; + const yesNo = (yes: boolean) => yes ? `${subtle}No${reset} ${accent}‹ Yes ›${reset}` : `${accent}‹ No ›${reset} ${subtle}Yes${reset}`; + const lines: string[] = [` ${primary}Dotfiles import${reset} ${subtle}supported settings only · nothing in the repository is run · the repository is not changed${reset}`, '']; + let controls: Array<[string, string]> = [['Esc', 'close']]; + if (state.step === 'source') { + lines.push(` ${secondary}Source${reset} ${primary}${state.source}${accent}_${reset}`, '', + ` ${subtle}A local directory or Git checkout (plain, GNU Stow packages or a chezmoi source), or a Git URL (cloned only after you confirm).${reset}`); + controls = [['Enter', 'scan'], ['Esc', 'close']]; + } else if (state.step === 'clone' && state.clone) { + lines.push(` ${primary}Clone ${state.clone.url}?${reset}`, ` ${subtle}into ${state.clone.target} · depth 1 · no submodules · Git hooks disabled · nothing in it is run${reset}`, '', + ` ${primary}Download?${reset} ${yesNo(state.clone.yes)}`); + controls = [['←→', 'No / Yes'], ['Enter', 'confirm']]; + } else if (state.step === 'review' && state.review) { + lines.push(` ${primary}Dotfiles import plan${reset}`, '', ...state.review.lines.map(line => ` ${line.startsWith(' +') ? accent : line.startsWith(' ~') ? primary : subtle}${line}${reset}`), '', + ` ${primary}Apply?${reset} ${yesNo(state.review.yes)}`); + controls = [['←→', 'No / Yes'], ['Enter', 'confirm'], ['Esc', 'back']]; + } else if (state.step === 'result') { + lines.push(` ${primary}Done${reset}`, '', ...state.results.map(line => ` ${secondary}• ${line}${reset}`), '', ` ${subtle}Edit imported tmux settings any time in /tmux.${reset}`); + controls = [['Enter', 'close']]; + } else if (state.scan) { + lines.push(` ${secondary}Source${reset} ${state.scan.root}`, ` ${secondary}Type${reset} ${SOURCE_LABELS[state.scan.type]}${state.scan.packages.length ? ` · packages: ${state.scan.packages.join(', ')}` : ''}`); + if (state.scan.scripts.length) lines.push(` ${subtle}${state.scan.scripts.length} script${state.scan.scripts.length === 1 ? '' : 's'} found and never run (for example ${state.scan.scripts[0]})${reset}`); + lines.push('', ` ${subtle}Found${reset}`); + if (!state.items.length) lines.push(` ${subtle}No configuration NMSh understands was found.${reset}`); + state.items.forEach((item, index) => { + const selected = index === state.selected && state.expanded === undefined; + const glyph = item.kind === 'fields' || item.kind === 'copy' ? `${accent}✓${reset}` : item.kind === 'inspect' ? `${subtle}○${reset}` : `${warning}!${reset}`; + lines.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${glyph} ${selected ? primary : secondary}${padCells(item.file.tool.label, 10)}${reset}${padCells(item.file.repoPath, 32)}${selected && item.modes.length > 1 ? `${accent}‹ ${MODE_LABELS[item.mode]} ›${reset}` : MODE_LABELS[item.mode]}`); + lines.push(` ${subtle}${item.note}${item.fields?.length ? ' · Enter reviews each value' : ''}${reset}`); + if (state.expanded === index && item.fields) { + lines.push(` ${subtle}${padCells('Setting', 24)}${padCells('Dotfiles', 16)}${padCells('Current', 16)}Use${reset}`); + item.fields.forEach((field, fieldIndex) => { + const on = fieldIndex === state.field; + lines.push(` ${on ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${padCells(field.label, 24)}${padCells(field.repo, 16)}${padCells(field.current, 16)}${on ? accent : field.conflict ? warning : subtle}${field.use ? 'Use dotfiles value' : 'Keep current value'}${reset}`); + }); + } + }); + const reviewSelected = state.selected === state.items.length && state.expanded === undefined; + lines.push('', `${reviewSelected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${reviewSelected ? accent : subtle}Review all changes ›${reset}`); + controls = state.expanded !== undefined ? [['↑↓', 'value'], ['←→', 'dotfiles / current'], ['Esc', 'back']] : [['↑↓', 'select'], ['←→', 'choose'], ['Enter', 'details / review'], ['Esc', 'close']]; + } + if (state.message) lines.push('', ` ${secondary}${state.message}${reset}`); + return framePanel([...lines.slice(0, Math.max(3, height - 3)), '', renderControls(controls)].map(line => truncateAnsi(line, columns)), columns).slice(0, Math.max(1, height)); +} diff --git a/src/dotfiles/plan.ts b/src/dotfiles/plan.ts new file mode 100644 index 00000000..18ea7807 --- /dev/null +++ b/src/dotfiles/plan.ts @@ -0,0 +1,174 @@ +import {copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {dirname} from 'node:path'; +import {parse as parseToml} from 'smol-toml'; +import {sha256} from '../ask/fileEdit.js'; +import { + applyTmuxChange, describeTmuxChange, loadTmuxModel, optionProvenance, parseTmuxConfig, readUserTmuxConfig, saveTmuxModel, TMUX_ACTIONS, TMUX_OPTIONS, + type TmuxChange, +} from '../tools/config/tmux.js'; +import {writeTmuxManaged} from '../tools/config/tmuxManaged.js'; +import {destinationFor, readFound, type FoundFile, type ScanResult} from './scan.js'; + +/** + * Turning a scan into reviewed choices. Structured or command-style config + * goes through its adapter at field level (current vs dotfiles, never + * silently the repository's value on a conflict); a structured file may be + * copied exactly, with a backup, after review; executable config is inspect + * only. The repository itself is never modified. + */ + +export type ItemKind = 'fields' | 'copy' | 'inspect' | 'templated' | 'unreadable'; +export type ItemMode = 'import' | 'copy' | 'skip'; + +export interface FieldChoice {change: TmuxChange; label: string; repo: string; current: string; source: string; use: boolean; conflict: boolean} + +export interface DotfilesItem { + file: FoundFile; + kind: ItemKind; + mode: ItemMode; + modes: readonly ItemMode[]; + note: string; + fields?: FieldChoice[]; + destination?: string; + /** sha256 of the destination when reviewed ('absent' when missing): copying refuses if it changed since. */ + destinationSha?: string; + content?: string; + diff?: string[]; +} + +function shortDiff(before: string | undefined, after: string): string[] { + if (before === undefined) return [`+ new file (${after.split('\n').length} lines)`]; + const a = before.split('\n'); + const b = after.split('\n'); + const out: string[] = []; + for (let index = 0; index < Math.max(a.length, b.length) && out.length < 12; index++) { + if (a[index] === b[index]) continue; + if (a[index] !== undefined) out.push(`- ${a[index]}`); + if (b[index] !== undefined) out.push(`+ ${b[index]}`); + } + return out.length ? out : [' identical to the current file']; +} + +function tmuxFields(text: string, env: NodeJS.ProcessEnv): FieldChoice[] { + const repo = parseTmuxConfig(text); + const model = loadTmuxModel(env); + const mine = readUserTmuxConfig(env); + const user = mine ? parseTmuxConfig(mine.text) : undefined; + const fields: FieldChoice[] = []; + for (const [id, value] of Object.entries(repo.options)) { + const option = TMUX_OPTIONS.find(item => item.id === id)!; + const current = optionProvenance(option, model, user); + if (current.effective === value) continue; + const conflict = current.source !== 'tmux default'; + fields.push({change: {kind: 'option', id, value}, label: option.label, repo: value, current: current.effective, source: current.source, use: !conflict, conflict}); + } + if (repo.prefix) { + const current = model.prefix ?? user?.prefix ?? 'C-b'; + if (current !== repo.prefix) { + const conflict = Boolean(model.prefix ?? user?.prefix); + fields.push({change: {kind: 'prefix', key: repo.prefix}, label: 'Prefix', repo: repo.prefix, current, source: model.prefix ? 'NMSh managed file' : user?.prefix ? 'your tmux config' : 'tmux default', use: !conflict, conflict}); + } + } + for (const binding of repo.bindings) { + if (binding.action === 'send-prefix') continue; + const existing = [...model.bindings, ...(user?.bindings ?? [])].find(item => item.key === binding.key && item.table === binding.table); + if (existing?.action === binding.action) continue; + fields.push({change: {kind: 'binding', binding}, label: `Key ${binding.key}`, repo: TMUX_ACTIONS[binding.action].label, current: existing ? TMUX_ACTIONS[existing.action].label : '—', + source: existing ? 'existing binding' : 'unbound', use: !existing, conflict: Boolean(existing)}); + } + return fields; +} + +const structured = (tool: string) => tool === 'starship' || tool === 'helix' || tool === 'bat'; + +export function buildPlan(scan: ScanResult, env: NodeJS.ProcessEnv = process.env): DotfilesItem[] { + const items: DotfilesItem[] = []; + const seen = new Set(); + for (const file of scan.found) { + const base = {file}; + if (file.templated) { items.push({...base, kind: 'templated', mode: 'skip', modes: ['skip'], note: 'chezmoi template: not literal config; review it with chezmoi (NMSh does not render templates)'}); continue; } + if (file.tool.configClass === 'executable') { items.push({...base, kind: 'inspect', mode: 'skip', modes: ['skip'], note: 'Executable config: inspect only; never sourced, copied or rewritten'}); continue; } + if (seen.has(file.tool.id)) { items.push({...base, kind: 'inspect', mode: 'skip', modes: ['skip'], note: `Another ${file.tool.label} file was already selected from this repository`}); continue; } + const content = readFound(scan, file); + if (typeof content !== 'string') { items.push({...base, kind: 'unreadable', mode: 'skip', modes: ['skip'], note: content.error}); continue; } + seen.add(file.tool.id); + if (file.tool.id === 'tmux') { + const fields = tmuxFields(content, env); + const parsed = parseTmuxConfig(content); + items.push({...base, kind: 'fields', mode: fields.length ? 'import' : 'skip', modes: fields.length ? ['import', 'skip'] : ['skip'], fields, content, + note: `${fields.length} supported value${fields.length === 1 ? '' : 's'} differ · ${parsed.unsupported.length} other lines stay out · ${parsed.ignored.length} dynamic lines never run`}); + continue; + } + if (structured(file.tool.id)) { + if (file.target.endsWith('.toml')) { try { parseToml(content); } catch { items.push({...base, kind: 'unreadable', mode: 'skip', modes: ['skip'], note: 'Not valid TOML; skipped'}); continue; } } + const destination = destinationFor(file.tool, env); + let before: string | undefined; + try { before = readFileSync(destination, 'utf8'); } catch { before = undefined; } + items.push({...base, kind: 'copy', mode: 'skip', modes: ['skip', 'copy'], content, destination, destinationSha: before === undefined ? 'absent' : sha256(before), diff: shortDiff(before, content), + note: `Copy exact file to ${destination}${before === undefined ? '' : ' (the current file is backed up first)'}`}); + continue; + } + items.push({...base, kind: 'inspect', mode: 'skip', modes: ['skip'], note: 'No managed import for this tool'}); + } + return items; +} + +/** The combined review text: everything that would change, and what is skipped. */ +export function reviewLines(items: readonly DotfilesItem[], include: readonly string[]): string[] { + const lines: string[] = []; + let fragments = 0; + let copies = 0; + for (const item of items) { + lines.push(`${item.file.tool.label} ${item.file.repoPath}`); + if (item.mode === 'import' && item.fields) { + const used = item.fields.filter(field => field.use); + if (used.length) fragments = 1; + for (const field of used) lines.push(` + ${describeTmuxChange(field.change)}`); + if (!used.length) lines.push(' (no values selected)'); + } else if (item.mode === 'copy') { copies++; lines.push(` ~ copy to ${item.destination}`, ...item.diff!.slice(0, 6).map(line => ` ${line}`)); } + else lines.push(` · ${item.kind === 'inspect' || item.kind === 'templated' ? item.note : 'skipped'}`); + } + if (include.length) lines.push('', 'tmux.conf gains one include of the NMSh-managed tmux file:', ...include.map(line => ` ${line}`)); + lines.push('', `Files: ${fragments} managed fragment${fragments === 1 ? '' : 's'} updated · ${include.length ? 1 : 0} reviewed include · ${copies} file${copies === 1 ? '' : 's'} copied (backed up first) · 0 scripts run · the repository is not changed`); + return lines; +} + +/** Backup then atomic replace, refusing if the destination changed since review or is a symlink. */ +function copyExact(item: DotfilesItem, now: Date): string { + const destination = item.destination!; + if (existsSync(destination)) { + if (lstatSync(destination).isSymbolicLink()) return `${destination} is a symlink (to ${realpathSync(destination)}); not replaced.`; + if (sha256(readFileSync(destination, 'utf8')) !== item.destinationSha) return `${destination} changed since review; nothing was written.`; + copyFileSync(destination, `${destination}.nmsh-backup-${now.toISOString().replace(/[:.]/gu, '-')}`); + } else if (item.destinationSha !== 'absent') return `${destination} disappeared since review; nothing was written.`; + mkdirSync(dirname(destination), {recursive: true}); + const staged = `${destination}.nmsh-${process.pid}.tmp`; + writeFileSync(staged, item.content!, {encoding: 'utf8', mode: 0o644}); + renameSync(staged, destination); + return `copied to ${destination}`; +} + +/** Applies the reviewed choices through the tool adapters. Per-item results; one failure does not stop the others. */ +export function applyPlan(items: readonly DotfilesItem[], env: NodeJS.ProcessEnv = process.env, now = new Date()): string[] { + const results: string[] = []; + for (const item of items) { + try { + if (item.mode === 'import' && item.fields) { + let model = loadTmuxModel(env); + for (const field of item.fields.filter(choice => choice.use)) { + const next = applyTmuxChange(model, field.change); + if ('error' in next) results.push(`${item.file.tool.label}: ${next.error}`); else model = next; + } + saveTmuxModel(model, env); + const written = writeTmuxManaged(model, env); + results.push(`${item.file.tool.label}: ${written.ok ? 'supported values saved to the NMSh-managed tmux file' : written.error}`); + } else if (item.mode === 'copy') results.push(`${item.file.tool.label}: ${copyExact(item, now)}`); + } catch (error) { + results.push(`${item.file.tool.label}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return results; +} + +export const homeOf = (env: NodeJS.ProcessEnv = process.env) => env.HOME || homedir(); diff --git a/src/dotfiles/scan.ts b/src/dotfiles/scan.ts new file mode 100644 index 00000000..7d1d6d98 --- /dev/null +++ b/src/dotfiles/scan.ts @@ -0,0 +1,138 @@ +import {existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {isAbsolute, join, relative, resolve} from 'node:path'; +import {TOOL_CONFIG_REGISTRY, configLocations, type ToolConfigEntry} from '../tools/config/registry.js'; + +/** + * Dotfiles discovery: a repository is untrusted data. The scan only lists + * and reads bounded regular files; it never runs install scripts, Make + * targets, chezmoi scripts or templates, Git hooks, Stow or any shell, Lua or + * Vimscript. Recognized files are routed to the first-party tool registry. + */ + +export type SourceType = 'plain' | 'git' | 'stow' | 'chezmoi'; +export const SOURCE_LABELS: Record = {plain: 'Plain directory', git: 'Git checkout', stow: 'GNU Stow-style repository', chezmoi: 'chezmoi source state'}; + +export interface FoundFile { + /** Path inside the repository. */ + repoPath: string; + /** The home-relative path it represents (Stow package and chezmoi prefixes removed). */ + target: string; + tool: ToolConfigEntry; + /** Stow package, when the source is Stow-style. */ + package?: string; + /** chezmoi template: not literal config; reviewed with chezmoi, never rendered here. */ + templated?: boolean; + symlink?: boolean; + size: number; +} + +export interface ScanResult { + root: string; + type: SourceType; + packages: string[]; + found: FoundFile[]; + /** Scripts and generators that exist but are never run. */ + scripts: string[]; + truncated: boolean; +} + +const SKIP = new Set(['.git', 'node_modules', '.cache', '.venv', 'vendor', '__pycache__']); +const MAX_ENTRIES = 5000; +const MAX_DEPTH = 7; +export const MAX_FILE = 512 * 1024; +const SCRIPT = /(?:^|\/)(?:install|bootstrap|setup)(?:\.[a-z]+)?$|(?:^|\/)Makefile$|(?:^|\/)run_(?:once_|onchange_)?(?:before_|after_)?[^/]+$|\.(?:sh|bash|zsh|py|rb|pl|js|mjs)$/u; + +export function expandSource(input: string, cwd: string, env: NodeJS.ProcessEnv = process.env): string { + const text = input.trim(); + const home = env.HOME || homedir(); + if (text === '~' || text.startsWith('~/')) return join(home, text.slice(1)); + return isAbsolute(text) ? text : resolve(cwd, text); +} + +export const isRemoteSource = (input: string): boolean => /^(?:https:\/\/|git@|ssh:\/\/)[^\s'"]+$/u.test(input.trim()); + +/** chezmoi source names → target names (dot_, private_, executable_, readonly_ and .tmpl). */ +export function chezmoiTarget(path: string): {target: string; templated: boolean} { + let templated = false; + const segments = path.split('/').map(segment => { + let name = segment; + if (name.endsWith('.tmpl')) { templated = true; name = name.slice(0, -5); } + for (;;) { + const next = name.replace(/^(?:private_|executable_|readonly_|empty_|exact_|create_|modify_|encrypted_|symlink_)/u, ''); + if (next === name) break; + name = next; + } + return name.startsWith('dot_') ? `.${name.slice(4)}` : name; + }); + return {target: segments.join('/'), templated}; +} + +function detectType(root: string, top: string[]): {type: SourceType; packages: string[]} { + if (top.includes('.chezmoiroot') || top.some(name => /^\.chezmoi/u.test(name)) || top.some(name => /^dot_/u.test(name))) return {type: 'chezmoi', packages: []}; + // Stow: top-level package directories whose contents are home-relative (dotfiles or .config). + const packages = top.filter(name => !name.startsWith('.') && !SKIP.has(name)).filter(name => { + try { + const full = join(root, name); + return lstatSync(full).isDirectory() && readdirSync(full).some(entry => entry.startsWith('.')); + } catch { return false; } + }); + if (packages.length >= 1 && packages.length >= top.filter(name => !name.startsWith('.')).length / 2) return {type: 'stow', packages}; + return {type: top.includes('.git') ? 'git' : 'plain', packages: []}; +} + +/** Bounded listing and recognition. Nothing found is executed or followed outside the root. */ +export function scanDotfiles(root: string): ScanResult | {error: string} { + let real: string; + try { real = realpathSync(root); } catch { return {error: `${root} does not exist.`}; } + if (!statSync(real).isDirectory()) return {error: `${root} is not a directory.`}; + const top = readdirSync(real); + const {type, packages} = detectType(real, top); + const found: FoundFile[] = []; + const scripts: string[] = []; + let entries = 0; + let truncated = false; + const walk = (directory: string, depth: number) => { + if (depth > MAX_DEPTH || truncated) return; + let names: string[]; + try { names = readdirSync(directory); } catch { return; } + for (const name of names) { + if (++entries > MAX_ENTRIES) { truncated = true; return; } + if (SKIP.has(name)) continue; + const full = join(directory, name); + const repoPath = relative(real, full); + let info; + try { info = lstatSync(full); } catch { continue; } + if (info.isDirectory()) { walk(full, depth + 1); continue; } + const symlink = info.isSymbolicLink(); + if (!info.isFile() && !symlink) continue; + if (SCRIPT.test(repoPath)) { scripts.push(repoPath); } + let target = repoPath; + let templated = false; + let pkg: string | undefined; + if (type === 'stow') { const [first, ...rest] = repoPath.split('/'); if (packages.includes(first!)) { pkg = first; target = rest.join('/'); } } + if (type === 'chezmoi') ({target, templated} = chezmoiTarget(repoPath)); + const tool = TOOL_CONFIG_REGISTRY.find(entry => entry.dotfiles.test(target) || entry.dotfiles.test(repoPath)); + if (!tool) continue; + found.push({repoPath, target, tool, size: symlink ? 0 : info.size, ...(pkg ? {package: pkg} : {}), ...(templated ? {templated} : {}), ...(symlink ? {symlink} : {})}); + } + }; + walk(real, 0); + return {root: real, type, packages, found, scripts: scripts.slice(0, 50), truncated}; +} + +/** Reads one found file as text, bounded; symlinks are not dereferenced (they are shown, not trusted). */ +export function readFound(scan: ScanResult, file: FoundFile): string | {error: string} { + if (file.symlink) return {error: 'Symlink inside the repository; not followed. Review it yourself.'}; + if (file.size > MAX_FILE) return {error: 'Larger than 512 KiB; skipped.'}; + try { + const text = readFileSync(join(scan.root, file.repoPath), 'utf8'); + return text.includes('\u0000') ? {error: 'Binary file; skipped.'} : text; + } catch { return {error: 'Could not be read.'}; } +} + +/** The destination this machine uses for a tool (its first existing config location, else the first). */ +export function destinationFor(tool: ToolConfigEntry, env: NodeJS.ProcessEnv = process.env): string { + const locations = configLocations(tool, env); + return locations.find(path => existsSync(path)) ?? locations[0]!; +} diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index 5b219126..7a69c3eb 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -3,11 +3,20 @@ 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'); + // Substantial surfaces by area, then everything else; aliases sit beside their command, not in a second row. + const aliasesOf = (name: string) => slashCommands.filter(item => item.alias === name).map(item => `\`${item.name}\``); + const row = (item: typeof slashCommands[number]) => `| \`${item.name}\`${aliasesOf(item.name).length ? ` (also ${aliasesOf(item.name).join(', ')})` : ''} | ${item.description} |`; + const groups = (['Appearance', 'Composer & transcript', 'Providers', 'Tools & integration'] as const).map(group => + `### ${group}\n\n| Command | What it does |\n| --- | --- |\n${slashCommands.filter(item => item.group === group && !item.alias).map(row).join('\n')}`).join('\n\n'); + const commands = slashCommands.filter(item => !item.group && !item.alias).map(row).join('\n'); return authoredMarkdown(`# NMSh help ## Commands +${groups} + +### More commands + | Command | What it does | | --- | --- | ${commands} diff --git a/src/pickers/Picker.ts b/src/pickers/Picker.ts index bef938cd..ac433bd5 100644 --- a/src/pickers/Picker.ts +++ b/src/pickers/Picker.ts @@ -34,13 +34,18 @@ export function pickerSelection(output: string, candidates: readonly PickerCandi return {kind: 'selected', candidate}; } +/** NMSh-owned fzf arguments; `layout` follows the composer side. */ +export function fzfPickerArgs(layout: 'default' | 'reverse'): string[] { + return ['--no-multi', '--no-sort', '--delimiter=\t', '--with-nth=2..', `--layout=${layout}`, '--no-mouse', '--pointer=>', '--marker=*']; +} + /** Native surfaces delegate their existing editor UI through the same boundary. */ export async function openPicker(provider: PickerProviderId, candidates: readonly PickerCandidate[], native: () => void, - handoff: PickerHandoff, env: NodeJS.ProcessEnv = process.env, themeArgs: readonly string[] = []): Promise { + handoff: PickerHandoff, env: NodeJS.ProcessEnv = process.env, themeArgs: readonly string[] = [], layout: 'default' | 'reverse' = 'default'): Promise { if (provider === 'native') { native(); return; } const binary = resolveCommand(provider === 'fzf' ? 'fzf' : 'tv', env.PATH ?? '', []); if (!binary) { native(); return {kind: 'fallback', reason: `${provider} is not installed; using Native`}; } - const result = await handoff(signal => runPicker(binary, provider, candidates, signal, env, 300_000, themeArgs)); + const result = await handoff(signal => runPicker(binary, provider, candidates, signal, env, 300_000, themeArgs, layout)); if (result.kind === 'fallback') native(); return result; } @@ -48,7 +53,7 @@ export async function openPicker(provider: PickerProviderId, candidates: readonl /** Interactive, bounded, host-TTY process; only call while the host has handed off ownership. */ export async function runPicker(binary: string, provider: Exclude, candidates: readonly PickerCandidate[], signal: AbortSignal, env: NodeJS.ProcessEnv = process.env, - timeoutMs = 300_000, themeArgs: readonly string[] = []): Promise { + timeoutMs = 300_000, themeArgs: readonly string[] = [], layout: 'default' | 'reverse' = 'default'): Promise { if (signal.aborted) return {kind: 'cancelled'}; const input = pickerInput(candidates); if (candidates.length > 100_000 || Buffer.byteLength(input) > 16 * 1024 * 1024) @@ -58,7 +63,8 @@ export async function runPicker(binary: string, provider: Exclude', '--marker=*']; + // The query follows NMSh's composer: bottom (fzf's own default) or top (reverse). Never the user's standalone fzf config. + if (provider === 'fzf') args = fzfPickerArgs(layout); else { // No user cable, hooks, preview command or persisted history is loaded. await writeFile(join(directory, 'config.toml'), 'history_size = 0\n', {mode: 0o600}); diff --git a/src/providers/ProvidersOverview.ts b/src/providers/ProvidersOverview.ts index 5ae30ee1..70656ea1 100644 --- a/src/providers/ProvidersOverview.ts +++ b/src/providers/ProvidersOverview.ts @@ -4,19 +4,26 @@ import {renderControls} from '../ui/controls.js'; import {GLYPHS} from '../ui/glyphs.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {COLUMN_GUTTER, labelColumnWidth, padCells, truncateAnsi, truncateText} from '../util/text.js'; -import {familyFacts, PROVIDER_FAMILIES, type SwitchableFamily} from './families.js'; -import type {ProviderStatus} from './providers.js'; +import {familyFacts, providerFamily, PROVIDER_FAMILIES, type SwitchableFamily} from './families.js'; +import {providerInstall, type ProviderStatus} from './providers.js'; /** - * /providers: one overview of everything NMSh uses through a provider, what - * else is available, installed or missing, and the way into each family's - * existing panel. It reads the one configuration and runtime detection; it - * owns no provider state of its own. + * /providers: the one provider control surface. Every family is a row; + * Enter expands its providers inline in the same panel (never a second + * screen), Enter on a usable provider selects it at once, and Enter on a + * missing installable one opens an inline install confirmation (default No). + * It reads the one configuration and runtime detection; it owns no provider + * state of its own. */ export type OverviewRowId = SwitchableFamily | 'understanding' | 'shell'; export interface ProvidersOverviewState { selected: number; + expanded?: OverviewRowId; + /** Inline install confirmation under a provider row. */ + confirm?: {family: SwitchableFamily; id: string; yes: boolean}; + /** An install running inline. */ + installing?: {family: SwitchableFamily; id: string; line: string}; /** Detection still running. */ detecting: boolean; message?: string; @@ -32,27 +39,48 @@ export interface OverviewFacts { shell: {current: string; defaultShell: string}; } -export type OverviewAction = {kind: 'close'} | {kind: 'open'; row: OverviewRowId} | {kind: 'detect'}; +export type OverviewAction = + | {kind: 'close'} | {kind: 'detect'} + | {kind: 'open'; row: OverviewRowId} + | {kind: 'select'; family: SwitchableFamily; id: string} + | {kind: 'install'; family: SwitchableFamily; id: string}; export const OVERVIEW_ROWS: readonly OverviewRowId[] = [...PROVIDER_FAMILIES.map(family => family.family), 'understanding', 'shell']; -export function createProvidersOverview(): ProvidersOverviewState { - return {selected: 0, detecting: true}; +type Item = {kind: 'family'; row: OverviewRowId} | {kind: 'provider'; family: SwitchableFamily; id: string} | {kind: 'configure'; family: 'prompt'}; + +export function createProvidersOverview(focus?: SwitchableFamily): ProvidersOverviewState { + const state: ProvidersOverviewState = {selected: 0, detecting: true}; + if (focus) { state.expanded = focus; state.selected = OVERVIEW_ROWS.indexOf(focus); } + return state; } -export function providersOverviewKey(state: ProvidersOverviewState, key: Key): OverviewAction | undefined { - if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; - if (key.kind === 'up' || key.kind === 'down') { - state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + OVERVIEW_ROWS.length) % OVERVIEW_ROWS.length; - state.message = undefined; - return undefined; +export function overviewItems(state: ProvidersOverviewState): Item[] { + const items: Item[] = []; + for (const row of OVERVIEW_ROWS) { + items.push({kind: 'family', row}); + const definition = providerFamily(row); + if (state.expanded !== row || !definition) continue; + for (const provider of definition.providers) items.push({kind: 'provider', family: definition.family, id: provider.id}); + if (row === 'prompt') items.push({kind: 'configure', family: 'prompt'}); } - if (key.kind === 'enter') return {kind: 'open', row: OVERVIEW_ROWS[state.selected]!}; - if (key.kind === 'text' && key.value.toLowerCase() === 'r') return {kind: 'detect'}; - return undefined; + return items; } -/** One provider's factual state in words, never by color alone. */ +/** One provider's state in words and a glyph, never color alone. */ +export function providerStatusLabel(row: ReturnType['rows'][number], fallbackLabel: string): string { + const kind = row.descriptor.kind; + if (row.active) return '● Active'; + if (row.preferred) return `✓ Selected · fallback → ${fallbackLabel}`; + if (kind === 'native') return 'Built in'; + if (kind === 'none') return 'Off'; + if (!row.status) return 'Checking…'; + if (row.status.state === 'installed') return `Available${row.status.version ? ` · ${row.status.version}` : ''}`; + if (row.status.state === 'missing') return providerInstall(row.descriptor) ? 'Missing · Enter to install' : 'Missing'; + return `Unavailable${row.status.detail ? ` · ${row.status.detail}` : ''}`; +} + +/** Kept for callers that only need the install provenance wording. */ export function providerStateText(kind: string, status: ProviderStatus | undefined, nmshInstalled: boolean): string { if (kind === 'native') return 'Built in'; if (kind === 'none') return 'Off'; @@ -62,40 +90,114 @@ export function providerStateText(kind: string, status: ProviderStatus | undefin return `Unavailable${status.detail ? ` · ${status.detail}` : ''}`; } -export function renderProvidersOverview(state: ProvidersOverviewState, facts: OverviewFacts, columns: number): string[] { +export function providersOverviewKey(state: ProvidersOverviewState, key: Key, facts?: Pick): OverviewAction | undefined { + if (state.installing) return undefined; + if (state.confirm) { + if (key.kind === 'left' || key.kind === 'right') { state.confirm.yes = !state.confirm.yes; return undefined; } + if (key.kind === 'enter') { + const {family, id, yes} = state.confirm; + state.confirm = undefined; + if (yes) return {kind: 'install', family, id}; + state.message = 'Nothing was installed.'; + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') { state.confirm = undefined; state.message = 'Nothing was installed.'; } + return undefined; + } + const items = overviewItems(state); + state.selected = Math.max(0, Math.min(state.selected, items.length - 1)); + const item = items[state.selected]!; + if (key.kind === 'escape' || key.kind === 'interrupt') { + // Esc collapses the expanded family first; it closes only when nothing is expanded. + if (state.expanded) { + const row = state.expanded; + state.expanded = undefined; + state.selected = OVERVIEW_ROWS.indexOf(row); + return undefined; + } + return {kind: 'close'}; + } + if (key.kind === 'up' || key.kind === 'down') { + state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + items.length) % items.length; + state.message = undefined; + return undefined; + } + if (key.kind === 'text' && key.value.toLowerCase() === 'r') return {kind: 'detect'}; + if (key.kind !== 'enter' && !(key.kind === 'text' && key.value === ' ')) return undefined; + if (item.kind === 'family') { + if (item.row === 'understanding' || item.row === 'shell') return {kind: 'open', row: item.row}; + state.expanded = state.expanded === item.row ? undefined : item.row; + state.selected = overviewItems(state).findIndex(entry => entry.kind === 'family' && entry.row === item.row); + return undefined; + } + if (item.kind === 'configure') return {kind: 'open', row: 'prompt'}; + const definition = providerFamily(item.family)!; + const descriptor = definition.providers.find(provider => provider.id === item.id)!; + const status = facts?.statuses.get(descriptor.id); + if (descriptor.kind === 'external' && status?.state === 'missing') { + if (providerInstall(descriptor)) state.confirm = {family: item.family, id: item.id, yes: false}; + else state.message = `${descriptor.label} is not installed and NMSh has no verified install recipe for it here.`; + return undefined; + } + if (descriptor.kind === 'external' && status && status.state !== 'installed') { state.message = `${descriptor.label} is unavailable${status.detail ? `: ${status.detail}` : ''}.`; return undefined; } + return {kind: 'select', family: item.family, id: item.id}; +} + +export function renderProvidersOverview(state: ProvidersOverviewState, facts: OverviewFacts, columns: number, height = Infinity): string[] { const primary = foreground(UI_COLORS.primary); const secondary = foreground(UI_COLORS.secondary); const subtle = foreground(UI_COLORS.subtle); const accent = foreground(UI_COLORS.accent); const reset = '\u001b[0m'; - const rows = [`${primary} Providers${reset} ${subtle}what NMSh uses, what else is available · [active] is in use, ${GLYPHS.selection} is the selected row${reset}`, '']; + const items = overviewItems(state); + const selected = Math.max(0, Math.min(state.selected, items.length - 1)); + const lines: Array<{text: string; item?: number}> = [{text: `${primary} Providers${reset} ${subtle}what NMSh uses for each job · Enter expands a family and selects a provider${reset}`}, {text: ''}]; const nameWidth = labelColumnWidth([...PROVIDER_FAMILIES.map(family => family.title), 'Local understanding'], columns, 2, 30); const activeWidth = Math.max(8, Math.min(28, columns - 2 - nameWidth - 2 * COLUMN_GUTTER - 8)); - const line = (index: number, title: string, active: string, tag: string) => { - const selected = index === state.selected; - return `${selected ? `${accent}${GLYPHS.selection}` : ' '} ${selected ? primary : secondary}${padCells(title, nameWidth)}${reset}${primary}${padCells(truncateText(active, activeWidth), activeWidth)}${reset}${subtle}${tag}${reset}`; - }; - PROVIDER_FAMILIES.forEach((definition, index) => { - const familyState = familyFacts(definition, facts.configuration, facts.statuses); - const active = definition.providers.find(provider => provider.id === familyState.active)!; - rows.push(line(index, definition.title, active.label, familyState.notice ? `[active] fallback · ${familyState.notice}` : '[active]')); - if (index === state.selected) { - const providerWidth = labelColumnWidth(familyState.rows.map(row => row.descriptor.label), columns, 6, 16); - for (const row of familyState.rows) { - const marks = [row.active ? '[active]' : '', row.preferred && !row.active ? '[preferred]' : ''].filter(Boolean).join(' '); - const status = providerStateText(row.descriptor.kind, row.status, facts.installedByNmsh.has(row.descriptor.executable ?? row.descriptor.id)); - rows.push(` ${secondary}${padCells(row.descriptor.label, providerWidth)}${reset}${subtle}${status}${marks ? ` ${marks}` : ''}${row.status?.binary ? ` ${row.status.binary}` : ''}${reset}`); + const mark = (index: number) => index === selected ? `${accent}${GLYPHS.selection}${reset}` : ' '; + items.forEach((item, index) => { + const isSelected = index === selected; + if (item.kind === 'family') { + const expander = item.row === 'understanding' || item.row === 'shell' ? ' ' : state.expanded === item.row ? '▾' : '▸'; + const definition = providerFamily(item.row); + if (definition) { + const familyState = familyFacts(definition, facts.configuration, facts.statuses); + const active = definition.providers.find(provider => provider.id === familyState.active)!; + const preferred = definition.providers.find(provider => provider.id === familyState.preferred); + const tag = familyState.notice ? `Selected ${preferred?.label ?? familyState.preferred} · fallback → ${active.label}` : '● Active'; + lines.push({item: index, text: `${mark(index)} ${subtle}${expander}${reset} ${isSelected ? primary : secondary}${padCells(definition.title, nameWidth)}${reset}${primary}${padCells(truncateText(active.label, activeWidth), activeWidth)}${reset}${subtle}${tag}${reset}`}); + } else if (item.row === 'understanding') { + lines.push({item: index, text: `${mark(index)} ${isSelected ? primary : secondary}${padCells('Local understanding', nameWidth)}${reset}${primary}${padCells(truncateText(facts.understanding.active, activeWidth), activeWidth)}${reset}${subtle}Enter opens it${reset}`}); + if (isSelected) for (const detail of facts.understanding.detail) lines.push({text: ` ${subtle}${detail}${reset}`}); + } else { + lines.push({item: index, text: `${mark(index)} ${isSelected ? primary : secondary}${padCells('Shell', nameWidth)}${reset}${primary}${padCells(`${facts.shell.current} (this session)`, activeWidth)}${reset}${subtle}default ${facts.shell.defaultShell} · Enter opens /shell${reset}`}); } + return; + } + if (item.kind === 'configure') { + lines.push({item: index, text: `${mark(index)} ${isSelected ? accent : subtle}Configure the prompt (themes, styles, modules) in /prompt ›${reset}`}); + return; } + const definition = providerFamily(item.family)!; + const familyState = familyFacts(definition, facts.configuration, facts.statuses); + const row = familyState.rows.find(entry => entry.descriptor.id === item.id)!; + const fallbackLabel = definition.providers.find(provider => provider.id === definition.fallback)?.label ?? definition.fallback; + const providerWidth = labelColumnWidth(familyState.rows.map(entry => entry.descriptor.label), columns, 6, 16); + const description = row.descriptor.kind === 'external' && row.status?.binary ? row.status.binary : row.descriptor.description; + lines.push({item: index, text: `${mark(index)} ${isSelected ? primary : secondary}${padCells(row.descriptor.label, providerWidth)}${reset}${row.active ? accent : subtle}${padCells(providerStatusLabel(row, fallbackLabel), 38)}${reset}${subtle}${truncateText(description, 40)}${reset}`}); + if (state.confirm && state.confirm.family === item.family && state.confirm.id === item.id) { + const install = providerInstall(row.descriptor)!; + lines.push({text: ` ${primary}Install with: ${install.label}${reset}`}); + lines.push({text: ` ${primary}Install now?${reset} ${state.confirm.yes ? `${subtle}No${reset} ${accent}‹ Yes ›${reset}` : `${accent}‹ No ›${reset} ${subtle}Yes${reset}`} ${subtle}←→ choose · Enter confirm · Esc cancel${reset}`}); + } + if (state.installing && state.installing.family === item.family && state.installing.id === item.id) lines.push({text: ` ${subtle}${state.installing.line}${reset}`}); }); - const understandingIndex = PROVIDER_FAMILIES.length; - rows.push(line(understandingIndex, 'Local understanding', facts.understanding.active, '[active]')); - if (state.selected === understandingIndex) for (const detail of facts.understanding.detail) rows.push(` ${subtle}${detail}${reset}`); - rows.push(line(understandingIndex + 1, 'Shell', `${facts.shell.current} (this session)`, `default ${facts.shell.defaultShell} · managed in /shell`)); - rows.push('', ` ${subtle}Enter opens the family: switch, install (previewed, starts on No), configure. Uninstall of what NMSh installed is in /tools.${reset}`); - if (state.detecting) rows.push(` ${subtle}Detecting installed providers…${reset}`); - if (state.message) rows.push('', ` ${secondary}${state.message}${reset}`); - rows.push('', renderControls([['↑↓', 'select'], ['Enter', 'open'], ['R', 'detect again'], ['Esc', 'close']])); - return rows.map(row => truncateAnsi(row, columns)); + lines.push({text: ''}, {text: ` ${subtle}Selecting applies at once. Installs are shown first and start on No; uninstall of what NMSh installed is in /tools.${reset}`}); + if (state.detecting) lines.push({text: ` ${subtle}Detecting installed providers…${reset}`}); + if (state.message) lines.push({text: ''}, {text: ` ${secondary}${state.message}${reset}`}); + const controls = renderControls([['↑↓', 'select'], ['Enter', state.expanded ? 'select / collapse' : 'expand'], ['R', 'detect again'], ['Esc', state.expanded ? 'collapse' : 'close']]); + const budget = Number.isFinite(height) ? Math.max(3, height - 2) : lines.length; + const at = Math.max(0, lines.findIndex(line => line.item === selected)); + const start = lines.length <= budget ? 0 : Math.max(0, Math.min(at - Math.floor(budget / 2), lines.length - budget)); + return [...lines.slice(start, start + budget).map(line => line.text), '', controls].map(row => truncateAnsi(row, columns)); } - diff --git a/src/themeBridge/artifacts.ts b/src/themeBridge/artifacts.ts index 2b5b7584..29ac6df6 100644 --- a/src/themeBridge/artifacts.ts +++ b/src/themeBridge/artifacts.ts @@ -16,7 +16,7 @@ import type {BridgeMode, BridgeTargetId} from './model.js'; */ export const ADAPTER_VERSION = 1; -export type ManagedTarget = Extract | 'vivid'; +export type ManagedTarget = Extract | 'vivid' | 'tmuxConfig'; export type HookTarget = Extract; export interface ConfigHook { @@ -55,6 +55,8 @@ export function artifactPath(target: ManagedTarget, env: NodeJS.ProcessEnv = pro const root = bridgeDirectory(env); switch (target) { case 'tmux': return join(root, 'tmux', 'nmsh-bridge.tmux.conf'); + // The one file tmux.conf includes: Tool Configuration settings, then the Theme Bridge colors. + case 'tmuxConfig': return join(root, 'tmux', 'nmsh.tmux.conf'); case 'neovim': return join(root, 'nvim', 'colors', 'nmsh-bridge.lua'); case 'vim': return join(root, 'vim', 'colors', 'nmsh-bridge.vim'); case 'vivid': return join(root, 'vivid', 'nmsh-bridge.yml'); @@ -82,7 +84,7 @@ export function batConfigDirectory(env: NodeJS.ProcessEnv = process.env): string export const runtimeDirectory = (target: 'neovim' | 'vim', env: NodeJS.ProcessEnv = process.env) => dirname(dirname(artifactPath(target, env))); const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -const TARGETS: readonly ManagedTarget[] = ['tmux', 'neovim', 'vim', 'helix', 'bat', 'vivid']; +const TARGETS: readonly ManagedTarget[] = ['tmux', 'neovim', 'vim', 'helix', 'bat', 'vivid', 'tmuxConfig']; /** A malformed or stale ledger yields no ownership (so nothing can be deleted on its say-so). */ export function loadLedger(env: NodeJS.ProcessEnv = process.env): Ledger { @@ -215,7 +217,7 @@ export function hookSpec(target: HookTarget, env: NodeJS.ProcessEnv = process.en } const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(home, '.config'); if (target === 'tmux') { - const fragment = artifactPath('tmux', env); + const fragment = artifactPath('tmuxConfig', env); if (!hookSafePath(fragment)) return {error: `The managed file path ${fragment} cannot be quoted safely for tmux.`}; const existing = firstExisting([join(home, '.tmux.conf'), join(xdg, 'tmux', 'tmux.conf')]); return {configPath: existing ?? join(home, '.tmux.conf'), lines: [HOOK_COMMENT.tmux, `source-file -q '${fragment}'`], createIfMissing: !existing}; @@ -260,21 +262,30 @@ export function planHook(spec: HookSpec, home: string): {plan: FileEditPlan} | { return {error: 'reason' in result ? result.reason : 'Cannot edit that file.'}; } +/** The ledger entry that records a target's include: tmux's lives with the one managed tmux file. */ +export const hookOwner = (target: HookTarget): ManagedTarget => target === 'tmux' ? 'tmuxConfig' : target; + +/** The recorded include for a target, if any. */ +export function recordedHook(target: HookTarget, env: NodeJS.ProcessEnv = process.env): ConfigHook | undefined { + return loadLedger(env).entries[hookOwner(target)]?.hook; +} + /** Applies a confirmed hook plan and records the exact lines in the ledger. */ export function applyHook(target: HookTarget, plan: FileEditPlan, spec: HookSpec, env: NodeJS.ProcessEnv = process.env, now = new Date()): {ok: true} | {ok: false; error: string} { const ledger = loadLedger(env); - const entry = ledger.entries[target]; + const owner = hookOwner(target); + const entry = ledger.entries[owner]; if (!entry) return {ok: false, error: 'Generate the managed file first.'}; const result = applyPlan(plan); if (!result.ok) return {ok: false, error: result.reason}; - ledger.entries[target] = {...entry, hook: {configPath: spec.configPath, lines: [...spec.lines], insertedAt: now.toISOString()}}; + ledger.entries[owner] = {...entry, hook: {configPath: spec.configPath, lines: [...spec.lines], insertedAt: now.toISOString()}}; saveLedger(ledger, env); return {ok: true}; } /** The plan to remove exactly the recorded hook lines; anything else in the file is untouched. */ export function planHookRemoval(target: HookTarget, home: string, env: NodeJS.ProcessEnv = process.env): {plan: FileEditPlan} | {gone: true} | {error: string} { - const hook = loadLedger(env).entries[target]?.hook; + const hook = recordedHook(target, env); if (!hook) return {error: 'NMSh has no recorded include for this target.'}; const facts = inspectFile(hook.configPath, homeRoots(home)); if (facts.content === undefined) return {error: `${hook.configPath} ${facts.refusal ?? 'cannot be read'}; nothing was changed.`}; @@ -292,7 +303,7 @@ export function applyHookRemoval(target: HookTarget, plan: FileEditPlan | undefi if (!result.ok) return {ok: false, error: result.reason}; } const ledger = loadLedger(env); - const entry = ledger.entries[target]; + const entry = ledger.entries[hookOwner(target)]; if (entry) { delete entry.hook; saveLedger(ledger, env); } return {ok: true}; } diff --git a/src/themeBridge/runtime.ts b/src/themeBridge/runtime.ts index 25804b61..9580e25e 100644 --- a/src/themeBridge/runtime.ts +++ b/src/themeBridge/runtime.ts @@ -13,9 +13,11 @@ import { } from './model.js'; import {bridgeEnvPath, writeEnvironmentFiles, type BridgeEnvironment, type ListingWrapper} from './environment.js'; import { - artifactPath, batConfigDirectory, helixConfigDirectory, hookSpec, ledgerPath, loadLedger, ownership, removeArtifact, saveLedger, sha256, writeArtifact, + artifactPath, batConfigDirectory, helixConfigDirectory, hookSpec, recordedHook, ledgerPath, loadLedger, ownership, removeArtifact, saveLedger, sha256, writeArtifact, type HookTarget, type ManagedTarget, } from './artifacts.js'; +import {loadTmuxModel} from '../tools/config/tmux.js'; +import {modelIsEmpty, tmuxManagedNeeded, writeTmuxManaged} from '../tools/config/tmuxManaged.js'; import { BAT_THEME_NAME, batTheme, bsdLsColors, fzfColorArgs, helixTheme, lsColorsFallback, neovimColorscheme, pagerEnvironment, parseFzfVersion, tmuxFragment, validateBatTheme, validateHelixTheme, validateNeovimColorscheme, validateTmuxFragment, validateVimColorscheme, validLsColors, vimColorscheme, vividTheme, @@ -182,7 +184,7 @@ export function batReady(env: NodeJS.ProcessEnv = process.env): boolean { } function hookPresent(target: HookTarget, env: NodeJS.ProcessEnv, home: string): 'none' | 'current' | 'stale' | 'missing' { - const hook = loadLedger(env).entries[target]?.hook; + const hook = recordedHook(target, env); if (!hook) return 'none'; const text = readSmall(hook.configPath); if (text === undefined || !text.includes(`${hook.lines.join('\n')}\n`)) return 'missing'; @@ -195,7 +197,8 @@ function hookPresent(target: HookTarget, env: NodeJS.ProcessEnv, home: string): /** Readiness of a managed target: what (if anything) still needs a reviewed step. */ export function managedReadiness(target: Extract, env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): string { const ledger = loadLedger(env); - const owned = ownership(target, ledger, env); + // tmux.conf includes the one managed tmux file, so that file decides tmux's setup state. + const owned = ownership(target === 'tmux' ? 'tmuxConfig' : target, ledger, env); if (owned === 'modified' || owned === 'unknown') return 'Conflict'; if (owned !== 'owned') return 'Needs setup'; if (target === 'bat') return batReady(env) ? 'Ready' : 'Needs cache build'; @@ -226,7 +229,7 @@ export function reportTargets({source, facts, level, env = process.env}: BridgeC readiness = managedReadiness(managed, env, home); if (readiness === 'Conflict') { status = 'Conflict'; notes.push(`${artifactPath(managed, env)} exists and is not NMSh's unchanged file; NMSh will not overwrite it.`); } else if (setting.mode !== 'independent' && readiness === 'Needs setup' && target === 'bat') { status = 'Needs setup'; notes.push('bat needs a generated custom theme and a reviewed cache build before BAT_THEME is set.'); } - if (setting.mode === 'independent' && (readiness === 'Active' || readiness === 'Needs include' || readiness === 'Needs activation')) readiness = loadLedger(env).entries[managed]?.hook ? 'Include kept (inactive)' : undefined; + if (setting.mode === 'independent' && (readiness === 'Active' || readiness === 'Needs include' || readiness === 'Needs activation')) readiness = managed !== 'bat' && recordedHook(managed as HookTarget, env) ? 'Include kept (inactive)' : undefined; if (target === 'helix' && setting.mode !== 'independent') { notes.push('Coverage: syntax, markup, diff, diagnostics and editor UI. Running Helix instances are not recolored; new ones use the file.'); if (readiness === 'Needs activation') notes.push('Select it with :theme nmsh-bridge, or review the config change.'); @@ -354,6 +357,16 @@ export async function applyThemeBridge(context: BridgeContext): Promise` against the * user's running server, on explicit request only. No shell, no other command. */ -export async function reloadTmux(env: NodeJS.ProcessEnv = process.env, path = artifactPath('tmux', env)): Promise<{ok: boolean; message: string}> { +export async function reloadTmux(env: NodeJS.ProcessEnv = process.env, path = artifactPath('tmuxConfig', env)): Promise<{ok: boolean; message: string}> { const binary = resolveCommand('tmux', env.PATH ?? ''); if (!binary) return {ok: false, message: 'tmux is not installed.'}; if (!existsSync(path)) return {ok: false, message: 'There is no NMSh-managed tmux file to load yet.'}; @@ -441,7 +454,9 @@ export function integrationHealth(context: BridgeContext): HealthItem[] { const item = (state: HealthState, action?: HealthItem['action'], detail = HEALTH_LABELS[state]): HealthItem => ({target, label, state, detail, ...(action ? {action} : {})}); if (!context.facts[target]?.installed) return item('not-installed'); if (BRIDGE_CAPABILITY[target] === 'detected') return item('not-managed'); - if (effectiveSetting(context.source.themeBridge, target).mode === 'independent') return item('independent'); + // tmux settings from /tmux need the same one include even while Theme Bridge leaves tmux Independent. + const tmuxConfigured = target === 'tmux' && !modelIsEmpty(loadTmuxModel(env)); + if (effectiveSetting(context.source.themeBridge, target).mode === 'independent' && !tmuxConfigured) return item('independent'); if (BRIDGE_CAPABILITY[target] === 'direct') return item('ready', undefined, 'Ready · applied to NMSh shells and launches'); const managed = target as Extract; const readiness = managedReadiness(managed, env, home); diff --git a/src/tools/catalog.ts b/src/tools/catalog.ts index 0f34cfa3..c7c65911 100644 --- a/src/tools/catalog.ts +++ b/src/tools/catalog.ts @@ -34,7 +34,8 @@ export interface Tool extends ProviderDescriptor { providerFamily?: ProviderFamily; /** Provider integration is distinct from the recommendation tier. */ integration?: 'welcome' | 'picker' | 'navigation' | 'history' | 'completion' | 'prompt'; - configuration?: 'starship'; + /** A registered Tool Configuration adapter (src/tools/config/registry.ts); only these get Configure. */ + configuration?: 'starship' | 'tmux'; language?: string; } @@ -87,7 +88,7 @@ export const TOOLS: readonly Tool[] = [ tool('stow', 'GNU Stow', 'Shell / Workflow', 'Explicitly managed dotfile symlinks.', 'https://www.gnu.org/software/stow/'), tool('tealdeer', 'TLDR (tealdeer)', 'Shell / Workflow', 'Practical local command examples (command tldr, package tealdeer). Optional; Ask uses its local cache and never updates it.', 'https://github.com/tealdeer-rs/tealdeer', {executable: 'tldr', package: 'tealdeer', recommended: true}), - tool('tmux', 'tmux', 'Shell / Workflow', 'Independent terminal multiplexer.', 'https://github.com/tmux/tmux', {versionArgs: ['-V']}), + tool('tmux', 'tmux', 'Shell / Workflow', 'Independent terminal multiplexer.', 'https://github.com/tmux/tmux', {versionArgs: ['-V'], configuration: 'tmux'}), tool('docker', 'Docker CLI', 'Containers / Infrastructure', 'Container client; daemon availability is not inferred.', 'https://docs.docker.com/', {package: 'docker', ...ENVIRONMENT, relevantTo: ['containers']}), tool('kubectl', 'kubectl', 'Containers / Infrastructure', 'Kubernetes client; credentials/cluster are not inspected.', 'https://kubernetes.io/docs/reference/kubectl/', {versionArgs: undefined, package: 'kubernetes-cli', ...ENVIRONMENT, relevantTo: ['kubernetes']}), tool('mise', 'mise', 'Project / Language Tooling', 'Optional project tooling; metadata evaluation needs consent.', 'https://mise.jdx.dev/', {versionArgs: undefined, ...ENHANCED}), diff --git a/src/tools/config/ConfigureList.ts b/src/tools/config/ConfigureList.ts new file mode 100644 index 00000000..1ca7ba5a --- /dev/null +++ b/src/tools/config/ConfigureList.ts @@ -0,0 +1,41 @@ +import type {Key} from '../../terminal/keys.js'; +import {framePanel} from '../../ui/PanelShell.js'; +import {renderControls} from '../../ui/controls.js'; +import {foreground, UI_COLORS} from '../../ui/palette.js'; +import {GLYPHS} from '../../ui/glyphs.js'; +import {padCells, truncateAnsi} from '../../util/text.js'; +import {OWNERSHIP_LABELS, type ToolConfigEntry} from './registry.js'; + +/** /configure: every registered tool, what NMSh can configure for it and how; Enter opens the tool's editor. */ +export interface ConfigureListState {selected: number; message?: string} +export type ConfigureListAction = {kind: 'close'} | {kind: 'open'; id: string} | {kind: 'bridge'}; + +export function configureListKey(state: ConfigureListState, key: Key, tools: ReadonlyArray): ConfigureListAction | undefined { + state.message = undefined; + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (key.kind === 'up' || key.kind === 'down') { state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + tools.length) % tools.length; return undefined; } + if (key.kind !== 'enter') return undefined; + const tool = tools[state.selected]!; + if (tool.configurable) return {kind: 'open', id: tool.id}; + if (tool.ownership === 'theme-bridge') return {kind: 'bridge'}; + state.message = `${tool.label}: ${tool.summary}.`; + return undefined; +} + +export function renderConfigureList(state: ConfigureListState, tools: ReadonlyArray, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001B[0m'; + const lines = [` ${primary}Tool Configuration${reset} ${subtle}supported settings for registered tools only; detection never grants write access${reset}`, '']; + tools.forEach((tool, index) => { + const selected = index === state.selected; + const action = tool.configurable ? 'Configure ›' : tool.ownership === 'theme-bridge' ? 'Theme Bridge ›' : 'Inspect only'; + lines.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${selected ? primary : secondary}${padCells(tool.label, 12)}${reset}${padCells(tool.installed ? 'Installed' : 'Not installed', 15)}${selected ? accent : subtle}${padCells(action, 16)}${reset}${subtle}${OWNERSHIP_LABELS[tool.ownership]}${reset}`); + if (selected) lines.push(` ${subtle}${tool.summary} · takes effect: ${tool.takesEffect}${reset}`); + }); + if (state.message) lines.push('', ` ${secondary}${state.message}${reset}`); + lines.push('', renderControls([['↑↓', 'select'], ['Enter', 'open'], ['Esc', 'close']])); + return framePanel(lines.map(line => truncateAnsi(line, columns)), columns).slice(0, Math.max(1, height)); +} diff --git a/src/tools/config/TmuxPanel.ts b/src/tools/config/TmuxPanel.ts new file mode 100644 index 00000000..31d1f20d --- /dev/null +++ b/src/tools/config/TmuxPanel.ts @@ -0,0 +1,280 @@ +import type {Key} from '../../terminal/keys.js'; +import {framePanel, renderTabStrip} from '../../ui/PanelShell.js'; +import {renderControls} from '../../ui/controls.js'; +import {foreground, background, UI_COLORS} from '../../ui/palette.js'; +import {GLYPHS} from '../../ui/glyphs.js'; +import {editText} from '../../ui/formControls.js'; +import {padCells, truncateAnsi} from '../../util/text.js'; +import { + applyTmuxChange, bindingConflicts, describeTmuxChange, optionProvenance, STATUS_MODULES, STATUS_SEPARATORS, TMUX_ACTION_IDS, TMUX_ACTIONS, + TMUX_DEFAULT_BINDINGS, TMUX_OPTIONS, validTmuxKey, type StatusModuleId, type TmuxActionId, type TmuxChange, type TmuxImport, type TmuxModel, + type TmuxStatusLayout, +} from './tmux.js'; + +/** + * /tmux (also /configure tmux): tmux's Config Studio. It edits a draft of + * the typed model; nothing is written until Review & apply shows every + * change (and the one include, when missing) and the user picks Yes. + * tmux appearance (Status Studio, Theme Bridge) and the pane frontend are + * shown as different owners: NMSh's own prompt is configured in /prompt. + */ + +export const TMUX_TABS = ['General', 'Keys', 'Status', 'Pane frontend', 'Import'] as const; +type Tab = 'general' | 'keys' | 'status' | 'frontend' | 'import'; +const TAB_IDS: readonly Tab[] = ['general', 'keys', 'status', 'frontend', 'import']; + +const PREFIXES = ['C-b', 'C-a', 'C-Space', 'C-s', 'C-q'] as const; +const LEFT_PRESETS: StatusModuleId[][] = [['session'], ['session', 'host'], ['host'], ['session', 'windowIndex'], []]; +const RIGHT_PRESETS: StatusModuleId[][] = [['time'], ['date', 'time'], ['host', 'time'], ['paneCwd', 'time'], ['paneCommand', 'time'], []]; +const KEY_ACTIONS: readonly TmuxActionId[] = TMUX_ACTION_IDS.filter(id => id !== 'send-prefix'); + +export interface TmuxPanelState { + tab: Tab; + focus: 'tabs' | 'list'; + selected: Record; + draft: TmuxModel; + saved: TmuxModel; + /** The user's own tmux.conf, parsed (supported subset), for provenance and import. */ + user?: TmuxImport; + userPath?: string; + /** Typing a key for a binding row. */ + keyEntry?: {action: TmuxActionId; text: string}; + review?: {lines: string[]; include: string[]; yes: boolean}; + /** Theme Bridge state for tmux, one line (owned by /theme-bridge). */ + bridge: string; + installed: boolean; + message?: string; +} + +export type TmuxPanelAction = {kind: 'close'} | {kind: 'review'} | {kind: 'apply'} | {kind: 'reload'} | {kind: 'openPrompt'} | {kind: 'openBridge'}; + +export function createTmuxPanel(saved: TmuxModel, user: {path: string; parsed: TmuxImport} | undefined, bridge: string, installed: boolean): TmuxPanelState { + return {tab: 'general', focus: 'list', selected: {general: 0, keys: 0, status: 0, frontend: 0, import: 0}, draft: structuredClone(saved), saved: structuredClone(saved), + ...(user ? {user: user.parsed, userPath: user.path} : {}), bridge, installed}; +} + +/** Typed changes from saved to draft: what Review shows and what Apply writes. */ +export function pendingChanges(state: Pick): TmuxChange[] { + const changes: TmuxChange[] = []; + for (const option of TMUX_OPTIONS) if (state.draft.options[option.id] !== state.saved.options[option.id]) changes.push({kind: 'option', id: option.id, ...(state.draft.options[option.id] !== undefined ? {value: state.draft.options[option.id]} : {})}); + if (state.draft.prefix !== state.saved.prefix) changes.push({kind: 'prefix', ...(state.draft.prefix ? {key: state.draft.prefix} : {})}); + const key = (binding: {key: string; table: string; action: string}) => `${binding.table}\u0000${binding.key}\u0000${binding.action}`; + const saved = new Set(state.saved.bindings.map(key)); + const draft = new Set(state.draft.bindings.map(key)); + for (const binding of state.draft.bindings) if (!saved.has(key(binding)) && binding.action !== 'send-prefix') changes.push({kind: 'binding', binding}); + for (const binding of state.saved.bindings) if (!draft.has(key(binding)) && binding.action !== 'send-prefix') changes.push({kind: 'binding', binding, remove: true}); + if (JSON.stringify(state.draft.status) !== JSON.stringify(state.saved.status)) changes.push({kind: 'status', ...(state.draft.status ? {layout: state.draft.status} : {})}); + if (state.draft.frontend !== state.saved.frontend) changes.push({kind: 'frontend', value: state.draft.frontend}); + return changes; +} + +type Row = {id: string; label: string; value: string; detail?: string; editable: boolean}; + +function cycle(values: readonly T[], current: T, delta: number): T { + const index = values.findIndex(value => JSON.stringify(value) === JSON.stringify(current)); + return values[(index + delta + values.length) % values.length]!; +} + +function apply(state: TmuxPanelState, change: TmuxChange): void { + const next = applyTmuxChange(state.draft, change); + if ('error' in next) state.message = next.error; else state.draft = next; +} + +function statusLayout(model: TmuxModel): TmuxStatusLayout { + return model.status ?? {left: ['session'], right: ['time'], separator: '·', windowFormat: 'index-name'}; +} + +function rows(state: TmuxPanelState): Row[] { + const draft = state.draft; + if (state.tab === 'general' || state.tab === 'status') { + const options = TMUX_OPTIONS.filter(option => state.tab === 'general' ? option.group !== 'Status' : option.group === 'Status'); + const list: Row[] = options.map(option => { + const provenance = optionProvenance(option, draft, state.user); + return {id: `option:${option.id}`, label: option.label, value: provenance.effective, editable: true, + detail: `${provenance.source}${provenance.user !== undefined && provenance.override !== undefined && provenance.user !== provenance.override ? ` · your config: ${provenance.user}` : ''}${option.guidance ? ` · ${option.guidance}` : ''}`}; + }); + if (state.tab === 'status') { + const layout = statusLayout(draft); + const names = (ids: StatusModuleId[]) => ids.length ? ids.map(id => STATUS_MODULES[id].label).join(', ') : 'Nothing'; + list.push({id: 'studio', label: 'Status Studio', value: draft.status ? 'NMSh layout' : 'Inherit', editable: true, detail: draft.status ? 'NMSh writes the status formats below' : 'your config or tmux defaults keep the formats'}); + if (draft.status) { + list.push({id: 'left', label: 'Left', value: names(layout.left), editable: true}, {id: 'right', label: 'Right', value: names(layout.right), editable: true}, + {id: 'separator', label: 'Separator', value: layout.separator, editable: true}, + {id: 'windows', label: 'Window labels', value: layout.windowFormat === 'index-name' ? 'Index:name' : layout.windowFormat === 'name' ? 'Name' : 'Index', editable: true}); + } + list.push({id: 'bridge', label: 'Theme Bridge', value: state.bridge, editable: false, detail: 'colors come from /theme-bridge · Enter opens it'}); + } + return [...list, {id: 'review', label: 'Review & apply', value: `${pendingChanges(state).length} change${pendingChanges(state).length === 1 ? '' : 's'} ›`, editable: false}]; + } + if (state.tab === 'keys') { + const prefix = draft.prefix ?? state.user?.prefix ?? 'C-b'; + const list: Row[] = [{id: 'prefix', label: 'Prefix', value: prefix, editable: true, detail: draft.prefix ? 'NMSh managed file' : state.user?.prefix ? 'your tmux config' : 'tmux default'}]; + for (const action of KEY_ACTIONS) { + const mine = draft.bindings.find(binding => binding.action === action); + const theirs = state.user?.bindings.find(binding => binding.action === action); + const builtin = TMUX_DEFAULT_BINDINGS.find(binding => binding.action === action); + const shown = mine ?? theirs ?? builtin; + list.push({id: `bind:${action}`, label: TMUX_ACTIONS[action].label, editable: true, + value: shown ? `${shown.table === 'root' ? '' : 'prefix '}${shown.key}` : '—', + detail: mine ? 'NMSh binding · Del removes it' : theirs ? 'your tmux config' : builtin ? 'tmux default' : 'not bound · Enter adds one'}); + } + const conflicts = bindingConflicts(draft, state.user); + if (conflicts.length) list.push({id: 'conflicts', label: 'Overrides', value: `${conflicts.length} of your bindings`, editable: false, detail: conflicts.map(item => `${item.user.key}: ${TMUX_ACTIONS[item.user.action].label} → ${TMUX_ACTIONS[item.binding.action].label}`).join(' · ')}); + return [...list, {id: 'review', label: 'Review & apply', value: `${pendingChanges(state).length} change${pendingChanges(state).length === 1 ? '' : 's'} ›`, editable: false}]; + } + if (state.tab === 'frontend') { + return [ + {id: 'frontend', label: 'New panes', value: draft.frontend === 'nmsh' ? 'Start NMSh' : 'Start normal shell', editable: true, + detail: 'Applies to new panes and windows without an explicit command. Existing panes keep their current process. tmux\'s default-shell is not changed.'}, + {id: 'prompt', label: 'NMSh prompt', value: 'Configure in /prompt ›', editable: false, detail: 'The real NMSh prompt, composer and theme run inside the pane; they are configured once, in /prompt'}, + {id: 'review', label: 'Review & apply', value: `${pendingChanges(state).length} change${pendingChanges(state).length === 1 ? '' : 's'} ›`, editable: false}, + ]; + } + const user = state.user; + return [ + {id: 'source', label: 'Your tmux config', value: state.userPath ?? 'none found', editable: false}, + {id: 'found', label: 'Supported values', value: user ? `${Object.keys(user.options).length} settings · ${user.prefix ? 'prefix · ' : ''}${user.bindings.length} bindings` : '—', editable: false}, + {id: 'skipped', label: 'Left as yours', value: user ? `${user.unsupported.length} other lines · ${user.ignored.length} dynamic (never run)` : '—', editable: false, + detail: user?.ignored.length ? `not evaluated: ${user.ignored.slice(0, 3).join(' · ')}` : undefined}, + {id: 'copy', label: 'Copy into NMSh', value: 'Copy supported values into the NMSh managed file ›', editable: false, + detail: 'Makes them editable here; your tmux.conf is not changed'}, + {id: 'review', label: 'Review & apply', value: `${pendingChanges(state).length} change${pendingChanges(state).length === 1 ? '' : 's'} ›`, editable: false}, + ]; +} + +export function tmuxPanelKey(state: TmuxPanelState, key: Key): TmuxPanelAction | undefined { + state.message = undefined; + if (state.review) { + if (key.kind === 'left' || key.kind === 'right') { state.review.yes = !state.review.yes; return undefined; } + if (key.kind === 'enter') { + const yes = state.review.yes; + state.review = undefined; + if (yes) return {kind: 'apply'}; + state.message = 'Nothing was changed.'; + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') { state.review = undefined; state.message = 'Nothing was changed.'; } + return undefined; + } + if (state.keyEntry) { + if (key.kind === 'escape' || key.kind === 'interrupt') { state.keyEntry = undefined; return undefined; } + if (key.kind === 'enter') { + const text = state.keyEntry.text.trim(); + if (!validTmuxKey(text)) { state.message = `${text || '(empty)'} is not a key NMSh can bind (examples: C-a, M-Left, F5, |).`; return undefined; } + apply(state, {kind: 'binding', binding: {key: text, table: /^M-|^F\d/u.test(text) ? 'root' : 'prefix', action: state.keyEntry.action}}); + state.keyEntry = undefined; + return undefined; + } + const next = editText(state.keyEntry.text, key); + if (next !== undefined) state.keyEntry.text = next.slice(0, 16); + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (state.focus === 'tabs') { + if (key.kind === 'left' || key.kind === 'right') state.tab = TAB_IDS[(TAB_IDS.indexOf(state.tab) + (key.kind === 'left' ? -1 : 1) + TAB_IDS.length) % TAB_IDS.length]!; + else if (key.kind === 'down' || key.kind === 'enter') state.focus = 'list'; + return undefined; + } + const list = rows(state); + const index = Math.max(0, Math.min(state.selected[state.tab], list.length - 1)); + if (key.kind === 'up' || key.kind === 'down') { + if (key.kind === 'up' && index === 0) { state.focus = 'tabs'; return undefined; } + state.selected[state.tab] = Math.max(0, Math.min(list.length - 1, index + (key.kind === 'up' ? -1 : 1))); + return undefined; + } + const row = list[index]!; + const delta = key.kind === 'left' ? -1 : key.kind === 'right' ? 1 : 0; + if (!delta && !row.editable && key.kind !== 'enter') return undefined; + if (row.id === 'review' && key.kind === 'enter') return pendingChanges(state).length ? {kind: 'review'} : (state.message = 'Nothing to apply; NMSh can reload tmux with R.', undefined); + if (row.id === 'prompt' && key.kind === 'enter') return {kind: 'openPrompt'}; + if (row.id === 'bridge' && key.kind === 'enter') return {kind: 'openBridge'}; + if (row.id === 'copy' && key.kind === 'enter') { + if (!state.user) { state.message = 'No tmux config was found to copy from.'; return undefined; } + for (const [id, value] of Object.entries(state.user.options)) apply(state, {kind: 'option', id, value}); + if (state.user.prefix) apply(state, {kind: 'prefix', key: state.user.prefix}); + for (const binding of state.user.bindings) apply(state, {kind: 'binding', binding}); + state.message = 'Copied into the draft; Review & apply shows every change before anything is written.'; + return undefined; + } + if (key.kind === 'text' && key.value.toLowerCase() === 'r' && !row.id.startsWith('bind:')) return {kind: 'reload'}; + if (row.id.startsWith('option:')) { + const option = TMUX_OPTIONS.find(item => item.id === row.id.slice(7))!; + if ((key.kind === 'delete' || key.kind === 'backspace')) { apply(state, {kind: 'option', id: option.id}); state.message = `${option.label}: inherit`; return undefined; } + if (!delta && key.kind !== 'enter') return undefined; + const current = state.draft.options[option.id] ?? optionProvenance(option, state.draft, state.user).effective; + const next = option.values ? cycle(option.values, current, delta || 1) + : String(Math.max(option.range![0], Math.min(option.range![1], Number(current) + (delta || 1) * (option.range![1] > 10_000 ? 10_000 : option.range![1] > 100 ? 10 : 1)))); + apply(state, {kind: 'option', id: option.id, value: next}); + return undefined; + } + if (row.id === 'prefix') { + if (key.kind === 'delete' || key.kind === 'backspace') { apply(state, {kind: 'prefix'}); return undefined; } + if (delta || key.kind === 'enter') apply(state, {kind: 'prefix', key: cycle(PREFIXES, (state.draft.prefix ?? 'C-b') as typeof PREFIXES[number], delta || 1)}); + return undefined; + } + if (row.id.startsWith('bind:')) { + const action = row.id.slice(5) as TmuxActionId; + const mine = state.draft.bindings.find(binding => binding.action === action); + if ((key.kind === 'delete' || key.kind === 'backspace') && mine) { apply(state, {kind: 'binding', binding: mine, remove: true}); return undefined; } + if (key.kind === 'enter') state.keyEntry = {action, text: mine?.key ?? ''}; + return undefined; + } + const layout = statusLayout(state.draft); + if (row.id === 'studio' && (delta || key.kind === 'enter')) { apply(state, {kind: 'status', ...(state.draft.status ? {} : {layout})}); return undefined; } + if (row.id === 'left' && (delta || key.kind === 'enter')) apply(state, {kind: 'status', layout: {...layout, left: cycle(LEFT_PRESETS, layout.left, delta || 1)}}); + else if (row.id === 'right' && (delta || key.kind === 'enter')) apply(state, {kind: 'status', layout: {...layout, right: cycle(RIGHT_PRESETS, layout.right, delta || 1)}}); + else if (row.id === 'separator' && (delta || key.kind === 'enter')) apply(state, {kind: 'status', layout: {...layout, separator: cycle(STATUS_SEPARATORS, layout.separator, delta || 1)}}); + else if (row.id === 'windows' && (delta || key.kind === 'enter')) apply(state, {kind: 'status', layout: {...layout, windowFormat: cycle(['index-name', 'name', 'index'] as const, layout.windowFormat, delta || 1)}}); + else if (row.id === 'frontend' && (delta || key.kind === 'enter')) apply(state, {kind: 'frontend', value: state.draft.frontend === 'nmsh' ? 'shell' : 'nmsh'}); + return undefined; +} + +/** A tmux-style status line preview, drawn with NMSh's own UI roles (facts are samples, never probed). */ +export function statusPreview(model: TmuxModel, columns: number): string { + const layout = statusLayout(model); + const sample: Record = {session: 'main', windowIndex: '1', windowName: 'nvim', paneIndex: '0', paneCommand: 'nvim', paneCwd: 'notMyShell', host: 'host', date: '2026-10-05', time: '14:32'}; + const join = (ids: StatusModuleId[]) => ids.length ? ` ${ids.map(id => sample[id]).join(` ${layout.separator} `)} ` : ''; + const window = (index: number, name: string) => layout.windowFormat === 'name' ? ` ${name} ` : layout.windowFormat === 'index' ? ` ${index} ` : ` ${index}:${name} `; + const reset = '\u001B[0m'; + const bar = `${background(UI_COLORS.projectBackground)}${foreground(UI_COLORS.projectForeground)}`; + const left = `${bar}${join(layout.left)}${reset}`; + const windows = `${foreground(UI_COLORS.secondary)}${window(1, 'zsh')}${reset}${background(UI_COLORS.selection)}${foreground(UI_COLORS.primary)}${window(2, 'nvim')}${reset}`; + const right = `${foreground(UI_COLORS.secondary)}${join(layout.right)}${reset}`; + return truncateAnsi(` ${left}${windows} ${right}`, columns); +} + +export function renderTmuxPanel(state: TmuxPanelState, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001B[0m'; + const finish = (lines: string[], controls: Array<[string, string]>) => + framePanel([...lines.slice(0, Math.max(3, height - 3)), '', renderControls(controls)].map(line => truncateAnsi(line, columns)), columns).slice(0, Math.max(1, height)); + if (state.review) { + const lines = [` ${primary}tmux › Review${reset}`, '', ` ${secondary}NMSh writes only its managed tmux file:${reset}`, + ...state.review.lines.map(line => ` ${accent}+${reset} ${line}`)]; + if (state.review.include.length) lines.push('', ` ${secondary}tmux.conf does not load it yet; this one include is added:${reset}`, ...state.review.include.map(line => ` ${line.startsWith('+') ? accent : subtle}${line}${reset}`)); + lines.push('', ` ${subtle}Your other tmux settings are untouched. New servers load it; a running server needs Reload.${reset}`, '', + ` ${primary}Apply?${reset} ${state.review.yes ? `${subtle}No${reset} ${accent}‹ Yes ›${reset}` : `${accent}‹ No ›${reset} ${subtle}Yes${reset}`}`); + return finish(lines, [['←→', 'No / Yes'], ['Enter', 'confirm'], ['Esc', 'back']]); + } + const head = [` ${primary}tmux${reset} ${subtle}${state.installed ? 'Tool Configuration · NMSh writes one managed file your tmux.conf includes' : 'not installed · settings are kept for when it is'}${reset}`, + renderTabStrip(TMUX_TABS, TAB_IDS.indexOf(state.tab), columns, state.focus === 'tabs'), '']; + const list = rows(state); + const index = Math.max(0, Math.min(state.selected[state.tab], list.length - 1)); + const body: string[] = []; + list.forEach((row, rowIndex) => { + const selected = state.focus === 'list' && rowIndex === index; + const value = state.keyEntry && row.id === `bind:${state.keyEntry.action}` ? `${primary}${state.keyEntry.text}${accent}_${reset}` : selected && row.editable ? `${accent}‹ ${row.value} ›${reset}` : row.value; + body.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${selected ? primary : secondary}${padCells(row.label, 24)}${reset}${value}`); + if (selected && row.detail) body.push(` ${subtle}${row.detail}${reset}`); + }); + if (state.tab === 'status') body.push('', ` ${subtle}Preview · tmux status line (sample facts)${reset}`, statusPreview(state.draft, columns)); + if (state.tab === 'frontend') body.push('', ` ${subtle}tmux appearance (status line, borders) and the pane frontend are different owners: the prompt inside a pane belongs to the program running there.${reset}`); + if (state.message) body.push('', ` ${secondary}${state.message}${reset}`); + return finish([...head, ...body], state.focus === 'tabs' ? [['←→', 'tabs'], ['↓', 'select'], ['Esc', 'close']] + : [['↑↓', 'select'], ['←→', 'change'], ['Enter', 'edit / open'], ['Del', 'inherit'], ['R', 'reload tmux'], ['Esc', 'close']]); +} + +export {describeTmuxChange}; diff --git a/src/tools/config/registry.ts b/src/tools/config/registry.ts new file mode 100644 index 00000000..138b048d --- /dev/null +++ b/src/tools/config/registry.ts @@ -0,0 +1,89 @@ +import {homedir} from 'node:os'; +import {join} from 'node:path'; +import {resolveCommand} from '../../providers/providers.js'; + +/** + * First-party registry of reviewed tool configuration adapters. It is not a + * plugin system: only entries here can ever be configured by NMSh, and an + * entry's `ownership` says exactly how (a managed fragment plus one reviewed + * include, the tool's own config CLI, Theme Bridge only, or read-only). + * Detection never grants write authority. /tools, /configure, /tmux, Ask and + * /dotfiles all read this one table. + */ + +/** structured: TOML/JSON/YAML data · command: a command-style config (tmux) · executable: code (Lua, Vimscript, shell). */ +export type ConfigClass = 'structured' | 'command' | 'executable'; +export type Ownership = 'managed-fragment' | 'native-cli' | 'theme-bridge' | 'read-only'; + +export interface ToolConfigEntry { + id: string; + label: string; + executable: string; + configClass: ConfigClass; + ownership: Ownership; + /** Config files this tool reads (for discovery and dotfiles mapping; never all writable). */ + locations: (env: NodeJS.ProcessEnv, home: string) => string[]; + /** When a supported change takes effect. */ + takesEffect: string; + /** What NMSh can configure, in plain words. */ + summary: string; + /** Dotfiles relative paths (inside a repo or Stow package) this adapter recognizes. */ + dotfiles: RegExp; + /** Whether /configure opens an editor for it. */ + configurable: boolean; +} + +const xdg = (env: NodeJS.ProcessEnv, home: string) => env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(home, '.config'); + +export const TOOL_CONFIG_REGISTRY: readonly ToolConfigEntry[] = [ + {id: 'tmux', label: 'tmux', executable: 'tmux', configClass: 'command', ownership: 'managed-fragment', configurable: true, + locations: (env, home) => [join(home, '.tmux.conf'), join(xdg(env, home), 'tmux', 'tmux.conf')], + takesEffect: 'New tmux servers through the include; a running server on Reload', + summary: 'Settings, prefix and key bindings, Status Studio, new panes start NMSh, Theme Bridge colors', + dotfiles: /(?:^|\/)(?:\.tmux\.conf|tmux\/tmux\.conf|\.config\/tmux\/tmux\.conf)$/u}, + {id: 'starship', label: 'Starship', executable: 'starship', configClass: 'structured', ownership: 'native-cli', configurable: true, + locations: (env, home) => [env.STARSHIP_CONFIG ?? join(xdg(env, home), 'starship.toml')], + takesEffect: 'The next prompt', summary: 'Module visibility through Starship\'s own config CLI, with a backup', + dotfiles: /(?:^|\/)(?:starship\.toml|\.config\/starship\.toml)$/u}, + {id: 'helix', label: 'Helix', executable: 'hx', configClass: 'structured', ownership: 'theme-bridge', configurable: false, + locations: (env, home) => [join(xdg(env, home), 'helix', 'config.toml')], + takesEffect: 'New Helix processes', summary: 'The generated NMSh theme and its one reviewed activation (Theme Bridge)', + dotfiles: /(?:^|\/)(?:helix\/config\.toml|\.config\/helix\/config\.toml)$/u}, + {id: 'bat', label: 'bat', executable: 'bat', configClass: 'structured', ownership: 'theme-bridge', configurable: false, + locations: (env, home) => [join(env.BAT_CONFIG_DIR ?? join(xdg(env, home), 'bat'), 'config')], + takesEffect: 'After a reviewed cache build', summary: 'A generated custom theme and BAT_THEME in NMSh shells (Theme Bridge)', + dotfiles: /(?:^|\/)(?:bat\/config|\.config\/bat\/config)$/u}, + {id: 'neovim', label: 'Neovim', executable: 'nvim', configClass: 'executable', ownership: 'theme-bridge', configurable: false, + locations: (env, home) => [join(xdg(env, home), 'nvim', 'init.lua'), join(xdg(env, home), 'nvim', 'init.vim')], + takesEffect: 'New Neovim processes', summary: 'A generated colorscheme and one reviewed include; Lua config is never rewritten', + dotfiles: /(?:^|\/)(?:nvim\/init\.(?:lua|vim)|\.config\/nvim\/init\.(?:lua|vim))$/u}, + {id: 'vim', label: 'Vim', executable: 'vim', configClass: 'executable', ownership: 'theme-bridge', configurable: false, + locations: (_env, home) => [join(home, '.vimrc'), join(home, '.vim', 'vimrc')], + takesEffect: 'New Vim processes', summary: 'A generated colorscheme and one reviewed include; Vimscript is never rewritten', + dotfiles: /(?:^|\/)(?:\.vimrc|\.vim\/vimrc|vimrc)$/u}, + {id: 'zsh', label: 'zsh', executable: 'zsh', configClass: 'executable', ownership: 'read-only', configurable: false, + locations: (_env, home) => [join(home, '.zshrc')], takesEffect: '—', summary: 'Inspect only; shell code is never sourced or rewritten (NMSh owns its composer)', + dotfiles: /(?:^|\/)(?:\.zshrc|\.zshenv|\.zprofile|zshrc)$/u}, + {id: 'bash', label: 'Bash', executable: 'bash', configClass: 'executable', ownership: 'read-only', configurable: false, + locations: (_env, home) => [join(home, '.bashrc')], takesEffect: '—', summary: 'Inspect only; shell code is never sourced or rewritten', + dotfiles: /(?:^|\/)(?:\.bashrc|\.bash_profile|bashrc)$/u}, + {id: 'fish', label: 'Fish', executable: 'fish', configClass: 'executable', ownership: 'read-only', configurable: false, + locations: (env, home) => [join(xdg(env, home), 'fish', 'config.fish')], takesEffect: '—', summary: 'Inspect only; shell code is never sourced or rewritten', + dotfiles: /(?:^|\/)(?:fish\/config\.fish|\.config\/fish\/config\.fish)$/u}, +]; + +export function toolConfigEntry(id: string): ToolConfigEntry | undefined { + return TOOL_CONFIG_REGISTRY.find(entry => entry.id === id || entry.executable === id); +} + +export const OWNERSHIP_LABELS: Record = { + 'managed-fragment': 'NMSh-managed file + one reviewed include', 'native-cli': 'the tool\'s own config CLI, with backup', + 'theme-bridge': 'Theme Bridge (generated theme + reviewed activation)', 'read-only': 'inspect only', +}; + +/** Installed facts for the registry (PATH lookups only; nothing is run). */ +export function registryFacts(env: NodeJS.ProcessEnv = process.env): Array { + return TOOL_CONFIG_REGISTRY.map(entry => ({...entry, installed: Boolean(resolveCommand(entry.executable, env.PATH ?? ''))})); +} + +export const configLocations = (entry: ToolConfigEntry, env: NodeJS.ProcessEnv = process.env) => entry.locations(env, env.HOME || homedir()); diff --git a/src/tools/config/tmux.ts b/src/tools/config/tmux.ts new file mode 100644 index 00000000..fc61fa5f --- /dev/null +++ b/src/tools/config/tmux.ts @@ -0,0 +1,371 @@ +import {existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {nmshConfigDirectory} from '../../configuration/paths.js'; + +/** + * tmux Tool Configuration: a typed model (option overrides, NMSh-owned key + * bindings, a Status Studio layout, the pane frontend) stored as NMSh data + * and rendered into ONE NMSh-managed tmux file. The user's tmux.conf only + * ever gains one reviewed include of that file; everything NMSh configures + * lives there, so updates are atomic rewrites of NMSh's own file and removal + * is exact. No arbitrary strings become tmux commands: every option, value, + * key, action and status module comes from the catalogs below, and status + * modules never use #(...) shell execution. + */ + +export type TmuxScope = 'server' | 'session' | 'window'; +export interface TmuxOptionDef { + id: string; + label: string; + scope: TmuxScope; + description: string; + /** Allowed values (enum) or an integer range. */ + values?: readonly string[]; + range?: [number, number]; + /** tmux's documented default, used for Effective when nothing else sets it. */ + defaultValue: string; + group: 'General' | 'Status' | 'Keys'; + guidance?: string; +} + +export const TMUX_OPTIONS: readonly TmuxOptionDef[] = [ + {id: 'mouse', label: 'Mouse', scope: 'session', values: ['on', 'off'], defaultValue: 'off', group: 'General', description: 'Mouse selects panes and windows, resizes panes and scrolls'}, + {id: 'base-index', label: 'First window number', scope: 'session', range: [0, 9], defaultValue: '0', group: 'General', description: 'Number of the first window'}, + {id: 'pane-base-index', label: 'First pane number', scope: 'window', range: [0, 9], defaultValue: '0', group: 'General', description: 'Number of the first pane'}, + {id: 'renumber-windows', label: 'Renumber windows', scope: 'session', values: ['on', 'off'], defaultValue: 'off', group: 'General', description: 'Close gaps in window numbers when a window closes'}, + {id: 'history-limit', label: 'Scrollback lines', scope: 'session', range: [1000, 1_000_000], defaultValue: '2000', group: 'General', description: 'Lines kept per pane'}, + {id: 'escape-time', label: 'Escape delay (ms)', scope: 'server', range: [0, 2000], defaultValue: '500', group: 'General', description: 'How long tmux waits after Esc for a key sequence'}, + {id: 'focus-events', label: 'Focus events', scope: 'server', values: ['on', 'off'], defaultValue: 'off', group: 'General', description: 'Pass terminal focus in/out to programs'}, + {id: 'extended-keys', label: 'Extended keys', scope: 'server', values: ['on', 'off', 'always'], defaultValue: 'off', group: 'General', description: 'Extended key reporting for programs that ask for it'}, + {id: 'set-clipboard', label: 'Clipboard', scope: 'server', values: ['on', 'off', 'external'], defaultValue: 'external', group: 'General', description: 'Whether tmux and programs may set the terminal clipboard (OSC 52)'}, + {id: 'default-terminal', label: 'Terminal type', scope: 'server', values: ['tmux-256color', 'screen-256color'], defaultValue: 'screen', group: 'General', + description: 'TERM inside tmux', guidance: 'tmux-256color needs its terminfo entry on this system and on hosts you SSH to; screen-256color works almost everywhere'}, + {id: 'status', label: 'Status line', scope: 'session', values: ['on', 'off', '2'], defaultValue: 'on', group: 'Status', description: 'Show the status line (2 = two lines)'}, + {id: 'status-position', label: 'Status position', scope: 'session', values: ['bottom', 'top'], defaultValue: 'bottom', group: 'Status', description: 'Top or bottom of the window'}, + {id: 'status-interval', label: 'Status refresh (s)', scope: 'session', range: [1, 3600], defaultValue: '15', group: 'Status', description: 'Seconds between status refreshes'}, + {id: 'status-justify', label: 'Window list', scope: 'session', values: ['left', 'centre', 'right', 'absolute-centre'], defaultValue: 'left', group: 'Status', description: 'Where the window list sits'}, + {id: 'status-keys', label: 'Command prompt keys', scope: 'session', values: ['emacs', 'vi'], defaultValue: 'emacs', group: 'Keys', description: 'Key style in tmux\'s command prompt'}, + {id: 'mode-keys', label: 'Copy mode keys', scope: 'window', values: ['emacs', 'vi'], defaultValue: 'emacs', group: 'Keys', description: 'Key style in copy mode'}, +]; + +export const TMUX_ACTIONS = { + 'send-prefix': {label: 'Send prefix', command: 'send-prefix'}, + 'new-window': {label: 'New window', command: 'new-window -c "#{pane_current_path}"'}, + 'split-vertical': {label: 'Split left/right', command: 'split-window -h -c "#{pane_current_path}"'}, + 'split-horizontal': {label: 'Split top/bottom', command: 'split-window -v -c "#{pane_current_path}"'}, + 'next-window': {label: 'Next window', command: 'next-window'}, + 'previous-window': {label: 'Previous window', command: 'previous-window'}, + 'pane-left': {label: 'Select pane left', command: 'select-pane -L'}, + 'pane-right': {label: 'Select pane right', command: 'select-pane -R'}, + 'pane-up': {label: 'Select pane up', command: 'select-pane -U'}, + 'pane-down': {label: 'Select pane down', command: 'select-pane -D'}, + 'resize-left': {label: 'Resize pane left', command: 'resize-pane -L 5'}, + 'resize-right': {label: 'Resize pane right', command: 'resize-pane -R 5'}, + 'resize-up': {label: 'Resize pane up', command: 'resize-pane -U 5'}, + 'resize-down': {label: 'Resize pane down', command: 'resize-pane -D 5'}, + 'copy-mode': {label: 'Copy mode', command: 'copy-mode'}, + 'reload': {label: 'Reload NMSh-managed config', command: 'RELOAD'}, + 'detach': {label: 'Detach', command: 'detach-client'}, +} as const; +export type TmuxActionId = keyof typeof TMUX_ACTIONS; +export const TMUX_ACTION_IDS = Object.keys(TMUX_ACTIONS) as TmuxActionId[]; +export type TmuxTable = 'prefix' | 'root'; +export interface TmuxBinding {key: string; table: TmuxTable; action: TmuxActionId} + +/** tmux's own default prefix-table bindings for the catalog actions (for "default" provenance and conflicts). */ +export const TMUX_DEFAULT_BINDINGS: readonly TmuxBinding[] = [ + {key: 'c', table: 'prefix', action: 'new-window'}, {key: '%', table: 'prefix', action: 'split-vertical'}, {key: '"', table: 'prefix', action: 'split-horizontal'}, + {key: 'n', table: 'prefix', action: 'next-window'}, {key: 'p', table: 'prefix', action: 'previous-window'}, {key: 'Left', table: 'prefix', action: 'pane-left'}, + {key: 'Right', table: 'prefix', action: 'pane-right'}, {key: 'Up', table: 'prefix', action: 'pane-up'}, {key: 'Down', table: 'prefix', action: 'pane-down'}, + {key: '[', table: 'prefix', action: 'copy-mode'}, {key: 'd', table: 'prefix', action: 'detach'}, +]; + +export const STATUS_MODULES = { + session: {label: 'Session name', format: '#S'}, + windowIndex: {label: 'Window index', format: '#I'}, + windowName: {label: 'Window name', format: '#W'}, + paneIndex: {label: 'Pane index', format: '#P'}, + paneCommand: {label: 'Pane command', format: '#{pane_current_command}'}, + paneCwd: {label: 'Pane directory', format: '#{b:pane_current_path}'}, + host: {label: 'Host', format: '#h'}, + date: {label: 'Date', format: '%Y-%m-%d'}, + time: {label: 'Time', format: '%H:%M'}, +} as const; +export type StatusModuleId = keyof typeof STATUS_MODULES; +export const STATUS_MODULE_IDS = Object.keys(STATUS_MODULES) as StatusModuleId[]; +export const STATUS_SEPARATORS = ['·', '|', '│', '›'] as const; + +export interface TmuxStatusLayout {left: StatusModuleId[]; right: StatusModuleId[]; separator: typeof STATUS_SEPARATORS[number]; windowFormat: 'index-name' | 'name' | 'index'} + +export interface TmuxModel { + /** NMSh overrides only; absent options inherit (user config or tmux default). */ + options: Record; + /** Prefix key, e.g. C-a; absent inherits. */ + prefix?: string; + /** NMSh-owned bindings (only these are written; user bindings are never removed). */ + bindings: TmuxBinding[]; + /** Status Studio; absent leaves status formats alone. */ + status?: TmuxStatusLayout; + /** New panes/windows without an explicit command: the default shell, or NMSh. */ + frontend: 'shell' | 'nmsh'; +} + +export const DEFAULT_TMUX_MODEL = (): TmuxModel => ({options: {}, bindings: [], frontend: 'shell'}); + +const KEY = /^(?:(?:C|M|S)-){0,3}(?:[a-zA-Z0-9]|F(?:[1-9]|1[0-2])|Up|Down|Left|Right|Space|Enter|Tab|BSpace|Escape|Home|End|PPage|NPage|[\\|\-_=+[\];',./`%"!@#$^&*()<>?:{}~])$/u; +export const validTmuxKey = (key: string): boolean => KEY.test(key); +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +export function validOptionValue(option: TmuxOptionDef, value: string): boolean { + if (option.values) return option.values.includes(value); + if (option.range) { const number = Number(value); return /^\d{1,7}$/u.test(value) && number >= option.range[0] && number <= option.range[1]; } + return false; +} + +export function normalizeTmuxModel(value: unknown): TmuxModel { + const model = DEFAULT_TMUX_MODEL(); + if (!isRecord(value)) return model; + if (isRecord(value.options)) for (const [id, raw] of Object.entries(value.options)) { + const option = TMUX_OPTIONS.find(item => item.id === id); + if (option && typeof raw === 'string' && validOptionValue(option, raw)) model.options[id] = raw; + } + if (typeof value.prefix === 'string' && validTmuxKey(value.prefix)) model.prefix = value.prefix; + if (Array.isArray(value.bindings)) for (const item of value.bindings.slice(0, 64)) { + if (isRecord(item) && typeof item.key === 'string' && validTmuxKey(item.key) && (item.table === 'prefix' || item.table === 'root') + && TMUX_ACTION_IDS.includes(item.action as TmuxActionId) && !model.bindings.some(binding => binding.key === item.key && binding.table === item.table)) { + model.bindings.push({key: item.key, table: item.table, action: item.action as TmuxActionId}); + } + } + if (isRecord(value.status)) { + const modules = (list: unknown) => Array.isArray(list) ? list.filter((id): id is StatusModuleId => STATUS_MODULE_IDS.includes(id as StatusModuleId)).slice(0, 6) : []; + const separator = STATUS_SEPARATORS.includes(value.status.separator as typeof STATUS_SEPARATORS[number]) ? value.status.separator as typeof STATUS_SEPARATORS[number] : '·'; + const windowFormat = value.status.windowFormat === 'name' || value.status.windowFormat === 'index' ? value.status.windowFormat : 'index-name'; + model.status = {left: modules(value.status.left), right: modules(value.status.right), separator, windowFormat}; + } + if (value.frontend === 'nmsh') model.frontend = 'nmsh'; + return model; +} + +export const tmuxModelPath = (env: NodeJS.ProcessEnv = process.env) => join(nmshConfigDirectory(env), 'tools', 'tmux.json'); +export const tmuxManagedPath = (env: NodeJS.ProcessEnv = process.env) => join(nmshConfigDirectory(env), 'theme-bridge', 'tmux', 'nmsh.tmux.conf'); + +export function loadTmuxModel(env: NodeJS.ProcessEnv = process.env): TmuxModel { + try { return normalizeTmuxModel(JSON.parse(readFileSync(tmuxModelPath(env), 'utf8'))); } catch { return DEFAULT_TMUX_MODEL(); } +} + +export function saveTmuxModel(model: TmuxModel, env: NodeJS.ProcessEnv = process.env): void { + const path = tmuxModelPath(env); + mkdirSync(dirname(path), {recursive: true, mode: 0o700}); + const staged = `${path}.${process.pid}.tmp`; + writeFileSync(staged, `${JSON.stringify(normalizeTmuxModel(model), null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); + renameSync(staged, path); +} + +/** A path safe inside single quotes in tmux and /bin/sh. */ +export const quotablePath = (path: string): boolean => /^\/[^'"\\\u0000-\u001f\u007f$`]+$/u.test(path); + +/** + * The pane frontend command: run NMSh, unless this tmux server was itself + * started inside an NMSh session (NMSH_ACTIVE inherited), where NMSh's own + * recursion guard would refuse; then the user's login shell starts instead. + * Run through /bin/sh so it works whatever tmux's default-shell is. + */ +export function frontendCommand(nmsh: string): string | undefined { + if (!quotablePath(nmsh)) return undefined; + return `exec /bin/sh -c 'if [ "$NMSH_ACTIVE" = 1 ]; then exec "\${SHELL:-/bin/sh}" -l; else exec ${nmsh}; fi'`; +} + +function setDirective(option: TmuxOptionDef, value: string): string { + return option.scope === 'server' ? `set -s ${option.id} ${value}` : option.scope === 'window' ? `setw -g ${option.id} ${value}` : `set -g ${option.id} ${value}`; +} + +const tmuxQuote = (text: string) => `"${text.replace(/[\\"$]/gu, match => `\\${match}`)}"`; + +function statusFormat(modules: readonly StatusModuleId[], separator: string): string { + return modules.length ? ` ${modules.map(id => STATUS_MODULES[id].format).join(` ${separator} `)} ` : ''; +} + +/** + * The managed tmux file: typed overrides, NMSh bindings, Status Studio + * formats, the pane frontend, then the Theme Bridge colors (when generated). + */ +export function renderTmuxConfig(model: TmuxModel, paths: {self: string; theme: string; nmsh?: string}): string { + const lines = ['# Generated by NMSh Tool Configuration and Theme Bridge. NMSh replaces this file; your own tmux.conf stays yours.']; + for (const option of TMUX_OPTIONS) { + const value = model.options[option.id]; + if (value !== undefined) lines.push(setDirective(option, value)); + } + if (model.prefix) lines.push(`set -g prefix ${model.prefix}`); + for (const binding of model.bindings) { + const action = TMUX_ACTIONS[binding.action]; + const command = binding.action === 'reload' ? `source-file '${paths.self}' \\; display-message "NMSh tmux config reloaded"` : action.command; + lines.push(`bind-key ${binding.table === 'root' ? '-n ' : ''}${tmuxKey(binding.key)} ${command}`); + } + if (model.status) { + lines.push(`set -g status-left ${tmuxQuote(statusFormat(model.status.left, model.status.separator))}`); + lines.push(`set -g status-right ${tmuxQuote(statusFormat(model.status.right, model.status.separator))}`); + const window = model.status.windowFormat === 'name' ? ' #W ' : model.status.windowFormat === 'index' ? ' #I ' : ' #I:#W '; + lines.push(`setw -g window-status-format ${tmuxQuote(window)}`, `setw -g window-status-current-format ${tmuxQuote(window)}`); + } + if (model.frontend === 'nmsh' && paths.nmsh) { + const command = frontendCommand(paths.nmsh); + if (command) lines.push(`set -g default-command ${tmuxQuote(command)}`); + } + if (quotablePath(paths.theme)) lines.push(`source-file -q '${paths.theme}'`); + return `${lines.join('\n')}\n`; +} + +function tmuxKey(key: string): string { + return /^[A-Za-z0-9]+$|^(?:(?:C|M|S)-)+[A-Za-z0-9]+$/u.test(key) ? key : `'${key.replace(/'/gu, "\\'")}'`; +} + +/** Validates the managed file line by line against exactly what renderTmuxConfig can produce. */ +export function validateTmuxConfig(content: string): boolean { + const options = new Set(TMUX_OPTIONS.map(option => option.id)); + const commands = new Set(Object.values(TMUX_ACTIONS).map(action => action.command)); + return content.split('\n').every(line => { + if (line === '' || line.startsWith('# ')) return true; + if (/^set -g prefix \S+$/u.test(line)) return validTmuxKey(line.slice(14)); + let match = /^(?:set -s|set -g|setw -g) ([a-z-]+) (\S+)$/u.exec(line); + if (match) { + const option = TMUX_OPTIONS.find(item => item.id === match![1]); + return options.has(match[1]!) && Boolean(option) && validOptionValue(option!, match[2]!); + } + match = /^bind-key (?:-n )?(\S+) (.+)$/u.exec(line); + if (match) return commands.has(match[2]!) || /^source-file '\/[^']+' \\; display-message "NMSh tmux config reloaded"$/u.test(match[2]!); + if (/^set -g status-(?:left|right) "[^"\\$]*"$/u.test(line) || /^setw -g window-status(?:-current)?-format " #[IW](?::#W)? "$/u.test(line)) { + const formats = (line.match(/"([^"]*)"/u)?.[1] ?? ''); + return !/#\(/u.test(formats); + } + if (/^set -g default-command "exec \/bin\/sh -c 'if \[ \\"\\\$NMSH_ACTIVE\\" = 1 \]; then exec \\"\\\$\{SHELL:-\/bin\/sh\}\\" -l; else exec \/[^'"\\$`]+; fi'"$/u.test(line)) return true; + return /^source-file -q '\/[^']+'$/u.test(line); + }); +} + +// ---- Reading the user's own tmux config (supported subset only) --------------------- + +export interface TmuxImport { + options: Record; + prefix?: string; + bindings: TmuxBinding[]; + /** Lines that are user-owned and were not understood (never evaluated). */ + unsupported: string[]; + /** Dynamic or recursive directives that were deliberately not followed. */ + ignored: string[]; +} + +const OPTION_ALIASES: Record = {}; + +/** + * Conservative parser for canonical set/set-option/set-window-option, + * bind-key and unbind-key lines. if-shell, run-shell, source-file, %if, + * command substitutions and anything else are reported, never evaluated. + */ +export function parseTmuxConfig(text: string): TmuxImport { + const result: TmuxImport = {options: {}, bindings: [], unsupported: [], ignored: []}; + for (const raw of text.split(/\r?\n/u).slice(0, 5000)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + if (/^(?:if-shell|if|run-shell|run|source-file|source|%if|%else|%endif|%hidden)\b/u.test(line) || /#\(|\$\(|`/u.test(line)) { result.ignored.push(line.slice(0, 120)); continue; } + const set = /^(set-option|set|set-window-option|setw)((?:\s+-[a-zA-Z]+)*)\s+([a-z@][a-z0-9-]*)\s+(.+)$/u.exec(line); + if (set) { + const id = OPTION_ALIASES[set[3]!] ?? set[3]!; + const value = set[4]!.trim().replace(/^["']|["']$/gu, ''); + const option = TMUX_OPTIONS.find(item => item.id === id); + if (id === 'prefix' && validTmuxKey(value)) result.prefix = value; + else if (option && validOptionValue(option, value)) result.options[id] = value; + else result.unsupported.push(line.slice(0, 120)); + continue; + } + const bind = /^(?:bind-key|bind)((?:\s+-[a-zA-Z]+(?:\s+[a-z-]+)?)*)\s+(\S+)\s+(.+)$/u.exec(line); + if (bind) { + const flags = bind[1] ?? ''; + const table: TmuxTable | undefined = /-n\b/u.test(flags) || /-T\s+root/u.test(flags) ? 'root' : /-T\s+/u.test(flags) && !/-T\s+prefix/u.test(flags) ? undefined : 'prefix'; + const key = bind[2]!.replace(/^['"]|['"]$/gu, ''); + const command = bind[3]!.trim(); + const action = TMUX_ACTION_IDS.find(id => TMUX_ACTIONS[id].command === command || TMUX_ACTIONS[id].command.split(' -c ')[0] === command); + if (table && action && validTmuxKey(key)) result.bindings.push({key, table, action}); + else result.unsupported.push(line.slice(0, 120)); + continue; + } + result.unsupported.push(line.slice(0, 120)); + } + return result; +} + +/** The user's own tmux config file (existing one preferred), read bounded; undefined when absent. */ +export function readUserTmuxConfig(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): {path: string; text: string} | undefined { + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(home, '.config'); + for (const path of [join(home, '.tmux.conf'), join(xdg, 'tmux', 'tmux.conf')]) { + try { + if (!existsSync(path) || statSync(path).size > 512 * 1024) continue; + return {path, text: readFileSync(path, 'utf8')}; + } catch { continue; } + } + return undefined; +} + +export interface FieldProvenance {effective: string; override?: string; user?: string; source: 'NMSh managed file' | 'your tmux config' | 'tmux default'} + +/** Effective value and where it comes from: NMSh override, then the user's config, then tmux's default. */ +export function optionProvenance(option: TmuxOptionDef, model: TmuxModel, user: TmuxImport | undefined): FieldProvenance { + const override = model.options[option.id]; + const mine = user?.options[option.id]; + if (override !== undefined) return {effective: override, override, ...(mine !== undefined ? {user: mine} : {}), source: 'NMSh managed file'}; + if (mine !== undefined) return {effective: mine, user: mine, source: 'your tmux config'}; + return {effective: option.defaultValue, source: 'tmux default'}; +} + +/** NMSh bindings that would override one of the user's own bindings for the same key and table. */ +export function bindingConflicts(model: TmuxModel, user: TmuxImport | undefined): Array<{binding: TmuxBinding; user: TmuxBinding}> { + return model.bindings.flatMap(binding => { + const theirs = user?.bindings.find(item => item.key === binding.key && item.table === binding.table && item.action !== binding.action); + return theirs ? [{binding, user: theirs}] : []; + }); +} + +/** Typed changes Ask, dotfiles and the panel all apply through. */ +export type TmuxChange = + | {kind: 'option'; id: string; value?: string} + | {kind: 'prefix'; key?: string} + | {kind: 'binding'; binding: TmuxBinding; remove?: boolean} + | {kind: 'status'; layout?: TmuxStatusLayout} + | {kind: 'frontend'; value: 'shell' | 'nmsh'}; + +export function applyTmuxChange(model: TmuxModel, change: TmuxChange): TmuxModel | {error: string} { + const next = structuredClone(model); + if (change.kind === 'option') { + const option = TMUX_OPTIONS.find(item => item.id === change.id); + if (!option) return {error: `NMSh has no supported tmux setting ${change.id}.`}; + if (change.value === undefined) delete next.options[change.id]; + else if (!validOptionValue(option, change.value)) return {error: `${option.label}: ${change.value} is not one of the documented values.`}; + else next.options[change.id] = change.value; + } else if (change.kind === 'prefix') { + if (change.key === undefined) { delete next.prefix; next.bindings = next.bindings.filter(binding => binding.action !== 'send-prefix'); } + else if (!validTmuxKey(change.key)) return {error: `${change.key} is not a key NMSh can bind safely.`}; + else { + next.prefix = change.key; + next.bindings = [...next.bindings.filter(binding => binding.action !== 'send-prefix'), {key: change.key, table: 'prefix', action: 'send-prefix'}]; + } + } else if (change.kind === 'binding') { + if (!validTmuxKey(change.binding.key) || !TMUX_ACTION_IDS.includes(change.binding.action)) return {error: 'That binding is not supported.'}; + next.bindings = next.bindings.filter(binding => !(binding.key === change.binding.key && binding.table === change.binding.table)); + if (!change.remove) next.bindings.push(change.binding); + } else if (change.kind === 'status') { + if (change.layout) next.status = change.layout; else delete next.status; + } else next.frontend = change.value; + return normalizeTmuxModel(next); +} + +/** One plain sentence per change, for reviews. */ +export function describeTmuxChange(change: TmuxChange): string { + if (change.kind === 'option') { + const option = TMUX_OPTIONS.find(item => item.id === change.id); + return `${option?.label ?? change.id}: ${change.value === undefined ? 'inherit (remove NMSh override)' : change.value}`; + } + if (change.kind === 'prefix') return change.key ? `Prefix: ${change.key} (and ${change.key} ${change.key} sends it through)` : 'Prefix: inherit'; + if (change.kind === 'binding') return `${change.remove ? 'Remove' : 'Bind'} ${change.binding.table === 'root' ? '' : 'prefix '}${change.binding.key} → ${TMUX_ACTIONS[change.binding.action].label}`; + if (change.kind === 'status') return change.layout ? 'Status line layout from Status Studio' : 'Status line layout: inherit'; + return `New panes and windows start ${change.value === 'nmsh' ? 'NMSh' : 'your default shell'}`; +} diff --git a/src/tools/config/tmuxManaged.ts b/src/tools/config/tmuxManaged.ts new file mode 100644 index 00000000..cb37f81c --- /dev/null +++ b/src/tools/config/tmuxManaged.ts @@ -0,0 +1,34 @@ +import {existsSync, realpathSync} from 'node:fs'; +import {resolveCommand} from '../../providers/providers.js'; +import {artifactPath, loadLedger, writeArtifact} from '../../themeBridge/artifacts.js'; +import {DEFAULT_TMUX_MODEL, loadTmuxModel, quotablePath, renderTmuxConfig, validateTmuxConfig, type TmuxModel} from './tmux.js'; + +/** + * Writing the one NMSh-managed tmux file (staged, validated, atomic, + * ownership-checked through the Theme Bridge ledger). It carries the Tool + * Configuration settings and sources the Theme Bridge colors. + */ + +/** The absolute NMSh launcher for new tmux panes: the `nmsh` on PATH, resolved; never a guess. */ +export function nmshExecutable(env: NodeJS.ProcessEnv = process.env): string | undefined { + const found = resolveCommand('nmsh', env.PATH ?? ''); + if (!found) return undefined; + try { const real = realpathSync(found); return quotablePath(real) ? real : quotablePath(found) ? found : undefined; } catch { return quotablePath(found) ? found : undefined; } +} + +export const modelIsEmpty = (model: TmuxModel) => JSON.stringify(model) === JSON.stringify(DEFAULT_TMUX_MODEL()); + +/** Needed when NMSh configures tmux, Theme Bridge styles it, or the file already exists as NMSh's. */ +export function tmuxManagedNeeded(model: TmuxModel, bridgeActive: boolean, env: NodeJS.ProcessEnv = process.env): boolean { + return !modelIsEmpty(model) || bridgeActive || Boolean(loadLedger(env).entries.tmuxConfig); +} + +export function writeTmuxManaged(model: TmuxModel = loadTmuxModel(), env: NodeJS.ProcessEnv = process.env): {ok: true; path: string; changed: boolean} | {ok: false; error: string} { + const nmsh = model.frontend === 'nmsh' ? nmshExecutable(env) : undefined; + if (model.frontend === 'nmsh' && !nmsh) return {ok: false, error: 'The nmsh launcher was not found on PATH (or its path cannot be quoted safely), so new panes cannot start NMSh.'}; + const path = artifactPath('tmuxConfig', env); + const content = renderTmuxConfig(model, {self: path, theme: artifactPath('tmux', env), ...(nmsh ? {nmsh} : {})}); + return writeArtifact('tmuxConfig', content, validateTmuxConfig, {mode: 'follow', themeRef: '', format: 'tmux-managed', formatVersion: 1}, env); +} + +export const tmuxManagedExists = (env: NodeJS.ProcessEnv = process.env) => existsSync(artifactPath('tmuxConfig', env)); diff --git a/src/ui/CommandPalette.ts b/src/ui/CommandPalette.ts index 97572f8e..2a2bec36 100644 --- a/src/ui/CommandPalette.ts +++ b/src/ui/CommandPalette.ts @@ -41,8 +41,9 @@ const ARGUMENT_COMMANDS = new Set(['/copy N']); export function paletteItems(): PaletteItem[] { const items: PaletteItem[] = [{id: 'inspector:toggle', label: 'Toggle command inspector', detail: 'Local token knowledge at the composer cursor', category: 'Command', action: {kind: 'toggleInspector'}}]; for (const command of slashCommands) { - if (ARGUMENT_COMMANDS.has(command.name)) continue; - items.push({id: `slash:${command.name}`, label: command.name, detail: command.description, category: 'Command', + // Aliases resolve to the same surface as their canonical command: one palette entry each. + if (ARGUMENT_COMMANDS.has(command.name) || command.alias) continue; + items.push({id: `slash:${command.name}`, label: command.title ? `${command.title} · ${command.name}` : command.name, detail: command.description, category: 'Command', action: {kind: 'slash', command: command.insertion.trim()}}); } for (const entry of SETTINGS_ENTRIES) { diff --git a/src/ui/RowPanel.ts b/src/ui/RowPanel.ts new file mode 100644 index 00000000..230581a6 --- /dev/null +++ b/src/ui/RowPanel.ts @@ -0,0 +1,64 @@ +import type {Key} from '../terminal/keys.js'; +import type {PromptConfiguration} from '../prompt/configuration.js'; +import {adjustSettingsRow, settingsRowValue, SETTINGS_ROWS, toggleSettingsRow, type SettingsRow} from './SettingsPanel.js'; +import {framePanel} from './PanelShell.js'; +import {renderControls} from './controls.js'; +import {foreground, UI_COLORS} from './palette.js'; +import {GLYPHS} from './glyphs.js'; +import {padCells, truncateAnsi} from '../util/text.js'; + +/** + * A focused panel over a fixed set of the canonical Settings rows (for + * example the Status Strip). It holds no settings of its own: every change + * is the Settings row's own `select`, applied through the normal + * configuration path by the caller. + */ +export interface RowPanelState { + title: string; + subtitle: string; + rowIds: readonly string[]; + selected: number; +} + +export function createRowPanel(title: string, subtitle: string, rowIds: readonly string[]): RowPanelState { + return {title, subtitle, rowIds, selected: 0}; +} + +function rows(state: RowPanelState, config: PromptConfiguration): SettingsRow[] { + return state.rowIds.map(id => SETTINGS_ROWS.find(row => row.id === id)).filter((row): row is SettingsRow => Boolean(row) && (row!.when?.(config) ?? true)); +} + +export type RowPanelAction = {kind: 'close'} | {kind: 'change'; configuration: PromptConfiguration}; + +export function rowPanelKey(state: RowPanelState, key: Key, config: PromptConfiguration): RowPanelAction | undefined { + const visible = rows(state, config); + state.selected = Math.max(0, Math.min(state.selected, visible.length - 1)); + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (key.kind === 'up' || key.kind === 'down') { state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + visible.length) % visible.length; return undefined; } + const row = visible[state.selected]; + if (!row) return undefined; + const next = key.kind === 'left' || key.kind === 'right' ? adjustSettingsRow(row, config, key.kind === 'left' ? -1 : 1) + : key.kind === 'enter' || (key.kind === 'text' && key.value === ' ') ? toggleSettingsRow(row, config) : undefined; + return next ? {kind: 'change', configuration: next} : undefined; +} + +export function renderRowPanel(state: RowPanelState, config: PromptConfiguration, columns: number, height: number, preview: readonly string[]): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001B[0m'; + const visible = rows(state, config); + const out = [` ${primary}${state.title}${reset} ${subtle}${state.subtitle}${reset}`, '']; + visible.forEach((row, index) => { + const selected = index === state.selected; + const value = settingsRowValue(row, config) ?? ''; + const indent = row.parent ? ' ' : ''; + out.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${indent}${selected ? primary : secondary}${padCells(row.label.trim(), 22 - indent.length)}${reset}${selected ? `${accent}‹ ${value} ›${reset}` : value}`); + }); + const description = visible[state.selected]?.description; + if (description && height >= out.length + preview.length + 6) out.push('', ` ${subtle}${description}${reset}`); + if (preview.length && height >= out.length + preview.length + 4) out.push('', ` ${subtle}Preview${reset}`, ...preview); + out.push('', renderControls([['↑↓', 'select'], ['←→', 'change'], ['Esc', 'close']])); + return framePanel(out.map(line => truncateAnsi(line, columns)), columns).slice(0, Math.max(1, height)); +} diff --git a/tests/askConcepts.test.ts b/tests/askConcepts.test.ts index 4af5ad18..21297e6a 100644 --- a/tests/askConcepts.test.ts +++ b/tests/askConcepts.test.ts @@ -51,7 +51,7 @@ const MATRIX: Array<[string, string]> = [ ['change cursor blink', '/cursor'], ['change syntax highlighting', '/syntax'], ['change transcript layout', '/layout'], ['open the screensaver', '/screensaver'], ['show agent activity', '/agents'], ['what version am i running', '/version'], ['check for updates', '/update'], ['change keyboard shortcuts', '/keyboard'], ['open session presets', '/presets'], ['change live activity colors', '/activity'], - ['change the welcome screen', '/providers'], ['configure the status strip', 'answer:/settings'], ['change nerd font icons', 'answer:/settings'], + ['change the welcome screen', '/providers'], ['configure the status strip', '/strip'], ['change nerd font icons', '/glyphs'], ['use nushell', 'unsupported'], ['open a new tab', 'unsupported'], ]; diff --git a/tests/autocomplete.test.ts b/tests/autocomplete.test.ts index 0f95f68d..cb1bfba6 100644 --- a/tests/autocomplete.test.ts +++ b/tests/autocomplete.test.ts @@ -4,8 +4,9 @@ import {parseSlashCommand, slashSuggestions, suggestionWindow} from '../src/comm import {calculateScreenLayout} from '../src/app/layout.js'; test('slash autocomplete exposes copy variants and help', () => { - assert.deepEqual(slashSuggestions('/co').map(item => item.name), ['/copy', '/copy N', '/config']); - assert.deepEqual(slashSuggestions('/h').map(item => item.name), ['/help', '/history']); + assert.deepEqual(slashSuggestions('/co').map(item => item.name), ['/copy', '/copy N', '/config', '/composer', '/configure']); + assert.deepEqual(slashSuggestions('/h').map(item => item.name), ['/help', '/history', '/history-provider']); + assert.deepEqual(parseSlashCommand('/history-provider'), {kind: 'providers', family: 'history'}, '/history stays history search'); assert.deepEqual(slashSuggestions('/z').map(item => item.name), ['/zsh']); assert.deepEqual(parseSlashCommand('/clear'), {kind: 'clear'}); assert.deepEqual(parseSlashCommand('/resume'), {kind: 'resume'}); diff --git a/tests/columnGutter.test.ts b/tests/columnGutter.test.ts index 5a91b9ac..04ec49b7 100644 --- a/tests/columnGutter.test.ts +++ b/tests/columnGutter.test.ts @@ -47,7 +47,7 @@ test('/providers: the longest family name keeps a gutter before its provider', ( assertGutter(rows, 'Directory navigation'); assertGutter(rows, 'Local understanding'); // Family rows share one data column regardless of the selection marker. - const family = rows.filter(row => / \[active\]/u.test(row) && !row.includes('is in use') && !row.startsWith(' ')); + const family = rows.filter(row => /● Active|fallback →/u.test(row) && !row.startsWith(' ')); const dataColumn = (row: string) => { const match = /\S {2,}(?=\S)/u.exec(row)!; return displayWidth(row.slice(0, match.index + match[0].length)); }; assert.ok(family.length > 3); assert.equal(new Set(family.map(dataColumn)).size, 1, family.join('\n')); diff --git a/tests/commandPalette.test.ts b/tests/commandPalette.test.ts index 7d9b5c3e..fb785b2a 100644 --- a/tests/commandPalette.test.ts +++ b/tests/commandPalette.test.ts @@ -17,7 +17,9 @@ test('one registry lists slash commands, settings pages, config rows and explici const items = paletteItems(); const ids = new Set(items.map(item => item.id)); assert.equal(ids.size, items.length, 'ids are unique'); - for (const command of slashCommands.filter(command => command.name !== '/copy N')) assert.ok(ids.has(`slash:${command.name}`), command.name); + // Aliases (/composer, /pickers, /status-strip) resolve to their canonical command's one entry. + for (const command of slashCommands.filter(command => command.name !== '/copy N' && !command.alias)) assert.ok(ids.has(`slash:${command.name}`), command.name); + for (const command of slashCommands.filter(command => command.alias)) assert.ok(!ids.has(`slash:${command.name}`) && ids.has(`slash:${command.alias}`), command.name); for (const entry of SETTINGS_ENTRIES) assert.ok(ids.has(`open:${entry.id}`), entry.id); for (const row of SETTINGS_ROWS) assert.ok(ids.has(`config:${row.id}`), row.id); for (const item of items) { diff --git a/tests/dotfilesCommands.test.ts b/tests/dotfilesCommands.test.ts new file mode 100644 index 00000000..062160ab --- /dev/null +++ b/tests/dotfilesCommands.test.ts @@ -0,0 +1,167 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {chezmoiTarget, isRemoteSource, scanDotfiles, type ScanResult} from '../src/dotfiles/scan.js'; +import {applyPlan, buildPlan, reviewLines} from '../src/dotfiles/plan.js'; +import {createDotfilesPanel, dotfilesKey} from '../src/dotfiles/DotfilesPanel.js'; +import {parseSlashCommand, slashCommands} from '../src/commands/slashCommands.js'; +import {paletteItems} from '../src/ui/CommandPalette.js'; +import {helpMarkdown} from '../src/help/helpContent.js'; + +const sandbox = () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-dotfiles-')); + const home = join(root, 'home'); + const repo = join(root, 'repo'); + mkdirSync(home); + mkdirSync(repo); + const put = (path: string, text: string) => { mkdirSync(dirname(join(repo, path)), {recursive: true}); writeFileSync(join(repo, path), text); }; + return {root, home, repo, put, env: {HOME: home, XDG_CONFIG_HOME: join(home, '.config')} as NodeJS.ProcessEnv, done: () => rmSync(root, {recursive: true, force: true})}; +}; +const snapshot = (dir: string): string => readdirSync(dir, {recursive: true}).map(String).sort().map(name => { + const info = statSync(join(dir, name), {throwIfNoEntry: false}); + return `${name}:${info?.isFile() ? readFileSync(join(dir, name), 'utf8') : ''}`; +}).join('\n'); +const scan = (dir: string) => { const result = scanDotfiles(dir); assert.ok(!('error' in result)); return result as ScanResult; }; + +test('dotfiles: plain, Stow and chezmoi layouts are recognized; scripts are listed and never run', () => { + const box = sandbox(); + try { + const marker = join(box.root, 'ran'); + box.put('install.sh', `#!/bin/sh\ntouch '${marker}'\n`); + chmodSync(join(box.repo, 'install.sh'), 0o755); + box.put('.tmux.conf', 'set -g mouse on\n'); + box.put('.zshrc', 'echo hi\n'); + const plain = scan(box.repo); + assert.equal(plain.type, 'plain'); + assert.deepEqual(plain.found.map(file => file.tool.id).sort(), ['tmux', 'zsh']); + assert.deepEqual(plain.scripts, ['install.sh']); + const items = buildPlan(plain, box.env); + assert.equal(items.find(item => item.file.tool.id === 'zsh')!.kind, 'inspect', 'shell rc is executable config: inspect only'); + assert.equal(readdirSync(box.root).includes('ran'), false); + } finally { box.done(); } + const stow = sandbox(); + try { + stow.put('tmux/.tmux.conf', 'set -g mouse on\n'); + stow.put('starship/.config/starship.toml', 'add_newline = false\n'); + const result = scan(stow.repo); + assert.equal(result.type, 'stow'); + assert.deepEqual(result.packages.sort(), ['starship', 'tmux']); + assert.ok(result.found.some(file => file.package === 'starship' && file.target === '.config/starship.toml')); + } finally { stow.done(); } + const chezmoi = sandbox(); + try { + chezmoi.put('dot_tmux.conf', 'set -g mouse on\n'); + chezmoi.put('private_dot_config/starship.toml.tmpl', 'format = "{{ .chezmoi.hostname }}"\n'); + chezmoi.put('run_once_install.sh', 'exit 1\n'); + const result = scan(chezmoi.repo); + assert.equal(result.type, 'chezmoi'); + assert.deepEqual(chezmoiTarget('private_dot_config/executable_x.tmpl'), {target: '.config/x', templated: true}); + const items = buildPlan(result, chezmoi.env); + assert.equal(items.find(item => item.file.tool.id === 'starship')!.kind, 'templated', 'templates are never rendered'); + assert.ok(result.scripts.includes('run_once_install.sh')); + } finally { chezmoi.done(); } +}); + +test('dotfiles: symlinks, oversized, malformed and binary files are skipped with a reason', () => { + const box = sandbox(); + try { + writeFileSync(join(box.root, 'secret'), 'set -g mouse on\n'); + symlinkSync(join(box.root, 'secret'), join(box.repo, '.tmux.conf')); + box.put('.config/starship.toml', 'this is = = not toml'); + box.put('.config/helix/config.toml', 'x'.repeat(600 * 1024)); + box.put('.config/bat/config', 'a\u0000b'); + const items = buildPlan(scan(box.repo), box.env); + const by = (id: string) => items.find(item => item.file.tool.id === id)!; + assert.match(by('tmux').note, /Symlink/u); + assert.match(by('starship').note, /Not valid TOML/u); + assert.match(by('helix').note, /512 KiB/u); + assert.match(by('bat').note, /Binary/u); + assert.ok(items.every(item => item.mode === 'skip')); + } finally { box.done(); } +}); + +test('dotfiles: field-level choices keep current values on conflict; review defaults to No; apply backs up and leaves the repository unchanged', () => { + const box = sandbox(); + try { + writeFileSync(join(box.home, '.tmux.conf'), 'set -g mouse off\n'); + box.put('.tmux.conf', 'set -g mouse on\nset -g escape-time 10\nrun-shell ~/x.sh\n'); + box.put('.config/starship.toml', 'add_newline = false\n'); + mkdirSync(join(box.home, '.config'), {recursive: true}); + writeFileSync(join(box.home, '.config', 'starship.toml'), 'add_newline = true\n'); + const before = snapshot(box.repo); + const items = buildPlan(scan(box.repo), box.env); + const tmux = items.find(item => item.file.tool.id === 'tmux')!; + const mouse = tmux.fields!.find(field => field.label === 'Mouse')!; + assert.deepEqual([mouse.conflict, mouse.use, mouse.current], [true, false, 'off'], 'a conflict keeps your value unless chosen'); + assert.equal(tmux.fields!.find(field => field.repo === '10')!.use, true); + assert.match(tmux.note, /1 dynamic lines never run/u); + const starship = items.find(item => item.file.tool.id === 'starship')!; + assert.equal(starship.mode, 'skip', 'copying a whole file is opt-in'); + starship.mode = 'copy'; + + const panel = createDotfilesPanel(box.repo); + panel.step = 'review'; + panel.review = {lines: reviewLines(items, []), yes: false}; + assert.equal(dotfilesKey(panel, {kind: 'enter'}), undefined, 'Enter on the default No applies nothing'); + assert.match(panel.message!, /Nothing was changed/u); + assert.match(reviewLines(items, []).join('\n'), /0 scripts run · the repository is not changed/u); + + const results = applyPlan(items, box.env, new Date('2026-01-02T03:04:05Z')); + assert.equal(results.length, 2, results.join('\n')); + assert.equal(readFileSync(join(box.home, '.config', 'starship.toml'), 'utf8'), 'add_newline = false\n'); + assert.ok(readdirSync(join(box.home, '.config')).some(name => name.startsWith('starship.toml.nmsh-backup-'))); + assert.equal(readFileSync(join(box.home, '.tmux.conf'), 'utf8'), 'set -g mouse off\n', 'your tmux.conf is untouched (include is a separate reviewed step)'); + assert.equal(snapshot(box.repo), before, 'the repository is never modified'); + } finally { box.done(); } +}); + +test('dotfiles: copy refuses when the destination changed since review; remote sources need a confirmed clone', () => { + const box = sandbox(); + try { + box.put('.config/starship.toml', 'add_newline = false\n'); + const items = buildPlan(scan(box.repo), box.env); + items[0]!.mode = 'copy'; + mkdirSync(join(box.home, '.config'), {recursive: true}); + writeFileSync(join(box.home, '.config', 'starship.toml'), 'appeared later\n'); + assert.match(applyPlan(items, box.env)[0]!, /disappeared|changed since review/u); + assert.equal(readFileSync(join(box.home, '.config', 'starship.toml'), 'utf8'), 'appeared later\n'); + } finally { box.done(); } + assert.equal(isRemoteSource('https://github.com/me/dotfiles'), true); + assert.equal(isRemoteSource('~/dotfiles'), false); + assert.equal(isRemoteSource('https://x/y; rm -rf ~'), false); + const panel = createDotfilesPanel(); + panel.step = 'clone'; + panel.clone = {url: 'https://github.com/me/dotfiles', target: '/tmp/x', yes: false}; + assert.equal(dotfilesKey(panel, {kind: 'enter'}), undefined); + assert.match(panel.message!, /Nothing was downloaded/u); +}); + +test('commands: aliases normalize to one action; provider shortcuts; leaf settings are not commands', () => { + const same = (a: string, b: string) => assert.deepEqual(parseSlashCommand(a), parseSlashCommand(b), `${a} = ${b}`); + same('/composer', '/layout'); + same('/glyph', '/glyphs'); + same('/strip', '/status-strip'); + same('/pickers', '/picker'); + same('/picker', '/providers picker'); + same('/tmux', '/configure tmux'); + assert.deepEqual(parseSlashCommand('/history-provider'), {kind: 'providers', family: 'history'}); + assert.deepEqual(parseSlashCommand('/history'), {kind: 'history', query: ''}, '/history stays the history picker'); + for (const [command, kind] of [['/motion', 'motion'], ['/chrome', 'chrome'], ['/integrations', 'integrations'], ['/dotfiles', 'dotfiles'], ['/configure', 'configure']] as const) assert.equal(parseSlashCommand(command)!.kind, kind); + assert.deepEqual(parseSlashCommand('/dotfiles ~/dots'), {kind: 'dotfiles', source: '~/dots'}); + for (const leaf of ['/shimmer', '/folding', '/mouse', '/prefix']) assert.equal(parseSlashCommand(leaf)!.kind, 'unknown', `${leaf} is a setting, not a command`); + const names = new Set(slashCommands.map(command => command.name)); + for (const alias of slashCommands.filter(command => command.alias)) assert.ok(names.has(alias.alias!), `${alias.name} points at a real command`); +}); + +test('palette and help list each new surface once, with human labels and grouped help', () => { + const items = paletteItems(); + const commandIds = items.map(item => item.id); + assert.equal(new Set(commandIds).size, commandIds.length, 'no duplicate palette entries'); + for (const alias of ['/composer', '/glyph', '/status-strip', '/pickers']) assert.ok(!items.some(item => item.detail.startsWith(alias) || item.label === alias), `${alias} not listed twice`); + const help = String(helpMarkdown()); + for (const group of ['Appearance', 'Composer & transcript', 'Providers', 'Tools & integration']) assert.match(help, new RegExp(`### ${group}`, 'u')); + for (const command of ['/motion', '/chrome', '/glyphs', '/strip', '/tmux', '/configure', '/integrations', '/dotfiles', '/history-provider']) assert.ok(help.includes(`\`${command}\``), command); + assert.match(help, /`\/layout` \(also `\/composer`\)/u); +}); diff --git a/tests/fullChromaQa.test.ts b/tests/fullChromaQa.test.ts index dc75f3b0..673453dc 100644 --- a/tests/fullChromaQa.test.ts +++ b/tests/fullChromaQa.test.ts @@ -108,6 +108,9 @@ test('C/H: Setup Cat Appearance says when previews include Chroma, and its Chrom state.draft = normalizePromptConfiguration({presentation: {preset: 'off'}}); assert.doesNotMatch(plain(app['setupPreview'](state, 100)), /previews include Chroma/u); state.draft = normalizePromptConfiguration({presentation: {preset: 'aurora', motion: 'comet'}}); + // The base theme is shown by default; P turns the local preview Chroma on (the setting itself is unchanged). + assert.match(plain(app['setupPreview'](state, 100)), /Preview Chroma Off/u); + state.previewChroma = true; assert.ok(plain(app['setupPreview'](state, 100)).includes(CHROMA_PREVIEW_NOTE)); const chromaRow = (rows: string[]) => rows.find(item => stripAnsi(item).trimStart().startsWith('Chroma'))!; const realNow = Date.now; diff --git a/tests/providersOverview.test.ts b/tests/providersOverview.test.ts index 23504a64..a9ee1bc4 100644 --- a/tests/providersOverview.test.ts +++ b/tests/providersOverview.test.ts @@ -3,8 +3,7 @@ import assert from 'node:assert/strict'; import {TerminalApp} from '../src/app/TerminalApp.js'; import {DEFAULT_PROMPT_CONFIGURATION} from '../src/prompt/configuration.js'; import {familyFacts, PROVIDER_FAMILIES, providerFamily, selectProvider} from '../src/providers/families.js'; -import {createProvidersOverview, OVERVIEW_ROWS, providersOverviewKey, renderProvidersOverview, providerStateText} from '../src/providers/ProvidersOverview.js'; -import {SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; +import {createProvidersOverview, OVERVIEW_ROWS, overviewItems, providersOverviewKey, renderProvidersOverview, providerStateText} from '../src/providers/ProvidersOverview.js'; import {stripAnsi} from '../src/util/text.js'; import type {ProviderStatus} from '../src/providers/providers.js'; @@ -12,56 +11,79 @@ const config = () => structuredClone(DEFAULT_PROMPT_CONFIGURATION); const facts = (statuses: Map, installed = new Set(), configuration = config()) => ({configuration, statuses, installedByNmsh: installed, understanding: {active: 'Built-in', detail: ['Mode: Off']}, shell: {current: 'zsh', defaultShell: 'zsh'}}); -test('/providers overview lists every family, active vs selected, factual install state, fallback', () => { +test('/providers overview lists every family inline; modern status words; selected vs active fallback', () => { const statuses = new Map([['deja', {state: 'installed', binary: '/opt/homebrew/bin/deja', version: '1.2'}], ['atuin', {state: 'missing'}]]); - const state = createProvidersOverview(); + const state = createProvidersOverview('suggestions'); state.detecting = false; - state.selected = OVERVIEW_ROWS.indexOf('suggestions'); const text = stripAnsi(renderProvidersOverview(state, facts(statuses, new Set(['deja'])), 160).join('\n')); for (const family of PROVIDER_FAMILIES) assert.match(text, new RegExp(family.title, 'u')); + assert.match(text, /▾ Suggestions\s+NMSh Native\s+● Active/u, 'the family is expanded in the same panel'); + assert.match(text, /Deja\s+Available · 1\.2/u); + assert.match(text, /NMSh Native\s+● Active/u); + assert.doesNotMatch(text, /\[active\]|\[preferred\]/u, 'no bracket badges'); assert.match(text, /Local understanding\s+Built-in/u); assert.match(text, /Shell\s+zsh \(this session\)/u); - assert.match(text, /Deja\s+Installed 1\.2 · installed by NMSh/u, 'NMSh-installed vs found is factual'); - assert.match(text, /\[active\] is in use, › is the selected row/u); assert.equal(providerStateText('external', {state: 'installed'}, false), 'Installed · found on this system'); const withMissing = config(); withMissing.history = 'atuin'; const history = familyFacts(providerFamily('history')!, withMissing, statuses); assert.equal(history.preferred, 'atuin'); assert.equal(history.active, 'native', 'a missing preferred provider falls back'); - assert.match(history.notice!, /Atuin unavailable · using/u); assert.equal(withMissing.history, 'atuin', 'fallback never rewrites the saved preference'); - assert.equal(familyFacts(providerFamily('history')!, withMissing, new Map()).active, 'atuin', 'while detection runs, the preference stands'); + const fallback = createProvidersOverview('history'); + fallback.detecting = false; + const shown = stripAnsi(renderProvidersOverview(fallback, facts(statuses, new Set(), withMissing), 160).join('\n')); + assert.match(shown, /History\s+NMSh Native\s+Selected Atuin · fallback → NMSh Native/u); + assert.match(shown, /Atuin\s+✓ Selected · fallback → NMSh Native/u); }); -test('/providers keys: select, open, detect again, close; switching uses the one configuration', () => { +test('/providers keys: inline expand, select at once, inline install confirm defaulting No, Esc collapses first', () => { + const statuses = new Map([['fzf', {state: 'installed', binary: '/x/fzf'}], ['television', {state: 'missing'}]]); + const f = {configuration: config(), statuses}; const state = createProvidersOverview(); - assert.equal(providersOverviewKey(state, {kind: 'down'}), undefined); - assert.deepEqual(providersOverviewKey(state, {kind: 'enter'}), {kind: 'open', row: OVERVIEW_ROWS[1]}); - assert.deepEqual(providersOverviewKey(state, {kind: 'text', value: 'r'}), {kind: 'detect'}); - assert.deepEqual(providersOverviewKey(state, {kind: 'escape'}), {kind: 'close'}); + state.selected = OVERVIEW_ROWS.indexOf('picker'); + assert.equal(providersOverviewKey(state, {kind: 'enter'}, f), undefined); + assert.equal(state.expanded, 'picker'); + const items = overviewItems(state); + state.selected = items.findIndex(item => item.kind === 'provider' && item.id === 'fzf'); + assert.deepEqual(providersOverviewKey(state, {kind: 'enter'}, f), {kind: 'select', family: 'picker', id: 'fzf'}); + state.selected = items.findIndex(item => item.kind === 'provider' && item.id === 'television'); + providersOverviewKey(state, {kind: 'enter'}, f); + assert.deepEqual(state.confirm, {family: 'picker', id: 'television', yes: false}, 'inline confirmation starts on No'); + assert.equal(providersOverviewKey(state, {kind: 'enter'}, f), undefined, 'Enter on No installs nothing'); + assert.deepEqual(providersOverviewKey(state, {kind: 'text', value: 'r'}, f), {kind: 'detect'}); + assert.equal(providersOverviewKey(state, {kind: 'escape'}, f), undefined, 'Esc collapses the family first'); + assert.equal(state.expanded, undefined); + assert.deepEqual(providersOverviewKey(state, {kind: 'escape'}, f), {kind: 'close'}); const next = selectProvider(config(), 'suggestions', 'deja')!; assert.equal(next.suggestions, 'deja'); assert.equal(selectProvider(config(), 'suggestions', 'not-a-provider'), undefined); - const row = SETTINGS_ROWS.find(item => item.id === 'suggestions'); - assert.equal(row?.control, 'child', 'Settings opens the same family panel'); }); -test('app: /providers opens the overview; Enter on a family opens its existing panel; Local understanding opens its setup', async () => { +test('app: /picker focuses Picker inline; selecting fzf is immediately Active; /history hands off to fzf at the composer side', async () => { const app = new TerminalApp(); Object.defineProperty(app, 'render', {value: () => {}}); Object.defineProperty(app, 'dimensions', {value: () => ({columns: 140, rows: 40})}); try { app['startupPending'] = false; - app['editor'].insert('/providers'); + app['editor'].insert('/picker'); await app['submit'](); - assert.ok(app['providersOverview']); - app['providersOverview'].selected = OVERVIEW_ROWS.indexOf('suggestions'); - app['handleKey']({kind: 'enter'}); - assert.equal(app['providerPanelState']?.family, 'suggestions'); - app['providerPanelState'] = undefined; + const overview = app['providersOverview']!; + assert.equal(overview.expanded, 'picker', '/picker opens /providers focused on Picker'); + assert.equal(app['providerPanelState'], undefined, 'no nested family panel'); + app['providerStatuses'].set('fzf', {state: 'installed', binary: '/x/fzf'}); + app['selectProviderInline']('picker', 'fzf'); + assert.equal(app['promptConfiguration'].picker, 'fzf'); + const text = stripAnsi(renderProvidersOverview(overview, app['providersOverviewFacts'](), 140).join('\n')); + assert.match(text, /Picker\s+fzf\s+● Active/u, 'the overview is factual at once'); + assert.equal(app['fzfLayout'](), 'default', 'composer Bottom: fzf query at the bottom'); + app['promptConfiguration'].composerPosition = 'top'; + assert.equal(app['fzfLayout'](), 'reverse', 'composer Top: fzf query at the top'); + app['selectProviderInline']('picker', 'native'); + assert.equal(app['promptConfiguration'].picker, 'native'); + app['providersOverview'] = undefined; app['openProvidersOverview'](); - app['providersOverview'].selected = OVERVIEW_ROWS.indexOf('understanding'); + app['providersOverview']!.selected = OVERVIEW_ROWS.indexOf('understanding'); app['handleKey']({kind: 'enter'}); assert.ok(app['understandingPanel']); } finally { app['stop'](0); app['session'].kill(); } diff --git a/tests/themeBridge.test.ts b/tests/themeBridge.test.ts index 0096cd4f..72d603c6 100644 --- a/tests/themeBridge.test.ts +++ b/tests/themeBridge.test.ts @@ -21,6 +21,8 @@ import { } from '../src/themeBridge/artifacts.js'; import {applyThemeBridge, bridgeColorLevel, bridgeEnvironment, fzfBridgeArgs, reloadTmux, reportTargets, type BridgeContext, type TargetFacts} from '../src/themeBridge/runtime.js'; import {themeBridgeKey} from '../src/themeBridge/runtime.js'; +import {writeTmuxManaged} from '../src/tools/config/tmuxManaged.js'; +import {DEFAULT_TMUX_MODEL} from '../src/tools/config/tmux.js'; const installed = (...targets: BridgeTargetId[]): Record => Object.fromEntries(BRIDGE_TARGETS.map(target => [target, {installed: targets.includes(target), ...(target === 'fzf' ? {version: '0.74.4 (brew)'} : {})}])) as Record; @@ -246,10 +248,12 @@ test('exact includes: shown plan, exact lines appended, exact removal, duplicate writeFileSync(conf, 'set -g mouse on\nbind r source-file ~/.tmux.conf\n'); const palette = paletteFromTheme(builtinTheme('nord'), 'builtin:nord'); assert.ok(writeArtifact('tmux', tmuxFragment(palette), validateTmuxFragment, {mode: 'follow', themeRef: 'builtin:nord', format: 'tmux-fragment', formatVersion: 1}, box.env).ok); + // tmux.conf includes the one managed tmux file (settings + Theme Bridge colors). + assert.ok(writeTmuxManaged(DEFAULT_TMUX_MODEL(), box.env).ok); const spec = hookSpec('tmux', box.env, box.home); assert.ok(!('error' in spec)); assert.equal(spec.configPath, conf, 'the existing user config is the target'); - assert.deepEqual(spec.lines, ['# NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)', `source-file -q '${artifactPath('tmux', box.env)}'`]); + assert.deepEqual(spec.lines, ['# NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)', `source-file -q '${artifactPath('tmuxConfig', box.env)}'`]); const planned = planHook(spec, box.home); assert.ok('plan' in planned); assert.ok(planned.plan.preview.some(line => line.startsWith('+ source-file -q')), 'the exact line is previewed'); @@ -263,7 +267,7 @@ test('exact includes: shown plan, exact lines appended, exact removal, duplicate assert.ok('plan' in removal); assert.ok(applyHookRemoval('tmux', removal.plan, box.env).ok); assert.equal(readFileSync(conf, 'utf8'), 'set -g mouse on\nbind r source-file ~/.tmux.conf\n\n', 'only the NMSh lines are removed'); - assert.equal(loadLedger(box.env).entries.tmux?.hook, undefined); + assert.equal(loadLedger(box.env).entries.tmuxConfig?.hook, undefined); // Duplicated includes are never guessed at. assert.ok(applyHook('tmux', (planHook(spec, box.home) as {plan: never}).plan, spec, box.env).ok); writeFileSync(conf, `${readFileSync(conf, 'utf8')}${spec.lines.join('\n')}\n`); diff --git a/tests/toolConfig.test.ts b/tests/toolConfig.test.ts new file mode 100644 index 00000000..b1c25348 --- /dev/null +++ b/tests/toolConfig.test.ts @@ -0,0 +1,186 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import { + applyTmuxChange, bindingConflicts, DEFAULT_TMUX_MODEL, frontendCommand, optionProvenance, parseTmuxConfig, renderTmuxConfig, TMUX_OPTIONS, validateTmuxConfig, + type TmuxChange, type TmuxModel, +} from '../src/tools/config/tmux.js'; +import {writeTmuxManaged} from '../src/tools/config/tmuxManaged.js'; +import {TOOL_CONFIG_REGISTRY, toolConfigEntry} from '../src/tools/config/registry.js'; +import {createTmuxPanel, pendingChanges, renderTmuxPanel, tmuxPanelKey} from '../src/tools/config/TmuxPanel.js'; +import {applyHook, artifactPath, hookSpec, loadLedger, planHook} from '../src/themeBridge/artifacts.js'; +import {integrationHealth, type BridgeContext} from '../src/themeBridge/runtime.js'; +import {BRIDGE_TARGETS, type BridgeTargetId} from '../src/themeBridge/model.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {resolveRequest} from '../src/ask/resolver.js'; +import {stripAnsi} from '../src/util/text.js'; + +const sandbox = () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-toolcfg-')); + const home = join(root, 'home'); + mkdirSync(home); + return {root, home, env: {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), PATH: process.env.PATH} as NodeJS.ProcessEnv, done: () => rmSync(root, {recursive: true, force: true})}; +}; +const apply = (model: TmuxModel, ...changes: TmuxChange[]) => changes.reduce((next, change) => { const result = applyTmuxChange(next, change); assert.ok(!('error' in result), JSON.stringify(result)); return result as TmuxModel; }, model); +const tmux = ['/opt/homebrew/bin/tmux', '/usr/local/bin/tmux', '/usr/bin/tmux'].find(existsSync); + +test('tmux model: only catalog options, documented values, safe keys; arbitrary strings never become commands', () => { + const model = apply(DEFAULT_TMUX_MODEL(), {kind: 'option', id: 'mouse', value: 'on'}, {kind: 'option', id: 'status-position', value: 'top'}, {kind: 'prefix', key: 'C-a'}, + {kind: 'option', id: 'mode-keys', value: 'vi'}, {kind: 'binding', binding: {key: '|', table: 'prefix', action: 'split-vertical'}}); + assert.equal(model.prefix, 'C-a'); + assert.ok(model.bindings.some(binding => binding.action === 'send-prefix' && binding.key === 'C-a'), 'send-prefix follows the prefix'); + for (const bad of [{kind: 'option', id: 'default-shell', value: '/bin/evil'}, {kind: 'option', id: 'mouse', value: 'maybe'}, {kind: 'prefix', key: '$(rm -rf ~)'}, + {kind: 'binding', binding: {key: 'x', table: 'prefix', action: 'run-shell'}}] as unknown as TmuxChange[]) assert.ok('error' in applyTmuxChange(model, bad), JSON.stringify(bad)); + const text = renderTmuxConfig(model, {self: '/n/nmsh.tmux.conf', theme: '/n/theme.conf'}); + assert.ok(validateTmuxConfig(text)); + assert.match(text, /^set -g mouse on$/mu); + assert.match(text, /^setw -g mode-keys vi$/mu); + assert.doesNotMatch(text, /run-shell|if-shell|#\(|default-shell/u); + assert.equal(validateTmuxConfig(`${text}run-shell "curl x | sh"\n`), false); + assert.equal(validateTmuxConfig('set -g status-right "#(whoami)"\n'), false, 'status modules never execute shell'); + const status = renderTmuxConfig(apply(model, {kind: 'status', layout: {left: ['session'], right: ['host', 'time'], separator: '·', windowFormat: 'index-name'}}), {self: '/n/s', theme: '/n/t'}); + assert.match(status, /^set -g status-right " #h · %H:%M "$/mu); +}); + +test('tmux frontend: new panes start NMSh by absolute path; inside an NMSh-started server they fall back to the login shell; default-shell untouched', () => { + const box = sandbox(); + try { + const fakeNmsh = join(box.root, 'nmsh'); + const marker = join(box.root, 'ran'); + writeFileSync(fakeNmsh, `#!/bin/sh\necho NMSH-FRONTEND\n[ -n "$TMUX" ] && echo yes > '${marker}'\n`); + chmodSync(fakeNmsh, 0o755); + const command = frontendCommand(fakeNmsh)!; + assert.equal(frontendCommand("/tmp/it's"), undefined, 'unquotable paths are refused'); + const run = (env: Record) => spawnSync('/bin/sh', ['-c', command], {encoding: 'utf8', env: {PATH: '/usr/bin:/bin', SHELL: '/bin/echo', ...env}}).stdout.trim(); + assert.equal(run({}), 'NMSH-FRONTEND'); + assert.equal(run({NMSH_ACTIVE: '1'}), '-l', 'NMSh recursion guard respected: the login shell (here echo) starts instead'); + const text = renderTmuxConfig(apply(DEFAULT_TMUX_MODEL(), {kind: 'frontend', value: 'nmsh'}), {self: '/n/s', theme: '/n/t', nmsh: fakeNmsh}); + assert.ok(validateTmuxConfig(text)); + assert.match(text, /^set -g default-command "exec \/bin\/sh -c/mu); + assert.doesNotMatch(text, /default-shell/u); + if (!tmux) return; + const conf = join(box.root, 'n.conf'); + writeFileSync(conf, text); + const socket = `nmsh-front-${process.pid}`; + const result = spawnSync(tmux, ['-L', socket, '-f', conf, 'new-session', '-d', '-x', '80', '-y', '10', ';', 'run-shell', 'sleep 0.5', ';', 'show-options', '-g', 'default-shell', ';', 'kill-server'], + {encoding: 'utf8', env: {...process.env, TMUX: '', NMSH_ACTIVE: '', TMUX_TMPDIR: box.root}}); + assert.ok(existsSync(marker), 'a new pane really ran the frontend'); + assert.doesNotMatch(result.stdout, /default-shell .*nmsh/u); + } finally { box.done(); } +}); + +test('tmux managed file: real tmux loads every setting; one include only; import reads a safe subset and never evaluates dynamic lines', () => { + const box = sandbox(); + try { + const model = apply(DEFAULT_TMUX_MODEL(), {kind: 'option', id: 'mouse', value: 'on'}, {kind: 'option', id: 'escape-time', value: '10'}, {kind: 'prefix', key: 'C-a'}, + {kind: 'binding', binding: {key: 'r', table: 'prefix', action: 'reload'}}); + const written = writeTmuxManaged(model, box.env); + assert.ok(written.ok); + const conf = join(box.home, '.tmux.conf'); + writeFileSync(conf, 'set -g history-limit 5000\nbind x kill-pane\n'); + const spec = hookSpec('tmux', box.env, box.home); + assert.ok(!('error' in spec)); + const planned = planHook(spec, box.home); + assert.ok('plan' in planned); + assert.ok(applyHook('tmux', planned.plan, spec, box.env).ok); + assert.ok('noop' in planHook(spec, box.home), 'running again never duplicates the include'); + assert.equal(readFileSync(conf, 'utf8').split(artifactPath('tmuxConfig', box.env)).length - 1, 1); + assert.ok(readFileSync(conf, 'utf8').startsWith('set -g history-limit 5000\nbind x kill-pane\n'), 'unknown user config preserved'); + assert.ok(loadLedger(box.env).entries.tmuxConfig?.hook); + if (tmux) { + const socket = `nmsh-cfg-${process.pid}`; + const result = spawnSync(tmux, ['-L', socket, '-f', conf, 'new-session', '-d', ';', 'show-options', '-g', 'mouse', ';', 'show-options', '-g', 'prefix', ';', 'show-options', '-s', 'escape-time', ';', 'show-options', '-g', 'history-limit', ';', 'kill-server'], + {encoding: 'utf8', env: {...process.env, TMUX: '', TMUX_TMPDIR: box.root}}); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /mouse on[\s\S]*prefix C-a[\s\S]*escape-time 10[\s\S]*history-limit 5000/u); + } + const parsed = parseTmuxConfig('set -g mouse on\nset-option -g prefix C-a\nsetw -g mode-keys vi\nbind | split-window -h\nif-shell "true" "set -g mouse off"\nrun-shell ~/x.sh\nsource-file ~/.other\nset -g status-right "#(date)"\nset -g @plugin tmux-plugins/tpm\n'); + assert.deepEqual(parsed.options, {mouse: 'on', 'mode-keys': 'vi'}); + assert.equal(parsed.prefix, 'C-a'); + assert.deepEqual(parsed.bindings, [{key: '|', table: 'prefix', action: 'split-vertical'}]); + assert.equal(parsed.ignored.length, 4, 'if-shell, run-shell, source-file and #() are never followed'); + assert.ok(parsed.unsupported.some(line => line.includes('@plugin'))); + } finally { box.done(); } +}); + +test('provenance and conflicts: effective value, NMSh override, your config, tmux default', () => { + const mouse = TMUX_OPTIONS.find(option => option.id === 'mouse')!; + const user = parseTmuxConfig('set -g mouse off\nbind | split-window -v\n'); + assert.deepEqual(optionProvenance(mouse, DEFAULT_TMUX_MODEL(), user), {effective: 'off', user: 'off', source: 'your tmux config'}); + const model = apply(DEFAULT_TMUX_MODEL(), {kind: 'option', id: 'mouse', value: 'on'}, {kind: 'binding', binding: {key: '|', table: 'prefix', action: 'split-vertical'}}); + assert.deepEqual(optionProvenance(mouse, model, user), {effective: 'on', override: 'on', user: 'off', source: 'NMSh managed file'}); + assert.equal(optionProvenance(mouse, DEFAULT_TMUX_MODEL(), undefined).source, 'tmux default'); + assert.equal(bindingConflicts(model, user).length, 1, 'an NMSh binding over one of yours is shown, never silently removed'); +}); + +test('/tmux panel: tabs, changes stay in the draft, Review defaults to No, Status Studio preview, two owners stated', () => { + const state = createTmuxPanel(DEFAULT_TMUX_MODEL(), undefined, 'Independent', true); + tmuxPanelKey(state, {kind: 'right'}); + assert.equal(state.draft.options.mouse, 'on'); + assert.deepEqual(pendingChanges(state), [{kind: 'option', id: 'mouse', value: 'on'}]); + state.tab = 'status'; + state.selected.status = 3; + tmuxPanelKey(state, {kind: 'right'}); + const text = stripAnsi(renderTmuxPanel(state, 120, 50).join('\n')); + assert.match(text, /General\s+Keys\s+Status\s+Pane frontend\s+Import/u); + assert.match(text, /Preview · tmux status line/u); + state.tab = 'frontend'; + assert.match(stripAnsi(renderTmuxPanel(state, 120, 50).join('\n')), /different owners/u); + state.review = {lines: ['Mouse: on'], include: [], yes: false}; + assert.equal(tmuxPanelKey(state, {kind: 'enter'}), undefined, 'Enter on the default No applies nothing'); + state.review = {lines: ['Mouse: on'], include: [], yes: false}; + tmuxPanelKey(state, {kind: 'right'}); + assert.deepEqual(tmuxPanelKey(state, {kind: 'enter'}), {kind: 'apply'}); +}); + +test('registry: detection never grants write authority; only registered adapters are configurable; executable configs are inspect-only', () => { + assert.deepEqual(TOOL_CONFIG_REGISTRY.filter(entry => entry.configurable).map(entry => entry.id), ['tmux', 'starship']); + for (const id of ['neovim', 'vim', 'zsh', 'bash', 'fish']) { + const entry = toolConfigEntry(id)!; + assert.equal(entry.configClass, 'executable'); + assert.notEqual(entry.ownership, 'managed-fragment'); + assert.equal(entry.configurable, false); + } + assert.equal(toolConfigEntry('foo'), undefined, 'unknown tools have no adapter'); +}); + +test('Ask: tmux and provider requests map only onto typed actions; nothing changes before Yes', () => { + const context = {cwd: '/r', home: '/h', repoRoot: '/r', branch: 'main', dirty: false, worktrees: [], shell: 'zsh', defaultShell: 'zsh'} as never; + const action = (request: string) => (resolveRequest(request, context) as {action?: {kind: string; changes?: TmuxChange[]; setting?: string; value?: string}}).action; + assert.deepEqual(action('turn tmux mouse on')?.changes, [{kind: 'option', id: 'mouse', value: 'on'}]); + assert.deepEqual(action('change tmux prefix to ctrl+a')?.changes, [{kind: 'prefix', key: 'C-a'}]); + assert.deepEqual(action('put tmux status at top')?.changes, [{kind: 'option', id: 'status-position', value: 'top'}]); + assert.deepEqual(action('make new tmux panes run nmsh')?.changes, [{kind: 'frontend', value: 'nmsh'}]); + assert.deepEqual([action('use fzf for pickers')?.setting, action('use fzf for pickers')?.value], ['picker', 'fzf']); + assert.equal(action('use native suggestions')?.value, 'nmsh'); + assert.equal(action('make theme bridge follow nmsh')?.kind, 'themeBridge'); + assert.equal(action('update all integrations')?.kind, 'slash'); + assert.equal(action('import dotfiles from ~/dotfiles')?.kind, 'slash'); + const outcome = resolveRequest('change tmux prefix to $(rm -rf ~)', context) as {action?: {changes?: TmuxChange[]}}; + assert.ok(!outcome.action?.changes?.length, 'request text never becomes a key or command'); + const proposal = resolveRequest('turn tmux mouse on', context) as {safety?: string}; + assert.equal(proposal.safety, 'mutate', 'configuration needs Ask\'s final Yes/No'); +}); + +test('integrations health: tmux settings need the include; Review lists it; idempotent', () => { + const box = sandbox(); + try { + const facts = Object.fromEntries(BRIDGE_TARGETS.map(target => [target, {installed: target === 'tmux'}])) as BridgeContext['facts']; + mkdirSync(join(box.env.XDG_CONFIG_HOME!, 'nmsh', 'tools'), {recursive: true}); + writeFileSync(join(box.env.XDG_CONFIG_HOME!, 'nmsh', 'tools', 'tmux.json'), JSON.stringify({options: {mouse: 'on'}})); + assert.ok(writeTmuxManaged(undefined, box.env).ok); + const context: BridgeContext = {source: normalizePromptConfiguration({}), facts, level: 'truecolor', env: box.env}; + const health = () => Object.fromEntries(integrationHealth(context).map(item => [item.target, item])) as Record[number]>; + assert.equal(health().tmux.state, 'needs-include'); + assert.equal(health().vim.state, 'not-installed'); + const spec = hookSpec('tmux', box.env, box.home); + assert.ok(!('error' in spec)); + applyHook('tmux', (planHook(spec, box.home) as {plan: never}).plan, spec, box.env); + assert.equal(health().tmux.state, 'ready'); + writeFileSync(join(box.home, '.tmux.conf'), ''); + assert.equal(health().tmux.state, 'stale-include', 'a removed include is reported, not re-added silently'); + } finally { box.done(); } +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index ebf66250..cd77b4fc 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -20,7 +20,7 @@ test('offline catalog, truthful filters and curated argv recipes do not execute state.query = ''; state.tab = 'installed'; assert.deepEqual(visibleTools(state).map(tool => tool.id), ['fzf']); state.tab = 'configure'; - assert.deepEqual(visibleTools(state).map(tool => tool.id), ['starship']); + assert.deepEqual(visibleTools(state).map(tool => tool.id), ['starship', 'tmux'], 'Configure lists only tools with a registered adapter'); assert.equal(toolInstall(TOOLS[0]!, false), undefined); assert.deepEqual(toolInstall(TOOLS[0]!, true)?.args, ['install', 'ripgrep']); assert.deepEqual(parseSlashCommand('/tools'), {kind: 'tools'}); From df92d51bbbac1c58880992fbb7542ec0d5587b78 Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:54:40 +0530 Subject: [PATCH 08/16] Harden dotfiles exact copy and tmux pane frontend; count only output against the detached retention cap - Dotfiles: exact-copy is an explicit, fail-closed registry capability (exactCopy validator). Parseable is not safe: Starship, Helix and bat configs can run commands, so they are inspect-only with a stated reason. Apply re-checks authority, so a forced copy mode writes nothing. - tmux: the pane frontend is a fixed /bin/sh program; the NMSh path is passed as shell-quoted argv data ("$1"), never as program text. Any absolute path without control characters works literally; the validator accepts only that exact form. - Detached output: prompt metadata (alias/function lists) no longer consumes the output retention budget, which made in-limit output look truncated on Ubuntu (global compinit). The output cap is unchanged. - Shell switch test reports shell state on timeout. - Remove a stray committed .swp; ignore *.swp. --- .gitignore | 1 + .swp | Bin 4096 -> 0 bytes CHANGELOG.md | 4 ++-- src/dotfiles/plan.ts | 22 ++++++++++-------- src/session/StreamBacklog.ts | 17 +++++++++----- src/tools/config/registry.ts | 19 ++++++++++++--- src/tools/config/tmux.ts | 28 +++++++++++++++++++--- tests/dotfilesCommands.test.ts | 41 +++++++++++++++++++++++++++++---- tests/shellSwitchApp.test.ts | 10 +++++++- tests/streamBacklog.test.ts | 16 +++++++++++++ tests/toolConfig.test.ts | 38 +++++++++++++++++++++++++++++- 11 files changed, 166 insertions(+), 30 deletions(-) delete mode 100644 .swp diff --git a/.gitignore b/.gitignore index ba34fb68..b0939c4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ node_modules/ .DS_Store +*.swp diff --git a/.swp b/.swp deleted file mode 100644 index 4b78cc6337061d299a8827d8dd28b02b8c3c6437..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmYc?2=nw+u+%eT00IFJ0Ra{L3=CO`nZ>2q*@-YQT(lQZovv?UvQvJ3wytAlkzP)I za$*iH)uZC0Aut*O0}}$Ap~gmr1|YkYl@t|(g+d3$8Kbt2hQMeDjE2By2#kinXb6mk Qz-S1JhQMeD3`ht701~wjmjD0& diff --git a/CHANGELOG.md b/CHANGELOG.md index d07edee2..91a95a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `/providers` is one inline panel: each family with its status (● Active, ✓ Selected · fallback, Available, Missing · Enter to install); Enter selects immediately or installs after a confirmation (default No). Shortcuts `/picker` (`/pickers`), `/suggestions`, `/navigation`, `/welcome`, `/history-provider` and `/providers ` open it focused. - Pickers follow the composer: with the composer at the bottom the query sits at the bottom and results above it (NMSh Native and fzf); at the top the query is at the top. - **Tool Configuration** (`/configure`, `/tmux`): one first-party registry says which tools NMSh can configure (tmux, Starship), which it themes, and which are inspect-only (shell rc files, Neovim/Vim config). Detection never grants write authority. -- **tmux Config Studio** (`/tmux`): General settings from a documented catalog, keymaps and prefix with conflict display, a Status Studio with a live preview (status modules never run shell), an optional NMSh pane frontend (`default-command` that starts NMSh by absolute path and falls back to your login shell inside NMSh; `default-shell` untouched), and import of a supported subset of an existing tmux.conf (`if-shell`, `run-shell`, `source-file` and `#()` are never followed). Each value shows where it comes from. Everything goes into one NMSh-managed tmux file, included once after review. +- **tmux Config Studio** (`/tmux`): General settings from a documented catalog, keymaps and prefix with conflict display, a Status Studio with a live preview (status modules never run shell), an optional NMSh pane frontend (`default-command` that runs a fixed `/bin/sh` program with the NMSh path passed as quoted argv data, never as shell text, and falls back to your login shell inside NMSh; `default-shell` untouched), and import of a supported subset of an existing tmux.conf (`if-shell`, `run-shell`, `source-file` and `#()` are never followed). Each value shows where it comes from. Everything goes into one NMSh-managed tmux file, included once after review. - `/integrations`: health of every managed integration (current, missing, stale, conflict) with Review all / Apply all (default No); after one-time activation, managed files update automatically when the theme changes. -- `/dotfiles [path or Git URL]`: plain, Git, GNU Stow and chezmoi sources. Remote sources are cloned only after confirmation (depth 1, no submodules, hooks disabled). Nothing in the repository is run, templates are not rendered, executable configs are inspect-only, conflicting values default to your current ones, copied files are backed up, and one combined review (default No) precedes any change. The repository is never modified. +- `/dotfiles [path or Git URL]`: plain, Git, GNU Stow and chezmoi sources. Remote sources are cloned only after confirmation (depth 1, no submodules, hooks disabled). Nothing in the repository is run, templates are not rendered, tmux imports supported fields only, Starship/Helix/bat and all executable configs are inspect-only (parseable config can still run commands, so nothing is copied without an explicit per-tool safety validator, and none exists today), conflicting values default to your current ones, and one combined review (default No) precedes any change. The repository is never modified. - Ask maps tmux, provider, Theme Bridge, integrations and dotfiles requests onto these typed actions only, behind its final Yes/No. - Commands: `/motion`, `/chrome`, `/glyphs` (`/glyph`), `/composer` (alias of `/layout`), `/strip` (`/status-strip`), `/configure`, `/tmux`, `/integrations`, `/dotfiles`. `/help` groups commands by area (Appearance, Composer & transcript, Providers, Tools & integration) and the palette lists each surface once with a readable label. Individual settings are not commands. diff --git a/src/dotfiles/plan.ts b/src/dotfiles/plan.ts index 18ea7807..be627c6c 100644 --- a/src/dotfiles/plan.ts +++ b/src/dotfiles/plan.ts @@ -13,9 +13,11 @@ import {destinationFor, readFound, type FoundFile, type ScanResult} from './scan /** * Turning a scan into reviewed choices. Structured or command-style config * goes through its adapter at field level (current vs dotfiles, never - * silently the repository's value on a conflict); a structured file may be - * copied exactly, with a backup, after review; executable config is inspect - * only. The repository itself is never modified. + * silently the repository's value on a conflict). A file is copied exactly + * only when its registry entry declares an exactCopy validator and the + * content passes it (fail-closed: being parseable is not being safe); + * everything else, including all executable config, is inspect only. The + * repository itself is never modified. */ export type ItemKind = 'fields' | 'copy' | 'inspect' | 'templated' | 'unreadable'; @@ -80,8 +82,6 @@ function tmuxFields(text: string, env: NodeJS.ProcessEnv): FieldChoice[] { return fields; } -const structured = (tool: string) => tool === 'starship' || tool === 'helix' || tool === 'bat'; - export function buildPlan(scan: ScanResult, env: NodeJS.ProcessEnv = process.env): DotfilesItem[] { const items: DotfilesItem[] = []; const seen = new Set(); @@ -100,8 +100,10 @@ export function buildPlan(scan: ScanResult, env: NodeJS.ProcessEnv = process.env note: `${fields.length} supported value${fields.length === 1 ? '' : 's'} differ · ${parsed.unsupported.length} other lines stay out · ${parsed.ignored.length} dynamic lines never run`}); continue; } - if (structured(file.tool.id)) { - if (file.target.endsWith('.toml')) { try { parseToml(content); } catch { items.push({...base, kind: 'unreadable', mode: 'skip', modes: ['skip'], note: 'Not valid TOML; skipped'}); continue; } } + if (file.target.endsWith('.toml')) { try { parseToml(content); } catch { items.push({...base, kind: 'unreadable', mode: 'skip', modes: ['skip'], note: 'Not valid TOML; skipped'}); continue; } } + const exact = file.tool.exactCopy?.(content); + if (exact && !exact.ok) { items.push({...base, kind: 'inspect', mode: 'skip', modes: ['skip'], note: `Inspect only: ${exact.reason}`}); continue; } + if (exact?.ok) { const destination = destinationFor(file.tool, env); let before: string | undefined; try { before = readFileSync(destination, 'utf8'); } catch { before = undefined; } @@ -109,7 +111,7 @@ export function buildPlan(scan: ScanResult, env: NodeJS.ProcessEnv = process.env note: `Copy exact file to ${destination}${before === undefined ? '' : ' (the current file is backed up first)'}`}); continue; } - items.push({...base, kind: 'inspect', mode: 'skip', modes: ['skip'], note: 'No managed import for this tool'}); + items.push({...base, kind: 'inspect', mode: 'skip', modes: ['skip'], note: file.tool.dotfilesNote ?? 'Inspect only: no reviewed import for this tool; never copied'}); } return items; } @@ -126,7 +128,7 @@ export function reviewLines(items: readonly DotfilesItem[], include: readonly st if (used.length) fragments = 1; for (const field of used) lines.push(` + ${describeTmuxChange(field.change)}`); if (!used.length) lines.push(' (no values selected)'); - } else if (item.mode === 'copy') { copies++; lines.push(` ~ copy to ${item.destination}`, ...item.diff!.slice(0, 6).map(line => ` ${line}`)); } + } else if (item.mode === 'copy' && item.kind === 'copy') { copies++; lines.push(` ~ copy to ${item.destination}`, ...item.diff!.slice(0, 6).map(line => ` ${line}`)); } else lines.push(` · ${item.kind === 'inspect' || item.kind === 'templated' ? item.note : 'skipped'}`); } if (include.length) lines.push('', 'tmux.conf gains one include of the NMSh-managed tmux file:', ...include.map(line => ` ${line}`)); @@ -137,6 +139,8 @@ export function reviewLines(items: readonly DotfilesItem[], include: readonly st /** Backup then atomic replace, refusing if the destination changed since review or is a symlink. */ function copyExact(item: DotfilesItem, now: Date): string { const destination = item.destination!; + const verdict = item.file.tool.exactCopy?.(item.content!); + if (!verdict?.ok) return 'no exact-copy authority for this tool; nothing was written.'; if (existsSync(destination)) { if (lstatSync(destination).isSymbolicLink()) return `${destination} is a symlink (to ${realpathSync(destination)}); not replaced.`; if (sha256(readFileSync(destination, 'utf8')) !== item.destinationSha) return `${destination} changed since review; nothing was written.`; diff --git a/src/session/StreamBacklog.ts b/src/session/StreamBacklog.ts index cd6b0db9..216065e3 100644 --- a/src/session/StreamBacklog.ts +++ b/src/session/StreamBacklog.ts @@ -16,7 +16,12 @@ type SpoolRecord = BacklogEvent export interface BacklogLimits { /** Unacknowledged bytes kept in memory before spilling to the spool file. */ memoryBytes: number; - /** Spool size after which output payloads are dropped (with a counted marker). */ + /** + * Output bytes in the spool after which output payloads are dropped (with a + * counted marker). Command boundaries and prompt metadata are always kept + * and do not consume this budget, so a large alias/function list cannot make + * in-limit output look truncated. + */ spoolBytes: number; } @@ -85,7 +90,7 @@ export class StreamBacklog { private memory: BacklogEvent[] = []; private memoryBytes = 0; private spooled = false; - private spoolSize = 0; + private spoolOutput = 0; private acked = 0; private journal?: string; private truncated = 0; @@ -146,18 +151,18 @@ export class StreamBacklog { // The runtime directory is private (0700); the spool directory is too. mkdirSync(dirname(this.spoolPath), {recursive: true, mode: 0o700}); this.spooled = true; - this.spoolSize = 0; + this.spoolOutput = 0; if (this.acked > 0) records.push({kind: 'ack', seq: this.acked, journalId: this.journal ?? ''}); } let dropped = 0; for (const event of this.memory) { - if (event.kind === 'output' && this.spoolSize + event.data.length > this.limits.spoolBytes) { + if (event.kind === 'output' && this.spoolOutput + event.data.length > this.limits.spoolBytes) { dropped += event.data.length; continue; } if (dropped > 0) { records.push({kind: 'truncated', bytes: dropped}); this.truncated += dropped; dropped = 0; } records.push(event); - this.spoolSize += eventBytes(event); + if (event.kind === 'output') this.spoolOutput += event.data.length; this.lastSpooledSeq = event.seq; } if (dropped > 0) { records.push({kind: 'truncated', bytes: dropped}); this.truncated += dropped; } @@ -175,7 +180,7 @@ export class StreamBacklog { private removeSpool(): void { try { unlinkSync(this.spoolPath); } catch { /* already gone */ } this.spooled = false; - this.spoolSize = 0; + this.spoolOutput = 0; } /** Bytes currently on disk; for tests and diagnostics. */ diff --git a/src/tools/config/registry.ts b/src/tools/config/registry.ts index 138b048d..074eef33 100644 --- a/src/tools/config/registry.ts +++ b/src/tools/config/registry.ts @@ -31,6 +31,16 @@ export interface ToolConfigEntry { dotfiles: RegExp; /** Whether /configure opens an editor for it. */ configurable: boolean; + /** + * Authority to install a dotfiles file byte-for-byte. Absent means never: + * parsing as TOML/JSON says nothing about whether the tool later runs + * commands from it (Starship custom modules, bat's pager, Helix :sh keys). + * An entry may only declare this with a validator that proves the content + * cannot cause execution; none does today. + */ + exactCopy?: (content: string) => {ok: true} | {ok: false; reason: string}; + /** Why dotfiles leaves this tool's config inspect-only, in plain words. */ + dotfilesNote?: string; } const xdg = (env: NodeJS.ProcessEnv, home: string) => env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(home, '.config'); @@ -44,15 +54,18 @@ export const TOOL_CONFIG_REGISTRY: readonly ToolConfigEntry[] = [ {id: 'starship', label: 'Starship', executable: 'starship', configClass: 'structured', ownership: 'native-cli', configurable: true, locations: (env, home) => [env.STARSHIP_CONFIG ?? join(xdg(env, home), 'starship.toml')], takesEffect: 'The next prompt', summary: 'Module visibility through Starship\'s own config CLI, with a backup', - dotfiles: /(?:^|\/)(?:starship\.toml|\.config\/starship\.toml)$/u}, + dotfiles: /(?:^|\/)(?:starship\.toml|\.config\/starship\.toml)$/u, + dotfilesNote: 'Inspect only: Starship config can run commands (custom modules); never copied. Use /configure starship for supported modules'}, {id: 'helix', label: 'Helix', executable: 'hx', configClass: 'structured', ownership: 'theme-bridge', configurable: false, locations: (env, home) => [join(xdg(env, home), 'helix', 'config.toml')], takesEffect: 'New Helix processes', summary: 'The generated NMSh theme and its one reviewed activation (Theme Bridge)', - dotfiles: /(?:^|\/)(?:helix\/config\.toml|\.config\/helix\/config\.toml)$/u}, + dotfiles: /(?:^|\/)(?:helix\/config\.toml|\.config\/helix\/config\.toml)$/u, + dotfilesNote: 'Inspect only: Helix keybindings can run shell commands; never copied. Theme Bridge still manages its own theme'}, {id: 'bat', label: 'bat', executable: 'bat', configClass: 'structured', ownership: 'theme-bridge', configurable: false, locations: (env, home) => [join(env.BAT_CONFIG_DIR ?? join(xdg(env, home), 'bat'), 'config')], takesEffect: 'After a reviewed cache build', summary: 'A generated custom theme and BAT_THEME in NMSh shells (Theme Bridge)', - dotfiles: /(?:^|\/)(?:bat\/config|\.config\/bat\/config)$/u}, + dotfiles: /(?:^|\/)(?:bat\/config|\.config\/bat\/config)$/u, + dotfilesNote: 'Inspect only: bat config can start other programs (pager); never copied. Theme Bridge still manages its own theme'}, {id: 'neovim', label: 'Neovim', executable: 'nvim', configClass: 'executable', ownership: 'theme-bridge', configurable: false, locations: (env, home) => [join(xdg(env, home), 'nvim', 'init.lua'), join(xdg(env, home), 'nvim', 'init.vim')], takesEffect: 'New Neovim processes', summary: 'A generated colorscheme and one reviewed include; Lua config is never rewritten', diff --git a/src/tools/config/tmux.ts b/src/tools/config/tmux.ts index fc61fa5f..6429fef5 100644 --- a/src/tools/config/tmux.ts +++ b/src/tools/config/tmux.ts @@ -2,6 +2,7 @@ import {existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync import {homedir} from 'node:os'; import {dirname, join} from 'node:path'; import {nmshConfigDirectory} from '../../configuration/paths.js'; +import {shellQuote} from '../../host/terminalHost.js'; /** * tmux Tool Configuration: a typed model (option overrides, NMSh-owned key @@ -172,8 +173,29 @@ export const quotablePath = (path: string): boolean => /^\/[^'"\\\u0000-\u001f\u * Run through /bin/sh so it works whatever tmux's default-shell is. */ export function frontendCommand(nmsh: string): string | undefined { - if (!quotablePath(nmsh)) return undefined; - return `exec /bin/sh -c 'if [ "$NMSH_ACTIVE" = 1 ]; then exec "\${SHELL:-/bin/sh}" -l; else exec ${nmsh}; fi'`; + if (!frontendPath(nmsh)) return undefined; + // The program text is fixed; the executable path is only ever argv data ("$1"). + return `${FRONTEND_PROGRAM} ${shellQuote(nmsh)}`; +} + +const FRONTEND_PROGRAM = `exec /bin/sh -c 'if [ "$NMSH_ACTIVE" = 1 ]; then exec "\${SHELL:-/bin/sh}" -l; else exec "$1"; fi' nmsh-frontend`; + +/** Any absolute path without control characters (a tmux config line cannot carry them). */ +const frontendPath = (path: string): boolean => /^\/[^\u0000-\u001f\u007f]*$/u.test(path); + +/** Inverse of tmuxQuote for the subset it emits; undefined for anything else. */ +function tmuxUnquote(quoted: string): string | undefined { + const match = /^"((?:[^"\\$]|\\[\\"$])*)"$/u.exec(quoted); + return match ? match[1]!.replace(/\\(.)/gu, '$1') : undefined; +} + +/** True only for exactly FRONTEND_PROGRAM followed by one shellQuote'd absolute path. */ +function validFrontendLine(quoted: string): boolean { + const command = tmuxUnquote(quoted); + if (!command?.startsWith(`${FRONTEND_PROGRAM} `)) return false; + const arg = command.slice(FRONTEND_PROGRAM.length + 1); + const path = /^'(?:[^']|'\\'')*'$/u.test(arg) ? arg.slice(1, -1).replace(/'\\''/gu, "'") : /^[\w@%+=:,./-]+$/u.test(arg) ? arg : undefined; + return path !== undefined && frontendPath(path) && shellQuote(path) === arg; } function setDirective(option: TmuxOptionDef, value: string): string { @@ -238,7 +260,7 @@ export function validateTmuxConfig(content: string): boolean { const formats = (line.match(/"([^"]*)"/u)?.[1] ?? ''); return !/#\(/u.test(formats); } - if (/^set -g default-command "exec \/bin\/sh -c 'if \[ \\"\\\$NMSH_ACTIVE\\" = 1 \]; then exec \\"\\\$\{SHELL:-\/bin\/sh\}\\" -l; else exec \/[^'"\\$`]+; fi'"$/u.test(line)) return true; + if (line.startsWith('set -g default-command ')) return validFrontendLine(line.slice('set -g default-command '.length)); return /^source-file -q '\/[^']+'$/u.test(line); }); } diff --git a/tests/dotfilesCommands.test.ts b/tests/dotfilesCommands.test.ts index 062160ab..5dd93b26 100644 --- a/tests/dotfilesCommands.test.ts +++ b/tests/dotfilesCommands.test.ts @@ -98,7 +98,7 @@ test('dotfiles: field-level choices keep current values on conflict; review defa assert.equal(tmux.fields!.find(field => field.repo === '10')!.use, true); assert.match(tmux.note, /1 dynamic lines never run/u); const starship = items.find(item => item.file.tool.id === 'starship')!; - assert.equal(starship.mode, 'skip', 'copying a whole file is opt-in'); + assert.deepEqual([starship.kind, starship.modes], ['inspect', ['skip']], 'parseable is not copyable: no exact-copy authority'); starship.mode = 'copy'; const panel = createDotfilesPanel(box.repo); @@ -110,18 +110,22 @@ test('dotfiles: field-level choices keep current values on conflict; review defa const results = applyPlan(items, box.env, new Date('2026-01-02T03:04:05Z')); assert.equal(results.length, 2, results.join('\n')); - assert.equal(readFileSync(join(box.home, '.config', 'starship.toml'), 'utf8'), 'add_newline = false\n'); - assert.ok(readdirSync(join(box.home, '.config')).some(name => name.startsWith('starship.toml.nmsh-backup-'))); + assert.match(results.join('\n'), /Starship: no exact-copy authority/u, 'a forced copy mode is still refused at apply time'); + assert.equal(readFileSync(join(box.home, '.config', 'starship.toml'), 'utf8'), 'add_newline = true\n'); + assert.ok(!readdirSync(join(box.home, '.config')).some(name => name.startsWith('starship.toml.nmsh-backup-'))); assert.equal(readFileSync(join(box.home, '.tmux.conf'), 'utf8'), 'set -g mouse off\n', 'your tmux.conf is untouched (include is a separate reviewed step)'); assert.equal(snapshot(box.repo), before, 'the repository is never modified'); } finally { box.done(); } }); -test('dotfiles: copy refuses when the destination changed since review; remote sources need a confirmed clone', () => { +test('dotfiles: an entry declaring exactCopy copies with backup, but refuses when the destination changed since review; remote sources need a confirmed clone', () => { const box = sandbox(); try { box.put('.config/starship.toml', 'add_newline = false\n'); - const items = buildPlan(scan(box.repo), box.env); + const result = scan(box.repo); + result.found[0]!.tool = {...result.found[0]!.tool, exactCopy: () => ({ok: true})}; + const items = buildPlan(result, box.env); + assert.deepEqual(items[0]!.modes, ['skip', 'copy'], 'only an explicitly declared validator grants copy'); items[0]!.mode = 'copy'; mkdirSync(join(box.home, '.config'), {recursive: true}); writeFileSync(join(box.home, '.config', 'starship.toml'), 'appeared later\n'); @@ -165,3 +169,30 @@ test('palette and help list each new surface once, with human labels and grouped for (const command of ['/motion', '/chrome', '/glyphs', '/strip', '/tmux', '/configure', '/integrations', '/dotfiles', '/history-provider']) assert.ok(help.includes(`\`${command}\``), command); assert.match(help, /`\/layout` \(also `\/composer`\)/u); }); + +test('dotfiles: parseable but command-capable Starship, bat and Helix configs are inspect-only; nothing is copied or run', async () => { + const box = sandbox(); + try { + const canary = join(box.root, 'PWNED'); + box.put('.config/starship.toml', `format = "$custom"\n[custom.evil]\ncommand = "touch '${canary}'"\nwhen = "true"\nshell = ["sh"]\n`); + box.put('.config/bat/config', `--pager="sh -c 'touch ${canary}'"\n--paging=always\n`); + box.put('.config/helix/config.toml', `[keys.normal]\nX = ":sh touch ${canary}"\n[keys.normal.space]\nY = [":run-shell-command touch ${canary}"]\n`); + const before = snapshot(box.repo); + const items = buildPlan(scan(box.repo), box.env); + assert.deepEqual(items.map(item => item.file.tool.id).sort(), ['bat', 'helix', 'starship']); + for (const item of items) { + assert.deepEqual([item.kind, item.mode, item.modes], ['inspect', 'skip', ['skip']], item.file.tool.id); + assert.match(item.note, /^Inspect only: .*never copied/u, item.note); + assert.equal(item.content, undefined); + item.mode = 'copy'; + } + assert.doesNotMatch(reviewLines(items, []).join('\n'), /copy to|1 file/u); + const results = applyPlan(items, box.env); + assert.ok(results.every(line => /no exact-copy authority/u.test(line)), results.join('\n')); + assert.deepEqual(readdirSync(box.home), [], 'no destination was written'); + assert.equal(readdirSync(box.root).includes('PWNED'), false, 'nothing executed'); + assert.equal(snapshot(box.repo), before); + const {TOOL_CONFIG_REGISTRY} = await import('../src/tools/config/registry.js'); + assert.deepEqual(TOOL_CONFIG_REGISTRY.filter(entry => entry.exactCopy).map(entry => entry.id), [], 'no registry entry holds exact-copy authority'); + } finally { box.done(); } +}); diff --git a/tests/shellSwitchApp.test.ts b/tests/shellSwitchApp.test.ts index 7a663827..bcadd67b 100644 --- a/tests/shellSwitchApp.test.ts +++ b/tests/shellSwitchApp.test.ts @@ -13,9 +13,11 @@ import {SemanticService} from '../src/shell/SemanticService.js'; const available = ['fish', 'bash'].every(id => shellAdapter(id as 'fish' | 'bash').resolveExecutable(process.env)); const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; +let diagnose: () => string = () => ''; + async function until(check: () => boolean, timeoutMs = 15000): Promise { const deadline = Date.now() + timeoutMs; - while (!check()) { if (Date.now() > deadline) throw new Error('condition not reached'); await new Promise(resolve => setTimeout(resolve, 25)); } + while (!check()) { if (Date.now() > deadline) throw new Error(`condition not reached\n${diagnose()}`); await new Promise(resolve => setTimeout(resolve, 25)); } } function transcript(app: TerminalApp): string { @@ -30,6 +32,12 @@ test('app hot swap: zsh → fish → bash → zsh keeps draft, transcript and cw const app = new TerminalApp({client, mode: 'in-process', shell: 'zsh'}); Object.defineProperty(app, 'render', {value: () => {}}); Object.defineProperty(app, 'dimensions', {value: () => ({columns: 100, rows: 30})}); + diagnose = () => { + const shell = (client as unknown as {shell: {pid?: number; isReady?: boolean; startupRaw?: string}}).shell; + return JSON.stringify({shellId: app['shellId'], pid: shell.pid, alive: shell.pid ? alive(shell.pid) : false, isReady: shell.isReady, + switchedShellStarting: app['switchedShellStarting'], startupPending: app['startupPending'], running: Boolean(app['running']), + startupRaw: shell.startupRaw?.slice(-1500), transcript: transcript(app).slice(-1500)}, null, 1); + }; try { client.start(); await until(() => app['shellCwd'] === cwd && !app['running']); diff --git a/tests/streamBacklog.test.ts b/tests/streamBacklog.test.ts index 843d39fb..159bd670 100644 --- a/tests/streamBacklog.test.ts +++ b/tests/streamBacklog.test.ts @@ -57,6 +57,22 @@ test('past the spool limit output is dropped with a count; command boundaries ar rmSync(dir, {recursive: true, force: true}); }); +test('prompt metadata (a large alias/function list) never consumes the output budget', () => { + const dir = scratch(); + const backlog = new StreamBacklog(join(dir, 's.jsonl'), {memoryBytes: 10, spoolBytes: 200}); + // Ubuntu's global compinit puts hundreds of autoload names in each prompt's knowledge. + const knowledge = 'function _x\n'.repeat(1000); + backlog.append({kind: 'prompt', seq: 1, at: 1, exitCode: 0, cwd: '/w', knowledge}); + backlog.append({kind: 'exec', seq: 2, at: 2, command: 'seq 1 50'}); + backlog.append(output(3, 'o'.repeat(200))); + backlog.append({kind: 'prompt', seq: 4, at: 4, exitCode: 0, cwd: '/w', knowledge}); + assert.equal(backlog.truncatedBytes, 0, 'output within the limit is complete'); + assert.deepEqual(backlog.events().map(event => event.kind), ['prompt', 'exec', 'output', 'prompt']); + backlog.append(output(5, 'z'.repeat(20))); + assert.equal(backlog.truncatedBytes, 20, 'the output cap still holds'); + rmSync(dir, {recursive: true, force: true}); +}); + test('a spool torn by a crash mid-write reads back as its complete records', () => { const dir = scratch(); const path = join(dir, 's.jsonl'); diff --git a/tests/toolConfig.test.ts b/tests/toolConfig.test.ts index b1c25348..1d130307 100644 --- a/tests/toolConfig.test.ts +++ b/tests/toolConfig.test.ts @@ -53,7 +53,8 @@ test('tmux frontend: new panes start NMSh by absolute path; inside an NMSh-start writeFileSync(fakeNmsh, `#!/bin/sh\necho NMSH-FRONTEND\n[ -n "$TMUX" ] && echo yes > '${marker}'\n`); chmodSync(fakeNmsh, 0o755); const command = frontendCommand(fakeNmsh)!; - assert.equal(frontendCommand("/tmp/it's"), undefined, 'unquotable paths are refused'); + assert.equal(frontendCommand('/tmp/a\nb'), undefined, 'control characters are refused'); + assert.equal(frontendCommand('relative/nmsh'), undefined, 'only absolute paths'); const run = (env: Record) => spawnSync('/bin/sh', ['-c', command], {encoding: 'utf8', env: {PATH: '/usr/bin:/bin', SHELL: '/bin/echo', ...env}}).stdout.trim(); assert.equal(run({}), 'NMSH-FRONTEND'); assert.equal(run({NMSH_ACTIVE: '1'}), '-l', 'NMSh recursion guard respected: the login shell (here echo) starts instead'); @@ -72,6 +73,41 @@ test('tmux frontend: new panes start NMSh by absolute path; inside an NMSh-start } finally { box.done(); } }); +test('tmux frontend: the NMSh path is argv data through both tmux and /bin/sh parsing; hostile names never execute', () => { + const box = sandbox(); + try { + const sentinel = join(box.root, 'PWNED'); + const names = ['plain', 'with space', 'semi;touch PWNED', 'amp&touch PWNED', 'pipe|touch PWNED', 'paren(touch PWNED)', 'dollar$(touch PWNED)', 'dollar$HOME', + 'tick`touch PWNED`', "single'quote", 'double"quote', 'back\\slash', 'lt', 'hash#x', "mix'\";touch PWNED;'"]; + for (const [index, name] of names.entries()) { + const dir = join(box.root, `${index}-${name}`); + mkdirSync(dir); + const nmsh = join(dir, 'nmsh'); + const out = join(box.root, `out-${index}`); + writeFileSync(nmsh, `#!/bin/sh\necho "$0" > '${out}'\n`); + chmodSync(nmsh, 0o755); + const command = frontendCommand(nmsh); + assert.ok(command, name); + const ran = spawnSync('/bin/sh', ['-c', command], {cwd: box.root, encoding: 'utf8', env: {PATH: '/usr/bin:/bin', SHELL: '/bin/echo'}}); + assert.equal(ran.status, 0, `${name}: ${ran.stderr}`); + assert.equal(readFileSync(out, 'utf8').trim(), nmsh, `${name}: exec'd the literal path`); + const text = renderTmuxConfig(apply(DEFAULT_TMUX_MODEL(), {kind: 'frontend', value: 'nmsh'}), {self: '/n/s', theme: '/n/t', nmsh}); + assert.ok(validateTmuxConfig(text), name); + rmSync(out); + if (!tmux) continue; + const conf = join(box.root, `t-${index}.conf`); + writeFileSync(conf, text); + const socket = `nmsh-adv-${process.pid}-${index}`; + spawnSync(tmux, ['-L', socket, '-f', conf, 'new-session', '-d', '-c', box.root, '-x', '80', '-y', '10', ';', 'run-shell', 'sleep 0.3', ';', 'kill-server'], + {encoding: 'utf8', env: {...process.env, TMUX: '', NMSH_ACTIVE: '', TMUX_TMPDIR: box.root}}); + assert.equal(readFileSync(out, 'utf8').trim(), nmsh, `${name}: real tmux pane exec'd the literal path`); + } + assert.equal(existsSync(sentinel), false, 'no injected command ran'); + assert.equal(validateTmuxConfig(`set -g default-command "exec /bin/sh -c 'touch /tmp/x' nmsh-frontend /bin/sh"\n`), false); + assert.equal(validateTmuxConfig(`set -g default-command ${JSON.stringify(`${frontendCommand('/a/nmsh')!}; touch x`)}\n`), false, 'trailing shell text is rejected'); + } finally { box.done(); } +}); + test('tmux managed file: real tmux loads every setting; one include only; import reads a safe subset and never evaluates dynamic lines', () => { const box = sandbox(); try { From fd351fbf5c2797954ece5cce9addedc40f646647 Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:23:24 +0530 Subject: [PATCH 09/16] Fix Bash file-listing wrapper parse failure; add real-shell and prompt-literal regression tests Bash alias-expands `ls()` while parsing, so with ls already an alias (Ubuntu's default bashrc) the generated bootstrap failed to parse and the managed Bash never became ready. Use the `function ls { ... }` form; zsh and fish were unaffected. - Real bash/zsh/fish tests of the listing wrappers: parse with ls/gls aliased, --color=auto before user argv, existing alias/function kept, missing executables not wrapped, Off removes only NMSh's wrapper. - Live managed Bash with an aliased ls in ~/.bashrc reaches ready. - Native prompt: hostile Git branch names, repository names and context values (zsh % escapes, $()/backtick/${}, quotes, ANSI/OSC/C1) render literally and inertly in live, snapshot and historical prompts; no canary runs. No renderer change was needed. --- src/themeBridge/environment.ts | 5 +- tests/promptGitLiteral.test.ts | 122 +++++++++++++++++++++++++++++++ tests/themeBridgeListing.test.ts | 102 ++++++++++++++++++++++++++ tests/themeBridgeLive.test.ts | 17 ++++- 4 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 tests/promptGitLiteral.test.ts create mode 100644 tests/themeBridgeListing.test.ts diff --git a/src/themeBridge/environment.ts b/src/themeBridge/environment.ts index 7d9635fa..926d9e29 100644 --- a/src/themeBridge/environment.ts +++ b/src/themeBridge/environment.ts @@ -257,8 +257,9 @@ nmsh_bridge_listing() { { builtin alias "$2" || builtin declare -F "$2"; } >/dev/null 2>&1 && return 0 builtin type -P "$2" >/dev/null 2>&1 || return 0 case $2 in - ls) ls() { command ls --color=auto "$@"; } ;; - gls) gls() { command gls --color=auto "$@"; } ;; + # 'function name' form: Bash alias-expands 'name()' while parsing, and ls is often an alias (Ubuntu's default bashrc). + ls) function ls { command ls --color=auto "$@"; } ;; + gls) function gls { command gls --color=auto "$@"; } ;; esac printf -v "$marker" '%s' 1 elif [[ -n "\${!marker+x}" ]]; then diff --git a/tests/promptGitLiteral.test.ts b/tests/promptGitLiteral.test.ts new file mode 100644 index 00000000..57a556c9 --- /dev/null +++ b/tests/promptGitLiteral.test.ts @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {buildContextLine, nativePromptSnapshot} from '../src/prompt/prompt.js'; +import {DEFAULT_PROMPT_CONFIGURATION, DEFAULT_TRANSCRIPT_APPEARANCE, normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {resolvePromptContext, type PromptContext} from '../src/shell/ShellContext.js'; +import {renderHistoricalContext} from '../src/output/TranscriptPresenter.js'; +import {stripAnsi} from '../src/util/text.js'; + +/** + * Repository-controlled text (branch, repository directory name, cwd) is data + * in NMSh Native: Git is probed with execFile (no shell), the managed shell's + * own PROMPT/PS1 are empty, and the TypeScript renderer never hands these + * values to a prompt parser. So zsh prompt escapes (%n, %F{red}, %(?.a.b)) + * and shell substitutions ($(..), `..`, ${..}) must show literally, nothing + * may run, and control characters must be inert. Cf. the Oh My Zsh + * PROMPT_SUBST branch-name advisory (GHSA-x96c-8w82-wf96). + */ + +const SGR = /\u001B\[[0-9;:]*m/gu; +const CONTROL = /[\u0000-\u001f\u007f-\u009f]/u; + +/** Only NMSh's own SGR color sequences may remain; nothing from the data can form an escape. */ +function assertInert(ansi: string, label: string): void { + const residue = ansi.replace(SGR, ''); + assert.equal(CONTROL.test(residue), false, `${label}: a control character or non-SGR escape survived: ${JSON.stringify(residue)}`); +} + +const configuration = normalizePromptConfiguration({...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider: 'nmsh'}); +const historicalLevels = ['full', 'compact', 'minimal'] as const; + +/** Live composer + header line, snapshot, and every historical level, all checked. */ +function renderEverywhere(context: PromptContext, width = 600): {live: string[]; history: string[]} { + const before = structuredClone(context); + const live = (['composer', 'header'] as const).map(placement => buildContextLine(context, width, configuration, placement)); + const snapshot = nativePromptSnapshot(context, configuration); + const history = historicalLevels.flatMap(level => { + const row = renderHistoricalContext({cwd: context.cwd, project: context.project, branch: context.branch, prompt: snapshot}, width, + {...DEFAULT_TRANSCRIPT_APPEARANCE, historicalPrompt: true, historicalPromptLevel: level}); + return row ? [row.ansi] : []; + }); + assert.deepEqual(context, before, 'rendering never mutates the context'); + for (const [index, line] of [...live, ...history].entries()) assertInert(line, `render ${index}`); + return {live, history}; +} + +const PROMPT_ESCAPES = ['%n', '%M', '%~', '%F{red}x%f', '%(?.yes.no)', '%{%}', '%B%U']; + +function git(cwd: string, ...args: string[]) { + const result = spawnSync('git', args, {cwd, encoding: 'utf8', env: {...process.env, GIT_CONFIG_NOSYSTEM: '1', HOME: cwd}}); + return result; +} + +test('real Git: hostile branch names Git accepts stay literal in the live prompt, snapshot and history; nothing executes', {skip: !spawnSync('git', ['--version']).stdout && 'git not installed'}, async () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-gitlit-')); + try { + // A canary command on PATH: any substitution that ran would create the file. + const bin = join(root, 'bin'); + mkdirSync(bin); + const canary = join(root, 'PWNED'); + writeFileSync(join(bin, 'pwn'), `#!/bin/sh\ntouch '${canary}'\n`); + chmodSync(join(bin, 'pwn'), 0o755); + const savedPath = process.env.PATH; + process.env.PATH = `${bin}:${savedPath}`; + const repo = join(root, 'repo$(pwn)%n'); + mkdirSync(repo); + assert.equal(git(repo, 'init', '-q', '-b', 'main').status, 0); + git(repo, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'x'); + const branches = [...PROMPT_ESCAPES, '$USER', '$(pwn)', '`pwn`', '${HOME}', "it's\"quoted\"", 'a;pwn', 'a&pwn', 'a|pwn', 'ünïcødé/分支/🚀', + `long-${'x'.repeat(200)}`]; + try { + const legal = branches.filter(branch => git(repo, 'check-ref-format', '--branch', branch).status === 0); + // Names Git forbids (here %~ and %(?.yes.no)) are covered at the renderer level below, never by weakening Git. + assert.deepEqual(branches.filter(branch => !legal.includes(branch)), ['%~', '%(?.yes.no)'], 'only ~ and ? are refused by Git here'); + for (const branch of legal) { + const made = git(repo, 'checkout', '-q', '-b', branch); + assert.equal(made.status, 0, `${branch}: ${made.stderr}`); + const context = await resolvePromptContext(repo, undefined, '/nonexistent-home'); + assert.equal(context.branch, branch, 'the branch arrives as the exact data Git stored'); + assert.equal(context.project, 'repo$(pwn)%n'); + const {live, history} = renderEverywhere(context, branch.length > 100 ? 120 : 600); + if (branch.length <= 100) { + assert.ok(live.some(line => stripAnsi(line).includes(branch)), `${branch}: shown literally in the live prompt`); + assert.ok(history.some(line => stripAnsi(line).includes(branch)), `${branch}: shown literally in history`); + assert.ok(live.some(line => stripAnsi(line).includes('repo$(pwn)%n')), 'the repository name is literal too'); + } + git(repo, 'checkout', '-q', 'main'); + } + } finally { process.env.PATH = savedPath; } + assert.equal(existsSync(canary), false, 'no substitution was executed'); + assert.equal(readdirSync(root).includes('PWNED'), false); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('renderer: values Git itself forbids (backslash, spaces, ANSI, OSC, BEL, C1, NUL) are rendered inert, never as escapes', () => { + const hostile = [ + 'back\\slash\\e[31m', 'with space ; & | $(pwn) `pwn`', '\u001B[2J\u001B[Hcleared', '\u001B]0;owned title\u0007', '\u001B]8;;https://evil.example\u001B\\link\u001B]8;;\u001B\\', + '\u009B31mC1-CSI', '\u009D0;c1-osc\u009C', 'nul\u0000byte', 'bell\u0007', 'cr\rover', 'tab\tand\nnewline', '\u001BPdcs\u001B\\', + ]; + for (const value of hostile) { + const context: PromptContext = {cwd: `/tmp/${value}`, project: value, branch: value, git: {staged: 0, modified: 0, untracked: 0, conflicts: 0, ahead: 0, behind: 0}, + kubeContext: value, dockerContext: value}; + const {live, history} = renderEverywhere(context); + for (const line of [...live, ...history]) { + assert.equal(/\u001B\]|\u001B\[2J|\u001BP|\u009B|\u009D/u.test(line), false, `${JSON.stringify(value)}: no data-derived escape`); + } + const visible = value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ''); + if (!/[\u0000-\u001f\u007f-\u009f]/u.test(value)) assert.ok(live.some(line => stripAnsi(line).includes(visible)), `${value}: literal`); + } +}); + +test('renderer: zsh prompt escapes are plain text here, not translated, so a literal % is preserved rather than doubled', () => { + for (const branch of PROMPT_ESCAPES) { + const {live} = renderEverywhere({cwd: '/r', project: 'r', branch}); + const shown = stripAnsi(live[0]!); + assert.ok(shown.includes(branch), `${branch} literal`); + assert.equal(shown.includes(branch.replace(/%/gu, '%%')) && !branch.includes('%%'), false, 'no Oh My Zsh-style %% escaping leaks into display'); + } +}); diff --git a/tests/themeBridgeListing.test.ts b/tests/themeBridgeListing.test.ts new file mode 100644 index 00000000..2fb05a64 --- /dev/null +++ b/tests/themeBridgeListing.test.ts @@ -0,0 +1,102 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {bridgeBootstrap, renderEnvironmentFile, type BridgeEnvironment, type ListingWrapper} from '../src/themeBridge/environment.js'; + +/** + * File listing color wrappers through real shells: the generated bootstrap + * parses (also when ls is already an alias, as in Ubuntu's default bashrc, + * which once broke Bash's `ls()` form), wraps only real executables, never + * replaces the user's alias or function, keeps --color=auto before the + * user's argv, and Off removes only NMSh's wrapper. + */ + +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const zsh = ['/bin/zsh', '/usr/bin/zsh', '/opt/homebrew/bin/zsh'].find(existsSync); +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const shells = {bash, zsh, fish} as const; + +function box(withGls: boolean) { + const root = mkdtempSync(join(tmpdir(), 'nmsh-listing-')); + const bin = join(root, 'bin'); + mkdirSync(bin); + for (const name of withGls ? ['ls', 'gls'] : ['ls']) { + writeFileSync(join(bin, name), '#!/bin/sh\necho "ARGS:$*"\n'); + chmodSync(join(bin, name), 0o755); + } + return {root, bin, done: () => rmSync(root, {recursive: true, force: true})}; +} + +const values = (listing: ListingWrapper[]): BridgeEnvironment => ({LS_COLORS: 'di=34', ...(listing.length ? {listing} : {})}); + +/** Runs a script that loads the bootstrap, syncs listing On, runs probes, switches Off, runs probes again. */ +function run(shell: keyof typeof shells, listing: ListingWrapper[], options: {withGls?: boolean; userSetup?: string} = {}) { + const sandbox = box(options.withGls ?? true); + try { + const live = join(sandbox.root, 'env'); + const on = join(sandbox.root, 'on'); + const off = join(sandbox.root, 'off'); + writeFileSync(on, renderEnvironmentFile(shell, values(listing))); + writeFileSync(off, renderEnvironmentFile(shell, values([]))); + const probe = shell === 'fish' + ? 'echo "ls=$(ls --color=never a)"; echo "gls=$(gls b 2>/dev/null; or echo NONE)"; keep' + : 'echo "ls=$(ls --color=never a)"; echo "gls=$(gls b 2>/dev/null || echo NONE)"; keep'; + const keep = shell === 'fish' ? 'function keep; echo KEPT; end' : 'keep() { echo KEPT; }'; + const script = [shell === 'bash' ? 'shopt -s expand_aliases' : '', options.userSetup ?? '', keep, bridgeBootstrap(shell, live), + `cp '${on}' '${live}'`, 'nmsh_bridge_sync', 'echo ---on', probe, `cp '${off}' '${live}'`, 'nmsh_bridge_sync', 'echo ---off', probe].join('\n'); + const file = join(sandbox.root, 'script'); + writeFileSync(file, script); + const result = spawnSync(shells[shell]!, shell === 'zsh' ? ['-f', file] : shell === 'bash' ? ['--norc', '--noprofile', file] : ['--no-config', file], + {encoding: 'utf8', env: {PATH: `${sandbox.bin}:/usr/bin:/bin`, HOME: sandbox.root}}); + const [, onPart = '', offPart = ''] = result.stdout.split(/---(?:on|off)\n/u); + return {status: result.status, stderr: result.stderr, on: onPart.trim(), off: offPart.trim(), script}; + } finally { sandbox.done(); } +} + +test('bash: every listing variant of the generated bootstrap and environment file parses (bash -n), also with ls/gls aliased', {skip: !bash && 'bash 4.4+ not installed'}, () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-listing-parse-')); + try { + for (const listing of [[], ['ls'], ['gls'], ['ls', 'gls']] as ListingWrapper[][]) { + const file = join(root, `env-${listing.join('-') || 'none'}`); + writeFileSync(file, `${bridgeBootstrap('bash', file)}\n${renderEnvironmentFile('bash', values(listing))}`); + const parsed = spawnSync(bash!, ['-n', file], {encoding: 'utf8'}); + assert.equal(parsed.status, 0, `${listing.join(',')}: ${parsed.stderr}`); + // bash -n alone missed the real bug: Bash alias-expands `ls()` while parsing when ls is an alias. + const sourced = spawnSync(bash!, ['--norc', '-c', `shopt -s expand_aliases\nalias ls='ls --color=auto'\nalias gls='gls -F'\nsource '${file}' && echo SOURCED`], {encoding: 'utf8'}); + assert.match(sourced.stdout, /SOURCED/u, `${listing.join(',')}: ${sourced.stderr}`); + } + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +for (const shell of ['bash', 'zsh', 'fish'] as const) { + test(`${shell}: listing wrappers put --color=auto before the user's argv; Off removes only NMSh's wrapper; unrelated functions survive`, {skip: !shells[shell] && `${shell} not installed`}, () => { + const result = run(shell, ['ls', 'gls']); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.equal(result.on, 'ls=ARGS:--color=auto --color=never a\ngls=ARGS:--color=auto b\nKEPT', 'explicit --color=never comes later and wins'); + // Fish ships its own ls function (already --color=auto); NMSh never replaces an existing function, so only gls changes there. + assert.equal(result.off, `ls=ARGS:${shell === 'fish' ? '--color=auto ' : ''}--color=never a\ngls=ARGS:b\nKEPT`); + }); + + test(`${shell}: an existing alias or function is never replaced; a missing executable is never wrapped`, {skip: !shells[shell] && `${shell} not installed`}, () => { + const setup = shell === 'fish' ? 'function gls; echo USERGLS; end' : `alias ls='ls -F'\ngls() { echo USERGLS; }`; + const own = run(shell, ['ls', 'gls'], {userSetup: setup}); + assert.equal(own.status, 0, own.stderr); + assert.equal(own.stderr, ''); + assert.match(own.on, /^gls=USERGLS$/mu, 'the user function stays'); + assert.match(own.off, /^gls=USERGLS$/mu, 'Off does not remove what NMSh did not create'); + if (shell !== 'fish') { + assert.match(own.on, /^ls=ARGS:-F --color=never a$/mu, 'the user alias stays'); + assert.match(own.off, /^ls=ARGS:-F --color=never a$/mu); + } + const missing = run(shell, ['ls', 'gls'], {withGls: false}); + assert.equal(missing.status, 0, missing.stderr); + assert.match(missing.on, /^gls=NONE$/mu, 'no wrapper for an executable that is not installed'); + const lsOnly = run(shell, ['ls']); + assert.match(lsOnly.on, /^ls=ARGS:--color=auto --color=never a\ngls=ARGS:b$/mu, 'only the enabled wrapper exists'); + }); +} diff --git a/tests/themeBridgeLive.test.ts b/tests/themeBridgeLive.test.ts index 52cd7f41..e6b25e7e 100644 --- a/tests/themeBridgeLive.test.ts +++ b/tests/themeBridgeLive.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {existsSync, readdirSync} from 'node:fs'; +import {existsSync, readdirSync, writeFileSync} from 'node:fs'; import {join} from 'node:path'; import {spawnSync} from 'node:child_process'; import {LiveSandbox, until} from './helpers/liveFrontend.js'; @@ -39,3 +39,18 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { } finally { await sandbox.dispose(); } }); } + +test('live bash: with ls aliased in ~/.bashrc (as Ubuntu ships it) and File listing colors on, the managed shell parses its bootstrap and becomes ready', + {skip: bash ? false : 'bash not installed', timeout: 90_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'bash', themeBridge: {enabled: true, targets: {lsColors: {mode: 'follow'}}}}, + {PATH: `${bash!.replace(/\/bash$/u, '')}:${process.env.PATH}`}); + try { + writeFileSync(join(sandbox.home, '.bashrc'), "alias ls='ls -F'\nalias gls='gls -F'\n"); + const frontend = sandbox.launch(); + const envFile = join(sandbox.config, 'nmsh', 'theme-bridge', 'environment.bash'); + await until(() => existsSync(envFile), 20_000, 'the Theme Bridge environment file'); + await frontend.run('echo BASH-READY', /BASH-READY/u); + await frontend.run('echo "lc=${LS_COLORS:+set}"', /lc=set/u); + assert.doesNotMatch(frontend.output, /syntax error/u); + } finally { await sandbox.dispose(); } + }); From 9673b2fd9c7e2feee3e9f8c116f3f9a7b0ec479a Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:55:38 +0530 Subject: [PATCH 10/16] Shell frameworks and Oh My Posh provider, Keep Awake, Homebrew install provenance Shell frameworks and prompt engines - /tools detects tools by an explicit strategy (executable or a registered filesystem detector). Oh My Zsh, Powerlevel10k, Prezto, Zim, zinit and Antidote are found by documented layouts only; detection grants no install, uninstall, configuration or provider authority. Zsh-only tools stay visible under Bash/Fish ("used by Zsh only"). - Oh My Zsh: guided install that never runs the installer. NMSh backs up and fingerprints .zshrc, shows the official source and the documented KEEP_ZSHRC=yes CHSH=no RUNZSH=no (REPO/REMOTE/BRANCH pinned) steps, and verifies afterwards; an unexpected .zshrc change is reported, never silently restored. .zshrc.pre-oh-my-zsh comparison with a reviewed restore (default No, backup first, atomic replace, no merge). - Powerlevel10k in /tools routes to the existing detector, provider and p10k configurator. - Oh My Posh: new Prompt provider (oh-my-posh print primary, argv only, no TTY, bounded, timed out, cancellable, Native fallback) and a curated homebrew/core install; Theme Studio import of its config stays static. - Ask: typed routes for these requests; dotfiles treats .p10k.zsh, Oh My Zsh themes/plugins and Oh My Posh configs as inspect-only. Keep Awake (/caffeinate, /awake, /zoomies) - One controller over the OS mechanism: /usr/bin/caffeinate, systemd-inhibit (idle/sleep only, Display reported unsupported) and SetThreadExecutionState from a fixed PowerShell helper (no away mode, no powercfg). Detached so it outlives the window; ownership is a token plus the exact command line, so unverifiable records are cleared and nothing is killed. Strict timeouts, status, idempotent stop, confirmed mode changes that start the new assertion first. Homebrew - A Homebrew keg is recognized from the Cellar layout and Homebrew's INSTALL_RECEIPT; /update, /version, /status and doctor say so and point to brew upgrade nmsh instead of touching the Cellar. - Release workflow proposes the raiseCatError/homebrew-tap formula update as a pull request for stable releases only. --- .github/workflows/homebrew-tap.yml | 67 +++++ CHANGELOG.md | 4 + README.md | 4 +- src/app/TerminalApp.ts | 116 +++++++- src/ask/concepts.ts | 5 +- src/ask/configActions.ts | 67 ++++- src/ask/types.ts | 2 + src/cli/doctor.ts | 2 + src/commands/slashCommands.ts | 21 ++ src/configuration/portability.ts | 2 +- src/dotfiles/plan.ts | 2 +- src/help/helpContent.ts | 6 +- src/keepAwake/KeepAwakePanel.ts | 89 ++++++ src/keepAwake/keepAwake.ts | 301 +++++++++++++++++++ src/prompt/Powerlevel10kConfigurator.ts | 5 +- src/prompt/PromptPanel.ts | 1 + src/prompt/configuration.ts | 13 +- src/prompt/ohMyPosh.ts | 112 ++++++++ src/sessions/TranscriptStore.ts | 2 +- src/tools/OhMyZshView.ts | 157 ++++++++++ src/tools/ToolsPanel.ts | 100 ++++++- src/tools/catalog.ts | 65 ++++- src/tools/config/registry.ts | 22 +- src/tools/frameworks.ts | 157 ++++++++++ src/ui/CommandPalette.ts | 4 + src/update/update.ts | 34 +++ tests/autocomplete.test.ts | 2 +- tests/homebrewInstall.test.ts | 33 +++ tests/keepAwake.test.ts | 243 ++++++++++++++++ tests/powerlevel10k.test.ts | 7 +- tests/providers.test.ts | 2 +- tests/shellFrameworks.test.ts | 366 ++++++++++++++++++++++++ tests/tools.test.ts | 5 +- 33 files changed, 1975 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/homebrew-tap.yml create mode 100644 src/keepAwake/KeepAwakePanel.ts create mode 100644 src/keepAwake/keepAwake.ts create mode 100644 src/prompt/ohMyPosh.ts create mode 100644 src/tools/OhMyZshView.ts create mode 100644 src/tools/frameworks.ts create mode 100644 tests/homebrewInstall.test.ts create mode 100644 tests/keepAwake.test.ts create mode 100644 tests/shellFrameworks.test.ts diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml new file mode 100644 index 00000000..71703ac0 --- /dev/null +++ b/.github/workflows/homebrew-tap.yml @@ -0,0 +1,67 @@ +name: Homebrew tap + +# After a stable GitHub release is published, propose the matching formula +# update to raiseCatError/homebrew-tap as a pull request (reviewable; the +# tap's own CI installs and tests it). Never runs for prereleases, dev pushes +# or pull requests. +# +# Needs one secret, HOMEBREW_TAP_TOKEN: a fine-grained token limited to the +# raiseCatError/homebrew-tap repository with Contents and Pull requests write. + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + formula: + if: ${{ !github.event.release.prerelease && !github.event.release.draft }} + runs-on: ubuntu-latest + steps: + - name: Release facts + id: release + env: + TAG: ${{ github.event.release.tag_name }} + run: | + # Strict stable semver only: the value later reaches a URL and a sed expression. + printf '%s' "$TAG" | grep -Eqx 'v[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}' || { echo "Not a stable version tag."; exit 1; } + version="${TAG#v}" + url="https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/${TAG}.tar.gz" + curl -fsSL --retry 3 -o release.tar.gz "$url" + sha256="$(sha256sum release.tar.gz | cut -d' ' -f1)" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "url=$url" >> "$GITHUB_OUTPUT" + echo "sha256=$sha256" >> "$GITHUB_OUTPUT" + + - name: Check out the tap + uses: actions/checkout@v4 + with: + repository: raiseCatError/homebrew-tap + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: tap + + - name: Propose the formula update + working-directory: tap + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + VERSION: ${{ steps.release.outputs.version }} + URL: ${{ steps.release.outputs.url }} + SHA256: ${{ steps.release.outputs.sha256 }} + run: | + formula=Formula/nmsh.rb + if grep -q "^ url \"$URL\"$" "$formula"; then + # A published archive is immutable: never re-point the same version at different bytes. + grep -q "^ sha256 \"$SHA256\"$" "$formula" || { echo "The formula already names $URL with a different sha256. Publish a new patch release instead."; exit 1; } + echo "Formula already at $VERSION."; exit 0 + fi + sed -i -E "s|^ url \".*\"$| url \"$URL\"|; s|^ sha256 \".*\"$| sha256 \"$SHA256\"|" "$formula" + git diff --stat + branch="nmsh-$VERSION" + git switch -c "$branch" + git -c user.name=J -c user.email=315733358+raiseCatError@users.noreply.github.com commit -am "nmsh $VERSION" + git push origin "$branch" + gh pr create --title "nmsh $VERSION" --body "Release: https://github.com/raiseCatError/notMyShell/releases/tag/v$VERSION + Archive: $URL + sha256: $SHA256" diff --git a/CHANGELOG.md b/CHANGELOG.md index 91a95a71..0b02ca83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `/appearance` is a compact launcher: Theme Studio, Prompt, Cursor & effects, UI chrome, Chroma, Motion, Theme Bridge and host window. ### Providers, tools and integrations + +- **Keep Awake** (`/caffeinate`, `/awake`, `/zoomies`; one surface and state): Idle, Display, System and All, optional `30m`/`2h` timeouts, `status` and `stop`. Backends are detected, not assumed: Apple `/usr/bin/caffeinate` (fixed flags; System needs AC power), `systemd-inhibit` with `idle`/`sleep` only around an NMSh-owned wait helper (Display is reported unsupported on Linux), and Windows `SetThreadExecutionState` from a fixed hidden PowerShell helper (no away mode, no `powercfg`). The assertion is a detached process that outlives the window; ownership is a random token plus the exact command line, so an unverifiable record is cleared and nothing is killed. Changing mode asks first (default No) and starts the new assertion before releasing the old. + +- **Shell frameworks and prompt engines:** `/tools` detects tools by an explicit strategy (executable or a registered filesystem detector), so Oh My Zsh, Powerlevel10k, Prezto, Zim, zinit and Antidote appear with factual status (`Installed · Zsh framework · used by Zsh only` under Bash/Fish). Detection grants no install, configuration or provider authority. Oh My Zsh has a guided install that keeps `.zshrc` and a `.zshrc.pre-oh-my-zsh` comparison with a reviewed restore; NMSh never runs its installer. Powerlevel10k is a `/tools` item that routes to the existing provider and `p10k configure` flow. **Oh My Posh** is a new Prompt provider (`oh-my-posh print primary`, argv only, no TTY, bounded, cancellable; Native fallback when it fails) and a curated `/tools` install (homebrew/core); its config can be imported into Theme Studio as static colors. Ask understands these requests; dotfiles treats `.p10k.zsh`, Oh My Zsh themes/plugins and Oh My Posh configs as inspect-only. - `/providers` is one inline panel: each family with its status (● Active, ✓ Selected · fallback, Available, Missing · Enter to install); Enter selects immediately or installs after a confirmation (default No). Shortcuts `/picker` (`/pickers`), `/suggestions`, `/navigation`, `/welcome`, `/history-provider` and `/providers ` open it focused. - Pickers follow the composer: with the composer at the bottom the query sits at the bottom and results above it (NMSh Native and fzf); at the top the query is at the top. - **Tool Configuration** (`/configure`, `/tmux`): one first-party registry says which tools NMSh can configure (tmux, Starship), which it themes, and which are inspect-only (shell rc files, Neovim/Vim config). Detection never grants write authority. diff --git a/README.md b/README.md index 3795e9cd..bfdd9a19 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,9 @@ NMSh provides a richer interactive frontend without throwing away the proven rob - Show-on-command modules: Kubernetes and Docker context (and optionally toolchains) appear only while a relevant command such as `kubectl` or `docker` is typed; typed text is never executed to decide - Width-aware path shortening keeps the repository name and current directory whole while abbreviating parents as the terminal narrows - Terminal glyph style (Nerd Font or Safe/ASCII) is chosen on first run and can be changed in `/config` (Glyph style) or previewed under `/settings` → Settings → Glyph style. Existing v0.3 configurations keep Nerd Font styling; `NMSH_ICONS=nerd|safe` overrides the saved choice for the current process. -- Optional Starship or Powerlevel10k prompt providers; NMSh keeps the editor. The Starship module editor changes only reviewed settings, with a backup of an existing config. +- Optional Starship, Oh My Posh or Powerlevel10k prompt providers; NMSh keeps the editor, composer, transcript and history, and the provider supplies only the prompt content. Starship and Oh My Posh are cross-shell prompt engines run directly by NMSh (no shell rc change); Powerlevel10k is a Zsh prompt theme rendered in an isolated helper. The Starship module editor changes only reviewed settings, with a backup of an existing config. +- Keep Awake: `/caffeinate` (also `/awake` and `/zoomies`) keeps the computer or display awake with the operating system's own mechanism: Apple `caffeinate` on macOS, the systemd inhibitor (`systemd-inhibit`, idle and sleep only) on Linux, and the `SetThreadExecutionState` API on Windows. Modes are Idle, Display, System and All, with an optional time (`/zoomies display 2h`); `/zoomies` alone opens the panel and `/zoomies stop` ends it. It survives closing the NMSh window, changes no power settings, and shows a mode as unavailable where the platform cannot honour it (Display on Linux). +- Shell frameworks in `/tools` (Shell / Workflow): Oh My Zsh is a Zsh framework, not a command. Its guided install keeps your `.zshrc` (NMSh backs it up, shows the official installer and the documented `KEEP_ZSHRC=yes CHSH=no RUNZSH=no` settings, you run it yourself, and NMSh verifies what changed). If you installed it yourself and have `.zshrc.pre-oh-my-zsh`, NMSh compares the two files and can restore the previous one after a backup and a confirmation; it never merges shell code. Prezto, Zim, zinit and Antidote are detected and shown as inspect-only. - Prompt provider **None**: composer only (no prompt row, modules, right prompt or marker); everything else in NMSh keeps working - Native prompt styles: Powerline, Soft, Minimal, Outline (`/prompt` → Style) - One-line or two-line composer layouts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index b2e7c2f2..8c0b4074 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -9,6 +9,8 @@ import {SessionPresetStore, PresetStartup, presetNeedsAcknowledgement, type Sess import {createPresetPanel, presetPanelKey, renderPresetPanel, type PresetPanel} from '../session/PresetPanel.js'; import {MiseProjectService, detectMiseProject} from '../tools/MiseProject.js'; import {misePanelKey, renderMisePanel, type MisePanel} from '../tools/MisePanel.js'; +import {detectBackend, KeepAwakeController} from '../keepAwake/keepAwake.js'; +import {createKeepAwakePanel, describeStart, keepAwakeKey, renderKeepAwakePanel, requestStart, statusLines, type KeepAwakePanel} from '../keepAwake/KeepAwakePanel.js'; import {homedir} from 'node:os'; import {createNotificationService, formatCommandNotification, shouldNotify, type TerminalFocus} from '../notifications/commandNotifications.js'; import {blockAffordance, blockCopyPayload, blockPaletteItems, type BlockActionId} from '../ui/BlockActions.js'; @@ -29,7 +31,7 @@ import {createScreensaverPanel, effectiveMode, idleFrameRows, idleMotion, idlePa import {captureFromRows, cropCapture, type ScreenCapture} from '../idle/screenCapture.js'; import {makeRng} from '../idle/screenEffects.js'; import {SCREEN_MODE_EFFECT, pickRandomSaver, saverLoopComplete, IDLE_FRAME_MS as SAVER_FRAME_MS} from '../idle/scenes.js'; -import {createThemeStudio, previewTheme, renderThemeStudio, STUDIO_MIN_SIZE, studioKey, writeThemeExport, type StudioContext, type StudioTab, type ThemeStudioState} from '../appearance/ThemeStudio.js'; +import {createThemeStudio, readThemeImport, previewTheme, renderThemeStudio, STUDIO_MIN_SIZE, studioKey, writeThemeExport, type StudioContext, type StudioTab, type ThemeStudioState} from '../appearance/ThemeStudio.js'; import {addTheme, deleteTheme, duplicateBuiltin, duplicateCurrentToCustom, duplicateRefToCustom, duplicateTheme, renameTheme, saveTheme, setActiveTheme, type ActionResult} from '../appearance/themeLibraryActions.js'; import {findTheme} from '../appearance/themeLibrary.js'; import {activeThemeRef, assetRef, selectableThemes, themeRefLabel} from '../appearance/themeRefs.js'; @@ -54,7 +56,8 @@ import {parseHexColor} from '../chroma/color.js'; import type {CustomTheme} from '../appearance/customTheme.js'; import {commandWord, createInstallPrompt, ignoreInstallSuggestion, installCandidate, installPromptKey, renderInstallPrompt, shouldOfferInstall, type InstallPromptState} from '../tools/InstallSuggestion.js'; -import {knownToolForExecutable, suggestibleToolFor, toolInstall, TOOLS, type Tool} from '../tools/catalog.js'; +import {detectTool, knownToolForExecutable, suggestibleToolFor, toolInstall, TOOLS, type Tool} from '../tools/catalog.js'; +import {openGuidedInstall, openPrevious} from '../tools/OhMyZshView.js'; import {planPackageInstall} from '../packages/managers.js'; import {loadToolUpdateState, runToolUpdateCheck, toolUpdateCheckDue, type ToolUpdateState} from '../tools/ToolUpdates.js'; import type {CommandSource} from '../shell/SemanticService.js'; @@ -117,11 +120,12 @@ import {hasVisibleContextModule, loadPromptConfiguration, NATIVE_PALETTE_IDS, sa import {detectStarship, renderStarshipPrompt, type StarshipPromptResult, type StarshipStatus} from '../prompt/starship.js'; import {STARSHIP_MODULES, StarshipConfigAdapter} from '../prompt/StarshipConfigAdapter.js'; import {detectPowerlevel10k, renderPowerlevel10kPrompt, type Powerlevel10kStatus} from '../prompt/powerlevel10k.js'; +import {detectOhMyPosh, renderOhMyPoshPrompt} from '../prompt/ohMyPosh.js'; import {configuratorFileChanged, launchPowerlevel10kConfigurator, preparePowerlevel10kConfigurator} from '../prompt/Powerlevel10kConfigurator.js'; import {galleryPalettes, promptPanelOwnsKey, appearanceModulesRow, closeGradientEditor, onGradientRow, openGradientEditor, applyLayoutChoice, onModulesRow, layoutLabel, describePromptConfiguration, PROVIDER_ORDER, providerLabel, handlePromptPanelKey, layoutChoiceIndex, renderPromptPanel, type PromptPanelState} from '../prompt/PromptPanel.js'; import type {PromptSnapshot} from '../prompt/snapshot.js'; import {CommandContextCache, commandWords, type CommandContextId} from '../prompt/commandContext.js'; -import {applyUpdate, checkForUpdate, compareVersions, detectInstall, fetchLatestRelease, installRoot, loadUpdateState, planUpdate, prepareAutomaticUpdate, readyVersion, recordInstalled, systemRunner, updatesDisabledByEnvironment, type ReleaseInfo, type UpdateCheckFrequency} from '../update/update.js'; +import {applyUpdate, checkForUpdate, compareVersions, detectInstall, installProvenanceLabel, fetchLatestRelease, installRoot, loadUpdateState, planUpdate, prepareAutomaticUpdate, readyVersion, recordInstalled, systemRunner, updatesDisabledByEnvironment, type ReleaseInfo, type UpdateCheckFrequency} from '../update/update.js'; import {resolvePathAbbreviations} from '../prompt/pathDisplay.js'; import {resolvePromptContext, type PromptContext} from '../shell/ShellContext.js'; import type {AttachedSession, SessionClient, SessionConnection, StreamStamp} from '../session/SessionClient.js'; @@ -130,7 +134,7 @@ import {cursorStyleSequence, TerminalRenderer} from '../terminal/TerminalRendere import {KeyDecoder, type Key} from '../terminal/keys.js'; import {promptConfigurationPath} from '../configuration/paths.js'; import {displayWidth, repeatToWidth, stripAnsi, truncateAnsi, truncateText} from '../util/text.js'; -import {parseSlashCommand, slashCommands, slashSuggestions, suggestionWindow} from '../commands/slashCommands.js'; +import {parseSlashCommand, slashCommands, slashSuggestions, suggestionWindow, type ParsedSlashCommand} from '../commands/slashCommands.js'; import {ClipboardUnavailableError, copyFeedback, copyStats, writeClipboard} from '../clipboard/clipboard.js'; import {beginSelection, extendSelection, isRowSelected, selectedText, type TranscriptSelection} from '../output/TranscriptSelection.js'; import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js'; @@ -399,6 +403,8 @@ export class TerminalApp { /** /prompt preview rendering; never shown as the live prompt. */ private panelExternalPrompt?: {provider: PromptProviderId; result: StarshipPromptResult}; private p10kStatus?: Powerlevel10kStatus; + /** Cancels a superseded Oh My Posh render, so at most one child runs. */ + private ohMyPoshRender?: AbortController; private promptPanelState?: PromptPanelState; private transcriptPanelState?: TranscriptPanelState; /** The shared provider gallery for families without a bespoke panel (Welcome, Suggestions). */ @@ -479,6 +485,28 @@ export class TerminalApp { /** Frontend PATH and recipe lookups for install offers; replaceable in tests. */ private installProbe = {onPath: (name: string) => resolveCommand(name) !== undefined, recipe: (tool: Tool) => planPackageInstall(tool) ?? toolInstall(tool)}; private misePanel?: MisePanel; + private keepAwakePanel?: KeepAwakePanel; + private keepAwakeController?: KeepAwakeController; + /** One controller for /caffeinate, /awake and /zoomies; the backend is detected once. */ + private keepAwake(): KeepAwakeController { return this.keepAwakeController ??= new KeepAwakeController(detectBackend()); } + + private handleKeepAwakeSlash(command: string, slash: Extract): void { + const controller = this.keepAwake(); + if (slash.op === 'panel') { + this.panelOrigin = undefined; + this.keepAwakePanel = createKeepAwakePanel(controller, slash.invalid ? `"${slash.invalid}" is not a Keep Awake mode or duration. Modes: idle, display, system, all; durations like 45s, 30m, 2h.` : undefined); + } else if (slash.op === 'status') this.output.addFrontendInteraction(command, statusLines(controller).join('\n'), INFO); + else if (slash.op === 'stop') this.output.addFrontendInteraction(command, controller.stop(), INFO); + else { + const result = controller.start(slash.mode!, slash.timeoutSeconds); + if (result.kind === 'needsConfirm') { + // A different mode is running: the panel asks first (default No). + this.keepAwakePanel = createKeepAwakePanel(controller); + requestStart(this.keepAwakePanel, controller, slash.mode!, slash.timeoutSeconds); + } else this.output.addFrontendInteraction(command, describeStart(result), result.kind === 'failed' || result.kind === 'unsupported' ? ERROR : INFO); + } + this.render(); + } private readonly miseService = new MiseProjectService(); private toolConfigurationLoading = false; private toolConfigurationGeneration = 0; @@ -1054,6 +1082,11 @@ export class TerminalApp { void this.handleMiseKey(key, this.misePanel); return; } + if (this.keepAwakePanel) { + if (keepAwakeKey(this.keepAwakePanel, this.keepAwake(), key) === 'close') { this.keepAwakePanel = undefined; this.returnFromPanel(); } + this.render(); + return; + } if (this.installPrompt) { void this.handleInstallPromptKey(key, this.installPrompt); return; @@ -2083,13 +2116,14 @@ export class TerminalApp { else if (slash.kind === 'dotfiles') { this.panelOrigin = undefined; this.dotfiles = createDotfilesPanel(slash.source ?? '~/dotfiles'); if (slash.source) await this.handleDotfilesAction({kind: 'scan', source: slash.source}, this.dotfiles); this.render(); } else if (slash.kind === 'settings') this.openSettingsPanel(slash.view); else if (slash.kind === 'tools') { this.panelOrigin = undefined; this.startTools(); } + else if (slash.kind === 'keepAwake') this.handleKeepAwakeSlash(command, slash); else if (slash.kind === 'setup') { this.panelOrigin = undefined; this.startSetup(slash.entry); } else if (slash.kind === 'transcript') { this.panelOrigin = undefined; this.startTranscriptSettings(); } else if (slash.kind === 'syntax') { this.panelOrigin = undefined; this.startSyntaxSettings(); } else if (slash.kind === 'layout') { this.panelOrigin = undefined; this.startLayoutSettings(); } else if (slash.kind === 'keyboard') { this.panelOrigin = undefined; await this.startKeyboard(); } else if (slash.kind === 'handoff') this.leaveForOrdinaryShell(slash.shell ?? this.promptConfiguration.shellBackend, command); - else if (slash.kind === 'version') this.output.addFrontendInteraction(command, formatBuildIdentity(this.buildIdentity), INFO); + else if (slash.kind === 'version') this.output.addFrontendInteraction(command, `${formatBuildIdentity(this.buildIdentity)}\nInstalled ${installProvenanceLabel()}`, INFO); else if (slash.kind === 'update') void this.runUpdateCommand(command, slash.apply); else if (slash.kind === 'clear') await this.startFreshPresentation(); else if (slash.kind === 'presets') this.startPresets(); @@ -2919,6 +2953,14 @@ export class TerminalApp { this.p10kStatus = this.detectPowerlevel10k(configuration); return renderPowerlevel10kPrompt(this.context, this.p10kStatus); } + if (configuration.provider === 'ohMyPosh') { + const status = await detectOhMyPosh(configuration.ohMyPosh.configPath ?? undefined); + this.ohMyPoshRender?.abort(); + const controller = new AbortController(); + this.ohMyPoshRender = controller; + try { return await renderOhMyPoshPrompt(this.context, status, process.env, {signal: controller.signal}); } + finally { if (this.ohMyPoshRender === controller) this.ohMyPoshRender = undefined; } + } const env = this.starshipEnvironment(configuration); this.starshipStatus ??= await detectStarship(env); if (!this.starshipStatus.installed) throw new Error('Starship is not installed or not available on PATH.'); @@ -3052,6 +3094,10 @@ export class TerminalApp { state.step = 'powerlevel10k'; state.selectedIndex = 0; if (state.p10kStatus.installed) await this.refreshPanelPreview(state); + } else if (state.draft.provider === 'ohMyPosh' && !(await detectOhMyPosh(state.draft.ohMyPosh.configPath ?? undefined)).installed) { + // Nothing to render with: stay on the provider list and say why. + state.draft.provider = state.saved?.provider ?? this.promptConfiguration.provider; + state.message = 'Oh My Posh is not installed. Install it from /tools (Shell / Workflow) and choose it again.'; } else if (state.draft.provider === 'none') { // Composer only: no layout applies; the appearance step keeps the theme and the input marker. state.step = 'appearance'; @@ -3059,6 +3105,7 @@ export class TerminalApp { } else { state.step = 'layout'; state.selectedIndex = layoutChoiceIndex(state.draft); + if (state.draft.provider === 'ohMyPosh') await this.refreshPanelPreview(state); } } else if (state.step === 'powerlevel10k') { const installed = Boolean(state.p10kStatus?.installed); @@ -3331,6 +3378,7 @@ export class TerminalApp { if (this.toolConfiguration) return renderConfigurationPanel(this.toolConfiguration, columns, this.dimensions().rows); if (this.presetPanel) return renderPresetPanel(this.presetPanel, columns, this.dimensions().rows); if (this.misePanel) return renderMisePanel(this.misePanel, columns, this.dimensions().rows); + if (this.keepAwakePanel) return renderKeepAwakePanel(this.keepAwakePanel, this.keepAwake(), columns, this.dimensions().rows); if (this.installPrompt) return renderInstallPrompt(this.installPrompt, columns); if (this.themeStudio) return this.renderThemeStudioRows(this.themeStudio, columns); if (this.themeBridgePanel) return renderThemeBridgePanel(this.themeBridgePanel, this.themeBridgePanelContext(), columns, this.dimensions().rows); @@ -3682,9 +3730,24 @@ export class TerminalApp { const state = this.toolsPanel = createToolsPanel(new Set([config.history, config.picker, config.navigation, config.welcome, config.provider]), onboarding); state.updates = this.toolUpdates; state.activation = toolId => integrationActivation(toolId, this.shellId, this.shellNames, this.shellNamesComplete); + state.prompt = {selected: config.provider, effective: this.effectivePromptProvider}; + state.shellBackend = this.shellId; void refreshTools(state, () => { if (!this.stopped && this.toolsPanel === state) this.render(); }); } + /** /tools → Use as prompt: the one canonical provider setting, then a truthful render (fallback to Native if it fails). */ + private async useToolAsPrompt(state: ToolsPanel, provider: NonNullable): Promise { + const saved = structuredClone(this.promptConfiguration); + this.promptConfiguration.provider = provider; + try { savePromptConfiguration(this.promptConfiguration, undefined, saved); } catch { /* applies to this window */ } + this.starshipStatus = undefined; + await this.refreshProviderPrompt(); + state.prompt = {selected: this.promptConfiguration.provider, effective: this.effectivePromptProvider}; + state.message = this.effectivePromptProvider === provider + ? `${providerLabel(provider)} is now the prompt provider. No shell rc file was changed.` + : `${providerLabel(provider)} could not render (${this.externalPromptError ?? 'unknown error'}); NMSh Native stays active.`; + } + private async handleToolsKey(key: Key, state: ToolsPanel): Promise { if (state.confirm) { await confirmToolInstall(state, key, () => this.renderTaskPresentation()); @@ -3705,6 +3768,36 @@ export class TerminalApp { this.misePanel = {project, selected: 0, result: this.miseService.cached(project)}; } else if (action === 'configure' && state.detail?.configuration) { this.toolsPanel = undefined; await this.openConfigure(state.detail.configuration, '/tools'); } + else if (action === 'usePrompt' && state.detail?.promptProvider) await this.useToolAsPrompt(state, state.detail.promptProvider); + else if (action === 'promptSettings') { this.toolsPanel = undefined; await this.startPromptSettings(false); } + else if (action === 'p10kConfigure') { + // The existing configurator flow: backups of ~/.p10k.zsh and .zshrc, then the official wizard. + this.toolsPanel = undefined; + await this.startPromptSettings(false); + if (this.promptPanelState) { + this.promptPanelState.draft.provider = 'powerlevel10k'; + this.promptPanelState.p10kStatus = this.detectPowerlevel10k(this.promptPanelState.draft); + this.promptPanelState.step = 'p10kConfirm'; + this.promptPanelState.selectedIndex = 0; + } + } else if (action === 'importAppearance') { + const status = await detectOhMyPosh(this.promptConfiguration.ohMyPosh.configPath ?? undefined); + if (!status.configPath) state.message = 'Oh My Posh is using its built-in default config, so there is no local file to import. Choose a config in /prompt, or import a file in /theme → Import.'; + else if (!status.configExists) state.message = `Oh My Posh config not found: ${status.configPath}`; + else { + // The existing Theme Studio import (static colors only); the provider is not switched. + this.toolsPanel = undefined; + this.openThemeStudio('import'); + if (this.themeStudio) { + this.themeStudio.importPath = status.configPath; + const result = readThemeImport(status.configPath, this.shellCwd, 'auto'); + if ('errors' in result) this.themeStudio.message = result.errors.join(' '); + else this.themeStudio.importPreview = result; + } + } + } else if (action === 'openFiles' && state.openPaths?.length === 2) { + await this.performHostAction(this.hostActions().openDiff(state.openPaths[0]!, state.openPaths[1]!), message => { state.message = message; this.render(); }); + } else if (action === 'provider') { const family = state.detail?.providerFamily; if (family === 'welcome' || family === 'history' || family === 'picker' || family === 'navigation') { @@ -5000,6 +5093,7 @@ export class TerminalApp { return [ statusSection('Build & Platform', [ {label: 'Version', value: build.version}, + {label: 'Installed', value: installProvenanceLabel()}, {label: 'Build', value: `${build.commit}${build.branch ? ` (${build.branch}${build.dirty ? ', dirty' : ''})` : ''}`, tone: build.commit === 'unknown' ? 'muted' : undefined}, {label: 'Platform', value: `${process.platform} ${process.arch}`}, {label: 'Platform support', value: this.platformInfo.support, tone: this.platformInfo.wsl?.version === 1 ? 'warning' as const : undefined}, @@ -7093,6 +7187,18 @@ export class TerminalApp { } case 'resumeTranscript': await this.restoreTranscriptById(action.id); return; case 'attachSession': this.switchToLiveSession(action.id, 'detached'); return; + case 'toolView': { + const tool = TOOLS.find(item => item.id === action.tool); + if (!tool) return; + this.startTools(); + const panel = this.toolsPanel!; + panel.detail = tool; + panel.statuses[tool.id] = await detectTool(tool); + if (action.view === 'guided') panel.framework = openGuidedInstall(); + else if (action.view === 'previous') panel.framework = openPrevious(); + else if (action.view === 'p10kConfigure' || action.view === 'importAppearance') await this.handleToolsKey({kind: 'text', value: action.view === 'p10kConfigure' ? 'c' : 't'} as Key, panel); + return; + } case 'setting': { if (action.setting === 'shellBackend') { if (isShellId(action.value)) this.updateConfiguration(configuration => { configuration.shellBackend = action.value as ShellId; }); diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index 2681771c..42a6ed07 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -161,6 +161,9 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'tools', label: 'Optional tools', support: 'actionable', capability: 'tools.open', open: '/tools', covers: ['/tools', 'settings:Tools'], aliases: ['tools', 'optional tools', 'installs', 'install suggestions'], description: '/tools lists optional tools NMSh can use, with previewed installs that start on No.'}, + {id: 'keepAwake', label: 'Keep Awake', support: 'actionable', open: '/caffeinate', covers: ['/caffeinate', '/awake', '/zoomies'], + aliases: ['keep awake', 'keep-awake', 'caffeinate', 'awake', 'zoomies', 'prevent sleep', 'stay awake'], + description: 'Keep Awake (/caffeinate, /awake, /zoomies) keeps the computer or display awake with the operating system\'s own mechanism (Apple caffeinate, the systemd inhibitor or the Windows execution-state API) until you stop it or its timeout ends. No power settings change.'}, {id: 'presets', label: 'Session presets', support: 'actionable', open: '/presets', covers: ['/presets'], aliases: ['presets', 'preset', 'session presets', 'named presets', 'session preset'], description: 'Presets are named session setups (folder, shell, startup command) you can create, inspect and launch from /presets.'}, @@ -326,7 +329,7 @@ export const GUIDE_SECTIONS: readonly GuideSection[] = [ {id: 'shells', title: 'Shells', why: 'One NMSh window can run zsh, Fish or Bash, and you can leave for a plain shell and come back.', concepts: ['shell', 'leave', 'otherShells']}, {id: 'completion', title: 'Completion & suggestions', why: 'Tab completion lists real candidates; ghost suggestions predict the rest of the line.', concepts: ['completion', 'suggestions']}, {id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'transcripts']}, - {id: 'sessions', title: 'Sessions', why: 'Sessions keep running when a window closes; reattach, switch or get notified.', concepts: ['sessions', 'agentSessions', 'presets', 'notices', 'commandNotifications', 'panes']}, + {id: 'sessions', title: 'Sessions', why: 'Sessions keep running when a window closes; reattach, switch or get notified.', concepts: ['sessions', 'agentSessions', 'presets', 'notices', 'commandNotifications', 'panes', 'keepAwake']}, {id: 'ask', title: 'Ask', why: 'Plain-English help that knows NMSh, your commands and this repository, and never runs anything you did not confirm.', concepts: ['ask']}, {id: 'intelligence', title: 'Local intelligence', why: 'An optional local model helps Ask map vague requests to known actions. It runs on this machine, never writes commands, and Ask works fully without it.', concepts: ['understanding'], notes: ['Modes: Off, Auto (used only when the deterministic resolver is unsure) and Always (consulted first). /llm shows the model, runtime and last inference.']}, diff --git a/src/ask/configActions.ts b/src/ask/configActions.ts index 75d876c6..16d71e60 100644 --- a/src/ask/configActions.ts +++ b/src/ask/configActions.ts @@ -4,6 +4,10 @@ import {describeTmuxChange, type TmuxChange} from '../tools/config/tmux.js'; import {BRIDGE_TARGET_LABELS, type BridgeTargetId} from '../themeBridge/model.js'; import {THEME_PALETTE_IDS} from '../prompt/configuration.js'; import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; +import {detectOhMyZsh, previousZshrc} from '../tools/frameworks.js'; +import {TOOLS, toolInstall} from '../tools/catalog.js'; +import {resolveCommand} from '../providers/providers.js'; +import {installProposal} from './commands.js'; /** * Deterministic Ask routing for NMSh surfaces and supported configuration. @@ -57,7 +61,68 @@ function tmuxChanges(text: string): TmuxChange[] { return changes; } -export function resolveConfigRequest(text: string): AskOutcome | undefined { +const OMZ = /\boh[ -]?my[ -]?zsh\b|\bomz\b/u; +const OMP = /\boh[ -]?my[ -]?posh\b|\bomp\b/u; +const P10K = /\bpowerlevel ?10k\b|\bp10k\b/u; +const view = (tool: string, which: 'detail' | 'guided' | 'previous' | 'p10kConfigure' | 'importAppearance', text: string): AskOutcome => + ({kind: 'proposal', capability: 'tools.open', safety: 'navigate', confidence: 0.92, direct: true, text, action: {kind: 'toolView', tool, view: which, label: text}}); +const promptSwitch = (value: 'ohMyPosh' | 'powerlevel10k', label: string): AskOutcome => + ({kind: 'proposal', capability: 'provider.switch', safety: 'mutate', confidence: 0.92, text: `Use ${label} as the prompt? NMSh renders it directly; no shell rc file changes.`, + action: {kind: 'setting', setting: 'prompt', value, label: `Prompt: ${label}`}}); + +/** Shell frameworks and prompt engines: facts, navigation to the exact /tools view, or a typed provider switch. Never a shell command or rc edit. */ +function resolveFrameworkRequest(text: string, env: NodeJS.ProcessEnv): AskOutcome | undefined { + if (/\bpre[ -]?oh[ -]?my[ -]?zsh\b|\b(?:old|previous)\b.*\bzshrc\b|\bzshrc\b.*\b(?:old|previous|before oh my zsh)\b|\bwhat happened to my\b.*\bzshrc\b/u.test(text)) { + const pair = previousZshrc(env); + if (!pair) return {kind: 'answer', capability: 'tools.open', text: 'There is no .zshrc.pre-oh-my-zsh here, so the Oh My Zsh installer did not save an earlier .zshrc in this home (or ZDOTDIR).'}; + return view('oh-my-zsh', 'previous', `Compare ${pair.current} with ${pair.previous} in /tools (restoring there backs up the current file and asks first)`); + } + if (OMZ.test(text)) { + if (/\binstalled\b|\bdo i have\b|\bis there\b/u.test(text)) { + const found = detectOhMyZsh(env); + return {kind: 'answer', capability: 'tools.open', text: found ? `Yes. Oh My Zsh is installed at ${found.path}${found.source ? ` (from ${found.source})` : ''}. It is a Zsh framework, used by Zsh only.` : 'No. No Oh My Zsh installation was found at $ZSH or ~/.oh-my-zsh.'}; + } + if (/\binstall\b/u.test(text)) return view('oh-my-zsh', 'guided', 'Open the Oh My Zsh guided install (keeps your .zshrc; you run the official installer yourself)'); + return view('oh-my-zsh', 'detail', 'Open Oh My Zsh in /tools'); + } + if (OMP.test(text)) { + if (/\bimport\b|\btheme studio\b|\binto nmsh\b/u.test(text)) return view('oh-my-posh', 'importAppearance', 'Import your Oh My Posh appearance into an NMSh Native theme (static colors only; the provider is not switched)'); + if (/\binstall\b/u.test(text)) { + const tool = TOOLS.find(item => item.id === 'oh-my-posh')!; + if (resolveCommand('oh-my-posh', env.PATH ?? '')) return {kind: 'answer', capability: 'tools.open', text: 'Oh My Posh is already installed.'}; + const recipe = toolInstall(tool); + return recipe ? installProposal('oh-my-posh', {tool: tool.id, label: recipe.label}) : view('oh-my-posh', 'detail', 'Open Oh My Posh in /tools (no curated install here)'); + } + if (/\b(?:use|switch to|set)\b.*\bprompt\b|\bas (?:my )?prompt\b/u.test(text)) return promptSwitch('ohMyPosh', 'Oh My Posh'); + return view('oh-my-posh', 'detail', 'Open Oh My Posh in /tools'); + } + if (P10K.test(text)) { + if (/\bconfigure\b|\bwizard\b|\bset up\b/u.test(text)) return view('powerlevel10k', 'p10kConfigure', 'Configure Powerlevel10k (backs up ~/.p10k.zsh and .zshrc, then runs p10k configure)'); + if (/\buse\b|\bswitch to\b/u.test(text)) return promptSwitch('powerlevel10k', 'Powerlevel10k'); + } + return undefined; +} + +/** Keep Awake: deterministic, typed through the /caffeinate action; starting or stopping waits for Ask's Yes. */ +function resolveKeepAwakeRequest(text: string): AskOutcome | undefined { + const slash = (command: string, safety: 'navigate' | 'mutate', label: string): AskOutcome | undefined => { + const parsed = parseSlashCommand(command); + return parsed ? {kind: 'proposal', capability: 'feature.open', safety, confidence: 0.92, ...(safety === 'navigate' ? {direct: true} : {}), text: label, action: {kind: 'slash', slash: parsed, label: command}} : undefined; + }; + if (/\bzoomies\b/u.test(text) && !/\bstop\b|\bstatus\b/u.test(text)) return slash('/zoomies', 'navigate', 'Open Keep Awake (/zoomies)'); + if (/\b(?:is )?(?:caffeinate|keep[ -]?awake)\b.*\b(?:running|on|active)\b|\bkeep[ -]?awake status\b/u.test(text)) return slash('/caffeinate status', 'navigate', 'Keep Awake status'); + if (/\bkeep (?:my |the )?(?:screen|display|monitor) (?:awake|on)\b/u.test(text)) return slash('/caffeinate display', 'mutate', 'Keep the display and the machine awake (/caffeinate display) until you stop it'); + if (/\b(?:don'?t|do not|never) let (?:my |the )?(?:computer|mac|laptop|machine|pc) sleep\b|\bprevent (?:system )?sleep\b/u.test(text)) return slash('/caffeinate system', 'mutate', 'Prevent automatic system sleep (/caffeinate system) until you stop it'); + if (/\bstop\b.*\b(?:keeping|keep)\b.*\bawake\b|\bstop (?:caffeinate|keep[ -]?awake)\b|\blet my (?:computer|mac|laptop|machine) sleep\b/u.test(text)) return slash('/caffeinate stop', 'mutate', 'Stop Keep Awake (normal sleep returns)'); + if (/\bkeep (?:my |the )?(?:computer|mac|laptop|machine|pc) awake\b/u.test(text)) return slash('/caffeinate idle', 'mutate', 'Prevent automatic idle sleep (/caffeinate idle) until you stop it'); + return undefined; +} + +export function resolveConfigRequest(text: string, env: NodeJS.ProcessEnv = process.env): AskOutcome | undefined { + const awake = resolveKeepAwakeRequest(text); + if (awake) return awake; + const framework = resolveFrameworkRequest(text, env); + if (framework) return framework; // Provider choices map onto the same typed provider setting /providers uses. const picker = /\b(?:use|switch to|pick)\s+(fzf|television|tv|native|nmsh native|nmsh)\b.*\bpickers?\b|\bpickers?\b.*\b(?:use|to)\s+(fzf|television|tv|native|nmsh)\b/u.exec(text); if (picker) { diff --git a/src/ask/types.ts b/src/ask/types.ts index 8cd7d799..012e1003 100644 --- a/src/ask/types.ts +++ b/src/ask/types.ts @@ -70,6 +70,8 @@ export type AskAction = | {kind: 'tmux'; changes: TmuxChange[]; label: string} /** Theme Bridge settings: switch, policy and per-target Manual pins (stable theme references only). */ | {kind: 'themeBridge'; enabled?: boolean; policy?: BridgePolicy; targets?: Partial>; label: string} + /** Open /tools at one curated tool, optionally at its guided install, previous-zshrc comparison, p10k configurator or appearance import (navigation only). */ + | {kind: 'toolView'; tool: string; view: 'detail' | 'guided' | 'previous' | 'p10kConfigure' | 'importAppearance'; label: string} | {kind: 'setting'; setting: 'suggestions' | 'history' | 'welcome' | 'picker' | 'navigation' | 'prompt' | 'localUnderstanding' | 'shellBackend' | 'composerDividers'; value: string; label: string}; /** diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index fd50a887..75b4170c 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,3 +1,4 @@ +import {installProvenanceLabel} from '../update/update.js'; import {homedir} from 'node:os'; import {promptConfigurationPath} from '../configuration/paths.js'; import {detectPlatform} from '../host/platform.js'; @@ -23,6 +24,7 @@ export function doctorReport(env: NodeJS.ProcessEnv = process.env, extra: Array< const activeShell = {id: config.shellBackend, ...(resolved ? {path: resolved} : {})}; const rows: Array<[string, string]> = [ ['NMSh', formatBuildIdentity(readBuildIdentity())], + ['Installed', installProvenanceLabel()], ['Node', process.version], ['Platform', `${process.platform} ${process.arch} · kernel ${platform.kernel}`], ['Support', platform.support], diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index c381d4ef..46df1a85 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -30,6 +30,7 @@ const META: Record> = { '/tools': {group: 'Tools & integration', title: 'Open Tools'}, '/configure': {group: 'Tools & integration', title: 'Open Tool Configuration'}, '/tmux': {group: 'Tools & integration', title: 'Configure tmux'}, '/integrations': {group: 'Tools & integration', title: 'Check Integrations'}, '/dotfiles': {group: 'Tools & integration', title: 'Import Dotfiles'}, + '/caffeinate': {group: 'Tools & integration', title: 'Open Keep Awake'}, '/awake': {alias: '/caffeinate'}, '/zoomies': {alias: '/caffeinate'}, }; const RAW_COMMANDS: readonly SlashCommand[] = [ @@ -54,6 +55,9 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/setup cursor', insertion: '/setup cursor', description: 'Setup Cat: cursor shape, effects and colors, with a live preview'}, {name: '/setup syntax', insertion: '/setup syntax', description: 'Setup Cat: editor, syntax colors and suggestions'}, {name: '/setup tools', insertion: '/setup tools', description: 'Setup Cat: optional tools, update checks and install suggestions'}, + {name: '/caffeinate', insertion: '/caffeinate', description: 'Keep Awake: keep the computer or display awake (idle, display, system, all; optional 30m/2h; status, stop). Uses the OS mechanism'}, + {name: '/awake', insertion: '/awake', description: 'Same as /caffeinate (Keep Awake)'}, + {name: '/zoomies', insertion: '/zoomies', description: 'Same as /caffeinate (Keep Awake)'}, {name: '/tools', insertion: '/tools', description: 'Browse optional tools, installation previews and supported configuration'}, {name: '/config', insertion: '/config', description: 'Open NMSh settings (Config view)'}, {name: '/status', insertion: '/status', description: 'Show NMSh status'}, @@ -128,6 +132,8 @@ export type ParsedSlashCommand = | {kind: 'activity'} | {kind: 'screensaver'; start: boolean; mode?: IdleMode} | {kind: 'tools'} + /** /caffeinate, /awake and /zoomies: one Keep Awake action. A timeout is a validated number of seconds; `invalid` names bad input. */ + | {kind: 'keepAwake'; op: 'panel' | 'status' | 'stop' | 'start'; mode?: 'idle' | 'display' | 'system' | 'all'; timeoutSeconds?: number; invalid?: string} | {kind: 'setup'; entry?: string} | {kind: 'settings'; view: 'config' | 'status'} | {kind: 'transcript'} @@ -189,6 +195,21 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined return {kind: 'screensaver', start: screensaver[1] === 'start', ...(screensaver[2] ? {mode: screensaver[2] as IdleMode} : {})}; } if (/^\/tools\s*$/u.test(input)) return {kind: 'tools'}; + // Unlisted compatibility spelling; /caffeinate stop is the documented form. + if (/^\/caffeinate-stop\s*$/u.test(input)) return {kind: 'keepAwake', op: 'stop'}; + const awake = /^\/(?:caffeinate|awake|zoomies)(?:\s+(\S+))?(?:\s+(\S+))?\s*$/u.exec(input); + if (awake) { + const [, word, duration] = awake; + if (!word) return {kind: 'keepAwake', op: 'panel'}; + if ((word === 'status' || word === 'stop') && !duration) return {kind: 'keepAwake', op: word}; + if (word === 'idle' || word === 'display' || word === 'system' || word === 'all') { + if (!duration) return {kind: 'keepAwake', op: 'start', mode: word}; + const match = /^([1-9]\d{0,5})([smh])$/u.exec(duration); + const seconds = match ? Number(match[1]) * (match[2] === 'h' ? 3600 : match[2] === 'm' ? 60 : 1) : 0; + return seconds && seconds <= 7 * 24 * 3600 ? {kind: 'keepAwake', op: 'start', mode: word, timeoutSeconds: seconds} : {kind: 'keepAwake', op: 'panel', invalid: duration}; + } + return {kind: 'keepAwake', op: 'panel', invalid: word}; + } const setup = /^\/setup(?:\s+(prompt|appearance|chroma|tools|editor|transcript|cursor|syntax|motion|sessions|shell|ask))?\s*$/u.exec(input); if (setup) return setup[1] ? {kind: 'setup', entry: setup[1]} : {kind: 'setup'}; if (/^\/(?:settings|config)\s*$/u.test(input)) return {kind: 'settings', view: 'config'}; diff --git a/src/configuration/portability.ts b/src/configuration/portability.ts index c591da2d..f7063dca 100644 --- a/src/configuration/portability.ts +++ b/src/configuration/portability.ts @@ -46,7 +46,7 @@ export const CATEGORY_IDS = Object.keys(PORTABLE_CATEGORIES) as PortableCategory * provider config files (Starship, Powerlevel10k), which rarely exist at the * same place elsewhere and can reveal a home directory layout. */ -export const NEVER_EXPORTED = ['onboardingComplete', 'toolsSetupComplete', 'glyphChoiceComplete', 'starship', 'powerlevel10k'] as const; +export const NEVER_EXPORTED = ['onboardingComplete', 'toolsSetupComplete', 'glyphChoiceComplete', 'starship', 'powerlevel10k', 'ohMyPosh'] as const; export interface PortableDocument { format: typeof PORTABLE_FORMAT; diff --git a/src/dotfiles/plan.ts b/src/dotfiles/plan.ts index be627c6c..9672aa83 100644 --- a/src/dotfiles/plan.ts +++ b/src/dotfiles/plan.ts @@ -39,7 +39,7 @@ export interface DotfilesItem { diff?: string[]; } -function shortDiff(before: string | undefined, after: string): string[] { +export function shortDiff(before: string | undefined, after: string): string[] { if (before === undefined) return [`+ new file (${after.split('\n').length} lines)`]; const a = before.split('\n'); const b = after.split('\n'); diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index 7a69c3eb..b7634eb3 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -39,7 +39,11 @@ Settings → Theme (and /setup appearance) picks a Built-in theme (NMSh themes a ## Theme Bridge -/theme-bridge (also /appearance, Settings and Setup) extends NMSh themes to terminal tools (fzf, less/man, LS_COLORS, tmux, Neovim, Vim, Helix), one tool at a time. Every tool starts Independent: NMSh injects and changes nothing for it. Follow NMSh uses the active theme; Choose theme pins any Built-in, Imported or Custom theme that the main theme never changes. fzf colors apply only to fzf launched by NMSh (FZF_DEFAULT_OPTS and rc files are untouched). less/man and LS_COLORS values reach NMSh shells (zsh, Bash, Fish) at their next prompt through an NMSh-owned environment file; Independent restores what was there. tmux, Neovim, Vim and Helix get NMSh-generated color files; loading them in new instances needs one include line (for Helix, a theme = "nmsh-bridge" assignment; otherwise run :theme nmsh-bridge) that NMSh shows exactly and adds only after you confirm, and removes exactly. Running editors are not recolored live; tmux can reload on request. bat and delta are detected but stay Independent: their custom themes need bat's cache or git config, which NMSh does not modify. Details: [Theme Bridge](https://github.com/raiseCatError/notMyShell/blob/dev/docs/design/theme-bridge.md). +/theme-bridge (also /appearance, Settings and Setup) extends NMSh themes to terminal tools. It is Off by default: one switch plus Apply themes Manual, Follow NMSh or Choose theme. Under Manual each tool is Independent (NMSh injects and changes nothing for it), Follow NMSh or Choose theme (a pinned Built-in, Imported or Custom theme). fzf colors apply only to fzf launched by NMSh (FZF_DEFAULT_OPTS and rc files are untouched). less/man colors and File listing colors (GNU ls/gls through LS_COLORS, macOS/BSD ls through CLICOLOR and LSCOLORS) reach NMSh shells (zsh, Bash, Fish) at their next prompt through an NMSh-owned environment file; Independent restores what was there. tmux, Neovim, Vim, Helix and bat get NMSh-generated themes (bat after a reviewed cache build, selected with BAT_THEME in NMSh shells); loading them in new instances needs one include line (for Helix, a theme = "nmsh-bridge" assignment) that NMSh shows exactly and adds only after you confirm, and removes exactly. Running editors are not recolored live; tmux can reload on request. delta is detected only: NMSh does not change git config. Details: [Theme Bridge](https://github.com/raiseCatError/notMyShell/blob/dev/docs/design/theme-bridge.md). + +Keep Awake (/caffeinate, also /awake and /zoomies) keeps the computer awake with the operating system's own mechanism: Apple caffeinate on macOS, the systemd inhibitor on Linux, the execution-state API on Windows. Modes are Idle, Display, System and All, optionally for a time (/zoomies display 2h, /caffeinate idle 30m); /caffeinate status shows it and /caffeinate stop ends it. It keeps running after the NMSh window closes, never changes power settings, and shows a mode as unavailable when the platform cannot honour it (Display on Linux). + +Prompt providers (/prompt, /providers): NMSh Native, None, Starship, Powerlevel10k and Oh My Posh. NMSh keeps the editor, composer, transcript and history; an external provider supplies only the prompt content, and NMSh falls back to Native, saying so, when it cannot render. Starship and Oh My Posh are cross-shell prompt engines that NMSh runs directly, with no shell rc change; Powerlevel10k is a Zsh theme rendered in an isolated helper. In /tools, Oh My Zsh is a Zsh framework (not a command): its guided install keeps your .zshrc and NMSh never runs the installer itself; if you have .zshrc.pre-oh-my-zsh, NMSh can compare it with .zshrc and restore it after a backup and a confirmation, but never merges shell code. Oh My Zsh themes and plugins, ~/.p10k.zsh and Oh My Posh configs are inspect-only for dotfiles. ## Idle visuals diff --git a/src/keepAwake/KeepAwakePanel.ts b/src/keepAwake/KeepAwakePanel.ts new file mode 100644 index 00000000..5cc70d53 --- /dev/null +++ b/src/keepAwake/KeepAwakePanel.ts @@ -0,0 +1,89 @@ +import type {Key} from '../terminal/keys.js'; +import {createConfirm, handleConfirmKey, renderConfirm, type ConfirmState} from '../ui/formControls.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {renderControls} from '../ui/controls.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateAnsi} from '../util/text.js'; +import {colorLevel} from '../presentation/capabilities.js'; +import {formatDuration, KEEP_AWAKE_MODES, MODE_DESCRIPTIONS, MODE_LABELS, unsupportedReason, type KeepAwakeController, type KeepAwakeMode, type StartResult} from './keepAwake.js'; + +/** The one Keep Awake surface behind /caffeinate, /awake and /zoomies. */ +export interface KeepAwakePanel { + selected: number; + message?: string; + /** A different mode is running: replacing it waits for this confirmation (default No). */ + confirm?: {state: ConfirmState; mode: KeepAwakeMode; timeoutSeconds?: number; from: KeepAwakeMode}; +} + +export function createKeepAwakePanel(controller: KeepAwakeController, message?: string): KeepAwakePanel { + const status = controller.status(); + const index = status.state === 'running' ? KEEP_AWAKE_MODES.indexOf(status.record.mode) : 0; + return {selected: Math.max(0, index), ...(message ?? (status.state === 'off' ? status.note : undefined) ? {message: message ?? (status.state === 'off' ? status.note : undefined)} : {})}; +} + +/** One factual sentence for a start attempt. */ +export function describeStart(result: StartResult): string { + switch (result.kind) { + case 'started': return `Keep Awake on · ${MODE_LABELS[result.record.mode]}${result.record.timeoutSeconds ? ` for ${formatDuration(result.record.timeoutSeconds)}` : ''}${result.replaced ? ` (replaced ${MODE_LABELS[result.replaced]})` : ''}. It keeps running after this window closes; /caffeinate stop ends it.`; + case 'already': return `Already running · ${MODE_LABELS[result.record.mode]}.`; + case 'needsConfirm': return `${MODE_LABELS[result.from]} is running. Change Keep Awake mode to ${MODE_LABELS[result.to]}?`; + case 'unsupported': return result.reason; + case 'failed': return result.reason; + } +} + +/** Start from the panel or a slash command; a different running mode asks first. */ +export function requestStart(panel: KeepAwakePanel, controller: KeepAwakeController, mode: KeepAwakeMode, timeoutSeconds?: number): void { + const result = controller.start(mode, timeoutSeconds); + if (result.kind === 'needsConfirm') panel.confirm = {state: createConfirm(), mode, from: result.from, ...(timeoutSeconds ? {timeoutSeconds} : {})}; + panel.message = describeStart(result); +} + +export function keepAwakeKey(panel: KeepAwakePanel, controller: KeepAwakeController, key: Key): 'close' | undefined { + if (panel.confirm) { + const decision = handleConfirmKey(key, panel.confirm.state); + if (decision === 'confirm') { const {mode, timeoutSeconds} = panel.confirm; panel.confirm = undefined; panel.message = describeStart(controller.start(mode, timeoutSeconds, true)); } + else if (decision === 'cancel') { panel.message = `Kept ${MODE_LABELS[panel.confirm.from]}. Nothing was changed.`; panel.confirm = undefined; } + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return 'close'; + if (key.kind === 'up' || key.kind === 'down') panel.selected = (panel.selected + (key.kind === 'up' ? KEEP_AWAKE_MODES.length - 1 : 1)) % KEEP_AWAKE_MODES.length; + else if (key.kind === 'enter') requestStart(panel, controller, KEEP_AWAKE_MODES[panel.selected]!); + else if (key.kind === 'text' && key.value.toLowerCase() === 's') panel.message = controller.stop(); + return undefined; +} + +export function statusLines(controller: KeepAwakeController, now = Date.now()): string[] { + const status = controller.status(); + const backend = controller.backend?.label ?? 'none'; + if (status.state === 'off') return ['Keep Awake', 'Off', `Backend ${backend}`, ...(status.note ? [status.note] : [])]; + const record = status.record; + const since = new Date(record.startedAt); + const elapsed = Math.max(0, Math.round((now - record.startedAt) / 1000)); + return ['Keep Awake', 'Running', `Mode ${MODE_LABELS[record.mode]}`, `Backend ${backend}`, + `Since ${String(since.getHours()).padStart(2, '0')}:${String(since.getMinutes()).padStart(2, '0')}`, `Duration ${formatDuration(Math.max(1, elapsed))}`, + ...(record.timeoutSeconds ? [`Ends after ${formatDuration(record.timeoutSeconds)}`] : []), `PID ${record.pid}`]; +} + +export function renderKeepAwakePanel(panel: KeepAwakePanel, controller: KeepAwakeController, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary), subtle = foreground(UI_COLORS.subtle), accent = foreground(UI_COLORS.accent), reset = '\u001b[0m'; + const status = controller.status(); + const rows = [` ${primary}Keep Awake${reset} ${subtle}/caffeinate · /awake · /zoomies${reset}`, '']; + if (!controller.backend) rows.push(` ${subtle}${unsupportedReason()}${reset}`); + for (const [index, mode] of KEEP_AWAKE_MODES.entries()) { + const supported = controller.supports(mode); + const pointer = index === panel.selected ? `${accent}›${reset}` : ' '; + const running = status.state === 'running' && status.record.mode === mode ? ` ${accent}● running${reset}` : ''; + rows.push(` ${pointer} ${primary}${MODE_LABELS[mode].padEnd(8)}${reset} ${subtle}${supported ? MODE_DESCRIPTIONS[mode] : `Unavailable on ${controller.backend ? `the ${controller.backend.label}` : 'this system'}`}${reset}${running}`); + } + rows.push('', ...statusLines(controller).slice(1).map(line => ` ${subtle}${line === 'Off' || line === 'Running' ? `Current ${line}` : line}${reset}`)); + for (const note of controller.backend?.notes ?? []) rows.push(` ${subtle}${note}${reset}`); + rows.push(` ${subtle}Nothing in your power settings changes; Stop (or the timeout) ends it.${reset}`); + if (panel.confirm) { + rows.push('', ` ${primary}Change Keep Awake mode? ${MODE_LABELS[panel.confirm.from]} → ${MODE_LABELS[panel.confirm.mode]}${reset}`, + ` ${renderConfirm(panel.confirm.state, {focused: true, color: colorLevel() !== 'none'})}`); + } else if (panel.message) rows.push('', ` ${panel.message}`); + rows.push('', renderControls(panel.confirm ? [['←→', 'choose'], ['Enter', 'confirm'], ['Esc', 'cancel']] + : [['↑↓', 'mode'], ['Enter', 'keep awake'], ['S', 'stop'], ['Esc', 'close']])); + return framePanel(rows.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); +} diff --git a/src/keepAwake/keepAwake.ts b/src/keepAwake/keepAwake.ts new file mode 100644 index 00000000..5ac36833 --- /dev/null +++ b/src/keepAwake/keepAwake.ts @@ -0,0 +1,301 @@ +import {spawn, spawnSync} from 'node:child_process'; +import {randomBytes} from 'node:crypto'; +import {existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; + +/** + * Keep Awake (/caffeinate, /awake, /zoomies): one controller over the + * operating system's own inhibition mechanism. + * + * macOS Apple /usr/bin/caffeinate (fixed path, fixed flags) + * Linux systemd-inhibit wrapping an NMSh-owned wait helper (idle/sleep only) + * Windows Kernel32 SetThreadExecutionState from a fixed PowerShell helper + * + * Modes are user intent, not flags. Every value reaching a child is an enum + * or a validated integer; nothing from input becomes program text. The + * assertion lives in a detached process that outlives the NMSh window and + * ends on Stop or its timeout. NMSh changes no power settings, desktop + * preferences or rc files, and never kills a process it cannot prove it owns. + */ + +export const KEEP_AWAKE_MODES = ['idle', 'display', 'system', 'all'] as const; +export type KeepAwakeMode = typeof KEEP_AWAKE_MODES[number]; +export const MODE_LABELS: Record = {idle: 'Idle', display: 'Display', system: 'System', all: 'All'}; +export const MODE_DESCRIPTIONS: Record = { + idle: 'Prevent automatic idle sleep', display: 'Keep the display and the machine awake', + system: 'Prevent automatic system sleep', all: 'All normal wake assertions available here', +}; + +export interface KeepAwakeCapabilities {idle: boolean; display: boolean; system: boolean} + +export interface LaunchPlan {command: string; args: string[]; env?: NodeJS.ProcessEnv} + +export interface KeepAwakeBackend { + id: 'macos-caffeinate' | 'linux-systemd-inhibit' | 'windows-execution-state'; + label: string; + capabilities: KeepAwakeCapabilities; + /** Factual notes shown in the panel (e.g. Apple's -s needs AC power). */ + notes: string[]; + /** undefined when this backend cannot honour the mode. */ + plan(mode: KeepAwakeMode, token: string, timeoutSeconds?: number): LaunchPlan | undefined; +} + +export const MAX_TIMEOUT_SECONDS = 7 * 24 * 3600; + +/** Strict durations only: 45s, 30m, 2h (1 second to 7 days). */ +export function parseDuration(text: string | undefined): number | undefined | 'invalid' { + if (text === undefined || text === '') return undefined; + const match = /^([1-9]\d{0,5})([smh])$/u.exec(text); + if (!match) return 'invalid'; + const seconds = Number(match[1]) * (match[2] === 'h' ? 3600 : match[2] === 'm' ? 60 : 1); + return seconds <= MAX_TIMEOUT_SECONDS ? seconds : 'invalid'; +} + +export function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60; + return h ? `${h}h${m ? ` ${m}m` : ''}` : m ? `${m}m` : `${s}s`; +} + +// ---- macOS ---------------------------------------------------------------------------- + +export const CAFFEINATE = '/usr/bin/caffeinate'; +const CAFFEINATE_FLAGS: Record = {idle: ['-i'], display: ['-d', '-i'], system: ['-s'], all: ['-d', '-i', '-s']}; + +export function macBackend(): KeepAwakeBackend { + return { + id: 'macos-caffeinate', label: 'Apple caffeinate', capabilities: {idle: true, display: true, system: true}, + notes: ['System (-s) holds only while the Mac is on AC power.'], + plan: (mode, _token, timeout) => ({command: CAFFEINATE, args: [...CAFFEINATE_FLAGS[mode], ...(timeout ? ['-t', String(timeout)] : [])]}), + }; +} + +// ---- Linux ---------------------------------------------------------------------------- + +export const SYSTEMD_INHIBIT_PATHS = ['/usr/bin/systemd-inhibit', '/bin/systemd-inhibit'] as const; +/** Normal inhibitors only: never shutdown, lid switch, power or suspend keys. */ +const INHIBIT_WHAT: Partial> = {idle: 'idle', system: 'sleep', all: 'idle:sleep'}; + +/** The NMSh-owned process the inhibitor wraps: it only waits (bounded), so the lock ends with it. Fixed code; token and seconds are argv. */ +export const WAIT_HELPER = 'const s=Number(process.argv[2]);const t=setTimeout(()=>process.exit(0),s>0?s*1000:2147483647);process.on("SIGTERM",()=>{clearTimeout(t);process.exit(0)});setInterval(()=>{},1<<30)'; + +export function linuxBackend(inhibit: string, node: string = process.execPath): KeepAwakeBackend { + return { + id: 'linux-systemd-inhibit', label: 'systemd inhibitor', capabilities: {idle: true, display: false, system: true}, + notes: ['Display: not supported by the systemd inhibitor (it is not a display API); use Idle or System.', 'Lid close and power keys keep their normal behavior.'], + plan: (mode, token, timeout) => { + const what = INHIBIT_WHAT[mode]; + if (!what) return undefined; + return {command: inhibit, args: [`--what=${what}`, '--mode=block', '--who=notMyShell', '--why=Keep-awake requested by NMSh', + node, '-e', WAIT_HELPER, '--', String(timeout ?? 0), `nmsh-keep-awake=${token}`]}; + }, + }; +} + +// ---- Windows -------------------------------------------------------------------------- + +export const ES_CONTINUOUS = 0x80000000; +export const ES_SYSTEM_REQUIRED = 0x00000001; +export const ES_DISPLAY_REQUIRED = 0x00000002; +/** Windows has no separate idle assertion: Idle and System both mean SYSTEM_REQUIRED. Away mode is never used. */ +export function executionStateFlags(mode: KeepAwakeMode): number { + return (ES_CONTINUOUS | ES_SYSTEM_REQUIRED | (mode === 'display' || mode === 'all' ? ES_DISPLAY_REQUIRED : 0)) >>> 0; +} + +/** Fixed first-party script; only numeric constants and the hex token are substituted. */ +export function windowsHelperScript(flags: number, token: string, timeout?: number): string { + if (!Number.isSafeInteger(flags) || !/^[0-9a-f]{32}$/u.test(token) || (timeout !== undefined && (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > MAX_TIMEOUT_SECONDS))) { + throw new Error('Refusing keep-awake helper values.'); + } + return [`# nmsh-keep-awake=${token}`, + 'Add-Type -Namespace NMSh -Name Power -MemberDefinition \'[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint esFlags);\'', + `if ([NMSh.Power]::SetThreadExecutionState([uint32]${flags}) -eq 0) { exit 2 }`, + `$deadline = ${timeout ? `(Get-Date).AddSeconds(${timeout})` : '[DateTime]::MaxValue'}`, + 'try { while ((Get-Date) -lt $deadline) { Start-Sleep -Seconds 5 } } finally { [void][NMSh.Power]::SetThreadExecutionState([uint32]2147483648) }'].join('\n'); +} + +export function windowsBackend(powershell: string): KeepAwakeBackend { + return { + id: 'windows-execution-state', label: 'Windows execution-state API', capabilities: {idle: true, display: true, system: true}, + notes: ['Idle and System are the same Windows assertion (system required).', 'No power plan setting is changed.'], + plan: (mode, token, timeout) => ({command: powershell, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', + Buffer.from(windowsHelperScript(executionStateFlags(mode), token, timeout), 'utf16le').toString('base64')]}), + }; +} + +/** The backend detection decides; the platform alone does not. */ +export function detectBackend(platform: NodeJS.Platform = process.platform, exists: (path: string) => boolean = existsSync, + env: NodeJS.ProcessEnv = process.env): KeepAwakeBackend | undefined { + if (platform === 'darwin') return exists(CAFFEINATE) ? macBackend() : undefined; + if (platform === 'linux') { + const inhibit = SYSTEMD_INHIBIT_PATHS.find(exists); + return inhibit ? linuxBackend(inhibit) : undefined; + } + if (platform === 'win32') { + const root = env.SystemRoot ?? env.windir ?? 'C:\\Windows'; + const powershell = `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + return exists(powershell) ? windowsBackend(powershell) : undefined; + } + return undefined; +} + +export function unsupportedReason(platform: NodeJS.Platform = process.platform): string { + return platform === 'linux' ? 'No supported Linux inhibitor was detected (systemd-inhibit). NMSh does not install one or change power settings.' + : platform === 'darwin' ? '/usr/bin/caffeinate was not found.' : platform === 'win32' ? 'Windows PowerShell was not found for the execution-state helper.' + : 'Keep Awake has no backend on this platform.'; +} + +// ---- Processes and ownership ---------------------------------------------------------- + +export interface ProcessProbe { + start(plan: LaunchPlan): number | undefined; + alive(pid: number): boolean; + /** The process's full command line, or undefined when it cannot be read. */ + commandLine(pid: number): string | undefined; + signal(pid: number, signal: NodeJS.Signals): void; + now(): number; +} + +export const systemProbe: ProcessProbe = { + start(plan) { + const child = spawn(plan.command, plan.args, {detached: true, stdio: 'ignore', env: plan.env ?? process.env, windowsHide: true}); + child.on('error', () => { /* reported by the startup check */ }); + child.unref(); + return child.pid; + }, + alive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; } }, + commandLine(pid) { + if (process.platform === 'linux') { + try { return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\u0000').filter(Boolean).join(' '); } catch { return undefined; } + } + if (process.platform === 'win32') { + const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${Math.trunc(pid)}").CommandLine`], {encoding: 'utf8', timeout: 5000, windowsHide: true}); + return result.status === 0 ? result.stdout.trim() || undefined : undefined; + } + const result = spawnSync('/bin/ps', ['-ww', '-o', 'command=', '-p', String(Math.trunc(pid))], {encoding: 'utf8', timeout: 3000}); + return result.status === 0 ? result.stdout.trim() || undefined : undefined; + }, + signal(pid, signal) { try { process.kill(pid, signal); } catch { /* already gone */ } }, + now: () => Date.now(), +}; + +export interface KeepAwakeRecord { + version: 1; + token: string; + backend: KeepAwakeBackend['id']; + mode: KeepAwakeMode; + pid: number; + startedAt: number; + timeoutSeconds?: number; + /** The exact launch, for ownership checks. */ + command: string; + args: string[]; +} + +export type KeepAwakeStatus = + | {state: 'off'; note?: string} + | {state: 'running'; record: KeepAwakeRecord}; + +export function keepAwakePath(env: NodeJS.ProcessEnv = process.env): string { + return join(nmshConfigDirectory(env), 'keep-awake.json'); +} + +/** A record's process is ours only if it is alive and its command line is the exact launch (including the token where the backend carries one). */ +function owned(record: KeepAwakeRecord, probe: ProcessProbe): boolean { + if (!Number.isSafeInteger(record.pid) || record.pid <= 1 || !probe.alive(record.pid)) return false; + const line = probe.commandLine(record.pid); + if (!line) return false; + if (record.backend === 'macos-caffeinate') return line === [record.command, ...record.args].join(' '); + // Linux carries the token in argv; Windows carries it inside the encoded script (the last argument). + return record.backend === 'linux-systemd-inhibit' ? line.includes(`nmsh-keep-awake=${record.token}`) : line.includes(record.args.at(-1)!); +} + +export type StartResult = + | {kind: 'started'; record: KeepAwakeRecord; replaced?: KeepAwakeMode} + | {kind: 'already'; record: KeepAwakeRecord} + | {kind: 'needsConfirm'; from: KeepAwakeMode; to: KeepAwakeMode} + | {kind: 'unsupported'; reason: string} + | {kind: 'failed'; reason: string; previousStopped?: boolean}; + +export class KeepAwakeController { + constructor(readonly backend: KeepAwakeBackend | undefined, private readonly probe: ProcessProbe = systemProbe, + readonly path: string = keepAwakePath(), private readonly platform: NodeJS.Platform = process.platform) {} + + private load(): KeepAwakeRecord | undefined { + try { + const data = JSON.parse(readFileSync(this.path, 'utf8')) as KeepAwakeRecord; + if (data.version !== 1 || typeof data.token !== 'string' || !KEEP_AWAKE_MODES.includes(data.mode) || !Number.isSafeInteger(data.pid) + || typeof data.command !== 'string' || !Array.isArray(data.args)) return undefined; + return data; + } catch { return undefined; } + } + + private save(record: KeepAwakeRecord): void { + mkdirSync(dirname(this.path), {recursive: true, mode: 0o700}); + const staged = `${this.path}.${process.pid}.tmp`; + writeFileSync(staged, `${JSON.stringify(record, null, 2)}\n`, {mode: 0o600}); + renameSync(staged, this.path); + } + + private clear(): void { rmSync(this.path, {force: true}); } + + /** Verified state. Unprovable records are cleared (never killed). */ + status(): KeepAwakeStatus { + const record = this.load(); + if (!record) return {state: 'off'}; + if (owned(record, this.probe)) return {state: 'running', record}; + this.clear(); + const expired = record.timeoutSeconds && this.probe.now() >= record.startedAt + record.timeoutSeconds * 1000; + return {state: 'off', note: expired ? `The ${MODE_LABELS[record.mode]} keep-awake ended after its ${formatDuration(record.timeoutSeconds!)} timeout.` + : 'The earlier keep-awake is no longer running (or could not be verified as NMSh\'s); its record was cleared and nothing was stopped.'}; + } + + supports(mode: KeepAwakeMode): boolean { + if (!this.backend) return false; + const caps = this.backend.capabilities; + return mode === 'idle' ? caps.idle : mode === 'display' ? caps.display : mode === 'system' ? caps.system : caps.idle || caps.system; + } + + /** Starts, or reports already-running / needs-confirm. `confirmed` replaces a different running mode. */ + start(mode: KeepAwakeMode, timeoutSeconds?: number, confirmed = false): StartResult { + if (!this.backend) return {kind: 'unsupported', reason: unsupportedReason(this.platform)}; + if (!this.supports(mode)) return {kind: 'unsupported', reason: `${MODE_LABELS[mode]} is not supported by the ${this.backend.label}. ${this.backend.notes[0] ?? ''}`.trim()}; + const current = this.status(); + if (current.state === 'running') { + if (current.record.mode === mode && current.record.timeoutSeconds === timeoutSeconds) return {kind: 'already', record: current.record}; + if (!confirmed) return {kind: 'needsConfirm', from: current.record.mode, to: mode}; + } + const token = randomBytes(16).toString('hex'); + const plan = this.backend.plan(mode, token, timeoutSeconds); + if (!plan) return {kind: 'unsupported', reason: `${MODE_LABELS[mode]} is not supported by the ${this.backend.label}.`}; + // Start the replacement first, then release the old assertion: no unprotected gap. + const pid = this.probe.start(plan); + const record: KeepAwakeRecord = {version: 1, token, backend: this.backend.id, mode, pid: pid ?? 0, startedAt: this.probe.now(), + ...(timeoutSeconds ? {timeoutSeconds} : {}), command: plan.command, args: plan.args}; + if (!pid || !this.probe.alive(pid)) { + return {kind: 'failed', reason: `${this.backend.label} did not start.${current.state === 'running' ? ` The ${MODE_LABELS[current.record.mode]} keep-awake is still running.` : ''}`}; + } + if (current.state === 'running') this.terminate(current.record); + this.save(record); + return {kind: 'started', record, ...(current.state === 'running' ? {replaced: current.record.mode} : {})}; + } + + private terminate(record: KeepAwakeRecord): void { + this.probe.signal(record.pid, 'SIGTERM'); + const deadline = this.probe.now() + 2000; + const pause = new Int32Array(new SharedArrayBuffer(4)); + // Poll ownership, not bare liveness: an exited child awaiting reaping no longer has our command line. + while (owned(record, this.probe) && this.probe.now() < deadline) Atomics.wait(pause, 0, 0, 50); + if (owned(record, this.probe)) this.probe.signal(record.pid, 'SIGKILL'); + } + + /** Idempotent. Stops only a verified NMSh-owned assertion. */ + stop(): string { + const record = this.load(); + if (!record) return 'Keep Awake is off.'; + if (!owned(record, this.probe)) { this.clear(); return 'Keep Awake is off (the earlier record could not be verified as NMSh\'s, so nothing was stopped).'; } + this.terminate(record); + this.clear(); + return `Keep Awake stopped (${MODE_LABELS[record.mode]}). Normal sleep behavior is back.`; + } +} diff --git a/src/prompt/Powerlevel10kConfigurator.ts b/src/prompt/Powerlevel10kConfigurator.ts index e6c946fd..ece76282 100644 --- a/src/prompt/Powerlevel10kConfigurator.ts +++ b/src/prompt/Powerlevel10kConfigurator.ts @@ -21,11 +21,12 @@ export function powerlevel10kZshrcPath(env: NodeJS.ProcessEnv = process.env): st return resolve(join(env.ZDOTDIR || env.HOME || homedir(), '.zshrc')); } -function fingerprint(content: Buffer): string { +export function fingerprint(content: Buffer): string { return createHash('sha256').update(content).digest('hex'); } -async function snapshot(path: string): Promise { +/** Fingerprint and back up one config file before a handoff that may change it (absent files are recorded as absent). */ +export async function snapshot(path: string): Promise { try { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${path} is not a regular file; open the wizard manually from /zsh.`); diff --git a/src/prompt/PromptPanel.ts b/src/prompt/PromptPanel.ts index 8d7f972b..4f7b8026 100644 --- a/src/prompt/PromptPanel.ts +++ b/src/prompt/PromptPanel.ts @@ -93,6 +93,7 @@ export const PROMPT_PROVIDERS: readonly ProviderDescriptor[] = {id: 'nmsh', family: 'prompt', label: 'NMSh Native', kind: 'native', description: 'built-in themes, geometry, and modules'}, {id: 'starship', family: 'prompt', label: 'Starship', kind: 'external', executable: 'starship', description: 'use its themes/configuration'}, {id: 'powerlevel10k', family: 'prompt', label: 'Powerlevel10k', kind: 'external', description: 'use your ~/.p10k.zsh left prompt'}, + {id: 'ohMyPosh', family: 'prompt', label: 'Oh My Posh', kind: 'external', executable: 'oh-my-posh', description: 'render with oh-my-posh; no shell rc change'}, {id: 'none', family: 'prompt', label: 'None', kind: 'none', description: 'composer only: no prompt row, modules or marker'}, ]; export const PROVIDER_ORDER: readonly PromptProviderId[] = PROMPT_PROVIDERS.map(provider => provider.id); diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 18d6290d..770f6fee 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -83,7 +83,7 @@ export function applyShellModuleVisibility(configuration: Pick = new Set(['toolchain', 'kubeContext', 'dockerContext']); /** `none` is composer only: no prompt row, modules, right prompt or marker; everything else in NMSh stays on. */ -export type PromptProviderId = 'nmsh' | 'starship' | 'powerlevel10k' | 'none'; +export type PromptProviderId = 'nmsh' | 'starship' | 'powerlevel10k' | 'ohMyPosh' | 'none'; export type NativeEndStyle = PowerlineEdgeStyle; export type NativeStartStyle = PowerlineEdgeStyle; export type NativeConnectorStyle = PowerlineConnectorStyle; @@ -607,6 +607,8 @@ export interface PromptConfiguration { starship: {configPath: string | null}; /** Optional overrides; null uses detection and the default ~/.p10k.zsh. Never written to. */ powerlevel10k: {themePath: string | null; configPath: string | null}; + /** Optional local config path; null uses POSH_CONFIG, else Oh My Posh's built-in default. Never written to. */ + ohMyPosh: {configPath: string | null}; notifications: NotificationSettings; transcript: TranscriptAppearance; syntax: SyntaxAppearance; @@ -673,6 +675,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { mirrorRight: true, vibrance: 'standard', textColors: 'theme', accent: 'mauve', styleProfiles: normalizeStyleProfiles(undefined)}, starship: {configPath: null}, powerlevel10k: {themePath: null, configPath: null}, + ohMyPosh: {configPath: null}, transcript: {...DEFAULT_TRANSCRIPT_APPEARANCE}, syntax: {...DEFAULT_SYNTAX_APPEARANCE}, placement: 'header', @@ -752,12 +755,14 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio shellBackend: isShellId(value.shellBackend) ? value.shellBackend : 'zsh', openWith: OPEN_WITH_IDS.includes(value.openWith as OpenWith) ? value.openWith as OpenWith : 'auto', toolUpdateChecks, installSuggestions, ignoredInstallSuggestions, promptSymbol: normalizePromptSymbol(value.promptSymbol), ...(promptSymbolCustom ? {promptSymbolCustom} : {})}; - const provider: PromptProviderId = promptValue.provider === 'starship' || promptValue.provider === 'powerlevel10k' || promptValue.provider === 'none' + const provider: PromptProviderId = promptValue.provider === 'starship' || promptValue.provider === 'powerlevel10k' || promptValue.provider === 'ohMyPosh' || promptValue.provider === 'none' ? promptValue.provider : 'nmsh'; const p10kValue = isRecord(promptValue.powerlevel10k) ? promptValue.powerlevel10k : {}; const optionalPath = (value: unknown) => typeof value === 'string' && value.trim() ? value : null; const powerlevel10k = {themePath: optionalPath(p10kValue.themePath), configPath: optionalPath(p10kValue.configPath)}; + const ompValue = isRecord(promptValue.ohMyPosh) ? promptValue.ohMyPosh : {}; + const ohMyPosh = {configPath: optionalPath(ompValue.configPath)}; const nativeValue = isRecord(promptValue.nmsh) ? promptValue.nmsh : promptValue; const starshipValue = isRecord(promptValue.starship) ? promptValue.starship : {}; const endStyle = normalizeEdgeStyle(nativeValue.endStyle, 'fadeWedge'); @@ -811,7 +816,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, toolsSetupComplete, glyphStyle, glyphChoiceComplete, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, - presentation, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, notifications, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, spacing, gap, separator, ...themed}; + presentation, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, ohMyPosh, transcript, syntax, notifications, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, spacing, gap, separator, ...themed}; } const modules: ContextModuleConfig[] = []; @@ -854,7 +859,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio return {provider, onboardingComplete: value.onboardingComplete === true, toolsSetupComplete, - glyphStyle, glyphChoiceComplete, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, + glyphStyle, glyphChoiceComplete, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, ohMyPosh, starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, modules, separator, spacing, gap, ...themed}; } diff --git a/src/prompt/ohMyPosh.ts b/src/prompt/ohMyPosh.ts new file mode 100644 index 00000000..8c314e93 --- /dev/null +++ b/src/prompt/ohMyPosh.ts @@ -0,0 +1,112 @@ +import {execFile, spawn} from 'node:child_process'; +import {accessSync, constants} from 'node:fs'; +import {homedir} from 'node:os'; +import {isAbsolute, resolve} from 'node:path'; +import {promisify} from 'node:util'; +import {resolveCommand} from '../providers/providers.js'; +import type {PromptContext} from '../shell/ShellContext.js'; +import {parseStarshipPrompt, type StarshipPromptResult} from './starship.js'; + +const execFileAsync = promisify(execFile); + +/** + * Oh My Posh as an external prompt provider. NMSh runs the executable + * directly (`oh-my-posh print primary`), never `oh-my-posh init` and never + * through a shell or the user's rc files: argv only, the cwd and last exit + * status passed as documented flags, no controlling TTY, bounded output, a + * timeout that kills the whole process group, and cancellation. Its ANSI + * output crosses the same safe span parser as Starship and Powerlevel10k. + * + * Config: the path chosen in NMSh, else POSH_CONFIG (documented by Oh My + * Posh), else Oh My Posh's own built-in default. No rc file is parsed. + */ + +export interface OhMyPoshStatus { + installed: boolean; + binary?: string; + version?: string; + /** undefined: Oh My Posh's built-in default configuration. */ + configPath?: string; + configSource: 'nmsh' | 'POSH_CONFIG' | 'default'; + configExists: boolean; +} + +export const OH_MY_POSH_OUTPUT_LIMIT = 256 * 1024; + +function readable(path: string): boolean { + try { accessSync(path, constants.R_OK); return true; } catch { return false; } +} + +/** Absolute, readable-or-not local path; remote (URL) configs are never fetched by NMSh's choice. */ +export function normalizeOhMyPoshConfigPath(value: string | undefined, home = homedir()): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || /^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed) || /[\u0000-\u001f\u007f]/u.test(trimmed)) return undefined; + const expanded = trimmed.replace(/^~(?=\/|$)/u, home); + return isAbsolute(expanded) ? resolve(expanded) : undefined; +} + +export function ohMyPoshConfig(configured: string | undefined, env: NodeJS.ProcessEnv = process.env, home = homedir()): Pick { + const chosen = normalizeOhMyPoshConfigPath(configured, home); + if (chosen) return {configPath: chosen, configSource: 'nmsh', configExists: readable(chosen)}; + const fromEnv = normalizeOhMyPoshConfigPath(env.POSH_CONFIG, home); + if (fromEnv) return {configPath: fromEnv, configSource: 'POSH_CONFIG', configExists: readable(fromEnv)}; + return {configSource: 'default', configExists: false}; +} + +export async function detectOhMyPosh(configured?: string, env: NodeJS.ProcessEnv = process.env, + binary = resolveCommand('oh-my-posh', env.PATH ?? '')): Promise { + const config = ohMyPoshConfig(configured, env, env.HOME || homedir()); + if (!binary) return {installed: false, ...config}; + let version: string | undefined; + try { + const result = await execFileAsync(binary, ['version'], {timeout: 3000, maxBuffer: 4096, env: ohMyPoshEnvironment(env)}); + version = result.stdout.trim().split('\n')[0] || undefined; + } catch { /* Found; a failing version command does not hide that. */ } + return {installed: true, binary, ...(version ? {version} : {}), ...config}; +} + +/** The documented `print primary` argv. Every value is its own argv element; nothing is shell text. */ +export function ohMyPoshArgs(context: Pick, status: Pick, width = 200): string[] { + const exit = context.exitStatus; + return ['print', 'primary', `--pwd=${context.cwd}`, ...(exit === undefined ? ['--no-status'] : [`--status=${exit}`]), + `--terminal-width=${width}`, '--escape=false', ...(status.configPath ? [`--config=${status.configPath}`] : [])]; +} + +/** The environment is inherited minus POSH_CONFIG (the config is explicit argv) and anything that could hand it a TTY. */ +function ohMyPoshEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const {POSH_CONFIG: _config, ...rest} = env; + return {...rest, TERM: env.TERM || 'xterm-256color', NO_COLOR: undefined}; +} + +export function renderOhMyPoshPrompt(context: PromptContext, status: OhMyPoshStatus, env: NodeJS.ProcessEnv = process.env, + options: {timeoutMs?: number; signal?: AbortSignal; width?: number} = {}): Promise { + if (!status.installed || !status.binary) return Promise.reject(new Error('Oh My Posh is not installed or not available on PATH.')); + if (status.configPath && !status.configExists) return Promise.reject(new Error(`Oh My Posh config not found: ${status.configPath}`)); + const cwd = isAbsolute(context.cwd) ? context.cwd : process.cwd(); + return new Promise((resolvePrompt, reject) => { + const child = spawn(status.binary!, ohMyPoshArgs({...context, cwd}, status, options.width), { + cwd, env: ohMyPoshEnvironment(env), stdio: ['ignore', 'pipe', 'ignore'], detached: true, + }); + let stdout = ''; + let settled = false; + const kill = () => { try { process.kill(-child.pid!, 'SIGKILL'); } catch { child.kill('SIGKILL'); } }; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + options.signal?.removeEventListener('abort', abort); + if (error) reject(error); else resolvePrompt(parseStarshipPrompt(stdout)); + }; + const abort = () => { kill(); finish(new Error('Oh My Posh prompt was cancelled.')); }; + const timer = setTimeout(() => { kill(); finish(new Error('Oh My Posh prompt timed out.')); }, options.timeoutMs ?? 3000); + if (options.signal?.aborted) { abort(); return; } + options.signal?.addEventListener('abort', abort, {once: true}); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', chunk => { + stdout += chunk; + if (stdout.length > OH_MY_POSH_OUTPUT_LIMIT) { kill(); finish(new Error('Oh My Posh prompt output exceeded its limit.')); } + }); + child.on('error', error => finish(error)); + child.on('close', code => finish(code === 0 ? undefined : new Error(`Oh My Posh exited with ${code}.`))); + }); +} diff --git a/src/sessions/TranscriptStore.ts b/src/sessions/TranscriptStore.ts index 28ed60ba..d915fb6a 100644 --- a/src/sessions/TranscriptStore.ts +++ b/src/sessions/TranscriptStore.ts @@ -96,7 +96,7 @@ function isTranscript(value: unknown): value is OutputTranscript { && (record.historicalContext.promptless === undefined || record.historicalContext.promptless === true))) && (record.historicalContext?.prompt === undefined || (typeof record.historicalContext.prompt === 'object' && record.historicalContext.prompt !== null - && ['nmsh', 'starship', 'powerlevel10k'].includes(record.historicalContext.prompt.provider) + && ['nmsh', 'starship', 'powerlevel10k', 'ohMyPosh'].includes(record.historicalContext.prompt.provider) && Array.isArray(record.historicalContext.prompt.segments) && (record.historicalContext.prompt.gapEnabled === undefined || typeof record.historicalContext.prompt.gapEnabled === 'boolean') && record.historicalContext.prompt.segments.every(segment => segment && typeof segment.text === 'string' diff --git a/src/tools/OhMyZshView.ts b/src/tools/OhMyZshView.ts new file mode 100644 index 00000000..871436f1 --- /dev/null +++ b/src/tools/OhMyZshView.ts @@ -0,0 +1,157 @@ +import {constants, copyFileSync, lstatSync, readFileSync, renameSync, writeFileSync} from 'node:fs'; +import {randomUUID} from 'node:crypto'; +import {dirname, join} from 'node:path'; +import type {Key} from '../terminal/keys.js'; +import {createConfirm, handleConfirmKey, renderConfirm, type ConfirmState} from '../ui/formControls.js'; +import {fingerprint} from '../prompt/Powerlevel10kConfigurator.js'; +import {shortDiff} from '../dotfiles/plan.js'; +import {fileFacts, OH_MY_ZSH_INSTALL, previousZshrc, type FileFacts} from './frameworks.js'; + +/** + * Oh My Zsh's two special views in /tools. + * + * Guided install: NMSh never downloads or runs the installer (it never runs + * install scripts). It fingerprints and backs up .zshrc, shows the official + * source and the exact documented settings that keep .zshrc and the login + * shell untouched, and afterwards verifies what changed. An unexpected + * .zshrc change is reported prominently; nothing is restored silently. + * + * Previous zshrc: compares .zshrc with the installer's .zshrc.pre-oh-my-zsh. + * Restoring is a separate confirmation (default No) that first backs up the + * current file and then replaces it atomically. Shell code is never merged, + * sourced or parsed. + */ + +export interface ZshrcSnapshot {path: string; sha256?: string; backup?: string; error?: string} + +export type OhMyZshView = + | {kind: 'guided'; env: NodeJS.ProcessEnv; snapshot?: ZshrcSnapshot; verified?: string[]; unexpected?: boolean} + | {kind: 'previous'; env: NodeJS.ProcessEnv; current: FileFacts; previous: FileFacts; diff: string[]; confirm?: ConfirmState; result?: string}; + +const stamp = (now: Date) => now.toISOString().replace(/[:.]/gu, '-'); +const hash = (content: Buffer) => fingerprint(content); + +export function openGuidedInstall(env: NodeJS.ProcessEnv = process.env): OhMyZshView { + return {kind: 'guided', env}; +} + +/** Fingerprint and back up .zshrc (absent is recorded as absent). Never renames or edits the original. */ +export function snapshotZshrc(env: NodeJS.ProcessEnv = process.env, now = new Date()): ZshrcSnapshot { + const path = OH_MY_ZSH_INSTALL.snapshotTargets(env)[0]!; + try { + const info = lstatSync(path); + if (!info.isFile()) return {path, error: `${path} is ${info.isSymbolicLink() ? 'a symlink' : 'not a regular file'}; NMSh records nothing and makes no backup. Back it up yourself before installing.`}; + const content = readFileSync(path); + const backup = `${path}.nmsh-backup-${stamp(now)}-${randomUUID()}`; + copyFileSync(path, backup, constants.COPYFILE_EXCL); + return {path, sha256: hash(content), backup}; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {path}; + return {path, error: error instanceof Error ? error.message : String(error)}; + } +} + +/** What changed since the snapshot: installation present, .zshrc fingerprint, installer side files. */ +export function verifyInstall(view: Extract): void { + const found = OH_MY_ZSH_INSTALL.verify(view.env); + const snapshot = view.snapshot; + const now = snapshot ? fileFacts(snapshot.path, hash) : undefined; + const lines = [found ? `Oh My Zsh found at ${found.path}.` : 'Oh My Zsh was not found yet (no oh-my-zsh.sh with lib/ and themes/ at $ZSH or ~/.oh-my-zsh).']; + view.unexpected = false; + if (snapshot && !snapshot.error) { + const changed = (now?.sha256 ?? undefined) !== snapshot.sha256; + view.unexpected = changed; + lines.push(changed + ? `UNEXPECTED: ${snapshot.path} changed although KEEP_ZSHRC=yes was requested. Your backup is ${snapshot.backup ?? '(there was no file before)'}. NMSh did not restore anything.` + : `${snapshot.path} is unchanged.`); + } else lines.push('No .zshrc snapshot was taken, so NMSh cannot say whether it changed.'); + for (const side of OH_MY_ZSH_INSTALL.filesAtRisk(view.env).slice(1)) { + const facts = fileFacts(side, hash); + if (facts.exists) lines.push(`${side} exists (${facts.bytes} bytes, ${facts.modified?.toISOString().slice(0, 16).replace('T', ' ')}).`); + } + view.verified = lines; +} + +export function openPrevious(env: NodeJS.ProcessEnv = process.env): OhMyZshView | undefined { + const pair = previousZshrc(env); + if (!pair) return undefined; + const current = fileFacts(pair.current, hash); + const previous = fileFacts(pair.previous, hash); + let diff: string[] = []; + try { + diff = shortDiff(current.exists ? readFileSync(pair.current, 'utf8') : undefined, readFileSync(pair.previous, 'utf8')); + } catch { diff = ['(not readable as text)']; } + return {kind: 'previous', env, current, previous, diff}; +} + +/** Back up the current .zshrc, then atomically replace it with the previous file. Refuses if either changed since review. */ +export function restorePrevious(view: Extract, now = new Date()): string { + const current = fileFacts(view.current.path, hash); + const previous = fileFacts(view.previous.path, hash); + if (current.sha256 !== view.current.sha256 || previous.sha256 !== view.previous.sha256) return 'A file changed since review; nothing was written. Reopen to review again.'; + if (current.symlink) return `${view.current.path} is a symlink; NMSh does not replace it. Nothing was written.`; + if (!previous.exists) return `${view.previous.path} is gone; nothing was written.`; + let backup = ''; + if (current.exists) { + backup = `${view.current.path}.nmsh-backup-${stamp(now)}-${randomUUID()}`; + copyFileSync(view.current.path, backup, constants.COPYFILE_EXCL); + } + const staged = join(dirname(view.current.path), `.zshrc.nmsh-${process.pid}.tmp`); + writeFileSync(staged, readFileSync(view.previous.path), {mode: 0o644, flag: 'wx'}); + renameSync(staged, view.current.path); + return `Restored ${view.previous.path} to ${view.current.path}.${backup ? ` The replaced file is backed up at ${backup}.` : ''} ${view.previous.path} was left in place. New Zsh sessions use it; NMSh did not merge anything.`; +} + +/** Returns 'back' when the view should close; 'open' with the two paths for the app to open. */ +export function ohMyZshKey(view: OhMyZshView, key: Key): 'back' | {open: string[]} | undefined { + if (view.kind === 'previous' && view.confirm) { + const decision = handleConfirmKey(key, view.confirm); + if (decision === 'confirm') { view.confirm = undefined; view.result = restorePrevious(view); } + else if (decision === 'cancel') { view.confirm = undefined; view.result = 'Cancelled. Nothing was changed.'; } + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return 'back'; + if (key.kind !== 'text') return undefined; + const lower = key.value.toLowerCase(); + if (view.kind === 'guided') { + if (lower === 'b') view.snapshot = snapshotZshrc(view.env); + else if (lower === 'v') verifyInstall(view); + return undefined; + } + if (lower === 'k') return 'back'; + if (lower === 'e') return {open: [view.current.path, view.previous.path]}; + if (lower === 'r') { view.result = undefined; view.confirm = createConfirm(); } + return undefined; +} + +const facts = (label: string, file: FileFacts) => file.exists + ? [`${label} ${file.path}`, ` ${file.bytes} bytes · modified ${file.modified?.toISOString().slice(0, 16).replace('T', ' ')} · sha256 ${file.sha256?.slice(0, 16)}…${file.symlink ? ' · symlink' : ''}`] + : [`${label} ${file.path} (missing)`]; + +/** Plain rows (the panel adds color and framing). */ +export function renderOhMyZshView(view: OhMyZshView): {rows: string[]; footer: Array<[string, string]>} { + if (view.kind === 'guided') { + const adapter = OH_MY_ZSH_INSTALL; + const rows = ['Oh My Zsh needs its official installer. NMSh will not run it for you.', '', + 'With the settings below the installer:', ' ✓ keeps your current .zshrc (KEEP_ZSHRC=yes, --keep-zshrc)', ' ✓ does not change your login shell (CHSH=no)', + ' ✓ does not start another Zsh (RUNZSH=no, --unattended)', ' ✓ clones only the official repository (REPO/REMOTE/BRANCH pinned)', '', + `Source ${adapter.source}`, `Needs ${adapter.prerequisites.join(', ')}`, '', + 'Steps (run them yourself in a terminal; inspect the file before running it):', ...adapter.steps(view.env).map((step, index) => ` ${index + 1}. ${step}`), '', + ...adapter.recoveryHints.map(hint => `• ${hint}`), '']; + const snapshot = view.snapshot; + if (!snapshot) rows.push('B first: NMSh fingerprints and backs up .zshrc so it can verify afterwards.'); + else if (snapshot.error) rows.push(`Snapshot: ${snapshot.error}`); + else rows.push(snapshot.sha256 ? `Snapshot: ${snapshot.path} sha256 ${snapshot.sha256.slice(0, 16)}… · backup ${snapshot.backup}` : `Snapshot: ${snapshot.path} does not exist yet (recorded as absent).`); + if (view.verified) rows.push('', ...(view.unexpected ? ['! ' + view.verified[1]!] : []), ...view.verified.filter((_, index) => !(view.unexpected && index === 1))); + return {rows, footer: [['B', 'back up .zshrc'], ['V', 'verify after installing'], ['Esc', 'back']]}; + } + const rows = ['Previous zshrc found. This does not mean anything is wrong: the Oh My Zsh installer saves the old file there.', '', + ...facts('Current ', view.current), ...facts('Previous', view.previous), '', 'Changes (current → previous, first lines):', ...view.diff.map(line => ` ${line}`), '', + 'Shell code is not merged or sourced by NMSh. Edit by hand if you want parts of both.']; + if (view.confirm) { + rows.push('', `Restore previous: ${view.current.path} is backed up, then replaced by ${view.previous.path}. ${view.previous.path} stays.`, renderConfirm(view.confirm, {focused: true})); + return {rows, footer: [['←→', 'choose'], ['Enter', 'confirm'], ['Esc', 'cancel']]}; + } + if (view.result) rows.push('', view.result); + return {rows, footer: [['E', 'open both'], ['K', 'keep current'], ['R', 'restore previous…'], ['Esc', 'back']]}; +} diff --git a/src/tools/ToolsPanel.ts b/src/tools/ToolsPanel.ts index 6278c841..24676dc8 100644 --- a/src/tools/ToolsPanel.ts +++ b/src/tools/ToolsPanel.ts @@ -1,5 +1,5 @@ import type {Key} from '../terminal/keys.js'; -import {clearProviderDetection, detectProvider, type ProviderStatus, type ProviderInstall} from '../providers/providers.js'; +import {clearProviderDetection, type ProviderStatus, type ProviderInstall} from '../providers/providers.js'; import {TaskProgress, renderTaskProgress} from '../status/TaskProgress.js'; import {createConfirm, editText, handleConfirmKey, renderConfirm, type ConfirmState} from '../ui/formControls.js'; import {renderTabStrip, framePanel} from '../ui/PanelShell.js'; @@ -7,7 +7,11 @@ import {colorLevel} from '../presentation/capabilities.js'; import {foregroundOf} from '../chroma/chroma.js'; import {languageIdentity} from '../languages/linguistLanguageColors.js'; import {displayWidth, stripAnsi, truncateAnsi, truncateText} from '../util/text.js'; -import {TOOLS, TOOL_CATEGORIES, TOOL_TIER_LABELS, toolInstall, toolInstallUnavailable, type Tool, type ToolTier} from './catalog.js'; +import {detectTool, TOOLS, TOOL_CATEGORIES, TOOL_TIER_LABELS, toolInstall, toolInstallUnavailable, type Tool, type ToolTier} from './catalog.js'; +import type {PromptProviderId} from '../prompt/configuration.js'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; +import {previousZshrc} from './frameworks.js'; +import {ohMyZshKey, openGuidedInstall, openPrevious, renderOhMyZshView, type OhMyZshView} from './OhMyZshView.js'; import {lifecycleNote, providerLifecycle} from '../providers/providers.js'; import {toolOwner, toolUpgrade, UNKNOWN_OWNER_UPDATE, type ToolUpdateState} from './ToolUpdates.js'; import {background, foreground, UI_COLORS} from '../ui/palette.js'; @@ -49,6 +53,14 @@ export interface ToolsPanel { selection?: Set; /** A combined plan under review; installs nothing until its own confirmation. */ bulk?: BulkReview; + /** Canonical prompt provider state (selected in settings vs. effective now); supplied by the app, never duplicated. */ + prompt?: {selected: PromptProviderId; effective: PromptProviderId}; + /** The session's current shell backend, for factual "Zsh only" labels. */ + shellBackend?: ShellId; + /** Oh My Zsh guided install or previous-zshrc comparison, over the detail view. */ + framework?: OhMyZshView; + /** Files the app should open (set with the 'openFiles' action). */ + openPaths?: string[]; } export interface BulkReview { items: Array<{tool: Tool; plan: PackagePlan}>; @@ -89,7 +101,7 @@ export async function refreshTools(state: ToolsPanel, changed: () => void): Prom // Bound probes rather than spawning the whole catalog simultaneously. for (let index = 0; index < TOOLS.length; index += 3) { await Promise.all(TOOLS.slice(index, index + 3).map(async tool => { - state.statuses[tool.id] = await detectProvider(tool); + state.statuses[tool.id] = await detectTool(tool); })); changed(); } @@ -116,7 +128,29 @@ export function visibleTools(state: ToolsPanel): Tool[] { || Number(state.statuses[a.id]?.state === 'installed') - Number(state.statuses[b.id]?.state === 'installed') || a.label.localeCompare(b.label)); } const relevantRank = (tool: Tool) => tool.tier === 'recommended' ? 0 : tool.tier === 'enhanced' ? 1 : 2; -export type ToolsAction = 'close' | 'configure' | 'provider' | 'refresh' | 'finishOnboarding' | 'mise' | 'checkUpdates'; +export type ToolsAction = 'close' | 'configure' | 'provider' | 'refresh' | 'finishOnboarding' | 'mise' | 'checkUpdates' + | 'usePrompt' | 'promptSettings' | 'p10kConfigure' | 'importAppearance' | 'openFiles'; + +/** The prompt-provider word for a tool, from the one canonical state. */ +export function promptRole(state: ToolsPanel, tool: Tool): string | undefined { + if (!tool.promptProvider || !state.prompt) return undefined; + if (state.prompt.effective === tool.promptProvider) return 'Active prompt provider'; + if (state.prompt.selected === tool.promptProvider) return 'Selected prompt provider · not active (NMSh Native in use)'; + return 'Prompt provider'; +} + +/** Factual one-line status: what it is, and scope when the backend differs. */ +export function toolStatusLine(state: ToolsPanel, tool: Tool): string { + const status = state.statuses[tool.id]; + const kind = tool.capabilities?.includes('shell framework') ? 'Zsh framework' : tool.promptProvider ? promptRole(state, tool) ?? 'Prompt provider' : undefined; + const zshOnly = tool.shells?.length === 1 && tool.shells[0] === 'zsh'; + if (!status) return 'Checking'; + if (status.state === 'installed') { + const scope = zshOnly && state.shellBackend && state.shellBackend !== 'zsh' ? 'used by Zsh only' : undefined; + return ['Installed', kind, scope].filter(Boolean).join(' · '); + } + return status.state === 'missing' ? ['Not installed', zshOnly ? 'Zsh only' : undefined].filter(Boolean).join(' · ') : 'Needs attention'; +} /** Whether an installed tool has an update according to the last check. */ export function toolHasUpdate(state: ToolsPanel, tool: Tool): boolean { @@ -139,6 +173,12 @@ export function toolsKey(state: ToolsPanel, key: Key): ToolsAction | undefined { return undefined; } if (state.confirm) return undefined; // Async install owner handles confirmation. + if (state.framework) { + const result = ohMyZshKey(state.framework, key); + if (result === 'back') state.framework = undefined; + else if (result) { state.openPaths = result.open; return 'openFiles'; } + return undefined; + } if (key.kind === 'escape' || key.kind === 'interrupt') { if (state.selection?.size && !state.detail) { state.selection.clear(); state.message = 'Selection cleared. Nothing was installed.'; } else if (state.detail) { state.detail = undefined; state.message = undefined; } @@ -147,12 +187,28 @@ export function toolsKey(state: ToolsPanel, key: Key): ToolsAction | undefined { } else if (state.detail) { if (key.kind !== 'text') return undefined; if (key.value.toLowerCase() === 'm' && state.detail.id === 'mise') return 'mise'; + const installed = state.statuses[state.detail.id]?.state === 'installed'; + const lower = key.value.toLowerCase(); + if (state.detail.promptProvider && installed && lower === 'a') return 'usePrompt'; + if (state.detail.promptProvider && lower === 's') return 'promptSettings'; + if (state.detail.id === 'powerlevel10k' && installed && lower === 'c') return 'p10kConfigure'; + if (state.detail.capabilities?.includes('Theme Studio import source') && installed && lower === 't') return 'importAppearance'; + if (state.detail.installAdapter && !installed && lower === 'i') { state.framework = openGuidedInstall(); return undefined; } + if (state.detail.id === 'oh-my-zsh' && lower === 'o') { + const view = openPrevious(); + if (view) state.framework = view; else state.message = 'No .zshrc.pre-oh-my-zsh was found.'; + return undefined; + } if (key.value.toLowerCase() === 'u' && toolHasUpdate(state, state.detail)) { const upgrade = toolUpgrade(state.detail, toolOwner(state.statuses[state.detail.id]?.binary), state.updates!); if (upgrade) { state.recipe = upgrade; state.upgrading = true; state.confirm = createConfirm(); } else state.message = `${UNKNOWN_OWNER_UPDATE} NMSh did not install ${state.detail.label} and does not guess its package manager.`; return undefined; } + if (state.detail.detection?.kind === 'filesystem' && (lower === 'x' || lower === 'i')) { + state.message = `NMSh does not install or remove ${state.detail.label}; it is detected only.`; + return undefined; + } if (key.value.toLowerCase() === 'x' && state.statuses[state.detail.id]?.state === 'installed') { const provenance = state.provenance ?? new InstallProvenance(); const plan = planToolUninstall(state.detail, provenance.find(state.detail.id), toolOwner(state.statuses[state.detail.id]?.binary)); @@ -219,7 +275,7 @@ export async function confirmToolInstall(state: ToolsPanel, key: Key, changed: ( if (run) await run(task, recipe); else await task.run(recipe.command, [...recipe.args]); clearProviderDetection(); - state.statuses[tool.id] = await detectProvider(tool); + state.statuses[tool.id] = await detectTool(tool); if (task.state.status === 'succeeded' && state.statuses[tool.id]?.state === 'installed') { delete state.errors[tool.id]; if (!upgrading) try { (state.provenance ?? new InstallProvenance()).record(tool, recipe); } catch { /* provenance is best effort */ } @@ -253,7 +309,7 @@ async function confirmBulk(state: ToolsPanel, key: Key, changed: () => void, continue; } clearProviderDetection(); - state.statuses[tool.id] = await detectProvider(tool); + state.statuses[tool.id] = await detectTool(tool); if (task.state.status === 'succeeded' && state.statuses[tool.id]?.state === 'installed') { delete state.errors[tool.id]; try { (state.provenance ?? new InstallProvenance()).record(tool, plan); } catch { /* provenance is best effort */ } @@ -280,7 +336,7 @@ async function runUninstall(state: ToolsPanel, changed: () => void, run?: (task: if (run) await run(task, recipe); else await task.run(recipe.command, [...recipe.args]); clearProviderDetection(); - state.statuses[tool.id] = await detectProvider(tool); + state.statuses[tool.id] = await detectTool(tool); if (task.state.status === 'succeeded') { try { (state.provenance ?? new InstallProvenance()).forget(tool.id); } catch { /* record cleanup is best effort */ } state.message = state.statuses[tool.id]?.state === 'missing' @@ -322,7 +378,10 @@ function statusBadge(state: ToolsPanel, tool: Tool): {text: string; color: strin export function toolBadges(state: ToolsPanel, tool: Tool): string[] { const lifecycle = providerLifecycle(tool); - return [...(tool.integration ? [`Integrated · ${tool.integration[0]!.toUpperCase()}${tool.integration.slice(1)}`] : []), + const role = promptRole(state, tool); + const zshOnly = tool.shells?.length === 1 && tool.shells[0] === 'zsh' && state.shellBackend !== undefined && state.shellBackend !== 'zsh'; + return [...(tool.capabilities?.includes('shell framework') ? ['Zsh framework'] : []), ...(role ? [role] : []), ...(zshOnly ? ['Zsh only'] : []), + ...(tool.integration ? [`Integrated · ${tool.integration[0]!.toUpperCase()}${tool.integration.slice(1)}`] : []), ...(tool.discoveryKind === 'environment' ? ['Detected environment'] : tool.tier ? [TOOL_TIER_LABELS[tool.tier]] : []), ...(providerLifecycle(tool) === 'legacy' ? [`${tool.successor ?? 'Maintained alternative'} recommended`] : []), ...(state.configured.has(tool.id) ? ['Configured in NMSh'] : []), @@ -399,6 +458,11 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): } else if (state.task?.state.status === 'running') { rows.push(...renderTaskProgress(state.task.state)); footer = [['Please wait', 'installation in progress']]; + } else if (state.framework) { + const view = renderOhMyZshView(state.framework); + rows.push(` ${PRIMARY}${BOLD}${state.framework.kind === 'guided' ? 'Oh My Zsh · guided install' : 'Oh My Zsh · previous zshrc'}${RESET}`, '', + ...view.rows.map(row => row.startsWith('! ') ? ` ${FAILURE}${row}${RESET}` : ` ${SECONDARY}${row}${RESET}`)); + footer = view.footer; } else if (state.detail) { const tool = state.detail; const badge = statusBadge(state, tool); @@ -406,8 +470,14 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): rows.push(` ${PRIMARY}${BOLD}${tool.label}${RESET} ${badge.color}${badge.text}${RESET}${toolBadges(state, tool).length ? ` ${SUBTLE}${toolBadges(state, tool).join(' · ')}${RESET}` : ''}`, ` ${SUBTLE}${tool.description}${RESET}`, '', field('Category', tool.category), field('Source', tool.source), - field('Install', state.statuses[tool.id]?.state === 'missing' - ? installPlanFor(state, tool)?.label ?? installUnavailableFor(state, tool) : tool.package ? `Package ${tool.package}` : 'Installed outside NMSh')); + field('Status', toolStatusLine(state, tool)), + ...(tool.capabilities?.length ? [field('Is', tool.capabilities.join(' · '))] : []), + ...(tool.detection?.kind === 'filesystem' && state.statuses[tool.id]?.detail ? [field('Found', state.statuses[tool.id]!.detail!)] : []), + field('Install', tool.installAdapter && state.statuses[tool.id]?.state === 'missing' ? 'Guided: NMSh keeps your .zshrc and never runs the installer itself (I)' + : state.statuses[tool.id]?.state === 'missing' + ? installPlanFor(state, tool)?.label ?? installUnavailableFor(state, tool) + : tool.package ? `Package ${tool.package}` : tool.detection?.kind === 'filesystem' ? 'Installed outside NMSh · not managed by NMSh' : 'Installed outside NMSh')); + if (tool.id === 'oh-my-zsh' && previousZshrc()) rows.push(field('Previous', 'Previous zshrc found (.zshrc.pre-oh-my-zsh) · O to compare')); const lifecycle = lifecycleNote(tool); if (lifecycle) rows.push(field('Lifecycle', lifecycle)); const outdated = tool.package ? state.updates?.outdated[tool.package] : undefined; @@ -423,12 +493,18 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): if (activation) rows.push(field('Shell', `${ACTIVATION_LABELS[activation.state]} · ${activation.detail}`)); rows.push('', ` ${SUBTLE}${activation ? 'Shell state comes from the running session; rc files are never read.' : 'Shell hook state is not inferred; existing hooks stay authoritative.'}${RESET}`); footer = [ - ...(state.statuses[tool.id]?.state === 'missing' ? [['I', 'install…'] as [string, string]] : []), - ...(state.statuses[tool.id]?.state === 'installed' ? [['X', 'uninstall…'] as [string, string]] : []), + ...(state.statuses[tool.id]?.state === 'missing' && tool.detection?.kind !== 'filesystem' ? [['I', 'install…'] as [string, string]] : []), + ...(state.statuses[tool.id]?.state === 'installed' && tool.detection?.kind !== 'filesystem' ? [['X', 'uninstall…'] as [string, string]] : []), ...(toolHasUpdate(state, tool) ? [['U', 'update…'] as [string, string]] : []), ...(tool.id === 'mise' ? [['M', 'project awareness'] as [string, string]] : []), ...(tool.configuration && state.statuses[tool.id]?.state === 'installed' ? [['C', 'configure'] as [string, string]] : []), ...(tool.providerFamily ? [['P', 'provider'] as [string, string]] : []), + ...(tool.promptProvider && state.statuses[tool.id]?.state === 'installed' ? [['A', 'use as prompt'] as [string, string]] : []), + ...(tool.promptProvider ? [['S', 'prompt settings'] as [string, string]] : []), + ...(tool.id === 'powerlevel10k' && state.statuses[tool.id]?.state === 'installed' ? [['C', 'configure (p10k configure)'] as [string, string]] : []), + ...(tool.capabilities?.includes('Theme Studio import source') && state.statuses[tool.id]?.state === 'installed' ? [['T', 'import appearance into NMSh Native'] as [string, string]] : []), + ...(tool.installAdapter && state.statuses[tool.id]?.state === 'missing' ? [['I', 'guided install…'] as [string, string]] : []), + ...(tool.id === 'oh-my-zsh' && previousZshrc() ? [['O', 'previous zshrc'] as [string, string]] : []), ['R', 'refresh'], ['Esc', 'back']]; } else { const tierLabel = state.tier === 'enhanced' ? 'Recommended + Enhanced' : state.tier === 'recommended' || state.recommendedOnly ? 'Recommended only' : ''; diff --git a/src/tools/catalog.ts b/src/tools/catalog.ts index c7c65911..dea29c68 100644 --- a/src/tools/catalog.ts +++ b/src/tools/catalog.ts @@ -1,7 +1,11 @@ -import {installUnavailableReason, providerInstall, resolveCommand, type ProviderDescriptor, type ProviderInstall, type ProviderFamily} from '../providers/providers.js'; +import {detectProvider, installUnavailableReason, providerInstall, resolveCommand, type ProviderDescriptor, type ProviderInstall, type ProviderFamily, type ProviderStatus} from '../providers/providers.js'; +import type {PromptProviderId} from '../prompt/configuration.js'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; +import {detectBackend as detectKeepAwake} from '../keepAwake/keepAwake.js'; +import {detectAntidote, detectOhMyZsh, detectPowerlevel10kTool, detectPrezto, detectZim, detectZinit, type FilesystemFact} from './frameworks.js'; export const TOOL_CATEGORIES = ['Search & Files', 'Git & Development', 'Navigation & History', - 'Data / Structured Text', 'Environment & Secrets', 'Shell / Workflow', 'Containers / Infrastructure', 'Project / Language Tooling'] as const; + 'Data / Structured Text', 'Environment & Secrets', 'Shell / Workflow', 'Containers / Infrastructure', 'Project / Language Tooling', 'System capabilities'] as const; /** * Optional tiers. Recommended is a small, conservative toolkit; Enhanced is a * separate set some people like. Neither is "better for everyone", and NMSh @@ -11,6 +15,18 @@ export type ToolTier = 'recommended' | 'enhanced'; export const TOOL_TIER_LABELS: Record = {recommended: 'Recommended', enhanced: 'Enhanced'}; export type ToolDiscoveryKind = 'utility' | 'environment'; +/** + * How a curated tool is detected, registered explicitly. `executable` is a + * PATH lookup; `filesystem` is a registered structural detector for + * frameworks and themes that are not commands. Detection never grants + * install, uninstall, configuration or provider authority. + */ +export type ToolDetection = {kind: 'executable'} | {kind: 'filesystem'; detect: (env: NodeJS.ProcessEnv) => FilesystemFact | undefined}; + +/** What a tool is, stated explicitly (never inferred from being installed). A tool may have several. */ +export type ToolCapability = 'executable utility' | 'shell framework' | 'prompt engine' | 'Prompt provider' | 'configurable' + | 'special installer' | 'filesystem-detected' | 'dotfiles inspect-only' | 'Theme Studio import source' | 'executable config'; + export interface Tool extends ProviderDescriptor { category: typeof TOOL_CATEGORIES[number]; /** Kept for existing callers: true exactly for the Recommended tier. */ @@ -37,6 +53,15 @@ export interface Tool extends ProviderDescriptor { /** A registered Tool Configuration adapter (src/tools/config/registry.ts); only these get Configure. */ configuration?: 'starship' | 'tmux'; language?: string; + /** Default: the executable on PATH. */ + detection?: ToolDetection; + capabilities?: readonly ToolCapability[]; + /** Shells the tool belongs to; absent means any. Shown factually, never hidden for another backend. */ + shells?: readonly ShellId[]; + /** The canonical Prompt provider this tool is (one identity with /providers and /prompt). */ + promptProvider?: Exclude; + /** A first-party guided installer adapter (src/tools/frameworks.ts) instead of a package recipe. */ + installAdapter?: 'oh-my-zsh'; } function tool(id: string, label: string, category: Tool['category'], description: string, @@ -47,6 +72,10 @@ function tool(id: string, label: string, category: Tool['category'], description } const ENHANCED = {tier: 'enhanced'} as const; const ENVIRONMENT = {discoveryKind: 'environment', commandNotFound: false} as const; +/** Not a command: no executable, no package recipe, never identified by command-not-found. */ +const filesystem = (detect: (env: NodeJS.ProcessEnv) => FilesystemFact | undefined) => + ({detection: {kind: 'filesystem', detect}, executable: undefined, package: '', versionArgs: undefined, commandNotFound: false} as const); +const ZSH_FRAMEWORK = {shells: ['zsh'], capabilities: ['shell framework', 'filesystem-detected', 'dotfiles inspect-only']} as const; /** Curated offline metadata. No third-party submissions, update checks or marketplace. */ export const TOOLS: readonly Tool[] = [ @@ -67,7 +96,22 @@ export const TOOLS: readonly Tool[] = [ tool('xh', 'xh', 'Data / Structured Text', 'Friendly and fast HTTP client.', 'https://github.com/ducaale/xh', {package: 'xh', ...ENHANCED}), tool('direnv', 'direnv', 'Environment & Secrets', 'Project environment tooling; approval/hooks remain yours.', 'https://direnv.net/', ENHANCED), tool('pass', 'pass', 'Environment & Secrets', 'Password tooling; NMSh does not read its secret store.', 'https://www.passwordstore.org/', {versionArgs: undefined}), - tool('starship', 'Starship', 'Shell / Workflow', 'Optional prompt with supported module configuration.', 'https://starship.rs/', {configuration: 'starship'}), + tool('starship', 'Starship', 'Shell / Workflow', 'Cross-shell prompt engine; an NMSh Prompt provider with supported module configuration.', 'https://starship.rs/', + {configuration: 'starship', promptProvider: 'starship', capabilities: ['executable utility', 'prompt engine', 'Prompt provider', 'configurable']}), + tool('oh-my-posh', 'Oh My Posh', 'Shell / Workflow', 'Cross-shell prompt engine; an NMSh Prompt provider rendered directly, with no shell rc change.', 'https://ohmyposh.dev/', + {versionArgs: ['version'], promptProvider: 'ohMyPosh', capabilities: ['executable utility', 'prompt engine', 'Prompt provider', 'Theme Studio import source', 'dotfiles inspect-only']}), + tool('powerlevel10k', 'Powerlevel10k', 'Shell / Workflow', 'Zsh prompt theme; an NMSh Prompt provider rendered in an isolated helper. Its config is Zsh code.', 'https://github.com/romkatv/powerlevel10k', + {...filesystem(detectPowerlevel10kTool), shells: ['zsh'], promptProvider: 'powerlevel10k', capabilities: ['prompt engine', 'Prompt provider', 'filesystem-detected', 'configurable', 'executable config']}), + tool('oh-my-zsh', 'Oh My Zsh', 'Shell / Workflow', 'Zsh framework (not a command). Guided install keeps your .zshrc; its themes and plugins are Zsh code, inspect only.', 'https://ohmyz.sh/', + {...filesystem(detectOhMyZsh), ...ZSH_FRAMEWORK, installAdapter: 'oh-my-zsh', capabilities: [...ZSH_FRAMEWORK.capabilities, 'special installer']}), + tool('prezto', 'Prezto', 'Shell / Workflow', 'Zsh framework, detected only. Inspect only; NMSh does not install or configure it.', 'https://github.com/sorin-ionescu/prezto', + {...filesystem(detectPrezto), ...ZSH_FRAMEWORK}), + tool('zim', 'Zim (zimfw)', 'Shell / Workflow', 'Zsh framework, detected only. Inspect only; NMSh does not install or configure it.', 'https://zimfw.sh/', + {...filesystem(detectZim), ...ZSH_FRAMEWORK}), + tool('zinit', 'zinit', 'Shell / Workflow', 'Zsh plugin manager, detected only. Inspect only; NMSh does not install or configure it.', 'https://github.com/zdharma-continuum/zinit', + {...filesystem(detectZinit), ...ZSH_FRAMEWORK}), + tool('antidote', 'Antidote', 'Shell / Workflow', 'Zsh plugin manager, detected only. Inspect only; NMSh does not install or configure it.', 'https://antidote.sh/', + {...filesystem(detectAntidote), ...ZSH_FRAMEWORK}), tool('shellcheck', 'ShellCheck', 'Shell / Workflow', 'Find common shell script mistakes.', 'https://github.com/koalaman/shellcheck', {package: 'shellcheck', ...ENHANCED, relevantTo: ['shell-scripts']}), tool('shfmt', 'shfmt', 'Shell / Workflow', 'Format shell scripts consistently.', 'https://github.com/mvdan/sh', {package: 'shfmt', ...ENHANCED, relevantTo: ['shell-scripts']}), tool('just', 'just', 'Shell / Workflow', 'A command runner for project-specific tasks.', 'https://github.com/casey/just', {package: 'just', ...ENHANCED, relevantTo: ['project', 'javascript', 'python', 'go', 'rust']}), @@ -89,6 +133,10 @@ export const TOOLS: readonly Tool[] = [ tool('tealdeer', 'TLDR (tealdeer)', 'Shell / Workflow', 'Practical local command examples (command tldr, package tealdeer). Optional; Ask uses its local cache and never updates it.', 'https://github.com/tealdeer-rs/tealdeer', {executable: 'tldr', package: 'tealdeer', recommended: true}), tool('tmux', 'tmux', 'Shell / Workflow', 'Independent terminal multiplexer.', 'https://github.com/tmux/tmux', {versionArgs: ['-V'], configuration: 'tmux'}), + tool('keep-awake', process.platform === 'darwin' ? 'Apple caffeinate' : process.platform === 'win32' ? 'Windows execution-state API' : 'systemd inhibitor', 'System capabilities', + `${process.platform === 'darwin' ? 'Built into macOS' : process.platform === 'win32' ? 'Built into Windows' : 'System capability, detected'}; used by Keep Awake (/caffeinate, /awake, /zoomies). Nothing to install, upgrade or remove here.`, + process.platform === 'darwin' ? 'https://ss64.com/mac/caffeinate.html' : process.platform === 'win32' ? 'https://learn.microsoft.com/windows/win32/api/winbase/nf-winbase-setthreadexecutionstate' : 'https://www.freedesktop.org/software/systemd/man/latest/systemd-inhibit.html', + {...filesystem(() => { const backend = detectKeepAwake(); return backend ? {path: backend.label} : undefined; }), discoveryKind: 'environment'}), tool('docker', 'Docker CLI', 'Containers / Infrastructure', 'Container client; daemon availability is not inferred.', 'https://docs.docker.com/', {package: 'docker', ...ENVIRONMENT, relevantTo: ['containers']}), tool('kubectl', 'kubectl', 'Containers / Infrastructure', 'Kubernetes client; credentials/cluster are not inspected.', 'https://kubernetes.io/docs/reference/kubectl/', {versionArgs: undefined, package: 'kubernetes-cli', ...ENVIRONMENT, relevantTo: ['kubernetes']}), tool('mise', 'mise', 'Project / Language Tooling', 'Optional project tooling; metadata evaluation needs consent.', 'https://mise.jdx.dev/', {versionArgs: undefined, ...ENHANCED}), @@ -97,6 +145,15 @@ export const TOOLS: readonly Tool[] = [ tool('python3', 'Python', 'Project / Language Tooling', 'Python runtime.', 'https://www.python.org/', {language: 'Python', package: 'python', ...ENVIRONMENT, relevantTo: ['python']}), ]; +/** One detection entry point: the tool's registered strategy, never a guess. */ +export function detectTool(tool: Tool, env: NodeJS.ProcessEnv = process.env): Promise { + if (tool.detection?.kind === 'filesystem') { + const fact = tool.detection.detect(env); + return Promise.resolve(fact ? {state: 'installed', detail: fact.source ? `${fact.path} · ${fact.source}` : fact.path} : {state: 'missing'}); + } + return detectProvider(tool, env.PATH ?? ''); +} + /** Legacy tools and tools without a curated package keep no recipe. */ function withRecipe(tool: Tool): Tool { return tool.legacy || !tool.package ? tool : {...tool, recipe: {brew: tool.package}}; @@ -123,7 +180,7 @@ export function toolsInTier(tier: ToolTier): Tool[] { */ export function knownToolForExecutable(word: string, tools: readonly Tool[] = TOOLS): Tool | undefined { if (!/^[A-Za-z0-9][A-Za-z0-9_.+-]*$/u.test(word)) return undefined; - return tools.find(tool => tool.commandNotFound !== false && ((tool.executable ?? tool.id) === word || tool.commandAliases?.includes(word))); + return tools.find(tool => tool.commandNotFound !== false && tool.detection?.kind !== 'filesystem' && ((tool.executable ?? tool.id) === word || tool.commandAliases?.includes(word))); } /** diff --git a/src/tools/config/registry.ts b/src/tools/config/registry.ts index 074eef33..b412cd7d 100644 --- a/src/tools/config/registry.ts +++ b/src/tools/config/registry.ts @@ -1,6 +1,7 @@ import {homedir} from 'node:os'; import {join} from 'node:path'; import {resolveCommand} from '../../providers/providers.js'; +import {detectOhMyZsh, detectPowerlevel10kTool} from '../frameworks.js'; /** * First-party registry of reviewed tool configuration adapters. It is not a @@ -41,6 +42,8 @@ export interface ToolConfigEntry { exactCopy?: (content: string) => {ok: true} | {ok: false; reason: string}; /** Why dotfiles leaves this tool's config inspect-only, in plain words. */ dotfilesNote?: string; + /** Installed fact for tools that are not an executable (registered filesystem detector). */ + present?: (env: NodeJS.ProcessEnv) => boolean; } const xdg = (env: NodeJS.ProcessEnv, home: string) => env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(home, '.config'); @@ -74,6 +77,21 @@ export const TOOL_CONFIG_REGISTRY: readonly ToolConfigEntry[] = [ locations: (_env, home) => [join(home, '.vimrc'), join(home, '.vim', 'vimrc')], takesEffect: 'New Vim processes', summary: 'A generated colorscheme and one reviewed include; Vimscript is never rewritten', dotfiles: /(?:^|\/)(?:\.vimrc|\.vim\/vimrc|vimrc)$/u}, + {id: 'powerlevel10k', label: 'Powerlevel10k', executable: 'zsh', configClass: 'executable', ownership: 'read-only', configurable: false, + present: env => Boolean(detectPowerlevel10kTool(env)), + locations: (env, home) => [env.POWERLEVEL9K_CONFIG_FILE ?? join(home, '.p10k.zsh')], takesEffect: '—', + summary: 'Prompt provider; its own wizard (p10k configure, after backups) owns ~/.p10k.zsh, which is Zsh code', + dotfiles: /(?:^|\/)\.p10k\.zsh$/u}, + {id: 'oh-my-zsh', label: 'Oh My Zsh', executable: 'zsh', configClass: 'executable', ownership: 'read-only', configurable: false, + present: env => Boolean(detectOhMyZsh(env)), + locations: (env, home) => [join(env.ZSH ?? join(home, '.oh-my-zsh'), 'custom')], takesEffect: '—', + summary: 'Inspect only; themes, plugins and custom files are Zsh code, never sourced, copied or rewritten', + dotfiles: /(?:^|\/)(?:\.zshrc\.pre-oh-my-zsh|[^/]+\.zsh-theme|\.oh-my-zsh\/custom\/.+\.zsh|oh-my-zsh\/custom\/.+\.zsh)$/u}, + {id: 'oh-my-posh', label: 'Oh My Posh', executable: 'oh-my-posh', configClass: 'structured', ownership: 'read-only', configurable: false, + locations: (env, _home) => (env.POSH_CONFIG ? [env.POSH_CONFIG] : []), takesEffect: 'The next prompt (as the NMSh Prompt provider)', + summary: 'Prompt provider rendered directly; import its static colors in /theme → Import', + dotfiles: /(?:^|\/)[^/]+\.omp\.(?:json|ya?ml|toml)$/u, + dotfilesNote: 'Inspect only: Oh My Posh segments and templates can run tools; never copied. Import its static colors in /theme → Import'}, {id: 'zsh', label: 'zsh', executable: 'zsh', configClass: 'executable', ownership: 'read-only', configurable: false, locations: (_env, home) => [join(home, '.zshrc')], takesEffect: '—', summary: 'Inspect only; shell code is never sourced or rewritten (NMSh owns its composer)', dotfiles: /(?:^|\/)(?:\.zshrc|\.zshenv|\.zprofile|zshrc)$/u}, @@ -86,7 +104,7 @@ export const TOOL_CONFIG_REGISTRY: readonly ToolConfigEntry[] = [ ]; export function toolConfigEntry(id: string): ToolConfigEntry | undefined { - return TOOL_CONFIG_REGISTRY.find(entry => entry.id === id || entry.executable === id); + return TOOL_CONFIG_REGISTRY.find(entry => entry.id === id) ?? TOOL_CONFIG_REGISTRY.find(entry => entry.executable === id); } export const OWNERSHIP_LABELS: Record = { @@ -96,7 +114,7 @@ export const OWNERSHIP_LABELS: Record = { /** Installed facts for the registry (PATH lookups only; nothing is run). */ export function registryFacts(env: NodeJS.ProcessEnv = process.env): Array { - return TOOL_CONFIG_REGISTRY.map(entry => ({...entry, installed: Boolean(resolveCommand(entry.executable, env.PATH ?? ''))})); + return TOOL_CONFIG_REGISTRY.map(entry => ({...entry, installed: entry.present ? entry.present(env) : Boolean(resolveCommand(entry.executable, env.PATH ?? ''))})); } export const configLocations = (entry: ToolConfigEntry, env: NodeJS.ProcessEnv = process.env) => entry.locations(env, env.HOME || homedir()); diff --git a/src/tools/frameworks.ts b/src/tools/frameworks.ts new file mode 100644 index 00000000..5808eaf9 --- /dev/null +++ b/src/tools/frameworks.ts @@ -0,0 +1,157 @@ +import {lstatSync, readFileSync, statSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {isAbsolute, join, resolve} from 'node:path'; +import {detectPowerlevel10k, powerlevel10kThemeCandidates} from '../prompt/powerlevel10k.js'; + +/** + * Filesystem detection for curated tools that are not executables: Zsh + * frameworks and prompt themes. Each detector is registered explicitly and + * looks only at documented locations for a recognizable structure (a + * directory merely named like the framework is not enough). Nothing is + * sourced, executed or written; a detection grants no install, uninstall, + * configuration or provider authority by itself. + */ + +export interface FilesystemFact { + /** Framework root or theme file that proved the installation. */ + path: string; + /** How it was found, in plain words, only when known (e.g. "$ZSH", "via Oh My Zsh"). */ + source?: string; +} + +const isDirectory = (path: string) => { try { return statSync(path).isDirectory(); } catch { return false; } }; +const isFile = (path: string) => { try { return statSync(path).isFile(); } catch { return false; } }; + +/** A trustworthy absolute directory path from the environment, or undefined. */ +function envDirectory(value: string | undefined, home: string): string | undefined { + const trimmed = value?.trim().replace(/^~(?=\/|$)/u, home); + return trimmed && isAbsolute(trimmed) && !/[\u0000-\u001f\u007f]/u.test(trimmed) ? resolve(trimmed) : undefined; +} + +/** Oh My Zsh's canonical layout: the loader plus its lib and themes directories. */ +export function isOhMyZshRoot(path: string): boolean { + return isFile(join(path, 'oh-my-zsh.sh')) && isDirectory(join(path, 'lib')) && isDirectory(join(path, 'themes')); +} + +export function detectOhMyZsh(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): FilesystemFact | undefined { + const configured = envDirectory(env.ZSH, home); + if (configured && isOhMyZshRoot(configured)) return {path: configured, source: '$ZSH'}; + const fallback = join(home, '.oh-my-zsh'); + return isOhMyZshRoot(fallback) ? {path: fallback} : undefined; +} + +/** Prezto: documented clone location ${ZDOTDIR:-$HOME}/.zprezto with its init.zsh and modules. */ +export function detectPrezto(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): FilesystemFact | undefined { + const root = join(envDirectory(env.ZDOTDIR, home) ?? home, '.zprezto'); + return isFile(join(root, 'init.zsh')) && isDirectory(join(root, 'modules')) ? {path: root} : undefined; +} + +/** Zim: ${ZIM_HOME:-${ZDOTDIR:-$HOME}/.zim} holding the zimfw.zsh manager. */ +export function detectZim(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): FilesystemFact | undefined { + const configured = envDirectory(env.ZIM_HOME, home); + const root = configured ?? join(envDirectory(env.ZDOTDIR, home) ?? home, '.zim'); + return isFile(join(root, 'zimfw.zsh')) ? {path: root, ...(configured ? {source: '$ZIM_HOME'} : {})} : undefined; +} + +/** zinit: documented ${XDG_DATA_HOME:-$HOME/.local/share}/zinit/zinit.git/zinit.zsh, or $ZINIT_HOME. */ +export function detectZinit(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): FilesystemFact | undefined { + const configured = envDirectory(env.ZINIT_HOME, home); + const root = configured ?? join(envDirectory(env.XDG_DATA_HOME, home) ?? join(home, '.local', 'share'), 'zinit', 'zinit.git'); + return isFile(join(root, 'zinit.zsh')) ? {path: root, ...(configured ? {source: '$ZINIT_HOME'} : {})} : undefined; +} + +/** Antidote: documented clone ${ZDOTDIR:-$HOME}/.antidote, or the Homebrew formula's share directory. */ +export function detectAntidote(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): FilesystemFact | undefined { + const clone = join(envDirectory(env.ZDOTDIR, home) ?? home, '.antidote'); + if (isFile(join(clone, 'antidote.zsh'))) return {path: clone}; + for (const prefix of [envDirectory(env.HOMEBREW_PREFIX, home), '/opt/homebrew', '/usr/local'].filter((value): value is string => Boolean(value))) { + const shared = join(prefix, 'share', 'antidote'); + if (isFile(join(shared, 'antidote.zsh'))) return {path: shared, source: 'Homebrew'}; + } + return undefined; +} + +/** Powerlevel10k through its one existing detector; the source is named only when the found path says it. */ +export function detectPowerlevel10kTool(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): FilesystemFact | undefined { + const status = detectPowerlevel10k(env, home, powerlevel10kThemeCandidates(env, home)); + if (!status.installed || !status.themePath) return undefined; + const path = status.themePath; + const source = /\/custom\/themes\/powerlevel10k\//u.test(path) ? 'via Oh My Zsh' + : /\/share\/powerlevel10k\//u.test(path) && /^(?:\/opt\/homebrew|\/usr\/local)\//u.test(path) ? 'Homebrew' + : /zinit\/plugins\//u.test(path) ? 'via zinit' + : path === join(home, 'powerlevel10k', 'powerlevel10k.zsh-theme') ? 'standalone clone' + : path.startsWith('/usr/share/') ? 'system package' : undefined; + return {path, ...(source ? {source} : {})}; +} + +// ---- Oh My Zsh: guided install and previous-zshrc recovery -------------------------- + +/** + * Curated metadata for a tool whose upstream installer has config side + * effects. NMSh does not run such installers (it never runs install + * scripts); it explains, snapshots, hands the terminal to the person, and + * verifies afterwards. First-party only. + */ +export interface InstallAdapter { + id: string; + /** Exact official source shown to the person. */ + source: string; + prerequisites: readonly string[]; + /** Files the upstream installer may replace or rename by default. */ + filesAtRisk: (env: NodeJS.ProcessEnv) => string[]; + /** Files fingerprinted (and backed up) before the handoff. */ + snapshotTargets: (env: NodeJS.ProcessEnv) => string[]; + /** Fixed, documented settings that disable the side effects; shown verbatim. */ + safeEnvironment: Readonly>; + safeArgs: readonly string[]; + /** The person runs it in a terminal; NMSh never executes it. */ + handoff: 'manual-terminal'; + steps: (env: NodeJS.ProcessEnv) => string[]; + verify: (env: NodeJS.ProcessEnv) => FilesystemFact | undefined; + recoveryHints: readonly string[]; +} + +export const zdotdir = (env: NodeJS.ProcessEnv = process.env) => envDirectory(env.ZDOTDIR, env.HOME || homedir()) ?? (env.HOME || homedir()); + +export const OH_MY_ZSH_INSTALLER_URL = 'https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh'; + +const quote = (value: string) => `'${value.replace(/'/gu, `'\\''`)}'`; + +/** Checked against the current official tools/install.sh (KEEP_ZSHRC, CHSH, RUNZSH, REPO/REMOTE/BRANCH, --unattended, --keep-zshrc). */ +export const OH_MY_ZSH_INSTALL: InstallAdapter = { + id: 'oh-my-zsh', + source: OH_MY_ZSH_INSTALLER_URL, + prerequisites: ['zsh', 'git', 'curl or wget'], + filesAtRisk: env => [join(zdotdir(env), '.zshrc'), join(zdotdir(env), '.zshrc.pre-oh-my-zsh'), join(zdotdir(env), '.shell.pre-oh-my-zsh')], + snapshotTargets: env => [join(zdotdir(env), '.zshrc')], + safeEnvironment: {KEEP_ZSHRC: 'yes', CHSH: 'no', RUNZSH: 'no', REPO: 'ohmyzsh/ohmyzsh', REMOTE: 'https://github.com/ohmyzsh/ohmyzsh.git', BRANCH: 'master'}, + safeArgs: ['--unattended', '--keep-zshrc'], + handoff: 'manual-terminal', + steps: env => { + const file = join(env.TMPDIR && isAbsolute(env.TMPDIR) ? env.TMPDIR : '/tmp', 'ohmyzsh-install.sh'); + const settings = Object.entries(OH_MY_ZSH_INSTALL.safeEnvironment).map(([name, value]) => `${name}=${value}`).join(' '); + return [`curl -fsSL -o ${quote(file)} ${OH_MY_ZSH_INSTALLER_URL}`, `less ${quote(file)}`, `shasum -a 256 ${quote(file)}`, + `${settings} sh ${quote(file)} ${OH_MY_ZSH_INSTALL.safeArgs.join(' ')}`]; + }, + verify: env => detectOhMyZsh(env), + recoveryHints: ['KEEP_ZSHRC=yes leaves your .zshrc in place, so Oh My Zsh is not loaded until you add its lines yourself (see its templates/zshrc.zsh-template).', + 'If .zshrc changed anyway, NMSh shows the change and keeps the backup it made; it never restores silently.'], +}; + +export interface FileFacts {path: string; exists: boolean; bytes?: number; modified?: Date; sha256?: string; symlink?: boolean} + +export function fileFacts(path: string, hash: (content: Buffer) => string): FileFacts { + try { + const link = lstatSync(path); + const info = statSync(path); + if (!info.isFile()) return {path, exists: false}; + return {path, exists: true, bytes: info.size, modified: info.mtime, sha256: hash(readFileSync(path)), symlink: link.isSymbolicLink()}; + } catch { return {path, exists: false}; } +} + +/** The current .zshrc and the installer's .zshrc.pre-oh-my-zsh, when the latter exists. */ +export function previousZshrc(env: NodeJS.ProcessEnv = process.env): {current: string; previous: string} | undefined { + const dir = zdotdir(env); + const previous = join(dir, '.zshrc.pre-oh-my-zsh'); + return isFile(previous) ? {current: join(dir, '.zshrc'), previous} : undefined; +} diff --git a/src/ui/CommandPalette.ts b/src/ui/CommandPalette.ts index 2a2bec36..746effcd 100644 --- a/src/ui/CommandPalette.ts +++ b/src/ui/CommandPalette.ts @@ -64,6 +64,10 @@ export function paletteItems(): PaletteItem[] { action: {kind: 'cycleOutputFolding'}}, {id: 'transcript:details', label: 'Expand or collapse output', detail: 'Same as Ctrl+O on the latest block', category: 'Transcript', action: {kind: 'toggleDetails'}}, + {id: 'awake:idle', label: 'Keep computer awake', detail: '/caffeinate idle · Keep Awake (awake, zoomies): prevent idle sleep', category: 'Command', action: {kind: 'slash', command: '/caffeinate idle'}}, + {id: 'awake:display', label: 'Keep display awake', detail: '/caffeinate display · Keep Awake: display and machine stay awake (sleep)', category: 'Command', action: {kind: 'slash', command: '/caffeinate display'}}, + {id: 'awake:status', label: 'Keep-awake status', detail: '/caffeinate status · caffeinate, awake, zoomies', category: 'Command', action: {kind: 'slash', command: '/caffeinate status'}}, + {id: 'awake:stop', label: 'Stop keep-awake', detail: '/caffeinate stop · normal sleep returns', category: 'Command', action: {kind: 'slash', command: '/caffeinate stop'}}, {id: 'transcript:latest', label: 'Jump to latest output', detail: 'Same as Ctrl+End', category: 'Transcript', action: {kind: 'latest'}}, ); for (const palette of NATIVE_PALETTE_IDS) { diff --git a/src/update/update.ts b/src/update/update.ts index 025cdd4c..e7bb3db9 100644 --- a/src/update/update.ts +++ b/src/update/update.ts @@ -142,10 +142,41 @@ export function installRoot(moduleUrl = import.meta.url): string { export type InstallInfo = | {kind: 'checkout'; root: string; branch?: string; head: string} + /** Installed with Homebrew: the keg owns these files; updates go through `brew upgrade nmsh`. */ + | {kind: 'homebrew'; root: string; version: string; prefix: string; tap?: string} | {kind: 'unsupported'; root: string; reason: string}; +/** + * A Homebrew keg, from Homebrew's own metadata: NMSh's libexec inside + * /Cellar/nmsh// next to the INSTALL_RECEIPT.json Homebrew + * writes for every installed keg (it names the tap). No brew process is run. + */ +export function detectHomebrewInstall(root: string): Extract | undefined { + let real: string; + try { real = realpathSync(root); } catch { return undefined; } + const match = /^(.+)\/Cellar\/nmsh\/([^/]+)\/libexec$/u.exec(real); + if (!match) return undefined; + const [, prefix, version] = match; + try { + const receipt = JSON.parse(readFileSync(join(prefix!, 'Cellar', 'nmsh', version!, 'INSTALL_RECEIPT.json'), 'utf8')) as {source?: {tap?: unknown}}; + const tap = typeof receipt.source?.tap === 'string' ? receipt.source.tap : undefined; + return {kind: 'homebrew', root: real, version: version!, prefix: prefix!, ...(tap ? {tap} : {})}; + } catch { return undefined; } +} + +/** One factual provenance line for /version, /status and diagnostics (no network, no brew process). */ +export function installProvenanceLabel(root: string = installRoot()): string { + const homebrew = detectHomebrewInstall(root); + if (homebrew) return `Homebrew${homebrew.tap ? ` (${homebrew.tap})` : ''} · update with brew upgrade nmsh`; + return existsSync(join(root, '.git')) ? `source checkout · ${root}` : `other/manual installation · ${root}`; +} + +export const HOMEBREW_UPDATE_STEPS = ['brew update', 'brew upgrade nmsh'] as const; + /** Provenance from facts only: an official, clean git checkout, or unsupported with the reason. */ export async function detectInstall(root: string, runner: CommandRunner = systemRunner): Promise { + const homebrew = detectHomebrewInstall(root); + if (homebrew) return homebrew; if (!existsSync(join(root, '.git'))) { return {kind: 'unsupported', root, reason: `${root} is not a git checkout, so NMSh cannot tell how it was installed.`}; } @@ -187,6 +218,9 @@ export function manualSteps(root: string, tag: string): string[] { export async function planUpdate(install: InstallInfo, release: ReleaseInfo, runner: CommandRunner = systemRunner, tagCommit: (tag: string) => Promise = tag => fetchTagCommit(tag)): Promise { const manual = manualSteps(install.root, release.tag); + if (install.kind === 'homebrew') { + return {ok: false, reason: `Installed with Homebrew${install.tap ? ` (${install.tap})` : ''}; Homebrew owns these files, so NMSh does not change them.`, manual: [...HOMEBREW_UPDATE_STEPS]}; + } if (install.kind !== 'checkout') return {ok: false, reason: install.reason, manual: [`Download ${release.url}`, 'and reinstall it the way you installed NMSh.']}; const {root} = install; const dirty = await runner.run('git', ['status', '--porcelain', '--untracked-files=no'], root); diff --git a/tests/autocomplete.test.ts b/tests/autocomplete.test.ts index cb1bfba6..8560751b 100644 --- a/tests/autocomplete.test.ts +++ b/tests/autocomplete.test.ts @@ -7,7 +7,7 @@ test('slash autocomplete exposes copy variants and help', () => { assert.deepEqual(slashSuggestions('/co').map(item => item.name), ['/copy', '/copy N', '/config', '/composer', '/configure']); assert.deepEqual(slashSuggestions('/h').map(item => item.name), ['/help', '/history', '/history-provider']); assert.deepEqual(parseSlashCommand('/history-provider'), {kind: 'providers', family: 'history'}, '/history stays history search'); - assert.deepEqual(slashSuggestions('/z').map(item => item.name), ['/zsh']); + assert.deepEqual(slashSuggestions('/z').map(item => item.name), ['/zoomies', '/zsh']); assert.deepEqual(parseSlashCommand('/clear'), {kind: 'clear'}); assert.deepEqual(parseSlashCommand('/resume'), {kind: 'resume'}); }); diff --git a/tests/homebrewInstall.test.ts b/tests/homebrewInstall.test.ts new file mode 100644 index 00000000..79b9b3dd --- /dev/null +++ b/tests/homebrewInstall.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {detectHomebrewInstall, detectInstall, installProvenanceLabel, planUpdate, type CommandRunner} from '../src/update/update.js'; + +const release = {tag: 'v0.18.0', version: '0.18.0', url: 'https://github.com/raiseCatError/notMyShell/releases/tag/v0.18.0', summary: []}; +const noGit: CommandRunner = {run: async () => { throw new Error('git must not run for a Homebrew install'); }}; + +test('Homebrew-installed NMSh: detected from the keg and its INSTALL_RECEIPT; /update never touches the Cellar', async () => { + const prefix = mkdtempSync(join(tmpdir(), 'nmsh-brew-')); + try { + const keg = join(prefix, 'Cellar', 'nmsh', '0.16.0'); + const libexec = join(keg, 'libexec'); + mkdirSync(libexec, {recursive: true}); + // Without Homebrew's receipt, a look-alike path is not claimed as Homebrew. + assert.equal(detectHomebrewInstall(libexec), undefined); + writeFileSync(join(keg, 'INSTALL_RECEIPT.json'), JSON.stringify({source: {tap: 'raisecaterror/tap'}})); + mkdirSync(join(prefix, 'opt')); + symlinkSync(keg, join(prefix, 'opt', 'nmsh')); + const viaOpt = join(prefix, 'opt', 'nmsh', 'libexec'); + const install = await detectInstall(viaOpt, noGit); + assert.equal(install.kind, 'homebrew'); + assert.deepEqual(install.kind === 'homebrew' && [install.version, install.tap], ['0.16.0', 'raisecaterror/tap']); + const planned = await planUpdate(install, release, noGit, async () => { throw new Error('no network'); }); + assert.equal(planned.ok, false); + assert.deepEqual(!planned.ok && planned.manual, ['brew update', 'brew upgrade nmsh']); + assert.match(!planned.ok ? planned.reason : '', /Installed with Homebrew \(raisecaterror\/tap\); Homebrew owns these files/u); + assert.equal(installProvenanceLabel(viaOpt), 'Homebrew (raisecaterror/tap) · update with brew upgrade nmsh'); + assert.match(installProvenanceLabel(prefix), /^other\/manual installation/u); + } finally { rmSync(prefix, {recursive: true, force: true}); } +}); diff --git a/tests/keepAwake.test.ts b/tests/keepAwake.test.ts new file mode 100644 index 00000000..c28f4c1d --- /dev/null +++ b/tests/keepAwake.test.ts @@ -0,0 +1,243 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import { + CAFFEINATE, detectBackend, ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, executionStateFlags, KeepAwakeController, linuxBackend, macBackend, + parseDuration, systemProbe, windowsBackend, windowsHelperScript, type LaunchPlan, type ProcessProbe, +} from '../src/keepAwake/keepAwake.js'; +import {createKeepAwakePanel, keepAwakeKey, renderKeepAwakePanel} from '../src/keepAwake/KeepAwakePanel.js'; +import {parseSlashCommand, slashCommands} from '../src/commands/slashCommands.js'; +import {paletteItems} from '../src/ui/CommandPalette.js'; +import {helpMarkdown} from '../src/help/helpContent.js'; +import {resolveConfigRequest} from '../src/ask/configActions.js'; +import {knownToolForExecutable, TOOLS} from '../src/tools/catalog.js'; +import {stripAnsi} from '../src/util/text.js'; + +/** A fake process table: starts get fresh PIDs; command lines are exactly the launch. */ +function fakeProbe(): ProcessProbe & {processes: Map; started: LaunchPlan[]; signals: Array<[number, string]>; clock: number; failNext?: boolean} { + const probe = { + processes: new Map(), started: [] as LaunchPlan[], signals: [] as Array<[number, string]>, clock: 1_000_000, failNext: false, next: 4000, + start(plan: LaunchPlan) { + probe.started.push(plan); + if (probe.failNext) { probe.failNext = false; return undefined; } + const pid = probe.next++; + probe.processes.set(pid, [plan.command, ...plan.args].join(' ')); + return pid; + }, + alive: (pid: number) => probe.processes.has(pid), + commandLine: (pid: number) => probe.processes.get(pid), + signal(pid: number, signal: string) { probe.signals.push([pid, signal]); if (signal === 'SIGTERM') probe.processes.delete(pid); }, + now: () => probe.clock, + }; + return probe; +} +const box = () => { const root = mkdtempSync(join(tmpdir(), 'nmsh-awake-')); return {path: join(root, 'keep-awake.json'), done: () => rmSync(root, {recursive: true, force: true})}; }; + +test('aliases: /caffeinate, /awake and /zoomies parse to one canonical action', () => { + for (const name of ['/caffeinate', '/awake', '/zoomies']) { + assert.deepEqual(parseSlashCommand(name), {kind: 'keepAwake', op: 'panel'}); + assert.deepEqual(parseSlashCommand(`${name} display`), {kind: 'keepAwake', op: 'start', mode: 'display'}); + assert.deepEqual(parseSlashCommand(`${name} idle 30m`), {kind: 'keepAwake', op: 'start', mode: 'idle', timeoutSeconds: 1800}); + assert.deepEqual(parseSlashCommand(`${name} system 2h`), {kind: 'keepAwake', op: 'start', mode: 'system', timeoutSeconds: 7200}); + assert.deepEqual(parseSlashCommand(`${name} all 45s`), {kind: 'keepAwake', op: 'start', mode: 'all', timeoutSeconds: 45}); + assert.deepEqual(parseSlashCommand(`${name} status`), {kind: 'keepAwake', op: 'status'}); + assert.deepEqual(parseSlashCommand(`${name} stop`), {kind: 'keepAwake', op: 'stop'}); + } + assert.deepEqual(parseSlashCommand('/caffeinate-stop'), {kind: 'keepAwake', op: 'stop'}); + for (const bad of ['/zoomies display 1d', '/zoomies display 30', '/zoomies display $(id)', '/zoomies display 9999999h', '/zoomies turbo']) { + const parsed = parseSlashCommand(bad); + assert.equal(parsed?.kind === 'keepAwake' && parsed.op, 'panel', bad); + assert.ok(parsed?.kind === 'keepAwake' && parsed.invalid, bad); + } + assert.equal(parseDuration('30m'), 1800); + assert.equal(parseDuration('0m'), 'invalid'); + assert.equal(parseDuration('1.5h'), 'invalid'); +}); + +test('discoverability: one palette entry per surface plus human actions; help and Ask', () => { + const awake = slashCommands.filter(command => ['/caffeinate', '/awake', '/zoomies'].includes(command.name)); + assert.equal(awake.length, 3); + assert.deepEqual(awake.filter(command => command.alias).map(command => command.name), ['/awake', '/zoomies']); + const labels = paletteItems().map(item => item.label); + for (const label of ['Keep computer awake', 'Keep display awake', 'Keep-awake status', 'Stop keep-awake']) assert.ok(labels.includes(label), label); + for (const word of ['caffeinate', 'awake', 'zoomies', 'sleep', 'display']) { + assert.ok(paletteItems().some(item => `${item.label} ${item.detail}`.toLowerCase().includes(word)), word); + } + assert.match(helpMarkdown(), /Keep Awake \(\/caffeinate, also \/awake and \/zoomies\)/u); + const ask = (text: string) => resolveConfigRequest(text, {}) as {safety?: string; action?: {slash?: unknown; label?: string}} | undefined; + assert.deepEqual([ask('keep my computer awake')?.action?.label, ask('keep my computer awake')?.safety], ['/caffeinate idle', 'mutate']); + assert.equal(ask('keep my screen awake')?.action?.label, '/caffeinate display'); + assert.equal(ask("don't let my computer sleep")?.action?.label, '/caffeinate system'); + assert.deepEqual([ask('give my computer zoomies')?.action?.label, ask('give my computer zoomies')?.safety], ['/zoomies', 'navigate'], 'a joke only opens the panel'); + assert.deepEqual([ask('stop keeping my computer awake')?.action?.label, ask('stop keeping my computer awake')?.safety], ['/caffeinate stop', 'mutate']); + assert.deepEqual([ask('is caffeinate running')?.action?.label, ask('is caffeinate running')?.safety], ['/caffeinate status', 'navigate']); + assert.equal(knownToolForExecutable('caffeinate'), undefined, 'no command-not-found install for caffeinate'); + const capability = TOOLS.find(tool => tool.id === 'keep-awake')!; + assert.deepEqual([capability.category, capability.package, capability.commandNotFound], ['System capabilities', '', false]); +}); + +test('macOS: fixed /usr/bin/caffeinate and exact argv per mode', () => { + const mac = macBackend(); + assert.deepEqual(mac.plan('idle', 't'), {command: CAFFEINATE, args: ['-i']}); + assert.deepEqual(mac.plan('display', 't'), {command: CAFFEINATE, args: ['-d', '-i']}); + assert.deepEqual(mac.plan('system', 't'), {command: CAFFEINATE, args: ['-s']}); + assert.deepEqual(mac.plan('all', 't'), {command: CAFFEINATE, args: ['-d', '-i', '-s']}); + assert.deepEqual(mac.plan('display', 't', 1800)?.args, ['-d', '-i', '-t', '1800']); + assert.equal(CAFFEINATE, '/usr/bin/caffeinate'); + assert.match(mac.notes.join(' '), /AC power/u); + assert.equal(detectBackend('darwin', path => path === '/usr/bin/caffeinate')?.id, 'macos-caffeinate'); + assert.equal(detectBackend('darwin', () => false), undefined); +}); + +test('Linux: systemd-inhibit with idle/sleep only, an NMSh wait helper, Display unsupported, no systemd → nothing guessed', () => { + assert.equal(detectBackend('linux', () => false), undefined); + const linux = detectBackend('linux', path => path === '/usr/bin/systemd-inhibit')!; + assert.equal(linux.id, 'linux-systemd-inhibit'); + assert.deepEqual(linux.capabilities, {idle: true, display: false, system: true}); + const what = (mode: 'idle' | 'system' | 'all') => linux.plan(mode, 'abc', 60)!.args.find(arg => arg.startsWith('--what=')); + assert.equal(what('idle'), '--what=idle'); + assert.equal(what('system'), '--what=sleep'); + assert.equal(what('all'), '--what=idle:sleep'); + assert.equal(linux.plan('display', 'abc'), undefined); + const plan = linuxBackend('/usr/bin/systemd-inhibit', '/node').plan('all', 'abc', 60)!; + assert.deepEqual(plan.args.slice(0, 4), ['--what=idle:sleep', '--mode=block', '--who=notMyShell', '--why=Keep-awake requested by NMSh']); + assert.deepEqual(plan.args.slice(4, 6), ['/node', '-e']); + assert.deepEqual(plan.args.slice(-3), ['--', '60', 'nmsh-keep-awake=abc']); + assert.doesNotMatch(plan.args.join(' '), /shutdown|handle-lid-switch|handle-power-key|handle-suspend-key/u); + const controller = new KeepAwakeController(linux, fakeProbe(), box().path, 'linux'); + assert.deepEqual(controller.start('display'), {kind: 'unsupported', reason: 'Display is not supported by the systemd inhibitor. Display: not supported by the systemd inhibitor (it is not a display API); use Idle or System.'}); + assert.match(new KeepAwakeController(undefined, fakeProbe(), box().path, 'linux').start('idle').kind === 'unsupported' + ? (new KeepAwakeController(undefined, fakeProbe(), box().path, 'linux').start('idle') as {reason: string}).reason : '', /No supported Linux inhibitor was detected/u); +}); + +test('Windows: SetThreadExecutionState flags; never away mode; fixed script with only validated values', () => { + const AWAYMODE = 0x40; + assert.equal(executionStateFlags('idle'), (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) >>> 0); + assert.equal(executionStateFlags('system'), (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) >>> 0); + assert.equal(executionStateFlags('display'), (ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED) >>> 0); + assert.equal(executionStateFlags('all'), executionStateFlags('display')); + for (const mode of ['idle', 'display', 'system', 'all'] as const) assert.equal(executionStateFlags(mode) & AWAYMODE, 0); + const token = 'a'.repeat(32); + const script = windowsHelperScript(executionStateFlags('display'), token, 600); + assert.match(script, /SetThreadExecutionState\(\[uint32\]2147483651\)/u); + assert.match(script, /AddSeconds\(600\)/u); + assert.match(script, /finally \{ \[void\]\[NMSh\.Power\]::SetThreadExecutionState\(\[uint32\]2147483648\) \}/u, 'cleared on exit'); + assert.doesNotMatch(script, /powercfg|Set-ItemProperty|HKLM|HKCU|RunAs/iu); + assert.throws(() => windowsHelperScript(1, '"; Remove-Item C:\\ #', 1)); + assert.throws(() => windowsHelperScript(1, token, 0)); + const plan = windowsBackend('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe').plan('system', token)!; + assert.deepEqual(plan.args.slice(0, 6), ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand']); + assert.equal(Buffer.from(plan.args[6]!, 'base64').toString('utf16le'), windowsHelperScript(executionStateFlags('system'), token)); + assert.equal(detectBackend('win32', path => path.endsWith('powershell.exe'), {SystemRoot: 'C:\\Windows'})?.id, 'windows-execution-state'); +}); + +test('controller: one assertion, same mode idempotent, a different mode asks then replaces start-first, stop idempotent', () => { + const file = box(); + try { + const probe = fakeProbe(); + const controller = new KeepAwakeController(macBackend(), probe, file.path, 'darwin'); + assert.equal(controller.status().state, 'off'); + const first = controller.start('idle'); + assert.equal(first.kind, 'started'); + const pid = first.kind === 'started' ? first.record.pid : 0; + assert.equal(controller.start('idle').kind, 'already'); + assert.equal(probe.started.length, 1, 'no duplicate assertion'); + assert.deepEqual(controller.start('display'), {kind: 'needsConfirm', from: 'idle', to: 'display'}); + assert.equal(probe.started.length, 1, 'nothing changes before confirmation'); + const replaced = controller.start('display', undefined, true); + assert.equal(replaced.kind === 'started' && replaced.replaced, 'idle'); + assert.ok(probe.signals.some(([signalled, signal]) => signalled === pid && signal === 'SIGTERM'), 'old one stopped gracefully after the new one started'); + assert.equal(probe.processes.size, 1); + // A failed replacement does not claim the old one stopped. + probe.failNext = true; + const failed = controller.start('all', undefined, true); + assert.match(failed.kind === 'failed' ? failed.reason : '', /did not start\. The Display keep-awake is still running\./u); + assert.equal(controller.status().state, 'running'); + assert.match(controller.stop(), /Keep Awake stopped \(Display\)/u); + assert.equal(controller.stop(), 'Keep Awake is off.', 'stop is idempotent'); + assert.equal(existsSync(file.path), false); + } finally { file.done(); } +}); + +test('ownership: a reused PID or a foreign command line is never killed; the record is cleared as stale; timeout expiry is reported', () => { + const file = box(); + try { + const probe = fakeProbe(); + const controller = new KeepAwakeController(macBackend(), probe, file.path, 'darwin'); + const started = controller.start('idle', 60); + assert.equal(started.kind, 'started'); + const pid = started.kind === 'started' ? started.record.pid : 0; + // The PID now belongs to something else. + probe.processes.set(pid, '/usr/bin/vim notes.txt'); + assert.match(controller.stop(), /could not be verified as NMSh's, so nothing was stopped/u); + assert.deepEqual(probe.signals, []); + assert.equal(existsSync(file.path), false); + // Persisted across restarts: a new controller verifies before saying Running. + const again = controller.start('idle', 60); + const restarted = new KeepAwakeController(macBackend(), probe, file.path, 'darwin'); + assert.equal(restarted.status().state, 'running'); + // Timeout: the backend process ended on its own. + probe.processes.delete(again.kind === 'started' ? again.record.pid : 0); + probe.clock += 61_000; + const status = restarted.status(); + assert.equal(status.state, 'off'); + assert.match(status.state === 'off' ? status.note ?? '' : '', /ended after its 1m timeout/u); + // A corrupt record is ignored. + writeFileSync(file.path, '{"version":1,"pid":"1; kill"}'); + assert.equal(restarted.status().state, 'off'); + } finally { file.done(); } +}); + +test('Linux ownership needs the token in the command line', () => { + const file = box(); + try { + const probe = fakeProbe(); + const controller = new KeepAwakeController(linuxBackend('/usr/bin/systemd-inhibit', '/node'), probe, file.path, 'linux'); + const started = controller.start('system'); + assert.equal(started.kind, 'started'); + const record = JSON.parse(readFileSync(file.path, 'utf8')); + assert.match(probe.commandLine(record.pid)!, new RegExp(`nmsh-keep-awake=${record.token}`, 'u')); + probe.processes.set(record.pid, '/usr/bin/systemd-inhibit --what=sleep sleep 99'); + assert.equal(controller.status().state, 'off', 'same PID without our token is not ours'); + assert.deepEqual(probe.signals, []); + } finally { file.done(); } +}); + +test('panel: same surface for every alias; unsupported modes stay visible and factual; mode change defaults to No', () => { + const file = box(); + try { + const probe = fakeProbe(); + const controller = new KeepAwakeController(linuxBackend('/usr/bin/systemd-inhibit', '/node'), probe, file.path, 'linux'); + const panel = createKeepAwakePanel(controller); + let text = renderKeepAwakePanel(panel, controller, 120, 40).map(stripAnsi).join('\n'); + assert.match(text, /Display\s+Unavailable on the systemd inhibitor/u); + assert.match(text, /Current\s+Off/u); + assert.match(text, /Backend\s+systemd inhibitor/u); + keepAwakeKey(panel, controller, {kind: 'enter'}); + assert.equal(controller.status().state, 'running'); + keepAwakeKey(panel, controller, {kind: 'down'}); + keepAwakeKey(panel, controller, {kind: 'down'}); + keepAwakeKey(panel, controller, {kind: 'enter'}); + assert.equal(panel.confirm?.state.choice, 'no'); + keepAwakeKey(panel, controller, {kind: 'enter'}); + assert.match(panel.message!, /Kept Idle\. Nothing was changed\./u); + text = renderKeepAwakePanel(panel, controller, 120, 40).map(stripAnsi).join('\n'); + assert.match(text, /Idle .*● running/u); + keepAwakeKey(panel, controller, {kind: 'text', value: 's'}); + assert.equal(controller.status().state, 'off'); + } finally { file.done(); } +}); + +test('real macOS caffeinate: start, verify, stop (bounded by a 30s timeout)', {skip: process.platform !== 'darwin' || !existsSync(CAFFEINATE) ? 'macOS only' : false}, () => { + const file = box(); + const controller = new KeepAwakeController(macBackend(), systemProbe, file.path, 'darwin'); + try { + const started = controller.start('idle', 30); + assert.equal(started.kind, 'started', JSON.stringify(started)); + assert.equal(controller.status().state, 'running'); + assert.match(controller.stop(), /stopped/u); + assert.equal(controller.status().state, 'off'); + } finally { controller.stop(); file.done(); } +}); diff --git a/tests/powerlevel10k.test.ts b/tests/powerlevel10k.test.ts index c41711d0..3becd3c3 100644 --- a/tests/powerlevel10k.test.ts +++ b/tests/powerlevel10k.test.ts @@ -113,16 +113,17 @@ test('provider config round-trips and switching keeps inactive provider settings assert.equal(describePromptConfiguration(config), 'Powerlevel10k · two-line divider'); }); -test('/prompt offers four providers (None is composer only) and an honest Powerlevel10k step', () => { +test('/prompt offers five providers (None is composer only) and an honest Powerlevel10k step', () => { const state: PromptPanelState = {onboarding: false, step: 'provider', selectedIndex: 0, draft: structuredClone(DEFAULT_PROMPT_CONFIGURATION), saved: structuredClone(DEFAULT_PROMPT_CONFIGURATION)}; - assert.deepEqual(PROVIDER_ORDER, ['nmsh', 'starship', 'powerlevel10k', 'none']); + assert.deepEqual(PROVIDER_ORDER, ['nmsh', 'starship', 'powerlevel10k', 'ohMyPosh', 'none']); let rows = renderPromptPanel(state, 140, []).map(stripAnsi); assert.ok(rows.some(row => row.includes('NMSh Native · built-in themes, geometry, and modules ● ✓ saved'))); assert.ok(rows.some(row => row.includes('Powerlevel10k · use your ~/.p10k.zsh left prompt'))); assert.ok(rows.some(row => row.includes('None · composer only: no prompt row, modules or marker'))); handlePromptPanelKey({kind: 'up'} as Key, state); - assert.equal(state.selectedIndex, 3, 'wraps to None, the last provider'); + assert.equal(state.selectedIndex, 4, 'wraps to None, the last provider'); + assert.ok(rows.some(row => row.includes('Oh My Posh · render with oh-my-posh; no shell rc change'))); const installed = {...state, step: 'powerlevel10k' as const, selectedIndex: 0, p10kStatus: {installed: true, themePath: '/t/powerlevel10k.zsh-theme', configPath: '/h/.p10k.zsh', configExists: true}}; diff --git a/tests/providers.test.ts b/tests/providers.test.ts index d3f37bed..9c7f2f99 100644 --- a/tests/providers.test.ts +++ b/tests/providers.test.ts @@ -91,7 +91,7 @@ test('shared panel shows status badges, preview, and asks before installing', () }); test('prompt providers run on the shared descriptor with unchanged labels and rows', () => { - assert.deepEqual(PROMPT_PROVIDERS.map(provider => providerLabel(provider.id)), ['NMSh Native', 'Starship', 'Powerlevel10k', 'None']); + assert.deepEqual(PROMPT_PROVIDERS.map(provider => providerLabel(provider.id)), ['NMSh Native', 'Starship', 'Powerlevel10k', 'Oh My Posh', 'None']); assert.equal(providerRowText(PROMPT_PROVIDERS[1]!, {draft: 'starship', saved: 'nmsh', status: 'none'}), 'Starship · use its themes/configuration ●'); }); diff --git a/tests/shellFrameworks.test.ts b/tests/shellFrameworks.test.ts new file mode 100644 index 00000000..69117ca2 --- /dev/null +++ b/tests/shellFrameworks.test.ts @@ -0,0 +1,366 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {detectTool, knownToolForExecutable, suggestibleToolFor, toolInstall, TOOLS} from '../src/tools/catalog.js'; +import {detectOhMyZsh, detectPowerlevel10kTool, detectPrezto, detectZim, detectZinit, detectAntidote, OH_MY_ZSH_INSTALL, previousZshrc} from '../src/tools/frameworks.js'; +import {ohMyZshKey, openGuidedInstall, openPrevious, renderOhMyZshView, snapshotZshrc, verifyInstall, type OhMyZshView} from '../src/tools/OhMyZshView.js'; +import {createToolsPanel, renderTools, toolBadges, toolsKey, toolStatusLine} from '../src/tools/ToolsPanel.js'; +import {detectPowerlevel10k, powerlevel10kThemeCandidates} from '../src/prompt/powerlevel10k.js'; +import {detectOhMyPosh, ohMyPoshArgs, OH_MY_POSH_OUTPUT_LIMIT, renderOhMyPoshPrompt} from '../src/prompt/ohMyPosh.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {selectProvider} from '../src/providers/families.js'; +import {scanDotfiles, type ScanResult} from '../src/dotfiles/scan.js'; +import {applyPlan, buildPlan} from '../src/dotfiles/plan.js'; +import {readThemeImport} from '../src/appearance/ThemeStudio.js'; +import {resolveConfigRequest} from '../src/ask/configActions.js'; +import {stripAnsi} from '../src/util/text.js'; + +const sandbox = () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-frameworks-')); + const home = join(root, 'home'); + mkdirSync(home); + const put = (path: string, text = '') => { mkdirSync(dirname(path), {recursive: true}); writeFileSync(path, text); return path; }; + return {root, home, put, env: {HOME: home, PATH: '/usr/bin:/bin'} as NodeJS.ProcessEnv, done: () => rmSync(root, {recursive: true, force: true})}; +}; +const omzTree = (put: (path: string, text?: string) => string, root: string) => { + put(join(root, 'oh-my-zsh.sh'), '# loader\n'); + put(join(root, 'lib', 'git.zsh')); + put(join(root, 'themes', 'robbyrussell.zsh-theme')); +}; +const tool = (id: string) => TOOLS.find(item => item.id === id)!; +const enter = {kind: 'enter'} as const; + +test('Oh My Zsh: default and $ZSH roots need the real layout; a same-named directory is not enough; never a command', async () => { + const box = sandbox(); + try { + mkdirSync(join(box.home, '.oh-my-zsh')); + assert.equal(detectOhMyZsh(box.env), undefined, 'an empty ~/.oh-my-zsh is not an installation'); + omzTree(box.put, join(box.home, '.oh-my-zsh')); + assert.deepEqual(detectOhMyZsh(box.env), {path: join(box.home, '.oh-my-zsh')}); + const custom = join(box.root, 'frameworks', 'omz'); + omzTree(box.put, custom); + assert.deepEqual(detectOhMyZsh({...box.env, ZSH: custom}), {path: custom, source: '$ZSH'}); + assert.deepEqual(detectOhMyZsh({...box.env, ZSH: 'relative/omz'}), {path: join(box.home, '.oh-my-zsh')}, 'a relative $ZSH is not trusted'); + assert.equal((await detectTool(tool('oh-my-zsh'), box.env)).state, 'installed'); + assert.equal(knownToolForExecutable('oh-my-zsh'), undefined, 'not a command: no command-not-found identity'); + assert.equal(suggestibleToolFor('oh-my-zsh'), undefined); + assert.equal(toolInstall(tool('oh-my-zsh'), true, 'darwin'), undefined, 'no package recipe'); + } finally { box.done(); } +}); + +test('/tools: Zsh frameworks stay visible under Bash/Fish as "Zsh only"; prompt providers show the canonical state', () => { + const state = createToolsPanel(); + state.statuses['oh-my-zsh'] = {state: 'installed', detail: '/h/.oh-my-zsh'}; + state.statuses.prezto = {state: 'missing'}; + state.statuses['oh-my-posh'] = {state: 'installed', version: '31.4.1'}; + state.shellBackend = 'bash'; + state.prompt = {selected: 'ohMyPosh', effective: 'ohMyPosh'}; + assert.equal(toolStatusLine(state, tool('oh-my-zsh')), 'Installed · Zsh framework · used by Zsh only'); + assert.equal(toolStatusLine(state, tool('prezto')), 'Not installed · Zsh only'); + assert.equal(toolStatusLine(state, tool('oh-my-posh')), 'Installed · Active prompt provider'); + assert.ok(toolBadges(state, tool('oh-my-zsh')).includes('Zsh only')); + state.prompt = {selected: 'nmsh', effective: 'nmsh'}; + assert.equal(toolStatusLine(state, tool('oh-my-posh')), 'Installed · Prompt provider'); + // No meaningless Configure / Install / Uninstall for a detected-only framework. + state.detail = tool('prezto'); + const detail = renderTools(state, 120, 40).map(stripAnsi).join('\n'); + assert.doesNotMatch(detail, /C configure|I install|X uninstall/u); + toolsKey(state, {kind: 'text', value: 'i'}); + assert.match(state.message!, /does not install or remove Prezto; it is detected only/u); +}); + +test('other frameworks: only documented, structural signals', () => { + const box = sandbox(); + try { + assert.equal(detectPrezto(box.env), undefined); + box.put(join(box.home, '.zprezto', 'init.zsh')); + mkdirSync(join(box.home, '.zprezto', 'modules')); + assert.ok(detectPrezto(box.env)); + box.put(join(box.home, '.zim', 'zimfw.zsh')); + assert.ok(detectZim(box.env)); + mkdirSync(join(box.home, '.local', 'share', 'zinit', 'zinit.git'), {recursive: true}); + assert.equal(detectZinit(box.env), undefined, 'an empty zinit directory is not an installation'); + box.put(join(box.home, '.local', 'share', 'zinit', 'zinit.git', 'zinit.zsh')); + assert.ok(detectZinit(box.env)); + box.put(join(box.home, '.antidote', 'antidote.zsh')); + assert.ok(detectAntidote({...box.env, HOMEBREW_PREFIX: join(box.root, 'nobrew')})); + assert.equal(TOOLS.some(item => item.id === 'antigen'), false, 'Antigen has no stable install location, so it is not listed'); + } finally { box.done(); } +}); + +test('Powerlevel10k in /tools reuses the existing detector and names an Oh My Zsh custom-theme install', async () => { + const box = sandbox(); + try { + const theme = box.put(join(box.home, '.oh-my-zsh', 'custom', 'themes', 'powerlevel10k', 'powerlevel10k.zsh-theme')); + const env = {...box.env, HOMEBREW_PREFIX: join(box.root, 'nobrew')}; + const existing = detectPowerlevel10k(env, box.home, powerlevel10kThemeCandidates(env, box.home).filter(path => path.startsWith(box.root))); + assert.equal(existing.themePath, theme); + const fact = detectPowerlevel10kTool(env, box.home); + if (fact?.path === theme) assert.equal(fact.source, 'via Oh My Zsh'); + assert.equal(tool('powerlevel10k').promptProvider, 'powerlevel10k'); + assert.equal(knownToolForExecutable('powerlevel10k'), undefined); + const panel = createToolsPanel(); + panel.statuses.powerlevel10k = {state: 'installed'}; + panel.detail = tool('powerlevel10k'); + assert.equal(toolsKey(panel, {kind: 'text', value: 'a'}), 'usePrompt', 'Use as prompt goes to the canonical provider setting'); + assert.equal(toolsKey(panel, {kind: 'text', value: 'c'}), 'p10kConfigure', 'Configure routes to the existing p10k configurator flow'); + const config = selectProvider(normalizePromptConfiguration({}), 'prompt', 'powerlevel10k'); + assert.equal(config?.provider, 'powerlevel10k'); + } finally { box.done(); } +}); + +/** A fake oh-my-posh: records argv, cwd and TTY state; behavior switches on NMSH_FAKE_OMP. */ +function fakeOmp(root: string): {bin: string; log: string} { + const bin = join(root, 'bin'); + mkdirSync(bin, {recursive: true}); + const log = join(root, 'omp.log'); + writeFileSync(join(bin, 'oh-my-posh'), `#!/bin/sh +if [ "$1" = version ]; then echo 31.4.1; exit 0; fi +: > '${log}' +for arg in "$@"; do printf 'ARG:%s\\n' "$arg" >> '${log}'; done +printf 'CWD:%s\\n' "$(pwd)" >> '${log}' +if [ -t 0 ]; then echo TTY:yes >> '${log}'; else echo TTY:no >> '${log}'; fi +case "$NMSH_FAKE_OMP" in + sleep) sleep 10 ;; + flood) yes xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ;; + fail) exit 3 ;; +esac +printf '\\033[38;2;10;20;30m~/repo\\033[0m \\033[48;2;1;2;3mmain\\033[0m\\nsecond' +`); + chmodSync(join(bin, 'oh-my-posh'), 0o755); + return {bin, log}; +} + +test('Oh My Posh provider: documented argv only, cwd and status as flags, config as argv data, no TTY, ANSI parsed', async () => { + const box = sandbox(); + try { + const {bin, log} = fakeOmp(box.root); + const canary = join(box.root, 'PWNED'); + const hostileConfig = box.put(join(box.root, `cfg $(touch ${canary}) ;x`, 'my theme.omp.json'), '{}'); + const env = {...box.env, PATH: `${bin}:/usr/bin:/bin`, POSH_CONFIG: '/ignored/by/argv.json'}; + const status = await detectOhMyPosh(hostileConfig, env); + assert.deepEqual([status.installed, status.version, status.configSource, status.configExists], [true, '31.4.1', 'nmsh', true]); + const cwd = join(box.root, 'work dir'); + mkdirSync(cwd); + const result = await renderOhMyPoshPrompt({cwd, project: 'w', exitStatus: 1}, status, env); + const lines = readFileSync(log, 'utf8').trim().split('\n'); + assert.deepEqual(lines.filter(line => line.startsWith('ARG:')).map(line => line.slice(4)), + ['print', 'primary', `--pwd=${cwd}`, '--status=1', '--terminal-width=200', '--escape=false', `--config=${hostileConfig}`]); + assert.ok(lines.includes(`CWD:${realpathSync(cwd)}`) || lines.includes(`CWD:${cwd}`), lines.join('\n')); + assert.ok(lines.includes('TTY:no'), 'never given a TTY'); + assert.equal(existsSync(canary), false, 'the config path is data, never shell text'); + assert.equal(result.text, '~/repo main second'); + assert.equal(result.normalizedMultiline, true); + assert.deepEqual(result.segments[0]?.foreground, {red: 10, green: 20, blue: 30}); + assert.deepEqual(ohMyPoshArgs({cwd: '/x'}, {}), ['print', 'primary', '--pwd=/x', '--no-status', '--terminal-width=200', '--escape=false'], 'no status yet → --no-status; no config → built-in default'); + assert.deepEqual(readdirSync(box.home), [], 'no rc file or config was created'); + } finally { box.done(); } +}); + +test('Oh My Posh provider: timeout, output bound, cancellation and failure all reject truthfully', async () => { + const box = sandbox(); + try { + const {bin} = fakeOmp(box.root); + const env = {...box.env, PATH: `${bin}:/usr/bin:/bin`}; + const status = await detectOhMyPosh(undefined, env); + assert.equal(status.configSource, 'default'); + const context = {cwd: box.root, project: 'r', exitStatus: 0}; + const started = Date.now(); + await assert.rejects(renderOhMyPoshPrompt(context, status, {...env, NMSH_FAKE_OMP: 'sleep'}, {timeoutMs: 300}), /timed out/u); + assert.ok(Date.now() - started < 3000); + await assert.rejects(renderOhMyPoshPrompt(context, status, {...env, NMSH_FAKE_OMP: 'flood'}), new RegExp(`exceeded its limit`, 'u')); + assert.ok(OH_MY_POSH_OUTPUT_LIMIT <= 256 * 1024); + const controller = new AbortController(); + const pending = renderOhMyPoshPrompt(context, status, {...env, NMSH_FAKE_OMP: 'sleep'}, {signal: controller.signal}); + controller.abort(); + await assert.rejects(pending, /cancelled/u); + await assert.rejects(renderOhMyPoshPrompt(context, status, {...env, NMSH_FAKE_OMP: 'fail'}), /exited with 3/u); + await assert.rejects(renderOhMyPoshPrompt(context, {...status, installed: false, binary: undefined}, env), /not installed/u); + await assert.rejects(renderOhMyPoshPrompt(context, {...status, configPath: join(box.root, 'missing.json'), configExists: false}, env), /config not found/u); + } finally { box.done(); } +}); + +test('Oh My Posh: one canonical provider id; config path persists; curated core-formula install only', () => { + const config = normalizePromptConfiguration({provider: 'ohMyPosh', ohMyPosh: {configPath: '/c/theme.omp.json'}}); + assert.equal(config.provider, 'ohMyPosh'); + assert.equal(config.ohMyPosh.configPath, '/c/theme.omp.json'); + assert.equal(normalizePromptConfiguration({}).ohMyPosh.configPath, null); + assert.equal(selectProvider(normalizePromptConfiguration({}), 'prompt', 'ohMyPosh')?.provider, 'ohMyPosh'); + assert.equal(toolInstall(tool('oh-my-posh'), true, 'darwin')?.label, 'brew install oh-my-posh', 'homebrew/core formula; no custom tap'); + assert.equal(knownToolForExecutable('oh-my-posh')?.id, 'oh-my-posh'); +}); + +test('Oh My Posh selected but missing: NMSh Native is effective and the history snapshot says so; Prompt None unaffected', async () => { + const directory = mkdtempSync(join(tmpdir(), 'nmsh-omp-app-')); + const previous = {XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, PATH: process.env.PATH}; + process.env.XDG_CONFIG_HOME = directory; + const {TerminalApp} = await import('../src/app/TerminalApp.js'); + const app = new TerminalApp(); + try { + app['context'] = {cwd: directory, project: 'p'}; + process.env.PATH = '/nonexistent-nmsh'; + app['promptConfiguration'].provider = 'ohMyPosh'; + await app['refreshProviderPrompt'](); + assert.equal(app['effectivePromptProvider'], 'nmsh'); + assert.match(app['externalPromptError'] ?? '', /not installed/u); + assert.equal(app['currentPromptSnapshot']('ls')?.provider, 'nmsh'); + const {bin} = fakeOmp(directory); + process.env.PATH = `${bin}:/usr/bin:/bin`; + app['promptConfiguration'].provider = 'ohMyPosh'; + await app['refreshProviderPrompt'](); + assert.equal(app['effectivePromptProvider'], 'ohMyPosh'); + assert.equal(app['currentPromptSnapshot']('ls')?.provider, 'ohMyPosh', 'history records the effective provider'); + app['promptConfiguration'].provider = 'none'; + await app['refreshProviderPrompt'](); + assert.equal(app['currentPromptSnapshot']('ls'), undefined, 'Prompt None stores no prompt'); + } finally { + process.env.PATH = previous.PATH; + if (previous.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previous.XDG_CONFIG_HOME; + app['stop'](0); + app['session'].kill(); + rmSync(directory, {recursive: true, force: true}); + } +}); + +test('Oh My Zsh guided install: NMSh never downloads or runs it; the safe settings are exact; snapshot and verify report changes', () => { + const box = sandbox(); + try { + for (const file of ['src/tools/frameworks.ts', 'src/tools/OhMyZshView.ts']) { + assert.doesNotMatch(readFileSync(file, 'utf8'), /child_process|fetch\(|https\.get|spawn|exec\(/u, `${file} cannot run or fetch anything`); + } + const steps = OH_MY_ZSH_INSTALL.steps({TMPDIR: '/tmp'}).join('\n'); + assert.doesNotMatch(steps, /\|\s*(?:ba|z)?sh\b|sh -c "\$\(curl/u, 'no curl | sh'); + assert.match(steps, /^curl -fsSL -o '\/tmp\/ohmyzsh-install\.sh' https:\/\/raw\.githubusercontent\.com\/ohmyzsh\/ohmyzsh\/master\/tools\/install\.sh$/mu, 'download to a file first'); + assert.match(steps, /^less /mu, 'inspect before running'); + assert.match(steps, /KEEP_ZSHRC=yes CHSH=no RUNZSH=no REPO=ohmyzsh\/ohmyzsh REMOTE=https:\/\/github\.com\/ohmyzsh\/ohmyzsh\.git BRANCH=master sh '\/tmp\/ohmyzsh-install\.sh' --unattended --keep-zshrc/u); + + const zshrc = box.put(join(box.home, '.zshrc'), 'export EDITOR=vim\n'); + const view = openGuidedInstall(box.env) as Extract; + assert.match(renderOhMyZshView(view).rows.join('\n'), /will not run it for you/u); + ohMyZshKey(view, {kind: 'text', value: 'b'}); + assert.ok(view.snapshot?.sha256 && view.snapshot.backup && existsSync(view.snapshot.backup)); + assert.equal(readFileSync(view.snapshot.backup, 'utf8'), 'export EDITOR=vim\n'); + assert.equal(readFileSync(zshrc, 'utf8'), 'export EDITOR=vim\n', 'the original is not renamed or edited'); + + // The person runs a (fixture) installer that honours KEEP_ZSHRC. + omzTree(box.put, join(box.home, '.oh-my-zsh')); + ohMyZshKey(view, {kind: 'text', value: 'v'}); + assert.equal(view.unexpected, false); + assert.match(view.verified!.join('\n'), /Oh My Zsh found at .*\.oh-my-zsh\.[\s\S]*\.zshrc is unchanged/u); + + // A misbehaving installer rewrites .zshrc anyway: reported, never restored silently. + writeFileSync(zshrc, 'source $ZSH/oh-my-zsh.sh\n'); + writeFileSync(join(box.home, '.zshrc.pre-oh-my-zsh'), 'export EDITOR=vim\n'); + ohMyZshKey(view, {kind: 'text', value: 'v'}); + assert.equal(view.unexpected, true); + assert.match(renderOhMyZshView(view).rows.join('\n'), /! UNEXPECTED: .*\.zshrc changed although KEEP_ZSHRC=yes was requested\. Your backup is .*NMSh did not restore anything/u); + assert.match(view.verified!.join('\n'), /\.zshrc\.pre-oh-my-zsh exists/u); + assert.equal(readFileSync(zshrc, 'utf8'), 'source $ZSH/oh-my-zsh.sh\n'); + } finally { box.done(); } +}); + +test('previous zshrc: compare, default No changes nothing, restore backs up and replaces atomically, never merges', () => { + const box = sandbox(); + try { + assert.equal(previousZshrc(box.env), undefined); + const current = box.put(join(box.home, '.zshrc'), 'export ZSH=~/.oh-my-zsh\nsource $ZSH/oh-my-zsh.sh\n'); + const previous = box.put(join(box.home, '.zshrc.pre-oh-my-zsh'), 'alias ll="ls -l"\n'); + const view = openPrevious(box.env) as Extract; + const text = renderOhMyZshView(view).rows.join('\n'); + assert.match(text, /does not mean anything is wrong/u); + assert.match(text, /sha256 [0-9a-f]{16}…/u); + assert.match(text, /\+ alias ll="ls -l"/u); + assert.deepEqual(ohMyZshKey(view, {kind: 'text', value: 'e'}), {open: [current, previous]}); + ohMyZshKey(view, {kind: 'text', value: 'r'}); + assert.equal(view.confirm?.choice, 'no', 'restore starts on No'); + ohMyZshKey(view, enter); + assert.match(view.result!, /Cancelled\. Nothing was changed/u); + assert.equal(readFileSync(current, 'utf8'), 'export ZSH=~/.oh-my-zsh\nsource $ZSH/oh-my-zsh.sh\n'); + + ohMyZshKey(view, {kind: 'text', value: 'r'}); + ohMyZshKey(view, {kind: 'right'}); + ohMyZshKey(view, enter); + assert.match(view.result!, /Restored .* backed up at .*\.zshrc\.nmsh-backup-/u); + assert.equal(readFileSync(current, 'utf8'), 'alias ll="ls -l"\n', 'the previous file exactly; nothing merged'); + assert.ok(existsSync(previous), 'the previous file stays'); + const backup = readdirSync(box.home).find(name => name.startsWith('.zshrc.nmsh-backup-'))!; + assert.equal(readFileSync(join(box.home, backup), 'utf8'), 'export ZSH=~/.oh-my-zsh\nsource $ZSH/oh-my-zsh.sh\n'); + + // Changed since review: refused. + const again = openPrevious(box.env) as Extract; + writeFileSync(current, 'edited meanwhile\n'); + ohMyZshKey(again, {kind: 'text', value: 'r'}); + ohMyZshKey(again, {kind: 'right'}); + ohMyZshKey(again, enter); + assert.match(again.result!, /changed since review; nothing was written/u); + assert.equal(readFileSync(current, 'utf8'), 'edited meanwhile\n'); + } finally { box.done(); } +}); + +test('dotfiles: OMZ, p10k and Oh My Posh files are inspect-only; OMP gets no exact copy; Theme Studio import stays static', () => { + const box = sandbox(); + try { + const repo = join(box.root, 'repo'); + const canary = join(box.root, 'PWNED'); + box.put(join(repo, '.zshrc'), `export ZSH=$HOME/.oh-my-zsh\nZSH_THEME=agnoster\nplugins=(git)\nsource $ZSH/oh-my-zsh.sh\ntouch ${canary}\n`); + box.put(join(repo, '.zshrc.pre-oh-my-zsh'), `touch ${canary}\n`); + box.put(join(repo, '.oh-my-zsh', 'custom', 'themes', 'mine.zsh-theme'), `PROMPT='$(touch ${canary})%~ '\n`); + box.put(join(repo, '.p10k.zsh'), `typeset -g POWERLEVEL9K_MODE=nerdfont-v3\ntouch ${canary}\n`); + box.put(join(repo, 'posh', 'a.omp.json'), JSON.stringify({extends: 'https://example.invalid/remote.omp.json', palette: {accent: '#ff0000'}, + blocks: [{type: 'prompt', segments: [{type: 'path', background: '#224466', foreground: '#ffffff', template: '{{ .Path }}'}, {type: 'command', properties: {command: `touch ${canary}`}, template: '{{ .Env.HOME }}', background: 'p:accent', foreground: '#ffffff'}]}]})); + box.put(join(repo, 'posh', 'b.omp.yaml'), 'blocks:\n - type: prompt\n segments:\n - type: path\n background: "#112233"\n foreground: "#ffffff"\n'); + box.put(join(repo, 'posh', 'c.omp.toml'), `[[blocks]]\ntype = "prompt"\n[[blocks.segments]]\ntype = "command"\nbackground = "#334455"\nforeground = "#ffffff"\ntemplate = "{{ .Shell }}"\n[blocks.segments.properties]\ncommand = "touch ${canary}"\n`); + const scan = scanDotfiles(repo) as ScanResult; + const by = (path: string) => scan.found.find(file => file.repoPath === path)?.tool.id; + assert.equal(by('.zshrc'), 'zsh'); + assert.equal(by('.zshrc.pre-oh-my-zsh'), 'oh-my-zsh'); + assert.equal(by('.oh-my-zsh/custom/themes/mine.zsh-theme'), 'oh-my-zsh'); + assert.equal(by('.p10k.zsh'), 'powerlevel10k'); + for (const path of ['posh/a.omp.json', 'posh/b.omp.yaml', 'posh/c.omp.toml']) assert.equal(by(path), 'oh-my-posh', path); + const items = buildPlan(scan, box.env); + for (const item of items) { + assert.deepEqual(item.modes, ['skip'], `${item.file.repoPath}: ${item.note}`); + assert.notEqual(item.kind, 'copy'); + item.mode = 'copy'; + } + assert.match(items.find(item => item.file.repoPath === 'posh/a.omp.json')!.note, /Inspect only: .*never copied\. Import its static colors/u); + assert.ok(applyPlan(items, box.env).every(line => /no exact-copy authority/u.test(line))); + assert.deepEqual(readdirSync(box.home), [], 'nothing was written'); + + const imported = readThemeImport(join(repo, 'posh', 'a.omp.json'), repo, 'auto'); + assert.ok(!('errors' in imported), JSON.stringify(imported)); + if (!('errors' in imported)) { + assert.ok(imported.warnings.some(warning => /never fetched or merged/u.test(warning)), 'remote extends are not followed'); + assert.ok(imported.warnings.some(warning => /templates are not imported/u.test(warning))); + } + readThemeImport(join(repo, 'posh', 'c.omp.toml'), repo, 'auto'); + assert.equal(existsSync(canary), false, 'nothing ran during scan, plan, apply or import'); + } finally { box.done(); } +}); + +test('Ask: framework requests map onto facts, /tools views or a typed provider switch; never a shell command', () => { + const box = sandbox(); + try { + const ask = (text: string) => resolveConfigRequest(text, box.env) as {kind: string; text: string; action?: {kind: string; tool?: string; view?: string; setting?: string; value?: string}} | undefined; + assert.match(ask('is oh my zsh installed')!.text, /^No\./u); + omzTree(box.put, join(box.home, '.oh-my-zsh')); + assert.match(ask('is oh my zsh installed')!.text, /^Yes\. Oh My Zsh is installed at .*Zsh only/u); + assert.deepEqual([ask('install oh my zsh safely')!.action?.kind, ask('install oh my zsh safely')!.action?.view], ['toolView', 'guided']); + assert.match(ask('what happened to my old zshrc')!.text, /no \.zshrc\.pre-oh-my-zsh/u); + box.put(join(box.home, '.zshrc.pre-oh-my-zsh'), 'x\n'); + for (const request of ['what happened to my old zshrc', 'show my pre oh my zsh config', 'restore my previous zshrc']) { + assert.deepEqual([ask(request)!.action?.kind, ask(request)!.action?.view], ['toolView', 'previous'], request); + } + assert.deepEqual([ask('use oh my posh as my prompt')!.action?.setting, ask('use oh my posh as my prompt')!.action?.value], ['prompt', 'ohMyPosh']); + assert.equal(ask('import my oh my posh theme into nmsh')!.action?.view, 'importAppearance'); + assert.equal(ask('configure powerlevel10k')!.action?.view, 'p10kConfigure'); + assert.deepEqual([ask('use powerlevel10k')!.action?.setting, ask('use powerlevel10k')!.action?.value], ['prompt', 'powerlevel10k']); + const install = ask('install oh my posh'); + assert.ok(install?.action?.kind === 'installTool' || install?.action?.kind === 'toolView', JSON.stringify(install)); + for (const request of ['install oh my zsh safely', 'restore my previous zshrc', 'use oh my posh as my prompt']) { + assert.doesNotMatch(JSON.stringify(ask(request)), /curl|\| ?sh|chsh|argv/u, request); + } + } finally { box.done(); } +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index cd77b4fc..7f1dfbdc 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -149,9 +149,10 @@ test('Tools v2: long lists scroll with a factual "more" cue; narrow widths keep const state = createToolsPanel(); const rows = renderTools(state, 90, 18).map(stripAnsi); assert.ok(rows.some(row => /↓ \d+ more/u.test(row))); - for (let index = 0; index < 40; index += 1) toolsKey(state, {kind: 'down'}); + for (let index = 0; index < TOOLS.length + 5; index += 1) toolsKey(state, {kind: 'down'}); const end = renderTools(state, 90, 18).map(stripAnsi); - assert.ok(end.some(row => row.includes('›') && row.includes('Python')), 'selection stays visible at the end'); + const last = visibleTools(state).at(-1)!.label; + assert.ok(end.some(row => row.includes('›') && row.includes(last)), 'selection stays visible at the end'); for (const width of [20, 33, 45, 59, 61]) { const narrow = renderTools(state, width, 18); assert.ok(narrow.every(row => displayWidth(row) <= width), `@${width}`); From b2075dbda87eb770c5fff8fd2923b9ce7717e973 Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:57:20 +0530 Subject: [PATCH 11/16] Oh My Zsh guided steps download into your home, not a shared temp path; Keep Awake ownership check uses absolute PowerShell - The guided install's file lived at a predictable /tmp name another local user could replace between download, inspection and running. - On Windows the ownership check resolved powershell.exe through PATH; it now uses the System32 path like the backend itself. --- src/keepAwake/keepAwake.ts | 4 +++- src/tools/frameworks.ts | 3 ++- tests/shellFrameworks.test.ts | 7 ++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/keepAwake/keepAwake.ts b/src/keepAwake/keepAwake.ts index 5ac36833..2955c223 100644 --- a/src/keepAwake/keepAwake.ts +++ b/src/keepAwake/keepAwake.ts @@ -169,7 +169,9 @@ export const systemProbe: ProcessProbe = { try { return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\u0000').filter(Boolean).join(' '); } catch { return undefined; } } if (process.platform === 'win32') { - const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${Math.trunc(pid)}").CommandLine`], {encoding: 'utf8', timeout: 5000, windowsHide: true}); + // Absolute System32 path: never a PATH lookup. + const root = process.env.SystemRoot ?? process.env.windir ?? 'C:\\Windows'; + const result = spawnSync(`${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${Math.trunc(pid)}").CommandLine`], {encoding: 'utf8', timeout: 5000, windowsHide: true}); return result.status === 0 ? result.stdout.trim() || undefined : undefined; } const result = spawnSync('/bin/ps', ['-ww', '-o', 'command=', '-p', String(Math.trunc(pid))], {encoding: 'utf8', timeout: 3000}); diff --git a/src/tools/frameworks.ts b/src/tools/frameworks.ts index 5808eaf9..a0aaeafd 100644 --- a/src/tools/frameworks.ts +++ b/src/tools/frameworks.ts @@ -128,7 +128,8 @@ export const OH_MY_ZSH_INSTALL: InstallAdapter = { safeArgs: ['--unattended', '--keep-zshrc'], handoff: 'manual-terminal', steps: env => { - const file = join(env.TMPDIR && isAbsolute(env.TMPDIR) ? env.TMPDIR : '/tmp', 'ohmyzsh-install.sh'); + // A file in your own home, not a predictable name in a shared temp directory that another user could replace between download and run. + const file = join(env.HOME && isAbsolute(env.HOME) ? env.HOME : homedir(), 'ohmyzsh-install.sh'); const settings = Object.entries(OH_MY_ZSH_INSTALL.safeEnvironment).map(([name, value]) => `${name}=${value}`).join(' '); return [`curl -fsSL -o ${quote(file)} ${OH_MY_ZSH_INSTALLER_URL}`, `less ${quote(file)}`, `shasum -a 256 ${quote(file)}`, `${settings} sh ${quote(file)} ${OH_MY_ZSH_INSTALL.safeArgs.join(' ')}`]; diff --git a/tests/shellFrameworks.test.ts b/tests/shellFrameworks.test.ts index 69117ca2..9af04ec1 100644 --- a/tests/shellFrameworks.test.ts +++ b/tests/shellFrameworks.test.ts @@ -230,11 +230,12 @@ test('Oh My Zsh guided install: NMSh never downloads or runs it; the safe settin for (const file of ['src/tools/frameworks.ts', 'src/tools/OhMyZshView.ts']) { assert.doesNotMatch(readFileSync(file, 'utf8'), /child_process|fetch\(|https\.get|spawn|exec\(/u, `${file} cannot run or fetch anything`); } - const steps = OH_MY_ZSH_INSTALL.steps({TMPDIR: '/tmp'}).join('\n'); + const steps = OH_MY_ZSH_INSTALL.steps({HOME: '/home/me', TMPDIR: '/tmp'}).join('\n'); + assert.doesNotMatch(steps, /\/tmp\//u, 'never a predictable file in a shared temp directory'); assert.doesNotMatch(steps, /\|\s*(?:ba|z)?sh\b|sh -c "\$\(curl/u, 'no curl | sh'); - assert.match(steps, /^curl -fsSL -o '\/tmp\/ohmyzsh-install\.sh' https:\/\/raw\.githubusercontent\.com\/ohmyzsh\/ohmyzsh\/master\/tools\/install\.sh$/mu, 'download to a file first'); + assert.match(steps, /^curl -fsSL -o '\/home\/me\/ohmyzsh-install\.sh' https:\/\/raw\.githubusercontent\.com\/ohmyzsh\/ohmyzsh\/master\/tools\/install\.sh$/mu, 'download to a file first'); assert.match(steps, /^less /mu, 'inspect before running'); - assert.match(steps, /KEEP_ZSHRC=yes CHSH=no RUNZSH=no REPO=ohmyzsh\/ohmyzsh REMOTE=https:\/\/github\.com\/ohmyzsh\/ohmyzsh\.git BRANCH=master sh '\/tmp\/ohmyzsh-install\.sh' --unattended --keep-zshrc/u); + assert.match(steps, /KEEP_ZSHRC=yes CHSH=no RUNZSH=no REPO=ohmyzsh\/ohmyzsh REMOTE=https:\/\/github\.com\/ohmyzsh\/ohmyzsh\.git BRANCH=master sh '\/home\/me\/ohmyzsh-install\.sh' --unattended --keep-zshrc/u); const zshrc = box.put(join(box.home, '.zshrc'), 'export EDITOR=vim\n'); const view = openGuidedInstall(box.env) as Extract; From 122e105f4eec6b668e45a363f16f7150ba5218f8 Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:24:36 +0530 Subject: [PATCH 12/16] Add GitHub Sponsors funding metadata --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..258d777e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: raiseCatError From f0cda6749320ef21d42fcf543a976b37724500d4 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:41:47 +0530 Subject: [PATCH 13/16] Keep Awake hands the composer straight back and shows itself as composer chrome; /tools selection is a real band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep Awake - /caffeinate, /awake and /zoomies with no argument opened a panel that was never drawn (it was missing from the active-panel set), so the composer looked frozen, keys went to the hidden panel, Enter quietly started an assertion and only Ctrl+C (the panel's close key) gave the prompt back. The panel is registered (the Mise panel had the same omission), and a start, an accepted mode change or a stop now closes it and records one line under the command that opened it. The assertion was always an NMSh-owned detached process; nothing ran through the shell, and a typed shell caffeinate stays an ordinary foreground command. - Presentation: while active, "Awake · " is NMSh composer chrome, never prompt or provider output. Placement Composer edge (default) takes a plain top divider, else the bottom divider when a header prompt owns the top edge, else one row next to the composer; Above composer and Input row (only when the edit is single-line with room to spare; the editor's wrapping, caret, selection and hit testing use the narrowed width) are explicit choices. The decision is made with the screen plan each frame and never rewrites the saved setting. - Both composer edges render through one exact-width edge renderer, also used by the Chroma repaint, so an animated divider never erases the accessory and transition tints stay off it. - Status Strip: an active Keep Awake is always in an enabled strip (no per-item switch); it narrows (label, Awake, glyph) before anything would drop it, and a disabled strip stays disabled. - Idle reminder (default On, 30 s without NMSh input): adds the time and a muted /zoomies stop on the edge when it fits, else one muted row; the next input collapses it. Screensaver status (default On, Bottom left, six positions) is a small separate element; the saver and Vespyr are untouched. - Display Text / Icon / Icon + text through the semantic icon set (Safe mode keeps text). Colors are theme roles: accent while active, muted for the reminder. Off renders nothing anywhere. - The panel gains Duration and Presentation, Screensaver and Status Strip sections. Ask answers status questions and plans start, change, timed and stop requests through the same controller, deterministically. - A deterministic-only inert backend (NMSH_DETERMINISTIC=1 with NMSH_KEEP_AWAKE_BACKEND=inert) runs the real slash, controller, ownership and presentation paths for tests and recorded demos without keeping a machine awake. /tools - The selected row uses the shared selected band (the active tab's treatment): full width, bold label, quiet text lifted onto the band, and reverse video without color. README art - scripts/demos/vespyr-svg.ts renders a small transparent divider from Vespyr's real sprite pixels and blink pose. --- scripts/demos/vespyr-svg.ts | 44 ++++ src/app/TerminalApp.ts | 217 ++++++++++++++++++-- src/app/screenPlan.ts | 22 +- src/ask/configActions.ts | 34 +++- src/help/helpContent.ts | 2 +- src/keepAwake/KeepAwakePanel.ts | 100 ++++++++- src/keepAwake/keepAwake.ts | 25 ++- src/keepAwake/presentation.ts | 218 ++++++++++++++++++++ src/prompt/configuration.ts | 6 +- src/prompt/glyphChoices.ts | 2 + src/status/StatusStrip.ts | 27 ++- src/tools/ToolsPanel.ts | 18 +- src/ui/LayoutPanel.ts | 1 + src/ui/PanelShell.ts | 19 ++ tests/keepAwakeForeground.test.ts | 77 +++++++ tests/keepAwakePresentation.test.ts | 305 ++++++++++++++++++++++++++++ tests/tools.test.ts | 31 +++ 17 files changed, 1087 insertions(+), 61 deletions(-) create mode 100644 scripts/demos/vespyr-svg.ts create mode 100644 src/keepAwake/presentation.ts create mode 100644 tests/keepAwakeForeground.test.ts create mode 100644 tests/keepAwakePresentation.test.ts diff --git a/scripts/demos/vespyr-svg.ts b/scripts/demos/vespyr-svg.ts new file mode 100644 index 00000000..b6d9915b --- /dev/null +++ b/scripts/demos/vespyr-svg.ts @@ -0,0 +1,44 @@ +/** + * Renders README art from Vespyr's real sprite (src/idle/catSprite.ts): the + * same pixels, poses and colors NMSh draws in the terminal, as a small + * transparent SVG divider. The only motion is the sprite's own blink pose, + * shown briefly every few seconds (SMIL, no script), so it stays calm. + * + * npx tsx scripts/demos/vespyr-svg.ts (writes assets/readme/vespyr-divider.svg) + */ +import {writeFileSync} from 'node:fs'; +import {CAT_BODY, CAT_EYE, catPixels} from '../../src/idle/catSprite.js'; + +const hex = (value: number) => `#${value.toString(16).padStart(6, '0')}`; +const PIXEL = 4; +const open = catPixels('idle'); +const blink = catPixels('blink'); +const width = 800, height = 44; +// Vespyr sits centered on the rule, facing the way the sprite faces (left). +const baseline = 38; +const originX = 400 - (open[0]!.length * PIXEL) / 2, originY = baseline - open.length * PIXEL; + +const rects: string[] = []; +open.forEach((row, y) => [...row].forEach((pixel, x) => { + if (pixel === '.') return; + const at = `x="${originX + x * PIXEL}" y="${originY + y * PIXEL}" width="${PIXEL}" height="${PIXEL}"`; + if (pixel === 'E' && blink[y]![x] !== 'E') rects.push(``); + else rects.push(``); +})); + +const svg = ` + + + + ${rects.join('\n ')} + +`; + +writeFileSync(new URL('../../assets/readme/vespyr-divider.svg', import.meta.url), svg); +console.log(`vespyr-divider.svg: ${svg.length} bytes`); diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 8c0b4074..f00eed2c 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -9,7 +9,8 @@ import {SessionPresetStore, PresetStartup, presetNeedsAcknowledgement, type Sess import {createPresetPanel, presetPanelKey, renderPresetPanel, type PresetPanel} from '../session/PresetPanel.js'; import {MiseProjectService, detectMiseProject} from '../tools/MiseProject.js'; import {misePanelKey, renderMisePanel, type MisePanel} from '../tools/MisePanel.js'; -import {detectBackend, KeepAwakeController} from '../keepAwake/keepAwake.js'; +import {detectBackend, keepAwakePath, KeepAwakeController, type KeepAwakeRecord} from '../keepAwake/keepAwake.js'; +import {awakeDuration, awakeLabel, awakeStyle, awakeView, edgeAccessoryColumns, edgeFits, placeOnSaver, renderComposerEdge, resolveAccessorySlot, sliceAnsiCells} from '../keepAwake/presentation.js'; import {createKeepAwakePanel, describeStart, keepAwakeKey, renderKeepAwakePanel, requestStart, statusLines, type KeepAwakePanel} from '../keepAwake/KeepAwakePanel.js'; import {homedir} from 'node:os'; import {createNotificationService, formatCommandNotification, shouldNotify, type TerminalFocus} from '../notifications/commandNotifications.js'; @@ -237,7 +238,7 @@ import {detectShellEnvironment, shellEnvironmentRows, type ShellEnvironmentRepor import {agentColor, agentCompletionText, renderAgentStats} from '../agents/AgentStatsView.js'; import {detectAgentCommand} from '../agents/agents.js'; import {describeNotice, noticeExpiresAt, noticeKey, noticeVisible, selectNotices, sessionLabel, type NoticeView, type SessionNotice} from '../session/SessionNotices.js'; -import {cursorScreenRow, planScreen, regionAt, withNoticeRows, withStatusRow, screenRowFromTerminal, terminalRowFromScreen, type Region, type ScreenPlan} from './screenPlan.js'; +import {composerEdgeStates, cursorScreenRow, planScreen, regionAt, withNoticeRows, withStatusRow, screenRowFromTerminal, terminalRowFromScreen, type Region, type ScreenPlan} from './screenPlan.js'; import {AppearanceState, handleAppearanceKey, renderAppearancePanel, BLUR_MODES} from '../appearance/AppearancePanel.js'; import {KeyboardState, handleKeyboardKey, renderKeyboardPanel} from '../keyboard/KeyboardPanel.js'; import {Highlighter} from '../input/Highlighter.js'; @@ -274,6 +275,10 @@ const PRIMARY = lazyForeground(UI_COLORS.primary); const SECONDARY = lazyForeground(UI_COLORS.secondary); const SUBTLE = lazyForeground(UI_COLORS.subtle); const SEPARATOR = lazyForeground(UI_COLORS.separator); +/** Keep Awake presentation poll: record changes, timeout, idle reminder and its minute counter. */ +const AWAKE_POLL_MS = 5000; +/** Free cells the input row keeps after typed text before Keep Awake yields it (Input row placement). */ +const AWAKE_INPUT_SLACK = 8; const ACCENT = lazyForeground(UI_COLORS.accent); /** NMSh ran this install with the user's confirmation; record it so an uninstall can be offered honestly. */ function recordInstall(toolId: string, install: {label: string; command: string; args: readonly string[]}): void { @@ -490,21 +495,137 @@ export class TerminalApp { /** One controller for /caffeinate, /awake and /zoomies; the backend is detected once. */ private keepAwake(): KeepAwakeController { return this.keepAwakeController ??= new KeepAwakeController(detectBackend()); } + /** The verified Keep Awake record presentation shows; undefined (Off) renders nothing anywhere. */ + private awakeRecord?: KeepAwakeRecord; + private awakeTimer?: () => void; + /** Last local NMSh input (keys, mouse, paste): the idle reminder's clock. Output and OS idle state never count. */ + private lastUserInput = Date.now(); + /** A cheap key for what Keep Awake shows, so the poll repaints only when it changes. */ + private awakeShown = ''; + + /** Full verified refresh (ownership checked): on launch, after every Keep Awake action, and when the stored record changes. */ + private refreshAwake(): void { + if (!this.keepAwakeController && !existsSync(keepAwakePath())) { this.awakeRecord = undefined; return; } + const status = this.keepAwake().status(); + this.awakeRecord = status.state === 'running' ? status.record : undefined; + } + + /** Polled while NMSh presents: a file read, plus an ownership check only when the record changed or its timeout passed. */ + private pollAwake(): void { + const stored = this.keepAwakeController || existsSync(keepAwakePath()) ? this.keepAwake().peek() : undefined; + const current = this.awakeRecord; + const expired = current?.timeoutSeconds !== undefined && Date.now() >= current.startedAt + current.timeoutSeconds * 1000; + if (stored?.token !== current?.token || expired) this.refreshAwake(); + const shown = this.awakeShownKey(); + if (shown !== this.awakeShown) { this.awakeShown = shown; this.render(); } + } + + private awakeShownKey(): string { + const record = this.awakeRecord; + if (!record) return ''; + return `${record.token}|${this.awakeIdle()}|${awakeDuration(record, Date.now())}`; + } + + /** One timer while Keep Awake is active (or could become active from another window); none once NMSh stops presenting. */ + private syncAwake(): void { + const wanted = this.presentationStarted && !this.stopped && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended; + if (wanted && !this.awakeTimer) this.awakeTimer = presentationClock.subscribe(() => this.pollAwake(), AWAKE_POLL_MS); + else if (!wanted && this.awakeTimer) { this.awakeTimer(); this.awakeTimer = undefined; } + } + + /** Expanded reminder after no local NMSh input for the configured delay (a deterministic demo may shorten it). */ + private awakeIdle(now = Date.now()): boolean { + const settings = this.promptConfiguration.keepAwake; + if (!this.awakeRecord || !settings.idleReminder) return false; + const override = isDeterministicPresentation() ? Number(process.env.NMSH_DEMO_AWAKE_IDLE_MS) : Number.NaN; + const delay = Number.isFinite(override) && override >= 0 ? override : settings.idleAfterSeconds * 1000; + return now - this.lastUserInput >= delay; + } + + private awakeViewNow(now = Date.now()) { + const record = this.awakeRecord; + return record ? awakeView(record, this.promptConfiguration.keepAwake, this.awakeIdle(now), now) : undefined; + } + + /** + * Input row placement: the width reserved at the end of the first input row, + * or 0. Only when completely safe: a single-line edit with room to spare and + * no right prompt on that row. Wrapping, the caret, selection and mouse hit + * testing all use the narrowed width (inputColumns), so typed text can never + * run under it. + */ + private awakeInputReserve(columns: number): number { + const view = this.awakeViewNow(); + if (!view || this.promptConfiguration.keepAwake.placement !== 'input') return 0; + if (this.promptConfiguration.composerLayout === 'oneLine' && this.effectivePromptProvider === 'nmsh') return 0; + if (this.editor.displayText.includes('\n')) return 0; + const reserve = view.compact.width + 2; + const prefix = this.inputFirstLinePrefix(columns); + const used = displayWidth(prefix ?? `${GLYPHS.prompt} `) + displayWidth(this.editor.displayText) + 1; + return used + AWAKE_INPUT_SLACK <= columns - reserve ? reserve : 0; + } + + /** The editor's width: the terminal width less any Keep Awake input reservation. */ + private inputColumns(columns: number): number { + return Math.max(1, columns - this.awakeInputReserve(columns)); + } + + /** Resolves this frame's slot against real composer geometry; the saved preference never changes. */ + private awakeDecision(plan: ScreenPlan, columns: number): ScreenPlan['awake'] { + const view = this.awakeViewNow(); + if (!view || plan.panelActive) return undefined; + const edges = composerEdgeStates(plan, this.promptConfiguration.placement); + const fits = edgeFits(columns, view.compact.width); + const slot = resolveAccessorySlot(this.promptConfiguration.keepAwake.placement, { + topEdge: edges.topEdge === 'available' && fits ? 'available' : edges.topEdge === 'occupied' ? 'occupied' : 'unavailable', + bottomEdge: edges.bottomEdge === 'available' && fits ? 'available' : 'unavailable', + inputTrailing: this.awakeInputReserve(columns) > 0 ? 'available' : 'unavailable', + }); + return {slot, expandedOnEdge: Boolean(view.expanded && (slot === 'topEdge' || slot === 'bottomEdge') && edgeFits(columns, view.expanded.width))}; + } + + /** Rows the adjacent slot needs: its own row, or the muted reminder when the idle form does not fit where the label is. */ + private awakeAdjacentRows(decision: ScreenPlan['awake']): number { + if (!decision) return 0; + if (decision.slot === 'adjacentRow') return 1; + return this.awakeIdle() && !decision.expandedOnEdge ? 1 : 0; + } + + private awakeRows(plan: ScreenPlan, columns: number): string[] { + const view = this.awakeViewNow(); + if (!view || !plan.awake) return []; + return [plan.awake.slot === 'adjacentRow' ? view.row(columns) : view.reminder.ansi]; + } + + /** The composer edge (border or separator) through the one shared renderer: the rule, plus the accessory when this edge holds it. */ + private composerEdgeRow(kind: 'composerBorder' | 'separator', plan: ScreenPlan, columns: number, now: number): string { + const rule = `${paintDivider(repeatToWidth(GLYPHS.separator, columns), this.promptConfiguration.presentation, now)}${RESET}`; + const accessory = this.edgeAccessory(kind, plan, now); + return accessory ? renderComposerEdge({rule, width: columns, accessory}) : rule; + } + + private edgeAccessory(kind: 'composerBorder' | 'separator', plan: ScreenPlan, now = Date.now()): {ansi: string; width: number} | undefined { + const view = this.awakeViewNow(now); + if (!view || plan.awake?.slot !== (kind === 'composerBorder' ? 'topEdge' : 'bottomEdge')) return undefined; + return plan.awake.expandedOnEdge && view.expanded ? view.expanded : view.compact; + } + private handleKeepAwakeSlash(command: string, slash: Extract): void { const controller = this.keepAwake(); if (slash.op === 'panel') { this.panelOrigin = undefined; - this.keepAwakePanel = createKeepAwakePanel(controller, slash.invalid ? `"${slash.invalid}" is not a Keep Awake mode or duration. Modes: idle, display, system, all; durations like 45s, 30m, 2h.` : undefined); + this.keepAwakePanel = {...createKeepAwakePanel(controller, slash.invalid ? `"${slash.invalid}" is not a Keep Awake mode or duration. Modes: idle, display, system, all; durations like 45s, 30m, 2h.` : undefined), command: command.split(/\s+/u)[0]!}; } else if (slash.op === 'status') this.output.addFrontendInteraction(command, statusLines(controller).join('\n'), INFO); else if (slash.op === 'stop') this.output.addFrontendInteraction(command, controller.stop(), INFO); else { const result = controller.start(slash.mode!, slash.timeoutSeconds); if (result.kind === 'needsConfirm') { // A different mode is running: the panel asks first (default No). - this.keepAwakePanel = createKeepAwakePanel(controller); + this.keepAwakePanel = {...createKeepAwakePanel(controller), command: command.split(/\s+/u)[0]!}; requestStart(this.keepAwakePanel, controller, slash.mode!, slash.timeoutSeconds); } else this.output.addFrontendInteraction(command, describeStart(result), result.kind === 'failed' || result.kind === 'unsupported' ? ERROR : INFO); } + this.refreshAwake(); this.render(); } private readonly miseService = new MiseProjectService(); @@ -901,6 +1022,7 @@ export class TerminalApp { } }); this.presentationStarted = true; + this.refreshAwake(); // Re-apply (or clean up) Theme Bridge state once per launch; nothing happens when it was never used. this.scheduleThemeBridge(500); this.scheduleWelcomeBlink(); @@ -958,7 +1080,11 @@ export class TerminalApp { return; } // Focus reports alone are not user activity (a terminal can report them on its own). - if (keys.some(key => key.kind !== 'focusIn' && key.kind !== 'focusOut')) this.noteActivity(); + if (keys.some(key => key.kind !== 'focusIn' && key.kind !== 'focusOut')) { + // Pointer motion alone is not interaction for the Keep Awake reminder; keys, clicks, scroll and paste are. + if (keys.some(key => key.kind !== 'focusIn' && key.kind !== 'focusOut' && key.kind !== 'mouseMove')) this.lastUserInput = Date.now(); + this.noteActivity(); + } for (const key of keys) { const before = {text: this.editor.text, index: this.editor.displayCursorIndex}; this.handleKey(key); @@ -976,7 +1102,7 @@ export class TerminalApp { if (this.editor.text !== before.text || this.editor.displayCursorIndex === before.index || this.settingsPanelActive || this.passthrough) return; const columns = this.dimensions().columns; const prefix = this.inputFirstLinePrefix(columns); - const at = (index: number) => layoutInput(this.editor.displayText, index, columns, Number.POSITIVE_INFINITY, prefix); + const at = (index: number) => layoutInput(this.editor.displayText, index, this.inputColumns(columns), Number.POSITIVE_INFINITY, prefix); const from = at(before.index), to = at(this.editor.displayCursorIndex); if (from.caretRow !== to.caretRow) return; this.transitions.travel(from.caretColumn, to.caretColumn, to.caretRow, Date.now()); @@ -1083,7 +1209,17 @@ export class TerminalApp { return; } if (this.keepAwakePanel) { - if (keepAwakeKey(this.keepAwakePanel, this.keepAwake(), key) === 'close') { this.keepAwakePanel = undefined; this.returnFromPanel(); } + const result = keepAwakeKey(this.keepAwakePanel, this.keepAwake(), key, this.promptConfiguration.keepAwake); + if (result && typeof result === 'object' && 'settings' in result) { + const next = result.settings; + this.updateConfiguration(configuration => { configuration.keepAwake = next; }); + } else if (result) { + const command = this.keepAwakePanel.command; + this.keepAwakePanel = undefined; + if (typeof result === 'object' && 'done' in result) this.output.addFrontendInteraction(command ?? '/caffeinate', result.done, INFO); + this.refreshAwake(); + this.returnFromPanel(); + } this.render(); return; } @@ -3351,7 +3487,7 @@ export class TerminalApp { private get settingsPanelActive(): boolean { return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState - || this.resumeBrowser || this.appearanceHub || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.pasteReview); + || this.resumeBrowser || this.appearanceHub || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.pasteReview || this.misePanel || this.keepAwakePanel); } /** Complex panels declare the smallest size that shows their essential controls. */ @@ -3378,7 +3514,7 @@ export class TerminalApp { if (this.toolConfiguration) return renderConfigurationPanel(this.toolConfiguration, columns, this.dimensions().rows); if (this.presetPanel) return renderPresetPanel(this.presetPanel, columns, this.dimensions().rows); if (this.misePanel) return renderMisePanel(this.misePanel, columns, this.dimensions().rows); - if (this.keepAwakePanel) return renderKeepAwakePanel(this.keepAwakePanel, this.keepAwake(), columns, this.dimensions().rows); + if (this.keepAwakePanel) return renderKeepAwakePanel(this.keepAwakePanel, this.keepAwake(), columns, this.dimensions().rows, this.promptConfiguration.keepAwake); if (this.installPrompt) return renderInstallPrompt(this.installPrompt, columns); if (this.themeStudio) return this.renderThemeStudioRows(this.themeStudio, columns); if (this.themeBridgePanel) return renderThemeBridgePanel(this.themeBridgePanel, this.themeBridgePanelContext(), columns, this.dimensions().rows); @@ -4421,10 +4557,21 @@ export class TerminalApp { const time = idle.still ? 20_000 : sceneTime(now - idle.startedAt, idle.frame, idle.mode); const frame = idleFrameRows(this.idleGrid, {mode: idle.mode, width: columns, height: rows, time: Math.max(0, time - idle.offset), palette: idlePaletteFor(this.promptConfiguration), level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', ...(idle.capture ? {capture: idle.capture} : {})}); - try { this.renderer.render({rows: frame, columns, cursorRow: 1, cursorColumn: 1, cursorVisible: false}); } + const shown = this.saverAwake(frame, columns, now); + try { this.renderer.render({rows: shown, columns, cursorRow: 1, cursorColumn: 1, cursorVisible: false}); } catch (error) { this.onTerminate(); throw error; } } + /** The screensaver's positioned Keep Awake status: a small separate element; the saver and mascot are untouched. */ + private saverAwake(frame: string[], columns: number, now: number): string[] { + const record = this.awakeRecord; + const settings = this.promptConfiguration.keepAwake; + if (!record || !settings.screensaver) return frame; + const text = `${awakeLabel(record, settings.display, 'full')} · ${awakeDuration(record, now)}`; + return placeOnSaver(frame, columns, {ansi: `${awakeStyle.active()}${text}${awakeStyle.reset}`, width: displayWidth(text)}, settings.screensaverPosition, + (row, column, ansi, width) => `${sliceAnsiCells(row, 0, column)}${RESET}${' '.repeat(Math.max(0, column - Math.min(column, displayWidth(stripAnsi(sliceAnsiCells(row, 0, column))))))}${ansi}${sliceAnsiCells(row, column + width, columns)}${RESET}`); + } + private stopIdleFrames(): void { this.idleSubscription?.(); this.idleSubscription = undefined; } @@ -5820,8 +5967,14 @@ export class TerminalApp { // Notices never squeeze the composer or transcript out: small screens simply do not show them. const notices = count > 0 && rows - find >= 12 + count ? count : 0; const plan = this.planComposer(columns, rows - notices - find, fullInput, suggestions, panelRows); + // Keep Awake: decided against this geometry; an adjacent row (when needed and the screen has room) sits closest to the composer. + const decision = this.awakeDecision(plan, columns); + const wanted = this.awakeAdjacentRows(decision); + const awakeRows = wanted && rows - notices - find >= 10 + wanted ? wanted : 0; + const base = awakeRows ? this.planComposer(columns, rows - notices - find - awakeRows, fullInput, suggestions, panelRows) : plan; + const awake = decision && (decision.slot !== 'adjacentRow' || awakeRows) ? decision : undefined; // The find bar sits right above the composer, notices above it. - return withNoticeRows(withNoticeRows(plan, find, 'find'), notices); + return withNoticeRows(withNoticeRows(withNoticeRows({...base, ...(awake ? {awake} : {})}, find, 'find'), notices), awakeRows, 'awake'); } /** One compact line per notice (max three, the last may summarize overflow). */ @@ -5978,7 +6131,11 @@ export class TerminalApp { } private statusStripRow(columns: number): string { - return renderStatusStrip(this.promptConfiguration.statusStrip, this.stripStats, columns); + // Active Keep Awake is always part of an enabled strip (no per-item switch); the strip itself is never forced on. + const record = this.awakeRecord; + const display = this.promptConfiguration.keepAwake.display; + return renderStatusStrip(this.promptConfiguration.statusStrip, this.stripStats, columns, undefined, + record ? {full: awakeLabel(record, display, 'full'), short: awakeLabel(record, display, 'short'), glyph: awakeLabel(record, display, 'glyph')} : undefined); } /** One timer while the strip is on and NMSh owns the screen; none otherwise. */ @@ -7612,13 +7769,18 @@ export class TerminalApp { if (this.editor.ghost && !this.editor.hasPasteAtoms && this.editor.cursorIndex === graphemes(this.editor.text).length && row === input.rows[input.rows.length - 1]) { suffix = `${SECONDARY}${this.editor.ghost.substring(this.editor.text.length)}${RESET}`; } - const line = truncateAnsi(`${prefix}${textStyled}${suffix}`, columns); - return row.charStart === 0 && row === input.allRows[0] ? `${line}${this.oneLineRightContext(line, columns)}` : line; + const editorColumns = this.inputColumns(columns); + const line = truncateAnsi(`${prefix}${textStyled}${suffix}`, editorColumns); + if (row.charStart !== 0 || row !== input.allRows[0]) return line; + // Input row placement: the reserved trailing cells, outside the editor's own width. + const accessory = plan.awake?.slot === 'inputTrailing' && editorColumns < columns ? this.awakeViewNow()?.compact : undefined; + if (accessory) return `${line}${RESET}${' '.repeat(Math.max(0, columns - displayWidth(line) - accessory.width))}${accessory.ansi}`; + return `${line}${this.oneLineRightContext(line, columns)}`; }); // The plan decides where each region lives; this only decides what paints into it. - // Composer top and bottom divider lines; the prompt row's divider fill uses the same source. - const separator = `${paintDivider(repeatToWidth(GLYPHS.separator, columns), this.promptConfiguration.presentation, Date.now())}${RESET}`; + // Composer top and bottom edges share one renderer (composerEdgeRow); the prompt row's divider fill uses the same rule source. + const now = Date.now(); const regionRows = (region: Region): string[] => { switch (region.kind) { // Setup Cat owns a clean screen: the ordinary transcript/welcome is not drawn behind it (presentation only; nothing is cleared). @@ -7646,10 +7808,11 @@ export class TerminalApp { const activity = truncateAnsi(this.currentActivity(), columns); return plan.composerPosition === 'top' ? ['', activity] : [activity, '']; } - case 'composerBorder': return [separator]; + case 'composerBorder': return [this.composerEdgeRow('composerBorder', plan, columns, now)]; case 'prompt': return [promptLine]; case 'input': return inputRows; - case 'separator': return [separator]; + case 'separator': return [this.composerEdgeRow('separator', plan, columns, now)]; + case 'awake': return this.awakeRows(plan, columns); case 'status': return [this.statusStripRow(columns)]; case 'notices': return this.noticeRows(columns); case 'find': return this.searchChrome(columns); @@ -7764,7 +7927,7 @@ export class TerminalApp { const input = regions('input')[0]; if (!input) continue; const prefix = this.inputFirstLinePrefix(columns); - const at = (index: number) => layoutInput(this.editor.displayText, index, columns, Number.POSITIVE_INFINITY, prefix); + const at = (index: number) => layoutInput(this.editor.displayText, index, this.inputColumns(columns), Number.POSITIVE_INFINITY, prefix); const from = at(transition.from); const to = at(transition.to); const caret = this.layoutEditorInput(columns, Math.max(1, input.height)); @@ -7774,7 +7937,7 @@ export class TerminalApp { const input = regions('input')[0]; if (!input) continue; const caret = this.layoutEditorInput(columns, Math.max(1, input.height)); - add(input.top + transition.row + (caret.caretRow - layoutInput(this.editor.displayText, this.editor.displayCursorIndex, columns, Number.POSITIVE_INFINITY, this.inputFirstLinePrefix(columns)).caretRow), + add(input.top + transition.row + (caret.caretRow - layoutInput(this.editor.displayText, this.editor.displayCursorIndex, this.inputColumns(columns), Number.POSITIVE_INFINITY, this.inputFirstLinePrefix(columns)).caretRow), transitionPaint.travel(transition.from, transition.to, t, transition.look)); } else if (transition.kind === 'seal') { const transcript = plan.regions.find(region => region.kind === 'transcript'); @@ -7797,6 +7960,13 @@ export class TerminalApp { } } } + // Transition light passes over the rule but not over a Keep Awake accessory: its text stays semantically stable. + for (const region of regions('separator', 'composerBorder')) { + const accessory = this.edgeAccessory(region.kind as 'separator' | 'composerBorder', plan, now); + const span = accessory && edgeAccessoryColumns(columns, accessory.width); + const cells = span && paints.get(region.top); + if (cells) for (let column = span.start - 1; column <= span.end; column += 1) cells.delete(column); + } if (paints.size) for (const [row, cells] of paints) rows[row] = overlayRow(rows[row] ?? '', cells, columns); // A clock only while a transition is live (~30 fps for their short lifetime); none otherwise. const busy = this.transitions.busy; @@ -7816,6 +7986,7 @@ export class TerminalApp { this.idle = undefined; this.screensaverAnimation?.(); this.screensaverAnimation = undefined; this.stripTimer?.(); this.stripTimer = undefined; + this.awakeTimer?.(); this.awakeTimer = undefined; this.noticeTimer?.(); this.noticeTimer = undefined; this.panelAnimation?.(); this.panelAnimation = undefined; this.presentationSubscription?.(); this.presentationSubscription = undefined; @@ -7840,8 +8011,9 @@ export class TerminalApp { for (let index = 0; index < region.height; index++) rows[region.top + index] = content[index] ?? ''; } // Live composer divider lines move with Chroma only when Divider lines follow Chroma. + // The composed edge (rule + any Keep Awake accessory) is repainted, never a bare rule over it. if ((region.kind === 'separator' || region.kind === 'composerBorder') && dividerAnimated(settings)) { - rows[region.top] = paintDivider(repeatToWidth(GLYPHS.separator, frame.columns ?? 80), settings, now) + RESET; + rows[region.top] = this.composerEdgeRow(region.kind, plan, frame.columns ?? 80, now); } // The prompt row is re-rendered from the same semantic modules; only Chroma colors move (its divider fill too). if (region.kind === 'prompt' && region.height > 0 && (this.promptChromaAnimated() || dividerAnimated(settings)) && this.decorativeMotionAllowed()) { @@ -7884,6 +8056,7 @@ export class TerminalApp { private syncPresentationClock(): void { if (!this.presentationStarted || this.stopped || this.idle) return; this.syncStatusStrip(); + this.syncAwake(); this.syncNotices(); this.syncAgents(); const settings = this.promptConfiguration.presentation; @@ -7953,7 +8126,7 @@ export class TerminalApp { return layoutInput( this.editor.displayText, this.editor.displayCursorIndex, - columns, + this.inputColumns(columns), maxVisibleRows, this.inputFirstLinePrefix(columns), ); diff --git a/src/app/screenPlan.ts b/src/app/screenPlan.ts index 7bd17471..dfa316d8 100644 --- a/src/app/screenPlan.ts +++ b/src/app/screenPlan.ts @@ -32,7 +32,9 @@ export type RegionKind = /** Cross-session notices: frontend chrome immediately above the composer, never transcript. */ | 'notices' /** The transcript find bar (query, match count, options); frontend chrome above the composer. */ - | 'find'; + | 'find' + /** Keep Awake's adjacent row (its own row, or the muted idle reminder): frontend chrome right next to the composer, never transcript. */ + | 'awake'; export interface Region { kind: RegionKind; @@ -89,6 +91,22 @@ export interface ScreenPlan { panelActive: boolean; /** Where the active panel is anchored (meaningful while panelActive). */ panelPosition: 'bottom' | 'top'; + /** Where this frame's Keep Awake accessory lives, when one is active (decided with the plan so every consumer agrees). */ + awake?: {slot: 'topEdge' | 'bottomEdge' | 'adjacentRow' | 'inputTrailing'; expandedOnEdge: boolean}; +} + +/** + * Whether each composer edge can host auxiliary text. A plain composer border + * or separator is available; a header prompt row (the prompt drawn into the + * top divider) occupies the top edge; no rule at all (dividers Off, a tiny + * screen) is unavailable. Width is checked by the caller. + */ +export function composerEdgeStates(plan: ScreenPlan, contextPlacement: ContextPlacement): {topEdge: 'available' | 'occupied' | 'unavailable'; bottomEdge: 'available' | 'unavailable'} { + const has = (kind: RegionKind) => plan.regions.some(region => region.kind === kind && region.height > 0); + return { + topEdge: has('composerBorder') ? 'available' : has('prompt') && contextPlacement === 'header' ? 'occupied' : 'unavailable', + bottomEdge: has('separator') ? 'available' : 'unavailable', + }; } export interface RegionHit { @@ -225,7 +243,7 @@ const COMPOSER_KINDS: ReadonlySet = new Set(['composerBorder', 'prom * moves, so the transcript keeps its geometry. Without a composer (a panel owns * the screen) the plan is returned unchanged. */ -export function withNoticeRows(plan: ScreenPlan, count: number, kind: 'notices' | 'find' = 'notices'): ScreenPlan { +export function withNoticeRows(plan: ScreenPlan, count: number, kind: 'notices' | 'find' | 'awake' = 'notices'): ScreenPlan { const index = plan.regions.findIndex(region => COMPOSER_KINDS.has(region.kind)); if (count <= 0 || index === -1 || plan.panelActive) return plan; const at = plan.regions[index]!.top; diff --git a/src/ask/configActions.ts b/src/ask/configActions.ts index 16d71e60..652c17a4 100644 --- a/src/ask/configActions.ts +++ b/src/ask/configActions.ts @@ -103,18 +103,38 @@ function resolveFrameworkRequest(text: string, env: NodeJS.ProcessEnv): AskOutco return undefined; } -/** Keep Awake: deterministic, typed through the /caffeinate action; starting or stopping waits for Ask's Yes. */ +/** + * Keep Awake: deterministic, typed through the /caffeinate action against the + * one controller. Status and opening run directly; starting, changing or + * stopping waits for Ask's Yes. No model is involved. + */ +const AWAKE_NAME = String.raw`(?:zoomies|caffeinate|keep[ -]?awake|awake)`; +function awakeDuration(text: string): string { + const match = /\bfor (?:an? |one )?(\d{1,3})?\s*(hours?|hrs?|h|minutes?|mins?|m)\b/u.exec(text); + if (!match) return ''; + return ` ${match[1] ?? '1'}${match[2]!.startsWith('h') ? 'h' : 'm'}`; +} function resolveKeepAwakeRequest(text: string): AskOutcome | undefined { const slash = (command: string, safety: 'navigate' | 'mutate', label: string): AskOutcome | undefined => { const parsed = parseSlashCommand(command); return parsed ? {kind: 'proposal', capability: 'feature.open', safety, confidence: 0.92, ...(safety === 'navigate' ? {direct: true} : {}), text: label, action: {kind: 'slash', slash: parsed, label: command}} : undefined; }; - if (/\bzoomies\b/u.test(text) && !/\bstop\b|\bstatus\b/u.test(text)) return slash('/zoomies', 'navigate', 'Open Keep Awake (/zoomies)'); - if (/\b(?:is )?(?:caffeinate|keep[ -]?awake)\b.*\b(?:running|on|active)\b|\bkeep[ -]?awake status\b/u.test(text)) return slash('/caffeinate status', 'navigate', 'Keep Awake status'); - if (/\bkeep (?:my |the )?(?:screen|display|monitor) (?:awake|on)\b/u.test(text)) return slash('/caffeinate display', 'mutate', 'Keep the display and the machine awake (/caffeinate display) until you stop it'); - if (/\b(?:don'?t|do not|never) let (?:my |the )?(?:computer|mac|laptop|machine|pc) sleep\b|\bprevent (?:system )?sleep\b/u.test(text)) return slash('/caffeinate system', 'mutate', 'Prevent automatic system sleep (/caffeinate system) until you stop it'); - if (/\bstop\b.*\b(?:keeping|keep)\b.*\bawake\b|\bstop (?:caffeinate|keep[ -]?awake)\b|\blet my (?:computer|mac|laptop|machine) sleep\b/u.test(text)) return slash('/caffeinate stop', 'mutate', 'Stop Keep Awake (normal sleep returns)'); - if (/\bkeep (?:my |the )?(?:computer|mac|laptop|machine|pc) awake\b/u.test(text)) return slash('/caffeinate idle', 'mutate', 'Prevent automatic idle sleep (/caffeinate idle) until you stop it'); + const name = new RegExp(String.raw`\b${AWAKE_NAME}\b`, 'u'); + // Status: "is zoomies on", "are we keeping the computer awake", "what awake mode is active". + if ((/^(?:is|are|what|which|how long)\b/u.test(text) && name.test(text) && /\b(?:on|running|active|mode|keeping|still)\b/u.test(text)) + || new RegExp(String.raw`\b${AWAKE_NAME} status\b`, 'u').test(text)) return slash('/caffeinate status', 'navigate', 'Keep Awake status'); + // Stop: "stop zoomies", "turn caffeinate off", "let my mac sleep". + if (new RegExp(String.raw`\b(?:stop|end|cancel|disable|turn off|switch off)\b.*\b${AWAKE_NAME}\b|\b${AWAKE_NAME}\b.*\b(?:off|stop)\b`, 'u').test(text) + || /\bstop\b.*\b(?:keeping|keep)\b.*\bawake\b|(? on a free composer edge (or a row next to the composer), in the Status Strip when the strip is on, and optionally on the screensaver; after 30 seconds without NMSh input it adds the time and a muted /zoomies stop. Placement, display, the idle reminder and the screensaver status are set in the Keep Awake panel. Off shows nothing. Prompt providers (/prompt, /providers): NMSh Native, None, Starship, Powerlevel10k and Oh My Posh. NMSh keeps the editor, composer, transcript and history; an external provider supplies only the prompt content, and NMSh falls back to Native, saying so, when it cannot render. Starship and Oh My Posh are cross-shell prompt engines that NMSh runs directly, with no shell rc change; Powerlevel10k is a Zsh theme rendered in an isolated helper. In /tools, Oh My Zsh is a Zsh framework (not a command): its guided install keeps your .zshrc and NMSh never runs the installer itself; if you have .zshrc.pre-oh-my-zsh, NMSh can compare it with .zshrc and restore it after a backup and a confirmation, but never merges shell code. Oh My Zsh themes and plugins, ~/.p10k.zsh and Oh My Posh configs are inspect-only for dotfiles. diff --git a/src/keepAwake/KeepAwakePanel.ts b/src/keepAwake/KeepAwakePanel.ts index 5cc70d53..cc8cc633 100644 --- a/src/keepAwake/KeepAwakePanel.ts +++ b/src/keepAwake/KeepAwakePanel.ts @@ -5,11 +5,17 @@ import {renderControls} from '../ui/controls.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; import {colorLevel} from '../presentation/capabilities.js'; +import {AWAKE_DISPLAY_LABELS, AWAKE_DISPLAYS, AWAKE_IDLE_AFTER, AWAKE_PLACEMENT_LABELS, AWAKE_PLACEMENTS, AWAKE_SAVER_POSITION_LABELS, AWAKE_SAVER_POSITIONS, type KeepAwakePresentation} from './presentation.js'; import {formatDuration, KEEP_AWAKE_MODES, MODE_DESCRIPTIONS, MODE_LABELS, unsupportedReason, type KeepAwakeController, type KeepAwakeMode, type StartResult} from './keepAwake.js'; /** The one Keep Awake surface behind /caffeinate, /awake and /zoomies. */ export interface KeepAwakePanel { + /** Row focus: the four modes, then Duration and the presentation rows (PANEL_SETTINGS). */ selected: number; + /** Index into PANEL_DURATIONS used when a mode starts from the panel. */ + duration?: number; + /** The slash command that opened the panel (/caffeinate, /awake or /zoomies); results are recorded under it. */ + command?: string; message?: string; /** A different mode is running: replacing it waits for this confirmation (default No). */ confirm?: {state: ConfirmState; mode: KeepAwakeMode; timeoutSeconds?: number; from: KeepAwakeMode}; @@ -21,6 +27,33 @@ export function createKeepAwakePanel(controller: KeepAwakeController, message?: return {selected: Math.max(0, index), ...(message ?? (status.state === 'off' ? status.note : undefined) ? {message: message ?? (status.state === 'off' ? status.note : undefined)} : {})}; } +/** Panel durations; slash commands accept any strict duration (45s, 30m, 2h). */ +export const PANEL_DURATIONS: ReadonlyArray<{label: string; seconds?: number}> = [ + {label: 'Until stopped'}, {label: '30 min', seconds: 1800}, {label: '1 hour', seconds: 3600}, {label: '2 hours', seconds: 7200}, {label: '4 hours', seconds: 14_400}, +]; + +/** Rows after the modes, in panel order. Each cycles its value with ←→ or Enter. */ +export const PANEL_SETTINGS = ['duration', 'placement', 'display', 'idleReminder', 'idleAfterSeconds', 'screensaver', 'screensaverPosition'] as const; +type PanelSetting = typeof PANEL_SETTINGS[number]; +const ROW_COUNT = KEEP_AWAKE_MODES.length + PANEL_SETTINGS.length; + +const cycle = (list: readonly T[], value: T, step: number): T => list[(list.indexOf(value) + step + list.length) % list.length]!; + +/** The next presentation settings for one row change; undefined for Duration (panel-local). */ +function changeSetting(row: PanelSetting, settings: KeepAwakePresentation, step: number): KeepAwakePresentation | undefined { + switch (row) { + case 'duration': return undefined; + case 'placement': return {...settings, placement: cycle(AWAKE_PLACEMENTS, settings.placement, step)}; + case 'display': return {...settings, display: cycle(AWAKE_DISPLAYS, settings.display, step)}; + case 'idleReminder': return {...settings, idleReminder: !settings.idleReminder}; + case 'idleAfterSeconds': return {...settings, idleAfterSeconds: cycle(AWAKE_IDLE_AFTER as readonly number[], settings.idleAfterSeconds, step)}; + case 'screensaver': return {...settings, screensaver: !settings.screensaver}; + case 'screensaverPosition': return {...settings, screensaverPosition: cycle(AWAKE_SAVER_POSITIONS, settings.screensaverPosition, step)}; + } +} + +const seconds = (value: number) => value < 60 ? `${value} sec` : `${value / 60} min`; + /** One factual sentence for a start attempt. */ export function describeStart(result: StartResult): string { switch (result.kind) { @@ -33,23 +66,49 @@ export function describeStart(result: StartResult): string { } /** Start from the panel or a slash command; a different running mode asks first. */ -export function requestStart(panel: KeepAwakePanel, controller: KeepAwakeController, mode: KeepAwakeMode, timeoutSeconds?: number): void { +export function requestStart(panel: KeepAwakePanel, controller: KeepAwakeController, mode: KeepAwakeMode, timeoutSeconds?: number): StartResult { const result = controller.start(mode, timeoutSeconds); if (result.kind === 'needsConfirm') panel.confirm = {state: createConfirm(), mode, from: result.from, ...(timeoutSeconds ? {timeoutSeconds} : {})}; panel.message = describeStart(result); + return result; } -export function keepAwakeKey(panel: KeepAwakePanel, controller: KeepAwakeController, key: Key): 'close' | undefined { +/** + * What a key did. A finished action (started, already running, stopped) hands + * the composer straight back with its one-line result: the assertion runs in + * its own NMSh-owned process, so nothing here waits on it like a shell command. + * Failures and the change confirmation stay in the panel. + */ +export type KeepAwakeKeyResult = 'close' | {done: string} | {settings: KeepAwakePresentation} | undefined; + +const finished = (result: StartResult) => result.kind === 'started' || result.kind === 'already'; + +export function keepAwakeKey(panel: KeepAwakePanel, controller: KeepAwakeController, key: Key, settings?: KeepAwakePresentation): KeepAwakeKeyResult { if (panel.confirm) { const decision = handleConfirmKey(key, panel.confirm.state); - if (decision === 'confirm') { const {mode, timeoutSeconds} = panel.confirm; panel.confirm = undefined; panel.message = describeStart(controller.start(mode, timeoutSeconds, true)); } - else if (decision === 'cancel') { panel.message = `Kept ${MODE_LABELS[panel.confirm.from]}. Nothing was changed.`; panel.confirm = undefined; } + if (decision === 'confirm') { + const {mode, timeoutSeconds} = panel.confirm; + panel.confirm = undefined; + const result = controller.start(mode, timeoutSeconds, true); + panel.message = describeStart(result); + if (finished(result)) return {done: panel.message}; + } else if (decision === 'cancel') { panel.message = `Kept ${MODE_LABELS[panel.confirm.from]}. Nothing was changed.`; panel.confirm = undefined; } return undefined; } if (key.kind === 'escape' || key.kind === 'interrupt') return 'close'; - if (key.kind === 'up' || key.kind === 'down') panel.selected = (panel.selected + (key.kind === 'up' ? KEEP_AWAKE_MODES.length - 1 : 1)) % KEEP_AWAKE_MODES.length; - else if (key.kind === 'enter') requestStart(panel, controller, KEEP_AWAKE_MODES[panel.selected]!); - else if (key.kind === 'text' && key.value.toLowerCase() === 's') panel.message = controller.stop(); + if (key.kind === 'up' || key.kind === 'down') { panel.selected = (panel.selected + (key.kind === 'up' ? ROW_COUNT - 1 : 1)) % ROW_COUNT; return undefined; } + const row = PANEL_SETTINGS[panel.selected - KEEP_AWAKE_MODES.length]; + if (row && (key.kind === 'left' || key.kind === 'right' || key.kind === 'enter')) { + const step = key.kind === 'left' ? -1 : 1; + if (row === 'duration') { panel.duration = ((panel.duration ?? 0) + step + PANEL_DURATIONS.length) % PANEL_DURATIONS.length; return undefined; } + const next = settings && changeSetting(row, settings, step); + return next ? {settings: next} : undefined; + } + if (key.kind === 'enter' && !row) { + const result = requestStart(panel, controller, KEEP_AWAKE_MODES[panel.selected]!, PANEL_DURATIONS[panel.duration ?? 0]!.seconds); + if (finished(result)) return {done: panel.message!}; + } + else if (key.kind === 'text' && key.value.toLowerCase() === 's') return {done: controller.stop()}; return undefined; } @@ -65,7 +124,7 @@ export function statusLines(controller: KeepAwakeController, now = Date.now()): ...(record.timeoutSeconds ? [`Ends after ${formatDuration(record.timeoutSeconds)}`] : []), `PID ${record.pid}`]; } -export function renderKeepAwakePanel(panel: KeepAwakePanel, controller: KeepAwakeController, columns: number, height: number): string[] { +export function renderKeepAwakePanel(panel: KeepAwakePanel, controller: KeepAwakeController, columns: number, height: number, settings?: KeepAwakePresentation): string[] { const primary = foreground(UI_COLORS.primary), subtle = foreground(UI_COLORS.subtle), accent = foreground(UI_COLORS.accent), reset = '\u001b[0m'; const status = controller.status(); const rows = [` ${primary}Keep Awake${reset} ${subtle}/caffeinate · /awake · /zoomies${reset}`, '']; @@ -76,6 +135,28 @@ export function renderKeepAwakePanel(panel: KeepAwakePanel, controller: KeepAwak const running = status.state === 'running' && status.record.mode === mode ? ` ${accent}● running${reset}` : ''; rows.push(` ${pointer} ${primary}${MODE_LABELS[mode].padEnd(8)}${reset} ${subtle}${supported ? MODE_DESCRIPTIONS[mode] : `Unavailable on ${controller.backend ? `the ${controller.backend.label}` : 'this system'}`}${reset}${running}`); } + if (settings) { + const value = (row: PanelSetting): string => { + switch (row) { + case 'duration': return PANEL_DURATIONS[panel.duration ?? 0]!.label; + case 'placement': return AWAKE_PLACEMENT_LABELS[settings.placement]; + case 'display': return AWAKE_DISPLAY_LABELS[settings.display]; + case 'idleReminder': return settings.idleReminder ? 'On' : 'Off'; + case 'idleAfterSeconds': return seconds(settings.idleAfterSeconds); + case 'screensaver': return settings.screensaver ? 'On' : 'Off'; + case 'screensaverPosition': return AWAKE_SAVER_POSITION_LABELS[settings.screensaverPosition]; + } + }; + const LABELS: Record = {duration: 'Duration', placement: 'Placement', display: 'Display', idleReminder: 'Idle reminder', + idleAfterSeconds: 'Idle after', screensaver: 'Show status', screensaverPosition: 'Position'}; + const line = (row: PanelSetting) => { + const focused = panel.selected === KEEP_AWAKE_MODES.length + PANEL_SETTINGS.indexOf(row); + return ` ${focused ? `${accent}›${reset}` : ' '} ${primary}${LABELS[row].padEnd(15)}${reset} ${focused ? accent : subtle}${focused ? `‹ ${value(row)} ›` : value(row)}${reset}`; + }; + rows.push(line('duration'), '', ` ${subtle}Presentation${reset}`, line('placement'), line('display'), line('idleReminder'), line('idleAfterSeconds'), + '', ` ${subtle}Screensaver${reset}`, line('screensaver'), line('screensaverPosition'), + '', ` ${subtle}Status Strip${reset}`, ` ${subtle}Shown automatically while active (when the Status Strip is on)${reset}`); + } rows.push('', ...statusLines(controller).slice(1).map(line => ` ${subtle}${line === 'Off' || line === 'Running' ? `Current ${line}` : line}${reset}`)); for (const note of controller.backend?.notes ?? []) rows.push(` ${subtle}${note}${reset}`); rows.push(` ${subtle}Nothing in your power settings changes; Stop (or the timeout) ends it.${reset}`); @@ -84,6 +165,7 @@ export function renderKeepAwakePanel(panel: KeepAwakePanel, controller: KeepAwak ` ${renderConfirm(panel.confirm.state, {focused: true, color: colorLevel() !== 'none'})}`); } else if (panel.message) rows.push('', ` ${panel.message}`); rows.push('', renderControls(panel.confirm ? [['←→', 'choose'], ['Enter', 'confirm'], ['Esc', 'cancel']] - : [['↑↓', 'mode'], ['Enter', 'keep awake'], ['S', 'stop'], ['Esc', 'close']])); + : panel.selected >= KEEP_AWAKE_MODES.length ? [['↑↓', 'row'], ['←→', 'change'], ['S', 'stop'], ['Esc', 'close']] + : [['↑↓', 'row'], ['Enter', 'keep awake'], ['S', 'stop'], ['Esc', 'close']])); return framePanel(rows.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); } diff --git a/src/keepAwake/keepAwake.ts b/src/keepAwake/keepAwake.ts index 2955c223..f68a70f4 100644 --- a/src/keepAwake/keepAwake.ts +++ b/src/keepAwake/keepAwake.ts @@ -32,7 +32,7 @@ export interface KeepAwakeCapabilities {idle: boolean; display: boolean; system: export interface LaunchPlan {command: string; args: string[]; env?: NodeJS.ProcessEnv} export interface KeepAwakeBackend { - id: 'macos-caffeinate' | 'linux-systemd-inhibit' | 'windows-execution-state'; + id: 'macos-caffeinate' | 'linux-systemd-inhibit' | 'windows-execution-state' | 'inert'; label: string; capabilities: KeepAwakeCapabilities; /** Factual notes shown in the panel (e.g. Apple's -s needs AC power). */ @@ -123,9 +123,27 @@ export function windowsBackend(powershell: string): KeepAwakeBackend { }; } +// ---- Inert (tests and recorded demos only) ---------------------------------------------- + +/** + * A backend that asserts nothing: the same NMSh-owned, detached, bounded wait + * helper the Linux backend wraps, without the inhibitor. It exists so the real + * slash, controller, ownership and presentation paths can run in tests and + * VHS recordings without keeping a machine awake. Honored only together with + * NMSH_DETERMINISTIC=1; it never appears in ordinary use. + */ +export function inertBackend(node: string = process.execPath): KeepAwakeBackend { + return { + id: 'inert', label: 'inert demo backend (no assertion)', capabilities: {idle: true, display: true, system: true}, + notes: ['Deterministic demo/test mode: nothing is kept awake.'], + plan: (_mode, token, timeout) => ({command: node, args: ['-e', WAIT_HELPER, '--', String(timeout ?? 0), `nmsh-keep-awake=${token}`]}), + }; +} + /** The backend detection decides; the platform alone does not. */ export function detectBackend(platform: NodeJS.Platform = process.platform, exists: (path: string) => boolean = existsSync, env: NodeJS.ProcessEnv = process.env): KeepAwakeBackend | undefined { + if (env.NMSH_DETERMINISTIC === '1' && env.NMSH_KEEP_AWAKE_BACKEND === 'inert') return inertBackend(); if (platform === 'darwin') return exists(CAFFEINATE) ? macBackend() : undefined; if (platform === 'linux') { const inhibit = SYSTEMD_INHIBIT_PATHS.find(exists); @@ -209,7 +227,7 @@ function owned(record: KeepAwakeRecord, probe: ProcessProbe): boolean { if (!line) return false; if (record.backend === 'macos-caffeinate') return line === [record.command, ...record.args].join(' '); // Linux carries the token in argv; Windows carries it inside the encoded script (the last argument). - return record.backend === 'linux-systemd-inhibit' ? line.includes(`nmsh-keep-awake=${record.token}`) : line.includes(record.args.at(-1)!); + return record.backend === 'linux-systemd-inhibit' || record.backend === 'inert' ? line.includes(`nmsh-keep-awake=${record.token}`) : line.includes(record.args.at(-1)!); } export type StartResult = @@ -241,6 +259,9 @@ export class KeepAwakeController { private clear(): void { rmSync(this.path, {force: true}); } + /** The stored record without an ownership check: cheap enough to poll for presentation, never used to stop anything. */ + peek(): KeepAwakeRecord | undefined { return this.load(); } + /** Verified state. Unprovable records are cleared (never killed). */ status(): KeepAwakeStatus { const record = this.load(); diff --git a/src/keepAwake/presentation.ts b/src/keepAwake/presentation.ts new file mode 100644 index 00000000..a84d988d --- /dev/null +++ b/src/keepAwake/presentation.ts @@ -0,0 +1,218 @@ +import {semanticIcon} from '../prompt/glyphChoices.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth} from '../util/text.js'; +import {formatDuration, MODE_LABELS, type KeepAwakeMode} from './keepAwake.js'; + +/** + * Keep Awake presentation: how an active assertion shows up in NMSh chrome. + * + * Keep Awake is NMSh-owned composer chrome, never prompt or provider output. + * The prompt owns its structural space and this accessory moves around it: + * it takes a free composer edge, else a row adjacent to the composer, and + * never truncates or rewrites prompt, right prompt or editor content. When + * Keep Awake is off nothing here renders anything. + */ + +export const AWAKE_PLACEMENTS = ['edge', 'above', 'input'] as const; +export type AwakePlacement = typeof AWAKE_PLACEMENTS[number]; +export const AWAKE_PLACEMENT_LABELS: Record = {edge: 'Composer edge', above: 'Above composer', input: 'Input row'}; + +export const AWAKE_DISPLAYS = ['text', 'icon', 'iconText'] as const; +export type AwakeDisplay = typeof AWAKE_DISPLAYS[number]; +export const AWAKE_DISPLAY_LABELS: Record = {text: 'Text', icon: 'Icon', iconText: 'Icon + text'}; + +export const AWAKE_SAVER_POSITIONS = ['topLeft', 'topCenter', 'topRight', 'bottomLeft', 'bottomCenter', 'bottomRight'] as const; +export type AwakeSaverPosition = typeof AWAKE_SAVER_POSITIONS[number]; +export const AWAKE_SAVER_POSITION_LABELS: Record = { + topLeft: 'Top left', topCenter: 'Top center', topRight: 'Top right', bottomLeft: 'Bottom left', bottomCenter: 'Bottom center', bottomRight: 'Bottom right', +}; + +export const AWAKE_IDLE_AFTER = [15, 30, 60, 120, 300] as const; + +export interface KeepAwakePresentation { + placement: AwakePlacement; + display: AwakeDisplay; + /** Expand to duration + the muted stop hint after no NMSh input for `idleAfterSeconds`. */ + idleReminder: boolean; + idleAfterSeconds: number; + screensaver: boolean; + screensaverPosition: AwakeSaverPosition; +} + +export const DEFAULT_KEEP_AWAKE_PRESENTATION: Readonly = { + placement: 'edge', display: 'text', idleReminder: true, idleAfterSeconds: 30, screensaver: true, screensaverPosition: 'bottomLeft', +}; + +export function normalizeKeepAwakePresentation(value: unknown): KeepAwakePresentation { + const v = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + const d = DEFAULT_KEEP_AWAKE_PRESENTATION; + const pick = (list: readonly T[], key: string, fallback: T): T => list.includes(v[key] as T) ? v[key] as T : fallback; + return { + placement: pick(AWAKE_PLACEMENTS, 'placement', d.placement), + display: pick(AWAKE_DISPLAYS, 'display', d.display), + idleReminder: typeof v.idleReminder === 'boolean' ? v.idleReminder : d.idleReminder, + idleAfterSeconds: AWAKE_IDLE_AFTER.includes(v.idleAfterSeconds as typeof AWAKE_IDLE_AFTER[number]) ? v.idleAfterSeconds as number : d.idleAfterSeconds, + screensaver: typeof v.screensaver === 'boolean' ? v.screensaver : d.screensaver, + screensaverPosition: pick(AWAKE_SAVER_POSITIONS, 'screensaverPosition', d.screensaverPosition), + }; +} + +/** The facts presentation needs: the same record the controller verified. */ +export interface AwakeFacts {mode: KeepAwakeMode; startedAt: number; timeoutSeconds?: number} + +/** How much of the label fits: full (Awake · Display), short (Awake) or glyph (the icon alone, when the glyph mode has one). */ +export type AwakeForm = 'full' | 'short' | 'glyph'; + +/** Plain label text. Icon mode falls back to text when the glyph mode has no icon (Safe/ASCII). */ +export function awakeLabel(facts: AwakeFacts, display: AwakeDisplay, form: AwakeForm = 'full', icon = semanticIcon('awake')): string { + const text = form === 'full' ? `Awake · ${MODE_LABELS[facts.mode]}` : 'Awake'; + if (form === 'glyph') return icon || 'Awake'; + if (display === 'icon' && icon) return form === 'full' ? `${icon} ${MODE_LABELS[facts.mode]}` : icon; + if (display === 'iconText' && icon) return `${icon} ${text}`; + return text; +} + +/** Elapsed time, or what is left when a timeout was set: factual and coarse (no seconds after the first minute). */ +export function awakeDuration(facts: AwakeFacts, now: number): string { + const elapsed = Math.max(0, Math.floor((now - facts.startedAt) / 1000)); + if (facts.timeoutSeconds) { + const left = Math.max(0, facts.timeoutSeconds - elapsed); + return `${formatDuration(left >= 60 ? left - left % 60 : left)} left`; + } + return formatDuration(elapsed >= 60 ? elapsed - elapsed % 60 : Math.max(1, elapsed)); +} + +export const AWAKE_STOP_HINT = '/zoomies stop'; + +/** Semantic roles: informational while active, the same identity muted for the idle reminder. Theme-aware; NO_COLOR and 256-color follow colorEscape. */ +export const awakeStyle = { + active: () => foreground(UI_COLORS.accent), + muted: () => foreground(UI_COLORS.subtle), + failure: () => foreground(UI_COLORS.failure), + reset: '\u001b[0m', +}; + +// ---- Composer accessory slots ------------------------------------------------------------------ + +export type SlotState = 'available' | 'occupied' | 'unavailable'; +export type AccessorySlot = 'topEdge' | 'bottomEdge' | 'adjacentRow' | 'inputTrailing'; + +export interface ComposerSlots {topEdge: SlotState; bottomEdge: SlotState; inputTrailing: SlotState} + +/** + * Resolved every frame from real composer geometry. The saved preference is + * never changed by a fallback: Composer edge means "a free edge if there is + * one", Input row means "only when it is completely safe". + */ +export function resolveAccessorySlot(placement: AwakePlacement, slots: ComposerSlots): AccessorySlot { + if (placement === 'above') return 'adjacentRow'; + if (placement === 'input') return slots.inputTrailing === 'available' ? 'inputTrailing' : 'adjacentRow'; + if (slots.topEdge === 'available') return 'topEdge'; + if (slots.bottomEdge === 'available') return 'bottomEdge'; + return 'adjacentRow'; +} + +/** Rule cells kept on each side of an accessory, so the edge still reads as a divider. */ +export const EDGE_LEAD_MIN = 6; +export const EDGE_TRAIL = 2; + +/** Whether an accessory of this width fits on an edge of `columns` cells. */ +export function edgeFits(columns: number, accessoryWidth: number): boolean { + return accessoryWidth > 0 && columns >= accessoryWidth + 2 + EDGE_LEAD_MIN + EDGE_TRAIL; +} + +/** + * Cells [start, end) of an ANSI string, carrying the SGR state in effect at + * `start`. Divider rules are single-width glyphs, which is all this needs. + */ +export function sliceAnsiCells(value: string, start: number, end: number): string { + let out = ''; + let lastSgr = ''; + let cell = 0; + const pattern = /(\u001b\[[0-?]*[ -/]*[@-~])|([\s\S])/gu; + for (const match of value.matchAll(pattern)) { + if (match[1]) { + if (cell < start) { if (match[1].endsWith('m')) lastSgr = match[1]; } + else if (cell < end) out += match[1]; + continue; + } + const width = displayWidth(match[2]!); + if (cell >= start && cell + width <= end) out += match[2]; + cell += width; + if (cell >= end) break; + } + return `${lastSgr}${out}`; +} + +/** + * One composer edge: the painted rule with the accessory set into its + * trailing portion. The result is exactly `width` cells; the rule keeps its + * own colors (and Chroma) and the accessory keeps its own, so animating the + * rule never touches the accessory text. + */ +export function renderComposerEdge(options: {rule: string; width: number; accessory?: {ansi: string; width: number}}): string { + const {rule, width, accessory} = options; + if (!accessory || !edgeFits(width, accessory.width)) return rule; + const lead = width - accessory.width - 2 - EDGE_TRAIL; + return `${sliceAnsiCells(rule, 0, lead)}\u001b[0m ${accessory.ansi} \u001b[0m${sliceAnsiCells(rule, width - EDGE_TRAIL, width)}\u001b[0m`; +} + +/** Columns the accessory occupies on a composed edge row (for keeping transition tints off it). */ +export function edgeAccessoryColumns(width: number, accessoryWidth: number): {start: number; end: number} | undefined { + if (!edgeFits(width, accessoryWidth)) return undefined; + const start = width - accessoryWidth - 1 - EDGE_TRAIL; + return {start, end: start + accessoryWidth}; +} + +// ---- What to show this frame ----------------------------------------------------------------- + +export interface AwakeView { + /** Compact label for an edge or the input row, styled. */ + compact: {ansi: string; width: number}; + /** The idle form for the same slot when it fits; otherwise undefined and `reminder` carries the extra facts. */ + expanded?: {ansi: string; width: number}; + /** Muted duration + stop hint for one adjacent row (never repeating the compact label). */ + reminder: {ansi: string; width: number}; + /** One self-contained row for the adjacent slot: compact, plus the idle facts while idle. */ + row: (columns: number) => string; +} + +const styled = (text: string, style: string) => ({ansi: `${style}${text}${awakeStyle.reset}`, width: displayWidth(text)}); + +export function awakeView(facts: AwakeFacts, settings: KeepAwakePresentation, idle: boolean, now: number, icon = semanticIcon('awake')): AwakeView { + const label = awakeLabel(facts, settings.display, 'full', icon); + const duration = awakeDuration(facts, now); + const compact = styled(label, awakeStyle.active()); + const expandedText = `${label} · ${duration}`; + const expanded = {ansi: `${awakeStyle.active()}${label}${awakeStyle.reset}${awakeStyle.muted()} · ${duration} ${AWAKE_STOP_HINT}${awakeStyle.reset}`, + width: displayWidth(`${expandedText} ${AWAKE_STOP_HINT}`)}; + const reminder = styled(`${duration} ${AWAKE_STOP_HINT}`, awakeStyle.muted()); + return { + compact, + ...(idle ? {expanded} : {}), + reminder, + row: (columns: number) => { + if (!idle) return fitRow(compact, columns); + const gap = columns - displayWidth(expandedText) - displayWidth(AWAKE_STOP_HINT) - 1; + if (gap >= 2) return `${awakeStyle.active()}${label}${awakeStyle.reset}${awakeStyle.muted()} · ${duration}${' '.repeat(gap)}${AWAKE_STOP_HINT}${awakeStyle.reset}`; + return fitRow(compact, columns); + }, + }; +} + +function fitRow(part: {ansi: string; width: number}, columns: number): string { + return part.width <= columns ? part.ansi : `${awakeStyle.active()}Awake${awakeStyle.reset}`; +} + +// ---- Screensaver -------------------------------------------------------------------------- + +/** The positioned screensaver status (one row, inset one cell from the frame edge). */ +export function placeOnSaver(rows: readonly string[], columns: number, text: {ansi: string; width: number}, position: AwakeSaverPosition, + overlay: (row: string, column: number, ansi: string, width: number) => string): string[] { + if (!rows.length || text.width + 2 > columns) return [...rows]; + const row = position.startsWith('top') ? 0 : rows.length - 1; + const column = position.endsWith('Left') ? 1 : position.endsWith('Right') ? columns - text.width - 1 : Math.floor((columns - text.width) / 2); + const out = [...rows]; + out[row] = overlay(out[row] ?? '', column, text.ansi, text.width); + return out; +} diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 770f6fee..32384865 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -37,6 +37,7 @@ import {normalizeVibrance, type Vibrance} from '../chroma/color.js'; import {isShellId, type ShellId} from '../shell/adapters/ShellAdapter.js'; import {normalizeProfiles, type AgentProfile} from '../agents/sessions/manager.js'; import {OPEN_WITH_IDS, type OpenWith} from '../host/HostActions.js'; +import {DEFAULT_KEEP_AWAKE_PRESENTATION, normalizeKeepAwakePresentation, type KeepAwakePresentation} from '../keepAwake/presentation.js'; export type WelcomeProviderId = 'vespyr' | 'fastfetch' | 'neofetch' | 'macchina' | 'zigfetch' | 'none'; export const WELCOME_PROVIDER_IDS: readonly WelcomeProviderId[] = ['vespyr', 'fastfetch', 'neofetch', 'macchina', 'zigfetch', 'none']; @@ -546,6 +547,8 @@ export interface PromptConfiguration { themeBridge: ThemeBridgeSettings; cursor: CursorSettings; statusStrip: StatusStripSettings; + /** How an active Keep Awake shows in NMSh chrome (placement, display, idle reminder, screensaver). Off shows nothing. */ + keepAwake: KeepAwakePresentation; idleVisuals: IdleVisualSettings; liveActivity: LiveActivitySettings; /** Where NMSh chrome (frames, rules, tabs, selection, accents) takes its colors from. */ @@ -657,6 +660,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { promptSymbol: 'chevron', cursor: {...DEFAULT_CURSOR}, statusStrip: {...DEFAULT_STATUS_STRIP}, + keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION}, agentProfiles: [], sessionNotices: true, askRecord: true, @@ -749,7 +753,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio ? [...new Set(value.ignoredInstallSuggestions.filter((id): id is string => typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}$/u.test(id)))].slice(0, 256) : []; const promptSymbolCustom = normalizeCustomGlyph(value.promptSymbolCustom); - const tooling = {motion: normalizeMotion(value.motion), pastePreview: (value.pastePreview === 'always' || value.pastePreview === 'off' ? value.pastePreview : 'smart') as 'smart' | 'always' | 'off', cursor: normalizeCursor(value.cursor), statusStrip: normalizeStatusStrip(value.statusStrip), idleVisuals: normalizeIdleVisuals(value.idleVisuals), liveActivity: normalizeLiveActivity(value.liveActivity), uiChrome: normalizeUiChrome(value.uiChrome), + const tooling = {motion: normalizeMotion(value.motion), pastePreview: (value.pastePreview === 'always' || value.pastePreview === 'off' ? value.pastePreview : 'smart') as 'smart' | 'always' | 'off', cursor: normalizeCursor(value.cursor), statusStrip: normalizeStatusStrip(value.statusStrip), keepAwake: normalizeKeepAwakePresentation(value.keepAwake), idleVisuals: normalizeIdleVisuals(value.idleVisuals), liveActivity: normalizeLiveActivity(value.liveActivity), uiChrome: normalizeUiChrome(value.uiChrome), sessionNotices: value.sessionNotices !== false, agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, askRecord: value.askRecord !== false, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, localUnderstanding: normalizeLocalUnderstanding(value.localUnderstanding), shellBackend: isShellId(value.shellBackend) ? value.shellBackend : 'zsh', diff --git a/src/prompt/glyphChoices.ts b/src/prompt/glyphChoices.ts index 5de265ff..a9eed7ce 100644 --- a/src/prompt/glyphChoices.ts +++ b/src/prompt/glyphChoices.ts @@ -164,6 +164,8 @@ export const SEMANTIC_ICONS = { update: {nerd: '', unicode: '↻', ascii: '^'}, sparkle: {nerd: '✦', unicode: '✦', ascii: '*'}, moon: {nerd: '', unicode: '☾', ascii: ''}, + /** Keep Awake: an open eye (no mascot, no cup); Safe/ASCII has none, so the text carries it. */ + awake: {nerd: '\u{f06e}', unicode: '\u25c9', ascii: ''}, palette: {nerd: '', unicode: '', ascii: ''}, } as const satisfies Record; export type SemanticIconId = keyof typeof SEMANTIC_ICONS; diff --git a/src/status/StatusStrip.ts b/src/status/StatusStrip.ts index 850889db..1d6437f4 100644 --- a/src/status/StatusStrip.ts +++ b/src/status/StatusStrip.ts @@ -153,26 +153,37 @@ export function stripItems(settings: StatusStripSettings, stats: SystemStats, no return items; } +/** Keep Awake in the strip: present whenever it is active and the strip is on; its forms from widest to narrowest. */ +export interface StripAwake {full: string; short: string; glyph: string} + /** * The right-aligned strip row, or '' when nothing fits. Lower-priority items * (uptime, CPU, RAM) drop first so the clock survives on narrow terminals. + * An active Keep Awake ranks above all of them: it narrows (Awake · Display, + * Awake, its glyph) before anything else would have to drop it. */ -export function renderStatusStrip(settings: StatusStripSettings, stats: SystemStats, columns: number, now?: Date): string { +export function renderStatusStrip(settings: StatusStripSettings, stats: SystemStats, columns: number, now?: Date, awake?: StripAwake): string { if (!settings.enabled || columns < STRIP_MIN_COLUMNS) return ''; let items = stripItems(settings, stats, now); const subtle = foreground(UI_COLORS.subtle); const secondary = foreground(UI_COLORS.secondary); + const accent = foreground(UI_COLORS.accent); const reset = '\u001B[0m'; - const plain = (list: StripItem[]) => list.map(item => { const icon = semanticIcon(item.icon); return icon ? `${icon} ${item.text}` : item.text; }).join(' · '); - while (items.length && displayWidth(plain(items)) > columns - 2) { - const drop = items.reduce((worst, item) => item.priority > worst.priority ? item : worst); - items = items.filter(item => item !== drop); + const forms = awake ? [...new Set([awake.full, awake.short, awake.glyph])] : []; + let form = 0; + const awakeText = () => forms[form]; + const plain = (list: StripItem[]) => [...list.map(item => { const icon = semanticIcon(item.icon); return icon ? `${icon} ${item.text}` : item.text; }), ...(awakeText() ? [awakeText()!] : [])].join(' · '); + while (displayWidth(plain(items)) > columns - 2) { + // Decorative items drop first, lowest priority first; Keep Awake only narrows, then goes last of all. + if (items.length) { const drop = items.reduce((worst, item) => item.priority > worst.priority ? item : worst); items = items.filter(item => item !== drop); } + else if (form < forms.length) form += 1; + else break; } - if (!items.length) return ''; - const body = items.map(item => { + if (!items.length && !awakeText()) return ''; + const body = [...items.map(item => { const icon = semanticIcon(item.icon); return `${icon ? `${subtle}${icon} ` : ''}${secondary}${item.text}`; - }).join(`${subtle} · `); + }), ...(awakeText() ? [`${accent}${awakeText()}`] : [])].join(`${subtle} · `); const width = displayWidth(plain(items)); return `${' '.repeat(Math.max(0, columns - width - 1))}${body}${reset}`; } diff --git a/src/tools/ToolsPanel.ts b/src/tools/ToolsPanel.ts index 24676dc8..3cfb2c33 100644 --- a/src/tools/ToolsPanel.ts +++ b/src/tools/ToolsPanel.ts @@ -2,7 +2,7 @@ import type {Key} from '../terminal/keys.js'; import {clearProviderDetection, type ProviderStatus, type ProviderInstall} from '../providers/providers.js'; import {TaskProgress, renderTaskProgress} from '../status/TaskProgress.js'; import {createConfirm, editText, handleConfirmKey, renderConfirm, type ConfirmState} from '../ui/formControls.js'; -import {renderTabStrip, framePanel} from '../ui/PanelShell.js'; +import {renderTabStrip, framePanel, onSelectedBand, selectedRowBand} from '../ui/PanelShell.js'; import {colorLevel} from '../presentation/capabilities.js'; import {foregroundOf} from '../chroma/chroma.js'; import {languageIdentity} from '../languages/linguistLanguageColors.js'; @@ -395,14 +395,14 @@ function toolRow(state: ToolsPanel, tool: Tool, selected: boolean, columns: numb const badge = statusBadge(state, tool); const labelWidth = columns >= 60 ? 22 : Math.max(8, columns - 18); const label = truncateText(tool.label, labelWidth - 1).padEnd(labelWidth); - const pointer = selected ? `${ACCENT}${GLYPHS.selection}` : ' '; - const status = columns >= 34 ? `${badge.color}${badge.text.padEnd(18)}` : `${badge.color}${badge.text.slice(0, 1)} `; - const extra = columns >= 60 ? `${selected ? SECONDARY : SUBTLE}${toolBadges(state, tool).join(' · ')}` : ''; - const row = ` ${pointer} ${selected ? `${BOLD}${PRIMARY}` : SECONDARY}${label}${RESET}${selected ? SELECTED : ''}${status}${extra}`; - if (!selected) return truncateAnsi(`${row}${RESET}`, columns); - // Fill the whole row so the selection reads as a band, not just colored text. - const plain = truncateAnsi(row, columns); - return `${SELECTED}${plain}${SELECTED}${' '.repeat(Math.max(0, columns - displayWidth(plain)))}${RESET}`; + const statusText = columns >= 34 ? badge.text.padEnd(18) : `${badge.text.slice(0, 1)} `; + const badges = columns >= 60 ? toolBadges(state, tool).join(' · ') : ''; + if (!selected) return truncateAnsi(` ${SECONDARY}${label}${RESET}${badge.color}${statusText}${SUBTLE}${badges}${RESET}`, columns); + // The shared selected band (the active tab's treatment): pointer, bold label, and every quiet part lifted + // to the band's foreground; Installed and Needs attention keep their meaning colors. + const quiet = onSelectedBand(); + const statusColor = badge.color === SUBTLE ? quiet : badge.color; + return selectedRowBand(` ${ACCENT}${GLYPHS.selection}${RESET} ${BOLD}${label}${RESET}${statusColor}${statusText}${RESET}${quiet}${badges}`, columns); } const BOLD = '\u001b[1m'; diff --git a/src/ui/LayoutPanel.ts b/src/ui/LayoutPanel.ts index ba2e7473..93beaa59 100644 --- a/src/ui/LayoutPanel.ts +++ b/src/ui/LayoutPanel.ts @@ -113,6 +113,7 @@ export function renderLayoutPreview(choice: LayoutChoice, columns: number, rows: status: [], notices: [], find: [], + awake: [], }; const frame = new Array(plan.rows).fill(''); for (const region of plan.regions) { diff --git a/src/ui/PanelShell.ts b/src/ui/PanelShell.ts index 73b714b0..086f83f8 100644 --- a/src/ui/PanelShell.ts +++ b/src/ui/PanelShell.ts @@ -3,6 +3,7 @@ import {background, foreground, UI_COLORS} from './palette.js'; import {truncateAnsi, displayWidth} from '../util/text.js'; import {theme} from '../chroma/chroma.js'; import {renderSurface} from './surface.js'; +import {colorLevel} from '../presentation/capabilities.js'; const RESET = '\u001B[0m'; const BOLD = '\u001B[1m'; @@ -51,6 +52,24 @@ export function renderTabStrip(tabs: readonly string[], selected: number, column return truncateAnsi(line, width); } +/** + * The strong selected-row treatment, shared with the active tab: one + * full-width deep-lavender band with the theme's project foreground. Without + * color it is reverse video, so the selection never depends on color alone. + * Every reset inside the row re-opens the band, so styled parts stay on it. + */ +export function selectedRowBand(row: string, columns: number): string { + const none = colorLevel() === 'none'; + const band = none ? '\u001B[7m' : `${background(UI_COLORS.projectBackground)}${foreground(UI_COLORS.projectForeground)}`; + const body = truncateAnsi(row, columns).replaceAll(RESET, `${RESET}${band}`); + return `${band}${body}${band}${' '.repeat(Math.max(0, columns - displayWidth(body)))}${RESET}`; +} + +/** Foreground for quiet text on the selected band: readable on it, never the dim muted gray. */ +export function onSelectedBand(): string { + return colorLevel() === 'none' ? '' : foreground(UI_COLORS.projectForeground); +} + /** Framing belongs to the live overlay, never to OutputBuffer or an archive. */ export function framePanel(rows: string[], columns: number, treatment?: TreatmentSettings): string[] { const framed = renderSurface(rows, columns, {frame: 'topLine', frameColor: theme('separator')}); diff --git a/tests/keepAwakeForeground.test.ts b/tests/keepAwakeForeground.test.ts new file mode 100644 index 00000000..e3081489 --- /dev/null +++ b/tests/keepAwakeForeground.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, processAlive, until} from './helpers/liveFrontend.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; + +/** + * Regression: /caffeinate, /awake and /zoomies are NMSh frontend actions. They + * start an NMSh-owned background process through the controller and hand the + * composer straight back; they never run anything in the user's shell, never + * become the shell's foreground command and never need Ctrl+C to return. + * + * The real slash dispatch, controller, ownership record and presentation run; + * only the backend is the deterministic inert one, so no test ever keeps a + * machine awake. + */ +const ENV = {NMSH_DETERMINISTIC: '1', NMSH_KEEP_AWAKE_BACKEND: 'inert'}; + +test('slash Keep Awake runs in the background and the shell stays ready (no Ctrl+C needed)', {timeout: 120_000}, async () => { + const box = new LiveSandbox({}, ENV); + const record = join(box.config, 'nmsh', 'keep-awake.json'); + const owned = () => existsSync(record) ? JSON.parse(readFileSync(record, 'utf8')) as {pid: number; token: string; mode: string} : undefined; + try { + const frontend = box.launch(); + await frontend.waitFor(/❯/u); + await frontend.run('echo READY', /READY/u); + + // Bare /zoomies only opens the panel: nothing is launched yet. + let mark = frontend.mark; + frontend.pty.write('/zoomies\r'); + await frontend.waitFor(/\/caffeinate · \/awake · \/zoomies/u, mark); + assert.equal(owned(), undefined, 'the panel alone starts no backend'); + frontend.pty.write('\u001b'); + + // A start from the slash command returns at once. + await frontend.run('/zoomies display 1m', /Keep Awake on · Display for 1m/u); + const first = owned(); + assert.ok(first && processAlive(first.pid), 'an NMSh-owned background process holds the assertion'); + assert.equal(first.mode, 'display'); + // The shell is idle at its prompt: an ordinary command runs and completes right away. + await frontend.run("printf 'still-responsive\\n'", /still-responsive/u); + assert.ok(processAlive(first.pid), 'Keep Awake keeps running while the shell works'); + await frontend.waitFor(/Awake · Display/u, 0); + + await frontend.run('/zoomies stop', /Keep Awake stopped \(Display\)/u); + await until(() => !processAlive(first.pid), 5000, 'the owned process to end'); + assert.equal(owned(), undefined); + + // The panel path: Enter starts, and the composer comes back without Esc or Ctrl+C. + mark = frontend.mark; + frontend.pty.write('/awake\r'); + await frontend.waitFor(/\/caffeinate · \/awake · \/zoomies/u, mark); + mark = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/Keep Awake on · Idle/u, mark); + const second = owned(); + assert.ok(second && processAlive(second.pid)); + await frontend.run("printf 'composer-is-back\\n'", /composer-is-back/u); + await frontend.run('/awake stop', /Keep Awake stopped \(Idle\)/u); + await until(() => !processAlive(second.pid), 5000, 'the owned process to end'); + + // None of this was a shell command: no journaled caffeinate, no foreground lifecycle. + const commands = (await box.transcripts().list()).flatMap(session => session.transcript.records).map(item => item.command); + assert.ok(!commands.some(command => /caffeinate|-e .*nmsh-keep-awake/u.test(command ?? '')), commands.join('\n')); + assert.ok(commands.includes("printf 'still-responsive\\n'")); + } finally { + const left = owned(); + if (left && processAlive(left.pid)) process.kill(left.pid, 'SIGTERM'); + await box.dispose(); + } +}); + +test('a typed shell caffeinate is an ordinary shell command, never intercepted', () => { + for (const command of ['caffeinate -i', 'caffeinate', 'caffeinate -d -t 5', 'sudo caffeinate -s']) assert.equal(parseSlashCommand(command), undefined, command); + for (const command of ['/caffeinate', '/awake display', '/zoomies stop']) assert.equal(parseSlashCommand(command)?.kind, 'keepAwake', command); +}); diff --git a/tests/keepAwakePresentation.test.ts b/tests/keepAwakePresentation.test.ts new file mode 100644 index 00000000..74ea7686 --- /dev/null +++ b/tests/keepAwakePresentation.test.ts @@ -0,0 +1,305 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {dividerAnimated, normalizeTreatmentSettings} from '../src/chroma/treatment.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {regionOf, type ScreenPlan} from '../src/app/screenPlan.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import {renderStatusStrip} from '../src/status/StatusStrip.js'; +import {DEFAULT_STATUS_STRIP, type PromptConfiguration} from '../src/prompt/configuration.js'; +import type {KeepAwakeRecord} from '../src/keepAwake/keepAwake.js'; +import { + AWAKE_SAVER_POSITIONS, awakeLabel, DEFAULT_KEEP_AWAKE_PRESENTATION, edgeFits, normalizeKeepAwakePresentation, placeOnSaver, + renderComposerEdge, resolveAccessorySlot, sliceAnsiCells, type KeepAwakePresentation, +} from '../src/keepAwake/presentation.js'; + +const RECORD: KeepAwakeRecord = {version: 1, token: 'a'.repeat(32), backend: 'macos-caffeinate', mode: 'display', pid: 1, + startedAt: Date.now() - 47 * 60_000, command: '/usr/bin/caffeinate', args: ['-d', '-i']}; + +type Frame = {rows: string[]; plain: string[]; plan: ScreenPlan; cursorRow: number; cursorColumn: number}; + +interface Scene { + config?: Partial; + awake?: Partial; + active?: boolean; + idle?: boolean; + text?: string; + columns?: number; + rows?: number; + provider?: PromptConfiguration['provider']; +} + +/** A real TerminalApp frame with Keep Awake state injected (the controller's verified record). */ +function frame(scene: Scene = {}): Frame { + const app = new TerminalApp() as any; + const columns = scene.columns ?? 80, rows = scene.rows ?? 20; + try { + Object.defineProperty(app, 'dimensions', {value: () => ({columns, rows})}); + Object.defineProperty(app, 'fetchSuggestions', {value: async () => {}}); + app.output.beginCommand('ls', ['❯ ls']); + app.output.write('alpha\nbeta\n'); + app.output.complete(0); + app.promptConfiguration = {...app.promptConfiguration, ...scene.config, keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION, ...scene.awake}}; + if (scene.provider) { + app.effectivePromptProvider = scene.provider; + if (scene.provider !== 'nmsh' && scene.provider !== 'none') app.externalPrompt = {ansi: 'EXTERNAL-PROMPT ~/demo main', text: 'EXTERNAL-PROMPT ~/demo main'}; + } + app.awakeRecord = scene.active === false ? undefined : RECORD; + app.lastUserInput = scene.idle ? 0 : Date.now(); + if (scene.text) app.editor.insert(scene.text); + const frames: Array<{rows: string[]; cursorRow: number; cursorColumn: number}> = []; + app.renderer.render = (next: {rows: string[]; cursorRow: number; cursorColumn: number}) => { frames.push(next); }; + app.render(); + const last = frames.at(-1)!; + return {rows: last.rows, plain: last.rows.map(stripAnsi), plan: app.presentationFrame.plan, cursorRow: last.cursorRow, cursorColumn: last.cursorColumn}; + } finally { + app.stop(0); + app.session.kill(); + } +} + +const rowOf = (f: Frame, kind: Parameters[1]) => { const region = regionOf(f.plan, kind); return region ? f.plain[region.top]! : undefined; }; +const everyRowFits = (f: Frame, columns: number) => f.rows.forEach((row, index) => assert.ok(displayWidth(stripAnsi(row)) <= columns, `row ${index} overflows: ${stripAnsi(row)}`)); + +let config: ReturnType; +test.before(() => { config = isolateConfig(); }); +test.after(() => config.restore()); + +test('Off renders nothing anywhere: no icon, no placeholder, no row', () => { + for (const placement of ['edge', 'above', 'input'] as const) { + const off = frame({active: false, awake: {placement}, config: {statusStrip: {...DEFAULT_STATUS_STRIP, enabled: true}}, text: 'npm test'}); + assert.ok(!off.plain.some(row => /Awake|zoomies/u.test(row)), placement); + assert.equal(regionOf(off.plan, 'awake'), undefined); + assert.equal(off.plan.awake, undefined); + } +}); + +test('Composer edge: a plain top divider hosts it; the prompt, input and bottom rule are unchanged', () => { + for (const layout of [{composerLayout: 'oneLine'}, {composerLayout: 'twoLine', placement: 'composer'}] as const) { + const on = frame({config: layout, text: 'npm test'}); + const off = frame({config: layout, text: 'npm test', active: false}); + assert.equal(on.plan.awake?.slot, 'topEdge'); + assert.match(rowOf(on, 'composerBorder')!, /^─+ Awake · Display ──$/u); + assert.equal(displayWidth(rowOf(on, 'composerBorder')!), 80); + for (const kind of ['prompt', 'input', 'separator'] as const) assert.equal(rowOf(on, kind), rowOf(off, kind), `${layout.composerLayout} ${kind}`); + assert.deepEqual([on.cursorRow - regionOf(on.plan, 'input')!.top, on.cursorColumn], [off.cursorRow - regionOf(off.plan, 'input')!.top, off.cursorColumn]); + } +}); + +test('Composer edge: a header prompt owns the top edge, so Awake takes the bottom edge and the prompt is untouched', () => { + const on = frame({config: {composerLayout: 'twoLine', placement: 'header'}}); + const off = frame({config: {composerLayout: 'twoLine', placement: 'header'}, active: false}); + assert.equal(on.plan.awake?.slot, 'bottomEdge'); + assert.equal(rowOf(on, 'prompt'), rowOf(off, 'prompt'), 'prompt row byte-for-byte unchanged'); + assert.ok(!rowOf(on, 'prompt')!.includes('Awake')); + assert.match(rowOf(on, 'separator')!, /Awake · Display ──$/u); +}); + +test('Dividers Off: neither edge exists, the dividers stay off, and one adjacent row carries it', () => { + const on = frame({config: {composerDividers: false}}); + assert.equal(on.plan.awake?.slot, 'adjacentRow'); + assert.equal(regionOf(on.plan, 'composerBorder'), undefined); + assert.equal(regionOf(on.plan, 'separator'), undefined); + const awake = regionOf(on.plan, 'awake')!; + assert.equal(on.plain[awake.top], 'Awake · Display'); + assert.ok(awake.top < regionOf(on.plan, 'prompt')!.top, 'directly above the composer'); +}); + +test('slot policy: top, else bottom, else adjacent; the saved preference is never rewritten', () => { + const edge = (top: 'available' | 'occupied' | 'unavailable', bottom: 'available' | 'unavailable') => + resolveAccessorySlot('edge', {topEdge: top, bottomEdge: bottom, inputTrailing: 'unavailable'}); + assert.equal(edge('available', 'available'), 'topEdge'); + assert.equal(edge('occupied', 'available'), 'bottomEdge'); + assert.equal(edge('unavailable', 'available'), 'bottomEdge', 'top too narrow, bottom fits'); + assert.equal(edge('occupied', 'unavailable'), 'adjacentRow', 'top occupied + bottom unavailable'); + assert.equal(edge('unavailable', 'unavailable'), 'adjacentRow', 'neither edge safe'); + assert.equal(resolveAccessorySlot('above', {topEdge: 'available', bottomEdge: 'available', inputTrailing: 'available'}), 'adjacentRow'); + assert.equal(resolveAccessorySlot('input', {topEdge: 'available', bottomEdge: 'available', inputTrailing: 'unavailable'}), 'adjacentRow'); + // Too narrow for any edge: the frame falls back, the setting stays Composer edge. + const narrow = frame({columns: 22, config: {composerLayout: 'twoLine', placement: 'header'}}); + assert.equal(narrow.plan.awake?.slot, 'adjacentRow'); + assert.ok(!edgeFits(20, 15)); +}); + +test('Bottom, Top and Flow: placement is relative to the composer, never the screen', () => { + for (const composerPosition of ['bottom', 'top', 'flow'] as const) { + const plain = frame({config: {composerPosition, composerLayout: 'twoLine', placement: 'composer'}}); + const border = regionOf(plain.plan, 'composerBorder')!; + assert.equal(plain.plan.awake?.slot, 'topEdge', composerPosition); + assert.match(plain.plain[border.top]!, /Awake · Display/u); + const header = frame({config: {composerPosition, composerLayout: 'twoLine', placement: 'header'}}); + assert.equal(header.plan.awake?.slot, 'bottomEdge', composerPosition); + assert.match(rowOf(header, 'separator')!, /Awake · Display/u); + const above = frame({config: {composerPosition}, awake: {placement: 'above'}}); + const row = regionOf(above.plan, 'awake')!; + const composerTop = Math.min(...above.plan.regions.filter(region => ['composerBorder', 'prompt', 'input'].includes(region.kind)).map(region => region.top)); + assert.equal(row.top + 1, composerTop, `${composerPosition}: right before the composer block`); + } +}); + +test('Prompt None and an external provider: Keep Awake is NMSh chrome, never provider output', () => { + const none = frame({provider: 'none'}); + assert.equal(none.plan.awake?.slot, 'topEdge'); + assert.match(rowOf(none, 'composerBorder')!, /Awake · Display/u); + for (const placement of ['header', 'composer'] as const) { + const external = frame({provider: 'starship', config: {composerLayout: 'twoLine', placement}}); + const without = frame({provider: 'starship', config: {composerLayout: 'twoLine', placement}, active: false}); + assert.equal(rowOf(external, 'prompt'), rowOf(without, 'prompt'), 'provider output unchanged'); + assert.ok(!rowOf(external, 'prompt')!.includes('Awake')); + assert.ok(external.plain.some(row => row.includes('Awake · Display'))); + } +}); + +test('Input row: reserves real editor width (caret and wrapping agree) and yields when the edit gets long or multi-line', () => { + const on = frame({config: {composerLayout: 'twoLine'}, awake: {placement: 'input'}, text: 'npm test'}); + assert.equal(on.plan.awake?.slot, 'inputTrailing'); + const input = rowOf(on, 'input')!; + assert.match(input, /^❯ npm test +Awake · Display$/u); + assert.equal(displayWidth(input), 80); + assert.equal(on.cursorColumn, 11, 'caret right after the typed text, never inside the status'); + const long = frame({config: {composerLayout: 'twoLine'}, awake: {placement: 'input'}, text: 'x'.repeat(65)}); + assert.equal(long.plan.awake?.slot, 'adjacentRow', 'editable input wins'); + assert.ok(rowOf(long, 'input')!.includes('x'.repeat(65))); + // The one-line Native composer keeps its right prompt on that row: Input row falls back. + const oneLine = frame({config: {composerLayout: 'oneLine'}, awake: {placement: 'input'}, text: 'ls'}); + assert.equal(oneLine.plan.awake?.slot, 'adjacentRow'); +}); + +test('idle reminder: expands on the edge when it fits, else compact edge + one muted reminder row; input collapses it', () => { + const wide = frame({idle: true, config: {composerLayout: 'twoLine', placement: 'header'}}); + assert.match(rowOf(wide, 'separator')!, /Awake · Display · 47m {3}\/zoomies stop ──$/u); + assert.equal(regionOf(wide.plan, 'awake'), undefined); + const narrow = frame({idle: true, columns: 40, config: {composerLayout: 'twoLine', placement: 'header'}}); + assert.match(rowOf(narrow, 'separator')!, /Awake · Display ──$/u); + const reminder = regionOf(narrow.plan, 'awake')!; + assert.equal(narrow.plain[reminder.top], '47m /zoomies stop', 'duration + stop only; the label is not repeated'); + assert.ok(narrow.rows[reminder.top]!.includes('/zoomies stop')); + const active = frame({idle: false, columns: 40, config: {composerLayout: 'twoLine', placement: 'header'}}); + assert.equal(regionOf(active.plan, 'awake'), undefined, 'collapsed after input'); + assert.match(rowOf(active, 'separator')!, /Awake · Display/u, 'still shown while typing'); + const reminderOff = frame({idle: true, awake: {idleReminder: false}}); + assert.ok(!reminderOff.plain.some(row => row.includes('/zoomies stop'))); +}); + +test('Status Strip: active Awake is always in an enabled strip, narrows before it drops, and never enables the strip', () => { + const awake = {full: 'Awake · Display', short: 'Awake', glyph: 'Awake'}; + const stats = {cpu: 8, memory: {used: 4.2 * 1024 ** 3, total: 16 * 1024 ** 3}}; + const settings = {...DEFAULT_STATUS_STRIP, enabled: true, clock: true, cpu: true, ram: true}; + const now = new Date(2026, 0, 1, 6, 44); + assert.match(stripAnsi(renderStatusStrip(settings, stats, 100, now, awake)), /CPU 8% · .*RAM 26% · .*06:44 · Awake · Display$/u, 'Awake joins the end of the strip'); + assert.match(stripAnsi(renderStatusStrip(settings, stats, 36, now, awake)).trim(), /Awake · Display$/u, 'decorative items drop first'); + assert.equal(stripAnsi(renderStatusStrip(settings, stats, 30, now, {...awake, full: 'Awake · Display + System and more'})).trim(), 'Awake'); + assert.equal(renderStatusStrip({...settings, enabled: false}, stats, 100, now, awake), '', 'a disabled strip stays disabled'); + const app = frame({config: {statusStrip: {...settings}}}); + assert.match(app.plain[regionOf(app.plan, 'status')!.top]!, /Awake · Display$/u); +}); + +test('display styles use the glyph abstraction; Safe/ASCII has no icon, so text carries the meaning', () => { + assert.equal(awakeLabel(RECORD, 'text'), 'Awake · Display'); + assert.equal(awakeLabel(RECORD, 'iconText', 'full', 'E'), 'E Awake · Display'); + assert.equal(awakeLabel(RECORD, 'icon', 'full', 'E'), 'E Display'); + assert.equal(awakeLabel(RECORD, 'icon', 'full', ''), 'Awake · Display', 'Safe mode falls back to text'); + assert.equal(awakeLabel({...RECORD, mode: 'all'}, 'text'), 'Awake · All'); +}); + +test('the shared edge renderer is exact-width, keeps rule colors and works without color', () => { + const rule = `\u001b[38;5;99m${'─'.repeat(60)}\u001b[39m`; + const edge = renderComposerEdge({rule, width: 60, accessory: {ansi: '\u001b[38;5;183mAwake · Idle\u001b[0m', width: 12}}); + assert.equal(displayWidth(stripAnsi(edge)), 60); + assert.match(stripAnsi(edge), /^─{44} Awake · Idle ──$/u); + assert.ok(edge.startsWith('\u001b[38;5;99m')); + assert.equal(stripAnsi(sliceAnsiCells(rule, 58, 60)), '──'); + assert.ok(sliceAnsiCells(rule, 58, 60).startsWith('\u001b[38;5;99m'), 'trailing segment carries the rule color'); + const noColor = renderComposerEdge({rule: '─'.repeat(30), width: 30, accessory: {ansi: 'Awake · Idle', width: 12}}); + assert.equal(noColor, `${'─'.repeat(14)}\u001b[0m Awake · Idle \u001b[0m──\u001b[0m`); + assert.equal(renderComposerEdge({rule: '─'.repeat(15), width: 15, accessory: {ansi: 'Awake · Idle', width: 12}}), '─'.repeat(15), 'too narrow: the plain rule'); +}); + +test('Chroma-animated dividers repaint the composed edge: Awake never disappears and its text never shimmers', () => { + const app = new TerminalApp() as any; + try { + Object.defineProperty(app, 'dimensions', {value: () => ({columns: 80, rows: 20})}); + Object.defineProperty(app, 'fetchSuggestions', {value: async () => {}}); + Object.defineProperty(app, 'decorativeMotionAllowed', {value: () => true}); + app.promptConfiguration = {...app.promptConfiguration, composerLayout: 'twoLine', placement: 'header', keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION}, + presentation: normalizeTreatmentSettings({...app.promptConfiguration.presentation, preset: 'aurora', motion: 'travel', rules: true})}; + assert.ok(dividerAnimated(app.promptConfiguration.presentation), 'the divider really animates'); + app.awakeRecord = RECORD; + const frames: string[][] = []; + app.renderer.render = (next: {rows: string[]}) => { frames.push(next.rows); }; + app.render(); + const separator = regionOf(app.presentationFrame.plan, 'separator')!; + const awakeText = (row: string) => /\u001b\[0m (.*Awake · Display.*?)\u001b\[0m \u001b\[0m/u.exec(row)?.[1]; + const first = awakeText(frames.at(-1)![separator.top]!); + assert.ok(first, 'composed edge on the base frame'); + for (const at of [1000, 2500, 7000]) { + app.paintPresentation(Date.now() + at); + const row = frames.at(-1)![separator.top]!; + assert.match(stripAnsi(row), /Awake · Display ──$/u, `animated frame +${at}ms keeps the accessory`); + assert.equal(awakeText(row), first, 'accessory styling is stable while the rule animates'); + } + } finally { + app.stop(0); + app.session.kill(); + } +}); + +test('screensaver: On shows one positioned status in each of the six positions; Off shows nothing; saver rows otherwise untouched', () => { + const saver = Array.from({length: 6}, () => '.'.repeat(40)); + const overlay = (row: string, column: number, ansi: string, width: number) => `${row.slice(0, column)}${ansi}${row.slice(column + width)}`; + const text = {ansi: 'Awake · Idle · 5m', width: 17}; + const expected: Record = {topLeft: [0, 1], topCenter: [0, 11], topRight: [0, 22], bottomLeft: [5, 1], bottomCenter: [5, 11], bottomRight: [5, 22]}; + for (const position of AWAKE_SAVER_POSITIONS) { + const out = placeOnSaver(saver, 40, text, position, overlay); + const [row, column] = expected[position]!; + assert.equal(out[row]!.indexOf('Awake'), column, position); + out.forEach((line, index) => { if (index !== row) assert.equal(line, saver[index]); assert.equal(line.length, 40); }); + } + assert.equal(DEFAULT_KEEP_AWAKE_PRESENTATION.screensaverPosition, 'bottomLeft'); + const app = new TerminalApp() as any; + try { + app.promptConfiguration = {...app.promptConfiguration, keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION}}; + app.awakeRecord = RECORD; + const rows = Array.from({length: 10}, () => ' '.repeat(60)); + assert.match(stripAnsi(app.saverAwake(rows, 60, Date.now()).at(-1)!), /^ Awake · Display · 47m +$/u); + app.promptConfiguration = {...app.promptConfiguration, keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION, screensaver: false}}; + assert.deepEqual(app.saverAwake(rows, 60, Date.now()), rows); + app.awakeRecord = undefined; + app.promptConfiguration = {...app.promptConfiguration, keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION}}; + assert.deepEqual(app.saverAwake(rows, 60, Date.now()), rows, 'Off: nothing on the saver'); + } finally { + app.stop(0); + app.session.kill(); + } +}); + +test('never in transcript, history, /copy or the editor, and every row stays within the terminal width', () => { + for (const scene of [{}, {idle: true}, {awake: {placement: 'above' as const}}, {awake: {placement: 'input' as const}, text: 'echo hi'}, {config: {composerDividers: false}}]) { + const app = new TerminalApp() as any; + try { + Object.defineProperty(app, 'dimensions', {value: () => ({columns: 80, rows: 20})}); + Object.defineProperty(app, 'fetchSuggestions', {value: async () => {}}); + app.output.beginCommand('ls', ['❯ ls']); app.output.write('alpha\n'); app.output.complete(0); + app.promptConfiguration = {...app.promptConfiguration, ...(scene as Scene).config, keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION, ...(scene as Scene).awake}}; + app.awakeRecord = RECORD; + app.lastUserInput = (scene as Scene).idle ? 0 : Date.now(); + if ((scene as Scene).text) app.editor.insert((scene as Scene).text); + app.renderer.render = () => {}; + app.render(); + assert.ok(!app.output.wrapped(80).some((row: {plain: string}) => /Awake|zoomies stop/u.test(row.plain)), 'transcript'); + assert.ok(!/Awake/u.test(app.editor.text), 'editor source'); + assert.ok(!(app.output.recent(1)?.output ?? '').includes('Awake'), '/copy source'); + } finally { + app.stop(0); + app.session.kill(); + } + } + for (const columns of [40, 60, 80, 120]) everyRowFits(frame({columns, idle: true}), columns); +}); + +test('settings normalize to the documented defaults', () => { + assert.deepEqual(normalizeKeepAwakePresentation(undefined), {placement: 'edge', display: 'text', idleReminder: true, idleAfterSeconds: 30, screensaver: true, screensaverPosition: 'bottomLeft'}); + assert.deepEqual(normalizeKeepAwakePresentation({placement: 'input', display: 'iconText', idleReminder: false, idleAfterSeconds: 60, screensaver: false, screensaverPosition: 'topRight'}), + {placement: 'input', display: 'iconText', idleReminder: false, idleAfterSeconds: 60, screensaver: false, screensaverPosition: 'topRight'}); + assert.equal(normalizeKeepAwakePresentation({placement: 'statusbar', idleAfterSeconds: 7}).placement, 'edge'); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 7f1dfbdc..6fc56ac8 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -324,3 +324,34 @@ test('integration activation: runtime evidence from the shell snapshot, separate assert.match(plain, /Shell\s+Active in this shell/u); assert.match(plain, /rc files are never read/u); }); + +test('the selected tool row is an unmistakable band that follows the selection, with color and without it', () => { + const saved = {NO_COLOR: process.env.NO_COLOR, COLORTERM: process.env.COLORTERM}; + const selectedRows = (rows: string[]) => rows.filter(row => row.includes('\u001b[48;') || row.includes('\u001b[7m')); + try { + delete process.env.NO_COLOR; process.env.COLORTERM = 'truecolor'; + const state = createToolsPanel(); + for (const tool of TOOLS) state.statuses[tool.id] = {state: 'missing'}; + const first = renderTools(state, 100, 40); + const band = selectedRows(first).filter(row => !/Discover/u.test(stripAnsi(row))); + assert.equal(band.length, 1, 'exactly one selected tool row'); + const label = visibleTools(state)[0]!.label; + assert.ok(stripAnsi(band[0]!).includes(label)); + assert.equal(displayWidth(stripAnsi(band[0]!)), 100, 'a full-width band, not tinted text'); + assert.ok(!band[0]!.includes('\u001b[38;2;125;133;144m'), 'no dim muted text on the band'); + toolsKey(state, {kind: 'down'}); + const moved = selectedRows(renderTools(state, 100, 40)).filter(row => !/Discover/u.test(stripAnsi(row))); + assert.equal(moved.length, 1); + assert.ok(stripAnsi(moved[0]!).includes(visibleTools(state)[1]!.label), 'the band moves with the selection'); + state.query = visibleTools(state)[3]!.label; + const filtered = selectedRows(renderTools(state, 100, 40)).filter(row => !/Discover/u.test(stripAnsi(row))); + assert.equal(filtered.length, 1, 'search keeps one clear selection'); + process.env.NO_COLOR = '1'; + const plain = renderTools(createToolsPanel(), 60, 30).filter(row => row.includes('\u001b[7m')); + assert.equal(plain.length, 1, 'NO_COLOR: reverse video carries the selection'); + assert.match(stripAnsi(plain[0]!), /^ {2}› /u); + } finally { + if (saved.NO_COLOR === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = saved.NO_COLOR; + if (saved.COLORTERM === undefined) delete process.env.COLORTERM; else process.env.COLORTERM = saved.COLORTERM; + } +}); From 431f043efdcfecf41a16693584819b0d8852ac5d Mon Sep 17 00:00:00 2001 From: J <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:59:14 +0530 Subject: [PATCH 14/16] Document current NMSh architecture for project owners --- ARCHITECTURE.md | 379 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..de1be363 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,379 @@ +# notMyShell Architecture + +## 1. The 30-second explanation + +notMyShell (NMSh) is a terminal frontend. It is not a shell: your real zsh, Bash, or Fish still interprets commands and runs programs. It is not a terminal emulator: Ghostty, Terminal.app, Kitty, or another terminal application still draws the window, fonts, and terminal cells. + +NMSh sits between them and owns the interaction around commands: the composer, suggestions, history presentation, command feedback, session navigation, panels, themes, and integrations. + +```text +Terminal emulator: window, fonts, terminal protocols + ↕ +NMSh frontend: editor, transcript, panels, presentation + ↕ session client +Session owner: usually a separate local NMSh service + ↕ pseudo-terminal (PTY) + Real zsh / Bash / Fish + ↕ + Programs / OS +``` + +The shell stays alive between commands. `cd`, exported variables, aliases, functions, and jobs therefore belong to a real continuing shell, rather than to a simulation assembled by NMSh. This document describes the current implementation, including development features; it is not a statement that every feature described has been released. + +## 2. What NMSh is made from + +The application is **TypeScript running on Node.js**, built into JavaScript for its command-line launcher. Node supplies process management, streams, filesystem access, events, and local sockets. TypeScript makes the contracts between shell events, settings, panels, and actions explicit. + +**node-pty** creates a pseudo-terminal: a communication channel that looks like a terminal to the shell and its programs. Ordinary pipes would not provide the same interactive behavior, terminal sizing, and job control. + +The frontend draws using **ANSI and related terminal escape sequences**. These are byte instructions for colors, cursor movement, screen modes, hyperlinks, and input reporting. NMSh has its own renderer and screen planner rather than a general web UI framework. **string-width** measures terminal cell widths so wide characters and decorative glyphs do not break wrapping and cursor placement. + +Small **shell bootstrap scripts** load the user's normal shell environment, suppress competing shell prompt/editor UI, and report command lifecycle events. Local JSON stores settings and transcripts. **smol-toml**, **yaml**, and **fast-xml-parser** support bounded data imports and reviewed configuration formats; they do not make executable configuration safe to run. + +The main coordinator is `TerminalApp`. It connects input, shell events, output state, panels, settings, and rendering. The editor, shell transport, transcript, and domain controllers provide more focused responsibilities around that coordinator. + +## 3. The terminal + shell relationship + +The outer terminal translates physical keys into bytes and displays bytes NMSh writes. NMSh reads those keys and usually edits its own command buffer. The real shell runs inside an inner PTY. + +The object that owns that PTY is `ShellSession`. It launches one persistent interactive shell, writes submissions, forwards interrupts, resizes the PTY, and separates authenticated lifecycle messages from program output. Normally a separate `SessionService` owns `ShellSession`; an in-process fallback can own it inside the frontend. + +```text +Normal command: +keys → NMSh editor → shell PTY → program +screen ← NMSh transcript ← PTY output + +Interactive/fullscreen program: +keys ─────────────────→ shell PTY → program +screen ←─────────────── program's terminal output + NMSh presentation suspended +``` + +The shell remains authoritative for parsing, expansion, pipes, redirections, working directory, and jobs. NMSh owns the editable text and its presentation. It does not turn each command into a separate Node subprocess. + +For fullscreen and interactive programs, NMSh switches to **passthrough**: it suspends its screen, gives the program the full terminal dimensions, and forwards input and output. A known-command heuristic can select this immediately; output classification can also detect terminal behavior that requires it. Terminal modes are tracked so attachment and return to the composer can restore the appropriate state. + +Typing `caffeinate -i` invokes a program through the shell and occupies that shell until it finishes. `/zoomies` invokes NMSh's separate Keep Awake controller. It can keep an OS assertion alive without occupying the managed shell. + +## 4. What happens when I type a command? + +Consider `npm test`: + +1. **Keystrokes arrive.** The key decoder turns terminal bytes into editing actions. NMSh's `CommandEditor` keeps the command text and cursor position. +2. **Presentation updates.** A synchronous lexical highlighter identifies words, strings, operators, and other tokens. Background command classification supplies facts such as whether `npm` is an executable, alias, or builtin. Suggestions and completion services offer candidates without executing the unfinished command. +3. **Enter submits.** `TerminalApp` reads the actual editor text and checks for an NMSh slash action. For a shell command, it creates a command block, captures historical context, starts activity feedback, and sends the text through the session client to `ShellSession`. +4. **The real shell executes.** Its bootstrap reports a command-start marker. The shell resolves `npm`, performs its own parsing, and launches the program in its continuing environment. +5. **Output returns through the PTY.** Shell protocol decoding removes NMSh lifecycle messages. Program output flows into the output buffer, where terminal formatting and progress updates are represented for the transcript. A session service also retains sequenced events for recovery and reattachment. +6. **The shell becomes ready again.** A marker reports exit status and working directory. NMSh completes the block, records elapsed time, updates history and context, and refreshes the prompt and suggestions. + +```text +key → editor → highlight/suggestions → submit → session client + → persistent shell → program → PTY output → transcript + → shell readiness marker → completed block + next prompt +``` + +The command's displayed colors never become part of the submitted text. A command can have rich NMSh presentation while the shell receives ordinary command text. + +## 5. The composer + +The **composer** is the whole input area: editable text, optional prompt/context, suggestions, and surrounding edges. The editor inside it supports multiline input and owns editing rather than delegating to the shell's line editor. + +The composer can be docked at **Bottom**, docked at **Top**, or placed in **Flow** after the newest transcript output. A one-line layout places context alongside input; a two-line layout separates them. Long input still wraps and can contain explicit newlines. Prompt None currently uses the one-line geometry. + +Prompt/context describes the shell and project; editable input is the command being composed. Dividers and borders are presentation around those regions. Accessories such as Keep Awake can occupy an available edge, an adjacent row, or trailing input space according to the screen plan. They do not become prompt-provider content or editable text. + +Each frame has one geometry plan, produced by `src/app/screenPlan.ts`. Rendering, cursor placement, wrapping, mouse hit testing, viewport height, panel placement, and PTY sizing use this shared plan. That prevents a border or accessory from shifting the cursor while another subsystem still assumes the old coordinates. + +## 6. Prompts and providers + +The prompt supplies context; NMSh supplies the composer that contains it. Current choices are: + +| Choice | Where context comes from | +|---|---| +| NMSh Native | NMSh modules for directory, project, Git, toolchains, and other context | +| None | No prompt/context content | +| Starship | A bounded invocation of the installed Starship executable | +| Powerlevel10k | An isolated zsh helper loading the installed theme and its configuration | +| Oh My Posh | A bounded invocation of the installed Oh My Posh executable | + +External renderers receive shell context such as directory and exit status. Their output is parsed into prompt content that NMSh lays out. They do not receive ownership of the live shell's editor. Powerlevel10k's helper deliberately disables ZLE, zsh's interactive line editor, so it cannot compete with NMSh. + +These helpers have time/output limits and use pipes rather than attaching their UI to the host terminal. They are still trust boundaries: running an installed prompt program or loading Powerlevel10k configuration can execute that provider's code. + +**Prompt None can still show a composer marker.** The marker identifies where input starts and belongs to NMSh's editor presentation. Removing context does not remove the editor's own marker or accessories. + +## 7. Output and transcript + +NMSh separates **program output** from **NMSh-authored presentation**. The output buffer, `OutputBuffer`, holds command records and parsed output lines. A transcript presenter decides how those records appear: command headers, historical context, feedback, folding, and spacing. + +Raw output is not blindly printed over the composer. `AnsiOutputParser` interprets a bounded subset of terminal behavior, including colors, carriage returns, backspaces, and hyperlinks, to retain a usable representation. Classification can choose transcript presentation, progress handling, or passthrough. This is not semantic recoloring of arbitrary stdout: the program's text and color choices remain its own. + +NMSh-authored submitted command lines can keep semantic highlighting. Folding changes which output rows are visible; it does not rerun commands or turn hidden output into a different result. Search, selection, and copy work from transcript state. `/copy` exports plain text without NMSh's ANSI chrome. + +Historical command blocks retain prompt/context snapshots from submission time. Viewing an old command should not pretend it ran in today's directory or Git state. Frontend chrome such as the Status Strip, find bar, and accessory rows stays outside the transcript. + +The viewport follows the newest output during execution (**FOLLOW**). Scrolling into history enters **DETACHED** viewport mode so new output does not pull the view away. This is a viewing state, distinct from detaching a live shell session. + +## 8. Sessions + +A **live session** is a running shell and PTY. A **saved transcript** is a record of what happened. Those have different lifetimes. + +Normally the frontend connects to a per-user local service, starting it on demand. The service owns shells independently of terminal windows, permits one controlling frontend per session, and keeps running when that frontend detaches or disappears. + +```text +create → attached ⇄ detached → shell exits / explicit termination + │ │ │ + └──── transcript journal ────────┘ + ↓ + saved transcript +``` + +Reattaching a live session reconnects to the same shell: aliases, variables, jobs, and current program remain there. The frontend restores its journal and replays later service events. Restoring an archived transcript through `/resume` replaces this window's presentation, archiving its current view first, while retaining its current live shell. It cannot resurrect the archived shell's environment or jobs. + +Retention is bounded. Unacknowledged stream events begin in memory and spill to a JSON-lines spool. Once a frontend has durably journaled events, it acknowledges their sequence numbers. Output beyond spool limits can be dropped, with truncation reported on replay. + +If the service cannot be established before session creation is confirmed, NMSh falls back to an in-process shell with a notice. That shell cannot survive frontend exit or be detached and reattached. A service crash or OS restart also does not magically preserve live processes. + +Session status combines authenticated shell lifecycle, observed terminal modes, activity emitted by programs, and the PTY's foreground-process name when available. Silence does not prove completion or a need for attention. Unknown facts remain unknown rather than being guessed from an animation or command name. + +## 9. ShellAdapter + +Shell-specific behavior lives behind a common boundary called `ShellAdapter`. Its three implementations support **zsh**, **Bash 4.4 or newer**, and **Fish**. All launch real persistent interactive shells and report the same lifecycle grammar. + +Adapters own executable selection, bootstrap files, environment setup, builtins, history parsing, and completion sources. The composer, transcript, session transport, and panels remain shared. Capabilities describe real differences: Bash completion does not provide Fish-style descriptions, for example. + +The zsh adapter uses a private bootstrap directory and disables ZLE. Bash uses a private rcfile and disables Readline editing. Fish retains its editor internally, so NMSh suppresses its prompt-to-command redraw bytes and answers necessary terminal queries. Consequently, Fish messages printed while idle at its prompt can also be suppressed. + +The interactive shell loads the user's startup configuration as part of normal shell startup. NMSh does not rewrite those files to establish its UI. Startup output is bounded and sanitized; typed input waits for readiness so it cannot accidentally answer a `read` in a startup file. Bootstrap also suppresses automatic Fastfetch startup. + +Command classification uses an isolated zsh helper, `SemanticService`, or live-name snapshots plus PATH classification for Bash/Fish. Completion helpers are separate from the execution PTY. They query trusted shell facilities without executing the partial command as a submission. + +Switching backend replaces the shell while keeping NMSh session identity, history, draft, and settings. The current implementation archives the old presentation and starts a fresh view for the new backend. It does not migrate aliases, variables, or jobs. Switching is refused while execution, startup, or known background/stopped jobs make replacement unsafe. + +## 10. Slash commands and NMSh actions + +`npm test` goes to the real shell. Recognized slash commands such as `/theme`, `/tools`, and `/zoomies` are parsed into **NMSh actions** and dispatched inside the frontend. + +These actions open panels, update settings, navigate sessions, or invoke a domain controller. They are not aliases installed in the user's shell. The slash parser returns structured action kinds instead of treating every action as a shell string. Unknown single-line slash input currently reports an unknown NMSh command rather than executing it. + +Panels are control surfaces for NMSh state and supported integrations. Where an action changes external files or installs software, its controller builds a specific reviewed operation rather than handing panel text to a shell. + +## 11. notMyUI / panel system + +**notMyUI** is the shared terminal UI vocabulary used by NMSh panels. It is implemented in this repository, primarily under `src/ui/`, rather than as an independent application framework. + +Shared primitives handle panel frames, tabs, grouped lists, selected rows, controls, colors, glyphs, and focus presentation. Domain panels hold their own state and turn keyboard or mouse interaction into actions; the coordinator dispatches those actions and integrates the panel with screen geometry. + +Rows identify selectable settings or commands. Focus determines whether navigation operates on a list, tabs, or an editing control. Settings and the command palette reuse these ideas, making different features feel related without forcing every panel into an identical widget implementation. + +## 12. Themes and appearance + +A **semantic palette** names colors by purpose: primary text, subtle text, success, warning, failure, selection, syntax roles, and so on. Native themes map those roles to actual colors. Renderers can then agree on what a warning means without each choosing a hardcoded color. + +**Theme Studio** creates, edits, imports, duplicates, and exports NMSh themes. Imported colors become normal local theme assets with stable IDs and source metadata. They do not require the original application or source file to remain installed. Imports parse supported formats as data rather than executing Lua, templates, shell snippets, or includes. + +**Chroma** adds decorative treatments, gradients, and motion to eligible NMSh surfaces. It is a presentation layer, not a replacement for the palette's underlying meaning. It does not enter generated external artifacts. + +Glyph modes choose between richer symbols and more portable alternatives. Color capability detection, `NO_COLOR`, and reduced-motion settings degrade presentation without changing command behavior. Cursor presentation, idle visuals, and UI chrome have their own settings around the shared appearance system. + +Theme Studio controls NMSh's appearance. **Theme Bridge** is the separate mechanism for extending selected colors to external tools. + +## 13. Theme Bridge + +Choosing an NMSh theme does not automatically rewrite application configuration. Theme Bridge is an explicit, initially disabled system with known target adapters. + +Its global policy can **Follow NMSh**, **Choose theme** to pin a theme, or **Manual** to use each target's saved choice. Per-target choices are Independent, Follow NMSh, and Choose theme. Global policy changes preserve the Manual choices for later restoration. + +| Current target | How colors reach it | +|---|---| +| fzf | NMSh-controlled invocation options | +| less / man | Allowlisted session environment values | +| File listing colors | GNU/BSD color variables and controlled listing behavior | +| bat | Generated theme file, reviewed cache setup, and theme environment selection | +| tmux | Generated managed configuration/colors and reviewed activation | +| Neovim / Vim | Generated colorschemes and reviewed activation hooks | +| Helix | Generated theme in its themes directory and reviewed selection | +| delta | Detected only; NMSh does not manage its Git configuration | + +Environment changes reach a persistent shell through one **environment sink**: generated files for zsh, Bash, and Fish, applied by the adapter's prompt hook. Their grammar permits allowlisted variables and quoted literals. Values take effect at the next prompt. Clearing a value restores what NMSh replaced only while the shell still contains NMSh's value. + +Generated files and inserted activation lines are recorded in an **ownership ledger**. Content hashes prove whether a file still matches what NMSh wrote. A user-edited or unrecognized artifact causes a conflict instead of being overwritten. Activation in executable configuration is an exact reviewed edit, not a generic merge of shell, Lua, or Vimscript. bat and Helix need artifacts in their own theme directories; many other artifacts live under NMSh's Theme Bridge directory. + +## 14. Tools, providers and integrations + +These terms describe different relationships: + +| Term | Meaning in NMSh | +|---|---| +| Tool | A curated external utility or detected shell-environment component | +| Provider | A selected implementation of a frontend role, such as prompt, history, picker, suggestions, or navigation | +| Integration | A supported connection to an external system, including health and ownership information | +| Configurable tool | A tool with a reviewed configuration adapter; detection alone is insufficient | +| Shell framework | Shell code/plugins loaded by startup configuration, such as Oh My Zsh or Prezto | +| Package-manager infrastructure | Software such as Homebrew that installs and manages other packages | + +`src/tools/catalog.ts` is the central curated tool registry. It records detection, categories, installation recipes, and relevant capabilities. Frameworks can be detected from filesystem evidence even when they have no executable on PATH. Detection grants no automatic installation or write authority. + +The provider-family registry connects role choices to persisted settings. The tool-configuration registry separately determines what `/configure` may manage. `/integrations` presents integration readiness and repair/setup actions, including Theme Bridge health. + +Homebrew is package-manager infrastructure: NMSh uses it for known install recipes and package facts. It is not simply another interchangeable prompt or utility provider. Special installers and framework setup have explicit flows and boundaries rather than being treated as ordinary package names. + +## 15. Dotfiles + +The dotfiles surface discovers recognized configuration in a local directory or checkout, including plain, Git, GNU Stow-style, and chezmoi layouts. An explicit remote flow can obtain a checkout before scanning. Repository content remains untrusted data. + +Discovery is bounded by entry count, depth, and file size. Recognition uses the tool-configuration registry. Symlinks are shown without being followed; scripts and templates are identified without being run or rendered. + +The review plan distinguishes supported field imports, exact copies, and inspect-only files. tmux imports supported options and bindings into NMSh's own model. Exact copying requires a registry-specific validator: being valid TOML or JSON alone is insufficient. Executable configurations remain inspect-only in this workflow. + +Changes are reviewed before application. Copy plans check whether the destination changed since review and back up existing content. NMSh does not execute repository installers, hooks, Make targets, Stow operations, chezmoi scripts, or arbitrary configuration code to discover what a repository means. + +## 16. Keep Awake + +`/caffeinate`, `/awake`, and `/zoomies` are three names for one NMSh-owned feature. A controller creates an OS power assertion in a detached background process, independently of the shell PTY. + +Backends use Apple's fixed-path `caffeinate` on macOS, `systemd-inhibit` around an NMSh wait helper on supported Linux systems, and an execution-state helper on Windows when its prerequisite is available. Capabilities differ: the Linux backend does not claim display inhibition. This backend's Windows code does not imply native Windows shell/frontend support. + +NMSh records the PID, launch arguments, ownership token where applicable, mode, and timing. Before stopping an assertion it verifies that the live process matches its owned record, rather than killing any process named `caffeinate` or trusting a reused PID. Stop sends termination to that verified process and only escalates while ownership still matches. + +The assertion can outlive an NMSh window. Duration and explicit stop control its lifetime. Presentation can use a composer accessory, Status Strip, idle reminder, or screensaver state; those are views of the controller's state, not text sent to the shell. + +## 17. Host / terminal integration + +Terminal integration uses optional escape-sequence protocols. **OSC** means Operating System Command, a terminal protocol family; these messages are not shell commands. + +- **OSC 7** tells a host the current directory, helping it open new tabs or panes in the right place. +- **OSC 8** attaches hyperlink targets to displayed text. +- **OSC 133** marks prompt, input, command-start, and command-end zones for cooperating terminals or multiplexers. +- **Private OSC 777 NMSh messages** carry shell readiness and execution events with a per-session token. NMSh validates that token before treating output as lifecycle evidence. + +Public host markers are derived from NMSh's authenticated lifecycle. NMSh does not depend on receiving them back, and holds them while a fullscreen program owns the screen. + +Capability detection combines passive host profiles and active probes. Ghostty, Kitty, WezTerm, iTerm2, and Windows Terminal have specific hints; Terminal.app receives conservative baseline behavior plus directory signaling. Windows Terminal detection can describe a WSL host without implying native Windows execution support. + +Keyboard, mouse, synchronized drawing, hyperlinks, colors, and graphics degrade independently. Direct preference/keyboard configuration integration is currently Ghostty-specific. tmux and other multiplexers hide outer-host hints, so NMSh does not assume that an outer terminal's graphics or keyboard features work unchanged inside a pane. + +## 18. tmux + +tmux is an independent multiplexer that owns sessions, windows, panes, borders, and its status line. NMSh can run inside a pane, or a shell command can launch tmux through passthrough. + +Theme Bridge supplies colors. **Config Studio**, opened through `/tmux`, manages supported options, keys, a status layout, and an optional pane frontend. NMSh stores a typed model and generates one managed configuration file that combines its settings and applicable Bridge colors. The user's config includes that file through a reviewed activation step. + +The optional **NMSh pane frontend** changes tmux's `default-command` for new panes/windows without an explicit command. It does not change `default-shell`, which still identifies the shell tmux uses to launch commands. Existing panes retain their current processes. The generated launcher guards against recursively starting NMSh inside NMSh's managed shell. + +The prompt inside a pane belongs to the program running there; tmux's status appearance is a different owner. Config Studio imports only supported literal settings and bindings, leaving dynamic commands and unsupported configuration outside its model. + +## 19. Ask + +Ask resolves plain-English requests into NMSh's existing capabilities. Its first path is deterministic: intents, command knowledge, project facts, and supported actions work without a model. + +Results are typed answers, proposals, choices, unsupported requests, or refusals. Actions identify specific operations such as opening a panel, attaching a session, changing a setting, running a fixed command, or applying a verified edit plan. Existing controllers perform those operations. + +Optional **local understanding** can help interpret requests when enabled. Auto uses a model when deterministic interpretation is unclear or ambiguous; Always prefers model assistance while still using deterministic action builders. A separate local model service manages runtime work. Model assistance chooses from a bounded capability inventory and produces validated structured interpretations. NMSh then resolves those interpretations against known facts and its deterministic policies; a failed model leaves the deterministic result available. + +Model text is not directly executed as a shell command or filesystem operation. Configuration, mutation, and installation require confirmation of the exact action in Ask; destructive suggestions are Copy/Insert only. Copying or inserting a suggested command does not execute it. File plans and supported configuration still use their normal path, hash, and ownership checks. + +## 20. Configuration and persistence + +Let `` mean the directory selected by the shared path resolver: + +- An absolute `$XDG_CONFIG_HOME/nmsh` when `XDG_CONFIG_HOME` is set. +- Otherwise `~/Library/Application Support/notMyShell` on macOS. +- Otherwise `~/.config/nmsh`. + +| State | Location / owner | +|---|---| +| Settings, provider choices, theme assets, Bridge policy | `/config.json` | +| Saved transcripts and summary metadata | `/sessions/` | +| Bridge environment files and ownership ledger | `/theme-bridge/` | +| Most generated Bridge artifacts | Subdirectories of `theme-bridge/`; bat/Helix themes use their tool directories | +| tmux's typed configuration model | `/tools/tmux.json` | +| Keep Awake process record | `/keep-awake.json` | +| Live service sockets and stream spools | Private runtime directory, separate from configuration | + +On Linux, a verified private `$XDG_RUNTIME_DIR/nmsh` is preferred for live runtime data. Otherwise NMSh uses a private per-user temporary directory; `NMSH_RUNTIME_DIR` can override it. Socket names distinguish protocol versions. Live shell environment stays in memory, not in the transcript store as a recoverable shell image. + +Configuration loading normalizes data, supplies defaults, and migrates older shapes. Theme-library normalization maintains stable references and a compatibility mirror of the active custom theme. Saving settings uses staged replacement and preserves unrelated fields; unreadable or malformed existing settings are refused for saving rather than silently overwritten. + +## 21. Security boundaries + +The central distinction is between **data NMSh can validate** and **code an external system executes**. Normal interactive shell startup and explicitly selected providers execute trusted user-installed code. Import and discovery workflows do not receive that same authority. + +Theme imports use bounded parsers without templates, includes, or code execution. Arbitrary configuration is not generically rewritten. Supported installs and helper operations use fixed executables and structured argument arrays rather than interpolating requests into shell strings. Helpers have time/output bounds appropriate to their role. + +Generated-file hashes and ownership records protect external artifacts; process verification protects stop/kill operations. A discovered file, tool, or PID is not proof that NMSh owns it. Raw PTY output retains program formatting through a controlled presentation path, and unauthenticated output cannot complete an NMSh command by impersonating its private protocol. + +These boundaries do not make arbitrary shell commands or providers harmless. They constrain NMSh's own authority. See [SECURITY.md](SECURITY.md) for the security policy and detailed trust boundaries. + +## 22. Repository map + +| If you want to understand… | Start here | +|---|---| +| Startup and frontend orchestration | `src/index.ts`, `src/app/` | +| Screen layout and terminal drawing | `src/app/screenPlan.ts`, `src/terminal/` | +| Editing, highlighting, suggestions | `src/input/`, `src/suggestions/` | +| PTY, adapters, shell knowledge and completion | `src/shell/` | +| Live service, sockets, replay and attachment | `src/session/` | +| Saved transcripts and resume UI | `src/sessions/` | +| Output parsing, folding, selection and presentation | `src/output/` | +| Native/external prompts and shared settings | `src/prompt/`, `src/configuration/` | +| Panels and shared UI primitives | `src/ui/` plus feature-specific panel modules | +| Theme Studio, palettes and decoration | `src/appearance/`, `src/chroma/`, `src/motion/`, `src/cursor/`, `src/idle/` | +| External colors and ownership | `src/themeBridge/` | +| Curated tools, configuration and packages | `src/tools/`, `src/providers/`, `src/packages/` | +| Dotfiles and Keep Awake | `src/dotfiles/`, `src/keepAwake/` | +| Slash actions, Ask and optional local models | `src/commands/`, `src/ask/`, `src/understanding/` | +| Host capabilities and passthrough policy | `src/host/`, `src/presentation/`, `src/passthrough/` | +| Agent-session integration and managed tasks | `src/agents/`, `src/tasks/` | +| Automated behavior evidence | `tests/`, verification scripts in `scripts/` | + +## 23. A few end-to-end examples + +### Running `git status` + +`editor text → semantic command presentation → submission block → session client → real shell → Git → PTY output → parsed transcript → readiness/status marker → completed block and refreshed context` + +Git chooses its output; NMSh supplies the surrounding command history and feedback. + +### Opening `/theme` + +`slash parser → Theme Studio state → shared panel primitives + screen plan → edit/select local theme → normalized settings save → NMSh surfaces repaint` + +An enabled Follow NMSh Bridge policy can then synchronize supported external targets through their own adapters. + +### Starting `/zoomies display` + +`slash parser → Keep Awake controller → backend capability check → fixed OS launch → ownership record → accessory/status presentation` + +On macOS this creates a display assertion outside the shell. A backend without display support reports that limitation instead of pretending it worked. + +### Detaching and resuming a session + +`/detach → journal checkpoint → frontend disconnect → service retains shell + output events → nmsh --attach → same shell attachment → journal restore + event replay → composer or active-program passthrough` + +`/resume` also offers saved transcripts. Selecting an archive restores its presentation into the current window without restoring its old shell, as described in section 8. + +### Applying a Theme Bridge target + +`enable Bridge + choose Neovim Follow NMSh → resolve semantic palette → validate generated colorscheme → check artifact ownership → write artifact + ledger → review activation diff → apply verified hook` + +Later theme changes can regenerate the unchanged owned artifact. User modifications cause a conflict. The editor must load/reload the colorscheme for its display to change. + +## 24. Deeper reading + +These documents expand particular areas. Older design and research records explain decisions at their time; current code remains authoritative. + +- [Shell adapters](docs/architecture/shell-adapter.md): backend contracts, differences, switching, and ordinary-shell handoff. +- [Terminal stack](docs/architecture/terminal-stack.md): layer terminology; its zsh-only and bottom-editor wording predates the current adapters/layouts. +- [Terminal hosts](docs/architecture/terminal-host.md) and [multiplexer interoperability](docs/architecture/multiplexer-interop.md): capability boundaries and nested-terminal behavior. +- [notMyUI](docs/architecture/notmyui.md) and [TUI primitives](docs/architecture/tui-primitives-v012.md): common controls, surfaces, and focus. +- [Prompt customization](docs/architecture/prompt-customization.md): Native context, shape, and appearance choices. +- [Supported tool configuration](docs/architecture/supported-tool-configuration.md): configuration classes, adapters, and ownership. +- [Theme Bridge design](docs/design/theme-bridge.md): imports, target behavior, environment sink, and generated-file safety. +- [Chroma and UI chrome](docs/design/chroma-and-ui-chrome.md): how decoration and semantic presentation fit together. +- [Structured execution](docs/design/structured-execution.md) and [session interaction](docs/design/session-interaction-ux.md): command blocks and viewport interaction decisions. +- [Session journal](docs/design/session-journal-v0.4.md) and [sessions, agents, and intelligence](docs/design/v016-sessions-agents-intelligence.md): persistence and later session capabilities. +- [Shell UX and Ask](docs/design/v016-shell-ux-ask.md): typed assistance and shell interaction policies. +- [Accessibility baseline](docs/accessibility/baseline.md): color, motion, glyph, keyboard, and host considerations. +- [SECURITY.md](SECURITY.md): trust model and reporting policy. +- [CONTRIBUTING.md](CONTRIBUTING.md): development setup, validation, and contribution workflow. From 16c2d5912e3077fe2c31fb8e0f3d7e9ab412bd18 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:07:45 +0530 Subject: [PATCH 15/16] Docs pass and reproducible VHS demos; Prompt None wording; link ARCHITECTURE.md Docs - README rewritten for a first visit: what NMSh is and is not, highlights, a short hero clip, a visual tour, source install (no Homebrew command until the tap exists), core commands, Keep Awake, shells/frameworks/ providers, terminals, accessibility, safety and ownership, limitations. Stale zsh-only, fixed-bottom, bat/Theme Bridge and badge claims fixed. - AGENTS.md: current shell model, composer edges and the screen plan, safety boundaries, current unreleased surfaces, PR #315, no-attribution rule, demo command. SECURITY.md: supported versions and the boundaries NMSh keeps. SUPPORT.md: nmsh doctor. CONTRIBUTING, ROADMAP (PR #315 in development; OSC 0/2 still deferred), llms.txt and the Theme Bridge doc updated; new docs/design/keep-awake.md and docs/demos.md. - ARCHITECTURE.md is linked as the high-level overview from README, CONTRIBUTING, AGENTS and llms.txt. - Prompt None: help, the prompt picker and Setup said "no marker"; the input marker has always stayed, and the wording now says so. Demos - npm run demos records every scripts/demos/*.tape with VHS against a disposable demo home (fixture repo ~/Projects/demo, neutral identity, no network, update checks and npm's notifier off), encodes GIFs and stills from VHS frames with ffmpeg, then stops everything still holding files in the demo home and fails if anything survives. Keep Awake is recorded with the inert backend; sessions detach with SIGHUP as a closing window does. - Clips: hero, composer and prompts, Theme Studio, Theme Bridge, providers and tools, live sessions, screensavers with Vespyr, Keep Awake; three stills; a Vespyr divider generated from the real sprite. - The old asciinema/tmux recorder, which recorded the real home directory, is removed; dev/tapes stays for development captures. - tests/docsAssets.test.ts checks local doc links and images, that every published clip has its tape, and that docs, tapes and vector art name no real home path or secret. --- AGENTS.md | 33 +- CHANGELOG.md | 13 +- CONTRIBUTING.md | 7 +- README.md | 370 ++++++++------------- ROADMAP.md | 3 +- SECURITY.md | 14 +- SUPPORT.md | 2 +- assets/readme/composer.gif | Bin 0 -> 1599306 bytes assets/readme/keep-awake.gif | Bin 0 -> 842624 bytes assets/readme/keep-awake.png | Bin 0 -> 74260 bytes assets/readme/nmsh-composer.png | Bin 0 -> 41503 bytes assets/readme/nmsh-demo.gif | Bin 132035 -> 611906 bytes assets/readme/screensavers.gif | Bin 0 -> 1053260 bytes assets/readme/sessions.gif | Bin 0 -> 137717 bytes assets/readme/theme-bridge.gif | Bin 0 -> 552369 bytes assets/readme/theme-studio.png | Bin 0 -> 102500 bytes assets/readme/themes.gif | Bin 0 -> 408112 bytes assets/readme/tools.gif | Bin 0 -> 824419 bytes assets/readme/vespyr-divider.svg | 65 ++++ dev/tapes/README.md | 22 +- docs/demos.md | 62 ++++ docs/design/keep-awake.md | 57 ++++ docs/design/theme-bridge.md | 2 +- docs/testing/deterministic-presentation.md | 10 +- llms.txt | 52 +-- package.json | 3 +- scripts/demos/README.md | 52 +++ scripts/demos/composer.tape | 59 ++++ scripts/demos/keep-awake.tape | 33 ++ scripts/demos/main.tape | 43 +++ scripts/demos/render.mjs | 193 +++++++++++ scripts/demos/screensavers.tape | 45 +++ scripts/demos/sessions.tape | 29 ++ scripts/demos/settings.tape | 17 + scripts/demos/theme-bridge.tape | 29 ++ scripts/demos/themes.tape | 31 ++ scripts/demos/tools.tape | 37 +++ scripts/readme-demo/demo.cast | 122 ------- scripts/readme-demo/record.cjs | 57 ---- src/help/helpContent.ts | 2 +- src/prompt/PromptPanel.ts | 2 +- src/prompt/configuration.ts | 2 +- src/setup/providerExplanations.ts | 2 +- tests/docsAssets.test.ts | 50 +++ tests/powerlevel10k.test.ts | 2 +- tests/promptNone.test.ts | 2 +- 46 files changed, 1058 insertions(+), 466 deletions(-) create mode 100644 assets/readme/composer.gif create mode 100644 assets/readme/keep-awake.gif create mode 100644 assets/readme/keep-awake.png create mode 100644 assets/readme/nmsh-composer.png create mode 100644 assets/readme/screensavers.gif create mode 100644 assets/readme/sessions.gif create mode 100644 assets/readme/theme-bridge.gif create mode 100644 assets/readme/theme-studio.png create mode 100644 assets/readme/themes.gif create mode 100644 assets/readme/tools.gif create mode 100644 assets/readme/vespyr-divider.svg create mode 100644 docs/demos.md create mode 100644 docs/design/keep-awake.md create mode 100644 scripts/demos/README.md create mode 100644 scripts/demos/composer.tape create mode 100644 scripts/demos/keep-awake.tape create mode 100644 scripts/demos/main.tape create mode 100644 scripts/demos/render.mjs create mode 100644 scripts/demos/screensavers.tape create mode 100644 scripts/demos/sessions.tape create mode 100644 scripts/demos/settings.tape create mode 100644 scripts/demos/theme-bridge.tape create mode 100644 scripts/demos/themes.tape create mode 100644 scripts/demos/tools.tape delete mode 100644 scripts/readme-demo/demo.cast delete mode 100644 scripts/readme-demo/record.cjs create mode 100644 tests/docsAssets.test.ts diff --git a/AGENTS.md b/AGENTS.md index cd5a47cb..44fe0177 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,12 @@ # NMSh Agent Guide ## What NMSh is -notMyShell (NMSh) is a terminal frontend that operates over a persistent, real zsh session. Instead of replacing the shell or running commands as isolated subprocesses, NMSh orchestrates a hidden pseudo-terminal (PTY) running zsh. It captures input via a fixed editor, highlights it semantically, and sends it to the real shell. +notMyShell (NMSh) is a terminal frontend that operates over a persistent, real shell session (zsh by default, Bash 4.4+ or Fish through the ShellAdapter). Instead of replacing the shell or running commands as isolated subprocesses, NMSh orchestrates a hidden pseudo-terminal (PTY) running that shell. It captures input in its own composer, highlights it semantically, and sends it to the real shell. ## Core invariants - NMSh is a frontend over a persistent real shell. - Do not replace ShellSession with command-by-command spawning. -- Preserve real zsh state between commands. +- Preserve real shell state between commands (zsh, Bash and Fish alike). - Raw PTY stdout/stderr must remain raw/presentation-safe. - Do not semantically recolor arbitrary PTY output. - NMSh-owned submitted command lines may have semantic highlighting. @@ -29,9 +29,12 @@ notMyShell (NMSh) is a terminal frontend that operates over a persistent, real z - **history viewport**: Scrollable past commands and raw PTY output - **autocomplete/suggestions**: Real-time completion hints below the input - **live activity**: Real-time animation and elapsed time for running commands -- **context/prompt**: Evaluated from the shell and displayed on the bottom editor -- **persistent editor**: The fixed input box at the bottom of the screen -- **separator**: A visual divider between output and the editor +- **context/prompt**: Evaluated from the shell; NMSh Native or an external provider (Starship, Oh My Posh, Powerlevel10k), or None +- **composer**: The persistent editor, docked Bottom or Top, or in Flow after the newest output; one-line or two-line +- **composer edges**: Optional divider rows around the composer (one shared edge renderer); NMSh-owned accessories such as Keep Awake use a free edge and never touch prompt content +- **frontend chrome**: Status Strip, notices, find bar and accessory rows are planned by `src/app/screenPlan.ts`, never written to the transcript + +`src/app/screenPlan.ts` is the single geometry source per frame: render, hit testing, cursor, viewport and PTY sizing all read the same plan. NMSh uses a FOLLOW mode during execution, pinning the output viewport to the bottom. During historical inspection, it enters DETACHED mode. @@ -50,7 +53,17 @@ As the user types, partial input is tokenized. Known executables, aliases, and b Submitted commands retain their semantic presentation in the NMSh output history. The styling (e.g. lavender for known commands, red for unknown commands) persists even after the command completes. ## Shell compatibility -Released (0.16.0): a real ShellAdapter with zsh, Fish and Bash 4.4+ backends ([docs/architecture/shell-adapter.md](docs/architecture/shell-adapter.md)). Nushell and PowerShell remain future. +Released (0.16.0): a real ShellAdapter with zsh, Fish and Bash 4.4+ backends ([docs/architecture/shell-adapter.md](docs/architecture/shell-adapter.md)). Nushell and PowerShell remain future. Changes must keep all three backends working. + +## Safety boundaries +- Shell config, framework code and parseable tool configs are executable: never source, merge or silently edit them. Offered edits are exact diffs behind a confirmation. +- Theme imports and dotfiles are data: bounded parsers, no includes, templates, network or repository code execution; dotfiles exact copies fail closed. +- NMSh changes only what its ownership ledger proves it wrote; Keep Awake signals only its own verified process. +- Installs are typed argv shown before confirmation; special installers are never run by NMSh. +- Chroma never reaches generated external artifacts. + +## Current surfaces (unreleased beyond v0.16.0) +Theme Studio (`/theme`), Theme Bridge (`/theme-bridge`), `/providers`, `/tools` with filesystem-detected shell frameworks, `/configure`, `/tmux` Config Studio, `/integrations`, `/dotfiles`, the Oh My Posh provider, and Keep Awake (`/caffeinate`, `/awake`, `/zoomies`, with composer-edge, Status Strip, idle-reminder and screensaver presentation). See CHANGELOG.md → Unreleased. ## Testing / verification During implementation, run focused affected tests. Use `npm run verify:fast` for @@ -71,8 +84,11 @@ for sharding, platform gates and exact-release evidence requirements. npm run verify:fast npm run verify npm run verify:release +npm run demos # re-record README/docs media with VHS (scripts/demos/README.md) ``` +Do not hardcode test totals in docs; they change with every slice. + When writing tests involving `TerminalApp`, you must carefully tear down child processes and temp ZDOTDIRs: ```typescript app['stop'](0); @@ -87,6 +103,7 @@ app['session'].kill(); - prefer localized changes - do not casually rewrite the renderer or PTY architecture - no fabricated manual verification +- commits and PRs carry no AI attribution and no AI co-author trailers - distinguish automated verification from human GUI/runtime validation ## Planning and GitHub tracking @@ -102,10 +119,12 @@ For substantial implementation work: 5. **Only close work requiring human validation after that validation occurs** Key references: +- [ARCHITECTURE.md](ARCHITECTURE.md) — the human-readable architecture overview (runtime and data flows, repository map); deeper detail in `docs/architecture/` and `docs/design/` - [ROADMAP.md](ROADMAP.md) — product direction and issue index - [GitHub Issues](https://github.com/raiseCatError/notMyShell/issues) — actionable work - [v0.16.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.16.0) — current stable release -- [PR #303](https://github.com/raiseCatError/notMyShell/pull/303) — the merged cumulative v0.16 release; [#308](https://github.com/raiseCatError/notMyShell/issues/308) tracks release readiness +- [PR #303](https://github.com/raiseCatError/notMyShell/pull/303) — the merged cumulative v0.16 release +- [PR #315](https://github.com/raiseCatError/notMyShell/pull/315) — open development PR for the work after v0.16.0 (not released; the package version stays 0.16.0) - [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 - [docs/design/structured-execution.md](docs/design/structured-execution.md) — v0.2.0 design decisions diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b02ca83..06d32003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Themes, Theme Bridge and host cooperation -- **Prompt provider None**: composer only (no prompt row, modules, right prompt or marker) while editing, suggestions, syntax colors, history, themes and Theme Bridge keep working; commands submitted under None store no prompt snapshot. +- **Prompt provider None**: composer only (no prompt row, modules or right prompt; the input marker stays) while editing, suggestions, syntax colors, history, themes and Theme Bridge keep working; commands submitted under None store no prompt snapshot. - **Historical prompt** Full / Compact / Minimal / Off (`/transcript`, Settings, Setup); presentation only over the unchanged stored snapshot. - **Native theme library**: any number of Custom and Imported themes with stable ids (bounded to 64); the single custom theme migrates into it and stays active. Imported is provenance only: imported themes are ordinary Native themes you can edit, rename, duplicate, export, select and pin. - **Theme Studio** (`/theme`): Built-in · Imported · Custom · Import tabs, one editor and the real Native preview for every theme. Settings → Theme and `/setup appearance` select Built-in, Imported and Custom themes directly. @@ -20,6 +20,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Providers, tools and integrations - **Keep Awake** (`/caffeinate`, `/awake`, `/zoomies`; one surface and state): Idle, Display, System and All, optional `30m`/`2h` timeouts, `status` and `stop`. Backends are detected, not assumed: Apple `/usr/bin/caffeinate` (fixed flags; System needs AC power), `systemd-inhibit` with `idle`/`sleep` only around an NMSh-owned wait helper (Display is reported unsupported on Linux), and Windows `SetThreadExecutionState` from a fixed hidden PowerShell helper (no away mode, no `powercfg`). The assertion is a detached process that outlives the window; ownership is a random token plus the exact command line, so an unverifiable record is cleared and nothing is killed. Changing mode asks first (default No) and starts the new assertion before releasing the old. +- Keep Awake **presentation**: while active, `Awake · ` is NMSh composer chrome (never prompt or provider output). Placement **Composer edge** (default) uses a plain top divider, else the bottom divider when a header prompt owns the top edge, else one row next to the composer; **Above composer** and **Input row** (only when it is completely safe; editor width, caret and hit testing account for it) are explicit choices, and a fallback never rewrites the setting. Both edges render through one shared edge renderer, so animated Chroma dividers keep it. An enabled **Status Strip** always includes it (narrowing before it drops); after 30 s without NMSh input an **idle reminder** adds the time and a muted `/zoomies stop`; the **screensaver** shows a small positioned status (default Bottom left). Display is Text, Icon or Icon + text. Off shows nothing. The panel gains Duration and these settings; Ask answers status questions and plans timed, change and stop requests through the same controller. +- Fixed: `/caffeinate`, `/awake` or `/zoomies` without arguments opened a panel that was never drawn, so the composer looked occupied until Ctrl+C (the Mise panel had the same problem). A start or stop from the panel now returns to the composer at once; the assertion was and remains an NMSh-owned background process, never a shell command. - **Shell frameworks and prompt engines:** `/tools` detects tools by an explicit strategy (executable or a registered filesystem detector), so Oh My Zsh, Powerlevel10k, Prezto, Zim, zinit and Antidote appear with factual status (`Installed · Zsh framework · used by Zsh only` under Bash/Fish). Detection grants no install, configuration or provider authority. Oh My Zsh has a guided install that keeps `.zshrc` and a `.zshrc.pre-oh-my-zsh` comparison with a reviewed restore; NMSh never runs its installer. Powerlevel10k is a `/tools` item that routes to the existing provider and `p10k configure` flow. **Oh My Posh** is a new Prompt provider (`oh-my-posh print primary`, argv only, no TTY, bounded, cancellable; Native fallback when it fails) and a curated `/tools` install (homebrew/core); its config can be imported into Theme Studio as static colors. Ask understands these requests; dotfiles treats `.p10k.zsh`, Oh My Zsh themes/plugins and Oh My Posh configs as inspect-only. - `/providers` is one inline panel: each family with its status (● Active, ✓ Selected · fallback, Available, Missing · Enter to install); Enter selects immediately or installs after a confirmation (default No). Shortcuts `/picker` (`/pickers`), `/suggestions`, `/navigation`, `/welcome`, `/history-provider` and `/providers ` open it focused. @@ -50,6 +52,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Screensavers: the capture keeps authored backgrounds and readable glyph colors (no black-on-dark chrome), Circletastic forms a few small circles completely before it stabilizes, rotates, accelerates and explodes (all at once or staggered, keeping ring momentum), and raiseCatError now uses the NMSh cat sprite, roams the whole screen, overlaps text freely, meows, and sometimes sits on a purely visual fake keyboard (never reaching the editor or shell). - Any exact command in the curated `/tools` catalog (not only Recommended ones) is recognized when missing; the prompt names the package when it differs (`tldr` is provided by tealdeer). TLDR (tealdeer) is now Recommended; Ask uses only its local cache (`--no-auto-update`) and says when examples are unavailable. +### Polish (this pass) +- `/tools`: the selected row is the shared selected band (the active tab's treatment): full width, bold, readable on the band, reverse video under `NO_COLOR`. +- Prompt None wording: the input marker stays (it always did); help, the prompt picker and Setup now say so. + +### Docs and demos +- [ARCHITECTURE.md](ARCHITECTURE.md): a plain-language overview of how NMSh works, linked from the README, CONTRIBUTING, AGENTS and llms.txt. +- README rewritten around what NMSh is, a short hero clip, a visual tour and the safety model; stale zsh-only, fixed-bottom, bat and release claims corrected. +- Reproducible visual docs: `npm run demos` renders the README and [demo gallery](docs/demos.md) clips from committed VHS tapes in `scripts/demos/` against a disposable demo home (no user config, no network, the inert Keep Awake backend). It replaces the old asciinema/tmux recorder. A small Vespyr divider is generated from the real sprite. + ### Updates and sessions - **Automatic updates** (Automatic / Notify only / Off, Daily or Weekly). New installs default to Automatic / Daily; saved Daily/Weekly checks migrate to Notify only and Off stays Off. Automatic prepares a verified stable release only where `/update apply`'s own checks pass, with the same build verification and rollback; the running session keeps its version. Status shows Running version, Latest, Mode and State. - Detached sessions can be ended from the startup picker with `X` and confirmation; the transcript is archived and stays in `/resume`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14205686..e4be6415 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,10 +17,11 @@ npm run build Before starting work, check: +- **[ARCHITECTURE.md](ARCHITECTURE.md)** — how the pieces fit together; start here before changing a subsystem - **[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.16.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.16.0)** — current stable release -- **[#132 Flow / Classic composer](https://github.com/raiseCatError/notMyShell/issues/132)** — next planned direction (see [ROADMAP.md](ROADMAP.md)) +- **[PR #315](https://github.com/raiseCatError/notMyShell/pull/315)** — current development after v0.16.0 (unreleased; see [ROADMAP.md](ROADMAP.md)) - **GitHub Project** — [NMSh Development](https://github.com/users/raiseCatError/projects/1) — live development status board ## Branch Model @@ -93,3 +94,7 @@ When opening a Pull Request: - **Reproducibility**: Keep generated/demo assets reproducible using the scripts in `scripts/`. Commits do not need to follow an excessively strict convention (e.g. Conventional Commits), but clear, descriptive messages are preferred. + +## Visual docs and demos + +README and [docs/demos.md](docs/demos.md) media come from committed VHS tapes in [`scripts/demos/`](scripts/demos/README.md). If a change alters what a clip shows, re-record it with `npm run demos` (or `npm run demos -- `) and commit the tape and the regenerated asset together. Recordings run against a disposable demo home and never read your own NMSh config, shell rc files or history. diff --git a/README.md b/README.md index bfdd9a19..dd021c11 100644 --- a/README.md +++ b/README.md @@ -7,175 +7,101 @@

License - macOS - Node.js - zsh + macOS, Linux (beta), WSL 2 + Node.js 22+ + zsh, Bash, Fish CI


-**notMyShell (NMSh)** is a terminal frontend for a real persistent zsh session. It adds a persistent bottom input editor, semantic syntax highlighting, autocomplete, scrollable history, and richer command feedback while preserving normal shell state, aliases, functions, environment, and PTY behavior. +**notMyShell (NMSh)** runs your real zsh, Bash or Fish in a persistent session and gives it a better front end: a composer that stays put, semantic highlighting, a readable transcript, live command feedback, sessions that survive closing the window, and themes that can reach the tools you use. -Current stable release: [v0.16.0 — Sessions, Agents & Portability](https://github.com/raiseCatError/notMyShell/releases/tag/v0.16.0). - -Working on NMSh? See [AGENTS.md](AGENTS.md). - -## Visual demo +Current stable release: [v0.16.0 — Sessions, Agents & Portability](https://github.com/raiseCatError/notMyShell/releases/tag/v0.16.0). The `master` branch is the released state; newer work (themes, Theme Bridge, tool configuration, shell frameworks, Keep Awake) is in development and listed under *Unreleased* in the [changelog](CHANGELOG.md).
- NMSh Demo -

NMSh showing semantic highlighting, the pinned input bar, and live activity feedback.

+ NMSh: typing a highlighted command, running it with live activity, then switching the theme from /theme +

Typing with semantic highlighting, live command feedback, and a theme change from /theme. Recorded from the real binary with VHS.

-
-
- - Divider - -
-
+## What NMSh is (and is not) -## What is NMSh? - -NMSh is **NOT** a replacement shell implementation, and it is **NOT** a terminal emulator. - -It is a frontend that wraps your real zsh environment. NMSh owns the prompt, multiline input editor, syntax highlighting, and history presentation. Real zsh owns the parsing, command execution, aliases, and environment variables. +NMSh is a **frontend**. Your shell stays underneath and does what it always did: parsing, execution, aliases, functions, environment, job control. NMSh owns what you see and type: the composer and editor, completion and suggestions, history and transcript, prompt and themes.
- NMSh Architecture + Terminal host → NMSh frontend → ShellAdapter → your real shell
-## Why NMSh? - -NMSh provides a richer interactive frontend without throwing away the proven robustness of a real shell parser. It brings a Claude Code-like interaction model to your daily shell: - -- **Fixed bottom input:** A stable workspace that never jumps around. -- **Scrollable history:** Output history that doesn't disappear when you edit. -- **Rich editor:** True multiline input that acts like a text editor. -- **Preserved semantics:** Your real shell aliases, functions, and pipelines still work. +- **Not a shell.** It does not reimplement zsh, Bash or Fish; it runs them. +- **Not a terminal emulator.** Keep Ghostty, Terminal.app, VS Code, Zed or whatever you use. +- **Not a prompt theme.** The Native prompt is optional; Starship, Oh My Posh or Powerlevel10k can supply the prompt instead, or none at all. +- **Not an AI terminal.** `/ask` maps plain requests onto typed NMSh actions locally, with an optional local model; nothing needs an account. -
-
- - Divider - -
-
+## Highlights -## Features - -### Shell -- Real zsh execution and parsing -- Real aliases, functions, and environment -- `zoxide` integration -- Completion bridge using real zsh completion data - -### Editor -- Multiline input and selection -- Predictive ghost suggestions (NMSh Native: fuzzy, frecency, directory, and sequence ranking; optional Deja provider): → accepts, Alt+→ accepts a word, Ctrl+N / Ctrl+P show alternatives, Esc dismisses -- **Semantic syntax highlighting** (differentiates executables, builtins, aliases, and functions instantly) - -### Prompt -- NMSh Native prompt (default) with Lavender Native, Brand / Semantic, Cool First, Warm First, and Grayscale themes -- Independent Start / Connector / Connector fade / Gap / End geometry (wedge, flat, rounded, slanted, and fading outer edges), icons On/Off, and a module manager: `/prompt` → Main Prompt -- Rich Git state (staged, modified, untracked, conflicts, ahead/behind/diverged, operations, clean) with its own Enabled, Colors (Semantic default, Follow theme, Grayscale), Geometry, and Connector fade settings: `/prompt` → Rich Git -- Right-side prompt context: any module can sit left or right (`/prompt` → Modules, `P`); the right side mirrors its geometry to face left by default (`M`) and is the first thing to go on narrow terminals -- Show-on-command modules: Kubernetes and Docker context (and optionally toolchains) appear only while a relevant command such as `kubectl` or `docker` is typed; typed text is never executed to decide -- Width-aware path shortening keeps the repository name and current directory whole while abbreviating parents as the terminal narrows -- Terminal glyph style (Nerd Font or Safe/ASCII) is chosen on first run and can be changed in `/config` (Glyph style) or previewed under `/settings` → Settings → Glyph style. Existing v0.3 configurations keep Nerd Font styling; `NMSH_ICONS=nerd|safe` overrides the saved choice for the current process. -- Optional Starship, Oh My Posh or Powerlevel10k prompt providers; NMSh keeps the editor, composer, transcript and history, and the provider supplies only the prompt content. Starship and Oh My Posh are cross-shell prompt engines run directly by NMSh (no shell rc change); Powerlevel10k is a Zsh prompt theme rendered in an isolated helper. The Starship module editor changes only reviewed settings, with a backup of an existing config. -- Keep Awake: `/caffeinate` (also `/awake` and `/zoomies`) keeps the computer or display awake with the operating system's own mechanism: Apple `caffeinate` on macOS, the systemd inhibitor (`systemd-inhibit`, idle and sleep only) on Linux, and the `SetThreadExecutionState` API on Windows. Modes are Idle, Display, System and All, with an optional time (`/zoomies display 2h`); `/zoomies` alone opens the panel and `/zoomies stop` ends it. It survives closing the NMSh window, changes no power settings, and shows a mode as unavailable where the platform cannot honour it (Display on Linux). -- Shell frameworks in `/tools` (Shell / Workflow): Oh My Zsh is a Zsh framework, not a command. Its guided install keeps your `.zshrc` (NMSh backs it up, shows the official installer and the documented `KEEP_ZSHRC=yes CHSH=no RUNZSH=no` settings, you run it yourself, and NMSh verifies what changed). If you installed it yourself and have `.zshrc.pre-oh-my-zsh`, NMSh compares the two files and can restore the previous one after a backup and a confirmation; it never merges shell code. Prezto, Zim, zinit and Antidote are detected and shown as inspect-only. -- Prompt provider **None**: composer only (no prompt row, modules, right prompt or marker); everything else in NMSh keeps working -- Native prompt styles: Powerline, Soft, Minimal, Outline (`/prompt` → Style) -- One-line or two-line composer layouts - -### Interface -- Command lifecycle rows with activity animation and nested Node TAP activity -- Scrollable history with muted snapshots of each command's prompt, shown Full, Compact, Minimal or Off; tune dividers and history colors with `/transcript` -- Native theme library: `/theme` (Theme Studio) browses Built-in themes and manages Imported and Custom ones; import NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON/YAML/TOML), Kitty, Ghostty, iTerm2 or WezTerm TOML files as data only, with a preview and loss disclosure before anything is saved -- Theme Bridge (`/theme-bridge`, opt-in): fzf launched by NMSh, less/man, file listing colors (`ls`), bat, tmux, Neovim, Vim and Helix can Follow NMSh or use a pinned theme, globally or per tool; nothing changes for a tool until you choose, and config includes are shown and confirmed first -- Tool configuration: `/tmux` (settings, keys, status line, optional NMSh pane frontend, import from an existing tmux.conf), `/configure`, `/integrations` health with Review all, and `/dotfiles` to import supported settings from a local or Git dotfiles repository (nothing in it is run) -- Host cooperation on capable terminals: OSC 7 working directory and OSC 133 command zones derived from NMSh's own command lifecycle, and NMSh-authored OSC 8 links (help, docs, dev-server URLs) kept separate from program output -- Output folding (Config → Output folding: Off / Smart / Always): long output collapses to its first and last lines around `› N lines hidden · Ctrl+O`; Smart keeps failures and useful output expanded; `/copy` and `/resume` always keep the full output -- Composer position Bottom, Top, or Flow (Config → Composer position). Flow places the prompt and input right after the newest output, like a conventional terminal, and they scroll with it. Combine any position with Normal or Chat transcript presentation (Config → Transcript presentation). `/layout` or `/composer` (also Config → Layout) previews every combination with sample content before you choose. -- Welcome providers: Vespyr (default), Fastfetch, Neofetch (legacy, if installed), or None (`/settings` → Welcome) -- Command palette: `/palette`, F1, or Ctrl+Shift+P (Cmd+Shift+P where the terminal reports it) to search NMSh commands, settings, and actions -- Sticky command headers keep the current command visible while scrolling -- **Live sessions:** closing a window detaches its shell instead of ending it, and running commands keep going. Come back through the startup prompt (Config → Sessions: Ask, Always or Never), `/resume` (LIVE sessions with their status, above archived transcripts), or `nmsh --attach ` (`nmsh --sessions` lists them). `exit`, Ctrl+D and `/zsh` end a session. -- NMSh checkpoints the local presentation session during use; `/clear` starts a fresh view and `/resume` browses retained sessions without rewinding live zsh state -- `/zsh` hands off to an ordinary interactive zsh -- Rich paste atoms for large multiline pastes -- `/copy` and `/copy N` for instant clipboard access -- `/history` interactive search -- `/version`, `/appearance`, and `/keyboard` integrations -- `/settings` (alias `/config`) edits NMSh preferences and `/status` shows runtime status, while direct commands such as `/prompt` and `/transcript` remain available - -### Interactive Apps -- Safe passthrough yielding for full-screen applications like `fzf`, `vim`, `nano`, and `less`. +- **A composer that stays where you want it** — Bottom, Top or Flow (right after the newest output), one-line or two-line, with true multiline editing. +- **Semantic highlighting** — commands, builtins, aliases and functions are classified against your real shell as you type; partial input is never executed. +- **A transcript you can use** — command blocks with status and timing, folding for long output, `/find` and `/filter`, plain-text `/copy`. +- **Live sessions** — closing a window detaches the shell instead of killing it; running commands keep going. `/resume` or `nmsh --attach` brings them back. +- **Prompt providers** — NMSh Native (Powerline, Soft, Minimal, Outline styles), Starship, Oh My Posh, Powerlevel10k or None. +- **Theme Studio and Theme Bridge** — built-in, imported and custom themes in `/theme`; opt-in `/theme-bridge` carries the active theme to fzf, less/man, file listings, bat, tmux, Vim, Neovim and Helix through files NMSh owns and you review. +- **Curated tools** — `/tools` finds, explains and (on request) installs a short list of shell tools; `/providers` picks the picker, history, navigation and suggestion providers; `/tmux` and `/dotfiles` import settings safely. +- **Shell frameworks, handled carefully** — Oh My Zsh, Powerlevel10k, Prezto, Zim, zinit and Antidote are detected; shell config is treated as code, never as harmless data. +- **Keep Awake** — `/zoomies` (also `/caffeinate`, `/awake`) keeps the machine or display awake through the OS's own mechanism and shows that it is on, quietly. +- **Personality, optional** — Chroma color treatments, motion, screensavers and Vespyr, the NMSh cat. All of it respects Reduced Motion, Safe glyphs and `NO_COLOR`. -
- - Divider - + Vespyr, the NMSh cat, sitting on a divider
-
- -## Syntax Highlighting -Highlighting is entirely NMSh-native and non-blocking. A fast lexical layer tokenizes the input, while an asynchronous semantic bridge queries your real zsh environment to classify command tokens. - -NMSh safely queries metadata (`whence -w`) and never executes partially typed input. - -`/syntax` (also under `/settings` → Syntax) turns highlighting on or off and picks its colors: follow the prompt theme (default; with Starship or Powerlevel10k this means the saved NMSh Native palette), choose any Native theme independently, or Grayscale, which keeps categories apart through lightness, weight, and underline. Live preview rows show the result before saving. Submitted commands keep the look they were entered with; raw command output is never recolored and `/copy` stays plain text. - -
- - Syntax highlighting demo - -
- -
-
- - Divider - -
-
- -## Shell Compatibility - -NMSh runs a real, persistent shell underneath and keeps it: zsh (default), Fish, or Bash 4.4+. The same composer, transcript, sessions, prompt UI, Settings, Chroma, history presentation and completion menu work over each. `/shell` lists what is installed and switches the current session in place (same session, cwd and transcript); Settings → Default shell chooses the shell for new sessions. NMSh never installs a shell. See [ShellAdapter](docs/architecture/shell-adapter.md) for exactly what differs (for example, Bash completion has no descriptions). - -**What works naturally:** -- Aliases, functions, PATH, and environment variables -- `zoxide` integration, pipelines, redirects, and external commands - -**No plugin manager required.** NMSh provides its editor, completion menu, prompt, transcript and sessions itself. Existing frameworks and plugin managers (Oh My Zsh, Antidote, Zinit, Fisher, …) can keep providing compatible shell-level functionality; `/status` and `nmsh doctor` show what is detected and how it relates to NMSh. - -**UI Plugin differences:** -- Foreign prompt rendering in the managed shell (Powerlevel10k, RPROMPT, ZLE prompts, Fish prompts) is suppressed so it cannot fight NMSh. You can still choose Starship or Powerlevel10k as an NMSh prompt provider. Powerlevel10k's left prompt is rendered in an isolated helper, without its prompt character, gitstatus daemon, or right prompt. -- `zsh-autosuggestions` and `zsh-syntax-highlighting` draw through ZLE, which NMSh keeps off; NMSh's own suggestions and highlighting are shown instead, and the plugins keep working in `/zsh` and ordinary zsh. -- Native `fzf-tab` integration is not currently supported; safe zsh completion/widget interoperability remains unresolved in [issue #52](https://github.com/raiseCatError/notMyShell/issues/52). - -NMSh loads your own shell startup files in a controlled bootstrap and never edits them. - -See [ROADMAP.md](ROADMAP.md) for planned work (Nushell and native Windows are later). - -## Installation - -**Prerequisites:** -- macOS, or Linux (beta: tested in CI on Ubuntu and Fedora; not physically validated), or Windows through WSL 2 (see [platforms](docs/architecture/platforms.md)) -- Node.js (v22+) -- zsh (Fish and Bash 4.4+ are optional additional backends) -- A compatible terminal host: an integrated terminal (such as Zed or VS Code) or a standalone terminal (such as Ghostty or macOS Terminal) - -Clone the repository and install dependencies: +## Visual tour + +Every clip below is the real NMSh binary, recorded from a disposable demo home with committed [VHS tapes](scripts/demos/). More in the [demo gallery](docs/demos.md). + + + + + + + + + + + + + +
+ Composer & prompts
+ /prompt and /layout: one-line ↔ two-line, Bottom → Top → Flow, prompt styles.

+ Changing the composer layout and prompt style +
+ Themes
+ /theme: browse built-in themes with a live preview, then duplicate one into Custom.

+ Theme Studio switching between built-in themes +
+ Live sessions
+ A command keeps running while its window is gone; reattach and it is still there.

+ Detaching from a running session and reattaching +
+ Screensavers & Vespyr
+ /screensaver: a few of the built-in savers, including Bouncing Vespyr.

+ Screensaver gallery previews +
+ Keep Awake
+ /zoomies display starts it and hands the prompt straight back; Awake · Display sits on the composer edge and in the Status Strip; /zoomies stop ends it. (Recorded with the inert demo backend, so nothing was actually kept awake.)

+ Starting and stopping Keep Awake +
+ +## Install + +NMSh is installed from source today. You need: + +- macOS, Linux (beta: automated CI on Ubuntu and Fedora, not yet physically validated) or Windows through WSL 2 ([platforms](docs/architecture/platforms.md)) +- Node.js 22 or newer +- zsh (default), and optionally Bash 4.4+ or Fish ```sh git clone https://github.com/raiseCatError/notMyShell.git @@ -183,130 +109,104 @@ cd notMyShell npm install npm run build npm link +nmsh ``` -*(Note: Depending on your npm version, you may be prompted to allow lifecycle scripts required by `node-pty`. You can safely approve this or set `allowScripts` appropriately.)* - -After linking, run the CLI from anywhere: +npm may ask to allow `node-pty`'s install script; it is required. To start NMSh from Ghostty or another GUI terminal, use absolute paths so macOS `PATH` differences cannot break startup: -```sh -nmsh +``` +command = direct:/absolute/path/to/node /absolute/path/to/nmsh ``` -### Moving settings and uninstalling - -- `nmsh config export` / `nmsh config import FILE` move your settings between machines, hosts and shells (versioned JSON, preview before apply, selectable categories; no history or secrets). See [portability](docs/design/v016-portability-uninstall-diagnostics.md). -- `nmsh uninstall` previews and removes only NMSh's own launcher links; your settings are kept unless `--delete-data`, and your shell config is never touched. -- `nmsh doctor` prints a short diagnostic for issue reports. +Do not make NMSh your login shell; it is a frontend and your shell stays your shell. -### Updating +**Updating:** `/update` shows the latest stable release and the exact plan; `/update apply` installs it into a clean official source checkout, verifies the build, and rolls back on failure. Automatic updates (Automatic / Notify only / Off) use the same checks. -`/update` checks GitHub for the latest stable release and shows current → available, a short release summary, and the exact plan. `/update apply` then installs that release. NMSh updates a source checkout of this repository only when the checkout is clean, its `origin` is this repository, the fetched release tag matches the commit GitHub reports, and moving to the tag is a fast-forward. It then runs `npm install` and `npm run build` and verifies the new build identity. If anything fails, it restores the previous commit and rebuilds it. Otherwise it explains why and prints the manual steps. It never pulls arbitrary branches, discards changes, or touches your settings, transcripts, or shell profile. Restart NMSh afterwards to use the new version. +**Moving and removing:** `nmsh config export` / `nmsh config import FILE` move settings between machines (preview first, no history or secrets); `nmsh uninstall` removes only NMSh's own launcher links; `nmsh doctor` prints a diagnostic for bug reports. -Config → Automatic updates is Automatic, Notify only or Off, with a Daily or Weekly check frequency. New installs default to Automatic / Daily; saved Daily/Weekly notification preferences migrate to Notify only, Off stays Off. Automatic only prepares a verified stable release on an official, clean source checkout (the same checks `/update apply` makes, with the same rollback); the running session keeps its version and the new one starts on the next launch. Anything that cannot be proven safe gets one quiet notice and the manual steps. No credentials or telemetry are involved. +## Core commands -## Ghostty Setup +Type `/` in the composer for the full list, or `/help` for everything grouped by area. The ones you will reach for most: -For the most robust startup experience in Ghostty, configure it to run NMSh using absolute paths. GUI applications on macOS sometimes have unpredictable `PATH` resolution. +| Area | Commands | +| --- | --- | +| Settings and setup | `/settings` (`/config`), `/setup`, `/palette` (F1), `/help`, `/status` | +| Composer and prompt | `/prompt`, `/layout` (`/composer`), `/transcript`, `/syntax`, `/cursor` | +| Look and motion | `/appearance`, `/theme`, `/theme-bridge`, `/chroma`, `/motion`, `/chrome`, `/glyphs`, `/strip`, `/screensaver` | +| Tools | `/tools`, `/providers`, `/configure`, `/tmux`, `/integrations`, `/dotfiles` | +| Sessions and history | `/resume`, `/sessions`, `/history`, `/find`, `/filter`, `/copy`, `/clear` | +| Shells | `/shell` (switch zsh / Bash / Fish in place), `/zsh` (hand off to an ordinary shell) | +| Everyday extras | `/ask`, `/watch`, `/open`, `/zoomies` (`/caffeinate`, `/awake`), `/update`, `/doctor` | -1. Find your absolute paths: - ```sh - command -v node - command -v nmsh - ``` +### Keep Awake -2. Add the direct command to your Ghostty config (`~/.config/ghostty/config`): - ``` - command = direct:/absolute/path/to/node /absolute/path/to/nmsh - ``` +`/caffeinate`, `/awake` and `/zoomies` are the same feature: -Do not instruct macOS to change your default login shell to NMSh. NMSh is a frontend; zsh remains the underlying shell. +```text +/zoomies open the panel (starts nothing by itself) +/zoomies display keep the display and the machine awake until stopped +/zoomies system 2h prevent system sleep for two hours +/zoomies status mode, backend, start time, timeout +/zoomies stop end it +``` -## Host Compatibility +It uses the operating system's own mechanism: Apple `caffeinate` on macOS, a systemd inhibitor on Linux (idle and sleep only; the inhibitor is not a display API, so Display is shown as unavailable there), and the `SetThreadExecutionState` API on Windows. The assertion is an NMSh-owned background process, so the prompt comes straight back; it keeps running after the window closes and ends on `stop` or its timeout. Typing `caffeinate` yourself is still an ordinary shell command. While it is active, NMSh shows `Awake · ` on a free composer edge (or a row next to the composer), in the Status Strip when that is on, and optionally on the screensaver; nothing shows while it is off. Power settings are never changed, and `/zoomies stop` only stops what NMSh can prove it started. -Keep your terminal. Keep your shell. Upgrade the interaction layer. NMSh is intentionally terminal-host independent: you can move between integrated terminals, standalone terminals and different hosts and keep the same NMSh interaction layer. No host is required or preferred. +## Compatibility -- **Integrated terminals** are a first-class NMSh use case. Integrated terminals such as Zed and VS Code are regularly used during development and receive frequent real-world testing. -- **Standalone terminals** are equally first-class. Standalone terminals such as Ghostty and macOS Terminal are also regularly used and physically validated. -- **Other compatible hosts** are supported where NMSh's terminal capabilities allow, but some have not yet received the same level of physical validation. +**Shells.** zsh (default), Bash 4.4+ and Fish run behind one [ShellAdapter](docs/architecture/shell-adapter.md); the composer, transcript, sessions, prompt, themes and completion menu work over each, and `/shell` switches the current session in place. NMSh loads your startup files in a controlled bootstrap and never edits them. ZLE prompt and widget UI (Powerlevel10k's in-shell prompt, zsh-autosuggestions, zsh-syntax-highlighting) is kept off inside NMSh so it cannot fight the composer; those plugins keep working in `/zsh` and ordinary shells. Native `fzf-tab` is not supported ([#52](https://github.com/raiseCatError/notMyShell/issues/52)). -*Supported* means NMSh is designed to work with the host's capability profile; *physically validated* means a real manual QA pass has been completed on that host. +**Shell frameworks and prompt providers.** They are different things, and NMSh treats them differently: -| Host | Kind | Notes | -|------|------|-------| -| Zed | Integrated | Wheel/trackpad scrolling of the transcript through standard SGR mouse reporting; Shift keeps Zed's own text selection. Appearance is configured by Zed. Additional sessions use `/resume` or `nmsh --attach`. | -| VS Code | Integrated | `Shift+Enter` may require custom `keybindings.json` forwarding. Opacity/blur controls are not applicable. | -| Ghostty | Standalone | `/keyboard` and `/appearance` integration, new windows for sessions. | -| macOS Terminal | Standalone | Shift+Enter works out of the box; keyboard scrolling (PageUp/PageDown). | -| Kitty | Capability profile (CI fixtures) | Kitty keyboard protocol, mouse reporting and graphics are assumed only from the profile plus protocol replies; new windows need `allow_remote_control`. Not physically validated. | -| iTerm2 | Capability profile (CI fixtures) | Mouse, hyperlinks and image protocol from the profile; new windows through AppleScript. Not physically validated. | -| WezTerm | Capability profile (CI fixtures) | Same as iTerm2 for images; new windows through `wezterm cli spawn`. Not physically validated. | -| Windows Terminal (via WSL) | Capability profile (CI fixtures) | Detected from `WT_SESSION`; mouse, hyperlinks and truecolor only. Not physically validated. | -| Unknown or embedded hosts | Generic | Baseline capabilities, upgraded only by protocol replies. | +| | What it is | What NMSh does | +| --- | --- | --- | +| Oh My Zsh | Zsh framework | Detects it; guided install that keeps your `.zshrc` (you run the official installer); compares and can restore `.zshrc.pre-oh-my-zsh` after a backup and confirmation | +| Powerlevel10k | Zsh prompt theme | Optional prompt provider rendered in an isolated helper; `p10k configure` on request | +| Starship, Oh My Posh | Cross-shell prompt engines | Optional prompt providers run directly by NMSh, no rc changes | +| Prezto, Zim, zinit, Antidote | Zsh ecosystem tools | Detected and shown, inspect-only | -NMSh owns terminal-native interaction; the editor around it owns editor-native interaction. `/find` and `/filter` search and filter the transcript; `/open path:line:col` and `/open-diff a b` hand files to Zed or VS Code (or `$VISUAL`/`$EDITOR`) instead of rebuilding an editor inside the terminal. See [product boundary and HostActions](docs/architecture/host-actions.md). Inline images (`/about`) appear only where the host implements Kitty graphics or iTerm2 images ([image surface](docs/architecture/image-surface.md)). +NMSh never sources or installs framework code on its own, and never merges shell configuration. -Where a host differs, NMSh says so factually (for example, "Appearance is configured by Zed."). Host profiles only supply conservative capability hints, and optional protocols still come from the shared probe. +**Terminals.** NMSh is host-independent. Zed, VS Code, Ghostty and Terminal.app are used daily during development, and Ghostty and Terminal.app are physically validated; Kitty, iTerm2, WezTerm and Windows Terminal (through WSL) have capability profiles covered by CI fixtures but have not had the same physical QA. Hosts differ in keyboard and mouse reporting — see [terminal host](docs/architecture/terminal-host.md) and [HostActions](docs/architecture/host-actions.md) for details, and `/keyboard` for Ghostty key forwarding (Option+Backspace, Cmd+A). -## Keyboard Behavior +**Accessibility.** Safe/ASCII glyphs (`/glyphs`), `NO_COLOR`, 256-color terminals and Reduced Motion are first-class: meaning never depends on color or icons alone, and motion stops when you ask it to. See [accessibility](docs/accessibility/). -- **Enter:** Submit command -- **Ctrl+J:** Portable multiline newline fallback -- **Shift+Enter (Ghostty/macOS Terminal):** Insert a newline in the editor -- **Option+Left/Right:** Move cursor by word -- **Option+Backspace:** Delete previous word (requires Ghostty forwarding setup) -- **Ctrl+W:** Delete previous word -- **Cmd+A:** Select all input (requires Ghostty forwarding setup) -- **Cmd+Up/Down:** Jump to top/bottom of buffer (requires Ghostty forwarding setup) -- **Shift+Left/Right:** Character selection -- **Up/Down:** Recall previous/next submitted commands when the caret is on the first/last editor line; Down past the newest restores your unsent draft. Multiline drafts move by line first. A completion or slash-command menu is entered with Down; Up from its first row returns to history. Panels and /history, /dirs keep their own Up/Down. -- **Ctrl+F:** Find in the transcript (adds a term; terms AND together). `/find` does the same; Cmd+F stays the host's own find. -- **PageUp/PageDown, mouse wheel:** Scroll output history +## Safety and ownership -*(Note: In VS Code, Shift+Enter is often indistinguishable from Enter by default. Use Ctrl+J as a reliable multiline fallback.)* +- Everything runs locally. No account, no telemetry, no cloud backend ([privacy](docs/privacy.md)). +- **Your shell config is code.** NMSh does not edit rc files behind your back. The few changes it can make on request (for example a Theme Bridge include, or restoring a backed-up `.zshrc`) are shown as an exact diff and wait for your confirmation. +- **Imports are data.** Theme files are parsed with bounded data parsers; nothing is sourced, templated or fetched. `/dotfiles` never runs anything from a repository; it imports supported settings and leaves executable configs inspect-only. +- **NMSh owns what it writes, and only that.** Generated files are recorded in an ownership ledger and are replaced or removed only while they still match what NMSh wrote. Keep Awake stops only the process it can prove it started. +- **Installs are explicit.** `/tools` shows the exact package-manager command and asks first; nothing elevates silently. -### /keyboard & /appearance +See [SECURITY.md](SECURITY.md) to report a vulnerability. -Some advanced shortcuts (like Option+Backspace, Cmd+A) are normally consumed by the terminal host before NMSh sees them. The `/keyboard` slash command installs managed, opt-in forwarding rules exclusively into your Ghostty configuration. It does not alter any macOS system keybindings. +## Known limitations -The `/appearance` slash command provides an interactive UI to adjust Ghostty's window background opacity, blur mode, and blur radius. +- The completion bridge is close to, but not full parity with, a configured interactive zsh. +- Highlighting covers common command structure, not the entire zsh grammar. +- Powerlevel10k's right prompt, instant prompt and gitstatus daemon are not reproduced by the provider. +- Theme Bridge recolors new tool instances; editors and shells already running outside NMSh are not recolored live. delta is shown but not managed (it reads bat's cache and git config, which NMSh leaves alone). Terminal title ownership (OSC 0/2) is not implemented. +- Hosts without mouse reporting scroll the transcript with PageUp/PageDown. -## Known Limitations +## Documentation -- **Mouse behavior:** Native mouse selection or Shift-drag behavior may feel different because NMSh enables mouse reporting. -- **Hosts without mouse reporting:** on baseline hosts (for example Terminal.app) scroll the transcript with PageUp/PageDown. A host setting that turns wheel scrolling into arrow keys on the alternate screen makes the wheel walk command history instead. -- **ZLE widgets:** Certain complex third-party ZLE (Zsh Line Editor) widgets are not directly portable. -- **zsh grammar:** Syntax highlighting intentionally does not implement the entire, exhaustive zsh grammar; it focuses on providing fast semantic assistance for common command structures. Highlighting colors are theme-aware via `/syntax`. -- **Completion:** The completion bridge is not full parity with a configured interactive zsh, and native `fzf-tab` is not supported yet ([#52](https://github.com/raiseCatError/notMyShell/issues/52)). -- **Nested activity:** Only directly observed Node TAP v13 streams produce nested activity rows. -- **Powerlevel10k provider:** The right prompt, instant prompt, gitstatus daemon, and p10k settings defined only in `.zshrc` are not reproduced. -- **Theme Bridge:** already-running editors and shells outside NMSh are not recolored live; bat and delta are detected but stay Independent (their custom themes need bat's cache or git config, which NMSh does not modify). Terminal title ownership (OSC 0/2) is not implemented. +- [Architecture](ARCHITECTURE.md) — how NMSh works end to end, in plain language; deeper docs live under `docs/architecture/` and `docs/design/` +- [Demo gallery](docs/demos.md) — every feature clip in one place +- [ShellAdapter](docs/architecture/shell-adapter.md), [platforms](docs/architecture/platforms.md), [terminal stack](docs/architecture/terminal-stack.md) +- [Themes, imports and Theme Bridge](docs/design/theme-bridge.md), [Chroma and UI chrome](docs/design/chroma-and-ui-chrome.md), [idle visuals](docs/design/idle-visuals.md) +- [Roadmap](ROADMAP.md) · [Changelog](CHANGELOG.md) · [Support](SUPPORT.md) ## Development ```sh -npm run build -npm run typecheck -npm test -git diff --check +npm run verify:fast # build + core tests (iteration) +npm run verify # build + full suite +npm run demos # re-record the README clips with VHS (see scripts/demos/README.md) ``` -## Community & Documentation - -- **Contributing** → [CONTRIBUTING.md](CONTRIBUTING.md) -- **Security** → [SECURITY.md](SECURITY.md) -- **Roadmap** → [ROADMAP.md](ROADMAP.md) -- **Changelog** → [CHANGELOG.md](CHANGELOG.md) - -See [ROADMAP.md](ROADMAP.md) for future multi-shell architecture and extensibility plans. - -## Security & Privacy - -- Everything executes locally on your machine through your local shell. -- No cloud backend or account is required. -- No telemetry is collected; agent activity stats and session notices are local, optional and never contain prompts or output. -- NMSh never edits your shell configuration. See [docs/privacy.md](docs/privacy.md). +Contributor and agent guidance: [CONTRIBUTING.md](CONTRIBUTING.md) and [AGENTS.md](AGENTS.md). `NMSH_DETERMINISTIC=1` makes presentation repeatable for tests and recordings ([deterministic presentation](docs/testing/deterministic-presentation.md)). ## License -NMSh is licensed under the **GNU General Public License v3.0** (GPL-3.0-only). See the `LICENSE` file for details. +NMSh is licensed under the **GNU General Public License v3.0** (GPL-3.0-only). See [LICENSE](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md index 7f9a040f..db7cf9dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,7 @@ |---|---| | **Current release** | [v0.16.0 — Sessions, Agents & Portability](https://github.com/raiseCatError/notMyShell/releases/tag/v0.16.0) — released | | **Next development milestone** | [v0.17.0 — Compatibility & Discovery](https://github.com/raiseCatError/notMyShell/milestone/15) | +| **In development (unreleased)** | [PR #315](https://github.com/raiseCatError/notMyShell/pull/315) — Theme Studio, Theme Bridge, tool configuration, shell frameworks, Keep Awake; see CHANGELOG → Unreleased | | **Unscheduled** | [Future / Backlog](https://github.com/raiseCatError/notMyShell/milestone/7) | | **Development branch** | `dev` | | **Project board** | [NMSh Development](https://github.com/users/raiseCatError/projects/1) | @@ -145,7 +146,7 @@ The work formerly planned as v0.8–v0.15 ships together with v0.16 in one cumul | Release | Theme | Tracker | |---|---|---| -| v0.18.0 | Theme Bridge and semantic terminal integration | [#304](https://github.com/raiseCatError/notMyShell/issues/304), delivered in focused slices | +| v0.18.0 | Theme Bridge and semantic terminal integration | [#304](https://github.com/raiseCatError/notMyShell/issues/304), delivered in focused slices; in development on [PR #315](https://github.com/raiseCatError/notMyShell/pull/315), not released. Still deferred: terminal title ownership (OSC 0/2), delta custom styles, terminal emulator and editor base-theme takeover. | | v0.19.0 | NMSh Native module ecosystem: architecture, lazy probes, caching and provenance first, then a bounded high-value module set (not the whole catalog) | [#305](https://github.com/raiseCatError/notMyShell/issues/305) | ## Backlog — future, unscheduled diff --git a/SECURITY.md b/SECURITY.md index 4568ad23..f602c8f8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ Security is critical for NMSh, as it executes and presents shell commands and ha ## Supported Versions -Formal stable version releases do not exist yet. Security fixes currently target the latest `master` branch (and the latest published release when releases begin). +Security fixes target the latest stable release (currently v0.16.0, the `master` branch) and the active development branch. Older releases are not patched separately; update with `/update`. ## Scope @@ -17,6 +17,18 @@ Examples of in-scope security vulnerabilities include: - Arbitrary file access - Privilege or security boundary mistakes - Unsafe configuration writes +- A path where NMSh executes content it should only read (theme imports, dotfiles repositories, tool configs) +- NMSh stopping, replacing or deleting something it cannot prove it created + +## Boundaries NMSh is designed to keep + +Reports that break any of these are in scope: + +- **Shell configuration is executable.** NMSh never sources, merges or silently edits rc files or framework code; the few edits it offers (an include line, restoring a backed-up `.zshrc`) are exact diffs applied only after confirmation. +- **Imports are data.** Theme imports use bounded data parsers (no includes, templates, Lua, entities or network). `/dotfiles` never runs repository content, clones only after confirmation (no submodules, hooks disabled), and fails closed when a copy cannot be verified exactly. +- **Ownership.** Generated files are tracked in an ownership ledger and replaced or removed only while their content still matches; Keep Awake signals only a process whose token and exact command line match its record. +- **Installers.** Tool installs are typed package-manager argv shown before confirmation; nothing elevates silently. Special installers (Oh My Zsh) are never run by NMSh. +- **External prompt providers** (Starship, Oh My Posh, Powerlevel10k) run as bounded, non-interactive helpers with argv only; their output is treated as untrusted display text. ## Reporting a Vulnerability diff --git a/SUPPORT.md b/SUPPORT.md index d0c8e551..fe1277cb 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -3,7 +3,7 @@ If you need help with NMSh, please follow the guidelines below to ensure your request reaches the right place. ## Bug Reports -If you have encountered a bug, panic, or reproducible crash, please open a **[Bug Report](../../issues/new?template=bug_report.yml)** via GitHub Issues. Make sure to fill out the requested template so we can reliably reproduce the problem. +If you have encountered a bug, panic, or reproducible crash, please open a **[Bug Report](../../issues/new?template=bug_report.yml)** via GitHub Issues. Make sure to fill out the requested template so we can reliably reproduce the problem. Including the output of `nmsh doctor` (or `/doctor`) helps; it contains no history, prompts or secrets. ## Feature Requests If you have an idea for a new feature or improvement, please open a **[Feature Request](../../issues/new?template=feature_request.yml)** via GitHub Issues. diff --git a/assets/readme/composer.gif b/assets/readme/composer.gif new file mode 100644 index 0000000000000000000000000000000000000000..9ebc15615581dab0e3001811c0ebd388981e6c89 GIT binary patch literal 1599306 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�LN5 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%0DtaKA+ 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 (Jk0xZyPI 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

C_w7L@$t0nb67(YA)q;*~qs6-J} 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 z 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

c@7 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

2Ok0|aT12x*9kpl1Y72zy{F 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%0A%o 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