diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index 5c549a2..3810c95 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,50 @@ function SessionCard(props: SessionCardProps) { {metaSummary()} + + {/* Row 4: window/tab list (when session has >1 window) */} + 1}> + + {(win) => { + const winAgentIcon = () => { + const s = (win as any).agentStatus; + 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 "✓"; + 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) return ""; + if (s === "idle") return P().surface2; + 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/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..b3041a0 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, @@ -48,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 4e818e3..ed31101 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -525,7 +525,44 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa } } - const sessions: SessionData[] = orderedMuxSessions.map(({ name, createdAt, windows, dir, provider }) => { + // Build windowId -> agent mapping by scanning pane process trees directly + const windowAgentMap = new Map(); + try { + 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 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 {} + + 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 +591,14 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa ports: getSessionPorts(name), localLinks: buildLocalLinks(getSessionPorts(name), portlessState), windows, + windowList: (windowList ?? []).map((w) => { + const winAgent = windowAgentMap.get(w.id); + return { + ...w, + agentStatus: winAgent?.status, + agentName: winAgent?.agent, + }; + }), uptime, agentState: tracker.getState(name), agents: tracker.getAgents(name), @@ -1055,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>(); @@ -1455,6 +1505,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..2d28519 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; @@ -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,6 +32,7 @@ export interface SessionData { ports: number[]; localLinks: LocalLink[]; windows: number; + windowList: WindowData[]; uptime: string; agentState: AgentEvent | null; agents: AgentEvent[]; @@ -107,6 +113,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 }