* feat(cli): support refreshInterval in statusLine for periodic refresh The statusLine (#3311) re-runs only when Agent state changes (token count, model, git branch, etc.). Commands that display *external* data — a clock, rate-limit counters, CI build status — have no Agent event to hook into and go stale between messages. Add an optional `ui.statusLine.refreshInterval` field (seconds, minimum 1) that schedules a setInterval alongside the existing event-driven updates. Overlap with state-change debounce is safe: `doUpdate` kills any in-flight child and bumps the generation counter, so only the most recent output reaches the footer. Validation lives in `getStatusLineConfig`: - Must be `number`, `Number.isFinite(...)`, `>= 1` - Anything else is silently dropped (no interval scheduled) No changes to the default behavior — configs without `refreshInterval` behave exactly as before. * fix(cli): yield periodic statusLine tick when previous exec is in flight Review feedback on #3383: with `refreshInterval: 1` and a command whose real exec time exceeds 1s, each tick was unconditionally calling `doUpdate()` — which kills the in-flight child and bumps the generation counter — so the prior exec's callback was always discarded as stale. `setOutput` was never reached and the statusline stayed empty until `refreshInterval` was removed or the command became faster. Guard the interval callback with an `activeChildRef` check so a pending exec is allowed to finish. State-change triggers (model switch, token count, branch, etc.) still go through `scheduleUpdate` → `doUpdate` directly and legitimately preempt stale children; only the periodic tick yields. The existing 5s exec timeout is still the hard ceiling. Also drop the redundant `'refreshInterval' in raw` check — the `typeof raw.refreshInterval === 'number'` guard already excludes missing / undefined values. Tests: - Add regression test `'skips periodic ticks while a previous exec is still running'` — three ticks during one unfinished exec trigger zero new spawns; the next tick after callback completion does spawn. - Update two existing tests to resolve the mount exec before expecting subsequent ticks (the old tests implicitly relied on the starvation behavior being tolerated). * test(cli): assert user-visible lines state in starvation regression Self-review insight: the existing `skips periodic ticks while a previous exec is still running` test only counted `exec` calls — it confirmed the guard prevents redundant spawns, but would have silently passed even if the eventual callback was still being discarded as stale (which is the actual user-visible symptom of the starvation bug). Add `expect(result.current.lines).toEqual(['done'])` after resolving the mount's pending callback. Without the guard, generationRef would have bumped 3 times during the yielded ticks, the callback's captured gen would fail the stale check, `setOutput` would never fire, and `lines` would stay empty — now caught explicitly. * perf(cli): dedupe statusLine output to skip unchanged Footer re-renders Review feedback on #3383 (narrow terminal stacking): when `refreshInterval` fires at 1s and the command output is unchanged, the mount-and-setOutput cycle still allocates a new array and triggers a Footer re-render. Under certain narrow-terminal conditions, Ink's erase-line accounting mis-counts wrapped rows and stale content accumulates on screen. The Footer-layout root cause is in #3311's narrow-mode flex setup and Ink's truncate semantics, which is out of scope for this PR. But we can cut the re-render surface here by preserving the `lines` array reference when the command produces identical output — a strict Pareto improvement for any caller (clock-style statuslines with second-precision still re-render; rate-limit / branch / CI-status style statuslines that change infrequently stop triggering work every tick). Tests: - `preserves the same lines array reference when output is unchanged` asserts referential equality after a re-exec with identical stdout. - `produces a new reference when output changes` guards against over-eager dedup that would miss legitimate updates. * fix(cli): stabilize Footer rendering in narrow terminals Narrow-terminal E2E feedback on #3383: with `refreshInterval` at 1s, empty lines were accumulating above the input prompt each tick. Root cause is in the Footer flex layout — originally from #3311 — where Ink miscounts logical rows vs the physical rows the terminal actually uses. Two adjustments, both idiomatic (used elsewhere in the repo already): 1. Left column — `minWidth={0}`. Without this, Yoga's `min-width: auto` default keeps the Box at its natural content width, so a statusline wider than the terminal doesn't engage `<Text wrap="truncate">`; the text renders at content-width and the terminal wraps it physically. `minWidth={0}` lets the column shrink so the text child can truncate at container width. 2. Right section — `flexWrap="wrap"`. With multiple indicators (sandbox label, debug badge, dream, context-usage) the row can exceed a narrow terminal's width. Without `flexWrap` Ink lays them out in a single logical row, but the terminal physically wraps to two — Ink's erase sequence (`\e[2K\e[1A…` per logical row) then clears one row while two exist, and the extra row ghosts every re-render. With `wrap` Ink tracks the second row explicitly and erases correctly. Together these make the Footer's row count match between Ink's logical view and the terminal's physical view, so frequent re-renders (as `refreshInterval` enables) stop accumulating ghost rows. Needs verification in a real narrow TTY — from this environment I can reason about the flex semantics and confirm both props are supported by Ink's Box, but actually observing ghost-row elimination requires process.stdout.columns on a real terminal. * Revert "fix(cli): stabilize Footer rendering in narrow terminals" This reverts commit9758cda85f. Reason: I could not reproduce BZ-D's reported ghost-row stacking in tmux (40x25, 2-line statusline + real exec + Static history + refreshInterval: 1) over 14+ ticks. Both `minWidth={0}` and `flexWrap="wrap"` are legitimate defensive idioms, but without a failing repro I can't verify they address the reported bug, and I shouldn't ship a speculative layout change as "the fix". Keeping the output-dedup commit (e1d321186) — that one is a strict improvement regardless of the underlying Ink behavior. Will request BZ-D's specific terminal setup and reopen with a verified fix (or confirm the issue is specific to a particular emulator, not flex/Ink).
14 KiB
Status Line
Display custom information in the footer using a shell command.
The status line lets you run a shell command whose output is displayed in the footer's left section. 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.
Single-line status (default approval mode — 1 row):
┌─────────────────────────────────────────────────────────────────┐
│ user@host ~/project (main) ctx:34% 🔒 docker | Debug | 67% │ ← status line
└─────────────────────────────────────────────────────────────────┘
Multi-line status (up to 2 lines — 2 rows):
┌─────────────────────────────────────────────────────────────────┐
│ user@host ~/project (main) ctx:34% 🔒 docker | Debug | 67% │ ← status line 1
│ ████████░░░░░░░░░░ 34% context │ ← status line 2
└─────────────────────────────────────────────────────────────────┘
Multi-line status + non-default mode (3 rows max):
┌─────────────────────────────────────────────────────────────────┐
│ user@host ~/project (main) ctx:34% 🔒 docker | Debug | 67% │ ← status line 1
│ ████████░░░░░░░░░░ 34% context │ ← status line 2
│ auto-accept edits (shift + tab to cycle) │ ← mode indicator
└─────────────────────────────────────────────────────────────────┘
When configured, the status line replaces the default "? for shortcuts" hint. High-priority messages (Ctrl+C/D exit prompts, Esc, vim INSERT mode) temporarily override the status line. The status line text is truncated to fit within the available width.
Prerequisites
jqis recommended for parsing the JSON input (install viabrew install jq,apt install jq, etc.)- Simple commands that don't need JSON data (e.g.
git branch --show-current) work withoutjq
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:
{
"ui": {
"statusLine": {
"type": "command",
"command": "input=$(cat); model=$(echo \"$input\" | jq -r '.model.display_name'); pct=$(echo \"$input\" | jq -r '.context_window.used_percentage'); echo \"$model ctx:${pct}%\""
}
}
}
| Field | Type | Required | Description |
|---|---|---|---|
type |
"command" |
Yes | Must be "command" |
command |
string | Yes | Shell command to execute. Receives JSON via stdin, stdout is displayed (up to 2 lines). |
refreshInterval |
number | No | Re-run the command every N seconds (minimum 1). Useful for data that changes without an Agent state event (clock, quota, uptime). |
JSON input
The command receives a JSON object via stdin with the following fields:
{
"session_id": "abc-123",
"version": "0.14.1",
"model": {
"display_name": "qwen-3-235b"
},
"context_window": {
"context_window_size": 131072,
"used_percentage": 34.3,
"remaining_percentage": 65.7,
"current_usage": 45000,
"total_input_tokens": 30000,
"total_output_tokens": 5000
},
"workspace": {
"current_dir": "/home/user/project"
},
"git": {
"branch": "main"
},
"metrics": {
"models": {
"qwen-3-235b": {
"api": {
"total_requests": 10,
"total_errors": 0,
"total_latency_ms": 5000
},
"tokens": {
"prompt": 30000,
"completion": 5000,
"total": 35000,
"cached": 10000,
"thoughts": 2000
}
}
},
"files": {
"total_lines_added": 120,
"total_lines_removed": 30
}
},
"vim": {
"mode": "INSERT"
}
}
| Field | Type | Description |
|---|---|---|
session_id |
string | Unique session identifier |
version |
string | Qwen Code version |
model.display_name |
string | Current model name |
context_window.context_window_size |
number | Total context window size in tokens |
context_window.used_percentage |
number | Context window usage as percentage (0–100) |
context_window.remaining_percentage |
number | Context window remaining as percentage (0–100) |
context_window.current_usage |
number | Token count from the last API call (current context size) |
context_window.total_input_tokens |
number | Total input tokens consumed this session |
context_window.total_output_tokens |
number | Total output tokens consumed this session |
workspace.current_dir |
string | Current working directory |
git |
object | absent | Present only inside a git repository. |
git.branch |
string | Current branch name |
metrics.models.<id>.api |
object | Per-model API stats: total_requests, total_errors, total_latency_ms |
metrics.models.<id>.tokens |
object | Per-model token usage: prompt, completion, total, cached, thoughts |
metrics.files |
object | File change stats: total_lines_added, total_lines_removed |
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
{
"ui": {
"statusLine": {
"type": "command",
"command": "input=$(cat); model=$(echo \"$input\" | jq -r '.model.display_name'); pct=$(echo \"$input\" | jq -r '.context_window.used_percentage'); echo \"$model ctx:${pct}%\""
}
}
}
Output: qwen-3-235b ctx:34%
Git branch + directory
{
"ui": {
"statusLine": {
"type": "command",
"command": "input=$(cat); branch=$(echo \"$input\" | jq -r '.git.branch // empty'); dir=$(basename \"$(echo \"$input\" | jq -r '.workspace.current_dir')\"); echo \"$dir${branch:+ ($branch)}\""
}
}
}
Output: my-project (main)
Note: The
git.branchfield is provided directly in the JSON input — no need to shell out togit.
File change stats
{
"ui": {
"statusLine": {
"type": "command",
"command": "input=$(cat); added=$(echo \"$input\" | jq -r '.metrics.files.total_lines_added'); removed=$(echo \"$input\" | jq -r '.metrics.files.total_lines_removed'); echo \"+$added/-$removed lines\""
}
}
}
Output: +120/-30 lines
Live clock and git branch
Use refreshInterval when the statusline shows data that changes without an Agent event (e.g. the clock, uptime, or rate-limit counters):
{
"ui": {
"statusLine": {
"type": "command",
"command": "input=$(cat); branch=$(echo \"$input\" | jq -r '.git.branch // \"no-git\"'); echo \"$(date +%H:%M:%S) ($branch)\"",
"refreshInterval": 1
}
}
}
Output (refreshed every second): 14:32:07 (main)
Script file for complex commands
For longer commands, save a script file at ~/.qwen/statusline-command.sh:
#!/bin/bash
input=$(cat)
model=$(echo "$input" | jq -r '.model.display_name')
pct=$(echo "$input" | jq -r '.context_window.used_percentage')
branch=$(echo "$input" | jq -r '.git.branch // empty')
added=$(echo "$input" | jq -r '.metrics.files.total_lines_added')
removed=$(echo "$input" | jq -r '.metrics.files.total_lines_removed')
parts=()
[ -n "$model" ] && parts+=("$model")
[ -n "$branch" ] && parts+=("($branch)")
[ "$pct" != "0" ] 2>/dev/null && parts+=("ctx:${pct}%")
([ "$added" -gt 0 ] || [ "$removed" -gt 0 ]) 2>/dev/null && parts+=("+${added}/-${removed}")
echo "${parts[*]}"
Then reference it in settings:
{
"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), vim mode is toggled, git branch changes, tool calls complete, or file changes occur. Updates are debounced (300ms). Set
refreshInterval(seconds) to additionally re-run the command on a timer — useful for data that changes without an Agent event (clock, rate limits, build status). - Timeout: Commands that take longer than 5 seconds are killed. The status line clears on failure.
- Output: Multi-line output is supported (up to 2 lines; extra lines are discarded). Each line is rendered as a separate row with dimmed colors in the footer's left section. Lines that exceed the available width are truncated.
- Hot reload: Changes to
ui.statusLinein settings take effect immediately — no restart required. - Shell: Commands run via
/bin/shon macOS/Linux. On Windows,cmd.exeis used by default — wrap POSIX commands withbash -c "..."or point to a bash script (e.g.bash ~/.qwen/statusline-command.sh). - Removal: Delete the
ui.statusLinekey from settings to disable. 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 '{"session_id":"test","version":"0.14.1","model":{"display_name":"test"},"context_window":{"context_window_size":0,"used_percentage":0,"remaining_percentage":100,"current_usage":0,"total_input_tokens":0,"total_output_tokens":0},"workspace":{"current_dir":"/tmp"},"metrics":{"models":{},"files":{"total_lines_added":0,"total_lines_removed":0}}}' | sh -c 'your_command' |
| Stale data | No trigger fired | Send a message or switch models to trigger an update — or set refreshInterval to re-run the command on a timer |
| Command too slow | Complex script | Optimize the script or move heavy work to a background cache |