Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@
"license": "BSD-3-Clause",
"name": "yas",
"repository": "https://github.com/tmck-code/yet-another-statusline",
"version": "0.7.1"
"version": "0.8.0"
}
35 changes: 30 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ _Avoid_: "cache hits" in cost discussions (the per-token price, not the hit-coun
Tokens generated by the assistant (`output_tokens`). Shown after the `↑` glyph.

**Day Total**:
Sum of **Billed Input**, **Cache Read**, and **Output** across every session today. Persisted to `~/.claude/statusline-tokens.log`, keyed by `session_id` so re-running today's session rewrites its row rather than double-counting. Shown after the `/` in the tokens row.
Sum of **Billed Input**, **Cache Read**, and **Output** across every session today. Persisted to `~/.claude/yas/state/runtime/tokens.log`, keyed by `session_id` so re-running today's session rewrites its row rather than double-counting. Shown after the `/` in the tokens row.

**Token-Rate Sparkline**:
The single-row mini-chart trailing the `<rate> t/m` label in the wide tokens row. Drawn from the block elements ` ▁▂▃▄▅▆▇█` (U+2581–U+2588, blank for zero): each cell's height and colour come from its bucket's ratio to the window peak, newest on the left (the live, in-flight cell — sitting next to the `t/m` label and dimmed) with older samples trailing off to the right. It reads the last `token_window` seconds of per-bucket throughput (default 60 s) from `~/.claude/statusline-token-rate.log`.
The single-row mini-chart trailing the `<rate> t/m` label in the wide tokens row. Drawn from the block elements ` ▁▂▃▄▅▆▇█` (U+2581–U+2588, blank for zero): each cell's height and colour come from its bucket's ratio to the window peak, newest on the left (the live, in-flight cell — sitting next to the `t/m` label and dimmed) with older samples trailing off to the right. It reads the last `token_window` seconds of per-bucket throughput (default 60 s) from `~/.claude/yas/state/runtime/token-rate.log`.
_Avoid_: "graph" (it is a one-row glyph strip, not a plotted axis), and the old half-block two-row form (removed — it used patchy "Symbols for Legacy Computing" glyphs).

**Day Stats Toggle**:
Expand Down Expand Up @@ -71,7 +71,7 @@ _Avoid_: "hue shift" (was the earlier term; **Shift** is canonical and matches t
### Theming

**Theme**:
The complete set of colour decisions the statusline draws with — decorative slots (border, path, branch, helper, label), the compaction traffic-light ladder, the rainbow border gradient stops, the per-model **Anchor** + warm/cool **Shift** tables, and the two **Pill Foreground** slots. Lives as a `Theme` dataclass instance in `claude/yas/themes.py`. Selected at runtime via `--theme=name`, `CLAUDE_STATUSLINE_THEME`, or `~/.claude/statusline-theme` — in that priority order, falling back to `claude-dark`. See [docs/adr/0002-theme-system.md](docs/adr/0002-theme-system.md).
The complete set of colour decisions the statusline draws with — decorative slots (border, path, branch, helper, label), the compaction traffic-light ladder, the rainbow border gradient stops, the per-model **Anchor** + warm/cool **Shift** tables, and the two **Pill Foreground** slots. Lives as a `Theme` dataclass instance in `claude/yas/themes.py`. Selected at runtime via `--theme=name`, `CLAUDE_STATUSLINE_THEME`, or `[appearance].theme` in `yas.toml` — in that priority order, falling back to `claude-dark`. The legacy `~/.claude/statusline-theme` file is no longer read: the installer folds a non-empty value into `yas.toml` once, and the migration then removes the file. See [docs/adr/0002-theme-system.md](docs/adr/0002-theme-system.md).
_Avoid_: "palette" as a synonym (a palette is a *source* — Catppuccin Latte, Solarized Light — and a **Theme** is the fully populated dataclass derived from one).

**Pill Foreground**:
Expand Down Expand Up @@ -105,7 +105,7 @@ _Avoid_: "nested subagents" as the feature name (the canonical term is **Subagen

**Done state**: A subagent is Done when its transcript captures `end_ts` (an epoch > 0). The primary signal is an assistant line with `stop_reason == "end_turn"` (the epoch of that line). Some sidechain transcripts never emit `end_turn` — every assistant line is `tool_use` or null, including the final result message — so a **terminal-text fallback** also applies: when no `end_turn` is seen and the *last* assistant line is terminal text (a `text` block with no pending `tool_use`), its timestamp becomes `end_ts`. A still-running agent's last assistant line is a `tool_use` awaiting a result, so the fallback cannot fire mid-run. An assistant line *after* an `end_turn` resets `end_ts` to 0 — the agent was resumed (e.g. a warm agent handed a follow-up via SendMessage), so Done means the transcript *currently ends* in an ended turn; a resumed agent that finishes again goes Done at the later time via a new `end_turn` or the terminal-text fallback. Per-message token usage is read **last-line-wins** (keyed by message id): streamed usage counters grow across an id's writes and the final write carries the real totals, so a first-write dedup would undercount output. The activity snippet is message-scoped: block memory accumulates across a streamed message id's writes (its first write is usually the thinking block), so the agent's later `tool_use`/`text` blocks are observed with the usual tool_use > text > thinking priority. In the two-line form a Done agent carries **no marker glyph** — it renders with dimmed styling throughout (duration, type, description, all stats) and a *frozen* elapsed time (`end_ts − first_timestamp` rather than `now − first_timestamp`); a running agent keeps live colours and a ticking duration. In the one-line collapse a Done agent still renders with `✓` (`GLYPH_SUBAGENT_DONE`) in place of the running row's plain `·` marker, dimmed throughout.

**Cohort retirement**: Subagents are shown as a *cohort* scoped to the current user turn (bounded by the `UserPromptSubmit` hook writing `~/.claude/yas-last-prompt.json`). Membership rules:
**Cohort retirement**: Subagents are shown as a *cohort* scoped to the current user turn (bounded by the `UserPromptSubmit` hook writing `~/.claude/yas/state/signals/last-prompt.json`). Membership rules:
- When the turn marker is available: include agents whose `first_timestamp ≥ last_prompt_ts`, plus any still-writing stragglers from the previous turn (transcript written within the liveness window).
- When the turn marker is absent (hook not installed): fall back to a 60s recency window — include any agent written within 60 s, or still running (`end_ts == 0`).

Expand Down Expand Up @@ -251,6 +251,31 @@ _Avoid_: "error message" (it is a per-knob *ignored-values* tally, not a single
Opt-in, **wide-layout-only** superscript captions painted into the rainbow top border and the dim/solid separator rows directly above the value each names. Each label is anchored over the value's real column — `build_wide` measures the already-rendered content string (ANSI stripped, whitespace-token offsets) rather than using a fixed tuned-offset table, so a caption tracks its value exactly and is emitted only when that value is present. Coverage: `changes` over the git dirty block (`•N*M`); `session` over the session timer always plus `clear` over the clear timer only when it is displayed (clear content non-empty — otherwise `session` alone anchors over the single timer); for the 5h cell `5h` over its glyph plus, in full form, `remain` (countdown), `used` (used %), and `burn rate` (trend) — compact/reset form carries only `5h` + `used`; for the 7d cell `7d` plus `used` and `burn rate` (when a trend renders); `cache` over the cache countdown; on the context separator `context` (token count), `fill` (the context-window `(N%)`), `dumb` (the compaction-risk %); on the tokens separator `input sess/day`/`cache sess/day`/`output sess/day` over the token columns, `cost sess/day` over the cost, and `tokens over time` over the sparkline; `skills + plugins` over the skills row; and content-start captions over the dynamic section separators — `plan` (todo-checklist / task row), `agent` (subagent cohort), `workflow` (workflow cohort), `specs` (OpenSpec change bars), with `plan` + `agent` splitting the caption in the side-by-side checklist+subagents block. In **Subagent Tree View**'s stacked (non-side-by-side) layout, the `agent` caption is followed by five column labels over the tree-single row's own measured anchors: `name` (over the description column), `model` (over the model field), `tok` (over the tok field — the abbreviated form, not `tokens`, because the full word's own run collides with `LOC r/w`'s at typical cohort widths and would get dropped by the label overlay's fitting), `LOC r/w` (anchored so its own `/` stacks over the `/` in the field's `<read> / <changed>` data, not over the field's start), and `log` (over the trailing activity snippet) — derived from the same `desc_col`/`stats_col`/`activity_col`/`subagent_cluster_field_offsets` anchors the rows themselves use, never a hardcoded offset. The session-level tokens/cost row's own lines-read/-changed segment caption reads `LOC read/write` (the same string abbreviates to `LOC r/w` via `LABEL_ABBREVIATIONS` under width pressure) — the two captions differ because the subagent-tree header is permanently the short form to stay under the narrower per-column data field, while the session-level segment has room for the full word. Default `false`; resolved through the **Precedence Chain** exactly like the wide-only `justify` knob. Each label glyph takes the **gradient** colour (and, on dim separators, the same per-column dim factor) of the column it occupies — never a flat colour. Labels overwrite only *fill* glyphs (`─`/`┄`); they yield to elbows (`┬┴┼`), the frame corners, the embedded session id, and any active **Model Pill** — truncating before, or dropping at, a non-fill column so no elbow, column, or content ever shifts. Narrow and medium layouts ignore the knob entirely.
_Avoid_: "rows" (labels add no content rows — they overlay the existing border/separator frame) and "headers" (they caption individual values positionally, not whole sections as a heading row would). The value-label words map to canonical concepts: `remain` = the rate-limit reset countdown, `used` = the **Five-Hour Limit** / **Seven-Day Limit** usage percentage, `burn rate` = the **Burndown Trend**, `limit` = the **Context Window Size** `(N%)` headroom figure, and `until dumb` = the **Compaction-Risk Zone** percentage.

**On-Disk Layout**:
Everything YAS writes except `yas.toml` lives under a single `$CLAUDE_CONFIG_DIR/yas/` subtree:

```
$CLAUDE_CONFIG_DIR/
yas.toml # user config — never moved, never removed by uninstall
yas/
cache/ # regenerable; safe to `rm -rf` at any time
config.toml.cache
transcripts/<session_id>.json
state/
version.json # {schema_version, yas_version, migrated_at}
runtime/ # written and read by the renderer
tokens.log
token-rate.log
render.log
signals/ # written by external processes, read by yas
last-prompt.json # UserPromptSubmit hook
terminal-width # ops/alacritty.py helper
sessions/<session_id>.json
```

`yas/cache/` is disposable by contract — every value in it is re-derivable, so deleting the whole tree costs only a re-parse, never data. Migration from the old flat `~/.claude/statusline-*` / `yas-*` files runs automatically (eagerly on re-install, lazily on the next render) and marks completion in `state/version.json`, after which the steady-state cost is a single `stat()`.
_Avoid_: "state dir" for the whole subtree (`yas/cache/` is disposable, `yas/state/` is not — the distinction is the point).

### Derived session state

**SessionView**:
Expand All @@ -261,7 +286,7 @@ _Avoid_: "session model" (that is **SessionInfo**, the raw JSON parse; **Session
The **Session Share %** denominator carried on the **SessionView**: `(Billed Input + Cache Read + Output) + Σ(Running Subagent total_input + output)`. The one genuinely *derived* SessionView field — it composes the **Transcript Usage** and **Running Subagent** readers — so it lives in exactly one place rather than being recomputed per caller.

**Tick Record** (`record_tick`):
The per-render disk-write step, owned by `app` and kept off the **SessionView** so the view stays a pure read. It persists this tick's **Transcript Usage** to the **Day Total** ledger (`TokenLog.update`) and the rate log (`TokenRate.update`), then returns a `TickRecord` bundling `token_log`, `day_cost`, and the token rate. `app` threads the `TickRecord` into the wide layout builder, so **Day Total** and day-cost never masquerade as gathered facts. Sits beside the existing per-render payload write in `app.main` (the `statusline-output` index file) — the one place per-render side effects belong.
The per-render disk-write step, owned by `app` and kept off the **SessionView** so the view stays a pure read. It persists this tick's **Transcript Usage** to the **Day Total** ledger (`TokenLog.update`) and the rate log (`TokenRate.update`), then returns a `TickRecord` bundling `token_log`, `day_cost`, and the token rate. `app` threads the `TickRecord` into the wide layout builder, so **Day Total** and day-cost never masquerade as gathered facts. Sits beside the existing per-render payload write in `app.main` (the `yas/state/sessions/<session_id>.json` payload) — the one place per-render side effects belong.
_Avoid_: "gather the day total" (the **Day Total** is *written*, not gathered — it depends on this tick's persist, which is why it is a **Tick Record** value and not a **SessionView** field).

## Relationships
Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ curl -fsSL https://raw.githubusercontent.com/tmck-code/yet-another-statusline/ma
curl -fsSL https://raw.githubusercontent.com/tmck-code/yet-another-statusline/main/ops/install.sh | YAS_NO_TTY=1 YAS_PYTHON=3.15 bash
```

### Upgrading — the `yas/` layout move

Everything YAS writes except `yas.toml` now lives under a single
`$CLAUDE_CONFIG_DIR/yas/` subtree (`yas/cache/` and `yas/state/`). The migration
runs automatically — eagerly when you re-run `ops/install.sh`, lazily on the
first render otherwise — and is a one-off; afterwards the check costs one
`stat()`.

Day-total token counts and the last-prompt handshake are **moved** and survive
the upgrade. Rate-limit history, render timings, the transcript parse cache and
`mon`'s per-session payloads are **regenerated rather than moved**, so expect a
brief cold start after the first upgrade: the `t/m` rate and sparkline, the
render-time figure and `mon`'s session list start out empty and refill within
one render tick to about five minutes, depending on the item.

### Reconfigure later — `/yas:config`

Run `/yas:config` any time to re-run the wizard against the already-installed
Expand Down Expand Up @@ -120,7 +135,12 @@ aliases when both are set — the aliases keep working but are deprecated.
- **`show_day_stats`** — when `true` (the default), shows today's cumulative token and cost totals alongside the session's, as `session/day` pairs. **Note:** this key lives under **`[tokens]`**, not `[layout]`, unlike the other display toggles.
- **`openspec_scan_depth`** — how many repo-levels below `cwd` the OpenSpec downward scan descends looking for nested `openspec/` roots (monorepo-of-repos layout). `1` (the default) finds a repo directly below `cwd`; `2` also finds one nested a level deeper; `0` disables the downward scan entirely (only an `openspec/` found by walking *upward* from `cwd` is used). Unlike the other numeric knobs, `0` is a legal value here.
- **CLI flags** — `--theme NAME` / `--bg-shift DIR` also accept the `--theme=NAME` / `--bg-shift=DIR` form. Pass them in the `statusLine.command` of your `~/.claude/settings.json`.
- **Legacy theme file** — `~/.claude/statusline-theme` (contents = a theme name) still works as the lowest-priority theme fallback, below `[appearance].theme`.
- **Legacy theme file** — `~/.claude/statusline-theme` is **no longer read**. If you re-run the installer while the file is non-empty and `yas.toml` does not already set a theme, the installer folds its value into `yas.toml` once, and the migration then deletes the file. Otherwise set the theme by hand:

```toml
[appearance]
theme = "claude-dark"
```

### Context state word

Expand Down Expand Up @@ -218,7 +238,7 @@ model = [

| var | default | description |
|-----|---------|-------------|
| `CLAUDE_CONFIG_DIR` | `~/.claude` | base dir for config/state files (`yas.toml`, theme file, width file, token-rate log, output payloads) |
| `CLAUDE_CONFIG_DIR` | `~/.claude` | base dir for `yas.toml` and the `yas/` state/cache subtree (logs, width file, session payloads) |
| `YAS_DEBUG` | _(unset)_ | when set, prints detailed per-value config-rejection reasons to stderr |
| `COLUMNS` | _(unset)_ | terminal-width fallback when tmux / width-file detection fail |

Expand All @@ -227,7 +247,7 @@ model = [
Width is detected by the first source that returns a positive value:

1. `tmux display-message -p '#{pane_width}'`
2. `~/.claude/terminal-width` file
2. `~/.claude/yas/state/signals/terminal-width` file (written by `ops/alacritty.py`, which honours `CLAUDE_CONFIG_DIR`)
3. `COLUMNS` env var
4. `shutil.get_terminal_size()` / `/dev/tty` ioctl

Expand Down
12 changes: 9 additions & 3 deletions claude/mon/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from datetime import datetime, timedelta
from pathlib import Path

from yas.constants import CLAUDE_DIR
from yas.constants import projects_dir, sessions_dir


@dataclass
Expand All @@ -20,9 +20,12 @@ class ActiveSession:
def find_active_jsonls(
include_after: timedelta,
now: datetime,
projects_root: Path = CLAUDE_DIR / 'projects',
projects_root: Path | None = None,
) -> list[tuple[Path, float]]:
"""Return (jsonl_path, mtime) pairs for .jsonl files whose mtime is within include_after of now."""
if projects_root is None:
projects_root = projects_dir()

result: list[tuple[Path, float]] = []
now_ts = now.timestamp()
cutoff = include_after.total_seconds()
Expand All @@ -42,9 +45,12 @@ def find_active_jsonls(


def index_payloads_by_session(
payloads_root: Path = CLAUDE_DIR / 'statusline-output',
payloads_root: Path | None = None,
) -> dict[str, tuple[Path, float, dict[str, object]]]:
"""Return most-recent payload file per session_id as (path, mtime, parsed_dict)."""
if payloads_root is None:
payloads_root = sessions_dir()

index: dict[str, tuple[Path, float, dict[str, object]]] = {}

if not payloads_root.exists():
Expand Down
Loading