Selaa lähdekoodia

refactor(skills/fleet-ops): unbroken tree connectors, icon-after-glyph

The previous grouped-tree layout put the state icon at column 0, where
the tree's vertical line should run. The eye lost the scaffold every
two rows. Re-root the tree at the header rule: groups become first-class
branches with ├─/└─ at column 0, icons sit AFTER the connector, and
children continue under │ without interruption.

Before:
  ⏳ RUNNING   (1)
    └─ alpha                            1s
  ✅ READY     (1)               <- visual scaffold restarts every group
    └─ beta                             0s

After:
  ├─ ⏳ RUNNING   (1)
  │  └─ alpha                           1s
  └─ ✅ READY     (1)
     └─ beta                            0s

term.sh: term_tree_branch / term_tree_last replaced with composable
term_tree_node (prefix, connector, label, meta) + term_tree_indent
that returns the 3-col continuation segment ("│  " or "   ") for an
ancestor. This composes to N levels — DESIGN.md now shows both a
2-level (groups → leaves) and 3-level (groups → branches → leaves)
example.

Tree-control rule documented in DESIGN.md: connectors are the scaffold,
nothing breaks the vertical, icons go on the label side.

fleet-ops e2e: 20/20 still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0xDarkMatter 1 kuukausi sitten
vanhempi
sitoutus
69246e6fb1
3 muutettua tiedostoa jossa 122 lisäystä ja 71 poistoa
  1. 63 37
      docs/DESIGN.md
  2. 29 24
      skills/_lib/term.sh
  3. 30 10
      skills/fleet-ops/scripts/fleet.sh

+ 63 - 37
docs/DESIGN.md

@@ -74,51 +74,79 @@ terminal width so the header reads as the section's banner.
 
 ### Grouped tree (default body)
 
-State icon + group label + count, then children under tree connectors.
-Empty groups are omitted — never render a group with `(0)`.
+**Tree-control rule:** the connectors `├─ │ └─` are the scaffold. They
+run in their own column from the top of the body to the last leaf, and
+**nothing breaks the vertical**. Icons and labels live to the right of
+the connector, not between it and its parent's `│`. If you find yourself
+wanting to put an icon where the `│` should continue, you don't have a
+tree — you have a list with decorations.
 
-```
-── fleet ─────────────────────────────────────────────────────  4 lanes · 3 active
-
-  ⏳ RUNNING   (2)
-    ├─ feat/auth-rewrite             12m
-    └─ spike/wasm-eval               34m
+#### 2-level: groups → leaves
 
-  ✅ READY     (1)
-    └─ fix/cache-bust                2m
+The default for state-bucketed views (lanes, PR checks, jobs).
 
-  🚀 LANDED    (1)
-    └─ chore/bump-deps               1h
+```
+── fleet ─────────────────────────────────────────────────────  4 lanes · 3 active
+├─ ⏳ RUNNING   (2)
+│  ├─ feat/auth-rewrite             12m
+│  └─ spike/wasm-eval               34m
+├─ ✅ READY     (1)
+│  └─ fix/cache-bust                2m
+└─ 🚀 LANDED    (1)
+   └─ chore/bump-deps               1h
 ```
 
+Notice: the `│` running down column 0 is unbroken from the first group
+to the last child of the second-last group. The `└─` on `LANDED`
+terminates the vertical cleanly. Empty groups are omitted — never
+render `(0)`.
+
 Why grouped instead of flat: when ten lanes are in flight, scanning a
 flat table for "what's actually ready to land?" forces your eyes to do
 the filtering. Grouping does it for you, and the count tells you at a
 glance whether the answer is none, one, or twelve.
 
+#### 3-level: groups → branches → leaves
+
+For hierarchies with intermediate structure — repos with branches with
+files, projects with packages with tests, lanes with commits with
+patches. Same rule: connectors don't break.
+
+```
+── repo ──────────────────────────────────────────────────────  X:/Forge/claude-mods · 2 worktrees
+├─ 📦 main
+│  ├─ src/
+│  │  ├─ index.ts                   modified
+│  │  └─ utils/
+│  │     ├─ format.ts               modified
+│  │     └─ parse.ts                added
+│  └─ README.md                     clean
+└─ 🌿 feat/auth-rewrite
+   └─ src/
+      ├─ auth.ts                    new
+      └─ middleware/
+         └─ session.ts              modified
+```
+
+Each level adds a 3-column indent: `│  ` while the ancestor still has
+siblings to render, `   ` once the ancestor is on its last sibling. The
+helpers in `term.sh` (`term_tree_node`, `term_tree_indent`,
+`term_tree_connector`) compose this prefix so you don't have to count
+spaces.
+
 ### Flat status table (escape hatch)
 
 When the data is genuinely flat — `git status`-style fields, a single
 PR's checks — drop the tree. Glyph-first, no nested tables.
 
 ```
