From 75ff2a54d5caf8839e3044228f3ec4466573cb36 Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Mon, 13 Apr 2026 15:56:09 +0800 Subject: [PATCH 1/4] feat: expose per-session window list in sidebar Add window/tab enumeration to the session data model so the sidebar shows individual windows when a session has more than one tab. Changes across all layers: - Contract: add MuxWindowInfo type, optional windowList on MuxSessionInfo - TmuxProvider: batch-fetch all windows and group by session - Runtime: pipe windowList through SessionData and computeState - Server: handle new switch-window client command - TUI: render clickable window rows under sessions with multiple tabs --- apps/tui/src/index.tsx | 28 +++++++++++++++++++++ packages/mux/contract/src/index.ts | 1 + packages/mux/contract/src/types.ts | 9 +++++++ packages/mux/providers/tmux/src/provider.ts | 15 +++++++++++ packages/runtime/src/contracts/index.ts | 2 +- packages/runtime/src/contracts/mux.ts | 1 + packages/runtime/src/index.ts | 1 + packages/runtime/src/server/index.ts | 21 +++++++++++++++- packages/runtime/src/shared.ts | 4 ++- 9 files changed, 79 insertions(+), 3 deletions(-) diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index 5c549a2..4dce639 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -313,6 +313,13 @@ function App() { send({ type: "switch-session", name }); } + function switchToWindow(sessionName: string, windowIndex: number) { + setCurrentSession(sessionName); + setFocusedSession(sessionName); + setPanelFocus("sessions"); + send({ type: "switch-window", sessionName, windowIndex }); + } + function reIdentify() { const sessionName = getLocalSessionName(); if (!sessionName) return; @@ -881,6 +888,9 @@ function App() { send({ type: "focus-session", name: session.name }); switchToSession(session.name); }} + onSwitchWindow={(windowIndex) => { + switchToWindow(session.name, windowIndex); + }} /> )} @@ -1514,6 +1524,7 @@ interface SessionCardProps { theme: Accessor; statusColors: Accessor; onSelect: () => void; + onSwitchWindow?: (windowIndex: number) => void; } function SessionCard(props: SessionCardProps) { @@ -1685,6 +1696,23 @@ function SessionCard(props: SessionCardProps) { {metaSummary()} + + {/* Row 4: window/tab list (when session has >1 window) */} + 1}> + + {(win) => ( + { + props.onSwitchWindow?.(win.index); + }} + fg={win.active ? P().green : P().overlay0}> + + {` ${win.active ? "▸" : " "} ${win.index}:${win.name}`} + + + )} + + diff --git a/packages/mux/contract/src/index.ts b/packages/mux/contract/src/index.ts index 8080b63..b8ef8af 100644 --- a/packages/mux/contract/src/index.ts +++ b/packages/mux/contract/src/index.ts @@ -1,6 +1,7 @@ // ─── Types ─────────────────────────────────────────────────────────────────── export type { MuxSpecificationVersion, + MuxWindowInfo, MuxSessionInfo, ActiveWindow, SidebarPane, diff --git a/packages/mux/contract/src/types.ts b/packages/mux/contract/src/types.ts index 0a0e1fa..d85d66b 100644 --- a/packages/mux/contract/src/types.ts +++ b/packages/mux/contract/src/types.ts @@ -5,11 +5,20 @@ export type MuxSpecificationVersion = "v1"; // ─── Core data types ───────────────────────────────────────────────────────── +export interface MuxWindowInfo { + readonly id: string; + readonly index: number; + readonly name: string; + readonly active: boolean; + readonly paneCount: number; +} + export interface MuxSessionInfo { readonly name: string; readonly createdAt: number; readonly dir: string; readonly windows: number; + readonly windowList?: readonly MuxWindowInfo[]; } export interface ActiveWindow { diff --git a/packages/mux/providers/tmux/src/provider.ts b/packages/mux/providers/tmux/src/provider.ts index f39d5b3..bea1c68 100644 --- a/packages/mux/providers/tmux/src/provider.ts +++ b/packages/mux/providers/tmux/src/provider.ts @@ -48,11 +48,26 @@ export class TmuxProvider implements MuxProviderV1, WindowCapable, SidebarCapabl const sessions = tmux.listSessions() .filter((s) => s.name !== STASH_SESSION); const activeDirs = tmux.getActiveSessionDirs(); + const allWindows = tmux.listWindows(); + const windowsBySession = new Map(); + for (const w of allWindows) { + if (w.sessionName === STASH_SESSION) continue; + let list = windowsBySession.get(w.sessionName); + if (!list) { list = []; windowsBySession.set(w.sessionName, list); } + list.push(w); + } return sessions.map((s) => ({ name: s.name, createdAt: s.createdAt, dir: activeDirs.get(s.name) ?? s.dir, windows: s.windowCount, + windowList: (windowsBySession.get(s.name) ?? []).map((w) => ({ + id: w.id, + index: w.index, + name: w.name, + active: w.active, + paneCount: w.paneCount, + })), })); } diff --git a/packages/runtime/src/contracts/index.ts b/packages/runtime/src/contracts/index.ts index db61f32..9f4651e 100644 --- a/packages/runtime/src/contracts/index.ts +++ b/packages/runtime/src/contracts/index.ts @@ -1,3 +1,3 @@ export type { AgentStatus, AgentEvent } from "./agent"; export { TERMINAL_STATUSES } from "./agent"; -export type { MuxProvider, MuxSessionInfo } from "./mux"; +export type { MuxProvider, MuxWindowInfo, MuxSessionInfo } from "./mux"; diff --git a/packages/runtime/src/contracts/mux.ts b/packages/runtime/src/contracts/mux.ts index 59252f1..db0448c 100644 --- a/packages/runtime/src/contracts/mux.ts +++ b/packages/runtime/src/contracts/mux.ts @@ -1,5 +1,6 @@ export type { MuxSpecificationVersion, + MuxWindowInfo, MuxSessionInfo, ActiveWindow, SidebarPane, diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 1709453..8fee1d2 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,6 +1,7 @@ export type { MuxProvider, MuxProviderV1, + MuxWindowInfo, MuxSessionInfo, ActiveWindow, SidebarPane, diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index 4e818e3..96590e2 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -525,7 +525,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa } } - const sessions: SessionData[] = orderedMuxSessions.map(({ name, createdAt, windows, dir, provider }) => { + const sessions: SessionData[] = orderedMuxSessions.map(({ name, createdAt, windows, windowList, dir, provider }) => { sessionProviders.set(name, provider); const git = getGitInfo(dir); const providerPaneCounts = paneCountMaps.get(provider); @@ -554,6 +554,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa ports: getSessionPorts(name), localLinks: buildLocalLinks(getSessionPorts(name), portlessState), windows, + windowList: (windowList ?? []) as import("../shared").SessionData["windowList"], uptime, agentState: tracker.getState(name), agents: tracker.getAgents(name), @@ -1455,6 +1456,24 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa } break; } + case "switch-window": { + const clientSess = clientSessionNames.get(ws); + const tty = (clientSess ? clientTtyBySession.get(clientSess) : undefined) + ?? cmd.clientTty ?? clientTtys.get(ws); + const p = sessionProviders.get(cmd.sessionName) ?? mux; + p.switchSession(cmd.sessionName, tty); + // Select the target window by index + try { + Bun.spawnSync(["tmux", "select-window", "-t", `${cmd.sessionName}:${cmd.windowIndex}`], { + stdout: "pipe", stderr: "pipe", + }); + } catch {} + focusedSession = cmd.sessionName; + cachedCurrentSession = cmd.sessionName; + cachedCurrentSessionTs = Date.now(); + broadcastFocusOnly(); + break; + } case "switch-index": { const clientSess = clientSessionNames.get(ws); const tty = (clientSess ? clientTtyBySession.get(clientSess) : undefined) diff --git a/packages/runtime/src/shared.ts b/packages/runtime/src/shared.ts index 65a40fb..27dcf55 100644 --- a/packages/runtime/src/shared.ts +++ b/packages/runtime/src/shared.ts @@ -1,5 +1,5 @@ import type { AgentStatus, AgentEvent } from "./contracts/agent"; -import type { MuxSessionInfo } from "./contracts/mux"; +import type { MuxSessionInfo, MuxWindowInfo } from "./contracts/mux"; import type { SessionFilterMode } from "./config"; export const SERVER_PORT = 7391; @@ -27,6 +27,7 @@ export interface SessionData { ports: number[]; localLinks: LocalLink[]; windows: number; + windowList: MuxWindowInfo[]; uptime: string; agentState: AgentEvent | null; agents: AgentEvent[]; @@ -107,6 +108,7 @@ export interface SessionMetadata { export type ClientCommand = | { type: "switch-session"; name: string; clientTty?: string } + | { type: "switch-window"; sessionName: string; windowIndex: number; clientTty?: string } | { type: "switch-index"; index: number } | { type: "new-session" } | { type: "hide-session"; name: string } From eb4fbda8804e3873153411c68498e249dc6b384f Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Mon, 13 Apr 2026 16:51:02 +0800 Subject: [PATCH 2/4] feat: show per-window agent status icons in sidebar Map agent paneId to windowId so each window row displays the agent status icon (spinner, checkmark, error, etc.) for agents running in that specific window. Makes it easy to identify which tab has an active agent at a glance. - Build paneId->windowId map in computeState via tmux list-panes - Enrich WindowData with agentStatus and agentName fields - Render agent status icons on window rows in the TUI --- apps/tui/src/index.tsx | 47 +++++++++++++++++++++------- packages/runtime/src/index.ts | 1 + packages/runtime/src/server/index.ts | 21 ++++++++++++- packages/runtime/src/shared.ts | 7 ++++- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index 4dce639..bf7a5ab 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -1700,17 +1700,42 @@ function SessionCard(props: SessionCardProps) { {/* Row 4: window/tab list (when session has >1 window) */} 1}> - {(win) => ( - { - props.onSwitchWindow?.(win.index); - }} - fg={win.active ? P().green : P().overlay0}> - - {` ${win.active ? "▸" : " "} ${win.index}:${win.name}`} - - - )} + {(win) => { + const winAgentIcon = () => { + const s = (win as any).agentStatus; + if (!s || s === "idle") return ""; + if (s === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!; + if (s === "tool-running") return "⚙"; + if (s === "done") return "✓"; + if (s === "error") return "✗"; + if (s === "waiting") return "◉"; + if (s === "interrupted" || s === "stale") return "⚠"; + return ""; + }; + const winAgentColor = () => { + const s = (win as any).agentStatus; + if (!s || s === "idle") return ""; + return SC()[s] ?? P().overlay0; + }; + return ( + + { + props.onSwitchWindow?.(win.index); + }} + fg={win.active ? P().green : P().overlay0}> + + {` ${win.active ? "▸" : " "} ${win.index}:${win.name}`} + + + + + {" "}{winAgentIcon()} + + + + ); + }} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 8fee1d2..b3041a0 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -49,6 +49,7 @@ export { } from "./shared"; export type { SessionData, + WindowData, ServerState, FocusUpdate, ResizeNotify, diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index 96590e2..7919ff1 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -525,6 +525,17 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa } } + // Build paneId -> windowId map for agent-to-window association + const paneToWindow = new Map(); + try { + const raw = shell(["tmux", "list-panes", "-a", "-F", "#{pane_id}|#{window_id}"]); + for (const line of raw.split("\n")) { + if (!line) continue; + const sep = line.indexOf("|"); + if (sep > 0) paneToWindow.set(line.slice(0, sep), line.slice(sep + 1)); + } + } catch {} + const sessions: SessionData[] = orderedMuxSessions.map(({ name, createdAt, windows, windowList, dir, provider }) => { sessionProviders.set(name, provider); const git = getGitInfo(dir); @@ -554,7 +565,15 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa ports: getSessionPorts(name), localLinks: buildLocalLinks(getSessionPorts(name), portlessState), windows, - windowList: (windowList ?? []) as import("../shared").SessionData["windowList"], + windowList: (windowList ?? []).map((w) => { + const agents = tracker.getAgents(name); + const windowAgent = agents.find((a) => a.paneId && paneToWindow.get(a.paneId) === w.id); + return { + ...w, + agentStatus: windowAgent?.status, + agentName: windowAgent?.agent, + }; + }), uptime, agentState: tracker.getState(name), agents: tracker.getAgents(name), diff --git a/packages/runtime/src/shared.ts b/packages/runtime/src/shared.ts index 27dcf55..2d28519 100644 --- a/packages/runtime/src/shared.ts +++ b/packages/runtime/src/shared.ts @@ -15,6 +15,11 @@ export interface LocalLink { label: string; } +export interface WindowData extends MuxWindowInfo { + agentStatus?: AgentStatus; + agentName?: string; +} + export interface SessionData { name: string; createdAt: number; @@ -27,7 +32,7 @@ export interface SessionData { ports: number[]; localLinks: LocalLink[]; windows: number; - windowList: MuxWindowInfo[]; + windowList: WindowData[]; uptime: string; agentState: AgentEvent | null; agents: AgentEvent[]; From 5b72a9dcebab476073638a7b7d6bec805fa174bc Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Mon, 13 Apr 2026 17:00:28 +0800 Subject: [PATCH 3/4] feat: show idle agent indicator on window rows --- apps/tui/src/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index bf7a5ab..3810c95 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -1703,7 +1703,8 @@ function SessionCard(props: SessionCardProps) { {(win) => { const winAgentIcon = () => { const s = (win as any).agentStatus; - if (!s || s === "idle") return ""; + if (!s) return ""; + if (s === "idle") return "○"; if (s === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!; if (s === "tool-running") return "⚙"; if (s === "done") return "✓"; @@ -1714,7 +1715,8 @@ function SessionCard(props: SessionCardProps) { }; const winAgentColor = () => { const s = (win as any).agentStatus; - if (!s || s === "idle") return ""; + if (!s) return ""; + if (s === "idle") return P().surface2; return SC()[s] ?? P().overlay0; }; return ( From 3224f8a6af75e215e3ba5ae3ce57f5df782c4e6d Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Mon, 13 Apr 2026 17:07:37 +0800 Subject: [PATCH 4/4] fix: scan process tree per-window for multi-agent detection The tracker's applyPanePresence overwrites paneId when multiple panes run the same agent, so only the last pane wins. Instead, build the windowId-to-agent mapping directly in computeState by scanning the process tree per-pane and grouping by window. --- packages/runtime/src/server/index.ts | 48 ++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index 7919ff1..ed31101 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -525,14 +525,40 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa } } - // Build paneId -> windowId map for agent-to-window association - const paneToWindow = new Map(); + // Build windowId -> agent mapping by scanning pane process trees directly + const windowAgentMap = new Map(); try { - const raw = shell(["tmux", "list-panes", "-a", "-F", "#{pane_id}|#{window_id}"]); + const raw = shell(["tmux", "list-panes", "-a", "-F", "#{session_name}|#{pane_id}|#{pane_pid}|#{window_id}|#{pane_title}"]); + const sidebarPaneIds = new Set(); + for (const { panes: sbPanes } of listSidebarPanesByProvider()) { + for (const sb of sbPanes) sidebarPaneIds.add(sb.paneId); + } + const tree = buildProcessTree(); for (const line of raw.split("\n")) { if (!line) continue; - const sep = line.indexOf("|"); - if (sep > 0) paneToWindow.set(line.slice(0, sep), line.slice(sep + 1)); + const i1 = line.indexOf("|"); + const i2 = line.indexOf("|", i1 + 1); + const i3 = line.indexOf("|", i2 + 1); + const i4 = line.indexOf("|", i3 + 1); + const paneSession = line.slice(0, i1); + const paneId = line.slice(i1 + 1, i2); + const panePid = parseInt(line.slice(i2 + 1, i3), 10); + const windowId = line.slice(i3 + 1, i4); + const paneTitle = line.slice(i4 + 1); + if (sidebarPaneIds.has(paneId)) continue; + if (paneTitle === "opensessions-sidebar") continue; + for (const [agentName, patterns] of Object.entries(AGENT_TITLE_PATTERNS)) { + if (!matchProcessTreeFast(panePid, patterns, tree)) continue; + // Get the best status from the tracker for this agent in this session + const agents = tracker.getAgents(paneSession); + const agentEvent = agents.find((a) => a.agent === agentName); + const status = agentEvent?.status ?? "idle"; + const existing = windowAgentMap.get(windowId); + // Keep the most active status if multiple agents in same window + if (!existing || STATUS_PRIORITY[status] > STATUS_PRIORITY[existing.status]) { + windowAgentMap.set(windowId, { agent: agentName, status }); + } + } } } catch {} @@ -566,12 +592,11 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa localLinks: buildLocalLinks(getSessionPorts(name), portlessState), windows, windowList: (windowList ?? []).map((w) => { - const agents = tracker.getAgents(name); - const windowAgent = agents.find((a) => a.paneId && paneToWindow.get(a.paneId) === w.id); + const winAgent = windowAgentMap.get(w.id); return { ...w, - agentStatus: windowAgent?.status, - agentName: windowAgent?.agent, + agentStatus: winAgent?.status, + agentName: winAgent?.agent, }; }), uptime, @@ -1075,6 +1100,11 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa opencode: ["opencode"], }; + const STATUS_PRIORITY: Record = { + "tool-running": 7, running: 6, error: 5, stale: 4, + interrupted: 3, waiting: 2, done: 1, idle: 0, + }; + const PANE_HIGHLIGHT_BORDER = "fg=#fab387,bold"; const PANE_HIGHLIGHT_MS = 300; const pendingHighlightResets = new Map>();