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
+
+
+
+
+
+ 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 () => {