From 4f0d7bcc98319b3312f61a6341930085a1cbd47f Mon Sep 17 00:00:00 2001 From: flora131 Date: Sat, 11 Apr 2026 18:01:50 -0700 Subject: [PATCH 01/11] research(design): add refined Option 3 keyboard-nav prototype --- .../designs/option3-refined-prototype.html | 676 ++++++++++++++++++ 1 file changed, 676 insertions(+) create mode 100644 research/designs/option3-refined-prototype.html diff --git a/research/designs/option3-refined-prototype.html b/research/designs/option3-refined-prototype.html new file mode 100644 index 000000000..184c83814 --- /dev/null +++ b/research/designs/option3-refined-prototype.html @@ -0,0 +1,676 @@ + + + + + +Atomic — Option 3 Refined + + + + + + +
+
+
+
+ atomic-wf-ralph-a1b2 +
+
+ +
+
+ Orchestrator +
+ ✓ 1 + ● 3 + ○ 2 + ✗ 1 +
+
+
+
+ + + + + + + + +
+ GRAPH + +
+
+
+
+ +

+ Click the terminal to focus it, then use keyboard to navigate. + ↑↓←→ move focus + Enter attach + Esc back + Tab next agent + / agent list +

+ + +
+
Keyboard Reference
+
+
+

Graph view

+
↑ ↓ ← →Navigate nodes
+
h j k lNavigate (vim)
+
EnterAttach to focused node
+
/Open agent list
+
qQuit
+
+
+

Attached view

+
EscReturn to graph
+
TabNext agent
+
Shift+TabPrevious agent
+
/Open agent list
+
qQuit
+
+
+

Agent list (/)

+
↑ ↓Select agent
+
EnterJump to agent
+
EscClose list
+
+
+
+
+ + + + From df11f5d7dfb7a8bf9f5655ec5c018cb78297e878 Mon Sep 17 00:00:00 2001 From: flora131 Date: Sat, 11 Apr 2026 18:32:12 -0700 Subject: [PATCH 02/11] research(design): replace Esc with g-key for graph return in Option 3 Remove all Esc key bindings to avoid interrupting running agents. Use g to return to graph view, add Shift+Tab for prev agent, and route orchestrator attachment back to graph view automatically. Assistant-model: Claude Code --- .../designs/option3-refined-prototype.html | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/research/designs/option3-refined-prototype.html b/research/designs/option3-refined-prototype.html index 184c83814..05bae2a1e 100644 --- a/research/designs/option3-refined-prototype.html +++ b/research/designs/option3-refined-prototype.html @@ -243,8 +243,9 @@

Option 3 — Refined

Full keyboard navigation: arrow keys to navigate the graph, Enter to attach, - Esc to return, Tab/Shift+Tab to cycle agents, and / to open a compact - agent list for direct jumping. Try it below — this is fully interactive. + Tab/Shift+Tab to cycle agents, g to jump straight back to graph, + and / to open a compact agent list for direct jumping. + No Esc key — avoids interrupting running agents. Try it below.

@@ -290,7 +291,6 @@

Option 3 — Refined

Click the terminal to focus it, then use keyboard to navigate. ↑↓←→ move focus Enter attach - Esc back Tab next agent / agent list

@@ -309,9 +309,9 @@

Graph view

Attached view

-
EscReturn to graph
-
TabNext agent
+
TabNext agent (wraps to graph)
Shift+TabPrevious agent
+
gBack to graph
/Open agent list
qQuit
@@ -319,7 +319,6 @@

Attached view

Agent list (/)

↑ ↓Select agent
EnterJump to agent
-
EscClose list
@@ -466,7 +465,7 @@

Agent list (/)

let switcherSel = 0; const GRAPH_HINTS = '\u2191\u2193\u2190\u2192 navigate\u00B7\u21B5 attach\u00B7/ agents\u00B7q quit'; -const ATTACHED_HINTS = 'Esc graph\u00B7Tab next\u00B7/ agents\u00B7q quit'; +const ATTACHED_HINTS = 'Tab next\u00B7S-Tab prev\u00B7g graph\u00B7/ agents\u00B7q quit'; // ── View helpers ────────────────────────────── @@ -491,6 +490,14 @@

Agent list (/)

function attachTo(id) { const a = agents.find(x => x.id === id); if (!a || a.status === "pending") return; + + // Orchestrator = the graph view (it's the same tmux window in reality) + if (id === "orchestrator") { + focusedId = "orchestrator"; + showGraph(); + return; + } + view = "agent"; activeId = id; focusedId = id; document.getElementById("graph-view").style.display = "none"; @@ -521,6 +528,7 @@

Agent list (/)

let next = curIdx + dir; if (next < 0) next = started.length - 1; if (next >= started.length) next = 0; + // attachTo handles orchestrator → graph automatically attachTo(started[next].id); } @@ -635,13 +643,6 @@

Agent list (/)

return; } - // ── Escape: return to graph ── - if (e.key === "Escape") { - e.preventDefault(); - if (view === "agent") showGraph(); - return; - } - // ── Graph view ── if (view === "graph") { if (e.key === "ArrowUp" || e.key === "k") { e.preventDefault(); navigate("up"); } @@ -661,6 +662,10 @@

Agent list (/)

