Back to home

JohnXu22786

statusline

Real-time terminal statusline for agent harnesses: model, context usage, sub-agents, rate limits and session time in one line (zero dependencies).

Stars
0
Language
Python
Created
Aug 16, 2026
Updated
Aug 17, 2026

Introduction

简体中文

statusline

A real-time terminal status line for plugin-based agent harnesses. Model name, context usage bar, sub-agent count, rate-limit state, session duration, current time and other key information are compressed into a single continuously refreshed line; it also emits structured JSON for scripts, monitoring panels, or other plugins to consume directly.

  • Zero third-party dependencies: pure Python standard library (>= 3.8), no external packages;
  • Zero network requests: the render path performs no network I/O; data comes only from state fed in by the host;
  • Three output forms: terminal line (with ANSI coloring) / single JSON / JSONL stream;
  • Three refresh strategies: once (render once and exit), watch (poll a state file), stream (event-driven);
  • Configurable: display items and order (layout), progress-bar width and style, thresholds, theme, separator — layered overrides via config file / environment variables / command line;
  • Lenient yet rigorous: unknown fields in snapshots are always ignored (forward compatible), wrong types in known fields fail fast, and no missing field can crash rendering;
  • Self-contained: the root manifest.json self-describes the plugin contract, so any plugin-based harness can load it — see "Integrating with plugin-based harnesses".
deepseek-chat · ██████▊          42% · 2/5 · 200 req left · 01:17:06 · 05:17:06

Installing in DSH

The plugin is published for DeepSeek Harness (dsh). Install it into a profile with:

dsh plugin --profile demo add github:JohnXu22786/statusline

Remove it with:

dsh plugin --profile demo remove statusline

Directory layout

statusline/
├── manifest.json        # Plugin contract (self-describing manifest; harness reads this to load)
├── pyproject.toml       # Optional: packaging metadata for pip install
├── README.md
├── LICENSE
├── .gitignore           # Only excludes build artifacts (__pycache__ etc.)
├── statusline/          # Implementation package (pure standard library)
│   ├── __init__.py      # Version number
│   ├── __main__.py      # python -m statusline entry point
│   ├── cli.py           # Argument parsing, three run modes, exit codes
│   ├── config.py        # Three-layer config merge and validation
│   ├── snapshot.py      # State snapshot contract parsing and normalization
│   ├── metrics.py       # Pure functions: progress bar / threshold levels / duration formatting
│   ├── theme.py         # Color themes (role -> ANSI code)
│   ├── segments.py      # Display item registry (one function per segment)
│   ├── emit.py          # Line assembly and output (terminal / JSON / JSONL)
│   └── errors.py        # Unified exception hierarchy
├── tests/               # Standard-library unittest, 147 test cases (+ node:test bridge cases)
└── examples/
    ├── state.example.json     # Example snapshot (data fed to the plugin)
    ├── config.example.json    # Example config
    └── harness.example.py     # Full loading-flow demo (see "Integrating with plugin-based harnesses")

Installation

No installation needed to run; the repo directory works as-is:

# Option 1: run directly (no install)
python -m statusline once --state-file examples/state.example.json

# Option 2: pip install (optional, provides the statusline command)
pip install -e .
statusline once --state-file examples/state.example.json

Quick start

# Render once: feed a snapshot file
python -m statusline once --state-file examples/state.example.json

# Render once: feed a snapshot from a pipe, output structured JSON
Get-Content examples/state.example.json | python -m statusline once --emit jsonl

# Continuous refresh: host periodically writes snapshots to state.json, plugin polls and re-renders
# (on first run state.json does not exist yet; the plugin prints one stderr hint and keeps waiting — expected)
python -m statusline watch --state-file state.json

# Event-driven: host pushes snapshots line by line (recommended integration, see below)
Get-Content snapshots.jsonl | python -m statusline stream --emit jsonl

Command line

statusline [global options] {once|watch|stream}
OptionDescription
--config FILEConfig file path; when omitted, auto-reads statusline.json in the current directory (if present)
--emit {terminal,json,jsonl}Output format. json and jsonl serialize identically, one frame per line
--theme {default,mono,vivid}Color theme
--no-colorDisable ANSI colors
--state-file FILEState snapshot file (single JSON object, or JSONL taking the last line)
--layout A,B,CDisplay items and order, comma-separated
--interval SECONDSRefresh interval in watch mode
--width NContext bar width (0..200; 0 hides the bar, leaving percent or numbers)
--versionPrint version

Three modes:

  • once (default): render once and exit. State source falls back in order --state-file → stdin pipe → empty snapshot (hint only);
  • watch: periodically re-render the same line. When the state file is unavailable (missing / parse failure) it keeps an empty snapshot and prints one stderr hint, then recovers automatically once the file appears. Requires --state-file or STATUSLINE_STATE_FILE;
  • stream: read JSONL snapshots line by line from stdin, render one line each. Exits with an error immediately when stdin is a terminal (prevents accidental hangs); unparseable lines are skipped with a hint, without interruption.

Exit codes: 0 normal (including intentional Ctrl-C exit); 1 unexpected error; 2 usage error; 3 config or state-data error.

