diff --git a/CHANGELOG.md b/CHANGELOG.md index 08a47da1..61060a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Safe saving: the new text is written beside the file and renamed over it, keeping its mode, group, line endings, BOM and final newline; a symlink stays a symlink. If the file changed, was replaced or was deleted since you opened it, nothing is written until you choose Keep editing, Overwrite or Reload. A new file never replaces one that appeared meanwhile. - Limits: your own regular UTF-8 text files up to 1 MiB with one hard link, writable, with one kind of line ending. Others are refused with the reason and stay readable in `/view`. Extended attributes and ACLs are not carried over by a save. Unsaved text is lost if the NMSh window itself is closed. - **Semantic Output** for test runs and builds ([#386](https://github.com/raiseCatError/notMyShell/issues/386), first phase). A completed block whose output NMSh recognizes gets one overview row with the counts the tool printed (`✔ node --test · 1,772 passed · 0 failed · 25 skipped`, `✘ TypeScript · 3 errors in 2 files`) and, when it listed failures or diagnostics, a details row that opens them, taken verbatim from the output. Recognized: Node's test runner (spec and TAP), Jest, Vitest, pytest, `cargo test`, `go test`, TypeScript, Rust and GCC/Clang diagnostics; anything else stays raw only, and short passing output gets nothing. Details start open when the raw output is folded; click the details row or use Actions → Show or hide details. The output itself, its folding, search, selection and plain `/copy` are unchanged. `/copy --semantic` copies the reading (raw for an output without one, as the note says) and `/copy --both` the raw output and the reading under labelled NMSh separators. Settings → Transcript → Semantic Output turns it off. +- **Native modules: Bun, Nix shell and container** ([#364](https://github.com/raiseCatError/notMyShell/issues/364)). Bun's requested version comes from `.bun-version` or `package.json` (`packageManager`, `engines.bun`) and the active one from its install location; the Nix shell module shows `pure`/`impure` from `IN_NIX_SHELL`; the container module names Docker, Podman, a dev container or Codespaces from marker files and host variables. None runs anything; all are off until enabled in `/modules`, which recommends Bun and Nix when their files are present. +- **`/modules explain` and `/modules timings`** ([#365](https://github.com/raiseCatError/notMyShell/issues/365)) write a plain-text report into the transcript: each configured module's surface, whether it is on screen and why, and its sources' state, age and last collection time; or the slowest last collections. They read what the Context Engine already has and collect nothing. - **`/track`** keeps one live result of a finite command in the transcript, refreshed in place ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). `/track df -h` or `/track --every 5s -- docker system df` writes the command once, then shows a status row (`live · every 5s · updated 13:49:25 · exit 0`, a failing exit, a timeout, paused, stale, refreshing) and the latest result under it, with a stable height. The shell stays free; nothing accumulates per refresh, and find, selection and `/copy` never include the live rows. `/track copy` copies the latest result, `/track refresh`, `pause`, `resume` and `stop` control it, and stopping leaves the last result in the transcript once. Samples run `sh -c` from the starting directory with your shell's PATH (not its aliases or functions), never overlap, and are bounded (200 kB, a timeout, at most 8 trackers); destructive and unknown commands are refused or need a Yes, as for `/watch`. Trackers stop with the window that started them. `/watch` keeps its manager and its behavior; both share one scheduler, which gains stricter interval parsing, refusals for substitutions, background jobs and heredocs, UTF-8-safe bounded capture, visible timeouts and a stop grace period (from draft #394). ### Fixed diff --git a/docs/design/native-module-coverage.md b/docs/design/native-module-coverage.md index c04e6051..c51ecf07 100644 --- a/docs/design/native-module-coverage.md +++ b/docs/design/native-module-coverage.md @@ -26,9 +26,9 @@ module behaves identically to its upstream namesake. | Git | `git_branch`, `git_status`, `git_commit`, `git_state`, `git_metrics` / `git` | Native: `gitBranch`, `gitStatus`; pack `nmsh.vcs` (`stash`, `upstream`) | Covered; commit/state/metrics are candidates only if requested | | Other VCS | `hg_branch`, `fossil_*`, `pijul_channel`, `vcsh` / `mercurial`, `fossil`, `jujutsu`, `sapling`, `svn`, `plastic`, `dvc` | Pack `nmsh.vcs`: `hg` (bookmark/branch), `jj` (repository state) | **Done: Jujutsu and Mercurial** ([#374](https://github.com/raiseCatError/notMyShell/pull/374)), from declarative `.jj` / `.hg` metadata; the rest defer to a provider | | Core runtimes | `nodejs`, `python`, `golang`, `rust`, `java`, `package` / `node`, `python`, `golang`, `rust`, `java`, `project` | Native: pack `nmsh.project` (`package`, `node`, `python`, `go`, `rust`, `java`) with requested vs. active versions | Covered | -| More language runtimes | `ruby`, `php`, `dotnet`, `swift`, `kotlin`, `zig`, `elixir`, `lua`, `haskell`, `dart`, `deno`, `bun`, `scala`, `perl`, `ocaml`, `nim`, `julia`, `r`, `crystal` | Only generic: built-in `toolchain` and `discoveredTools` show that a project uses a tool, not its version | **Done: Ruby, PHP, .NET, Swift, Zig, Deno ([#370](https://github.com/raiseCatError/notMyShell/pull/370)); Elixir, Dart, Flutter ([#372](https://github.com/raiseCatError/notMyShell/pull/372)). Remaining candidates: Bun, Kotlin/Scala, Lua, Haskell.** Same pattern as `runtimes.ts`: requested version from the project's own file, active version from the install path or the runtime's metadata, never by running it | +| More language runtimes | `ruby`, `php`, `dotnet`, `swift`, `kotlin`, `zig`, `elixir`, `lua`, `haskell`, `dart`, `deno`, `bun`, `scala`, `perl`, `ocaml`, `nim`, `julia`, `r`, `crystal` | Only generic: built-in `toolchain` and `discoveredTools` show that a project uses a tool, not its version | **Done: Ruby, PHP, .NET, Swift, Zig, Deno ([#370](https://github.com/raiseCatError/notMyShell/pull/370)); Elixir, Dart, Flutter ([#372](https://github.com/raiseCatError/notMyShell/pull/372)); Bun (v0.21: `.bun-version`, `package.json` `packageManager`/`engines.bun`, the install location). Remaining candidates: Kotlin/Scala, Lua, Haskell.** Same pattern as `runtimes.ts`: requested version from the project's own file, active version from the install path or the runtime's metadata, never by running it | | Build systems | `cmake`, `meson`, `gradle`, `maven`, `buf`, `xmake`, `bazel` / `cmake`, `gradle`, `mvn`, `bazel`, `nx` | Gradle/Maven appear as the `java` module's build tool; others only as discovered tools | Covered for Java; **candidate: a single `build` module**: done ([#373](https://github.com/raiseCatError/notMyShell/pull/373): Bazel, Gradle, Maven, CMake, Meson, Make) rather than one module each | -| Environment managers | `conda`, `direnv`, `mise`, `nix_shell`, `pixi`, `guix_shell`, `spack`, `singularity`, `container` / `nix-shell` | Native: `direnv` and `tools` (pack `nmsh.environment`); Python environments including conda/venv inside `python` | Covered for direnv/conda/venv; **candidates: mise, Nix shell, devcontainer/container** | +| Environment managers | `conda`, `direnv`, `mise`, `nix_shell`, `pixi`, `guix_shell`, `spack`, `singularity`, `container` / `nix-shell` | Native: `direnv` and `tools` (pack `nmsh.environment`); Python environments including conda/venv inside `python` | Covered for direnv/conda/venv; mise through `tools`. **Done in v0.21: Nix shell** (`IN_NIX_SHELL`, pure/impure) **and container** (Docker/Podman marker files, Codespaces and dev container host variables; pack `nmsh.system`) | | Cloud and infrastructure | `aws`, `gcloud`, `azure`, `kubernetes`, `terraform`, `helm`, `pulumi`, `docker_context`, `openstack`, `nats`, `opa` / `aws`, `az`, `gcp`, `kubectl`, `terraform`, `helm`, `pulumi`, `docker`, `argocd`, `cf`, `firebase` | Native: built-ins `kubeContext`, `dockerContext`; packs `nmsh.cloud` (`aws`, `gcp`, `azure`), `nmsh.infrastructure` (`terraform`, `helm`, `pulumi`) | Covered for the common set; ArgoCD, Firebase, Cloud Foundry, OpenStack, NATS, OPA: provider | | Frameworks and app tooling | `angular`, `react`, `svelte`, `tauri`, `flutter`, `unity`, `umbraco` (Oh My Posh) | None | **Do not duplicate.** These are project-type labels derived from the same files the runtimes read; they add chrome, not information | | Package managers | `npm`, `pnpm`, `yarn`, `bun` (OMP) | `package` module reports the package and its manager | Covered | @@ -55,3 +55,13 @@ directories (including hostile and symlinked files), and the explain/timings vie Starship, Oh My Posh and Powerlevel10k keep working as external prompt providers; nothing here replaces them. Importing their *visual* configuration into NMSh themes is a separate problem owned by [#304](https://github.com/raiseCatError/notMyShell/issues/304); translating their segment *logic* is not attempted. + +## v0.21 review (2026-10-18) + +Bun, Nix shell and container were added with the same rules (declarative files, allowlisted variables and marker-file +presence only; nothing run). The `requires` bound of a Context Pack rose from 16 to 24 so a pack can name as many +capabilities as it may hold modules. Kotlin/Scala, Lua and Haskell stay candidates: each needs a declarative version +source that is reliable enough to show (a Gradle Kotlin plugin version, a rockspec, a Stack resolver) and was not judged +worth a module per language in this release. Diagnostics (#365): `/modules explain` and `/modules timings` give a +plain-text report of routing, visibility reasons, source states and the slowest last collections, from the engine's +snapshots only. diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index aea9bb28..10d08c22 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -20,7 +20,7 @@ Automated results here are not physical-terminal validation. | 3 | `/edit` | #402 | `feature/v021-edit` (stacked on slice 2) | | PR open | | 4 | Semantic Output | #386 | `feature/v021-semantic` (stacked on slice 3) | | PR open | | 5 | `/track` | #393 (#394) | `feature/v021-track` (stacked on slice 4) | | PR open | -| 6 | Modules and diagnostics | #364, #365 | | | | +| 6 | Modules and diagnostics | #364, #365 | `feature/v021-modules` (stacked on slice 5) | | PR open | | 7 | Themes and controls | #401, #336 | | | | ## Decisions @@ -40,7 +40,7 @@ Automated results here are not physical-terminal validation. Checklist: [v0.21 physical QA](../testing/v021-physical-qa.md). Slice 1: section 1 (multi-row progress in Ghostty and Terminal.app, queued highlight colors). Slice 2: section 2 (`/view` keys, rendering, refusals, hostile text, NO_COLOR). Slice 3: section 3 (`/edit` editing keys, saving, conflicts, refusals). Slice 4: section 4 (overview rows, details -toggle, copy flags, Off). Slice 5: section 5 (`/track` block, refresh, copy, stop, cleanup). +toggle, copy flags, Off). Slice 5: section 5 (`/track` block, refresh, copy, stop, cleanup). Slice 6: section 6 (Bun, Nix, container, reports). ## Verification notes diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index ed702e27..3fbe93a7 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -23,6 +23,7 @@ | 4 Semantic Output | Ghostty | macOS | zsh | | Not run | | | 5 `/track` | Ghostty | macOS | zsh | | Not run | | | 5 `/track` | Terminal.app | macOS | Fish | | Not run | | +| 6 Modules | Ghostty | macOS | zsh | | Not run | | ## 1. Multi-row progress redraws (#388) @@ -103,3 +104,13 @@ - [ ] 5.7 An unknown command asks for Yes first; `rm`, `sudo`, `cd`, `$(...)`, `&` and heredocs are refused. - [ ] 5.8 Close the window with a tracker running: no `sh` or command processes are left (`ps`). `/watch git status` still opens its manager. + +## 6. Native modules and diagnostics + +- [ ] 6.1 In a folder with `bun.lock` and `"packageManager": "bun@1.1.30"`, `/modules` recommends Bun; enabled, typing + `bun` shows its version (requested, and active when installed through Homebrew). +- [ ] 6.2 `nix-shell -p hello` (or `nix develop`) with the Nix shell module enabled: it shows `impure`; it disappears + after `exit`. +- [ ] 6.3 In a Docker or Podman container (or a Codespace), the container module names it; on the host it shows nothing. +- [ ] 6.4 `/modules explain` lists every configured module with its surface and reason; `/modules timings` lists the + slowest collections; neither changes what is collected (the Status Strip CPU stays as it was). diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index e12189a7..0ca8fe6c 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -271,6 +271,7 @@ import {runDoctor} from '../doctor/doctor.js'; import {parseWatch, watchSafety, WatchTasks, type WatchSafety} from '../tasks/WatchTasks.js'; import {renderWatchPanel, watchPanelKey, watchRow, type WatchPanelState} from '../tasks/WatchPanel.js'; import {finalResultRows, trackBlockRows} from '../tasks/trackBlock.js'; +import {explainModules, moduleTimings} from '../prompt/moduleReport.js'; import {analyzePaste, KIND_LABELS, needsPreview, PASTE_EXACT_NOTE, pasteHeader, primaryKind, type PasteAnalysis, nmshCommandNote} from '../input/pasteGuard.js'; import type {BatchShell} from '../input/pasteBatch.js'; import {createPasteReview, displaySafe, pasteReviewKey, renderPasteReview, type PasteReviewState} from '../input/PasteReview.js'; @@ -2944,6 +2945,8 @@ export class TerminalApp { 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 === 'modules' && slash.report === 'explain') this.output.addFrontendBlock(command.trim(), explainModules(this.promptConfiguration.modules, this.modulesContext(), this.promptConfiguration)); + else if (slash.kind === 'modules' && slash.report === 'timings') this.output.addFrontendBlock(command.trim(), moduleTimings(id => this.contextEngine.status(id))); else if (slash.kind === 'modules') { this.panelOrigin = undefined; this.openModulesManager(); } else if (slash.kind === 'chroma') { this.panelOrigin = undefined; this.startChromaSettings(); } else if (slash.kind === 'screensaver') { diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 430f61fb..8791c228 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -51,7 +51,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {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: '/modules', insertion: '/modules', description: 'Context modules, catalog and Context Packs: show, route to a surface, search; saved as you change them'}, + {name: '/modules', insertion: '/modules', description: 'Context modules, catalog and Context Packs: show, route to a surface, search; saved as you change them (/modules explain · /modules timings)'}, {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'}, {name: '/screensaver', insertion: '/screensaver', description: 'Idle visuals: live gallery, timeout and colors'}, @@ -178,7 +178,7 @@ export type ParsedSlashCommand = | {kind: 'appearance'} | {kind: 'motion'} | {kind: 'prompt'} - | {kind: 'modules'} + | {kind: 'modules'; report?: 'explain' | 'timings'} | {kind: 'chroma'} | {kind: 'theme'} | {kind: 'themeBridge'} @@ -281,7 +281,8 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined 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 (/^\/modules\s*$/u.test(input)) return {kind: 'modules'}; + const modules = /^\/modules(?:\s+(explain|timings))?\s*$/u.exec(input); + if (modules) return modules[1] ? {kind: 'modules', report: modules[1] as 'explain' | 'timings'} : {kind: 'modules'}; 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'}; diff --git a/src/context/capabilities/environment.ts b/src/context/capabilities/environment.ts index de29ec19..609ae706 100644 --- a/src/context/capabilities/environment.ts +++ b/src/context/capabilities/environment.ts @@ -77,4 +77,23 @@ export const direnv = defineCapability({ }, }); -export const ENVIRONMENT_CAPABILITIES = [toolVersions, direnv] as CapabilityDefinition[]; +export interface NixShellFact { + /** `pure` / `impure` as nix-shell and nix develop report it; `active` for an older nix that sets only 1. */ + state: 'pure' | 'impure' | 'active'; +} + +export const nixShell = defineCapability({ + id: 'env.nixShell', + title: 'Nix shell', + reads: ['IN_NIX_SHELL, set in the shell by nix-shell and nix develop (never runs nix)'], + scope: 'session', family: 'metadata', cost: 'cheap', trust: 'session', sensitivity: 'public', persistence: 'snapshot-safe', + fields: ['state'], env: ['IN_NIX_SHELL'], ttlMs: 30_000, timeoutMs: 500, invalidateOn: ['command'], + preview: {state: 'impure'}, + async resolve(context) { + const value = context.env.values.IN_NIX_SHELL; + if (!value) return undefined; + return {value: {state: value === 'pure' || value === 'impure' ? value : 'active'}, evidence: 'IN_NIX_SHELL'}; + }, +}); + +export const ENVIRONMENT_CAPABILITIES = [toolVersions, direnv, nixShell] as CapabilityDefinition[]; diff --git a/src/context/capabilities/runtimes.ts b/src/context/capabilities/runtimes.ts index 7dda17ed..90142ee8 100644 --- a/src/context/capabilities/runtimes.ts +++ b/src/context/capabilities/runtimes.ts @@ -366,6 +366,20 @@ export const zigRuntime = simpleRuntime('runtime.zig', 'Zig toolchain', 'zig', [ export const denoRuntime = simpleRuntime('runtime.deno', 'Deno runtime', 'deno', ['.dvmrc', 'deno.json / deno.jsonc presence', '.tool-versions', 'mise.toml'], {requested: '2.1.0', requestedFrom: '.dvmrc', active: '2.1.4', manager: 'Homebrew'}, async context => versionFile(context, ['.dvmrc']), ['deno.json', 'deno.jsonc', 'deno.lock']); +export const bunRuntime = simpleRuntime('runtime.bun', 'Bun runtime', 'bun', ['.bun-version', 'package.json (packageManager "bun@…", engines.bun)', '.tool-versions', 'mise.toml'], + {requested: '1.1.30', requestedFrom: 'package.json', active: '1.1.34', manager: 'Homebrew'}, async context => { + const file = await versionFile(context, ['.bun-version']); + if (file) return file; + const manifest = await findNearest(context, ['package.json']); + const pkg = manifest ? parseJsonData(await readMetadataText(manifest.path, LARGE_METADATA_BYTES)) : undefined; + if (!record(pkg)) return undefined; + // Corepack's pin names the package manager and its exact version; engines.bun is a range. + const pinned = /^bun@(\d[\w.+-]{0,31})$/u.exec(text(pkg.packageManager, 64) ?? '')?.[1]; + if (pinned) return {version: pinned, from: 'package.json packageManager'}; + const range = record(pkg.engines) ? text(pkg.engines.bun, 64) : undefined; + return range ? {version: range, from: 'package.json engines'} : undefined; + }, ['bun.lockb', 'bun.lock', 'bunfig.toml']); + /** A release version as an SDK's own version file states it (Dart's `version`, Flutter's `version`): one bounded line, nothing else. */ const RELEASE = /^v?(\d+\.\d+(?:\.\d+)?(?:[-+][\w.]{1,32})?)$/u; async function releaseFile(path: string): Promise { @@ -436,5 +450,5 @@ export const flutterRuntime = runtimeCapability('runtime.flutter', 'Flutter SDK' return value ? {value, evidence: [requested?.from, active.active ? 'flutter on PATH' : undefined].filter(Boolean).join(', ') || 'flutter'} : undefined; }); -export const RUNTIME_CAPABILITIES: readonly CapabilityDefinition[] = [nodeRuntime, pythonRuntime, goRuntime, rustRuntime, javaRuntime, rubyRuntime, phpRuntime, dotnetRuntime, swiftRuntime, zigRuntime, denoRuntime, +export const RUNTIME_CAPABILITIES: readonly CapabilityDefinition[] = [nodeRuntime, pythonRuntime, goRuntime, rustRuntime, javaRuntime, rubyRuntime, phpRuntime, dotnetRuntime, swiftRuntime, zigRuntime, denoRuntime, bunRuntime, elixirRuntime, dartRuntime, flutterRuntime] as CapabilityDefinition[]; diff --git a/src/context/capabilities/system.ts b/src/context/capabilities/system.ts index d6530039..3f92ffe6 100644 --- a/src/context/capabilities/system.ts +++ b/src/context/capabilities/system.ts @@ -3,6 +3,7 @@ import {defineCapability, PREVIEW_NOW, type CapabilityDefinition} from '../capab import {parseXmlData, readMetadataText, record} from '../services.js'; import {runExternal} from '../../providers/providers.js'; import {linuxBattery, parseMeminfo, parsePmset, parseVmStat} from '../../status/systemStats.js'; +import {access} from 'node:fs/promises'; /** * Machine and session facts. Readers are OS metadata files and fixed system @@ -189,4 +190,25 @@ export const uptime = defineCapability({ async resolve() { return {value: {seconds: Math.floor(osUptime())}, evidence: 'system uptime'}; }, }); -export const SYSTEM_CAPABILITIES = [operatingSystem, sessionUser, sessionJobs, sessionDuration, clock, memory, battery, cpu, uptime] as CapabilityDefinition[]; +export interface ContainerFact {kind: 'Codespaces' | 'Dev Container' | 'Podman' | 'Docker'} + +async function present(path: string): Promise { + try { await access(path); return true; } catch { return false; } +} + +export const container = defineCapability({ + id: 'system.container', title: 'Container', + reads: ['/run/.containerenv and /.dockerenv presence (never their content)', 'CODESPACES and REMOTE_CONTAINERS set by dev container hosts'], + scope: 'machine', family: 'system', cost: 'bounded-async', trust: 'session', sensitivity: 'public', persistence: 'snapshot-safe', + fields: ['kind'], env: ['CODESPACES', 'REMOTE_CONTAINERS'], ttlMs: 3_600_000, timeoutMs: 500, invalidateOn: [], + preview: {kind: 'Docker'}, + async resolve(context) { + if (context.env.values.CODESPACES === 'true') return {value: {kind: 'Codespaces'}, evidence: 'CODESPACES'}; + if (context.env.values.REMOTE_CONTAINERS === 'true') return {value: {kind: 'Dev Container'}, evidence: 'REMOTE_CONTAINERS'}; + if (await present('/run/.containerenv')) return {value: {kind: 'Podman'}, evidence: '/run/.containerenv'}; + if (await present('/.dockerenv')) return {value: {kind: 'Docker'}, evidence: '/.dockerenv'}; + return undefined; + }, +}); + +export const SYSTEM_CAPABILITIES = [operatingSystem, sessionUser, sessionJobs, sessionDuration, clock, memory, battery, cpu, uptime, container] as CapabilityDefinition[]; diff --git a/src/context/packs/builtin/nmsh.environment.json b/src/context/packs/builtin/nmsh.environment.json index c9b9257f..ed450ba1 100644 --- a/src/context/packs/builtin/nmsh.environment.json +++ b/src/context/packs/builtin/nmsh.environment.json @@ -7,7 +7,7 @@ "license": "GPL-3.0-only", "provenance": {"author": "notMyShell", "notes": "First-party pack shipped with NMSh. Independent implementation."}, "compatibility": {"contextApi": 1, "nmsh": ">=0.17.0"}, - "requires": ["env.toolVersions", "env.direnv"], + "requires": ["env.toolVersions", "env.direnv", "env.nixShell"], "modules": [ { "id": "tools", "label": "mise / asdf", "description": "Tools the nearest mise.toml or .tool-versions requests (never runs mise or asdf).", @@ -26,10 +26,19 @@ {"text": "direnv "}, {"fact": "env.direnv", "field": "state"} ]}] + }, + { + "id": "nix", "label": "Nix shell", "description": "Whether this shell is inside nix-shell or nix develop, from IN_NIX_SHELL (never runs nix).", + "category": "environment", "priority": 37, "role": "system", "icon": "cubes", "condition": "always", + "surfaces": {"preferred": "rightContext"}, + "segments": [{"label": "nix", "parts": [ + {"fact": "env.nixShell", "field": "state"} + ]}] } ], "recommend": [ {"module": "tools", "evidence": [{"kind": "workspaceFile", "names": ["mise.toml", ".mise.toml", ".tool-versions"]}]}, - {"module": "direnv", "evidence": [{"kind": "workspaceFile", "names": [".envrc"]}]} + {"module": "direnv", "evidence": [{"kind": "workspaceFile", "names": [".envrc"]}]}, + {"module": "nix", "evidence": [{"kind": "workspaceFile", "names": ["flake.nix", "shell.nix"]}]} ] } diff --git a/src/context/packs/builtin/nmsh.project.json b/src/context/packs/builtin/nmsh.project.json index e81db5d4..33b83041 100644 --- a/src/context/packs/builtin/nmsh.project.json +++ b/src/context/packs/builtin/nmsh.project.json @@ -7,7 +7,7 @@ "license": "GPL-3.0-only", "provenance": {"author": "notMyShell", "notes": "First-party pack shipped with NMSh. Independent implementation; tool behavior studied from public documentation only."}, "compatibility": {"contextApi": 1, "nmsh": ">=0.17.0"}, - "requires": ["project.package", "project.build", "runtime.node", "runtime.python", "runtime.go", "runtime.ruby", "runtime.php", "runtime.dotnet", "runtime.swift", "runtime.zig", "runtime.deno", "runtime.elixir", "runtime.dart", "runtime.flutter", "runtime.rust", "runtime.java"], + "requires": ["project.package", "project.build", "runtime.node", "runtime.python", "runtime.go", "runtime.ruby", "runtime.php", "runtime.dotnet", "runtime.swift", "runtime.zig", "runtime.deno", "runtime.bun", "runtime.elixir", "runtime.dart", "runtime.flutter", "runtime.rust", "runtime.java"], "modules": [ { "id": "package", "label": "Package", "description": "Version and package manager from the nearest declarative manifest.", @@ -137,6 +137,18 @@ {"fact": "runtime.deno", "field": "requested", "prefix": " ", "optional": true, "when": {"field": "mismatch", "equals": true}} ]}] }, + { + "id": "bun", "label": "Bun", "description": "Bun runtime from .bun-version, package.json (packageManager, engines) or the install location; never run.", + "category": "runtime", "priority": 38, "role": "runtime", "icon": "code", "condition": "onCommand", + "triggers": ["bun", "bunx"], + "surfaces": {"preferred": "contextRail"}, + "segments": [{"label": "bun", "parts": [ + {"fact": "runtime.bun", "field": "active", "format": "version", "optional": true}, + {"fact": "runtime.bun", "field": "requested", "optional": true, "when": {"field": "active", "present": false}}, + {"fact": "runtime.bun", "field": "requestedFrom", "join": " · ", "optional": true, "when": {"field": "mismatch", "equals": true}}, + {"fact": "runtime.bun", "field": "requested", "prefix": " ", "optional": true, "when": {"field": "mismatch", "equals": true}} + ]}] + }, { "id": "elixir", "label": "Elixir", "description": "Elixir version from mix.exs, .tool-versions or the install location; never run.", "category": "runtime", "priority": 37, "role": "runtime", "icon": "elixir", "condition": "onCommand", @@ -208,6 +220,7 @@ {"module": "swift", "evidence": [{"kind": "workspaceFile", "names": ["Package.swift", ".swift-version"]}]}, {"module": "zig", "evidence": [{"kind": "workspaceFile", "names": ["build.zig", "build.zig.zon", ".zigversion"]}]}, {"module": "deno", "evidence": [{"kind": "workspaceFile", "names": ["deno.json", "deno.jsonc", ".dvmrc"]}]}, + {"module": "bun", "evidence": [{"kind": "workspaceFile", "names": ["bun.lockb", "bun.lock", "bunfig.toml", ".bun-version"]}]}, {"module": "build", "evidence": [{"kind": "workspaceFile", "names": ["MODULE.bazel", "WORKSPACE", "settings.gradle.kts", "settings.gradle", "build.gradle.kts", "build.gradle", "pom.xml", "CMakeLists.txt", "meson.build"]}]}, {"module": "elixir", "evidence": [{"kind": "workspaceFile", "names": ["mix.exs"]}]}, {"module": "dart", "evidence": [{"kind": "workspaceFile", "names": ["pubspec.yaml"]}]}, diff --git a/src/context/packs/builtin/nmsh.system.json b/src/context/packs/builtin/nmsh.system.json index f7949b44..92ec0a61 100644 --- a/src/context/packs/builtin/nmsh.system.json +++ b/src/context/packs/builtin/nmsh.system.json @@ -7,8 +7,16 @@ "license": "GPL-3.0-only", "provenance": {"author": "notMyShell", "notes": "First-party pack shipped with NMSh; shares the Status Strip's OS readers."}, "compatibility": {"contextApi": 1, "nmsh": ">=0.17.0"}, - "requires": ["system.os", "session.user", "session.jobs", "session.duration", "system.time", "system.battery", "system.memory"], + "requires": ["system.os", "session.user", "session.jobs", "session.duration", "system.time", "system.battery", "system.memory", "system.container"], "modules": [ + { + "id": "container", "label": "Container", "description": "Whether this shell runs in a container (Docker, Podman, a dev container or Codespaces), from marker files and host variables.", + "category": "system", "priority": 14, "role": "system", "icon": "cubes", "condition": "always", + "surfaces": {"preferred": "rightContext"}, + "segments": [{"label": "container", "parts": [ + {"fact": "system.container", "field": "kind"} + ]}] + }, { "id": "os", "label": "Operating system", "description": "Operating system name and version.", "category": "system", "priority": 12, "role": "system", "condition": "always", diff --git a/src/context/packs/schema.ts b/src/context/packs/schema.ts index 31c43481..7153a62b 100644 --- a/src/context/packs/schema.ts +++ b/src/context/packs/schema.ts @@ -293,7 +293,8 @@ export function parsePack(bytes: string | Uint8Array, capabilities: ReadonlyMap< ...(provenance.notes !== undefined ? {notes: str(provenance.notes, 'pack.provenance.notes', 240)} : {})}, compatibility: {contextApi: bounded(compatibility.contextApi, 'pack.compatibility.contextApi', 1, 99), ...(compatibility.nmsh !== undefined ? {nmsh: str(compatibility.nmsh, 'pack.compatibility.nmsh', 32, NMSH_RANGE)} : {})}, - requires: list(record.requires, 'pack.requires', 16, (entry, at) => str(entry, at, 64, /^[a-z][a-zA-Z0-9]*(?:\.[a-z][a-zA-Z0-9]*){1,3}$/u)), + // As many capabilities as modules: a pack's modules may each need a different one. + requires: list(record.requires, 'pack.requires', 24, (entry, at) => str(entry, at, 64, /^[a-z][a-zA-Z0-9]*(?:\.[a-z][a-zA-Z0-9]*){1,3}$/u)), modules: list(record.modules, 'pack.modules', 24, module, 1), ...(record.recommend !== undefined ? {recommend: list(record.recommend, 'pack.recommend', 24, (entry, at) => { const rule = obj(entry, at, ['module', 'evidence']); diff --git a/src/context/shellEnvironment.ts b/src/context/shellEnvironment.ts index c0e07561..115a9001 100644 --- a/src/context/shellEnvironment.ts +++ b/src/context/shellEnvironment.ts @@ -39,6 +39,8 @@ export const CONTEXT_ENV_VALUES: Readonly> = { MISE_ENV: 64, MISE_DATA_DIR: 1024, ASDF_DATA_DIR: 1024, // direnv's own bookkeeping says what it loaded; NMSh never runs direnv. DIRENV_DIR: 1024, DIRENV_FILE: 1024, + // nix-shell and nix develop mark their shells; dev container hosts mark theirs. + IN_NIX_SHELL: 16, REMOTE_CONTAINERS: 8, CODESPACES: 8, }; /** Reported as present or absent only; values are never read by the snapshot code. */ diff --git a/src/prompt/moduleReport.ts b/src/prompt/moduleReport.ts new file mode 100644 index 00000000..7ad74098 --- /dev/null +++ b/src/prompt/moduleReport.ts @@ -0,0 +1,53 @@ +import {moduleDefinition} from '../context/modules.js'; +import {CORE_CAPABILITIES} from '../context/registry.js'; +import {routeModule, surfaceAvailability} from '../context/surfaceRouter.js'; +import {MODULE_SURFACE_LABELS, type ContextModuleConfig, type ModuleSurface, type PromptConfiguration} from './configuration.js'; +import {whyText, type ModulesContext} from './ModulesPanel.js'; + +/** + * Plain-text module diagnostics for the transcript (#365), in the spirit of `starship explain` and `starship timings`: + * `/modules explain` says, per configured module, where it is routed, whether it is on screen and why, and the state of + * the capabilities it reads; `/modules timings` lists the slowest last collections. Both read the engine's snapshots + * and the settings only: nothing is collected, run or read from disk to produce them, and no module value is shown + * (only states, ages, durations and the capabilities' own evidence summaries). + */ + +type Status = NonNullable>>; + +const inert = (text: string, limit: number) => text.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}]/gu, ' ').slice(0, limit); +const age = (status: Status, now: number) => status.collectedAt === undefined ? '' : ` ${Math.max(0, Math.round((now - status.collectedAt) / 1000))}s ago`; +const took = (status: Status) => status.durationMs === undefined ? '' : `, took ${status.durationMs < 1 ? '<1' : Math.round(status.durationMs)} ms`; +const STATE: Record = {fresh: 'fresh', stale: 'stale', pending: 'collecting', absent: 'nothing to show', timeout: 'timed out', failed: 'failed', idle: 'not collected'}; + +export function explainModules(modules: readonly ContextModuleConfig[], context: ModulesContext, configuration: PromptConfiguration, now = Date.now()): string[] { + const rows: string[] = []; + for (const module of modules) { + const definition = moduleDefinition(module.id); + if (!definition) { rows.push(`${inert(module.id, 64)} · its Context Pack is not installed or enabled; nothing is collected`); continue; } + const surface = routeModule(module); + const availability = surface === 'hidden' ? undefined : surfaceAvailability(surface, configuration); + const sourced = [...definition.facts.keys()].map(id => ({id, status: context.status?.(id)})).filter((entry): entry is {id: string; status: Status} => Boolean(entry.status)); + const statuses = sourced.map(entry => entry.status); + const why = !module.visible ? 'turned off in Modules' + : availability && !availability.available ? availability.reason + : context.conditionMet?.(module) === false ? 'hidden: its condition does not match here' + : whyText(module, surface, statuses, definition.triggers); + const where = surface === 'hidden' ? 'Hidden' : MODULE_SURFACE_LABELS[surface as ModuleSurface]; + rows.push(`${inert(definition.label, 48)} (${module.id}) · ${where} · ${inert(why, 200)}`); + const sources = sourced.map(({id, status}) => `${id} ${STATE[status.state] ?? status.state}${age(status, now)}${took(status)}${status.evidence ? ` (${inert(status.evidence, 80)})` : ''}`); + if (sources.length) rows.push(` reads ${sources.join('; ')}`); + } + if (!rows.length) rows.push('No modules are configured.'); + rows.push('States come from this window\'s Context Engine; producing this report collected nothing.'); + return rows; +} + +export function moduleTimings(status: (id: string) => Status | undefined, now = Date.now(), limit = 15): string[] { + const measured = CORE_CAPABILITIES.map(capability => ({id: capability.id, ...status(capability.id)})).filter((entry): entry is Status & {id: string} => entry.durationMs !== undefined) + .sort((a, b) => b.durationMs! - a.durationMs!); + if (!measured.length) return ['No module data has been collected in this window yet.']; + const rows = measured.slice(0, limit).map(entry => `${String(entry.durationMs! < 1 ? '<1' : Math.round(entry.durationMs!)).padStart(6)} ms ${entry.id} · ${STATE[entry.state] ?? entry.state}${age(entry, now)}`); + if (measured.length > limit) rows.push(`… ${measured.length - limit} faster ones not listed`); + rows.push('Wall time of each capability\'s last collection, excluding time queued; nothing was collected to show this.'); + return rows; +} diff --git a/tests/contextCapabilities.test.ts b/tests/contextCapabilities.test.ts index b7d213e7..b16d710c 100644 --- a/tests/contextCapabilities.test.ts +++ b/tests/contextCapabilities.test.ts @@ -7,11 +7,11 @@ import {createHash} from 'node:crypto'; import type {CapabilityDefinition} from '../src/context/capability.js'; import {projectPackage} from '../src/context/capabilities/project.js'; import {projectBuild} from '../src/context/capabilities/build.js'; -import {dartRuntime, denoRuntime, dotnetRuntime, elixirRuntime, flutterRuntime, goRuntime, javaRuntime, nodeRuntime, phpRuntime, pythonRuntime, rubyRuntime, rustRuntime, swiftRuntime, versionMismatch, zigRuntime} from '../src/context/capabilities/runtimes.js'; -import {direnv, toolVersions} from '../src/context/capabilities/environment.js'; +import {bunRuntime, dartRuntime, denoRuntime, dotnetRuntime, elixirRuntime, flutterRuntime, goRuntime, javaRuntime, nodeRuntime, phpRuntime, pythonRuntime, rubyRuntime, rustRuntime, swiftRuntime, versionMismatch, zigRuntime} from '../src/context/capabilities/runtimes.js'; +import {direnv, nixShell, toolVersions} from '../src/context/capabilities/environment.js'; import {docker, helmChart, kubernetes, pulumi, terraform} from '../src/context/capabilities/infrastructure.js'; import {aws, azure, gcp} from '../src/context/capabilities/cloud.js'; -import {clock, operatingSystem, sessionDuration, sessionJobs, sessionUser} from '../src/context/capabilities/system.js'; +import {clock, container, operatingSystem, sessionDuration, sessionJobs, sessionUser} from '../src/context/capabilities/system.js'; import {gitExtras, jujutsu, mercurial} from '../src/context/capabilities/vcs.js'; import {resetServiceCaches, versionFromInstallPath} from '../src/context/services.js'; import {CORE_CAPABILITIES} from '../src/context/registry.js'; @@ -718,3 +718,40 @@ test('vcs.hg and vcs.jj: Mercurial and Jujutsu state from their own directories, assert.equal(await resolve(jujutsu, {cwd: join(home, 'src'), home}), undefined); } finally { await rm(home, {recursive: true, force: true}); } }); + +test('v0.21 modules: Bun from its own files, a Nix shell from IN_NIX_SHELL, a container from markers and host variables; nothing run', async () => { + resetServiceCaches(); + const root = await workspace(); + const tools = await workspace(); + try { + const cellar = join(tools, 'Cellar', 'bun', '1.1.34', 'bin'); + await mkdir(cellar, {recursive: true}); + await sentinelExecutable(join(cellar, 'bun'), join(tools, 'BUN_RAN'), '9.9.9'); + // Corepack's pin is exact; engines is a range; .bun-version wins over both. + await writeFile(join(root, 'package.json'), JSON.stringify({packageManager: 'bun@1.1.30', engines: {bun: '>=1.0'}})); + assert.deepEqual(await resolve(bunRuntime, {cwd: root, root, env: {PATH: cellar}}), {requested: '1.1.30', requestedFrom: 'package.json packageManager', active: '1.1.34', manager: 'Homebrew', mismatch: true}); + await writeFile(join(root, 'package.json'), JSON.stringify({engines: {bun: '>=1.0'}})); + resetServiceCaches(); + assert.equal((await resolve(bunRuntime, {cwd: root, root, env: {PATH: cellar}}))?.requested, '>=1.0'); + await writeFile(join(root, '.bun-version'), '1.1.34\n'); + resetServiceCaches(); + assert.deepEqual(await resolve(bunRuntime, {cwd: root, root, env: {PATH: cellar}}), {requested: '1.1.34', requestedFrom: '.bun-version', active: '1.1.34', manager: 'Homebrew'}); + // Hostile package.json content stays inert data; a non-bun packageManager is no request. + await rm(join(root, '.bun-version')); + await writeFile(join(root, 'package.json'), JSON.stringify({packageManager: 'pnpm@9.0.0; touch PWNED', engines: 'not an object'})); + resetServiceCaches(); + assert.equal(await resolve(bunRuntime, {cwd: root, root, env: {PATH: ''}, fields: ['requested']}), undefined); + assert.equal(await exists(join(tools, 'BUN_RAN')), false, 'bun was never run'); + assert.equal(await exists(join(root, 'PWNED')), false); + + assert.deepEqual(await resolve(nixShell, {cwd: root, env: {IN_NIX_SHELL: 'impure'}}), {state: 'impure'}); + assert.deepEqual(await resolve(nixShell, {cwd: root, env: {IN_NIX_SHELL: '1'}}), {state: 'active'}); + assert.equal(await resolve(nixShell, {cwd: root, env: {}}), undefined); + + assert.deepEqual(await resolve(container, {cwd: root, env: {CODESPACES: 'true'}}), {kind: 'Codespaces'}); + assert.deepEqual(await resolve(container, {cwd: root, env: {REMOTE_CONTAINERS: 'true'}}), {kind: 'Dev Container'}); + const onHost = await resolve(container, {cwd: root, env: {}}); + const markers = (await exists('/.dockerenv')) || (await exists('/run/.containerenv')); + assert.equal(onHost === undefined, !markers, 'only the real marker files decide, never workspace content'); + } finally { await rm(root, {recursive: true, force: true}); await rm(tools, {recursive: true, force: true}); resetServiceCaches(); } +}); diff --git a/tests/moduleManager.test.ts b/tests/moduleManager.test.ts index 3304ddd3..e537af1f 100644 --- a/tests/moduleManager.test.ts +++ b/tests/moduleManager.test.ts @@ -92,7 +92,7 @@ test('Packs tab: recommendations with evidence, bundled and installed packs, exp s.modulesTab = 'packs'; const shown = text(s); assert.match(shown, /Recommended here\n.*○ Terraform +main\.tf in this project/u); - assert.match(shown, /Bundled with NMSh\n.*Project & runtimes +nmsh\.project 1\.0\.0 · bundled · 16 modules/u); + assert.match(shown, /Bundled with NMSh\n.*Project & runtimes +nmsh\.project 1\.0\.0 · bundled · 17 modules/u); assert.match(shown, /Installed\n.*Acme infra +acme\.infra 1\.0\.0 · enabled · 1 modules/u); handlePromptPanelKey(key({kind: 'text', value: ' '}), s); assert.equal(s.draft.modules.find(module => module.id === 'nmsh.infrastructure:terraform')!.visible, true, 'only an explicit Space turns a recommendation on'); diff --git a/tests/moduleReport.test.ts b/tests/moduleReport.test.ts new file mode 100644 index 00000000..c593bbfb --- /dev/null +++ b/tests/moduleReport.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {explainModules, moduleTimings} from '../src/prompt/moduleReport.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; + +test('/modules explain and /modules timings parse as reports; plain /modules opens the manager', () => { + assert.deepEqual(parseSlashCommand('/modules explain'), {kind: 'modules', report: 'explain'}); + assert.deepEqual(parseSlashCommand('/modules timings'), {kind: 'modules', report: 'timings'}); + assert.deepEqual(parseSlashCommand('/modules'), {kind: 'modules'}); + assert.notEqual(parseSlashCommand('/modules nonsense')?.kind, 'modules'); +}); + +test('explain: where each module goes, whether it shows and why, and its sources\' states; reading snapshots only', () => { + const configuration = normalizePromptConfiguration({}); + const asked: string[] = []; + const now = 100_000; + const rows = explainModules(configuration.modules, { + status: id => { asked.push(id); return id === 'runtime.deno' ? {state: 'fresh', collectedAt: now - 3000, durationMs: 4.2, evidence: '.dvmrc\u001b[2J'} : {state: 'idle'}; }, + conditionMet: () => true, + }, configuration, now); + const text = rows.join('\n'); + assert.ok(rows.length > configuration.modules.length / 2, 'one or two rows per module'); + assert.match(text, /\(gitBranch\) · Main Prompt · /u); + assert.match(text, /\(nmsh\.project:deno\) · Hidden · turned off in Modules\n reads runtime\.deno fresh 3s ago, took 4 ms \(\.dvmrc \[2J\)/u, 'evidence is drawn inert'); + assert.match(text, /\(nmsh\.project:bun\) · Hidden · turned off in Modules\n reads runtime\.bun not collected/u); + assert.doesNotMatch(text, /\u001b/u); + assert.match(rows.at(-1)!, /collected nothing/u); + assert.ok(asked.length > 0 && asked.every(id => /^[a-z]/u.test(id)), 'only the status snapshot was consulted'); + const off = explainModules([{...configuration.modules[0]!, visible: false}], {}, configuration, now); + assert.match(off[0]!, /turned off in Modules/u); + assert.match(explainModules([{...configuration.modules[0]!, id: 'not.installed' as never}], {}, configuration)[0]!, /its Context Pack is not installed/u); +}); + +test('timings: the slowest last collections first, bounded, honest when nothing was measured', () => { + assert.deepEqual(moduleTimings(() => undefined), ['No module data has been collected in this window yet.']); + const durations: Record = {'runtime.node': 40, 'vcs.git': 3, 'cloud.aws': 120, 'system.os': 0.4}; + const rows = moduleTimings(id => durations[id] === undefined ? {state: 'idle'} : {state: 'fresh', collectedAt: 0, durationMs: durations[id]!}, 5000, 3); + assert.match(rows[0]!, /^ +120 ms cloud\.aws · fresh 5s ago$/u); + assert.match(rows[1]!, /^ +40 ms runtime\.node/u); + assert.match(rows[2]!, /^ +3 ms vcs\.git/u); + assert.match(rows[3]!, /^… 1 faster ones not listed$/u); + assert.match(rows.at(-1)!, /excluding time queued; nothing was collected/u); +});