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
18 changes: 18 additions & 0 deletions .changeset/inline-once-overrides.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@nanocollective/nanocoder": minor
---

Added `?key=value` inline overrides to slash commands. An override is applied via the existing session-override plumbing and restored to its prior value when the command finishes, so users can test a setting for a single command without changing the session state. Closes #1151.

**Working examples:**
- `/usage ?context-max=200k` - renders context usage against a 200k limit for this command only, then restores the prior limit.
- `/compact ?threshold=80` - gates the manual compaction on current usage: compacts normally at or above 80%, otherwise reports the usage and skips (mirroring the automatic path's gate). Composes with `?context-max`, which the gate reads as the limit.
- `/compact ?preview`, `/compact ?auto-on` - boolean flags forwarded as their `--flag` long forms.

**Behavior:**
- `threshold` values outside 50–95 and unparseable values are ignored (fail open: the command runs un-gated).
- `auto-compact` is parsed and round-tripped through the session-override stores (apply then restore), but no built-in slash command currently reads it synchronously during dispatch — reserved plumbing for the automatic compaction path.
- Unknown `?foo=bar` keys are never consumed: the dispatcher forwards them to the command handler verbatim (so each command's own unknown-arg handling runs) and queues one warning naming the key, so a typo cannot silently no-op.
- Values containing additional `=` are preserved (`?config=key=value` → `'key=value'`).
- Override applies only to the current command; it does not affect global session state.
- Custom commands and MCP prompts bypass override parsing (they receive `?foo=1` literally).
202 changes: 201 additions & 1 deletion source/app/utils/app-util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,24 @@ import {
parseCustomCommandArgs,
} from './app-util.js';
import {SETTINGS_TAB_IDS} from '@/app/components/settings-constants';
import {parseInput} from '@/command-parser';
import {commandRegistry} from '@/commands';
import {lazyCommands} from '@/commands/lazy-registry';
import BashProgress from '@/components/bash-progress';
import CommandProgress from '@/components/command-progress';
import type {MessageSubmissionOptions} from '@/types/index';
import type {Session} from '@/session/session-manager';
import {sessionManager} from '@/session/session-manager';
import type {Message, MessageSubmissionOptions} from '@/types/index';
import {
autoCompactSessionOverrides,
resetAutoCompactSession,
setAutoCompactThreshold,
} from '@/utils/auto-compact';
import {
applyOnceOverrides,
expandOverrideArgs,
parseInlineOverrides,
} from '@/utils/inline-overrides';

// Test command parsing edge cases
// These tests document the expected behavior of parsing patterns
Expand Down Expand Up @@ -1216,3 +1227,192 @@ test('progress spinner - only slow commands opt in', t => {
const help = lazyCommands.find(c => c.name === 'help');
t.is(help?.progressLabel, undefined);
});

// --- Inline `?key=value` overrides (issue #1151) ---

test('inline overrides - /usage ?context-max=200k is parsed into a session-override', t => {
// ?context-max is a session-override key (handled by applyOnceOverrides),
// not a legacy --flag, so expandOverrideArgs leaves it alone and the
// dispatcher applies it through the existing session-override stores.
// /usage reads getSessionContextLimit() inside its awaited handler, so
// the override is observable for that command's run.
const trimmed = '/usage ?context-max=200k'.slice(1).trim().split(/\s+/);
const {args, overrides} = parseInlineOverrides(trimmed.slice(1));
t.deepEqual(args, []);
t.deepEqual(overrides, [{key: 'context-max', value: '200k'}]);
t.deepEqual(expandOverrideArgs(overrides), []);
});

test('inline overrides - mixed positional args survive the split', t => {
const trimmed = '/compact --mechanical ?preview --llm'
.slice(1)
.trim()
.split(/\s+/);
const {args, overrides} = parseInlineOverrides(trimmed.slice(1));
t.deepEqual(args, ['--mechanical', '--llm']);
t.deepEqual(overrides, [{key: 'preview', value: true}]);
});

test('inline overrides - applyOnceOverrides restore function is idempotent and safe', async t => {
const restore = await applyOnceOverrides([]);
t.notThrows(() => restore());
t.notThrows(() => restore());
});

function queuedText(node: React.ReactNode): string {
if (React.isValidElement(node)) {
const {message} = node.props as {message?: unknown};
return typeof message === 'string' ? message : '';
}
return typeof node === 'string' ? node : '';
}

test.serial('inline overrides - unknown ?keys warn and reach the handler unchanged', async t => {
// Regression: an unrecognised `?key=value` token must neither vanish
// silently nor pollute the override stores. The dispatcher forwards it
// verbatim (so `/context-max` reports its usual "invalid limit" error
// for the `?bogus=1` positional) and queues one warning naming the key.
const texts: string[] = [];
const options = createResumeTestOptions({
onAddToChatQueue: node => {
texts.push(queuedText(node));
},
});

await handleMessageSubmission('/context-max ?bogus=1', options);

t.true(
texts.some(text => text.includes('?bogus')),
`expected an unknown-override warning naming ?bogus, got: ${JSON.stringify(texts)}`,
);
t.true(
texts.some(text => text.includes('Invalid context limit')),
`expected the handler's normal invalid-limit error, got: ${JSON.stringify(texts)}`,
);
});

test.serial('inline overrides - dispatcher applies a ?context-max override and restores the prior value', async t => {
// Regression: a once-scoped override must write the new value into the
// session-override store for the duration of the command, and restore the
// **prior** value (not null) afterwards so a pre-existing session setting
// survives the override. /usage is exercised end-to-end via
// handleMessageSubmission because it is the one built-in that reads
// getSessionContextLimit() synchronously inside its handler.
const {getSessionContextLimit, setSessionContextLimit, resetSessionContextLimit} =
await import('@/models/index.js');
resetSessionContextLimit();
setSessionContextLimit(8192);

let limitDuringCall: number | null | undefined;
const options = createResumeTestOptions({
onAddToChatQueue: () => {
limitDuringCall = getSessionContextLimit();
},
});

await handleMessageSubmission('/usage ?context-max=200k', options);

t.is(
limitDuringCall,
200000,
'applyOnceOverrides should have written 200000 before the usage handler ran',
);
t.is(
getSessionContextLimit(),
8192,
'restoreOnce should have put the prior 8192 back after the command',
);

resetSessionContextLimit();
});

test.serial('inline overrides - /compact ?threshold skips compaction below the threshold', async t => {
// End-to-end for the headline example: the once-threshold gates the
// manual compaction (mirroring the automatic path's gate). A tiny
// transcript against a huge once-limit sits near 0%, so the command
// reports the skip instead of compacting — and restores both stores.
const {
getSessionContextLimit,
resetSessionContextLimit,
} = await import('@/models/index.js');
resetAutoCompactSession();
resetSessionContextLimit();
setAutoCompactThreshold(50);

const texts: string[] = [];
const options = createResumeTestOptions({
onAddToChatQueue: node => {
texts.push(queuedText(node));
},
});
const messages: Message[] = [
{role: 'user', content: 'Hello, please compact this short transcript.'},
];
options.messages = messages;
let rewritten: Message[] | null = null;
options.setMessages = msgs => {
rewritten = msgs;
};

await handleMessageSubmission(
'/compact ?threshold=80 ?context-max=999999999',
options,
);

t.true(
texts.some(text => text.includes('below') && text.includes('80%')),
`expected a below-threshold skip message, got: ${JSON.stringify(texts)}`,
);
t.is(rewritten, null, 'skipped compaction must not rewrite messages');
t.is(
autoCompactSessionOverrides.threshold,
50,
'prior threshold is restored after the gated command',
);
t.is(
getSessionContextLimit(),
null,
'once context limit is restored after the gated command',
);

resetAutoCompactSession();
resetSessionContextLimit();
});

test.serial('inline overrides - /compact ?threshold proceeds at or above the threshold', async t => {
// Mirror image: a 1-token once-limit puts any transcript at hundreds of
// percent, so the gate passes and the normal mechanical compaction runs.
const {resetSessionContextLimit} = await import('@/models/index.js');
resetAutoCompactSession();
resetSessionContextLimit();

const texts: string[] = [];
const options = createResumeTestOptions({
onAddToChatQueue: node => {
texts.push(queuedText(node));
},
});
options.messages = [
{role: 'user', content: 'Hello, please compact this short transcript.'},
];
let rewritten: Message[] | null = null;
options.setMessages = msgs => {
rewritten = msgs;
};

await handleMessageSubmission('/compact ?threshold=80 ?context-max=1', options);

t.true(
texts.some(text => text.includes('Compacted')),
`expected a compaction success message, got: ${JSON.stringify(texts)}`,
);
t.not(rewritten, null, 'compaction above the threshold rewrites messages');
t.is(
autoCompactSessionOverrides.threshold,
null,
'no prior threshold means null after restore',
);

resetAutoCompactSession();
resetSessionContextLimit();
});
117 changes: 90 additions & 27 deletions source/app/utils/app-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,20 @@ import {executeBashCommand, formatBashResultForLLM} from '@/tools/execute-bash';
import type {ImageAttachment, LLMClient} from '@/types/core';
import type {Message, MessageSubmissionOptions} from '@/types/index';
import {formatError} from '@/utils/error-formatter';
import {errorMsg, infoMsg, successMsg} from '@/utils/message-factory';
import {
applyOnceOverrides,
expandOverrideArgs,
formatInlineToken,
getOnceThreshold,
isRecognizedOverrideKey,
parseInlineOverrides,
} from '@/utils/inline-overrides';
import {
errorMsg,
infoMsg,
successMsg,
warningMsg,
} from '@/utils/message-factory';
import {clearReadTracker} from '@/utils/read-tracker';
import {clearExpandableToolResults} from '@/utils/tool-result-display';
import {handleCompactCommand} from './handlers/compact-handler';
Expand Down Expand Up @@ -694,6 +707,8 @@ async function handleSlashCommand(
// live server, not a module import. Intercepting here instead mirrors how
// handleCustomCommand (checked just above) already dispatches the other
// runtime-discovered command source - project `.nanocoder/commands/` files.
// Both runtime-discovered sources bypass `?key=value` parsing below and
// receive the raw message, so free-text `?word` tokens are never stripped.
if (
await handleMCPPromptCommand(
commandName,
Expand All @@ -704,34 +719,82 @@ async function handleSlashCommand(
return;
}

const commandParts = message.slice(1).trim().split(/\s+/);

if (await handleCompactCommand(commandParts, options)) return;
if (await handleContextMaxCommand(commandParts, options)) return;
if (await handleCommandCreate(commandParts, options)) return;
if (await handleAgentCreate(commandParts, options)) return;
if (await handleAgentCopy(commandParts, options)) return;
if (await handleToolCreate(commandParts, options)) return;
if (await handleSkillsCreate(commandParts, options)) return;
if (await handleSpecialCommand(commandName, options)) return;
if (await handleCheckpointLoad(commandParts, options)) return;
// Stateful handlers that replay or resume chat flow live alongside each other.
if (await handleResumeCommand(commandParts, options)) return;
if (
await handleRetryCommand(
[
commandName,
...parseCustomCommandArgs(message.slice(commandName.length + 2)),
],
options,
// ?key=value tokens let a user test a per-session setting for one
// command without committing it to the session. Recognised keys are
// consumed here: once-scoped settings via the session-override stores
// (restored in `finally`), legacy boolean flags by expanding them to
// their `--flag` form. Anything else is forwarded to the command
// handler verbatim (rebuilt into `commandParts`/`cleanedMessage`) so
// each command's own unknown-arg handling runs — and surfaced once as
// a warning so a typo like `?threshhold=80` cannot silently no-op.
const rawParts = message.slice(1).trim().split(/\s+/);
const {args: positional, overrides} = parseInlineOverrides(rawParts.slice(1));
const recognized = overrides.filter(o => isRecognizedOverrideKey(o.key));
const passthrough = overrides
.filter(o => !isRecognizedOverrideKey(o.key))
.map(formatInlineToken);
const expandedFlags = expandOverrideArgs(recognized);
const restTokens = [...positional, ...passthrough, ...expandedFlags].join(
' ',
);
const cleanedMessage = restTokens
? `/${commandName} ${restTokens}`
: `/${commandName}`;
const restoreOnce = await applyOnceOverrides(recognized);
// Explicit once-context for consumers: unlike the session store (which
// cannot tell a once-override apart from a persisted one), this is only
// set when the user typed `?threshold=` on this command.
const onceThreshold = getOnceThreshold(recognized);
try {
const commandParts = [
commandName,
...positional,
...passthrough,
...expandedFlags,
];

if (passthrough.length > 0) {
const keys = [...new Set(passthrough)].join(', ');
options.onAddToChatQueue(
warningMsg(
`Unknown inline override(s): ${keys} — no once-scoped setting or flag matches. Forwarded to the command unchanged.`,
'inline-override-unknown',
),
);
}

if (await handleCompactCommand(commandParts, options, onceThreshold))
return;
if (await handleContextMaxCommand(commandParts, options)) return;
if (await handleCommandCreate(commandParts, options)) return;
if (await handleAgentCreate(commandParts, options)) return;
if (await handleAgentCopy(commandParts, options)) return;
if (await handleToolCreate(commandParts, options)) return;
if (await handleSkillsCreate(commandParts, options)) return;
if (await handleSpecialCommand(commandName, options)) return;
if (await handleCheckpointLoad(commandParts, options)) return;
// Stateful handlers that replay or resume chat flow live alongside each other.
if (await handleResumeCommand(commandParts, options)) return;
if (
await handleRetryCommand(
[
commandName,
...parseCustomCommandArgs(
cleanedMessage.slice(commandName.length + 2),
),
],
options,
)
)
)
return;
if (handleCopilotLogin(commandParts, options)) return;
if (handleCodexLogin(commandParts, options)) return;
if (handleStatsCommand(commandParts, options)) return;
return;
if (handleCopilotLogin(commandParts, options)) return;
if (handleCodexLogin(commandParts, options)) return;
if (handleStatsCommand(commandParts, options)) return;

await handleBuiltInCommand(message, options);
await handleBuiltInCommand(cleanedMessage, options);
} finally {
restoreOnce();
}
}

/**
Expand Down
Loading
Loading