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
14 changes: 10 additions & 4 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9736,15 +9736,19 @@ export class TerminalApp {
*/
private async resolveAsk(text: string, state: AskState): Promise<AskOutcome> {
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)};
}
Expand All @@ -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();
Expand Down
22 changes: 21 additions & 1 deletion src/ask/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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)) {
Expand Down
3 changes: 2 additions & 1 deletion src/ask/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]};

Expand Down
102 changes: 100 additions & 2 deletions tests/askCommandSuggestionsApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ function fixture() {
const calls: CommandRequest[] = [];
let reply: Awaited<ReturnType<typeof app['understanding']['suggestCommand']>> = {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<ReturnType<typeof app['understanding']['interpretAsk']>> = 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'}; }};
}

Expand Down Expand Up @@ -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 · <folder>" 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(); }
});
Loading