Skip to content

Add a TUI sidebar listing other active sessions - #498

Closed
maxscheurer wants to merge 1 commit into
aebrer:masterfrom
maxscheurer:feature/issue-497-tui-session-sidebar
Closed

Add a TUI sidebar listing other active sessions#498
maxscheurer wants to merge 1 commit into
aebrer:masterfrom
maxscheurer:feature/issue-497-tui-session-sidebar

Conversation

@maxscheurer

Copy link
Copy Markdown
Contributor

Closes #497

Add a TUI sidebar to the main session view listing other active dreb sessions on the machine, with a per-session liveness registry as the prerequisite.

Implementation plan posted as a comment below.

@maxscheurer

Copy link
Copy Markdown
Contributor Author

Implementation Plan — Issue 497 (TUI sidebar listing other active sessions)

Problem

Terminal TUI sessions have no way to see what their parallel dreb sessions are doing. Today the only fleet view is the web dashboard, and nothing distinguishes a live session from a dead one. This plan adds (a) a per-session liveness registry so live sessions are discoverable, and (b) a sidebar in the interactive TUI that lists the other live sessions with project, model, status, and last activity.

User-driven constraints (this planning request)

  1. The sidebar must be width-bounded — it must not dominate the chat area.
  2. Each entry's status (running / needs-attention / idle / error) must be visible, matching the dashboard's status vocabulary.

Both constraints shape the design below (see "Key design decisions" 1 and 2).

Architecture — two logical stages, one PR

The feature decomposes cleanly; both stages land in this PR as two commits so the diff stays reviewable (per CONTRIBUTING.md).

Stage 1 — Liveness registry (net-new prerequisite). Nothing today marks a session as live. Each interactive session writes a small state file at startup, heartbeats it on a timer, and removes it on clean exit. A reader reaps stale entries (dead pid or expired heartbeat). Independently useful and unit-testable.

Stage 2 — Sidebar UI (consumer). A persistent, non-capturing, right-anchored TUI overlay that reads the registry on a short interval, renders the live sessions (excluding self) with a status glyph, and is toggled by a keybinding.

Deliverables

  1. Liveness registry modulepackages/coding-agent/src/core/live-registry.ts:
    • LiveStatus type ("running" | "attention" | "idle" | "error") and LiveEntry type (session id, pid, cwd, branch, model, status, optional statusReason, lastActivity, lastHeartbeat, startedAt).
    • getLiveDir() = join(getAgentDir(), "live") (respects the ENV_AGENT_DIR override at config.ts:183 → testable).
    • Atomic write (tmp file + rename), read-all, remove.
    • A pure reapStale(entries, now, pidAlive?) for testability.
    • LiveSessionWriter class: owns the heartbeat setInterval, reads a caller-supplied statusProvider()/modelProvider() each tick, and dispose()s (clears timer + removes file).
  2. Status derivation — a small pure deriveLiveStatus(signals) (see decision 2) + an InteractiveMode.getLiveStatus() that assembles the signals from the live session and returns { status, reason }.
  3. Sidebar componentpackages/coding-agent/src/modes/interactive/components/sessions-sidebar.ts: a Component that renders a header + one row per entry (status glyph, truncated cwd, truncated model, relative last-activity), self-hides/renders empty when there are no other sessions.
  4. WiringInteractiveMode creates the writer + sidebar, attaches the overlay, adds a toggle keybinding, and manages the reader refresh timer.
  5. Keybinding — new app.sessions.sidebar.toggle action.
  6. Docs — keybindings, TUI, and both READMEs (see Files).

Key design decisions (with rationale + evidence)

1. Width is a fixed bounded column count, not a percentage. OverlayOptions has width, minWidth, maxHeight but no maxWidth (tui.ts:123-163); the only hard cap is the built-in clamp Math.max(1, Math.min(width, availWidth)) (tui.ts:940), which prevents overflow but allows a full-width overlay. So "not too wide" is enforced by choosing a fixed column count (default 40, floor minWidth: 26) — it can never grow with terminal size. A visible: predicate hides the sidebar entirely on narrow terminals (e.g. termWidth < 96). anchor: "right-center" + margin: { right: 0, top: 1 } pins it flush to the right edge sized exactly by width (tui.ts:1028-1043). Precedent for this exact shape: examples/extensions/overlay-qa-tests.ts:175 and the docs/tui.md:129-134 recipe. Exact numbers are tunable decision points (see open questions).

