Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions apps/tui/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -881,6 +888,9 @@ function App() {
send({ type: "focus-session", name: session.name });
switchToSession(session.name);
}}
onSwitchWindow={(windowIndex) => {
switchToWindow(session.name, windowIndex);
}}
/>
)}
</For>
Expand Down Expand Up @@ -1514,6 +1524,7 @@ interface SessionCardProps {
theme: Accessor<Theme>;
statusColors: Accessor<Theme["status"]>;
onSelect: () => void;
onSwitchWindow?: (windowIndex: number) => void;
}

function SessionCard(props: SessionCardProps) {
Expand Down Expand Up @@ -1685,6 +1696,50 @@ function SessionCard(props: SessionCardProps) {
<span style={{ fg: toneColor(metaTone(), P()), attributes: DIM }}>{metaSummary()}</span>
</text>
</Show>

{/* Row 4: window/tab list (when session has >1 window) */}
<Show when={(props.session.windowList?.length ?? 0) > 1}>
<For each={props.session.windowList}>
{(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 (
<box flexDirection="row">
<text truncate wrapMode="none" flexGrow={1}
onMouseDown={() => {
props.onSwitchWindow?.(win.index);
}}
fg={win.active ? P().green : P().overlay0}>
<span style={{ fg: win.active ? P().green : P().overlay0 }}>
{` ${win.active ? "▸" : " "} ${win.index}:${win.name}`}
</span>
</text>
<Show when={winAgentIcon()}>
<text flexShrink={0}>
<span style={{ fg: winAgentColor() }}>{" "}{winAgentIcon()}</span>
</text>
</Show>
</box>
);
}}
</For>
</Show>
</box>
</box>

Expand Down
1 change: 1 addition & 0 deletions packages/mux/contract/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// ─── Types ───────────────────────────────────────────────────────────────────
export type {
MuxSpecificationVersion,
MuxWindowInfo,
MuxSessionInfo,
ActiveWindow,
SidebarPane,
Expand Down
9 changes: 9 additions & 0 deletions packages/mux/contract/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions packages/mux/providers/tmux/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, typeof allWindows>();
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,
})),
}));
}

Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/src/contracts/index.ts
Original file line number Diff line number Diff line change
@@ -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";
1 change: 1 addition & 0 deletions packages/runtime/src/contracts/mux.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type {
MuxSpecificationVersion,
MuxWindowInfo,
MuxSessionInfo,
ActiveWindow,
SidebarPane,
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type {
MuxProvider,
MuxProviderV1,
MuxWindowInfo,
MuxSessionInfo,
ActiveWindow,
SidebarPane,
Expand Down Expand Up @@ -48,6 +49,7 @@ export {
} from "./shared";
export type {
SessionData,
WindowData,
ServerState,
FocusUpdate,
ResizeNotify,
Expand Down
70 changes: 69 additions & 1 deletion packages/runtime/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { agent: string; status: import("../contracts/agent").AgentStatus }>();
try {
const raw = shell(["tmux", "list-panes", "-a", "-F", "#{session_name}|#{pane_id}|#{pane_pid}|#{window_id}|#{pane_title}"]);
const sidebarPaneIds = new Set<string>();
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);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1055,6 +1100,11 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa
opencode: ["opencode"],
};

const STATUS_PRIORITY: Record<string, number> = {
"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<string, ReturnType<typeof setTimeout>>();
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion packages/runtime/src/shared.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -27,6 +32,7 @@ export interface SessionData {
ports: number[];
localLinks: LocalLink[];
windows: number;
windowList: WindowData[];
uptime: string;
agentState: AgentEvent | null;
agents: AgentEvent[];
Expand Down Expand Up @@ -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 }
Expand Down