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'));