Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 12 additions & 6 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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)
Expand All @@ -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') {
Expand Down Expand Up @@ -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']}
Expand Down Expand Up @@ -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. */
Expand Down
6 changes: 3 additions & 3 deletions src/ask/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).'},
Expand Down
51 changes: 43 additions & 8 deletions src/host/HostActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.'}),
};
}
Expand Down Expand Up @@ -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';
Expand All @@ -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. */
Expand Down Expand Up @@ -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;
}
44 changes: 44 additions & 0 deletions src/host/terminalEditors.ts
Original file line number Diff line number Diff line change
@@ -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<TerminalEditorId> {
/** 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<HostEnvironment, 'which'>): TerminalEditorDescriptor[] {
return TERMINAL_EDITORS.filter(editor => editor.executable && host.which(editor.executable));
}
Loading
Loading