Skip to content

tui: interactive tutorial — guided 8-step tour of core keybindings and the program board - #735

Merged
edwin-zvs merged 11 commits into
mainfrom
tui-interactive-tutorial
Jul 9, 2026
Merged

edwin-zvs merged 11 commits into
mainfrom
tui-interactive-tutorial

Conversation

@edwin-zvs

@edwin-zvs edwin-zvs commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds an interactive tutorial ("tour") for the construct TUI aimed at
non-emacs users, per specs/0077-tui-interactive-tutorial.md.

Core principles

  • Real actions, never simulation. The tour is a small floating
    coach-mark card that observes the TUI's real KeyAction dispatch and
    daemon events, and advances only when the user actually does the thing.
    The UI stays fully live — the tour never steals input, never blocks
    actions, never renders a fake screen.
  • Per-keystroke feedback. Step 1 mirrors the chord state machine live
    ("pending: C-x — now press Ctrl+G to cancel it"); a wrong key while a
    chord is expected shows what was actually pressed plus a gentle
    re-prompt.
  • Every step clickable, except step 1 (which teaches real keystrokes —
    clicking its key labels shows a nudge instead of dispatching the chord).
    Every other step's key labels are real HintZones dispatching the exact
    KeyAction a keypress would.
  • The card owns its own clicks. The card floats over live surfaces (a
    mouse-grabbing PTY, the program board); clicks inside its rect dispatch
    its controls no matter what's underneath, body clicks are consumed
    (never fall through to the covered surface), scroll passes through,
    in-flight drags keep their owner, and an open help modal's
    close-on-interaction wins.
  • Footer is step navigation. [prev step] [next step] [end tour] —
    next advances without completing the step's condition; prev is purely
    navigational (re-arms the previous step's transient progress checks,
    never undoes real-world effects) and is hidden on step 1 and on the
    completed card.
  • Honest, state-aware copy. Steps 4 and 7 teach their keys in working
    order (C-x o to the list first — a bare C-n/? from view focus
    correctly goes to the PTY, and the tour never steals it) and say so when
    focus or a single-session list would make the exercise look broken.
    Step 6 tracks the live run: "still spawning the subagent" → "it's up,
    select it" → "the run already finished" (fast models / already-archived
    subagent).
  • Never auto-starts. Entry points: a call-to-action row on the
    empty-state welcome card (▶ [start the interactive tour] — or press t,
    emphasized as an invitation until the tour is completed once), a
    clickable tour: t segment in the empty-state modeline hint, and a
    tutorial/tour command-palette command. No tour affordance in the
    with-sessions UI beyond the palette.

The 8 steps: reading chord notation + the C-g escape hatch (real
keystrokes only) → create a practice session → send it a message → get
around (focus + selection) → program board: apply the Tasks template, add
a task, run it → split screen (C-x 2 or C-x 3), watch a live subagent,
close the split (C-x 0 or C-x 1) → panic buttons (help, quit) → clean
up (delete the practice session).

Degraded mode: when no agent harness is available, steps 2/3 fall back
to shell, step 5 becomes editing-only (no run), step 6 becomes a plain
split-and-close exercise — the card says so and points at /configure.

Persistence: a tutorial-done marker file (mirrors the existing
configure-seen pattern) gates the welcome-card highlight; the current
step persists in TuiState so an interrupted tour resumes instead of
restarting. [end tour] closes without writing the marker unless invoked
from the final step.

Vim profile: chord labels are sourced from a per-action/profile lookup
cross-checked against keymap::vim() (e.g. C-w s instead of C-x 2),
never hardcoded emacs strings.

Test coverage

  • welcome card shows the tour call-to-action; its HintZone dispatches
    StartTutorial both by key (t) and by click, and the zone is
    asserted to start on its label; the modeline tour: t segment clicks
    through in the empty state and is absent with sessions
  • starting the tour renders the step-1 card text
  • step-1 chord flow: C-x shows pending feedback, C-g cancels and
    advances the micro-exercise, a wrong key shows a correction
  • completing step 1 (C-x C-f) both opens the new-session picker and
    advances to step 2
  • [end tour] click closes the card without writing the done marker
  • card clicks travel the REAL on_mouse path: over a mouse-grabbing PTY
    child (regression for the "hover works, click doesn't" report), over an
    open program modal (zone dispatches, body click consumed, program cursor
    untouched), and under an open help modal (help wins); normal PTY mouse
    pass-through still works when no tour is active
  • step 4 through real on_key with one session: PTY-captured C-n
    forwards and doesn't tick, list-focused C-n ticks, C-x o completes
  • footer: [prev step] hidden on step 1, prev re-arms the previous step's
    transient flags only (remembered facts survive), [next step]/[got it] advance
  • step 6: either split and either collapse action completes its sub-check
    in both modes; card copy adapts across waiting/listed/finished states
  • a sweep test asserts every step/mode/profile/state card line fits the
    44-col inner width
  • marker present → welcome line renders un-highlighted (and highlighted
    when absent); TuiState round-trips the resume step
  • degraded-mode unit tests for step 5's checklist/completion (no run row,
    completes without a run)
  • full existing agentd-cli test suite (722 tests) and cargo clippy --no-deps both pass — note: cargo clippy -p agentd-cli (with deps)
    currently fails on an unrelated pre-existing clippy::never_loop error
    in construct-adapter-smith, reproducible on a clean main checkout,
    untouched by this PR

Test plan

  • cargo test -p agentd-cli --bin construct — 722 passed
  • cargo clippy -p agentd-cli --bin construct --all-targets --no-deps — clean
  • vhs recording: welcome screen → t → step-1 card → C-x pending →
    C-g cancel/advance → C-x C-f opens picker with the step-2 card
    (frames attached below)

🤖 Generated with Claude Code

edwin-zvs added 4 commits July 8, 2026 15:01
… program board

Adds a step-driven coach-mark tour (spec 0076) that observes real KeyAction
dispatch and daemon events and advances only when the user actually does the
thing, never simulating a fake screen. Covers reading chord notation and the
C-g escape hatch, creating a practice session, sending it a message, moving
around the list/view, running the Tasks program template with a live
subagent, splitting panes, the panic-button keys, and cleanup — with a
degraded fallback when no agent harness is configured, click-through parity
for every step but the first, and marker-file + resume persistence.
Merged latest origin/main (which landed spec 0076 for the "Run with
comment" feature) and renumbered this tour's spec accordingly, updating
all in-code "spec 0076" references to match.
vhs verification showed several body lines overflowing the card's 44-col
inner width and getting silently clipped (the per-line Paragraphs aren't
wrapped, to keep HintZone column math exact for embedded key labels).
Rewrote the long lines to fit on one row each; no behavior change.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

vhs recording (welcome screen → press `t` → step-1 card → `C-x` pending feedback → `C-g` cancel/advance → `C-x C-f` opens the picker with the step-2 card) — `gh` CLI doesn't support attaching binary media to a comment, so this is a local-path pointer rather than an inline attachment:

  • mp4: /tmp/ttdemo/tour.mp4
  • step-1 card frame: /tmp/ttdemo/step1-card.png
  • step-1 C-g cancel frame: /tmp/ttdemo/step1-cancel.png
  • step-2 picker frame: /tmp/ttdemo/step2-picker.png

Confirmed by direct frame inspection: card renders correctly at each of the four moments, text fits the card width with no clipping.

edwin-zvs added 2 commits July 8, 2026 15:27
Spec 0077 calls for the highlighted invite once the configure dialog has
been dismissed AND the tour is undone, not tour-undone alone. In practice
the two conditions coincide on every real launch (open_configure_popup
marks configure_dialog_seen immediately on open, before the first render),
but add the explicit AND for fidelity to the spec and to guard state a
test harness can reach that a real launch never does.
The tutorial card floats over the practice session's pane. From step 2
onward that session's child (e.g. Claude Code) has enabled mouse
tracking, so on_mouse's forward_mouse_to_child gate handed every mouse
event over the pane straight to the child's PTY and returned before
handle_left_click and the card's own shortcut_hints zones ever ran.
Hovering still worked (computed render-side from app.mouse_pos), but
clicks silently went nowhere. The tutorial tests didn't catch this
because they drive handle_left_click directly, skipping the on_mouse
gate that real mouse input goes through.

Fix follows the existing URL-click precedent (which intercepts before
forward_mouse_to_child for the same reason: a hover-underlined
affordance must respond to clicks regardless of who owns the mouse):

- Track the card's rect per frame in a new layout.tutorial_card_area
  (not modal_area — the card is deliberately non-modal and modal_area
  carries dismiss-on-outside-click semantics that don't apply here).
- on_mouse now skips forward_mouse_to_child for events landing inside
  the card, so Up(Left) reaches handle_left_click and dispatches
  TutorialSkipStep/TutorialEndTour normally.
- handle_left_click consumes clicks that land inside the card but miss
  every hint zone (card body/border), so a click on the opaque card
  doesn't fall through to pane focus/cursor-placement/selection
  underneath.
- Scroll events over the card intentionally still fall through to the
  normal scroll path (nothing on the card scrolls) — no change needed
  there since the new guard only touches forward_mouse_to_child.

Adds a regression test that drives the real on_mouse Down+Up path with
a mouse-tracking child behind the card (the existing tests only cover
handle_left_click directly), plus an inverse-guard test confirming
normal PTY mouse pass-through to a mouse-tracking child still works
when no tour is active.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

Fixed: tour card [skip step] / [end tour] clicks did nothing

Bug: hovering the tour card's [skip step] / [end tour] labels underlined them (hover is computed render-side from app.mouse_pos), but clicking was a no-op.

Root cause: the tutorial card floats over the practice session's pane. From tour step 2 onward that session's child (Claude Code) enables mouse tracking. on_mouse's forward_mouse_to_child gate forwards any mouse event landing inside a mouse-tracking pane straight to the child's PTY and returns early — before handle_left_click and the card's own shortcut_hints zones ever run. The existing tutorial tests didn't catch this because they call handle_left_click directly, bypassing the on_mouse gate real mouse input goes through.

Fix (commit f23c870), following the existing URL-click precedent that already intercepts before forward_mouse_to_child for the identical reason:

  • Added layout.tutorial_card_area: Option<Rect>, set each frame in render_tutorial_card and cleared alongside shortcut_hints. Deliberately not modal_area — the card is non-modal by design and modal_area carries dismiss-on-outside-click semantics that don't apply here.
  • on_mouse now skips forward_mouse_to_child for events inside the card rect, so Up(Left) reaches handle_left_click and dispatches TutorialSkipStep / TutorialEndTour via the existing run_action arms.
  • handle_left_click consumes clicks that land inside the card but miss every hint zone (card body/border), so a click "on" the opaque card doesn't fall through to pane focus / PTY cursor placement / selection on the pane underneath.
  • Scroll events over the card are left to fall through to the normal scroll path unchanged (the card has nothing to scroll) — no extra handling needed since the new guard only touches forward_mouse_to_child.

Tests: added tutorial_card_click_reaches_card_through_on_mouse_over_grabbing_child (drives the real on_mouse Down+Up path with a mouse-tracking child behind the card — puts a session's ItemHistory into mouse-tracking mode by feeding it DECSET \x1b[?1000h, starts the tour, renders a frame, then clicks the [end tour] hint zone through on_mouse and asserts the tour closed and nothing leaked to the child's PTY input queue) and mouse_still_forwards_to_child_when_no_tutorial_card (inverse guard: same click shape with no tour active still forwards to the child normally). Existing handle_left_click-based tutorial tests are unchanged.

cargo test -p agentd-cli --bin construct: 709 passed, 0 failed.
cargo clippy -p agentd-cli --bin construct --all-targets --no-deps: clean (pre-existing warnings only, none introduced by this change).
cargo build: clean.

…card click ownership

Seven user-reported fixes to the interactive tour (spec 0077), grouped
because they share the card's copy/observation machinery:

1. Step 4 "C-n not effective" with a single session. Root cause is
   twofold: (a) after step 3 focus sits in the view pane, where a bare
   C-n is (correctly) forwarded into the practice session's PTY and
   never resolves to NextSession — the sub-check can't tick until the
   user hops to the list; (b) with the list focused the action DID tick
   even with one session, but nothing visibly moved, which reads as
   broken. Fix is observation + copy, not input rerouting (the tour
   never steals keys from the PTY): the card now teaches the working
   order as a numbered list (1. C-x o — jump to the list, 2. C-n / C-p —
   move the selection) and, when only one session exists, adds an honest
   note that the selection stays put until there's a fleet.

2. Footer navigation: [skip step] renamed to [next step]
   (KeyAction::TutorialSkipStep → TutorialNextStep; step 7's [got it]
   rides along), plus a new [prev step] (KeyAction::TutorialPrevStep),
   hidden on step 1 and on the completed card. Prev is purely
   navigational: it decrements the step and re-arms that step's
   transient progress flags (checklist booleans, step-1 phase,
   feedback) so it can be demonstrated again, but never undoes
   real-world effects — sessions, program contents, and remembered
   facts (practice session, observed subagent) stay put. Re-entering a
   step whose real condition already holds either acknowledges the
   state (step 6) or completes again on the next observation (step 2:
   creating another session advances again); [next step] is always
   there, so navigation can't wedge the tour.

3. Step 6 adapts to the live run state instead of pointing at a
   session that may not exist: while the subagent hasn't appeared the
   card says the agent is still spawning it (can take a minute); once
   it's listed the card points at it; and if the run already finished —
   task in ## Done and/or the subagent already archived by the board's
   rule (fast models) — the card acknowledges the finished run instead
   of wedging on a vanished session.

4. Card mouse ownership generalized beyond the mouse-grabbing-PTY fix
   (f23c870): an open program sets modal_area and handle_program_mouse
   used to consume card clicks for cursor placement. The card-rect
   check is hoisted above handle_program_mouse in on_mouse, a press
   inside the card no longer starts text selection/drags on covered
   surfaces, and handle_left_click gets a dedicated card branch before
   the modal check (topmost zone wins; body clicks consume). Scroll
   still falls through (the card has nothing to scroll — the program /
   PTY underneath keeps scrolling), in-flight drags keep their owner
   (program resize included), and an open help modal's
   close-on-interaction wins over the card.

5. Step 7 is focus-aware for the same reason as step 4: a bare ? only
   resolves to ToggleHelp from list focus. Ordered copy (1. C-x o,
   2. ?) plus an explicit hint line while focus is off the list;
   clicking the ? label keeps working from any focus.

6. Step 6 wrap-up teaches the collapse pair — C-x 0 closes this pane,
   C-x 1 keeps only this one — as a new "close the split" sub-check in
   both modes; either action completes it (it ticks on the action
   firing, layout-independent, so it can't wedge).

7. Step 6's split is presented as an explicit either-or choice
   (C-x 2 below, C-x 3 right), both clickable; either ticks the split
   sub-check (the observation already accepted both).

The card's height is now content-driven (step 6's state copy + 4-row
checklist vs. the small completed card), and a sweep test asserts every
step/mode/profile/state line fits the 44-col inner width. Spec 0077
gains the footer-navigation and click-ownership semantics.

New tests drive the real paths: step 4 through on_key (PTY-forward
non-tick, list-focus tick, full completion), card-over-program clicks
through on_mouse, help-modal precedence, either-split/either-collapse
completion, prev-step re-arm + step-1 hiding, and the card copy state
variants.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

Tour usability batch (commit 6415cb2) — 7 user-reported fixes

1. Step 4: "C-n is not effective" with a single session. Confirmed root cause is both suspected halves: (a) after step 3 the focus is in the view pane, where a bare C-n is (correctly) captured by the practice session's PTY and never resolves to NextSession — the sub-check couldn't tick until the user pressed C-x o first, and the old card copy didn't make that ordering explicit; (b) from list focus the action fired and the sub-check did tick even with one session — but nothing visibly moved, which reads as broken. Fix is observation + copy, not input rerouting (the tour never steals keys from the PTY): the card now teaches the working order as a numbered list (1. C-x o — jump to the list, 2. C-n / C-p — move the selection) and, when only one session exists, adds "(one session now, so it stays put — matters once you have a fleet)". A regression test drives the real on_key path both ways: PTY-captured C-n forwards + doesn't tick; list-focused C-n ticks; C-x o then completes the step.

2. Footer: [skip step] → [next step], new [prev step]. KeyAction::TutorialSkipStep renamed to TutorialNextStep (step 7's [got it] rides along); new TutorialPrevStep. Footer order [prev step] [next step] [end tour] (36 cols, fits the 44-col card); prev hidden on step 1 and on the completed card (which keeps [close]). Prev is purely navigational: decrements the step and re-arms that step's transient progress flags (checklist booleans, step-1 phase, feedback) — never undoes real-world effects (sessions, program contents, and remembered facts like the practice session / observed subagent stay put). Re-entry choice, as the simplest non-wedging behavior: a re-entered step whose real-world condition already holds either acknowledges the state in its copy (step 6) or completes again on the next observation (step 2: pressing C-x C-f and creating another session advances again); [next step] is always available, so navigation can never wedge the tour.

3. Step 6 adapts to the live run state. While the subagent hasn't appeared: "⧗ the agent is spawning a subagent — can take a minute depending on model." Once it's listed: "Subagent's up! Select it in the other pane — it's nested under this session." If the run already finished — task under ## Done and/or the subagent already archived by the board's rule (fast models) — the card acknowledges "The task is Done!" and moves on to the collapse teaching instead of pointing at a vanished session.

4. Card clicks now work over the program view too. Same #735 click-ownership principle as the mouse-grabbing-PTY fix (f23c870), generalized: an open program sets modal_area, and handle_program_mouse consumed card clicks for cursor placement before the click pipeline saw the card's zones. The card-rect check is hoisted above handle_program_mouse in on_mouse, a press inside the card no longer starts text-selection/drag gestures on covered surfaces, and handle_left_click gets a dedicated card branch before the modal check (topmost zone wins; card-body clicks are consumed — the card is opaque, so click-through is always wrong). Deliberate carve-outs: scroll still falls through (the card has nothing to scroll — the program/PTY underneath keeps scrolling as if the card weren't there; this also refines f23c870, where scroll over the card was blocked from PTY forwarding), in-flight drags keep their owner (program resize included), and an open help modal's close-on-interaction wins over the card.

5. Step 7 is focus-aware. Same root cause shape as step 4: a bare ? only resolves to ToggleHelp from list focus; from the view/program it's correctly typed into that surface. Ordered copy (1. C-x o — hop to the list, 2. ? — open help) plus an explicit hint while focus is off the list ("focus is in the view now, so ? would just be typed there — C-x o first"). Clicking the ? label keeps working from any focus, and help still closes on any key while the tour is active.

6. Step 6 wrap-up teaches the collapse pair. New "close the split" sub-check in both modes — C-x 0 closes this pane, C-x 1 keeps only this one; either action completes it. It ticks on the action firing (layout-independent), so it can't wedge even if the user never actually split.

7. Step 6's split is an explicit either-or choice. "Split: C-x 2 (below) or C-x 3 (right)." — both clickable, either ticks the split sub-check (the observation already accepted both; the copy now says so).

Also: the card's height is now content-driven (step 6's state-aware copy plus its 4-row checklist vs. the small completed card no longer share one fixed 12-row box), a sweep test asserts every step/mode/profile/state line fits the 44-col inner width, and specs/0077 gains the footer-navigation and click-ownership semantics.

Tests: cargo test -p agentd-cli --bin construct — 721 passed, 0 failed. cargo clippy -p agentd-cli --bin construct --all-targets --no-deps — exit 0, no new warnings. cargo build (debug) — clean.

… segment

The tour was the fifth row of the welcome card's chord list — but the
audience that needs it most can't parse chord tables yet, so it hid
among them. Two discoverability changes:

- The welcome card gets a distinct call-to-action directly under the
  blurb and above the chord list: "▶ [start the interactive tour] — or
  press t". The bracketed label is a single clickable HintZone
  (StartTutorial) with the standard hover treatment; `t` stays bound
  as-is. The row always renders; only its emphasis (accent + BOLD) is
  gated by the existing configure_dialog_seen && !tutorial_done invite
  logic (a6bdff2). The old "t  take the 2-minute tour" chord row is
  removed and the remaining four chord rows shift down with a blank
  separator (base_rows/shortcut_rows updated so click targets stay
  aligned; the CTA-zone test asserts the zone starts on its "[").

- The empty-state modeline hint gains a fourth segment: "new: C-x C-f
  help: ?  palette: C-x x  tour: t". The hint is now rendered as
  individually clickable segments (same HintZone pattern as the
  minibuffer hint and the persistent notices) instead of one plain
  string; a running column keeps the zones exact even with the
  approval badge / chord echo ahead of them. With sessions present
  nothing changes — the hint (and its zones) only exist in the
  empty/welcome state.

Spec 0077's "exactly two entry points" wording updated to describe the
welcome-state surfaces plus the palette. Tests: CTA renders, its zone
dispatches StartTutorial by click and stays aligned; emphasis still
gated by the done marker (existing highlight tests pass against the
CTA); modeline segment clicks start the tour in the empty state and is
absent with sessions; exact-string modeline assertion updated.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

Tour discoverability on the welcome screen (commit 47113e1)

User request: the tour was the fifth row of the welcome card's chord list, but the audience that needs it most can't parse chord tables yet — it hid among them.

  • Welcome card call-to-action. New distinct row directly under the blurb, above the chord list: ▶ [start the interactive tour] — or press t. The bracketed label is one clickable HintZone (StartTutorial) with the standard hover treatment; t stays bound as-is. The row always renders; only its emphasis (accent + bold) keeps the existing configure_dialog_seen && !tutorial_done invite gating from a6bdff2. The old t take the 2-minute tour chord row is gone; the four remaining chord rows shift down with a blank separator, and base_rows/shortcut_rows were updated so every click target stays aligned (a test asserts the CTA zone starts on its [).
  • Empty-state modeline hint. new: C-x C-f help: ? palette: C-x x gains a fourth segment, tour: t — and the whole hint is now rendered as individually clickable segments (same HintZone pattern as the minibuffer hint / persistent notices) instead of one plain string. Empty/welcome state only; the with-sessions modeline is unchanged (tested both ways).

Spec 0077's "exactly two entry points" wording updated to describe the welcome-state surfaces (card CTA + modeline hint segment) plus the palette.

Tests: cargo test -p agentd-cli --bin construct — 722 passed, 0 failed. Clippy --no-deps exit 0, no new warnings. A vhs frame of the new welcome screen was captured to verify the render (CTA bold on a fresh state dir, chord list decluttered, tour: t in the modeline).

Two more tour fixes (items 9-10 of the #735 batch):

Dim + disable the tour CTA while the tour is open. StartTutorial is a
no-op while a tour runs, but the welcome-card CTA and the modeline
"tour: t" segment still rendered as live affordances (hover underlined,
click did nothing) — the inverse of the batch's click-ownership rule:
if it can't respond it must not look clickable. While app.tutorial is
Some, both render with theme.dim (no accent, no invite bold), suppress
hover, and register no HintZone, so clicks fall through as ordinary
area clicks. The row still renders (with its "— or press t" suffix —
`t` is equally inert mid-tour, so the whole row dims as one). Both
come back, marker-gated emphasis intact, on the first frame after the
tour ends.

Step 5's "apply the Tasks template" / "add a task under Todo" never
ticked (user report). Root cause confirmed: both sub-checks were
driven solely by the daemon's PROGRAM_STATE event, but template
application (apply_program_template) and typing the task line are
LOCAL edits to the program popup's buffer — the daemon only learns
about them on save (C-x C-s) or run (C-x C-r), so the boxes stayed
empty while the user was actually doing the step. New client-side
observation on the existing render tick (cheap string scan, gated to
step 5 with unticked boxes) reads the popup buffer directly:

- "template applied" = popup.template_id == "tasks" OR the buffer
  contains all three board sections (## Todo / ## In Progress /
  ## Done) — content-based, so hand-typed sections count too;
- "task added" = a non-empty line under ## Todo before the next
  heading (the built-in template ships an empty Todo, so applying it
  does not spuriously tick this);
- scoped to the practice session's program when known; a resumed tour
  (which loses practice_session_id — not persisted) falls back to the
  program being edited instead of leaving the step un-completable;
- the daemon event path stays as a second source (program synced from
  another client) — whichever source sees it first ticks, and ticks
  are sticky: later buffer edits never untick.

Tests: CTA/modeline lose their StartTutorial zones and render dim
while a tour is active, returning the frame after it ends; step-5
local template apply + typed task line tick before any save (and a
task under ## Done only does not tick the Todo box); other sessions'
programs never tick; ticks sticky across a buffer wipe;
has_tasks_board_sections unit-tested for template, hand-typed,
partial, and empty boards.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

Items 9-10 (commit 61af9e8)

9. Tour CTA goes inert while a tour is open. StartTutorial is a no-op mid-tour, but the welcome-card CTA and the modeline tour: t segment still looked live (hover underlined, click did nothing) — the inverse of this batch's click-ownership rule: if it can't respond, it must not look clickable. While app.tutorial is Some, both render dimmed (theme.dim, no accent, no invite bold), hover shows nothing, and no StartTutorial HintZone is registered — clicks fall through as ordinary area clicks. The whole CTA row dims as one, "— or press t" included (t is equally inert mid-tour). Both return, marker-gated emphasis intact, on the first frame after the tour ends.

10. Step 5's "apply the Tasks template" / "add a task under Todo" never ticked. Root cause confirmed exactly as suspected: both sub-checks were driven solely by the daemon's PROGRAM_STATE event, but apply_program_template and typing the task line are LOCAL edits to the program popup's buffer — the daemon only learns about them on save (C-x C-s) or run (C-x C-r), so the boxes stayed empty while the user was actually doing the step (program_opened/run_started ticked fine — they're KeyAction-driven). Fix: a client-side observation on the existing render tick (cheap string scan, gated to step 5 with unticked boxes) reads the popup buffer directly:

  • template applied = popup.template_id == "tasks" OR the buffer contains all three board sections — content-based, so hand-typed sections count too;
  • task added = a non-empty line under ## Todo before the next heading (the built-in template ships an empty Todo, so applying it doesn't spuriously tick this);
  • scoped to the practice session's program when known (a resumed tour, which loses practice_session_id, falls back to the program being edited);
  • the daemon event path stays as a second source — whichever sees it first ticks, and ticks are sticky (later edits never untick).

Checkmarks now appear as the user acts, before any save.

Tests: new coverage for zone removal + dim render while touring (and restoration after), local template-apply/typed-task ticking pre-save, the Done-only-task negative case, other-session guard, stickiness, and the section-detection helper. cargo test -p agentd-cli --bin construct — 726 passed, 0 failed. Clippy --no-deps exit 0 (the two collapsible_match notes in tutorial.rs pre-date this batch). cargo build clean.

Item 11 of the #735 batch. User report: on step 6 neither "subagent
appears" nor "task moves to Done" was detected; user decision: agent
progress must not render as checkboxes at all.

Root cause (both detections, one gate): every step-6 observer was
scoped to practice_session_id, which is never persisted — it is only
learned by watching step 2's session creation live. After a tour
resume, or when the user navigates to step 6 with [next step] without
creating the session through step 2 (the natural flow when re-testing
the tour), practice_session_id is None and BOTH hooks silently dropped
every event:
- tutorial_on_program_state returned early for all program/state
  events, so the agent's ## Done edit — which reaches the TUI only
  through that daemon event, since an open popup deliberately does not
  absorb daemon updates into a locally edited buffer — never ticked
  the gate;
- tutorial_on_session_created's step-6 arm required a known practice
  parent, so the subagent's creation STATE never registered either.
The event plumbing itself was verified sound: program_update/edit all
broadcast program/state, session create broadcasts STATE, the TUI
subscribes unfiltered, and construct_subagent_create stamps kind
Subagent + parent_session_id of the creating session — exactly what
the observer matches on. Only the None-practice gate was fatal.

Fixes:
- Unknown-practice fallback + adoption: a program/state event for the
  program being displayed (open popup) or the selected session ticks
  and ADOPTS that session as the practice session; a Subagent-kind
  session spawning during step 6 is accepted and its parent adopted.
  Adoption repairs the rest of the tour's scoped observers (step 8's
  delete gate included). Known-practice scoping is unchanged, and
  unrelated sessions/programs still never tick.
- Done detection now combines BOTH sources with OR: the daemon
  program/state event (the agent's edits — primary for this step) and
  the popup-buffer scan (step-6 extension of item 10's observer, for
  view semantics that do refresh or a hand-moved line). Sticky once
  true.
- Step-6 checklist is USER actions only, identical in both modes:
  split the pane / hop panes / close the split. "subagent appears" and
  "task moves to Done" are gone from the checklist — the agent's side
  is narrated by the card's status line (waiting → up → Done) and
  still gates completion via task_done in non-degraded mode. New
  hop_done sub-check ticks on any FocusWindow* action; the card's
  "C-x <arrow>" label is now clickable (dispatches a real hop), so a
  mouse-only user can tick it, and it joins the user gate in both
  modes. Fast-archive stays covered: an archived subagent never blocks
  completion — the Done gate alone carries the agent's side.

Tests: daemon program/state with the task under ## Done ticks the gate
while the popup shows a stale buffer; unknown-practice adoption via
both event kinds (plus an unrelated-program negative); known-practice
subagent linkage through the real STATE notification (and an
unrelated-parent negative); checklist rows asserted user-actions-only
in every step-6 state; completion gates re-checked in both modes incl.
the fast-archive inverse; either split / either hop / either collapse
action ticks through run_action.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

Item 11 (commit a69cfc9): step-6 Done/subagent detection fixed; checklist = user actions only

Root cause found (one gate killed both detections). Every step-6 observer was scoped to practice_session_id — which is never persisted; it's only learned by watching step 2's session creation live. After a tour resume, or when the user reaches step 6 via [next step] without doing step 2 (the natural flow when re-testing the tour), it is None, and both hooks silently dropped everything:

  • tutorial_on_program_state early-returned on every program/state event — and the agent's ## Done edit reaches the TUI only through that daemon event (an open popup deliberately does not absorb daemon updates into a locally edited buffer, so no buffer scan can see it);
  • tutorial_on_session_created's step-6 arm required a known practice parent, so the subagent's creation STATE never registered.

The plumbing itself checked out sound end-to-end: program_update/program_edit all broadcast program/state; session creation broadcasts STATE; the TUI subscribes unfiltered; and construct_subagent_create (the tool the practice agent uses) stamps kind: Subagent + parent_session_id of the creating session — exactly the linkage the observer matches. Only the None-practice gate was fatal. (Item 10's popup-buffer scan was not the culprit — it was step-5-scoped — but step 6 indeed had no second source; see below.)

Fixes:

  • Unknown-practice fallback + adoption. A program/state event for the program being displayed (open popup) or the selected session now ticks and adopts that session as the practice session; a Subagent-kind session spawning during step 6 is accepted and its parent adopted. Adoption repairs every downstream scoped observer (step 8's delete gate included). Known-practice scoping is unchanged; unrelated sessions/programs still never tick (tested).
  • Done detection ORs both sources, sticky once true: the daemon event (primary — the agent's edits) and the popup-buffer scan (step-6 extension of item 10's observer, for refreshed views / hand-moved lines).
  • Checklist = user actions only, identical in both modes: split the pane / hop panes / close the split. "subagent appears" and "task moves to Done" are gone from the checklist per the user's decision — the agent's side is narrated by the card's status line (spawning → up → "The task is Done!") and still gates completion via task_done in non-degraded mode (my call per the "your call" latitude: no agent-driven row at all, the status line carries the gate). New hop_done sub-check ticks on any FocusWindow* action; the card's C-x <arrow> label is now clickable (dispatches a real hop) so a mouse-only user can tick it; it joins the user gate in both modes.
  • Fast-archive path: an already-archived subagent never blocks completion — the Done gate alone carries the agent's side (status line falls back to "finished", tested).

Tests: daemon program/state with the task under ## Done ticks while the popup shows a stale buffer; unknown-practice adoption via both event kinds + unrelated-program negative; known-practice subagent linkage through the real STATE notification + unrelated-parent negative; checklist asserted user-actions-only in every step-6 state; gates re-checked in both modes incl. the fast-archive inverse. cargo test -p agentd-cli --bin construct — 730 passed, 0 failed. Clippy --no-deps exit 0, no new warnings. Build clean.

…click

The empty-state hint (lengthened by the tour-discoverability `tour: t`
segment) and the right-aligned version notice both register HintZones on
the modeline row. handle_left_click is first-match-wins, so when the two
overlapped, clicking the daemon segment silently dispatched whichever hint
action sat underneath it instead of opening the restart-daemon confirm.
The collision point depends on crate::BUILD_ID's length (clean CI build vs
a local `-dirty` suffix), which is why it only surfaced in CI.

Compute the right-aligned notice's width and start column first, then let
the left-side hint segments drop from the tail (lowest priority first) once
they'd cross into the notice's footprint. A dropped segment renders nothing
and registers no zone, so a click on the notice can never reach a hint
hidden beneath it.

Adds a BUILD_ID-independent regression test that forces the collision with
a long daemon build id and sweeps several terminal widths, asserting no two
same-row zones ever overlap and the daemon segment always opens its own
confirm.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

CI failure fixed (67f595f): mismatched_build_renders_both_versions_and_daemon_segment_click_opens_restart_confirm failed in CI only, not locally — root cause was a modeline hint/notice zone collision.

The empty-state hint row (lengthened by the tour: t segment added for discoverability) and the right-aligned version notice both register click zones on the same modeline row. handle_left_click is first-match-wins, so when the two overlapped at certain widths, clicking the daemon segment silently dispatched whichever hint sat underneath instead of opening the restart-daemon confirm. The collision point depends on crate::BUILD_ID's length — CI builds clean, local builds carry a -dirty suffix — which is why it only broke in CI.

Fix: the notice's width/position is now computed first, and left-side hint segments drop from the tail (lowest priority first: tour: t, then palette:, then help:) whenever they'd cross into the notice's footprint. A dropped segment renders nothing and registers no zone, so a click on the notice can never reach a hint hidden beneath it.

Added a BUILD_ID-independent regression test that forces the collision with a long daemon build id and sweeps several terminal widths, asserting no two same-row zones ever overlap.

731 tests pass, clippy clean.

@edwin-zvs
edwin-zvs merged commit 60db873 into main Jul 9, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the tui-interactive-tutorial branch July 9, 2026 11:28
edwin-zvs added a commit that referenced this pull request Jul 10, 2026
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.
edwin-zvs added a commit that referenced this pull request Jul 10, 2026
…ats) (#745)

* tui: fork + subagent lineage view popup

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.

* tui: bind C-n/C-p as lineage popup navigation aliases

Mirrors the session-list's own emacs-style NextSession/PrevSession
convention inside the lineage popup, alongside the existing j/k/arrows.

* tui: lineage preview on the pane title bar's harness label

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.

* tui: fold the lineage popup into a focusable session-attached preview

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.

* tui: fix C-x Tab toggle, click-away focus loss, and slide-anchored preview

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.

* tui: clear lineage preview focus even when the click forwards to a child

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.

* tui: move lineage preview stats from per-node totals to per-segment rail 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.

* tui: fix lineage preview click-to-focus under mouse capture, switch rail 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).

* tui: render the lineage preview as a boxed-lane diagram per the concept 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.

* tui: lineage diagram polish — chronological event order, box-scoped selection 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.

* tui: bullet turn-info lines on the lineage lanes, terminal ✓/✗ on the 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.

* tui: lay the lineage diagram out on one global time-ordered event queue

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.

* tui: lineage diagram polish — leading ✓/✗ markers, close-time lane nesting, 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.

* tui: lineage preview interaction round — mouse targets, scroll/resize, 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).

* fix: lineage preview panicked when a box lay past its right edge

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.

* tui: reuse freed lineage columns for late branches; preview scrollbars + 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.

* tui: git-graph-style rails mode for the lineage preview, toggled from 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.

* tui: per-session highlight across both lineage views; compact boxes mode

1. Selecting or hovering a session now lights up its ENTIRE timeline in
   both view modes. Every rail glyph, lane bar, connector, and turn-info
   span is tagged with its owning session (rail/bar/arrow glyphs moved
   from the anonymous Rail role to Border{session_id}; the three
   Segment* roles gained a session_id), and the renderer brightens
   everything owned by the selected/hovered session — rails, branch and
   merge arrows, bullets, window text (outcome glyphs keep their hue and
   go bold), plus a bold label on hover. Nothing belonging to other
   lanes changes.

2. Boxes mode is more compact:
   - Arrow labels are icon-only: ├─⑂──▸ for forks, ▸ for subagents,
     │◂─↩──┘ for merges — the words "fork"/"subagent"/"merge" are gone,
     shrinking the minimum branch offset (especially for subagents,
     whose word was 10 columns wide).
   - Column allocation now matches git-graph in both modes: pure
     innermost-non-overlapping interval reuse, dropping the rule that
     forced a lane outside every overlapping MERGING sibling. A merge
     arrow crossing a live lane placed inside it bridges over its bar,
     exactly like git-graph merge lines — trading the never-cross
     guarantee for narrower diagrams, per review.

Snapshot and arrow-text expectations updated; new test locks the
ownership-scoped highlight (hovering f lights only f's spans); the
right-edge clip regression test nests one level deeper since the
compact layout made its two-level diagram fit the narrow viewport.
Spec 0080 updated.

* spec: lineage preview — icon-only arrows, interval column reuse, ownership highlight

* tui: pack lineage boxes at arrow-minimum offsets; spaced arrow glyphs

Turn-info labels no longer reserve horizontal space in the boxed-lane
layout. Previously every child column had to clear its parent's widest
window label (max_seg_w + 5), pushing boxes far right of short arrows —
the reported screenshot showed first-level boxes starting ~20 columns
out. Now:

- Column offsets are the arrows' minimum (8) plus packed slot widths,
  where a lane's footprint is just its box and its children — the
  per-lane max-label-width dry-run pass is deleted entirely.
- A window's text simply runs underneath any lane bar it crosses: the
  text wins the cells and the bar shows a gap on that row (the same
  "lines go under" reading git-graph gives crossing edges).
- Two windows that would collide on a shared closing row (a merge's
  parent+fork pair, an End group's simultaneous closes) stagger onto
  adjacent rows: emit left-to-right, bumping to the next row whenever a
  lane's marker would land inside the previous window's text.

Arrow glyphs also gain breathing room per review: `├─ ⑂ ──▸`,
`│◂─ ↩ ──┘`. The canonical snapshot shrinks from a 38-column diagram to
20 columns; tests that assumed shared closing rows now assert the
staggered adjacent-row layout, and the outcome-glyph test anchors on
span ownership instead of row position. Spec 0080 updated.

* tui: lineage labels match the session list's glyph-only state color; text-color border; activity-capped elapsed

1. Session labels in both lineage modes now style like the session list:
   only the status glyph carries the live-state color (a Done session's
   check mark goes blue-ish), while the name/harness text keeps the
   default text color. The glyph is split into its own NodeStatus span
   in both draw_box and the rails label painter; selection fill and
   hover bold treat it as part of the label.

2. The preview widget's border switches from the lineage accent to the
   default foreground text color — still distinct from the session
   panes' border colors, bold while keyboard-focused.

3. A live lane's final turn-info window now measures its elapsed up to
   the session's last activity (last_event_at) instead of ticking
   toward "now" — the number reads as execution time for the turns in
   the window, not idle wall-clock. The lane's row position on the
   timeline still sits at "now"; sessions without a recorded
   last_event_at keep the open-ended fallback.

New tests: Done glyph blue / name text-colored in both modes; live
elapsed frozen at last_event_at (an hour of idle adds nothing). Border
and highlight tests updated for the new colors and the NodeStatus span.
Spec 0080 updated.

* tui: minimal-x box placement, 2-indent lanes, border-colored highlight, renamed modes

1. The hover/selection highlight color now matches the preview widget's
   own border (default text color, bold) so highlighted lines read as
   the same chrome.

2. The mode toggle labels rename: "boxes" → "lineage", "rails" →
   "lineage (compact)". The preview's minimum width now accounts for
   the toggle so the longer label never vanishes on tiny diagrams.

3. Boxed-lane vertical lines start two columns in from the box's left
   edge (was one).

4. Box placement is now minimal-x, eliminating the large empty regions
   the slot-footprint model produced (the reported "second (claude)"
   pushed far right past the widest sibling). Since every event gets
   fresh rows, box rectangles can never collide — so the whole
   footprint/slot machinery (assign_offsets, per-lane child lists,
   x_off) is deleted. The only hard constraints kept: the branch
   arrow's minimum reach from the parent's lane, and lane-column
   uniqueness among lifetime-overlapping lanes (scan right by 2 until
   free). Everything else overlaps: boxes reuse columns across rows,
   lane bars gap behind boxes/turn-info they pass under, and a merge
   arrow crossing a live lane bridges over its bar like git-graph
   merge lines (locked by a rewritten bridging test, since the
   "later-closing sits outside" rule this replaces no longer holds).

* spec: lineage preview — minimal-x placement, 2-indent lanes, mode names, highlight color

* tui: highlight branch glyphs with their session; persist pinned lineage previews; dim resting names

1. The ⑂ / ▸ branch-arrow glyphs now light up with their branching
   session: the Edge span gained a session_id (the ↩ merge glyph was
   already owned by the merging fork via its Border span), so
   selecting or hovering a session brightens every glyph on its
   timeline along with its rails, arrows, and turn info.

2. Pinned lineage previews survive TUI restarts: tui-state.json gains
   lineage_pinned_session_ids (restored on launch — pins for vanished
   sessions are harmless since the preview only renders with lineage
   to show) and lineage_view_compact (the boxes/rails toggle), the
   same pattern open_program_session_ids uses. Daemon restarts already
   preserved the in-memory pins.

3. Unhighlighted session names in both modes render slightly recessed
   (Modifier::DIM on the default text color), so the hovered/selected
   session pops; the status glyph keeps its full-intensity state color.

A lane-spread placement experiment for this round was reverted after
review confirmed the existing minimal-x behavior is as intended.

* tui: give fresh lanes visible starts; spread concurrent lanes under box spans

Two boxed-lane layout refinements from screenshots:

1. When a box would be drawn directly on top of a live lane's very
   first bar row (fork branches, next event's box immediately covers
   its lane column — the lane seemed to start nowhere), one spacer row
   is inserted so the lane's timeline visibly begins under its own box
   before passing behind the covering box.

2. A lane now hangs anywhere under its box's interior span, not at a
   fixed +2 indent: placement picks the leftmost free column at least
   6 columns away from every lifetime-overlapping lane (or the best
   separation available), so concurrent vertical lines stop running
   side by side — without widening the diagram, since the box's own
   span bounds the choice. Simple diagrams are unchanged (no
   competition → the classic 2-column indent wins). A consequence:
   concurrent sibling boxes now pack at the SAME minimal x (their rows
   never collide; only their lanes must differ), so the three
   column-order tests assert equality instead of ordering.

New regression test drives the reported scenario (fork then an
immediately-covering subagent box): asserts the spacer row shows the
fork's bar under its own box and the two lanes sit ≥6 columns apart.
Spec 0080 updated.

* lineage: label turn-info windows with summed compute time

Turn-info durations previously measured the wall-clock gap between a
window's boundary events, which counts idle sitting-at-a-prompt time.
Now they show the session's actual compute time within the window:

- protocol: SessionSummary grows busy_ms (banked working time) +
  busy_running_since_ms (open Running span) with busy_ms_at() to read
  the total mid-span; ForkedFrom.parent_busy_ms and
  ForkMerge.merged_busy_ms snapshot the parent's total at the two
  boundaries that split a timeline. All serde(default) — old records
  read as zero stamps.
- daemon: set_state_tracked() routes every session-state mutation so
  entering Running opens a span and leaving banks it; merge() stamps
  merged_busy_ms from the parent's current total.
- client: fork_session stamps parent_busy_ms the same way.
- cli: each lane carries its fork/merge/lifetime busy snapshots; every
  turn-info window (both layouts) labels the delta between its two
  boundary snapshots, and a lane's final window drains the remainder
  of the lifetime total. Windows without busy data (records that
  predate tracking) keep the legacy wall-clock span.

Spec 0080 updated with the compute-time semantics and fallback.

* lineage: count only chat messages in turn-info windows

The "N msgs" figure was a raw transcript-sequence delta, so tool
blocks, status rows, and PTY ordering markers all inflated it. Windows
now count actual Message events:

- protocol: SessionSummary.message_count (daemon-maintained tally of
  persisted Message events), ForkedFrom.parent_message_count and
  ForkMerge.merged_message_count boundary snapshots — the message-only
  counterparts to event_count / transcript_seq / merged_seq. All
  serde(default).
- daemon: increments the tally as Message events persist, zeroes it on
  session reset, stamps it at merge, and recounts it from the
  transcript at load so it self-heals for records saved before the
  field existed (or that lagged a crash).
- client: fork_session stamps the parent's tally at fork.
- cli: both layouts label each window with the message delta between
  its boundary snapshots; records without tallies fall back to the raw
  transcript-event delta (unchanged legacy rendering). Window
  existence/skip logic still keys on transcript events, so diagram
  structure is unchanged.

Spec 0080 updated with the count semantics.

* tui: move lineage view into a sidebar section

The floating hover/pin lineage preview anchored to the pane title's
harness label is replaced by a collapsible section of the left sidebar,
stacked sessions | lineage | operator (spec 0081; 0080 superseded):

- The section renders the SELECTED session's fork/subagent tree
  whenever it has lineage to show — no hover trigger, no pin, no
  per-session open state. Master-detail: it follows the list selection
  and highlights the selected session's node.
- Bare Tab (list pane focused) switches keyboard focus sessions ⇄
  lineage — an on_key intercept scoped to list focus, so PTY sessions
  keep receiving Tab. C-x Tab still toggles section focus from
  anywhere. The row vocabulary (j/k/C-n/C-p, Enter jump, m/d
  merge/discard, Esc) carries over unchanged.
- Header row: collapse toggle (click), view-mode toggle (full ⇄
  compact; compact is now the default — it's built for the narrow
  column). Body: box click jumps, click focuses, wheel scrolls
  (shift = horizontal), scrollbar tints; content-capped height (never
  below the list minimum, never more than half the rows region).
- Deleted: hover grace timers, pin state, drag-resize, floating-box
  anchoring, harness-label trigger + hit plumbing, and the
  mouse-forwarding exclusions the overlay needed.
- Persistence: lineage_pinned_session_ids replaced by a global
  lineage_collapsed flag; lineage_view_compact now defaults on.
- Session list: forks already nested indented under their parent —
  now locked in with a list_items-level test.

Tests rewritten for the section (render placement, header collapse,
mode toggle, box-jump, body-focus, blur, wheel, right-edge clipping,
Tab focus switch, C-x Tab chord, fork indentation).

* tui: nest forks of forks in the session list

A fork of a fork was invisible in the list: excluded from the top level
for having forked_from, but never pushed under its parent either (fork
nesting was single-level). push_session now walks fork descendants
depth-first (one indent level, matching the flat subagent convention,
with a depth cap as a cycle guard), so every live fork appears under
its ancestor row. Spec 0081 notes the recursive nesting.

* tui: lineage section polish — header bar, padding, animation, drag height

Ten refinements to the sidebar lineage section (spec 0081 updated):

- Header is now a ─ rule carrying the label and controls, matching the
  operator panel's title bar: ' ⑂ lineage ' label (accent+bold only
  while the section owns keyboard focus), 'full ⇄ compact' mode toggle,
  and a −/+ collapse button at the right end. The bare rule doubles as
  a height drag handle (up grows); the dragged height persists in
  tui-state.json alongside collapse + mode.
- One blank padding row above and below the diagram.
- Focus highlight is exclusive between the sidebar's regions: focusing
  the lineage section takes the highlight off the sessions title bar
  (session_rows_focused) and vice versa.
- Selecting a subagent no longer hides the section: has_lineage now
  counts a session's own upward parent link, not just downward edges
  and its fork origin.
- Hit targets widened from boxes to every owned cell — lane bars,
  branch glyphs, turn-info markers/text — so hovering any of them
  highlights the session across the diagram and clicking jumps to it
  (span-based hits via LineageSpan::owner()).
- Horizontal scrolling: ScrollLeft/Right kept, and a plain wheel now
  scrolls sideways when sideways is the only overflowing axis (few
  rows, wide diagram — the common compact-mode shape).
- Status glyphs animate with the session list's shared spinner and
  gate; a working session's live turn-info bullet spins in phase
  (historical bullets stay static).

Tests: header rule + padding render, collapse button toggle, drag
resize + height persistence round-trip, plain-wheel sideways fallback,
ScrollLeft/Right, subagent-selection keeps the section, lane/turn-info
click jump, focus exclusivity, spinner substitution (node + live
bullet, static elsewhere).

* tui: flush the lineage collapse button to the header's right edge
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