Skip to content

tui: fork + subagent lineage view popup#745

Open
edwin-zvs wants to merge 17 commits into
mainfrom
lineage-view
Open

tui: fork + subagent lineage view popup#745
edwin-zvs wants to merge 17 commits into
mainfrom
lineage-view

Conversation

@edwin-zvs

@edwin-zvs edwin-zvs commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Upgrades the fork log popup (C-x q / q) from a flat "N forks" status
line into a live tree/graph view unifying both relationships a session
can have with other sessions:

  • Fork edges (, dashed/lighter) — mergeable siblings via
    forked_from (spec 0078).
  • Subagent edges (, solid) — true parent/child helpers via
    parent_session_id (spec 0014).

The view opens rooted at the topmost fork/subagent ancestor of the
selected session and renders the full tree back down git log --graph-style
(vertical/diagonal rail, glyph at each branch point), recursively and with
multiple siblings per level.

What shipped (full scope, nothing deferred)

  1. Tree renderingcrates/cli/src/lineage.rs: pure, App-independent
    tree construction (build_tree/build_subtree) and git log --graph-style
    row layout (flatten/LineageRow::rail_prefix), decoupled from ratatui so
    the same logic can back a future pinnable/dockable panel without a rewrite
    — the popup module only wires it to live data (spec's priority Add in-app text selection for TUI copy #1 and Add zarvis reset slash command #5).
  2. Terminal-state rendering + jump-to-transcript — a merged fork
    (ForkMergeMode::Result) renders closing back into its parent's column
    (↩ merged, dimmed) and Enter on it jumps to the parent, where the
    merge actually injected the result message — same event, no duplicated
    view. A discarded fork renders dimmed + struck-through (✗ discarded),
    unambiguous against a still-open fork (priority docs(agents): require branch + worktree + PR for all changes #2).
  3. Live ticker — a ~1s interval scoped strictly to "popup open"
    (lineage_popup_refresh in run_loop) refreshes session stats; nothing
    polls while the popup is closed. Topology/state already update live via
    the existing STATE broadcast subscription (self.sessions), same path
    the branch-rail ⑂N badge already uses (priority fix(tui): shadow parser for mouse-scroll history in claude / codex sessions #3).
  4. Depth/breadth capMAX_DEPTH = 6, MAX_SIBLINGS = 12, collapsing
    into a single +N more marker rather than growing unboundedly
    (priority fix(ui): anchor pin tile crop to bottom of source screen #4).
  5. Reusable component structuring — tree logic lives in its own module
    (crates/cli/src/lineage.rs) with no App/ratatui dependency; the popup
    (crates/cli/src/app/lineage_popup.rs) is a thin adapter layer
    (priority Add zarvis reset slash command #5, though not promoted to a dockable panel — that's future
    work the module structure permits but doesn't build).

Interaction

  • j/k/arrows move between nodes.
  • Enter jumps into the selected session (or its parent, for a merged
    fork — see above).
  • m / d merge / discard a selected open fork, calling into the
    exact same code path the C-x m minibuffer menu already uses
    (App::apply_fork_merge, extracted from MinibufferIntent::MergeMenu
    not reimplemented). A minor incidental fix along the way: the status
    message now says "fork discarded" for a discard instead of always
    "fork merged".
  • Esc closes the popup. Any other key closes it and re-dispatches the
    same keystroke through ordinary routing (handle_lineage_popup_key
    returns false), the same rule handle_configure_key already
    documents for auto-opened modals — verified with a unit test
    (unhandled_key_closes_the_popup_and_reports_unhandled).

Non-goals (per spec 0079)

Does not change what a fork or subagent is (specs 0078/0014 still
govern), does not add a second merge/discard implementation, and does not
promote the tree view to a pinnable/dockable panel yet.

Update: lineage preview on the harness label (spec 0080)

Adds a second, lighter-weight presentation of the same lineage data, layered
on top of the existing pane title bar rather than a new glyph or a second
modal:

  • On a session that actually has lineage to show (forked_from.is_some(),
    or it has at least one live fork/subagent descendant —
    crate::lineage::has_lineage, crates/cli/src/lineage.rs), the pane
    title bar's existing harness label (apply_pane_title_right_cluster)
    becomes a hover/click trigger. Ordinary sessions get no hit registered at
    all — the label keeps rendering exactly as before, with zero visual or
    behavioral change.
  • Hover shows a small preview box anchored to that session's own pane
    (the same corner the sticky-widget popover anchors to), rendering the
    identical tree — reused directly via crate::lineage::build_tree/
    flatten and the modal's own render_lineage_row, not re-derived. A
    short grace period (LINEAGE_PREVIEW_HOVER_GRACE_MS) lets the pointer
    travel from the label down onto the preview body without it vanishing,
    and resting on the body itself keeps renewing that grace, mirroring the
    (separately deprecated, spec 0003) session-widget hover shape as
    independent state (app/lineage_preview.rs), not a dependency on it.
  • Click toggles a persistent pin, keeping the preview open regardless of
    hover; clicking again un-pins it.
  • The C-x q / q modal was unchanged at this point in the PR — still
    the only fully interactive, keyboard-navigable, merge/discard-capable
    surface; this preview was read-only (no row-jump, no keyboard nav).
    Superseded by "Update 2" below, which deletes the modal and makes
    this preview itself keyboard-interactive.
  • New LayoutSnapshot fields: harness_label_hits (populated only for
    lineage-bearing sessions) and lineage_preview_area, following this
    codebase's per-frame hit-rect convention. New App state:
    lineage_preview_hover / lineage_preview_pinned, independent of the
    widget system's own hover/pin fields.
  • Mouse dispatch wired into both paths this codebase already splits click
    handling across: handle_left_click (plain session view, click-release)
    and handle_program_mouse (active Program popup, click-press) — mirroring
    exactly how the existing sticky-widget title-square click is wired in both
    places.
  • Design recorded in specs/0080-lineage-preview-on-harness-label.md.

Note for reviewers: an earlier iteration of this update added a dedicated
icon next to the harness label as the hover/click trigger instead of
reusing the label itself. That approach was replaced with the harness-label
trigger described above before this push — no icon glyph, no new title-bar
column, smaller diff.

Update 2: delete the global modal — the preview is now the only lineage UI

The C-x q / q full-screen popup described above is deleted. A
session's lineage is a per-session concern; keeping a second, architecturally
distinct global dialog next to a session-attached preview that showed the
same data was unnecessary duplication. Its interaction vocabulary was
ported onto the preview itself rather than dropped:

  • crates/cli/src/app/lineage_popup.rs is gone. App::lineage_popup,
    KeyAction::OpenForkLog, its C-x q (emacs) / q (vim) bindings, and
    render_lineage_popup are all removed.
  • New keybinding: C-x Tab (both keymap profiles — bare Tab stays
    intentionally unbound, so this compound chord was free) toggles the
    selected session's lineage preview between closed and "pinned +
    keyboard-focused" in one keystroke — a no-op when the session has no
    lineage, same gate the harness label's own hover/click affordance uses.
  • New state: App::lineage_preview_focused (plus selection/scroll
    fields), entered via C-x Tab or by clicking inside a visible preview's
    body (a new LayoutSnapshot::lineage_preview_body_hit, distinct from
    the harness-label trigger and from the border-swallowing
    lineage_preview_area). Entering focus also pins the preview open, since
    focusing implies wanting to keep interacting with it.
  • The full keyboard vocabulary now lives on the preview:
    j/k/arrows/C-n/C-p navigate, Enter jumps in (and clears both
    focus and pin — "leaving to go work in that session"), m/d
    merge/discard via the same App::apply_fork_merge the old popup called
    (not reimplemented), Esc clears focus only — it deliberately does
    not un-pin, so a preview you explicitly pinned stays visible after
    you're done navigating it. Any other key clears focus and is reported
    unhandled so App::on_key re-dispatches the same keystroke through
    ordinary routing (C-x C-c still quits, C-x b still switches sessions,
    etc.) — the exact "closing overlay never eats a live keystroke" contract
    the old modal's gate used, now the only lineage gate in on_key.
  • Two-tier harness-label styling: hover-only is the light tier (bold,
    unchanged); pinned is now the strong tier — bold + Modifier::REVERSED,
    this codebase's existing convention for an emphatic, persistent
    interactive-text cue (reused from the action-link/URL hover styling,
    applied here as the persistent state rather than a hover flash).
  • Border reacts to focus: the preview's own border now uses
    pane_border_styletheme.border_focused while keyboard-focused, the
    dimmer theme.border otherwise (hover/pin without focus) — the same
    focus language every other pane border in this UI already uses.
  • Specs: specs/0079-fork-and-subagent-lineage-view.md is marked
    Status: superseded by spec 0080 (kept as a historical record of the
    ported tree-construction/interaction rules); specs/0080 now documents
    the combined hover/pin/focus surface as the single lineage UI.

Update 3: activity stats move from per-node totals to per-segment rail annotations

Each node in the tree used to show its OWN cumulative stats (message count,
elapsed time, cost) on its own line. That's replaced with separate,
non-selectable annotation rows on the rail, positioned BETWEEN the markers
that bound them — a node's own creation, each fork child's fork-out point,
each fork child's merge-back point (only for an actual merge — a discard
never injects anything into the parent's transcript, so it isn't a
boundary), and "now" (or the node's own terminal point, if it has one).
Each gap describes what happened in exactly that window, not a lifetime
total:

◆ ● claude — auth-refactor
│   12 msgs · 8m12s
├─⑂ ● claude — fork idea A
│  │   2 msgs · 1m05s
│  ↩ merged
│   5 msgs · 3m40s
└─⑂ ● claude — fork idea B
   │   1 msg · 30s

A node's own line is now just rail + edge glyph + status + harness
[+ title] [+ merged/discarded marker] — no stats.

  • No new fetches. SessionSummary::event_count, ForkedFrom:: transcript_seq, and the new ForkMerge::merged_seq are all the same
    transcript sequence counter, so segment boundaries and their deltas are
    plain arithmetic over data already in memory, recomputed fresh on every
    render.
  • Protocol: ForkMerge gains merged_seq: u64
    (#[serde(default)] for backward compatibility with merge records
    persisted before this field existed) — the parent-timeline counterpart to
    ForkedFrom::transcript_seq. Stamped by SessionManager::merge()
    (crates/daemon/src/session.rs) from the fork's PARENT's current
    event_count at merge time.
  • crates/cli/src/lineage.rs: flatten now takes the sessions slice
    (needed to derive segments) and interleaves a new non-selectable
    LineageRowKind::Segment row kind at the correct points; rail_prefix
    renders segments with a plain continuing bar, never a branch connector.
    stats_label (per-node) is replaced by segment_label (per-window).
  • Design decisions (left open by the brief, resolved here):
    • Subagent children don't stamp a parent-timeline position the way forks
      do (no equivalent field), so they never act as boundary markers — just
      recursed into in place. A node whose only children are subagents falls
      out of the same loop with one whole-life trailing segment, same as a
      true leaf.
    • A childless node still gets exactly one segment — its whole life, from
      creation to "now" (or to its own merge time, if it's itself a fork
      that has since merged/discarded) — so every node's activity is visible
      somewhere, not just nodes with forks.
    • A zero-length window (two consecutive checkpoints land on the same
      transcript_seq) is skipped everywhere, not just the trailing "to
      now" case.
    • Cost (SessionSummary::cost_usd) is dropped from the lineage view
      entirely rather than attributed to a segment — it's a single
      cumulative total with no per-checkpoint snapshot the way event_count
      has.
  • Verified the existing depth/breadth-cap and narrow-terminal preview
    tests still pass unmodified with the extra segment rows — the preview's
    height/scroll math already scales with row count.
  • specs/0080-lineage-preview-on-harness-label.md updated with the new
    per-segment design.

Test plan

  • cargo build (debug, full workspace) passes — this update also
    touches crates/protocol and crates/daemon, not just crates/cli.
  • cargo test -p agentd --lib — 190 passed, 0 failed (net +3: the new
    merge() merged_seq-stamping tests — parent's current event_count,
    a gone parent falling back to 0, merging a non-fork session fails).
  • cargo test -p agentd-cli — 797 passed, 0 failed (net +16 versus the
    781 baseline from the prior update: lineage.rs gained segment
    boundary-computation tests — single fork, multiple forks with mixed
    merged/discarded/still-open outcomes, a zero-length-window skip, a
    leaf's own trailing segment (including one that ends at its own
    merge time rather than "now"), subagent children not splitting the
    parent's timeline, segment rows never selectable, and segment rail
    prefixes never using a branch connector — plus ui.rs gained render
    tests confirming a node's line no longer carries stats and that a
    segment row's text/style is distinct from a node row's.
    • cargo test -p agentd-cli also runs tests/reconnect.rs — 1 passed.
  • cargo fmt was run; it reformatted many unrelated pre-existing files
    across the repo (known repo-wide drift per AGENTS.md) — those were
    reverted before committing. Only the files this PR touches are
    included.

Spec

specs/0079-fork-and-subagent-lineage-view.md (superseded — the modal it
described is deleted, its still-true rules folded into 0080) and
specs/0080-lineage-preview-on-harness-label.md (now the single spec for
the hover/pin/focus lineage preview, including the per-segment stats
design from Update 3).

Binaries

Single-binary architecture (daemon + client in one binary). Update 3 also
touches crates/protocol and crates/daemon, in addition to crates/cli
(TUI) — all compile into the same binary. Built at
.claude/worktrees/agent-a8674ece67dcfe847/target/debug/construct.

edwin-zvs added 17 commits July 9, 2026 13:14
Upgrade the fork log popup (C-x q / q) from a flat "N forks" status line
into a live git-log-graph-style tree unifying fork lineage (forked_from,
spec 0078) and subagent parent/child relationships (parent_session_id,
spec 0014) for the selected session's full ancestry.

- crates/cli/src/lineage.rs: App-independent tree construction + layout
  (recursive, multi-branch, depth/breadth capped with "+N more" markers),
  reusable by a future dockable panel.
- crates/cli/src/app/lineage_popup.rs: wires the tree to live session
  data, keyboard navigation (j/k/arrows, Enter, m/d, Esc), and the
  existing merge/discard path (extracted into App::apply_fork_merge and
  shared with the C-x m minibuffer menu, not reimplemented).
- crates/cli/src/ui.rs: renders the popup — distinct glyphs/styles for
  fork (⑂, dashed) vs subagent (▸, solid) edges, merged/discarded
  terminal-state styling, a ~1s ticker scoped to "popup open" for
  elapsed-time/cost freshness.
- Jumping into a merged fork's node instead lands on its parent, where
  the merge actually injected its result message — same event, not a
  duplicate view.

Adds specs/0079-fork-and-subagent-lineage-view.md.
Mirrors the session-list's own emacs-style NextSession/PrevSession
convention inside the lineage popup, alongside the existing j/k/arrows.
Sessions with fork/subagent lineage to show now get a hover/click
preview on their existing pane title-bar harness label: hovering
reveals a small, session-attached box rendering the same tree data
the C-x q / q modal shows (reused directly from crate::lineage, not
re-derived), with a short grace period so the pointer can travel from
the label down onto the preview body. Clicking the label toggles a
persistent pin. Ordinary sessions with no lineage register no hit and
render the label exactly as before.

The modal stays completely unchanged - this is a second, lighter,
read-only, session-attached surface layered on top of the existing
label, not a replacement. Visibility/hover-grace/pin state mirrors the
shape of the (separately deprecated) session-widget hover/pin system
as independent state, per specs/0080.
Delete the global C-x q / q fork/subagent lineage modal entirely and
port its keyboard vocabulary (j/k/arrows/C-n/C-p navigate, Enter jumps
in, m/d merge/discard, Esc backs out) onto the per-session lineage
preview instead, reached via a click inside the preview's body or the
new C-x Tab toggle (both keymap profiles). There is now exactly one
lineage UI, not two.

Also sharpens the preview's existing hover/pin styling into two visibly
distinct intensities (light bold hover vs. strong bold+REVERSED pin) and
gives the preview's own border the same focused/unfocused treatment
every other pane border uses.

Esc clears keyboard focus only, without un-pinning, so a preview the
user explicitly pinned stays visible after they're done navigating it.

specs/0079 is marked superseded; its still-true tree-construction and
interaction rules are folded into specs/0080, which now documents the
combined hover/pin/focus surface.
…eview

Three bugs in the lineage preview (spec 0080):

- `C-x Tab` opened but never closed. Its own prefix key (`C-x`) wasn't in
  the focused-preview's key vocabulary, so the "unhandled key clears
  focus and falls through" contract cleared focus on the FIRST key of
  the chord, before `Tab` ever arrived to complete it — by the time the
  chord resolved, focus already read as "not focused" and the toggle
  re-opened instead of closing. Both keys of the chord (Ctrl-X, Tab) now
  fall through without touching focus, so the toggle handler sees the
  true prior state.

- Clicking away from a focused preview didn't clear focus — there was no
  code path for it at all. Falling through every lineage-specific click
  check (harness label, body, border) with none matching now clears
  focus, mirroring how any other focused element in this UI behaves.
  (Un-pinning still only happens via the harness label or C-x Tab, same
  as before — a click elsewhere loses focus, not visibility.)

- The preview slid right along with the Program popup while terminal
  focus was active (`C-x C-o`), since it was anchored to the popup's own
  `safe_block_inner` — derived from the popup's `rect`, which
  `program_popup_visible_rect` shifts right for the slide. It now
  anchors to a rect derived from `base_rect` (the pane's fixed
  boundary) instead, computed before `block` is consumed so it survives
  the slide untouched.

Regression tests added for all three, including two that go through the
real `on_key`/`on_mouse` dispatch (not the underlying methods directly)
since that's exactly where the C-x Tab bug lived.
The previous fix for "click elsewhere loses focus" lived inside
handle_left_click, which only runs for clicks that reach it. A click
over a pane whose child has grabbed the mouse (DECSET ?1000h — common
for interactive CLIs like Claude Code) is instead handed straight to
that child's PTY by forward_mouse_to_child and returns before
handle_left_click ever runs, on any pane including the same session's
own content underneath a focused preview. So clicking "elsewhere" often
did nothing, exactly the class of bug PR #735 already hit and fixed
for the tutorial card.

Moved the check earlier, into on_mouse itself, right alongside the
title-rename commit-on-click-away logic it mirrors: a side effect only
(it doesn't consume the event), so the click still proceeds to
forward_mouse_to_child, pane-focus switching, or wherever else it was
headed. The handle_left_click copy stays as a second layer for
whatever reaches it directly.
…ail annotations

Instead of each node in the fork/subagent lineage preview showing its own
cumulative message count and elapsed time, activity stats now render as
separate, non-selectable rows on the rail, positioned between the markers
that bound them: a node's own creation, each fork child's fork-out point,
each fork child's merge-back point (only for an actual merge — a discard
never injects anything into the parent's transcript, so it isn't a
boundary), and "now" (or the node's own terminal point, if it has one). A
node's own line is now just rail + edge glyph + status + harness [+ title]
[+ merged/discarded marker].

This is possible with no new fetches because `SessionSummary::event_count`,
`ForkedFrom::transcript_seq`, and the new `ForkMerge::merged_seq` are all
the same transcript sequence counter, so segment boundaries and their
deltas are plain arithmetic over data already in memory. `merged_seq` is
stamped by `SessionManager::merge()` from the PARENT's event_count at merge
time — the parent-timeline counterpart to `ForkedFrom::transcript_seq`.

Design decisions left open by the brief:
- Subagent children don't stamp a parent-timeline position the way forks
  do, so they never act as boundary markers on the parent's timeline —
  they're just recursed into in place. A node whose only children are
  subagents falls out of the same loop with one whole-life trailing
  segment, same as a true leaf.
- A childless node still gets exactly one segment — its whole life, from
  creation to "now" (or to its own merge time, if it's itself a fork that
  has since merged/discarded) — so every node's activity is visible
  somewhere, not just nodes with forks.
- A zero-length window (two consecutive checkpoints land on the same
  transcript_seq) is skipped everywhere, not just the trailing "to now"
  case, so e.g. root doing nothing while a fork was outstanding renders no
  "0 msgs" line.
- Cost (`SessionSummary::cost_usd`) is dropped from the lineage view
  entirely rather than attributed to a segment — it's a single cumulative
  total with no per-checkpoint snapshot the way event_count has.

`crates/cli/src/lineage.rs`: `flatten` now takes the sessions slice (needed
to derive segments) and interleaves a new non-selectable `LineageRowKind::
Segment` row kind into the flattened output; `rail_prefix` renders segments
with a plain continuing bar, never a branch connector; `stats_label` is
replaced by `segment_label`. `crates/cli/src/ui.rs`'s `render_lineage_row`
drops the per-node stats call and adds a `Segment` render arm styled
distinctly (`theme.dim`) from a node's per-state label color.
`crates/protocol/src/lib.rs` adds `ForkMerge::merged_seq` (`#[serde(default)]`
for backward compatibility with merge records persisted before this field
existed). `crates/daemon/src/session.rs`'s `merge()` looks up the fork's
parent and stamps its current event_count.

Verified the existing depth/breadth-cap and narrow-terminal preview tests
still pass unmodified with the extra segment rows — the preview's
height/scroll math already scales with row count, no adjustment needed.

Updated specs/0080-lineage-preview-on-harness-label.md with the new
per-segment design.
…ail to timeline-style vertical lines

A click landing on the lineage preview body reached forward_mouse_to_child
before handle_left_click whenever the pane underneath had a mouse-tracking
child (very common — Claude Code and others enable DECSET ?1000h), so the
click never focused the preview at all: it just got forwarded into the PTY
underneath and swallowed. is_over_lineage_preview's own doc comment already
stated the intent ("swallow clicks ... so it doesn't act as a click-
through") — this closes the other half of that: exclude the preview's own
area from forward_mouse_to_child's hit-test, mirroring how card_owns_event
already protects the tutorial card. New regression test drives the real
Down-then-Up on_mouse path through a mouse-tracking pane and confirms both
that focus is gained AND that the click does NOT also forward to the child
(verified failing without the fix, then passing with it).

Also switches the lineage rail from a git-log-graph `├─`/`└─` branch-corner
style to a pure vertical `│` connector, mirroring the `:::timeline`
markdown extension's rendering convention used elsewhere in this UI. A
node's own edge glyph (◆/⑂/▸) already marks "a branch starts here", so the
corner glyph was redundant; is_last now only affects whether rows NESTED
under a given row draw a continuing rail in that column, never the row's
own prefix. Updated the two rail-prefix tests in lineage.rs and spec 0080's
worked example (which also corrected a pre-existing inaccuracy: "↩ merged"
renders inline on the fork's own row, not as a separate line).
…pt design

Replaces the flat rail-prefixed row list with a 2D diagram implementing
the concept sketch from the program doc: each session is a bordered box
(status glyph + title/harness [+ terminal marker]); its own timeline is a
vertical lane hanging below the box; a fork branches off the parent's
lane with a labeled arrow (├─⑂ fork ──▸) into its own box placed to the
right; a merged fork returns to the parent's lane with a labeled merge
arrow (│◂─ ↩ merge ──┘); subagents branch with a ▸ subagent arrow and
never return. Turn info renders ON the lanes between the markers that
bound each window, and — new with this layout — the parent's activity
WHILE a merged fork was out renders side-by-side with the fork's own
lane, level with it, exactly as the concept draws it. (The old linear
walk silently dropped that window: its checkpoint jumped from fork-out
seq to merged_seq without emitting the gap, undercounting the parent's
own totals. The new layout emits it, so every parent message is now
attributed to exactly one visible window.)

lineage.rs's row model changes from {depth, is_last, rails, kind} rows
with a rail_prefix() to role-tagged span runs (Rail / Border / Edge /
Segment / Node / More) cut from a character-grid canvas, still free of
ratatui types so the full diagram is unit-tested as plain text —
including a snapshot test locking the concept scenario's exact rendering
and a wide-char (CJK title) box-alignment test. flatten() now takes
now_ms (labels are composed at layout time; the preview rebuilds from
live state every frame, so they never go stale). ui.rs's
render_lineage_row shrinks to a role→theme-style mapping; the unfocused
preview no longer wraps lines (wrapping would shear the drawing — wide
diagrams clip at the preview's edge); the render guard switches from
row-count to selectable-node-count since a lone session now produces
several rows of box. Keyboard selection lands on box label rows — the
only selectable rows — so the existing nav/merge/discard vocabulary and
all hover/pin/focus mechanics carry over unchanged.

Box labels follow the concept's "<status> <name> (<harness>)" shape,
falling back to just "<status> <harness>" for untitled sessions, with
titles truncated at 24 chars. Cost/tokens remain absent from turn info:
there is still no per-checkpoint snapshot to attribute them to a window
(unchanged decision from the segment-stats round).

Spec 0080 updated: new "boxed-lane diagram" section and the worked
example replaced with the concept-shaped rendering.
…election highlight, lane/label geometry

Five refinements to the boxed-lane lineage diagram, per review:

1. The keyboard-selection highlight now covers exactly the selected
   session's box rectangle — its border fragments and label, across all
   three of its rows — instead of a full-width List bar. LineageSpan::
   Border gained a session_id tag so the renderer can pick out one box's
   fragments even on rows shared with wiring; the focused branch drops
   ratatui's List highlight_style entirely and applies the style inside
   render_lineage_row.

2. Fork and subagent arrow labels render at the same brightness
   (theme.dim) — the word already distinguishes them; the subagent
   label's harness accent read as an unrelated emphasis.

3. Branch arrows put a space between the shaft and the glyph:
   "├─ ⑂ fork ──▸" / "├─ ▸ subagent ──▸".

4. Rows now follow the node's timeline in strict chronological order.
   layout_node collects the node's fork-out / merge-back / subagent-spawn
   events, sorts them by time (branches before merges on a tie), and
   walks them top to bottom — so fork A, fork B, merge A renders exactly
   those three connectors in that order, instead of grouping A's merge
   arrow with A's block. A fork whose merge comes later keeps its lane
   column "live": later branches allocate columns to its right and their
   arrow shafts bridge over its bar (the bar wins the crossing cell, the
   dashes break around it — put_if_empty), and the lane back-fills down
   to its merge arrow when that event finally renders. Each turn-info
   window now emits at its CLOSING marker's row, which also replaces the
   previous side-by-side "during" placement with plain sequential rows.

5. Geometry per the review sketch: the lane hangs one column in from the
   box's left edge (was two), and turn info outdents two columns from
   the lane, flowing over it — matching the original concept's
   "(turn info)" interrupting the line.

The snapshot test is recomputed for the new geometry (every column
position re-verified by hand); a new test locks the fork-A/fork-B/
merge-A ordering, the live-lane column stacking, the arrow bridging, and
the chronological segment sequence; another locks the box-scoped
highlight (only the selected box's spans may carry the highlight bg).
Spec 0080 updated to match.
… final window

Turn-info lines now render as "• N msgs · elapsed" with the bullet
sitting ON the lane (where the bar would be) and the text to its right —
replacing the outdent-two-columns placement — matching the concept
sketch's "• (turn info)" notation. A node's FINAL turn-info line appends
a terminal-outcome glyph when its session has ended: ✓ for Done (styled
matrix_flash_good, the checklist marks' "done" color) and ✗ for Errored
(theme.warning). Mid-timeline windows and still-live sessions keep the
plain bullet; a fork's merged/discarded outcome is deliberately not
repeated on its turn info — the merge arrow and the box label's
"↩ merged" / "✗ discarded" marker already carry it.

Two new span roles (SegmentBullet, SegmentOutcome{ok}) keep the glyphs
independently styleable; put_segment now owns the whole line's layout
and returns its right edge, and child-box column allocation accounts for
the bullet's two extra columns. Snapshot recomputed (hand-verified);
new tests cover ✓ on Done, ✗ on Errored, no glyph while live, and that
only the final window of a Done parent carries the glyph — never its
mid-timeline windows or a still-open fork's.
The previous layout was chronological only within a single node's own
event list, and a child's whole block (box + its turn info) was emitted
at branch time — so a fork's own turn-info line rendered right below its
box even though that window actually closed much later (at its merge),
and a lane's terminal moment (session going Done/Errored) always sank to
the bottom regardless of when it happened. Events across DIFFERENT lanes
never interleaved at all.

The layout is now a flat, global timeline: the recursive layout_node is
replaced by layout_tree, which flattens the tree into lanes (collect_
lanes), builds one queue of every event in the whole diagram — box
appearances (fork-out / subagent spawn / root creation), merge-backs,
and lane ends (discard time, Done/Errored at last_event_at, or "now"
for live lanes) — sorts it by time (branches before merges on ties,
child times clamped to parents' so clock skew can't invert the tree),
and walks it top to bottom. Consequences:

- Every arrow and every turn-info line sits at its position in global
  event-time order: a subagent that finished before a later fork
  branched shows its ✓-marked final window above that fork's arrow.
- A turn-info window renders at its CLOSING event's row, on its own
  lane — so windows closing at the same instant share one row side by
  side, restoring the concept sketch's paired "(turn info)  (turn info)"
  for a merged fork's life next to its parent's while-it-was-out window,
  and putting all live lanes' trailing windows together on the final
  "now" row.
- Lane columns are allocated at box time past every live lane's widest
  possible label (a per-lane pre-pass dry-runs the event walk to compute
  max label widths), so side-by-side turn info can never collide; lane
  bars are back-filled at the end from each box to its lane's last row.

Snapshot recomputed (the merge row now carries both windows); a new test
locks the terminal-state-between-events placement; the ordering tests'
expected sequences updated to the global-timeline order with comments
explaining each window's closing event. Spec 0080 updated.
…sting, compact rows

Four refinements to the lineage diagram:

1. A merged fork's box label drops its "↩ merged" marker — the merge
   arrow and its final window's ✓ already carry the outcome. A discarded
   fork keeps "✗ discarded" (a discard draws no arrow).

2. The terminal-outcome glyph now LEADS a lane's final turn-info line in
   place of the bullet ("✓ 2 msgs · 3m20s") instead of trailing it, and
   covers every terminal event kind: fork merged → ✓, fork discarded →
   ✗, session Done → ✓, session Errored → ✗; live lanes keep the plain
   bullet.

3. Lane columns nest by close time (new assign_offsets pre-pass):
   overlapping sibling lanes stack outward with the later-terminating
   lane further out, regardless of branch order — so an inner lane's
   merge/end arrow returns to the parent without crossing an outer lane
   that's still running, and closing arrows nest instead of crossing.
   Sequential siblings whose lifetimes don't overlap still reuse the
   inner column. Column offsets are now assigned statically upfront
   (footprints computed bottom-up over the lane tree) rather than
   greedily at branch time, since the ordering needs lookahead: a lane
   that branches first but closes last must leave room inside itself.
   A new test locks the case where A branches before B but stays open
   while B merges — B nests inside A and B's merge arrow ends before
   A's column begins.

4. Rows pack tight: the blank spacer rows around turn-info lines and
   arrows are gone. The canonical single-merged-fork diagram drops from
   15 rows to 10, with the lane bars that remain coming only from box
   sides, arrows, and the end-fill.

Snapshot recomputed; spec 0080 and the module docs updated to match.
…, box wrap, styling

Eleven refinements to the lineage preview, per review:

- Session boxes wrap long names: the label wraps at a max interior width
  (28 cols) onto extra box rows up to a 2-line cap, then ellipsizes; box
  bounds and the whole layout account for variable box heights.
- Forks are never dimmed for being forks: the session LIST no longer
  dims forked_from sessions (archived stays muted), and lineage box
  labels always use live-state styling; a discarded fork keeps its
  strikethrough on top of its state color.
- The selection highlight fills only the box INTERIOR (bg) and brightens
  the border LINE (fg only — no background behind border glyphs).
- Boxes are mouse targets: flatten_with_boxes exposes each box's canvas
  bounds; the renderer maps them to screen hit-rects (clipped to the
  scrolled viewport). Hovering a box brightens its border; clicking it
  jumps to that session and closes the preview, exactly like Enter.
  Clicking the body outside any box still focuses the widget.
- Opening the preview from a fork/subagent starts the keyboard selection
  on that session's own box, not the tree's root.
- The preview scrolls vertically and horizontally (mouse wheel /
  horizontal wheel) in every mode; keyboard selection still drags the
  viewport while focused. Horizontal clipping slices span runs at the
  cut, padding a straddled wide char.
- One blank padding row between the last content line and the bottom
  border.
- The widget border uses the lineage accent (matrix_flash_good — the
  harness-label hover/toggle highlight color) instead of the session
  pane border colors; keyboard focus makes it bold.
- Turn-info lines always get a lane-bar row above them; a lane's final
  window has nothing below (the lane ends), mid-timeline windows are
  followed by structural rows that carry the lane onward.
- The preview is drag-resizable: left border adjusts width, bottom
  border height, corner both; the override sticks until closed. Resize
  participates in the drag-gesture guards like every other construct
  drag.
- Initial size is content-based (diagram width/height + borders +
  padding, clamped to the pane) instead of a fixed 60%-width sliver —
  wider by default and never clipping a diagram that would fit.

New tests: label wrap + ellipsis + border alignment, box bounds
reporting, open-from-fork initial selection, box click-to-jump through
handle_left_click, wheel scroll + border drag-resize through on_mouse,
interior-vs-border highlight semantics, fork-labels-style-like-normal.
Spec 0080 updated (border accent, sizing/scrolling/resizing, mouse
targets, wrap, turn-info bar rule).
The box-hit viewport clip skipped boxes above, below, and left of the
visible viewport but not ones starting past its RIGHT edge, so
`right.min(view_right) - vis_x` underflowed (debug-build subtract-with-
overflow panic at render time) whenever the diagram was wider than the
widget — trivially reachable with a nested fork, whose box starts deep
to the right, in a content-clamped or user-resized preview.

Complete the skip guard with the missing `b.x >= view_right` side and
make the clipped-edge arithmetic saturating so no future geometry can
underflow. Regression test renders a fork-of-a-fork into a deliberately
narrow (drag-resized) preview — verified to reproduce the exact panic
without the fix — and asserts the fully-clipped box registers no hit
while every registered hit stays inside the preview's inner rect.
…s + shift-wheel

Layout: a lane had to stack outside EVERY overlapping earlier-closing
sibling, so a fork branching late (after inner lanes had already closed)
piled a new column onto the far right even though only a still-open,
never-merging sibling overlapped it — the diagram grew one column per
open fork. The outside-stacking constraint actually only matters for
siblings that MERGE (their return arrow to the parent lane would cross a
live lane inside them); overlapping siblings that never merge (open
forks, discards, terminal sessions) draw no returning arrow. Slot
assignment now takes the innermost slot whose occupants don't overlap,
subject to staying outside overlapping MERGING siblings only — a late
open fork reuses the column a merged fork freed, cutting the overall
width (new test mirrors the reported screenshot shape: merged fork +
early open fork + late open fork now needs two slots, not three).

Preview chrome: vertical and horizontal scrollbars render when the
diagram overflows the viewport — background tints (track 30% / thumb
80%, the terminal scrollbar's opacity approximation) along the right
inner column and the bottom-pad row, preserving diagram glyphs
underneath. Shift+wheel scrolls sideways for terminals without a
horizontal wheel, alongside the existing ScrollLeft/Right support.
… the top border

Adds a second visualization so the two layouts can be compared live: a
compact git-graph-style view — one 2-column rail per session (columns
reused once a lane closes, classic git-graph column allocation), events
as one-line entries in the same global time order the boxed layout uses,
├─┐ / ├─┘ connectors curving between rails (breaking around live rails
they cross), and all labels/turn-info in a single left-aligned text
column right of the rails:

  ●    ● root (claude)
  •    12 msgs · 5m00s
  ├─┐  ⑂ ● idea A (claude)
  • │  3 msgs · 3m20s
  │ ✓  2 msgs · 3m20s
  ├─┘  ↩ merge
  •    5 msgs · 5m00s

A toggle button on the preview's top border (labeled with the current
mode) switches between "boxes" and "rails". flatten_rails reuses the
whole lane/event pipeline (collect_lanes, build_events, checkpoint walk)
and emits the same row/span model, so keyboard selection, box hover /
click-to-jump hit rects, scrolling, and resizing all work identically in
both modes; toggling resets scroll offsets since the geometries don't
correspond.

Tests: a rails-mode snapshot of the canonical merged-fork scenario,
rail-column reuse for sequential forks, and an end-to-end toggle test
(click switches mode, diagrams genuinely differ, interactive surfaces
stay registered, click again switches back — re-reading the toggle hit
since the content-sized preview moves when the diagram shrinks).
Spec 0080 updated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant