feat(tui): add native status line template system#13885
Conversation
bd1113d to
6157cdd
Compare
There was a problem hiding this comment.
Pull request overview
This PR implements a native status line template system for the TUI that displays project info, session data, model/token stats, and custom shell command output in the terminal title and TUI footers. The feature addresses issue #8619 by providing a zero-LLM-cost alternative to the plugin breadcrumb approach, which was consuming ~30 tokens per message by injecting display-only content into the conversation context.
Changes:
- Added server-side template resolution system with built-in variables (project/session/model data), format specs (basename, k, bar, time), and shell command execution
- Introduced new API endpoint
/tui/statuslinefor polling resolved templates with configurable intervals - Extended plugin hook system with
tui.statusLine.variablesfor custom variable injection - Integrated status line display into TUI components (terminal title, session footer, home footer)
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/sdk/js/src/v2/gen/types.gen.ts | Generated TypeScript types for status line config and API response |
| packages/sdk/js/src/v2/gen/sdk.gen.ts | Generated SDK client method for /tui/statusline endpoint |
| packages/plugin/src/index.ts | Added tui.statusLine.variables hook for plugin-provided variables |
| packages/opencode/test/statusline/statusline.test.ts | Unit tests for format specs, template resolution, and shell command execution |
| packages/opencode/src/statusline/index.ts | Core implementation: variable builtins, shell commands, template resolution, format specs |
| packages/opencode/src/server/routes/tui.ts | API route handler for /tui/statusline endpoint |
| packages/opencode/src/config/config.ts | Zod schema validation for status_line config with template targets, interval, and commands |
| packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | Added status line footer display in session view |
| packages/opencode/src/cli/cmd/tui/routes/home.tsx | Replaced default home footer with status line template when configured |
| packages/opencode/src/cli/cmd/tui/context/statusline.tsx | SolidJS context provider for polling and managing status line state |
| packages/opencode/src/cli/cmd/tui/app.tsx | Integrated StatusLineProvider and terminal title updates |
| docs/statusline.md | Comprehensive documentation of configuration, syntax, variables, and examples |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| body?: never | ||
| path?: never | ||
| query?: { | ||
| directory?: string |
There was a problem hiding this comment.
The API type definition includes a directory query parameter that is not used in the route implementation. The route only validates and uses sessionID. This unused parameter should be removed from the type definition to avoid confusion.
| directory?: string |
There was a problem hiding this comment.
directory is a global query parameter applied by middleware in server.ts (line 226) — it appears on every SDK method, not just this one. It scopes the request to the correct project instance. No change needed.
| */ | ||
| public statusline<ThrowOnError extends boolean = false>( | ||
| parameters?: { | ||
| directory?: string |
There was a problem hiding this comment.
The directory parameter is defined in the SDK method but is never used by the API endpoint. The server-side route at /tui/statusline only validates and uses sessionID. This parameter should be removed from the SDK method signature to match the actual API implementation.
There was a problem hiding this comment.
Same as above — directory is global middleware, present on all SDK methods. Not specific to this endpoint.
| const start = () => { | ||
| if (timer) clearInterval(timer) | ||
| poll() | ||
| timer = setInterval(poll, frequency() * 1000) | ||
| } |
There was a problem hiding this comment.
The start function uses the current value of frequency() when setting up the interval, but if the interval changes after the first poll (via setFrequency), the timer won't be updated to use the new interval. The timer continues using the old frequency value until start is called again. Consider restarting the timer when the frequency changes, or use a different approach to ensure the interval is dynamically updated.
There was a problem hiding this comment.
The frequency only changes once (first poll sets it from the default to the configured value). After that, start() is re-invoked on every session change via the createEffect, which restarts the timer with the current frequency. A dynamic timer update mechanism would add complexity without practical benefit.
| * Template strings per display target with {variable:format} placeholders | ||
| */ | ||
| templates?: { | ||
| [key: string]: string |
There was a problem hiding this comment.
The config schema in config.ts enforces specific template keys using z.enum(["terminal_title", "session_footer", "home_footer"]), but the generated type definition allows any string key. This creates a discrepancy where the TypeScript types are less strict than the runtime validation. The generated types should reflect the same constraint to provide better type safety and IDE support.
| [key: string]: string | |
| terminal_title?: string | |
| session_footer?: string | |
| home_footer?: string |
There was a problem hiding this comment.
The SDK types are auto-generated by ./packages/sdk/js/script/build.ts. z.record(z.enum([...]), z.string()) maps to { [key: string]: string } in OpenAPI 3.1. Hand-editing generated files would be overwritten on next regeneration.
📖 StatusLine Template System — Reference DocumentationConfigurationAdd {
"tui": {
"status_line": {
"templates": {
"terminal_title": "opencode | {project_name} ({shell:branch})",
"session_footer": "{model_id} | {total_tokens:k} tokens | ${total_cost}",
"home_footer": "{cwd:basename} on {shell:branch}",
},
"interval": 5,
"commands": {
"branch": "git branch --show-current",
"short_cwd": "echo $PWD | sed \"s|$HOME|~|\" | rev | cut -d/ -f1-3 | rev",
},
},
},
}Config Fields
Display Targets
Template SyntaxTemplates use Built-in VariablesAlways available: Session-only: Format Specs
Shell CommandsCommands run via "commands": {
"branch": "git branch --show-current",
"dirty": "git diff-index --quiet HEAD -- 2>/dev/null && [ -z \"$(git ls-files --others --exclude-standard | head -1)\" ] && echo '' || echo '*'",
"short_path": "echo $PWD | sed \"s|$HOME|~|\" | awk -F/ '{for(i=1;i<NF;i++) printf substr($i,1,1)\"/\"; print $NF}'",
"wt_path": "toplevel=$(git rev-parse --show-toplevel 2>/dev/null); main=$(git worktree list 2>/dev/null | head -1 | awk '{print $1}'); if [ \"$toplevel\" != \"$main\" ] && echo \"$toplevel\" | grep -q '.worktrees\\|.worktree'; then echo \"$(basename $main)/.w/$(basename $toplevel)\"; else basename \"${toplevel:-$PWD}\"; fi",
"changes": "git diff HEAD --numstat 2>/dev/null | awk '{a+=$1;r+=$2} END {if(a+r>0) printf \"+%d/-%d\",a,r; else print \"\"}'",
"stashes": "git stash list 2>/dev/null | wc -l | tr -d ' '",
"ahead_behind": "git rev-list --left-right --count HEAD...@{u} 2>/dev/null | awk '{if($1>0||$2>0) printf \"+%s -%s\",$1,$2; else print \"=\"}'"
}Plugin Variablesplugin.hook("tui.statusLine.variables", async (input, result) => {
result.variables.my_var = "custom value"
return result
})Full Example{
"tui": {
"status_line": {
"templates": {
"terminal_title": "{shell:wt_path} ({shell:branch}{shell:dirty}) [{model_id}]",
"session_footer": "ctx {context_used_pct:bar10} {context_used_pct}% | {total_tokens:k} tok | ${total_cost} | {session_duration:%H:%M:%S}",
"home_footer": "{shell:short_path} on {shell:branch} {shell:changes}",
},
"interval": 5,
"commands": {
"branch": "git branch --show-current",
"dirty": "git diff-index --quiet HEAD -- 2>/dev/null && [ -z \"$(git ls-files --others --exclude-standard | head -1)\" ] && echo '' || echo '*'",
"wt_path": "toplevel=$(git rev-parse --show-toplevel 2>/dev/null); main=$(git worktree list 2>/dev/null | head -1 | awk '{print $1}'); if [ \"$toplevel\" != \"$main\" ] && echo \"$toplevel\" | grep -q '.worktrees\\|.worktree'; then echo \"$(basename $main)/.w/$(basename $toplevel)\"; else basename \"${toplevel:-$PWD}\"; fi",
"short_path": "echo $PWD | sed \"s|$HOME|~|\" | awk -F/ '{for(i=1;i<NF;i++) printf substr($i,1,1)\"/\"; print $NF}'",
"changes": "git diff HEAD --numstat 2>/dev/null | awk '{a+=$1;r+=$2} END {if(a+r>0) printf \"+%d/-%d\",a,r; else print \"\"}'",
"stashes": "git stash list 2>/dev/null | wc -l | tr -d ' '",
},
},
},
} |
|
Based on the interest in visual formatting from #8619 (left/right segments, multi-line, colors): The current implementation resolves templates to plain strings. This works well for content, but makes it impossible for a rendering layer to style individual segments (color, alignment, truncation) — the resolved output is just If visual formatting is desired in the future, // Current: resolve() → string
"main | 15k tok | $0.02"
// Future: resolve() → Segment[]
[
{ value: "main", variable: "shell:branch" },
{ value: " | ", literal: true },
{ value: "15k", variable: "total_tokens", spec: "k" },
{ value: " tok | $", literal: true },
{ value: "0.02", variable: "total_cost" },
]This would let a renderer do things like:
The change would be localized to Not proposing this for this PR — just noting the path forward if the team wants richer visual control. |
|
I would also like to display some custom status on the TUI, hoping to merge. |
|
This is a really well-designed approach. The template system with built-in variables + shell commands + plugin data covers the use cases I was trying to address with a simpler A few observations from trying to build something similar: Plugin data integration is the key differentiator. The existing token-tracker and tps-meter plugins use The zero-LLM-cost point is important. I was using a plugin that injected breadcrumbs into the system prompt for context tracking. Your approach eliminates that entirely. One question: does the template resolver handle reactive updates during streaming? For tok/s display, the value needs to update multiple times per second while the model is generating. If the resolver runs on a timer or event-driven basis, what's the refresh cadence? |
|
Following up on my earlier comment. I filed #18969 proposing a I was planning to submit a PR for the plugin hook, but I'm going to hold off and see how this PR progresses. If the template system lands with plugin data support, #18969 becomes unnecessary. If it doesn't include plugin data or stalls, I'll pick up the simpler hook approach. Either way, the core need is the same: plugins need a way to surface persistent status in the footer without toast spam. |
Fixes anomalyco#8619 Users define per-target template strings in config (tui.status_line) resolved from (1) static built-in variables, (2) shell commands, and (3) plugin data. Display targets: terminal_title, session_footer, home_footer. Replaces the plugin breadcrumb approach without LLM calls. - StatusLine resolver with format specs (:basename, :bar, :k, time) - GET /tui/statusline endpoint with configurable polling interval - Plugin hook: tui.statusLine.variables - 24 unit tests
After merging dev, TUI settings moved from Config.Info to a dedicated tui.json (TuiConfig). The statusline module still read from the old Config.get().tui path which no longer exists, causing typecheck failures.
23d5025 to
440bb26
Compare
|
Hey @andrewdunndev @FurryWolfX — thanks for the interest! I've rebased the branch onto current dev and fixed the CI failures. Changes:
All green now. Also cc @marcusquinn — this relates to #18969 as an alternative approach for persistent status display via templates. |
|
sounds great! 🫡 |
|
This PR would also solve an important background-agent observability use case if Concrete example: a background delegation plugin can expose variables like:
Then users could compose their own footer: {
"status_line": {
"templates": {
"session_footer": "{model_id} | {plugin:bg_status} | ctx {context_used_pct}%"
}
}
}The key requirement is that plugin-provided variables remain UI-only/zero-context and are refreshed often enough for runtime state like active background agents. If that holds, the template approach seems better than a bespoke footer-item API because users control layout while plugins only provide data. |
What does this PR do?
Fixes #8619
Adds a native status line template system. Users define per-target template strings in config (
tui.status_line.templates) that are resolved server-side from built-in variables (project info, session data, model/token stats), shell commands, and plugin-provided data. Display targets:terminal_title,session_footer,home_footer.This replaces the plugin breadcrumb approach (which costs ~30 tokens/message and pollutes chat context) with a zero-LLM-cost alternative.
How did you verify your code works?
tsgo --noEmitclean across all 17 packagesGET /tui/statuslinereturns resolved templates