Skip to content

feat(tui): add native status line template system#13885

Open
diegomarino wants to merge 6 commits into
anomalyco:devfrom
diegomarino:feat/statusline-template
Open

feat(tui): add native status line template system#13885
diegomarino wants to merge 6 commits into
anomalyco:devfrom
diegomarino:feat/statusline-template

Conversation

@diegomarino
Copy link
Copy Markdown

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?

  • 24 unit tests covering resolver, format specs, and shell command execution
  • Full test suite passes (1008 pass, 0 fail, 5 skip)
  • tsgo --noEmit clean across all 17 packages
  • Build passes across all 11 platform targets
  • Manual TUI verification: terminal title, session footer, and home footer all rendering correctly
  • API endpoint verified via curl: GET /tui/statusline returns resolved templates

Copilot AI review requested due to automatic review settings February 16, 2026 19:44
@diegomarino diegomarino force-pushed the feat/statusline-template branch from bd1113d to 6157cdd Compare February 16, 2026 19:47
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/statusline for polling resolved templates with configurable intervals
  • Extended plugin hook system with tui.statusLine.variables for 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
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
directory?: string

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above — directory is global middleware, present on all SDK methods. Not specific to this endpoint.

Comment on lines +24 to +28
const start = () => {
if (timer) clearInterval(timer)
poll()
timer = setInterval(poll, frequency() * 1000)
}
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/opencode/src/cli/cmd/tui/context/statusline.tsx
Comment thread packages/sdk/js/src/v2/gen/types.gen.ts Outdated
* Template strings per display target with {variable:format} placeholders
*/
templates?: {
[key: string]: string
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
[key: string]: string
terminal_title?: string
session_footer?: string
home_footer?: string

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/opencode/test/statusline/statusline.test.ts Outdated
Comment thread packages/opencode/src/statusline/index.ts Outdated
@diegomarino
Copy link
Copy Markdown
Author

📖 StatusLine Template System — Reference Documentation

Configuration

Add tui.status_line to your opencode.json or ~/.config/opencode/opencode.jsonc:

{
  "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

Field Type Default Description
templates Record<target, string> Template strings per display target
interval number (1-300) 10 Polling interval in seconds
commands Record<name, string> Shell commands whose output becomes {shell:name} variables

Display Targets

Target Where it appears
terminal_title Terminal tab / window title bar
session_footer Below the prompt in a session view
home_footer Footer on the home screen (replaces default)

Template Syntax

Templates use {variable} or {variable:format} tokens. Unresolved variables become empty strings.

Built-in Variables

Always available: {directory}, {worktree}, {cwd}, {cwd_basename}, {project_name}, {git_branch}, {timestamp}

Session-only: {session_id}, {session_title}, {session_slug}, {session_status}, {session_created}, {session_updated}, {session_duration}, {message_count}, {model_id}, {model_name}, {model_family}, {provider_id}, {agent}, {tokens_input}, {tokens_output}, {tokens_reasoning}, {tokens_cache_read}, {tokens_cache_write}, {total_cost}, {total_tokens}, {model_context_limit}, {context_used_pct}

Format Specs

Spec Description Example
:basename Last path segment /a/b/projectproject
:k Thousands 1500015k
:bar / :barN Progress bar (default 10 wide) 75███████░░░
:%H:%M:%S Duration or time 50250001:23:45
:%Y-%m-%d Date epoch ms → 2025-02-16

Shell Commands

Commands run via sh -c in the project worktree with a 5s timeout. Output is trimmed (max 1KB) and available as {shell:name}.

"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 Variables

plugin.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 ' '",
      },
    },
  },
}

@diegomarino
Copy link
Copy Markdown
Author

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 "main | 15k tok | $0.02" with no way to know what each piece is.

If visual formatting is desired in the future, resolve() could return structured segments instead of a flat string:

// 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:

  • Color by variable type (costs in yellow, git in green)
  • Left/right alignment by splitting at a separator token
  • Width-aware truncation of specific segments
  • Multi-line layout by grouping segments

The change would be localized to resolve() + the footer rendering in session/index.tsx and home.tsx. The user-facing config (templates, commands, variables) stays the same.

Not proposing this for this PR — just noting the path forward if the team wants richer visual control.

@FurryWolfX
Copy link
Copy Markdown

I would also like to display some custom status on the TUI, hoping to merge.

@andrewdunndev
Copy link
Copy Markdown

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 tui.footer.items plugin hook (#18969).

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 tui.toast.show for persistent status because there's no footer hook. Your {plugin:key} template variable solves this cleanly. If this lands, plugins like token-tracker could switch from toasts to footer display with zero code changes on the plugin side (just config).

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?

@andrewdunndev
Copy link
Copy Markdown

Following up on my earlier comment. I filed #18969 proposing a tui.footer.items plugin hook for persistent status display. After reviewing this PR more carefully, I think the template system with {plugin:key} variables is the better architecture for this. It gives users control over layout while plugins provide the data.

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.
@diegomarino diegomarino force-pushed the feat/statusline-template branch from 23d5025 to 440bb26 Compare March 30, 2026 18:30
@diegomarino
Copy link
Copy Markdown
Author

Hey @andrewdunndev @FurryWolfX — thanks for the interest! I've rebased the branch onto current dev and fixed the CI failures. Changes:

  • status_line config moved to tui.json: dev extracted TUI settings into a dedicated tui.json file (TuiConfig). The statusline config now lives in tui-schema.ts / TuiOptions instead of the old Config.Info.tui.
  • Adapted to new plugin slot system: now uses TuiPluginRuntime.Slot. Status line templates override the plugin slot when configured.

All green now. Also cc @marcusquinn — this relates to #18969 as an alternative approach for persistent status display via templates.

@marcusquinn
Copy link
Copy Markdown

sounds great! 🫡

@IrrealV
Copy link
Copy Markdown

IrrealV commented Apr 23, 2026

This PR would also solve an important background-agent observability use case if tui.statusLine.variables can be provided by local plugins and refreshed during normal session/plugin events.

Concrete example: a background delegation plugin can expose variables like:

  • {plugin:bg_status} -> BG: idle / BG: 2 running
  • {plugin:bg_agents} -> sdd-verify,sdd-apply

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Native StatusLine Hook for Plugins (Context-Free Display)

6 participants