+── push-gate ─────────────────────────────────────────────────  refusing
   ✅  secret scan        clean
   ✅  forbidden files    none
   ❌  divergence         3 ahead, 1 behind
 ```
 
-### Plain tree (filesystem-style, no state grouping)
-
-For hierarchies that aren't keyed on state — a directory tree, an
-include graph. Use sparingly; the grouped-tree above is preferred for
-state.
-
-```
-repo/
-├─ main                           clean
-├─ feat/auth-rewrite              ahead 3, dirty
-└─ fix/cache-bust                 behind 1
-```
+The header rule still anchors the section; only the body is flat.
 
 ### Section divider
 
@@ -166,25 +194,23 @@ Disabled when stdout isn't a TTY, or `NO_COLOR` is set. Forced on with
 ────────────────────────────────────────────────────────────────
 ```
 
-### After — rule on top, grouped tree as default
+### After — rule on top, grouped tree with unbroken connectors
 
 ```
 ── fleet ─────────────────────────────────────────────────────  3 lanes · 2 active
-
-  ⏳ RUNNING   (1)
-    └─ feat/auth-rewrite             12m
-
-  ✅ READY     (1)
-    └─ fix/cache-bust                2m
-
-  🚀 LANDED    (1)
-    └─ chore/bump-deps               1h
+├─ ⏳ RUNNING   (1)
+│  └─ feat/auth-rewrite             12m
+├─ ✅ READY     (1)
+│  └─ fix/cache-bust                2m
+└─ 🚀 LANDED    (1)
+   └─ chore/bump-deps               1h
 ```
 
 The header rule survives — it's the strongest visual cue that you're
-inside a skill's output. The flat table gives way to grouped state, so
-"what's ready" and "what's still running" answer themselves before you
-read a single branch name.
+inside a skill's output. The flat table gives way to a tree where
+groups are first-class branches: the `│` runs uninterrupted from the
+first group to the last leaf above the terminating `└─`, and icons
+sit *after* the connector instead of breaking it.
 
 ### `git-ops/status` reformatted in the same language
 

+ 29 - 24
skills/_lib/term.sh

@@ -169,39 +169,44 @@ TERM_TREE_BRANCH=""    # ├─  /  +-
 TERM_TREE_LAST=""      # └─  /  `-
 TERM_TREE_VERT=""      # │   /  |
 