TTY behavior: watch / stream refresh the current line in place on a terminal (carriage return + clear line + colored line); under a pipe / redirection each frame is a standalone line; once always prints a full line.

State snapshot (input contract)

The single data surface between host and plugin. A snapshot is a JSON object shaped as follows (protocol name statusline/snapshot-v1, all fields optional):

{
  "model": "deepseek-chat",
  "session_id": "sess-20260816-001",
  "started_at": "2026-08-16T04:00:00+08:00",
  "context": {"used": 27500, "limit": 65536},
  "agents": {"active": 2, "total": 5},
  "limits": {"rate_limited": false, "retry_after": null, "remaining": 200}
}
FieldTypeDescription
modelstringCurrent model name; the model segment hides when missing
session_idstringSession identifier; passed through into JSON output verbatim
started_atnumber / ISO-8601 stringSession start time, Unix seconds or ISO string
context.usedint ≥ 0Tokens used; alternatively give context.input + context.output and let the plugin sum them
context.limitint > 0Context window limit; when missing only the used token count is shown
agents.activeint ≥ 0Number of running sub-agents
agents.totalint ≥ 0Cumulative spawned sub-agents
limits.rate_limitedboolWhether rate-limited
limits.retry_afternumber ≥ 0Suggested retry seconds while rate-limited
limits.remainingint ≥ 0Remaining request quota

Parsing rules: unknown fields are always ignored (forward compatible); wrong types for known fields, negative numbers, non-finite values (NaN/Infinity), and invalid timestamps raise SnapshotError immediately (exit code 3) — dirty data never reaches rendering. Integer values accept integral floats like 100.0; context.limit = 0 counts as missing; an explicit null counts as an unset field. Any missing field simply hides the corresponding display segment, never crashes.

Configuration

Three-layer override: defaults < config file < environment variables < command line. The config file is JSON; a full example lives in examples/config.example.json:

