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( - - - -