2. Status is composed, not read from one flag. There is no single source of truth for status in interactive mode — the signals are scattered. deriveLiveStatus(signals) composes them with dashboard-matching priority error > attention > running > idle (mirrors runtimeStatus, dashboard/.../fleet.tsx:14-19):

  • error — a fatal error is present. Source: AgentState.error (agent/src/types.ts:305), reachable via this.session.agent.state.error (currently unread by coding-agent). Transient — cleared at the next agent_start (matches the dashboard, which clears on agent_start, runtime-pool.ts:503-509).
  • attention (the "needs attention" set) — a pending user-facing dialog OR running background agents:
    • this.extensionAsk / extensionSelector / extensionInput / extensionEditor non-undefined (interactive-mode.ts:260-263) — ask_user / select / confirm / input / editor prompts.
    • getRunningBackgroundAgents().length > 0 (core/tools/subagent.ts:1868) — parent paused for background agents.
  • runningthis.session.isStreaming OR this.session.isCompacting OR this.session.pendingMessageCount > 0 (agent-session.ts:1403/1466/2048).
  • idle — none of the above.

deriveLiveStatus is a pure function (input signals object → { status, reason }) so it is directly unit-testable; getLiveStatus() only assembles the signals object from the live session. Not representable (no persistent signal in interactive mode): suggest_next (ghost-text only) and tool-permission (does not exist — all tools auto-execute). The "attention" set therefore covers dialogs + background agents + error, which are the meaningful "needs attention" cases; the gap is documented.

3. Liveness = heartbeat + stale reaping (self-healing). The writer updates lastHeartbeat unconditionally every ~1.5s (an idle session still heartbeats); lastActivity tracks real work separately. Clean-exit removal (in stop()) is best-effort only — there is no process.on("exit") and no SIGTERM handler (interactive-mode.ts), so kill -9/crash orphans the file. The reader is what guarantees correctness: an entry is stale when now - lastHeartbeat > STALE_MS (threshold chosen tolerant of blocked event loops, e.g. 6–8s) or the pid is dead (process.kill(pid,0) throws). Heartbeat age is the decisive signal; the pid check is secondary (guards against pid reuse).

4. Overlay is nonCapturing. Focus stays on the editor, so (a) chat input/streaming is undisturbed and (b) the editor's handleInput action-dispatch loop keeps running, so the toggle keybinding works while the sidebar is shown (custom-editor.ts:70-74). The user toggle is folded into the visible: closure (w => w >= MIN && this._sidebarVisible); the keybinding flips _sidebarVisible and calls requestRender().

5. Deterministic ordering. Per AGENTS.md: sort by project path (cwd) alphabetical, then startedAt as tiebreak. Never by lastActivity/lastHeartbeat.

6. Self-exclusion. The reader excludes the entry whose sessionId equals the current this.session.sessionId (and, defensively, pid === process.pid).

Files to create or modify

Create

  • packages/coding-agent/src/core/live-registry.ts — registry + deriveLiveStatus + LiveSessionWriter.
  • packages/coding-agent/src/modes/interactive/components/sessions-sidebar.ts — sidebar component.
  • packages/coding-agent/test/live-registry.test.ts
  • packages/coding-agent/test/sessions-sidebar.test.ts

Modify

  • packages/coding-agent/src/modes/interactive/interactive-mode.tsgetLiveStatus(); in init() (ends ~line 655, after UI start + footer subscription at 651-654) create the writer (initial write + heartbeat) and the sidebar overlay (hidden by default); toggleSessionsSidebar() + start/stopSessionsRefresh(); register onAction("app.sessions.sidebar.toggle", ...) alongside app.tasks.toggle (~line 2324); in stop() (~line 5723, beside footerDataProvider.dispose()) call liveWriter.dispose() + stopSessionsRefresh() (guarded/idempotent for the --startup-benchmark path that calls stop() without a full shutdown).
  • packages/coding-agent/src/core/keybindings.ts — add app.sessions.sidebar.toggle to AppKeybindings + KEYBINDINGS (a non-colliding default, or unbound like app.tasks.toggle at lines 130-133).
  • packages/coding-agent/docs/keybindings.md — add the action to the ### Sessions table (lines 94-108).
  • packages/coding-agent/docs/tui.md — note the persistent nonCapturing overlay + visible:-predicate toggle pattern (currently the docs emphasize the recreate-fresh pattern at line 157).
  • packages/coding-agent/README.md## Interactive Mode (line 136): mention the sidebar in the layout + a command/shortcut line.
  • README.md (root) — ## Core capabilities TUI bullets (lines 15, 79).

