From 17b9fe35f0b8a8816b9a96dd995637ebd31aa20e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:44:30 +0530 Subject: [PATCH 01/82] Add curated external welcome presets --- src/output/Welcome.ts | 3 ++- src/output/WelcomeProviders.ts | 20 ++++++++++++------- src/prompt/configuration.ts | 4 ++-- tests/welcomeProviders.test.ts | 36 +++++++++++++++++++++++++++++++--- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/output/Welcome.ts b/src/output/Welcome.ts index 0a21db71..5de02bab 100644 --- a/src/output/Welcome.ts +++ b/src/output/Welcome.ts @@ -1,5 +1,6 @@ import {homedir} from 'node:os'; import type {BuildIdentity} from '../buildInfo.js'; +import type {WelcomeProviderId} from '../prompt/configuration.js'; import {background, foreground, UI_COLORS} from '../ui/palette.js'; import {displayWidth, repeatToWidth, stripAnsi, truncateAnsi, truncateText} from '../util/text.js'; import type {WrappedRow} from './viewport.js'; @@ -19,7 +20,7 @@ export interface WelcomeSnapshot { cwd: string; shell: 'zsh'; /** External welcome captured once at session start; absent means Vespyr. */ - provider?: 'fastfetch' | 'neofetch'; + provider?: Exclude; /** SGR-only rows the external provider printed. */ captured?: string[]; } diff --git a/src/output/WelcomeProviders.ts b/src/output/WelcomeProviders.ts index 572df427..b48aa7fd 100644 --- a/src/output/WelcomeProviders.ts +++ b/src/output/WelcomeProviders.ts @@ -10,6 +10,10 @@ export const WELCOME_PROVIDERS: readonly ProviderDescriptor[] ...(process.platform === 'darwin' ? {install: {label: 'brew install fastfetch', command: 'brew', args: ['install', 'fastfetch']}} : {})}, {id: 'neofetch', family: 'welcome', label: 'Neofetch', kind: 'external', executable: 'neofetch', versionArgs: ['--version'], legacy: true, description: 'archived upstream; used only if already installed'}, + {id: 'macchina', family: 'welcome', label: 'Macchina', kind: 'external', executable: 'macchina', versionArgs: ['--version'], + description: 'system information fetcher in maintenance mode'}, + {id: 'zigfetch', family: 'welcome', label: 'Zigfetch', kind: 'external', executable: 'zigfetch', + description: 'minimal system information fetcher; uses your installed configuration'}, {id: 'none', family: 'welcome', label: 'None', kind: 'none', description: 'no startup welcome'}, ]; @@ -18,7 +22,7 @@ export function welcomeProvider(id: WelcomeProviderId): ProviderDescriptor = {fastfetch: ['--pipe', 'false'], neofetch: []}; +const CAPTURE_ARGS: Partial> = {fastfetch: ['--pipe', 'false']}; export const WELCOME_CAPTURE_TIMEOUT_MS = 2500; const MAX_CAPTURE_BYTES = 128 * 1024; const MAX_ROWS = 40; @@ -30,15 +34,17 @@ export type WelcomeCapture = {ok: true; lines: string[]} | {ok: false; reason: s * Runs the user's installed fetch tool once (argv, no stdin, timeout, * bounded output) and flattens what it printed into SGR-only rows. */ -export async function captureWelcome(id: 'fastfetch' | 'neofetch', cwd: string, env: NodeJS.ProcessEnv = process.env): Promise { - const status = await detectProvider(welcomeProvider(id), env.PATH ?? ''); +export async function captureWelcome(id: Exclude, cwd: string, + env: NodeJS.ProcessEnv = process.env): Promise { + const descriptor = welcomeProvider(id); + const status = await detectProvider(descriptor, env.PATH ?? ''); if (status.state !== 'installed' || !status.binary) return {ok: false, reason: 'not installed'}; - // A plain `zsh -f` parent lets fetch tools report the shell NMSh fronts instead of node; - // the command stays argv (`"$0" "$@"`), and `exit` keeps zsh from exec-replacing itself. + // Run under plain zsh so fetch tools report NMSh's shell rather than node. + // The script is fixed and only forwards positional argv; provider data is never interpolated. const zsh = findExecutable('zsh', env.PATH ?? ''); const [binary, args] = zsh - ? [zsh, ['-f', '-c', '"$0" "$@"; exit $?', status.binary, ...CAPTURE_ARGS[id]]] - : [status.binary, CAPTURE_ARGS[id]]; + ? [zsh, ['-f', '-c', '"$0" "$@"; exit $?', status.binary, ...(CAPTURE_ARGS[id] ?? [])]] + : [status.binary, CAPTURE_ARGS[id] ?? []]; const result = await runExternal(binary, args, {timeoutMs: WELCOME_CAPTURE_TIMEOUT_MS, maxBytes: MAX_CAPTURE_BYTES, cwd, env: {...env, TERM: env.TERM ?? 'xterm-256color'}}); if (!result.ok) return {ok: false, reason: result.error ?? 'failed'}; diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 6eeb9ad6..db7334d2 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -15,8 +15,8 @@ import { type PowerlineShape, } from './powerline.js'; -export type WelcomeProviderId = 'vespyr' | 'fastfetch' | 'neofetch' | 'none'; -export const WELCOME_PROVIDER_IDS: readonly WelcomeProviderId[] = ['vespyr', 'fastfetch', 'neofetch', 'none']; +export type WelcomeProviderId = 'vespyr' | 'fastfetch' | 'neofetch' | 'macchina' | 'zigfetch' | 'none'; +export const WELCOME_PROVIDER_IDS: readonly WelcomeProviderId[] = ['vespyr', 'fastfetch', 'neofetch', 'macchina', 'zigfetch', 'none']; export type ContextPlacement = 'header' | 'composer'; export type ComposerLayout = 'oneLine' | 'twoLine'; diff --git a/tests/welcomeProviders.test.ts b/tests/welcomeProviders.test.ts index 503d4710..8200e983 100644 --- a/tests/welcomeProviders.test.ts +++ b/tests/welcomeProviders.test.ts @@ -29,11 +29,14 @@ async function withFakeTool(name: string, script: string, run: (env: NodeJS.P } } -test('providers: Vespyr is native and default, Neofetch is legacy, None exists', () => { - assert.deepEqual(WELCOME_PROVIDERS.map(provider => provider.id), ['vespyr', 'fastfetch', 'neofetch', 'none']); +test('providers: Vespyr is native and default, external presets are metadata, and None exists', () => { + assert.deepEqual(WELCOME_PROVIDERS.map(provider => provider.id), ['vespyr', 'fastfetch', 'neofetch', 'macchina', 'zigfetch', 'none']); assert.equal(WELCOME_PROVIDERS[0]!.kind, 'native'); assert.equal(WELCOME_PROVIDERS[2]!.legacy, true); assert.equal(WELCOME_PROVIDERS[2]!.install, undefined, 'archived tool is never offered for install'); + assert.equal(WELCOME_PROVIDERS[3]!.executable, 'macchina'); + assert.match(WELCOME_PROVIDERS[3]!.description, /maintenance mode/u); + assert.equal(WELCOME_PROVIDERS[4]!.executable, 'zigfetch'); assert.equal(normalizePromptConfiguration({}).welcome, 'vespyr'); assert.equal(normalizePromptConfiguration({welcome: 'fastfetch'}).welcome, 'fastfetch'); assert.equal(normalizePromptConfiguration({welcome: 'custom-script'}).welcome, 'vespyr'); @@ -49,12 +52,19 @@ test('flattening keeps SGR color, honors logo-side cursor moves, and drops other assert.deepEqual(flattenTerminalOutput('\n\n \nwide 世界\n\n').map(stripAnsi), ['wide 世界']); }); -test('fastfetch is captured once with argv and the user configuration, not via a shell', async () => { +test('external presets use the shared argv adapter and user configuration', async () => { await withFakeTool('fastfetch', 'if [ "$1" = "--version" ]; then echo "fastfetch 2.0"; exit 0; fi\nprintf "\\033[32mlogo\\033[m args:%s\\n" "$*"', async env => { const result = await captureWelcome('fastfetch', tmpdir(), env); assert.equal(result.ok, true); assert.deepEqual(result.ok && result.lines.map(stripAnsi), ['logo args:--pipe false']); }); + for (const name of ['macchina', 'zigfetch'] as const) { + await withFakeTool(name, 'printf "tool:%s args:%s\\n" "$0" "$*"', async env => { + const result = await captureWelcome(name, tmpdir(), env); + assert.equal(result.ok, true); + assert.equal(result.ok && stripAnsi(result.lines[0]!).endsWith(`/${name} args:`), true); + }); + } }); test('a hanging or missing fetch tool fails within the timeout instead of blocking', async () => { @@ -68,6 +78,26 @@ test('a hanging or missing fetch tool fails within the timeout instead of blocki assert.deepEqual(await captureWelcome('fastfetch', tmpdir(), {PATH: '/nonexistent-nmsh'}), {ok: false, reason: 'not installed'}); }); +test('a missing selected provider falls back to the native welcome without blocking startup', async () => { + const app = new TerminalApp(); + const originalPath = process.env.PATH; + let rendered!: () => void; + const didRender = new Promise(resolve => { rendered = resolve; }); + Object.defineProperty(app, 'render', {value: rendered}); + app['promptConfiguration'].welcome = 'macchina'; + try { + process.env.PATH = '/nonexistent-nmsh'; + app['startWelcome'](tmpdir()); + await didRender; + assert.equal(app['output'].transcript().welcome?.provider, undefined, 'Vespyr is represented by the native snapshot'); + assert.equal(app['output'].transcript().welcome?.cwd, tmpdir()); + } finally { + process.env.PATH = originalPath; + app['stop'](0); + app['session'].kill(); + } +}); + test('captured welcome rows are clipped, hidden when narrow, and stay out of copy and commands', () => { const snapshot = {...createWelcomeSnapshot(identity, '/tmp'), provider: 'fastfetch' as const, captured: ['\u001B[32m' + 'x'.repeat(60) + '\u001B[0m', 'OS: test']}; From 3d67999d0bda8349445f37d94433a388654838fb Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:52:45 +0530 Subject: [PATCH 02/82] Add offline Linguist language colors --- docs/architecture/language-identity-colors.md | 15 + licenses/GITHUB-LINGUIST-MIT.txt | 22 + scripts/lib/linguistLanguageColors.mjs | 53 ++ scripts/update-linguist-language-colors.mjs | 22 + .../linguistLanguageColors.generated.ts | 698 ++++++++++++++++++ src/languages/linguistLanguageColors.ts | 24 + tests/linguistLanguageColors.test.ts | 51 ++ 7 files changed, 885 insertions(+) create mode 100644 docs/architecture/language-identity-colors.md create mode 100644 licenses/GITHUB-LINGUIST-MIT.txt create mode 100644 scripts/lib/linguistLanguageColors.mjs create mode 100644 scripts/update-linguist-language-colors.mjs create mode 100644 src/languages/linguistLanguageColors.generated.ts create mode 100644 src/languages/linguistLanguageColors.ts create mode 100644 tests/linguistLanguageColors.test.ts diff --git a/docs/architecture/language-identity-colors.md b/docs/architecture/language-identity-colors.md new file mode 100644 index 00000000..1e1fcc08 --- /dev/null +++ b/docs/architecture/language-identity-colors.md @@ -0,0 +1,15 @@ +# Language identity colors + +NMSh exposes GitHub Linguist's language colors as identity data for consumers such as tool cards, project metadata, and palette results. These colors identify a language only. They do not indicate success, warning, failure, focus, or any other UI state. The module is separate from `src/ui/palette.ts`; future Chroma work can adapt these colors for contrast without changing their identity values. + +The generated mapping is checked in at `src/languages/linguistLanguageColors.generated.ts`. Normal rendering reads only this local TypeScript data and makes no network request. `languageIdentityColor(name)` normalizes Unicode, case, and whitespace; it recognizes canonical names and aliases published by Linguist. Unknown names return the neutral `UNKNOWN_LANGUAGE_IDENTITY_COLOR` value. + +## Provenance and license + +The source is [`github-linguist/linguist`'s `lib/linguist/languages.yml`](https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml). Linguist describes the `color` field as its CSS color for a language, and documents that aliases are used for language lookup. The source repository is distributed under the [MIT License](https://github.com/github-linguist/linguist/blob/main/LICENSE), reproduced at `licenses/GITHUB-LINGUIST-MIT.txt`. The generated file records the exact Linguist commit used. + +## Updating the mapping + +Run `node scripts/update-linguist-language-colors.mjs` intentionally when updating the cached dataset. The script reads the current Linguist revision, fetches its pinned `languages.yml`, extracts only names, aliases, and valid `#RRGGBB` colors, and writes entries in stable name order. Review the generated diff and source revision with the normal code review. Build, tests, and runtime do not invoke this script or require network access. + +To add or change consumers, import `languageIdentityColor` from `src/languages/linguistLanguageColors.ts`. Keep any contrast-adjusted display color local to that rendering consumer; do not overwrite the identity color or use it as a semantic status color. diff --git a/licenses/GITHUB-LINGUIST-MIT.txt b/licenses/GITHUB-LINGUIST-MIT.txt new file mode 100644 index 00000000..acc8e6f2 --- /dev/null +++ b/licenses/GITHUB-LINGUIST-MIT.txt @@ -0,0 +1,22 @@ +Copyright (c) 2017 GitHub, Inc. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/scripts/lib/linguistLanguageColors.mjs b/scripts/lib/linguistLanguageColors.mjs new file mode 100644 index 00000000..275adba6 --- /dev/null +++ b/scripts/lib/linguistLanguageColors.mjs @@ -0,0 +1,53 @@ +function yamlString(value) { + const scalar = value.trim(); + if (scalar.startsWith('"')) return JSON.parse(scalar); + if (scalar.startsWith("'")) return scalar.slice(1, scalar.lastIndexOf("'")).replaceAll("''", "'"); + return scalar.replace(/\s+#.*$/u, '').trim(); +} + +/** Extracts only Linguist's canonical names, official aliases, and CSS colors. */ +export function parseLanguageColors(source) { + const languages = []; + let current; + + const finish = () => { + if (current?.color) languages.push(current); + }; + + for (const line of source.split(/\r?\n/u)) { + const header = /^([^\s#].*):\s*$/u.exec(line); + if (header && !header[1].startsWith('---')) { + finish(); + current = {name: yamlString(header[1]), aliases: [], color: undefined}; + continue; + } + if (!current) continue; + + const field = /^ ([\w-]+):(?:\s*(.*))?$/u.exec(line); + if (field) { + current.field = field[1]; + if (field[1] === 'color') { + const color = yamlString(field[2] ?? ''); + if (/^#[\da-fA-F]{6}$/u.test(color)) current.color = color.toUpperCase(); + } + continue; + } + + if (current.field === 'aliases') { + const alias = /^ -\s+(.+?)\s*$/u.exec(line); + if (alias) { + const value = yamlString(alias[1]); + if (value) current.aliases.push(value); + } + } + } + finish(); + return languages.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); +} + +export function renderLanguageColorsModule(languages, revision) { + const rows = languages.map(language => ` ${JSON.stringify(language.name)}: {color: ${JSON.stringify(language.color)}, aliases: ${JSON.stringify(language.aliases)}}`).join(',\n'); + return `/** Generated from github-linguist/linguist lib/linguist/languages.yml. Do not edit by hand. */\n` + + `export const LINGUIST_LANGUAGE_COLORS_REVISION = ${JSON.stringify(revision)};\n` + + `export const LINGUIST_LANGUAGE_COLORS = {\n${rows}\n} as const;\n`; +} diff --git a/scripts/update-linguist-language-colors.mjs b/scripts/update-linguist-language-colors.mjs new file mode 100644 index 00000000..755126a2 --- /dev/null +++ b/scripts/update-linguist-language-colors.mjs @@ -0,0 +1,22 @@ +import {writeFile} from 'node:fs/promises'; +import {dirname, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {parseLanguageColors, renderLanguageColorsModule} from './lib/linguistLanguageColors.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const api = await fetch('https://api.github.com/repos/github-linguist/linguist/commits/main', { + headers: {'User-Agent': 'notMyShell-language-color-update'}, +}); +if (!api.ok) throw new Error(`Unable to read Linguist revision: HTTP ${api.status}`); +const {sha} = await api.json(); +if (typeof sha !== 'string' || !/^[\da-f]{40}$/u.test(sha)) throw new Error('GitHub returned an invalid Linguist revision'); + +const sourceUrl = `https://raw.githubusercontent.com/github-linguist/linguist/${sha}/lib/linguist/languages.yml`; +const source = await fetch(sourceUrl, {headers: {'User-Agent': 'notMyShell-language-color-update'}}); +if (!source.ok) throw new Error(`Unable to read Linguist language data: HTTP ${source.status}`); +const languages = parseLanguageColors(await source.text()); +if (languages.length < 100) throw new Error(`Refusing to write incomplete Linguist data (${languages.length} entries)`); + +const output = resolve(root, 'src/languages/linguistLanguageColors.generated.ts'); +await writeFile(output, renderLanguageColorsModule(languages, sha)); +process.stdout.write(`Wrote ${languages.length} language colors from Linguist ${sha}\n`); diff --git a/src/languages/linguistLanguageColors.generated.ts b/src/languages/linguistLanguageColors.generated.ts new file mode 100644 index 00000000..399e0ad4 --- /dev/null +++ b/src/languages/linguistLanguageColors.generated.ts @@ -0,0 +1,698 @@ +/** Generated from github-linguist/linguist lib/linguist/languages.yml. Do not edit by hand. */ +export const LINGUIST_LANGUAGE_COLORS_REVISION = "d0921d10bb68a1249fc9eac64e472759f36e2fd8"; +export const LINGUIST_LANGUAGE_COLORS = { + "1C Enterprise": {color: "#814CCC", aliases: []}, + "2-Dimensional Array": {color: "#38761D", aliases: []}, + "4D": {color: "#004289", aliases: []}, + "ABAP": {color: "#E8274B", aliases: []}, + "ABAP CDS": {color: "#555E25", aliases: []}, + "AGS Script": {color: "#B9D9FF", aliases: ["ags"]}, + "AIDL": {color: "#34EB6B", aliases: []}, + "AL": {color: "#3AA2B5", aliases: []}, + "ALGOL": {color: "#D1E0DB", aliases: []}, + "AMPL": {color: "#E6EFBB", aliases: []}, + "ANTLR": {color: "#9DC3FF", aliases: []}, + "API Blueprint": {color: "#2ACCA8", aliases: []}, + "APL": {color: "#5A8164", aliases: []}, + "ASP.NET": {color: "#9400FF", aliases: ["aspx","aspx-vb"]}, + "ATS": {color: "#1AC620", aliases: ["ats2"]}, + "ActionScript": {color: "#882B0F", aliases: ["actionscript 3","actionscript3","as3"]}, + "Ada": {color: "#02F88C", aliases: ["ada95","ada2005"]}, + "Adblock Filter List": {color: "#800000", aliases: ["ad block filters","ad block","adb","adblock"]}, + "Adobe Font Metrics": {color: "#FA0F00", aliases: ["acfm","adobe composite font metrics","adobe multiple font metrics","amfm"]}, + "Agda": {color: "#315665", aliases: []}, + "Aiken": {color: "#640FF8", aliases: []}, + "Aleo": {color: "#154BF9", aliases: []}, + "Alloy": {color: "#64C800", aliases: []}, + "Alpine Abuild": {color: "#0D597F", aliases: ["abuild","apkbuild"]}, + "Altium Designer": {color: "#A89663", aliases: ["altium"]}, + "AngelScript": {color: "#C7D7DC", aliases: []}, + "Answer Set Programming": {color: "#A9CC29", aliases: []}, + "Ant Build System": {color: "#A9157E", aliases: []}, + "Antlers": {color: "#FF269E", aliases: []}, + "ApacheConf": {color: "#D12127", aliases: ["aconf","apache"]}, + "Apex": {color: "#1797C0", aliases: []}, + "Apollo Guidance Computer": {color: "#0B3D91", aliases: []}, + "AppleScript": {color: "#101F1F", aliases: ["apples","osascript"]}, + "Arc": {color: "#AA2AFE", aliases: []}, + "ArkTS": {color: "#0080FF", aliases: []}, + "AsciiDoc": {color: "#73A0C5", aliases: []}, + "AspectJ": {color: "#A957B0", aliases: []}, + "Assembly": {color: "#6E4C13", aliases: ["asm","nasm"]}, + "Astro": {color: "#FF5A03", aliases: []}, + "Asymptote": {color: "#FF0000", aliases: []}, + "Augeas": {color: "#9CC134", aliases: []}, + "AutoHotkey": {color: "#6594B9", aliases: ["ahk"]}, + "AutoIt": {color: "#1C3552", aliases: ["au3","AutoIt3","AutoItScript"]}, + "Avro IDL": {color: "#0040FF", aliases: []}, + "Awk": {color: "#C30E9B", aliases: []}, + "B": {color: "#DA7666", aliases: []}, + "B (Formal Method)": {color: "#8AA8C5", aliases: []}, + "B4X": {color: "#00E4FF", aliases: ["basic for android"]}, + "BAML": {color: "#A855F7", aliases: []}, + "BASIC": {color: "#FF0000", aliases: []}, + "BBCode": {color: "#CAFF42", aliases: []}, + "BIRD2": {color: "#B6D7E4", aliases: ["bird","bird3"]}, + "BQN": {color: "#2B7067", aliases: []}, + "Ballerina": {color: "#FF5000", aliases: []}, + "Batchfile": {color: "#C1F12E", aliases: ["bat","batch","dosbatch","winbatch"]}, + "Beef": {color: "#A52F4E", aliases: []}, + "Berry": {color: "#15A13C", aliases: ["be"]}, + "BibTeX": {color: "#778899", aliases: []}, + "Bicep": {color: "#519ABA", aliases: []}, + "Bikeshed": {color: "#5562AC", aliases: []}, + "Bison": {color: "#6A463F", aliases: []}, + "BitBake": {color: "#00BCE4", aliases: []}, + "Blade": {color: "#F7523F", aliases: []}, + "BlitzBasic": {color: "#00FFAE", aliases: ["b3d","blitz3d","blitzplus","bplus"]}, + "BlitzMax": {color: "#CD6400", aliases: ["bmax"]}, + "Blueprint": {color: "#3584E4", aliases: ["blp"]}, + "Bluespec": {color: "#12223C", aliases: ["bluespec bsv","bsv"]}, + "Bluespec BH": {color: "#12223C", aliases: ["bh","bluespec classic"]}, + "Boo": {color: "#D4BEC1", aliases: []}, + "Boogie": {color: "#C80FA0", aliases: []}, + "Brainfuck": {color: "#2F2530", aliases: []}, + "BrighterScript": {color: "#66AABB", aliases: []}, + "Brightscript": {color: "#662D91", aliases: []}, + "Browserslist": {color: "#FFD539", aliases: []}, + "Bru": {color: "#F4AA41", aliases: []}, + "BuildStream": {color: "#006BFF", aliases: []}, + "C": {color: "#555555", aliases: []}, + "C#": {color: "#7355DD", aliases: ["csharp","cake","cakescript"]}, + "C++": {color: "#F34B7D", aliases: ["cpp"]}, + "C3": {color: "#2563EB", aliases: []}, + "CAP CDS": {color: "#0092D1", aliases: ["cds"]}, + "CLIPS": {color: "#00A300", aliases: []}, + "CMake": {color: "#DA3434", aliases: []}, + "COLLADA": {color: "#F1A42B", aliases: []}, + "CQL": {color: "#006091", aliases: []}, + "CSON": {color: "#244776", aliases: []}, + "CSS": {color: "#663399", aliases: []}, + "CSV": {color: "#237346", aliases: []}, + "CUE": {color: "#5886E1", aliases: []}, + "CWeb": {color: "#00007A", aliases: []}, + "Cabal Config": {color: "#483465", aliases: ["Cabal"]}, + "Caddyfile": {color: "#22B638", aliases: ["Caddy"]}, + "Cadence": {color: "#00EF8B", aliases: []}, + "Cairo": {color: "#FF4A48", aliases: []}, + "Cairo Zero": {color: "#FF4A48", aliases: []}, + "CameLIGO": {color: "#3BE133", aliases: []}, + "Cangjie": {color: "#00868B", aliases: []}, + "Cap'n Proto": {color: "#C42727", aliases: []}, + "Carbon": {color: "#222222", aliases: []}, + "Ceylon": {color: "#DFA535", aliases: []}, + "Chapel": {color: "#8DC63F", aliases: ["chpl"]}, + "ChucK": {color: "#3F8000", aliases: []}, + "Circom": {color: "#707575", aliases: []}, + "Cirru": {color: "#CCCCFF", aliases: []}, + "Clarion": {color: "#DB901E", aliases: []}, + "Clarity": {color: "#5546FF", aliases: []}, + "Classic ASP": {color: "#6A40FD", aliases: ["asp"]}, + "Clean": {color: "#3F85AF", aliases: []}, + "Click": {color: "#E4E6F3", aliases: []}, + "Clojure": {color: "#DB5855", aliases: []}, + "Closure Templates": {color: "#0D948F", aliases: ["soy"]}, + "Cloud Firestore Security Rules": {color: "#FFA000", aliases: []}, + "Clue": {color: "#0009B5", aliases: []}, + "CodeQL": {color: "#140F46", aliases: ["ql"]}, + "CoffeeScript": {color: "#244776", aliases: ["coffee","coffee-script"]}, + "ColdFusion": {color: "#ED2CD6", aliases: ["cfm","cfml","coldfusion html"]}, + "ColdFusion CFC": {color: "#ED2CD6", aliases: ["cfc"]}, + "Common Lisp": {color: "#3FB68B", aliases: ["lisp"]}, + "Common Workflow Language": {color: "#B5314C", aliases: ["cwl"]}, + "Component Pascal": {color: "#B0CE4E", aliases: []}, + "Cooklang": {color: "#E15A29", aliases: []}, + "Crystal": {color: "#000100", aliases: []}, + "Csound": {color: "#1A1A1A", aliases: ["csound-orc"]}, + "Csound Document": {color: "#1A1A1A", aliases: ["csound-csd"]}, + "Csound Score": {color: "#1A1A1A", aliases: ["csound-sco"]}, + "Cuda": {color: "#3A4E3A", aliases: []}, + "Curry": {color: "#531242", aliases: []}, + "Cylc": {color: "#00B3FD", aliases: []}, + "Cypher": {color: "#34C0EB", aliases: []}, + "Cython": {color: "#FEDF5B", aliases: ["pyrex"]}, + "D": {color: "#BA595E", aliases: ["Dlang"]}, + "D2": {color: "#526EE8", aliases: ["d2lang"]}, + "DM": {color: "#447265", aliases: ["byond"]}, + "Dafny": {color: "#FFEC25", aliases: []}, + "Darcs Patch": {color: "#8EFF23", aliases: ["dpatch"]}, + "Dart": {color: "#00B4AB", aliases: []}, + "Daslang": {color: "#D3D3D3", aliases: []}, + "DataWeave": {color: "#003A52", aliases: []}, + "Debian Package Control File": {color: "#D70751", aliases: []}, + "DenizenScript": {color: "#FBEE96", aliases: []}, + "Dhall": {color: "#DFAFFF", aliases: []}, + "DirectX 3D File": {color: "#AACE60", aliases: []}, + "Dockerfile": {color: "#384D54", aliases: ["Containerfile"]}, + "Dogescript": {color: "#CCA760", aliases: []}, + "Dotenv": {color: "#E5D559", aliases: []}, + "Dune": {color: "#89421E", aliases: []}, + "Dylan": {color: "#6C616E", aliases: []}, + "E": {color: "#CCCE35", aliases: []}, + "ECL": {color: "#8A1267", aliases: []}, + "ECLiPSe": {color: "#001D9D", aliases: []}, + "EJS": {color: "#A91E50", aliases: []}, + "EQ": {color: "#A78649", aliases: []}, + "Earthly": {color: "#2AF0FF", aliases: ["Earthfile"]}, + "Easybuild": {color: "#069406", aliases: []}, + "Ecere Projects": {color: "#913960", aliases: []}, + "Ecmarkup": {color: "#EB8131", aliases: ["ecmarkdown"]}, + "Edge": {color: "#0DFFE0", aliases: []}, + "EdgeQL": {color: "#31A7FF", aliases: ["esdl"]}, + "EditorConfig": {color: "#FFF1F2", aliases: ["editor-config"]}, + "Eiffel": {color: "#4D6977", aliases: []}, + "Elixir": {color: "#8847B9", aliases: []}, + "Elm": {color: "#60B5CC", aliases: []}, + "Elvish": {color: "#55BB55", aliases: []}, + "Elvish Transcript": {color: "#55BB55", aliases: []}, + "Emacs Lisp": {color: "#C065DB", aliases: ["cask","eask","elisp","emacs"]}, + "EmberScript": {color: "#FFF4F3", aliases: []}, + "Erlang": {color: "#B83998", aliases: []}, + "Euphoria": {color: "#FF790B", aliases: []}, + "F#": {color: "#B845FC", aliases: ["fsharp"]}, + "F*": {color: "#572E30", aliases: ["fstar"]}, + "FIGlet Font": {color: "#FFDDBB", aliases: ["FIGfont"]}, + "FIRRTL": {color: "#2F632F", aliases: []}, + "FLUX": {color: "#88CCFF", aliases: []}, + "FPP": {color: "#D37327", aliases: []}, + "Factor": {color: "#636746", aliases: []}, + "Fancy": {color: "#7B9DB4", aliases: []}, + "Fantom": {color: "#14253C", aliases: []}, + "Faust": {color: "#C37240", aliases: []}, + "Fennel": {color: "#FFF3D7", aliases: []}, + "Filebench WML": {color: "#F6B900", aliases: []}, + "FlatBuffers": {color: "#ED284A", aliases: []}, + "Flix": {color: "#D44A45", aliases: []}, + "Fluent": {color: "#FFCC33", aliases: []}, + "Forth": {color: "#341708", aliases: []}, + "Fortran": {color: "#4D41B1", aliases: []}, + "Fortran Free Form": {color: "#4D41B1", aliases: []}, + "FreeBASIC": {color: "#141AC9", aliases: ["fb"]}, + "FreeMarker": {color: "#0050B2", aliases: ["ftl"]}, + "Frege": {color: "#00CAFE", aliases: []}, + "Futhark": {color: "#5F021F", aliases: []}, + "G-code": {color: "#D08CF2", aliases: []}, + "GAML": {color: "#FFC766", aliases: []}, + "GAMS": {color: "#F49A22", aliases: []}, + "GAP": {color: "#0000CC", aliases: []}, + "GCC Machine Description": {color: "#FFCFAB", aliases: []}, + "GDScript": {color: "#355570", aliases: []}, + "GDShader": {color: "#478CBF", aliases: []}, + "GEDCOM": {color: "#003058", aliases: []}, + "GLSL": {color: "#5686A5", aliases: []}, + "GSC": {color: "#FF6800", aliases: []}, + "Game Maker Language": {color: "#71B417", aliases: []}, + "Gemfile.lock": {color: "#701516", aliases: []}, + "Gemini": {color: "#FF6900", aliases: ["gemtext"]}, + "Genero 4gl": {color: "#63408E", aliases: []}, + "Genero per": {color: "#D8DF39", aliases: []}, + "Genie": {color: "#FB855D", aliases: []}, + "Genshi": {color: "#951531", aliases: ["xml+genshi","xml+kid"]}, + "Gentoo Ebuild": {color: "#9400FF", aliases: []}, + "Gentoo Eclass": {color: "#9400FF", aliases: []}, + "Gerber Image": {color: "#D20B00", aliases: ["rs-274x"]}, + "Gherkin": {color: "#5B2063", aliases: ["cucumber"]}, + "Git Attributes": {color: "#F44D27", aliases: ["gitattributes"]}, + "Git Commit": {color: "#F44D27", aliases: ["commit"]}, + "Git Config": {color: "#F44D27", aliases: ["gitconfig","gitmodules"]}, + "Git Revision List": {color: "#F44D27", aliases: ["Git Blame Ignore Revs"]}, + "Gleam": {color: "#FFAFF3", aliases: []}, + "Glimmer JS": {color: "#F5835F", aliases: ["gjs"]}, + "Glimmer TS": {color: "#3178C6", aliases: ["gts"]}, + "Glyph": {color: "#C1AC7F", aliases: []}, + "Gno": {color: "#226C57", aliases: ["gnolang"]}, + "Gnuplot": {color: "#F0A9F0", aliases: []}, + "Go": {color: "#00ADD8", aliases: ["golang"]}, + "Go Checksums": {color: "#00ADD8", aliases: ["go.sum","go sum","go.work.sum","go work sum"]}, + "Go Module": {color: "#00ADD8", aliases: ["go.mod","go mod"]}, + "Go Template": {color: "#00ADD8", aliases: ["gotmpl"]}, + "Go Workspace": {color: "#00ADD8", aliases: ["go.work","go work"]}, + "Godot Resource": {color: "#355570", aliases: []}, + "Golo": {color: "#88562A", aliases: []}, + "Gosu": {color: "#82937F", aliases: []}, + "Grace": {color: "#615F8B", aliases: []}, + "Gradle": {color: "#02303A", aliases: []}, + "Gradle Kotlin DSL": {color: "#02303A", aliases: []}, + "Grammatical Framework": {color: "#FF0000", aliases: ["gf"]}, + "GraphQL": {color: "#E10098", aliases: []}, + "Graphviz (DOT)": {color: "#2596BE", aliases: []}, + "Groovy": {color: "#4298B8", aliases: []}, + "Groovy Server Pages": {color: "#4298B8", aliases: ["gsp","java server page"]}, + "GtkRC": {color: "#7FE719", aliases: ["gtk","gtk 1","gtk 2"]}, + "HAProxy": {color: "#106DA9", aliases: []}, + "HCL": {color: "#844FBA", aliases: ["HashiCorp Configuration Language","opentofu","terraform"]}, + "HIP": {color: "#4F3A4F", aliases: []}, + "HLSL": {color: "#AACE60", aliases: []}, + "HOCON": {color: "#9FF8EE", aliases: []}, + "HTML": {color: "#E34C26", aliases: ["xhtml"]}, + "HTML+ECR": {color: "#2E1052", aliases: ["ecr"]}, + "HTML+EEX": {color: "#6E4A7E", aliases: ["eex","heex","leex"]}, + "HTML+ERB": {color: "#701516", aliases: ["erb","rhtml","html+ruby"]}, + "HTML+PHP": {color: "#4F5D95", aliases: []}, + "HTML+Razor": {color: "#512BE4", aliases: ["razor"]}, + "HTTP": {color: "#005C9C", aliases: []}, + "HXML": {color: "#F68712", aliases: []}, + "Hack": {color: "#878787", aliases: []}, + "Haml": {color: "#ECE2A9", aliases: []}, + "Handlebars": {color: "#F7931E", aliases: ["hbs","htmlbars"]}, + "Harbour": {color: "#0E60E3", aliases: []}, + "Hare": {color: "#9D7424", aliases: []}, + "Haskell": {color: "#5E5086", aliases: []}, + "Haxe": {color: "#DF7900", aliases: []}, + "HiveQL": {color: "#DCE200", aliases: []}, + "HolyC": {color: "#FFEFAF", aliases: []}, + "Hosts File": {color: "#308888", aliases: ["hosts"]}, + "Hurl": {color: "#FF0288", aliases: []}, + "Hy": {color: "#7790B2", aliases: ["hylang"]}, + "IDL": {color: "#A3522F", aliases: []}, + "IGOR Pro": {color: "#0000CC", aliases: ["igor","igorpro"]}, + "IL Assembly": {color: "#512BD4", aliases: ["ilasm","msil"]}, + "INI": {color: "#D1DBE0", aliases: ["conf","dosini"]}, + "ISPC": {color: "#2D68B1", aliases: []}, + "Idris": {color: "#B30000", aliases: []}, + "Ignore List": {color: "#000000", aliases: ["ignore","gitignore","git-ignore"]}, + "ImHex Pattern Language": {color: "#3A6BE0", aliases: ["ImHex","ImHexPatternLanguage","imhexpl"]}, + "ImageJ Macro": {color: "#99AAFF", aliases: ["ijm"]}, + "Imba": {color: "#16CEC6", aliases: []}, + "Inno Setup": {color: "#264B99", aliases: []}, + "Io": {color: "#A9188D", aliases: []}, + "Ioke": {color: "#078193", aliases: []}, + "Isabelle": {color: "#FEFE00", aliases: []}, + "Isabelle ROOT": {color: "#FEFE00", aliases: []}, + "J": {color: "#9EEDFF", aliases: []}, + "JAR Manifest": {color: "#B07219", aliases: []}, + "JASS": {color: "#FF0303", aliases: ["jass2"]}, + "JCL": {color: "#D90E09", aliases: []}, + "JFlex": {color: "#DBCA00", aliases: []}, + "JSON": {color: "#292929", aliases: ["geojson","jsonl","sarif","topojson"]}, + "JSON with Comments": {color: "#292929", aliases: ["jsonc"]}, + "JSON5": {color: "#267CB9", aliases: []}, + "JSONLD": {color: "#0C479C", aliases: []}, + "JSONiq": {color: "#40D47E", aliases: []}, + "Jac": {color: "#FC792D", aliases: []}, + "Jai": {color: "#AB8B4B", aliases: []}, + "Janet": {color: "#0886A5", aliases: []}, + "Jasmin": {color: "#D03600", aliases: []}, + "Java": {color: "#B07219", aliases: []}, + "Java Properties": {color: "#2A6277", aliases: []}, + "Java Server Pages": {color: "#2A6277", aliases: ["jsp"]}, + "Java Template Engine": {color: "#2A6277", aliases: ["jte"]}, + "JavaScript": {color: "#F1E05A", aliases: ["js","node"]}, + "JavaScript+ERB": {color: "#F1E05A", aliases: []}, + "Jest Snapshot": {color: "#15C213", aliases: []}, + "JetBrains MPS": {color: "#21D789", aliases: ["mps"]}, + "Jinja": {color: "#A52A22", aliases: ["django","html+django","html+jinja","htmldjango"]}, + "Jison": {color: "#56B3CB", aliases: []}, + "Jison Lex": {color: "#56B3CB", aliases: []}, + "Jolie": {color: "#843179", aliases: []}, + "Jsonnet": {color: "#0064BD", aliases: []}, + "Julia": {color: "#A270BA", aliases: []}, + "Julia REPL": {color: "#A270BA", aliases: []}, + "Jupyter Notebook": {color: "#DA5B0B", aliases: ["IPython Notebook"]}, + "Just": {color: "#384D54", aliases: ["Justfile"]}, + "KCL": {color: "#7ABABF", aliases: []}, + "KDL": {color: "#FFB3B3", aliases: []}, + "KFramework": {color: "#4195C5", aliases: []}, + "KRL": {color: "#28430A", aliases: []}, + "Kaitai Struct": {color: "#773B37", aliases: ["ksy"]}, + "KakouneScript": {color: "#6F8042", aliases: ["kak","kakscript"]}, + "KerboScript": {color: "#41ADF0", aliases: []}, + "KiCad Layout": {color: "#2F4AAB", aliases: ["pcbnew"]}, + "KiCad Legacy Layout": {color: "#2F4AAB", aliases: []}, + "KiCad Schematic": {color: "#2F4AAB", aliases: ["eeschema schematic"]}, + "KoLmafia ASH": {color: "#B9D9B9", aliases: []}, + "Koka": {color: "#215166", aliases: []}, + "Kotlin": {color: "#A97BFF", aliases: []}, + "LFE": {color: "#4C3023", aliases: []}, + "LLVM": {color: "#185619", aliases: []}, + "LLVM TableGen": {color: "#6E8B3D", aliases: ["tablegen"]}, + "LOLCODE": {color: "#CC9900", aliases: []}, + "LSL": {color: "#3D9970", aliases: []}, + "LabVIEW": {color: "#FEDE06", aliases: []}, + "Lambdapi": {color: "#8027A3", aliases: []}, + "Langium": {color: "#2C8C87", aliases: []}, + "Lark": {color: "#2980B9", aliases: []}, + "Lasso": {color: "#999999", aliases: ["lassoscript"]}, + "Latte": {color: "#F2A542", aliases: []}, + "Leo": {color: "#C4FFC2", aliases: []}, + "Less": {color: "#1D365D", aliases: ["less-css"]}, + "Lex": {color: "#DBCA00", aliases: ["flex"]}, + "LigoLANG": {color: "#0E74FF", aliases: []}, + "LilyPond": {color: "#9CCC7C", aliases: []}, + "Liquid": {color: "#67B8DE", aliases: []}, + "Liquidsoap": {color: "#990066", aliases: []}, + "Literate Agda": {color: "#315665", aliases: []}, + "Literate CoffeeScript": {color: "#244776", aliases: ["litcoffee"]}, + "Literate Haskell": {color: "#5E5086", aliases: ["lhaskell","lhs"]}, + "LiveCode Script": {color: "#0C5BA5", aliases: []}, + "LiveScript": {color: "#499886", aliases: ["live-script","ls"]}, + "Lobster": {color: "#F95428", aliases: []}, + "Logtalk": {color: "#295B9A", aliases: []}, + "LookML": {color: "#652B81", aliases: []}, + "Lua": {color: "#000080", aliases: []}, + "Luau": {color: "#00A2FF", aliases: []}, + "M3U": {color: "#179C7D", aliases: ["hls playlist","m3u playlist"]}, + "MATLAB": {color: "#E16737", aliases: ["octave"]}, + "MAXScript": {color: "#00A6A6", aliases: []}, + "MDX": {color: "#FCB32C", aliases: []}, + "MLIR": {color: "#5EC8DB", aliases: []}, + "MQL4": {color: "#62A8D6", aliases: []}, + "MQL5": {color: "#4A76B8", aliases: []}, + "MTML": {color: "#B7E1F4", aliases: []}, + "Macaulay2": {color: "#D8FFFF", aliases: ["m2"]}, + "Makefile": {color: "#427819", aliases: ["bsdmake","make","mf"]}, + "Mako": {color: "#7E858D", aliases: []}, + "Markdown": {color: "#083FA1", aliases: ["md","pandoc"]}, + "Marko": {color: "#42BFF2", aliases: ["markojs"]}, + "Mask": {color: "#F97732", aliases: []}, + "Mathematical Programming System": {color: "#0530AD", aliases: []}, + "Max": {color: "#C4A79C", aliases: ["max/msp","maxmsp"]}, + "MeTTa": {color: "#6A5ACD", aliases: []}, + "Mercury": {color: "#FF2B2B", aliases: []}, + "Mermaid": {color: "#FF3670", aliases: ["mermaid example"]}, + "Meson": {color: "#007800", aliases: []}, + "Metal": {color: "#8F14E9", aliases: []}, + "MiniScript": {color: "#4B4A56", aliases: []}, + "MiniYAML": {color: "#FF1111", aliases: []}, + "MiniZinc": {color: "#06A9E6", aliases: []}, + "Mint": {color: "#02B046", aliases: []}, + "Mirah": {color: "#C7A938", aliases: []}, + "Modelica": {color: "#DE1D31", aliases: []}, + "Modula-2": {color: "#10253F", aliases: []}, + "Modula-3": {color: "#223388", aliases: []}, + "Mojo": {color: "#FF4C1F", aliases: []}, + "Monkey C": {color: "#8D6747", aliases: []}, + "MoonBit": {color: "#B92381", aliases: []}, + "MoonScript": {color: "#FF4585", aliases: []}, + "Motoko": {color: "#FBB03B", aliases: []}, + "Motorola 68K Assembly": {color: "#005DAA", aliases: ["m68k"]}, + "Move": {color: "#4A137A", aliases: []}, + "Mustache": {color: "#724B3B", aliases: []}, + "NCL": {color: "#28431F", aliases: []}, + "NMODL": {color: "#00356B", aliases: []}, + "NPM Config": {color: "#CB3837", aliases: ["npmrc"]}, + "NWScript": {color: "#111522", aliases: []}, + "Nasal": {color: "#1D2C4E", aliases: []}, + "Nearley": {color: "#990000", aliases: []}, + "Nemerle": {color: "#3D3C6E", aliases: []}, + "NetLinx": {color: "#0AA0FF", aliases: []}, + "NetLinx+ERB": {color: "#747FAA", aliases: []}, + "NetLogo": {color: "#FF6375", aliases: []}, + "NewLisp": {color: "#87AED7", aliases: []}, + "Nextflow": {color: "#3AC486", aliases: []}, + "Nginx": {color: "#009639", aliases: ["nginx configuration file"]}, + "Nickel": {color: "#E0C3FC", aliases: []}, + "Nim": {color: "#FFC200", aliases: []}, + "Nit": {color: "#009917", aliases: []}, + "Nix": {color: "#7E7EFF", aliases: ["nixos"]}, + "Noir": {color: "#2F1F49", aliases: ["nargo"]}, + "Nu": {color: "#C9DF40", aliases: ["nush"]}, + "NumPy": {color: "#9C8AF9", aliases: []}, + "Nunjucks": {color: "#3D8137", aliases: ["njk"]}, + "Nushell": {color: "#4E9906", aliases: ["nu-script","nushell-script"]}, + "OASv2-json": {color: "#85EA2D", aliases: []}, + "OASv2-yaml": {color: "#85EA2D", aliases: []}, + "OASv3-json": {color: "#85EA2D", aliases: []}, + "OASv3-yaml": {color: "#85EA2D", aliases: []}, + "OCaml": {color: "#EF7A08", aliases: []}, + "OMNeT++ MSG": {color: "#A0E0A0", aliases: ["omnetpp-msg"]}, + "OMNeT++ NED": {color: "#08607C", aliases: ["omnetpp-ned"]}, + "ObjectScript": {color: "#424893", aliases: []}, + "Objective-C": {color: "#438EFF", aliases: ["obj-c","objc","objectivec"]}, + "Objective-C++": {color: "#6866FB", aliases: ["obj-c++","objc++","objectivec++"]}, + "Objective-J": {color: "#FF0C5A", aliases: ["obj-j","objectivej","objj"]}, + "Odin": {color: "#60AFFE", aliases: ["odinlang","odin-lang"]}, + "Omgrofl": {color: "#CABBFF", aliases: []}, + "Opal": {color: "#F7EDE0", aliases: []}, + "Open Policy Agent": {color: "#7D9199", aliases: []}, + "OpenAPI Specification v2": {color: "#85EA2D", aliases: ["oasv2"]}, + "OpenAPI Specification v3": {color: "#85EA2D", aliases: ["oasv3"]}, + "OpenCL": {color: "#ED2E2D", aliases: []}, + "OpenEdge ABL": {color: "#5CE600", aliases: ["progress","openedge","abl"]}, + "OpenQASM": {color: "#AA70FF", aliases: []}, + "OpenSCAD": {color: "#E5CD45", aliases: []}, + "Option List": {color: "#476732", aliases: ["opts","ackrc"]}, + "Org": {color: "#77AA99", aliases: []}, + "OverPy": {color: "#78B355", aliases: ["opy"]}, + "OverpassQL": {color: "#CCE2AA", aliases: []}, + "Oxygene": {color: "#CDD0E3", aliases: []}, + "Oz": {color: "#FAB738", aliases: []}, + "P4": {color: "#7055B5", aliases: []}, + "PDDL": {color: "#0D00FF", aliases: []}, + "PEG.js": {color: "#234D6B", aliases: []}, + "PHP": {color: "#4F5D95", aliases: ["inc"]}, + "PLSQL": {color: "#DAD8D8", aliases: []}, + "PLpgSQL": {color: "#336790", aliases: []}, + "POV-Ray SDL": {color: "#6BAC65", aliases: ["pov-ray","povray"]}, + "Pact": {color: "#F7A8B8", aliases: []}, + "Pan": {color: "#CC0000", aliases: []}, + "Papyrus": {color: "#6600CC", aliases: []}, + "Parrot": {color: "#F3CA0A", aliases: []}, + "Pascal": {color: "#E3F171", aliases: ["delphi","objectpascal"]}, + "Pawn": {color: "#DBB284", aliases: []}, + "Pep8": {color: "#C76F5B", aliases: []}, + "Perl": {color: "#0298C3", aliases: ["cperl"]}, + "PicoLisp": {color: "#6067AF", aliases: []}, + "PigLatin": {color: "#FCD7DE", aliases: []}, + "Pike": {color: "#005390", aliases: []}, + "Pip Requirements": {color: "#FFD343", aliases: []}, + "Pkl": {color: "#6B9543", aliases: []}, + "PlantUML": {color: "#FBBD16", aliases: []}, + "PogoScript": {color: "#D80074", aliases: []}, + "Polar": {color: "#AE81FF", aliases: []}, + "Portugol": {color: "#F8BD00", aliases: []}, + "PostCSS": {color: "#DC3A0C", aliases: []}, + "PostScript": {color: "#DA291C", aliases: ["postscr"]}, + "Power Query": {color: "#D38E0D", aliases: ["powerquery"]}, + "PowerBuilder": {color: "#8F0F8D", aliases: []}, + "PowerShell": {color: "#012456", aliases: ["posh","pwsh"]}, + "Praat": {color: "#C8506D", aliases: []}, + "Prisma": {color: "#0C344B", aliases: []}, + "Pro*C": {color: "#BB8368", aliases: []}, + "Processing": {color: "#0096D8", aliases: []}, + "Procfile": {color: "#3B2F63", aliases: []}, + "Prolog": {color: "#74283C", aliases: []}, + "Promela": {color: "#DE0000", aliases: []}, + "Propeller Spin": {color: "#7FA2A7", aliases: []}, + "Pug": {color: "#A86454", aliases: []}, + "Puppet": {color: "#302B6D", aliases: []}, + "PureBasic": {color: "#5A6986", aliases: []}, + "PureScript": {color: "#1D222D", aliases: []}, + "Pyret": {color: "#EE1E10", aliases: []}, + "Python": {color: "#3572A5", aliases: ["py","py3","python3","rusthon"]}, + "Python console": {color: "#3572A5", aliases: ["pycon"]}, + "Python traceback": {color: "#3572A5", aliases: []}, + "Q#": {color: "#FED659", aliases: ["qsharp"]}, + "QML": {color: "#44A51C", aliases: []}, + "Qt Script": {color: "#00B841", aliases: []}, + "Quake": {color: "#882233", aliases: []}, + "QuakeC": {color: "#975777", aliases: []}, + "Quartus Simulation IP": {color: "#58C42E", aliases: []}, + "QuickBASIC": {color: "#008080", aliases: ["qb","qbasic","qb64","classic qbasic","classic quickbasic"]}, + "Quint": {color: "#9D6CE5", aliases: []}, + "R": {color: "#198CE7", aliases: ["Rscript","splus"]}, + "RAML": {color: "#77D9FB", aliases: []}, + "RAScript": {color: "#2C97FA", aliases: []}, + "RBS": {color: "#701516", aliases: []}, + "RDoc": {color: "#701516", aliases: []}, + "REXX": {color: "#D90E09", aliases: ["arexx"]}, + "RMarkdown": {color: "#198CE7", aliases: []}, + "RON": {color: "#A62C00", aliases: []}, + "ROS Interface": {color: "#22314E", aliases: ["rosmsg"]}, + "RPGLE": {color: "#2BDE21", aliases: ["ile rpg","sqlrpgle"]}, + "RUNOFF": {color: "#665A4E", aliases: []}, + "Racket": {color: "#3C5CAA", aliases: []}, + "Ragel": {color: "#9D5200", aliases: ["ragel-rb","ragel-ruby"]}, + "Raku": {color: "#0000FB", aliases: ["perl6","perl-6"]}, + "Rascal": {color: "#FFFAA0", aliases: []}, + "ReScript": {color: "#ED5051", aliases: []}, + "Reason": {color: "#FF5847", aliases: []}, + "ReasonLIGO": {color: "#FF5847", aliases: []}, + "Rebol": {color: "#358A5B", aliases: []}, + "Record Jar": {color: "#0673BA", aliases: []}, + "Red": {color: "#F50000", aliases: ["red/system"]}, + "Redscript": {color: "#F44336", aliases: []}, + "Regular Expression": {color: "#009A00", aliases: ["regexp","regex"]}, + "Ren'Py": {color: "#FF7F7F", aliases: ["renpy"]}, + "Rez": {color: "#FFDAB3", aliases: []}, + "Rhai": {color: "#FBA63B", aliases: []}, + "Ring": {color: "#2D54CB", aliases: []}, + "Riot": {color: "#A71E49", aliases: []}, + "RobotFramework": {color: "#00C0B5", aliases: []}, + "Roc": {color: "#7C38F5", aliases: []}, + "Rocq Prover": {color: "#D0B68C", aliases: ["coq","rocq"]}, + "Roff": {color: "#ECDEBE", aliases: ["groff","man","manpage","man page","man-page","mdoc","nroff","troff"]}, + "Roff Manpage": {color: "#ECDEBE", aliases: []}, + "Rouge": {color: "#CC0088", aliases: []}, + "RouterOS Script": {color: "#DE3941", aliases: []}, + "Ruby": {color: "#701516", aliases: ["jruby","macruby","rake","rb","rbx"]}, + "Rust": {color: "#DEA584", aliases: ["rs"]}, + "SAS": {color: "#B34936", aliases: []}, + "SCSS": {color: "#C6538C", aliases: []}, + "SIP": {color: "#4E8D83", aliases: []}, + "SPARQL": {color: "#0C4597", aliases: []}, + "SQF": {color: "#3F3F3F", aliases: []}, + "SQL": {color: "#E38C00", aliases: []}, + "SQLPL": {color: "#E38C00", aliases: []}, + "SRecode Template": {color: "#348A34", aliases: []}, + "STL": {color: "#373B5E", aliases: ["ascii stl","stla"]}, + "SVG": {color: "#FF9900", aliases: []}, + "Sail": {color: "#259DD5", aliases: []}, + "Salt": {color: "#57BCAD", aliases: ["saltstack","saltstate"]}, + "Sass": {color: "#A53B70", aliases: []}, + "Scala": {color: "#C22D40", aliases: []}, + "Scaml": {color: "#BD181A", aliases: []}, + "Scenic": {color: "#FDC700", aliases: []}, + "Scheme": {color: "#1E4AEC", aliases: []}, + "Scilab": {color: "#CA0F21", aliases: []}, + "Self": {color: "#0579AA", aliases: []}, + "ShaderLab": {color: "#222C37", aliases: []}, + "Shell": {color: "#89E051", aliases: ["sh","shell-script","bash","zsh","envrc"]}, + "ShellCheck Config": {color: "#CECFCB", aliases: ["shellcheckrc"]}, + "Shen": {color: "#120F14", aliases: []}, + "Simple File Verification": {color: "#C9BFED", aliases: ["sfv"]}, + "Singularity": {color: "#64E6AD", aliases: []}, + "Slang": {color: "#1FBEC9", aliases: []}, + "Slash": {color: "#007EFF", aliases: []}, + "Slice": {color: "#003FA2", aliases: []}, + "Slim": {color: "#2B2B2B", aliases: []}, + "Slint": {color: "#2379F4", aliases: []}, + "SmPL": {color: "#C94949", aliases: ["coccinelle"]}, + "Smalltalk": {color: "#596706", aliases: ["squeak"]}, + "Smarty": {color: "#F0C040", aliases: []}, + "Smithy": {color: "#C44536", aliases: []}, + "Snakemake": {color: "#419179", aliases: ["snakefile"]}, + "Solidity": {color: "#AA6746", aliases: []}, + "SourcePawn": {color: "#F69E1D", aliases: ["sourcemod"]}, + "SpiceDB Schema": {color: "#A5318A", aliases: []}, + "Squirrel": {color: "#800000", aliases: []}, + "Stan": {color: "#B2011D", aliases: []}, + "Standard ML": {color: "#DC566D", aliases: ["sml"]}, + "Starlark": {color: "#76D275", aliases: ["bazel","bzl"]}, + "Stata": {color: "#1A5F91", aliases: []}, + "StringTemplate": {color: "#3FB34F", aliases: []}, + "Stylus": {color: "#FF6347", aliases: []}, + "SubRip Text": {color: "#9E0101", aliases: []}, + "SugarSS": {color: "#2FCC9F", aliases: []}, + "SuperCollider": {color: "#46390B", aliases: []}, + "SurrealQL": {color: "#FF00A0", aliases: ["surql"]}, + "Survex data": {color: "#FFCC99", aliases: []}, + "Svelte": {color: "#FF3E00", aliases: []}, + "Sway": {color: "#00F58C", aliases: []}, + "Sweave": {color: "#198CE7", aliases: []}, + "Swift": {color: "#F05138", aliases: []}, + "SystemVerilog": {color: "#DAE1C2", aliases: []}, + "TI Program": {color: "#A0AA87", aliases: []}, + "TL-Verilog": {color: "#C40023", aliases: []}, + "TLA": {color: "#4B0079", aliases: []}, + "TMDL": {color: "#F0C913", aliases: ["Tabular Model Definition Language"]}, + "TOML": {color: "#9C4221", aliases: []}, + "TSQL": {color: "#E38C00", aliases: []}, + "TSV": {color: "#237346", aliases: ["tab-seperated values"]}, + "TSX": {color: "#3178C6", aliases: ["typescriptreact"]}, + "TXL": {color: "#0178B8", aliases: []}, + "Tact": {color: "#48B5FF", aliases: []}, + "Talon": {color: "#333333", aliases: []}, + "Tcl": {color: "#E4CC98", aliases: ["sdc","xdc"]}, + "TeX": {color: "#3D6117", aliases: ["latex"]}, + "Teal": {color: "#00B1BC", aliases: []}, + "Terra": {color: "#00004C", aliases: []}, + "Terraform Template": {color: "#7B42BB", aliases: []}, + "TextGrid": {color: "#C8506D", aliases: []}, + "TextMate Properties": {color: "#DF66E4", aliases: ["tm-properties"]}, + "Textile": {color: "#FFE7AC", aliases: []}, + "Thrift": {color: "#D12127", aliases: []}, + "Toit": {color: "#C2C9FB", aliases: []}, + "Tolk": {color: "#30A1F5", aliases: []}, + "Tor Config": {color: "#59316B", aliases: ["torrc"]}, + "Tree-sitter Query": {color: "#8EA64C", aliases: ["tsq"]}, + "Turing": {color: "#CF142B", aliases: []}, + "Twig": {color: "#C1D026", aliases: []}, + "TypeScript": {color: "#3178C6", aliases: ["ts"]}, + "TypeSpec": {color: "#4A3665", aliases: ["tsp"]}, + "Typst": {color: "#239DAD", aliases: ["typ"]}, + "Unified Parallel C": {color: "#4E3617", aliases: []}, + "Unity3D Asset": {color: "#222C37", aliases: []}, + "Uno": {color: "#9933CC", aliases: []}, + "UnrealScript": {color: "#A54C4D", aliases: []}, + "Untyped Plutus Core": {color: "#36ADBD", aliases: []}, + "UrWeb": {color: "#CCCCEE", aliases: ["Ur/Web","Ur"]}, + "V": {color: "#4F87C4", aliases: ["vlang"]}, + "VBA": {color: "#867DB1", aliases: ["visual basic for applications"]}, + "VBScript": {color: "#15DCDC", aliases: []}, + "VCL": {color: "#148AA8", aliases: []}, + "VHDL": {color: "#ADB2CB", aliases: []}, + "Vala": {color: "#A56DE2", aliases: []}, + "Valve Data Format": {color: "#F26025", aliases: ["keyvalues","vdf"]}, + "Velocity Template Language": {color: "#507CFF", aliases: ["vtl","velocity"]}, + "Vento": {color: "#FF0080", aliases: []}, + "Verilog": {color: "#B2B7F8", aliases: []}, + "Verse": {color: "#518EF8", aliases: []}, + "Vespa Schema Definition": {color: "#61D790", aliases: ["vespa"]}, + "Vim Help File": {color: "#199F4B", aliases: ["help","vimhelp"]}, + "Vim Snippet": {color: "#199F4B", aliases: ["SnipMate","UltiSnip","UltiSnips","NeoSnippet"]}, + "Vim script": {color: "#199F4B", aliases: ["vim","viml","nvim","vimscript"]}, + "Visual Basic .NET": {color: "#945DB7", aliases: ["visual basic","vbnet","vb .net","vb.net"]}, + "Visual Basic 6.0": {color: "#2C6353", aliases: ["vb6","vb 6","visual basic 6","visual basic classic","classic visual basic"]}, + "Volt": {color: "#1F1F1F", aliases: []}, + "Vue": {color: "#41B883", aliases: []}, + "Vyper": {color: "#9F4CF2", aliases: []}, + "WDL": {color: "#42F1F4", aliases: ["Workflow Description Language"]}, + "WGSL": {color: "#1A5E9A", aliases: []}, + "Web Ontology Language": {color: "#5B70BD", aliases: []}, + "WebAssembly": {color: "#04133B", aliases: ["wast","wasm"]}, + "WebAssembly Interface Type": {color: "#6250E7", aliases: ["wit"]}, + "Whiley": {color: "#D5C397", aliases: []}, + "Wikitext": {color: "#FC5757", aliases: ["mediawiki","wiki"]}, + "Windows Registry Entries": {color: "#52D5FF", aliases: []}, + "Witcher Script": {color: "#FF0000", aliases: []}, + "Wolfram Language": {color: "#DD1100", aliases: ["mathematica","mma","wolfram","wolfram lang","wl"]}, + "Wollok": {color: "#A23738", aliases: []}, + "World of Warcraft Addon Data": {color: "#F7E43F", aliases: []}, + "Wren": {color: "#383838", aliases: ["wrenlang"]}, + "X10": {color: "#4B6BEF", aliases: ["xten"]}, + "XC": {color: "#99DA07", aliases: []}, + "XML": {color: "#0060AC", aliases: ["rss","xsd","wsdl"]}, + "XML Property List": {color: "#0060AC", aliases: []}, + "XQuery": {color: "#5232E7", aliases: []}, + "XSLT": {color: "#EB8CEB", aliases: ["xsl"]}, + "Xmake": {color: "#22A079", aliases: []}, + "Xojo": {color: "#81BD41", aliases: []}, + "Xonsh": {color: "#285EEF", aliases: []}, + "Xtend": {color: "#24255D", aliases: []}, + "YAML": {color: "#CB171E", aliases: ["yml"]}, + "YARA": {color: "#220000", aliases: []}, + "YASnippet": {color: "#32AB90", aliases: ["snippet","yas"]}, + "Yacc": {color: "#4B6C4B", aliases: []}, + "Yul": {color: "#794932", aliases: []}, + "ZAP": {color: "#0D665E", aliases: []}, + "ZIL": {color: "#DC75E5", aliases: []}, + "ZenScript": {color: "#00BCD1", aliases: []}, + "Zephir": {color: "#118F9E", aliases: []}, + "Zig": {color: "#EC915C", aliases: []}, + "Zimpl": {color: "#D67711", aliases: []}, + "Zmodel": {color: "#FF7100", aliases: []}, + "crontab": {color: "#EAD7AC", aliases: ["cron","cron table"]}, + "eC": {color: "#913960", aliases: []}, + "fish": {color: "#4AAE47", aliases: []}, + "hoon": {color: "#00B171", aliases: []}, + "iCalendar": {color: "#EC564C", aliases: ["iCal"]}, + "jq": {color: "#C7254E", aliases: []}, + "kvlang": {color: "#1DA6E0", aliases: []}, + "mIRC Script": {color: "#3D57C3", aliases: []}, + "mcfunction": {color: "#E22837", aliases: []}, + "mdsvex": {color: "#5F9EA0", aliases: []}, + "mupad": {color: "#244963", aliases: []}, + "nanorc": {color: "#2D004D", aliases: []}, + "nesC": {color: "#94B0C7", aliases: []}, + "ooc": {color: "#B0B77E", aliases: []}, + "pkg-config": {color: "#2B5E82", aliases: ["pkgconf"]}, + "q": {color: "#0040CD", aliases: []}, + "reStructuredText": {color: "#141414", aliases: ["rst"]}, + "sed": {color: "#64B970", aliases: []}, + "templ": {color: "#66D0DD", aliases: []}, + "ucode": {color: "#00B8D4", aliases: []}, + "vCard": {color: "#EE2647", aliases: ["virtual contact file","electronic business card"]}, + "wisp": {color: "#7582D1", aliases: []}, + "xBase": {color: "#403A40", aliases: ["advpl","clipper","foxpro"]} +} as const; diff --git a/src/languages/linguistLanguageColors.ts b/src/languages/linguistLanguageColors.ts new file mode 100644 index 00000000..34c71ecd --- /dev/null +++ b/src/languages/linguistLanguageColors.ts @@ -0,0 +1,24 @@ +import {LINGUIST_LANGUAGE_COLORS} from './linguistLanguageColors.generated.js'; + +/** Neutral identity fallback for names without a Linguist color. */ +export const UNKNOWN_LANGUAGE_IDENTITY_COLOR = '#8F8A98'; + +export function normalizeLanguageName(name: string): string { + return name.normalize('NFKC').trim().replace(/\s+/gu, ' ').toLocaleLowerCase('en-US'); +} + +const languageColors = new Map(); +for (const [name, entry] of Object.entries(LINGUIST_LANGUAGE_COLORS)) { + languageColors.set(normalizeLanguageName(name), entry.color); +} +for (const [name, entry] of Object.entries(LINGUIST_LANGUAGE_COLORS)) { + for (const alias of entry.aliases) { + const normalized = normalizeLanguageName(alias); + if (!languageColors.has(normalized)) languageColors.set(normalized, entry.color); + } +} + +/** Returns a Linguist identity color; this value carries no semantic status. */ +export function languageIdentityColor(name: string): string { + return languageColors.get(normalizeLanguageName(name)) ?? UNKNOWN_LANGUAGE_IDENTITY_COLOR; +} diff --git a/tests/linguistLanguageColors.test.ts b/tests/linguistLanguageColors.test.ts new file mode 100644 index 00000000..17f2a27c --- /dev/null +++ b/tests/linguistLanguageColors.test.ts @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {join} from 'node:path'; +import {languageIdentityColor, normalizeLanguageName, UNKNOWN_LANGUAGE_IDENTITY_COLOR} from '../src/languages/linguistLanguageColors.js'; +import {UI_COLORS} from '../src/ui/palette.js'; +import {parseLanguageColors, renderLanguageColorsModule} from '../scripts/lib/linguistLanguageColors.mjs'; + +test('known languages and official aliases resolve to Linguist identity colors', () => { + assert.equal(languageIdentityColor('Rust'), '#DEA584'); + assert.equal(languageIdentityColor('TypeScript'), '#3178C6'); + assert.equal(languageIdentityColor('JS'), languageIdentityColor('JavaScript')); +}); + +test('language names normalize case, Unicode, and whitespace; unknown names use a neutral fallback', () => { + assert.equal(normalizeLanguageName(' TYPEscript '), 'typescript'); + assert.equal(languageIdentityColor(' TYPEscript '), languageIdentityColor('TypeScript')); + assert.equal(languageIdentityColor(' a language NMSh does not know '), UNKNOWN_LANGUAGE_IDENTITY_COLOR); +}); + +test('language identity remains separate from semantic status colors', () => { + assert.notEqual(languageIdentityColor('TypeScript'), UI_COLORS.success); + assert.notEqual(languageIdentityColor('TypeScript'), UI_COLORS.failure); + assert.equal(UNKNOWN_LANGUAGE_IDENTITY_COLOR, '#8F8A98'); +}); + +test('the checked-in generated table is deterministic for identical source input', async () => { + const fixture = [ + '---', + 'Zulu:', + ' color: "#abcdef"', + ' aliases:', + ' - zed', + 'Alpha:', + ' color: "#123456"', + 'No Color:', + ' type: data', + ].join('\n'); + const first = renderLanguageColorsModule(parseLanguageColors(fixture), 'a'.repeat(40)); + const second = renderLanguageColorsModule(parseLanguageColors(fixture), 'a'.repeat(40)); + assert.equal(first, second); + assert.ok(first.indexOf('"Alpha"') < first.indexOf('"Zulu"')); + + const generated = await readFile(join(process.cwd(), 'src/languages/linguistLanguageColors.generated.ts'), 'utf8'); + assert.match(generated, /LINGUIST_LANGUAGE_COLORS_REVISION = "[\da-f]{40}"/u); +}); + +test('runtime lookup is local and synchronous', () => { + assert.equal(typeof languageIdentityColor('Go'), 'string'); + assert.equal(languageIdentityColor('Go') instanceof Promise, false); +}); From 3bf1871df48665433a7e7a30a7ecb699e3fd4bf5 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:11:36 +0530 Subject: [PATCH 03/82] Add optional VHS demo tapes --- dev/tapes/.gitignore | 1 + dev/tapes/README.md | 20 ++++++++++++++++++++ dev/tapes/command.tape | 16 ++++++++++++++++ dev/tapes/settings.tape | 17 +++++++++++++++++ dev/tapes/welcome.tape | 15 +++++++++++++++ 5 files changed, 69 insertions(+) create mode 100644 dev/tapes/.gitignore create mode 100644 dev/tapes/README.md create mode 100644 dev/tapes/command.tape create mode 100644 dev/tapes/settings.tape create mode 100644 dev/tapes/welcome.tape diff --git a/dev/tapes/.gitignore b/dev/tapes/.gitignore new file mode 100644 index 00000000..ea1472ec --- /dev/null +++ b/dev/tapes/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/dev/tapes/README.md b/dev/tapes/README.md new file mode 100644 index 00000000..68845d3e --- /dev/null +++ b/dev/tapes/README.md @@ -0,0 +1,20 @@ +# VHS development demos + +These optional VHS tapes capture a welcome, settings navigation, and one ordinary command. They are development/demo tooling only; NMSh does not depend on VHS at runtime, and `npm test` does not invoke it. + +## Run + +From the repository root, install the normal project dependencies if needed, then run: + +```sh +mkdir -p dev/tapes/output +vhs dev/tapes/welcome.tape +vhs dev/tapes/settings.tape +vhs dev/tapes/command.tape +``` + +VHS writes GIFs under `dev/tapes/output/`, which is ignored by Git. VHS is not installed automatically. Install it using the instructions in the [official VHS repository](https://github.com/charmbracelet/vhs) if you want to record these demos. + +The tapes use real NMSh startup, wall-clock values, and the ambient Vespyr blink. They are illustrative recordings, not deterministic snapshots or CI assertions. The deterministic presentation mode tracked in #173 is not available yet; generated media should be reviewed before sharing and should not be committed as golden output until that mode is implemented. + +The tape files are validated with `vhs validate 'dev/tapes/*.tape'`. To regenerate all recordings locally after a change, run each tape command above. Keep tapes short, avoid network access and optional provider binaries, and do not add Chat, Command Palette, or Native Prompt style coverage while those surfaces remain owned by the active v0.5 work. diff --git a/dev/tapes/command.tape b/dev/tapes/command.tape new file mode 100644 index 00000000..ee690fe0 --- /dev/null +++ b/dev/tapes/command.tape @@ -0,0 +1,16 @@ +Require npm +Require zsh + +Output dev/tapes/output/command.gif +Set Shell "zsh" +Set Width 1200 +Set Height 720 +Set FontSize 18 +Set TypingSpeed 60ms + +Hide +Type "npm run dev" Enter +Sleep 2s +Show +Type "printf 'Hello from NMSh\\n'" Enter +Sleep 2s diff --git a/dev/tapes/settings.tape b/dev/tapes/settings.tape new file mode 100644 index 00000000..de772dc7 --- /dev/null +++ b/dev/tapes/settings.tape @@ -0,0 +1,17 @@ +Require npm +Require zsh + +Output dev/tapes/output/settings.gif +Set Shell "zsh" +Set Width 1200 +Set Height 720 +Set FontSize 18 +Set TypingSpeed 60ms + +Hide +Type "npm run dev" Enter +Sleep 2s +Type "/settings" Enter +Sleep 2s +Show +Sleep 2s diff --git a/dev/tapes/welcome.tape b/dev/tapes/welcome.tape new file mode 100644 index 00000000..9fa440e6 --- /dev/null +++ b/dev/tapes/welcome.tape @@ -0,0 +1,15 @@ +Require npm +Require zsh + +Output dev/tapes/output/welcome.gif +Set Shell "zsh" +Set Width 1200 +Set Height 720 +Set FontSize 18 +Set TypingSpeed 60ms + +Hide +Type "npm run dev" Enter +Sleep 2s +Show +Sleep 3s From 4f6347e50083d68f6f5b56df85ddc4761ac4360d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:06:30 +0530 Subject: [PATCH 04/82] Make VHS demos wait for visible states --- dev/tapes/command.tape | 7 ++++--- dev/tapes/settings.tape | 6 +++--- dev/tapes/welcome.tape | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/dev/tapes/command.tape b/dev/tapes/command.tape index ee690fe0..3db8be1a 100644 --- a/dev/tapes/command.tape +++ b/dev/tapes/command.tape @@ -4,13 +4,14 @@ Require zsh Output dev/tapes/output/command.gif Set Shell "zsh" Set Width 1200 -Set Height 720 +Set Height 650 Set FontSize 18 Set TypingSpeed 60ms Hide Type "npm run dev" Enter -Sleep 2s +Wait+Screen /notMyShell/ Show -Type "printf 'Hello from NMSh\\n'" Enter +Type "echo 'Hello from NMSh'" Enter +Wait+Screen /Hello from NMSh/ Sleep 2s diff --git a/dev/tapes/settings.tape b/dev/tapes/settings.tape index de772dc7..0a662cd0 100644 --- a/dev/tapes/settings.tape +++ b/dev/tapes/settings.tape @@ -4,14 +4,14 @@ Require zsh Output dev/tapes/output/settings.gif Set Shell "zsh" Set Width 1200 -Set Height 720 +Set Height 650 Set FontSize 18 Set TypingSpeed 60ms Hide Type "npm run dev" Enter -Sleep 2s +Wait+Screen /notMyShell/ Type "/settings" Enter -Sleep 2s +Wait+Screen /Search settings/ Show Sleep 2s diff --git a/dev/tapes/welcome.tape b/dev/tapes/welcome.tape index 9fa440e6..a8401b54 100644 --- a/dev/tapes/welcome.tape +++ b/dev/tapes/welcome.tape @@ -4,12 +4,12 @@ Require zsh Output dev/tapes/output/welcome.gif Set Shell "zsh" Set Width 1200 -Set Height 720 +Set Height 650 Set FontSize 18 Set TypingSpeed 60ms Hide Type "npm run dev" Enter -Sleep 2s +Wait+Screen /notMyShell/ Show Sleep 3s From cd5743291f2ac0979066e2e233ccd48c49f8071f Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:06:20 +0530 Subject: [PATCH 05/82] Render structured native completion with fuzzy filtering --- docs/performance-benchmarks.md | 2 + scripts/benchmarks.ts | 4 ++ src/app/TerminalApp.ts | 54 +++++++++++++++------- src/shell/CompletionMenu.ts | 33 +++++++++++++ src/shell/CompletionService.ts | 25 ++++++++-- src/shell/completion.ts | 21 +++++++++ tests/completionMenu.test.ts | 84 ++++++++++++++++++++++++++++++++++ 7 files changed, 202 insertions(+), 21 deletions(-) create mode 100644 src/shell/CompletionMenu.ts create mode 100644 tests/completionMenu.test.ts diff --git a/docs/performance-benchmarks.md b/docs/performance-benchmarks.md index 0f9ab4b1..f8f312cb 100644 --- a/docs/performance-benchmarks.md +++ b/docs/performance-benchmarks.md @@ -35,3 +35,5 @@ Measured on macOS arm64, Node 26.8.1, 8GiB RAM; informational single-machine res | Transcript JSON snapshot, 100k | 459.95 | 878.17 | Full transcript wrapping and snapshot serialization exceed the editor-frame target. They are whole-transcript workloads, not evidence that bounded history search needs a native helper. Avoid performing them as part of completion/history queries. Startup/socket latency is not measured here. + +Structured completion filtering (500 candidates) measured p50 0.06ms / p95 0.13ms. A direct native-source probe measured cold Git parent-context capture at 226.64ms and command capture at 84.10ms. These cold operations are asynchronous and exceed the 50ms investigation target; warm parent-context results are cached for two seconds (32 contexts maximum) and locally filtered. Results are invalidated by buffer/cwd generation before rendering. Capture execution remains bounded to 1.5 seconds and 1MiB output. diff --git a/scripts/benchmarks.ts b/scripts/benchmarks.ts index 7d0b2804..f8d1961a 100644 --- a/scripts/benchmarks.ts +++ b/scripts/benchmarks.ts @@ -5,6 +5,7 @@ import {OutputBuffer} from '../src/output/OutputBuffer.js'; import {CommandEditor} from '../src/input/CommandEditor.js'; import {layoutInput} from '../src/input/inputLayout.js'; import {Highlighter} from '../src/input/Highlighter.js'; +import {filterCompletions, parseNativeCompletions} from '../src/shell/completion.js'; import {planScreen} from '../src/app/screenPlan.js'; import {encodeMessage, FrameDecoder} from '../src/session/SessionProtocol.js'; import {parseZshHistory} from '../src/shell/HistoryService.js'; @@ -81,7 +82,10 @@ function setup(): void { } } +const completionFixture = parseNativeCompletions(Array.from({length: 500}, (_, index) => `--option-${index} -- description ${index}`).join('\n'), {buffer: 'tool ', cwd: '/work'}); + const benchmarks: Benchmark[] = [ + {name: 'completion/filter-500', run: () => filterCompletions(completionFixture, 'op4'), units: 500, unitName: 'candidates'}, ...suggestionCounts.map(count => ({ name: `history/current-text-scan-${count}`, run: () => histories.get(count)!.map(entry => entry.command).filter(command => command.toLowerCase().includes('nonexistent')).slice(0, 100), diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 7d5d5a7c..ffcbdf5e 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -11,6 +11,8 @@ import { import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js'; import {appendFileSync, existsSync} from 'node:fs'; import {delimiter, join} from 'node:path'; +import {renderCompletion, COMPLETION_ACTIONS} from '../shell/CompletionMenu.js'; +import {resolveAction} from '../ui/actions.js'; import {CompletionService, type CompletionCandidate} from '../shell/CompletionService.js'; import {HistoryService} from '../shell/HistoryService.js'; import {SuggestionController} from '../suggestions/SuggestionController.js'; @@ -132,6 +134,7 @@ export class TerminalApp { private readonly completionService = new CompletionService(); private shellSuggestions: CompletionCandidate[] = []; private lastSuggestionInput = ""; + private completionGeneration = 0; private context: PromptContext = {cwd: process.cwd(), project: '…', exitStatus: 0}; private readonly commandContexts = new CommandContextCache(() => this.render()); private promptConfiguration: PromptConfiguration = loadPromptConfiguration(); @@ -769,6 +772,26 @@ export class TerminalApp { return; } + if (!this.running && !this.historySearchActive && !this.editor.text.startsWith('/') && this.shellSuggestions.length > 0 + && !this.suggestions.alternativesOpen) { + const action = resolveAction(COMPLETION_ACTIONS, key); + if (action?.id === 'move') { + this.selectedSuggestion = (this.selectedSuggestion + (key.kind === 'down' ? 1 : -1) + this.shellSuggestions.length) % this.shellSuggestions.length; + return; + } + if (action?.id === 'insert') { + const candidate = this.shellSuggestions[this.selectedSuggestion] ?? this.shellSuggestions[0]; + if (candidate) this.applySuggestion(candidate); + return; + } + if (action?.id === 'cancel') { + this.completionGeneration += 1; + this.completionService.cancel(); + this.shellSuggestions = []; + return; + } + } + if (!this.running && this.handleSuggestionKey(key)) { this.selectedSuggestion = 0; return; @@ -920,24 +943,22 @@ export class TerminalApp { private async fetchSuggestions(): Promise { - if (this.running || this.editor.hasPasteAtoms || this.editor.text.startsWith('/')) { - this.shellSuggestions = []; - return; - } const input = this.editor.text; - if (input === this.lastSuggestionInput) return; - this.lastSuggestionInput = input; - - if (input.trim().length === 0) { - this.shellSuggestions = []; - this.render(); - return; - } - - const comps = await this.completionService.suggest(input, this.context.cwd); - if (this.editor.text === input) { + const cwd = this.context.cwd; + const eligible = !this.running && !this.settingsPanelActive && !this.editor.hasPasteAtoms && !input.startsWith('/') && Boolean(input.trim()); + const key = eligible ? JSON.stringify([input, cwd]) : ''; + if (key === this.lastSuggestionInput) return; + this.lastSuggestionInput = key; + const generation = ++this.completionGeneration; + this.completionService.cancel(); + // Clear before the next frame: results for another buffer must never flash. + this.shellSuggestions = []; + this.selectedSuggestion = 0; + if (!eligible) return; + const comps = await this.completionService.suggest(input, cwd); + if (!this.stopped && generation === this.completionGeneration && this.editor.text === input && this.context.cwd === cwd + && !this.running && !this.settingsPanelActive && !this.editor.hasPasteAtoms) { this.shellSuggestions = comps; - this.selectedSuggestion = 0; this.render(); } } @@ -2665,6 +2686,7 @@ export class TerminalApp { ? [...panelRows.slice(1), panelRows[0]!] : panelRows ?? []; case 'suggestions': return suggestionView.items.map((suggestion, visibleIndex) => { const selected = suggestionView.start + visibleIndex === effectiveSelection; + if ('source' in suggestion && 'replacement' in suggestion) return renderCompletion(suggestion, selected, columns); return truncateAnsi( `${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ${suggestion.name.padEnd(10)}${RESET}${SECONDARY} ${suggestion.description}${RESET}`, columns, diff --git a/src/shell/CompletionMenu.ts b/src/shell/CompletionMenu.ts new file mode 100644 index 00000000..bc994da3 --- /dev/null +++ b/src/shell/CompletionMenu.ts @@ -0,0 +1,33 @@ +import type {CompletionCandidate} from './completion.js'; +export {filterCompletions} from './completion.js'; +import {completionLabel} from './completion.js'; +import {GLYPHS, getCurrentGlyphMode} from '../ui/glyphs.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateAnsi, truncateText, displayWidth} from '../util/text.js'; +import type {UiAction} from '../ui/actions.js'; + +export const COMPLETION_ACTIONS: readonly UiAction[] = [ + {id: 'move', label: 'move', keyLabel: '↑↓', kinds: ['up', 'down']}, + {id: 'insert', label: 'insert', keyLabel: 'Tab', kinds: ['complete']}, + {id: 'cancel', label: 'dismiss', keyLabel: 'Esc', kinds: ['escape']}, +]; + +const ICONS = {command: '\uf489', subcommand: '\uf489', option: '\uf024', argument: '\uf12e', + file: '\uf15b', directory: '\uf07b', value: '\uf12e'} as const; + +/** One selectable row: groups are inline so geometry and selection indices stay identical. */ +export function renderCompletion(candidate: CompletionCandidate, selected: boolean, columns: number): string { + const accent = foreground(selected ? UI_COLORS.accent : UI_COLORS.secondary); + const secondary = foreground(UI_COLORS.secondary); + const marker = selected ? GLYPHS.selection : ' '; + const icon = getCurrentGlyphMode() === 'nerd' ? `${ICONS[candidate.kind]} ` : ''; + const display = completionLabel(candidate.display); + const group = completionLabel(candidate.group ?? candidate.kind); + const prefix = `${marker} ${icon}`; + const width = Math.max(0, columns - displayWidth(prefix)); + if (width < 28) return truncateAnsi(`${accent}${prefix}${truncateText(display, width)}\u001b[0m`, columns); + const labelWidth = Math.min(28, Math.floor(width * 0.5)); + const label = truncateText(display, labelWidth); + const padding = ' '.repeat(Math.max(1, labelWidth - displayWidth(label) + 1)); + return truncateAnsi(`${accent}${prefix}${label}${padding}\u001b[0m${secondary}[${group}] ${completionLabel(candidate.description)}\u001b[0m`, columns); +} diff --git a/src/shell/CompletionService.ts b/src/shell/CompletionService.ts index 89f40e48..daf971cf 100644 --- a/src/shell/CompletionService.ts +++ b/src/shell/CompletionService.ts @@ -1,7 +1,7 @@ import {runExternal} from '../providers/providers.js'; import {dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; -import {parseNativeCompletions, type CompletionCandidate, type CompletionContext, type CompletionSource} from './completion.js'; +import {filterCompletions, parseNativeCompletions, type CompletionCandidate, type CompletionContext, type CompletionSource} from './completion.js'; export type {CompletionCandidate} from './completion.js'; const script = join(dirname(fileURLToPath(import.meta.url)), 'capture.zsh'); @@ -9,12 +9,27 @@ const script = join(dirname(fileURLToPath(import.meta.url)), 'capture.zsh'); /** Existing isolated capture source; no composer text is submitted to the managed shell. */ export class NativeCompletionSource implements CompletionSource { readonly id = 'zsh-native'; + private readonly cache = new Map(); async query(context: CompletionContext, signal: AbortSignal): Promise { - const result = await runExternal('zsh', [script, context.buffer], { - cwd: context.cwd, env: process.env, signal, timeoutMs: 1500, maxBytes: 1024 * 1024, - }); - return result.ok && !signal.aborted ? parseNativeCompletions(result.stdout, context) : []; + // Capture the token's parent context once, then fuzzy-filter locally while typing. + // Keep the legacy whitespace replacement boundary until quoted-token support is designed. + const start = context.buffer.lastIndexOf(' ') + 1; + const parent = context.buffer.slice(0, start); + const key = JSON.stringify([context.cwd, parent]); + const cached = this.cache.get(key); + let output: string; + if (cached && Date.now() - cached.at < 2000) output = cached.output; + else { + const result = await runExternal('zsh', [script, parent], { + cwd: context.cwd, env: process.env, signal, timeoutMs: 1500, maxBytes: 1024 * 1024, + }); + if (!result.ok || signal.aborted) return []; + output = result.stdout; + if (this.cache.size >= 32) this.cache.delete(this.cache.keys().next().value!); + this.cache.set(key, {at: Date.now(), output}); + } + return signal.aborted ? [] : filterCompletions(parseNativeCompletions(output, context), context.buffer.slice(start)); } } diff --git a/src/shell/completion.ts b/src/shell/completion.ts index 03eca017..af765da9 100644 --- a/src/shell/completion.ts +++ b/src/shell/completion.ts @@ -51,3 +51,24 @@ export function parseNativeCompletions(output: string, context: CompletionContex } return result; } + +/** Stable subsequence ranking. Exact/prefix matches lead; ties retain source order. */ +export function filterCompletions(candidates: readonly CompletionCandidate[], query: string): CompletionCandidate[] { + const needle = query.toLowerCase(); + if (!needle) return [...candidates]; + const scored: Array<{candidate: CompletionCandidate; score: number; index: number}> = []; + candidates.forEach((candidate, index) => { + const text = candidate.value.toLowerCase(); + let position = -1; + let gaps = 0; + for (const character of needle) { + const next = text.indexOf(character, position + 1); + if (next === -1) return; + gaps += next - position - 1; + position = next; + } + const score = text === needle ? -2000 : text.startsWith(needle) ? -1000 + text.length : gaps + text.length; + scored.push({candidate, score, index}); + }); + return scored.sort((a, b) => a.score - b.score || a.index - b.index).map(item => item.candidate); +} diff --git a/tests/completionMenu.test.ts b/tests/completionMenu.test.ts new file mode 100644 index 00000000..76a1b75b --- /dev/null +++ b/tests/completionMenu.test.ts @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {filterCompletions, parseNativeCompletions} from '../src/shell/completion.js'; +import {renderCompletion} from '../src/shell/CompletionMenu.js'; +import {setIconStyle} from '../src/ui/glyphs.js'; +import {stripAnsi, displayWidth} from '../src/util/text.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; + +const context = {buffer: 'git ', cwd: '/'}; +const values = () => parseNativeCompletions('status -- working tree\nstash -- save changes\nshow -- objects\ncheckout -- branches', context); + +test('completion subsequence filtering ranks prefixes first and stays stable with hundreds', () => { + assert.deepEqual(filterCompletions(values(), 'st').map(item => item.value), ['stash', 'status']); + assert.deepEqual(filterCompletions(values(), 'sho').map(item => item.value), ['show']); + assert.deepEqual(filterCompletions(values(), 'ckt').map(item => item.value), ['checkout']); + assert.deepEqual(filterCompletions(values(), 'zz'), []); + assert.equal(filterCompletions(Array.from({length: 500}, () => values()[0]!), 'sts').length, 500); +}); + +test('menu shows descriptions, source groups, safe icons and degrades at narrow widths', () => { + const item = {...values()[0]!, group: 'Git commands'}; + setIconStyle('safe'); + try { + const row = stripAnsi(renderCompletion(item, true, 80)); + assert.match(row, /^> /u); + assert.match(row, /Git commands.*working tree/u); + assert.doesNotMatch(row, /[\ue000-\uf8ff]/u); + for (const width of [1, 4, 12, 28, 80]) assert.ok(displayWidth(renderCompletion(item, true, width)) <= width); + assert.ok(stripAnsi(renderCompletion({...item, description: ''}, false, 80)).includes('status')); + const previous = process.env.NO_COLOR; + process.env.NO_COLOR = '1'; + try { assert.doesNotMatch(renderCompletion(item, true, 80), /\u001b\[(?:38|48);/u); } + finally { if (previous === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = previous; } + } finally { setIconStyle('nerd'); } +}); + +function appForTest(): TerminalApp { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + return app; +} +function cleanup(app: TerminalApp): void { app['stop'](0); app['session'].kill(); } + +test('keyboard completion moves selection, Tab inserts and Escape dismisses without execution', () => { + const app = appForTest(); + const submitted: string[] = []; + app['session'].submit = ((text: string) => submitted.push(text)) as never; + try { + app['editor'].insert('git '); + app['shellSuggestions'] = values(); + app['handleKey']({kind: 'down'}); + assert.equal(app['selectedSuggestion'], 1); + app['handleKey']({kind: 'complete'}); + assert.equal(app['editor'].text, 'git stash'); + assert.deepEqual(submitted, []); + app['shellSuggestions'] = values(); + app['handleKey']({kind: 'escape'}); + assert.deepEqual(app['shellSuggestions'], []); + } finally { cleanup(app); } +}); + +test('changed buffer clears old rows immediately and ABA requests cannot repaint', async () => { + const app = appForTest(); + const pending: Array<(items: ReturnType) => void> = []; + app['completionService'].suggest = () => new Promise(resolve => pending.push(resolve)); + try { + app['editor'].insert('git '); + app['shellSuggestions'] = values(); + const first = app['fetchSuggestions'](); + assert.deepEqual(app['shellSuggestions'], []); + app['editor'].insert('s'); + const second = app['fetchSuggestions'](); + app['editor'].backspace(); + const third = app['fetchSuggestions'](); + pending[2]!(values()); await third; + assert.equal(app['shellSuggestions'].length, 4); + pending[0]!([]); await first; + pending[1]!(values()); await second; + assert.equal(app['shellSuggestions'].length, 4); + app['editor'].clear(); app['editor'].insert('/history '); + await app['fetchSuggestions'](); + assert.deepEqual(app['shellSuggestions'], []); + } finally { cleanup(app); } +}); From 526f6bb8ffbfb41bf081ee7f2759c5ce6a2af5dc Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:23:21 +0530 Subject: [PATCH 06/82] Derive private command history with structured search and deletion --- docs/performance-benchmarks.md | 2 + scripts/benchmarks.ts | 18 +++- scripts/read-command-history.cjs | 30 ++++++ src/app/TerminalApp.ts | 71 ++++++++++--- src/help/helpContent.ts | 6 ++ src/output/OutputBuffer.ts | 4 + src/session/InProcessSessionClient.ts | 2 +- src/session/SessionClient.ts | 2 + src/session/SessionProtocol.ts | 4 +- src/session/SessionService.ts | 7 +- src/session/SocketSessionClient.ts | 2 +- src/session/StreamBacklog.ts | 2 +- src/sessions/TranscriptStore.ts | 7 +- src/sessions/archiveLive.ts | 11 +- src/shell/HistoryIndex.ts | 139 ++++++++++++++++++++++++++ src/shell/HistoryService.ts | 71 +++++++++++-- src/shell/ShellProtocol.ts | 6 +- src/shell/ShellSession.ts | 8 +- src/terminal/keys.ts | 4 +- tests/historySearch.test.ts | 10 +- tests/structuredHistory.test.ts | 109 ++++++++++++++++++++ 21 files changed, 470 insertions(+), 45 deletions(-) create mode 100644 scripts/read-command-history.cjs create mode 100644 src/shell/HistoryIndex.ts create mode 100644 tests/structuredHistory.test.ts diff --git a/docs/performance-benchmarks.md b/docs/performance-benchmarks.md index f8f312cb..2e46da46 100644 --- a/docs/performance-benchmarks.md +++ b/docs/performance-benchmarks.md @@ -37,3 +37,5 @@ Measured on macOS arm64, Node 26.8.1, 8GiB RAM; informational single-machine res Full transcript wrapping and snapshot serialization exceed the editor-frame target. They are whole-transcript workloads, not evidence that bounded history search needs a native helper. Avoid performing them as part of completion/history queries. Startup/socket latency is not measured here. Structured completion filtering (500 candidates) measured p50 0.06ms / p95 0.13ms. A direct native-source probe measured cold Git parent-context capture at 226.64ms and command capture at 84.10ms. These cold operations are asynchronous and exceed the 50ms investigation target; warm parent-context results are cached for two seconds (32 contexts maximum) and locally filtered. Results are invalidated by buffer/cwd generation before rendering. Capture execution remains bounded to 1.5 seconds and 1MiB output. + +Structured history queries with combined cwd/exit/duration/text filters and no matches (full scan) measured p50/p95 1.39/4.25ms at 10k and 11.38/11.70ms at 100k. Queries yield every 2,048 entries and stop after at most 100 results in the composer. Journal JSON loading/projection runs in a worker to keep whole-transcript parsing off the editor thread; zsh import yields between batches. Index setup and sorting are distinct from these warm-query measurements. diff --git a/scripts/benchmarks.ts b/scripts/benchmarks.ts index f8d1961a..f75e39bd 100644 --- a/scripts/benchmarks.ts +++ b/scripts/benchmarks.ts @@ -5,6 +5,7 @@ import {OutputBuffer} from '../src/output/OutputBuffer.js'; import {CommandEditor} from '../src/input/CommandEditor.js'; import {layoutInput} from '../src/input/inputLayout.js'; import {Highlighter} from '../src/input/Highlighter.js'; +import {HistoryIndex} from '../src/shell/HistoryIndex.js'; import {filterCompletions, parseNativeCompletions} from '../src/shell/completion.js'; import {planScreen} from '../src/app/screenPlan.js'; import {encodeMessage, FrameDecoder} from '../src/session/SessionProtocol.js'; @@ -12,7 +13,7 @@ import {parseZshHistory} from '../src/shell/HistoryService.js'; import {NativeSuggestions} from '../src/suggestions/NativeSuggestions.js'; import type {CommandEntry, SuggestionContext} from '../src/suggestions/types.js'; -type Benchmark = {name: string; run: () => void; samples?: number; warmup?: number; units?: number; unitName?: string}; +type Benchmark = {name: string; run: () => unknown; samples?: number; warmup?: number; units?: number; unitName?: string}; const args = new Set(process.argv.slice(2)); const memory = args.has('--memory'); @@ -29,6 +30,7 @@ const semanticCache = new Map(); for (const name of ['git', 'npm', 'rg', 'zsh', 'print']) semanticCache.set(name, 'executable'); const editorCharacters = Array.from(editorText); const suggestionCounts = [10_000, 100_000]; +const historyIndexes = new Map(); const histories = new Map(); const historyFiles = new Map(); const protocolFrames = Array.from({length: 400}, (_, seq) => encodeMessage({type: 'output', data: ansiPayload.slice(0, 256), seq, at: 1000 + seq})).join(''); @@ -60,6 +62,10 @@ function setup(): void { for (const count of suggestionCounts) { const entries = generatedHistory(count); histories.set(count, entries); + const index = new HistoryIndex(); + entries.forEach((entry, id) => index.add({...entry, id: String(id), source: 'nmsh', project: `project-${id % 80}`, session: `session-${id % 8}`, durationMs: id % 3000})); + index.all(); + historyIndexes.set(count, index); historyFiles.set(count, entries.map(entry => `: ${entry.at! / 1000}:1;${entry.command}`).join('\n')); } } @@ -85,6 +91,8 @@ function setup(): void { const completionFixture = parseNativeCompletions(Array.from({length: 500}, (_, index) => `--option-${index} -- description ${index}`).join('\n'), {buffer: 'tool ', cwd: '/work'}); const benchmarks: Benchmark[] = [ + ...suggestionCounts.map(count => ({name: `history/structured-query-${count}`, + run: () => historyIndexes.get(count)!.search('cwd:/work/project-7 exit:failure duration:>1s nonexistent'), units: count, unitName: 'entries'})), {name: 'completion/filter-500', run: () => filterCompletions(completionFixture, 'op4'), units: 500, unitName: 'candidates'}, ...suggestionCounts.map(count => ({ name: `history/current-text-scan-${count}`, @@ -176,14 +184,14 @@ function percentile(sorted: number[], value: number): number { return sorted[Math.max(0, Math.ceil(value * sorted.length) - 1)]!; } -function report(benchmark: Benchmark): void { +async function report(benchmark: Benchmark): Promise { const samples = benchmark.samples ?? sampleDefault; const warmup = benchmark.warmup ?? warmupDefault; - for (let index = 0; index < warmup; index += 1) benchmark.run(); + for (let index = 0; index < warmup; index += 1) await benchmark.run(); const times: number[] = []; for (let index = 0; index < samples; index += 1) { const start = performance.now(); - benchmark.run(); + await benchmark.run(); times.push(performance.now() - start); } times.sort((a, b) => a - b); @@ -201,7 +209,7 @@ if (runnable.length === 0) { console.error(`No benchmarks matched: ${selected.join(', ')}`); process.exitCode = 1; } else { - for (const benchmark of runnable) report(benchmark); + for (const benchmark of runnable) await report(benchmark); } if (memory) { if (globalThis.gc) globalThis.gc(); diff --git a/scripts/read-command-history.cjs b/scripts/read-command-history.cjs new file mode 100644 index 00000000..659459a7 --- /dev/null +++ b/scripts/read-command-history.cjs @@ -0,0 +1,30 @@ +// Reads authoritative journals off the editor thread; sends only eligible command metadata. +const {parentPort, workerData} = require('node:worker_threads'); +const {readdir, readFile} = require('node:fs/promises'); +const {join} = require('node:path'); + +(async () => { + let files; + try { files = await readdir(workerData.directory); } + catch (error) { if (error.code === 'ENOENT') return; throw error; } + for (const file of files) { + if (!file.endsWith('.json') || file.endsWith('.meta.json')) continue; + let session; + try { session = JSON.parse(await readFile(join(workerData.directory, file), 'utf8')); } + catch { continue; } + if (session.schemaVersion !== 1 || session.id !== file.slice(0, -5) || !Array.isArray(session.transcript?.records)) continue; + let records = []; + for (const record of session.transcript.records) { + if (record.historyEligible !== true || typeof record.command !== 'string' || /^\s/.test(record.command) + || !record.command.trim() || !Number.isSafeInteger(record.startId) || !Number.isInteger(record.exitCode)) continue; + const context = record.historicalContext; + records.push({command: record.command, historyEligible: true, startId: record.startId, exitCode: record.exitCode, + ...(Number.isFinite(record.startedAt) ? {startedAt: record.startedAt} : {}), + ...(Number.isFinite(record.durationMs) && record.durationMs >= 0 ? {durationMs: record.durationMs} : {}), + historicalContext: {cwd: typeof context?.cwd === 'string' ? context.cwd : undefined, + project: typeof context?.project === 'string' ? context.project : undefined}}); + if (records.length === 1024) { parentPort.postMessage({session: session.id, records}); records = []; } + } + if (records.length) parentPort.postMessage({session: session.id, records}); + } +})().catch(error => { throw error; }); diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index ffcbdf5e..f96650d5 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -19,7 +19,8 @@ import {SuggestionController} from '../suggestions/SuggestionController.js'; import {createPalette, handlePaletteKey, renderPalette, type PaletteItem, type PaletteState} from '../ui/CommandPalette.js'; import {NativeSuggestions} from '../suggestions/NativeSuggestions.js'; import {DejaSuggestions} from '../suggestions/DejaSuggestions.js'; -import {ignorePatternFromEnv, SUGGESTION_PROVIDERS} from '../suggestions/types.js'; +import type {HistoryEntry} from '../shell/HistoryIndex.js'; +import {isPrivateCommand, ignorePatternFromEnv, SUGGESTION_PROVIDERS} from '../suggestions/types.js'; import {CommandEditor} from '../input/CommandEditor.js'; import {OutputBuffer, serializeCopyPayload, type HistoricalContextSnapshot} from '../output/OutputBuffer.js'; import {createWelcomeSnapshot, renderWelcome, WELCOME_BLINK_CLOSED_MS, welcomeBlinkDelay} from '../output/Welcome.js'; @@ -135,6 +136,9 @@ export class TerminalApp { private shellSuggestions: CompletionCandidate[] = []; private lastSuggestionInput = ""; private completionGeneration = 0; + private historyQuery?: string; + private historyQueryAbort?: AbortController; + private historyResults: HistoryEntry[] = []; private context: PromptContext = {cwd: process.cwd(), project: '…', exitStatus: 0}; private readonly commandContexts = new CommandContextCache(() => this.render()); private promptConfiguration: PromptConfiguration = loadPromptConfiguration(); @@ -162,7 +166,7 @@ export class TerminalApp { /** Terminal modes the running command has set, for handing the terminal to it mid-command. */ private readonly commandModes = new AlternateScreenTracker(); private settingsPanelState?: SettingsPanelState; - private running?: {command: string; startedAt: number; interrupted: boolean; cleared: boolean; startId: number; cwd: string}; + private running?: {command: string; startedAt: number; interrupted: boolean; cleared: boolean; startId: number; cwd: string; historyAllowed?: number}; private hoveredLineIndex?: number; private focusedLineIndex?: number; private focusedActivityId?: string; @@ -219,7 +223,7 @@ export class TerminalApp { }); this.session.on('data', (data, stamp) => { if (this.inStream(stamp)) this.onShellData(data); }); this.session.on('prompt', (marker, stamp) => { if (this.inStream(stamp)) this.onShellPrompt(marker.exitCode, marker.cwd, stamp.at); }); - this.session.on('exec', (command, stamp) => { if (this.inStream(stamp)) this.onShellExec(command, stamp.at); }); + this.session.on('exec', (command, stamp) => { if (this.inStream(stamp)) this.onShellExec(command, stamp.at, stamp.historyAllowed); }); this.session.on('replayed', summary => this.finishReplay(summary)); this.session.on('exit', event => { this.shellEnded = true; @@ -285,7 +289,7 @@ export class TerminalApp { if (running) { this.output.resumeActive(running.command, running.startId, running.outputStartId, mode => this.onActiveModeChange(mode)); this.running = {command: running.command, startedAt: running.startedAt, interrupted: false, cleared: false, - startId: running.startId, cwd: running.cwd}; + startId: running.startId, cwd: running.cwd, historyAllowed: running.historyAllowed}; } } } @@ -341,13 +345,13 @@ export class TerminalApp { * zsh started a command line this frontend did not submit (it began while * detached, or came from type-ahead): give it its own transcript block. */ - private onShellExec(command: string, at = Date.now()): void { - if (this.running) return; + private onShellExec(command: string, at = Date.now(), historyAllowed?: number): void { + if (this.running) { this.running.historyAllowed = historyAllowed; return; } 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.tapActivityObserver.reset(this.output.activeOutputStartId ?? startId); - this.running = {command, startedAt: at, interrupted: false, cleared: false, startId, cwd: this.shellCwd}; + this.running = {command, startedAt: at, interrupted: false, cleared: false, startId, cwd: this.shellCwd, historyAllowed}; this.scheduleJournal(); if (!this.replaying) this.render(); } @@ -364,7 +368,7 @@ export class TerminalApp { const outputStartId = this.output.activeOutputStartId; return {sessionId: this.sessionId, seq: this.streamSeq, ...(running && outputStartId !== undefined ? {running: {command: running.command, startedAt: running.startedAt, - cwd: running.cwd, startId: running.startId, outputStartId}} : {})}; + cwd: running.cwd, startId: running.startId, outputStartId, historyAllowed: running.historyAllowed}} : {})}; } async run(): Promise { @@ -726,6 +730,15 @@ export class TerminalApp { this.historyViewport.latest(); } + if (key.kind === 'historyDelete' && this.historySearchActive) { + const entry = this.historyQuery === this.editor.text.substring(HISTORY_SEARCH.length) ? this.historyResults[this.selectedSuggestion] : undefined; + if (entry) void this.historyService.index.delete(entry.id).then(() => { + this.historyQuery = undefined; + this.historyResults = []; + this.render(); + }).catch(() => { this.output.addFrontendInteraction('/history', 'Could not persist history deletion.', ERROR); this.render(); }); + return; + } if (key.kind === 'historySearch') { if (!this.running) { this.editor.clear(); @@ -944,6 +957,11 @@ export class TerminalApp { private async fetchSuggestions(): Promise { const input = this.editor.text; + if (!this.historySearchActive && this.historyQuery !== undefined) { + this.historyQueryAbort?.abort(); + this.historyQuery = undefined; + this.historyResults = []; + } const cwd = this.context.cwd; const eligible = !this.running && !this.settingsPanelActive && !this.editor.hasPasteAtoms && !input.startsWith('/') && Boolean(input.trim()); const key = eligible ? JSON.stringify([input, cwd]) : ''; @@ -1448,6 +1466,13 @@ export class TerminalApp { const elapsed = completedAt.getTime() - command.startedAt; this.output.setActiveActivities(this.tapActivityObserver.finish(completedAt.getTime())); const completedRecord = this.output.complete(exitCode); + if (completedRecord) { + completedRecord.startedAt = command.startedAt; + completedRecord.durationMs = Math.max(0, elapsed); + completedRecord.historyEligible = command.historyAllowed === 1 && !isPrivateCommand(command.command, ignorePatternFromEnv()); + this.historyService.record(completedRecord, this.journal?.id ?? this.sessionId ?? 'current'); + this.historyQuery = undefined; + } this.suggestions.record({command: command.command, cwd: command.cwd, exitCode, at: command.startedAt, previous: this.submittedCommands[0]}); this.submittedCommands.unshift(command.command); if (this.submittedCommands.length > 50) this.submittedCommands.length = 50; @@ -2163,7 +2188,12 @@ export class TerminalApp { /** History loads in the background after startup; suggestions refine once it is indexed. */ private async loadHistory(): Promise { - await this.historyService.reload(); + try { await this.historyService.reload(); } catch { + if (this.stopped) return; + this.output.addFrontendInteraction('/history', 'Command history is unavailable; check local storage.', ERROR); + return; + } + this.historyQuery = undefined; if (this.stopped) return; await this.nativeSuggestions.loadInChunks(this.historyService.getEntries()); if (this.stopped) return; @@ -2551,10 +2581,23 @@ export class TerminalApp { return !this.editor.hasPasteAtoms && this.editor.text.startsWith(HISTORY_SEARCH); } - private historyMatches(query: string): Array<{name: string; insertion: string; description: string}> { - const q = query.toLowerCase(); - return this.historyService.getAll().filter(h => h.toLowerCase().includes(q)) - .slice(0, 100).map(m => ({name: m, insertion: m, description: 'History'})); + private historyMatches(query: string): Array<{id: string; name: string; insertion: string; description: string}> { + if (query !== this.historyQuery) { + this.historyQuery = query; + this.historyQueryAbort?.abort(); + const active = new AbortController(); + this.historyQueryAbort = active; + this.historyResults = []; + void this.historyService.search(query, active.signal).then(entries => { + if (this.stopped || active.signal.aborted || this.historyQuery !== query) return; + this.historyResults = entries; + this.selectedSuggestion = 0; + this.render(); + }).catch(() => {}); + } + return this.historyResults.map(entry => ({id: entry.id, name: entry.command.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '), insertion: entry.command, + description: [entry.exitCode === undefined ? undefined : `exit ${entry.exitCode}`, + entry.durationMs === undefined ? undefined : `${entry.durationMs}ms`, entry.cwd, entry.project].filter(Boolean).join(' · ') || 'History'})); } /** Composer suggestion rows for the current editor state; the same list render paints and geometry counts. */ @@ -2818,6 +2861,8 @@ export class TerminalApp { process.stdin.pause(); this.renderer.leave(); this.completionService.cancel(); + this.historyQueryAbort?.abort(); + this.historyService.dispose(); this.semanticService.kill(); this.finish(exitCode); } diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index cd64c7aa..f8320dc9 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -12,6 +12,12 @@ export function helpMarkdown(): AuthoredMarkdown { | --- | --- | ${commands} +## Command history + +Use /history with plain text or combine cwd:, project:, exit:, before:, after:, session: and duration: filters. Quote filter values containing spaces. Example: /history cwd:/work exit:failure duration:>1s git + +Enter or Tab restores the selected command without executing it. Ctrl+X removes the selected record from NMSh search. Session transcripts and the original zsh/Atuin history remain intact; deletion is remembered locally. + ## Tips - A large multiline paste is **one editable atom**; Enter submits its original text. Press Ctrl+O beside it to inspect or unwrap. diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 78fb9b47..cb2741d8 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -32,6 +32,10 @@ export interface SecondaryActivity { } export interface CompletedCommand { + /** Only explicitly eligible commands enter command history; transcript retention is separate. */ + historyEligible?: boolean; + startedAt?: number; + durationMs?: number; command: string; output: string; lifecycleText: string; diff --git a/src/session/InProcessSessionClient.ts b/src/session/InProcessSessionClient.ts index b0324051..4e5518cc 100644 --- a/src/session/InProcessSessionClient.ts +++ b/src/session/InProcessSessionClient.ts @@ -14,7 +14,7 @@ export class InProcessSessionClient extends EventEmitter im this.shell = factory(options); this.shell.on('data', data => this.emit('data', data, NO_STAMP)); this.shell.on('prompt', marker => this.emit('prompt', marker, NO_STAMP)); - this.shell.on('exec', command => this.emit('exec', command, NO_STAMP)); + this.shell.on('exec', (command, historyAllowed) => this.emit('exec', command, historyAllowed === undefined ? NO_STAMP : {historyAllowed})); this.shell.on('exit', event => this.emit('exit', event)); } diff --git a/src/session/SessionClient.ts b/src/session/SessionClient.ts index 9266db91..b290b70a 100644 --- a/src/session/SessionClient.ts +++ b/src/session/SessionClient.ts @@ -4,6 +4,8 @@ import type {TranscriptSession} from '../sessions/TranscriptStore.js'; /** Position of an event in a service session's stream, when it has one. */ export interface StreamStamp { + /** Explicit zsh history eligibility; absent from older services. */ + historyAllowed?: number; seq?: number; /** When the service observed the event (epoch ms). */ at?: number; diff --git a/src/session/SessionProtocol.ts b/src/session/SessionProtocol.ts index 71a35568..9a33c4d2 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -41,7 +41,7 @@ export type ServerMessage = * missed exactly once with the original timing. */ | {type: 'output'; data: string; seq?: number; at?: number} - | {type: 'exec'; command: string; seq: number; at: number} + | {type: 'exec'; command: string; seq: number; at: number; historyAllowed?: number} | {type: 'prompt'; exitCode: number; cwd: string; seq?: number; at?: number} /** End of the backlog sent after attach. */ | {type: 'replayed'; truncatedBytes: number} @@ -112,7 +112,7 @@ const SHAPES: Record = { detached: {sessionId: 'string'}, sessions: {sessions: 'sessions'}, output: {data: 'string', seq: 'int?', at: 'int?'}, - exec: {command: 'string', seq: 'int', at: 'int'}, + exec: {command: 'string', seq: 'int', at: 'int', historyAllowed: 'int?'}, prompt: {exitCode: 'int', cwd: 'string', seq: 'int?', at: 'int?'}, replayed: {truncatedBytes: 'int'}, killed: {sessionId: 'string'}, diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index 568e8b6e..ccd42536 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -58,7 +58,8 @@ export const DEFAULT_MAX_SESSIONS = 16; function toMessage(event: BacklogEvent): ServerMessage { switch (event.kind) { case 'output': return {type: 'output', data: event.data, seq: event.seq, at: event.at}; - case 'exec': return {type: 'exec', command: event.command, seq: event.seq, at: event.at}; + case 'exec': return {type: 'exec', command: event.command, seq: event.seq, at: event.at, + ...(event.historyAllowed === undefined ? {} : {historyAllowed: event.historyAllowed})}; case 'prompt': return {type: 'prompt', exitCode: event.exitCode, cwd: event.cwd, seq: event.seq, at: event.at}; } } @@ -302,11 +303,11 @@ export class SessionService { if (kept) emit({kind: 'output', seq: ++session.seq, at, data: kept}, {type: 'output', data, seq: session.seq, at}); else session.controller?.({type: 'output', data}); }); - shell.on('exec', command => { + shell.on('exec', (command, historyAllowed) => { const at = Date.now(); session.running = {command, since: at}; session.evidence.onExec(); - emit({kind: 'exec', seq: ++session.seq, at, command}); + emit({kind: 'exec', seq: ++session.seq, at, command, ...(historyAllowed === undefined ? {} : {historyAllowed})}); }); shell.on('prompt', marker => { record.cwd = marker.cwd; diff --git a/src/session/SocketSessionClient.ts b/src/session/SocketSessionClient.ts index d93eedc9..22c52f58 100644 --- a/src/session/SocketSessionClient.ts +++ b/src/session/SocketSessionClient.ts @@ -155,7 +155,7 @@ export class SocketSessionClient extends EventEmitter imple private receive(message: ServerMessage): void { if (message.type === 'output') this.emit('data', message.data, {seq: message.seq, at: message.at}); else if (message.type === 'prompt') this.emit('prompt', {exitCode: message.exitCode, cwd: message.cwd}, {seq: message.seq, at: message.at}); - else if (message.type === 'exec') this.emit('exec', message.command, {seq: message.seq, at: message.at}); + else if (message.type === 'exec') this.emit('exec', message.command, {seq: message.seq, at: message.at, historyAllowed: message.historyAllowed}); else if (message.type === 'replayed') this.emit('replayed', {truncatedBytes: message.truncatedBytes}); else if (message.type === 'exit') { this.finish(message.exitCode, message.signal); diff --git a/src/session/StreamBacklog.ts b/src/session/StreamBacklog.ts index f02e8a49..bc76fabf 100644 --- a/src/session/StreamBacklog.ts +++ b/src/session/StreamBacklog.ts @@ -4,7 +4,7 @@ import {dirname} from 'node:path'; /** One shell stream event as retained for replay. */ export type BacklogEvent = | {kind: 'output'; seq: number; at: number; data: string} - | {kind: 'exec'; seq: number; at: number; command: string} + | {kind: 'exec'; seq: number; at: number; command: string; historyAllowed?: number} | {kind: 'prompt'; seq: number; at: number; exitCode: number; cwd: string}; /** Non-event spool records: journal acknowledgements, truncation and the shell's end. */ diff --git a/src/sessions/TranscriptStore.ts b/src/sessions/TranscriptStore.ts index c08a0051..f3c06cb3 100644 --- a/src/sessions/TranscriptStore.ts +++ b/src/sessions/TranscriptStore.ts @@ -42,7 +42,7 @@ export interface LiveLink { /** Last shell stream event reflected in this transcript. */ seq: number; /** The command in flight when this checkpoint was taken. */ - running?: {command: string; startedAt: number; cwd: string; startId: number; outputStartId: number}; + running?: {command: string; startedAt: number; cwd: string; startId: number; outputStartId: number; historyAllowed?: number}; } function parseLive(value: unknown): LiveLink | undefined { @@ -81,7 +81,10 @@ function isTranscript(value: unknown): value is OutputTranscript { && (transcript.welcome.captured === undefined || (Array.isArray(transcript.welcome.captured) && transcript.welcome.captured.every(line => typeof line === 'string'))))) && Array.isArray(transcript.records) - && transcript.records.every(record => record && typeof record.command === 'string' + && transcript.records.every(record => record && (record.historyEligible === undefined || typeof record.historyEligible === 'boolean') + && (record.startedAt === undefined || Number.isFinite(record.startedAt)) + && (record.durationMs === undefined || (Number.isFinite(record.durationMs) && record.durationMs >= 0)) + && typeof record.command === 'string' && typeof record.output === 'string' && typeof record.lifecycleText === 'string' && typeof record.exitCode === 'number' && typeof record.startId === 'number' && typeof record.outputStartId === 'number' diff --git a/src/sessions/archiveLive.ts b/src/sessions/archiveLive.ts index dd7d628f..d1caf824 100644 --- a/src/sessions/archiveLive.ts +++ b/src/sessions/archiveLive.ts @@ -35,19 +35,24 @@ export async function archiveLiveSession(options: ArchiveLiveOptions): Promise { if (!running) return; const record = output.complete(exitCode); + if (record) { + record.startedAt = running.startedAt; + record.durationMs = Math.max(0, at - running.startedAt); + record.historyEligible = running.historyAllowed === 1 && !/^\s/u.test(running.command); + } const parts = completedActivity(running.command, at - running.startedAt, new Date(at), interrupted ? 0 : exitCode, interrupted, extractFacts(running.command, record?.output ?? '')); output.setCompletionLifecycle(`${parts.main}${parts.detail}`); @@ -59,7 +64,7 @@ export async function archiveLiveSession(options: ArchiveLiveOptions): Promise boolean { + const predicates: Array<(entry: HistoryEntry) => boolean> = []; + const plain: string[] = []; + for (const token of query.match(/(?:[^\s"]+|"[^"]*")+/gu) ?? []) { + const text = token.replace(/"/gu, ''); + const match = /^(cwd|project|exit|before|after|session|duration):(.*)$/u.exec(text); + if (!match) { plain.push(text.toLowerCase()); continue; } + const key = match[1]!; + const value = match[2]!; + if (!value) { predicates.push(() => false); continue; } + if (key === 'cwd') predicates.push(entry => entry.cwd === value || Boolean(entry.cwd?.startsWith(`${value.replace(/\/$/u, '')}/`))); + else if (key === 'project') predicates.push(entry => entry.project?.toLowerCase() === value.toLowerCase()); + else if (key === 'session') predicates.push(entry => Boolean(entry.session?.startsWith(value))); + else if (key === 'exit') predicates.push(entry => entry.exitCode !== undefined && (value === 'success' ? entry.exitCode === 0 : value === 'failure' ? entry.exitCode !== 0 : /^\d+$/u.test(value) && entry.exitCode === Number(value))); + else if (key === 'before' || key === 'after') { + const at = Date.parse(value); + predicates.push(entry => Number.isFinite(at) && entry.at !== undefined && (key === 'before' ? entry.at < at : entry.at > at)); + } else { + const comparison = /^(<=|>=|<|>|=)?(.*)$/u.exec(value)!; + const ms = duration(comparison[2]!); + predicates.push(entry => ms !== undefined && entry.durationMs !== undefined && + (comparison[1] === '<' ? entry.durationMs < ms : comparison[1] === '>' ? entry.durationMs > ms : + comparison[1] === '<=' ? entry.durationMs <= ms : comparison[1] === '>=' ? entry.durationMs >= ms : entry.durationMs === ms)); + } + } + return entry => predicates.every(predicate => predicate(entry)) && plain.every(term => entry.command.toLowerCase().includes(term)); +} + +/** Derived in-memory view; only deletion IDs persist, never a second copy of commands. */ +export class HistoryIndex { + private readonly entries = new Map(); + private deleted = new Set(); + private writes: Promise = Promise.resolve(); + private revision = 0; + private snapshot: HistoryEntry[] = []; + private snapshotRevision = -1; + + constructor(private readonly deletionFile?: string) {} + + async loadDeletions(): Promise { + if (!this.deletionFile) return; + try { + const data: unknown = JSON.parse(await readFile(this.deletionFile, 'utf8')); + if (!data || typeof data !== 'object' || !('version' in data) || data.version !== 1 || !('ids' in data) + || !Array.isArray(data.ids) || !data.ids.every(id => typeof id === 'string' && /^[a-f0-9]{64}$/u.test(id))) throw new Error('Invalid history deletion file'); + this.deleted = new Set(data.ids); + } catch (error) { + // Only absence is safe: an unreadable tombstone must not resurrect deleted commands. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + this.revision += 1; + } + + add(entry: HistoryEntry): void { + if (isPrivateCommand(entry.command) || this.deleted.has(entry.id)) return; + this.entries.set(entry.id, entry); + this.revision += 1; + } + + addJournal(session: TranscriptSession): void { + for (const record of session.transcript.records) { + const entry = journalHistory(record, session.id); + if (entry) this.add(entry); + } + } + + all(): readonly HistoryEntry[] { + if (this.snapshotRevision !== this.revision) { + this.snapshot = [...this.entries.values()].filter(entry => !this.deleted.has(entry.id)).sort((a, b) => (b.at ?? 0) - (a.at ?? 0)); + this.snapshotRevision = this.revision; + } + return this.snapshot; + } + + async search(query: string, signal?: AbortSignal, limit = 100): Promise { + const entries = this.all(); + const predicate = historyPredicate(query); + const result: HistoryEntry[] = []; + const count = Math.max(0, Math.min(500, limit)); + if (count === 0) return result; + for (let index = 0; index < entries.length; index += 1) { + if (signal?.aborted) return []; + const entry = entries[index]!; + if (predicate(entry)) result.push(entry); + if (result.length >= count) break; + if (index % 2048 === 2047) await new Promise(resolve => setImmediate(resolve)); + } + return signal?.aborted ? [] : result; + } + + async delete(id: string): Promise { + if (!this.entries.has(id)) return; + this.deleted.add(id); + this.revision += 1; + if (!this.deletionFile) return; + const file = this.deletionFile; + const data = JSON.stringify({version: 1, ids: [...this.deleted]}); + this.writes = this.writes.catch(() => {}).then(async () => { + await mkdir(dirname(file), {recursive: true, mode: 0o700}); + const temporary = `${file}.${randomUUID()}.tmp`; + await writeFile(temporary, data, {mode: 0o600}); + await rename(temporary, file); + }); + await this.writes; + } +} diff --git a/src/shell/HistoryService.ts b/src/shell/HistoryService.ts index bf7bab98..607630cf 100644 --- a/src/shell/HistoryService.ts +++ b/src/shell/HistoryService.ts @@ -2,6 +2,10 @@ import {readFile} from 'node:fs/promises'; import {homedir} from 'node:os'; import {join} from 'node:path'; import {findExecutable, runExternal} from '../providers/providers.js'; +import {HistoryIndex, historyId, journalHistory, type HistoryEntry} from './HistoryIndex.js'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import type {CompletedCommand} from '../output/OutputBuffer.js'; +import {Worker} from 'node:worker_threads'; import type {CommandEntry} from '../suggestions/types.js'; const ATUIN_FORMAT = '{time}\t{exit}\t{directory}\t{command}'; @@ -13,15 +17,53 @@ const ATUIN_FORMAT = '{time}\t{exit}\t{directory}\t{command}'; */ export class HistoryService { private entries: CommandEntry[] = []; + private worker?: Worker; + private readonly lifetime = new AbortController(); + readonly index = new HistoryIndex(join(nmshConfigDirectory(), 'history-deletions.json')); constructor(private readonly env: NodeJS.ProcessEnv = process.env) {} /** Loads (or reloads) history; callers start it once the app is running. */ + dispose(): void { this.lifetime.abort(); void this.worker?.terminate(); } + async reload(): Promise { + if (this.lifetime.signal.aborted) return; + await this.index.loadDeletions(); + await this.loadSource(); + this.entries.forEach((entry, index) => this.index.add({...entry, id: historyId('shell', `${entry.at ?? 0}:${entry.command}`), source: 'zsh'})); + await this.loadJournals(); + this.index.all(); + } + + private loadJournals(): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL('../../scripts/read-command-history.cjs', import.meta.url), + {workerData: {directory: join(nmshConfigDirectory(), 'sessions')}}); + this.worker = worker; + worker.on('message', (batch: {session: string; records: CompletedCommand[]}) => { + for (const record of batch.records) this.record(record, batch.session); + }); + worker.once('error', reject); + worker.once('exit', code => { + if (this.worker === worker) this.worker = undefined; + code === 0 ? resolve() : reject(new Error('History journal reader failed')); + }); + if (this.lifetime.signal.aborted) void worker.terminate(); + }); + } + + record(record: CompletedCommand, session: string): void { + const entry = journalHistory(record, session); + if (entry) this.index.add(entry); + } + + search(query: string, signal?: AbortSignal): Promise { return this.index.search(query, signal); } + + private async loadSource(): Promise { const atuin = findExecutable('atuin', this.env.PATH ?? ''); if (atuin) { const result = await runExternal(atuin, ['history', 'list', '--format', ATUIN_FORMAT, '--print0', '--timezone', '+0'], - {timeoutMs: 15000, maxBytes: 128 * 1024 * 1024, env: this.env}); + {timeoutMs: 15000, maxBytes: 128 * 1024 * 1024, env: this.env, signal: this.lifetime.signal}); if (result.ok) { this.entries = parseAtuinHistory(result.stdout); return; @@ -29,7 +71,7 @@ export class HistoryService { } try { const path = this.env.HISTFILE || join(homedir(), '.zsh_history'); - this.entries = parseZshHistory(await readFile(path)); + this.entries = await parseZshHistoryInChunks(await readFile(path)); } catch { this.entries = []; } @@ -73,19 +115,34 @@ export function unmetafy(bytes: Uint8Array): Buffer { } /** Plain or EXTENDED_HISTORY (`: start:elapsed;command`), with `\` continuation lines. */ -export function parseZshHistory(content: Uint8Array | string): CommandEntry[] { +function* zshHistoryEntries(content: Uint8Array | string): Generator { const text = typeof content === 'string' ? content : unmetafy(content).toString('utf8'); - const entries: CommandEntry[] = []; let current: CommandEntry | undefined; for (const line of text.split('\n')) { if (current && current.command.endsWith('\\')) { current.command = `${current.command.slice(0, -1)}\n${line}`; continue; } + if (current?.command.trim()) yield current; const match = /^: *(\d+):\d+;(.*)$/u.exec(line); current = match ? {command: match[2]!, at: Number(match[1]) * 1000} : line.trim() ? {command: line} : undefined; - if (current) entries.push(current); } - for (const entry of entries) if (entry.command.endsWith('\\')) entry.command = entry.command.slice(0, -1); - return entries.filter(entry => entry.command.trim()); + if (current) { + if (current.command.endsWith('\\')) current.command = current.command.slice(0, -1); + if (current.command.trim()) yield current; + } +} + +export function parseZshHistory(content: Uint8Array | string): CommandEntry[] { + return [...zshHistoryEntries(content)]; +} + +/** Import yields between batches; UI rendering never waits for the complete history. */ +export async function parseZshHistoryInChunks(content: Uint8Array | string): Promise { + const entries: CommandEntry[] = []; + for (const entry of zshHistoryEntries(content)) { + entries.push(entry); + if (entries.length % 2048 === 0) await new Promise(resolve => setImmediate(resolve)); + } + return entries; } diff --git a/src/shell/ShellProtocol.ts b/src/shell/ShellProtocol.ts index 00c54e31..17393ee5 100644 --- a/src/shell/ShellProtocol.ts +++ b/src/shell/ShellProtocol.ts @@ -7,7 +7,7 @@ export type ProtocolEvent = | {kind: 'data'; data: string} | {kind: 'marker'; marker: ShellMarker} /** zsh preexec: a command line is about to run in the foreground. */ - | {kind: 'exec'; command: string}; + | {kind: 'exec'; command: string; historyAllowed?: number}; export class ShellProtocolDecoder { private readonly prefix: string; @@ -40,6 +40,10 @@ export class ShellProtocolDecoder { if (end === -1) break; const payload = this.buffered.slice(this.prefix.length, end); this.buffered = this.buffered.slice(end + 1); + if (/^exec2;[01];/u.test(payload)) { + events.push({kind: 'exec', command: payload.slice(8), historyAllowed: Number(payload[6])}); + continue; + } if (payload.startsWith('exec;')) { events.push({kind: 'exec', command: payload.slice(5)}); continue; diff --git a/src/shell/ShellSession.ts b/src/shell/ShellSession.ts index a7e58f73..c0bc4601 100644 --- a/src/shell/ShellSession.ts +++ b/src/shell/ShellSession.ts @@ -9,7 +9,7 @@ import { ShellProtocolDecoder, type ShellMarker } from './ShellProtocol.js'; interface SessionEvents { data: [string]; prompt: [ShellMarker]; - exec: [string]; + exec: [string, number?]; exit: [{ exitCode: number; signal?: number }]; } @@ -100,7 +100,9 @@ function nmsh_precmd { function nmsh_preexec { nmsh_tty_echo echo - printf '\\e]777;nmsh;${token};exec;%s\\a' "\${1//[[:cntrl:]]/ }" + local nmsh_history_allowed=1 + [[ -n \$HISTORY_IGNORE && \$1 == \${~HISTORY_IGNORE} ]] && nmsh_history_allowed=0 + printf '\\e]777;nmsh;${token};exec2;%d;%s\\a' "\$nmsh_history_allowed" "\${1//[[:cntrl:]]/ }" } # Compose with whatever the user's config/plugins already installed instead @@ -201,7 +203,7 @@ add-zsh-hook preexec nmsh_preexec if (event.kind === 'data') { if (this.ready) this.emit('data', event.data); } else if (event.kind === 'exec') { - if (this.ready) this.emit('exec', event.command); + if (this.ready) this.emit('exec', event.command, event.historyAllowed); } else if (!this.ready) { this.ready = true; this.emit('prompt', event.marker); diff --git a/src/terminal/keys.ts b/src/terminal/keys.ts index 3e3e22b8..30486487 100644 --- a/src/terminal/keys.ts +++ b/src/terminal/keys.ts @@ -5,7 +5,7 @@ export type Key = | {kind: 'left' | 'right' | 'up' | 'down' | 'lineHome' | 'lineEnd' | 'backspace' | 'delete' | 'enter' | 'newline' | 'complete' | 'escape' | 'selectAll'} | {kind: 'selectLeft' | 'selectRight' | 'selectUp' | 'selectDown' | 'selectLineHome' | 'selectLineEnd'} | {kind: 'bufferHome' | 'bufferEnd' | 'selectBufferHome' | 'selectBufferEnd'} - | {kind: 'historySearch'} | {kind: 'suggestNext' | 'suggestPrevious' | 'palette'} | {kind: 'pageUp' | 'pageDown' | 'latest' | 'interrupt' | 'suspend' | 'eof' | 'wheelUp' | 'wheelDown' | 'mouseMove' | 'mouseClick' | 'focusPrevious' | 'focusNext' | 'toggleDetails'} & {x?: number; y?: number}; + | {kind: 'historySearch' | 'historyDelete'} | {kind: 'suggestNext' | 'suggestPrevious' | 'palette'} | {kind: 'pageUp' | 'pageDown' | 'latest' | 'interrupt' | 'suspend' | 'eof' | 'wheelUp' | 'wheelDown' | 'mouseMove' | 'mouseClick' | 'focusPrevious' | 'focusNext' | 'toggleDetails'} & {x?: number; y?: number}; const SEQUENCES: Array<[string, Key['kind']]> = [ @@ -80,6 +80,7 @@ const SEQUENCES: Array<[string, Key['kind']]> = [ ['\u001BOB', 'down'], ['\u001B[97;5u', 'lineHome'], // Kitty Ctrl+A ['\u001B[101;5u', 'lineEnd'], // Kitty Ctrl+E + ['\u001B[120;5u', 'historyDelete'], // Ctrl+X: only active in command history ['\u001B[119;5u', 'deleteWord'], // Kitty Ctrl+W ['\u001B[117;5u', 'deleteLineBefore'], // Kitty Ctrl+U ['\u001B[107;5u', 'deleteLineAfter'], // Kitty Ctrl+K @@ -157,6 +158,7 @@ export function decodeKeys(input: string): Key[] { else if (value === '\u0001') keys.push({kind: 'lineHome'} as Key); // Ctrl+A else if (value === '\u0005') keys.push({kind: 'lineEnd'} as Key); // Ctrl+E else if (value === '\u001B') keys.push({kind: 'escape'} as Key); + else if (value === '\u0018') keys.push({kind: 'historyDelete'} as Key); else if (value === '\u0012') keys.push({kind: 'historySearch'} as Key); else if (value === '\u000F') keys.push({kind: 'toggleDetails'} as Key); // Ctrl+O else if (value === '\u000E') keys.push({kind: 'suggestNext'} as Key); // Ctrl+N diff --git a/tests/historySearch.test.ts b/tests/historySearch.test.ts index 2d770d21..37bcaf1d 100644 --- a/tests/historySearch.test.ts +++ b/tests/historySearch.test.ts @@ -19,7 +19,7 @@ function historyApp(): {app: TerminalApp; leaked: string[]} { const leaked: string[] = []; Object.defineProperty(app, 'render', {value: () => {}}); Object.defineProperty(app, 'dimensions', {value: () => ({columns: 80, rows: 24})}); - app['historyService'].getAll = () => ['git status', 'ls -la', 'git log']; + app['historyService'].search = async query => ['git status', 'ls -la', 'git log'].filter(command => command.includes(query)).map((command, index) => ({id: String(index), command, source: 'zsh'})); app['session'].submit = ((data: string) => { leaked.push(data); }) as never; app['session'].write = ((data: string) => { leaked.push(data); }) as never; return {app, leaked}; @@ -39,6 +39,8 @@ test('bare /history enters the same search state as the slash suggestion', async app['editor'].insert('/history'); await enter(app); assert.equal(app['editor'].text, '/history '); + app['composerSuggestions'](); + await new Promise(resolve => setImmediate(resolve)); assert.deepEqual(app['composerSuggestions']().map((s: {name: string}) => s.name), ['git status', 'ls -la', 'git log']); assert.deepEqual(leaked, [], 'nothing reaches zsh'); assert.doesNotMatch(transcriptText(app), /Unknown NMSh command|\/history/u, 'no transcript entry'); @@ -52,6 +54,8 @@ test('/history query filters, arrows select, and Enter restores the match withou const {app, leaked} = historyApp(); try { app['editor'].insert('/history git'); + app['composerSuggestions'](); + await new Promise(resolve => setImmediate(resolve)); assert.deepEqual(app['composerSuggestions']().map((s: {name: string}) => s.name), ['git status', 'git log']); app['handleKey']({kind: 'down'}); await enter(app); @@ -64,10 +68,12 @@ test('/history query filters, arrows select, and Enter restores the match withou } }); -test('Tab applies the selected history match', () => { +test('Tab applies the selected history match', async () => { const {app} = historyApp(); try { app['editor'].insert('/history ls'); + app['composerSuggestions'](); + await new Promise(resolve => setImmediate(resolve)); app['handleKey']({kind: 'complete'}); assert.equal(app['editor'].text, 'ls -la'); } finally { diff --git a/tests/structuredHistory.test.ts b/tests/structuredHistory.test.ts new file mode 100644 index 00000000..0da0fcb9 --- /dev/null +++ b/tests/structuredHistory.test.ts @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {Worker} from 'node:worker_threads'; +import {parseZshHistory, parseZshHistoryInChunks} from '../src/shell/HistoryService.js'; +import {once} from 'node:events'; +import {HistoryIndex, journalHistory, historyId, type HistoryEntry} from '../src/shell/HistoryIndex.js'; +import {ShellProtocolDecoder} from '../src/shell/ShellProtocol.js'; +import {ShellSession} from '../src/shell/ShellSession.js'; +import {encodeMessage, decodeMessage} from '../src/session/SessionProtocol.js'; +import {decodeKeys} from '../src/terminal/keys.js'; + +const entry: HistoryEntry = {id: historyId('test', '1'), command: 'git status', cwd: '/work/app', project: 'app', + exitCode: 1, at: Date.UTC(2026, 0, 2), durationMs: 1500, session: 'session-abc', source: 'nmsh'}; + +test('structured history filters combine, text remains searchable, malformed filters fail closed', async () => { + const index = new HistoryIndex(); index.add(entry); + for (const query of ['git', 'cwd:/work project:app exit:failure git', 'before:2026-01-03 after:2026-01-01 session:session duration:>1s', 'exit:1 duration:<=2s']) { + assert.equal((await index.search(query)).length, 1, query); + } + for (const query of ['exit:success', 'before:bad', 'duration:bad', 'project:', 'cwd:/work/other', 'exit:127']) assert.deepEqual(await index.search(query), [], query); + index.add({...entry, id: historyId('test', '2'), cwd: '/work/my app'}); + assert.equal((await index.search('cwd:"/work/my app"')).length, 1); +}); + +test('journal history requires explicit shell eligibility and excludes leading spaces', () => { + const record = {command: 'git status', output: '', lifecycleText: '', exitCode: 0, startId: 1, outputStartId: 2, + startedAt: 100, durationMs: 200, historicalContext: {cwd: '/work', project: 'work'}}; + assert.equal(journalHistory(record, 'session'), undefined, 'legacy journals are excluded'); + assert.equal(journalHistory({...record, historyEligible: false}, 'session'), undefined); + assert.equal(journalHistory({...record, historyEligible: true, command: ' secret'}, 'session'), undefined); + assert.deepEqual(journalHistory({...record, historyEligible: true}, 'session'), {id: historyId('nmsh', 'session:1'), source: 'nmsh', + session: 'session', command: 'git status', cwd: '/work', project: 'work', exitCode: 0, at: 100, durationMs: 200}); +}); + +test('deletion survives reload without storing commands; corrupt deletion metadata fails closed', async () => { + const directory = await mkdtemp(join(tmpdir(), 'nmsh-history-index-')); + const file = join(directory, 'deletions.json'); + try { + const index = new HistoryIndex(file); await index.loadDeletions(); index.add(entry); await index.delete(entry.id); + assert.deepEqual(await index.search(''), []); + assert.doesNotMatch(await readFile(file, 'utf8'), /git status|\/work/u); + const restored = new HistoryIndex(file); await restored.loadDeletions(); restored.add(entry); + assert.deepEqual(await restored.search(''), []); + await writeFile(file, '{broken'); + await assert.rejects(new HistoryIndex(file).loadDeletions()); + } finally { await rm(directory, {recursive: true, force: true}); } +}); + +test('100k queries yield to input, are bounded and cancel stale scans', async () => { + const index = new HistoryIndex(); + for (let i = 0; i < 100_000; i += 1) index.add({...entry, id: String(i), command: `command ${i}`, at: i}); + index.all(); + assert.equal((await index.search('command')).length, 100); + const active = new AbortController(); + const pending = index.search('missing', active.signal); + active.abort(); assert.deepEqual(await pending, []); + let yielded = false; + setImmediate(() => { yielded = true; }); + await index.search('missing'); + assert.equal(yielded, true); +}); + +test('eligibility markers survive chunked shell decoding and current IPC, old exec stays unknown', () => { + const decoder = new ShellProtocolDecoder('token'); + assert.deepEqual(decoder.push('\u001b]777;nmsh;token;exec2;'), []); + assert.deepEqual(decoder.push('0;echo secret\u0007'), [{kind: 'exec', command: 'echo secret', historyAllowed: 0}]); + const message = {type: 'exec' as const, command: 'echo secret', historyAllowed: 0, seq: 1, at: 100}; + assert.deepEqual(decodeMessage(encodeMessage(message)), {ok: true, message}); + assert.deepEqual(decoder.push('\u001b]777;nmsh;token;exec;legacy\u0007'), [{kind: 'exec', command: 'legacy'}]); + assert.equal(decodeKeys('\u0018')[0]!.kind, 'historyDelete'); +}); + +test('real zsh reports unexported HISTORY_IGNORE without changing hooks or executing probes', {timeout: 10000}, async () => { + const home = await mkdtemp(join(tmpdir(), 'nmsh-history-shell-')); + await writeFile(join(home, '.zshrc'), "HISTORY_IGNORE='(echo secret|pwd)'\n"); + const shell = new ShellSession(home, 80, 24, home, {...process.env, HOME: home}); + const flags: Array = []; + shell.on('exec', (_command, allowed) => { flags.push(allowed); }); + try { + await once(shell, 'prompt'); + for (const command of ['echo public', 'echo secret', 'pwd']) { + const done = once(shell, 'prompt'); shell.submit(command); await done; + } + assert.deepEqual(flags, [1, 0, 0]); + } finally { shell.kill(); await rm(home, {recursive: true, force: true}); } +}); + + +test('chunked import preserves multiline/metafied parsing and worker reads only eligible journals', async () => { + const content = ': 100:2;echo hello\\\nworld\n: 101:0;pwd\n'; + assert.deepEqual(await parseZshHistoryInChunks(content), parseZshHistory(content)); + const directory = await mkdtemp(join(tmpdir(), 'nmsh-history-worker-')); + try { + await writeFile(join(directory, 'session.json'), JSON.stringify({schemaVersion: 1, id: 'session', transcript: {records: [ + {command: 'old', startId: 1, exitCode: 0}, + {command: 'secret', historyEligible: false, startId: 2, exitCode: 0}, + {command: 'public', historyEligible: true, startId: 3, exitCode: 0, output: 'raw output'}, + ]}})); + const worker = new Worker(new URL('../scripts/read-command-history.cjs', import.meta.url), {workerData: {directory}}); + const batches: Array<{records: Array<{command: string}>}> = []; + worker.on('message', batch => batches.push(batch)); + await once(worker, 'exit'); + assert.deepEqual(batches.flatMap(batch => batch.records).map(record => record.command), ['public']); + assert.doesNotMatch(JSON.stringify(batches), /raw output|secret/u); + } finally { await rm(directory, {recursive: true, force: true}); } +}); From 9c84118c322e061c5f7c2ea4a19ed8c80c49c211 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:29:41 +0530 Subject: [PATCH 07/82] Make Native and local Atuin history explicit providers --- src/app/TerminalApp.ts | 17 ++++-- src/prompt/configuration.ts | 9 ++- src/providers/providers.ts | 2 +- src/shell/HistoryIndex.ts | 5 ++ src/shell/HistoryService.ts | 107 ++++++++++++++++++++++++--------- src/shell/historyProviders.ts | 8 +++ src/ui/SettingsPanel.ts | 5 +- tests/historyProviders.test.ts | 57 ++++++++++++++++++ 8 files changed, 173 insertions(+), 37 deletions(-) create mode 100644 src/shell/historyProviders.ts create mode 100644 tests/historyProviders.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index f96650d5..f4fa4eaa 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -19,6 +19,7 @@ import {SuggestionController} from '../suggestions/SuggestionController.js'; import {createPalette, handlePaletteKey, renderPalette, type PaletteItem, type PaletteState} from '../ui/CommandPalette.js'; import {NativeSuggestions} from '../suggestions/NativeSuggestions.js'; import {DejaSuggestions} from '../suggestions/DejaSuggestions.js'; +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'; @@ -2104,7 +2105,7 @@ export class TerminalApp { else if (destination === 'transcript') this.startTranscriptSettings(); else if (destination === 'syntax') this.startSyntaxSettings(); else if (destination === 'layout') this.startLayoutSettings(); - else if (destination === 'welcome' || destination === 'suggestions') this.startProviderPanel(destination); + else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history') this.startProviderPanel(destination); else void this.startKeyboard(); } @@ -2139,6 +2140,7 @@ export class TerminalApp { {label: 'Composer', value: layoutLabel(config)}, {label: 'Glyph style', value: config.glyphStyle === 'nerd' ? 'Nerd Font' : 'Safe / ASCII'}, {label: 'Syntax', value: !config.syntax.highlighting ? 'Off' : config.syntax.colors === 'followPrompt' ? 'Follow prompt theme' : config.syntax.colors === 'theme' ? 'Choose theme' : 'Grayscale'}, + {label: 'Command history', value: this.historyService.status.detail ?? (this.historyService.status.active === 'atuin' ? 'Atuin · local read-only' : 'NMSh Native')}, {label: 'History colors', value: config.transcript.historyColors === 'followPrompt' ? 'Follow prompt' : config.transcript.historyColors === 'theme' ? 'Theme' : 'Grayscale'}, ], [ @@ -2188,7 +2190,10 @@ export class TerminalApp { /** History loads in the background after startup; suggestions refine once it is indexed. */ private async loadHistory(): Promise { - try { await this.historyService.reload(); } catch { + this.historyQuery = undefined; + this.historyQueryAbort?.abort(); + this.historyResults = []; + try { if (!await this.historyService.reload(this.promptConfiguration.history)) return; } catch { if (this.stopped) return; this.output.addFrontendInteraction('/history', 'Command history is unavailable; check local storage.', ERROR); return; @@ -2267,9 +2272,10 @@ export class TerminalApp { }); } - private startProviderPanel(family: 'welcome' | 'suggestions'): void { + private startProviderPanel(family: 'welcome' | 'suggestions' | 'history'): void { const state: ProviderPanelState = family === 'welcome' ? createProviderPanel(family, 'Welcome', WELCOME_PROVIDERS, this.promptConfiguration.welcome) + : family === 'history' ? createProviderPanel(family, 'Command history', HISTORY_PROVIDERS, this.promptConfiguration.history) : createProviderPanel(family, 'Suggestions', SUGGESTION_PROVIDERS, this.promptConfiguration.suggestions); this.providerPanelState = state; this.welcomePreviews.clear(); @@ -2284,6 +2290,7 @@ export class TerminalApp { /** The highlighted provider rendered by its own family; captures are cached per panel. */ private providerPreview(state: ProviderPanelState, width: number): string[] { const selected = providerPanelSelection(state); + if (state.family === 'history') return [`${SUBTLE}${selected.id === 'atuin' ? 'Read-only local history; existing hooks unchanged; no sync.' : 'Shell-approved journal metadata and imported zsh history.'}${RESET}`]; if (state.family === 'suggestions') { if (selected.id === 'none') return [`${SUBTLE}No ghost text while typing.${RESET}`]; return [`${ACCENT}${GLYPHS.prompt}${RESET} git st${SECONDARY}atus${RESET} ${SUBTLE}→ / End accept · Alt+→ next word · Ctrl+N/P alternatives · Esc dismiss${RESET}`]; @@ -2335,15 +2342,17 @@ export class TerminalApp { const selected = providerPanelSelection(state); const next = state.family === 'welcome' ? {...structuredClone(this.promptConfiguration), welcome: selected.id as PromptConfiguration['welcome']} + : state.family === 'history' ? {...structuredClone(this.promptConfiguration), history: selected.id as PromptConfiguration['history']} : {...structuredClone(this.promptConfiguration), suggestions: selected.id as PromptConfiguration['suggestions']}; try { savePromptConfiguration(next); this.promptConfiguration = next; this.providerPanelState = undefined; if (state.family === 'suggestions') this.applySuggestionProvider(); + if (state.family === 'history') void this.loadHistory(); this.output.addHistoryLine(state.family === 'welcome' ? `${SUCCESS}Welcome · ${selected.label} · shown on launch and /clear.${RESET}` - : `${SUCCESS}Suggestions · ${selected.label}.${RESET}`); + : `${SUCCESS}${state.title} · ${selected.label}.${RESET}`); } catch (error) { state.message = `Could not save: ${error instanceof Error ? error.message : String(error)}`; } diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 4dadbb5e..a29cb6d8 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -8,6 +8,7 @@ export type LiveSessionStartup = typeof LIVE_SESSION_STARTUP[number]; export const LIVE_SESSION_MULTIPLE = ['ask', 'open-all'] as const; export type LiveSessionMultiple = typeof LIVE_SESSION_MULTIPLE[number]; import type {OutputFoldingMode} from '../output/FoldPolicy.js'; +import type {HistoryProviderId} from '../shell/historyProviders.js'; import {SUGGESTION_PROVIDER_IDS, type SuggestionProviderId} from '../suggestions/types.js'; import { normalizeConnectorFadeColors, @@ -185,6 +186,8 @@ export interface PromptConfiguration { welcome: WelcomeProviderId; /** Ghost-text suggestion provider; external providers fall back to Native. */ suggestions: SuggestionProviderId; + /** Native default; Atuin is an explicit local read-only source. */ + history: HistoryProviderId; /** Predict a whole command on an empty prompt from the previous one. */ suggestionsOnEmpty: boolean; nmsh: { @@ -237,6 +240,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { outputFolding: 'smart', welcome: 'vespyr', suggestions: 'nmsh', + history: 'native', suggestionsOnEmpty: false, 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', @@ -301,6 +305,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio ? value.welcome as WelcomeProviderId : 'vespyr'; const suggestions: SuggestionProviderId = SUGGESTION_PROVIDER_IDS.includes(value.suggestions as SuggestionProviderId) ? value.suggestions as SuggestionProviderId : 'nmsh'; + const history: HistoryProviderId = value.history === 'atuin' ? 'atuin' : 'native'; const suggestionsOnEmpty = value.suggestionsOnEmpty === true; const provider: PromptProviderId = promptValue.provider === 'starship' || promptValue.provider === 'powerlevel10k' ? promptValue.provider @@ -347,7 +352,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, - glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, suggestionsOnEmpty, + glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, suggestionsOnEmpty, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, placement, composerLayout, composerPosition, transcriptPresentation, spacing, gap, separator}; } @@ -388,7 +393,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio modules.splice(before === -1 ? modules.length : before, 0, {...fallback}); }); - return {provider, onboardingComplete: value.onboardingComplete === true, glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, suggestionsOnEmpty, nmsh, transcript, syntax, powerlevel10k, + return {provider, onboardingComplete: value.onboardingComplete === true, glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, suggestionsOnEmpty, nmsh, transcript, syntax, powerlevel10k, starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, transcriptPresentation, modules, separator, spacing, gap}; } diff --git a/src/providers/providers.ts b/src/providers/providers.ts index efc7f0f5..edd218dd 100644 --- a/src/providers/providers.ts +++ b/src/providers/providers.ts @@ -7,7 +7,7 @@ import {delimiter, join} from 'node:path'; * runtime interface (prompt render, welcome render, suggestion query, ...); * this module only describes providers and how their availability looks. */ -export type ProviderFamily = 'prompt' | 'welcome' | 'suggestions'; +export type ProviderFamily = 'prompt' | 'welcome' | 'suggestions' | 'history'; export type ProviderKind = 'native' | 'external' | 'none'; export interface ProviderInstall { diff --git a/src/shell/HistoryIndex.ts b/src/shell/HistoryIndex.ts index e7504770..39a25348 100644 --- a/src/shell/HistoryIndex.ts +++ b/src/shell/HistoryIndex.ts @@ -84,6 +84,11 @@ export class HistoryIndex { this.revision += 1; } + clearImported(): void { + for (const [id, entry] of this.entries) if (entry.source !== 'nmsh') this.entries.delete(id); + this.revision += 1; + } + add(entry: HistoryEntry): void { if (isPrivateCommand(entry.command) || this.deleted.has(entry.id)) return; this.entries.set(entry.id, entry); diff --git a/src/shell/HistoryService.ts b/src/shell/HistoryService.ts index 607630cf..46af8491 100644 --- a/src/shell/HistoryService.ts +++ b/src/shell/HistoryService.ts @@ -1,46 +1,65 @@ import {readFile} from 'node:fs/promises'; import {homedir} from 'node:os'; import {join} from 'node:path'; -import {findExecutable, runExternal} from '../providers/providers.js'; +import {resolveCommand, runExternal} from '../providers/providers.js'; import {HistoryIndex, historyId, journalHistory, type HistoryEntry} from './HistoryIndex.js'; import {nmshConfigDirectory} from '../configuration/paths.js'; import type {CompletedCommand} from '../output/OutputBuffer.js'; import {Worker} from 'node:worker_threads'; import type {CommandEntry} from '../suggestions/types.js'; -const ATUIN_FORMAT = '{time}\t{exit}\t{directory}\t{command}'; +import {ATUIN_METADATA_FORMAT, type HistoryProviderId} from './historyProviders.js'; + /** * Local shell history, loaded once in the background so startup and - * keystrokes never wait for it. Atuin (with cwd, exit and time) when - * installed, otherwise $HISTFILE. + * keystrokes never wait for it. Native is the default; Atuin is read-only + * and queried only when explicitly selected. */ export class HistoryService { private entries: CommandEntry[] = []; private worker?: Worker; + private request?: AbortController; + private generation = 0; + status: {selected: HistoryProviderId; active: HistoryProviderId; detail?: string} = {selected: 'native', active: 'native'}; private readonly lifetime = new AbortController(); - readonly index = new HistoryIndex(join(nmshConfigDirectory(), 'history-deletions.json')); + readonly index: HistoryIndex; - constructor(private readonly env: NodeJS.ProcessEnv = process.env) {} + constructor(private readonly env: NodeJS.ProcessEnv = process.env) { + this.index = new HistoryIndex(join(nmshConfigDirectory(env), 'history-deletions.json')); + } /** Loads (or reloads) history; callers start it once the app is running. */ - dispose(): void { this.lifetime.abort(); void this.worker?.terminate(); } - - async reload(): Promise { - if (this.lifetime.signal.aborted) return; + dispose(): void { this.lifetime.abort(); this.request?.abort(); void this.worker?.terminate(); } + + async reload(provider: HistoryProviderId = 'native'): Promise { + if (this.lifetime.signal.aborted) return false; + const generation = ++this.generation; + this.request?.abort(); + const request = new AbortController(); + this.request = request; + void this.worker?.terminate(); await this.index.loadDeletions(); - await this.loadSource(); - this.entries.forEach((entry, index) => this.index.add({...entry, id: historyId('shell', `${entry.at ?? 0}:${entry.command}`), source: 'zsh'})); - await this.loadJournals(); + const loaded = await this.loadSource(provider, request.signal); + if (generation !== this.generation || request.signal.aborted) return false; + this.entries = loaded.entries; + this.status = {selected: provider, active: loaded.source === 'atuin' ? 'atuin' : 'native', detail: loaded.detail}; + this.index.clearImported(); + this.entries.forEach(entry => this.index.add({...entry, id: 'id' in entry && typeof entry.id === 'string' ? entry.id + : historyId(loaded.source, `${entry.at ?? 0}:${entry.command}`), source: loaded.source})); + await this.loadJournals(generation); + if (generation !== this.generation || request.signal.aborted) return false; this.index.all(); + return true; } - private loadJournals(): Promise { + private loadJournals(generation: number): Promise { return new Promise((resolve, reject) => { const worker = new Worker(new URL('../../scripts/read-command-history.cjs', import.meta.url), - {workerData: {directory: join(nmshConfigDirectory(), 'sessions')}}); + {workerData: {directory: join(nmshConfigDirectory(this.env), 'sessions')}}); this.worker = worker; worker.on('message', (batch: {session: string; records: CompletedCommand[]}) => { + if (generation !== this.generation || this.lifetime.signal.aborted) return; for (const record of batch.records) this.record(record, batch.session); }); worker.once('error', reject); @@ -59,22 +78,21 @@ export class HistoryService { search(query: string, signal?: AbortSignal): Promise { return this.index.search(query, signal); } - private async loadSource(): Promise { - const atuin = findExecutable('atuin', this.env.PATH ?? ''); - if (atuin) { - const result = await runExternal(atuin, ['history', 'list', '--format', ATUIN_FORMAT, '--print0', '--timezone', '+0'], - {timeoutMs: 15000, maxBytes: 128 * 1024 * 1024, env: this.env, signal: this.lifetime.signal}); - if (result.ok) { - this.entries = parseAtuinHistory(result.stdout); - return; - } + private async loadSource(provider: HistoryProviderId, signal: AbortSignal): Promise<{entries: CommandEntry[]; source: 'zsh' | 'atuin'; detail?: string}> { + let detail: string | undefined; + if (provider === 'atuin') { + const atuin = resolveCommand('atuin', this.env.PATH ?? '', []); + if (atuin) { + const result = await runExternal(atuin, ['history', 'list', '--format', ATUIN_METADATA_FORMAT, '--print0', '--timezone', '+0'], + {timeoutMs: 15000, maxBytes: 128 * 1024 * 1024, env: this.env, signal}); + if (result.ok) return {entries: await parseAtuinMetadataInChunks(result.stdout), source: 'atuin'}; + detail = `Atuin unavailable (${result.error ?? 'query failed'}); using Native`; + } else detail = 'Atuin is not installed; using Native'; } try { - const path = this.env.HISTFILE || join(homedir(), '.zsh_history'); - this.entries = await parseZshHistoryInChunks(await readFile(path)); - } catch { - this.entries = []; - } + const path = this.env.HISTFILE || join(this.env.HOME ?? homedir(), '.zsh_history'); + return {entries: await parseZshHistoryInChunks(await readFile(path)), source: 'zsh', detail}; + } catch { return {entries: [], source: 'zsh', detail}; } } /** Commands, oldest first. */ @@ -146,3 +164,34 @@ export async function parseZshHistoryInChunks(content: Uint8Array | string): Pro } return entries; } + + +/** Atuin's public CLI formats duration in its largest unit (approximate metadata). */ +function* atuinMetadataEntries(output: string): Generator { + for (const record of output.split('\0')) { + const [version, uuid, time, exit, cwd, duration, session, ...command] = record.split('\u001f'); + if (version !== 'nmsh-v1' || !uuid || !time || !/^\d+$/u.test(exit ?? '')) continue; + const text = command.join('\u001f').replace(/\n+$/u, ''); + if (!text.trim() || /^\s/u.test(text)) continue; + const at = Date.parse(`${time.trim().replace(' ', 'T')}Z`); + const match = /^(\d+(?:\.\d+)?)\s*(ns|µs|μs|us|ms|s|m|h|d)$/u.exec(duration ?? ''); + const units: Record = {ns: 0.000001, 'µs': 0.001, 'μs': 0.001, us: 0.001, ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000}; + yield {id: historyId('atuin', uuid), source: 'atuin', command: text, exitCode: Number(exit), + ...(Number.isFinite(at) ? {at} : {}), + ...(cwd && cwd !== 'unknown' ? {cwd} : {}), ...(session ? {session} : {}), + ...(match ? {durationMs: Number(match[1]) * units[match[2]!]!} : {})}; + } + } + +export function parseAtuinMetadata(output: string): HistoryEntry[] { + return [...atuinMetadataEntries(output)].sort((a, b) => (a.at ?? 0) - (b.at ?? 0)); +} + +async function parseAtuinMetadataInChunks(output: string): Promise { + const entries: HistoryEntry[] = []; + for (const entry of atuinMetadataEntries(output)) { + entries.push(entry); + if (entries.length % 2048 === 0) await new Promise(resolve => setImmediate(resolve)); + } + return entries.sort((a, b) => (a.at ?? 0) - (b.at ?? 0)); +} diff --git a/src/shell/historyProviders.ts b/src/shell/historyProviders.ts new file mode 100644 index 00000000..654bfa0e --- /dev/null +++ b/src/shell/historyProviders.ts @@ -0,0 +1,8 @@ +import type {ProviderDescriptor} from '../providers/providers.js'; +export type HistoryProviderId = 'native' | 'atuin'; +export const HISTORY_PROVIDERS: readonly ProviderDescriptor[] = [ + {id: 'native', family: 'history', label: 'NMSh Native', kind: 'native', description: 'shell-approved journals and zsh history'}, + {id: 'atuin', family: 'history', label: 'Atuin', kind: 'external', executable: 'atuin', versionArgs: ['--version'], + description: 'read your local Atuin history; no recording or sync', setup: 'NMSh never invokes sync or changes your existing zsh hooks.'}, +]; +export const ATUIN_METADATA_FORMAT = 'nmsh-v1\u001f{uuid}\u001f{time}\u001f{exit}\u001f{directory}\u001f{duration}\u001f{session}\u001f{command}'; diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index f6382250..eda1aa82 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -13,6 +13,7 @@ import { import {providerLabel} from '../prompt/PromptPanel.js'; import {welcomeProvider} from '../output/WelcomeProviders.js'; import {PROMPT_STYLES, PROMPT_STYLE_LABELS} from '../prompt/powerline.js'; +import {HISTORY_PROVIDERS} from '../shell/historyProviders.js'; import {SUGGESTION_PROVIDERS} from '../suggestions/types.js'; import {foregroundOf, status, theme} from '../chroma/chroma.js'; import {foreground, UI_COLORS} from './palette.js'; @@ -59,7 +60,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'; +export type SettingsDestination = 'glyph' | 'appearance' | 'prompt' | 'transcript' | 'syntax' | 'layout' | 'keyboard' | 'welcome' | 'suggestions' | 'history'; interface SettingsRowBase { id: string; @@ -149,6 +150,8 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ {id: 'suggestions', label: 'Suggestions', description: 'Ghost-text prediction provider: NMSh Native, Deja, or None', category: 'Suggestions', control: 'child', destination: 'suggestions', value: config => SUGGESTION_PROVIDERS.find(provider => provider.id === config.suggestions)?.label ?? config.suggestions}, + {id: 'history', label: 'Command history provider', description: 'Native journals and zsh history, or explicit local read-only Atuin', category: 'History', + control: 'child', destination: 'history', value: config => HISTORY_PROVIDERS.find(provider => provider.id === config.history)?.label ?? config.history}, {id: 'suggestionsOnEmpty', level: 'advanced', label: 'Empty-prompt prediction', description: 'Suggest the likely next command before typing', category: 'Suggestions', control: 'boolean', get: config => config.suggestionsOnEmpty, set: (config, suggestionsOnEmpty) => ({...config, suggestionsOnEmpty})}, ]; diff --git a/tests/historyProviders.test.ts b/tests/historyProviders.test.ts new file mode 100644 index 00000000..7e880bdb --- /dev/null +++ b/tests/historyProviders.test.ts @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmod, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {HistoryService, parseAtuinMetadata} from '../src/shell/HistoryService.js'; +import {HISTORY_PROVIDERS} from '../src/shell/historyProviders.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; + +const record = 'nmsh-v1\u001fid-1\u001f2026-01-02 10:00:00\u001f1\u001f/work\u001f1.5s\u001fsession-1\u001fgit status\0'; + +test('Atuin metadata preserves multiline commands, identity, session and approximate duration', () => { + const entries = parseAtuinMetadata(record + record.replace('id-1', 'id-2').replace('git status', 'echo "a\nb\t\u001fc"')); + assert.equal(entries[0]!.durationMs, 1500); + assert.equal(entries[0]!.session, 'session-1'); + assert.equal(entries[0]!.source, 'atuin'); + assert.equal(entries[1]!.command, 'echo "a\nb\t\u001fc"'); + assert.notEqual(entries[0]!.id, entries[1]!.id); + assert.deepEqual(parseAtuinMetadata('broken\0'), []); + assert.equal(parseAtuinMetadata(record.replace('1.5s', 'unknown'))[0]!.durationMs, undefined); +}); + +test('Native is the normalized default; explicit Atuin persists in the existing schema', () => { + assert.equal(normalizePromptConfiguration({}).history, 'native'); + assert.equal(normalizePromptConfiguration({history: 'atuin'}).history, 'atuin'); + assert.equal(normalizePromptConfiguration({history: 'unknown'}).history, 'native'); + assert.deepEqual(HISTORY_PROVIDERS.map(provider => provider.id), ['native', 'atuin']); +}); + +test('Atuin runs only when selected, uses read-only argv, and missing/failing tools fall back', async () => { + const directory = await mkdtemp(join(tmpdir(), 'nmsh-history-provider-')); + const file = join(directory, 'history'); + const invoked = join(directory, 'invoked'); + await writeFile(file, ': 100:0;native command\n'); + // printf is a shell builtin; a fake tool records only fixed operation names, no private history. + await writeFile(join(directory, 'atuin'), `#!/bin/sh\nprintf '%s\\n' "$1" "$2" > '${invoked}'\nprintf 'nmsh-v1\\037id-1\\0372026-01-02 10:00:00\\0371\\037/work\\0371.5s\\037session-1\\037git status\\000'\n`); + await chmod(join(directory, 'atuin'), 0o755); + const service = new HistoryService({PATH: directory, HOME: directory, HISTFILE: file, XDG_CONFIG_HOME: directory}); + try { + await service.reload(); + assert.deepEqual(service.getAll(), ['native command']); + await assert.rejects(readFile(invoked)); + await service.reload('atuin'); + assert.deepEqual(service.getAll(), ['git status']); + assert.equal(service.status.active, 'atuin'); + assert.equal((await service.search('session:session exit:failure duration:>1s'))[0]!.command, 'git status'); + assert.equal(await readFile(invoked, 'utf8'), 'history\nlist\n'); + await writeFile(join(directory, 'atuin'), '#!/bin/sh\nexit 1\n'); + await service.reload('atuin'); + assert.equal(service.status.active, 'native'); + assert.match(service.status.detail!, /unavailable/u); + assert.deepEqual(service.getAll(), ['native command']); + await rm(join(directory, 'atuin')); + await service.reload('atuin'); + assert.match(service.status.detail!, /not installed/u); + } finally { service.dispose(); await rm(directory, {recursive: true, force: true}); } +}); From 92240b3f5c5225fe56857e9a65baef510d88636b Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:42:03 +0530 Subject: [PATCH 08/82] Add supplied-candidate picker providers and terminal handoff --- src/app/TerminalApp.ts | 72 +++++++++++++++++-- src/pickers/Picker.ts | 99 ++++++++++++++++++++++++++ src/prompt/configuration.ts | 8 ++- src/providers/providers.ts | 2 +- src/ui/SettingsPanel.ts | 5 +- tests/picker.test.ts | 136 ++++++++++++++++++++++++++++++++++++ 6 files changed, 313 insertions(+), 9 deletions(-) create mode 100644 src/pickers/Picker.ts create mode 100644 tests/picker.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index f4fa4eaa..96a10447 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -19,6 +19,7 @@ import {SuggestionController} from '../suggestions/SuggestionController.js'; import {createPalette, handlePaletteKey, renderPalette, type PaletteItem, type PaletteState} from '../ui/CommandPalette.js'; import {NativeSuggestions} from '../suggestions/NativeSuggestions.js'; import {DejaSuggestions} from '../suggestions/DejaSuggestions.js'; +import {openPicker, PICKER_PROVIDERS, type PickerHandoff} from '../pickers/Picker.js'; import {HISTORY_PROVIDERS} from '../shell/historyProviders.js'; import type {HistoryEntry} from '../shell/HistoryIndex.js'; import {isPrivateCommand, ignorePatternFromEnv, SUGGESTION_PROVIDERS} from '../suggestions/types.js'; @@ -137,6 +138,8 @@ export class TerminalApp { private shellSuggestions: CompletionCandidate[] = []; private lastSuggestionInput = ""; private completionGeneration = 0; + private pickerOpening = false; + private pickerAbort?: AbortController; private historyQuery?: string; private historyQueryAbort?: AbortController; private historyResults: HistoryEntry[] = []; @@ -742,8 +745,7 @@ export class TerminalApp { } if (key.kind === 'historySearch') { if (!this.running) { - this.editor.clear(); - this.editor.insert(HISTORY_SEARCH); + void this.openHistoryPicker(''); } return; } @@ -996,6 +998,59 @@ export class TerminalApp { } } + private async openHistoryPicker(query: string): Promise { + if (this.running || this.externalPassthrough || this.pickerOpening) return; + this.pickerOpening = true; + try { + const original = this.editor.text; + const native = () => this.applySuggestion({insertion: `${HISTORY_SEARCH}${query}`}); + const candidates = this.promptConfiguration.picker === 'native' ? [] : (await this.historyService.search(query)).map(entry => ({ + 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); + 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); + this.render(); + } finally { this.pickerOpening = false; } + } + + /** External pickers temporarily own the host terminal, never the managed shell PTY. */ + private readonly pickerHandoff: PickerHandoff = async run => { + if (!process.stdin.isTTY || !process.stdout.isTTY || this.running || this.passthrough || this.externalPassthrough) + return {kind: 'fallback', reason: 'A free interactive terminal is required; using Native'}; + const controller = new AbortController(); + const abort = () => controller.abort(); + const ignoreInterrupt = () => { /* The foreground picker handles Ctrl+C. */ }; + this.externalPassthrough = true; + let detached = false; + let released = false; + let left = false; + try { + process.stdin.off('data', this.onInput); process.stdin.pause(); detached = true; + process.stdin.setRawMode(false); released = true; + this.renderer.leave(); left = true; + process.on('SIGINT', ignoreInterrupt); + process.on('SIGWINCH', abort); + this.pickerAbort = controller; + return await run(controller.signal); + } catch (error) { return {kind: 'fallback', reason: `Picker failed: ${String(error)}; using Native`}; } + finally { + process.off('SIGINT', ignoreInterrupt); process.off('SIGWINCH', abort); + this.pickerAbort = undefined; + if (!this.stopped) { + if (left) this.renderer.enter(); + if (released) process.stdin.setRawMode(true); + this.keyDecoder.reset(); + if (detached) { process.stdin.on('data', this.onInput); process.stdin.resume(); } + this.renderer.invalidate(); + } + this.externalPassthrough = false; + this.render(); + } + }; + private applySuggestion(suggestion: {insertion: string}): void { this.editor.clear(); this.editor.insert(suggestion.insertion); @@ -1018,7 +1073,10 @@ export class TerminalApp { else if (slash.kind === 'clear') await this.startFreshPresentation(); else if (slash.kind === 'resume') await this.openResumePicker(); else if (slash.kind === 'help') this.showHelp(command); - else if (slash.kind === 'history') this.submitHistorySearch(slash.query, command.startsWith(HISTORY_SEARCH)); + else if (slash.kind === 'history') { + if (command.startsWith(HISTORY_SEARCH)) this.submitHistorySearch(slash.query, true); + else await this.openHistoryPicker(slash.query); + } else if (slash.kind === 'palette') this.openPalette(); else this.output.addFrontendInteraction(command, `Unknown NMSh command: ${(slash as any).input || command}`, ERROR); } @@ -2105,7 +2163,7 @@ export class TerminalApp { else if (destination === 'transcript') this.startTranscriptSettings(); else if (destination === 'syntax') this.startSyntaxSettings(); else if (destination === 'layout') this.startLayoutSettings(); - else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history') this.startProviderPanel(destination); + else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history' || destination === 'picker') this.startProviderPanel(destination); else void this.startKeyboard(); } @@ -2272,9 +2330,10 @@ export class TerminalApp { }); } - private startProviderPanel(family: 'welcome' | 'suggestions' | 'history'): void { + private startProviderPanel(family: 'welcome' | 'suggestions' | 'history' | 'picker'): void { const state: ProviderPanelState = family === 'welcome' ? createProviderPanel(family, 'Welcome', WELCOME_PROVIDERS, this.promptConfiguration.welcome) + : family === 'picker' ? createProviderPanel(family, 'Picker', PICKER_PROVIDERS, this.promptConfiguration.picker) : family === 'history' ? createProviderPanel(family, 'Command history', HISTORY_PROVIDERS, this.promptConfiguration.history) : createProviderPanel(family, 'Suggestions', SUGGESTION_PROVIDERS, this.promptConfiguration.suggestions); this.providerPanelState = state; @@ -2290,6 +2349,7 @@ export class TerminalApp { /** The highlighted provider rendered by its own family; captures are cached per panel. */ private providerPreview(state: ProviderPanelState, width: number): string[] { const selected = providerPanelSelection(state); + if (state.family === 'picker') return [`${SUBTLE}Selections restore the composer; cancel leaves it unchanged. Missing or failing tools use Native.${RESET}`]; if (state.family === 'history') return [`${SUBTLE}${selected.id === 'atuin' ? 'Read-only local history; existing hooks unchanged; no sync.' : 'Shell-approved journal metadata and imported zsh history.'}${RESET}`]; if (state.family === 'suggestions') { if (selected.id === 'none') return [`${SUBTLE}No ghost text while typing.${RESET}`]; @@ -2342,6 +2402,7 @@ export class TerminalApp { const selected = providerPanelSelection(state); const next = state.family === 'welcome' ? {...structuredClone(this.promptConfiguration), welcome: selected.id as PromptConfiguration['welcome']} + : state.family === 'picker' ? {...structuredClone(this.promptConfiguration), picker: selected.id as PromptConfiguration['picker']} : state.family === 'history' ? {...structuredClone(this.promptConfiguration), history: selected.id as PromptConfiguration['history']} : {...structuredClone(this.promptConfiguration), suggestions: selected.id as PromptConfiguration['suggestions']}; try { @@ -2871,6 +2932,7 @@ export class TerminalApp { this.renderer.leave(); this.completionService.cancel(); this.historyQueryAbort?.abort(); + this.pickerAbort?.abort(); this.historyService.dispose(); this.semanticService.kill(); this.finish(exitCode); diff --git a/src/pickers/Picker.ts b/src/pickers/Picker.ts new file mode 100644 index 00000000..478f26b4 --- /dev/null +++ b/src/pickers/Picker.ts @@ -0,0 +1,99 @@ +import {spawn} from 'node:child_process'; +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'; + +export type PickerProviderId = 'native' | 'fzf' | 'television'; +export interface PickerCandidate { id: string; label: string; description?: string; value: string } +export type PickerResult = {kind: 'selected'; candidate: PickerCandidate} | {kind: 'cancelled'} | {kind: 'fallback'; reason: string}; +/** The host owns terminal release/restoration; a picker owns only selection. */ +export type PickerHandoff = (run: (signal: AbortSignal) => Promise) => Promise; +export const PICKER_PROVIDERS: readonly ProviderDescriptor[] = [ + {id: 'native', family: 'picker', label: 'NMSh Native', kind: 'native', description: 'search in the composer; select without executing'}, + {id: 'fzf', family: 'picker', label: 'fzf', kind: 'external', executable: 'fzf', versionArgs: ['--version'], description: 'optional terminal fuzzy picker'}, + {id: 'television', family: 'picker', label: 'Television', kind: 'external', executable: 'tv', versionArgs: ['--version'], description: 'optional terminal fuzzy picker'}, +]; +const plain = (text: string): string => text.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '); + +/** IDs on the wire are ordinal tokens, never commands or arbitrary tool output. */ +export function pickerInput(candidates: readonly PickerCandidate[]): string { + return candidates.map((candidate, index) => `${index}\t${plain(candidate.label)}${candidate.description ? ` · ${plain(candidate.description)}` : ''}\n`).join(''); +} +export function pickerSelection(output: string, candidates: readonly PickerCandidate[]): PickerResult { + const lines = output.trimEnd().split('\n'); + if (lines.length !== 1) return {kind: 'fallback', reason: 'Picker returned an invalid selection'}; + const line = lines[0]!; + const match = /^(0|[1-9]\d*)\t/u.exec(line); + const candidate = match ? candidates[Number(match[1])] : undefined; + if (!candidate || pickerInput([candidate]).replace(/^0/u, match![1]!).trimEnd() !== line) + return {kind: 'fallback', reason: 'Picker returned an unknown selection'}; + return {kind: 'selected', candidate}; +} + +/** 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 { + 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)); + if (result.kind === 'fallback') native(); + return result; +} + +/** 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 { + if (signal.aborted) return {kind: 'cancelled'}; + const input = pickerInput(candidates); + if (candidates.length > 100_000 || Buffer.byteLength(input) > 16 * 1024 * 1024) + return {kind: 'fallback', reason: 'Picker input exceeds the bounded limit; using Native'}; + const directory = await mkdtemp(join(tmpdir(), 'nmsh-picker-')); + try { + const environment = {...env}; + for (const key of Object.keys(environment)) if (/^(?:FZF_|TV_)/u.test(key)) delete environment[key]; + let args: string[]; + if (provider === 'fzf') args = ['--no-multi', '--no-sort', '--delimiter=\t', '--with-nth=2..', '--layout=reverse', '--no-mouse', '--pointer=>', '--marker=*']; + else { + // No user cable, hooks, preview command or persisted history is loaded. + await writeFile(join(directory, 'config.toml'), 'history_size = 0\n', {mode: 0o600}); + await mkdir(join(directory, 'cable')); + environment.XDG_CONFIG_HOME = directory; + environment.XDG_DATA_HOME = directory; + environment.XDG_CACHE_HOME = directory; + args = ['--config-file', join(directory, 'config.toml'), '--cable-dir', join(directory, 'cable'), '--no-preview', '--no-remote', '--keybindings', 'tab="select_next_entry";backtab="select_prev_entry"']; + } + if (provider === 'fzf' && env.NO_COLOR !== undefined) args.push('--color=bw'); + if (signal.aborted) return {kind: 'cancelled'}; + return await new Promise(resolve => { + const child = spawn(binary, args, {env: environment, stdio: ['pipe', 'pipe', 'inherit']}); + const chunks: Buffer[] = []; + let bytes = 0; + let failure: string | undefined; + let aborted = false; + let settled = false; + const abort = () => { aborted = true; child.kill('SIGKILL'); }; + const timer = setTimeout(() => { failure = 'Picker timed out; using Native'; child.kill('SIGKILL'); }, timeoutMs); + signal.addEventListener('abort', abort, {once: true}); + if (signal.aborted) abort(); + const finish = (result: PickerResult) => { + if (settled) return; + settled = true; clearTimeout(timer); signal.removeEventListener('abort', abort); resolve(result); + }; + child.stdout.on('data', (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > 64 * 1024) { failure = 'Picker output exceeds the bounded limit; using Native'; child.kill('SIGKILL'); } + else chunks.push(chunk); + }); + child.stdin.on('error', () => { /* Early cancel may close stdin before candidates finish writing. */ }); + child.on('error', error => finish({kind: 'fallback', reason: `Picker failed: ${error.message}; using Native`})); + child.on('close', code => finish(aborted || code === 1 || code === 130 ? {kind: 'cancelled'} : failure + ? {kind: 'fallback', reason: failure} : code === 0 ? pickerSelection(Buffer.concat(chunks).toString('utf8'), candidates) + : {kind: 'fallback', reason: `Picker exited with ${code}; using Native`})); + child.stdin.end(input); + }); + } catch (error) { return {kind: 'fallback', reason: `Picker failed: ${String(error)}; using Native`}; } + finally { await rm(directory, {recursive: true, force: true}); } +} diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index a29cb6d8..9475d70d 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -8,6 +8,7 @@ export type LiveSessionStartup = typeof LIVE_SESSION_STARTUP[number]; export const LIVE_SESSION_MULTIPLE = ['ask', 'open-all'] as const; export type LiveSessionMultiple = typeof LIVE_SESSION_MULTIPLE[number]; import type {OutputFoldingMode} from '../output/FoldPolicy.js'; +import type {PickerProviderId} from '../pickers/Picker.js'; import type {HistoryProviderId} from '../shell/historyProviders.js'; import {SUGGESTION_PROVIDER_IDS, type SuggestionProviderId} from '../suggestions/types.js'; import { @@ -188,6 +189,7 @@ export interface PromptConfiguration { suggestions: SuggestionProviderId; /** Native default; Atuin is an explicit local read-only source. */ history: HistoryProviderId; + picker: PickerProviderId; /** Predict a whole command on an empty prompt from the previous one. */ suggestionsOnEmpty: boolean; nmsh: { @@ -241,6 +243,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { welcome: 'vespyr', suggestions: 'nmsh', history: 'native', + picker: 'native', suggestionsOnEmpty: false, 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', @@ -305,6 +308,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio ? value.welcome as WelcomeProviderId : 'vespyr'; const suggestions: SuggestionProviderId = SUGGESTION_PROVIDER_IDS.includes(value.suggestions as SuggestionProviderId) ? value.suggestions as SuggestionProviderId : 'nmsh'; + const picker: PickerProviderId = value.picker === 'fzf' || value.picker === 'television' ? value.picker : 'native'; const history: HistoryProviderId = value.history === 'atuin' ? 'atuin' : 'native'; const suggestionsOnEmpty = value.suggestionsOnEmpty === true; const provider: PromptProviderId = promptValue.provider === 'starship' || promptValue.provider === 'powerlevel10k' @@ -352,7 +356,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, - glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, suggestionsOnEmpty, + glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, suggestionsOnEmpty, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, placement, composerLayout, composerPosition, transcriptPresentation, spacing, gap, separator}; } @@ -393,7 +397,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio modules.splice(before === -1 ? modules.length : before, 0, {...fallback}); }); - return {provider, onboardingComplete: value.onboardingComplete === true, glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, suggestionsOnEmpty, nmsh, transcript, syntax, powerlevel10k, + return {provider, onboardingComplete: value.onboardingComplete === true, glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, suggestionsOnEmpty, nmsh, transcript, syntax, powerlevel10k, starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, transcriptPresentation, modules, separator, spacing, gap}; } diff --git a/src/providers/providers.ts b/src/providers/providers.ts index edd218dd..e9e4578b 100644 --- a/src/providers/providers.ts +++ b/src/providers/providers.ts @@ -7,7 +7,7 @@ import {delimiter, join} from 'node:path'; * runtime interface (prompt render, welcome render, suggestion query, ...); * this module only describes providers and how their availability looks. */ -export type ProviderFamily = 'prompt' | 'welcome' | 'suggestions' | 'history'; +export type ProviderFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker'; export type ProviderKind = 'native' | 'external' | 'none'; export interface ProviderInstall { diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index eda1aa82..ad7824e7 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -13,6 +13,7 @@ import { import {providerLabel} from '../prompt/PromptPanel.js'; import {welcomeProvider} from '../output/WelcomeProviders.js'; import {PROMPT_STYLES, PROMPT_STYLE_LABELS} from '../prompt/powerline.js'; +import {PICKER_PROVIDERS} from '../pickers/Picker.js'; import {HISTORY_PROVIDERS} from '../shell/historyProviders.js'; import {SUGGESTION_PROVIDERS} from '../suggestions/types.js'; import {foregroundOf, status, theme} from '../chroma/chroma.js'; @@ -60,7 +61,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'; +export type SettingsDestination = 'glyph' | 'appearance' | 'prompt' | 'transcript' | 'syntax' | 'layout' | 'keyboard' | 'welcome' | 'suggestions' | 'history' | 'picker'; interface SettingsRowBase { id: string; @@ -152,6 +153,8 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ value: config => SUGGESTION_PROVIDERS.find(provider => provider.id === config.suggestions)?.label ?? config.suggestions}, {id: 'history', label: 'Command history provider', description: 'Native journals and zsh history, or explicit local read-only Atuin', category: 'History', control: 'child', destination: 'history', value: config => HISTORY_PROVIDERS.find(provider => provider.id === config.history)?.label ?? config.history}, + {id: 'picker', label: 'Picker provider', description: 'Native composer search, fzf, or Television; selections never execute', category: 'History', + control: 'child', destination: 'picker', value: config => PICKER_PROVIDERS.find(provider => provider.id === config.picker)?.label ?? config.picker}, {id: 'suggestionsOnEmpty', level: 'advanced', label: 'Empty-prompt prediction', description: 'Suggest the likely next command before typing', category: 'Suggestions', control: 'boolean', get: config => config.suggestionsOnEmpty, set: (config, suggestionsOnEmpty) => ({...config, suggestionsOnEmpty})}, ]; diff --git a/tests/picker.test.ts b/tests/picker.test.ts new file mode 100644 index 00000000..b8b35392 --- /dev/null +++ b/tests/picker.test.ts @@ -0,0 +1,136 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp, writeFile, rm} from 'node:fs/promises'; +import {join} from 'node:path'; +import {tmpdir} from 'node:os'; +import {openPicker, pickerInput, pickerSelection, runPicker} from '../src/pickers/Picker.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {resolveCommand} from '../src/providers/providers.js'; + +const candidates = [{id: 'secret-id', label: 'git\nstatus\u001b[31m', value: 'git status', description: '/work'}, + {id: 'other', label: 'echo hello', value: 'echo hello'}]; + +test('picker transport strips controls and accepts exactly one supplied row', () => { + const input = pickerInput(candidates); + assert.doesNotMatch(input.replace(/\n|\t/gu, ''), /[\u0000-\u001f\u007f-\u009f]/u); + assert.deepEqual(pickerSelection(input.split('\n')[1]! + '\n', candidates), {kind: 'selected', candidate: candidates[1]}); + for (const output of ['99\tfake\n', '0\tarbitrary command\n', input, '', '01\techo hello\n']) + assert.equal(pickerSelection(output, candidates).kind, 'fallback'); +}); + +test('native defaults, explicit provider choices and missing providers fall back without handoff', async () => { + assert.equal(normalizePromptConfiguration({}).picker, 'native'); + assert.equal(normalizePromptConfiguration({picker: 'fzf'}).picker, 'fzf'); + assert.equal(normalizePromptConfiguration({picker: 'television'}).picker, 'television'); + assert.equal(normalizePromptConfiguration({picker: 'invalid'}).picker, 'native'); + let native = 0; + const handoff = async () => { throw new Error('must not hand off'); }; + await openPicker('native', candidates, () => native++, handoff); + const result = await openPicker('television', candidates, () => native++, handoff, {PATH: ''}); + assert.equal(result?.kind, 'fallback'); + assert.equal(native, 2); +}); + +test('external pickers receive inert rows, isolated configuration, bounded cancellation and known selection', async () => { + const directory = await mkdtemp(join(tmpdir(), 'nmsh-picker-test-')); + const binary = join(directory, 'picker'); + try { + await writeFile(binary, `#!${process.execPath}\nlet text='';process.stdin.setEncoding('utf8');process.stdin.on('data',s=>text+=s);process.stdin.on('end',()=>{\nif(Object.keys(process.env).some(k=>k.startsWith('FZF_')||k.startsWith('TV_')))process.exit(2);\nif(process.argv.includes('--config-file') && process.env.XDG_CONFIG_HOME===process.env.ORIGINAL_CONFIG)process.exit(2);\nprocess.stdout.write(text.split('\\n')[1]+'\\n');});\n`, {mode: 0o700}); + const environment = {...process.env, FZF_DEFAULT_OPTS: '--bind=enter:execute(touch unsafe)', TV_CONFIG: 'unsafe', + ORIGINAL_CONFIG: '/unsafe', XDG_CONFIG_HOME: '/unsafe'}; + for (const provider of ['fzf', 'television'] as const) + assert.deepEqual(await runPicker(binary, provider, candidates, new AbortController().signal, environment), + {kind: 'selected', candidate: candidates[1]}); + await writeFile(binary, `#!${process.execPath}\nprocess.stdin.resume();setInterval(()=>{},1000);\n`, {mode: 0o700}); + const controller = new AbortController(); + const pending = runPicker(binary, 'fzf', candidates, controller.signal); + setTimeout(() => controller.abort(), 50); + assert.equal((await pending).kind, 'cancelled'); + assert.equal((await runPicker(binary, 'fzf', candidates, new AbortController().signal, process.env, 50)).kind, 'fallback'); + assert.equal((await runPicker('/missing/picker', 'fzf', candidates, new AbortController().signal)).kind, 'fallback'); + assert.equal((await runPicker(binary, 'fzf', [{id: 'large', label: 'x'.repeat(17 * 1024 * 1024), value: ''}], new AbortController().signal)).kind, 'fallback'); + } finally { await rm(directory, {recursive: true, force: true}); } +}); + +test('picker terminal ownership restores renderer, raw input and listeners on accept, cancel, resize and error', async () => { + const app = new TerminalApp(); + const stdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + const stdoutTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + const raw = process.stdin.setRawMode; + const pause = process.stdin.pause; + const resume = process.stdin.resume; + const events: string[] = []; + Object.defineProperty(process.stdin, 'isTTY', {value: true, configurable: true}); + Object.defineProperty(process.stdout, 'isTTY', {value: true, configurable: true}); + process.stdin.setRawMode = ((value: boolean) => { events.push(`raw:${value}`); return process.stdin; }); + process.stdin.pause = (() => process.stdin); + process.stdin.resume = (() => process.stdin); + app['renderer'].enter = () => { events.push('enter'); }; + app['renderer'].leave = () => { events.push('leave'); }; + app['renderer'].invalidate = () => {}; + Object.defineProperty(app, 'render', {value: () => {}}); + const signals = process.listenerCount('SIGWINCH'); + try { + for (const kind of ['selected', 'cancelled', 'resize', 'error'] as const) { + events.length = 0; + const result = await app['pickerHandoff'](async signal => { + assert.equal(app['externalPassthrough'], true); + if (kind === 'resize') { process.emit('SIGWINCH'); assert.equal(signal.aborted, true); } + if (kind === 'error') throw new Error('provider failed'); + return kind === 'selected' ? {kind, candidate: candidates[0]!} : {kind: 'cancelled'}; + }); + assert.equal(result.kind, kind === 'error' ? 'fallback' : kind === 'resize' ? 'cancelled' : kind); + assert.deepEqual(events, ['raw:false', 'leave', 'enter', 'raw:true']); + assert.equal(app['externalPassthrough'], false); + assert.equal(process.listenerCount('SIGWINCH'), signals); + process.stdin.off('data', app['onInput']); + } + } finally { + if (stdinTTY) Object.defineProperty(process.stdin, 'isTTY', stdinTTY); else delete (process.stdin as {isTTY?: boolean}).isTTY; + if (stdoutTTY) Object.defineProperty(process.stdout, 'isTTY', stdoutTTY); else delete (process.stdout as {isTTY?: boolean}).isTTY; + process.stdin.setRawMode = raw; process.stdin.pause = pause; process.stdin.resume = resume; + app['stop'](0); app['session'].kill(); + } +}); + +test('history picker restores only the editor and cancelled selection preserves the buffer', async () => { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + const submitted: string[] = []; + app['session'].submit = ((text: string) => submitted.push(text)) as never; + try { + app['editor'].insert('draft'); + await app['openHistoryPicker'](''); + assert.equal(app['editor'].text, '/history '); + assert.deepEqual(submitted, []); + } finally { app['stop'](0); app['session'].kill(); } +}); + +test('installed fzf preserves the original supplied row through display transforms', async t => { + const fzf = resolveCommand('fzf'); + if (!fzf) { t.skip('fzf is optional'); return; } + const directory = await mkdtemp(join(tmpdir(), 'nmsh-fzf-test-')); + const wrapper = join(directory, 'filtered-fzf'); + try { + // Noninteractive filter mode verifies the installed binary's transport, not physical terminal behavior. + await writeFile(wrapper, `#!${process.execPath}\nconst {spawn}=require('node:child_process');const child=spawn(${JSON.stringify(fzf)}, [...process.argv.slice(2), '--filter=echo'], {stdio:'inherit',env:process.env});child.on('exit',code=>process.exit(code??2));\n`, {mode: 0o700}); + assert.deepEqual(await runPicker(wrapper, 'fzf', candidates, new AbortController().signal), {kind: 'selected', candidate: candidates[1]}); + } finally { await rm(directory, {recursive: true, force: true}); } +}); + +test('history picker ignores a query result if typing or command execution moved on', async () => { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + let complete!: (entries: never[]) => void; + app['promptConfiguration'].picker = 'fzf'; + app['historyService'].search = () => new Promise(resolve => { complete = resolve; }); + try { + app['editor'].insert('draft'); + const pending = app['openHistoryPicker'](''); + app['editor'].insert(' changed'); + complete([]); await pending; + assert.equal(app['editor'].text, 'draft changed'); + assert.equal(app['externalPassthrough'], false); + } finally { app['stop'](0); app['session'].kill(); } +}); From 21c0e83f19ba9fca1a567672952348327bac030c Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:50:44 +0530 Subject: [PATCH 09/82] Add native directory navigation and optional zoxide snapshot queries --- docs/performance-benchmarks.md | 13 ++++ scripts/benchmarks.ts | 6 +- src/app/TerminalApp.ts | 74 ++++++++++++++++++-- src/commands/slashCommands.ts | 4 ++ src/help/helpContent.ts | 6 ++ src/prompt/configuration.ts | 8 ++- src/providers/providers.ts | 2 +- src/shell/DirectoryService.ts | 110 ++++++++++++++++++++++++++++++ src/ui/SettingsPanel.ts | 5 +- tests/directoryNavigation.test.ts | 95 ++++++++++++++++++++++++++ tests/settingsPanel.test.ts | 2 + 11 files changed, 315 insertions(+), 10 deletions(-) create mode 100644 src/shell/DirectoryService.ts create mode 100644 tests/directoryNavigation.test.ts diff --git a/docs/performance-benchmarks.md b/docs/performance-benchmarks.md index 2e46da46..cd32f139 100644 --- a/docs/performance-benchmarks.md +++ b/docs/performance-benchmarks.md @@ -39,3 +39,16 @@ Full transcript wrapping and snapshot serialization exceed the editor-frame targ Structured completion filtering (500 candidates) measured p50 0.06ms / p95 0.13ms. A direct native-source probe measured cold Git parent-context capture at 226.64ms and command capture at 84.10ms. These cold operations are asynchronous and exceed the 50ms investigation target; warm parent-context results are cached for two seconds (32 contexts maximum) and locally filtered. Results are invalidated by buffer/cwd generation before rendering. Capture execution remains bounded to 1.5 seconds and 1MiB output. Structured history queries with combined cwd/exit/duration/text filters and no matches (full scan) measured p50/p95 1.39/4.25ms at 10k and 11.38/11.70ms at 100k. Queries yield every 2,048 entries and stop after at most 100 results in the composer. Journal JSON loading/projection runs in a worker to keep whole-transcript parsing off the editor thread; zsh import yields between batches. Index setup and sorting are distinct from these warm-query measurements. + +### Directory ranking (v0.8 navigation) + +Same macOS arm64 / Node 26.8.1 host, 20 samples after 3 warmups. Native aggregation yields every 2,048 history records, caches by the immutable history snapshot, and decays visit contributions over seven days. + +| Workload | p50 | p95 | +| --- | --- | --- | +| Rank 10k records | 0.65 ms | 1.61 ms | +| Rank 100k records | 6.56 ms | 9.26 ms | +| Cached fuzzy query from 10k history | 0.04 ms | 0.05 ms | +| Cached fuzzy query from 100k history | 0.02 ms | 0.03 ms | + +These fixtures contain 80 unique directories. They measure native work, not private zoxide database latency or external picker rendering. zoxide queries use a bounded temporary database copy because upstream query sorts and saves its database. diff --git a/scripts/benchmarks.ts b/scripts/benchmarks.ts index f75e39bd..a6a741dd 100644 --- a/scripts/benchmarks.ts +++ b/scripts/benchmarks.ts @@ -5,6 +5,7 @@ import {OutputBuffer} from '../src/output/OutputBuffer.js'; import {CommandEditor} from '../src/input/CommandEditor.js'; import {layoutInput} from '../src/input/inputLayout.js'; import {Highlighter} from '../src/input/Highlighter.js'; +import {rankDirectories, DirectoryService} from '../src/shell/DirectoryService.js'; import {HistoryIndex} from '../src/shell/HistoryIndex.js'; import {filterCompletions, parseNativeCompletions} from '../src/shell/completion.js'; import {planScreen} from '../src/app/screenPlan.js'; @@ -58,7 +59,7 @@ function transcript(count: number): OutputBuffer { function setup(): void { const requested = selected.length === 0 ? ['suggestions', 'transcript'] : selected; - if (selected.length === 0 || selected.some(name => name.includes('history'))) { + if (selected.length === 0 || selected.some(name => name.includes('history') || name.includes('navigation'))) { for (const count of suggestionCounts) { const entries = generatedHistory(count); histories.set(count, entries); @@ -90,7 +91,10 @@ function setup(): void { const completionFixture = parseNativeCompletions(Array.from({length: 500}, (_, index) => `--option-${index} -- description ${index}`).join('\n'), {buffer: 'tool ', cwd: '/work'}); +const directoryServices = new Map(suggestionCounts.map(count => [count, new DirectoryService()])); const benchmarks: Benchmark[] = [ + ...suggestionCounts.map(count => ({name: `navigation/rank-${count}`, run: () => rankDirectories(historyIndexes.get(count)!.all()), units: count, unitName: 'entries'})), + ...suggestionCounts.map(count => ({name: `navigation/cached-query-${count}`, run: () => directoryServices.get(count)!.query(historyIndexes.get(count)!.all(), 'pr7', 'native')})), ...suggestionCounts.map(count => ({name: `history/structured-query-${count}`, run: () => historyIndexes.get(count)!.search('cwd:/work/project-7 exit:failure duration:>1s nonexistent'), units: count, unitName: 'entries'})), {name: 'completion/filter-500', run: () => filterCompletions(completionFixture, 'op4'), units: 500, unitName: 'candidates'}, diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 96a10447..78ade790 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -19,6 +19,7 @@ import {SuggestionController} from '../suggestions/SuggestionController.js'; import {createPalette, handlePaletteKey, renderPalette, type PaletteItem, type PaletteState} from '../ui/CommandPalette.js'; import {NativeSuggestions} from '../suggestions/NativeSuggestions.js'; import {DejaSuggestions} from '../suggestions/DejaSuggestions.js'; +import {DirectoryService, directoryCommand, NAVIGATION_PROVIDERS, type DirectoryCandidate} from '../shell/DirectoryService.js'; import {openPicker, PICKER_PROVIDERS, type PickerHandoff} from '../pickers/Picker.js'; import {HISTORY_PROVIDERS} from '../shell/historyProviders.js'; import type {HistoryEntry} from '../shell/HistoryIndex.js'; @@ -92,6 +93,7 @@ import {defaultRuntimeDir} from '../session/runtimeDir.js'; /** Editor text that marks interactive history search. */ const HISTORY_SEARCH = '/history '; +const DIRECTORY_SEARCH = '/dirs '; const PRIMARY = foreground(UI_COLORS.primary); const SECONDARY = foreground(UI_COLORS.secondary); const SUBTLE = foreground(UI_COLORS.subtle); @@ -138,6 +140,10 @@ export class TerminalApp { private shellSuggestions: CompletionCandidate[] = []; private lastSuggestionInput = ""; private completionGeneration = 0; + private readonly directoryService = new DirectoryService(); + private directoryQuery?: string; + private directoryQueryAbort?: AbortController; + private directoryResults: DirectoryCandidate[] = []; private pickerOpening = false; private pickerAbort?: AbortController; private historyQuery?: string; @@ -788,7 +794,7 @@ export class TerminalApp { return; } - if (!this.running && !this.historySearchActive && !this.editor.text.startsWith('/') && this.shellSuggestions.length > 0 + if (!this.running && !this.historySearchActive && !this.directorySearchActive && !this.editor.text.startsWith('/') && this.shellSuggestions.length > 0 && !this.suggestions.alternativesOpen) { const action = resolveAction(COMPLETION_ACTIONS, key); if (action?.id === 'move') { @@ -814,7 +820,7 @@ export class TerminalApp { } // History search navigates its own matches; other slash text navigates slash commands. - const suggestions = this.historySearchActive + const suggestions = this.directorySearchActive ? this.directoryMatches(this.editor.text.substring(DIRECTORY_SEARCH.length)) : this.historySearchActive ? this.historyMatches(this.editor.text.substring(HISTORY_SEARCH.length)) : this.editor.hasPasteAtoms ? [] : slashSuggestions(this.editor.text); const isSlash = !this.editor.hasPasteAtoms && this.editor.text.startsWith('/'); @@ -829,7 +835,7 @@ export class TerminalApp { if (suggestion) this.applySuggestion(suggestion); } else if (action === 'slash-suggestion') { const slash = suggestions[Math.min(this.selectedSuggestion, suggestions.length - 1)]; - if (slash) this.applySuggestion({insertion: slash.name}); + if (slash) this.applySuggestion({insertion: this.historySearchActive || this.directorySearchActive ? slash.insertion : slash.name}); } return; } else if (key.kind === 'text') { @@ -960,6 +966,9 @@ export class TerminalApp { private async fetchSuggestions(): Promise { const input = this.editor.text; + if (!this.directorySearchActive && this.directoryQuery !== undefined) { + this.directoryQueryAbort?.abort(); this.directoryQuery = undefined; this.directoryResults = []; + } if (!this.historySearchActive && this.historyQuery !== undefined) { this.historyQueryAbort?.abort(); this.historyQuery = undefined; @@ -1016,6 +1025,25 @@ export class TerminalApp { } finally { this.pickerOpening = false; } } + private async openDirectoryPicker(query: string): Promise { + if (this.running || this.externalPassthrough || this.pickerOpening) return; + this.pickerOpening = true; + try { + const original = this.editor.text; + const native = () => this.applySuggestion({insertion: `${DIRECTORY_SEARCH}${query}`}); + const directories = this.promptConfiguration.picker === 'native' ? [] : await this.directoryService.query( + this.historyService.index.all(), query, this.promptConfiguration.navigation); + 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); + 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); + this.render(); + } finally { this.pickerOpening = false; } + } + /** External pickers temporarily own the host terminal, never the managed shell PTY. */ private readonly pickerHandoff: PickerHandoff = async run => { if (!process.stdin.isTTY || !process.stdout.isTTY || this.running || this.passthrough || this.externalPassthrough) @@ -1077,6 +1105,12 @@ export class TerminalApp { if (command.startsWith(HISTORY_SEARCH)) this.submitHistorySearch(slash.query, true); else await this.openHistoryPicker(slash.query); } + else if (slash.kind === 'directories') { + if (command.startsWith(DIRECTORY_SEARCH)) { + const selected = this.directoryMatches(slash.query)[this.selectedSuggestion]; + this.applySuggestion({insertion: selected?.insertion ?? `${DIRECTORY_SEARCH}${slash.query}`}); + } else await this.openDirectoryPicker(slash.query); + } else if (slash.kind === 'palette') this.openPalette(); else this.output.addFrontendInteraction(command, `Unknown NMSh command: ${(slash as any).input || command}`, ERROR); } @@ -1529,6 +1563,8 @@ export class TerminalApp { completedRecord.startedAt = command.startedAt; completedRecord.durationMs = Math.max(0, elapsed); completedRecord.historyEligible = command.historyAllowed === 1 && !isPrivateCommand(command.command, ignorePatternFromEnv()); + this.directoryQuery = undefined; + this.directoryQueryAbort?.abort(); this.historyService.record(completedRecord, this.journal?.id ?? this.sessionId ?? 'current'); this.historyQuery = undefined; } @@ -2163,7 +2199,7 @@ export class TerminalApp { else if (destination === 'transcript') this.startTranscriptSettings(); else if (destination === 'syntax') this.startSyntaxSettings(); else if (destination === 'layout') this.startLayoutSettings(); - else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history' || destination === 'picker') this.startProviderPanel(destination); + else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history' || destination === 'picker' || destination === 'navigation') this.startProviderPanel(destination); else void this.startKeyboard(); } @@ -2198,6 +2234,8 @@ export class TerminalApp { {label: 'Composer', value: layoutLabel(config)}, {label: 'Glyph style', value: config.glyphStyle === 'nerd' ? 'Nerd Font' : 'Safe / ASCII'}, {label: 'Syntax', value: !config.syntax.highlighting ? 'Off' : config.syntax.colors === 'followPrompt' ? 'Follow prompt theme' : config.syntax.colors === 'theme' ? 'Choose theme' : 'Grayscale'}, + {label: 'Directory navigation', value: this.directoryService.status.detail ?? this.directoryService.status.active}, + {label: 'Picker', value: this.promptConfiguration.picker}, {label: 'Command history', value: this.historyService.status.detail ?? (this.historyService.status.active === 'atuin' ? 'Atuin · local read-only' : 'NMSh Native')}, {label: 'History colors', value: config.transcript.historyColors === 'followPrompt' ? 'Follow prompt' : config.transcript.historyColors === 'theme' ? 'Theme' : 'Grayscale'}, ], @@ -2330,9 +2368,10 @@ export class TerminalApp { }); } - private startProviderPanel(family: 'welcome' | 'suggestions' | 'history' | 'picker'): void { + private startProviderPanel(family: 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'): void { const state: ProviderPanelState = family === 'welcome' ? createProviderPanel(family, 'Welcome', WELCOME_PROVIDERS, this.promptConfiguration.welcome) + : family === 'navigation' ? createProviderPanel(family, 'Directory navigation', NAVIGATION_PROVIDERS, this.promptConfiguration.navigation) : family === 'picker' ? createProviderPanel(family, 'Picker', PICKER_PROVIDERS, this.promptConfiguration.picker) : family === 'history' ? createProviderPanel(family, 'Command history', HISTORY_PROVIDERS, this.promptConfiguration.history) : createProviderPanel(family, 'Suggestions', SUGGESTION_PROVIDERS, this.promptConfiguration.suggestions); @@ -2349,6 +2388,7 @@ export class TerminalApp { /** The highlighted provider rendered by its own family; captures are cached per panel. */ private providerPreview(state: ProviderPanelState, width: number): string[] { const selected = providerPanelSelection(state); + if (state.family === 'navigation') return [`${SUBTLE}Find with /dirs; selecting inserts a visible cd command. Press Enter separately to navigate.${RESET}`]; if (state.family === 'picker') return [`${SUBTLE}Selections restore the composer; cancel leaves it unchanged. Missing or failing tools use Native.${RESET}`]; if (state.family === 'history') return [`${SUBTLE}${selected.id === 'atuin' ? 'Read-only local history; existing hooks unchanged; no sync.' : 'Shell-approved journal metadata and imported zsh history.'}${RESET}`]; if (state.family === 'suggestions') { @@ -2402,6 +2442,7 @@ export class TerminalApp { const selected = providerPanelSelection(state); const next = state.family === 'welcome' ? {...structuredClone(this.promptConfiguration), welcome: selected.id as PromptConfiguration['welcome']} + : state.family === 'navigation' ? {...structuredClone(this.promptConfiguration), navigation: selected.id as PromptConfiguration['navigation']} : state.family === 'picker' ? {...structuredClone(this.promptConfiguration), picker: selected.id as PromptConfiguration['picker']} : state.family === 'history' ? {...structuredClone(this.promptConfiguration), history: selected.id as PromptConfiguration['history']} : {...structuredClone(this.promptConfiguration), suggestions: selected.id as PromptConfiguration['suggestions']}; @@ -2411,6 +2452,7 @@ export class TerminalApp { this.providerPanelState = undefined; if (state.family === 'suggestions') this.applySuggestionProvider(); if (state.family === 'history') void this.loadHistory(); + if (state.family === 'navigation') { this.directoryQueryAbort?.abort(); this.directoryQuery = undefined; this.directoryResults = []; } this.output.addHistoryLine(state.family === 'welcome' ? `${SUCCESS}Welcome · ${selected.label} · shown on launch and /clear.${RESET}` : `${SUCCESS}${state.title} · ${selected.label}.${RESET}`); @@ -2651,6 +2693,26 @@ export class TerminalApp { return !this.editor.hasPasteAtoms && this.editor.text.startsWith(HISTORY_SEARCH); } + private get directorySearchActive(): boolean { + return !this.editor.hasPasteAtoms && this.editor.text.startsWith(DIRECTORY_SEARCH); + } + + private directoryMatches(query: string): Array<{name: string; insertion: string; description: string}> { + if (query !== this.directoryQuery) { + this.directoryQuery = query; + this.directoryQueryAbort?.abort(); + const active = new AbortController(); + this.directoryQueryAbort = active; + this.directoryResults = []; + void this.directoryService.query(this.historyService.index.all(), query, this.promptConfiguration.navigation, active.signal).then(items => { + if (this.stopped || active.signal.aborted || this.directoryQuery !== query) return; + this.directoryResults = items; this.selectedSuggestion = 0; this.render(); + }).catch(() => {}); + } + return this.directoryResults.map(item => ({name: item.path, insertion: directoryCommand(item.path), + description: [item.project, item.visits === undefined ? 'zoxide' : `${item.visits} visits`].filter(Boolean).join(' · ')})); + } + private historyMatches(query: string): Array<{id: string; name: string; insertion: string; description: string}> { if (query !== this.historyQuery) { this.historyQuery = query; @@ -2673,6 +2735,7 @@ export class TerminalApp { /** Composer suggestion rows for the current editor state; the same list render paints and geometry counts. */ private composerSuggestions(): any[] { if (this.running || this.settingsPanelActive) return []; + if (this.directorySearchActive) return this.directoryMatches(this.editor.text.substring(DIRECTORY_SEARCH.length)); if (this.historySearchActive) return this.historyMatches(this.editor.text.substring(HISTORY_SEARCH.length)); if (!this.editor.hasPasteAtoms && this.editor.text.startsWith('/')) return slashSuggestions(this.editor.text); const alternatives = this.suggestions.alternatives(); @@ -2932,6 +2995,7 @@ export class TerminalApp { this.renderer.leave(); this.completionService.cancel(); this.historyQueryAbort?.abort(); + this.directoryQueryAbort?.abort(); this.pickerAbort?.abort(); this.historyService.dispose(); this.semanticService.kill(); diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 3f7b378b..0ad03782 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -24,6 +24,7 @@ export const slashCommands: readonly SlashCommand[] = [ {name: '/resume', insertion: '/resume', description: 'Browse archived NMSh transcripts'}, {name: '/help', insertion: '/help', description: 'Show NMSh commands'}, {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'}, ]; @@ -43,6 +44,7 @@ export type ParsedSlashCommand = | {kind: 'resume'} | {kind: 'help'} | {kind: 'palette'} + | {kind: 'directories', query: string} | {kind: 'history', query: string} | {kind: 'unknown'; input: string}; @@ -66,6 +68,8 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (/^\/resume\s*$/u.test(input)) return {kind: 'resume'}; if (/^\/help\s*$/u.test(input)) return {kind: 'help'}; if (/^\/palette\s*$/u.test(input)) return {kind: 'palette'}; + const directories = /^\/dirs(?:\s+([\s\S]*))?$/u.exec(input); + if (directories) return {kind: 'directories', query: (directories[1] ?? '').trim()}; const history = /^\/history(?:\s+([\s\S]*))?$/u.exec(input); if (history) return {kind: 'history', query: (history[1] ?? '').trim()}; return {kind: 'unknown', input}; diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index f8320dc9..e443741d 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -18,6 +18,12 @@ Use /history with plain text or combine cwd:, project:, exit:, before:, after:, Enter or Tab restores the selected command without executing it. Ctrl+X removes the selected record from NMSh search. Session transcripts and the original zsh/Atuin history remain intact; deletion is remembered locally. +## Directory navigation + +Use /dirs or the command palette to find recorded directories. Native ranks frequency and recency from approved command history. Select with arrows and Enter or Tab to insert a literal cd command, then press Enter separately to run it in zsh. Ordinary cd keeps its normal behavior. + +Config offers optional zoxide ranking and optional fzf/Television pickers. zoxide reads a temporary copy of the existing database; hooks and the original database remain unchanged. Missing or failing tools use Native. + ## Tips - A large multiline paste is **one editable atom**; Enter submits its original text. Press Ctrl+O beside it to inspect or unwrap. diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 9475d70d..fd80a992 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -8,6 +8,7 @@ export type LiveSessionStartup = typeof LIVE_SESSION_STARTUP[number]; export const LIVE_SESSION_MULTIPLE = ['ask', 'open-all'] as const; export type LiveSessionMultiple = typeof LIVE_SESSION_MULTIPLE[number]; import type {OutputFoldingMode} from '../output/FoldPolicy.js'; +import type {NavigationProviderId} from '../shell/DirectoryService.js'; import type {PickerProviderId} from '../pickers/Picker.js'; import type {HistoryProviderId} from '../shell/historyProviders.js'; import {SUGGESTION_PROVIDER_IDS, type SuggestionProviderId} from '../suggestions/types.js'; @@ -190,6 +191,7 @@ export interface PromptConfiguration { /** Native default; Atuin is an explicit local read-only source. */ history: HistoryProviderId; picker: PickerProviderId; + navigation: NavigationProviderId; /** Predict a whole command on an empty prompt from the previous one. */ suggestionsOnEmpty: boolean; nmsh: { @@ -244,6 +246,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { suggestions: 'nmsh', history: 'native', picker: 'native', + navigation: 'native', suggestionsOnEmpty: false, 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', @@ -308,6 +311,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio ? value.welcome as WelcomeProviderId : 'vespyr'; const suggestions: SuggestionProviderId = SUGGESTION_PROVIDER_IDS.includes(value.suggestions as SuggestionProviderId) ? value.suggestions as SuggestionProviderId : 'nmsh'; + const navigation: NavigationProviderId = value.navigation === 'zoxide' ? 'zoxide' : 'native'; const picker: PickerProviderId = value.picker === 'fzf' || value.picker === 'television' ? value.picker : 'native'; const history: HistoryProviderId = value.history === 'atuin' ? 'atuin' : 'native'; const suggestionsOnEmpty = value.suggestionsOnEmpty === true; @@ -356,7 +360,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, - glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, suggestionsOnEmpty, + glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, placement, composerLayout, composerPosition, transcriptPresentation, spacing, gap, separator}; } @@ -397,7 +401,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio modules.splice(before === -1 ? modules.length : before, 0, {...fallback}); }); - return {provider, onboardingComplete: value.onboardingComplete === true, glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, suggestionsOnEmpty, nmsh, transcript, syntax, powerlevel10k, + return {provider, onboardingComplete: value.onboardingComplete === true, glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, nmsh, transcript, syntax, powerlevel10k, starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, transcriptPresentation, modules, separator, spacing, gap}; } diff --git a/src/providers/providers.ts b/src/providers/providers.ts index e9e4578b..d468363c 100644 --- a/src/providers/providers.ts +++ b/src/providers/providers.ts @@ -7,7 +7,7 @@ import {delimiter, join} from 'node:path'; * runtime interface (prompt render, welcome render, suggestion query, ...); * this module only describes providers and how their availability looks. */ -export type ProviderFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker'; +export type ProviderFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'; export type ProviderKind = 'native' | 'external' | 'none'; export interface ProviderInstall { diff --git a/src/shell/DirectoryService.ts b/src/shell/DirectoryService.ts new file mode 100644 index 00000000..4e32c399 --- /dev/null +++ b/src/shell/DirectoryService.ts @@ -0,0 +1,110 @@ +import {readFile, writeFile, mkdtemp, rm, stat} from 'node:fs/promises'; +import {homedir, tmpdir} from 'node:os'; +import {isAbsolute, join} from 'node:path'; +import {resolveCommand, runExternal, type ProviderDescriptor} from '../providers/providers.js'; +import {fuzzyMatch} from '../suggestions/NativeSuggestions.js'; +import type {HistoryEntry} from './HistoryIndex.js'; + +export type NavigationProviderId = 'native' | 'zoxide'; +export interface DirectoryCandidate {path: string; score: number; visits?: number; project?: string} +export const NAVIGATION_PROVIDERS: readonly ProviderDescriptor[] = [ + {id: 'native', family: 'navigation', label: 'NMSh Native', kind: 'native', description: 'frequency and recency from approved command history'}, + {id: 'zoxide', family: 'navigation', label: 'zoxide', kind: 'external', executable: 'zoxide', versionArgs: ['--version'], + description: 'rank your existing directories through a private database snapshot'}, +]; +const safePath = (path: string): boolean => isAbsolute(path) && !/[\u0000-\u001f\u007f-\u009f]/u.test(path); +/** Literal zsh argument; expansions, option parsing and aliases cannot turn a path into code. */ +export function directoryCommand(path: string): string { + if (!safePath(path)) throw new Error('Invalid directory path'); + return `cd -- '${path.replace(/'/gu, "'\\''")}'`; +} + +/** Recent visits contribute more; the aggregate yields regularly on large histories. */ +export async function rankDirectories(entries: readonly HistoryEntry[], now = Date.now(), signal?: AbortSignal): Promise { + const paths = new Map(); + for (let index = 0; index < entries.length; index++) { + if (signal?.aborted) return []; + const entry = entries[index]!; + if (entry.cwd && safePath(entry.cwd)) { + const candidate = paths.get(entry.cwd) ?? {path: entry.cwd, score: 0, visits: 0, project: entry.project}; + candidate.visits = (candidate.visits ?? 0) + 1; + const days = entry.at === undefined ? 30 : Math.max(0, now - entry.at) / 86_400_000; + candidate.score += 1 / (1 + days / 7); + paths.set(candidate.path, candidate); + } + if (index % 2048 === 2047) await new Promise(resolve => setImmediate(resolve)); + } + return [...paths.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)); +} +export function filterDirectories(candidates: readonly DirectoryCandidate[], query: string): DirectoryCandidate[] { + const needle = query.trim().toLowerCase(); + return candidates.filter(candidate => !needle || fuzzyMatch(needle, candidate.path.toLowerCase()) !== undefined).slice(0, 500); +} +export function parseZoxide(output: string): DirectoryCandidate[] { + const candidates = new Map(); + for (const line of output.split('\n')) { + const match = /^\s*(\d+(?:\.\d+)?)\s+(.+)$/u.exec(line); + if (!match || !safePath(match[2]!)) continue; + const score = Number(match[1]); + if (Number.isFinite(score)) candidates.set(match[2]!, {path: match[2]!, score}); + } + return [...candidates.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)); +} + +export function zoxideDirectory(env: NodeJS.ProcessEnv, platform = process.platform): string | undefined { + if (env._ZO_DATA_DIR) return isAbsolute(env._ZO_DATA_DIR) ? env._ZO_DATA_DIR : undefined; + const home = env.HOME ?? homedir(); + return platform === 'darwin' ? join(home, 'Library', 'Application Support', 'zoxide') + : platform === 'linux' ? join(env.XDG_DATA_HOME || join(home, '.local', 'share'), 'zoxide') : undefined; +} + +export class DirectoryService { + private nativeSnapshot?: readonly HistoryEntry[]; + private nativeCache: DirectoryCandidate[] = []; + private nativeAt = 0; + private zoxideCache?: {at: number; candidates: DirectoryCandidate[]}; + status: {active: NavigationProviderId; detail?: string} = {active: 'native'}; + constructor(private readonly env: NodeJS.ProcessEnv = process.env) {} + + async query(entries: readonly HistoryEntry[], query: string, provider: NavigationProviderId, signal?: AbortSignal): Promise { + if (signal?.aborted) return []; + if (provider === 'zoxide') { + try { + if (!this.zoxideCache || Date.now() - this.zoxideCache.at > 30_000) { + const candidates = await this.queryZoxide(signal); + if (signal?.aborted) return []; + this.zoxideCache = {at: Date.now(), candidates}; + } + this.status = {active: 'zoxide'}; + return filterDirectories(this.zoxideCache.candidates, query); + } catch { this.status = {active: 'native', detail: 'zoxide unavailable; using Native'}; } + } else this.status = {active: 'native'}; + if (this.nativeSnapshot !== entries || Date.now() - this.nativeAt > 60_000) { + const candidates = await rankDirectories(entries, Date.now(), signal); + if (signal?.aborted) return []; + this.nativeSnapshot = entries; this.nativeAt = Date.now(); this.nativeCache = candidates; + } + return signal?.aborted ? [] : filterDirectories(this.nativeCache, query); + } + + private async queryZoxide(signal?: AbortSignal): Promise { + const binary = resolveCommand('zoxide', this.env.PATH ?? '', []); + const source = zoxideDirectory(this.env); + if (!binary || !source) throw new Error('zoxide not available'); + const file = join(source, 'db.zo'); + if ((await stat(file)).size > 16 * 1024 * 1024) throw new Error('zoxide database too large'); + const data = await readFile(file); + if (data.length > 16 * 1024 * 1024) throw new Error('zoxide database too large'); + const directory = await mkdtemp(join(tmpdir(), 'nmsh-zoxide-')); + try { + await writeFile(join(directory, 'db.zo'), data, {mode: 0o600}); + // zoxide query sorts/saves its database even with --all. Only the copy is writable. + const result = await runExternal(binary, ['query', '--list', '--score', '--all'], + {env: {...this.env, _ZO_DATA_DIR: directory}, signal, timeoutMs: 1500, maxBytes: 4 * 1024 * 1024}); + if (!result.ok) throw new Error('zoxide query failed'); + const candidates = parseZoxide(result.stdout); + if (result.stdout.trim() && candidates.length === 0) throw new Error('Unrecognized zoxide output'); + return candidates; + } finally { await rm(directory, {recursive: true, force: true}); } + } +} diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index ad7824e7..a7d425a3 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -13,6 +13,7 @@ import { import {providerLabel} from '../prompt/PromptPanel.js'; import {welcomeProvider} from '../output/WelcomeProviders.js'; import {PROMPT_STYLES, PROMPT_STYLE_LABELS} from '../prompt/powerline.js'; +import {NAVIGATION_PROVIDERS} from '../shell/DirectoryService.js'; import {PICKER_PROVIDERS} from '../pickers/Picker.js'; import {HISTORY_PROVIDERS} from '../shell/historyProviders.js'; import {SUGGESTION_PROVIDERS} from '../suggestions/types.js'; @@ -61,7 +62,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'; +export type SettingsDestination = 'glyph' | 'appearance' | 'prompt' | 'transcript' | 'syntax' | 'layout' | 'keyboard' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'; interface SettingsRowBase { id: string; @@ -155,6 +156,8 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ control: 'child', destination: 'history', value: config => HISTORY_PROVIDERS.find(provider => provider.id === config.history)?.label ?? config.history}, {id: 'picker', label: 'Picker provider', description: 'Native composer search, fzf, or Television; selections never execute', category: 'History', control: 'child', destination: 'picker', value: config => PICKER_PROVIDERS.find(provider => provider.id === config.picker)?.label ?? config.picker}, + {id: 'navigation', label: 'Directory navigation', description: 'Native command-history frecency or read-only zoxide snapshot', category: 'History', + control: 'child', destination: 'navigation', value: config => NAVIGATION_PROVIDERS.find(provider => provider.id === config.navigation)?.label ?? config.navigation}, {id: 'suggestionsOnEmpty', level: 'advanced', label: 'Empty-prompt prediction', description: 'Suggest the likely next command before typing', category: 'Suggestions', control: 'boolean', get: config => config.suggestionsOnEmpty, set: (config, suggestionsOnEmpty) => ({...config, suggestionsOnEmpty})}, ]; diff --git a/tests/directoryNavigation.test.ts b/tests/directoryNavigation.test.ts new file mode 100644 index 00000000..d34635d2 --- /dev/null +++ b/tests/directoryNavigation.test.ts @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp, mkdir, readFile, writeFile, rm} from 'node:fs/promises'; +import {join} from 'node:path'; +import {tmpdir} from 'node:os'; +import {DirectoryService, directoryCommand, rankDirectories, filterDirectories, parseZoxide, zoxideDirectory} from '../src/shell/DirectoryService.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {paletteItems} from '../src/ui/CommandPalette.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {resolveCommand, runExternal} from '../src/providers/providers.js'; +import type {HistoryEntry} from '../src/shell/HistoryIndex.js'; + +test('native frecency balances visits and recency, filters fuzzily and yields for 100k records', async () => { + const now = 1_800_000_000_000; + const entry = (id: string, cwd: string, at = now): HistoryEntry => ({id, cwd, at, command: 'pwd', source: 'nmsh'}); + const ranked = await rankDirectories([entry('1', '/recent'), entry('2', '/frequent'), entry('3', '/frequent'), + ...Array.from({length: 10}, (_, i) => entry(`old${i}`, '/old', now - 365 * 86_400_000)), entry('bad', '/control\u001b')], now); + assert.deepEqual(ranked.map(item => item.path), ['/frequent', '/recent', '/old']); + assert.deepEqual(filterDirectories(ranked, 'frq').map(item => item.path), ['/frequent']); + const entries = Array.from({length: 100_000}, (_, i) => entry(String(i), `/project${i % 100}`)); + let responsive = false; + setImmediate(() => { responsive = true; }); + assert.equal((await rankDirectories(entries, now)).length, 100); + assert.equal(responsive, true); + const controller = new AbortController(); controller.abort(); + assert.deepEqual(await rankDirectories(entries, now, controller.signal), []); +}); + +test('directory command is a literal real cd; paths cannot inject expansions or options', async () => { + const path = "/work/a'$(touch unwanted); spaced"; + const command = directoryCommand(path); + const result = await runExternal('/bin/zsh', ['-f', '-c', `${command.replace(/^cd -- /u, 'print -r -- ')}\n`]); + assert.equal(result.stdout.trimEnd(), path); + assert.equal(directoryCommand('/-danger'), "cd -- '/-danger'"); + assert.throws(() => directoryCommand('relative')); + assert.throws(() => directoryCommand('/bad\nline')); + assert.equal(parseSlashCommand('cd ../work'), undefined); + assert.equal(normalizePromptConfiguration({}).navigation, 'native'); + assert.equal(normalizePromptConfiguration({navigation: 'zoxide'}).navigation, 'zoxide'); + assert.ok(paletteItems().some(item => item.id === 'slash:/dirs')); +}); + +test('zoxide query uses a temporary copy, caches results and falls back on missing or bad tools', async () => { + const directory = await mkdtemp(join(tmpdir(), 'nmsh-directory-test-')); + try { + await writeFile(join(directory, 'db.zo'), 'original', {mode: 0o600}); + const binary = join(directory, 'zoxide'); + await writeFile(binary, `#!${process.execPath}\nconst fs=require('node:fs');if(process.env._ZO_DATA_DIR===process.env.ORIGINAL_DB)process.exit(2);fs.writeFileSync(process.env._ZO_DATA_DIR+'/db.zo','sorted');fs.appendFileSync(process.env.CALLS,JSON.stringify(process.argv.slice(2))+'\\n');process.stdout.write('20.5 /work/one\\n 4.0 /work/two spaces\\n');\n`, {mode: 0o700}); + const calls = join(directory, 'calls'); + const service = new DirectoryService({...process.env, PATH: directory, _ZO_DATA_DIR: directory, ORIGINAL_DB: directory, CALLS: calls}); + assert.equal((await service.query([], '', 'zoxide'))[0]?.path, '/work/one'); + assert.equal((await service.query([], 'two', 'zoxide'))[0]?.path, '/work/two spaces'); + assert.equal(await readFile(join(directory, 'db.zo'), 'utf8'), 'original'); + assert.deepEqual(JSON.parse((await readFile(calls, 'utf8')).trim()), ['query', '--list', '--score', '--all']); + const fallback = new DirectoryService({PATH: '', HOME: directory}); + assert.equal((await fallback.query([{id: '1', source: 'nmsh', command: 'pwd', cwd: '/native'}], '', 'zoxide'))[0]?.path, '/native'); + assert.equal(fallback.status.active, 'native'); + assert.deepEqual(parseZoxide('garbage\n3 relative\n5 /valid\n8 /invalid\u001b'), [{path: '/valid', score: 5}]); + assert.equal(zoxideDirectory({_ZO_DATA_DIR: 'relative'}), undefined); + } finally { await rm(directory, {recursive: true, force: true}); } +}); + +test('directory selection inserts only; next Enter uses the normal shell submit path', async () => { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + const submitted: string[] = []; + app['session'].submit = ((text: string) => submitted.push(text)) as never; + try { + app['editor'].insert('/dirs '); + app['directoryQuery'] = ''; + app['directoryResults'] = [{path: '/work space', score: 1, visits: 1}]; + app['handleKey']({kind: 'complete'}); + assert.equal(app['editor'].text, "cd -- '/work space'"); + assert.deepEqual(submitted, []); + await app['submit'](); + assert.deepEqual(submitted, ["cd -- '/work space'"]); + assert.equal(app['running']?.command, "cd -- '/work space'"); + } finally { app['stop'](0); app['session'].kill(); } +}); + +test('installed zoxide ranks a fixture without modifying its source database', async t => { + const binary = resolveCommand('zoxide'); + if (!binary) { t.skip('zoxide is optional'); return; } + const directory = await mkdtemp(join(tmpdir(), 'nmsh-zoxide-fixture-')); + try { + const target = join(directory, 'target'); await mkdir(target); + const env = {...process.env, PATH: binary.slice(0, binary.lastIndexOf('/')), _ZO_DATA_DIR: directory}; + assert.equal((await runExternal(binary, ['add', target], {env})).ok, true); + const before = await readFile(join(directory, 'db.zo')); + const items = await new DirectoryService(env).query([], '', 'zoxide'); + assert.equal(items[0]?.path, target); + assert.deepEqual(await readFile(join(directory, 'db.zo')), before); + } finally { await rm(directory, {recursive: true, force: true}); } +}); diff --git a/tests/settingsPanel.test.ts b/tests/settingsPanel.test.ts index f27a3e1a..02d32277 100644 --- a/tests/settingsPanel.test.ts +++ b/tests/settingsPanel.test.ts @@ -124,6 +124,8 @@ test('Status is read-only, uses no secrets, and marks unknown build identity qui for (const key of [{kind: 'down'}, {kind: 'enter'}, {kind: 'text', value: ' '}, {kind: 'text', value: '/'}] as const) app['handleKey'](key); assert.equal(JSON.stringify(app['promptConfiguration']), before); assert.equal(app['settingsPanelState']!.searchFocused, undefined, '/ does not search in Status'); + // Additional provider status may overflow one screen. Verify the first viewport explicitly. + app['settingsPanelState']!.contentIndex = 0; const rows = app['settingsPanelRows'](80).map(stripAnsi).join('\n'); assert.match(rows, /Version:/u); assert.match(rows, /Session journal:\s+(active|inactive)/u); From 8b8e19758427cede31b66f5120031394ac001261 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:58:49 +0530 Subject: [PATCH 10/82] Suggest conservative command-not-found corrections without execution --- src/app/TerminalApp.ts | 40 ++++++++++++ src/help/helpContent.ts | 4 ++ src/shell/CommandCorrection.ts | 100 +++++++++++++++++++++++++++++ tests/commandCorrection.test.ts | 109 ++++++++++++++++++++++++++++++++ tests/completionMenu.test.ts | 1 + 5 files changed, 254 insertions(+) create mode 100644 src/shell/CommandCorrection.ts create mode 100644 tests/commandCorrection.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 78ade790..83ac4b70 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -19,6 +19,7 @@ import {SuggestionController} from '../suggestions/SuggestionController.js'; import {createPalette, handlePaletteKey, renderPalette, type PaletteItem, type PaletteState} from '../ui/CommandPalette.js'; import {NativeSuggestions} from '../suggestions/NativeSuggestions.js'; import {DejaSuggestions} from '../suggestions/DejaSuggestions.js'; +import {CommandCorrectionService, CORRECTION_ACTIONS, renderCorrection, type CommandCorrection} from '../shell/CommandCorrection.js'; import {DirectoryService, directoryCommand, NAVIGATION_PROVIDERS, type DirectoryCandidate} from '../shell/DirectoryService.js'; import {openPicker, PICKER_PROVIDERS, type PickerHandoff} from '../pickers/Picker.js'; import {HISTORY_PROVIDERS} from '../shell/historyProviders.js'; @@ -140,6 +141,9 @@ export class TerminalApp { private shellSuggestions: CompletionCandidate[] = []; private lastSuggestionInput = ""; private completionGeneration = 0; + private readonly correctionService = new CommandCorrectionService(); + private correction?: CommandCorrection; + private correctionAbort?: AbortController; private readonly directoryService = new DirectoryService(); private directoryQuery?: string; private directoryQueryAbort?: AbortController; @@ -767,6 +771,7 @@ export class TerminalApp { return; } if (key.kind === 'interrupt') { + this.clearCorrection(); if (this.running) { this.running.interrupted = true; this.editor.clear(); @@ -794,6 +799,21 @@ export class TerminalApp { return; } + if (this.correction && !this.running && this.editor.text.length === 0) { + const action = resolveAction(CORRECTION_ACTIONS, key); + if (action?.id === 'insert') { + this.applySuggestion(this.correction); + this.clearCorrection(); + return; + } + if (action?.id === 'dismiss') { this.clearCorrection(); return; } + } + if (key.kind === 'text' || key.kind === 'paste' || key.kind === 'enter') this.clearCorrection(); + // Input can contain several decoded keys before the next render; guard stale candidates here too. + if (this.shellSuggestions.some(candidate => candidate.context && (candidate.context.buffer !== this.editor.text || candidate.context.cwd !== this.context.cwd))) { + this.shellSuggestions = []; this.completionGeneration += 1; this.completionService.cancel(); + } + if (!this.running && !this.historySearchActive && !this.directorySearchActive && !this.editor.text.startsWith('/') && this.shellSuggestions.length > 0 && !this.suggestions.alternativesOpen) { const action = resolveAction(COMPLETION_ACTIONS, key); @@ -1166,6 +1186,7 @@ export class TerminalApp { } private async submit(): Promise { + this.clearCorrection(); const command = this.editor.text; this.editor.clear(); if (!command.trim()) return; @@ -1582,6 +1603,7 @@ export class TerminalApp { this.output.addHistoryLine(`${rowStyle}${parts.main}${SECONDARY}${parts.detail}${RESET}`); } this.running = undefined; + if (!this.replaying && !command.interrupted && !command.cleared) void this.suggestCorrection(command.command, exitCode, completedRecord?.output ?? '').catch(() => {}); void this.journal?.flush().catch(() => { this.output.addFrontendInteraction('/resume', 'Could not persist the completed command.', ERROR); }); @@ -2693,6 +2715,21 @@ export class TerminalApp { return !this.editor.hasPasteAtoms && this.editor.text.startsWith(HISTORY_SEARCH); } + private clearCorrection(): void { + this.correctionAbort?.abort(); this.correctionAbort = undefined; this.correction = undefined; + } + + private async suggestCorrection(command: string, exitCode: number, output: string): Promise { + this.clearCorrection(); + if (exitCode !== 127 || this.editor.text || this.running) return; + const active = new AbortController(); + this.correctionAbort = active; + const correction = await this.correctionService.suggest(command, exitCode, output, active.signal); + if (!this.stopped && !active.signal.aborted && !this.editor.text && !this.running && this.correctionAbort === active) { + this.correction = correction; this.render(); + } + } + private get directorySearchActive(): boolean { return !this.editor.hasPasteAtoms && this.editor.text.startsWith(DIRECTORY_SEARCH); } @@ -2735,6 +2772,7 @@ export class TerminalApp { /** Composer suggestion rows for the current editor state; the same list render paints and geometry counts. */ private composerSuggestions(): any[] { if (this.running || this.settingsPanelActive) return []; + if (this.correction && this.editor.text.length === 0) return [this.correction]; if (this.directorySearchActive) return this.directoryMatches(this.editor.text.substring(DIRECTORY_SEARCH.length)); if (this.historySearchActive) return this.historyMatches(this.editor.text.substring(HISTORY_SEARCH.length)); if (!this.editor.hasPasteAtoms && this.editor.text.startsWith('/')) return slashSuggestions(this.editor.text); @@ -2862,6 +2900,7 @@ export class TerminalApp { ? [...panelRows.slice(1), panelRows[0]!] : panelRows ?? []; case 'suggestions': return 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) return renderCompletion(suggestion, selected, columns); return truncateAnsi( `${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ${suggestion.name.padEnd(10)}${RESET}${SECONDARY} ${suggestion.description}${RESET}`, @@ -2995,6 +3034,7 @@ export class TerminalApp { this.renderer.leave(); this.completionService.cancel(); this.historyQueryAbort?.abort(); + this.clearCorrection(); this.directoryQueryAbort?.abort(); this.pickerAbort?.abort(); this.historyService.dispose(); diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index e443741d..83909c39 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -24,6 +24,10 @@ Use /dirs or the command palette to find recorded directories. Native ranks freq Config offers optional zoxide ranking and optional fzf/Television pickers. zoxide reads a temporary copy of the existing database; hooks and the original database remain unchanged. Missing or failing tools use Native. +## Command correction + +After an unambiguous simple command-not-found typo, NMSh may show a local executable correction below the composer. Tab places it in the editor; review and press Enter separately. Esc dismisses it. Complex expressions, ambiguous matches and destructive targets are suppressed. The suggestion is frontend UI and stays out of command output, copy and history. + ## Tips - A large multiline paste is **one editable atom**; Enter submits its original text. Press Ctrl+O beside it to inspect or unwrap. diff --git a/src/shell/CommandCorrection.ts b/src/shell/CommandCorrection.ts new file mode 100644 index 00000000..59c05849 --- /dev/null +++ b/src/shell/CommandCorrection.ts @@ -0,0 +1,100 @@ +import {access, readdir, stat} from 'node:fs/promises'; +import {constants} from 'node:fs'; +import {delimiter, isAbsolute, join} from 'node:path'; +import {stripAnsi, truncateAnsi} from '../util/text.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {renderActionHelp, type UiAction} from '../ui/actions.js'; + +export interface CommandCorrection {correction: true; name: string; insertion: string; original: string; description: string} +export const CORRECTION_ACTIONS: readonly UiAction[] = [ + {id: 'insert', label: 'edit', keyLabel: 'Tab', kinds: ['complete']}, + {id: 'dismiss', label: 'dismiss', keyLabel: 'Esc', kinds: ['escape']}, +]; +const dangerous = new Set(['rm', 'rmdir', 'mv', 'dd', 'sudo', 'su', 'doas', 'chmod', 'chown', 'chgrp', 'kill', 'killall', + 'pkill', 'shutdown', 'reboot', 'halt', 'mkfs', 'fdisk', 'diskutil', 'truncate', 'unlink', 'eval', 'exec']); +const commandName = /^[a-zA-Z][a-zA-Z0-9_-]{2,31}$/u; + +/** One insertion/deletion/transposition; substitution is permitted only for longer words. */ +export function closeCommand(typed: string, known: string, countShortSubstitution = false): boolean { + if (typed === known || Math.abs(typed.length - known.length) > 1) return false; + if (typed.length === known.length) { + const differences = [...typed].map((letter, index) => letter === known[index] ? -1 : index).filter(index => index !== -1); + if (differences.length === 1) return countShortSubstitution || typed.length >= 5; + return differences.length === 2 && differences[1] === differences[0]! + 1 + && typed[differences[0]!] === known[differences[1]!] && typed[differences[1]!] === known[differences[0]!]; + } + const longer = typed.length > known.length ? typed : known; + const shorter = typed.length > known.length ? known : typed; + let index = 0; + while (index < shorter.length && longer[index] === shorter[index]) index++; + return longer.slice(index + 1) === shorter.slice(index); +} + +export function correctionTarget(input: string, names: readonly string[]): string | undefined { + if (!commandName.test(input) || names.includes(input)) return; + const matches = [...new Set(names)].filter(name => commandName.test(name) && closeCommand(input, name, true)); + if (matches.length !== 1 || !closeCommand(input, matches[0]!) || dangerous.has(matches[0]!)) return; + return matches[0]; +} + +export class CommandCorrectionService { + private cache?: {path: string; at: number; files: Map}; + constructor(private readonly env: NodeJS.ProcessEnv = process.env) {} + + async suggest(command: string, exitCode: number, output: string, signal?: AbortSignal): Promise { + // No quotes, substitutions, redirections, pipelines, assignments, multiline or leading-space private input. + const match = /^([a-zA-Z][a-zA-Z0-9_-]{2,31})(?:[ \t]+[a-zA-Z0-9_./:@%+=,-]+)*[ \t]*$/u.exec(command); + if (exitCode !== 127 || !match || signal?.aborted) return; + const typed = match[1]!; + const diagnostic = stripAnsi(output).split('\n').some(line => new RegExp(`^(?:zsh(?::[^:]+)*: |nmsh: )?command not found: ${typed}\\s*$`, 'u').test(line.trim())); + if (!diagnostic) return; + const files = await this.commandFiles(signal); + if (signal?.aborted) return; + const possible = [...files.keys()].filter(name => name === typed || closeCommand(typed, name, true)); + const executable: string[] = []; + for (const name of possible) { + if (signal?.aborted) return; + for (const file of files.get(name)!) { + try { + await access(file, constants.X_OK); + if (!(await stat(file)).isFile()) continue; + executable.push(name); break; + } catch { /* Missing, unreadable or non-executable entries are not candidates. */ } + } + } + const target = correctionTarget(typed, executable); + if (!target || signal?.aborted) return; + const insertion = target + command.slice(typed.length); + return {correction: true, original: command, insertion, name: insertion, + description: 'Correction · Tab edits · press Enter separately'}; + } + + private async commandFiles(signal?: AbortSignal): Promise> { + const path = this.env.PATH ?? ''; + if (this.cache?.path === path && Date.now() - this.cache.at < 60_000) return this.cache.files; + const files = new Map(); + let count = 0; + const directories = path.split(delimiter).filter(isAbsolute); + if (directories.length > 64) return files; + for (const directory of directories) { + if (signal?.aborted) return files; + try { + for (const entry of await readdir(directory, {withFileTypes: true})) { + if (++count > 20_000) return new Map(); // An incomplete namespace cannot justify a unique correction. + if (entry.isDirectory() || !commandName.test(entry.name)) continue; + const locations = files.get(entry.name) ?? []; + locations.push(join(directory, entry.name)); files.set(entry.name, locations); + } + } catch (error) { + if (!['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) return new Map(); + } + } + if (!signal?.aborted) this.cache = {path, at: Date.now(), files}; + return files; + } +} + +export function renderCorrection(correction: CommandCorrection, columns: number): string { + return truncateAnsi(`${foreground(UI_COLORS.accent)}${GLYPHS.prompt} ${correction.name}\u001b[0m ${foreground(UI_COLORS.subtle)}Correction · ${stripAnsi(renderActionHelp(CORRECTION_ACTIONS))} · Enter separately\u001b[0m`, columns); +} diff --git a/tests/commandCorrection.test.ts b/tests/commandCorrection.test.ts new file mode 100644 index 00000000..8fbd39a8 --- /dev/null +++ b/tests/commandCorrection.test.ts @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp, writeFile, rm} from 'node:fs/promises'; +import {join} from 'node:path'; +import {tmpdir} from 'node:os'; +import {CommandCorrectionService, correctionTarget, renderCorrection, type CommandCorrection} from '../src/shell/CommandCorrection.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {setIconStyle} from '../src/ui/glyphs.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const correction: CommandCorrection = {correction: true, original: 'gti status', insertion: 'git status', name: 'git status', description: 'Correction'}; +const diagnostic = 'zsh: command not found: gti\n'; +function cleanup(app: TerminalApp): void { app['stop'](0); app['session'].kill(); } + +test('deterministic one-edit matching requires an unambiguous safe command', () => { + assert.equal(correctionTarget('gti', ['git', 'grep', 'npm']), 'git'); + assert.equal(correctionTarget('grpe', ['grep']), 'grep'); + assert.equal(correctionTarget('gitt', ['git']), 'git'); + assert.equal(correctionTarget('pythom', ['python']), 'python'); + for (const [typed, names] of [['gti', ['git', 'gtd']], ['git', ['git']], ['gxx', ['git']], + ['nmp', ['npm', 'nmap']], ['rmdri', ['rmdir']], ['rg', ['rgt']], ['Gti', ['git']]] as const) + assert.equal(correctionTarget(typed, names), undefined); +}); + +test('only a simple exit-127 command-not-found diagnostic and executable files can suggest', async () => { + const directory = await mkdtemp(join(tmpdir(), 'nmsh-correction-test-')); + try { + await writeFile(join(directory, 'git'), '#!/bin/sh\nexit 0\n', {mode: 0o700}); + await writeFile(join(directory, 'gtd'), 'not executable', {mode: 0o600}); + const service = new CommandCorrectionService({PATH: directory}); + assert.equal((await service.suggest('gti status', 127, diagnostic))?.insertion, 'git status'); + assert.equal((await service.suggest('gti status', 127, 'zsh:1: command not found: gti\n'))?.insertion, 'git status'); + for (const command of [' gti status', 'gti | cat', 'gti; pwd', 'gti $(pwd)', 'gti "status"', 'gti >file', 'gti\nstatus', 'X=1 gti', '/tmp/gti', 'sudo gti']) + assert.equal(await service.suggest(command, 127, diagnostic), undefined); + assert.equal(await service.suggest('gti status', 1, diagnostic), undefined); + assert.equal(await service.suggest('gti status', 127, 'unrelated failure'), undefined); + assert.equal(await service.suggest('git status', 127, 'zsh: command not found: git\n'), undefined); + await writeFile(join(directory, 'gti'), '#!/bin/sh\nexit 127\n', {mode: 0o700}); + assert.equal(await new CommandCorrectionService({PATH: directory}).suggest('gti status', 127, diagnostic), undefined); + const controller = new AbortController(); controller.abort(); + assert.equal(await service.suggest('gti status', 127, diagnostic, controller.signal), undefined); + } finally { await rm(directory, {recursive: true, force: true}); } +}); + +test('Tab edits, Escape dismisses, and Enter never executes a suggestion automatically', async () => { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + const submitted: string[] = []; + app['session'].submit = ((text: string) => submitted.push(text)) as never; + try { + const before = app['output'].transcript(); + app['correction'] = correction; + assert.deepEqual(app['composerSuggestions'](), [correction]); + app['handleKey']({kind: 'complete'}); + assert.equal(app['editor'].text, 'git status'); + assert.deepEqual(submitted, []); + assert.deepEqual(app['output'].transcript(), before); + assert.equal(app['correction'], undefined); + app['editor'].clear(); app['correction'] = correction; + app['handleKey']({kind: 'escape'}); + assert.equal(app['correction'], undefined); + assert.equal(app['editor'].text, ''); + app['correction'] = correction; + await app['submit'](); + assert.deepEqual(submitted, []); + assert.equal(app['correction'], undefined); + } finally { cleanup(app); } +}); + +test('typing cancels pending correction and late results never repaint after ABA buffer changes', async () => { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + let complete!: (value: CommandCorrection) => void; + app['correctionService'].suggest = () => new Promise(resolve => { complete = resolve; }); + try { + const pending = app['suggestCorrection']('gti status', 127, diagnostic); + app['handleKey']({kind: 'text', value: 'x'}); + app['handleKey']({kind: 'backspace'}); + complete(correction); await pending; + assert.equal(app['editor'].text, ''); + assert.equal(app['correction'], undefined); + } finally { cleanup(app); } +}); + +test('correction uses Safe glyphs, no-color and clips cleanly in narrow terminals', () => { + const noColor = process.env.NO_COLOR; + process.env.NO_COLOR = '1'; setIconStyle('safe'); + try { + for (const width of [1, 8, 24, 80]) { + const row = renderCorrection(correction, width); + assert.ok(displayWidth(row) <= width); + assert.doesNotMatch(stripAnsi(row), /[\ue000-\uf8ff]/u); + assert.doesNotMatch(row, /\u001b\[(?:38|48);/u); + } + assert.match(stripAnsi(renderCorrection(correction, 100)), /Tab.*edit.*Esc.*dismiss/u); + } finally { setIconStyle('nerd'); if (noColor === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = noColor; } +}); + +test('multi-key input cannot Tab-insert completion from an older buffer before render', () => { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + try { + app['editor'].insert('git st'); + app['shellSuggestions'] = [{context: {buffer: 'git s', cwd: app['context'].cwd}, insertion: 'git stash'}] as never; + app['handleKey']({kind: 'complete'}); + assert.equal(app['editor'].text, 'git st'); + assert.deepEqual(app['shellSuggestions'], []); + } finally { cleanup(app); } +}); diff --git a/tests/completionMenu.test.ts b/tests/completionMenu.test.ts index 76a1b75b..df8a4856 100644 --- a/tests/completionMenu.test.ts +++ b/tests/completionMenu.test.ts @@ -37,6 +37,7 @@ test('menu shows descriptions, source groups, safe icons and degrades at narrow function appForTest(): TerminalApp { const app = new TerminalApp(); Object.defineProperty(app, 'render', {value: () => {}}); + app['context'].cwd = context.cwd; return app; } function cleanup(app: TerminalApp): void { app['stop'](0); app['session'].kill(); } From ff0fa1cc0605bf2be9d66112ed6137fd7baea51c Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:08:42 +0530 Subject: [PATCH 11/82] Harden completion paths, history imports and concurrent deletion privacy --- docs/performance-benchmarks.md | 4 ++ scripts/benchmarks.ts | 3 +- src/shell/CompletionService.ts | 5 +- src/shell/HistoryIndex.ts | 26 +++++++---- src/shell/HistoryService.ts | 22 +++++++-- src/shell/ShellSession.ts | 1 + tests/intelligenceHardening.test.ts | 72 +++++++++++++++++++++++++++++ tests/structuredHistory.test.ts | 6 +-- 8 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 tests/intelligenceHardening.test.ts diff --git a/docs/performance-benchmarks.md b/docs/performance-benchmarks.md index cd32f139..05f05f41 100644 --- a/docs/performance-benchmarks.md +++ b/docs/performance-benchmarks.md @@ -52,3 +52,7 @@ Same macOS arm64 / Node 26.8.1 host, 20 samples after 3 warmups. Native aggregat | Cached fuzzy query from 100k history | 0.02 ms | 0.03 ms | These fixtures contain 80 unique directories. They measure native work, not private zoxide database latency or external picker rendering. zoxide queries use a bounded temporary database copy because upstream query sorts and saves its database. + +### History index import (v0.8 hardening) + +Hashing imported source identities and populating the native index: 5 samples after 1 warmup on the same host. 10k records: p50 8.16 ms, p95 19.02 ms; 100k: p50 107.08 ms, p95 121.41 ms. These are total wall times, with a yield every 1,024 records so the work is spread across event-loop turns. File I/O, source parsing and final sorting are separate. Warm bounded queries remain as measured above; an import is background work, not a keystroke query. diff --git a/scripts/benchmarks.ts b/scripts/benchmarks.ts index a6a741dd..0f86e862 100644 --- a/scripts/benchmarks.ts +++ b/scripts/benchmarks.ts @@ -10,7 +10,7 @@ import {HistoryIndex} from '../src/shell/HistoryIndex.js'; import {filterCompletions, parseNativeCompletions} from '../src/shell/completion.js'; import {planScreen} from '../src/app/screenPlan.js'; import {encodeMessage, FrameDecoder} from '../src/session/SessionProtocol.js'; -import {parseZshHistory} from '../src/shell/HistoryService.js'; +import {parseZshHistory, indexImportedHistory} from '../src/shell/HistoryService.js'; import {NativeSuggestions} from '../src/suggestions/NativeSuggestions.js'; import type {CommandEntry, SuggestionContext} from '../src/suggestions/types.js'; @@ -93,6 +93,7 @@ const completionFixture = parseNativeCompletions(Array.from({length: 500}, (_, i const directoryServices = new Map(suggestionCounts.map(count => [count, new DirectoryService()])); const benchmarks: Benchmark[] = [ + ...suggestionCounts.map(count => ({name: `history/index-import-${count}`, run: () => indexImportedHistory(new HistoryIndex(), histories.get(count)!, 'zsh'), samples: 5, warmup: 1, units: count, unitName: 'entries'})), ...suggestionCounts.map(count => ({name: `navigation/rank-${count}`, run: () => rankDirectories(historyIndexes.get(count)!.all()), units: count, unitName: 'entries'})), ...suggestionCounts.map(count => ({name: `navigation/cached-query-${count}`, run: () => directoryServices.get(count)!.query(historyIndexes.get(count)!.all(), 'pr7', 'native')})), ...suggestionCounts.map(count => ({name: `history/structured-query-${count}`, diff --git a/src/shell/CompletionService.ts b/src/shell/CompletionService.ts index daf971cf..2e3866c5 100644 --- a/src/shell/CompletionService.ts +++ b/src/shell/CompletionService.ts @@ -15,7 +15,10 @@ export class NativeCompletionSource implements CompletionSource { // Capture the token's parent context once, then fuzzy-filter locally while typing. // Keep the legacy whitespace replacement boundary until quoted-token support is designed. const start = context.buffer.lastIndexOf(' ') + 1; - const parent = context.buffer.slice(0, start); + const token = context.buffer.slice(start); + const pathPrefix = token.includes('/') ? token.slice(0, token.lastIndexOf('/') + 1) : ''; + // Keep path components in the capture context; stripping them loses nested directory candidates. + const parent = /[\\'"]/u.test(token) ? context.buffer : context.buffer.slice(0, start) + pathPrefix; const key = JSON.stringify([context.cwd, parent]); const cached = this.cache.get(key); let output: string; diff --git a/src/shell/HistoryIndex.ts b/src/shell/HistoryIndex.ts index 39a25348..e9f257e2 100644 --- a/src/shell/HistoryIndex.ts +++ b/src/shell/HistoryIndex.ts @@ -1,6 +1,6 @@ -import {createHash, randomUUID} from 'node:crypto'; -import {mkdir, readFile, rename, writeFile} from 'node:fs/promises'; -import {dirname} from 'node:path'; +import {createHash} from 'node:crypto'; +import {mkdir, readFile, readdir, writeFile} from 'node:fs/promises'; +import {join} from 'node:path'; import {isPrivateCommand, type CommandEntry} from '../suggestions/types.js'; import type {CompletedCommand} from '../output/OutputBuffer.js'; import type {TranscriptSession} from '../sessions/TranscriptStore.js'; @@ -76,11 +76,17 @@ export class HistoryIndex { const data: unknown = JSON.parse(await readFile(this.deletionFile, 'utf8')); if (!data || typeof data !== 'object' || !('version' in data) || data.version !== 1 || !('ids' in data) || !Array.isArray(data.ids) || !data.ids.every(id => typeof id === 'string' && /^[a-f0-9]{64}$/u.test(id))) throw new Error('Invalid history deletion file'); - this.deleted = new Set(data.ids); + for (const id of data.ids) this.deleted.add(id); } catch (error) { // Only absence is safe: an unreadable tombstone must not resurrect deleted commands. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + try { + for (const entry of await readdir(`${this.deletionFile}.d`, {withFileTypes: true})) { + if (!entry.isFile() || !/^[a-f0-9]{64}$/u.test(entry.name)) throw new Error('Invalid history tombstone'); + this.deleted.add(entry.name); + } + } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } this.revision += 1; } @@ -131,13 +137,13 @@ export class HistoryIndex { this.deleted.add(id); this.revision += 1; if (!this.deletionFile) return; - const file = this.deletionFile; - const data = JSON.stringify({version: 1, ids: [...this.deleted]}); + if (!/^[a-f0-9]{64}$/u.test(id)) throw new Error('Invalid history ID'); + const directory = `${this.deletionFile}.d`; this.writes = this.writes.catch(() => {}).then(async () => { - await mkdir(dirname(file), {recursive: true, mode: 0o700}); - const temporary = `${file}.${randomUUID()}.tmp`; - await writeFile(temporary, data, {mode: 0o600}); - await rename(temporary, file); + await mkdir(directory, {recursive: true, mode: 0o700}); + // Independent immutable IDs avoid lost updates between persistent-session frontends. + try { await writeFile(join(directory, id), '', {mode: 0o600, flag: 'wx'}); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; } }); await this.writes; } diff --git a/src/shell/HistoryService.ts b/src/shell/HistoryService.ts index 46af8491..5fa2bf91 100644 --- a/src/shell/HistoryService.ts +++ b/src/shell/HistoryService.ts @@ -45,8 +45,8 @@ export class HistoryService { this.entries = loaded.entries; this.status = {selected: provider, active: loaded.source === 'atuin' ? 'atuin' : 'native', detail: loaded.detail}; this.index.clearImported(); - this.entries.forEach(entry => this.index.add({...entry, id: 'id' in entry && typeof entry.id === 'string' ? entry.id - : historyId(loaded.source, `${entry.at ?? 0}:${entry.command}`), source: loaded.source})); + await indexImportedHistory(this.index, this.entries, loaded.source, request.signal); + if (generation !== this.generation || request.signal.aborted) return false; await this.loadJournals(generation); if (generation !== this.generation || request.signal.aborted) return false; this.index.all(); @@ -85,7 +85,11 @@ export class HistoryService { if (atuin) { const result = await runExternal(atuin, ['history', 'list', '--format', ATUIN_METADATA_FORMAT, '--print0', '--timezone', '+0'], {timeoutMs: 15000, maxBytes: 128 * 1024 * 1024, env: this.env, signal}); - if (result.ok) return {entries: await parseAtuinMetadataInChunks(result.stdout), source: 'atuin'}; + if (result.ok) { + const entries = await parseAtuinMetadataInChunks(result.stdout); + if (!result.stdout.trim() || entries.length || result.stdout.split('\0').some(record => record.startsWith('nmsh-v1\u001f'))) + return {entries, source: 'atuin'}; + } detail = `Atuin unavailable (${result.error ?? 'query failed'}); using Native`; } else detail = 'Atuin is not installed; using Native'; } @@ -106,6 +110,16 @@ export class HistoryService { } } +/** Hashing and indexing imports must yield too, not only source parsing. */ +export async function indexImportedHistory(index: HistoryIndex, entries: readonly CommandEntry[], source: 'zsh' | 'atuin', signal?: AbortSignal): Promise { + for (let position = 0; position < entries.length; position++) { + if (signal?.aborted) return; + const entry = entries[position]!; + index.add({...entry, id: 'id' in entry && typeof entry.id === 'string' ? entry.id : historyId(source, `${entry.at ?? 0}:${entry.command}`), source}); + if (position % 1024 === 1023) await new Promise(resolve => setImmediate(resolve)); + } +} + export function parseAtuinHistory(output: string): CommandEntry[] { const entries: CommandEntry[] = []; for (const record of output.split('\0')) { @@ -187,7 +201,7 @@ export function parseAtuinMetadata(output: string): HistoryEntry[] { return [...atuinMetadataEntries(output)].sort((a, b) => (a.at ?? 0) - (b.at ?? 0)); } -async function parseAtuinMetadataInChunks(output: string): Promise { +export async function parseAtuinMetadataInChunks(output: string): Promise { const entries: HistoryEntry[] = []; for (const entry of atuinMetadataEntries(output)) { entries.push(entry); diff --git a/src/shell/ShellSession.ts b/src/shell/ShellSession.ts index c0bc4601..b5b81364 100644 --- a/src/shell/ShellSession.ts +++ b/src/shell/ShellSession.ts @@ -101,6 +101,7 @@ function nmsh_precmd { function nmsh_preexec { nmsh_tty_echo echo local nmsh_history_allowed=1 + [[ \$1 == [[:space:]]* ]] && nmsh_history_allowed=0 [[ -n \$HISTORY_IGNORE && \$1 == \${~HISTORY_IGNORE} ]] && nmsh_history_allowed=0 printf '\\e]777;nmsh;${token};exec2;%d;%s\\a' "\$nmsh_history_allowed" "\${1//[[:cntrl:]]/ }" } diff --git a/tests/intelligenceHardening.test.ts b/tests/intelligenceHardening.test.ts new file mode 100644 index 00000000..c389971d --- /dev/null +++ b/tests/intelligenceHardening.test.ts @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp, mkdir, writeFile, rm, readdir} from 'node:fs/promises'; +import {join} from 'node:path'; +import {tmpdir} from 'node:os'; +import {NativeCompletionSource} from '../src/shell/CompletionService.js'; +import {HistoryIndex, historyId} from '../src/shell/HistoryIndex.js'; +import {HistoryService, indexImportedHistory} from '../src/shell/HistoryService.js'; + +test('native fuzzy filtering preserves nested path capture context and cached insertion ranges', async () => { + const home = await mkdtemp(join(tmpdir(), 'nmsh-path-completion-')); + const originalHome = process.env.HOME; + process.env.HOME = home; + try { + await mkdir(join(home, 'src')); await writeFile(join(home, 'src', 'alpha.ts'), ''); + const source = new NativeCompletionSource(); + const context = {buffer: 'ls src/al', cwd: home}; + const items = await source.query(context, new AbortController().signal); + assert.ok(items.some(candidate => candidate.insertion === 'ls src/alpha.ts')); + const cached = await source.query({...context, buffer: 'ls src/aph'}, new AbortController().signal); + assert.ok(cached.some(candidate => candidate.insertion === 'ls src/alpha.ts' && candidate.context.buffer === 'ls src/aph')); + } finally { + if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; + await rm(home, {recursive: true, force: true}); + } +}); + +test('history hashing/indexing yields on 100k imports and respects cancellation', async () => { + const entries = Array.from({length: 100_000}, (_, index) => ({command: `echo ${index}`, at: index})); + let responsive = false; + setImmediate(() => { responsive = true; }); + const index = new HistoryIndex(); + await indexImportedHistory(index, entries, 'zsh'); + assert.equal(responsive, true); + assert.equal(index.all().length, 100_000); + const active = new AbortController(); + const cancelled = new HistoryIndex(); + const pending = indexImportedHistory(cancelled, entries, 'zsh', active.signal); + active.abort(); await pending; + assert.ok(cancelled.all().length < entries.length); +}); + +test('two history frontends cannot overwrite each other’s deletion IDs; earlier JSON remains readable', async () => { + const directory = await mkdtemp(join(tmpdir(), 'nmsh-history-deletions-')); + const file = join(directory, 'history-deletions.json'); + const first = {id: historyId('zsh', 'one'), command: 'echo one', source: 'zsh' as const}; + const second = {id: historyId('zsh', 'two'), command: 'echo two', source: 'zsh' as const}; + try { + const a = new HistoryIndex(file); const b = new HistoryIndex(file); + a.add(first); b.add(second); + await Promise.all([a.delete(first.id), b.delete(second.id)]); + assert.deepEqual((await readdir(`${file}.d`)).sort(), [first.id, second.id].sort()); + const restored = new HistoryIndex(file); await restored.loadDeletions(); + restored.add(first); restored.add(second); assert.deepEqual(await restored.search(''), []); + const legacy = historyId('zsh', 'legacy'); + await writeFile(file, JSON.stringify({version: 1, ids: [legacy]})); + await restored.loadDeletions(); restored.add({id: legacy, command: 'legacy', source: 'zsh'}); + assert.deepEqual(await restored.search(''), []); + } finally { await rm(directory, {recursive: true, force: true}); } +}); + +test('a successful but incompatible Atuin response falls back truthfully to Native', async () => { + const directory = await mkdtemp(join(tmpdir(), 'nmsh-atuin-format-')); + const service = new HistoryService({...process.env, HOME: directory, XDG_CONFIG_HOME: directory, PATH: directory}); + try { + await writeFile(join(directory, '.zsh_history'), ': 123:0;echo native\n'); + await writeFile(join(directory, 'atuin'), `#!${process.execPath}\nprocess.stdout.write('unsupported format\\n');\n`, {mode: 0o700}); + assert.equal(await service.reload('atuin'), true); + assert.equal(service.status.active, 'native'); + assert.deepEqual(service.getAll(), ['echo native']); + } finally { service.dispose(); await rm(directory, {recursive: true, force: true}); } +}); diff --git a/tests/structuredHistory.test.ts b/tests/structuredHistory.test.ts index 0da0fcb9..a7457a52 100644 --- a/tests/structuredHistory.test.ts +++ b/tests/structuredHistory.test.ts @@ -41,7 +41,7 @@ test('deletion survives reload without storing commands; corrupt deletion metada try { const index = new HistoryIndex(file); await index.loadDeletions(); index.add(entry); await index.delete(entry.id); assert.deepEqual(await index.search(''), []); - assert.doesNotMatch(await readFile(file, 'utf8'), /git status|\/work/u); + assert.equal(await readFile(join(`${file}.d`, entry.id), 'utf8'), ''); const restored = new HistoryIndex(file); await restored.loadDeletions(); restored.add(entry); assert.deepEqual(await restored.search(''), []); await writeFile(file, '{broken'); @@ -81,10 +81,10 @@ test('real zsh reports unexported HISTORY_IGNORE without changing hooks or execu shell.on('exec', (_command, allowed) => { flags.push(allowed); }); try { await once(shell, 'prompt'); - for (const command of ['echo public', 'echo secret', 'pwd']) { + for (const command of ['echo public', 'echo secret', 'pwd', ' echo private']) { const done = once(shell, 'prompt'); shell.submit(command); await done; } - assert.deepEqual(flags, [1, 0, 0]); + assert.deepEqual(flags, [1, 0, 0, 0]); } finally { shell.kill(); await rm(home, {recursive: true, force: true}); } }); From 2da4d025ff0a352a90e7594c7dc1083d6c5e0fe5 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:17:25 +0530 Subject: [PATCH 12/82] Document v0.8 command intelligence architecture and pending terminal QA --- ROADMAP.md | 10 +++- docs/design/command-intelligence.md | 86 +++++++++++++++++++++++++++++ docs/qa/v0.8.0-physical-qa.md | 50 +++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 docs/design/command-intelligence.md create mode 100644 docs/qa/v0.8.0-physical-qa.md diff --git a/ROADMAP.md b/ROADMAP.md index 7571e782..c1e1f53a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ |---|---| | **Current release** | [v0.7.0 — UI Foundation & Customization](https://github.com/raiseCatError/notMyShell/releases/tag/v0.7.0) | | **Development branch** | `dev` | -| **Next direction** | Not yet defined; see the Backlog below and open issues | +| **Next direction** | [v0.8.0 — Command Intelligence & Navigation](https://github.com/raiseCatError/notMyShell/milestone/8), in development | | **Project board** | [NMSh Development](https://github.com/users/raiseCatError/projects/1) | GitHub issues define actionable remaining work. A merged implementation may still be open with `needs-human-test` while physical terminal checks are pending. Closed issues represent completed work, not a promise that future refinements are finished. @@ -112,6 +112,14 @@ The final release candidate also included the passive-hover selection fix. | [#172](https://github.com/raiseCatError/notMyShell/issues/172) | Semantic motion engine; shimmer migrated onto it | | [#173](https://github.com/raiseCatError/notMyShell/issues/173) | Deterministic presentation mode | +## In development — v0.8.0 Command Intelligence & Navigation + +[Milestone #8](https://github.com/raiseCatError/notMyShell/milestone/8) contains #140, #141, #142, #143, #144, #145, #147 and #152. The [architecture and implemented scope](docs/design/command-intelligence.md) describe the unmerged review stack; the [physical-QA checklist](docs/qa/v0.8.0-physical-qa.md) remains pending. Package version stays at 0.7.0 during development. + +Native structured completion, command-level history, reusable optional pickers, explicit directory navigation and conservative edit-only corrections build on the released UI foundation. External completion sources remain research; #52 stays parked. #155 / #193 supply measurements without absorbing the full performance issue into this milestone. + +The earlier milestone #7 now represents unscheduled Discoverability & Integrations; its unrelated scope remains separate. + ## Backlog — Research and Future Features These remain open and are not scheduled for a release. diff --git a/docs/design/command-intelligence.md b/docs/design/command-intelligence.md new file mode 100644 index 00000000..c0bf36f7 --- /dev/null +++ b/docs/design/command-intelligence.md @@ -0,0 +1,86 @@ +# v0.8 command intelligence and navigation + +Development scope for [milestone #8](https://github.com/raiseCatError/notMyShell/milestone/8). This is an unmerged review stack, not a release record. Package version remains 0.7.0. Physical terminal validation is pending; use the [single QA checklist](../qa/v0.8.0-physical-qa.md). + +## Authority and dependency order + +The terminal host renders NMSh, which sends explicitly submitted input through SessionClient / nmshd / PTY to persistent real zsh. Completion, history, selection and correction operate in the frontend. Suggestions insert editor text; the user executes it separately. Raw PTY output, `/copy`, session restore and passthrough retain their existing ownership. + +The stack starts at dev `2d0e8f904896af2947d109a42df85b097b8ebc9a`, fetched and checked against GitHub before development. Merge order is #193 → #233 → #234 → #235 → #236 → #237 → #238 → #239 → #240 → acceptance documentation. Each dependent PR targets the preceding branch. Nothing has been merged, tagged or released. + +| Issue | Implemented development scope | +| --- | --- | +| #140 | Source-independent completion candidates and cancellable native capture | +| #141 | Descriptions, inline categories/groups, icons, fuzzy filtering, keyboard selection and ScreenPlan placement | +| #142 | External-source research delivered on the issue; no new external completion provider | +| #143 | Shell-approved command metadata, structured search, background journal projection and local deletion | +| #144 | Explicit optional local Atuin provider; Native default | +| #145 | Shared supplied-candidate picker boundary, Native/fzf/Television adapters and controlled host-terminal handoff | +| #147 | `/dirs`, palette action, native frecency and optional zoxide snapshot queries | +| #152 | Conservative command-not-found executable corrections that edit only | + +All eight issues remain open. #155 is a prerequisite investigation rather than added milestone scope. Its existing #193 branch was updated by merging current dev, preserving published history. Broader startup/socket/storage optimization remains open. + +## Completion + +`CompletionCandidate` carries insertion and display values, description, kind, source, optional group, UTF-16 replacement range and buffer/cwd context. `CompletionSource` supplies this model without UI dependencies; later inspector work can consume the same metadata without being implemented here. + +`NativeCompletionSource` retains the existing isolated native zsh capture helper. Tab is never forwarded into the persistent session PTY, and composer input is never submitted for execution to discover completions. Capture has a 1.5-second timeout and 1 MiB output cap. Parent contexts, including nested path prefixes, are cached for two seconds with 32 entries. Local subsequence filtering ranks exact/prefix matches first. + +CompletionService aborts superseded requests; the app checks generation, buffer, cwd and UI eligibility before accepting results. Rows clear immediately when context changes. Acceptance also checks context before Tab, covering multiple decoded keys before the next render. Arrow keys navigate, Tab inserts and Escape dismisses. + +Completion rows reuse shared actions, glyph selection and Chroma roles. Categories/groups are inline rather than separate nonselectable rows, preserving selection indices and layout geometry. ScreenPlan owns the suggestion region in Bottom, Top and Flow; narrow rows prioritize the label. Native source kinds are inferred from available values and suffixes, not a full CLI schema. + +Quoted/escaped token replacement retains the earlier whitespace-boundary limitation. Configured-zsh completion parity and fzf-tab remain parked in #52. The existing native completion functions are retained; no third-party generator runtime is added. + +## History and privacy + +`/history` searches commands; `/resume` still searches sessions. Search combines plain text with `cwd:`, `project:`, `exit:`, `before:`, `after:`, `session:` and `duration:`. Quotes support spaces in filter values. Invalid known filters match nothing. Enter/Tab restores a result, and a separate Enter executes it. + +New journal records add optional eligibility, start time and duration to the existing schema version 1. zsh preexec reports leading-space and unexported HISTORY_IGNORE eligibility through the authenticated marker, IPC, backlog and replay paths. Native indexing requires affirmative eligibility; older unknown records stay out. Transcripts remain intact independently of command-history policy. Project/cwd/exit/session/time/duration appear only when available. + +HistoryService imports the existing exported HISTFILE or `~/.zsh_history`, parses multiline/metafied zsh data in batches, and reads session journals in a worker. The worker projects eligible command metadata without returning raw output. The index derives from authoritative sources; there is no second persisted command database. Queries yield every 2,048 records and return at most 100 composer results (500 through the index API). Import hashing/indexing yields every 1,024 records. App cancellation prevents stale query rows or deletion of a stale selected result. + +Ctrl+X removes the selected record from NMSh search. Independent empty tombstone files named by SHA-256 IDs live in private `history-deletions.json.d`; concurrent frontends cannot replace each other's IDs. Earlier stack `history-deletions.json` version-1 IDs remain readable and union with tombstones. Corrupt/unreadable deletion metadata fails closed. Original journals and zsh/Atuin history are unchanged. Deletion applies to a record identity: another source or occurrence of the same command can remain. Other already-running frontends observe persisted deletions when they reload. + +Native is the persisted default. Atuin runs only when selected and reads its local list using a bounded formatted CLI call, cached until history reload. Detection/status and fallback use the shared provider gallery. No recording, deletion or sync operation is invoked. Existing user hooks retain their behavior. Atuin's formatted durations are approximate; absent metadata stays unknown. Reading Atuin does not double-record, although imported and journal representations of one command can both appear. + +No mandatory SQLite import or native binding is added. The supported Node floor includes releases before built-in SQLite availability/flag removal, and its synchronous API alone would not solve UI responsiveness. Current measured bounded queries support the derived-index approach; revisit storage/isolation if measurements warrant it. See [benchmarks](../performance-benchmarks.md). + +## Pickers and navigation + +Picker candidates contain an opaque ID, label, description and internal value. Native delegates to the existing composer search surface. fzf and Television receive flattened inert ordinal rows and return one exact known row. Unknown, transformed, oversized or multiple selections fail safely. The adapter never trusts returned text as a command. + +The app hands off the host terminal only while idle, detaches its input listener, releases raw mode and renderer ownership, then restores them in `finally`. The managed shell PTY receives no picker input. Accept, cancel and error restore input/renderer state; resize aborts selection and preserves the draft. Missing/failing providers use Native. Config offers explicit persisted Native/fzf/Television choices without installation. + +fzf default-command/options environment overrides are removed. Television receives temporary config, empty cable, isolated data/cache directories, zero query-history size, no preview and no remote channels. Adapter input is capped at 100k rows / 16 MiB, output at 64 KiB, interaction at five minutes. External tools own their appearance during handoff. Installed fzf transport was tested noninteractively; Television was absent locally and has contract tests, with real-tool QA pending. History supplies at most the newest 100 matching records; Native structured search remains the route across the full history. Bare `/history`, `/dirs` and palette actions open the configured picker; typed query forms keep Native incremental composer search. + +`/dirs` ranks approved history cwd records by frequency and seven-day recency decay, with fuzzy filtering and a snapshot cache. Its palette entry comes from the shared slash-action registry. Selection inserts a literal quoted `cd -- 'path'`; only subsequent Enter sends it through normal shell submission and transcript capture. Plain `cd` is not intercepted. Stale/missing directory paths can fail visibly in zsh; no hidden chdir changes shell state. + +Optional zoxide queries use the user's existing data location. Upstream query sorts/saves even with `--all`, so NMSh copies the bounded db.zo into a private temporary directory and queries only that copy. The original database/hooks remain unchanged. Queries are detached, time/output bounded and cached for 30 seconds; absent, incompatible or failing sources fall back to Native. A real installed zoxide fixture test verifies source-byte preservation. Directory-specific frecency ghost text is deferred; existing native `cd` argument completion remains available. + +## Corrections + +Exit 127 alone is insufficient. A simple unquoted command must have a matching command-not-found diagnostic and exactly one nearby executable in bounded cached absolute frontend PATH directories. One insertion/deletion/transposition is allowed; substitution is allowed only for longer words. Nearby alternatives suppress rather than lower confidence. Known executables returning 127, complex expressions, assignments, quoted/multiline/private input, dangerous targets and short ambiguous input are suppressed. + +Tab edits the empty composer, Escape dismisses, and Enter never accepts/executes the suggestion automatically. Typing cancels background discovery, including ABA buffer changes. The row is transient frontend presentation in ScreenPlan, so it cannot enter `/copy`, transcripts or history. Narrow width, Safe glyph and NO_COLOR paths are tested. + +Live-shell PATH mutations, live aliases/functions, history-based guesses and subcommand/flag correction are deferred. The current snapshots and native capture metadata do not establish sufficient authority for those cases. No subprocess is invoked to inspect an unknown command or discover a correction. + +## External completion research and deferred scope + +Evidence and primary links are recorded on [#142](https://github.com/raiseCatError/notMyShell/issues/142#issuecomment-5919411096); local Atuin findings are on [#144](https://github.com/raiseCatError/notMyShell/issues/144#issuecomment-5919625509). + +Carapace's JSON export includes value/display/description/tag data and is a promising future transport. Its callbacks, bridges and configurations can execute code; a universal static-only safe mode was not established. It was absent locally, so no latency number is claimed. Fig-compatible TypeScript specs are useful formats but module imports/generators are executable; licensing requires per-spec/dependency auditing. Inshellisense exposes structured completion and useful parsing/cache patterns, but its generator execution, terminal architecture and currently declared Node range do not fit direct embedding. No code was copied and no provider follow-up issue was created. + +Future external completion must default to inert audited data, with generators disabled until an explicit bounded execution model is defined. Current timeout/output limits are not a sandbox for arbitrary generators. + +All explicitly parked work remains outside this stack, including #52, inspector #84, installer #9, wider terminal compatibility and #208 TMPDIR hygiene. Unrelated PRs #181, #182, #183, #226 and #227 are untouched. No new issues were created. + +## Verification and acceptance + +Each code layer ran build, typecheck, full tests and diff checks; focused regressions cover keyboard selection, cancellation, narrow/Safe/no-color presentation, journal/IPC privacy, real-zsh hooks, large histories and terminal handoff. Final cumulative local suite: 714 passed. Benchmark script types pass. Existing regression coverage exercises sessions, startup restore, Flow/Chat, passthrough, raw output and copy; this is automated coverage, not physical QA. + +The navigation CI's first Node-22 run failed during existing GNU screen sandbox cleanup with ENOTEMPTY, cancelling Node 26. Investigation and the unchanged-head rerun are recorded on #238; both Node versions passed the rerun. A status viewport test assumption and runtime no-color correction help were corrected after recorded failures. An RTK-filtered final command returned an empty log with exit 1; raw proxy verification passed all 714 tests. The final code and acceptance heads receive workflow_dispatch because stacked targets do not automatically trigger the dev PR workflow. + +Review dependencies in order, integrate only after review and CI gates, then perform the deduplicated physical checklist. Keep milestone issues open until release, including after integration and physical QA. Version/release preparation follows physical QA under separate authorization. diff --git a/docs/qa/v0.8.0-physical-qa.md b/docs/qa/v0.8.0-physical-qa.md new file mode 100644 index 00000000..bb9d5df5 --- /dev/null +++ b/docs/qa/v0.8.0-physical-qa.md @@ -0,0 +1,50 @@ +# v0.8 physical terminal QA — pending + +This checklist has not been performed. Automated tests do not establish physical terminal behavior. Test the cumulative acceptance branch after review, or current dev after the stack is integrated. Keep package version at 0.7.0 until release preparation is separately authorized. + +Use Ghostty and Terminal.app for one focused pass. Exercise Bottom, Top and Flow, with both Normal and Chat presentation where relevant. Repeat a narrow-width sample with Safe glyphs and NO_COLOR; reduced motion should retain the same static behavior. Optional tools are only tested if already installed; do not install tools just for this checklist. + +## Completion + +- [ ] Type a normal command such as `git st`; move with arrows and insert with Tab. Enter must still be a separate execution step. +- [ ] Try options/descriptions and nested file paths such as `ls src/ap`. Plain candidates, inline categories and long descriptions remain readable. +- [ ] Type/delete rapidly, change command context, and press Tab while typing. Old-buffer candidates must not flash or insert. Escape dismisses. +- [ ] Check hundreds of candidates and a narrow terminal in Bottom, Top and Flow. Rows stay within NMSh's composer region without displacing or corrupting raw output. + +## History + +- [ ] `/history` and Ctrl+R search commands; `/resume` still browses sessions. Combine plain text with useful cwd/project/exit/date/session/duration filters; metadata absent from imports stays unknown. +- [ ] Arrow/Enter and Tab restore a selected command into the composer without execution. Check a multiline history command restores its original text. +- [ ] Run a synthetic public command, find its record, and use Ctrl+X. That selected record disappears and stays deleted after a reload/restart. Other sources/occurrences of the same command may remain; original shell history and transcripts are intact. +- [ ] Run a command beginning with a space, for example ` echo NMSH_QA_PRIVATE_08`. Its transcript may remain, but that command must not become an NMSh history result. If checking an unexported HISTORY_IGNORE pattern, verify the exact ignored command is excluded and restore your original pattern afterward. +- [ ] Search an existing large imported history while continuing to type. Results remain responsive; no internal storage inspection is required. +- [ ] If Atuin is installed, choose it explicitly in Config, inspect truthful status, search metadata and return to Native. NMSh selection must not execute a command or invoke sync. + +## Pickers + +- [ ] Native remains the default. Bare `/history` and the palette history action open the configured selection surface. +- [ ] If fzf is installed, select it in Config. Accept restores one supplied command into the composer; cancel preserves the draft. Return to Native afterward. +- [ ] If Television is installed, perform the same check. No user channel, preview command or remote channel should run through this adapter. +- [ ] During an external picker, cancel and resize. After each exit, typing, paste, arrows and normal command submission work; no raw-mode or keyboard-mode leak occurs. Missing/incompatible tools fall back to Native with a truthful notice. + +## Navigation + +- [ ] Run ordinary `cd` commands in zsh, then use `/dirs` or its palette action. Native ranks recorded directories and filters fuzzily. +- [ ] Selecting a directory inserts a quoted `cd -- …` command only. Review it and press Enter; cwd, transcript and session state then reflect the real shell command. +- [ ] Check a path with spaces or an apostrophe. Ordinary `cd ..` and shell-defined `cd` behavior remain normal. +- [ ] If zoxide is installed/configured, choose it in Config and verify ranked selection. Existing shell hooks continue to work; return to Native. No inspection of the database is required. + +## Corrections + +- [ ] Try a simple typo of an installed executable, for example `gti status` or `grpe --version`. When one candidate is unambiguous, a correction appears as NMSh UI. Tab edits the buffer; a separate Enter executes. Escape dismisses. +- [ ] Try an unrelated nonexistent name, an ambiguous typo and a compound/quoted command. No low-confidence suggestion should appear. An installed command returning 127 should not be mistaken for a command-name typo. +- [ ] Verify `/copy` contains shell output only, without correction chrome. Typing a fresh draft must remove any old correction. + +## Regression + +- [ ] Ghostty and Terminal.app: Flow/Chat and Bottom/Top resize, scrolling, command presentation and ordinary long-running commands remain usable. +- [ ] Enter and leave a fullscreen TUI and an inline interactive CLI; normal editor behavior returns. Raw PTY output remains faithful, and `/copy` remains plain. +- [ ] Detach during a command and reattach; startup restore and `/resume` retain the same live session and shell state. Ignored/private history remains excluded after replay. +- [ ] Existing prompt providers and ghost suggestions still work. No completion keystroke reaches the managed PTY as zsh editor UI. + +Record pass/fail observations against the relevant open issue and include terminal host/layout and a short reproduction for failures. Review and merge the stack in dependency order only after the automated/review gates; do not close issues or prepare a release merely because this checklist exists. From f63182b632410cbfc29fdb411b56b9ddbda6f8ab Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:59:00 +0530 Subject: [PATCH 13/82] Add local command inspector sharing completion knowledge --- src/app/TerminalApp.ts | 12 +++++ src/app/screenPlan.ts | 10 +++- src/shell/CommandInspector.ts | 12 +++++ src/shell/CommandKnowledge.ts | 89 ++++++++++++++++++++++++++++++++++ src/shell/CompletionService.ts | 3 +- src/ui/CommandPalette.ts | 5 +- src/ui/LayoutPanel.ts | 1 + tests/commandInspector.test.ts | 72 +++++++++++++++++++++++++++ tests/commandPalette.test.ts | 2 +- 9 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 src/shell/CommandInspector.ts create mode 100644 src/shell/CommandKnowledge.ts create mode 100644 tests/commandInspector.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 83ac4b70..1313b65a 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1,4 +1,5 @@ import {homedir} from 'node:os'; +import {inspectCommand, renderInspector} from '../shell/CommandInspector.js'; import {GLYPHS, setIconStyle} from '../ui/glyphs.js'; import {framePanel} from '../ui/PanelShell.js'; import { @@ -138,6 +139,7 @@ export class TerminalApp { private readonly submittedCommands: string[] = []; private readonly transcriptStore = new TranscriptStore(); private readonly completionService = new CompletionService(); + private inspectorVisible = false; private shellSuggestions: CompletionCandidate[] = []; private lastSuggestionInput = ""; private completionGeneration = 0; @@ -1179,6 +1181,9 @@ export class TerminalApp { case 'latest': this.historyViewport.latest(); break; + case 'toggleInspector': + this.inspectorVisible = !this.inspectorVisible; + break; case 'toggleDetails': this.output.toggleMostRelevant(); break; @@ -2785,6 +2790,11 @@ export class TerminalApp { * The one screen plan for the current state. Render, hit-testing, scroll, * focus, cursor and PTY sizing all call this instead of counting rows. */ + private inspectorRows(columns: number): string[] { + if (!this.inspectorVisible || this.running || this.settingsPanelActive || this.editor.hasPasteAtoms || this.editor.text.startsWith('/')) return []; + return renderInspector(inspectCommand(this.editor.text, this.editor.cursorIndex, this.shellCwd, this.shellSuggestions), columns); + } + private planFrame( columns: number, rows: number, @@ -2797,6 +2807,7 @@ export class TerminalApp { rows, inputRows: fullInput.allRows.length, suggestions, + inspectorRows: this.inspectorRows(columns).length, running: Boolean(this.running), detached: this.historyViewport.detached, hasOutput: transcriptRows > 0, @@ -2898,6 +2909,7 @@ export class TerminalApp { // Panels frame their composer-side edge: under Dock Top the frame line moves below the panel. case 'panel': return plan.composerPosition === 'top' && panelRows && /^[─-]+$/u.test(stripAnsi(panelRows[0] ?? '')) ? [...panelRows.slice(1), panelRows[0]!] : panelRows ?? []; + case 'inspector': return this.inspectorRows(columns); case 'suggestions': return suggestionView.items.map((suggestion, visibleIndex) => { const selected = suggestionView.start + visibleIndex === effectiveSelection; if ('correction' in suggestion) return renderCorrection(suggestion, columns); diff --git a/src/app/screenPlan.ts b/src/app/screenPlan.ts index 1230a180..098af397 100644 --- a/src/app/screenPlan.ts +++ b/src/app/screenPlan.ts @@ -20,6 +20,7 @@ export type RegionKind = | 'gap' | 'jump' | 'panel' + | 'inspector' | 'suggestions' | 'activity' | 'composerBorder' @@ -40,6 +41,7 @@ export interface ScreenPlanInput { inputRows: number; /** Suggestion rows the composer would like to show. */ suggestions: number; + inspectorRows?: number; running: boolean; detached: boolean; hasOutput: boolean; @@ -95,8 +97,9 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { return build(rows, top ? [...panel, ...transcript] : [...transcript, ...panel], {inputHeight: 0, suggestionCount: 0, panelActive: true, composerPosition: input.composerPosition ?? 'bottom'}); } + const inspectorHeight = Math.min(Math.max(0, input.inspectorRows ?? 0), Math.max(0, rows - 8)); const layout = calculateScreenLayout( - rows, + rows - inspectorHeight, input.inputRows, input.suggestions, input.running, @@ -117,6 +120,7 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { ['prompt', Number(layout.showPrompt)], ['input', layout.inputHeight], ['separator', Number(layout.showSeparator)], + ['inspector', inspectorHeight], ['suggestions', layout.suggestionCount], ['gap', Number(layout.showGap)], ['transcript', shown], @@ -130,7 +134,7 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { // Geometry is measured as if following, so the PTY never resizes as output // grows or the view scrolls back. const followLayout = input.detached - ? calculateScreenLayout(rows, input.inputRows, input.suggestions, input.running, false, input.hasOutput, + ? calculateScreenLayout(rows - inspectorHeight, input.inputRows, input.suggestions, input.running, false, input.hasOutput, input.contextPlacement, input.hasVisibleContext, input.composerLayout) : layout; const capacity = followLayout.outputHeight; @@ -144,6 +148,7 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { ['transcript', shown], ['gap', Number(followLayout.showGap)], ['activity', followLayout.showLiveActivity ? 2 : 0], + ['inspector', inspectorHeight], ['composerBorder', Number(followLayout.showComposerTopBorder)], ['prompt', Number(followLayout.showPrompt)], ['input', followLayout.inputHeight], @@ -162,6 +167,7 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { ['suggestions', layout.suggestionCount], // Activity line plus its blank spacer. ['activity', layout.showLiveActivity ? 2 : 0], + ['inspector', inspectorHeight], ['composerBorder', Number(layout.showComposerTopBorder)], ['prompt', Number(layout.showPrompt)], ['input', layout.inputHeight], diff --git a/src/shell/CommandInspector.ts b/src/shell/CommandInspector.ts new file mode 100644 index 00000000..14ae6a4b --- /dev/null +++ b/src/shell/CommandInspector.ts @@ -0,0 +1,12 @@ +import {inspectCommand, type InspectorContext} from './CommandKnowledge.js'; +import {truncateText} from '../util/text.js'; +export {inspectCommand}; + +/** Plain presentation is deterministic and safe under all glyph/color policies. */ +export function renderInspector(context: InspectorContext | undefined, width: number): string[] { + if (!context || width < 1) return []; + const title = `${context.kind === 'option' ? 'flag' : context.kind}: ${context.value}`; + if (width < 32) return [truncateText(`${title} - ${context.description}`, width)]; + return [truncateText(`Inspect ${title}`, width), + truncateText(`${context.description}${context.usage ? ` | ${context.usage}` : ''}`, width)]; +} diff --git a/src/shell/CommandKnowledge.ts b/src/shell/CommandKnowledge.ts new file mode 100644 index 00000000..8c2f5895 --- /dev/null +++ b/src/shell/CommandKnowledge.ts @@ -0,0 +1,89 @@ +import {Highlighter} from '../input/Highlighter.js'; +import {graphemes} from '../input/inputLayout.js'; +import {completionLabel, type CompletionCandidate, type CompletionKind} from './completion.js'; + +export interface CommandKnowledge { + value: string; + kind: CompletionKind; + description: string; + usage?: string; +} + +/** Explicit local facts, shared by completion and inspection. No executable adapters. */ +const COMMANDS: Record = { + git: {description: 'Distributed version control', usage: 'git [options]', words: [ + {value: 'status', kind: 'subcommand', description: 'Show working tree and index status'}, + {value: 'diff', kind: 'subcommand', description: 'Show changes between commits, index and working tree'}, + {value: 'log', kind: 'subcommand', description: 'Show commit history'}, + {value: 'add', kind: 'subcommand', description: 'Stage file content for the next commit'}, + {value: 'commit', kind: 'subcommand', description: 'Record staged changes'}, + ]}, + rg: {description: 'Search files for a pattern', usage: 'rg [options] [path ...]', words: [ + {value: '--hidden', kind: 'option', description: 'Search hidden files and directories'}, + {value: '--glob', kind: 'option', description: 'Include or exclude paths matching a glob', usage: ''}, + {value: '--ignore-case', kind: 'option', description: 'Search case insensitively'}, + {value: '--files', kind: 'option', description: 'List files that would be searched'}, + {value: '--line-number', kind: 'option', description: 'Show line numbers'}, + ]}, + npm: {description: 'Node package manager', usage: 'npm [options]', words: [ + {value: 'run', kind: 'subcommand', description: 'Run a script from package.json', usage: '