diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cf3ca81..0c14293c 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -9736,15 +9736,19 @@ export class TerminalApp { */ 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 (outcome.kind !== 'unclear' && outcome.kind !== 'unsupported') return outcome; + if (this.promptConfiguration.askCommandSuggestions !== 'local' || this.askState !== state) { + return outcome.kind === 'unsupported' && outcome.shellTask + ? {...outcome, text: `${outcome.text}\nCommand suggestions (Settings → Ask) can suggest one command from your local model for you to check.`} : 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'; + // The model declined: Ask's own answer stands, saying so. + if (!result.suggestion) return {...outcome, text: `Your local model (${model}) suggested no command for this. ${outcome.text}`}; return {kind: 'answer', capability: 'help.command', text: 'A suggested command, for you to check before running it yourself.', block: suggestionBlock(result.suggestion, model)}; } @@ -9756,7 +9760,9 @@ export class TerminalApp { const files = systemFileAssistEnvironment(homedir(), context.repoRoot, resolveCommand('python3'), process.execPath, name => resolveCommand(name), commands.install); const deterministic = resolveRequest(text, context, {rejected: state.rejected}, commands, files); const deterministicRoute = () => { this.understanding.lastRoute = {route: 'deterministic', at: Date.now()}; return deterministic; }; - if (!this.understanding.eligible('ask')) return deterministicRoute(); + // A refusal, or something Ask does not do (a shell task), is an answer, not uncertainty: in any mode the intent + // model never re-reads it into the nearest capability it was offered (QA F04). + if (!this.understanding.eligible('ask') || deterministic.kind === 'unsafe' || deterministic.kind === 'unsupported') return deterministicRoute(); // Auto: deterministic first; the model only when it is unsure. Always: the model first, still feeding deterministic builders. const unsure = deterministic.kind === 'unclear' || (deterministic.kind === 'choose' && deterministic.reason === 'ambiguous'); if (!this.understanding.prefersModel && !unsure) return deterministicRoute(); diff --git a/src/ask/resolver.ts b/src/ask/resolver.ts index 8080de29..82d6dee1 100644 --- a/src/ask/resolver.ts +++ b/src/ask/resolver.ts @@ -5,7 +5,7 @@ import type {ShellId} from '../shell/adapters/ShellAdapter.js'; import {CLEAR_LEAD, matchFiles} from './files.js'; import {resolveGit} from './gitAssist.js'; import {browseOutcome, resolveFiles} from './fileAssist.js'; -import {resolveRecipe} from './recipes.js'; +import {matchRecipe, resolveRecipe} from './recipes.js'; import {resolveProject} from './project.js'; import {resolveLocalModel} from './localModel.js'; import {resolveWatch} from './watchAssist.js'; @@ -113,6 +113,23 @@ export const CAPABILITIES: readonly Capability[] = [ const UNSAFE = /\b(?:delete|remove|rm|wipe|erase|purge|destroy|nuke|reset --hard|hard reset|git reset|git clean|clean up untracked|force push|push|commit|chmod|chown|sudo|kill|uninstall|drop|format|overwrite|truncate|rewrite history|rebase|checkout --|discard)\b/u; /** Requests understood as writing or authoring, which Ask has no capability for. */ const AUTHORING = /\b(?:write|create|generate|make|build|compile|refactor|fix|implement|send|email|translate)\b/u; +/** + * Shell tasks Ask has no capability for (QA F04): changing files in bulk (convert, resize, rename…), or finding them by + * size, age or a count. Ask says so itself and never hands them to the optional intent model, which maps them to the + * nearest listed capability ("list files" for "convert every png here to webp"); with command suggestions on, the + * person's local model may suggest one command instead. Archives have recipes, and a destructive request stays a + * refusal (UNSAFE), so neither is matched here. + */ +const FILE_WORDS = String.raw`(?:files?|folders?|director(?:y|ies)|images?|photos?|pictures?|videos?|pngs?|jpe?gs?|gifs?|webps?|heics?|svgs?|pdfs?|csvs?|mp[34]s?|movs?|wavs?|logs?)`; +const SHELL_TASKS = [ + new RegExp(String.raw`^(?:(?:please|can you|could you|help me|i want to|i need to|how (?:do|can|would) i|how to)\s+)*(?:convert|resize|rename|crop|rotate|scale|shrink|optimi[sz]e|minify|transcode|encode|merge|combine|concatenate|split|dedupe|deduplicate|lowercase|uppercase)\b.*\b${FILE_WORDS}\b`, 'u'), + new RegExp(String.raw`^(?:please |can you |could you |help me )?turn (?:the |these |those |all |every |my )*(?:\w+ )?${FILE_WORDS} into\b`, 'u'), + new RegExp(String.raw`\b(?:duplicate|identical) ${FILE_WORDS}\b`, 'u'), + new RegExp(String.raw`\b${FILE_WORDS}\b.*\b(?:bigger|larger|smaller|older|newer) than\b|\b(?:biggest|largest|smallest|oldest|newest)\b.*\b${FILE_WORDS}\b`, 'u'), + new RegExp(String.raw`\b${FILE_WORDS}\b.*\b(?:changed|modified|edited|created) (?:in the last|since|today|yesterday|this week|last week)\b`, 'u'), + /^(?:please |how (?:do|can) i |how to )?count (?:the )?(?:lines|words|characters|files)\b/u, +]; +export const isShellTask = (text: string): boolean => !UNSAFE.test(text) && SHELL_TASKS.some(pattern => pattern.test(text)); /** Vague references that need context to resolve. */ const VAGUE = /\b(?:thing|that one|the old one|the other one|from earlier|earlier|before|previous one|last one|it again)\b/u; @@ -199,6 +216,9 @@ function resolveExact(raw: string, context: AskContext, state: ResolveState = {} // Homebrew from its own facts; install/upgrade/uninstall are typed proposals behind the final Yes. const packages = resolvePackage(text, context); if (packages) return packages; + if (isShellTask(text) && !matchRecipe(text)) { + return {kind: 'unsupported', shellTask: true, text: 'Ask doesn\'t run shell tasks like this one itself; it finds, opens, shows and switches things in NMSh.'}; + } // Config files and verified edits (resolve → inspect → plan → preview → confirm); removal is answered, never planned. // "find files named config" is a file search, not a config request. if (/^(?:please )?(?:find|locate|search for|look for|show(?: me)?|list)\b.*\b(?:files?|folders?)\s+(?:named|called|matching)\b/u.test(text)) { diff --git a/src/ask/types.ts b/src/ask/types.ts index cce643e5..213feb9d 100644 --- a/src/ask/types.ts +++ b/src/ask/types.ts @@ -159,7 +159,8 @@ export type AskOutcome = direct?: boolean} | {kind: 'answer'; capability: CapabilityId; text: string; follow?: AskOption; block?: CommandBlock; next?: AskOption[]; referents?: AskReferents} | {kind: 'choose'; reason: 'ambiguous' | 'missing'; capability?: CapabilityId; question: string; options: AskOption[]; referents?: AskReferents} - | {kind: 'unsupported'; text: string; alternative?: AskOption} + /** shellTask: a task for a shell command (QA F04), which command suggestions may help with. */ + | {kind: 'unsupported'; text: string; alternative?: AskOption; shellTask?: true} | {kind: 'unsafe'; text: string; alternative?: AskOption; referents?: AskReferents} | {kind: 'unclear'; text: string; categories: AskOption[]}; diff --git a/tests/askCommandSuggestionsApp.test.ts b/tests/askCommandSuggestionsApp.test.ts index 8b7dde3c..38113500 100644 --- a/tests/askCommandSuggestionsApp.test.ts +++ b/tests/askCommandSuggestionsApp.test.ts @@ -16,13 +16,17 @@ function fixture() { 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; + let intent: Awaited> = undefined; + const intents: string[] = []; + app['understanding'].interpretAsk = async (request: {text: string}) => { intents.push(request.text); return intent; }; 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; }, + return {...frame, app, calls, intents, state, ask, setReply: (value: typeof reply) => { reply = value; }, + setIntent: (value: typeof intent) => { intent = value; }, + setMode: (mode: 'auto' | 'always') => { app['configuration'] = {...app['configuration'], localUnderstanding: {...app['configuration'].localUnderstanding, mode}}; }, enable: () => { app['promptConfiguration'] = {...app['promptConfiguration'], askCommandSuggestions: 'local'}; }}; } @@ -95,3 +99,97 @@ test('a closed Ask never asks the model', async () => { assert.deepEqual(f.calls, []); } finally { f.dispose(); } }); + +/** + * QA F04 (v0.22 physical QA, A12): with the QA model (Qwen3 0.6B) running, `/btw convert every png here to webp` showed + * "Files · " instead of a suggestion. Reproduced with that model: Ask's own resolver called the request unclear, + * so the intent model read it, picked files.list ({"capability":"files.list","confidence":0.99999}), and NMSh answered + * its canonical "list files". The suggestion step only runs for what Ask cannot place, so it never ran. "rename all + * .jpeg files to .jpg" became "Install which shell?" the same way. A shell task (changing files, or finding them by size + * or age) is something Ask itself does not do: it says so, never hands the request to the intent model, and with + * suggestions on asks the person's model for one command instead. + */ +const SHELL_TASKS = ['convert every png here to webp', 'resize all jpgs in this folder to 800px', 'rename all .jpeg files to .jpg', + 'find files bigger than 100mb', 'show me the biggest folders here', 'count lines of typescript in src', 'which files changed in the last day', + 'turn these heic photos into jpgs', 'find duplicate files here']; + +for (const mode of ['auto', 'always'] as const) { + test(`F04 (${mode}): a shell task reaches command suggestions, whatever capability the intent model picks`, async () => { + const f = fixture(); + try { + f.enable(); + f.setMode(mode); + // What the QA model answered for the QA request. + f.setIntent({capability: 'files.list', confidence: 0.99999, arguments: {}}); + for (const text of SHELL_TASKS) { + const before = f.calls.length; + const outcome = await f.ask(text); + assert.ok(outcome.kind === 'answer' && outcome.block, `${text}: ${outcome.kind} ${'capability' in outcome ? outcome.capability : ''}`); + assert.equal(outcome.block.literal, 'find . -type f -size +100M', text); + assert.equal(outcome.block.run, undefined, 'never runnable from Ask'); + assert.equal(f.calls.length, before + 1, `${text}: the local model was asked for a command once`); + assert.equal(f.calls.at(-1)!.text, text); + } + assert.deepEqual(f.intents, [], 'a shell task is never re-read by the intent model'); + } finally { f.dispose(); } + }); +} + +test('F04: with suggestions Off, a shell task says Ask does not run it and where suggestions are; no model is asked', async () => { + const f = fixture(); + try { + f.setIntent({capability: 'files.list', confidence: 0.99999, arguments: {}}); + for (const text of SHELL_TASKS) { + const outcome = await f.ask(text); + assert.equal(outcome.kind, 'unsupported', text); + assert.ok(outcome.kind === 'unsupported'); + assert.match(outcome.text, /Ask doesn't run shell tasks/u, text); + assert.match(outcome.text, /Command suggestions/u, `${text}: says where suggestions are turned on`); + } + assert.deepEqual(f.calls, []); + assert.deepEqual(f.intents, []); + } finally { f.dispose(); } +}); + +test('F04: what Ask does itself is unchanged: archives, listing, finding by name, refusals', async () => { + const f = fixture(); + try { + f.enable(); + f.setIntent({capability: 'files.list', confidence: 0.99999, arguments: {}}); + const archive = await f.ask('compress the logs folder into a tarball'); + assert.ok(archive.kind === 'answer' && archive.block?.argv[0] === 'tar', 'the archive recipe still answers'); + const list = await f.ask('list files'); + assert.ok(list.kind !== 'unsupported' && 'capability' in list && list.capability === 'file.browse'); + const named = await f.ask('find files named package.json'); + assert.ok('capability' in named && named.capability === 'file.find', `find by name: ${named.kind}`); + // NMSh's own things share these verbs: a branch, a pane, a session or a setting is never a shell task. + for (const text of ['rename this branch to release', 'merge main into this branch', 'split the pane', 'turn off the input dividers', + 'show me the newest session', 'convert my theme to light', 'what files are in this folder']) { + const outcome = await f.ask(text); + assert.ok(!(outcome.kind === 'unsupported' && outcome.shellTask), `${text}: not a shell task`); + } + for (const mode of ['auto', 'always'] as const) { + f.setMode(mode); + assert.equal((await f.ask('delete all my files')).kind, 'unsafe', `${mode}: a refusal stands, whatever the intent model says`); + assert.equal((await f.ask('rename all my files to delete them')).kind, 'unsafe', `${mode}: a destructive shell task is refused, not suggested`); + } + assert.deepEqual(f.calls.map(call => call.text).filter(text => !/^(?:split the pane|convert my theme to light)$/u.test(text)), [], + 'none of these reach command suggestions (except what Ask itself says it cannot do)'); + } finally { f.dispose(); } +}); + +test('F04: a declined suggestion says the model had no command, and Ask\'s own answer follows', async () => { + const f = fixture(); + try { + f.enable(); + f.setReply({suggestion: null}); + const task = await f.ask('convert every png here to webp'); + assert.equal(task.kind, 'unsupported'); + assert.ok(task.kind === 'unsupported'); + assert.match(task.text, /^Your local model \(Qwen3 0\.6B\) suggested no command for this\./u); + const vague = await f.ask(VAGUE); + assert.ok(vague.kind === 'unclear'); + assert.match(vague.text, /^Your local model \(Qwen3 0\.6B\) suggested no command for this\./u); + assert.ok(vague.categories.length, 'what Ask can help with is still offered'); + } finally { f.dispose(); } +});