Testing approach (mandatory)

  • live-registry.test.ts (point getAgentDir() at a temp dir via ENV_AGENT_DIR):
    • write→read round-trip; atomic write yields valid JSON (no torn read).
    • reapStale: fresh entries kept; lastHeartbeat older than STALE_MS reaped; exactly-at-threshold defined deterministically.
    • dead-pid reaping (use a guaranteed-dead pid so process.kill(pid,0) throws ESRCH).
    • LiveSessionWriter: writes on start, heartbeat advances lastHeartbeat between ticks (fake timers), dispose() clears the timer and removes the file, idempotent double-dispose is safe.
    • deriveLiveStatus pure function: each priority branch (error beats attention beats running beats idle), and the "attention" triggers (dialog vs background agents) independently.
  • sessions-sidebar.test.ts:
    • render(width) with N entries: header + N rows; cwd and model truncated to fit the fixed width; status glyph present per entry.
    • empty list renders empty/hidden cleanly; self-excluded entry not shown.
    • ordering deterministic (cwd alpha, startedAt tiebreak) — assert it does NOT reorder by lastActivity.
  • Wiring (light): assert the sidebar overlay is attached with the expected width/minWidth/anchor/nonCapturing, and that toggling flips _sidebarVisible and starts/stops the refresh timer (spy on requestRender + the timer).
  • Manual: run two dreb sessions in two terminals; verify the sidebar lists the other, shows a live status, and reaps it within the stale threshold after a hard kill.

Acceptance criteria

  • A sidebar is rendered in the main TUI session view with one entry per other active interactive dreb session on the machine; the current session is excluded.
  • Each entry displays project (cwd), model, status, and last activity.
  • The status reflects running / needs-attention / idle / error (composed per decision 2) and is visible on the entry.
  • A per-session liveness state file is written under the agent dir at startup, heartbeated periodically, removed on clean exit; stale entries (expired heartbeat or dead pid) are not displayed.
  • The sidebar can be toggled via a keybinding; showing/hiding it does not disturb chat input, streaming, or scrollback.
  • The sidebar width is bounded (fixed column count with a floor) and it self-hides on narrow terminals; it never dominates the chat area.
  • Entries are sorted deterministically (cwd alpha, startedAt tiebreak), not by last activity.
  • The sidebar renders cleanly (empty or hidden) when no other sessions are active.
  • Root README.md, packages/coding-agent/README.md, docs/keybindings.md, and docs/tui.md are updated per the AGENTS.md documentation rule.
  • All new behavior is covered by the tests above.

Risks and open questions

  1. Event-loop blocking can delay heartbeats (sync rewrite/parse/shell). Mitigation: tolerant STALE_MS (well above the 1.5s heartbeat).
  2. No process.on("exit")/SIGTERM handler — ungraceful exits orphan files; stale-reaping is the real guarantee (by design).
  3. Status has no single source of truth — composed from scattered signals; suggest_next and tool-permission are not representable (documented gap).
  4. Background-agent registry is module-level (process-global) in subagent.ts — correct for self-report (each process reports its own), but not per-InteractiveMode-instance (not an issue in practice, one interactive mode per process).
  5. --startup-benchmark calls stop() without process.exit/full init — the stop() cleanup must be idempotent and guard on initialization state.
  6. Suspend (Ctrl+Z) stops the UI but keeps the process alive and doesn't run stop(); the heartbeat pauses while the process is suspended (acceptable — the session isn't active), and resumes on SIGCONT.
  7. Open decision points (defaults proposed, tunable): exact width (40 / minWidth 26) and narrow-terminal hide threshold (96 cols); the toggle key (propose a non-colliding default, or unbound like the tasks panel); default visibility (propose hidden, user opts in); heartbeat (1.5s) and stale threshold (6–8s).

Plan created by mach6

@maxscheurer

Copy link
Copy Markdown
Contributor Author

Closing as mis-scoped: the sessions sidebar belongs in the dashboard, not the TUI. The TUI implementation has been removed from this branch (only the per-session liveness registry under packages/coding-agent/src/core/live-registry.ts remains, uncommitted, as a starting point). The feature will be re-scoped, re-investigated, and re-implemented as a dashboard-scoped issue/PR.

@maxscheurer maxscheurer closed this Sep 1, 2026
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.

Add a TUI sidebar listing other active sessions

1 participant