Skip to content
2 changes: 1 addition & 1 deletion .claude/skills/tmck-code-statusline/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The renderer is layered across three modules (`render/gradient.py`, `render/bord

- **`render/gradient.py`** — pure colour/sparkline math. Module-level `rainbow_step`, `rainbow_at`, `rainbow_color`, `model_key`, `_scale`, `paint_bg_span`, `pill_gradient_fg`, plus the **`GradientEngine`** class (`gradient_rgb`, `gradient_color`, `grad_at`, `gradient_bar`, `spark_*`, `sparkline`). No I/O, no terminal state.
- **`render/borders.py`** — **`BorderRenderer`** consumes a `GradientEngine`. Owns `border_top`, `border_bottom`, `border_separator`, `border_separator_dim`, `border_line`, `_dim_for_col`. All elbow / pill / fill / `right_pill` math lives here.
- **`renderer.py`** (top level) — **`Renderer`** composes the two (`self.gradient`, `self.border`) and adds every section helper (`path_git`, `path_git_compact`, `fit_path`, `model_section_compact`, `model_right_section`, `model_right_section_compact`, `plugins_skills`, `subagent_activity`, `subagent_row`, `task_row`, `tokens_cost`, `context_bar`, `context_line`, `context_line_compact`, `openspec_bar`, `spec_gradient_bar`, `burndown_trend`, `helper`, the colour pickers, `vsep_block`, …). Keeps thin delegators (`gradient_color`, `border_top`, …) for backward-compat callers and tests. Module-level `LEVEL_PCT` and `TOOL_ARG_KEY` dicts live here too.
- **`renderer.py`** (top level) — **`Renderer`** composes the two (`self.gradient`, `self.border`) and adds every section helper (`path_git`, `path_git_compact`, `fit_path`, `model_section_compact`, `model_right_section`, `model_right_section_compact`, `plugins_skills`, `subagent_activity`, `subagent_row`, `task_row`, `tokens_cost`, `context_line`, `context_line_compact`, `openspec_bar`, `spec_gradient_bar`, `burndown_trend`, `helper`, the colour pickers, `vsep_block`, …). Keeps thin delegators (`gradient_color`, `border_top`, …) for backward-compat callers and tests. Module-level `LEVEL_PCT` and `TOOL_ARG_KEY` dicts live here too.

Supporting modules:

Expand Down
6 changes: 6 additions & 0 deletions CODING_STANDARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@ smell applies only to code with **zero** callers — a dead `Config` knob, an
unused constant — not to a small helper that exists because it names a
concept clearly, even if only `renderer.py` calls it today.

**`@dataclass` is banned in `claude/yas/**`.** Its cost is class-definition
time paid at import, and the statusline is a cold-start CLI where import time
is runtime — 16 conversions measured 1.13-1.18x slower. Use hand-written
`__slots__` classes and accept the extra lines. Full measurements and
reasoning in `KNOWN_ISSUES.md`.

**`else` after `return` is accepted** where the symmetry between branches
reads better than dropping the `else` — not a review finding on its own.

Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ A `[[tokens.model]]` entry in `yas.toml` pairing a `match` substring with a `sof
_Avoid_: "per-model env var" (none exists by design).

**Config-Error Row**:
The compact `⚠ yas.toml: N values ignored (...)` row that renders just above the box's bottom border when one or more `yas.toml` values were rejected. It lists the rejected *knob names* (e.g. `soft_limit`, `tokens.model[0]`), not their values or reasons, and is capped to the render width. It appears only for `yas.toml`-sourced rejections (a bad `YAS_*` env var or CLI flag stays silent in the box); a malformed-file parse failure shows as the single entry `yas.toml: parse error`. Full per-value reasons are written to stderr only when `YAS_DEBUG` is set. A rejected knob silently falls back to its default — the row is informational, never fatal.
The compact `⚠ yas.toml: N values ignored (...)` row that renders just above the box's bottom border when one or more `yas.toml` values were rejected. It lists the rejected *knob names* (e.g. `soft_limit`, `tokens.model[0]`), not their values or reasons, and is capped to the render width. It appears only for `yas.toml`-sourced rejections (a bad `YAS_*` env var or CLI flag stays silent in the box); a malformed-file parse failure shows as the single entry `yas.toml: parse error`. A rejected knob silently falls back to its default — the row is informational, never fatal.
_Avoid_: "error message" (it is a per-knob *ignored-values* tally, not a single failure message, and never aborts the render).

**Section Labels** (`labels` knob / `YAS_LABELS`):
Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,7 @@ Bad config never crashes the statusline. A malformed `yas.toml` is ignored
wholesale, and a single bad / out-of-range / wrong-type value drops only that
one knob back to its default. When any `yas.toml` value is rejected, a compact
warning row — `⚠ yas.toml: N values ignored (...)` — appears at the bottom of
the box listing the rejected knob names. Detailed per-value reasons go to stderr
only when `YAS_DEBUG` is set.
the box listing the rejected knob names.

### Per-model `soft_limit` overrides

Expand Down Expand Up @@ -239,7 +238,6 @@ model = [
| var | default | description |
|-----|---------|-------------|
| `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 |

### Terminal width
Expand Down
12 changes: 4 additions & 8 deletions claude/mon.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import signal
import sys
import traceback
from argparse import Namespace
from datetime import datetime
from pathlib import Path

Expand Down Expand Up @@ -54,7 +55,7 @@ def _age_label(age_secs: int, width: int) -> str:
return f'{_DIM}{text}{"─" * fill}{_RESET}'


def tick(args) -> None:
def tick(args: Namespace) -> None:
sz = shutil.get_terminal_size(fallback=(120, 40))
cols, rows = sz.columns, sz.lines

Expand All @@ -64,15 +65,13 @@ def tick(args) -> None:
sessions = discover(args.include_after, now)
theme = resolve_theme(args.theme)

# Filter to bright/dim only (remove 'removed' sessions).
active = []
active = [] # bright/dim only, 'removed' sessions filtered out
for s in sessions:
tier = classify(s.jsonl_mtime, now_ts, args.idle_after, args.remove_after)
if tier != 'removed':
active.append((s, tier))

# Header and footer each take 1 row.
available_body = max(0, rows - 2)
available_body = max(0, rows - 2) # header + footer take 1 row each

if cols < MIN_WIDTH:
header = format_header(0, None, None, 0.0, cols)
Expand All @@ -83,7 +82,6 @@ def tick(args) -> None:
sys.stdout.flush()
return

# Render each session box; prepend an age label; apply dim post-processing.
width = max(MIN_WIDTH, min(160, cols - 6))
rendered_boxes: list[str] = []
for s, tier in active:
Expand All @@ -96,11 +94,9 @@ def tick(args) -> None:
box = apply_dim(box)
rendered_boxes.append(box)

# Clip to available body height.
visible_boxes, hidden_count = clip_to_height(rendered_boxes, available_body)
n_sessions = len(visible_boxes)

# Aggregate header data.
visible_sessions = [s for (s, _), box in zip(active, rendered_boxes) if box in visible_boxes]
five_h, seven_d = aggregate_rate_limits(visible_sessions)
day_cost = aggregate_day_cost(visible_sessions)
Expand Down
3 changes: 1 addition & 2 deletions claude/mon/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ def discover(

sessions = []
for jsonl_path, jsonl_mtime in active_jsonls:
# session_id is the stem of the jsonl filename
session_id = jsonl_path.stem
session_id = jsonl_path.stem # filename stem
entry = payload_index.get(session_id)
if entry is None:
continue
Expand Down
28 changes: 11 additions & 17 deletions claude/mon/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ def _pad_or_clip(s: str, width: int) -> str:
if vis < width:
return s + ' ' * (width - vis)
if vis > width:
# Clip: remove characters from the raw string until visible width == width
# Walk the raw string, skipping escape sequences.
# walk the raw string, skipping escape sequences, until visible width == width
import re
_ESC = re.compile(r'\033\[[0-9;]*m')
result = []
Expand Down Expand Up @@ -97,12 +96,12 @@ def _centre_line(text: str, width: int) -> str:
return ' ' * pad_left + text + ' ' * pad_right


def format_empty_body(width: int, height: int) -> str:
"""Return a multi-line string of exactly height lines with (no active sessions) centred."""
def _centred_body(message: str, width: int, height: int) -> str:
"""Return a multi-line string of exactly height lines with message centred."""
if height <= 0:
return ''
blank = ' ' * width
msg_line = _centre_line('(no active sessions)', width)
msg_line = _centre_line(message, width)
if height == 1:
return msg_line
mid = height // 2
Expand All @@ -111,18 +110,14 @@ def format_empty_body(width: int, height: int) -> str:
return '\n'.join(lines)


def format_empty_body(width: int, height: int) -> str:
"""Return a multi-line string of exactly height lines with (no active sessions) centred."""
return _centred_body('(no active sessions)', width, height)


def format_narrow_body(width: int, height: int) -> str:
"""Return a multi-line string of exactly height lines with (terminal too narrow) centred."""
if height <= 0:
return ''
blank = ' ' * width
msg_line = _centre_line('(terminal too narrow)', width)
if height == 1:
return msg_line
mid = height // 2
lines = [blank] * height
lines[mid] = msg_line
return '\n'.join(lines)
return _centred_body('(terminal too narrow)', width, height)


def clip_to_height(
Expand All @@ -138,8 +133,7 @@ def clip_to_height(
for box in rendered_boxes:
n_lines = box.count('\n') + 1
if used + n_lines > available_height:
# This box doesn't fit; all remaining boxes are hidden.
break
break # doesn't fit; all remaining boxes are hidden
visible.append(box)
used += n_lines
hidden_count = len(rendered_boxes) - len(visible)
Expand Down
18 changes: 6 additions & 12 deletions claude/mon/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,14 @@ def _handler(signum: int, frame: object) -> None:
signal.signal(sigwinch, _handler)


_DURATION_UNITS = {'h': 'hours', 's': 'seconds', 'm': 'minutes'}


def _parse_duration(s: str) -> timedelta:
if s.endswith('h'):
try:
return timedelta(hours=float(s[:-1]))
except ValueError:
pass
elif s.endswith('m'):
try:
return timedelta(minutes=float(s[:-1]))
except ValueError:
pass
elif s.endswith('s'):
unit = _DURATION_UNITS.get(s[-1:])
if unit is not None:
try:
return timedelta(seconds=float(s[:-1]))
return timedelta(**{unit: float(s[:-1])})
except ValueError:
pass
raise argparse.ArgumentTypeError(
Expand Down
45 changes: 5 additions & 40 deletions claude/yas/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,7 @@ def record_tick(session: SessionInfo, usage: TranscriptUsage) -> TickRecord:


def resolve_theme(cli_name: str | None) -> Theme:
"""Layered theme selection: CLI -> YAS_THEME -> CLAUDE_STATUSLINE_THEME
-> [appearance].theme -> CLAUDE_DARK.

Resolves live (fresh Config.load) so callers see the current environment and
CLAUDE_DIR; the import-time CONFIG singleton is for the module constants."""
"""Layered theme selection: CLI -> YAS_THEME -> CLAUDE_STATUSLINE_THEME -> [appearance].theme -> CLAUDE_DARK."""
if cli_name and cli_name in THEMES:
return THEMES[cli_name]
return THEMES.get(Config.load().theme, CLAUDE_DARK)
Expand All @@ -60,65 +56,34 @@ def render(session_info: dict[str, object], width: int, *, bg_shift: str = 'warm
else:
tick = record_tick(session, view.transcript_usage)
spec = build_wide(view, tick, width, r, soft_limit)
# The bottom-right border annotation: the version tag always (bold,
# grey→muted-grey gradient), preceded by the previous run's wall-clock
# when the show_render_time knob supplies it (`…47.2ms v0.6.2──╯`).
out = '\n'.join(render_layout(spec, r, timing, f'v{VERSION}'))
if parse_cache is not None:
parse_cache.save()
return apply_glyphs(out, glyph_mode, single_width)


def main(t0: float | None = None) -> None:
# Wall-clock start for the bottom-border run-time annotation. The entry
# shim passes a perf_counter() stamped before importing the app so the
# measured duration covers import cost too; a None default keeps `main`
# callable bare (tests) by stamping here instead.
if t0 is None:
t0 = time.perf_counter()
# Force UTF-8 on stdout so the script renders correctly on Windows
# (cp1252 default codec can't encode box-drawing or Nerd Font glyphs,
# crashes with UnicodeEncodeError on the first border char). Python's
# PEP 540 UTF-8 mode and PYTHONIOENCODING env var both fix this from
# the outside; reconfiguring stdout here removes the requirement that
# callers set either. No-op on platforms whose default codec is
# already UTF-8 (most Unix systems since Python 3.7).
t0 = time.perf_counter() # entry shim normally passes this, stamped before import
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
# Lazy one-time migration to the yas/{cache,state}/ layout. The `stat()`
# here is the whole steady-state cost once migrated; the import stays
# inside the guard so it's never paid on the hot path after that.
# REMOVE AFTER 0.11.0
sys.stdout.reconfigure(encoding='utf-8') # cp1252 default on Windows can't encode box/Nerd Font glyphs
if not version_file().exists():
from yas.migrate import migrate
from yas.migrate import migrate # lazy one-time migration to the yas/{cache,state}/ layout
migrate()
# Resolve config live so a freshly-set env var (e.g. YAS_FULL_WIDTH) or an
# edited yas.toml takes effect on this invocation; CLI flags are top priority.
cfg = Config.load(argv=sys.argv[1:], config_dir=config_path().parent)
bg_shift = cfg.bg_shift
theme = THEMES.get(cfg.theme, CLAUDE_DARK)

info = json.loads(sys.stdin.read())

# Write payload so the multi-session observer can index it. Keyed by
# session_id and overwritten in place under yas/state/sessions/, so the
# dir holds one file per session rather than one per render tick. The
# observer already collapses to the newest payload per session
# (mon/discovery.index_payloads_by_session), so the old timestamped
# filenames only ever accumulated dead weight.
# write payload for the multi-session observer, keyed by session_id and overwritten in place
session_id = _as_str(info.get('session_id')) or 'unknown'
try:
sessions_dir().mkdir(parents=True, exist_ok=True)
session_payload_path(session_id).write_text(json.dumps(info))
except OSError:
pass

# Previous run's wall-clock, shown in the bottom-right border when the
# show_render_time knob is on (off by default). A run can't know its own
# total before it has drawn, so each run displays the last one's value
# (absent on the very first render of a session). When off, the cache is
# never touched and `timing` stays empty — i.e. as if the feature did not
# exist.
timing = ''
if cfg.show_render_time:
prev_ms = RenderTiming.read(session_id)
Expand Down
Loading