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", diff --git a/research/designs/option3-refined-prototype.html b/research/designs/option3-refined-prototype.html new file mode 100644 index 000000000..a1e7e631a --- /dev/null +++ b/research/designs/option3-refined-prototype.html @@ -0,0 +1,682 @@ + + + + + +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 + 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

+
TabNext agent (wraps to graph)
+
Shift+TabPrevious agent
+
gBack to graph
+
/Open agent list
+
qQuit
+
+
+

Agent list (/)

+
↑ ↓Select agent
+
EnterJump to agent
+
+
+
+
+ + + + 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/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..50957b900 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). + * Used for the tmux status bar agent count and active-agent index. + */ + 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[]; diff --git a/src/sdk/components/session-graph-panel.tsx b/src/sdk/components/session-graph-panel.tsx index 9645cdd5d..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, @@ -34,6 +41,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 +85,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 +135,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 +209,54 @@ 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; + } + 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; @@ -200,11 +273,6 @@ export function SessionGraphPanel() { navigate("down"); return; } - if (key.name === "tab") { - navigate(key.shift ? "left" : "right"); - return; - } - // Enter: attach to focused node's tmux window if (key.name === "return") { doAttach(focusedIdRef.current); @@ -289,6 +357,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 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]); + 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 (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", 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]); + }; + }, []); + return (
@@ -349,6 +474,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/src/sdk/runtime/tmux.conf b/src/sdk/runtime/tmux.conf index edc13a98f..441ff051d 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 @@ -43,6 +45,22 @@ 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) ────────────── +# 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 + +# 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..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 @@ -170,6 +193,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]!; } 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); 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 () => {