mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
feat(cli): Phase 2 — slash command multi-mode expansion, ACP fixes, and UX improvements (#3377)
* refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1)
## Summary
Replace the hardcoded ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE whitelist with a
unified, capability-based command metadata model. This is Phase 1 of the slash
command architecture refactor described in docs/design/slash-command/.
## Key changes
### New types (types.ts)
- Add ExecutionMode ('interactive' | 'non_interactive' | 'acp')
- Add CommandSource ('builtin-command' | 'bundled-skill' | 'skill-dir-command' |
'plugin-command' | 'mcp-prompt')
- Add CommandType ('prompt' | 'local' | 'local-jsx')
- Extend SlashCommand interface with: source, sourceLabel, commandType,
supportedModes, userInvocable, modelInvocable, argumentHint, whenToUse,
examples (all optional, backward-compatible)
### New module (commandUtils.ts + commandUtils.test.ts)
- getEffectiveSupportedModes(): 3-priority inference
(explicit supportedModes > commandType > CommandKind fallback)
- filterCommandsForMode(): replaces filterCommandsForNonInteractive()
- 18 unit tests
### Whitelist removal (nonInteractiveCliCommands.ts)
- Remove ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE constant
- Remove filterCommandsForNonInteractive() function
- Replace with CommandService.getCommandsForMode(mode)
### CommandService enhancements (CommandService.ts)
- Add getCommandsForMode(mode: ExecutionMode): filters by mode, excludes hidden
- Add getModelInvocableCommands(): reserved for Phase 3 model tool-call use
### Built-in command annotations (41 files)
Annotate every built-in command with commandType:
- commandType='local' + supportedModes all-modes: btw, bug, compress, context,
init, summary (replaces the 6-command whitelist)
- commandType='local' interactive-only: export, memory, plan, insight
- commandType='local-jsx' interactive-only: all remaining ~31 commands
### Loader metadata injection (4 files)
Each loader stamps source/sourceLabel/commandType/modelInvocable on every
command it emits:
- BuiltinCommandLoader: source='builtin-command', modelInvocable=false
- BundledSkillLoader: source='bundled-skill', commandType='prompt',
modelInvocable=true
- command-factory (FileCommandLoader): source per extension/user origin,
commandType='prompt', modelInvocable=!extensionName
- McpPromptLoader: source='mcp-prompt', commandType='prompt', modelInvocable=true
### Bug fix
MCP_PROMPT commands were incorrectly excluded from non-interactive/ACP modes by
the old whitelist logic. commandType='prompt' now correctly allows them in all
modes.
### Session.ts / nonInteractiveHelpers.ts
- ACP session calls getAvailableCommands with explicit 'acp' mode
- Remove allowedBuiltinCommandNames parameter from buildSystemMessage() —
capability filtering is now self-contained in CommandService
* fix test ci
* feat(cli): Phase 2 slash command expansion + ACP fixes + UX improvements
Phase 2.1 - Command mode expansion:
- Extend 13 built-in commands to support non_interactive/acp modes
- A class: export, plan, statusline - supportedModes only
- A+ class: language, copy, restore - add non-interactive branches
- A' class: model, approvalMode - handle dialog paths in non-interactive
- B class: about, stats, insight, docs, clear - full non-interactive branches
- context: format output as readable Markdown instead of raw JSON
- export: use HTML as default format when no subcommand given
Phase 2.2 - SkillTool integration:
- SkillTool now consumes CommandService.getModelInvocableCommands()
Phase 2.3 - Mid-input slash ghost text:
- Replace mid-input dropdown completion with inline ghost text
- Match Claude Code behavior: gray dimmed completion hint in input box
- Tab accepts the ghost text completion
- Add findMidInputSlashCommand() and getBestSlashCommandMatch() utilities
ACP session bug fixes:
- Fix executionMode undefined in interactive mode (slashCommandProcessor)
- Fix slash command output not visible in Zed (use emitAgentMessage)
- Fix newline rendering in Zed (Markdown hard line-break)
- Fix history replay merging consecutive user messages (recordSlashCommand)
- Fix /clear not clearing model context (dynamic chat reference)
* feat: inline complete only for modelInvocable
* fix memory command
* fix: pass 'non_interactive' mode explicitly to getAvailableCommands
- Fix critical bug in nonInteractiveHelpers.ts: loadSlashCommandNames was
calling getAvailableCommands without specifying mode, causing it to default
to 'acp' instead of 'non_interactive'. Commands with supportedModes that
include 'non_interactive' but not 'acp' would be silently excluded.
- Apply the same fix in systemController.ts for the same reason.
- Update test mock to delegate filtering to production filterCommandsForMode()
instead of duplicating the logic inline, preventing divergence.
Fixes review comments by wenshao and tanzhenxin on PR #3283.
* fix: resolve TypeScript type error in nonInteractiveHelpers.test.ts
* fix test ci
* fix mcp prompt in skill manager
* revert pr#3345
* fix test ci
* feat(cli): adapt /insight for non_interactive mode with message return
- non_interactive: run generateStaticInsight() synchronously with no-op
progress callback, return { type: 'message' } with output path
- acp: keep existing stream_messages path with progress streaming
- interactive: unchanged
Add tests for non_interactive success and error paths.
Update phase2-technical-design.md and roadmap.md to reflect the
three-way mode split and clarify that MCP prompts do not need
modelInvocable (they are called via native MCP tool call mechanism).
* fix(cli): ghost text only shown when cursor is at end of slash token
Use strict equality (!==) instead of > in findMidInputSlashCommand so that
ghost text is only computed and Tab-accepted when the cursor sits exactly at
the trailing edge of the partial command token.
Previously, with the cursor inside an already-typed token (e.g. /re|view),
the ghost text suffix would still be shown and pressing Tab would insert it
at the cursor position, producing a duplicated tail. Using strict equality
makes ghost text disappear as soon as the cursor moves inside the token.
Add unit tests for findMidInputSlashCommand covering cursor-at-end,
cursor-inside-token, cursor-past-token, start-of-line, and
no-space-before-slash cases.
* fix(cli): support /model <model-id> in non-interactive and ACP modes
Previously, /model <model-id> (without --fast) fell through to the
non-interactive branch that only returned the current model info and
incorrectly told users to use --fast. Now:
- /model <model-id> → sets the main model via settings + config.setModel()
- /model → shows current model with correct usage hint
- /model --fast <id> → unchanged (sets fast model)
Fixes the inconsistency flagged in PR review: the help text said to use
'/model <model-id>' but the command returned a dialog action which is
unsupported in non-interactive mode.
* fix(cli): declare supportedModes on doctorCommand to enable non-interactive and ACP
The command's action already had non-interactive handling (returns a JSON
message with check results), but without supportedModes declared the
BUILT_IN fallback restricted it to interactive-only so it was never
registered in non_interactive or acp sessions.
* feat(skills): add SkillCommandLoader for user/project/extension skills as slash commands
- New SkillCommandLoader loads user, project, and extension level SKILL.md
files as slash commands (previously only bundled skills were slash-invocable)
- Extension skills follow plugin-command rules: modelInvocable only when
description or whenToUse is present
- User/project skills are always modelInvocable (matching bundled behavior)
- skill-manager now injects extensionName when loading extension-level skills
- Add when_to_use and disable-model-invocation frontmatter support to SKILL.md
and .md command files (SkillConfig, markdown-command-parser, command-factory,
BundledSkillLoader, FileCommandLoader)
- SkillTool filters out skills with disableModelInvocation and includes
whenToUse in the skill description shown to the model
- 16 unit tests for SkillCommandLoader covering all cases
* docs: update phase2 design doc to reflect final decisions on plan/statusline/copy/restore
These four commands are intentionally kept as interactive-only by design:
- /plan and /statusline: tightly coupled with interactive multi-turn UI
- /copy and /restore: clipboard and snapshot restore are inherently interactive
Update design doc classification table, section 4.2, 4.3, 5.2, 5.3,
file change summary, test requirements, behavior analysis table,
and implementation batch descriptions to reflect this decision.
* feat(cli): re-implement slashCommands.disabled denylist based on current refactored code
Adapts the feature originally introduced in pr#3445 to the current
CommandService / Phase-2 refactored code.
Sources (merged, de-duplicated, case-insensitive):
- settings key slashCommands.disabled (string[], UNION merge)
- --disabled-slash-commands CLI flag (comma-separated or repeated)
- QWEN_DISABLED_SLASH_COMMANDS environment variable
Enforcement points:
- CommandService.create() accepts optional disabledNames: ReadonlySet<string>
and removes matching commands post-rename, so disabled commands never appear
in autocomplete, mid-input ghost text, or model-invocable commands list.
- slashCommandProcessor (interactive TUI) passes the denylist to
CommandService.create so disabled commands are absent from dropdown/ghost text.
- nonInteractiveCliCommands.handleSlashCommand() keeps allCommands unfiltered
to distinguish disabled vs unknown; disabled commands return unsupported with
a "disabled by the current configuration" reason (not no_command).
- getAvailableCommands() (ACP) passes the denylist to CommandService.create.
Config plumbing:
- core/Config: ConfigParameters.disabledSlashCommands + getDisabledSlashCommands()
- cli/config: CliArgs.disabledSlashCommands + yargs option + loadCliConfig merge
- settingsSchema: slashCommands.disabled (MergeStrategy.UNION)
- settings.schema.json: regenerated
Tests: 28 pass (CommandService x4, nonInteractiveCliCommands x3 new cases)
* feat(cli): complete slashCommands.disabled coverage from pr#3445
Fill in the three items that were missing from the initial re-implementation:
- packages/cli/src/config/settings.test.ts: add UNION-merge test for
slashCommands.disabled across user and workspace scopes
- packages/cli/src/nonInteractiveCli.test.ts: add getDisabledSlashCommands
mock to the shared mockConfig fixture
- docs/users/configuration/settings.md: add slashCommands section (table +
example + note) and --disabled-slash-commands row in the CLI args table
* fix(cli): match disabled slash commands by alias as well as primary name
The denylist previously only checked cmd.name (the primary/canonical name),
so disabling a command by its alias (e.g. 'about' for the 'status' command)
had no effect. Fix both CommandService.create() and the isDisabled() helper
in nonInteractiveCliCommands.ts to also check altNames.
Also improve the user-facing error message to show the token the user actually
typed (e.g. /about) instead of always showing the primary name (/status).
This commit is contained in:
parent
58cdf101ba
commit
2710bdec0d
85 changed files with 2934 additions and 635 deletions
|
|
@ -618,11 +618,8 @@ class QwenAgent implements Agent {
|
|||
await geminiClient.initialize();
|
||||
}
|
||||
|
||||
const chat = geminiClient.getChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
config,
|
||||
this.connection,
|
||||
this.settings,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import type {
|
||||
ChatRecord,
|
||||
AgentResultDisplay,
|
||||
SlashCommandRecordPayload,
|
||||
NotificationRecordPayload,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
|
|
@ -90,8 +91,14 @@ export class HistoryReplayer {
|
|||
await this.replayToolResult(record);
|
||||
break;
|
||||
|
||||
case 'system':
|
||||
if (record.subtype === 'slash_command') {
|
||||
await this.replaySlashCommandResult(record);
|
||||
}
|
||||
// Other system subtypes (compression, telemetry, at_command) are skipped.
|
||||
break;
|
||||
|
||||
default:
|
||||
// Skip system records (compression, telemetry, slash commands)
|
||||
break;
|
||||
}
|
||||
this.setActiveRecordId(null);
|
||||
|
|
@ -224,6 +231,29 @@ export class HistoryReplayer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a slash_command system record by re-emitting its output as an
|
||||
* agent message chunk. This allows Zed to reconstruct the correct turn
|
||||
* structure (user → agent) on session resume without polluting model context.
|
||||
*/
|
||||
private async replaySlashCommandResult(record: ChatRecord): Promise<void> {
|
||||
const payload = record.systemPayload as
|
||||
| SlashCommandRecordPayload
|
||||
| undefined;
|
||||
if (payload?.phase !== 'result' || !payload.outputHistoryItems?.length) {
|
||||
return;
|
||||
}
|
||||
for (const item of payload.outputHistoryItems) {
|
||||
const text = typeof item['text'] === 'string' ? item['text'] : '';
|
||||
if (text) {
|
||||
await this.messageEmitter.emitAgentMessage(
|
||||
text.replace(/\n/g, ' \n'),
|
||||
record.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool name from a chat record's function response.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -117,6 +117,9 @@ describe('Session', () => {
|
|||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getAuthType: vi.fn().mockImplementation(() => currentAuthType),
|
||||
isCronEnabled: vi.fn().mockReturnValue(false),
|
||||
getGeminiClient: vi
|
||||
.fn()
|
||||
.mockReturnValue({ getChat: vi.fn().mockReturnValue(mockChat) }),
|
||||
} as unknown as Config;
|
||||
|
||||
mockClient = {
|
||||
|
|
@ -137,7 +140,6 @@ describe('Session', () => {
|
|||
|
||||
session = new Session(
|
||||
'test-session-id',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockClient,
|
||||
mockSettings,
|
||||
|
|
@ -308,7 +310,6 @@ describe('Session', () => {
|
|||
core.Storage.setRuntimeBaseDir(runtimeDir);
|
||||
session = new Session(
|
||||
'test-session-id',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockClient,
|
||||
mockSettings,
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ export class Session implements SessionContext {
|
|||
|
||||
constructor(
|
||||
id: string,
|
||||
private readonly chat: GeminiChat,
|
||||
readonly config: Config,
|
||||
private readonly client: AgentSideConnection,
|
||||
private readonly settings: LoadedSettings,
|
||||
|
|
@ -294,7 +293,9 @@ export class Session implements SessionContext {
|
|||
// Increment turn counter for each user prompt
|
||||
this.turn += 1;
|
||||
|
||||
const chat = this.chat;
|
||||
// Always fetch the current chat from GeminiClient so that /clear's
|
||||
// resetChat() (which replaces the chat instance) is reflected here.
|
||||
const chat = this.config.getGeminiClient()!.getChat();
|
||||
const promptId = this.config.getSessionId() + '########' + this.turn;
|
||||
|
||||
// Extract text from all text blocks to construct the full prompt text for logging
|
||||
|
|
@ -591,12 +592,12 @@ export class Session implements SessionContext {
|
|||
// Get response text from the chat history
|
||||
const history = chat.getHistory();
|
||||
const lastModelMessage = history
|
||||
.filter((msg) => msg.role === 'model')
|
||||
.filter((msg: Content) => msg.role === 'model')
|
||||
.pop();
|
||||
const responseText =
|
||||
lastModelMessage?.parts
|
||||
?.filter((p): p is { text: string } => 'text' in p)
|
||||
.map((p) => p.text)
|
||||
?.filter((p: Part): p is { text: string } & Part => 'text' in p)
|
||||
.map((p: { text: string }) => p.text)
|
||||
.join('') || '[no response text]';
|
||||
|
||||
const response = await messageBus.request<
|
||||
|
|
@ -894,14 +895,17 @@ export class Session implements SessionContext {
|
|||
null;
|
||||
const streamStartTime = Date.now();
|
||||
|
||||
const responseStream = await this.chat.sendMessageStream(
|
||||
this.config.getModel(),
|
||||
{
|
||||
message: nextMessage.parts ?? [],
|
||||
config: { abortSignal: ac.signal },
|
||||
},
|
||||
promptId,
|
||||
);
|
||||
const responseStream = await this.config
|
||||
.getGeminiClient()!
|
||||
.getChat()
|
||||
.sendMessageStream(
|
||||
this.config.getModel(),
|
||||
{
|
||||
message: nextMessage.parts ?? [],
|
||||
config: { abortSignal: ac.signal },
|
||||
},
|
||||
promptId,
|
||||
);
|
||||
nextMessage = null;
|
||||
|
||||
for await (const resp of responseStream) {
|
||||
|
|
@ -1752,44 +1756,59 @@ export class Session implements SessionContext {
|
|||
return normalizePartList(result.content);
|
||||
|
||||
case 'message': {
|
||||
await this.client.extNotification('_qwencode/slash_command', {
|
||||
sessionId: this.sessionId,
|
||||
command: originalPrompt
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join(' '),
|
||||
messageType: result.messageType,
|
||||
message: result.content || '',
|
||||
});
|
||||
|
||||
if (result.messageType === 'error') {
|
||||
// Throw error to stop execution
|
||||
throw new Error(result.content || 'Slash command failed.');
|
||||
}
|
||||
// For info messages, return null to indicate command was handled
|
||||
// Emit the message as an agent message chunk so Zed renders it in the
|
||||
// chat UI. extNotification only goes to the ACP debug log and is not
|
||||
// rendered by Zed.
|
||||
// Replace bare \n with Markdown hard line-breaks (two trailing spaces)
|
||||
// so Zed's Markdown renderer preserves the line structure.
|
||||
const rendered = (result.content || '').replace(/\n/g, ' \n');
|
||||
await this.messageEmitter.emitAgentMessage(rendered);
|
||||
// Write a system/slash_command record so history replay on restart can
|
||||
// re-emit this message. system records are skipped by
|
||||
// buildApiHistoryFromConversation, so this won't pollute model context.
|
||||
this.config.getChatRecordingService()?.recordSlashCommand({
|
||||
phase: 'result',
|
||||
rawCommand: originalPrompt
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((b) => (b.type === 'text' ? b.text : ''))
|
||||
.join(' '),
|
||||
outputHistoryItems: [
|
||||
{ type: 'assistant', text: result.content || '' },
|
||||
],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'stream_messages': {
|
||||
// Command returns multiple messages via async generator (ACP-preferred)
|
||||
const command = originalPrompt
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join(' ');
|
||||
|
||||
// Stream all messages to the client
|
||||
// Stream all messages to the client as agent message chunks.
|
||||
const chunks: string[] = [];
|
||||
for await (const msg of result.messages) {
|
||||
await this.client.extNotification('_qwencode/slash_command', {
|
||||
sessionId: this.sessionId,
|
||||
command,
|
||||
messageType: msg.messageType,
|
||||
message: msg.content,
|
||||
});
|
||||
|
||||
// If we encounter an error message, throw after sending
|
||||
if (msg.messageType === 'error') {
|
||||
throw new Error(msg.content || 'Slash command failed.');
|
||||
}
|
||||
await this.messageEmitter.emitAgentMessage(
|
||||
(msg.content || '').replace(/\n/g, ' \n'),
|
||||
);
|
||||
chunks.push(msg.content || '');
|
||||
}
|
||||
// Write a system/slash_command record for history replay (same reason as
|
||||
// 'message' case — system records are invisible to model history).
|
||||
if (chunks.length > 0) {
|
||||
this.config.getChatRecordingService()?.recordSlashCommand({
|
||||
phase: 'result',
|
||||
rawCommand: originalPrompt
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((b) => (b.type === 'text' ? b.text : ''))
|
||||
.join(' '),
|
||||
outputHistoryItems: [
|
||||
{ type: 'assistant', text: chunks.join('\n') },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// All messages sent successfully, return null to indicate command was handled
|
||||
|
|
|
|||
|
|
@ -524,13 +524,6 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
coerce: (tools: string[]) =>
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('allowed-tools', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Tools to allow, will bypass confirmation',
|
||||
coerce: (tools: string[]) =>
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('disabled-slash-commands', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
|
|
@ -542,6 +535,13 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
coerce: (names: string[]) =>
|
||||
names.flatMap((n) => n.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('allowed-tools', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Tools to allow, will bypass confirmation',
|
||||
coerce: (tools: string[]) =>
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('auth-type', {
|
||||
type: 'string',
|
||||
choices: [
|
||||
|
|
|
|||
|
|
@ -1126,6 +1126,36 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
|
||||
slashCommands: {
|
||||
type: 'object',
|
||||
label: 'Slash Commands',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Configuration for slash commands exposed by the CLI. Useful for ' +
|
||||
'locking down the command surface in multi-tenant or enterprise ' +
|
||||
'deployments.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
disabled: {
|
||||
type: 'array',
|
||||
label: 'Disabled Slash Commands',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Slash command names to hide and refuse to execute. Matched ' +
|
||||
'case-insensitively against the final command name (for extension ' +
|
||||
'commands this is the disambiguated form, e.g. "myext.deploy"). ' +
|
||||
'Merged as a union across settings scopes, so workspace settings ' +
|
||||
'can add to but not remove entries defined in system/user settings.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
permissions: {
|
||||
type: 'object',
|
||||
label: 'Permissions',
|
||||
|
|
@ -1175,36 +1205,6 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
|
||||
slashCommands: {
|
||||
type: 'object',
|
||||
label: 'Slash Commands',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Configuration for slash commands exposed by the CLI. Useful for ' +
|
||||
'locking down the command surface in multi-tenant or enterprise ' +
|
||||
'deployments.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
disabled: {
|
||||
type: 'array',
|
||||
label: 'Disabled Slash Commands',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Slash command names to hide and refuse to execute. Matched ' +
|
||||
'case-insensitively against the final command name (for extension ' +
|
||||
'commands this is the disambiguated form, e.g. "myext.deploy"). ' +
|
||||
'Merged as a union across settings scopes, so workspace settings ' +
|
||||
'can add to but not remove entries defined in system/user settings.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
tools: {
|
||||
type: 'object',
|
||||
label: 'Tools',
|
||||
|
|
|
|||
|
|
@ -152,12 +152,14 @@ describe('runNonInteractive', () => {
|
|||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
isCronEnabled: vi.fn().mockReturnValue(false),
|
||||
getCronScheduler: vi.fn().mockReturnValue(null),
|
||||
setModelInvocableCommandsProvider: vi.fn(),
|
||||
setModelInvocableCommandsExecutor: vi.fn(),
|
||||
getDisabledSlashCommands: vi.fn().mockReturnValue([]),
|
||||
getBackgroundTaskRegistry: vi.fn().mockReturnValue({
|
||||
setNotificationCallback: vi.fn(),
|
||||
setRegisterCallback: vi.fn(),
|
||||
getRunning: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getDisabledSlashCommands: vi.fn().mockReturnValue([]),
|
||||
} as unknown as Config;
|
||||
|
||||
mockSettings = {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ describe('handleSlashCommand', () => {
|
|||
getFolderTrustFeature: vi.fn().mockReturnValue(false),
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
||||
setModelInvocableCommandsProvider: vi.fn(),
|
||||
setModelInvocableCommandsExecutor: vi.fn(),
|
||||
getDisabledSlashCommands: vi.fn().mockReturnValue([]),
|
||||
storage: {},
|
||||
} as unknown as Config;
|
||||
|
|
@ -86,7 +88,7 @@ describe('handleSlashCommand', () => {
|
|||
name: 'help',
|
||||
description: 'Show help',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
// No commandType → falls back to BUILT_IN → interactive only
|
||||
// No supportedModes → BUILT_IN fallback → interactive only
|
||||
action: vi.fn(),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
||||
|
|
@ -135,7 +137,6 @@ describe('handleSlashCommand', () => {
|
|||
name: 'init',
|
||||
description: 'Initialize project',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local' as const,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'message',
|
||||
|
|
@ -163,7 +164,6 @@ describe('handleSlashCommand', () => {
|
|||
name: 'btw',
|
||||
description: 'Ask a side question',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local' as const,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'message',
|
||||
|
|
@ -276,4 +276,67 @@ describe('handleSlashCommand', () => {
|
|||
expect(result.messageType).toBe('info');
|
||||
}
|
||||
});
|
||||
|
||||
describe('disabled slash commands', () => {
|
||||
const mockDisabledCommand = {
|
||||
name: 'help',
|
||||
description: 'Show help',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Help content',
|
||||
}),
|
||||
};
|
||||
|
||||
it('should return unsupported with disabled reason for a disabled command', async () => {
|
||||
mockGetCommands.mockReturnValue([mockDisabledCommand]);
|
||||
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['help']);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/help',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('unsupported');
|
||||
if (result.type === 'unsupported') {
|
||||
expect(result.reason).toContain('disabled');
|
||||
expect(result.originalType).toBe('filtered_command');
|
||||
}
|
||||
});
|
||||
|
||||
it('should match disabled command names case-insensitively', async () => {
|
||||
mockGetCommands.mockReturnValue([mockDisabledCommand]);
|
||||
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['HELP']);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/help',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('unsupported');
|
||||
if (result.type === 'unsupported') {
|
||||
expect(result.reason).toContain('disabled');
|
||||
}
|
||||
});
|
||||
|
||||
it('should still return no_command for genuinely unknown commands even with a denylist', async () => {
|
||||
mockGetCommands.mockReturnValue([mockDisabledCommand]);
|
||||
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['help']);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/unknowncommand',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('no_command');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { CommandService } from './services/CommandService.js';
|
|||
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
|
||||
import { BundledSkillLoader } from './services/BundledSkillLoader.js';
|
||||
import { FileCommandLoader } from './services/FileCommandLoader.js';
|
||||
import { SkillCommandLoader } from './services/SkillCommandLoader.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
|
|
@ -200,15 +201,72 @@ export const handleSlashCommand = async (
|
|||
const allLoaders = [
|
||||
new BuiltinCommandLoader(config),
|
||||
new BundledSkillLoader(config),
|
||||
new SkillCommandLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
];
|
||||
|
||||
// Build the disabled-command set (case-insensitive).
|
||||
const disabledSlashCommandsRaw = config.getDisabledSlashCommands();
|
||||
const disabledNameSet = new Set<string>();
|
||||
for (const name of disabledSlashCommandsRaw) {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed) disabledNameSet.add(trimmed.toLowerCase());
|
||||
}
|
||||
const isDisabled = (cmd: { name: string; altNames?: readonly string[] }) =>
|
||||
disabledNameSet.has(cmd.name.toLowerCase()) ||
|
||||
(cmd.altNames ?? []).some((a) => disabledNameSet.has(a.toLowerCase()));
|
||||
|
||||
// Load the full command set (unfiltered by the denylist) so that the
|
||||
// fallback existence check below can distinguish a disabled command from a
|
||||
// truly unknown one. Without this, a disabled command would fall through to
|
||||
// `no_command` and be forwarded to the model as plain prompt text.
|
||||
const commandService = await CommandService.create(
|
||||
allLoaders,
|
||||
abortController.signal,
|
||||
);
|
||||
// Register model-invocable commands provider so SkillTool description stays
|
||||
// up-to-date in non-interactive / ACP mode.
|
||||
config.setModelInvocableCommandsProvider(() =>
|
||||
commandService.getModelInvocableCommands().map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description:
|
||||
typeof cmd.description === 'string' ? cmd.description : cmd.description,
|
||||
})),
|
||||
);
|
||||
// Register executor so SkillTool can invoke model-invocable commands
|
||||
// (e.g. MCP prompts) that are not file-based skills.
|
||||
config.setModelInvocableCommandsExecutor(
|
||||
async (name: string, args: string = '') => {
|
||||
const commands = commandService.getModelInvocableCommands();
|
||||
const cmd = commands.find((c) => c.name === name);
|
||||
if (!cmd?.action) return null;
|
||||
const minimalContext = {
|
||||
executionMode,
|
||||
invocation: {
|
||||
raw: args ? `/${name} ${args}` : `/${name}`,
|
||||
name,
|
||||
args,
|
||||
},
|
||||
services: { config, settings, git: undefined, logger: null },
|
||||
} as unknown as CommandContext;
|
||||
const result = await cmd.action(minimalContext, args);
|
||||
if (!result || result.type !== 'submit_prompt') return null;
|
||||
const content = result.content;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((p) =>
|
||||
typeof p === 'string' ? p : ((p as { text?: string }).text ?? ''),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
const allCommands = commandService.getCommands();
|
||||
const filteredCommands = commandService.getCommandsForMode(executionMode);
|
||||
const filteredCommands = commandService
|
||||
.getCommandsForMode(executionMode)
|
||||
.filter((cmd) => !isDisabled(cmd));
|
||||
|
||||
// First, try to parse with filtered commands
|
||||
const { commandToExecute, args } = parseSlashCommand(
|
||||
|
|
@ -224,11 +282,26 @@ export const handleSlashCommand = async (
|
|||
);
|
||||
|
||||
if (knownCommand) {
|
||||
// Derive the token the user actually typed (e.g. "about" when the
|
||||
// primary name is "status") to surface a helpful error message.
|
||||
const typedToken =
|
||||
rawQuery.trim().substring(1).trim().split(/\s+/)[0] ??
|
||||
knownCommand.name;
|
||||
if (isDisabled(knownCommand)) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason: t(
|
||||
'The command "/{{command}}" is disabled by the current configuration.',
|
||||
{ command: typedToken },
|
||||
),
|
||||
originalType: 'filtered_command',
|
||||
};
|
||||
}
|
||||
// Command exists but is not allowed in this mode
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason: t('The command "/{{command}}" is not supported in this mode.', {
|
||||
command: knownCommand.name,
|
||||
command: typedToken,
|
||||
}),
|
||||
originalType: 'filtered_command',
|
||||
};
|
||||
|
|
@ -304,10 +377,18 @@ export const getAvailableCommands = async (
|
|||
const loaders = [
|
||||
new BuiltinCommandLoader(config),
|
||||
new BundledSkillLoader(config),
|
||||
new SkillCommandLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
];
|
||||
|
||||
const commandService = await CommandService.create(loaders, abortSignal);
|
||||
const disabledSlashCommands = config.getDisabledSlashCommands();
|
||||
const commandService = await CommandService.create(
|
||||
loaders,
|
||||
abortSignal,
|
||||
disabledSlashCommands.length > 0
|
||||
? new Set(disabledSlashCommands)
|
||||
: undefined,
|
||||
);
|
||||
return commandService.getCommandsForMode(mode) as SlashCommand[];
|
||||
} catch (error) {
|
||||
// Handle errors gracefully - log and return empty array
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ export class BundledSkillLoader implements ICommandLoader {
|
|||
kind: CommandKind.SKILL,
|
||||
source: 'bundled-skill' as const,
|
||||
sourceLabel: 'Skill',
|
||||
commandType: 'prompt' as const,
|
||||
modelInvocable: true,
|
||||
modelInvocable: !skill.disableModelInvocation,
|
||||
whenToUse: skill.whenToUse,
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn> => {
|
||||
// Resolve template variables in skill body
|
||||
let body = skill.body;
|
||||
|
|
|
|||
|
|
@ -310,80 +310,6 @@ describe('CommandService', () => {
|
|||
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
|
||||
});
|
||||
|
||||
describe('disabledNames filtering', () => {
|
||||
it('should omit commands whose names are in the disabled set', async () => {
|
||||
const loader = new MockCommandLoader([
|
||||
mockCommandA,
|
||||
mockCommandB,
|
||||
mockCommandC,
|
||||
]);
|
||||
const service = await CommandService.create(
|
||||
[loader],
|
||||
new AbortController().signal,
|
||||
new Set(['command-b']),
|
||||
);
|
||||
const names = service.getCommands().map((cmd) => cmd.name);
|
||||
expect(names).toEqual(expect.arrayContaining(['command-a', 'command-c']));
|
||||
expect(names).not.toContain('command-b');
|
||||
});
|
||||
|
||||
it('should match disabled names case-insensitively', async () => {
|
||||
const loader = new MockCommandLoader([mockCommandA, mockCommandB]);
|
||||
const service = await CommandService.create(
|
||||
[loader],
|
||||
new AbortController().signal,
|
||||
new Set(['COMMAND-A']),
|
||||
);
|
||||
const names = service.getCommands().map((cmd) => cmd.name);
|
||||
expect(names).toEqual(['command-b']);
|
||||
});
|
||||
|
||||
it('should ignore empty entries and whitespace in the disabled set', async () => {
|
||||
const loader = new MockCommandLoader([mockCommandA, mockCommandB]);
|
||||
const service = await CommandService.create(
|
||||
[loader],
|
||||
new AbortController().signal,
|
||||
new Set(['', ' ', ' command-a ']),
|
||||
);
|
||||
const names = service.getCommands().map((cmd) => cmd.name);
|
||||
expect(names).toEqual(['command-b']);
|
||||
});
|
||||
|
||||
it('should be a no-op when disabledNames is undefined or empty', async () => {
|
||||
const loader = new MockCommandLoader([mockCommandA, mockCommandB]);
|
||||
const undefinedResult = await CommandService.create(
|
||||
[loader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(undefinedResult.getCommands()).toHaveLength(2);
|
||||
|
||||
const emptyResult = await CommandService.create(
|
||||
[new MockCommandLoader([mockCommandA, mockCommandB])],
|
||||
new AbortController().signal,
|
||||
new Set<string>(),
|
||||
);
|
||||
expect(emptyResult.getCommands()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should disable extension commands by their renamed (final) name', async () => {
|
||||
const builtin = createMockCommand('deploy', CommandKind.BUILT_IN);
|
||||
const extension = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'firebase',
|
||||
description: '[firebase] Deploy to Firebase',
|
||||
};
|
||||
const loader = new MockCommandLoader([builtin, extension]);
|
||||
const service = await CommandService.create(
|
||||
[loader],
|
||||
new AbortController().signal,
|
||||
new Set(['firebase.deploy']),
|
||||
);
|
||||
const names = service.getCommands().map((cmd) => cmd.name);
|
||||
// Built-in /deploy remains; the renamed extension command is gone.
|
||||
expect(names).toEqual(['deploy']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple secondary conflicts with incrementing suffixes', async () => {
|
||||
// User has /deploy, /gcp.deploy, and /gcp.deploy1
|
||||
const userCommand1 = createMockCommand('deploy', CommandKind.FILE);
|
||||
|
|
@ -419,4 +345,59 @@ describe('CommandService', () => {
|
|||
expect(deployExtension).toBeDefined();
|
||||
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
|
||||
});
|
||||
|
||||
describe('disabled commands (disabledNames parameter)', () => {
|
||||
it('should exclude commands whose names are in the disabledNames set', async () => {
|
||||
const mockLoader = new MockCommandLoader([
|
||||
mockCommandA,
|
||||
mockCommandB,
|
||||
mockCommandC,
|
||||
]);
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
new Set(['command-a']),
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands.find((c) => c.name === 'command-a')).toBeUndefined();
|
||||
expect(commands.find((c) => c.name === 'command-b')).toBeDefined();
|
||||
expect(commands.find((c) => c.name === 'command-c')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should match disabled names case-insensitively', async () => {
|
||||
const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]);
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
new Set(['COMMAND-A', 'Command-B']),
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not filter any commands when disabledNames is empty', async () => {
|
||||
const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]);
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
expect(service.getCommands()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not filter any commands when disabledNames is undefined', async () => {
|
||||
const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]);
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(service.getCommands()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,9 +34,8 @@ export class CommandService {
|
|||
*
|
||||
* This factory method orchestrates the entire command loading process. It
|
||||
* runs all provided loaders in parallel, aggregates their results, handles
|
||||
* name conflicts for extension commands by renaming them, optionally filters
|
||||
* out disabled commands, and then returns a fully constructed
|
||||
* `CommandService` instance.
|
||||
* name conflicts for extension commands by renaming them, and then returns a
|
||||
* fully constructed `CommandService` instance.
|
||||
*
|
||||
* Conflict resolution:
|
||||
* - Extension commands that conflict with existing commands are renamed to
|
||||
|
|
@ -102,8 +101,12 @@ export class CommandService {
|
|||
if (trimmed) normalizedDisabled.add(trimmed.toLowerCase());
|
||||
}
|
||||
if (normalizedDisabled.size > 0) {
|
||||
for (const name of Array.from(commandMap.keys())) {
|
||||
if (normalizedDisabled.has(name.toLowerCase())) {
|
||||
for (const [name, cmd] of Array.from(commandMap.entries())) {
|
||||
const matchesPrimary = normalizedDisabled.has(name.toLowerCase());
|
||||
const matchesAlias = (cmd.altNames ?? []).some((a) =>
|
||||
normalizedDisabled.has(a.toLowerCase()),
|
||||
);
|
||||
if (matchesPrimary || matchesAlias) {
|
||||
commandMap.delete(name);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -354,6 +354,9 @@ export class FileCommandLoader implements ICommandLoader {
|
|||
typeof validDef.frontmatter.description === 'string'
|
||||
? validDef.frontmatter.description
|
||||
: undefined,
|
||||
whenToUse: validDef.frontmatter?.when_to_use,
|
||||
disableModelInvocation:
|
||||
validDef.frontmatter?.['disable-model-invocation'],
|
||||
};
|
||||
|
||||
// Use factory to create command
|
||||
|
|
|
|||
|
|
@ -48,8 +48,6 @@ export class McpPromptLoader implements ICommandLoader {
|
|||
kind: CommandKind.MCP_PROMPT,
|
||||
source: 'mcp-prompt' as const,
|
||||
sourceLabel: `MCP: ${serverName}`,
|
||||
commandType: 'prompt' as const,
|
||||
modelInvocable: true,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'help',
|
||||
|
|
|
|||
290
packages/cli/src/services/SkillCommandLoader.test.ts
Normal file
290
packages/cli/src/services/SkillCommandLoader.test.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SkillCommandLoader } from './SkillCommandLoader.js';
|
||||
import { CommandKind } from '../ui/commands/types.js';
|
||||
import type { Config, SkillConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
function makeSkill(overrides: Partial<SkillConfig> = {}): SkillConfig {
|
||||
return {
|
||||
name: 'my-skill',
|
||||
description: 'My skill description',
|
||||
level: 'user',
|
||||
filePath: '/home/user/.qwen/skills/my-skill/SKILL.md',
|
||||
body: 'Skill body content.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SkillCommandLoader', () => {
|
||||
let mockConfig: Config;
|
||||
let mockSkillManager: { listSkills: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSkillManager = {
|
||||
listSkills: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
mockConfig = {
|
||||
getSkillManager: vi.fn().mockReturnValue(mockSkillManager),
|
||||
getBareMode: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
it('should return empty array when config is null', async () => {
|
||||
const loader = new SkillCommandLoader(null);
|
||||
expect(await loader.loadCommands(signal)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when SkillManager is not available', async () => {
|
||||
const config = {
|
||||
getSkillManager: vi.fn().mockReturnValue(null),
|
||||
getBareMode: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
const loader = new SkillCommandLoader(config);
|
||||
expect(await loader.loadCommands(signal)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array in bare mode', async () => {
|
||||
(mockConfig.getBareMode as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
expect(await loader.loadCommands(signal)).toEqual([]);
|
||||
expect(mockSkillManager.listSkills).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should query user, project, and extension levels', async () => {
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
await loader.loadCommands(signal);
|
||||
expect(mockSkillManager.listSkills).toHaveBeenCalledWith({ level: 'user' });
|
||||
expect(mockSkillManager.listSkills).toHaveBeenCalledWith({
|
||||
level: 'project',
|
||||
});
|
||||
expect(mockSkillManager.listSkills).toHaveBeenCalledWith({
|
||||
level: 'extension',
|
||||
});
|
||||
});
|
||||
|
||||
it('should load user skill as slash command with correct properties', async () => {
|
||||
const skill = makeSkill({ level: 'user' });
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'user' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const cmd = commands[0];
|
||||
expect(cmd.name).toBe('my-skill');
|
||||
expect(cmd.description).toBe('My skill description');
|
||||
expect(cmd.kind).toBe(CommandKind.SKILL);
|
||||
expect(cmd.source).toBe('skill-dir-command');
|
||||
expect(cmd.sourceLabel).toBe('User');
|
||||
expect(cmd.modelInvocable).toBe(true);
|
||||
});
|
||||
|
||||
it('should load project skill with sourceLabel "Project"', async () => {
|
||||
const skill = makeSkill({ level: 'project' });
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'project' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands[0].sourceLabel).toBe('Project');
|
||||
expect(commands[0].source).toBe('skill-dir-command');
|
||||
expect(commands[0].modelInvocable).toBe(true);
|
||||
});
|
||||
|
||||
it('should submit skill body as prompt', async () => {
|
||||
const skill = makeSkill();
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'user' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const result = await commands[0].action!(
|
||||
{ invocation: { raw: '/my-skill', args: '' } } as never,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'Skill body content.' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should append raw invocation when args are provided', async () => {
|
||||
const skill = makeSkill();
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'user' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const result = await commands[0].action!(
|
||||
{ invocation: { raw: '/my-skill foo', args: 'foo' } } as never,
|
||||
'foo',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'Skill body content.\n\n/my-skill foo' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when listSkills throws', async () => {
|
||||
mockSkillManager.listSkills.mockRejectedValue(new Error('load failed'));
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
expect(await loader.loadCommands(signal)).toEqual([]);
|
||||
});
|
||||
|
||||
describe('extension skills', () => {
|
||||
it('should be modelInvocable when description is present', async () => {
|
||||
const skill = makeSkill({
|
||||
level: 'extension',
|
||||
extensionName: 'superpowers-lab',
|
||||
description: 'Use tmux for interactive commands',
|
||||
});
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'extension' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands[0].modelInvocable).toBe(true);
|
||||
expect(commands[0].source).toBe('plugin-command');
|
||||
expect(commands[0].sourceLabel).toBe('Extension: superpowers-lab');
|
||||
});
|
||||
|
||||
it('should be modelInvocable when whenToUse is present', async () => {
|
||||
const skill = makeSkill({
|
||||
level: 'extension',
|
||||
extensionName: 'superpowers-lab',
|
||||
description: '',
|
||||
whenToUse: 'Use when you need tmux',
|
||||
});
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'extension' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands[0].modelInvocable).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT be modelInvocable when description and whenToUse are absent', async () => {
|
||||
const skill = makeSkill({
|
||||
level: 'extension',
|
||||
extensionName: 'superpowers-lab',
|
||||
description: '',
|
||||
whenToUse: undefined,
|
||||
});
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'extension' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands[0].modelInvocable).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT be modelInvocable when disableModelInvocation is true, even with description', async () => {
|
||||
const skill = makeSkill({
|
||||
level: 'extension',
|
||||
extensionName: 'superpowers-lab',
|
||||
description: 'Some description',
|
||||
disableModelInvocation: true,
|
||||
});
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'extension' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands[0].modelInvocable).toBe(false);
|
||||
});
|
||||
|
||||
it('should use "Extension: unknown" as sourceLabel when extensionName is absent', async () => {
|
||||
const skill = makeSkill({ level: 'extension', description: 'foo' });
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'extension' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands[0].sourceLabel).toBe('Extension: unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('user/project skill disableModelInvocation', () => {
|
||||
it('user skill with disableModelInvocation:true should NOT be modelInvocable', async () => {
|
||||
const skill = makeSkill({ level: 'user', disableModelInvocation: true });
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'user' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands[0].modelInvocable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should aggregate skills from all levels', async () => {
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) => {
|
||||
if (level === 'user')
|
||||
return Promise.resolve([
|
||||
makeSkill({ name: 'user-skill', level: 'user' }),
|
||||
]);
|
||||
if (level === 'project')
|
||||
return Promise.resolve([
|
||||
makeSkill({ name: 'proj-skill', level: 'project' }),
|
||||
]);
|
||||
if (level === 'extension')
|
||||
return Promise.resolve([
|
||||
makeSkill({
|
||||
name: 'ext-skill',
|
||||
level: 'extension',
|
||||
description: 'foo',
|
||||
}),
|
||||
]);
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(3);
|
||||
expect(commands.map((c) => c.name)).toEqual([
|
||||
'user-skill',
|
||||
'proj-skill',
|
||||
'ext-skill',
|
||||
]);
|
||||
});
|
||||
});
|
||||
107
packages/cli/src/services/SkillCommandLoader.ts
Normal file
107
packages/cli/src/services/SkillCommandLoader.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
createDebugLogger,
|
||||
appendToLastTextPart,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { ICommandLoader } from './types.js';
|
||||
import type {
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
CommandSource,
|
||||
} from '../ui/commands/types.js';
|
||||
import { CommandKind } from '../ui/commands/types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SKILL_COMMAND_LOADER');
|
||||
|
||||
/**
|
||||
* Loads user-level, project-level, and extension-level skills as slash
|
||||
* commands, making them directly invocable via /<skill-name>.
|
||||
*
|
||||
* - User/project skills: always model-invocable (same as bundled), unless
|
||||
* disable-model-invocation is set.
|
||||
* - Extension skills: model-invocable only when description or whenToUse is
|
||||
* present (same rule as plugin commands), unless disable-model-invocation
|
||||
* is set.
|
||||
*/
|
||||
export class SkillCommandLoader implements ICommandLoader {
|
||||
constructor(private readonly config: Config | null) {}
|
||||
|
||||
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
|
||||
if (this.config?.getBareMode?.()) {
|
||||
debugLogger.debug('Bare mode enabled, skipping skill commands');
|
||||
return [];
|
||||
}
|
||||
|
||||
const skillManager = this.config?.getSkillManager();
|
||||
if (!skillManager) {
|
||||
debugLogger.debug('SkillManager not available, skipping skill commands');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const [userSkills, projectSkills, extensionSkills] = await Promise.all([
|
||||
skillManager.listSkills({ level: 'user' }),
|
||||
skillManager.listSkills({ level: 'project' }),
|
||||
skillManager.listSkills({ level: 'extension' }),
|
||||
]);
|
||||
|
||||
const allSkills = [...userSkills, ...projectSkills, ...extensionSkills];
|
||||
|
||||
debugLogger.debug(
|
||||
`Loaded ${userSkills.length} user + ${projectSkills.length} project + ${extensionSkills.length} extension skill(s) as slash commands`,
|
||||
);
|
||||
|
||||
return allSkills.map((skill) => {
|
||||
const isExtension = skill.level === 'extension';
|
||||
|
||||
// Extension skills need explicit description or whenToUse to be
|
||||
// model-invocable (same rule as plugin commands).
|
||||
// User/project skills are always model-invocable.
|
||||
const modelInvocable = skill.disableModelInvocation
|
||||
? false
|
||||
: isExtension
|
||||
? !!(skill.description || skill.whenToUse)
|
||||
: true;
|
||||
|
||||
const sourceLabel = isExtension
|
||||
? `Extension: ${skill.extensionName ?? 'unknown'}`
|
||||
: skill.level === 'project'
|
||||
? 'Project'
|
||||
: 'User';
|
||||
|
||||
return {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
kind: CommandKind.SKILL,
|
||||
source: (isExtension
|
||||
? 'plugin-command'
|
||||
: 'skill-dir-command') as CommandSource,
|
||||
sourceLabel,
|
||||
modelInvocable,
|
||||
whenToUse: skill.whenToUse,
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn> => {
|
||||
const body = skill.body;
|
||||
|
||||
const content = context.invocation?.args
|
||||
? appendToLastTextPart([{ text: body }], context.invocation.raw)
|
||||
: [{ text: body }];
|
||||
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to load skill commands:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,8 @@ import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
|
|||
export interface CommandDefinition {
|
||||
prompt: string;
|
||||
description?: string;
|
||||
whenToUse?: string;
|
||||
disableModelInvocation?: boolean;
|
||||
}
|
||||
|
||||
const debugLogger = createDebugLogger('COMMAND_FACTORY');
|
||||
|
|
@ -116,8 +118,10 @@ export function createSlashCommandFromDefinition(
|
|||
? 'plugin-command'
|
||||
: 'skill-dir-command') as CommandSource,
|
||||
sourceLabel: extensionName ? `Plugin: ${extensionName}` : 'Custom',
|
||||
commandType: 'prompt' as const,
|
||||
modelInvocable: !extensionName,
|
||||
modelInvocable: definition.disableModelInvocation
|
||||
? false
|
||||
: !extensionName || !!(definition.description || definition.whenToUse),
|
||||
whenToUse: definition.whenToUse,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
_args: string,
|
||||
|
|
|
|||
|
|
@ -22,18 +22,14 @@ function makeCmd(overrides: Partial<SlashCommand>): SlashCommand {
|
|||
}
|
||||
|
||||
describe('getEffectiveSupportedModes', () => {
|
||||
// ── Priority 1: explicit supportedModes ───────────────────────────────
|
||||
it('explicit supportedModes overrides commandType inference', () => {
|
||||
const cmd = makeCmd({
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive'],
|
||||
});
|
||||
// ── Explicit supportedModes ────────────────────────────────────────────
|
||||
it('uses explicit supportedModes when declared', () => {
|
||||
const cmd = makeCmd({ supportedModes: ['interactive'] });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
|
||||
});
|
||||
|
||||
it('explicit supportedModes can expand to all modes even for local-jsx', () => {
|
||||
it('supportedModes can declare all modes', () => {
|
||||
const cmd = makeCmd({
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
});
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([
|
||||
|
|
@ -48,45 +44,13 @@ describe('getEffectiveSupportedModes', () => {
|
|||
expect(getEffectiveSupportedModes(cmd)).toEqual([]);
|
||||
});
|
||||
|
||||
// ── Priority 2: commandType inference ─────────────────────────────────
|
||||
it('commandType: prompt infers all modes', () => {
|
||||
const cmd = makeCmd({ kind: CommandKind.SKILL, commandType: 'prompt' });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([
|
||||
'interactive',
|
||||
'non_interactive',
|
||||
'acp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('commandType: local infers interactive only (conservative default)', () => {
|
||||
const cmd = makeCmd({ commandType: 'local' });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
|
||||
});
|
||||
|
||||
it('commandType: local-jsx infers interactive only', () => {
|
||||
const cmd = makeCmd({ commandType: 'local-jsx' });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
|
||||
});
|
||||
|
||||
it('commandType: local with explicit supportedModes can unlock non_interactive', () => {
|
||||
const cmd = makeCmd({
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
});
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([
|
||||
'interactive',
|
||||
'non_interactive',
|
||||
'acp',
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Priority 3: CommandKind fallback (backward compat) ────────────────
|
||||
it('no commandType, CommandKind.BUILT_IN falls back to interactive only', () => {
|
||||
// ── CommandKind fallback (no supportedModes) ───────────────────────────
|
||||
it('CommandKind.BUILT_IN without supportedModes falls back to interactive only', () => {
|
||||
const cmd = makeCmd({ kind: CommandKind.BUILT_IN });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
|
||||
});
|
||||
|
||||
it('no commandType, CommandKind.FILE falls back to all modes', () => {
|
||||
it('CommandKind.FILE without supportedModes falls back to all modes', () => {
|
||||
const cmd = makeCmd({ kind: CommandKind.FILE });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([
|
||||
'interactive',
|
||||
|
|
@ -95,7 +59,7 @@ describe('getEffectiveSupportedModes', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('no commandType, CommandKind.SKILL falls back to all modes', () => {
|
||||
it('CommandKind.SKILL without supportedModes falls back to all modes', () => {
|
||||
const cmd = makeCmd({ kind: CommandKind.SKILL });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([
|
||||
'interactive',
|
||||
|
|
@ -104,7 +68,7 @@ describe('getEffectiveSupportedModes', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('no commandType, CommandKind.MCP_PROMPT falls back to all modes (fixes original bug)', () => {
|
||||
it('CommandKind.MCP_PROMPT without supportedModes falls back to all modes (fixes original bug)', () => {
|
||||
const cmd = makeCmd({ kind: CommandKind.MCP_PROMPT });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([
|
||||
'interactive',
|
||||
|
|
@ -118,28 +82,26 @@ describe('filterCommandsForMode', () => {
|
|||
const commands: SlashCommand[] = [
|
||||
makeCmd({
|
||||
name: 'init',
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
}),
|
||||
makeCmd({
|
||||
name: 'model',
|
||||
commandType: 'local-jsx',
|
||||
// no explicit supportedModes → interactive only
|
||||
supportedModes: ['interactive'],
|
||||
}),
|
||||
makeCmd({
|
||||
name: 'review',
|
||||
kind: CommandKind.SKILL,
|
||||
commandType: 'prompt',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
}),
|
||||
makeCmd({
|
||||
name: 'gh-prompt',
|
||||
kind: CommandKind.MCP_PROMPT,
|
||||
commandType: 'prompt',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
}),
|
||||
makeCmd({
|
||||
name: 'my-script',
|
||||
kind: CommandKind.FILE,
|
||||
commandType: 'prompt',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
}),
|
||||
];
|
||||
|
||||
|
|
@ -154,7 +116,7 @@ describe('filterCommandsForMode', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('non_interactive mode excludes local-jsx commands', () => {
|
||||
it('non_interactive mode excludes interactive-only commands', () => {
|
||||
const result = filterCommandsForMode(commands, 'non_interactive');
|
||||
expect(result.map((c) => c.name)).toEqual([
|
||||
'init',
|
||||
|
|
@ -164,7 +126,7 @@ describe('filterCommandsForMode', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('acp mode excludes local-jsx commands', () => {
|
||||
it('acp mode excludes interactive-only commands', () => {
|
||||
const result = filterCommandsForMode(commands, 'acp');
|
||||
expect(result.map((c) => c.name)).toEqual([
|
||||
'init',
|
||||
|
|
@ -182,20 +144,22 @@ describe('filterCommandsForMode', () => {
|
|||
it('does not filter hidden commands (hidden filtering is caller responsibility)', () => {
|
||||
const withHidden = [
|
||||
...commands,
|
||||
makeCmd({ name: 'hidden-cmd', commandType: 'local', hidden: true }),
|
||||
makeCmd({
|
||||
name: 'hidden-cmd',
|
||||
hidden: true,
|
||||
// no supportedModes → BUILT_IN fallback → interactive only
|
||||
}),
|
||||
];
|
||||
const result = filterCommandsForMode(withHidden, 'non_interactive');
|
||||
// filterCommandsForMode does NOT filter hidden — it only filters by mode
|
||||
// hidden-cmd has commandType: 'local' but no supportedModes, so it's interactive only
|
||||
expect(result.some((c) => c.name === 'hidden-cmd')).toBe(false);
|
||||
});
|
||||
|
||||
it('hidden local command with explicit supportedModes still passes mode filter', () => {
|
||||
it('hidden command with explicit all-mode supportedModes still passes mode filter', () => {
|
||||
const withHidden = [
|
||||
...commands,
|
||||
makeCmd({
|
||||
name: 'hidden-cmd',
|
||||
commandType: 'local',
|
||||
hidden: true,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
}),
|
||||
|
|
@ -206,7 +170,9 @@ describe('filterCommandsForMode', () => {
|
|||
});
|
||||
|
||||
it('returns empty array when no commands match', () => {
|
||||
const jsxOnly = [makeCmd({ name: 'model', commandType: 'local-jsx' })];
|
||||
const jsxOnly = [
|
||||
makeCmd({ name: 'model', supportedModes: ['interactive'] }),
|
||||
];
|
||||
expect(filterCommandsForMode(jsxOnly, 'non_interactive')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,50 +20,29 @@ import {
|
|||
/**
|
||||
* Returns the effective list of execution modes for a command.
|
||||
*
|
||||
* Priority (highest to lowest):
|
||||
* 1. Explicit `supportedModes` declaration on the command
|
||||
* 2. Inference from `commandType`
|
||||
* 3. Fallback based on `CommandKind` (backward-compat for commands that
|
||||
* have not yet been migrated to declare commandType)
|
||||
* All commands must explicitly declare `supportedModes` (Phase 2+ requirement).
|
||||
* If a command omits it, this function falls back to a conservative default
|
||||
* based on `CommandKind` — built-in commands default to interactive-only,
|
||||
* while file/skill/mcp-prompt commands default to all modes.
|
||||
*
|
||||
* @param cmd The slash command to evaluate.
|
||||
* @returns The list of execution modes in which the command is available.
|
||||
*/
|
||||
export function getEffectiveSupportedModes(cmd: SlashCommand): ExecutionMode[] {
|
||||
// Priority 1: explicit declaration wins
|
||||
// Explicit declaration is always authoritative.
|
||||
if (cmd.supportedModes !== undefined) {
|
||||
return cmd.supportedModes;
|
||||
}
|
||||
|
||||
// Priority 2: infer from commandType
|
||||
if (cmd.commandType !== undefined) {
|
||||
switch (cmd.commandType) {
|
||||
case 'prompt':
|
||||
// prompt commands have no UI dependency — available in all modes
|
||||
return ['interactive', 'non_interactive', 'acp'];
|
||||
case 'local':
|
||||
// local commands default to interactive only (conservative).
|
||||
// Commands that are verified headless-friendly must explicitly declare
|
||||
// supportedModes (mirrors Claude Code's supportsNonInteractive: true).
|
||||
return ['interactive'];
|
||||
case 'local-jsx':
|
||||
// local-jsx commands always require the React/Ink runtime
|
||||
return ['interactive'];
|
||||
default:
|
||||
return ['interactive'];
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: backward-compat fallback based on CommandKind.
|
||||
// This branch should not be hit once all commands declare commandType.
|
||||
// Fallback based on CommandKind for commands that omit supportedModes.
|
||||
// Built-in commands without a declaration are conservative (interactive only).
|
||||
// File / skill / MCP-prompt commands retain their historical all-mode behavior.
|
||||
switch (cmd.kind) {
|
||||
case CommandKind.BUILT_IN:
|
||||
// Conservative default for unmigrated built-in commands
|
||||
return ['interactive'];
|
||||
case CommandKind.FILE:
|
||||
case CommandKind.SKILL:
|
||||
case CommandKind.MCP_PROMPT:
|
||||
// These kinds have always been available in all modes
|
||||
return ['interactive', 'non_interactive', 'acp'];
|
||||
default:
|
||||
return ['interactive'];
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ export const MarkdownCommandDefSchema = z.object({
|
|||
frontmatter: z
|
||||
.object({
|
||||
description: z.string().optional(),
|
||||
when_to_use: z.string().optional(),
|
||||
'disable-model-invocation': z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
prompt: z.string({
|
||||
required_error: 'The prompt content is required.',
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export const createMockCommandContext = (
|
|||
overrides: DeepPartial<CommandContext> = {},
|
||||
): CommandContext => {
|
||||
const defaultMocks: CommandContext = {
|
||||
executionMode: 'interactive',
|
||||
invocation: {
|
||||
raw: '',
|
||||
name: '',
|
||||
|
|
|
|||
|
|
@ -280,4 +280,65 @@ describe('aboutCommand', () => {
|
|||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
it('should return text summary without calling addItem', async () => {
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
const nonInteractiveContext = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
} as unknown as Partial<CommandContext>);
|
||||
// Attach a spy to the non-interactive context's ui
|
||||
nonInteractiveContext.ui.addItem = vi.fn();
|
||||
|
||||
const result = await aboutCommand.action(nonInteractiveContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('test-version'),
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
content: expect.stringContaining('test-model'),
|
||||
}),
|
||||
);
|
||||
expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include git commit and IDE when available', async () => {
|
||||
if (!aboutCommand.action) throw new Error('No action');
|
||||
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'vscode',
|
||||
sessionId: 'sess-1',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
gitCommit: 'abc1234',
|
||||
});
|
||||
|
||||
const nonInteractiveContext = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
} as unknown as Partial<CommandContext>);
|
||||
|
||||
const result = (await aboutCommand.action(nonInteractiveContext, '')) as {
|
||||
type: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
expect(result.content).toContain('abc1234');
|
||||
expect(result.content).toContain('vscode');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,15 +17,37 @@ export const aboutCommand: SlashCommand = {
|
|||
return t('show version info');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context) => {
|
||||
const systemInfo = await getExtendedSystemInfo(context);
|
||||
|
||||
if (context.executionMode !== 'interactive') {
|
||||
const lines = [
|
||||
`Qwen Code v${systemInfo.cliVersion}`,
|
||||
`Model: ${systemInfo.modelVersion}`,
|
||||
`Fast Model: ${systemInfo.fastModel ?? 'not set'}`,
|
||||
`Auth: ${systemInfo.selectedAuthType}`,
|
||||
`Platform: ${systemInfo.osPlatform} ${systemInfo.osArch} (${systemInfo.osRelease})`,
|
||||
`Node.js: ${systemInfo.nodeVersion}`,
|
||||
`Session: ${systemInfo.sessionId}`,
|
||||
...(systemInfo.gitCommit
|
||||
? [`Git commit: ${systemInfo.gitCommit}`]
|
||||
: []),
|
||||
...(systemInfo.ideClient ? [`IDE: ${systemInfo.ideClient}`] : []),
|
||||
];
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'info' as const,
|
||||
content: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
|
||||
type: MessageType.ABOUT,
|
||||
systemInfo,
|
||||
};
|
||||
|
||||
context.ui.addItem(aboutItem, Date.now());
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const agentsCommand: SlashCommand = {
|
|||
return t('Manage subagents for specialized task delegation.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'manage',
|
||||
|
|
@ -25,7 +25,7 @@ export const agentsCommand: SlashCommand = {
|
|||
return t('Manage existing subagents (view, edit, delete).');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'subagent_list',
|
||||
|
|
@ -37,7 +37,7 @@ export const agentsCommand: SlashCommand = {
|
|||
return t('Create a new subagent with guided setup.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'subagent_create',
|
||||
|
|
|
|||
|
|
@ -34,14 +34,15 @@ export const approvalModeCommand: SlashCommand = {
|
|||
return t('View or change the approval mode for tool usage');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<OpenDialogActionReturn | MessageActionReturn> => {
|
||||
const mode = parseApprovalModeArg(args);
|
||||
|
||||
// If no argument provided, open the dialog
|
||||
// If no argument provided, open dialog in interactive mode;
|
||||
// in non-interactive/ACP, return current state instead
|
||||
if (!args.trim()) {
|
||||
return {
|
||||
type: 'dialog',
|
||||
|
|
|
|||
|
|
@ -387,14 +387,14 @@ export const arenaCommand: SlashCommand = {
|
|||
name: 'arena',
|
||||
description: 'Manage Arena sessions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'start',
|
||||
description:
|
||||
'Start an Arena session with multiple models competing on the same task',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
|
|
@ -451,7 +451,7 @@ export const arenaCommand: SlashCommand = {
|
|||
name: 'stop',
|
||||
description: 'Stop the current Arena session',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
|
|
@ -493,7 +493,7 @@ export const arenaCommand: SlashCommand = {
|
|||
name: 'status',
|
||||
description: 'Show the current Arena session status',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
|
|
@ -536,7 +536,7 @@ export const arenaCommand: SlashCommand = {
|
|||
description:
|
||||
'Select a model result and merge its diff into the current workspace',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const authCommand: SlashCommand = {
|
|||
return t('Configure authentication information for login');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'auth',
|
||||
|
|
|
|||
|
|
@ -434,109 +434,4 @@ describe('btwCommand', () => {
|
|||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
let nonInteractiveContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
nonInteractiveContext = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
services: {
|
||||
config: createConfig(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return info message on success', async () => {
|
||||
mockRunForkedAgent.mockResolvedValue({
|
||||
text: 'the answer',
|
||||
usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 },
|
||||
});
|
||||
|
||||
const result = await btwCommand.action!(
|
||||
nonInteractiveContext,
|
||||
'my question',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'btw> my question\nthe answer',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error message on failure', async () => {
|
||||
mockRunForkedAgent.mockRejectedValue(new Error('network error'));
|
||||
|
||||
const result = await btwCommand.action!(
|
||||
nonInteractiveContext,
|
||||
'my question',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to answer btw question: network error',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('acp mode', () => {
|
||||
let acpContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
acpContext = createMockCommandContext({
|
||||
executionMode: 'acp',
|
||||
services: {
|
||||
config: createConfig(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return stream_messages generator on success', async () => {
|
||||
mockRunForkedAgent.mockResolvedValue({
|
||||
text: 'streamed answer',
|
||||
usage: { inputTokens: 5, outputTokens: 3, cacheHitTokens: 0 },
|
||||
});
|
||||
|
||||
const result = (await btwCommand.action!(acpContext, 'my question')) as {
|
||||
type: string;
|
||||
messages: AsyncGenerator;
|
||||
};
|
||||
|
||||
expect(result.type).toBe('stream_messages');
|
||||
|
||||
const messages = [];
|
||||
for await (const msg of result.messages) {
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ messageType: 'info', content: 'Thinking...' },
|
||||
{ messageType: 'info', content: 'btw> my question\nstreamed answer' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should yield error message on failure', async () => {
|
||||
mockRunForkedAgent.mockRejectedValue(new Error('api failure'));
|
||||
|
||||
const result = (await btwCommand.action!(acpContext, 'my question')) as {
|
||||
type: string;
|
||||
messages: AsyncGenerator;
|
||||
};
|
||||
|
||||
const messages = [];
|
||||
for await (const msg of result.messages) {
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ messageType: 'info', content: 'Thinking...' },
|
||||
{
|
||||
messageType: 'error',
|
||||
content: 'Failed to answer btw question: api failure',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -123,15 +123,12 @@ export const btwCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
const question = args.trim();
|
||||
const executionMode = context.executionMode ?? 'interactive';
|
||||
const abortSignal = context.abortSignal ?? new AbortController().signal;
|
||||
|
||||
if (!question) {
|
||||
return {
|
||||
|
|
@ -152,53 +149,17 @@ export const btwCommand: SlashCommand = {
|
|||
};
|
||||
}
|
||||
|
||||
// ACP mode: return a stream_messages async generator
|
||||
if (executionMode === 'acp') {
|
||||
const messages = async function* () {
|
||||
try {
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: t('Thinking...'),
|
||||
};
|
||||
|
||||
const answer = await askBtw(context, question, abortSignal);
|
||||
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: `btw> ${question}\n${answer}`,
|
||||
};
|
||||
} catch (error) {
|
||||
yield {
|
||||
messageType: 'error' as const,
|
||||
content: formatBtwError(error),
|
||||
};
|
||||
}
|
||||
const model = config.getModel();
|
||||
if (!model) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('No model configured.'),
|
||||
};
|
||||
|
||||
return { type: 'stream_messages', messages: messages() };
|
||||
}
|
||||
|
||||
// Non-interactive mode: return a simple message result
|
||||
if (executionMode === 'non_interactive') {
|
||||
try {
|
||||
const answer = await askBtw(context, question, abortSignal);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `btw> ${question}\n${answer}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: formatBtwError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive mode: use dedicated btwItem state for the fixed bottom area.
|
||||
// This does NOT occupy pendingItem, so the main conversation is never blocked.
|
||||
|
||||
// Cancel any previous in-flight btw before starting a new one.
|
||||
ui.cancelBtw();
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ export const bugCommand: SlashCommand = {
|
|||
return t('submit a bug report');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||
const bugDescription = (args || '').trim();
|
||||
|
|
|
|||
|
|
@ -227,4 +227,65 @@ describe('clearCommand', () => {
|
|||
expect(mockResetChat).not.toHaveBeenCalled();
|
||||
expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
let nonInteractiveContext: ReturnType<typeof createMockCommandContext>;
|
||||
|
||||
beforeEach(() => {
|
||||
nonInteractiveContext = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
services: {
|
||||
config: {
|
||||
getHookSystem: mockGetHookSystem,
|
||||
startNewSession: mockStartNewSession,
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
resetChat: mockResetChat,
|
||||
} as unknown as GeminiClient),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
},
|
||||
},
|
||||
session: {
|
||||
startNewSession: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return context boundary message in non-interactive mode', async () => {
|
||||
if (!clearCommand.action)
|
||||
throw new Error('clearCommand must have an action.');
|
||||
|
||||
const result = await clearCommand.action(nonInteractiveContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Context cleared. Previous messages are no longer in context.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should still call resetChat in non-interactive mode', async () => {
|
||||
if (!clearCommand.action)
|
||||
throw new Error('clearCommand must have an action.');
|
||||
|
||||
await clearCommand.action(nonInteractiveContext, '');
|
||||
|
||||
expect(mockResetChat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should still fire session events in non-interactive mode', async () => {
|
||||
if (!clearCommand.action)
|
||||
throw new Error('clearCommand must have an action.');
|
||||
|
||||
await clearCommand.action(nonInteractiveContext, '');
|
||||
|
||||
expect(mockFireSessionEndEvent).toHaveBeenCalledWith(
|
||||
SessionEndReason.Clear,
|
||||
);
|
||||
expect(mockFireSessionStartEvent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const clearCommand: SlashCommand = {
|
|||
return t('Clear conversation history and free up context');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context, _args) => {
|
||||
const { config } = context.services;
|
||||
|
||||
|
|
@ -83,5 +83,14 @@ export const clearCommand: SlashCommand = {
|
|||
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
|
||||
context.ui.clear();
|
||||
}
|
||||
|
||||
if (context.executionMode !== 'interactive') {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'info' as const,
|
||||
content: 'Context cleared. Previous messages are no longer in context.',
|
||||
};
|
||||
}
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export const compressCommand: SlashCommand = {
|
|||
return t('Compresses the context by replacing it with a summary.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context) => {
|
||||
const { ui } = context;
|
||||
|
|
|
|||
|
|
@ -308,6 +308,166 @@ export async function collectContextData(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format token count for display (e.g. 1234 -> "1.2k", 123456 -> "123.5k")
|
||||
*/
|
||||
function fmtTokens(tokens: number): string {
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return `${tokens}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a category row as text: " label .............. 1.2k tokens (3.4%)"
|
||||
*/
|
||||
function fmtCategoryRow(
|
||||
label: string,
|
||||
tokens: number,
|
||||
contextWindowSize: number,
|
||||
indent = ' ',
|
||||
): string {
|
||||
const percentage = ((tokens / contextWindowSize) * 100).toFixed(1);
|
||||
const right = `${fmtTokens(tokens)} tokens (${percentage}%)`;
|
||||
const leftPart = `${indent}${label}`;
|
||||
const totalWidth = 56;
|
||||
const dots = Math.max(1, totalWidth - leftPart.length - right.length);
|
||||
return `${leftPart}${' '.repeat(dots)}${right}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a HistoryItemContextUsage to a human-readable text string,
|
||||
* mirroring the layout of the interactive ContextUsage component.
|
||||
*/
|
||||
export function formatContextUsageText(data: HistoryItemContextUsage): string {
|
||||
const {
|
||||
modelName,
|
||||
totalTokens,
|
||||
contextWindowSize,
|
||||
breakdown,
|
||||
builtinTools,
|
||||
mcpTools,
|
||||
memoryFiles,
|
||||
skills,
|
||||
isEstimated,
|
||||
showDetails,
|
||||
} = data;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('## Context Usage');
|
||||
lines.push('');
|
||||
|
||||
if (isEstimated) {
|
||||
lines.push('*No API response yet. Send a message to see actual usage.*');
|
||||
lines.push('');
|
||||
lines.push('**Estimated pre-conversation overhead**');
|
||||
lines.push(
|
||||
`Model: ${modelName} Context window: ${fmtTokens(contextWindowSize)} tokens`,
|
||||
);
|
||||
lines.push('');
|
||||
} else {
|
||||
lines.push(
|
||||
`Model: ${modelName} Context window: ${fmtTokens(contextWindowSize)} tokens`,
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(fmtCategoryRow('Used', totalTokens, contextWindowSize));
|
||||
lines.push(fmtCategoryRow('Free', breakdown.freeSpace, contextWindowSize));
|
||||
lines.push(
|
||||
fmtCategoryRow(
|
||||
'Autocompact buffer',
|
||||
breakdown.autocompactBuffer,
|
||||
contextWindowSize,
|
||||
),
|
||||
);
|
||||
lines.push('');
|
||||
lines.push('**Usage by category**');
|
||||
}
|
||||
|
||||
lines.push(
|
||||
fmtCategoryRow('System prompt', breakdown.systemPrompt, contextWindowSize),
|
||||
);
|
||||
lines.push(
|
||||
fmtCategoryRow('Built-in tools', breakdown.builtinTools, contextWindowSize),
|
||||
);
|
||||
if (breakdown.mcpTools > 0) {
|
||||
lines.push(
|
||||
fmtCategoryRow('MCP tools', breakdown.mcpTools, contextWindowSize),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
fmtCategoryRow('Memory files', breakdown.memoryFiles, contextWindowSize),
|
||||
);
|
||||
lines.push(fmtCategoryRow('Skills', breakdown.skills, contextWindowSize));
|
||||
if (!isEstimated) {
|
||||
lines.push(
|
||||
fmtCategoryRow('Messages', breakdown.messages, contextWindowSize),
|
||||
);
|
||||
}
|
||||
|
||||
if (showDetails) {
|
||||
const sortedBuiltin = [...builtinTools].sort((a, b) => b.tokens - a.tokens);
|
||||
const sortedMcp = [...mcpTools].sort((a, b) => b.tokens - a.tokens);
|
||||
const sortedMemory = [...memoryFiles].sort((a, b) => b.tokens - a.tokens);
|
||||
const sortedSkills = [...skills].sort((a, b) => {
|
||||
if (a.loaded !== b.loaded) return a.loaded ? -1 : 1;
|
||||
return b.tokens + (b.bodyTokens ?? 0) - (a.tokens + (a.bodyTokens ?? 0));
|
||||
});
|
||||
|
||||
if (sortedBuiltin.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('**Built-in tools**');
|
||||
for (const tool of sortedBuiltin) {
|
||||
lines.push(
|
||||
fmtCategoryRow(tool.name, tool.tokens, contextWindowSize, ' └ '),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (sortedMcp.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('**MCP tools**');
|
||||
for (const tool of sortedMcp) {
|
||||
lines.push(
|
||||
fmtCategoryRow(tool.name, tool.tokens, contextWindowSize, ' └ '),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (sortedMemory.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('**Memory files**');
|
||||
for (const file of sortedMemory) {
|
||||
lines.push(
|
||||
fmtCategoryRow(file.path, file.tokens, contextWindowSize, ' └ '),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (sortedSkills.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('**Skills**');
|
||||
for (const skill of sortedSkills) {
|
||||
const label = skill.loaded ? `${skill.name} (active)` : skill.name;
|
||||
lines.push(
|
||||
fmtCategoryRow(label, skill.tokens, contextWindowSize, ' └ '),
|
||||
);
|
||||
if (skill.loaded && skill.bodyTokens && skill.bodyTokens > 0) {
|
||||
lines.push(
|
||||
fmtCategoryRow(
|
||||
'body loaded',
|
||||
skill.bodyTokens,
|
||||
contextWindowSize,
|
||||
' └ ',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push('');
|
||||
lines.push('*Run /context detail for per-item breakdown.*');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export const contextCommand: SlashCommand = {
|
||||
name: 'context',
|
||||
get description() {
|
||||
|
|
@ -316,7 +476,6 @@ export const contextCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context: CommandContext, args?: string) => {
|
||||
const showDetails =
|
||||
|
|
@ -351,7 +510,7 @@ export const contextCommand: SlashCommand = {
|
|||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: JSON.stringify(contextUsageItem, null, 2),
|
||||
content: formatContextUsageText(contextUsageItem),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
|
@ -362,7 +521,6 @@ export const contextCommand: SlashCommand = {
|
|||
return t('Show per-item context usage breakdown.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context: CommandContext) => {
|
||||
// Delegate to main action with 'detail' arg to show detailed view
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const copyCommand: SlashCommand = {
|
|||
return t('Copy the last result or code snippet to clipboard');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
|
||||
const chat = await context.services.config?.getGeminiClient()?.getChat();
|
||||
const history = chat?.getHistory();
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export const directoryCommand: SlashCommand = {
|
|||
return t('Manage workspace directories');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'add',
|
||||
|
|
@ -84,7 +84,7 @@ export const directoryCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
completion: async (_context: CommandContext, partialArg: string) =>
|
||||
getDirPathCompletions(partialArg),
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
|
|
@ -224,7 +224,7 @@ export const directoryCommand: SlashCommand = {
|
|||
return t('Show all directories in the workspace');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (context: CommandContext) => {
|
||||
const {
|
||||
ui: { addItem },
|
||||
|
|
|
|||
|
|
@ -96,4 +96,24 @@ describe('docsCommand', () => {
|
|||
// 'open' should be called in this specific sandbox case
|
||||
expect(open).toHaveBeenCalledWith(docsUrl);
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
it('should return docs URL without opening browser', async () => {
|
||||
if (!docsCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const nonInteractiveContext = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
});
|
||||
|
||||
const result = await docsCommand.action(nonInteractiveContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('qwenlm.github.io'),
|
||||
});
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,11 +20,20 @@ export const docsCommand: SlashCommand = {
|
|||
return t('open full Qwen Code documentation in your browser');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context: CommandContext): Promise<void> => {
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context: CommandContext) => {
|
||||
const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en';
|
||||
const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`;
|
||||
|
||||
// Non-interactive/ACP: return URL directly, no browser, no addItem
|
||||
if (context.executionMode !== 'interactive') {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'info' as const,
|
||||
content: `Qwen Code documentation: ${docsUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
||||
context.ui.addItem(
|
||||
{
|
||||
|
|
@ -50,5 +59,6 @@ export const docsCommand: SlashCommand = {
|
|||
);
|
||||
await open(docsUrl);
|
||||
}
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const doctorCommand: SlashCommand = {
|
|||
return t('Run installation and environment diagnostics');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context) => {
|
||||
const executionMode = context.executionMode ?? 'interactive';
|
||||
const abortSignal = context.abortSignal;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const editorCommand: SlashCommand = {
|
|||
return t('set external editor preference');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'editor',
|
||||
|
|
|
|||
|
|
@ -325,7 +325,8 @@ export const exportCommand: SlashCommand = {
|
|||
return t('Export current session message history to a file');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: exportHtmlAction,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'html',
|
||||
|
|
@ -333,7 +334,7 @@ export const exportCommand: SlashCommand = {
|
|||
return t('Export session to HTML format');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: exportHtmlAction,
|
||||
},
|
||||
{
|
||||
|
|
@ -342,7 +343,7 @@ export const exportCommand: SlashCommand = {
|
|||
return t('Export session to markdown format');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: exportMarkdownAction,
|
||||
},
|
||||
{
|
||||
|
|
@ -351,7 +352,7 @@ export const exportCommand: SlashCommand = {
|
|||
return t('Export session to JSON format');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: exportJsonAction,
|
||||
},
|
||||
{
|
||||
|
|
@ -360,7 +361,7 @@ export const exportCommand: SlashCommand = {
|
|||
return t('Export session to JSONL format (one message per line)');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: exportJsonlAction,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ const exploreExtensionsCommand: SlashCommand = {
|
|||
return t('Open extensions page in your browser');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: exploreAction,
|
||||
completion: completeExtensionsExplore,
|
||||
};
|
||||
|
|
@ -227,7 +227,7 @@ const manageExtensionsCommand: SlashCommand = {
|
|||
return t('Manage installed extensions');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: listAction,
|
||||
};
|
||||
|
||||
|
|
@ -237,7 +237,7 @@ const installCommand: SlashCommand = {
|
|||
return t('Install an extension from a git repo or local path');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: installAction,
|
||||
};
|
||||
|
||||
|
|
@ -247,7 +247,7 @@ export const extensionsCommand: SlashCommand = {
|
|||
return t('Manage extensions');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
subCommands: [
|
||||
manageExtensionsCommand,
|
||||
installCommand,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const helpCommand: SlashCommand = {
|
|||
name: 'help',
|
||||
altNames: ['?'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
get description() {
|
||||
return t('for help on Qwen Code');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ const listCommand: SlashCommand = {
|
|||
return t('List all configured hooks');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
_args: string,
|
||||
|
|
@ -186,7 +186,7 @@ export const hooksCommand: SlashCommand = {
|
|||
return t('Manage Qwen Code hooks');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||
return t('manage IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: (): SlashCommandActionReturn =>
|
||||
({
|
||||
type: 'message',
|
||||
|
|
@ -161,7 +161,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||
return t('manage IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
subCommands: [],
|
||||
};
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||
return t('check status of IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (): Promise<SlashCommandActionReturn> => {
|
||||
const { messageType, content } =
|
||||
await getIdeStatusMessageWithFiles(ideClient);
|
||||
|
|
@ -192,7 +192,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||
});
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (context) => {
|
||||
const installer = getIdeInstaller(currentIDE);
|
||||
const isSandBox = !!process.env['SANDBOX'];
|
||||
|
|
@ -280,7 +280,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||
return t('enable IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (context: CommandContext) => {
|
||||
context.services.settings.setValue(
|
||||
SettingScope.User,
|
||||
|
|
@ -305,7 +305,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||
return t('disable IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (context: CommandContext) => {
|
||||
context.services.settings.setValue(
|
||||
SettingScope.User,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ export const initCommand: SlashCommand = {
|
|||
return t('Analyzes the project and creates a tailored QWEN.md file.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
|
|
|
|||
|
|
@ -171,4 +171,62 @@ describe('insightCommand', () => {
|
|||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('non_interactive: returns message with output path and does not open browser', async () => {
|
||||
const nonInteractiveContext = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
services: {
|
||||
config: {} as CommandContext['services']['config'],
|
||||
},
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
setPendingItem: vi.fn(),
|
||||
setDebugMessage: vi.fn(),
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
if (!insightCommand.action) {
|
||||
throw new Error('insight command must have action');
|
||||
}
|
||||
|
||||
const result = await insightCommand.action(nonInteractiveContext, '');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
});
|
||||
expect((result as { content: string }).content).toContain(
|
||||
path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'),
|
||||
);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('non_interactive: returns error message when generation fails', async () => {
|
||||
mockGenerateStaticInsight.mockRejectedValue(new Error('disk full'));
|
||||
|
||||
const nonInteractiveContext = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
services: {
|
||||
config: {} as CommandContext['services']['config'],
|
||||
},
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
setPendingItem: vi.fn(),
|
||||
setDebugMessage: vi.fn(),
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
if (!insightCommand.action) {
|
||||
throw new Error('insight command must have action');
|
||||
}
|
||||
|
||||
const result = await insightCommand.action(nonInteractiveContext, '');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
});
|
||||
expect((result as { content: string }).content).toContain('disk full');
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,19 +29,53 @@ export const insightCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context: CommandContext) => {
|
||||
try {
|
||||
context.ui.setDebugMessage(t('Generating insights...'));
|
||||
|
||||
const projectsDir = join(Storage.getRuntimeBaseDir(), 'projects');
|
||||
if (!context.services.config) {
|
||||
if (context.executionMode !== 'interactive') {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'error' as const,
|
||||
content: 'Config service is not available.',
|
||||
};
|
||||
}
|
||||
throw new Error('Config service is not available');
|
||||
}
|
||||
const insightGenerator = new StaticInsightGenerator(
|
||||
context.services.config,
|
||||
);
|
||||
|
||||
if (context.executionMode === 'non_interactive') {
|
||||
// non_interactive: run synchronously and return a single message
|
||||
try {
|
||||
const outputPath = await insightGenerator.generateStaticInsight(
|
||||
projectsDir,
|
||||
() => {
|
||||
// progress callback is no-op in non_interactive mode
|
||||
},
|
||||
);
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'info' as const,
|
||||
content: t('Insight report generated at: {{path}}', {
|
||||
path: outputPath,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'error' as const,
|
||||
content: t('Failed to generate insights: {{error}}', {
|
||||
error: (error as Error).message,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (context.executionMode === 'acp') {
|
||||
const pendingMessages: Array<{
|
||||
messageType: 'info' | 'error';
|
||||
|
|
@ -215,6 +249,14 @@ export const insightCommand: SlashCommand = {
|
|||
} catch (error) {
|
||||
context.ui.setPendingItem(null);
|
||||
|
||||
if (context.executionMode !== 'interactive') {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'error' as const,
|
||||
content: `Failed to generate insights: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
|
|
@ -228,5 +270,6 @@ export const insightCommand: SlashCommand = {
|
|||
logger.error('Insight generation error:', error);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ export const languageCommand: SlashCommand = {
|
|||
return t('View or change the language setting');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
|
|
@ -269,7 +269,7 @@ export const languageCommand: SlashCommand = {
|
|||
return t('Set UI language');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
|
|
@ -324,7 +324,7 @@ export const languageCommand: SlashCommand = {
|
|||
});
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context, args) => {
|
||||
if (args.trim()) {
|
||||
return {
|
||||
|
|
@ -348,7 +348,7 @@ export const languageCommand: SlashCommand = {
|
|||
return t('Set LLM output language');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const mcpCommand: SlashCommand = {
|
|||
return t('Open MCP management dialog');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (): Promise<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const memoryCommand: SlashCommand = {
|
|||
return t('Open the memory manager.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async () => ({
|
||||
type: 'dialog',
|
||||
dialog: 'memory',
|
||||
|
|
|
|||
|
|
@ -139,4 +139,57 @@ describe('modelCommand', () => {
|
|||
content: 'Authentication type not available.',
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
it('should return current model without triggering dialog when no args', async () => {
|
||||
mockContext = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
services: {
|
||||
config: {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
model: 'qwen-max',
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
}),
|
||||
getModel: vi.fn().mockReturnValue('qwen-max'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await modelCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('qwen-max'),
|
||||
});
|
||||
expect((result as { type: string }).type).toBe('message');
|
||||
});
|
||||
|
||||
it('should return current fast model without triggering dialog for --fast no args', async () => {
|
||||
mockContext = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
invocation: { args: '--fast' },
|
||||
services: {
|
||||
config: {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
model: 'qwen-max',
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
}),
|
||||
getModel: vi.fn().mockReturnValue('qwen-max'),
|
||||
},
|
||||
settings: {
|
||||
merged: { fastModel: 'qwen-turbo' } as Record<string, unknown>,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await modelCommand.action!(mockContext, '--fast');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('qwen-turbo'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const modelCommand: SlashCommand = {
|
|||
return t('Switch the model for this session (--fast for suggestion model)');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
completion: async (_context, partialArg) => {
|
||||
if (partialArg && '--fast'.startsWith(partialArg)) {
|
||||
return [
|
||||
|
|
@ -54,7 +54,16 @@ export const modelCommand: SlashCommand = {
|
|||
if (args.startsWith('--fast')) {
|
||||
const modelName = args.replace('--fast', '').trim();
|
||||
if (!modelName) {
|
||||
// Open model dialog in fast-model mode
|
||||
// Open model dialog in fast-model mode (interactive) or return current fast model (non-interactive)
|
||||
if (context.executionMode !== 'interactive') {
|
||||
const fastModel =
|
||||
context.services.settings?.merged?.fastModel ?? 'not set';
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Current fast model: ${fastModel}\nUse "/model --fast <model-id>" to set fast model.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'fast-model',
|
||||
|
|
@ -101,6 +110,39 @@ export const modelCommand: SlashCommand = {
|
|||
};
|
||||
}
|
||||
|
||||
// Non-interactive/ACP: set model if an arg was provided, otherwise show current model
|
||||
if (context.executionMode !== 'interactive') {
|
||||
const modelName = args.trim();
|
||||
if (modelName) {
|
||||
// /model <model-id> — set the main model
|
||||
if (!settings) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Settings service not available.'),
|
||||
};
|
||||
}
|
||||
settings.setValue(
|
||||
getPersistScopeForModelSelection(settings),
|
||||
'model.name',
|
||||
modelName,
|
||||
);
|
||||
await config.setModel(modelName);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('Model') + ': ' + modelName,
|
||||
};
|
||||
}
|
||||
// /model with no args — show current model
|
||||
const currentModel = config.getModel() ?? 'unknown';
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Current model: ${currentModel}\nUse "/model <model-id>" to switch models or "/model --fast <model-id>" to set the fast model.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'model',
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const permissionsCommand: SlashCommand = {
|
|||
return t('Manage permission rules');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'permissions',
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export const planCommand: SlashCommand = {
|
|||
return t('Switch to plan mode or exit plan mode');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const quitCommand: SlashCommand = {
|
|||
return t('exit the cli');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: (context) => {
|
||||
const now = Date.now();
|
||||
const { sessionStartTime } = context.session.stats;
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: restoreAction,
|
||||
completion,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const resumeCommand: SlashCommand = {
|
|||
name: 'resume',
|
||||
altNames: ['continue'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
get description() {
|
||||
return t('Resume a previous session');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const settingsCommand: SlashCommand = {
|
|||
return t('View and edit Qwen Code settings');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'settings',
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export const setupGithubCommand: SlashCommand = {
|
|||
return t('Set up GitHub Actions');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const skillsCommand: SlashCommand = {
|
|||
return t('List available skills.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (context: CommandContext, args?: string) => {
|
||||
const rawArgs = args?.trim() ?? '';
|
||||
const [skillName = ''] = rawArgs.split(/\s+/);
|
||||
|
|
|
|||
|
|
@ -75,4 +75,75 @@ describe('statsCommand', () => {
|
|||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
let nonInteractiveContext: ReturnType<typeof createMockCommandContext>;
|
||||
|
||||
beforeEach(() => {
|
||||
nonInteractiveContext = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
});
|
||||
nonInteractiveContext.session.stats.sessionStartTime = startTime;
|
||||
});
|
||||
|
||||
it('should return text stats without calling addItem', async () => {
|
||||
if (!statsCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const result = (await statsCommand.action(nonInteractiveContext, '')) as {
|
||||
type: string;
|
||||
messageType: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('Session duration');
|
||||
expect(result.content).toContain('Prompts');
|
||||
expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if sessionStartTime is not available', async () => {
|
||||
if (!statsCommand.action) throw new Error('Command has no action');
|
||||
|
||||
nonInteractiveContext.session.stats.sessionStartTime = undefined;
|
||||
|
||||
const result = (await statsCommand.action(nonInteractiveContext, '')) as {
|
||||
type: string;
|
||||
messageType: string;
|
||||
};
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('error');
|
||||
});
|
||||
|
||||
it('stats model subcommand should return text in non-interactive mode', async () => {
|
||||
const modelSubCommand = statsCommand.subCommands?.find(
|
||||
(sc) => sc.name === 'model',
|
||||
);
|
||||
if (!modelSubCommand?.action) throw new Error('Subcommand has no action');
|
||||
|
||||
const result = (await modelSubCommand.action(
|
||||
nonInteractiveContext,
|
||||
'',
|
||||
)) as { type: string; content: string };
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stats tools subcommand should return text in non-interactive mode', async () => {
|
||||
const toolsSubCommand = statsCommand.subCommands?.find(
|
||||
(sc) => sc.name === 'tools',
|
||||
);
|
||||
if (!toolsSubCommand?.action) throw new Error('Subcommand has no action');
|
||||
|
||||
const result = (await toolsSubCommand.action(
|
||||
nonInteractiveContext,
|
||||
'',
|
||||
)) as { type: string; content: string };
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { formatDuration } from '../utils/formatters.js';
|
|||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
type MessageActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
|
@ -21,11 +22,20 @@ export const statsCommand: SlashCommand = {
|
|||
return t('check session stats. Usage: /stats [model|tools]');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (context: CommandContext) => {
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: (context: CommandContext): MessageActionReturn | void => {
|
||||
const now = new Date();
|
||||
const { sessionStartTime } = context.session.stats;
|
||||
if (!sessionStartTime) {
|
||||
if (context.executionMode !== 'interactive') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Session start time is unavailable, cannot calculate stats.',
|
||||
),
|
||||
};
|
||||
}
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
|
|
@ -37,6 +47,30 @@ export const statsCommand: SlashCommand = {
|
|||
}
|
||||
const wallDuration = now.getTime() - sessionStartTime.getTime();
|
||||
|
||||
if (context.executionMode !== 'interactive') {
|
||||
const { promptCount, metrics } = context.session.stats;
|
||||
let totalPromptTokens = 0;
|
||||
let totalCandidateTokens = 0;
|
||||
let totalRequests = 0;
|
||||
for (const modelMetrics of Object.values(metrics.models)) {
|
||||
totalPromptTokens += modelMetrics.tokens.prompt;
|
||||
totalCandidateTokens += modelMetrics.tokens.candidates;
|
||||
totalRequests += modelMetrics.api.totalRequests;
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: [
|
||||
`Session duration: ${formatDuration(wallDuration)}`,
|
||||
`Prompts: ${promptCount}`,
|
||||
`API requests: ${totalRequests}`,
|
||||
`Tokens — prompt: ${totalPromptTokens}, output: ${totalCandidateTokens}`,
|
||||
`Tool calls: ${metrics.tools.totalCalls} (${metrics.tools.totalSuccess} ok, ${metrics.tools.totalFail} fail)`,
|
||||
`Files: +${metrics.files.totalLinesAdded} / -${metrics.files.totalLinesRemoved} lines`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const statsItem: HistoryItemStats = {
|
||||
type: MessageType.STATS,
|
||||
duration: formatDuration(wallDuration),
|
||||
|
|
@ -51,8 +85,27 @@ export const statsCommand: SlashCommand = {
|
|||
return t('Show model-specific usage statistics.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (context: CommandContext) => {
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: (context: CommandContext): MessageActionReturn | void => {
|
||||
if (context.executionMode !== 'interactive') {
|
||||
const { metrics } = context.session.stats;
|
||||
const lines: string[] = [];
|
||||
for (const [modelName, modelMetrics] of Object.entries(
|
||||
metrics.models,
|
||||
)) {
|
||||
lines.push(
|
||||
`${modelName}: prompt=${modelMetrics.tokens.prompt}, output=${modelMetrics.tokens.candidates}, cached=${modelMetrics.tokens.cached}`,
|
||||
);
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
lines.push('No model usage data yet.');
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.MODEL_STATS,
|
||||
|
|
@ -67,8 +120,21 @@ export const statsCommand: SlashCommand = {
|
|||
return t('Show tool-specific usage statistics.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (context: CommandContext) => {
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: (context: CommandContext): MessageActionReturn | void => {
|
||||
if (context.executionMode !== 'interactive') {
|
||||
const { metrics } = context.session.stats;
|
||||
const { tools } = metrics;
|
||||
const toolNames = Object.keys(tools.byName);
|
||||
const content =
|
||||
toolNames.length > 0
|
||||
? [
|
||||
`Tool calls: ${tools.totalCalls} total (${tools.totalSuccess} ok, ${tools.totalFail} fail)`,
|
||||
...toolNames.map((name) => ` ${name}`),
|
||||
].join('\n')
|
||||
: 'No tool usage data yet.';
|
||||
return { type: 'message', messageType: 'info', content };
|
||||
}
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.TOOL_STATS,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const statuslineCommand: SlashCommand = {
|
|||
return t("Set up Qwen Code's status line UI");
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: (_context, args): SubmitPromptActionReturn => {
|
||||
const prompt =
|
||||
args.trim() || 'Configure my statusLine from my shell PS1 configuration';
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ export const summaryCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context): Promise<SlashCommandActionReturn> => {
|
||||
const { config } = context.services;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const terminalSetupCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
|
||||
action: async (): Promise<MessageActionReturn> => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const themeCommand: SlashCommand = {
|
|||
return t('change the theme');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'theme',
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const toolsCommand: SlashCommand = {
|
|||
return t('list available Qwen Code tools. Usage: /tools [desc]');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||
const subCommand = args?.trim();
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const trustCommand: SlashCommand = {
|
|||
return t('Manage folder trust settings');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'trust',
|
||||
|
|
|
|||
|
|
@ -274,23 +274,6 @@ export type CommandSource =
|
|||
// | 'plugin-skill'
|
||||
// | 'dynamic-skill'
|
||||
|
||||
/**
|
||||
* The execution type of a slash command, describing *how* it runs.
|
||||
*
|
||||
* - prompt: Produces a submit_prompt — content is sent to the model.
|
||||
* Default supportedModes: all. Default modelInvocable: true.
|
||||
*
|
||||
* - local: Runs local logic with no React/Ink UI dependency.
|
||||
* Can return message, stream_messages, submit_prompt, tool, etc.
|
||||
* Default supportedModes: ['interactive'] — must explicitly declare
|
||||
* supportedModes to unlock other modes (mirrors Claude Code's
|
||||
* supportsNonInteractive: true pattern).
|
||||
*
|
||||
* - local-jsx: Depends on React/Ink UI (dialogs, JSX components, etc.).
|
||||
* Default supportedModes: ['interactive'] only.
|
||||
*/
|
||||
export type CommandType = 'prompt' | 'local' | 'local-jsx';
|
||||
|
||||
export interface CommandCompletionItem {
|
||||
value: string;
|
||||
label?: string;
|
||||
|
|
@ -329,17 +312,11 @@ export interface SlashCommand {
|
|||
*/
|
||||
sourceLabel?: string;
|
||||
|
||||
/**
|
||||
* How this command executes. Set by built-in command files (local/local-jsx)
|
||||
* or by Loaders (prompt). Used by getEffectiveSupportedModes() to infer
|
||||
* which execution modes are supported.
|
||||
*/
|
||||
commandType?: CommandType;
|
||||
|
||||
// ── Phase 1: mode capability ───────────────────────────────────────────
|
||||
/**
|
||||
* Which execution modes this command is available in.
|
||||
* Explicit declaration takes priority over commandType inference.
|
||||
* Explicit declaration is always authoritative. If omitted, the system falls
|
||||
* back to a conservative default based on CommandKind.
|
||||
* See getEffectiveSupportedModes() in commandUtils.ts for the full logic.
|
||||
*/
|
||||
supportedModes?: ExecutionMode[];
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const vimCommand: SlashCommand = {
|
|||
return t('toggle vim mode on/off');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (context, _args) => {
|
||||
const newVimState = await context.ui.toggleVimEnabled();
|
||||
|
||||
|
|
|
|||
|
|
@ -190,6 +190,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
!justNavigatedHistory,
|
||||
);
|
||||
|
||||
// Ref so renderLineWithHighlighting (stable useCallback) can access fresh ghost text
|
||||
const midInputGhostTextRef = useRef<{
|
||||
text: string;
|
||||
insertPosition: number;
|
||||
} | null>(null);
|
||||
midInputGhostTextRef.current = completion.midInputGhostText;
|
||||
|
||||
const reverseSearchCompletion = useReverseSearchCompletion(
|
||||
buffer,
|
||||
shellHistoryData,
|
||||
|
|
@ -803,6 +810,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// Accept mid-input ghost text with Tab (when no dropdown is visible)
|
||||
if (
|
||||
key.name === 'tab' &&
|
||||
!key.paste &&
|
||||
!key.shift &&
|
||||
!completion.showSuggestions &&
|
||||
midInputGhostTextRef.current
|
||||
) {
|
||||
buffer.insert(midInputGhostTextRef.current.text);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Attachment mode handling - process before history navigation
|
||||
if (isAttachmentMode && attachments.length > 0) {
|
||||
if (key.name === 'left') {
|
||||
|
|
@ -1136,12 +1155,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
});
|
||||
|
||||
if (isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText)) {
|
||||
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
|
||||
</Text>,
|
||||
);
|
||||
// Check for mid-input ghost text (only renders when cursor is at end of input)
|
||||
const ghostText = midInputGhostTextRef.current;
|
||||
if (ghostText && showCursorOpt && ghostText.text.length > 0) {
|
||||
// First ghost char: inverted (as cursor). Rest: dimmed gray.
|
||||
const firstChar = ghostText.text[0]!;
|
||||
const rest = ghostText.text.slice(firstChar.length);
|
||||
renderedLine.push(
|
||||
<Text key="ghost-cursor">{chalk.inverse(firstChar)}</Text>,
|
||||
);
|
||||
if (rest.length > 0) {
|
||||
renderedLine.push(
|
||||
<Text key="ghost-rest" color={theme.text.secondary}>
|
||||
{rest}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
renderedLine.push(<Text key="ghost-zwsp">{`\u200B`}</Text>);
|
||||
} else {
|
||||
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <Text>{renderedLine}</Text>;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
|||
import { BundledSkillLoader } from '../../services/BundledSkillLoader.js';
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
||||
import { SkillCommandLoader } from '../../services/SkillCommandLoader.js';
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
import { isBtwCommand } from '../utils/commandUtils.js';
|
||||
import { clearScreen } from '../../utils/stdioHelpers.js';
|
||||
|
|
@ -262,7 +263,6 @@ export const useSlashCommandProcessor = (
|
|||
);
|
||||
const commandContext = useMemo(
|
||||
(): CommandContext => ({
|
||||
executionMode: 'interactive',
|
||||
services: {
|
||||
config,
|
||||
settings,
|
||||
|
|
@ -301,6 +301,7 @@ export const useSlashCommandProcessor = (
|
|||
sessionShellAllowlist,
|
||||
startNewSession,
|
||||
},
|
||||
executionMode: 'interactive' as const,
|
||||
}),
|
||||
[
|
||||
config,
|
||||
|
|
@ -359,6 +360,7 @@ export const useSlashCommandProcessor = (
|
|||
new McpPromptLoader(config),
|
||||
new BuiltinCommandLoader(config),
|
||||
new BundledSkillLoader(config),
|
||||
new SkillCommandLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
];
|
||||
const disabled = config?.getDisabledSlashCommands() ?? [];
|
||||
|
|
@ -367,6 +369,53 @@ export const useSlashCommandProcessor = (
|
|||
controller.signal,
|
||||
disabled.length > 0 ? new Set(disabled) : undefined,
|
||||
);
|
||||
// Register model-invocable commands provider so SkillTool can include
|
||||
// bundled skills, file commands, and MCP prompts in its description.
|
||||
if (config) {
|
||||
config.setModelInvocableCommandsProvider(() =>
|
||||
commandService.getModelInvocableCommands().map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description:
|
||||
typeof cmd.description === 'string'
|
||||
? cmd.description
|
||||
: cmd.description,
|
||||
})),
|
||||
);
|
||||
// Register executor so SkillTool can actually invoke model-invocable
|
||||
// commands (e.g. MCP prompts) that are not file-based skills.
|
||||
config.setModelInvocableCommandsExecutor(
|
||||
async (name: string, args: string = '') => {
|
||||
const commands = commandService.getModelInvocableCommands();
|
||||
const cmd = commands.find((c) => c.name === name);
|
||||
if (!cmd?.action) return null;
|
||||
// Build a minimal context; submit_prompt actions only need
|
||||
// invocation + services.config, not UI state.
|
||||
const minimalContext = {
|
||||
executionMode: 'non_interactive' as const,
|
||||
invocation: {
|
||||
raw: args ? `/${name} ${args}` : `/${name}`,
|
||||
name,
|
||||
args,
|
||||
},
|
||||
services: { config, settings, git: gitService, logger: null },
|
||||
} as unknown as Parameters<typeof cmd.action>[0];
|
||||
const result = await cmd.action(minimalContext, args);
|
||||
if (!result || result.type !== 'submit_prompt') return null;
|
||||
const content = result.content;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((p) =>
|
||||
typeof p === 'string'
|
||||
? p
|
||||
: ((p as { text?: string }).text ?? ''),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
// Avoid overwriting newer results from a subsequent effect run
|
||||
if (!controller.signal.aborted) {
|
||||
setCommands(commandService.getCommandsForMode('interactive'));
|
||||
|
|
@ -381,7 +430,7 @@ export const useSlashCommandProcessor = (
|
|||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [config, reloadTrigger, isConfigInitialized]);
|
||||
}, [config, reloadTrigger, isConfigInitialized, settings, gitService]);
|
||||
|
||||
const handleSlashCommand = useCallback(
|
||||
async (
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
|||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { logicalPosToOffset } from '../components/shared/text-buffer.js';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import {
|
||||
isSlashCommand,
|
||||
findMidInputSlashCommand,
|
||||
getBestSlashCommandMatch,
|
||||
} from '../utils/commandUtils.js';
|
||||
import { toCodePoints } from '../utils/textUtils.js';
|
||||
import { useAtCompletion } from './useAtCompletion.js';
|
||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||
|
|
@ -35,6 +39,8 @@ export interface UseCommandCompletionReturn {
|
|||
navigateUp: () => void;
|
||||
navigateDown: () => void;
|
||||
handleAutocomplete: (indexToUse: number) => void;
|
||||
/** Inline ghost text for mid-input slash commands (not at line start). */
|
||||
midInputGhostText: { text: string; insertPosition: number } | null;
|
||||
}
|
||||
|
||||
export function useCommandCompletion(
|
||||
|
|
@ -186,8 +192,12 @@ export function useCommandCompletion(
|
|||
let start = completionStart;
|
||||
let end = completionEnd;
|
||||
if (completionMode === CompletionMode.SLASH) {
|
||||
start = slashCompletionRange.completionStart;
|
||||
end = slashCompletionRange.completionEnd;
|
||||
// slashCompletionRange positions are relative to the query string.
|
||||
// completionStart is the line-column offset where the query begins
|
||||
// (0 for line-start slash commands, tokenStart for mid-input tokens).
|
||||
const lineOffset = completionStart;
|
||||
start = lineOffset + slashCompletionRange.completionStart;
|
||||
end = lineOffset + slashCompletionRange.completionEnd;
|
||||
}
|
||||
|
||||
if (start === -1 || end === -1) {
|
||||
|
|
@ -228,6 +238,32 @@ export function useCommandCompletion(
|
|||
],
|
||||
);
|
||||
|
||||
// Inline ghost text for mid-input slash commands (not at line start).
|
||||
// Computed synchronously via useMemo to avoid one-frame flicker.
|
||||
const midInputGhostText = useMemo((): {
|
||||
text: string;
|
||||
insertPosition: number;
|
||||
} | null => {
|
||||
if (!active || reverseSearchActive) return null;
|
||||
const cursorOffset = logicalPosToOffset(buffer.lines, cursorRow, cursorCol);
|
||||
const midCmd = findMidInputSlashCommand(buffer.text, cursorOffset);
|
||||
if (!midCmd) return null;
|
||||
const match = getBestSlashCommandMatch(
|
||||
midCmd.partialCommand,
|
||||
slashCommands,
|
||||
);
|
||||
if (!match) return null;
|
||||
return { text: match.suffix, insertPosition: cursorOffset };
|
||||
}, [
|
||||
buffer.text,
|
||||
buffer.lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
slashCommands,
|
||||
active,
|
||||
reverseSearchActive,
|
||||
]);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
|
|
@ -241,5 +277,6 @@ export function useCommandCompletion(
|
|||
navigateUp,
|
||||
navigateDown,
|
||||
handleAutocomplete,
|
||||
midInputGhostText,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
copyToClipboard,
|
||||
getUrlOpenCommand,
|
||||
CodePage,
|
||||
findMidInputSlashCommand,
|
||||
} from './commandUtils.js';
|
||||
|
||||
// Mock child_process
|
||||
|
|
@ -487,3 +488,51 @@ describe('commandUtils', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMidInputSlashCommand', () => {
|
||||
it('returns null when input starts with / (handled by start-of-line completion)', () => {
|
||||
expect(findMidInputSlashCommand('/review', 7)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when cursor is before the slash token', () => {
|
||||
// "hello /review", cursor at position 3 (inside "hello")
|
||||
expect(findMidInputSlashCommand('hello /review', 3)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns match when cursor is exactly at the end of the token', () => {
|
||||
// "hello /re", cursor at end (offset=9)
|
||||
const result = findMidInputSlashCommand('hello /re', 9);
|
||||
expect(result).toEqual({
|
||||
token: '/re',
|
||||
startPos: 6,
|
||||
partialCommand: 're',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when cursor is inside the token (not at the end)', () => {
|
||||
// "hello /review", cursor at offset 9 (inside 'review')
|
||||
// slashPos=6, fullCommand="review"(len=6), end=13 → 9 !== 13 → null
|
||||
expect(findMidInputSlashCommand('hello /review', 9)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when cursor has moved past the token into a space', () => {
|
||||
// "hello /review ", cursor at offset 14 (after the trailing space)
|
||||
expect(findMidInputSlashCommand('hello /review ', 14)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns match for empty partial (cursor immediately after /)', () => {
|
||||
// partialCommand="" → getBestSlashCommandMatch will return null, but
|
||||
// findMidInputSlashCommand itself should return the match object
|
||||
const result = findMidInputSlashCommand('hello /', 7);
|
||||
expect(result).toEqual({
|
||||
token: '/',
|
||||
startPos: 6,
|
||||
partialCommand: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when / is not preceded by whitespace', () => {
|
||||
// "hello/review", no space before slash
|
||||
expect(findMidInputSlashCommand('hello/review', 12)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import type { SpawnOptions } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import type { SlashCommand } from '../commands/types.js';
|
||||
|
||||
/**
|
||||
* Common Windows console code pages (CP) used for encoding conversions.
|
||||
|
|
@ -183,3 +184,83 @@ export const getUrlOpenCommand = (): string => {
|
|||
}
|
||||
return openCmd;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a slash command token found mid-input (not at position 0).
|
||||
* e.g., in "hello /st", startPos=6, partialCommand="st"
|
||||
*/
|
||||
export type MidInputSlashCommand = {
|
||||
/** Full token including slash, e.g. "/st" */
|
||||
token: string;
|
||||
/** Position of the "/" in the full input string */
|
||||
startPos: number;
|
||||
/** Command portion without slash, e.g. "st" */
|
||||
partialCommand: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds a slash command token that appears mid-input (not at position 0).
|
||||
* Only triggers when the "/" is preceded by whitespace and the cursor is
|
||||
* right at or within the partial command (no text between cursor and slash).
|
||||
*
|
||||
* Returns null when input starts with "/" (handled by start-of-line completion).
|
||||
*/
|
||||
export function findMidInputSlashCommand(
|
||||
input: string,
|
||||
cursorOffset: number,
|
||||
): MidInputSlashCommand | null {
|
||||
// Start-of-line slash handled by existing dropdown completion
|
||||
if (input.startsWith('/')) return null;
|
||||
|
||||
const beforeCursor = input.slice(0, cursorOffset);
|
||||
|
||||
// Match: whitespace then "/" then optional command chars, anchored at end
|
||||
// Capture whitespace instead of lookbehind to avoid JSC JIT regression
|
||||
const match = beforeCursor.match(/\s\/([a-zA-Z0-9_:-]*)$/);
|
||||
if (!match || match.index === undefined) return null;
|
||||
|
||||
const slashPos = match.index + 1; // +1 to skip the captured whitespace char
|
||||
const textAfterSlash = input.slice(slashPos + 1);
|
||||
|
||||
// Extend to next space (or end of input) to find the full command name
|
||||
const commandMatch = textAfterSlash.match(/^[a-zA-Z0-9_:-]*/);
|
||||
const fullCommand = commandMatch ? commandMatch[0] : '';
|
||||
|
||||
// Only show ghost text when cursor is exactly at the end of the token.
|
||||
// If the cursor is inside the token or past it, return null.
|
||||
if (cursorOffset !== slashPos + 1 + fullCommand.length) return null;
|
||||
|
||||
return {
|
||||
token: '/' + fullCommand,
|
||||
startPos: slashPos,
|
||||
partialCommand: input.slice(slashPos + 1, cursorOffset),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the best (alphabetically first) prefix-matching command for a partial
|
||||
* command string. Returns the completion suffix and full command name, or null.
|
||||
*
|
||||
* e.g. partialCommand="st" → { suffix: "ats", fullCommand: "stats" }
|
||||
*/
|
||||
export function getBestSlashCommandMatch(
|
||||
partialCommand: string,
|
||||
commands: readonly SlashCommand[],
|
||||
): { suffix: string; fullCommand: string } | null {
|
||||
if (!partialCommand) return null;
|
||||
const query = partialCommand.toLowerCase();
|
||||
let best: { suffix: string; fullCommand: string } | null = null;
|
||||
for (const cmd of commands) {
|
||||
// Only suggest model-invocable commands for mid-input completion,
|
||||
// since built-in commands typed in the middle of text won't be executed.
|
||||
if (!cmd.modelInvocable) continue;
|
||||
const name = cmd.name.toLowerCase();
|
||||
if (name.startsWith(query) && name !== query) {
|
||||
const suffix = cmd.name.slice(partialCommand.length);
|
||||
if (!best || cmd.name < best.fullCommand) {
|
||||
best = { suffix, fullCommand: cmd.name };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,50 +36,44 @@ import {
|
|||
} from './nonInteractiveHelpers.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../nonInteractiveCliCommands.js', async () => {
|
||||
const { filterCommandsForMode } = await import('../services/commandUtils.js');
|
||||
return {
|
||||
getAvailableCommands: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (
|
||||
_config: unknown,
|
||||
_signal: AbortSignal,
|
||||
mode: string = 'acp',
|
||||
) => {
|
||||
// Simulate capability-based filtering with commandType / supportedModes
|
||||
// Delegate to production filterCommandsForMode to avoid logic divergence
|
||||
const allCommands = [
|
||||
{ name: 'help', commandType: 'local-jsx' },
|
||||
{ name: 'commit', commandType: 'prompt' },
|
||||
{ name: 'memory', commandType: 'local' },
|
||||
{
|
||||
name: 'init',
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
},
|
||||
{
|
||||
name: 'summary',
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
},
|
||||
{
|
||||
name: 'compress',
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
},
|
||||
];
|
||||
vi.mock('../nonInteractiveCliCommands.js', () => ({
|
||||
getAvailableCommands: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (_config: unknown, _signal: AbortSignal, mode: string = 'acp') => {
|
||||
const allCommands = [
|
||||
{
|
||||
name: 'help',
|
||||
supportedModes: ['interactive'] as const,
|
||||
},
|
||||
{
|
||||
name: 'commit',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
supportedModes: ['interactive'] as const,
|
||||
},
|
||||
{
|
||||
name: 'init',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
},
|
||||
{
|
||||
name: 'summary',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
},
|
||||
{
|
||||
name: 'compress',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return filterCommandsForMode(
|
||||
allCommands as unknown as Parameters<
|
||||
typeof filterCommandsForMode
|
||||
>[0],
|
||||
mode as Parameters<typeof filterCommandsForMode>[1],
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
return allCommands.filter((cmd) =>
|
||||
(cmd.supportedModes as readonly string[]).includes(mode),
|
||||
);
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../ui/utils/computeStats.js', () => ({
|
||||
computeSessionStats: vi.fn().mockReturnValue({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue