ADR-154: ways introspect — one model, three front-ends¶
Context¶
ADR-153 defines a shared SessionIntrospection model. This ADR decides the
surfaces over it. There are three, plus a drill-down, and they should feel like
one tool a user can move between — which §4 realizes as one ways introspect
<mode> command:
- post-hoc replay of a finished session (exists today as
ways rethink). - a live monitor of the current session, same UI, refreshing as new ways fire.
- a non-interactive dump — structured output an autonomous agent reads to
investigate a session (exists today via
rethink --json). - the why-fired drill-down — from a list of fired ways, enter one and read the way, its trigger criteria, and the clip of session that matched.
The research pass established the current surface and its gaps:
rethinkis raw crossterm + hand-rolled ANSI strings (not ratatui), two sequential blocking loops (session picker → frame player), sharing thecmd/rendertable writer withways list. Single-panel, full-clear-redraw.- The model→(render | serialize) split already half-exists:
reconstruct_frames(pure data) feeds both the TUI andrethink_dump's JSON — the exact factoring the three front-ends want. - Two bugs (owned here as command semantics):
rethinksilently globalizes when current-project detection returnsNone(run manually,CLAUDE_PROJECT_DIRis unset), and there is no--list --jsonfor an agent to enumerate sessions before dumping one. (The events-log discovery bug is fixed in ADR-153.) attend-chatuses iocraft (a declarative TUI) with an async runtime for its rich interface — the maintainer's rich-TUI precedent, but it dragssmol/async-channelthatways-cli(fully synchronous) deliberately lacks.
Decision¶
1. One cmd/introspect/ module; front-ends differ only in their loop¶
Generalize the proven triplet into a module (mirroring the cmd/scan,
cmd/settings, cmd/lint subdir convention):
- model — build
SessionIntrospection(ADR-153). - render — extend
cmd/render's ANSI-Stringcontract withrender_way_detail/render_clip; no new rendering paradigm. - front-ends (the
ways introspect <mode>modes of §4), each a thin loop over the same model: replay— one-shot render → print, then the frame-player loop (today'srethink);live— the existingrethink::tui_looppoll skeleton, re-reading on a tick (see §3);dump— serde-serialize (extendrethink_dump);- drill-down — a selection/tab loop compositing model panels, shared by
replayandlive.
Model ↔ replay-timeline boundary (resolved 2026-07-03). The
SessionIntrospection model and rethink::build_frames are two distinct
views, not one clustering — and are deliberately kept that way rather than
unified. build_frames is the animation projection: it folds the full event
stream (way_fired + check_fired + way_redisclosed) into cumulative
"what's active at epoch N" frames, and drives replay's timeline. The model is
the analytical substrate: per-turn fired-way deltas joined to MatchCriteria,
matched_span, and the transcript key, and it drives dump and the drill-down's
"why" panel. The drill-down bridges them by way_id — focus a way in a
build_frames frame, and its detail is looked up in the model by id — so their
divergent epoch numbering never has to be reconciled. This preserves the proven
replay timeline untouched, keeps the model scoped to the fire stream (ADR-153),
and confines the JSON-vs-TUI drift the model guards against to the two surfaces
that actually share it (dump and the drill-down), both of which read the model.
Converging build_frames onto the model was rejected: it would load the
fire-stream substrate with cumulative-state, redisclosure, refire-threshold, and
token-timeline concerns that belong to the animation view alone.
2. Keep crossterm; grow a small micro-compositor — do not adopt ratatui¶
Build a ~200–400-line internal compositor over the existing ANSI-String panels:
panel = Vec<String> + width, a side-by-side placer, a scroll-window helper, a
tab-bar helper. This preserves the lean opt-level=z binary (~3.3 MB), adds zero
dependencies, and reuses render.rs's output verbatim as the "matched clip"
content.
Rejected for now: ratatui — ~+300–600 KB and 15–30 crates against a size-tuned
binary, a real compile hit under lto/codegen-units=1, and — decisively — its
immediate-mode Line/Span model breaks the ANSI-String contract that
cmd/render shares with ways list, forcing a rewrite of both. Escape hatch,
stated up front: if the drill-down is meant to become a genuine multi-pane,
mouse-driven, resizable, text-selectable inspector, adopt ratatui at that point
rather than grow a poor reimplementation and migrate later. The micro-compositor
is right for "live table + status bar" and "list-left / read-only-detail-right
with independent scroll"; ratatui is right for a true windowed inspector.
3. Live refresh: poll-with-timeout + mtime/size stat gate — not notify¶
introspect live reuses crossterm poll(Duration): the timeout is the refresh interval
(~100–250 ms, imperceptible), on timeout re-read the tail of the (append-only)
events log + transcript and re-render, on key event handle input. Skip the
re-parse when the files' mtime/length are unchanged. This stays synchronous, adds
zero dependencies, and matches the project's existing poll style. Rejected:
notify (event-driven) — it forces a background thread + channel + a blocking
select that attend-chat solved only by bringing in an async runtime; its
large-tree advantage does not apply to two append-only files.
4. Command surface and scoping semantics¶
The surfaces unify under a single ways introspect <mode> command (ADR-111
single-surface spirit) rather than sibling top-level verbs. Modes:
ways introspect replay— post-hoc replay of a finished session (the behaviourrethinkhas today). Default to the current Claude Code project; add--allfor every project and keep--project <path>for a specific one. When current-project detection returnsNone, fail loud (name the missing marker) or fall back to cwd — never silently globalize. Compare on the encoded project slug / normalized path, not a loose substring.ways introspect list --json— enumerate candidate sessions as structured data so an agent can pick one before dumping it (closes the non-interactive enumeration gap).ways introspect dump— the non-interactive JSON dump of a session (the behaviourrethink --jsonhas today).ways introspect live— live monitor of the current session; same scoping default.- the drill-down — a tab in
replay/live: a fired-ways list; entering a way opens a read-as-a-human panel with the way body, its trigger criteria (ADR-153MatchCriteria), and the matched session clip (precise once ADR-153 §3 enrichment lands; heuristic-labelled before).
Migration: ways rethink becomes a thin deprecated alias for ways
introspect replay (and rethink --json → introspect dump), printing a
one-line deprecation notice to stderr while continuing to work. Muscle memory
keeps working; the canonical surface is the consolidated one.
Consequences¶
Positive¶
- Three surfaces + a drill-down from one model — no drift between what an agent reads as JSON and what a human sees in the TUI.
- Zero new dependencies; the lean binary and the shared
render.rscontract both survive. rethinkstops silently globalizing and gains machine-listable sessions.
Negative¶
- The micro-compositor is hand-built layout code (panes, scroll, tabs) — bounded (~200–400 lines) but genuinely new, and less capable than ratatui's widgets.
- A live
introspect liveloop re-reading files is more moving parts than a one-shot dump; the stat-gate must be correct to avoid needless re-parse flicker.
Neutral¶
- If the inspector's ambitions grow, the escape hatch to ratatui is deliberate and documented — this decision is reversible, not a dead end. The concrete trigger that would flip it: the drill-down needing text selection or resizable / mouse-driven panes; short of that, the micro-compositor stays.
- Command naming is decided: one
ways introspect <replay|live|dump>surface, withways rethinkkept as a deprecated alias. Thethink/rethinkverb pair was rejected as too cute — the modes are plain and descriptive instead.
Alternatives Considered¶
- Adopt ratatui now for real layout/widget primitives. Rejected for the
current scope on binary-size, compile-cost, and the
render.rs-rewrite grounds above — with the explicit escape hatch if scope grows. - Reuse
attend-chat's iocraft + async stack. Rejected: it would drag an async runtime into a deliberately synchronous CLI — a larger intrusion than ratatui for less fit. notifyfilesystem watcher forthink. Rejected: event-driven latency is not needed for two append-only files, and it forces the async architecture ways-cli avoids; poll + stat-gate is the lean match.- Keep three independent implementations (rethink as-is, a separate think, a separate dumper). Rejected: guarantees drift; the whole point is one model.
References¶
- ADR-153 — the
SessionIntrospectionsubstrate these front-ends render. - ADR-111 — the single-tool-surface consolidation spirit this ADR follows in
choosing one
ways introspect <mode>command over sibling verbs. - Research pass 2026-07-02 (TUI stack, crossterm-vs-ratatui trade study, refresh mechanism, shared-model factoring) — the ground truth this ADR is built on.
Amendment (2026-10-02), §3. The fixed ~100–250 ms refresh is replaced by a stat backoff, on the operator's direction in #780 (PR #796;
tools/ways-cli/src/cmd/introspect/live.rs). The stat gate and the rejection ofnotifystand. A followed replay states the event log and the session's transcript 2 s after a write, doubling while quiet to a 60 s ceiling. A replay of a quiet session watches its transcript on the same backoff and goes live on the first write. The sessions list states each transcript the same way and stops re-stating one last written more than a day ago. A replay's own stream is never cut off. Replay and live are one view;introspect liveopens it following.