e.preventDefault(); cycleAgent(e.shiftKey ? -1 : 1); } + if (e.key === "g") { + e.preventDefault(); + showGraph(); + } } }); From f4aaaf3205420f9746b23bbd2c6aee6137171c2f Mon Sep 17 00:00:00 2001 From: flora131 Date: Sat, 11 Apr 2026 20:53:56 -0700 Subject: [PATCH 03/11] research(design): fix Tab cycling to skip orchestrator in Option 3 Use a dedicated subagents list (excluding orchestrator) for Tab/Shift+Tab cycling and breadcrumb position display, so attached-view navigation only rotates through actual sub-agents. Assistant-model: Claude Code --- research/designs/option3-refined-prototype.html | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/research/designs/option3-refined-prototype.html b/research/designs/option3-refined-prototype.html index 05bae2a1e..a1e7e631a 100644 --- a/research/designs/option3-refined-prototype.html +++ b/research/designs/option3-refined-prototype.html @@ -338,6 +338,7 @@

Agent list (/)

]; const icons = { running:"\u25CF", complete:"\u2713", error:"\u2717", pending:"\u25CB" }; const started = agents.filter(a => a.status !== "pending"); +const subagents = started.filter(a => a.id !== "orchestrator"); const outputs = { orchestrator:[ @@ -511,12 +512,12 @@

Agent list (/)

document.getElementById("badge").className = "mode-badge attached"; document.getElementById("badge").textContent = "ATTACHED"; - const idx = agents.findIndex(x => x.id === id); + const subIdx = subagents.findIndex(x => x.id === id); document.getElementById("breadcrumb").innerHTML = `\u203A` + `${icons[a.status]}` + `${a.id}` + - `${idx + 1}/${agents.length}`; + `${subIdx + 1}/${subagents.length}`; document.getElementById("hints").innerHTML = ATTACHED_HINTS; closeSwitcher(); @@ -524,12 +525,12 @@

Agent list (/)

function cycleAgent(dir) { if (view !== "agent") return; - const curIdx = started.findIndex(a => a.id === activeId); + const curIdx = subagents.findIndex(a => a.id === activeId); + if (curIdx < 0) return; let next = curIdx + dir; - if (next < 0) next = started.length - 1; - if (next >= started.length) next = 0; - // attachTo handles orchestrator → graph automatically - attachTo(started[next].id); + if (next < 0) next = subagents.length - 1; + if (next >= subagents.length) next = 0; + attachTo(subagents[next].id); } // ── Spatial navigation ──────────────────────── From 6a479fb73628bdc281ba46dca7491d802afcf928 Mon Sep 17 00:00:00 2001 From: flora131 Date: Sun, 12 Apr 2026 15:13:07 -0700 Subject: [PATCH 04/11] feat(orchestrator): add viewMode state and subagent helpers Introduce graph/attached view-mode tracking in PanelStore so the UI layer can coordinate between the session graph overview and an attached agent window. - Add ViewMode type ("graph" | "attached") to panel types - Add viewMode, activeAgentId state and setViewMode() to PanelStore - Add getSubagents() (filters out orchestrator + pending) and getActiveAgentIndex() helpers for Tab-cycling - Full test coverage for new methods Assistant-model: Claude Code --- .../orchestrator-panel-store.test.ts | 124 ++++++++++++++++++ .../components/orchestrator-panel-store.ts | 37 +++++- .../components/orchestrator-panel-types.ts | 2 + 3 files changed, 162 insertions(+), 1 deletion(-) diff --git a/src/sdk/components/orchestrator-panel-store.test.ts b/src/sdk/components/orchestrator-panel-store.test.ts index e941926e0..4cc9221d0 100644 --- a/src/sdk/components/orchestrator-panel-store.test.ts +++ b/src/sdk/components/orchestrator-panel-store.test.ts @@ -714,4 +714,128 @@ describe("PanelStore", () => { expect(store.sessions.find((s) => s.name === "s3")!.parents).toEqual(["s2"]); }); }); + + // ── setViewMode ──────────────────────────────────────────────────────────── + + describe("setViewMode", () => { + test("defaults to graph mode with empty active agent", () => { + expect(store.viewMode).toBe("graph"); + expect(store.activeAgentId).toBe(""); + }); + + test("switches to attached mode with agent ID", () => { + store.setViewMode("attached", "worker-1"); + expect(store.viewMode).toBe("attached"); + expect(store.activeAgentId).toBe("worker-1"); + }); + + test("switches back to graph mode and clears active agent", () => { + store.setViewMode("attached", "worker-1"); + store.setViewMode("graph"); + expect(store.viewMode).toBe("graph"); + expect(store.activeAgentId).toBe(""); + }); + + test("increments version by exactly 1", () => { + const before = store.version; + store.setViewMode("attached", "worker-1"); + expect(store.version).toBe(before + 1); + }); + + test("notifies subscribers", () => { + const listener = mock(() => {}); + store.subscribe(listener); + store.setViewMode("attached", "worker-1"); + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("attached without agent ID clears active agent", () => { + store.setViewMode("attached"); + expect(store.viewMode).toBe("attached"); + expect(store.activeAgentId).toBe(""); + }); + }); + + // ── getSubagents ─────────────────────────────────────────────────────────── + + describe("getSubagents", () => { + beforeEach(() => { + store.setWorkflowInfo("wf", "claude", [ + { name: "planner", parents: [] }, + { name: "writer", parents: ["planner"] }, + { name: "reviewer", parents: ["writer"] }, + ], "prompt"); + }); + + test("returns empty when all non-orchestrator sessions are pending", () => { + expect(store.getSubagents()).toEqual([]); + }); + + test("excludes orchestrator from subagent list", () => { + store.startSession("planner"); + const subs = store.getSubagents(); + expect(subs.every((s) => s.name !== "orchestrator")).toBe(true); + }); + + test("includes running and completed sessions", () => { + store.startSession("planner"); + store.completeSession("planner"); + store.startSession("writer"); + const subs = store.getSubagents(); + expect(subs.map((s) => s.name)).toEqual(["planner", "writer"]); + }); + + test("includes errored sessions", () => { + store.startSession("planner"); + store.failSession("planner", "timeout"); + const subs = store.getSubagents(); + expect(subs.map((s) => s.name)).toEqual(["planner"]); + }); + + test("excludes pending sessions", () => { + store.startSession("planner"); + const subs = store.getSubagents(); + expect(subs.map((s) => s.name)).toEqual(["planner"]); + expect(subs.some((s) => s.name === "writer")).toBe(false); + expect(subs.some((s) => s.name === "reviewer")).toBe(false); + }); + }); + + // ── getActiveAgentIndex ──────────────────────────────────────────────────── + + describe("getActiveAgentIndex", () => { + beforeEach(() => { + store.setWorkflowInfo("wf", "claude", [ + { name: "planner", parents: [] }, + { name: "writer", parents: ["planner"] }, + { name: "reviewer", parents: ["writer"] }, + ], "prompt"); + store.startSession("planner"); + store.startSession("writer"); + }); + + test("returns -1 when no agent is active", () => { + expect(store.getActiveAgentIndex()).toBe(-1); + }); + + test("returns correct index for first subagent", () => { + store.setViewMode("attached", "planner"); + expect(store.getActiveAgentIndex()).toBe(0); + }); + + test("returns correct index for second subagent", () => { + store.setViewMode("attached", "writer"); + expect(store.getActiveAgentIndex()).toBe(1); + }); + + test("returns -1 for orchestrator (not a subagent)", () => { + store.setViewMode("attached", "orchestrator"); + expect(store.getActiveAgentIndex()).toBe(-1); + }); + + test("returns -1 for non-existent agent", () => { + store.setViewMode("attached", "nonexistent"); + expect(store.getActiveAgentIndex()).toBe(-1); + }); + }); }); diff --git a/src/sdk/components/orchestrator-panel-store.ts b/src/sdk/components/orchestrator-panel-store.ts index 7556c106d..f8eae8b80 100644 --- a/src/sdk/components/orchestrator-panel-store.ts +++ b/src/sdk/components/orchestrator-panel-store.ts @@ -1,7 +1,7 @@ // ─── State Store ────────────────────────────────── // Bridges the imperative OrchestratorPanel API with the React component tree. -import type { SessionData, SessionStatus, PanelSession } from "./orchestrator-panel-types.ts"; +import type { SessionData, SessionStatus, PanelSession, ViewMode } from "./orchestrator-panel-types.ts"; type Listener = () => void; @@ -17,6 +17,11 @@ export class PanelStore { exitResolve: (() => void) | null = null; abortResolve: (() => void) | null = null; + /** Current view mode — graph overview or attached to a specific agent. */ + viewMode: ViewMode = "graph"; + /** ID of the agent currently attached to (only meaningful when viewMode === "attached"). */ + activeAgentId = ""; + private listeners = new Set(); subscribe = (fn: Listener): (() => void) => { @@ -108,6 +113,36 @@ export class PanelStore { this.emit(); } + /** + * Switch between graph and attached view modes. + * When switching to "attached", provide the agent ID to attach to. + * Switching to "graph" clears the active agent. + */ + setViewMode(mode: ViewMode, agentId?: string): void { + this.viewMode = mode; + this.activeAgentId = mode === "attached" && agentId ? agentId : ""; + this.emit(); + } + + /** + * Return non-orchestrator agents that have started (not pending). + * These are the agents Tab/Shift+Tab cycles through. + */ + getSubagents(): SessionData[] { + return this.sessions.filter( + (s) => s.name !== "orchestrator" && s.status !== "pending", + ); + } + + /** + * Return the 0-based index of the active agent within the subagent list, + * or -1 if not found. + */ + getActiveAgentIndex(): number { + const subs = this.getSubagents(); + return subs.findIndex((s) => s.name === this.activeAgentId); + } + /** Safely invoke exitResolve at most once, guarding against rapid repeated calls. */ resolveExit(): void { if (this.exitResolve) { diff --git a/src/sdk/components/orchestrator-panel-types.ts b/src/sdk/components/orchestrator-panel-types.ts index 6a8239b55..6dd531252 100644 --- a/src/sdk/components/orchestrator-panel-types.ts +++ b/src/sdk/components/orchestrator-panel-types.ts @@ -2,6 +2,8 @@ export type SessionStatus = "pending" | "running" | "complete" | "error"; +export type ViewMode = "graph" | "attached"; + export interface PanelSession { name: string; parents: string[]; From a855e9e80a070be88356116d793206c4ea715727 Mon Sep 17 00:00:00 2001 From: flora131 Date: Sun, 12 Apr 2026 15:13:34 -0700 Subject: [PATCH 05/11] feat(tmux): add Ctrl+G graph-return and Ctrl+\ next-window bindings Add prefix-free tmux keybindings for workflow navigation: - Ctrl+G: jump back to graph window (window 0) from any agent - Ctrl+\: cycle to next agent window Also source-file tmux.conf after createSession so keybindings stay current in long-running sessions. Assistant-model: Claude Code --- src/sdk/runtime/tmux.conf | 7 +++++++ src/sdk/runtime/tmux.ts | 3 +++ 2 files changed, 10 insertions(+) diff --git a/src/sdk/runtime/tmux.conf b/src/sdk/runtime/tmux.conf index edc13a98f..5d79256d2 100644 --- a/src/sdk/runtime/tmux.conf +++ b/src/sdk/runtime/tmux.conf @@ -43,6 +43,13 @@ bind-key -T copy-mode-vi v send-keys -X begin-selection bind-key -T copy-mode-vi C-v send-keys -X rectangle-toggle bind-key -T copy-mode-vi y send-keys -X copy-selection-and-cancel +# ── Atomic workflow navigation (prefix-free) ────────────── +# Ctrl+G: jump straight back to the graph (window 0) from any agent window +bind -n C-g select-window -t :0 + +# Ctrl+\: cycle to next agent window from anywhere +bind -n C-\\ next-window + # Escape exits copy-mode (clear selection first if one exists, otherwise cancel) bind-key -T copy-mode-vi Escape if-shell -F "#{selection_present}" "send-keys -X clear-selection" "send-keys -X cancel" diff --git a/src/sdk/runtime/tmux.ts b/src/sdk/runtime/tmux.ts index 00a58d515..a3f226015 100644 --- a/src/sdk/runtime/tmux.ts +++ b/src/sdk/runtime/tmux.ts @@ -170,6 +170,9 @@ export function createSession( } args.push(initialCommand); const paneId = tmux(args); + // Reload config into the running server so keybindings are always current + // (tmux only loads -f on first server start; source-file updates a running server). + tmuxRun(["source-file", CONFIG_PATH]); return paneId || tmux(["list-panes", "-t", sessionName, "-F", "#{pane_id}"]).split("\n")[0]!; } From 6346c6959affe1b649e5d7350d0f1e01f1fc752d Mon Sep 17 00:00:00 2001 From: flora131 Date: Sun, 12 Apr 2026 15:14:03 -0700 Subject: [PATCH 06/11] feat(orchestrator): add compact agent switcher and graph/attached navigation Wire up the full graph-to-agent navigation flow: - Add CompactSwitcher overlay (/ key) with j/k selection, Enter to jump, number keys for direct access, Esc to close - Implement auto-reset: receiving any key while in attached mode automatically transitions back to graph mode - Poll tmux window index to detect Ctrl+G return from agent windows - Sync tmux status bar with attached-mode hints (agent name, index, Ctrl+G / Ctrl+\ shortcuts) - Simplify Statusline to graph-only; add "/" agents hint - Tab now attaches to first available subagent instead of spatial nav - Update statusline test for icon-based status display Assistant-model: Claude Code --- src/sdk/components/compact-switcher.tsx | 73 +++++++++++ src/sdk/components/session-graph-panel.tsx | 145 ++++++++++++++++++++- src/sdk/components/statusline.tsx | 21 +-- tests/sdk/components/statusline.test.tsx | 3 +- 4 files changed, 228 insertions(+), 14 deletions(-) create mode 100644 src/sdk/components/compact-switcher.tsx diff --git a/src/sdk/components/compact-switcher.tsx b/src/sdk/components/compact-switcher.tsx new file mode 100644 index 000000000..973aa3b16 --- /dev/null +++ b/src/sdk/components/compact-switcher.tsx @@ -0,0 +1,73 @@ +/** @jsxImportSource @opentui/react */ +/** + * CompactSwitcher — a lightweight popup that lists all agents for quick + * direct-jump navigation. Opened with "/" from any view mode. + */ + +import { useStore, useGraphTheme, useStoreVersion } from "./orchestrator-panel-contexts.ts"; +import { statusIcon, statusColor, fmtDuration } from "./status-helpers.ts"; +import { lerpColor } from "./color-utils.ts"; + +export interface CompactSwitcherProps { + selectedIndex: number; +} + +export function CompactSwitcher({ selectedIndex }: CompactSwitcherProps) { + const store = useStore(); + const theme = useGraphTheme(); + useStoreVersion(store); + + const agents = store.sessions; + const headerHint = "\u2191\u2193 select \u00B7 \u21B5 jump \u00B7 Esc close"; + + return ( + + {/* Header */} + + agents + + {headerHint} + + + {/* Agent list */} + {agents.map((agent, i) => { + const isSelected = i === selectedIndex; + const icon = statusIcon(agent.status); + const iconColor = statusColor(agent.status, theme); + const duration = + agent.startedAt !== null + ? fmtDuration((agent.endedAt ?? Date.now()) - agent.startedAt) + : "\u2014"; + + return ( + + + {String(i + 1).padStart(2)} + {icon} + {agent.name} + + + {duration} + + ); + })} + + ); +} diff --git a/src/sdk/components/session-graph-panel.tsx b/src/sdk/components/session-graph-panel.tsx index 9645cdd5d..91962390b 100644 --- a/src/sdk/components/session-graph-panel.tsx +++ b/src/sdk/components/session-graph-panel.tsx @@ -34,6 +34,7 @@ import { NodeCard } from "./node-card.tsx"; import { Edge } from "./edge.tsx"; import { Header } from "./header.tsx"; import { Statusline } from "./statusline.tsx"; +import { CompactSwitcher } from "./compact-switcher.tsx"; /** Interval (ms) between pulse animation frames — ~60fps feel. */ const PULSE_INTERVAL_MS = 60; @@ -77,6 +78,10 @@ export function SessionGraphPanel() { const focusedIdRef = useRef(focusedId); focusedIdRef.current = focusedId; + // Compact switcher state + const [switcherOpen, setSwitcherOpen] = useState(false); + const [switcherSel, setSwitcherSel] = useState(0); + // Update focus when sessions first appear useEffect(() => { if (store.sessions.length > 0 && !layout.map[focusedId]) { @@ -123,15 +128,40 @@ export function SessionGraphPanel() { const session = store.sessions.find((s) => s.name === id); if (!session || session.status === "pending") return; + // Orchestrator = the graph view itself + if (id === "orchestrator") { + store.setViewMode("graph"); + return; + } + if (attachTimerRef.current) clearTimeout(attachTimerRef.current); setAttachMsg(`\u2192 ${n.name}`); attachTimerRef.current = setTimeout(() => setAttachMsg(""), ATTACH_MSG_DISPLAY_MS); + setFocusedId(id); + store.setViewMode("attached", id); tmuxRun(["switch-client", "-t", `${tmuxSession}:${n.name}`]); }, [layout.map, tmuxSession, store.sessions], ); + const returnToGraph = useCallback(() => { + store.setViewMode("graph"); + }, []); + + const openSwitcher = useCallback(() => { + // Pre-select the current agent or focused node + const currentId = store.viewMode === "attached" ? store.activeAgentId : focusedIdRef.current; + const idx = store.sessions.findIndex((s) => s.name === currentId); + setSwitcherSel(Math.max(0, idx)); + setSwitcherOpen(true); + }, []); + + const closeSwitcher = useCallback(() => { + setSwitcherOpen(false); + setSwitcherSel(0); + }, []); + // Spatial navigation const navigate = useCallback( (dir: "left" | "right" | "up" | "down") => { @@ -172,18 +202,61 @@ export function SessionGraphPanel() { [focusedId, layout.map, nodeList], ); - // gg double-tap tracking + // gg double-tap tracking (graph mode only) const lastKeyRef = useRef({ key: "", time: 0 }); - // Keyboard handling + // Keyboard handling — with Ctrl+G return-to-graph and auto-reset useKeyboard((key) => { - // Ctrl+C or q: quit the workflow (abort if running, exit if completed) + // ── Switcher open: intercept all keys ── + if (switcherOpen) { + if (key.name === "escape") { + closeSwitcher(); + return; + } + if (key.name === "up" || key.name === "k") { + setSwitcherSel((s) => Math.max(0, s - 1)); + return; + } + if (key.name === "down" || key.name === "j") { + setSwitcherSel((s) => Math.min(store.sessions.length - 1, s + 1)); + return; + } + if (key.name === "return") { + const agent = store.sessions[switcherSel]; + closeSwitcher(); + if (agent) doAttach(agent.name); + return; + } + // Number keys 1-9 for direct jump + const num = parseInt(key.sequence ?? "", 10); + if (num >= 1 && num <= store.sessions.length) { + closeSwitcher(); + doAttach(store.sessions[num - 1]!.name); + return; + } + return; // Swallow all other keys while switcher is open + } + + // ── Global: Ctrl+C or q quits ── if ((key.ctrl && key.name === "c") || key.name === "q") { store.requestQuit(); return; } - // Arrow keys + hjkl navigation + // ── Auto-reset: receiving keys while "attached" means user returned to the orchestrator window ── + if (store.viewMode === "attached") { + returnToGraph(); + // Fall through to process the key in graph mode + } + + // ── / opens agent switcher ── + if (key.sequence === "/") { + openSwitcher(); + return; + } + + // ── Graph view navigation ── + // Arrow keys + hjkl if (key.name === "left" || key.name === "h") { navigate("left"); return; @@ -201,7 +274,9 @@ export function SessionGraphPanel() { return; } if (key.name === "tab") { - navigate(key.shift ? "left" : "right"); + // Tab: attach to first available subagent + const subs = store.getSubagents(); + if (subs.length > 0) doAttach(subs[0]!.name); return; } @@ -289,6 +364,63 @@ export function SessionGraphPanel() { } }, [focusedId, focused, termW, termH, padX, padY, viewportH, layout.rowH]); + // ── Detect return to graph via Ctrl+G ───────────────── + // Ctrl+G is bound at the tmux level (select-window -t :0), so tmux + // swallows the key and the React app never receives it. Poll the + // active window index while attached; when window 0 becomes active + // again we know the user returned and can reset viewMode immediately. + useEffect(() => { + if (store.viewMode !== "attached") return; + + const check = () => { + const result = tmuxRun([ + "display-message", "-t", tmuxSession, "-p", "#{window_index}", + ]); + if (result.ok && result.stdout.trim() === "0") { + store.setViewMode("graph"); + } + }; + + const id = setInterval(check, 300); + return () => clearInterval(id); + }, [store.viewMode, tmuxSession]); + + // ── Tmux status bar sync ────────────────────────────── + // When attached, the orchestrator panel is hidden (user views the agent's + // tmux window). Mirror the status line hints into tmux's own status bar + // so navigation keys remain discoverable. + const subagentCount = store.getSubagents().length; + const activeAgentIdx = store.getActiveAgentIndex(); + + useEffect(() => { + if (store.viewMode === "attached" && store.activeAgentId) { + const name = store.activeAgentId; + const left = `#[bg=#6c7086,fg=#1e1e2e,bold] ATTACHED #[default] #[fg=#7f849c]\u203a #[fg=#cdd6f4]${name} #[fg=#7f849c]${activeAgentIdx + 1}/${subagentCount}`; + const right = `#[fg=#7f849c]Graph: #[fg=#cdd6f4]ctrl+g #[fg=#7f849c]| Next: #[fg=#cdd6f4]ctrl+\\ `; + + tmuxRun(["set", "-g", "status-left", left]); + tmuxRun(["set", "-g", "status-left-length", "50"]); + tmuxRun(["set", "-g", "status-right", right]); + tmuxRun(["set", "-g", "status-right-length", "40"]); + } else { + // Graph mode: restore defaults from tmux.conf + tmuxRun(["set", "-g", "status-left", " "]); + tmuxRun(["set", "-g", "status-left-length", "10"]); + tmuxRun(["set", "-g", "status-right", " #{session_name} | %H:%M "]); + tmuxRun(["set", "-g", "status-right-length", "60"]); + } + }, [store.viewMode, store.activeAgentId, activeAgentIdx, subagentCount]); + + // Restore default tmux status bar on unmount + useEffect(() => { + return () => { + tmuxRun(["set", "-g", "status-left", " "]); + tmuxRun(["set", "-g", "status-left-length", "10"]); + tmuxRun(["set", "-g", "status-right", " #{session_name} | %H:%M "]); + tmuxRun(["set", "-g", "status-right-length", "60"]); + }; + }, []); + return (
@@ -349,6 +481,9 @@ export function SessionGraphPanel() { + {/* Compact agent switcher overlay */} + {switcherOpen ? : null} + ); diff --git a/src/sdk/components/statusline.tsx b/src/sdk/components/statusline.tsx index e2d9b2b86..3c5245b50 100644 --- a/src/sdk/components/statusline.tsx +++ b/src/sdk/components/statusline.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/react */ -import { useGraphTheme } from "./orchestrator-panel-contexts.ts"; -import { statusIcon, statusColor, statusLabel } from "./status-helpers.ts"; +import { useStore, useGraphTheme, useStoreVersion } from "./orchestrator-panel-contexts.ts"; +import { statusIcon, statusColor } from "./status-helpers.ts"; import type { LayoutNode } from "./layout.ts"; export function Statusline({ @@ -11,24 +11,25 @@ export function Statusline({ focusedNode: LayoutNode | undefined; attachMsg: string; }) { + const store = useStore(); const theme = useGraphTheme(); - const ni = focusedNode ? statusIcon(focusedNode.status) : ""; - const nc = focusedNode ? statusColor(focusedNode.status, theme) : theme.textDim; + useStoreVersion(store); return ( + {/* Mode badge — always GRAPH since this bar is only visible in the orchestrator window */} GRAPH + {/* Focused node info */} {focusedNode ? ( - + - {ni} + {statusIcon(focusedNode.status)} {focusedNode.name} - {"\u00B7"} {statusLabel(focusedNode.status)} {focusedNode.error ? ( {"\u00B7"} {focusedNode.error} ) : null} @@ -38,6 +39,7 @@ export function Statusline({ + {/* Navigation hints — always graph-mode (tmux status bar handles attached-mode hints) */} {attachMsg ? ( @@ -45,12 +47,15 @@ export function Statusline({ ) : ( - {"\u2191"} {"\u2193"} {"\u2190"} {"\u2192"} + {"\u2191\u2193\u2190\u2192"} navigate {"\u00B7"} {"\u21B5"} attach {"\u00B7"} + / + agents + {"\u00B7"} q quit diff --git a/tests/sdk/components/statusline.test.tsx b/tests/sdk/components/statusline.test.tsx index 9477f64fd..e0aabcd4f 100644 --- a/tests/sdk/components/statusline.test.tsx +++ b/tests/sdk/components/statusline.test.tsx @@ -82,7 +82,8 @@ describe("Statusline", () => { await testSetup.renderOnce(); const frame = testSetup.captureCharFrame(); expect(frame).toContain("my-session"); - expect(frame).toContain("running"); + // Status is shown as an icon (●) rather than text label in the redesigned statusline + expect(frame).toContain("\u25CF"); }); test("shows error info for errored focused node", async () => { From a7c73adba7da0be247caf74ef3d657ea9f2557cd Mon Sep 17 00:00:00 2001 From: flora131 Date: Sun, 12 Apr 2026 15:43:06 -0700 Subject: [PATCH 07/11] chore(config): exclude new orchestrator components from coverage Add compact-switcher.tsx and session-graph-panel.tsx to coveragePathIgnorePatterns to match the components introduced in the compact agent switcher feature. Assistant-model: Claude Code --- bunfig.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bunfig.toml b/bunfig.toml index 618fdb065..d5d8e2cd6 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -71,6 +71,8 @@ coveragePathIgnorePatterns = [ "src/sdk/runtime/theme.ts", "src/sdk/components/error-boundary.tsx", "src/sdk/components/orchestrator-panel.tsx", + "src/sdk/components/compact-switcher.tsx", + "src/sdk/components/session-graph-panel.tsx", # Workflow runtime I/O (subprocess/SDK/filesystem dependent) "src/services/workflows/session.ts", "src/services/workflows/graph/nodes.ts", From 239443be466558aefb064d4e03ba8218f1ec77e2 Mon Sep 17 00:00:00 2001 From: flora131 Date: Sun, 12 Apr 2026 16:02:57 -0700 Subject: [PATCH 08/11] refactor(orchestrator): remove Tab/Shift+Tab cycling from graph panel Tab attach-to-first-subagent shortcut is no longer needed now that Ctrl+G and arrow-key navigation cover graph traversal. Update the getSubagents() docstring to reflect its remaining usage (tmux status bar) and drop the corresponding tests. Assistant-model: Claude Code --- .../components/orchestrator-panel-store.ts | 2 +- src/sdk/components/session-graph-panel.tsx | 7 ------ .../components/session-graph-panel.test.tsx | 25 ------------------- 3 files changed, 1 insertion(+), 33 deletions(-) diff --git a/src/sdk/components/orchestrator-panel-store.ts b/src/sdk/components/orchestrator-panel-store.ts index f8eae8b80..50957b900 100644 --- a/src/sdk/components/orchestrator-panel-store.ts +++ b/src/sdk/components/orchestrator-panel-store.ts @@ -126,7 +126,7 @@ export class PanelStore { /** * Return non-orchestrator agents that have started (not pending). - * These are the agents Tab/Shift+Tab cycles through. + * Used for the tmux status bar agent count and active-agent index. */ getSubagents(): SessionData[] { return this.sessions.filter( diff --git a/src/sdk/components/session-graph-panel.tsx b/src/sdk/components/session-graph-panel.tsx index 91962390b..cd514b5e0 100644 --- a/src/sdk/components/session-graph-panel.tsx +++ b/src/sdk/components/session-graph-panel.tsx @@ -273,13 +273,6 @@ export function SessionGraphPanel() { navigate("down"); return; } - if (key.name === "tab") { - // Tab: attach to first available subagent - const subs = store.getSubagents(); - if (subs.length > 0) doAttach(subs[0]!.name); - return; - } - // Enter: attach to focused node's tmux window if (key.name === "return") { doAttach(focusedIdRef.current); diff --git a/tests/sdk/components/session-graph-panel.test.tsx b/tests/sdk/components/session-graph-panel.test.tsx index af9c676f3..fbc475ea5 100644 --- a/tests/sdk/components/session-graph-panel.test.tsx +++ b/tests/sdk/components/session-graph-panel.test.tsx @@ -168,31 +168,6 @@ describe("SessionGraphPanel", () => { expect(frame).toContain("orchestrator"); }); - test("tab moves focus right", async () => { - const store = createPopulatedStore(); - const setup = await renderPanel(store); - - setup.mockInput.pressTab(); - await setup.renderOnce(); - - const frame = setup.captureCharFrame(); - expect(frame).toContain("worker-1"); - }); - - test("shift+tab moves focus left", async () => { - const store = createPopulatedStore(); - const setup = await renderPanel(store); - - // First move right, then shift-tab back - setup.mockInput.pressTab(); - await setup.renderOnce(); - setup.mockInput.pressTab({ shift: true }); - await setup.renderOnce(); - - const frame = setup.captureCharFrame(); - expect(frame).toContain("orchestrator"); - }); - test("G (shift+g) moves to deepest node", async () => { const store = createPopulatedStore(); const setup = await renderPanel(store); From ab117fb0fa299b2a59c82fde5beb78864be8d337 Mon Sep 17 00:00:00 2001 From: flora131 Date: Sun, 12 Apr 2026 16:29:40 -0700 Subject: [PATCH 09/11] refactor(orchestrator): remove number-key direct jump from agent switcher Number keys 1-9 conflicted with graph panel input handling; navigation is handled by the compact switcher UI instead. Assistant-model: Claude Code --- src/sdk/components/session-graph-panel.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/sdk/components/session-graph-panel.tsx b/src/sdk/components/session-graph-panel.tsx index cd514b5e0..87838f512 100644 --- a/src/sdk/components/session-graph-panel.tsx +++ b/src/sdk/components/session-graph-panel.tsx @@ -227,13 +227,6 @@ export function SessionGraphPanel() { if (agent) doAttach(agent.name); return; } - // Number keys 1-9 for direct jump - const num = parseInt(key.sequence ?? "", 10); - if (num >= 1 && num <= store.sessions.length) { - closeSwitcher(); - doAttach(store.sessions[num - 1]!.name); - return; - } return; // Swallow all other keys while switcher is open } From 200cd3c44bb44f1e8ce62c30cc67344e28391e1c Mon Sep 17 00:00:00 2001 From: flora131 Date: Sun, 12 Apr 2026 17:08:25 -0700 Subject: [PATCH 10/11] refactor(tmux): centralize status-bar defaults and escape format strings Extract TMUX_DEFAULT_STATUS_* constants into tmux.ts so the restore logic in session-graph-panel stays in sync with tmux.conf without duplicating magic strings. Add escapeTmuxFormat() to prevent agent IDs containing '#' from being interpreted as tmux style/command directives. Assistant-model: Claude Code --- src/sdk/components/session-graph-panel.tsx | 31 +++++++++++++--------- src/sdk/runtime/tmux.conf | 2 ++ src/sdk/runtime/tmux.ts | 23 ++++++++++++++++ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/sdk/components/session-graph-panel.tsx b/src/sdk/components/session-graph-panel.tsx index 87838f512..fe33b8b51 100644 --- a/src/sdk/components/session-graph-panel.tsx +++ b/src/sdk/components/session-graph-panel.tsx @@ -18,7 +18,14 @@ import { useRef, useContext, } from "react"; -import { tmuxRun } from "../runtime/tmux.ts"; +import { + tmuxRun, + escapeTmuxFormat, + TMUX_DEFAULT_STATUS_LEFT, + TMUX_DEFAULT_STATUS_LEFT_LENGTH, + TMUX_DEFAULT_STATUS_RIGHT, + TMUX_DEFAULT_STATUS_RIGHT_LENGTH, +} from "../runtime/tmux.ts"; import { useStore, useGraphTheme, @@ -380,8 +387,8 @@ export function SessionGraphPanel() { useEffect(() => { if (store.viewMode === "attached" && store.activeAgentId) { - const name = store.activeAgentId; - const left = `#[bg=#6c7086,fg=#1e1e2e,bold] ATTACHED #[default] #[fg=#7f849c]\u203a #[fg=#cdd6f4]${name} #[fg=#7f849c]${activeAgentIdx + 1}/${subagentCount}`; + const safeName = escapeTmuxFormat(store.activeAgentId); + const left = `#[bg=#6c7086,fg=#1e1e2e,bold] ATTACHED #[default] #[fg=#7f849c]\u203a #[fg=#cdd6f4]${safeName} #[fg=#7f849c]${activeAgentIdx + 1}/${subagentCount}`; const right = `#[fg=#7f849c]Graph: #[fg=#cdd6f4]ctrl+g #[fg=#7f849c]| Next: #[fg=#cdd6f4]ctrl+\\ `; tmuxRun(["set", "-g", "status-left", left]); @@ -389,21 +396,21 @@ export function SessionGraphPanel() { tmuxRun(["set", "-g", "status-right", right]); tmuxRun(["set", "-g", "status-right-length", "40"]); } else { - // Graph mode: restore defaults from tmux.conf - tmuxRun(["set", "-g", "status-left", " "]); - tmuxRun(["set", "-g", "status-left-length", "10"]); - tmuxRun(["set", "-g", "status-right", " #{session_name} | %H:%M "]); - tmuxRun(["set", "-g", "status-right-length", "60"]); + // Graph mode: restore defaults (constants from tmux.ts match tmux.conf) + tmuxRun(["set", "-g", "status-left", TMUX_DEFAULT_STATUS_LEFT]); + tmuxRun(["set", "-g", "status-left-length", TMUX_DEFAULT_STATUS_LEFT_LENGTH]); + tmuxRun(["set", "-g", "status-right", TMUX_DEFAULT_STATUS_RIGHT]); + tmuxRun(["set", "-g", "status-right-length", TMUX_DEFAULT_STATUS_RIGHT_LENGTH]); } }, [store.viewMode, store.activeAgentId, activeAgentIdx, subagentCount]); // Restore default tmux status bar on unmount useEffect(() => { return () => { - tmuxRun(["set", "-g", "status-left", " "]); - tmuxRun(["set", "-g", "status-left-length", "10"]); - tmuxRun(["set", "-g", "status-right", " #{session_name} | %H:%M "]); - tmuxRun(["set", "-g", "status-right-length", "60"]); + tmuxRun(["set", "-g", "status-left", TMUX_DEFAULT_STATUS_LEFT]); + tmuxRun(["set", "-g", "status-left-length", TMUX_DEFAULT_STATUS_LEFT_LENGTH]); + tmuxRun(["set", "-g", "status-right", TMUX_DEFAULT_STATUS_RIGHT]); + tmuxRun(["set", "-g", "status-right-length", TMUX_DEFAULT_STATUS_RIGHT_LENGTH]); }; }, []); diff --git a/src/sdk/runtime/tmux.conf b/src/sdk/runtime/tmux.conf index 5d79256d2..0e271629b 100644 --- a/src/sdk/runtime/tmux.conf +++ b/src/sdk/runtime/tmux.conf @@ -21,6 +21,8 @@ set -g focus-events on setw -g aggressive-resize on # Status bar — minimal +# These defaults are mirrored by TMUX_DEFAULT_STATUS_* constants in tmux.ts. +# Keep both in sync when changing. set -g status-left " " set -g status-right " #{session_name} | %H:%M " set -g status-right-length 60 diff --git a/src/sdk/runtime/tmux.ts b/src/sdk/runtime/tmux.ts index a3f226015..e62b746c3 100644 --- a/src/sdk/runtime/tmux.ts +++ b/src/sdk/runtime/tmux.ts @@ -23,6 +23,29 @@ const CONFIG_PATH = join(import.meta.dir, "tmux.conf"); // Core tmux primitives // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Default status-bar values — must match tmux.conf. +// Centralised here so restore logic in session-graph-panel stays in sync. +// --------------------------------------------------------------------------- + +export const TMUX_DEFAULT_STATUS_LEFT = " "; +export const TMUX_DEFAULT_STATUS_LEFT_LENGTH = "10"; +export const TMUX_DEFAULT_STATUS_RIGHT = " #{session_name} | %H:%M "; +export const TMUX_DEFAULT_STATUS_RIGHT_LENGTH = "60"; + +/** + * Escape a string for safe interpolation into tmux format strings. + * Replaces `#` with `##` to prevent tmux from interpreting `#[...]` + * as style directives or `#(...)` as shell command expansions. + */ +export function escapeTmuxFormat(value: string): string { + return value.replace(/#/g, "##"); +} + +// --------------------------------------------------------------------------- +// Core tmux primitives +// --------------------------------------------------------------------------- + /** Cached resolved multiplexer binary path. Resolved once on first use. */ let resolvedMuxBinary: string | null | undefined; // undefined = not yet resolved From 953da955d474ae00fe83f83ebf7c7649801e7f03 Mon Sep 17 00:00:00 2001 From: flora131 Date: Sun, 12 Apr 2026 17:08:45 -0700 Subject: [PATCH 11/11] docs(tmux): document prefix-free navigation bindings and SIGQUIT override Clarify that Ctrl+G / Ctrl+\ bindings use the root table and only apply inside Atomic's isolated tmux server. Note that Ctrl+\ replaces the default SIGQUIT signal and document the workaround. Assistant-model: Claude Code --- src/sdk/runtime/tmux.conf | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/sdk/runtime/tmux.conf b/src/sdk/runtime/tmux.conf index 0e271629b..441ff051d 100644 --- a/src/sdk/runtime/tmux.conf +++ b/src/sdk/runtime/tmux.conf @@ -46,6 +46,15 @@ bind-key -T copy-mode-vi C-v send-keys -X rectangle-toggle bind-key -T copy-mode-vi y send-keys -X copy-selection-and-cancel # ── Atomic workflow navigation (prefix-free) ────────────── +# These bindings use `bind -n` (root table) so they work without a prefix key. +# They only apply inside Atomic's isolated tmux server (-L atomic) and do NOT +# affect the user's regular tmux or shell sessions. +# +# NOTE: Ctrl+\ overrides the default SIGQUIT signal. Agent CLIs running in +# these panes will not receive SIGQUIT via Ctrl+\. Use `kill -QUIT ` if +# needed. The integrated agents (Claude Code, OpenCode, Copilot CLI) use +# Ctrl+C for interrupts and are not affected. + # Ctrl+G: jump straight back to the graph (window 0) from any agent window bind -n C-g select-window -t :0