-# term_group_header <icon> <LABEL> <count>  — "  ⏳ RUNNING   (3)"
-# Use as the parent line above term_tree_branch / term_tree_last children.
-term_group_header() {
-  local icon=$1 label=$2 count=$3
-  printf '  %s %-9s %s\n' "$icon" "$label" "$(term_color dim "($count)")"
+# Tree-control philosophy: the connectors (├─ │ └─) are the scaffold.
+# Icons and labels sit AFTER the connector, never between it and the
+# vertical line of its parent. To render a tree:
+#
+#   term_tree_node "" "$(term_tree_connector $i $last)" "⏳ RUNNING (3)"
+#   term_tree_node "│  " "$(term_tree_connector $j $last)" "feat/auth" "12m"
+#
+# `prefix` is what comes before this row's connector — built by walking
+# the ancestor chain and appending TERM_TREE_VERT+"  " for non-last
+# ancestors, or three spaces for last ancestors.
+
+# term_tree_connector <idx> <last_idx>  — echo branch or last glyph.
+term_tree_connector() {
+  if [[ "$1" -eq "$2" ]]; then printf '%s' "$TERM_TREE_LAST"
+  else printf '%s' "$TERM_TREE_BRANCH"; fi
 }
 
-# term_tree_branch <label> [meta]  — "    ├─ label                meta"
-term_tree_branch() {
-  local label=$1 meta=${2:-}
-  if [[ -n "$meta" ]]; then
-    printf '    %s %-32s %s\n' "$TERM_TREE_BRANCH" "$label" "$(term_color dim "$meta")"
-  else
-    printf '    %s %s\n' "$TERM_TREE_BRANCH" "$label"
-  fi
+# term_tree_indent <is_last>  — echo the 3-col continuation segment for
+# this ancestor: "│  " when more siblings follow, "   " when last.
+term_tree_indent() {
+  if [[ "$1" -eq 1 ]]; then printf '   '
+  else printf '%s  ' "$TERM_TREE_VERT"; fi
 }
 
-# term_tree_last <label> [meta]  — "    └─ label                meta"
-term_tree_last() {
-  local label=$1 meta=${2:-}
+# term_tree_node <prefix> <connector> <label> [meta]
+#   prefix:    ancestor-chain string (built from term_tree_indent calls)
+#   connector: result of term_tree_connector for THIS row
+#   label:     visible text (may include leading icon — won't break the line)
+#   meta:      optional dim trailing text
+term_tree_node() {
+  local prefix=$1 conn=$2 label=$3 meta=${4:-}
   if [[ -n "$meta" ]]; then
-    printf '    %s %-32s %s\n' "$TERM_TREE_LAST" "$label" "$(term_color dim "$meta")"
+    printf '%s%s %-32s %s\n' "$prefix" "$conn" "$label" "$(term_color dim "$meta")"
   else
-    printf '    %s %s\n' "$TERM_TREE_LAST" "$label"
+    printf '%s%s %s\n' "$prefix" "$conn" "$label"
   fi
 }
 
-# term_tree_connector <idx> <last_idx>  — echo branch or last, for loops.
-term_tree_connector() {
-  if [[ "$1" -eq "$2" ]]; then printf '%s' "$TERM_TREE_LAST"
-  else printf '%s' "$TERM_TREE_BRANCH"; fi
-}
-
 # term_table_row <c1> <c2> <c3>  — fixed-width 3-col row.
 term_table_row() {
   printf '  %-2s  %-32s %-10s %s\n' "${1:-}" "${2:-}" "${3:-}" "${4:-}"

+ 30 - 10
skills/fleet-ops/scripts/fleet.sh

@@ -174,30 +174,50 @@ cmd_fleet() {
     return
   fi
 
+  # Build list of non-empty group indices so we know which is "last" at
+  # the top level — the tree's vertical needs to terminate cleanly.
+  local active_groups=()
   local i
   for i in 0 1 2 3 4; do
+    [[ ${state_counts[$i]} -gt 0 ]] && active_groups+=("$i")
+  done
+
+  local g_idx=0
+  local g_last=$(( ${#active_groups[@]} - 1 ))
+  for i in "${active_groups[@]}"; do
     local n=${state_counts[$i]}
-    [[ $n -eq 0 ]] && continue
     local state=${order[$i]}
     local icon
     icon=$(term_state_icon "$state")
     [[ -z "$icon" || "$icon" == "?" ]] && icon="$ICON_UNKNOWN"
-    echo ""
-    term_group_header "$icon" "$state" "$n"
+
+    # Group line — connector at column 0, then icon + label live to its right.
+    local g_conn
+    g_conn=$(term_tree_connector "$g_idx" "$g_last")
+    local group_label
+    group_label="$icon $state"
+    term_tree_node "" "$g_conn" "$group_label" "($n)"
+
+    # Children indent = continuation of this group's connector.
+    local child_prefix
+    if [[ $g_idx -eq $g_last ]]; then
+      child_prefix=$(term_tree_indent 1)
+    else
+      child_prefix=$(term_tree_indent 0)
+    fi
 
     local lines="${state_buckets[$i]}"
-    local idx=0 last_idx=$((n - 1))
+    local c_idx=0 c_last=$((n - 1))
     local branch age meta
     while IFS='|' read -r branch age meta; do
       [[ -z "$branch" ]] && continue
-      local connector
-      connector=$(term_tree_connector "$idx" "$last_idx")
-      local label="$branch"
-      local meta_str="$age"
+      local c_conn meta_str="$age"
+      c_conn=$(term_tree_connector "$c_idx" "$c_last")
       [[ -n "$meta" ]] && meta_str="$age  $meta"
-      printf '    %s %-32s %s\n' "$connector" "$label" "$(term_color dim "$meta_str")"
-      idx=$((idx+1))
+      term_tree_node "$child_prefix" "$c_conn" "$branch" "$meta_str"
+      c_idx=$((c_idx+1))
     done <<< "$lines"
+    g_idx=$((g_idx+1))
   done
   echo ""
 }