diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 211ac8a..134cbe8 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -14,5 +14,5 @@ "license": "BSD-3-Clause", "name": "yas", "repository": "https://github.com/tmck-code/yet-another-statusline", - "version": "0.7.0" + "version": "0.7.1" } diff --git a/claude/yas/constants.py b/claude/yas/constants.py index f61562c..c4570ea 100644 --- a/claude/yas/constants.py +++ b/claude/yas/constants.py @@ -8,7 +8,7 @@ # Keep in sync with pyproject.toml's [project] version — pyproject isn't # shipped with the runtime copy under ~/.claude, so the value lives here too. -VERSION = '0.7.0' +VERSION = '0.7.1' HOME = Path(os.path.expanduser('~')) CLAUDE_DIR = Path(os.environ.get('CLAUDE_CONFIG_DIR', str(HOME / '.claude'))) @@ -89,10 +89,14 @@ # the row cannot hold both columns at full size plus the rate/spark leader, so # build_wide drops it for the compact context line instead of overflowing the # box. The exact, content-aware minimum is computed per-render by -# Renderer.tokens_cost (its ``min_width`` return) — this constant is the -# realistic-widest floor (the wide layout owns box >= MEDIUM_WIDTH=80, and the -# row first fits around box 84-85 for typical 6-7 digit token magnitudes). -TOKENS_COST_MIN_WIDTH = 85 +# Renderer.tokens_cost (its ``min_width`` return) — this constant is a flat +# floor on top of that. Deliberately pinned to MEDIUM_WIDTH (the box width +# where build_wide itself starts) rather than a higher magic number: the old +# value of 85 opened an 80-84 band where the plugin row (gated only by +# MEDIUM_WIDTH) had already appeared but the context/tokens row was still +# degraded to the compact form — an inconsistent shed ladder. Aligning the +# two floors means both upgrade together at the same box width. +TOKENS_COST_MIN_WIDTH = MEDIUM_WIDTH # Cap, in columns, on each individually-distributed justify "extra" slot in # the wide top row (path/elapsed/5h/7d/cache breathing room — see # `build_wide`'s justify block). Below `Renderer.JUSTIFY_PAD_CAP=4`'s sibling @@ -112,8 +116,9 @@ TOPROW_JUSTIFY_OUTER_CAP = 8 # Floor for the wide layout's four-segment tokens │ lines │ cost │ rate row. # This constant gates ONLY the lines segment; TOKENS_COST_MIN_WIDTH must stay -# at 85 because bumping it would regress every 85–103-column terminal into the -# compact context line (losing the cost/rate row entirely, not just the lines). +# at MEDIUM_WIDTH because bumping it would regress every terminal below 103 +# into the compact context line (losing the cost/rate row entirely, not just +# the lines). LINES_SEGMENT_MIN_WIDTH = 103 # Minimum gap between the narrow tasks-header's left cluster (glyph + done/total) @@ -388,10 +393,15 @@ class BarChars: PILL_BOT: '-', PILL_LEFT: '|', PILL_RIGHT: '|', - PILL_TL: ' ', - PILL_TR: ' ', - PILL_BL: ' ', - PILL_BR: ' ', + # Corners map to '+' (not ' ') so a pill's start/end column never blanks + # a structural elbow/corner it happens to coincide with -- '+' is exactly + # what BOX_T_DOWN/BOX_T_UP/BOX_ARC_T* already fold to in ascii mode, so + # the pill corner reads as a normal box corner whether or not it lines up + # with a divider underneath. + PILL_TL: '+', + PILL_TR: '+', + PILL_BL: '+', + PILL_BR: '+', } # Sparkline density ramp fallbacks (U+2581..U+2588), low->high. Some of these diff --git a/claude/yas/layout.py b/claude/yas/layout.py index 52f10fd..0d79488 100644 --- a/claude/yas/layout.py +++ b/claude/yas/layout.py @@ -1116,8 +1116,12 @@ def _resolve_toprow_shed() -> _TopRowShed: # total_slack > 0; fall through silently when total_slack == 0 (D3). total_slack = target_w - path_w path_extra = elapsed_extra = h5_left = h5_right = h7_left = h7_right = cache_extra = last_extra = 0 + # `_has_elapsed` is needed both inside the slack-distribution block below + # (to size the N-way split) and afterward for the baked-in-padding + # rebalance, which must run even when total_slack <= 0 -- so compute it + # once, unconditionally, ahead of the slack gate. + _has_elapsed = elapsed_section_w > 0 if view.cfg.justify and total_slack > 0: - _has_elapsed = elapsed_section_w > 0 _has_cache = cache_section_w > 0 _N = 3 + (1 if _has_elapsed else 0) + (1 if has_7d else 0) + (1 if _has_cache else 0) _extra_per = total_slack // _N @@ -1231,9 +1235,29 @@ def _resolve_toprow_shed() -> _TopRowShed: else: line_path = f'{line_path}{" " * path_extra}' path_w += path_extra - if elapsed_extra: - _e_left = elapsed_extra // 2 - _e_right = elapsed_extra - _e_left + if view.cfg.justify and _has_elapsed: + # `elapsed_content` may already carry asymmetric leading padding + # baked in by the renderer's fixed-width `rjust` (elapsed_section + # right-justifies to a constant cell width). Strip that baked-in + # run of leading spaces and fold it back into the slack pool + # before splitting, so the TOTAL whitespace either side of the + # visible digits ends up balanced -- not just the slack added + # here. This runs whenever elapsed is present and justify is on, + # *independent* of `total_slack > 0`: a sibling section (e.g. + # `fit_path` crossing a growth threshold) can consume the entire + # row's slack in the same pass, leaving `total_slack == 0` while + # the elapsed cell's own baked-in asymmetry is still present and + # otherwise never gets corrected (D3's "silent fall-through" only + # applies to *distributed* slack, not this pre-existing bake). + _plain_e = _ANSI_RE.sub('', elapsed_content) + _baked_left = len(_plain_e) - len(_plain_e.lstrip(' ')) + if _baked_left: + _b_end = _ansi_byte_offset(elapsed_content, _baked_left) + elapsed_content = elapsed_content[:_b_end].rstrip(' ') + elapsed_content[_b_end:] + _total_pad = _baked_left + elapsed_extra + if _total_pad: + _e_left = _total_pad // 2 + _e_right = _total_pad - _e_left elapsed_content = f'{" " * _e_left}{elapsed_content}{" " * _e_right}' elapsed_section_w += elapsed_extra @@ -1395,8 +1419,16 @@ def _resolve_toprow_shed() -> _TopRowShed: RowSpec('content', content=f'{middle}{" " * last_extra}', right_pill=right_text), ] else: + # Measure `middle` directly with `_visible_width` instead of hand-summing + # its components (path_w + vsep_w + elapsed_section_w + helper_w + ...): + # the elapsed vsep is built with `lead=1` (4 visible cols, not the + # generic vsep_w=5), so the summed estimate silently overcounted by 1 + # whenever an elapsed cell was present -- consuming pad's last reserved + # column and landing right_text (which carries no trailing space of its + # own) flush against the closing border at widths 79-81 (ascii/justify). pad = max(1, (width - 3) - (path_w + vsep_w + elapsed_section_w + helper_w + cache_section_w + (1 if cache_section_w else 0) + right_w)) content_full = f'{middle}{" " * pad}{right_text}' + rows += [ RowSpec('top_border', downs=path_row_downs, labels=top_labels), RowSpec('content', content=content_full), diff --git a/claude/yas/renderer.py b/claude/yas/renderer.py index 439c50a..0b20f05 100644 --- a/claude/yas/renderer.py +++ b/claude/yas/renderer.py @@ -422,7 +422,11 @@ def path_git( # middle-ellipsis). show_path=False yields the branch-only rung (glyph + # arrow + branch) used as a width-degradation step below the path forms. path_part = f'{self.PWD}{short_pwd}{self.R} ' if show_path else '' - glyph_part = f'{GLYPH_FOLDER} ' if show_icons else '' + # With icons off there is no glyph to reserve the row's usual 2-col + # left margin -- fall back to a single literal space so the path row + # lines up with rows that reserve their margin via a fixed-width + # rjust instead of an icon (e.g. context_line's `fmt_tok(...):>6`). + glyph_part = f'{GLYPH_FOLDER} ' if show_icons else ' ' return ( f'{self.ICON_PATH}{glyph_part}{path_part}' @@ -699,9 +703,18 @@ def model_right_section( else: glyph_part = f' {lead_glyph} ' if show_icons else ' ' if model_thinking: - right_text = f'{model_clr}{glyph_part}{model_name}{self.R} {model_clr}({model_thinking}){RESET}' + right_text = f'{model_clr}{glyph_part}{model_name}{self.R} {model_clr}({model_thinking}){RESET} ' else: - right_text = f'{model_clr}{glyph_part}{model_name}{self.R}' + right_text = f'{model_clr}{glyph_part}{model_name}{self.R} ' + # Trailing space above is deliberate, matching the `pct` pill + # branch's baked-in trailing padding cell above: without it, this + # is the rightmost content in the row and its own budget math + # (build_wide's `pad`) can land on exactly 0 spare columns at + # certain widths (e.g. justify+ascii at 79-81), landing a digit + # flush against the closing border. Baking the space in here + # makes the no-digit-adjacent-to-border invariant hold by + # construction rather than by relying on `pad` always having + # slack left over. right_w = _visible_width(right_text) @@ -780,7 +793,11 @@ def plugins_skills(self, skills_count: int, skills_names: str, plugin_names: str if plugin_names: plugins_glyph = f'{c_plugins}{BOLD}{GLYPH_PLUGINS} {self.R}' if show_icons else '' extras.append(f'{plugins_glyph}{self.SKILLS}{plugin_names}{self.R}') - return f' {self.LABEL}|{self.R} '.join(extras) + line = f' {self.LABEL}|{self.R} '.join(extras) + # With icons off, no glyph reserves the row's usual 2-col left + # margin -- add the same single literal space the path/tokens rows + # fall back to, so this row's leading value lines up with them. + return f' {line}' if (line and not show_icons) else line def tool_counts_row(self, counts: dict[str, tuple[int, int]], width: int, *, fill: float = 1.0) -> str: """Greedy-filled per-tool ``Name main/sub`` counts as a full-width line. @@ -1740,7 +1757,13 @@ def tokens_cost(self, sess_in: int, sess_cache: int, sess_out: int, day_in: int, in_icon = f'{ARROW_IN_ACTIVE} ' if in_active else f'{ARROW_IN_IDLE} ' # both 2 cols out_icon = f'{ARROW_OUT_ACTIVE} ' if out_active else f'{ARROW_OUT_IDLE} ' # both 2 cols else: - in_icon = out_icon = '' # show_icons=False: number-only, no arrow glyph + # show_icons=False: number-only, no arrow glyph. The row's left + # margin is reserved by `sess_in`'s own rjust (below) instead of + # an icon-shaped space -- mirrors context_line, which has no + # icon-reservation either and relies solely on its rjust'd + # number for the margin. Reserving *both* here would double up + # and misalign the two rows again. + in_icon = out_icon = '' # Inter-group gaps in the tokens column and the cost/leader edge pads. # They start at their minimums (gaps 1 space, edge pads 0) so the @@ -1751,7 +1774,14 @@ def tokens_cost(self, sess_in: int, sess_cache: int, sess_out: int, day_in: int, leader_lpad = '' if show_day_stats: - # Merged session/day per field; variable width, no fixed rjust (D2). + # Merged session/day per field; variable width, no fixed rjust (D2) + # -- except the row's LEADING number (`sess_in`), which is + # right-justified to `IN_W` (the same fixed width the + # session-only rung below uses) so it shares a stable right + # edge with row 2's context-fill number (also rjust'd, in + # `context_line`) and has headroom to grow (e.g. 25.9K -> + # 123.0K) without shifting every column after it or ending up + # flush against the row's left border. cache = (f'{self.TOK_DIM}({fmt_tok(sess_cache)}{self.R}' f'{self.TOK_DAY_DIM}/{fmt_tok(day_cache)}{self.R}' f'{self.TOK_DIM}){self.R}') @@ -1759,7 +1789,7 @@ def tokens_cost(self, sess_in: int, sess_cache: int, sess_out: int, day_in: int, def build_tokens() -> str: return ( f'{self.LABEL}{self.BOLDY}{in_icon}{self.R}' - f'{self.TOK}{fmt_tok(sess_in)}{self.R}{self.TOK_DAY_DIM}/{fmt_tok(day_in)}{self.R}{gap1}' + f'{self.TOK}{fmt_tok(sess_in).rjust(self.IN_W)}{self.R}{self.TOK_DAY_DIM}/{fmt_tok(day_in)}{self.R}{gap1}' f'{cache}' f'{self.LABEL}{gap2}{self.BOLDY}{out_icon}{self.R}' f'{self.TOK}{fmt_tok(sess_out)}{self.R}{self.TOK_DAY_DIM}/{fmt_tok(day_out)}{self.R}' diff --git a/pyproject.toml b/pyproject.toml index 0171c61..7b73003 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yet-another-statusline" -version = "0.7.0" +version = "0.7.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 fbde02a..4bfbcb2 100644 --- a/test/fixtures/claude_dark_wide.ansi +++ b/test/fixtures/claude_dark_wide.ansi @@ -1,7 +1,7 @@ ╭──51e977df-b46f-40d9-82b0-eb9ded60036d───────────────────┬────────────────────────────────────────────────────────────╮ -│  /h/u/my-project ∈ / │  +13:27 │ 󰔛 (-19:26) 61.0% │ 󰨴 89.0%  +28.0%  󱩑 Sonnet 4.6 │ +│  /h/u/my-project ∈ / │  +13:27 │ 󰔛 (-19:26) 61.0% │ 󰨴 89.0%  +28.0%  󱩑 Sonnet 4.6 │ ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┴┄┄┄┄┄┄┄┄┄┄┴┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┴┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ │   16.0K  (8%)  11%           ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ -├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┬┄┄┄┄┄┄┄┄┄┄┄┄┄┬┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┬┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ -│ ↓ 0/0 (0/0) ↑ 0/0 │  0  0 │  $0.00 / $0.00 │ 󱢧 0 t/m                                                      │ -╰────────────────────┴─────────────┴───────────────────┴───────────────────────────────────────────────────────────────╯ \ No newline at end of file +├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┬┄┄┄┄┄┄┄┄┄┄┄┄┄┬┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┬┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ +│ ↓  0/0 (0/0) ↑ 0/0 │  0  0 │  $0.00 / $0.00 │ 󱢧 0 t/m                                                 │ +╰─────────────────────────┴─────────────┴───────────────────┴──────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/test_borders.py b/test/test_borders.py index e980104..5a31b78 100644 --- a/test/test_borders.py +++ b/test/test_borders.py @@ -229,6 +229,38 @@ def test_border_separator_dim_right_flush_pill(r: borders.BorderRenderer) -> Non assert stripped[4] == '┴' +def test_ascii_mode_pill_corner_at_right_edge_is_not_blank(r: borders.BorderRenderer) -> None: + """Regression: PILL_TR/PILL_BR landing on the box's right edge used to + fold to ' ' in ascii mode, erasing the right corner entirely (audit + BUG 1). It must now fold to the same '+' every other corner/elbow uses. + """ + from yas.render.text import apply_glyph_mode + + pill = Pill(start=21, end=30, anchor=(120, 80, 80), shift=(80, 120, 80), pct=100) + top = apply_glyph_mode(strip_ansi(r.border_top(width=30, pill=pill)), 'ascii') + sep = apply_glyph_mode(strip_ansi(r.border_separator_dim(width=30, ups=(5,), pill=pill)), 'ascii') + assert _visible_width(top) == 30 + assert _visible_width(sep) == 30 + assert top[29] == '+' + assert sep[29] == '+' + assert ' ' not in top[20:30] + assert ' ' not in sep[20:30] + + +def test_ascii_mode_pill_corner_over_divider_elbow_is_not_blank(r: borders.BorderRenderer) -> None: + """Regression: a pill corner coinciding with a content divider column + used to blank the '┬'/'┴' elbow underneath it in ascii mode instead of + falling through to a real corner glyph (audit BUG 1). + """ + from yas.render.text import apply_glyph_mode + + pill = Pill(start=15, end=20, anchor=(120, 80, 80), shift=(80, 120, 80), pct=100) + top = apply_glyph_mode(strip_ansi(r.border_top(width=30, downs=(15,), pill=pill)), 'ascii') + sep = apply_glyph_mode(strip_ansi(r.border_separator_dim(width=30, ups=(15,), pill=pill)), 'ascii') + assert top[14] == '+' + assert sep[14] == '+' + + # --- label overlay (section 3) ------------------------------------------------ @pytest.mark.parametrize('method', ['border_top', 'border_separator', 'border_separator_dim']) diff --git a/test/test_justify.py b/test/test_justify.py index aa6858f..6845c2b 100644 --- a/test/test_justify.py +++ b/test/test_justify.py @@ -206,6 +206,76 @@ def test_justify_path_extra_split_around_git_block( # Inter-stat breathing room inside the 5h/7d helper sections +def test_justify_elapsed_field_balanced( + monkeypatch: pytest.MonkeyPatch, strip_ansi: Callable[[str], str], +) -> None: + """The elapsed/session-timer cell is a right-justified fixed-width atom + baked in by ``Renderer.elapsed_section`` (``rjust(8)``). The justify pass + must fold that baked-in leading padding into the slack it distributes, so + total LHS/RHS whitespace around the visible digits is balanced (diff <=1) + rather than stacking a fair added-slack split on top of an already + right-justified string (which produced a diff of 2+, e.g. ``' +13:27 '``).""" + _silence_dynamic(monkeypatch) + import re + + tested = 0 + for width in (100, 102, 104, 108, 111, 112, 113, 115, 120): + view = _view(Config(justify=True)) + raw = strip_ansi(_rendered_lines(view, width)[1]) + pipes = [i for i, ch in enumerate(raw) if ch == '│'] + assert len(pipes) >= 3, f'width={width} raw={raw!r}' + field = raw[pipes[1] + 1:pipes[2]] + if not re.search(r'\+\d', field): + continue # elapsed cell shed at this width + tested += 1 + left = len(field) - len(field.lstrip(' ')) + right = len(field) - len(field.rstrip(' ')) + assert abs(left - right) <= 1, ( + f'width={width} field={field!r} left={left} right={right}' + ) + assert tested > 0, 'no width in the sweep exercised the elapsed cell' + + +def test_justify_elapsed_field_balanced_at_zero_slack( + monkeypatch: pytest.MonkeyPatch, strip_ansi: Callable[[str], str], +) -> None: + """Regression for the residual centering defect found at widths 102/112 + (kitchen-sink demo scenario, YAS_JUSTIFY=1): when a sibling section (e.g. + `fit_path` crossing a growth threshold) consumes the ENTIRE row's slack + in the same layout pass, `total_slack` lands on exactly 0 -- the elapsed + cell's baked-in `rjust(8)` asymmetry (2 leading spaces, 0 trailing) must + still be rebalanced to <=1 diff even though no *distributed* slack exists + to fold into. Before the fix this produced a diff-2 split (e.g. + ``' +13:27 '``) because the rebalance was nested inside the + ``total_slack > 0`` gate and silently skipped. + + Widths 65/66/77/78 are where the standard fixture session naturally + lands on total_slack == 0 (dir-full path exactly fills its budget) while + the elapsed cell is still active -- picked by sweeping the fixture rather + than forcing it, so this exercises the real shed/slack interaction + instead of an artificial mock.""" + _silence_dynamic(monkeypatch) + import re + + tested = 0 + for width in (65, 66, 77, 78): + view = _view(Config(justify=True)) + spec = layout.build_wide(view, _tick(), width, _r) + raw = strip_ansi(layout.render_layout(spec, _r)[1]) + pipes = [i for i, ch in enumerate(raw) if ch == '│'] + assert len(pipes) >= 3, f'width={width} raw={raw!r}' + field = raw[pipes[1] + 1:pipes[2]] + if not re.search(r'\+\d', field): + continue # elapsed cell shed at this width + tested += 1 + left = len(field) - len(field.lstrip(' ')) + right = len(field) - len(field.rstrip(' ')) + assert abs(left - right) <= 1, ( + f'width={width} field={field!r} left={left} right={right}' + ) + assert tested > 0, 'no width in the sweep exercised the elapsed cell at zero slack' + + def test_justify_widens_helper_inter_stat_gap( monkeypatch: pytest.MonkeyPatch, strip_ansi: Callable[[str], str], ) -> None: @@ -224,3 +294,31 @@ def _gap(lines: list[str]) -> int: gap_on = _gap(_rendered_lines(_view(Config(justify=True)), width)) assert gap_off == 1 assert 1 < gap_on <= 3 + + +# Regression — no digit may ever be flush against a border char (found in the +# final confirm pass: widths 79-81, justify=True, glyph_mode='ascii' rendered +# `...Sonnet 4.6│` with zero trailing space). Root cause was `model_right_section`'s +# non-pill branch baking no trailing space into `right_text` (unlike the pill +# branch, which pads a cell after the model name) -- `build_wide`'s own `pad` +# math could land on exactly zero spare columns at these widths, so the fix +# bakes a guaranteed trailing space into `right_text` itself in renderer.py. + +@pytest.mark.parametrize('justify', [True, False]) +def test_no_digit_adjacent_to_border( + monkeypatch: pytest.MonkeyPatch, strip_ansi: Callable[[str], str], justify: bool, +) -> None: + """Sweep widths 60-130 (ascii glyph mode, the reported failure's glyph mode) + and assert no rendered row ever has a digit immediately touching `│`.""" + _silence_dynamic(monkeypatch) + monkeypatch.setenv('YAS_GLYPH_MODE', 'ascii') + import re + digit_touches_border = re.compile(r'\d[│|]|[│|]\d') + + for width in range(60, 131): + view = _view(Config(justify=justify, glyph_mode='ascii')) + for line in _rendered_lines(view, width): + raw = strip_ansi(line) + assert not digit_touches_border.search(raw), ( + f'width={width} justify={justify} raw={raw!r}' + ) diff --git a/test/test_layout_seam.py b/test/test_layout_seam.py index 008415b..98975bf 100644 --- a/test/test_layout_seam.py +++ b/test/test_layout_seam.py @@ -727,9 +727,10 @@ def test_wide_bottom_band_no_overflow_no_detached_elbows( ) -> None: """At the bottom of the wide band (box 80-84) the three-segment tokens row used to overflow the box and detach its two │ from the ┬/┴ elbows. The - builder now drops it for the compact context line below the fit floor, so at - EVERY width: no rendered row is wider than the box, and every ┬/┴ is backed - by a │ in the adjacent row.""" + fit floor (TOKENS_COST_MIN_WIDTH) is now pinned to MEDIUM_WIDTH == 80, i.e. + the wide layout's own entry point, so the row is present across this whole + band; regardless, at EVERY width: no rendered row is wider than the box, + and every ┬/┴ is backed by a │ in the adjacent row.""" from helper import strip_ansi from yas.render.text import _visible_width _silence_dynamic(monkeypatch) @@ -743,8 +744,12 @@ def test_wide_bottom_band_no_overflow_no_detached_elbows( def test_wide_bottom_band_drops_three_segment_tokens_row( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Below the fit floor the three-segment tokens │ cost │ rate row is dropped - (no 't/m' content row); at/above it the row is present.""" + """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.""" from helper import strip_ansi _silence_dynamic(monkeypatch) @@ -752,7 +757,8 @@ def has_tokens_row(spec: layout.LayoutSpec) -> bool: return any(row.kind == 'content' and 't/m' in strip_ansi(row.content) for row in spec.rows) - assert not has_tokens_row(layout.build_wide(_view(), _tick(), 82, _r)) + assert not has_tokens_row(layout.build_wide(_view(), _tick(), 75, _r)) + assert has_tokens_row(layout.build_wide(_view(), _tick(), 80, _r)) assert has_tokens_row(layout.build_wide(_view(), _tick(), 100, _r)) @@ -984,8 +990,11 @@ def _render(width: int) -> str: content_rows = [row for row in spec.rows if row.kind == 'content'] return content_rows[0].content - # Wide enough for both timers. - plain_both = strip_ansi(_render(76)) + # Wide enough for both timers. (Threshold is 77, not 76: `right_text`'s + # model-name cell now bakes in a guaranteed trailing space -- see the + # no-digit-adjacent-to-border fix in renderer.py's `model_right_section` + # -- which costs the row's shed budget 1 column across the board.) + plain_both = strip_ansi(_render(77)) assert '18:33' in plain_both and '13:27' in plain_both # Narrower: clear timer present, session timer shed. @@ -1074,18 +1083,61 @@ def test_tokens_row_two_elbows_in_85_102_band(monkeypatch: pytest.MonkeyPatch) - assert len(tokens_sep.downs) == 2, f'expected 2 downs at width=95, got {tokens_sep.downs}' -@pytest.mark.parametrize('width', [85, 90, 100, 103, 140]) +@pytest.mark.parametrize('width', [80, 85, 90, 100, 103, 140]) def test_tokens_row_present_across_lines_segment_threshold( monkeypatch: pytest.MonkeyPatch, width: int, ) -> None: - """TOKENS_COST_MIN_WIDTH=85 gating is unaffected by the new lines segment: - the tokens/cost content row (and hence the full, not compact, context line) - is present at every width from 85 up through and past the 103 threshold.""" + """TOKENS_COST_MIN_WIDTH (== MEDIUM_WIDTH == 80) gating is unaffected by the + new lines segment: the tokens/cost content row (and hence the full, not + compact, context line) is present at every width from 80 (build_wide's own + floor) up through and past the 103 lines-segment threshold.""" _silence_dynamic(monkeypatch) spec = layout.build_wide(_view(), _tick(), width, _r) assert _tokens_row_indices(spec), f'tokens/cost row missing at width={width}' +def test_context_row_upgrades_at_wide_layout_floor(monkeypatch: pytest.MonkeyPatch) -> None: + """Task threshold-alignment: TOKENS_COST_MIN_WIDTH is pinned to + MEDIUM_WIDTH, so the rich context line (token total + fraction, e.g. + '150.0K (75%) 100%') and the tokens/cost row's dividers both appear from + box 80 -- the same box width build_wide itself starts at -- eliminating + the old 80-84 band where the plugin row showed but the context row was + still degraded to the compact '75%'-only form.""" + from helper import strip_ansi + _silence_dynamic(monkeypatch) + + spec = layout.build_wide(_view(), _tick(), 80, _r) + lines = [strip_ansi(ln) for ln in layout.render_layout(spec, _r)] + assert _tokens_row_indices(spec), 'tokens/cost row missing at the wide-layout floor (box 80)' + assert any('%' in ln and '(' in ln for ln in lines), \ + 'expected the rich context line (fraction form) at box 80, not the compact one' + + +def test_plugin_row_and_rich_context_row_copresent_at_wide_layout_floor( + monkeypatch: pytest.MonkeyPatch) -> None: + """Empirical pin for the fixed inconsistency: at box 80 (terminal 86), + with plugin data present, the plugin row and the rich context row (token + total + fraction, e.g. '16.0K (8%) 11%') must both render together -- + there must be no band where the plugin row shows while the context row + is still degraded to the compact percent-only form. Before + TOKENS_COST_MIN_WIDTH was aligned to MEDIUM_WIDTH, the plugin row (gated + only by MEDIUM_WIDTH=80) could appear up to 5 box-columns ahead of the + rich context row (previously gated at 85).""" + from helper import strip_ansi + _silence_dynamic(monkeypatch) + monkeypatch.setattr(session_mod.Workspace, 'plugins', property(lambda self: 'foo,bar')) + + spec = layout.build_wide(_view(), _tick(), 80, _r) + lines = [strip_ansi(ln) for ln in layout.render_layout(spec, _r)] + + plugin_lines = [ln for ln in lines if 'foo,bar' in ln] + assert plugin_lines, 'plugins row should render at box 80' + + assert _tokens_row_indices(spec), 'tokens/cost row missing at box 80 alongside the plugin row' + assert any('%' in ln and '(' in ln for ln in lines), \ + 'expected the rich context line (fraction form) co-present with the plugin row at box 80' + + def test_clear_timer_no_additional_elbow(monkeypatch: pytest.MonkeyPatch) -> None: """Adding a clear timer does NOT add a new border elbow (single divider unchanged).""" _silence_dynamic(monkeypatch) diff --git a/test/test_path_git.py b/test/test_path_git.py index feebc5d..c604a43 100644 --- a/test/test_path_git.py +++ b/test/test_path_git.py @@ -218,3 +218,14 @@ def test_fit_path_show_icons_false_glyph_floor_is_empty(self) -> None: result = r.fit_path(pwd, git, 0, show_icons=False) assert result == r.path_glyph_only(show_icons=False) assert result == '' + + def test_path_git_show_icons_false_reserves_one_col_margin(self) -> None: + """With no folder glyph to reserve the row's left margin, `path_git` + falls back to a single literal leading space -- so the path row's + left margin (border_line's own gap + this space = 2 cols) lines up + with row 2 (`context_line`, which reserves its margin via a rjust'd + number) instead of sitting 1 column short of it.""" + r = Renderer() + git = GitInfo(branch='main', commit='abc1234') + out = strip_ansi(r.path_git('~/proj', git, show_icons=False)) + assert out.startswith(' ~/proj') diff --git a/test/test_plugins_skills.py b/test/test_plugins_skills.py index 5f1e8f1..e1b55e7 100644 --- a/test/test_plugins_skills.py +++ b/test/test_plugins_skills.py @@ -37,3 +37,33 @@ def test_plugins_skills_nothing() -> None: plugin_names='', ) assert out == '' + + +def test_plugins_skills_show_icons_false_reserves_one_col_margin() -> None: + """With no glyph to reserve the row's left margin, plugins_skills falls + back to a single literal leading space -- matching path_git/tokens_cost's + fallback -- so this row's left margin lines up with the rest of the box.""" + r = Renderer() + out = r.plugins_skills( + skills_count=1, skills_names='tdd', plugin_names='', show_icons=False, + ) + stripped = strip_ansi(out) + assert stripped.startswith(' tdd') + + +def test_plugins_skills_show_icons_false_nothing_stays_empty() -> None: + # No leading-margin space should leak in when there's nothing to show. + r = Renderer() + out = r.plugins_skills( + skills_count=0, skills_names='', plugin_names='', show_icons=False, + ) + assert out == '' + + +def test_plugins_skills_show_icons_true_unchanged() -> None: + """The icons-on row already reserves its own margin via the glyph; the + icons-off fallback space must not leak into this path.""" + r = Renderer() + on_default = r.plugins_skills(skills_count=1, skills_names='tdd', plugin_names='') + on_explicit = r.plugins_skills(skills_count=1, skills_names='tdd', plugin_names='', show_icons=True) + assert on_default == on_explicit diff --git a/test/test_tokens_cost.py b/test/test_tokens_cost.py index ecd72bb..1a8a06c 100644 --- a/test/test_tokens_cost.py +++ b/test/test_tokens_cost.py @@ -444,3 +444,64 @@ def test_tokens_cost_show_icons_false_session_only_drops_cost_icon() -> None: 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 +# left margin, `sess_in` (the leading number) is right-justified to `IN_W` +# instead. Two things this must guarantee: (1) the reserved width matches +# row 2's context-fill number (also rjust'd, in `context_line`) so the two +# rows' leading digits share the same right edge -- regardless of how many +# digits either value currently has; (2) growing `sess_in` past a realistic +# "everyday" width (e.g. 25.9K -> 123.0K) doesn't ripple into the columns +# 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) + 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. + lead_field = stripped[:Renderer.IN_W] + assert lead_field.rstrip(' ').endswith('1') + assert lead_field == '1'.rjust(Renderer.IN_W) + + +def test_tokens_cost_show_icons_false_leading_number_right_edge_stable_across_magnitude() -> None: + """The right edge of the reserved `sess_in` field must not move as the + value grows from a small session-start number up through a realistic + everyday count -- otherwise every column after it (the cache/day figures, + the │ dividers, the cost/rate columns) would shift underneath it.""" + small = strip_ansi(_call(show_icons=False, sess_in=1)[0][0]) + big = strip_ansi(_call(show_icons=False, sess_in=25_900)[0][0]) + huge = strip_ansi(_call(show_icons=False, sess_in=123_000)[0][0]) + # The char immediately after the reserved field (the day-count '/') sits + # at the same offset regardless of sess_in's magnitude. + assert small[Renderer.IN_W] == '/' + assert big[Renderer.IN_W] == '/' + assert huge[Renderer.IN_W] == '/' + + +def test_tokens_cost_show_icons_false_leading_number_matches_context_line_margin() -> None: + """Row 2 (`context_line`) and row 3 (`tokens_cost`) share the same + reserved-width convention with icons off: both rjust their leading + number to a fixed width immediately after border_line's own 1-space + gap, so the two rows' numbers share a stable right edge.""" + from yas.session import ContextWindow + + r = Renderer() + ctx = ContextWindow(total_input_tokens=16_000, total_output_tokens=0, + context_window_size=200_000, used_percentage=8.0) + ctx_line = strip_ansi(r.context_line(ctx, available=76, show_icons=False)) + tok_line = strip_ansi(_call(show_icons=False, sess_in=16_000)[0][0]) + # Both fields are right-justified to 6 columns from the row's start + # (border_line's leading space is stripped from both here, since neither + # string above includes it -- they're raw section content). + assert ctx_line[:Renderer.IN_W].rstrip(' ')[-1] == tok_line[:Renderer.IN_W].rstrip(' ')[-1] + assert len(ctx_line[:Renderer.IN_W]) == len(tok_line[:Renderer.IN_W]) == Renderer.IN_W + + +def test_tokens_cost_show_icons_true_leading_number_unchanged() -> None: + """With icons on, the icon itself already reserves the margin -- the + show_icons=False rjust fix must not perturb the icons-on row shape.""" + on_default = _call(show_icons=True, sess_in=1) + on_explicit = _call(show_icons=True, sess_in=1) + assert on_default == on_explicit diff --git a/uv.lock b/uv.lock index 916731f..2a0501d 100644 --- a/uv.lock +++ b/uv.lock @@ -421,7 +421,7 @@ wheels = [ [[package]] name = "yet-another-statusline" -version = "0.7.0" +version = "0.7.1" source = { virtual = "." } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" },