From 6784f0c02c5ff9e822dac1da0f36dd6ca4db0dd0 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 07:10:50 +0800 Subject: [PATCH 01/19] feat(ui): add customizable status line Allow users to configure a custom shell command to display in the UI footer status line. --- package-lock.json | 22 +++++-- .../cli/src/commands/channel/pidfile.test.ts | 6 +- packages/cli/src/config/settingsSchema.ts | 10 +++ packages/cli/src/ui/components/Footer.tsx | 8 +++ packages/cli/src/ui/hooks/useStatusLine.ts | 63 +++++++++++++++++++ packages/cli/src/ui/utils/MarkdownDisplay.tsx | 8 ++- packages/cli/src/ui/utils/TableRenderer.tsx | 5 +- packages/core/src/index.ts | 1 + .../schemas/settings.schema.json | 4 ++ test_readonly.ts | 2 + 10 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useStatusLine.ts create mode 100644 test_readonly.ts diff --git a/package-lock.json b/package-lock.json index 42e4a92977..b4d3617627 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1542,10 +1542,6 @@ "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", @@ -19046,6 +19042,16 @@ "@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", @@ -23059,7 +23065,6 @@ "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" @@ -23875,9 +23880,14 @@ "vite-plugin-dts": "^4.5.4" }, "peerDependencies": { - "@qwen-code/qwen-code-core": ">=0.13.0", + "@qwen-code/qwen-code-core": ">=0.13.1", "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/commands/channel/pidfile.test.ts b/packages/cli/src/commands/channel/pidfile.test.ts index 6e0d0398ee..a7db16aa15 100644 --- a/packages/cli/src/commands/channel/pidfile.test.ts +++ b/packages/cli/src/commands/channel/pidfile.test.ts @@ -86,7 +86,7 @@ describe('writeServiceInfo + readServiceInfo', () => { writeServiceInfo(['telegram']); // Now simulate dead process - + process.kill = vi.fn(() => { throw new Error('ESRCH'); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -122,7 +122,6 @@ describe('signalService', () => { }); it('returns false when process is not found', () => { - process.kill = vi.fn(() => { throw new Error('ESRCH'); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -140,7 +139,6 @@ describe('signalService', () => { describe('waitForExit', () => { it('returns true immediately if process is already dead', async () => { - process.kill = vi.fn(() => { throw new Error('ESRCH'); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -152,7 +150,7 @@ describe('waitForExit', () => { it('returns true when process dies within timeout', async () => { let alive = true; - + process.kill = vi.fn(() => { if (!alive) throw new Error('ESRCH'); return true; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index e765dd8014..f910443c56 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -429,6 +429,16 @@ const SETTINGS_SCHEMA = { description: 'The color theme for the UI.', showInDialog: true, }, + statusLine: { + type: 'string', + 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, + }, customThemes: { type: 'object', label: 'Custom Themes', diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index af81f6a5d7..a52c7ebee9 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -13,6 +13,7 @@ import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; +import { useStatusLine } from '../hooks/useStatusLine.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; @@ -23,6 +24,7 @@ export const Footer: React.FC = () => { const uiState = useUIState(); const config = useConfig(); const { vimEnabled, vimMode } = useVimMode(); + const customStatusLine = useStatusLine(); const { promptTokenCount, showAutoAcceptIndicator } = { promptTokenCount: uiState.sessionStats.lastPromptTokenCount, @@ -67,6 +69,12 @@ export const Footer: React.FC = () => { ); const rightItems: Array<{ key: string; node: React.ReactNode }> = []; + if (customStatusLine) { + rightItems.push({ + key: 'customStatusLine', + node: {customStatusLine}, + }); + } if (sandboxInfo) { rightItems.push({ key: 'sandbox', diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts new file mode 100644 index 0000000000..7cf1011f51 --- /dev/null +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; +import { exec } from 'child_process'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { isShellCommandReadOnlyAST } from '@qwen-code/qwen-code-core'; + +export function useStatusLine(): string | null { + const settings = useSettings(); + const statusLineCommand = settings.merged.ui?.statusLine; + const [output, setOutput] = useState(null); + + useEffect(() => { + if (!statusLineCommand) { + setOutput(null); + return; + } + + let isMounted = true; + + 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); + }; + }, [statusLineCommand]); + + return output; +} diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 642b04d6b9..25e2110d96 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -111,7 +111,9 @@ const MarkdownDisplayInternal: React.FC = ({ lines[index + 1].match(tableSeparatorRegex) ) { inTable = true; - tableHeaders = tableRowMatch[1].split(/(? cell.trim().replaceAll('\\|', '|')); + tableHeaders = tableRowMatch[1] + .split(/(? cell.trim().replaceAll('\\|', '|')); tableRows = []; } else { // Not a table, treat as regular text @@ -127,7 +129,9 @@ const MarkdownDisplayInternal: React.FC = ({ // Skip separator line - already handled } else if (inTable && tableRowMatch) { // Add table row - const cells = tableRowMatch[1].split(/(? cell.trim().replaceAll('\\|', '|')); + const cells = tableRowMatch[1] + .split(/(? cell.trim().replaceAll('\\|', '|')); // Ensure row has same column count as headers while (cells.length < tableHeaders.length) { cells.push(''); diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index db3ff8a950..7684a2b710 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -36,7 +36,10 @@ export const TableRenderer: React.FC = ({ // Ensure table fits within terminal width const totalWidth = columnWidths.reduce((sum, width) => sum + width + 1, 1); const fixedWidth = columnWidths.length + 1; - const scaleFactor = totalWidth > contentWidth ? (contentWidth - fixedWidth) / (totalWidth - fixedWidth) : 1; + const scaleFactor = + totalWidth > contentWidth + ? (contentWidth - fixedWidth) / (totalWidth - fixedWidth) + : 1; const adjustedWidths = columnWidths.map((width) => Math.floor(width * scaleFactor), ); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2708890b63..8220ce55ce 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -241,6 +241,7 @@ 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/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 4f92b74d7d..a26dcea23b 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -133,6 +133,10 @@ "type": "string", "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" + }, "customThemes": { "description": "Custom theme definitions.", "type": "object", diff --git a/test_readonly.ts b/test_readonly.ts new file mode 100644 index 0000000000..cad0d5a41a --- /dev/null +++ b/test_readonly.ts @@ -0,0 +1,2 @@ +import { isShellCommandReadOnlyAST } from '@qwen-code/qwen-code-core/out/utils/shellAstParser.js'; +console.log(await isShellCommandReadOnlyAST('git branch --show-current')); From 8d85492913a21533f8c9cd7fe08dee755b814bba Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 08:04:20 +0800 Subject: [PATCH 02/19] feat(ui): rewrite customizable status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the status line feature (originally by Gemini 3.1 Pro) to align with the upstream design: - Settings: change from plain string to object `{ type, command, padding? }` - Hook: event-driven with 300ms debounce instead of 5s polling; pass structured JSON context (session, model, tokens, vim) via stdin; generation counter to ignore stale exec callbacks; EPIPE guard on stdin - Footer: render status line as dedicated row with dimColor + truncate; suppress "? for shortcuts" hint when status line is active - Add `/statusline` slash command that delegates to a statusline-setup agent - Add `statusline-setup` built-in agent with PS1 conversion instructions - Remove unrelated changes (whitespace, formatting, package-lock, test file) - Fix copyright headers (Google LLC → Qwen) - Fix config path references (~/.qwen-code → ~/.qwen) Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 22 +- packages/cli/src/config/settingsSchema.ts | 11 +- .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/ui/commands/statuslineCommand.ts | 29 +++ packages/cli/src/ui/components/Footer.tsx | 68 ++--- packages/cli/src/ui/hooks/useStatusLine.ts | 246 +++++++++++++++--- packages/core/src/index.ts | 1 - packages/core/src/subagents/builtin-agents.ts | 85 ++++++ .../src/subagents/subagent-manager.test.ts | 10 +- .../schemas/settings.schema.json | 19 +- test_readonly.ts | 2 - 11 files changed, 398 insertions(+), 97 deletions(-) create mode 100644 packages/cli/src/ui/commands/statuslineCommand.ts delete mode 100644 test_readonly.ts diff --git a/package-lock.json b/package-lock.json index b4d3617627..42e4a92977 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 f910443c56..2dd479999b 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 5db6c965a0..38521ebe71 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 0000000000..6c5597212b --- /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 a52c7ebee9..cd5a54b346 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: Sandbox Info, Debug Mode, Context Usage, and Console Summary */} - - {rightItems.map(({ key, node }, index) => ( - - {index > 0 && | } - {node} - - ))} + {/* Right Section */} + + {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 7cf1011f51..7fcfc579ee 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); - useEffect(() => { - if (!statusLineCommand) { + // 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; } - let isMounted = true; + 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' }, + }), + }; - const executeCommand = async () => { - try { - const isReadOnly = await isShellCommandReadOnlyAST(statusLineCommand); - if (!isReadOnly) { - if (isMounted) setOutput('⚠️ Sandbox: Command must be read-only'); - return; + // 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); } + }, + ); - 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'); - } - }; + // 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; + } - // Execute immediately - executeCommand(); + 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, + ]); - // Poll every 5 seconds - const interval = setInterval(executeCommand, 5000); + // 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]); + // Initial execution + cleanup + useEffect(() => { + hasMountedRef.current = true; + const genRef = generationRef; + const debounceRef = debounceTimerRef; + doUpdate(); return () => { - isMounted = false; - clearInterval(interval); + // Invalidate any in-flight exec callbacks + genRef.current++; + if (debounceRef.current !== undefined) { + clearTimeout(debounceRef.current); + } }; - }, [statusLineCommand]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - return output; + return { text: output, padding }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8220ce55ce..2708890b63 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 59751c4bc4..5cb8d1cb8f 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 36c9c7b084..1f62c3e81b 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 a26dcea23b..3d16ee8a35 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 cad0d5a41a..0000000000 --- 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')); From 959690b89774d77ac40a9ac8a645fd3a96388f8b Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 08:19:03 +0800 Subject: [PATCH 03/19] fix: regenerate settings.schema.json via generate:settings-schema The hand-edited schema didn't match the auto-generated output. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../schemas/settings.schema.json | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 3d16ee8a35..3ae4949cd0 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -136,21 +136,7 @@ "statusLine": { "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"] + "additionalProperties": true }, "customThemes": { "description": "Custom theme definitions.", From be13adb6e70a2b45813c9cf6d2127174195ece53 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 11:32:21 +0800 Subject: [PATCH 04/19] fix: add SettingsContext to Footer tests useStatusLine hook requires SettingsContext, which was missing from the test render wrapper, causing all Footer tests to crash. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/components/Footer.test.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index f2b759e69b..ce6a807af0 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -11,6 +11,7 @@ import * as useTerminalSize from '../hooks/useTerminalSize.js'; import { type UIState, UIStateContext } from '../contexts/UIStateContext.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; import { VimModeProvider } from '../contexts/VimModeContext.js'; +import { SettingsContext } from '../contexts/SettingsContext.js'; import type { LoadedSettings } from '../../config/settings.js'; vi.mock('../hooks/useTerminalSize.js'); @@ -52,14 +53,17 @@ const createMockSettings = (): LoadedSettings => const renderWithWidth = (width: number, uiState: UIState) => { useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 }); + const mockSettings = createMockSettings(); return render( - - - -