Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions bin/fm-fleet-view.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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))"; "")
Expand All @@ -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() { # <snapshot-json>
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
Expand Down
12 changes: 12 additions & 0 deletions docs/fleet-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
74 changes: 74 additions & 0 deletions tests/fm-fleet-view.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading