Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
40 changes: 39 additions & 1 deletion packages/runtime/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,18 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa
}
}

const sessions: SessionData[] = orderedMuxSessions.map(({ name, createdAt, windows, dir, provider }) => {
// Build paneId -> windowId map for agent-to-window association
const paneToWindow = new Map<string, string>();
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);
const providerPaneCounts = paneCountMaps.get(provider);
Expand Down Expand Up @@ -554,6 +565,15 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa
ports: getSessionPorts(name),
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);
return {
...w,
agentStatus: windowAgent?.status,
agentName: windowAgent?.agent,
};
}),
uptime,
agentState: tracker.getState(name),
agents: tracker.getAgents(name),
Expand Down Expand Up @@ -1455,6 +1475,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