diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md
index 9389ba8f5f..761dd5f92a 100644
--- a/docs/users/configuration/settings.md
+++ b/docs/users/configuration/settings.md
@@ -98,6 +98,7 @@ Settings are organized into categories. All settings should be placed within the
| --------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` |
| `ui.customThemes` | object | Custom theme definitions. | `{}` |
+| `ui.statusLine` | object | Custom status line configuration. A shell command whose output is shown below the footer. See [Status Line](../features/status-line). | `undefined` |
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
| `ui.hideBanner` | boolean | Hide the application banner. | `false` |
diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts
index 4c793f589c..ddb89270cd 100644
--- a/docs/users/features/_meta.ts
+++ b/docs/users/features/_meta.ts
@@ -16,5 +16,6 @@ export default {
language: 'i18n',
channels: 'Channels',
hooks: 'Hooks',
+ 'status-line': 'Status Line',
'scheduled-tasks': 'Scheduled Tasks',
};
diff --git a/docs/users/features/status-line.md b/docs/users/features/status-line.md
new file mode 100644
index 0000000000..79b8c81f2f
--- /dev/null
+++ b/docs/users/features/status-line.md
@@ -0,0 +1,174 @@
+# Status Line
+
+> Display custom information beneath the footer using a shell command.
+
+The status line lets you run a shell command whose output is displayed as a persistent line below the footer bar. The command receives structured JSON context via stdin, so it can show session-aware information like the current model, token usage, git branch, or anything else you can script.
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ ? for shortcuts 🔒 docker | Debug | ◼◼◼◻ 67% │
+├─────────────────────────────────────────────────────────────────┤
+│ user@host ~/project (main) qwen-3-235b ctx:34% │ ← status line
+└─────────────────────────────────────────────────────────────────┘
+```
+
+## Prerequisites
+
+- [`jq`](https://jqlang.github.io/jq/) is recommended for parsing the JSON input (install via `brew install jq`, `apt install jq`, etc.)
+- Simple commands that don't need JSON data (e.g. `git branch --show-current`) work without `jq`
+
+## Quick setup
+
+The easiest way to configure a status line is the `/statusline` command. It launches a setup agent that reads your shell PS1 configuration and generates a matching status line:
+
+```
+/statusline
+```
+
+You can also give it specific instructions:
+
+```
+/statusline show model name and context usage percentage
+```
+
+## Manual configuration
+
+Add a `statusLine` object under the `ui` key in `~/.qwen/settings.json`:
+
+```json
+{
+ "ui": {
+ "statusLine": {
+ "type": "command",
+ "command": "input=$(cat); model=$(echo \"$input\" | jq -r '.model.id'); tokens=$(echo \"$input\" | jq -r '.context_window.last_prompt_token_count'); echo \"$model ctx:$tokens\"",
+ "padding": 0
+ }
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| --------- | ----------- | -------- | ------------------------------------------------------------------------------------- |
+| `type` | `"command"` | Yes | Must be `"command"` |
+| `command` | string | Yes | Shell command to execute. Receives JSON via stdin, first line of stdout is displayed. |
+| `padding` | number | No | Horizontal padding (default: `0`) |
+
+## JSON input
+
+The command receives a JSON object via stdin with the following fields:
+
+```json
+{
+ "session_id": "abc-123",
+ "cwd": "/home/user/project",
+ "model": {
+ "id": "qwen-3-235b"
+ },
+ "context_window": {
+ "context_window_size": 131072,
+ "last_prompt_token_count": 45000
+ },
+ "vim": {
+ "mode": "INSERT"
+ }
+}
+```
+
+| Field | Type | Description |
+| ---------------------------------------- | ---------------- | ---------------------------------------------------------------------------------- |
+| `session_id` | string | Unique session identifier |
+| `cwd` | string | Current working directory |
+| `model.id` | string | Current model identifier |
+| `context_window.context_window_size` | number | Total context window size |
+| `context_window.last_prompt_token_count` | number | Tokens used in the last prompt |
+| `vim` | object \| absent | Present only when vim mode is enabled. Contains `mode` (`"INSERT"` or `"NORMAL"`). |
+
+> **Important:** stdin can only be read once. Always store it in a variable first: `input=$(cat)`.
+
+## Examples
+
+### Model and token usage
+
+```json
+{
+ "ui": {
+ "statusLine": {
+ "type": "command",
+ "command": "input=$(cat); model=$(echo \"$input\" | jq -r '.model.id'); tokens=$(echo \"$input\" | jq -r '.context_window.last_prompt_token_count'); size=$(echo \"$input\" | jq -r '.context_window.context_window_size'); pct=$((tokens * 100 / (size > 0 ? size : 1))); echo \"$model ctx:${pct}%\""
+ }
+ }
+}
+```
+
+Output: `qwen-3-235b ctx:34%`
+
+### Git branch + directory
+
+```json
+{
+ "ui": {
+ "statusLine": {
+ "type": "command",
+ "command": "branch=$(git branch --show-current 2>/dev/null); dir=$(basename \"$PWD\"); echo \"$dir${branch:+ ($branch)}\""
+ }
+ }
+}
+```
+
+Output: `my-project (main)`
+
+> Note: `git` and `pwd` run in the workspace directory automatically.
+
+### Script file for complex commands
+
+For longer commands, save a script file at `~/.qwen/statusline-command.sh`:
+
+```bash
+#!/bin/bash
+input=$(cat)
+model=$(echo "$input" | jq -r '.model.id')
+tokens=$(echo "$input" | jq -r '.context_window.last_prompt_token_count')
+size=$(echo "$input" | jq -r '.context_window.context_window_size')
+branch=$(git branch --show-current 2>/dev/null)
+
+parts=()
+[ -n "$model" ] && parts+=("$model")
+[ -n "$branch" ] && parts+=("($branch)")
+if [ "$tokens" -gt 0 ] && [ "$size" -gt 0 ] 2>/dev/null; then
+ pct=$((tokens * 100 / size))
+ parts+=("ctx:${pct}%")
+fi
+
+echo "${parts[*]}"
+```
+
+Then reference it in settings:
+
+```json
+{
+ "ui": {
+ "statusLine": {
+ "type": "command",
+ "command": "bash ~/.qwen/statusline-command.sh"
+ }
+ }
+}
+```
+
+## Behavior
+
+- **Update triggers**: The status line updates when the model changes, a new message is sent (token count changes), or vim mode is toggled. Updates are debounced (300ms).
+- **Timeout**: Commands that take longer than 5 seconds are killed. The status line clears on failure.
+- **Output**: Only the first line of stdout is used. The text is rendered with dimmed colors and truncated to terminal width.
+- **Hot reload**: Changes to `ui.statusLine` in settings take effect immediately — no restart required.
+- **Shell**: Commands run via `/bin/sh` on macOS/Linux. On Windows, `cmd.exe` is used by default — wrap POSIX commands with `bash -c "..."` or point to a bash script (e.g. `bash ~/.qwen/statusline-command.sh`).
+- **Removal**: Delete the `ui.statusLine` key from settings to disable. The status line disappears and the "? for shortcuts" hint returns.
+
+## Troubleshooting
+
+| Problem | Cause | Fix |
+| ----------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Status line not showing | Config at wrong path | Must be under `ui.statusLine`, not root-level `statusLine` |
+| Empty output | Command fails silently | Test manually: `echo '{"model":{"id":"test"},"cwd":"/tmp","context_window":{"context_window_size":1,"last_prompt_token_count":0}}' \| sh -c 'your_command'` |
+| Stale data | No trigger fired | Send a message or switch models to trigger an update |
+| Command too slow | Complex script | Optimize the script or move heavy work to a background cache |
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 cb6a9a35f3..93e5e5121a 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -429,6 +429,17 @@ const SETTINGS_SCHEMA = {
description: 'The color theme for the UI.',
showInDialog: true,
},
+ statusLine: {
+ type: 'object',
+ label: 'Status Line',
+ category: 'UI',
+ requiresRestart: false,
+ default: undefined as
+ | { type: 'command'; command: string; padding?: number }
+ | undefined,
+ description: 'Custom status line display configuration.',
+ showInDialog: false,
+ },
customThemes: {
type: 'object',
label: 'Custom Themes',
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..5e0f1f110f
--- /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: `Use the Agent tool with subagent_type: "statusline-setup" and this prompt:\n\n${prompt}`,
+ },
+ ],
+ };
+ },
+};
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(
-
-
-
-
-
-
- ,
+
+
+
+
+
+
+
+
+ ,
);
};
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index 0305d0320f..f22f32c94a 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
*/
@@ -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';
@@ -24,6 +25,7 @@ export const Footer: React.FC = () => {
const uiState = useUIState();
const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
+ const { text: statusLineText, padding: statusLinePadding } = useStatusLine();
const { verboseMode } = useVerboseMode();
const { promptTokenCount, showAutoAcceptIndicator } = {
@@ -50,7 +52,9 @@ export const Footer: React.FC = () => {
const contextWindowSize =
config.getContentGeneratorConfig()?.contextWindowSize;
- // Left section should show exactly ONE thing at any time, in priority order.
+ // Left section shows one item in priority order. When a custom status line
+ // is active, only the default "? for shortcuts" hint is suppressed because
+ // the status line occupies its own row below.
const leftContent = uiState.ctrlCPressedOnce ? (
{t('Press Ctrl+C again to exit.')}
) : uiState.ctrlDPressedOnce ? (
@@ -64,7 +68,7 @@ export const Footer: React.FC = () => {
) : showAutoAcceptIndicator !== undefined &&
showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? (
- ) : (
+ ) : statusLineText ? null : (
{t('? for shortcuts')}
);
@@ -101,32 +105,46 @@ export const Footer: React.FC = () => {
node: {t('verbose')},
});
}
+
+ // 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 — match footer's horizontal inset */}
+ {statusLineText && (
+
+
+ {statusLineText}
+
+
+ )}
);
};
diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts
new file mode 100644
index 0000000000..cec5686a37
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useStatusLine.ts
@@ -0,0 +1,274 @@
+/**
+ * @license
+ * Copyright 2025 Qwen
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { exec, type ChildProcess } from 'child_process';
+import { useSettings } from '../contexts/SettingsContext.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useConfig } from '../contexts/ConfigContext.js';
+import { useVimMode } from '../contexts/VimModeContext.js';
+
+/**
+ * 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'
+ ) {
+ const config: StatusLineConfig = {
+ type: 'command',
+ command: raw.command,
+ };
+ if (
+ 'padding' in raw &&
+ typeof raw.padding === 'number' &&
+ Number.isFinite(raw.padding)
+ ) {
+ config.padding = Math.max(0, raw.padding);
+ }
+ return config;
+ }
+ 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 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.
+ // Initialized with current values so the state-change effect
+ // does not fire redundantly on mount.
+ const { lastPromptTokenCount } = uiState.sessionStats;
+ const { currentModel } = uiState;
+ const effectiveVim = vimEnabled ? vimMode : undefined;
+ const prevStateRef = useRef<{
+ promptTokenCount: number;
+ currentModel: string;
+ effectiveVim: string | undefined;
+ }>({
+ promptTokenCount: lastPromptTokenCount,
+ currentModel,
+ effectiveVim,
+ });
+
+ // 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 kill it on new updates / unmount.
+ const activeChildRef = useRef(undefined);
+ 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' },
+ }),
+ };
+
+ // Kill the previous child process if still running.
+ if (activeChildRef.current) {
+ activeChildRef.current.kill();
+ activeChildRef.current = undefined;
+ }
+
+ // Bump generation so earlier in-flight callbacks are ignored.
+ const gen = ++generationRef.current;
+
+ const child = exec(
+ cmd,
+ { cwd: cfg.getTargetDir(), timeout: 5000, maxBuffer: 1024 * 10 },
+ (error, stdout) => {
+ if (gen !== generationRef.current) return; // stale
+ activeChildRef.current = undefined;
+ if (!error && stdout) {
+ // Strip only the trailing newline to preserve intentional whitespace.
+ const line = stdout.replace(/\r?\n$/, '').split(/\r?\n/, 1)[0];
+ setOutput(line || null);
+ } else {
+ setOutput(null);
+ }
+ },
+ );
+
+ activeChildRef.current = child;
+
+ // 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
+ useEffect(() => {
+ if (!statusLineCommand) {
+ // Command removed — kill any in-flight process and discard callbacks.
+ activeChildRef.current?.kill();
+ activeChildRef.current = undefined;
+ generationRef.current++;
+ if (debounceTimerRef.current !== undefined) {
+ clearTimeout(debounceTimerRef.current);
+ debounceTimerRef.current = undefined;
+ }
+ setOutput(null);
+ return;
+ }
+
+ const prev = prevStateRef.current;
+ if (
+ lastPromptTokenCount !== prev.promptTokenCount ||
+ currentModel !== prev.currentModel ||
+ effectiveVim !== prev.effectiveVim
+ ) {
+ prev.promptTokenCount = lastPromptTokenCount;
+ prev.currentModel = currentModel;
+ prev.effectiveVim = effectiveVim;
+ scheduleUpdate();
+ }
+ }, [
+ statusLineCommand,
+ lastPromptTokenCount,
+ currentModel,
+ effectiveVim,
+ scheduleUpdate,
+ ]);
+
+ // 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) {
+ // Clear any pending debounce so we don't get a redundant second run.
+ if (debounceTimerRef.current !== undefined) {
+ clearTimeout(debounceTimerRef.current);
+ debounceTimerRef.current = undefined;
+ }
+ doUpdate();
+ }
+ // Cleanup when command is removed is handled by the state-change effect.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [statusLineCommand]);
+
+ // Initial execution + cleanup
+ useEffect(() => {
+ hasMountedRef.current = true;
+ const genRef = generationRef;
+ const debounceRef = debounceTimerRef;
+ const childRef = activeChildRef;
+ doUpdate();
+ return () => {
+ // Kill active child process and invalidate callbacks
+ childRef.current?.kill();
+ childRef.current = undefined;
+ 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/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/subagents/builtin-agents.ts b/packages/core/src/subagents/builtin-agents.ts
index 59751c4bc4..ed0110351e 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, ToolNames.ASK_USER_QUESTION],
+ 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 → (remove or replace with a space — the status line only displays one line)
+ - \\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"
+ }
+ }
+
+ IMPORTANT: stdin can only be consumed once. Always read it into 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. The statusLine setting is nested under the "ui" key:
+ {
+ "ui": {
+ "statusLine": {
+ "type": "command",
+ "command": "your_command_here"
+ }
+ }
+ }
+ Make sure to preserve any existing "ui" settings (theme, etc.) when updating.
+
+4. If ~/.qwen/settings.json is a symlink, update the target file instead.
+
+Guidelines:
+- The status line only displays the first line of stdout — ensure commands produce exactly one line of output
+- 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 1143813e4c..3e04017ff0 100644
--- a/packages/vscode-ide-companion/schemas/settings.schema.json
+++ b/packages/vscode-ide-companion/schemas/settings.schema.json
@@ -133,6 +133,11 @@
"type": "string",
"default": "Qwen Dark"
},
+ "statusLine": {
+ "description": "Custom status line display configuration.",
+ "type": "object",
+ "additionalProperties": true
+ },
"customThemes": {
"description": "Custom theme definitions.",
"type": "object",
diff --git a/packages/webui/src/hooks/useFollowupSuggestions.ts b/packages/webui/src/hooks/useFollowupSuggestions.ts
index ee5f010036..607990484b 100644
--- a/packages/webui/src/hooks/useFollowupSuggestions.ts
+++ b/packages/webui/src/hooks/useFollowupSuggestions.ts
@@ -112,7 +112,6 @@ function createFollowupController(
suggestion_length: text.length,
});
} catch (e: unknown) {
-
console.error('[followup] onOutcome callback threw:', e);
}
@@ -122,7 +121,6 @@ function createFollowupController(
try {
getOnAccept?.()?.(text);
} catch (error: unknown) {
-
console.error('[followup] onAccept callback threw:', error);
} finally {
if (acceptTimeoutId) {
@@ -154,7 +152,6 @@ function createFollowupController(
suggestion_length: currentState.suggestion.length,
});
} catch (e: unknown) {
-
console.error('[followup] onOutcome callback threw:', e);
}
}