From 4ab9f44a9126be29666c0b6d25d9c165ac753925 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:22:54 +0530 Subject: [PATCH 1/2] /open: choose a terminal editor when VISUAL and EDITOR are unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #366 (decision D3): with VISUAL and EDITOR unset and no Zed or VS Code around, /open (and `o` in /view) could only say that no editor was known. - src/host/terminalEditors.ts: a curated list (Neovim, Helix, Micro, Vim, nano, Emacs with -nw, Kakoune), detected on PATH only. Detection never runs an editor (no version arguments), and NMSh never installs one (no install recipes; the upstream page is named instead). - Settings → Sessions → Terminal editor opens the existing provider chooser (a new 'editor' provider family, so /providers and Ask see it too). An installed editor is saved as terminalEditor; a missing one shows why and is not saved. The highlighted editor previews the exact command /open would compose; nothing runs. - resolveHostActions keeps explicit choices first: Open with Zed or VS Code, the editor NMSh runs inside, then VISUAL/EDITOR, then the chosen editor, only when installed. With none chosen, the message names the editors installed here and where to choose. - editorArguments adds the column forms nano (+N,C) and Emacs (+N:C) document; Vim, Neovim and Kakoune keep +N; paths stay single arguments and are quoted for the session's shell when composed. Tests: precedence (environment, surrounding editor, explicit Open with, chosen, missing), per-editor line and column forms, configuration normalization, the family and Settings row, and the whole path through TerminalApp with stub executables on a controlled PATH. Ask's coverage audit now knows the family. Refs #366 --- CHANGELOG.md | 1 + src/app/TerminalApp.ts | 18 ++++--- src/ask/concepts.ts | 6 +-- src/host/HostActions.ts | 51 ++++++++++++++++--- src/host/terminalEditors.ts | 44 ++++++++++++++++ src/prompt/configuration.ts | 5 ++ src/providers/families.ts | 8 ++- src/providers/providers.ts | 2 +- src/ui/SettingsPanel.ts | 9 ++-- tests/terminalEditor.test.ts | 89 +++++++++++++++++++++++++++++++++ tests/terminalEditorApp.test.ts | 72 ++++++++++++++++++++++++++ 11 files changed, 282 insertions(+), 23 deletions(-) create mode 100644 src/host/terminalEditors.ts create mode 100644 tests/terminalEditor.test.ts create mode 100644 tests/terminalEditorApp.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41472b72..c89794d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`/modules explain` and `/modules timings`** ([#365](https://github.com/raiseCatError/notMyShell/issues/365)) write a plain-text report into the transcript: each configured module's surface, whether it is on screen and why (including when the Context Rail or Status Strip left it out, or shortened it, to fit the width in the last frame), and its sources' state, age and last collection time; or the slowest last collections. They read what the Context Engine already has and collect nothing. - **Five more bundled theme families** ([#401](https://github.com/raiseCatError/notMyShell/issues/401)): Ayu (Dark, Mirage, Light), Kanagawa (Wave, Dragon, Lotus), Everforest (Dark, Light), Flexoki (Dark, Light) and Night Owl (Night Owl, Light Owl), with colors copied exactly from each project's MIT-licensed source at a recorded revision. They theme NMSh's own prompt fills and chrome only; the terminal's own colors and configuration are never changed. Existing theme ids and saved choices are unchanged, and Lavender stays the default. Theme Studio's Built-in list gains `/` to filter by name, family, or `dark`/`light`. - **`/track`** keeps one live result of a finite command in the transcript, refreshed in place ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). `/track df -h` or `/track --every 5s -- docker system df` writes the command once, then shows a status row (`live · every 5s · updated 13:49:25 · exit 0`, a failing exit, a timeout, paused, stale, refreshing) and the latest result under it, with a stable height. The shell stays free; nothing accumulates per refresh, and find, selection and `/copy` never include the live rows. `/track copy` copies the latest result, `/track refresh`, `pause`, `resume` and `stop` control it, and stopping leaves the last result in the transcript once. Samples run `sh -c` from the starting directory with your shell's PATH (not its aliases or functions), never overlap, and are bounded (200 kB, a timeout, at most 8 trackers); destructive and unknown commands are refused or need a Yes, as for `/watch`. Trackers stop with the window that started them. `/watch` keeps its manager and its behavior; both share one scheduler, which gains stricter interval parsing, refusals for substitutions, background jobs and heredocs, UTF-8-safe bounded capture, visible timeouts and a stop grace period (from draft #394). +- **Choose a terminal editor for `/open` when VISUAL and EDITOR are not set** ([#366](https://github.com/raiseCatError/notMyShell/issues/366)). Settings → Terminal editor (also in `/providers`) lists Neovim, Helix, Micro, Vim, nano, Emacs and Kakoune, marks which are installed here, and saves one you have; one that is not installed cannot be chosen, and NMSh never installs an editor. `/open` then puts that editor's command in the composer for you to run, with the line and column in the form that editor documents (`hx file:12:5`, `nano +12,5 file`, `emacs -nw +12:5 file`, `nvim +12 file`). Zed or VS Code (around NMSh or chosen in Open with) and VISUAL/EDITOR still come first. When nothing is set, the message names the editors installed here. ### Fixed diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 62148e4a..ef2be7cc 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -227,7 +227,8 @@ import {extractFacts} from '../status/adapters.js'; import {focusForeground, foreground, background, UI_COLORS, lazyForeground} from '../ui/palette.js'; import {AgentActivityStore} from '../agents/AgentActivityStore.js'; import {isShellId, knowledgeJobCount, type ShellId} from '../shell/adapters/ShellAdapter.js'; -import {findSourceReferences, parseOpenArgument, resolveHostActions, resolveLocation, runHostAction, type HostAction, type HostActionAdapter} from '../host/HostActions.js'; +import {findSourceReferences, parseOpenArgument, resolveHostActions, resolveLocation, runHostAction, systemHostEnvironment, terminalEditorPreview, type HostAction, type HostActionAdapter} from '../host/HostActions.js'; +import {TERMINAL_EDITORS} from '../host/terminalEditors.js'; import {openPanelKey, renderOpenPanel, type OpenPanelState} from '../host/OpenPanel.js'; import {fishQuote, posixQuote} from '../shell/adapters/ShellAdapter.js'; import {compileQuery, createFind, findCount, parseSearchCommand, refreshFind, revealStart, stepFind, type FindState} from '../output/TranscriptSearch.js'; @@ -5463,7 +5464,7 @@ export class TerminalApp { else if (destination === 'cursor') this.openCursorPanel(); else if (destination === 'themeStudio') this.openThemeStudio(); else if (destination === 'themeBridge') void this.openThemeBridge(); - else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history' || destination === 'picker' || destination === 'navigation') this.startProviderPanel(destination); + else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history' || destination === 'picker' || destination === 'navigation' || destination === 'editor') this.startProviderPanel(destination); else if (destination === 'queue') this.rowPanel = createRowPanel('Queue', '/queue · /settings queue', [...QUEUE_SETTINGS_ROW_IDS], 'queue'); else void this.startKeyboard(); } @@ -7527,8 +7528,10 @@ export class TerminalApp { }); } - private startProviderPanel(family: 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'): void { - const state: ProviderPanelState = family === 'welcome' + private startProviderPanel(family: 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'editor'): void { + const state: ProviderPanelState = family === 'editor' + ? createProviderPanel(family, 'Terminal editor', TERMINAL_EDITORS, this.promptConfiguration.terminalEditor) + : 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) @@ -7548,6 +7551,7 @@ export class TerminalApp { 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 === 'editor') return terminalEditorPreview(selected.id, process.env).map(line => `${SUBTLE}${line}${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') { @@ -7608,7 +7612,9 @@ export class TerminalApp { private saveProviderChoice(state: ProviderPanelState): void { const selected = providerPanelSelection(state); - const next = state.family === 'welcome' + const next = state.family === 'editor' + ? {...structuredClone(this.promptConfiguration), terminalEditor: selected.id as PromptConfiguration['terminalEditor']} + : 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']} @@ -8505,7 +8511,7 @@ export class TerminalApp { private openPanel?: OpenPanelState; private hostActions(): HostActionAdapter { - return resolveHostActions(this.promptConfiguration.openWith); + return resolveHostActions(this.promptConfiguration.openWith, systemHostEnvironment(), this.promptConfiguration.terminalEditor); } /** path:line references in the newest outputs, each with the cwd its command ran in. */ diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index a4a9c32e..68770d94 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -199,9 +199,9 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'agents', label: 'Agent activity', support: 'actionable', open: '/agents', covers: ['/agents'], aliases: ['agents', 'agent activity', 'agent stats', 'agent statistics', 'claude code activity', 'codex activity', 'agent runs'], description: '/agents tracks local agent CLI activity (Claude Code, Codex CLI): durations, run counts and a heatmap. It never records prompts or output.'}, - {id: 'editor', label: 'Editor (open files)', support: 'actionable', capability: 'editor.status', where: 'Settings → Open with', covers: ['/open', '/open-diff'], - aliases: ['editor', 'open with', 'zed', 'vscode', 'vs code', 'visual studio code', 'open files in', 'open diff'], - description: '/open hands a path[:line[:column]] to your editor (Auto, Zed, VS Code, or VISUAL/EDITOR); /open-diff shows two files in its diff view.'}, + {id: 'editor', label: 'Editor (open files)', support: 'actionable', capability: 'editor.status', where: 'Settings → Open with, and Settings → Terminal editor', covers: ['/open', '/open-diff', 'family:editor'], + aliases: ['editor', 'open with', 'zed', 'vscode', 'vs code', 'visual studio code', 'open files in', 'open diff', 'terminal editor', 'neovim', 'nvim', 'helix', 'micro', 'nano', 'kakoune'], + description: '/open hands a path[:line[:column]] to your editor (Auto, Zed, VS Code, or VISUAL/EDITOR); /open-diff shows two files in its diff view. With VISUAL and EDITOR unset, Settings → Terminal editor chooses one installed here (Neovim, Helix, Micro, Vim, nano, Emacs, Kakoune); its command goes in the composer for you to run.'}, {id: 'keyboard', label: 'Keyboard', support: 'actionable', open: '/keyboard', covers: ['/keyboard', 'settings:Keyboard'], aliases: ['keyboard', 'key bindings', 'keybindings', 'shortcuts', 'keyboard shortcuts', 'option key', 'kitty keyboard'], description: '/keyboard configures terminal keyboard integration (enhanced keys, Option as Meta, Shift+Enter).'}, diff --git a/src/host/HostActions.ts b/src/host/HostActions.ts index 0bb3eb38..98d0dcbe 100644 --- a/src/host/HostActions.ts +++ b/src/host/HostActions.ts @@ -3,6 +3,7 @@ import {accessSync, constants, statSync} from 'node:fs'; import {spawnSync} from 'node:child_process'; import {basename, delimiter, isAbsolute, join, resolve} from 'node:path'; import {homedir} from 'node:os'; +import {installedTerminalEditors, terminalEditor, type TerminalEditorId} from './terminalEditors.js'; /** * HostActions: delegate editor-native work to the editor around NMSh. @@ -123,18 +124,20 @@ export function editorArguments(editor: readonly string[], target: SourceLocatio if (!target.line) return [...editor, target.path]; if (['hx', 'helix'].includes(name)) return [...editor, location(target)]; if (name === 'micro') return [...editor, `+${target.line}${target.column ? `:${target.column}` : ''}`, target.path]; + // nano documents +LINE,COLUMN and Emacs +LINE:COLUMN; the others take only +LINE. + if (name === 'nano' && target.column) return [...editor, `+${target.line},${target.column}`, target.path]; + if (['emacs', 'emacsclient'].includes(name) && target.column) return [...editor, `+${target.line}:${target.column}`, target.path]; if (['vi', 'vim', 'nvim', 'view', 'nano', 'emacs', 'emacsclient', 'kak', 'pico', 'mg', 'joe'].includes(name)) return [...editor, `+${target.line}`, target.path]; // Unknown editor: no guessed location flag; the file opens at its start. return [...editor, target.path]; } -function editorAdapter(editor: string[]): HostActionAdapter { - const label = editor[0] ?? 'editor'; +function editorAdapter(editor: readonly string[], shown = basename(editor[0] ?? 'editor')): HostActionAdapter { return { - id: 'editor', label: basename(label), + id: 'editor', label: shown, capabilities: {nativeFileOpen: true, nativeDirectoryOpen: false, nativeDiff: false}, - openFile: target => ({kind: 'compose', argv: editorArguments(editor, target), label: basename(label)}), - openDirectory: () => ({kind: 'unsupported', reason: `${basename(label)} is a terminal editor; NMSh does not open folders in it. Use cd here, or an editor with a folder view.`}), + openFile: target => ({kind: 'compose', argv: editorArguments(editor, target), label: shown}), + openDirectory: () => ({kind: 'unsupported', reason: `${shown} is a terminal editor; NMSh does not open folders in it. Use cd here, or an editor with a folder view.`}), openDiff: () => ({kind: 'unsupported', reason: 'No editor with a native diff view is configured. Run git diff (or diff -u) here, or set Open with to Zed or VS Code.'}), }; } @@ -180,9 +183,10 @@ export function configuredEditor(env: NodeJS.ProcessEnv): string[] | undefined { /** * Pick the adapter: an explicit choice wins; otherwise the editor NMSh runs - * inside (from its own terminal environment), then VISUAL/EDITOR. + * inside (from its own terminal environment), then VISUAL/EDITOR, then the + * terminal editor chosen in Settings (only when it is installed here). */ -export function resolveHostActions(openWith: OpenWith, host: HostEnvironment = systemHostEnvironment()): HostActionAdapter { +export function resolveHostActions(openWith: OpenWith, host: HostEnvironment = systemHostEnvironment(), chosen: TerminalEditorId = 'environment'): HostActionAdapter { const program = host.env.TERM_PROGRAM; const inZed = program === 'zed' || Boolean(host.env.ZED_TERM); const inVsCode = program === 'vscode'; @@ -208,7 +212,21 @@ export function resolveHostActions(openWith: OpenWith, host: HostEnvironment = s } return editorAdapter(editor); } - return NONE; + return chosenEditorAdapter(chosen, host); +} + +/** With VISUAL and EDITOR unset: the terminal editor chosen in Settings, or a factual message naming the installed ones. */ +function chosenEditorAdapter(chosen: TerminalEditorId, host: HostEnvironment): HostActionAdapter { + const editor = terminalEditor(chosen); + if (editor?.executable && editor.argv) { + if (host.which(editor.executable)) return editorAdapter(editor.argv, editor.label); + const reason = `${editor.label} (${editor.executable}) is chosen in Settings → Terminal editor but is not on PATH. Install it, choose another, or set VISUAL or EDITOR.`; + return {...NONE, openFile: () => ({kind: 'unsupported', reason})}; + } + const installed = installedTerminalEditors(host); + if (!installed.length) return NONE; + const reason = `No editor is known here: not inside Zed or VS Code, and neither VISUAL nor EDITOR is set. Choose one in /settings → Terminal editor (installed here: ${installed.map(item => item.label).join(', ')}), or set VISUAL or EDITOR.`; + return {...NONE, openFile: () => ({kind: 'unsupported', reason})}; } /** Launch a GUI editor detached, argv only. Resolves with a failure reason, never throws. */ @@ -281,3 +299,20 @@ export function resolveLocation(target: SourceLocation, cwd: string, home = home if (!stat.isFile()) return {ok: false, reason: `${path} is not a regular file.`}; return {ok: true, kind: 'file', location: {...target, path}}; } + +/** + * What the Terminal editor chooser shows for the highlighted choice: the command /open would compose (nothing runs), + * and whether VISUAL/EDITOR overrides it. + */ +export function terminalEditorPreview(id: string, env: NodeJS.ProcessEnv): string[] { + const configured = configuredEditor(env); + const editor = terminalEditor(id); + const example = {path: 'README.md', line: 12, column: 5}; + if (!editor?.argv) { + return configured ? [`VISUAL/EDITOR is ${configured.join(' ')}: /open README.md:12 composes ${editorArguments(configured, example).join(' ')}`] + : ['Neither VISUAL nor EDITOR is set: choose an editor here, or set one in your shell.']; + } + const lines = [`/open README.md:12:5 puts this in the composer for you to run: ${editorArguments(editor.argv, example).join(' ')}`]; + if (configured) lines.push(`VISUAL/EDITOR (${configured.join(' ')}) is set and is used instead.`); + return lines; +} diff --git a/src/host/terminalEditors.ts b/src/host/terminalEditors.ts new file mode 100644 index 00000000..5aaefdfb --- /dev/null +++ b/src/host/terminalEditors.ts @@ -0,0 +1,44 @@ +import type {ProviderDescriptor} from '../providers/providers.js'; +import type {HostEnvironment} from './HostActions.js'; + +/** + * Terminal editors /open and `o` in /view can hand a file to when VISUAL and EDITOR are unset (#366). A curated list, + * detected on PATH only: detection never runs an editor, and NMSh never installs one (no recipes; the upstream page is + * named instead). `environment` means VISUAL/EDITOR, the default. Line and column forms live in editorArguments. + */ +export type TerminalEditorId = 'environment' | 'nvim' | 'hx' | 'micro' | 'vim' | 'nano' | 'emacs' | 'kak'; + +export interface TerminalEditorDescriptor extends ProviderDescriptor { + /** The command NMSh composes, before the file arguments (Emacs needs -nw to stay in the terminal). */ + argv?: readonly string[]; +} + +export const TERMINAL_EDITORS: readonly TerminalEditorDescriptor[] = [ + {id: 'environment', family: 'editor', label: 'VISUAL / EDITOR', kind: 'native', + description: 'The editor your shell names in VISUAL or EDITOR; when neither is set, choose one below'}, + {id: 'nvim', family: 'editor', label: 'Neovim', kind: 'external', executable: 'nvim', argv: ['nvim'], + description: 'Opens at the line (+N)', source: 'https://neovim.io'}, + {id: 'hx', family: 'editor', label: 'Helix', kind: 'external', executable: 'hx', argv: ['hx'], + description: 'Opens at the line and column (file:N:C)', source: 'https://helix-editor.com'}, + {id: 'micro', family: 'editor', label: 'Micro', kind: 'external', executable: 'micro', argv: ['micro'], + description: 'Opens at the line and column (+N:C)', source: 'https://micro-editor.github.io'}, + {id: 'vim', family: 'editor', label: 'Vim', kind: 'external', executable: 'vim', argv: ['vim'], + description: 'Opens at the line (+N)', source: 'https://www.vim.org'}, + {id: 'nano', family: 'editor', label: 'nano', kind: 'external', executable: 'nano', argv: ['nano'], + description: 'Opens at the line and column (+N,C)', source: 'https://www.nano-editor.org'}, + {id: 'emacs', family: 'editor', label: 'Emacs', kind: 'external', executable: 'emacs', argv: ['emacs', '-nw'], + description: 'In the terminal (-nw), at the line and column (+N:C)', source: 'https://www.gnu.org/software/emacs/'}, + {id: 'kak', family: 'editor', label: 'Kakoune', kind: 'external', executable: 'kak', argv: ['kak'], + description: 'Opens at the line (+N)', source: 'https://kakoune.org'}, +]; + +export const TERMINAL_EDITOR_IDS: readonly TerminalEditorId[] = TERMINAL_EDITORS.map(editor => editor.id); + +export function terminalEditor(id: string): TerminalEditorDescriptor | undefined { + return TERMINAL_EDITORS.find(editor => editor.id === id); +} + +/** The curated editors found on this host's PATH, in catalog order. */ +export function installedTerminalEditors(host: Pick): TerminalEditorDescriptor[] { + return TERMINAL_EDITORS.filter(editor => editor.executable && host.which(editor.executable)); +} diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 7fef4d49..c60e322a 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -40,6 +40,7 @@ import {normalizeVibrance, type Vibrance} from '../chroma/color.js'; import {isShellId, type ShellId} from '../shell/adapters/ShellAdapter.js'; import {normalizeProfiles, type AgentProfile} from '../agents/sessions/manager.js'; import {OPEN_WITH_IDS, type OpenWith} from '../host/HostActions.js'; +import {TERMINAL_EDITOR_IDS, type TerminalEditorId} from '../host/terminalEditors.js'; import {DEFAULT_KEEP_AWAKE_PRESENTATION, normalizeKeepAwakePresentation, type KeepAwakePresentation} from '../keepAwake/presentation.js'; import {TERMINAL_TITLE_MODES, type TerminalTitleMode} from '../host/terminalTitle.js'; @@ -695,6 +696,8 @@ export interface PromptConfiguration { shellBackend: ShellId; /** Where /open and /open-diff delegate: the surrounding editor (auto), Zed, VS Code, or $VISUAL/$EDITOR. */ openWith: OpenWith; + /** The terminal editor /open and `o` in /view use when VISUAL and EDITOR are unset; `environment` is VISUAL/EDITOR only (#366). */ + terminalEditor: TerminalEditorId; /** The terminal window/tab title while NMSh owns the screen: Off (default, the terminal keeps its own), project, or project and session. */ terminalTitle: TerminalTitleMode; nmsh: { @@ -902,6 +905,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { workspacePanels: false, shellBackend: 'zsh', openWith: 'auto', + terminalEditor: 'environment', terminalTitle: 'session', idleVisuals: {...DEFAULT_IDLE_VISUALS, customStops: []}, liveActivity: {...DEFAULT_LIVE_ACTIVITY, customStops: []}, @@ -1014,6 +1018,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio localUnderstanding: normalizeLocalUnderstanding(value.localUnderstanding), shellBackend: isShellId(value.shellBackend) ? value.shellBackend : 'zsh', openWith: OPEN_WITH_IDS.includes(value.openWith as OpenWith) ? value.openWith as OpenWith : 'auto', + terminalEditor: TERMINAL_EDITOR_IDS.includes(value.terminalEditor as TerminalEditorId) ? value.terminalEditor as TerminalEditorId : 'environment', terminalTitle: TERMINAL_TITLE_MODES.includes(value.terminalTitle as TerminalTitleMode) ? value.terminalTitle as TerminalTitleMode : 'off' as TerminalTitleMode, toolUpdateChecks, installSuggestions, ignoredInstallSuggestions, promptSymbol: normalizePromptSymbol(value.promptSymbol), ...(promptSymbolCustom ? {promptSymbolCustom} : {})}; const provider: PromptProviderId = promptValue.provider === 'starship' || promptValue.provider === 'powerlevel10k' || promptValue.provider === 'ohMyPosh' || promptValue.provider === 'none' diff --git a/src/providers/families.ts b/src/providers/families.ts index aded9270..8de29f8c 100644 --- a/src/providers/families.ts +++ b/src/providers/families.ts @@ -5,6 +5,7 @@ import {SUGGESTION_PROVIDERS} from '../suggestions/types.js'; import {HISTORY_PROVIDERS} from '../shell/historyProviders.js'; import {PICKER_PROVIDERS} from '../pickers/Picker.js'; import {NAVIGATION_PROVIDERS} from '../shell/DirectoryService.js'; +import {TERMINAL_EDITORS} from '../host/terminalEditors.js'; import type {ProviderDescriptor, ProviderStatus} from './providers.js'; /** @@ -13,7 +14,7 @@ import type {ProviderDescriptor, ProviderStatus} from './providers.js'; * Settings and Setup Cat all read and write through these accessors, so * there is no second provider state. Detection results stay runtime-only. */ -export type SwitchableFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'; +export type SwitchableFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'editor'; export interface ProviderFamilyDefinition { family: SwitchableFamily; @@ -24,7 +25,7 @@ export interface ProviderFamilyDefinition { get: (config: PromptConfiguration) => string; set: (config: PromptConfiguration, id: string) => PromptConfiguration; /** The existing domain panel for deeper configuration, when there is one. */ - settingsDestination?: 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'; + settingsDestination?: 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'editor'; } export const PROVIDER_FAMILIES: readonly ProviderFamilyDefinition[] = [ @@ -40,6 +41,9 @@ export const PROVIDER_FAMILIES: readonly ProviderFamilyDefinition[] = [ set: (config, id) => ({...config, picker: id as PromptConfiguration['picker']}), settingsDestination: 'picker'}, {family: 'navigation', title: 'Directory navigation', providers: NAVIGATION_PROVIDERS, fallback: 'native', get: config => config.navigation, set: (config, id) => ({...config, navigation: id as PromptConfiguration['navigation']}), settingsDestination: 'navigation'}, + // Used by /open and `o` in /view only when VISUAL and EDITOR are unset (#366). + {family: 'editor', title: 'Terminal editor', providers: TERMINAL_EDITORS, fallback: 'environment', get: config => config.terminalEditor, + set: (config, id) => ({...config, terminalEditor: id as PromptConfiguration['terminalEditor']}), settingsDestination: 'editor'}, ]; export function providerFamily(family: string): ProviderFamilyDefinition | undefined { diff --git a/src/providers/providers.ts b/src/providers/providers.ts index c1086580..960e44bb 100644 --- a/src/providers/providers.ts +++ b/src/providers/providers.ts @@ -8,7 +8,7 @@ import {GLYPHS} from '../ui/glyphs.js'; * 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' | 'navigation' | 'tool'; +export type ProviderFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'editor' | 'tool'; export type ProviderKind = 'native' | 'external' | 'none'; export interface ProviderInstall { diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 4a551602..a36a2ccc 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -52,6 +52,7 @@ import {framePanel, renderTabStrip} from './PanelShell.js'; import {stepIndex, toggleValue} from './formControls.js'; import {ASK_PRESENTATION_LABELS, ASK_PRESENTATIONS} from '../ask/AskPanel.js'; import {displayWidth, highlightMatches, truncateAnsi} from '../util/text.js'; +import {TERMINAL_EDITORS} from '../host/terminalEditors.js'; /** The three top-level views of the one shared panel behind /settings, /config, and /status. */ export const SETTINGS_VIEWS = ['Settings', 'Status', 'Config'] as const; @@ -91,7 +92,7 @@ export function switchSettingsView(state: SettingsPanelState, delta: -1 | 1): vo } /** Where Enter leads: `glyph` is the rich glyph preview inside the panel, the rest are full panels. */ -export type SettingsDestination = 'glyph' | 'appearance' | 'prompt' | 'transcript' | 'syntax' | 'layout' | 'keyboard' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'toolConfig' | 'tools' +export type SettingsDestination = 'glyph' | 'appearance' | 'prompt' | 'transcript' | 'syntax' | 'layout' | 'keyboard' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'editor' | 'toolConfig' | 'tools' | 'setup' | 'setupQuick' | 'resetInstallSuggestions' | 'screensaver' | 'chromeColors' | 'cursor' | 'idleColors' | 'activityColors' | 'themeStudio' | 'themeBridge' | 'queue'; interface SettingsRowBase { @@ -497,6 +498,8 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'openWith', label: 'Open with', description: 'Where /open and /open-diff hand files: the editor around NMSh (Auto), Zed, VS Code, or VISUAL/EDITOR', category: 'Sessions', values: OPEN_WITH_IDS, labels: ['Auto', 'Zed', 'VS Code', 'VISUAL / EDITOR'], get: config => config.openWith, set: (config, openWith) => ({...config, openWith})}), + {id: 'terminalEditor', label: 'Terminal editor', description: 'Used by /open and o in /view when VISUAL and EDITOR are not set: choose one installed here', category: 'Sessions', + control: 'child', destination: 'editor', value: config => TERMINAL_EDITORS.find(editor => editor.id === config.terminalEditor)?.label ?? 'VISUAL / EDITOR'}, {id: 'welcome', label: 'Welcome provider', description: 'What new sessions show first: Vespyr, Fastfetch, Neofetch, or None', category: 'Welcome', control: 'child', destination: 'welcome', value: config => welcomeProvider(config.welcome).label}, {id: 'suggestions', label: 'Suggestions', description: 'Ghost-text prediction provider: NMSh Native, Deja, or None', category: 'Suggestions', @@ -677,7 +680,7 @@ export const CONFIG_GROUP_BY_ID: Readonly> = { glyphStyle: 'General', panelPosition: 'General', liveSessionStartup: 'General', liveSessionMultiple: 'General', updateMode: 'General', updateFrequency: 'General', promptVibrance: 'Appearance', showShell: 'Prompt & Composer', composerPosition: 'Prompt & Composer', composerDividers: 'Prompt & Composer', divider: 'Prompt & Composer', historicalPrompt: 'Prompt & Composer', - transcriptPresentation: 'Editor', openWith: 'Editor', suggestionsOnEmpty: 'Editor', pastePreview: 'Editor', + transcriptPresentation: 'Editor', openWith: 'Editor', terminalEditor: 'Editor', suggestionsOnEmpty: 'Editor', pastePreview: 'Editor', reducedMotion: 'Cursor & Motion', effectsOff: 'Cursor & Motion', shellBackend: 'Shell & Providers', suggestions: 'Shell & Providers', localUnderstanding: 'Local Understanding', @@ -711,7 +714,7 @@ const CONFIG_ORDER: readonly string[] = [ 'glyphStyle', 'panelPosition', 'liveSessionStartup', 'liveSessionMultiple', 'updateMode', 'updateFrequency', 'uiChrome', 'themeFamily', 'promptVibrance', 'treatmentPreset', 'shimmer', 'autoEffects', 'idleTimeout', 'activityStyle', 'activityColors', 'provider', 'promptStyle', 'promptSymbol', 'composerPosition', 'composerDividers', 'divider', 'historicalPrompt', 'showShell', 'statusStrip', - 'syntaxHighlighting', 'pastePreview', 'transcriptPresentation', 'suggestionsOnEmpty', 'openWith', + 'syntaxHighlighting', 'pastePreview', 'transcriptPresentation', 'suggestionsOnEmpty', 'openWith', 'terminalEditor', 'cursorShape', 'cursorRenderer', 'cursorMotion', 'cursorEffect', 'cursorIdle', 'cursorColor', 'cursorSpeed', 'cursorIntensity', 'cursorTrail', 'cursorParticles', 'cursorAdvanced', 'motion_rendering', 'motion_contextTransitions', 'motion_commandLaunch', 'motion_completionHighlight', 'motion_completionEffect', 'motion_cursorTravel', 'motion_eventFeedback', 'motion_intensity', 'motion_speed', 'reducedMotion', 'effectsOff', diff --git a/tests/terminalEditor.test.ts b/tests/terminalEditor.test.ts new file mode 100644 index 00000000..34b98a06 --- /dev/null +++ b/tests/terminalEditor.test.ts @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {editorArguments, resolveHostActions, type HostEnvironment} from '../src/host/HostActions.js'; +import {TERMINAL_EDITORS, installedTerminalEditors} from '../src/host/terminalEditors.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {PROVIDER_FAMILIES, selectProvider} from '../src/providers/families.js'; +import {SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; + +/** + * #366 (decision D3): with VISUAL and EDITOR unset, /open and `o` in /view can use a terminal editor the person chose + * from the ones installed here. Explicit choices win: Open with Zed or VS Code, the editor NMSh runs inside, then + * VISUAL/EDITOR, then the chosen editor. Nothing is assumed installed, nothing is installed, nothing is run. + */ + +function host(env: NodeJS.ProcessEnv, bins: Record = {}): HostEnvironment { + return {env, which: name => bins[name], helpText: () => ''}; +} +const target = {path: '/w/notes.md', line: 12, column: 5}; + +test('terminal editor: used only when VISUAL and EDITOR are unset, and only when installed', () => { + const chosen = resolveHostActions('auto', host({}, {hx: '/opt/bin/hx'}), 'hx'); + assert.deepEqual(chosen.openFile(target), {kind: 'compose', argv: ['hx', '/w/notes.md:12:5'], label: 'Helix'}); + // An explicit preference in the environment wins over NMSh's own choice. + const env = resolveHostActions('auto', host({EDITOR: 'vim'}, {hx: '/opt/bin/hx'}), 'hx'); + assert.deepEqual(env.openFile(target), {kind: 'compose', argv: ['vim', '+12', '/w/notes.md'], label: 'vim'}); + // So does the editor NMSh runs inside, and an explicit Open with. + const zed = resolveHostActions('auto', host({TERM_PROGRAM: 'zed'}, {zed: '/opt/bin/zed', hx: '/opt/bin/hx'}), 'hx'); + assert.equal(zed.id, 'zed'); + const code = resolveHostActions('vscode', host({}, {code: '/opt/bin/code', hx: '/opt/bin/hx'}), 'hx'); + assert.equal(code.id, 'vscode'); + // Open with VISUAL/EDITOR and nothing set: the chosen editor stands in. + const editorChoice = resolveHostActions('editor', host({}, {micro: '/opt/bin/micro'}), 'micro'); + assert.deepEqual(editorChoice.openFile(target), {kind: 'compose', argv: ['micro', '+12:5', '/w/notes.md'], label: 'Micro'}); + // Chosen but not installed here: said plainly; nothing else is guessed. + const missing = resolveHostActions('auto', host({}, {}), 'nvim'); + const reason = (missing.openFile(target) as {kind: string; reason: string}); + assert.equal(reason.kind, 'unsupported'); + assert.match(reason.reason, /Neovim \(nvim\) is chosen in Settings → Terminal editor but is not on PATH/u); +}); + +test('terminal editor: with none chosen, the message names the editors installed here and where to choose', () => { + const none = resolveHostActions('auto', host({}, {nvim: '/opt/bin/nvim', micro: '/opt/bin/micro'}), 'environment'); + const reason = (none.openFile(target) as {reason: string}).reason; + assert.match(reason, /VISUAL nor EDITOR/u); + assert.match(reason, /\/settings → Terminal editor/u); + assert.match(reason, /installed here: Neovim, Micro/u); + const bare = resolveHostActions('auto', host({}, {}), 'environment'); + assert.doesNotMatch((bare.openFile(target) as {reason: string}).reason, /installed here/u, 'no list when none is installed'); + assert.deepEqual(installedTerminalEditors(host({}, {kak: '/k', emacs: '/e'})).map(editor => editor.id), ['emacs', 'kak'], 'catalog order'); +}); + +test('terminal editor: each editor gets only the line and column forms its documentation describes', () => { + const argv = (id: string) => { + const editor = TERMINAL_EDITORS.find(item => item.id === id)!; + return editorArguments(editor.argv!, target); + }; + assert.deepEqual(argv('nvim'), ['nvim', '+12', '/w/notes.md'], 'Vim and Neovim: +N (no column flag)'); + assert.deepEqual(argv('vim'), ['vim', '+12', '/w/notes.md']); + assert.deepEqual(argv('hx'), ['hx', '/w/notes.md:12:5'], 'Helix: path:N:C'); + assert.deepEqual(argv('micro'), ['micro', '+12:5', '/w/notes.md'], 'Micro: +N:C'); + assert.deepEqual(argv('nano'), ['nano', '+12,5', '/w/notes.md'], 'nano: +N,C'); + assert.deepEqual(argv('emacs'), ['emacs', '-nw', '+12:5', '/w/notes.md'], 'Emacs in the terminal: -nw, +N:C'); + assert.deepEqual(argv('kak'), ['kak', '+12', '/w/notes.md'], 'Kakoune: +N'); + assert.deepEqual(editorArguments(['nano'], {path: '/a', line: 3}), ['nano', '+3', '/a'], 'no column: none passed'); + assert.deepEqual(editorArguments(['nano'], {path: '/a'}), ['nano', '/a'], 'no line: the file alone'); + // Paths stay one argument; nothing is joined into shell text here. + assert.deepEqual(editorArguments(['micro'], {path: '/w/a b; rm -rf ~.md', line: 1}), ['micro', '+1', '/w/a b; rm -rf ~.md']); +}); + +test('terminal editor: a setting of its own, normalized; a provider family for /providers, Ask and Settings', () => { + assert.equal(normalizePromptConfiguration({}).terminalEditor, 'environment', 'VISUAL/EDITOR by default'); + assert.equal(normalizePromptConfiguration({terminalEditor: 'hx'}).terminalEditor, 'hx'); + assert.equal(normalizePromptConfiguration({terminalEditor: 'sublime'}).terminalEditor, 'environment', 'unknown values fall back'); + const family = PROVIDER_FAMILIES.find(item => item.family === 'editor'); + assert.ok(family, 'the Terminal editor family is listed with the others'); + assert.equal(family.title, 'Terminal editor'); + assert.equal(family.fallback, 'environment'); + const base = normalizePromptConfiguration({}); + assert.equal(selectProvider(base, 'editor', 'micro')?.terminalEditor, 'micro'); + assert.equal(selectProvider(base, 'editor', 'notepad'), undefined, 'unknown editors change nothing'); + for (const editor of TERMINAL_EDITORS) { + assert.equal(editor.install, undefined, `${editor.label}: NMSh never installs an editor`); + assert.equal(editor.recipe, undefined, `${editor.label}: no install recipe`); + assert.equal(editor.versionArgs, undefined, `${editor.label}: detection never runs the editor`); + } + const row = SETTINGS_ROWS.find(item => item.id === 'terminalEditor'); + assert.ok(row && row.control === 'child' && row.destination === 'editor', 'Settings → Sessions → Terminal editor opens the chooser'); + assert.equal(row.category, 'Sessions'); +}); diff --git a/tests/terminalEditorApp.test.ts b/tests/terminalEditorApp.test.ts new file mode 100644 index 00000000..c3b4b469 --- /dev/null +++ b/tests/terminalEditorApp.test.ts @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {clearProviderDetection} from '../src/providers/providers.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #366 (D3) through the real TerminalApp: Settings → Terminal editor lists the curated editors with what is installed + * here, saves an installed choice, refuses one that is not, and /open then composes the chosen editor's command for + * the person to run. VISUAL/EDITOR are unset; PATH holds stub executables only; nothing is executed or installed. + */ + +async function settled(check: () => boolean, what: string): Promise { + const deadline = Date.now() + 5000; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise(done => setTimeout(done, 10)); + } +} + +test('Terminal editor: choose an installed editor, refuse a missing one; /open composes the chosen command', async () => { + // Hermetic: not inside Zed or VS Code either (the surrounding editor would rightly win). + const saved = {PATH: process.env.PATH, VISUAL: process.env.VISUAL, EDITOR: process.env.EDITOR, ZED_TERM: process.env.ZED_TERM}; + const bin = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-editors-'))); + const f = executionFrameFixture(100, 30); + try { + for (const name of ['hx', 'micro']) { writeFileSync(join(bin, name), '#!/bin/sh\nexit 0\n'); chmodSync(join(bin, name), 0o755); } + process.env.PATH = bin; + delete process.env.VISUAL; + delete process.env.EDITOR; + delete process.env.ZED_TERM; + clearProviderDetection(); + const app = f.app; + app['startProviderPanel']('editor'); + const state = app['providerPanelState']!; + assert.equal(state.title, 'Terminal editor'); + await settled(() => Object.keys(state.statuses).length === state.providers.length, 'detection'); + assert.equal(state.statuses.hx?.state, 'installed'); + assert.equal(state.statuses.nvim?.state, 'missing'); + // Highlighting shows the command /open would compose; nothing runs. + state.selectedIndex = state.providers.findIndex(provider => provider.id === 'hx'); + const preview = app['providerPreview'](state, 96).map(line => stripAnsi(line)).join('\n'); + assert.match(preview, /puts this in the composer for you to run: hx README\.md:12:5/u); + // A missing editor cannot be chosen: the reason is shown and nothing is saved. + state.selectedIndex = state.providers.findIndex(provider => provider.id === 'nvim'); + await app['handleProviderPanelKey']({kind: 'enter'}, state); + assert.match(state.message ?? '', /no curated install for Neovim/u); + assert.equal(app['promptConfiguration'].terminalEditor, 'environment'); + // An installed one is saved. + state.selectedIndex = state.providers.findIndex(provider => provider.id === 'hx'); + await app['handleProviderPanelKey']({kind: 'enter'}, state); + assert.equal(app['promptConfiguration'].terminalEditor, 'hx'); + assert.equal(app['providerPanelState'], undefined); + // /open composes Helix's own form, quoted for the session's shell, for the person to run. + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-editors-files-'))); + try { + mkdirSync(join(root, 'docs')); + writeFileSync(join(root, 'docs', 'a note.md'), 'x\n'); + await app['openLocation']('/open docs/a note.md:3', 'docs/a note.md:3', root); + const notes = (app['output'].transcript().lines as Array>).map(line => line.map(cell => cell?.text ?? ' ').join('').trim()).filter(Boolean).slice(-3).join(' | '); + assert.equal(app['editor'].text, `hx '${join(root, 'docs', 'a note.md')}:3'`, notes); + } finally { rmSync(root, {recursive: true, force: true}); } + } finally { + f.dispose(); + for (const [key, value] of Object.entries(saved)) if (value === undefined) delete process.env[key]; else process.env[key] = value; + clearProviderDetection(); + rmSync(bin, {recursive: true, force: true}); + } +}); From 898fbc3326760213ca8dfcd3171189ddef7486ff Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:53:25 +0530 Subject: [PATCH 2/2] Setup: Terminal editor is reachable from Sessions & alerts The Setup coverage audit requires every Settings row and panel to be reachable from Setup. A route row next to Open with opens the editor chooser over Setup; the choice joins the draft on the way back. --- src/setup/SetupCat.ts | 4 ++- tests/terminalEditorApp.test.ts | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 6b876774..d72465b0 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -415,6 +415,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ : 'NMSh titles the window while it owns the screen; programs keep their own title while they run'}, {row: configRow('agentActivity')}, {row: configRow('openWith')}, + routeRow('setupTerminalEditor', 'Terminal editor', 'For /open and o in /view when VISUAL and EDITOR are not set: choose one installed here', 'editor', 'Sessions'), {row: configRow('notifications')}, {row: configRow('notifyAfter')}, {row: configRow('notifyOnSuccess')}, @@ -501,6 +502,7 @@ export const SETUP_EQUIVALENTS: Readonly> = { provider: 'setupPromptProvider', welcome: 'setupWelcome', suggestions: 'setupSuggestions', history: 'setupHistory', navigation: 'setupNavigation', picker: 'setupPicker', cursorSpeed: 'cursorAdvanced', cursorIntensity: 'cursorAdvanced', cursorTrail: 'cursorAdvanced', cursorParticles: 'cursorAdvanced', tools: 'setupToolChoice', themeStudio: 'setupThemeStudio', onboardingBackdrop: 'setupPresets', themeBridge: 'setupThemeBridge', uiChromeColors: 'setupChromeColors', idleCustomColors: 'setupIdleColors', activityCustomColors: 'setupActivityColors', + terminalEditor: 'setupTerminalEditor', }; /** Where each Settings entry point (a full panel) is reached from Setup: a section, or the route row that opens it. */ @@ -508,7 +510,7 @@ export const SETUP_ENTRY_COVERAGE: Readonly> = { appearance: 'setupHostWindow', glyph: 'glyphStyle', prompt: 'setupPromptModules', transcript: 'transcriptPresentation', syntax: 'syntaxHighlighting', keyboard: 'setupKeyboard', welcome: 'setupWelcome', suggestions: 'setupSuggestions', history: 'setupHistory', picker: 'setupPicker', navigation: 'setupNavigation', layout: 'composerPosition', toolConfig: 'setupToolConfig', tools: 'setupBrowseTools', screensaver: 'idleTimeout', setup: 'setupToolChoice', cursor: 'cursorAdvanced', themeStudio: 'setupThemeStudio', themeBridge: 'setupThemeBridge', chromeColors: 'setupChromeColors', idleColors: 'setupIdleColors', activityColors: 'setupActivityColors', resetInstallSuggestions: 'resetInstallSuggestions', - queue: 'queueTimeline', setupQuick: 'setupPresets', + queue: 'queueTimeline', setupQuick: 'setupPresets', editor: 'setupTerminalEditor', }; function completionFacts(facts: CompletionFacts | undefined): string[] { diff --git a/tests/terminalEditorApp.test.ts b/tests/terminalEditorApp.test.ts index c3b4b469..e27de1aa 100644 --- a/tests/terminalEditorApp.test.ts +++ b/tests/terminalEditorApp.test.ts @@ -6,6 +6,9 @@ import {join} from 'node:path'; import {clearProviderDetection} from '../src/providers/providers.js'; import {stripAnsi} from '../src/util/text.js'; import {executionFrameFixture} from './support/executionTimelineFrames.js'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {SETUP_SECTIONS, sectionIndex, setupKey, setupSelectedRow} from '../src/setup/SetupCat.js'; /** * #366 (D3) through the real TerminalApp: Settings → Terminal editor lists the curated editors with what is installed @@ -70,3 +73,48 @@ test('Terminal editor: choose an installed editor, refuse a missing one; /open c rmSync(bin, {recursive: true, force: true}); } }); + +test('Setup → Sessions → Terminal editor opens the chooser over Setup and comes back to the same step with the choice', async () => { + const saved = {PATH: process.env.PATH, VISUAL: process.env.VISUAL, EDITOR: process.env.EDITOR, ZED_TERM: process.env.ZED_TERM}; + const bin = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-editors-'))); + const isolation = isolateConfig(); + const app = new TerminalApp(); + try { + writeFileSync(join(bin, 'hx'), '#!/bin/sh\nexit 0\n'); chmodSync(join(bin, 'hx'), 0o755); + process.env.PATH = bin; + delete process.env.VISUAL; delete process.env.EDITOR; delete process.env.ZED_TERM; + clearProviderDetection(); + app['render'] = () => {}; + app['startSetup']('sessions'); + const state = app['setupState']!; + const goTo = (row: string) => { + state.section = sectionIndex('sessions'); state.row = 0; + for (let guard = 0; guard < 80 && setupSelectedRow(state)?.row.id !== row; guard += 1) setupKey(state, {kind: 'down'}); + assert.equal(setupSelectedRow(state)?.row.id, row); + }; + // An unapplied edit elsewhere in the step waits in the draft. + goTo('terminalTitle'); + app['handleKey']({kind: 'right'}); + const pending = state.draft.terminalTitle; + assert.notEqual(pending, state.saved.terminalTitle); + goTo('setupTerminalEditor'); + const row = state.row; + app['handleKey']({kind: 'enter'}); + const panel = app['providerPanelState']; + assert.equal(panel?.family, 'editor', 'the real chooser opens over Setup'); + assert.equal(app['setupState'], undefined); + await settled(() => Object.keys(panel!.statuses).length === panel!.providers.length, 'detection'); + panel!.selectedIndex = panel!.providers.findIndex(provider => provider.id === 'hx'); + await app['handleProviderPanelKey']({kind: 'enter'}, panel!); + assert.equal(app['setupState'], state, 'back in the same Setup'); + assert.equal(SETUP_SECTIONS[state.section]!.id, 'sessions'); + assert.equal(state.row, row, 'on the same row'); + assert.equal(state.draft.terminalEditor, 'hx', 'the choice joins the draft'); + assert.equal(state.draft.terminalTitle, pending, 'the other unapplied edit survives'); + } finally { + app['stop'](0); app['session'].kill(); isolation.restore(); + for (const [key, value] of Object.entries(saved)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } + clearProviderDetection(); + rmSync(bin, {recursive: true, force: true}); + } +});