diff --git a/README.md b/README.md index 1056a46..aeecf0d 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ aliases when both are set — the aliases keep working but are deprecated. | `context_thresholds` | `YAS_CONTEXT_THRESHOLDS` | — | `[context].thresholds` | `25,50,70,90` | | `show_render_time` | `YAS_SHOW_RENDER_TIME` | — | `[layout].show_render_time` | `false` | | `show_tool_uses` | `YAS_SHOW_TOOL_USES` | — | `[layout].show_tool_uses` | `false` | +| `show_tokens_over_time` | `YAS_SHOW_TOKENS_OVER_TIME` | — | `[layout].show_tokens_over_time` | `false` | | `labels` | `YAS_LABELS` | — | `[layout].labels` | `false` | | `justify` | `YAS_JUSTIFY` | — | `[layout].justify` | `false` | | `show_day_stats` | `YAS_SHOW_DAY_STATS` | — | `[tokens].show_day_stats` | `true` | @@ -130,6 +131,7 @@ aliases when both are set — the aliases keep working but are deprecated. - **`full_width`** — when `true`, makes the box fill the terminal and ignore `max_width`. - **`show_render_time`** — when `true`, annotates the bottom-right border with the previous run's wall-clock render time (e.g. `…47.2ms──╯`). Off by default; each run shows the prior run's timing, so it is blank on a session's first render. - **`show_tool_uses`** — when `true`, adds a row (wide layout only) under the tokens/cost band, listing per-tool `tool_use` counts. +- **`show_tokens_over_time`** — when `true`, adds a full-width "tokens over time" row (wide layout only) under the tokens/cost band, showing the `t/m` rate and live sparkline. The trailing column of the tokens/cost row always shows skills + plugins; the sparkline is opt-in via this row. - **`labels`** — when `true`, paints small superscript field captions into the border/separator rows (wide layout only). **Note:** this is a different knob from `[context].labels`, which is the five-word state list — see [Context state word](#context-state-word). The two share a key name but live in different sections and take different types. - **`justify`** — when `true`, aligns fields into columns instead of packing them left (wide layout only). - **`show_day_stats`** — when `true` (the default), shows today's cumulative token and cost totals alongside the session's, as `session/day` pairs. **Note:** this key lives under **`[tokens]`**, not `[layout]`, unlike the other display toggles. diff --git a/claude/yas/config.py b/claude/yas/config.py index 52b6326..e0c973e 100644 --- a/claude/yas/config.py +++ b/claude/yas/config.py @@ -36,6 +36,7 @@ DEFAULT_THEME, DEFAULT_SHOW_DAY_STATS, DEFAULT_SHOW_TOOL_USES, + DEFAULT_SHOW_TOKENS_OVER_TIME, DEFAULT_TRANSCRIPT_CACHE, config_path, ) @@ -419,8 +420,9 @@ 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', 'transcript_cache', 'rate_limit_rules', 'errors', 'debug_lines', + 'show_render_time', 'show_tool_uses', 'show_tokens_over_time', 'soft_limit_models', + 'openspec_scan_depth', 'show_icons', 'transcript_cache', 'rate_limit_rules', + 'errors', 'debug_lines', ) max_width: int @@ -439,6 +441,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 @@ -465,6 +468,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, @@ -490,6 +494,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) @@ -512,6 +517,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}, transcript_cache={self.transcript_cache}, ' @@ -614,6 +620,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'), @@ -667,6 +677,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 801af94..ab2e1a2 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.9.0' +VERSION = '0.9.1' # Bumped by any future on-disk relayout under yas/; stamped into # state/version.json by yas.migrate so a future migration can detect and # convert an older layout. @@ -124,6 +124,7 @@ def settings_path() -> Path: 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 @@ -223,6 +224,25 @@ def settings_path() -> Path: # 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 = 300 + +# Minimum trailing-column width `tokens_cost` requires before it will include +# the "skills + plugins" column at all (few content cols + the ellipsis +# glyph). The column is included once this minimum fits, then clipped to +# whatever wider (or narrower) space is actually free -- gating on the full +# measured `trailing_content` width instead would shed the whole column for +# any list wider than the free space, rather than truncating it. +PLUGINS_TRAILING_MIN_W = 8 # 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 6b8c166..351de65 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, clip_visible, 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,50 +898,43 @@ 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 + # `PLUGINS_TRAILING_MAX_W` (see constants.py for why this is a fixed cap, + # not `width - 3`). `tokens_cost` clips further still, to whatever + # narrower (or wider) width the column actually gets once the + # tokens/cost segments claim their share. + plugins_avail = min(width - 3, PLUGINS_TRAILING_MAX_W) + plugins_line = clip_visible(plugins_line, plugins_avail) + # 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. This cost can be amortized when transcripts are # unchanged via the transcript cache (openspec/changes/cache-transcript-parses). - 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] @@ -1473,9 +1452,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_trailing_seg` + # then follows from the arithmetic (vsep_cols length == 1 + + # has_lines + has_trailing). tok_labels: list[tuple[str, int]] = [] if view.cfg.labels: _tp = _ANSI_RE.sub('', line_tokens[0]) @@ -1507,21 +1499,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_trailing_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_trailing_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)) @@ -1552,13 +1558,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 2a7420a..ff2e2f6 100644 --- a/claude/yas/render/text.py +++ b/claude/yas/render/text.py @@ -11,6 +11,7 @@ ELLIPSIS, GITHUB_TRANSLATE, MIDDLE_DOT, + RESET, STRIKE, UNICODE_TRANSLATE, UNSTRIKE, @@ -88,6 +89,49 @@ 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: + """Str index in ``ansi`` of visible position ``plain_idx``, ANSI escapes skipped whole (clamped to ``len(ansi)``).""" + pos = 0 + vis = 0 + 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 clip_visible(s: str, w: int) -> str: + """Clip ``s`` to ``w`` visible columns, appending ``ELLIPSIS`` + ``RESET`` if it overflows. + + ``s`` returned unchanged when it already fits. Cuts on a visible-column + boundary via ``_ansi_byte_offset`` so embedded ANSI escapes survive intact. + ``w <= 0`` returns ``''`` -- there is no room for even the ellipsis itself, + so emitting one would make the result 1 column wider than the budget + (the caller's divider/border lands one column short). + + When it does clip (``w >= 2``), the LAST column is always reserved as a + blank pad, not spent on the ellipsis -- every other cell in this row ends + ``' │'`` (a pad column before the divider/border), and a clipped cell + that ran its ellipsis flush to that column would end ``'…│'`` instead, + one column tighter than its neighbours (and, since ``ELLIPSIS`` is + East-Asian-ambiguous width, rendered 2 cols wide by some terminals -- + doubly reason not to let it sit flush against a border). At ``w == 1`` + there is no room for both the ellipsis and the pad, so the ellipsis alone + is returned. + """ + if w <= 0: + return '' + if _visible_width(s) <= w: + return s + if w == 1: + return f'{ELLIPSIS}{RESET}' + cut = _ansi_byte_offset(s, w - 2) + return f'{s[:cut]}{ELLIPSIS}{RESET} ' + + 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 9820ed7..f704379 100644 --- a/claude/yas/renderer.py +++ b/claude/yas/renderer.py @@ -85,6 +85,7 @@ ICON_TOK_RATE, PILL_LEFT, PILL_RIGHT, + PLUGINS_TRAILING_MIN_W, SEVEN_DAY_MINUTES, SEVEN_DAY_WARMUP_MINUTES, STRIKE, @@ -117,7 +118,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 _middle_ellipsis, _visible_width, clip_visible, fmt_tok, fmt_tok_fixed, strike from yas.tokens import TokenRate if TYPE_CHECKING: @@ -1702,58 +1703,39 @@ 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. - - 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. - 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. - - ``lines``, when given, is a ``(read, changed)`` session-total pair - rendered as a third segment between tokens and cost — but only when - the box is wide enough (``box_width >= max(min_width_with_lines, - LINES_SEGMENT_MIN_WIDTH)``); otherwise the segment and its ``│`` - divider are shed entirely and this method returns exactly today's - shape. ``TOKENS_COST_MIN_WIDTH`` (the row's own existence gate, - checked by the caller) is unaffected by this shed rule — it is - computed from the without-segment ``min_width`` only. - - 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 - surviving-minimum form (tokens sess/day alone), independent of - whether ``lines``/cost/the leader end up shown at a given width. + 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]. + + Invariants: + + - ``show_day_stats`` merges session/day per field as ``session/day`` + with a paired cache parenthetical; off, the row is session-only. + - ``justify`` (with day stats on) spends genuine slack as padding + *inside* the tokens/cost sections (capped at ``JUSTIFY_PAD_CAP``), + never touching ``min_width`` — at the tight floor the row is + byte-identical to ``justify`` off. + - The tokens/cost/lines columns are sized to their *measured* content + (via ``_visible_width``, never ``len()``), so their ``│`` dividers + always land on the rendered divider column. + - ``lines`` (a session-total ``(read, changed)`` pair) is included as + a third segment only when ``box_width >= max(min_width_with_lines, + LINES_SEGMENT_MIN_WIDTH)``; otherwise shed whole. + - ``trailing_content`` (e.g. "skills + plugins") is included as a + fourth segment once the box clears a small minimum + (``PLUGINS_TRAILING_MIN_W``) — blank-padded when empty, clipped + with an ellipsis when it doesn't fully fit the free width, shed + whole only when even the minimum doesn't fit. + - Shed ladder (highest-retained first, once the richest built form + overflows ``box_width``): tokens sess/day (never shed) ← loc r/w ← + cost ← trailing content. Each rung drops exactly one segment; + ``vsep_cols`` shrinks by one column per rung dropped. + + Returns ``([line], vsep_cols, 0, min_width, has_lines)`` — the dead + mark_col (=0) is a leftover 5-tuple slot; ``min_width`` is the floor + of the never-shed tokens-sess/day-alone form; ``has_lines`` reports + whether the loc r/w segment survived into the returned ``line``, for + the caller's label anchoring (see the hazard note in ``layout.py`` + where it's consumed). """ day_clr = self.day_cost_colour(day_cost) in_active, out_active = TokenRate.recently_active(session_id) @@ -1775,7 +1757,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) @@ -1844,13 +1825,12 @@ def build_lines() -> str: return (f'{read_icon}{self.TOK}{read_s}{self.R}' f'{changed_icon}{self.TOK}{changed_s}{self.R}') - vsep_w = 4 - vsep_leader_w = 4 - vsep_lines_w = 4 - label_w = 15 + vsep_w = 4 + vsep_trailing_w = 4 + vsep_lines_w = 4 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 @@ -1868,22 +1848,17 @@ 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, but only needs to CLEAR + # PLUGINS_TRAILING_MIN_W to be included (see include_trailing below) -- + # once included it fills whatever width is actually free, so it is + # shed entirely only when even that minimum doesn't fit. + 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 @@ -1894,30 +1869,44 @@ 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: included once the box clears the + # SMALLER of the segment's own measured width and PLUGINS_TRAILING_MIN_W + # -- gating on the full measured `trailing_w` here would shed the + # whole column for any list wider than the free space instead of + # truncating it (the in-column ellipsis clip below does the actual + # fit-to-width work once this gate says there's room to try). Not + # gated on `trailing_content` being non-empty either: 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. + min_width_with_trailing = min_width + min(trailing_w, PLUGINS_TRAILING_MIN_W) + vsep_trailing_w + include_trailing = box_width >= min_width_with_trailing + if include_trailing: + inner -= vsep_trailing_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/trailing_avail_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)): @@ -1930,11 +1919,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] @@ -1944,17 +1932,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. @@ -1972,56 +1958,52 @@ 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 │ + trailing_col = col2 + vsep_lines_w + w_end # 1-indexed position of the trailing │ 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) + trailing_col = w_middle + vsep_w + w_end + 5 # 1-indexed position of the trailing │ (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_trailing: + vsep_trailing = self.vsep_block(trailing_col, box_width, fill=fill, leader=True) + # The actual free width for this column, independent of `trailing_w` + # -- it fills this whether the content is narrower (blank-padded), + # wider (clipped with an ellipsis), or empty (blank). + trailing_avail_w = max(0, inner - w_middle - w_lines - w_end) + if trailing_w <= trailing_avail_w: + trailing = trailing_content + ' ' * (trailing_avail_w - trailing_w) + elif trailing_avail_w > 0: + trailing = clip_visible(trailing_content, trailing_avail_w) else: - spark = ' ' * bar_w - leader = f'{leader_lpad}{rate_label}{spark}' + trailing = '' 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_trailing: + if include_lines: + line = f'{tokens_col}{vsep}{lines_col}{vsep_lines}{cost_col}{vsep_trailing}{trailing}' + vsep_cols = (col1, col2, trailing_col) + else: + line = f'{tokens_col}{vsep}{cost_col}{vsep_trailing}{trailing}' + vsep_cols = (col1, trailing_col) + elif include_lines: + line = f'{tokens_col}{vsep}{lines_col}{vsep_lines}{cost_col}' vsep_cols = (col1, col2) - - # 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. + else: + line = f'{tokens_col}{vsep}{cost_col}' + vsep_cols = (col1,) + + # Shed ladder rungs (see the docstring's Invariants for the ordering). + # `has_lines_final` is set explicitly per rung actually used, rather + # than assumed from `include_lines`, so it always names the segment + # in the returned `line` -- see layout.py's tok_labels build for why + # the caller can't safely re-derive this by sniffing rendered content. content_w = box_width - 3 + 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}' @@ -2030,15 +2012,42 @@ def build_lines() -> str: rung_b = f'{tokens_col}{vsep}{cost_col}' rung_b_cols = (col1,) if _visible_width(rung_b) <= content_w: - line, vsep_cols = rung_b, rung_b_cols + line, vsep_cols, has_lines_final = rung_b, rung_b_cols, include_lines elif include_lines and _visible_width(f'{tokens_col}{vsep}{lines_col}') <= content_w: - line, vsep_cols = f'{tokens_col}{vsep}{lines_col}', (col1,) + line, vsep_cols, has_lines_final = f'{tokens_col}{vsep}{lines_col}', (col1,), True else: - line, vsep_cols = tokens_col, () + line, vsep_cols, has_lines_final = tokens_col, (), 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/pyproject.toml b/pyproject.toml index d81a45a..01c0773 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yet-another-statusline" -version = "0.9.0" +version = "0.9.1" 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/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 673fb30..cb6b83d 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 @@ -341,6 +342,47 @@ def test_env_transcript_cache_overrides_toml_false(tmp_path: Path) -> None: assert cfg.transcript_cache 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 23db9a5..0320247 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, now=None, **kwargs: 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:] @@ -749,16 +765,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)) @@ -842,9 +858,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 @@ -852,14 +869,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: @@ -875,6 +894,30 @@ def test_short_plugins_row_not_truncated(monkeypatch: pytest.MonkeyPatch) -> Non assert ELLIPSIS not in plugins_lines[0] +def test_moderate_plugins_row_fills_available_width_not_pre_clipped(monkeypatch: pytest.MonkeyPatch) -> None: + """A skills+plugins list that comfortably fits the trailing column's real + budget at a wide box must render in full, not get truncated against a + pre-clip cap that is narrower than the column's actual room (the bug: a + fixed 60-col pre-clip in `build_wide` cut the list short long before + `tokens_cost`'s own leader-width truncation ever got a chance to size it + to the real budget, leaving an early `…` and a wall of blank padding).""" + from helper import strip_ansi + from yas.constants import ELLIPSIS + _silence_dynamic(monkeypatch) + monkeypatch.setattr(skills_mod.LoadedSkills, 'from_transcript', + classmethod(lambda cls, path: skills_mod.LoadedSkills( + names=['a:audiovis-design', 'a:audiovis-fractal', 'a:audiovis-whereis']))) + monkeypatch.setattr(session_mod.Workspace, 'plugins', property(lambda self: 'dnb-expe,another-plugin')) + + width = 185 + spec = layout.build_wide(_view(), _tick(), width, _r) + lines = [strip_ansi(ln) for ln in layout.render_layout(spec, _r)] + plugins_lines = [ln for ln in lines if 'audiovis-design' in ln] + assert plugins_lines, 'skills+plugins content should render in the tokens/cost row' + assert 'another-plugin' in plugins_lines[0], 'the full list fits the column and must not be cut short' + assert ELLIPSIS not in plugins_lines[0], 'a list that fits its real budget must not show a truncation ellipsis' + + # --------------------------------------------------------------------------- # Task 6.4 — cache_section sub-hour and over-hour format # --------------------------------------------------------------------------- @@ -1051,13 +1094,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' @@ -1076,9 +1121,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..3af1bef 100644 --- a/test/test_tokens_cost.py +++ b/test/test_tokens_cost.py @@ -3,8 +3,8 @@ import pytest import yas.renderer as renderer -from yas.constants import GLYPH_LINES_CHANGED, GLYPH_LINES_READ, ICON_COST, ICON_TOK_RATE -from yas.render.text import _visible_width +from yas.constants import GLYPH_LINES_CHANGED, GLYPH_LINES_READ, ICON_COST +from yas.render.text import _visible_width, clip_visible from helper import strip_ansi Renderer = renderer.Renderer @@ -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,113 @@ 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]) + 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_genuinely_no_room() -> None: + # Only when the box can't even clear PLUGINS_TRAILING_MIN_W is the + # segment (and its divider) shed and the row falls back to the + # tokens|cost shape -- a merely-too-narrow-for-the-FULL-list box (see + # test_tokens_cost_trailing_content_truncated_to_fill_available_width) + # truncates instead. + lines, cols, _mark, _min, _has_lines = _call(box_width=50, trailing_content='x' * 200) + assert len(cols) == 1 + assert 'x' * 200 not in strip_ansi(lines[0]) + + +def test_tokens_cost_trailing_content_truncated_to_fill_available_width() -> None: + # Regression (Spec C1): a trailing list wider than the free column must be + # TRUNCATED to fill the actual free width, not shed whole -- gating the + # column's inclusion on the full measured content width sheds it entirely + # for any list wider than what's free, even though there's plenty of room + # for a clipped form. + lines, cols, _mark, _min, _has_lines = _call( + box_width=140, lines=(1234, 567), trailing_content='x' * 80, + ) + assert len(cols) == 3 # tokens|lines, lines|cost, cost|trailing -- not shed + stripped = strip_ansi(lines[0]) + trailing_region = stripped[cols[-1] - 3:] + assert trailing_region.rstrip().endswith('…') + # The row fills exactly to the box's content width (2-col lead + 1-col + # trailing border), proving the column consumed the real free space + # rather than stopping short at its measured (too-wide) content. + assert _visible_width(lines[0]) == 140 - 3 + + +def test_clip_visible_zero_budget_returns_empty() -> None: + # clip_visible(s, 0) must not append a lone ellipsis -- that would make + # the result 1 column wide when the caller has 0 columns of budget. + assert clip_visible('anything', 0) == '' + assert clip_visible('anything', -3) == '' + + +def test_tokens_cost_trailing_content_clip_ends_with_pad_space() -> None: + # A clipped trailing cell must end like every other cell in the row -- + # with a blank pad column before the divider/border, i.e. '...\u2026 ' + # not '...\u2026' flush. Regression: the ellipsis used to be spent on the + # LAST column, so the cell (and the row's own right border, once wrapped + # by border_line) ended '\u2026\u2502' instead of ' \u2502' like its + # neighbours -- doubly risky since ELLIPSIS is East-Asian-ambiguous width + # and some terminals render it 2 cols wide. + lines, _cols, _mark, _min, _has_lines = _call( + box_width=140, lines=(1234, 567), trailing_content='x' * 80, + ) 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 stripped.endswith('\u2026 '), repr(stripped[-5:]) + # And the fully-wrapped row (as the box actually renders it) ends the + # same way once the outer border is attached. + r = Renderer() + wrapped = strip_ansi(r.border_line(lines[0], 140)) + assert wrapped.endswith(' \u2502'), repr(wrapped[-5:]) + + +def test_tokens_cost_trailing_content_clip_never_widens_row() -> None: + # Regression: clip_visible(s, 0) used to still append an ellipsis (1 visible + # column) even though the caller had 0 columns of budget -- the trailing + # column would then be 1 column wider than its divider math assumed, + # pushing the row's own right border past the box width. Sweep a wide + # band of box widths (crossing every shed/clip boundary for this row) and + # assert every rendered row is exactly box_width - 3 cols, never wider. + for box in range(90, 200): + lines, _cols, _mark, _min, _has_lines = _call( + box_width=box, lines=(530, 1234), + trailing_content='audiovis-analysis,audiovis-backend,audiovis-whereis,' + 'audiovis-design,dnb-expert,prototype | ui-ux-pro-max', + ) + assert _visible_width(lines[0]) <= box - 3, (box, 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 +262,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 +276,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 +319,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 +342,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 +363,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 +410,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 +448,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 +463,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 +540,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 be73fc4..8eb46d0 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. diff --git a/uv.lock b/uv.lock index 21bd357..3d2a641 100644 --- a/uv.lock +++ b/uv.lock @@ -60,7 +60,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -421,7 +421,7 @@ wheels = [ [[package]] name = "yet-another-statusline" -version = "0.9.0" +version = "0.9.1" source = { virtual = "." } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, diff --git a/yas.example.toml b/yas.example.toml index c2e3700..5e6d8f9 100644 --- a/yas.example.toml +++ b/yas.example.toml @@ -30,6 +30,10 @@ # # a session's first render. env: YAS_SHOW_RENDER_TIME # show_tool_uses = false # bool; wide layout only — show the per-tool tool_use # # counts row below the tokens/cost rows. env: YAS_SHOW_TOOL_USES +# show_tokens_over_time = false # bool; wide layout only — add a full-width "tokens over +# # time" row (t/m rate + sparkline) below the tokens/cost +# # row. The tokens/cost row's trailing column now always +# # shows skills + plugins. env: YAS_SHOW_TOKENS_OVER_TIME # [tokens] # soft_limit = 150000 # int > 0; tokens; context-fill bar / % threshold. Raise for 1M-context models.