Skip to content

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

Open
diegomarino wants to merge 5 commits intoanomalyco:devfrom
diegomarino:feat/statusline-template
Open

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

Conversation

@diegomarino
Copy link

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
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
@diegomarino diegomarino force-pushed the feat/statusline-template branch from bd1113d to 6157cdd Compare February 16, 2026 19:47
Copy link
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
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
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
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.

* 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
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.

@diegomarino
Copy link
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
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.

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)

1 participant