From 1bb096dffbe6c91c59c7b472df3165dc0da19344 Mon Sep 17 00:00:00 2001 From: ecloin Date: Tue, 11 Aug 2026 10:41:32 -0400 Subject: [PATCH] fix(fleet-view): clip sidebar entries to a glanceable headline The captain sidebar rendered each entry's full backlog title. In a real home a backlog title is a body rather than a label, so the first NEEDS YOU entry printed a ~2400-character status dump of pull request verdicts, finding lists, and file paths, filled a 40-column pane, and pushed every later entry off screen. An entry headline is now the work item number plus the first sentence of its title within a codepoint-aware title budget, and the assembled headline is bounded again so N entries always cost about N * 3 lines. The same clipping covers every section, not only NEEDS YOU. The action line stays outside every budget: a pull request URL or review command goes out whole and soft-wraps, because a truncated command is worse to receive than a long one. The change is confined to the sidebar classifier, so --wide and --json render exactly as before and still carry titles in full. --- bin/fm-fleet-view.sh | 41 +++++++++++++++++--- docs/fleet-view.md | 12 ++++++ tests/fm-fleet-view.test.sh | 74 +++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/bin/fm-fleet-view.sh b/bin/fm-fleet-view.sh index ff5770c049..5404357bb9 100755 --- a/bin/fm-fleet-view.sh +++ b/bin/fm-fleet-view.sh @@ -80,6 +80,16 @@ C_WAIT='2' C_DONE='2' C_WARN='31' +# Clip budgets in codepoints, not bytes, for the sidebar's glance surface. +# A real backlog title carries a whole body - PR verdicts, finding lists, file +# paths - so an entry keeps only the title's first sentence within TITLE_CAP, +# any note within NOTE_CAP, and the assembled headline within HEADLINE_CAP, +# which is about two wrapped lines at the default pane width. An action line is +# deliberately outside every budget: a clipped command is worse than a long one. +TITLE_CAP=48 +NOTE_CAP=56 +HEADLINE_CAP=80 + resolve_width() { local w=${FM_FLEET_VIEW_WIDTH:-} if [ -z "$w" ] && [ -t 1 ]; then w=$(tput cols 2>/dev/null || true); fi @@ -165,7 +175,20 @@ CLASSIFY=' elif $s < 3600 then "\(($s / 60) | floor)m" elif $s < 86400 then "\(($s / 3600) | floor)h" else "\(($s / 86400) | floor)d" end; - def short($t): ($t.backlog.title // "") | clean; + # A backlog title is a body, not a label: in a real home it carries PR + # verdicts, finding lists, and file paths. An entry keeps only the first + # sentence of a title - up to the first ".", ";", or newline - within the title + # budget, and says so with a single ellipsis whenever anything was dropped. + def clip($raw): + ($raw | tostring) as $text + | ($text | clean) as $full + | ((($text | [splits("[.;\r\n]")])[0] // "") | clean) as $head + | (if $head == "" then $full else $head end) as $s + | ($s | cap($title_cap)) as $capped + | if $capped != $s then $capped + elif $s != $full then "\($s)…" + else $s end; + def short($t): clip($t.backlog.title // ""); def name($t): short($t) as $s | if $s == "" then ($t.id | tostring) else "\($t.id) \($s)" end; @@ -180,13 +203,17 @@ CLASSIFY=' def decision_line($t): (decisions($t)) as $d | ($d | map(select(.verb == "blocked")) | first) as $blocked - | (($blocked // $d[0]).summary | clean | unkey | cap(90)) as $note + | (($blocked // $d[0]).summary | clean | unkey | cap($note_cap)) as $note | if $blocked != null then (if $note == "" then "blocked, needs your help" else "blocked: \($note)" end) else (if $note == "" then "a decision is waiting on you" else "decision: \($note)" end) end; - def row($section; $headline; $action): "\($section)\t\($headline | clean)\t\($action | clean)"; + # The headline is bounded whatever it was assembled from, so N entries always + # cost about N * 3 lines and the second needs-you entry is never pushed off a + # short pane. The action stays whole: the captain copies it. + def row($section; $headline; $action): + "\($section)\t\($headline | clean | cap($headline_cap))\t\($action | clean)"; ([.tasks[]? | . as $t @@ -219,7 +246,7 @@ CLASSIFY=' elif $terminal then row("WAIT"; "\(name($t)) — finished, wrapping up"; "") elif $state == "paused" then - ((($t.hints.last_event_text | clean | sub("^[a-z-]+: *"; "") | unkey | cap(70))) as $why + ((($t.hints.last_event_text | clean | sub("^[a-z-]+: *"; "") | unkey | cap($note_cap))) as $why | row("WAIT"; "\(name($t)) — \(if $why == "" then "waiting on something outside" else $why end)"; "")) elif $state == "working" then row("FLIGHT"; "\(name($t)) — working \(age($t))"; "") @@ -231,14 +258,16 @@ CLASSIFY=' [.backlog.records[]? | select(.structured == true and .state == "done") | select(((.completion.date // "") | clean) == $today) - | row("DONE"; "\(.id) \(.title | clean)"; "") + | row("DONE"; "\(.id) \(clip(.title // ""))"; "") ])[] ' render_sidebar() { # local snapshot=$1 records live needs today today=${FM_FLEET_VIEW_TODAY:-$(date +%F)} - records=$(printf '%s' "$snapshot" | jq -r --arg today "$today" "$CLASSIFY" 2>/dev/null) || records='' + records=$(printf '%s' "$snapshot" | jq -r --arg today "$today" \ + --argjson title_cap "$TITLE_CAP" --argjson note_cap "$NOTE_CAP" \ + --argjson headline_cap "$HEADLINE_CAP" "$CLASSIFY" 2>/dev/null) || records='' live=$(printf '%s' "$records" | grep -c -v '^DONE ' 2>/dev/null || true) [ -n "$records" ] || live=0 diff --git a/docs/fleet-view.md b/docs/fleet-view.md index d8017c6a5b..75f773c5ed 100644 --- a/docs/fleet-view.md +++ b/docs/fleet-view.md @@ -35,6 +35,18 @@ wezterm cli split-pane --right --percent 30 -- \ Each redraw takes a fresh snapshot of the whole home, so prefer a calm interval over a tight one on a busy fleet. +## What an entry looks like + +Every entry in every section is a glance, not a record. +An entry headline is the work item number plus the first sentence of its title, clipped to about two wrapped lines and closed with a single `…` whenever anything was dropped. +That matters because a real backlog title is a body rather than a label: it accumulates pull request verdicts, finding lists, and file paths, and one unclipped title fills a narrow pane and pushes everything below it off screen. +No section ever renders a note body, a verdict list, findings, or a raw worker report. + +The action line under an entry is the exception and is never clipped. +A pull request URL or a review command goes out whole and soft-wraps if it has to, because a truncated command is worse to receive than a long one. + +`--wide` and `--json` are unaffected: both are whole-fleet reads rather than a narrow pane, so they carry titles in full. + ## What the sections mean `NEEDS YOU` is the only section that asks for anything. diff --git a/tests/fm-fleet-view.test.sh b/tests/fm-fleet-view.test.sh index edac8161e7..97e8cca5dd 100755 --- a/tests/fm-fleet-view.test.sh +++ b/tests/fm-fleet-view.test.sh @@ -172,6 +172,78 @@ EOF pass "needs-you covers open decisions, PR merge word, local-only review, and a gone worker" } +# A real backlog title is a body, not a label. This one reproduces the live +# 2026-08-11 render, where a single needs-you entry printed its whole title - +# a status dump of PR verdicts, findings, and file paths - and pushed every +# later entry off a short pane. +LONG_TITLE='review pr2909 add po to ap. VERDICT: pass with 4 findings; finding 1: bin/fm-fleet-view.sh line 168 renders the full backlog title; finding 2: docs/fleet-view.md does not describe the entry format; finding 3: tests/fm-fleet-view.test.sh has no long-title fixture; finding 4: state/2909.status carries the verdict body verbatim. Reviewed heads: 3f1a2b9 against origin/main, checks green, awaiting merge word from the captain.' + +test_long_title_stays_glanceable() { + local home out over + home=$(make_home longtitle) + { + printf '## In flight\n' + printf -- '- [ ] 2909 - %s (repo: alpha) (kind: ship) (since 2026-08-11)\n' "$LONG_TITLE" + printf -- '- [ ] 7002 - vault sync (repo: alpha) (kind: ship) (since 2026-08-11)\n' + } > "$home/data/backlog.md" + add_task "$home" 2909 ship ship 'done: PR is up' \ + "pr=https://github.com/ecloin/firstmate/pull/2909" + add_task "$home" 7002 local-only ship 'done: ready branch' + record_idle "$home" 2909 + record_idle "$home" 7002 + + out=$(view "$home" | unwrap) + assert_contains "$out" "NEEDS YOU (2)" "both finished tasks are waiting on the captain" + + # Every entry headline stays within the renderer's cap, which is about two + # wrapped lines at this pane width. The two-character section glyph and its + # space are the only allowance on top of it. + over=$(printf '%s\n' "$out" | grep '^[●◐○✓] ' | jq -Rr 'select(length > 84) | "\(length): \(.)"') + [ -z "$over" ] || fail "an entry headline must stay within the sidebar cap: $over" + + assert_contains "$out" "PR #2909 review pr2909 add po to ap…" \ + "a long title should clip to its first sentence with a single ellipsis" + assert_not_contains "$out" "VERDICT" "a verdict body must never reach the sidebar" + assert_not_contains "$out" "finding 2" "a finding list must never reach the sidebar" + assert_not_contains "$out" "bin/fm-fleet-view.sh" "a file path from a title must never reach the sidebar" + + assert_contains "$out" "7002 vault sync" \ + "a clipped first entry must leave the second needs-you entry on the pane" + + # The action line is deliberately outside every budget: a clipped command is + # worse than a long one, so both survive whole and unwrapped. + assert_contains "$out" "https://github.com/ecloin/firstmate/pull/2909" \ + "a PR URL must survive unclipped even when the entry was clipped" + assert_contains "$out" "bin/fm-review-diff.sh 7002" \ + "a review command must survive unclipped" + pass "a multi-hundred-character title clips to a glanceable entry without hiding the next one" +} + +test_long_title_clipping_covers_every_section() { + local home out over + home=$(make_home longtitle-sections) + { + printf '## In flight\n' + printf -- '- [ ] 3406 - %s (repo: alpha) (kind: ship) (since 2026-08-11)\n' "$LONG_TITLE" + printf -- '- [ ] 7001 - %s (repo: alpha) (kind: ship) (since 2026-08-11)\n' "$LONG_TITLE" + printf '## Done\n' + printf -- '- [x] 3410 - %s (repo: alpha) (kind: ship) (merged %s)\n' "$LONG_TITLE" "$TODAY" + } > "$home/data/backlog.md" + add_task "$home" 3406 ship ship 'working: rebasing onto the new base' + add_task "$home" 7001 ship ship 'paused: upstream release lands Thursday' + record_busy "$home" 3406 + record_idle "$home" 7001 + + out=$(view "$home" | unwrap) + assert_contains "$out" "IN FLIGHT (1)" "the working task belongs in flight" + assert_contains "$out" "WAITING (1)" "the declared wait belongs in waiting" + assert_contains "$out" "DONE TODAY (1)" "the landed work belongs in done today" + over=$(printf '%s\n' "$out" | grep '^[●◐○✓] ' | jq -Rr 'select(length > 84) | "\(length): \(.)"') + [ -z "$over" ] || fail "every section must clip its entries, not only NEEDS YOU: $over" + assert_not_contains "$out" "VERDICT" "no section may render a verdict body" + pass "in-flight, waiting, and done-today entries clip the same way as needs-you" +} + test_in_flight_shows_age() { local home out home=$(make_home flight) @@ -313,6 +385,8 @@ EOF test_empty_fleet_is_all_quiet test_needs_you_classification test_narrow_pane_never_overflows +test_long_title_stays_glanceable +test_long_title_clipping_covers_every_section test_in_flight_shows_age test_paused_and_scout_wait test_done_today_filters_by_date