{
  "theme": "default",
  "color": true,
  "separator": " · ",
  "layout": ["model", "context", "agents", "limits", "elapsed", "clock"],
  "context": {"width": 16, "bar": "blocks", "percent": true},
  "thresholds": {"warn": 0.75, "critical": 0.9},
  "clock": {"format": "%H:%M:%S"},
  "emit": "terminal",
  "refresh": {"interval": 1.0}
}
KeyDefaultDescription
themedefaultdefault / mono / vivid
colortrueWhether to colorize; false is equivalent to forcing mono
separator" · "Separator text between display segments
layoutall six onDisplay items and order: model / context / agents / limits / elapsed / clock
context.width16Progress bar cells (0..200)
context.barblocksblocks (eight-level block chars) or ascii ([##--])
context.percenttrueWhether to show the percentage after the bar
thresholds.warn0.75Warn color at or above this fill ratio
thresholds.critical0.9Critical color at or above this fill ratio
clock.format"%H:%M:%S"strftime format of the clock segment
emitterminalOutput format (same as --emit)
refresh.interval1.0watch refresh interval in seconds (> 0)

Environment variables (prefix STATUSLINE_):

VariableMaps to
STATUSLINE_THEME / STATUSLINE_COLOR / STATUSLINE_EMIT / STATUSLINE_SEPARATORsame-named scalar keys
STATUSLINE_LAYOUTlayout, comma-separated
STATUSLINE_STATE_FILEstate file path
STATUSLINE_WIDTH / STATUSLINE_BAR / STATUSLINE_PERCENTcontext.*
STATUSLINE_WARN_AT / STATUSLINE_CRITICAL_ATthresholds.*
STATUSLINE_CLOCK_FORMATclock.format
STATUSLINE_INTERVALrefresh.interval

Validation is strict: unknown config keys, unknown display items, warn >= critical, out-of-range widths, non-positive intervals and the like all raise ConfigError (exit code 3) with a readable reason, catching typos at startup.

Output

Terminal line

Display segments are joined by separator in layout order; segments without data hide automatically. The context segment renders as "eight-level block progress bar + percentage", colored by thresholds (ok green / warn yellow / critical red); other segments follow theme role colors.

JSON / JSONL (structured data surface)

For other tools, generated from the same source as the terminal line — the two are always consistent. One frame:

{
  "ts": 1786828603.973,
  "text": "deepseek-chat · ██████▊          42% · 2/5 · 200 req left · 01:17:06 · 05:17:06",
  "model": "deepseek-chat",
  "session_id": "sess-20260816-001",
  "elapsed_seconds": 4626.0,
  "context": {"used": 27500, "limit": 65536, "fraction": 0.4196},
  "agents": {"active": 2, "total": 5},
  "limits": {"rate_limited": false, "retry_after": null, "remaining": 200}
}

text is a plain-text line (no ANSI) that can go straight into a tmux status block, dzen, conky, or logs; missing data fields are null. once outputs a single JSON object; under watch / stream each frame is one line (NDJSON) for line-oriented consumption.

Color themes

Roledefaultvividmono
model (model name)cyan 36bright cyan 96none
ok / warn / critical (context bar)32 / 33 / 3192 / 93 / 91none
agents (sub-agent count)magenta 35bright magenta 95none
limit_ok / limit_bad (rate limit)dim 2 / bright red 1;31bright black 90 / bright red 1;91none
clock / elapsed (time)dim 2bright black 90none

Integrating with plugin-based harnesses

The manifest.json at the plugin root is a self-describing contract; a harness that loads it gets everything it needs:

FieldDescription
id / name / versionPlugin identity; id is used for deduplicated registration in the host
typerenderer: a rendering plugin that only consumes state and never modifies host behavior
entry.commandLaunch command; the {dir} placeholder is replaced with the plugin directory's absolute path
entry.cwdSubprocess working directory (also supports {dir}); the plugin auto-loads statusline.json from that directory
entry.modeSuggested default run mode: stream (long-running, event-driven)
eventsHost domain events the plugin subscribes to (session / sub-agent / quota)
inputInput contract: JSONL on stdin, schema statusline/snapshot-v1
outputOutput contract: line-by-line stdout; terminal-line passes through for display, JSON forms are for aggregation
lifecycleSupported run modes: oneshot / watch / stream
configConfig file conventions and environment variable prefix

Loading flow (harness-side convention):

  1. Scan / install plugin directories, read manifest.json;
  2. Validate id uniqueness and entry.command executability; replace {dir} with the plugin directory's absolute path;
  3. Launch the subprocess per entry.mode (stdin piped, stdout piped, stderr logged):
    • stream (recommended): a long-running process. The host translates subscribed events into snapshot JSON and writes them line by line to stdin; the plugin renders once per line; the process exits naturally at EOF;
    • oneshot: spawn a process per event, pass the snapshot via a --state-file temp file or stdin, read the rendered result and reap it; suitable for very low event frequency;
    • watch: the plugin polls a state file maintained by the host; the host only needs to write the file periodically — suitable when the host would rather not manage subprocess lifecycles;
  4. Consume stdout: terminal-line forms pass through for display; jsonl forms can be aggregated, filtered, and forwarded to other components; stderr content is treated as logs.

Suggested event → snapshot mapping (adapter-layer implementation; event names are the subscription declarations in manifest.events, payloads parsed per host conventions):

Host eventSnapshot fields
session/event (session start / end)started_at, context, agents, limits
telemetry/usage (usage report)context.used (or input+output), limits.remaining
agent/request, agent/finished (sub-agent spawn / finish)agents.active, agents.total
limits/changed (rate limit / quota change)limits.rate_limited, limits.retry_after, limits.remaining

Fields update incrementally per event — the plugin only renders what the snapshot provides; missing means the segment hides.

Full demo: examples/harness.example.py implements a minimal harness in pure standard library, walking the whole flow "read manifest → resolve entry → launch stream subprocess → translate simulated events into snapshots and feed them → consume rendered lines":

python examples/harness.example.py

dsh bundle: the repo ships an actual Node bridge (index.js + dsh.bundle + cordis.patch.yml) for DeepSeek Harness (dsh). After dsh plugin --profile demo add github:JohnXu22786/statusline, the statusline_render tool appears in the model's toolset: it takes a snapshot-v1 object (all fields optional) and renders the same line the CLI would, returning a structured JSON frame (with a plain text field) or a plain line with emit=terminal. Non-zero Python exits and invalid snapshots surface as readable tool-level errors instead of crashing the host.

To integrate on dsh, mount this directory as a plugin directory (or publish it per host conventions), then write a ~ten-line bridge plugin: in apply(ctx) launch python -m statusline stream and assemble the session / sub-agent / quota event payloads into snapshots written to stdin per the table above; if the host has no status-line component of its own, once mode can be hooked onto any event hook instead.

Development and testing

python -m unittest discover -s tests -v

Self-check points in the implementation: progress bar rounds to 1/8 blocks, threshold boundaries (warn includes left, excludes right), duration-format boundaries (59s / 59:59 / 23:59:59 / day switch past 24h), three-layer config override, snapshot lenient/strict boundaries, TTY vs non-TTY output behavior, Windows encoding safety (entry points normalize to UTF-8).

FAQ

  • Garbled Chinese or block characters on Windows? The plugin entry points normalize stdio to UTF-8; if your terminal still shows mojibake, run chcp 65001 in the session and reopen the terminal.
  • watch keeps hinting the state file is unavailable? The hint appears once and is expected; it recovers automatically once the file appears. Confirm the host actually writes that path.
  • stream exits with an error immediately? It means stdin is a terminal, not a pipe — this mode requires feeding like Get-Content state.json | python -m statusline stream.
  • Want a narrower status line? --width 0 hides the bar; combine with --layout model,context,clock to drop unneeded segments. Note --width 0 alone leaves the percentage showing — set context.percent to false (via config file or STATUSLINE_PERCENT=0) to get numbers-only used / limit.
  • How do other tools consume the state? --emit jsonl outputs one JSON line per frame; any script can parse line by line.
  • What if the snapshot has no context.limit? The context segment degrades to "used token count" (e.g. 27,500 tok), no bar, no percentage.

License

MIT — see LICENSE.