From 5aa165cb13de71cff223599395a5dbbfbb9c9155 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 19 Aug 2026 21:21:57 +1000 Subject: [PATCH 1/2] Fix skills+plugins section collapse and loc/cost label visibility The skills+plugins trailing column vanished entirely when empty instead of rendering as a blank bordered section, dragging the loc r/w and cost sections' borders/labels along with it. Separately, the loc r/w and cost labels silently disappeared with show_icons=False because label detection sniffed for an icon glyph instead of using an explicit flag. Also centers the loc r/w label within its section using the abbreviated label form actually rendered. Co-Authored-By: Claude Sonnet 5 --- claude/yas/config.py | 14 +- claude/yas/constants.py | 12 + claude/yas/layout.py | 154 +++++++------ claude/yas/render/text.py | 16 ++ claude/yas/renderer.py | 266 ++++++++++++---------- test/fixtures/claude_dark_wide.ansi | 2 +- test/test_config.py | 42 ++++ test/test_labels_layout.py | 69 +++++- test/test_layout_seam.py | 78 ++++--- test/test_tokens_cost.py | 333 +++++++++++++++------------- test/test_tokens_over_time.py | 64 ++++++ test/test_tool_counts_row.py | 2 +- 12 files changed, 671 insertions(+), 381 deletions(-) create mode 100644 test/test_tokens_over_time.py diff --git a/claude/yas/config.py b/claude/yas/config.py index e4c1caf..3134d64 100644 --- a/claude/yas/config.py +++ b/claude/yas/config.py @@ -32,6 +32,7 @@ DEFAULT_THEME, DEFAULT_SHOW_DAY_STATS, DEFAULT_SHOW_TOOL_USES, + DEFAULT_SHOW_TOKENS_OVER_TIME, ) from yas.themes import THEMES @@ -351,8 +352,8 @@ class Config: 'max_width', 'full_width', 'justify', 'labels', 'soft_limit', 'token_window', 'theme', 'bg_shift', 'glyph_mode', 'single_width', 'show_day_stats', 'context_state', 'context_labels', 'context_thresholds', - 'show_render_time', 'show_tool_uses', 'soft_limit_models', 'openspec_scan_depth', - 'show_icons', 'errors', 'debug_lines', + 'show_render_time', 'show_tool_uses', 'show_tokens_over_time', 'soft_limit_models', + 'openspec_scan_depth', 'show_icons', 'errors', 'debug_lines', ) max_width: int @@ -371,6 +372,7 @@ class Config: context_thresholds: tuple[int, ...] show_render_time: bool show_tool_uses: bool + show_tokens_over_time: bool soft_limit_models: tuple[tuple[str, int], ...] openspec_scan_depth: int show_icons: bool @@ -395,6 +397,7 @@ def __init__( context_thresholds: tuple[int, ...] = DEFAULT_CONTEXT_THRESHOLDS, show_render_time: bool = False, show_tool_uses: bool = DEFAULT_SHOW_TOOL_USES, + show_tokens_over_time: bool = DEFAULT_SHOW_TOKENS_OVER_TIME, soft_limit_models: tuple[tuple[str, int], ...] = (), openspec_scan_depth: int = DEFAULT_OPENSPEC_SCAN_DEPTH, show_icons: bool = True, @@ -418,6 +421,7 @@ def __init__( s(self, 'context_thresholds', context_thresholds) s(self, 'show_render_time', show_render_time) s(self, 'show_tool_uses', show_tool_uses) + s(self, 'show_tokens_over_time', show_tokens_over_time) s(self, 'soft_limit_models', soft_limit_models) s(self, 'openspec_scan_depth', openspec_scan_depth) s(self, 'show_icons', show_icons) @@ -438,6 +442,7 @@ def __repr__(self) -> str: f'show_day_stats={self.show_day_stats}, context_state={self.context_state}, ' f'context_labels={self.context_labels!r}, context_thresholds={self.context_thresholds!r}, ' f'show_render_time={self.show_render_time}, show_tool_uses={self.show_tool_uses}, ' + f'show_tokens_over_time={self.show_tokens_over_time}, ' f'soft_limit_models={self.soft_limit_models!r}, ' f'openspec_scan_depth={self.openspec_scan_depth}, ' f'show_icons={self.show_icons}, ' @@ -539,6 +544,10 @@ def cli_src(name: str) -> list[tuple[str, object]]: 'show_tool_uses', _env_sources(env, 'YAS_SHOW_TOOL_USES') + toml_src(layout, 'show_tool_uses'), _parse_bool, DEFAULT_SHOW_TOOL_USES, errors, debug) + show_tokens_over_time = _resolve( + 'show_tokens_over_time', + _env_sources(env, 'YAS_SHOW_TOKENS_OVER_TIME') + toml_src(layout, 'show_tokens_over_time'), + _parse_bool, DEFAULT_SHOW_TOKENS_OVER_TIME, errors, debug) justify = _resolve( 'justify', _env_sources(env, 'YAS_JUSTIFY') + toml_src(layout, 'justify'), @@ -584,6 +593,7 @@ def cli_src(name: str) -> list[tuple[str, object]]: context_thresholds=context_thresholds, show_render_time=show_render_time, show_tool_uses=show_tool_uses, + show_tokens_over_time=show_tokens_over_time, soft_limit_models=tuple(soft_limit_models), openspec_scan_depth=openspec_scan_depth, show_icons=show_icons, diff --git a/claude/yas/constants.py b/claude/yas/constants.py index c4570ea..5f6b475 100644 --- a/claude/yas/constants.py +++ b/claude/yas/constants.py @@ -25,6 +25,7 @@ DEFAULT_THEME = 'claude-dark' DEFAULT_SHOW_DAY_STATS = True DEFAULT_SHOW_TOOL_USES = False +DEFAULT_SHOW_TOKENS_OVER_TIME = False DEFAULT_JUSTIFY = False DEFAULT_LABELS = False # Context-state word (ported from Dumbometer, MIT). Opt-in: off by default so @@ -120,6 +121,17 @@ # into the compact context line (losing the cost/rate row entirely, not just # the lines). LINES_SEGMENT_MIN_WIDTH = 103 +# Upper bound, in visible columns, on the "skills + plugins" content fed into +# the tokens/cost row's trailing column (`tokens_cost`'s `trailing_content`). +# That column shares the row with the tokens/lines/cost segments, so it never +# gets anywhere near a full row's width -- pre-clipping to this fixed, +# realistic-widest cap (rather than the box's own width-3) keeps +# `tokens_cost`'s own include/shed gate satisfiable even at very wide boxes: +# without a fixed cap, clipping to `width - 3` makes the trailing content's +# measured width scale with the box itself, so the row's own min-width-with- +# leader gate (tokens + cost + trailing + vseps <= box_width) could never be +# satisfied for a long plugin/skill list at ANY width. +PLUGINS_TRAILING_MAX_W = 60 # Minimum gap between the narrow tasks-header's left cluster (glyph + done/total) # and its right-anchored active-task timer. The timer is flush to the content diff --git a/claude/yas/layout.py b/claude/yas/layout.py index 0d79488..ea8d374 100644 --- a/claude/yas/layout.py +++ b/claude/yas/layout.py @@ -20,9 +20,11 @@ GLYPH_HOURGLASS, GLYPH_RENAMED, GLYPH_WF_DIVIDER, + LABEL_ABBREVIATIONS, LINES_LABEL, NARROW_SIDE_BY_SIDE_MIN_WIDTH, PLAN_ONELINE_MIN_W, + PLUGINS_TRAILING_MAX_W, RESET, SUBAGENT_DESC_FLOOR, SUBAGENT_DISPLAY_CAP, @@ -59,7 +61,7 @@ from yas.render.pill import Pill from yas.render.gradient import model_display from yas.renderer import Renderer -from yas.render.text import _visible_width, _token_offsets, fmt_tok_fixed +from yas.render.text import _ansi_byte_offset, _visible_width, _token_offsets, fmt_tok_fixed from yas.tokens import TickRecord # Characters that can start a dirty-status block in the plain-text path string. @@ -92,22 +94,6 @@ class _TopRowShed(NamedTuple): elapsed_section_w: int -def _ansi_byte_offset(ansi: str, plain_idx: int) -> int: - """Return the byte (str index) in *ansi* that corresponds to plain-text - position *plain_idx* (0-indexed visible character count, ANSI escapes - excluded). Returns ``len(ansi)`` when *plain_idx* >= visible width.""" - pos = 0 # current byte position in `ansi` - vis = 0 # visible characters counted so far - while pos < len(ansi) and vis < plain_idx: - m = _ANSI_RE.match(ansi, pos) - if m: - pos = m.end() - continue - pos += 1 - vis += 1 - return pos - - class RowSpec: __slots__ = ( 'kind', 'content', 'bg_lead', 'bg_trail', 'pill_flush', 'ups', 'downs', @@ -912,49 +898,46 @@ def build_wide( skill_display = ','.join(s.split(':', 1)[-1] for s in skills.names) session_inout = view.session_inout + # `plugins_line` is computed ahead of `tokens_cost` below since it is now + # fed into that row as its trailing column (replacing the old in-row + # rate/sparkline leader, which moved to its own standalone `tokens_over_time` + # row further down). + plugins_line = r.plugins_skills(len(skills.names), skill_display, session.workspace.plugins, show_icons=view.cfg.show_icons) + # `plugins_line` now shares the tokens/cost row's trailing column rather + # than owning a full-width row of its own, so it is pre-clipped to a + # fixed, realistic-widest cap (`PLUGINS_TRAILING_MAX_W`) instead of + # `width - 3`: clipping to the box's own width would make the measured + # trailing content scale with the box, making `tokens_cost`'s own + # min-width-with-leader gate unsatisfiable at ANY width for a long list. + # `tokens_cost` clips further still, to whatever narrower width the + # column actually gets once the tokens/cost segments claim their share. + plugins_avail = min(width - 3, PLUGINS_TRAILING_MAX_W) + if _visible_width(plugins_line) > plugins_avail: + cut = _ansi_byte_offset(plugins_line, plugins_avail - 1) + plugins_line = f'{plugins_line[:cut]}{ELLIPSIS}{RESET}' + # Reading `view.tool_counts` here forces its transcript scan on every wide # render (previously only when `cfg.show_tool_uses` was on, for the # per-tool row further down) — needed to feed the session-total lines # read/changed segment into `tokens_cost` below. Accepted +2.9ms cost per # design.md Decision 6. - line_tokens, vsep_cols, _mark_col, tokens_min_w = r.tokens_cost( + line_tokens, vsep_cols, _mark_col, tokens_min_w, has_lines_seg = r.tokens_cost( usage.billed_in, usage.cache_read, usage.out, token_log.day_in, token_log.day_cache_read, token_log.day_out, - sess_cost, day_cost, tok_rate, + sess_cost, day_cost, plugins_line, session.session_id, width, fill, view.cfg.show_day_stats, view.cfg.justify, lines=(view.tool_counts.lines_read, view.tool_counts.lines_changed), show_icons=view.cfg.show_icons, ) - # The three-segment tokens │ cost │ rate row is fixed-content-width: at the - # bottom of the wide band (box ~80-84) it cannot hold both columns plus the - # rate/spark leader without overflowing the box and detaching its two │ from - # the ┬/┴ elbows. ``tokens_min_w`` is the exact content-aware floor reported - # by tokens_cost; below it (and below the worst-case constant) we drop the row - # and fall back to the compact context line the medium layout uses. + # The tokens │ [lines │] cost │ [skills+plugins] row is fixed-content-width: + # at the bottom of the wide band (box ~80-84) it cannot hold the tokens and + # cost columns without overflowing the box and detaching its │ from the + # ┬/┴ elbows. ``tokens_min_w`` is the exact content-aware floor reported + # by tokens_cost; below it (and below the worst-case constant) we drop the + # row and fall back to the compact context line the medium layout uses. tokens_fits = width >= max(tokens_min_w, TOKENS_COST_MIN_WIDTH) - plugins_line = r.plugins_skills(len(skills.names), skill_display, session.workspace.plugins, show_icons=view.cfg.show_icons) - # border_line pads to width - 3 ('│ ' + content + '│') but never truncates; - # a long plugin list would overflow the box, so clip it here. - # - # Width-gap audit Finding A: at very wide boxes this row's trailing pad - # (skills/plugin names are short, fixed content, already shown in full) - # grows into a large blank run — up to ~282 cols at width=350. Considered - # centering it under `cfg.justify` (implemented and measured); reverted: - # centering only relocates the dead space into two roughly-equal runs, - # it does not reduce it (measured 283 total post-centering vs 282 - # before, at width=350 — a wash, not an improvement), and each half is - # still individually a "large gap" by the audit's own threshold. There is - # no more information this row can show — the names are already fully - # rendered — so there is no fix here that doesn't either invent new - # content (out of scope) or edit `border_line`'s own padding behaviour - # (a different, riskier layer). Leaving this row's original left-aligned - # + trailing-pad behaviour as-is. - plugins_avail = width - 3 - if _visible_width(plugins_line) > plugins_avail: - cut = _ansi_byte_offset(plugins_line, plugins_avail - 1) - plugins_line = f'{plugins_line[:cut]}{ELLIPSIS}{RESET}' title_cap = max(10, width - 45) title_w = min(40, title_cap, max((len(n) for n, _, _ in changes), default=25)) openspec_bars = [r.openspec_bar(name, d, t, width, title_w) for name, d, t in changes] @@ -1472,9 +1455,22 @@ def _resolve_toprow_shed() -> _TopRowShed: # Tokens/cost separator labels: input/cache/output measured over the # three token columns left of the first vsep │ (input at the ↓ icon, # cache at the '(' parenthetical, output at the ↑ icon after the ')'), - # cost between the two vseps, and "tokens over time" over the rate - # sparkline after the second. The `sess/day` suffix names the + # cost centred in its own cell, and "skills + plugins" over the + # trailing column when present. The `sess/day` suffix names the # session/day pair shown only when day stats are on. + # + # `vsep_cols` (0-3 entries) doesn't self-describe which segments it + # bounds — (lines segment, trailing segment) are each independently + # optional (see `tokens_cost`'s shed ladder), so a bare length check + # is ambiguous at length 2. `has_lines_seg` comes straight back from + # `tokens_cost` (its own shed-ladder decision) rather than being + # re-derived by sniffing the rendered content for the read-lines + # glyph — that glyph is itself gated on `show_icons`, so with icons + # off the glyph-sniff silently read `False` even when the segment + # was present, dropping the 'loc r/w' label and mis-anchoring + # 'cost sess/day' onto the wrong (elbow) column. `has_leader_seg` + # then follows from the arithmetic (vsep_cols length == 1 + + # has_lines + has_leader). tok_labels: list[tuple[str, int]] = [] if view.cfg.labels: _tp = _ANSI_RE.sub('', line_tokens[0]) @@ -1506,21 +1502,35 @@ def _resolve_toprow_shed() -> _TopRowShed: tok_labels.append((_cache_lbl, _cache_anchor)) if _out_i != -1: tok_labels.append((f'output{_suf}', 3 + _out_i)) - # Centre `cost` within its cell (between the last two vseps) instead of - # left-anchoring at the cell's start. The cost cell is its own section - # (bounded by vseps), so this never conflicts with the token labels. - # Index from the end: vsep_cols is a 2-tuple when the lines segment is - # shed and a 3-tuple when it's included (design.md Decision 8), and the - # cost cell is always the pair immediately preceding the sparkline. - _cost_lbl = f'cost{_suf}' - _cost_mid = (vsep_cols[-2] + vsep_cols[-1]) // 2 - tok_labels.append((_cost_lbl, max(vsep_cols[-2] + 1, _cost_mid - len(_cost_lbl) // 2))) - tok_labels.append(('tokens over time', vsep_cols[-1] + 2)) - # `lines read/changed` caption, centred between the first two vseps — - # only present when the segment itself is (len == 3; Decision 8). - if len(vsep_cols) == 3: - _lines_mid = (vsep_cols[0] + vsep_cols[1]) // 2 - tok_labels.append((LINES_LABEL, max(vsep_cols[0] + 1, _lines_mid - len(LINES_LABEL) // 2))) + _has_lines_seg = has_lines_seg + _has_leader_seg = len(vsep_cols) > (1 + (1 if _has_lines_seg else 0)) + # Centre `cost` within its cell instead of left-anchoring at the + # cell's start. The cell's left edge is the tokens│ (no lines + # segment) or lines│ (lines segment) vsep; its right edge is the + # cost│skills-plugins vsep when that trailing segment is present, + # else the cell runs unbounded to the row's own end (no right + # anchor to centre against, so left-anchor with a fixed offset). + _cost_left = vsep_cols[1] if _has_lines_seg else vsep_cols[0] + _cost_lbl = f'cost{_suf}' + if _has_leader_seg: + _cost_mid = (_cost_left + vsep_cols[-1]) // 2 + tok_labels.append((_cost_lbl, max(_cost_left + 1, _cost_mid - len(_cost_lbl) // 2))) + tok_labels.append(('skills + plugins', vsep_cols[-1] + 2)) + else: + tok_labels.append((_cost_lbl, _cost_left + 2)) + # `lines read/changed` caption, centred between the first two vseps + # — only present when that segment itself is. The cell here is + # narrow enough that `_fit_label` (borders.py) almost always + # renders the abbreviated form ('loc r/w', LABEL_ABBREVIATIONS), + # not the full LINES_LABEL text passed through `tok_labels` — so + # centring must be computed against the abbreviation's length, + # not the long form's, or the placed text lands 3-4 columns left + # of true centre (the anchor is a start-of-text position, never + # re-centred once `_overlay_labels` picks a shorter rendered form). + if _has_lines_seg: + _lines_disp = LABEL_ABBREVIATIONS.get(LINES_LABEL, LINES_LABEL) + _lines_mid = (vsep_cols[0] + vsep_cols[1]) // 2 + tok_labels.append((LINES_LABEL, max(vsep_cols[0] + 1, _lines_mid - len(_lines_disp) // 2))) rows.append(RowSpec('separator_dim', downs=vsep_cols, labels=tok_labels)) for lt in line_tokens: rows.append(RowSpec('content', content=lt)) @@ -1551,13 +1561,17 @@ def sep_kind(normal: str) -> str: rows.append(RowSpec('content', content=r.tool_counts_row(tc.counts, width, fill=fill))) pending_ups = () - if plugins_line: - # Single "skills + plugins" caption anchored at content start (col 3). - plugins_labels: list[tuple[str, int]] = ( - [('skills + plugins', 3)] if view.cfg.labels else [] - ) - rows.append(RowSpec(sep_kind('separator_dim'), ups=pending_ups, labels=plugins_labels)) - rows.append(RowSpec('content', content=plugins_line)) + # Standalone "tokens over time" row (rate label + live sparkline), off by + # default (`cfg.show_tokens_over_time`) — formerly the trailing segment of + # the tokens/cost row above ("skills + plugins" now occupies that column + # instead; see the `tokens_cost` call earlier), now full-width on its own + # line below it. + if tokens_fits and view.cfg.show_tokens_over_time: + tot_labels: list[tuple[str, int]] = [('tokens over time', 3)] if view.cfg.labels else [] + rows.append(RowSpec(sep_kind('separator_dim'), ups=pending_ups, labels=tot_labels)) + rows.append(RowSpec('content', content=r.tokens_over_time( + tok_rate, session.session_id, width, fill=fill, show_icons=view.cfg.show_icons, + ))) pending_ups = () last_prompt_ts = read_last_prompt_ts(session.session_id) diff --git a/claude/yas/render/text.py b/claude/yas/render/text.py index 6cea012..5859887 100644 --- a/claude/yas/render/text.py +++ b/claude/yas/render/text.py @@ -88,6 +88,22 @@ def _visible_width(s: str) -> int: return sum(2 if _is_wide(ch) else 1 for ch in plain) +def _ansi_byte_offset(ansi: str, plain_idx: int) -> int: + """Return the byte (str index) in *ansi* that corresponds to plain-text + position *plain_idx* (0-indexed visible character count, ANSI escapes + excluded). Returns ``len(ansi)`` when *plain_idx* >= visible width.""" + pos = 0 # current byte position in `ansi` + vis = 0 # visible characters counted so far + while pos < len(ansi) and vis < plain_idx: + m = _ANSI_RE.match(ansi, pos) + if m: + pos = m.end() + continue + pos += 1 + vis += 1 + return pos + + def strike(s: str) -> str: """Wrap ``s``'s non-blank core in SGR 9 (strikethrough), padding excluded. diff --git a/claude/yas/renderer.py b/claude/yas/renderer.py index 0b20f05..a560344 100644 --- a/claude/yas/renderer.py +++ b/claude/yas/renderer.py @@ -117,7 +117,7 @@ from yas.info.subagents import RunningSubagent from yas.info.workflows import RunningWorkflow from yas.info.tasks import TaskList -from yas.render.text import _middle_ellipsis, _visible_width, fmt_tok, fmt_tok_fixed, strike +from yas.render.text import _ansi_byte_offset, _middle_ellipsis, _visible_width, fmt_tok, fmt_tok_fixed, strike from yas.tokens import TokenRate if TYPE_CHECKING: @@ -1698,32 +1698,31 @@ def task_row(self, tasks: TaskList, content_width: int, *, compact: bool = False # after every cap is met still feeds the rate/sparkline leader (as before). JUSTIFY_PAD_CAP = 4 - def tokens_cost(self, sess_in: int, sess_cache: int, sess_out: int, day_in: int, day_cache: int, day_out: int, sess_cost: float, day_cost: float, tok_rate: int, session_id: str = '', box_width: int = 80, fill: float = 1.0, show_day_stats: bool = True, justify: bool = False, lines: tuple[int, int] | None = None, show_icons: bool = True) -> tuple[list[str], tuple[int, ...], int, int]: - """One content line: tokens │ [lines │] cost │ rate-and-sparkline. + def tokens_cost(self, sess_in: int, sess_cache: int, sess_out: int, day_in: int, day_cache: int, day_out: int, sess_cost: float, day_cost: float, trailing_content: str = '', session_id: str = '', box_width: int = 80, fill: float = 1.0, show_day_stats: bool = True, justify: bool = False, lines: tuple[int, int] | None = None, show_icons: bool = True) -> tuple[list[str], tuple[int, ...], int, int, bool]: + """One content line: tokens │ [lines │] cost │ [trailing_content]. With ``show_day_stats`` (default), session and day figures merge per field as ``session/day`` with a paired cache parenthetical. When off, the row is session-only and keeps the original per-field justification. - When ``justify`` is on (and day stats are shown), horizontal slack that - would otherwise all flow to the sparkline leader is first spent as - breathing room *inside* the sections — widening the two inter-group gaps - in the tokens column and padding the cost/leader edges, each capped at - ``JUSTIFY_PAD_CAP`` spaces. ``min_width`` is unchanged: the optional - padding only consumes genuine slack, so at the tight floor the gaps - collapse to 1 and the row fits exactly as with ``justify`` off. + When ``justify`` is on (and day stats are shown), horizontal slack is + spent as breathing room *inside* the sections — widening the two + inter-group gaps in the tokens column and padding the cost edges, each + capped at ``JUSTIFY_PAD_CAP`` spaces. ``min_width`` is unchanged: the + optional padding only consumes genuine slack, so at the tight floor the + gaps collapse to 1 and the row fits exactly as with ``justify`` off. The tokens and cost columns are sized to the *measured* content (floored at a realistic-widest budget), so the two ``│`` dividers always land on the rendered content's divider column — they never detach from the ┬/┴ elbows above/below. ``show_icons`` (default on) gates every per-number glyph in this row — - the in/out token arrows, the cost icon, the lines read/changed icons, - and the rate-label gauge icon. When off, each icon (and its trailing - gap) is simply omitted from the builder closures below; every width - (``tokens_w``, ``cost_w``, ``lines_w``, ``rate_label_w``) is measured - from the *built* string via ``_visible_width``, so the column/vsep - math downstream adapts automatically — no separate width branch needed. + the in/out token arrows, the cost icon, and the lines read/changed + icons. When off, each icon (and its trailing gap) is simply omitted + from the builder closures below; every width (``tokens_w``, ``cost_w``, + ``lines_w``) is measured from the *built* string via ``_visible_width``, + so the column/vsep math downstream adapts automatically — no separate + width branch needed. ``lines``, when given, is a ``(read, changed)`` session-total pair rendered as a third segment between tokens and cost — but only when @@ -1734,22 +1733,35 @@ def tokens_cost(self, sess_in: int, sess_cache: int, sess_out: int, day_in: int, checked by the caller) is unaffected by this shed rule — it is computed from the without-segment ``min_width`` only. + ``trailing_content`` is pre-rendered content (e.g. "skills + plugins") + appended as a fourth, content-measured segment after cost — included + whenever the box has room for it, *even when empty*: this segment's + divider/border must not disappear just because there's nothing to + show (blank-padded to the leader column's width in that case). This + replaces the old in-row rate/sparkline leader, which is now its own + standalone row (see ``tokens_over_time``). + Shed ladder (highest-retained first): tokens sess/day, then loc r/w, - then cost, then tokens-over-time (the rate label + sparkline leader). - The richest form (everything the box has room for, per the existing - gates above) is tried first; if IT overflows ``box_width``, the row - falls through progressively leaner rungs that each drop exactly one - segment in shed order (tokens-over-time first, then cost, then loc) - until only tokens sess/day remains — the protected segment that is - never shed. ``vsep_cols`` shrinks by one column per rung dropped. - - Returns ``([line], vsep_cols, 0, min_width)``: ``vsep_cols`` has 0-3 - entries depending on which rung was used — the divider columns for - the builder's elbow threading — the dead mark_col (the old 60s tick - marker is gone, =0), and ``min_width`` — the smallest box width at - which this row fits without overflow, i.e. the floor of the + then cost, then the trailing content. The richest form (everything the + box has room for, per the existing gates above) is tried first; if IT + overflows ``box_width``, the row falls through progressively leaner + rungs that each drop exactly one segment in shed order (trailing + content first, then cost, then loc) until only tokens sess/day + remains — the protected segment that is never shed. ``vsep_cols`` + shrinks by one column per rung dropped. + + Returns ``([line], vsep_cols, 0, min_width, has_lines)``: ``vsep_cols`` + has 0-3 entries depending on which rung was used — the divider + columns for the builder's elbow threading — the dead mark_col (the + old 60s tick marker is gone, =0), ``min_width`` — the smallest box + width at which this row fits without overflow, i.e. the floor of the surviving-minimum form (tokens sess/day alone), independent of - whether ``lines``/cost/the leader end up shown at a given width. + whether ``lines``/cost/the trailing content end up shown at a given + width — and ``has_lines`` — whether the loc r/w segment survived into + the returned ``line`` (both the initial width gate AND the shed + ladder), so the caller can anchor labels to it without re-deriving + the same decision by sniffing the rendered content for a glyph that + ``show_icons=False`` would hide. """ day_clr = self.day_cost_colour(day_cost) in_active, out_active = TokenRate.recently_active(session_id) @@ -1771,7 +1783,6 @@ def tokens_cost(self, sess_in: int, sess_cache: int, sess_out: int, day_in: int, # widens them from genuine slack only (see the pad block after min_width). gap1 = gap2 = ' ' # ↓in/day | (cache) | ↑out/day inter-group gaps cost_lpad = cost_rpad = '' - leader_lpad = '' if show_day_stats: # Merged session/day per field; variable width, no fixed rjust (D2) @@ -1843,10 +1854,9 @@ def build_lines() -> str: vsep_w = 4 vsep_leader_w = 4 vsep_lines_w = 4 - label_w = 15 content_w = box_width - 3 - inner = content_w - vsep_w - vsep_leader_w # tokens + cost + leader budget + inner = content_w - vsep_w # tokens + cost budget (lines/trailing subtracted below when included) # Section widths track the *measured* content so each column hugs its # content and the two │ dividers sit directly after it (only the vsep's @@ -1864,22 +1874,16 @@ def build_lines() -> str: # tokens sess/day is the protected survivor of the shed ladder below, # so `min_width` is derived from THIS, not from the richest form. tokens_base_w = tokens_w - # The rate/spark leader can never compress below its bare `` t/m`` - # label; measure it here so the budget split and min_width are exact (when - # bar_w<=0 below, the leader is the bare label, which may exceed label_w+1). - rate_icon = f'{self.TOK_ICON}{ICON_TOK_RATE} ' if show_icons else '' - rate_label = f'{rate_icon}{self.TOK}{fmt_tok(tok_rate)}{self.R}{self.LABEL} t/m{self.R}' - rate_label_w = _visible_width(rate_label) - leader_min = max(label_w + 1, rate_label_w) - # The smallest box that holds both columns at their measured size plus the - # two vseps and the leader. Derived from the measured content, so it tracks - # token/cost/rate magnitude rather than being hardcoded. The leader floor - # here is the bare ``rate_label_w``, not ``leader_min``: at the tightest - # box the sparkline is omitted (bar_w<10) and the leader collapses to the - # bare `` t/m`` label, so the row genuinely fits at that narrower - # width. The builder only emits this row when ``box_width >= min_width``. - min_width = tokens_w + cost_w + vsep_w + vsep_leader_w + rate_label_w + 3 + # The trailing column is content-measured only, with no minimum reserve + # of its own (unlike the old rate/spark leader) -- it either fits at its + # full measured width or is shed entirely (see include_leader below). + trailing_w = _visible_width(trailing_content) + + # The smallest box that holds both columns at their measured size plus + # the tokens│cost vsep. Derived from the measured content, so it tracks + # token/cost magnitude rather than being hardcoded. + min_width = tokens_w + cost_w + vsep_w + 3 # The lines segment's own measured width and the with-segment floor. # Included only when the box clears both this floor and the fixed @@ -1890,30 +1894,41 @@ def build_lines() -> str: min_width_with_lines = min_width + lines_w + vsep_lines_w include_lines = lines is not None and box_width >= max(min_width_with_lines, LINES_SEGMENT_MIN_WIDTH) if include_lines: - inner -= vsep_lines_w # the lines segment's own vsep, alongside vsep_w/vsep_leader_w above + inner -= vsep_lines_w # the lines segment's own vsep + + # The trailing segment's own gate, mirroring include_lines: only shown + # when the box has genuine room for it at its full measured width. + min_width_with_leader = min_width + trailing_w + vsep_leader_w + # Included whenever the box has room -- unlike `include_lines`, this + # is NOT gated on `trailing_content` being non-empty: the "skills + + # plugins" section is always shown, blank-padded when there is + # nothing to display, so its border (divider + ┬/┴ elbows + label) + # never disappears just because no skills/plugins are loaded. + include_leader = box_width >= min_width_with_leader + if include_leader: + inner -= vsep_leader_w # the trailing segment's own vsep # Justify breathing room: spend genuine slack as padding *inside* the - # sections before it flows to the sparkline. ``free`` is the room beyond - # the tight minimum (min-gap content + min leader); it is exactly the - # slack that today all lands in the leader. We never touch ``min_width``, - # so at the floor ``free`` is 0, the gaps stay at 1, and the row is - # byte-for-byte the justify-off layout. Slots fill toward their caps via - # an even round-robin; whatever is consumed shrinks the leader by the - # same amount, and the remainder still feeds the sparkline. - # NOTE: the lines segment (when included) does NOT get a slot here — - # it is content-measured only (see w_lines below), same as tokens_col/ - # cost_col before padding. This is deliberate, not an oversight: giving - # it justify breathing room would make its width (and therefore col2/ - # col3) depend on `justify`, which no other content-measured segment - # in this row does. + # sections. ``free`` is the room beyond the tight minimum (min-gap + # content only -- the trailing segment never competes for this slack, + # see the NOTE below). We never touch ``min_width``, so at the floor + # ``free`` is 0, the gaps stay at 1, and the row is byte-for-byte the + # justify-off layout. Slots fill toward their caps via an even + # round-robin. + # NOTE: neither the lines segment nor the trailing segment gets a slot + # here — both are content-measured only (see w_lines/leader_w below), + # same as tokens_col/cost_col before padding. This is deliberate, not + # an oversight: giving them justify breathing room would make their + # width (and therefore the divider columns) depend on `justify`, which + # no other content-measured segment in this row does. cap = self.JUSTIFY_PAD_CAP if justify and show_day_stats: - free = max(0, inner - tokens_w - cost_w - leader_min) + free = max(0, inner - tokens_w - cost_w) # (slot extra above its 1-space/0-space minimum, per-slot cap). # gap1, gap2 sit at 1 already → extra cap is cap-1; the edge pads # sit at 0 → extra cap is the full cap. - slots = [cap - 1, cap - 1, cap, cap, cap] # gap1, gap2, cost_l, cost_r, leader_l - give = [0, 0, 0, 0, 0] + slots = [cap - 1, cap - 1, cap, cap] # gap1, gap2, cost_l, cost_r + give = [0, 0, 0, 0] budget = min(free, sum(slots)) while budget > 0 and any(give[i] < slots[i] for i in range(len(slots))): for i in range(len(slots)): @@ -1926,11 +1941,10 @@ def build_lines() -> str: gap2 = ' ' * (1 + give[1]) cost_lpad = ' ' * give[2] cost_rpad = ' ' * give[3] - leader_lpad = ' ' * give[4] # Rebuild the padded strings and grow the measured widths by the # injected pad so the budget split and col1/col2 follow the new - # divider positions exactly (the leader pad is accounted separately - # below). min_width above stays on the unpadded floor. + # divider positions exactly. min_width above stays on the unpadded + # floor. tokens_col = build_tokens() cost_col = build_cost() tokens_w += give[0] + give[1] @@ -1940,17 +1954,15 @@ def build_lines() -> str: # sizing and col1/col2 always land on the rendered │. TOKENS_BUDGET = tokens_w COST_BUDGET = cost_w - leader_lpad_w = len(leader_lpad) - avail = inner - leader_min # room left after the leader minimum - if TOKENS_BUDGET + COST_BUDGET <= avail: + if TOKENS_BUDGET + COST_BUDGET <= inner: w_middle, w_end = TOKENS_BUDGET, COST_BUDGET else: # Over budget: give each column at least its measured content, then # share any slack proportionally. Clamping at content (not the inflated # proportional share) keeps the cell sum from spilling past col1/col2. - w_middle = max(tokens_w, avail * TOKENS_BUDGET // (TOKENS_BUDGET + COST_BUDGET)) - w_end = max(cost_w, avail - w_middle) + w_middle = max(tokens_w, inner * TOKENS_BUDGET // (TOKENS_BUDGET + COST_BUDGET)) + w_end = max(cost_w, inner - w_middle) # Honest floor: never allocate a cell narrower than its own content. This # keeps the trailing pad >= 0 so the │ lands exactly at col1/col2. @@ -1968,56 +1980,58 @@ def build_lines() -> str: tokens_col += ' ' * max(0, w_middle - tokens_w) cost_col += ' ' * max(0, w_end - cost_w) - leader_w = max(label_w + 1, inner - w_middle - w_lines - w_end) - col1 = w_middle + 5 # 1-indexed position of the tokens│ vsep if include_lines: col2 = col1 + vsep_w + w_lines # 1-indexed position of the lines│ vsep - col3 = col2 + vsep_lines_w + w_end # 1-indexed position of the vsep_leader │ + col_after_lines = col2 + vsep_lines_w + w_end # 1-indexed position of the vsep_leader │ else: - col2 = w_middle + vsep_w + w_end + 5 # 1-indexed position of the vsep_leader │ (today's shape) - vsep = self.vsep_block(col1, box_width, fill=fill, leader=True) - vsep_leader = self.vsep_block(col3 if include_lines else col2, box_width, fill=fill, leader=True) + col_after_lines = w_middle + vsep_w + w_end + 5 # 1-indexed position of the vsep_leader │ (today's shape) + vsep = self.vsep_block(col1, box_width, fill=fill, leader=True) if include_lines: - lines_col = build_lines() - vsep_lines = self.vsep_block(col2, box_width, fill=fill, leader=True) - - # The justify leader pad sits between the vsep_leader │ and the rate - # label; it eats from the leader budget so the sparkline shrinks by the - # same amount it grew the breathing room. - bar_w = leader_w - rate_label_w - leader_lpad_w - - if bar_w < 10: - leader = f'{leader_lpad}{rate_label}' - else: - if session_id: - # 1 second per char (D4): span the most recent bar_w seconds, one - # char each (window == bar_w → 1s buckets). History is - # oldest→newest, so reverse it to put the newest (live) bucket on - # the LEFT, next to the t/m label — sparkline_1row dims that - # now-leftmost cell. - spark_history = TokenRate.history(session_id, bar_w, float(bar_w))[::-1] - spark = self.sparkline_1row(spark_history, live=True) + lines_col = build_lines() + vsep_lines = self.vsep_block(col2, box_width, fill=fill, leader=True) + + if include_leader: + vsep_leader = self.vsep_block(col_after_lines, box_width, fill=fill, leader=True) + leader_w = max(0, inner - w_middle - w_lines - w_end) + if trailing_w <= leader_w: + leader = trailing_content + ' ' * (leader_w - trailing_w) + elif leader_w > 0: + cut = _ansi_byte_offset(trailing_content, max(0, leader_w - 1)) + leader = f'{trailing_content[:cut]}{ELLIPSIS}{RESET}' else: - spark = ' ' * bar_w - leader = f'{leader_lpad}{rate_label}{spark}' + leader = '' vsep_cols: tuple[int, ...] - if include_lines: - line = f'{tokens_col}{vsep}{lines_col}{vsep_lines}{cost_col}{vsep_leader}{leader}' - vsep_cols = (col1, col2, col3) - else: - line = f'{tokens_col}{vsep}{cost_col}{vsep_leader}{leader}' + if include_leader: + if include_lines: + line = f'{tokens_col}{vsep}{lines_col}{vsep_lines}{cost_col}{vsep_leader}{leader}' + vsep_cols = (col1, col2, col_after_lines) + else: + line = f'{tokens_col}{vsep}{cost_col}{vsep_leader}{leader}' + vsep_cols = (col1, col_after_lines) + elif include_lines: + line = f'{tokens_col}{vsep}{lines_col}{vsep_lines}{cost_col}' vsep_cols = (col1, col2) + else: + line = f'{tokens_col}{vsep}{cost_col}' + vsep_cols = (col1,) # Shed ladder (highest-retained first): tokens sess/day -> loc r/w -> - # cost -> tokens-over-time (rate label + sparkline). The richest form - # built above is tried first; if it overflows the box, fall through - # progressively leaner rungs that each drop exactly one segment, in - # the order tokens-over-time -> cost -> loc, until we land on tokens - # sess/day alone, which is the protected survivor and is never shed. - # `min_width` (below) reflects THIS floor, not the richest form's. + # cost -> trailing content. The richest form built above is tried + # first; if it overflows the box, fall through progressively leaner + # rungs that each drop exactly one segment, in the order trailing + # content -> cost -> loc, until we land on tokens sess/day alone, + # which is the protected survivor and is never shed. `min_width` + # (below) reflects THIS floor, not the richest form's. content_w = box_width - 3 + # Tracks whether the lines segment survives into the FINAL rung + # actually used below -- the caller needs this to anchor the 'loc r/w' + # and 'cost sess/day' labels correctly, and can't reliably re-derive + # it by sniffing the rendered content for the read-glyph, since + # `show_icons=False` omits that glyph even when the segment is + # present (see layout.py's tok_labels build). + has_lines_final = include_lines if _visible_width(line) > content_w: if include_lines: rung_b = f'{tokens_col}{vsep}{lines_col}{vsep_lines}{cost_col}' @@ -2031,10 +2045,38 @@ def build_lines() -> str: line, vsep_cols = f'{tokens_col}{vsep}{lines_col}', (col1,) else: line, vsep_cols = tokens_col, () + has_lines_final = False min_width = tokens_base_w + 3 - return [line], vsep_cols, 0, min_width + return [line], vsep_cols, 0, min_width, has_lines_final + + def tokens_over_time(self, tok_rate: int, session_id: str, box_width: int, fill: float = 1.0, show_icons: bool = True) -> str: + """Full-width content line: rate label + live sparkline leader. + + Formerly the trailing segment of ``tokens_cost`` ("tokens over time"); + now its own standalone row so ``tokens_cost``'s trailing column is free + for other content (e.g. "skills + plugins"). ``session_id`` empty (no + history available) renders a blank span instead of a sparkline. + """ + rate_icon = f'{self.TOK_ICON}{ICON_TOK_RATE} ' if show_icons else '' + rate_label = f'{rate_icon}{self.TOK}{fmt_tok(tok_rate)}{self.R}{self.LABEL} t/m{self.R}' + rate_label_w = _visible_width(rate_label) + + bar_w = (box_width - 3) - rate_label_w + if bar_w < 10: + return rate_label + if session_id: + # 1 second per char (D4): span the most recent bar_w seconds, one + # char each (window == bar_w → 1s buckets). History is + # oldest→newest, so reverse it to put the newest (live) bucket on + # the LEFT, next to the t/m label — sparkline_1row dims that + # now-leftmost cell. + spark_history = TokenRate.history(session_id, bar_w, float(bar_w))[::-1] + spark = self.sparkline_1row(spark_history, live=True) + else: + spark = ' ' * bar_w + return f'{rate_label}{spark}' def context_bar(self, fill_ratio: float) -> str: ratio = min(max(fill_ratio, 0.0), 1.0) diff --git a/test/fixtures/claude_dark_wide.ansi b/test/fixtures/claude_dark_wide.ansi index 4bfbcb2..52501a9 100644 --- a/test/fixtures/claude_dark_wide.ansi +++ b/test/fixtures/claude_dark_wide.ansi @@ -3,5 +3,5 @@ ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┴┄┄┄┄┄┄┄┄┄┄┴┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┴┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ │   16.0K  (8%)  11%           ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┬┄┄┄┄┄┄┄┄┄┄┄┄┄┬┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┬┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ -│ ↓  0/0 (0/0) ↑ 0/0 │  0  0 │  $0.00 / $0.00 │ 󱢧 0 t/m                                                 │ +│ ↓  0/0 (0/0) ↑ 0/0 │  0  0 │  $0.00 / $0.00 │ │ ╰─────────────────────────┴─────────────┴───────────────────┴──────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/test_config.py b/test/test_config.py index 97b965d..bd26724 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -67,6 +67,7 @@ def test_default_when_nothing_set(tmp_path: Path) -> None: assert cfg.show_day_stats is True assert cfg.show_render_time is False assert cfg.show_tool_uses is False + assert cfg.show_tokens_over_time is False assert cfg.justify is False assert cfg.labels is False assert cfg.openspec_scan_depth == 1 @@ -295,6 +296,47 @@ def test_env_show_tool_uses_overrides_toml_false(tmp_path: Path) -> None: assert cfg.show_tool_uses is True +# show_tokens_over_time (standalone rate/sparkline row, wide layout; off by default) + +@requires_tomllib +def test_toml_show_tokens_over_time_true(tmp_path: Path) -> None: + (tmp_path / 'yas.toml').write_text('[layout]\nshow_tokens_over_time = true\n') + cfg = config.Config.load(env={}, config_dir=tmp_path) + assert cfg.show_tokens_over_time is True + + +def test_default_show_tokens_over_time_is_false(tmp_path: Path) -> None: + cfg = config.Config.load(env={}, config_dir=tmp_path) + assert cfg.show_tokens_over_time is False + + +@requires_tomllib +def test_toml_show_tokens_over_time_must_be_real_bool(tmp_path: Path) -> None: + (tmp_path / 'yas.toml').write_text('[layout]\nshow_tokens_over_time = "yes"\n') + cfg = config.Config.load(env={}, config_dir=tmp_path) + assert cfg.show_tokens_over_time is False # rejected to default + assert 'show_tokens_over_time' in cfg.errors + + +def test_env_show_tokens_over_time_falsy_values(tmp_path: Path) -> None: + for val in ('0', 'false', 'FALSE'): + cfg = config.Config.load(env={'YAS_SHOW_TOKENS_OVER_TIME': val}, config_dir=tmp_path) + assert cfg.show_tokens_over_time is False, f'expected False for YAS_SHOW_TOKENS_OVER_TIME={val!r}' + + +def test_env_show_tokens_over_time_truthy_values(tmp_path: Path) -> None: + for val in ('1', 'true', 'TRUE'): + cfg = config.Config.load(env={'YAS_SHOW_TOKENS_OVER_TIME': val}, config_dir=tmp_path) + assert cfg.show_tokens_over_time is True, f'expected True for YAS_SHOW_TOKENS_OVER_TIME={val!r}' + + +@requires_tomllib +def test_env_show_tokens_over_time_overrides_toml_true(tmp_path: Path) -> None: + (tmp_path / 'yas.toml').write_text('[layout]\nshow_tokens_over_time = true\n') + cfg = config.Config.load(env={'YAS_SHOW_TOKENS_OVER_TIME': '0'}, config_dir=tmp_path) + assert cfg.show_tokens_over_time is False + + # show_day_stats (seventh knob) def test_env_show_day_stats_zero_is_false(tmp_path: Path) -> None: diff --git a/test/test_labels_layout.py b/test/test_labels_layout.py index d12ad31..971663c 100644 --- a/test/test_labels_layout.py +++ b/test/test_labels_layout.py @@ -7,6 +7,8 @@ import time from pathlib import Path +import pytest + import yas.layout as layout import yas.renderer as renderer_mod import yas.session as session_mod @@ -61,13 +63,12 @@ def test_labels_on_paints_top_border_and_tokens_separator(): sep = _tokens_separator(lines) assert superscript('input') in sep assert superscript('cost') in sep - assert superscript('tokens over time') in sep def test_labels_off_has_no_superscripts(): lines = _render(labels=False) blob = '\n'.join(strip_ansi(ln) for ln in lines) - for word in ('5h', 'cache', 'input', 'cost', 'tokens over time'): + for word in ('5h', 'cache', 'input', 'cost'): assert superscript(word) not in blob @@ -155,7 +156,7 @@ def _tok_sep_and_content(lines: list[str]) -> tuple[str, str]: """The dim tokens separator and the tokens content row directly below it.""" for i, ln in enumerate(lines): plain = strip_ansi(ln) - if 't/m' in plain: + if '$' in plain: return strip_ansi(lines[i - 1]), plain return '', '' @@ -165,15 +166,29 @@ def _label_center(sep: str, word: str) -> float: return start + (len(word) - 1) / 2 -def _short_labels_view() -> SessionView: +def _short_labels_view(with_trailing: bool = False) -> SessionView: # day-stats off keeps the labels short enough to centre without contending # with the neighbouring token labels. - return SessionView(session_mod.SessionInfo.from_dict(_full_limits_dict()), + view = SessionView(session_mod.SessionInfo.from_dict(_full_limits_dict()), Config(labels=True, show_day_stats=False)) - - -def test_cost_label_centered_in_its_cell(): - lines = _render_view(_short_labels_view()) + if with_trailing: + # Inject a skill to populate the tokens/cost row's trailing + # "skills + plugins" segment with content -- the segment (and its + # right-hand vsep) is present either way (see `tokens_cost`'s + # `include_leader`, which no longer requires non-empty content), but + # this exercises the populated case explicitly. + from yas.info.skills import LoadedSkills + view.__dict__['skills'] = LoadedSkills(names=['demo:skill']) + return view + + +@pytest.mark.parametrize('with_trailing', [True, False]) +def test_cost_label_centered_in_its_cell(with_trailing: bool): + # The cost cell always has a right-hand vsep to centre against -- the + # trailing "skills + plugins" divider is present whether or not any + # skills/plugins are loaded (bug: it used to vanish along with the + # section when the list was empty). + lines = _render_view(_short_labels_view(with_trailing=with_trailing)) sep, cont = _tok_sep_and_content(lines) # border + 2 interior vseps normally, but at this width (200) the lines # read/changed segment is also included (box_width >= LINES_SEGMENT_MIN_WIDTH), @@ -186,8 +201,42 @@ def test_cost_label_centered_in_its_cell(): assert abs(_label_center(sep, 'cost') - cell_center) <= 1 +def test_skills_plugins_label_present_even_when_empty(): + # The "skills + plugins" section header is shown even with no + # skills/plugins loaded -- it must not disappear along with its content. + lines = _render_view(_short_labels_view(with_trailing=False)) + sep, _cont = _tok_sep_and_content(lines) + assert superscript('skills + plugins') in sep + + +def test_lines_and_cost_labels_present_with_icons_off(): + # Regression: with show_icons=False, tokens_cost's rendered row never + # carries the read-lines glyph layout.py used to sniff for -- it used to + # silently drop the 'loc r/w' caption and mis-anchor 'cost sess/day' onto + # the elbow between the loc and cost cells (dropped as well) whenever the + # lines segment was actually present. Both labels must still render. + view = SessionView(session_mod.SessionInfo.from_dict(_full_limits_dict()), + Config(labels=True, show_day_stats=False, show_icons=False)) + sep, _cont = _tok_sep_and_content(_render_view(view)) + assert superscript('loc r/w') in sep or superscript('loc read/write') in sep + assert superscript('cost') in sep + + +def test_lines_label_centered_in_its_cell(): + # 'loc read/write' almost always renders abbreviated ('loc r/w') in this + # cell -- centring must be computed against the abbreviation's length, + # not the long form's, or the placed text lands left of true centre + # (regression: the anchor used `len(LINES_LABEL)` while `_overlay_labels` + # placed the shorter abbreviated form). + lines = _render_view(_short_labels_view(with_trailing=True)) + sep, cont = _tok_sep_and_content(lines) + bars = [i for i, ch in enumerate(cont) if ch == '│'] + cell_center = (bars[1] + bars[2]) / 2 # loc r/w cell + assert abs(_label_center(sep, 'loc r/w') - cell_center) <= 1 + + def test_cache_label_centered_over_parenthetical(): - lines = _render_view(_short_labels_view()) + lines = _render_view(_short_labels_view(with_trailing=True)) sep, cont = _tok_sep_and_content(lines) open_i, close_i = cont.index('('), cont.index(')') assert abs(_label_center(sep, 'cache') - (open_i + close_i) / 2) <= 1 diff --git a/test/test_layout_seam.py b/test/test_layout_seam.py index 98975bf..50de3c4 100644 --- a/test/test_layout_seam.py +++ b/test/test_layout_seam.py @@ -75,10 +75,14 @@ def _kinds(spec: layout.LayoutSpec) -> list[str]: def _tokens_row_indices(spec: layout.LayoutSpec) -> list[int]: - """Content rows that carry the tokens/cost/rate line (the rate label 't/m').""" + """Content rows that carry the tokens/cost line. + + The rate label ('t/m') used to live inline in this row but now renders + in its own standalone `tokens_over_time` row (off by default) -- '$' (the + cost figure) is the stable marker for this row instead.""" from helper import strip_ansi return [i for i, row in enumerate(spec.rows) - if row.kind == 'content' and 't/m' in strip_ansi(row.content)] + if row.kind == 'content' and '$' in strip_ansi(row.content)] def test_tokens_row_is_single_content_line(monkeypatch: pytest.MonkeyPatch) -> None: @@ -99,8 +103,10 @@ def test_tokens_row_dividers_align_with_separators(monkeypatch: pytest.MonkeyPat separator above and ┴ on the separator below at the same visual column. At width=160 (>= LINES_SEGMENT_MIN_WIDTH=103) the lines read/changed - segment (design.md Decision 8) is included, so there are 3 interior │ - (lines | cost | rate) instead of the pre-Decision-8 2.""" + segment (design.md Decision 8) is included, and the trailing "skills + + plugins" divider is always present once the box has room (even with no + skills/plugins loaded) -- so there are 3 interior │ (tokens | lines | + cost | skills+plugins).""" from helper import strip_ansi _silence_dynamic(monkeypatch) # A dynamic section below ensures the row below tokens is a (seam) separator, @@ -261,18 +267,28 @@ def test_narrow_and_medium_no_cache_countdown(monkeypatch: pytest.MonkeyPatch) - def test_only_first_dynamic_separator_is_seam(monkeypatch: pytest.MonkeyPatch) -> None: - # Two dynamic sections (skills + subagents): first separator is the seam, - # the separator between them stays a normal dotted-dim separator. + # Two dynamic sections (subagents + a workflow run): first separator is + # the seam, the separator between them stays a normal dotted-dim + # separator. (Skills/plugins no longer produce a standalone section -- + # they merge into the tokens/cost row's trailing column instead.) + from yas.info.subagents import RunningSubagent as _WFAgent + from yas.info.workflows import RunningWorkflow, RunningWorkflows _silence_dynamic(monkeypatch) - monkeypatch.setattr(skills_mod.LoadedSkills, 'from_transcript', - classmethod(lambda cls, path: skills_mod.LoadedSkills(names=['x:demo']))) monkeypatch.setattr(subagents_mod.RunningSubagents, 'from_session', classmethod(lambda cls, sid, pdir: subagents_mod.RunningSubagents(subagents=[_make_sub()]))) - spec = layout.build_wide(_view(), _tick(), 140, _r) + view = _view() + now = time.time() + wf_agent = _WFAgent( + agent_type='wf-agent', description='', billed_in=0, output=0, + first_timestamp=now, total_input=0, end_ts=0.0, mtime=now, agent_id='wa1', + ) + run = RunningWorkflow(run_id='wf_x', name='wf_x', phase='', agents=[wf_agent]) + view.__dict__['workflows'] = RunningWorkflows(workflows=[run]) + spec = layout.build_wide(view, _tick(), 140, _r) kinds = _kinds(spec) assert kinds.count('separator_seam') == 1 seam_idx = kinds.index('separator_seam') - # A later separator (between skills and subagents) is normal, not a seam. + # A later separator (between subagents and the workflow run) is normal, not a seam. assert 'separator_dim' in kinds[seam_idx + 1:] @@ -745,16 +761,16 @@ def test_wide_bottom_band_drops_three_segment_tokens_row( monkeypatch: pytest.MonkeyPatch, ) -> None: """Below the fit floor (TOKENS_COST_MIN_WIDTH == MEDIUM_WIDTH == 80) the - three-segment tokens │ cost │ rate row is dropped (no 't/m' content row); - at/above it the row is present. TOKENS_COST_MIN_WIDTH is now pinned to - MEDIUM_WIDTH, so the floor sits below build_wide's own box >= 80 entry - point — passing a sub-80 width directly to build_wide (as this seam test - does) is the only way left to observe the dropped row.""" + tokens │ cost row is dropped (no '$' cost content row); at/above it the + row is present. TOKENS_COST_MIN_WIDTH is now pinned to MEDIUM_WIDTH, so + the floor sits below build_wide's own box >= 80 entry point — passing a + sub-80 width directly to build_wide (as this seam test does) is the only + way left to observe the dropped row.""" from helper import strip_ansi _silence_dynamic(monkeypatch) def has_tokens_row(spec: layout.LayoutSpec) -> bool: - return any(row.kind == 'content' and 't/m' in strip_ansi(row.content) + return any(row.kind == 'content' and '$' in strip_ansi(row.content) for row in spec.rows) assert not has_tokens_row(layout.build_wide(_view(), _tick(), 75, _r)) @@ -838,9 +854,10 @@ def agent_rows(width: int) -> list[layout.RowSpec]: # --------------------------------------------------------------------------- def test_long_plugins_row_clipped_to_box_width(monkeypatch: pytest.MonkeyPatch) -> None: - """A plugin list far wider than the box is clipped to the inner content - width with a trailing ellipsis instead of overflowing past the right - border — every rendered row stays exactly box-wide.""" + """A plugin list far wider than its column (now the trailing segment of + the tokens/cost row, not a standalone full-width row) is clipped with a + trailing ellipsis instead of overflowing past the right border — every + rendered row stays exactly box-wide.""" from helper import strip_ansi from yas.constants import ELLIPSIS from yas.render.text import _visible_width @@ -848,14 +865,16 @@ def test_long_plugins_row_clipped_to_box_width(monkeypatch: pytest.MonkeyPatch) plugins = ','.join(f'plugin-{i:02d}' for i in range(40)) # ~440 visible cols monkeypatch.setattr(session_mod.Workspace, 'plugins', property(lambda self: plugins)) - width = 140 + # Wide enough for the tokens/cost columns to leave meaningful room for the + # (still-clipped) trailing plugins content alongside them. + width = 400 spec = layout.build_wide(_view(), _tick(), width, _r) lines = [strip_ansi(ln) for ln in layout.render_layout(spec, _r)] for ln in lines: assert _visible_width(ln) == width, f'row overflows the box: {_visible_width(ln)} != {width}' plugins_lines = [ln for ln in lines if 'plugin-00' in ln] - assert plugins_lines, 'plugins row should render' - assert ELLIPSIS in plugins_lines[0], 'clipped plugins row should end with an ellipsis' + assert plugins_lines, 'plugins content should render in the tokens/cost row' + assert ELLIPSIS in plugins_lines[0], 'clipped plugins content should end with an ellipsis' def test_short_plugins_row_not_truncated(monkeypatch: pytest.MonkeyPatch) -> None: @@ -1047,13 +1066,15 @@ def test_clear_timer_sheds_entire_cell_on_path_protection( def test_tokens_row_three_elbows_at_wide_width(monkeypatch: pytest.MonkeyPatch) -> None: """At width=140 (>= LINES_SEGMENT_MIN_WIDTH=103) the lines read/changed - segment is included, so the tokens/cost separator row threads 3 elbows - (3-tuple downs/ups) instead of the pre-change 2.""" + segment is included, and the trailing "skills + plugins" divider is + always present once the box has room (even with no skills/plugins + loaded): the tokens/cost separator row threads 3 elbows (3-tuple + downs/ups: tokens|lines, lines|cost, cost|skills+plugins).""" from helper import strip_ansi _silence_dynamic(monkeypatch) spec = layout.build_wide(_view(), _tick(), 140, _r) # The tokens/cost separator is the separator_dim row immediately above the - # first tokens content row (the one carrying the 't/m' rate label). + # first tokens content row (the one carrying the '$' cost figure). t_idx = _tokens_row_indices(spec)[0] tokens_sep = spec.rows[t_idx - 1] assert tokens_sep.kind == 'separator_dim' @@ -1072,9 +1093,10 @@ def test_tokens_row_three_elbows_at_wide_width(monkeypatch: pytest.MonkeyPatch) def test_tokens_row_two_elbows_in_85_102_band(monkeypatch: pytest.MonkeyPatch) -> None: - """At width=95 (85 <= width < 103) the lines segment is shed: the - tokens/cost separator row threads only 2 elbows, identical to before this - change.""" + """At width=95 (85 <= width < 103) the lines segment is shed, but the + trailing "skills + plugins" divider is still present (always shown once + the box has room): the tokens/cost separator row threads 2 elbows + (tokens|cost, cost|skills+plugins).""" _silence_dynamic(monkeypatch) spec = layout.build_wide(_view(), _tick(), 95, _r) t_idx = _tokens_row_indices(spec)[0] diff --git a/test/test_tokens_cost.py b/test/test_tokens_cost.py index 1a8a06c..e303689 100644 --- a/test/test_tokens_cost.py +++ b/test/test_tokens_cost.py @@ -3,7 +3,7 @@ import pytest import yas.renderer as renderer -from yas.constants import GLYPH_LINES_CHANGED, GLYPH_LINES_READ, ICON_COST, ICON_TOK_RATE +from yas.constants import GLYPH_LINES_CHANGED, GLYPH_LINES_READ, ICON_COST from yas.render.text import _visible_width from helper import strip_ansi @@ -19,7 +19,7 @@ def _call(show_day_stats: bool = True, justify: bool = False, **over: Any) -> An sess_in=1, sess_cache=0, sess_out=2, day_in=3, day_cache=0, day_out=4, sess_cost=0.01, day_cost=0.02, - tok_rate=0, session_id='', box_width=BOX_WIDTH, + trailing_content='', session_id='', box_width=BOX_WIDTH, show_day_stats=show_day_stats, justify=justify, ) kw.update(over) @@ -29,37 +29,47 @@ def _call(show_day_stats: bool = True, justify: bool = False, **over: Any) -> An # Shape: exactly one content line def test_tokens_cost_returns_one_line() -> None: - lines, _cols, mark_col, _min = _call() + lines, _cols, mark_col, _min, _has_lines = _call() assert len(lines) == 1 assert mark_col == 0 # tick marker removed (D4) def test_tokens_cost_returns_one_line_session_only() -> None: - lines, _cols, _mark, _min = _call(show_day_stats=False) + lines, _cols, _mark, _min, _has_lines = _call(show_day_stats=False) assert len(lines) == 1 -# Divider columns line up with the rendered │ positions +# Shape: the "skills + plugins" trailing column always gets a divider once the +# box has room -- even with no trailing content, unlike the shed-when-too- +# narrow case below. Default box (160) is wide enough for tokens|cost plus +# the (empty) trailing column: a 2-tuple. + +def test_tokens_cost_empty_trailing_content_still_gets_divider() -> None: + _lines, cols, _mark, _min, _has_lines = _call() + assert len(cols) == 2 + def test_tokens_cost_cols_within_box() -> None: - _lines, (col1, col2), _mark, _min = _call() - assert 1 <= col1 < col2 <= BOX_WIDTH - 3 + _lines, cols, _mark, _min, _has_lines = _call() + for col in cols: + assert 1 <= col <= BOX_WIDTH - 3 def test_tokens_cost_divider_cols_match_rendered_bars() -> None: - # col1/col2 are 1-indexed columns assuming content starts at column 3 + # cols are 1-indexed columns assuming content starts at column 3 # (after the "│ " border lead); string index = col - 3. - lines, (col1, col2), _mark, _min = _call() + lines, cols, _mark, _min, _has_lines = _call() stripped = strip_ansi(lines[0]) - assert stripped[col1 - 3] == '│' - assert stripped[col2 - 3] == '│' + for col in cols: + assert stripped[col - 3] == '│' def test_tokens_cost_divider_cols_match_rendered_bars_with_lines_segment() -> None: - # Sibling of the 2-tuple case above: with the lines segment included (box - # wide enough to clear LINES_SEGMENT_MIN_WIDTH), vsep_cols is a 3-tuple and - # every reported column must still land on its rendered │. - lines, cols, _mark, _min = _call(box_width=110, lines=(1234, 567)) + # Sibling of the 2-tuple case above: with the lines segment ALSO included + # (box wide enough to clear LINES_SEGMENT_MIN_WIDTH), vsep_cols grows to a + # 3-tuple (tokens|lines, lines|cost, cost|leader) and every reported + # column must still land on its rendered │. + lines, cols, _mark, _min, _has_lines = _call(box_width=110, lines=(1234, 567)) assert len(cols) == 3 stripped = strip_ansi(lines[0]) for col in cols: @@ -68,50 +78,50 @@ def test_tokens_cost_divider_cols_match_rendered_bars_with_lines_segment() -> No def test_tokens_cost_dividers_track_content() -> None: # Columns hug their measured content, so larger token/cost magnitudes push - # both dividers further right than tiny content — they are not pinned to a - # fixed budget. The reported cols must still match the rendered │ exactly. - l1, (s_col1, s_col2), _m1, _s1 = _call( + # the first (tokens|cost) divider further right than tiny content — it is + # not pinned to a fixed budget. The reported col must still match the + # rendered │ exactly. + l1, s_cols, _m1, _s1, _h1 = _call( sess_in=1, sess_cache=0, sess_out=2, day_in=3, day_cache=0, day_out=4, sess_cost=0.01, day_cost=0.02, ) - l2, (b_col1, b_col2), _m2, _s2 = _call( + l2, b_cols, _m2, _s2, _h2 = _call( sess_in=128_400, sess_cache=1_245_000, sess_out=47_300, day_in=1_904_000, day_cache=18_300_000, day_out=612_500, sess_cost=3.27, day_cost=41.88, ) - assert b_col1 > s_col1 - assert b_col2 > s_col2 - for line, col1, col2 in ((l1[0], s_col1, s_col2), (l2[0], b_col1, b_col2)): + assert b_cols[0] > s_cols[0] + for line, cols in ((l1[0], s_cols), (l2[0], b_cols)): stripped = strip_ansi(line) - assert stripped[col1 - 3] == '│' - assert stripped[col2 - 3] == '│' + for col in cols: + assert stripped[col - 3] == '│' def test_tokens_cost_divider_grows_honestly_past_budget() -> None: # Once content exceeds the realistic-widest budget, the cell grows to hold it # so the divider never overflows — the │ shifts right rather than detaching. # The reported col must still match the rendered │ exactly. - lines, (col1, col2), _m, _s = _call( + lines, cols, _m, _s, _h = _call( sess_in=128_400, sess_cache=1_245_000, sess_out=47_300, day_in=1_904_000, day_cache=18_300_000, day_out=612_500, sess_cost=327.0, day_cost=4188.88, # cost '$ $327.00 / $4,188.88' = 21 cols > 20 budget ) stripped = strip_ansi(lines[0]) - assert stripped[col1 - 3] == '│' - assert stripped[col2 - 3] == '│' + for col in cols: + assert stripped[col - 3] == '│' def test_tokens_cost_dividers_differ_across_day_stats_toggle() -> None: # Columns hug content, so the merged session/day content (on) is wider than - # the session-only content (off); the dividers now differ between the two. - # Each render still keeps its │ at its reported cols. - l_on, (on_col1, on_col2), _m1, _s1 = _call(show_day_stats=True) - l_off, (off_col1, off_col2), _m2, _s2 = _call(show_day_stats=False) - assert (on_col1, on_col2) != (off_col1, off_col2) - for line, col1, col2 in ((l_on[0], on_col1, on_col2), (l_off[0], off_col1, off_col2)): + # the session-only content (off); the first divider now differs between the + # two. Each render still keeps its │ at its reported cols. + l_on, on_cols, _m1, _s1, _h1 = _call(show_day_stats=True) + l_off, off_cols, _m2, _s2, _h2 = _call(show_day_stats=False) + assert on_cols[0] != off_cols[0] + for line, cols in ((l_on[0], on_cols), (l_off[0], off_cols)): stripped = strip_ansi(line) - assert stripped[col1 - 3] == '│' - assert stripped[col2 - 3] == '│' + for col in cols: + assert stripped[col - 3] == '│' def test_tokens_cost_columns_hug_content() -> None: @@ -119,7 +129,7 @@ def test_tokens_cost_columns_hug_content() -> None: # 2-space lead — there is no extra pad past the content. Verify the rendered │ # matches the reported col, the two chars before it are the vsep lead spaces, # and the char before THAT is a non-space content char. - lines, (col1, _col2), _mark, _min = _call( + lines, (col1, *_rest), _mark, _min, _has_lines = _call( sess_in=128_400, sess_cache=1_245_000, sess_out=47_300, day_in=1_904_000, day_cache=18_300_000, day_out=612_500, sess_cost=3.27, day_cost=41.88, @@ -133,17 +143,48 @@ def test_tokens_cost_columns_hug_content() -> None: assert stripped[col1 - 6] != ' ' -def test_tokens_cost_rate_icon_after_second_divider() -> None: - lines, (_col1, col2), _mark, _min = _call() +# Trailing content column (e.g. "skills + plugins") + +def test_tokens_cost_trailing_content_appears_after_divider() -> None: + lines, cols, _mark, _min, _has_lines = _call(trailing_content='hello') stripped = strip_ansi(lines[0]) - # The rate-and-sparkline column begins just past the vsep_leader │. - assert ICON_TOK_RATE in stripped[col2 - 3:] + assert len(cols) == 2 + assert 'hello' in stripped[cols[-1] - 3:] + + +def test_tokens_cost_trailing_column_present_and_blank_when_content_empty() -> None: + # The "skills + plugins" section is shown (divider + blank padding) even + # with nothing to display -- its border must not depend on content. + with_empty = _call(trailing_content='') + lines, cols, _mark, _min, _has_lines = with_empty + assert len(cols) == 2 + stripped = strip_ansi(lines[0]) + assert stripped[cols[-1] - 2:].strip() == '' + + +def test_tokens_cost_trailing_content_dropped_when_too_narrow() -> None: + # A long trailing string can't fit at a tight box; the segment (and its + # divider) is shed and the row falls back to the tokens|cost shape. + lines, cols, _mark, _min, _has_lines = _call(box_width=90, trailing_content='x' * 200) + assert len(cols) == 1 + assert 'x' * 200 not in strip_ansi(lines[0]) + + +def test_tokens_cost_trailing_content_padded_to_column_width() -> None: + # A short trailing string is left-justified and padded with spaces out to + # the leader column's width, not truncated or centred. + lines, cols, _mark, _min, _has_lines = _call(trailing_content='hi') + stripped = strip_ansi(lines[0]) + # vsep_block(leader=True) renders the divider then a single trailing + # space; the leader text begins two columns past the │. + leader_region = stripped[cols[-1] - 3 + 2:] + assert leader_region.startswith('hi') # Merged session/day content (day stats on) def test_tokens_cost_merged_session_day_content() -> None: - lines, _cols, _mark, _min = _call( + lines, _cols, _mark, _min, _has_lines = _call( sess_in=128_400, sess_cache=1_245_000, sess_out=47_300, day_in=1_904_000, day_cache=18_300_000, day_out=612_500, sess_cost=3.27, day_cost=41.88, @@ -156,7 +197,7 @@ def test_tokens_cost_merged_session_day_content() -> None: # Session-only content (day stats off) def test_tokens_cost_session_only_content() -> None: - lines, _cols, _mark, _min = _call( + lines, _cols, _mark, _min, _has_lines = _call( show_day_stats=False, sess_in=128_400, sess_cache=1_245_000, sess_out=47_300, day_in=1_904_000, day_cache=18_300_000, day_out=612_500, @@ -170,46 +211,42 @@ def test_tokens_cost_session_only_content() -> None: assert '18.3M' not in s assert '612.5K' not in s assert '41.88' not in s - assert '/' not in s.split('t/m')[0] # no slash-merge before the rate label # Narrow-box regime (the 80-84 overflow / detached-divider bug). The wide layout -# owns box >= 80, but the three-segment row only genuinely fits around box 85. -# At every box width the rendered row must (i) not overflow the box and (ii) keep -# its two │ aligned with the reported divider cols. +# owns box >= 80, but the row's own content grows the floor with realistic +# magnitudes. At every box width the rendered row must (i) not overflow the box +# and (ii) keep its │ aligned with the reported divider col. # Realistic widest 6-7 digit magnitudes (the bug-report content). _NARROW = dict( sess_in=155_800, sess_cache=1_600_000, sess_out=18_000, day_in=8_400_000, day_cache=216_600_000, day_out=1_500_000, - sess_cost=6.15, day_cost=560.31, tok_rate=74_600, + sess_cost=6.15, day_cost=560.31, ) -@pytest.mark.parametrize('box', [85, 86]) -def test_tokens_cost_no_overflow_at_or_above_fit_floor(box: int) -> None: +def test_tokens_cost_no_overflow_at_or_above_fit_floor() -> None: # At/above its reported min_width the row fits the box exactly. (Below the # floor the row physically cannot shrink to its content minimum — that is why # build_wide drops it for the compact context line; see test_layout_seam.) - lines, _cols, _mark, min_w = _call(box_width=box, **_NARROW) - assert box >= min_w, (box, min_w) # 85/86 are at/above the floor for this content - # Content occupies box - 3 cols (2-col '│ ' lead + 1-col trailing '│'). - assert _visible_width(lines[0]) <= box - 3 + floor = _call(box_width=BOX_WIDTH, **_NARROW)[3] + for box in (floor, floor + 1, floor + 5): + lines, _cols, _mark, min_w, _has_lines = _call(box_width=box, **_NARROW) + assert box >= min_w, (box, min_w) + # Content occupies box - 3 cols (2-col '│ ' lead + 1-col trailing '│'). + assert _visible_width(lines[0]) <= box - 3 -@pytest.mark.parametrize('box', [80, 82, 84, 85]) +@pytest.mark.parametrize('box', [78, 80, 85, 90]) def test_tokens_cost_dividers_match_rendered_at_narrow_boxes(box: int) -> None: - # The assertion that previously only held at box 160: every reported divider - # column lands on the rendered │ — no detachment from the ┬/┴ elbows. - # box=80 is now narrow enough that the shed ladder drops the - # tokens-over-time (rate/sparkline) segment entirely -- one divider - # survives (tokens|cost) instead of two (tokens|cost|leader). - lines, cols, _mark, _min = _call(box_width=box, **_NARROW) + # Every reported divider column lands on the rendered │ — no detachment + # from the ┬/┴ elbows. The (empty) trailing "skills + plugins" divider + # still fits at these widths, so the shape is a 2-tuple (tokens|cost, + # cost|leader). + lines, cols, _mark, _min, _has_lines = _call(box_width=box, **_NARROW) stripped = strip_ansi(lines[0]) - if box == 80: - assert len(cols) == 1 - else: - assert len(cols) == 2 + assert len(cols) == 2 for col in cols: assert stripped[col - 3] == '│' @@ -217,9 +254,10 @@ def test_tokens_cost_dividers_match_rendered_at_narrow_boxes(box: int) -> None: @pytest.mark.parametrize('box', [103, 110, 130, 160]) def test_tokens_cost_dividers_match_rendered_at_wide_boxes_with_lines(box: int) -> None: # Sibling of the narrow-box divider check above, for the 3-tuple shape: - # every reported divider column (2 or 3, box-dependent) lands on the - # rendered │ once the lines segment is in play. - lines, cols, _mark, _min = _call(box_width=box, lines=(1234, 567), **_NARROW) + # every reported divider column lands on the rendered │ once both the + # lines segment and the trailing "skills + plugins" divider are in play. + lines, cols, _mark, _min, _has_lines = _call(box_width=box, lines=(1234, 567), **_NARROW) + assert len(cols) == 3 stripped = strip_ansi(lines[0]) for col in cols: assert stripped[col - 3] == '│' @@ -239,8 +277,9 @@ def test_tokens_cost_lines_segment_shed_below_103_is_byte_identical() -> None: def test_tokens_cost_lines_segment_present_at_or_above_103() -> None: # At a box wide enough to clear LINES_SEGMENT_MIN_WIDTH, the segment - # renders: vsep_cols grows to a 3-tuple and the glyphs/values appear. - lines, cols, _mark, _min = _call(box_width=110, lines=(1234, 567)) + # renders: vsep_cols grows to a 3-tuple (tokens|lines, lines|cost, + # cost|leader) and the glyphs/values appear. + lines, cols, _mark, _min, _has_lines = _call(box_width=110, lines=(1234, 567)) assert len(cols) == 3 s = strip_ansi(lines[0]) assert GLYPH_LINES_READ in s @@ -259,88 +298,40 @@ def test_tokens_cost_min_width_unchanged_when_lines_shed() -> None: assert min_with == min_without -def test_tokens_cost_sparkline_omitted_below_10_chars() -> None: - # The sparkline is dropped when fewer than 10 chars remain for the graph - # (bar_w < 10); the bare rate label survives. At a small box the leader - # collapses to its label_w+1 floor (16) and with a tiny rate label bar_w is - # 9, so the leader region after the 2nd divider is exactly the rate label - # width. At a wide box bar_w >= 10, so the leader region is wider (graph - # space present). Width-based so it doesn't depend on the on-disk rate log. - r = Renderer() - from yas.constants import ICON_TOK_RATE as _ICON - from yas.render.text import fmt_tok - rate_label_w = _visible_width( - f'{r.TOK_ICON}{_ICON} {r.TOK}{fmt_tok(0)}{r.R}{r.LABEL} t/m{r.R}' - ) - - def leader_region_w(box: int) -> int: - lines, (_c1, col2), _m, _s = _call(box_width=box) - s = strip_ansi(lines[0]) - # vsep_block(leader=True) renders the divider then a single trailing - # space; the leader text begins two columns past the │. - return _visible_width(s[col2 - 3 + 2:]) - - # Small box: bar_w < 10, sparkline omitted -> leader is just the bare label. - assert leader_region_w(60) == rate_label_w - # Wide box: bar_w >= 10, graph space present -> leader region is wider. - assert leader_region_w(BOX_WIDTH) > rate_label_w - # The rate label / icon stays present in both regimes. - assert ICON_TOK_RATE in strip_ansi(_call(box_width=60)[0][0]) - - -def test_tokens_cost_sparkline_omitted_below_10_chars_with_lines_segment() -> None: - # The sparkline-degrade behaviour above holds unchanged when the lines - # segment is present: bar_w < 10 still collapses the leader to the bare - # rate label, and the 3rd divider (lines segment) is still reported and - # matches its rendered │. - r = Renderer() - rate_label_w = _visible_width( - f'{r.TOK_ICON}{ICON_TOK_RATE} {r.TOK}{"74.6K"}{r.R}{r.LABEL} t/m{r.R}' - ) - - def leader_region_w(box: int) -> int: - lines, cols, _m, _s = _call(box_width=box, lines=(1234, 567), **_NARROW) - col2 = cols[-1] - s = strip_ansi(lines[0]) - return _visible_width(s[col2 - 3 + 2:]) - - # Narrow-but-lines-included box: bar_w < 10 -> bare label, same width as - # a rate label built from tok_rate=74_600 (matches _NARROW's tok_rate). - lines_narrow, cols_narrow, _m, _s = _call(box_width=103, lines=(1234, 567), **_NARROW) - assert len(cols_narrow) == 3 - assert leader_region_w(103) == rate_label_w - stripped_narrow = strip_ansi(lines_narrow[0]) - for col in cols_narrow: - assert stripped_narrow[col - 3] == '│' - # Wide box: bar_w >= 10, graph space present -> leader region is wider. - assert leader_region_w(160) > rate_label_w +def test_tokens_cost_min_width_unaffected_by_trailing_content() -> None: + # The trailing content column never affects the returned min_width — it is + # derived from the protected tokens-sess/day survivor alone. + for box in (60, 95, 160): + min_without = _call(box_width=box)[3] + min_with = _call(box_width=box, trailing_content='skills + plugins')[3] + assert min_with == min_without -# Justify breathing room (day stats on). Slack that would all feed the sparkline -# is first spent as padding *inside* the sections, each capped at 4 spaces. +# Justify breathing room (day stats on). Slack that would otherwise flow +# past the content is first spent as padding *inside* the sections. # Content with realistic magnitudes so the gaps/pads are visible in the strip. _JUSTIFY = dict( sess_in=17_900, sess_cache=34_600, sess_out=258, day_in=872_000, day_cache=33_000_000, day_out=306_100, - sess_cost=0.39, day_cost=85.48, tok_rate=18_100, + sess_cost=0.39, day_cost=85.48, ) def test_tokens_cost_justify_off_unchanged() -> None: # justify defaults to off; passing justify=False explicitly must be # byte-for-byte identical to the default call. - a_lines, a_cols, a_mark, a_min = _call(**_JUSTIFY) - b_lines, b_cols, b_mark, b_min = _call(justify=False, **_JUSTIFY) - assert (a_lines, a_cols, a_mark, a_min) == (b_lines, b_cols, b_mark, b_min) + a_lines, a_cols, a_mark, a_min, a_h = _call(**_JUSTIFY) + b_lines, b_cols, b_mark, b_min, b_h = _call(justify=False, **_JUSTIFY) + assert (a_lines, a_cols, a_mark, a_min, a_h) == (b_lines, b_cols, b_mark, b_min, b_h) def test_tokens_cost_justify_widens_gaps_and_pads_to_cap() -> None: # At a wide box with plenty of slack, justify fills every slot to the 4-space - # cap: the two tokens inter-group gaps become 4, and the cost LHS/RHS and the - # t/m leader LHS each get 4 spaces. - on, _c_on, _m_on, _s_on = _call(box_width=160, justify=True, **_JUSTIFY) - off, _c_off, _m_off, _s_off = _call(box_width=160, justify=False, **_JUSTIFY) + # cap: the two tokens inter-group gaps become 4, and the cost LHS/RHS each + # get 4 spaces. + on, _c_on, _m_on, _s_on, _h_on = _call(box_width=160, justify=True, **_JUSTIFY) + off, _c_off, _m_off, _s_off, _h_off = _call(box_width=160, justify=False, **_JUSTIFY) s_on = strip_ansi(on[0]) s_off = strip_ansi(off[0]) @@ -354,26 +345,23 @@ def test_tokens_cost_justify_widens_gaps_and_pads_to_cap() -> None: i = s_on.index(ICON_COST) # ICON_COST starts the cost cell assert s_on[i - 6:i] == '│' + ' ' * 5 # divider + 1 vsep trail + 4-space LHS cap assert '$85.48 ' in s_on # 4-space RHS cap trails the day cost - # t/m leader gains 4 spaces of LHS padding (again behind the 1 vsep-trail space). - j = s_on.index(ICON_TOK_RATE) # ICON_TOK_RATE leads the rate label - assert s_on[j - 6:j] == '│' + ' ' * 5 # divider + 1 vsep trail + 4-space leader cap def test_tokens_cost_justify_dividers_match_rendered_bars() -> None: - # The padding shifts col1/col2; both must still land exactly on the rendered - # │ so the ┬/┴ elbows above/below stay attached. - lines, (col1, col2), _mark, _min = _call(box_width=160, justify=True, **_JUSTIFY) + # The padding shifts the dividers; each must still land exactly on the + # rendered │ so the ┬/┴ elbows above/below stay attached. + lines, cols, _mark, _min, _has_lines = _call(box_width=160, justify=True, **_JUSTIFY) stripped = strip_ansi(lines[0]) - assert stripped[col1 - 3] == '│' - assert stripped[col2 - 3] == '│' + for col in cols: + assert stripped[col - 3] == '│' def test_tokens_cost_justify_min_width_unchanged() -> None: # The optional padding must not inflate min_width: the reported floor is # identical with justify on and off, and at that floor the row fits exactly. for box in range(78, 92): - _l_on, _c_on, _m_on, min_on = _call(box_width=box, justify=True, **_NARROW) - _l_off, _c_off, _m_off, min_off = _call(box_width=box, justify=False, **_NARROW) + _l_on, _c_on, _m_on, min_on, _h_on = _call(box_width=box, justify=True, **_NARROW) + _l_off, _c_off, _m_off, min_off, _h_off = _call(box_width=box, justify=False, **_NARROW) assert min_on == min_off # At the tight floor the gaps collapse to 1 (no slack), so the justify-on row # equals the justify-off row byte-for-byte. @@ -395,7 +383,7 @@ def test_tokens_cost_min_width_is_consistent_with_fit() -> None: # The reported min_width must be the exact smallest box at which the row fits # without overflow, so the builder's guard never under- or over-shows the row. for box in range(78, 92): - lines, _cols, _mark, min_w = _call(box_width=box, **_NARROW) + lines, _cols, _mark, min_w, _has_lines = _call(box_width=box, **_NARROW) fits = _visible_width(lines[0]) <= box - 3 assert fits == (box >= min_w), (box, min_w, _visible_width(lines[0])) @@ -410,40 +398,71 @@ def test_tokens_cost_show_icons_defaults_true() -> None: def test_tokens_cost_show_icons_false_drops_glyphs() -> None: - lines, _cols, _mark, _min = _call(show_icons=False, lines=(1234, 567), box_width=140) + lines, _cols, _mark, _min, _has_lines = _call(show_icons=False, lines=(1234, 567), box_width=140) text = lines[0] - for glyph in (ICON_COST, ICON_TOK_RATE, GLYPH_LINES_READ, GLYPH_LINES_CHANGED): + for glyph in (ICON_COST, GLYPH_LINES_READ, GLYPH_LINES_CHANGED): assert glyph not in text def test_tokens_cost_show_icons_true_keeps_glyphs() -> None: - lines, _cols, _mark, _min = _call(show_icons=True, lines=(1234, 567), box_width=140) + lines, _cols, _mark, _min, _has_lines = _call(show_icons=True, lines=(1234, 567), box_width=140) text = lines[0] - for glyph in (ICON_COST, ICON_TOK_RATE, GLYPH_LINES_READ, GLYPH_LINES_CHANGED): + for glyph in (ICON_COST, GLYPH_LINES_READ, GLYPH_LINES_CHANGED): assert glyph in text +def test_tokens_cost_has_lines_flag_true_when_segment_survives() -> None: + # Regression: `has_lines` must reflect the segment's actual inclusion in + # the returned line, not whether its (show_icons-gated) read glyph + # happens to be present -- the caller (layout.py) anchors the 'loc r/w' + # and 'cost sess/day' labels off this flag, not off glyph-sniffing. + _lines, _cols, _mark, _min, has_lines = _call(box_width=110, lines=(1234, 567)) + assert has_lines is True + + +def test_tokens_cost_has_lines_flag_true_with_icons_off() -> None: + # The bug this guards: with show_icons=False the read-lines glyph never + # appears in the rendered row even though the segment itself is present + # -- `has_lines` must still report True (it comes from the shed-ladder + # decision, not the rendered glyph). + lines, _cols, _mark, _min, has_lines = _call(show_icons=False, lines=(1234, 567), box_width=140) + assert has_lines is True + assert GLYPH_LINES_READ not in lines[0] # glyph absent, segment still present + + +def test_tokens_cost_has_lines_flag_false_when_shed() -> None: + # Below LINES_SEGMENT_MIN_WIDTH the segment is shed even though `lines=` + # was passed -- `has_lines` must report False so the caller doesn't try + # to anchor a label onto a segment that isn't there. + _lines, _cols, _mark, _min, has_lines = _call(box_width=95, lines=(1234, 567)) + assert has_lines is False + + +def test_tokens_cost_has_lines_flag_false_without_lines_arg() -> None: + _lines, _cols, _mark, _min, has_lines = _call() + assert has_lines is False + + def test_tokens_cost_show_icons_false_keeps_numbers_and_dividers() -> None: - lines, (col1, col2), _mark, _min = _call(show_icons=False, box_width=BOX_WIDTH) + lines, cols, _mark, _min, _has_lines = _call(show_icons=False, box_width=BOX_WIDTH) stripped = strip_ansi(lines[0]) - assert stripped[col1 - 3] == '│' - assert stripped[col2 - 3] == '│' + for col in cols: + assert stripped[col - 3] == '│' assert '0.01' in stripped and '0.02' in stripped def test_tokens_cost_show_icons_false_narrower_min_width() -> None: # Fewer glyphs means less content, so the row's own min_width floor with # icons off must not exceed the icons-on floor. - _l_on, _c_on, _m_on, min_on = _call(show_icons=True, **_NARROW) - _l_off, _c_off, _m_off, min_off = _call(show_icons=False, **_NARROW) + _l_on, _c_on, _m_on, min_on, _h_on = _call(show_icons=True, **_NARROW) + _l_off, _c_off, _m_off, min_off, _h_off = _call(show_icons=False, **_NARROW) assert min_off <= min_on def test_tokens_cost_show_icons_false_session_only_drops_cost_icon() -> None: - lines, _cols, _mark, _min = _call(show_icons=False, show_day_stats=False) + lines, _cols, _mark, _min, _has_lines = _call(show_icons=False, show_day_stats=False) text = lines[0] assert ICON_COST not in text - assert ICON_TOK_RATE not in text # show_icons=False, show_day_stats=True: with no icon to reserve the row's @@ -456,7 +475,7 @@ def test_tokens_cost_show_icons_false_session_only_drops_cost_icon() -> None: # after it or land the number flush against the row's own left border. def test_tokens_cost_show_icons_false_leading_number_right_justified() -> None: - lines, _cols, _mark, _min = _call(show_icons=False, sess_in=1) + lines, _cols, _mark, _min, _has_lines = _call(show_icons=False, sess_in=1) stripped = strip_ansi(lines[0]) # Row content starts right after the single border-gap space border_line # always inserts; IN_W is the reserved field width for the leading number. diff --git a/test/test_tokens_over_time.py b/test/test_tokens_over_time.py new file mode 100644 index 0000000..ddd2ea0 --- /dev/null +++ b/test/test_tokens_over_time.py @@ -0,0 +1,64 @@ +import yas.renderer as renderer +from yas.constants import ICON_TOK_RATE +from yas.render.text import _visible_width, fmt_tok +from helper import strip_ansi + +Renderer = renderer.Renderer + + +def _rate_label_w(r: renderer.Renderer, tok_rate: int, show_icons: bool = True) -> int: + icon = f'{r.TOK_ICON}{ICON_TOK_RATE} ' if show_icons else '' + return _visible_width(f'{icon}{r.TOK}{fmt_tok(tok_rate)}{r.R}{r.LABEL} t/m{r.R}') + + +def test_tokens_over_time_returns_single_line() -> None: + r = Renderer() + line = r.tokens_over_time(0, '', box_width=160) + assert isinstance(line, str) + assert '\n' not in line + + +def test_tokens_over_time_contains_rate_icon() -> None: + r = Renderer() + line = r.tokens_over_time(1234, '', box_width=160) + assert ICON_TOK_RATE in line + + +def test_tokens_over_time_fits_within_box() -> None: + r = Renderer() + for box in (60, 80, 110, 160, 220): + line = r.tokens_over_time(74_600, 'sess', box_width=box) + assert _visible_width(line) <= box - 3 + + +def test_tokens_over_time_sparkline_omitted_below_10_chars() -> None: + # The sparkline is dropped when fewer than 10 chars remain for the graph + # (bar_w < 10); the bare rate label survives. Width-based so it doesn't + # depend on the on-disk rate log. + r = Renderer() + rate_label_w = _rate_label_w(r, 0) + + def region_w(box: int) -> int: + return _visible_width(r.tokens_over_time(0, '', box_width=box)) + + # Small box: bar_w < 10, sparkline omitted -> region is just the bare label. + small_box = rate_label_w + 3 + 5 # box_width - 3 - rate_label_w == 5 < 10 + assert region_w(small_box) == rate_label_w + # Wide box: bar_w >= 10, graph space present -> region is wider. + assert region_w(160) > rate_label_w + assert ICON_TOK_RATE in r.tokens_over_time(0, '', box_width=small_box) + + +def test_tokens_over_time_blank_spark_without_session_id() -> None: + # No session_id -> no history to plot; the graph region is blank spaces + # rather than a rendered sparkline, but still fills the available width. + r = Renderer() + line = strip_ansi(r.tokens_over_time(500, '', box_width=160)) + rate_label_w = _rate_label_w(r, 500) + assert line[rate_label_w:] == ' ' * (157 - rate_label_w) + + +def test_tokens_over_time_show_icons_false_drops_icon() -> None: + r = Renderer() + line = r.tokens_over_time(1234, '', box_width=160, show_icons=False) + assert ICON_TOK_RATE not in line diff --git a/test/test_tool_counts_row.py b/test/test_tool_counts_row.py index 666c4f7..c317fc7 100644 --- a/test/test_tool_counts_row.py +++ b/test/test_tool_counts_row.py @@ -173,7 +173,7 @@ def test_row_directly_under_tokens(monkeypatch: pytest.MonkeyPatch) -> None: view.__dict__['tool_counts'] = ToolCounts({'Zbash': (5, 2)}) spec = layout.build_wide(view, _tick(), 160, _r) tok_idx = max(i for i, r in enumerate(spec.rows) - if r.kind == 'content' and 't/m' in strip_ansi(r.content)) + if r.kind == 'content' and '$' in strip_ansi(r.content)) tool_idx = next(i for i, r in enumerate(spec.rows) if r.kind == 'content' and 'Zbash' in strip_ansi(r.content)) # tokens content, then the seam separator, then the tool content row. From 0d4e98aa0b6febfc79776ac6152c77eb076cfed5 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 19 Aug 2026 21:23:17 +1000 Subject: [PATCH 2/2] Bump version to 0.7.2 --- .claude-plugin/plugin.json | 2 +- claude/yas/constants.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 134cbe8..07a58a0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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.7.2" } diff --git a/claude/yas/constants.py b/claude/yas/constants.py index 5f6b475..9a12880 100644 --- a/claude/yas/constants.py +++ b/claude/yas/constants.py @@ -8,7 +8,7 @@ # Keep in sync with pyproject.toml's [project] version — pyproject isn't # shipped with the runtime copy under ~/.claude, so the value lives here too. -VERSION = '0.7.1' +VERSION = '0.7.2' HOME = Path(os.path.expanduser('~')) CLAUDE_DIR = Path(os.environ.get('CLAUDE_CONFIG_DIR', str(HOME / '.claude'))) diff --git a/pyproject.toml b/pyproject.toml index 7b73003..518a90c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yet-another-statusline" -version = "0.7.1" +version = "0.7.2" description = "Claude Code statusline showing info at a glance: tokens, context, model, subagents, burn rate, skills, plugins, OpenSpec specs, task lists, and more" readme = "README.md" diff --git a/uv.lock b/uv.lock index 2a0501d..24daf58 100644 --- a/uv.lock +++ b/uv.lock @@ -421,7 +421,7 @@ wheels = [ [[package]] name = "yet-another-statusline" -version = "0.7.1" +version = "0.7.2" source = { virtual = "." } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" },