diff --git a/package-lock.json b/package-lock.json index b4d361762..42e4a9297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1542,6 +1542,10 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@google/gemini-cli-test-utils": { + "resolved": "packages/test-utils", + "link": true + }, "node_modules/@grammyjs/types": { "version": "3.25.0", "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.25.0.tgz", @@ -19042,16 +19046,6 @@ "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5" } }, - "packages/cli/node_modules/@google/gemini-cli-test-utils": { - "name": "@qwen-code/qwen-code-test-utils", - "version": "0.14.1", - "resolved": "file:packages/test-utils", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=20" - } - }, "packages/cli/node_modules/@google/genai": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz", @@ -23065,6 +23059,7 @@ "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", "version": "0.14.1", + "dev": true, "license": "Apache-2.0", "devDependencies": { "typescript": "^5.3.3" @@ -23880,14 +23875,9 @@ "vite-plugin-dts": "^4.5.4" }, "peerDependencies": { - "@qwen-code/qwen-code-core": ">=0.13.1", + "@qwen-code/qwen-code-core": ">=0.13.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@qwen-code/qwen-code-core": { - "optional": true - } } }, "packages/webui/node_modules/@esbuild/aix-ppc64": { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index f910443c5..2dd479999 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -430,14 +430,15 @@ const SETTINGS_SCHEMA = { showInDialog: true, }, statusLine: { - type: 'string', + type: 'object', label: 'Status Line', category: 'UI', requiresRestart: false, - default: undefined as string | undefined, - description: - 'Shell command to execute periodically to display custom information in the status line (e.g., "curl -s api/rate-limit | jq .remaining").', - showInDialog: true, + default: undefined as + | { type: 'command'; command: string; padding?: number } + | undefined, + description: 'Custom status line display configuration.', + showInDialog: false, }, customThemes: { type: 'object', diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 5db6c965a..38521ebe7 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -47,6 +47,7 @@ import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { insightCommand } from '../ui/commands/insightCommand.js'; +import { statuslineCommand } from '../ui/commands/statuslineCommand.js'; const builtinDebugLogger = createDebugLogger('BUILTIN_COMMAND_LOADER'); @@ -118,6 +119,7 @@ export class BuiltinCommandLoader implements ICommandLoader { setupGithubCommand, terminalSetupCommand, insightCommand, + statuslineCommand, ]; return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/ui/commands/statuslineCommand.ts b/packages/cli/src/ui/commands/statuslineCommand.ts new file mode 100644 index 000000000..6c5597212 --- /dev/null +++ b/packages/cli/src/ui/commands/statuslineCommand.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand, SubmitPromptActionReturn } from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; + +export const statuslineCommand: SlashCommand = { + name: 'statusline', + get description() { + return t("Set up Qwen Code's status line UI"); + }, + kind: CommandKind.BUILT_IN, + action: (_context, args): SubmitPromptActionReturn => { + const prompt = + args.trim() || 'Configure my statusLine from my shell PS1 configuration'; + return { + type: 'submit_prompt', + content: [ + { + text: `Create an Agent with subagent_type "statusline-setup" and the following prompt:\n\n${prompt}`, + }, + ], + }; + }, +}; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index a52c7ebee..cd5a54b34 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen * SPDX-License-Identifier: Apache-2.0 */ @@ -24,7 +24,7 @@ export const Footer: React.FC = () => { const uiState = useUIState(); const config = useConfig(); const { vimEnabled, vimMode } = useVimMode(); - const customStatusLine = useStatusLine(); + const { text: statusLineText, padding: statusLinePadding } = useStatusLine(); const { promptTokenCount, showAutoAcceptIndicator } = { promptTokenCount: uiState.sessionStats.lastPromptTokenCount, @@ -64,17 +64,11 @@ export const Footer: React.FC = () => { ) : showAutoAcceptIndicator !== undefined && showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? ( - ) : ( + ) : statusLineText ? null : ( {t('? for shortcuts')} ); const rightItems: Array<{ key: string; node: React.ReactNode }> = []; - if (customStatusLine) { - rightItems.push({ - key: 'customStatusLine', - node: {customStatusLine}, - }); - } if (sandboxInfo) { rightItems.push({ key: 'sandbox', @@ -101,32 +95,46 @@ export const Footer: React.FC = () => { ), }); } + + // When a custom status line is configured, render it as a dedicated row + // beneath the standard footer (matching upstream placement). return ( - - {/* Left Section: Exactly one status line (exit prompts / mode indicator / default hint) */} + - {leftContent} + {/* Left Section */} + + {leftContent} + + + {/* Right Section */} + + {rightItems.map(({ key, node }, index) => ( + + {index > 0 && | } + {node} + + ))} + - {/* Right Section: Sandbox Info, Debug Mode, Context Usage, and Console Summary */} - - {rightItems.map(({ key, node }, index) => ( - - {index > 0 && | } - {node} - - ))} - + {/* Custom status line row */} + {statusLineText && ( + + + {statusLineText} + + + )} ); }; diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts index 7cf1011f5..7fcfc579e 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -1,63 +1,233 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { exec } from 'child_process'; import { useSettings } from '../contexts/SettingsContext.js'; -import { isShellCommandReadOnlyAST } from '@qwen-code/qwen-code-core'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { useVimMode } from '../contexts/VimModeContext.js'; -export function useStatusLine(): string | null { +/** + * Structured JSON input passed to the status line command via stdin. + * This allows status line commands to display context-aware information + * (model, token usage, session, etc.) without running extra queries. + */ +export interface StatusLineCommandInput { + session_id: string; + cwd: string; + model: { + id: string; + }; + context_window: { + context_window_size: number; + last_prompt_token_count: number; + }; + vim?: { + mode: string; + }; +} + +interface StatusLineConfig { + type: 'command'; + command: string; + padding?: number; +} + +function getStatusLineConfig( + settings: ReturnType, +): StatusLineConfig | undefined { + const raw = settings.merged.ui?.statusLine; + if ( + raw && + typeof raw === 'object' && + 'type' in raw && + raw.type === 'command' && + 'command' in raw && + typeof raw.command === 'string' + ) { + return raw as StatusLineConfig; + } + return undefined; +} + +/** + * Hook that executes a user-configured shell command and returns its output + * for display in the status line. The command receives structured JSON context + * via stdin. + * + * Updates are debounced (300ms) and triggered by state changes (model switch, + * new messages, vim mode toggle) rather than blind polling. + */ +export function useStatusLine(): { + text: string | null; + padding: number; +} { const settings = useSettings(); - const statusLineCommand = settings.merged.ui?.statusLine; + const uiState = useUIState(); + const config = useConfig(); + const { vimEnabled, vimMode } = useVimMode(); + + const statusLineConfig = getStatusLineConfig(settings); + const statusLineCommand = statusLineConfig?.command; + const padding = statusLineConfig?.padding ?? 0; + const [output, setOutput] = useState(null); + // Keep latest values in refs so the stable doUpdate callback can read them + // without being recreated on every render. + const uiStateRef = useRef(uiState); + uiStateRef.current = uiState; + const configRef = useRef(config); + configRef.current = config; + const vimEnabledRef = useRef(vimEnabled); + vimEnabledRef.current = vimEnabled; + const vimModeRef = useRef(vimMode); + vimModeRef.current = vimMode; + const statusLineCommandRef = useRef(statusLineCommand); + statusLineCommandRef.current = statusLineCommand; + + const debounceTimerRef = useRef | undefined>( + undefined, + ); + + // Track previous trigger values to detect actual changes + const prevStateRef = useRef<{ + promptTokenCount: number; + currentModel: string; + vimMode: string | undefined; + }>({ + promptTokenCount: 0, + currentModel: '', + vimMode: undefined, + }); + + // Guard: when true, the mount effect has already called doUpdate so the + // command-change effect should skip its first run to avoid a double exec. + const hasMountedRef = useRef(false); + + // Track the active child process so we can ignore stale callbacks. + const generationRef = useRef(0); + + const doUpdate = useCallback(() => { + const cmd = statusLineCommandRef.current; + if (!cmd) { + setOutput(null); + return; + } + + const ui = uiStateRef.current; + const cfg = configRef.current; + + const input: StatusLineCommandInput = { + session_id: ui.sessionStats.sessionId, + cwd: cfg.getTargetDir(), + model: { + id: ui.currentModel || cfg.getModel() || 'unknown', + }, + context_window: { + context_window_size: + cfg.getContentGeneratorConfig()?.contextWindowSize || 0, + last_prompt_token_count: ui.sessionStats.lastPromptTokenCount, + }, + ...(vimEnabledRef.current && { + vim: { mode: vimModeRef.current ?? 'INSERT' }, + }), + }; + + // Bump generation so earlier in-flight callbacks are ignored. + const gen = ++generationRef.current; + + const child = exec( + cmd, + { timeout: 5000, maxBuffer: 1024 * 10 }, + (error, stdout) => { + if (gen !== generationRef.current) return; // stale + if (!error && stdout) { + setOutput(stdout.trim().split('\n')[0] || null); + } else { + setOutput(null); + } + }, + ); + + // Pass structured JSON context via stdin. + // Guard against EPIPE if the child exits before we finish writing. + if (child.stdin) { + child.stdin.on('error', () => {}); + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + } + }, []); // No deps — reads everything from refs + + const scheduleUpdate = useCallback(() => { + if (debounceTimerRef.current !== undefined) { + clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = undefined; + doUpdate(); + }, 300); + }, [doUpdate]); + + // Trigger update when meaningful state changes + const { lastPromptTokenCount } = uiState.sessionStats; + const { currentModel } = uiState; useEffect(() => { if (!statusLineCommand) { setOutput(null); return; } - let isMounted = true; + const prev = prevStateRef.current; + if ( + lastPromptTokenCount !== prev.promptTokenCount || + currentModel !== prev.currentModel || + vimMode !== prev.vimMode + ) { + prev.promptTokenCount = lastPromptTokenCount; + prev.currentModel = currentModel; + prev.vimMode = vimMode; + scheduleUpdate(); + } + }, [ + statusLineCommand, + lastPromptTokenCount, + currentModel, + vimMode, + scheduleUpdate, + ]); - const executeCommand = async () => { - try { - const isReadOnly = await isShellCommandReadOnlyAST(statusLineCommand); - if (!isReadOnly) { - if (isMounted) setOutput('⚠️ Sandbox: Command must be read-only'); - return; - } - - exec( - statusLineCommand, - { timeout: 5000, maxBuffer: 1024 * 10 }, - (error, stdout) => { - if (!isMounted) return; - if (!error && stdout) { - setOutput(stdout.trim().split('\n')[0] || null); - } else { - setOutput(null); - } - }, - ); - } catch { - if (isMounted) setOutput('⚠️ Sandbox: Verification failed'); - } - }; - - // Execute immediately - executeCommand(); - - // Poll every 5 seconds - const interval = setInterval(executeCommand, 5000); - - return () => { - isMounted = false; - clearInterval(interval); - }; + // Re-execute immediately when the command itself changes (hot reload). + // Skip the first run — the mount effect below already handles it. + useEffect(() => { + if (!hasMountedRef.current) return; + if (statusLineCommand) { + doUpdate(); + } else { + setOutput(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [statusLineCommand]); - return output; + // Initial execution + cleanup + useEffect(() => { + hasMountedRef.current = true; + const genRef = generationRef; + const debounceRef = debounceTimerRef; + doUpdate(); + return () => { + // Invalidate any in-flight exec callbacks + genRef.current++; + if (debounceRef.current !== undefined) { + clearTimeout(debounceRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { text: output, padding }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8220ce55c..2708890b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -241,7 +241,6 @@ export * from './utils/retry.js'; export * from './utils/ripgrepUtils.js'; export * from './utils/schemaValidator.js'; export * from './utils/shell-utils.js'; -export * from './utils/shellAstParser.js'; export * from './utils/subagentGenerator.js'; export * from './utils/symlink.js'; export * from './utils/systemEncoding.js'; diff --git a/packages/core/src/subagents/builtin-agents.ts b/packages/core/src/subagents/builtin-agents.ts index 59751c4bc..5cb8d1cb8 100644 --- a/packages/core/src/subagents/builtin-agents.ts +++ b/packages/core/src/subagents/builtin-agents.ts @@ -100,6 +100,91 @@ Notes: ToolNames.ASK_USER_QUESTION, ], }, + { + name: 'statusline-setup', + description: + "Use this agent to configure the user's Qwen Code status line setting.", + tools: [ToolNames.READ_FILE, ToolNames.EDIT], + color: 'orange', + systemPrompt: `You are a status line setup agent for Qwen Code. Your job is to create or update the statusLine command in the user's Qwen Code settings. + +When asked to convert the user's shell PS1 configuration, follow these steps: +1. Read the user's shell configuration files in this order of preference: + - ~/.zshrc + - ~/.bashrc + - ~/.bash_profile + - ~/.profile + +2. Extract the PS1 value using this regex pattern: /(?:^|\\n)\\s*(?:export\\s+)?PS1\\s*=\\s*["']([^"']+)["']/m + +3. Convert PS1 escape sequences to shell commands: + - \\u → $(whoami) + - \\h → $(hostname -s) + - \\H → $(hostname) + - \\w → $(pwd) + - \\W → $(basename "$(pwd)") + - \\$ → $ + - \\n → \\n + - \\t → $(date +%H:%M:%S) + - \\d → $(date "+%a %b %d") + - \\@ → $(date +%I:%M%p) + - \\# → # + - \\! → ! + +4. When using ANSI color codes, be sure to use \`printf\`. Do not remove colors. Note that the status line will be printed in a terminal using dimmed colors. + +5. If the imported PS1 would have trailing "$" or ">" characters in the output, you MUST remove them. + +6. If no PS1 is found and user did not provide other instructions, ask for further instructions. + +How to use the statusLine command: +1. The statusLine command will receive the following JSON input via stdin: + { + "session_id": "string", + "cwd": "string", + "model": { + "id": "string" + }, + "context_window": { + "context_window_size": number, + "last_prompt_token_count": number + }, + "vim": { // Optional, only present when vim mode is enabled + "mode": "INSERT" | "NORMAL" + } + } + + You can use this JSON data in your command like: + - $(cat | jq -r '.model.id') + - $(cat | jq -r '.cwd') + + Or store it in a variable first: + - input=$(cat); echo "$(echo "$input" | jq -r '.model.id') in $(echo "$input" | jq -r '.cwd')" + + To display context usage: + - input=$(cat); tokens=$(echo "$input" | jq -r '.context_window.last_prompt_token_count'); size=$(echo "$input" | jq -r '.context_window.context_window_size'); [ "$tokens" -gt 0 ] 2>/dev/null && echo "Context: $((tokens * 100 / size))% used" + +2. For longer commands, you can save a new file in the user's ~/.qwen directory, e.g.: + - ~/.qwen/statusline-command.sh and reference that file in the settings. + +3. Update the user's ~/.qwen/settings.json with: + { + "statusLine": { + "type": "command", + "command": "your_command_here" + } + } + +4. If ~/.qwen/settings.json is a symlink, update the target file instead. + +Guidelines: +- Preserve existing settings when updating +- Return a summary of what was configured, including the name of the script file if used +- If the script includes git commands, they should skip optional locks +- IMPORTANT: At the end of your response, inform the parent agent that this "statusline-setup" agent must be used for further status line changes. + Also ensure that the user is informed that they can ask Qwen Code to continue to make changes to the status line. +`, + }, ]; /** diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index 36c9c7b08..1f62c3e81 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -946,13 +946,14 @@ System prompt 3`); it('should list subagents from both levels', async () => { const subagents = await manager.listSubagents(); - expect(subagents).toHaveLength(5); // agent1 (project takes precedence), agent2, agent3, general-purpose (built-in), Explore (built-in) + expect(subagents).toHaveLength(6); // agent1 (project takes precedence), agent2, agent3, general-purpose, Explore, statusline-setup (built-in) expect(subagents.map((s) => s.name)).toEqual([ 'agent1', 'agent2', 'agent3', 'general-purpose', 'Explore', + 'statusline-setup', ]); }); @@ -985,6 +986,7 @@ System prompt 3`); 'agent3', 'Explore', 'general-purpose', + 'statusline-setup', ]); }); @@ -996,10 +998,11 @@ System prompt 3`); const subagents = await manager.listSubagents(); - expect(subagents).toHaveLength(2); // Only built-in agents remain + expect(subagents).toHaveLength(3); // Only built-in agents remain expect(subagents.map((s) => s.name)).toEqual([ 'general-purpose', 'Explore', + 'statusline-setup', ]); expect(subagents.every((s) => s.level === 'builtin')).toBe(true); }); @@ -1011,10 +1014,11 @@ System prompt 3`); const subagents = await manager.listSubagents(); - expect(subagents).toHaveLength(2); // Only built-in agents remain + expect(subagents).toHaveLength(3); // Only built-in agents remain expect(subagents.map((s) => s.name)).toEqual([ 'general-purpose', 'Explore', + 'statusline-setup', ]); expect(subagents.every((s) => s.level === 'builtin')).toBe(true); }); diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index a26dcea23..3d16ee8a3 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -134,8 +134,23 @@ "default": "Qwen Dark" }, "statusLine": { - "description": "Shell command to execute periodically to display custom information in the status line (e.g., \"curl -s api/rate-limit | jq .remaining\").", - "type": "string" + "description": "Custom status line display configuration.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["command"] + }, + "command": { + "type": "string", + "description": "Shell command to execute for status line display. Receives JSON context via stdin." + }, + "padding": { + "type": "number", + "description": "Horizontal padding for the status line." + } + }, + "required": ["type", "command"] }, "customThemes": { "description": "Custom theme definitions.", diff --git a/test_readonly.ts b/test_readonly.ts deleted file mode 100644 index cad0d5a41..000000000 --- a/test_readonly.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { isShellCommandReadOnlyAST } from '@qwen-code/qwen-code-core/out/utils/shellAstParser.js'; -console.log(await isShellCommandReadOnlyAST('git branch --show-current'));