// Session/window/pane tree styled after the VSCode "tmux manager" view. // Each session is a single row: status dot, name, inline tag badges, and a // right-aligned meta label (relative time · cpu · mem) that is overlaid by // the hover actions (attach, new window, …-menu). The overlay uses a grid // stack with opacity so rows never shift and the actions stay reachable via // keyboard focus. import { useState } from "react"; import { useTranslation } from "@termix/plugin-sdk/frontend"; import { AppWindow, BarChart2, ChevronDown, ChevronRight, MoreHorizontal, Pencil, Play, Plus, SquareSplitHorizontal, SquareSplitVertical, SquareTerminal, Tag, Trash2, X, } from "lucide-react"; import { Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Popover, PopoverContent, PopoverTrigger, } from "@termix/plugin-sdk/ui"; import type { TmuxPaneMetrics, TmuxSessionOverview } from "./api"; import { formatMem, formatRelativeTime } from "./format"; import type { SelectedPane } from "./types"; export interface SessionMetricsAgg { cpu: number; memKb: number; gpuMb: number; } const MAX_INLINE_TAGS = 2; interface SessionTreeProps { sessions: TmuxSessionOverview[]; expandedSessions: Set; onToggleSession: (name: string) => void; selectedPaneId: string | null; onSelectPane: (pane: SelectedPane) => void; metricsByPane: Map; metricsBySession: Map; /** Open the tag editor for a session (… menu). */ onEditTags: (sessionName: string) => void; /** Open a terminal attached to the session (the ▷ hover action). */ onAttachSession: (sessionName: string) => void; onSelectSession?: (sessionName: string) => void; /** Create a new window in the session (the + hover action). */ onNewWindow: (sessionName: string) => void; /** Open the rename dialog for a session (… menu). */ onRenameSession: (sessionName: string) => void; /** Open the kill confirmation for a session (… menu). */ onKillSession: (sessionName: string) => void; /** Open the kill confirmation for a single pane (hover ✕ on pane rows). */ onKillPane: (paneId: string) => void; /** Split the window containing a pane (hover actions on pane rows). */ onSplitPane: (paneId: string, direction: "h" | "v") => void; /** Open the kill confirmation for a window (hover ✕ on window rows). */ onKillWindow: (sessionName: string, windowIndex: number) => void; /** Narrow tree panel: the inline time/cpu/mem label is hidden (the hover * tooltip still carries the full status). */ compact: boolean; /** Timestamp used to render relative times; bumped periodically by the * parent so "Xm ago" labels do not go stale. */ now: number; } export function SessionTree({ sessions, expandedSessions, onToggleSession, selectedPaneId, onSelectPane, metricsByPane, metricsBySession, onEditTags, onAttachSession, onSelectSession, onNewWindow, onRenameSession, onKillSession, onKillPane, onSplitPane, onKillWindow, compact, now, }: SessionTreeProps) { const { t } = useTranslation(); // Collapsed windows, keyed "session windowIndex". Windows default to // expanded (panes visible) like the VSCode tmux manager. const [collapsedWindows, setCollapsedWindows] = useState>( new Set(), ); function windowKey(sessionName: string, windowIndex: number): string { return `${sessionName} ${windowIndex}`; } function toggleWindow(sessionName: string, windowIndex: number) { setCollapsedWindows((prev) => { const next = new Set(prev); const key = windowKey(sessionName, windowIndex); if (next.has(key)) next.delete(key); else next.add(key); return next; }); } function metaLabel(session: TmuxSessionOverview): string { const time = formatRelativeTime(session.lastActivity, now, t); const agg = metricsBySession.get(session.name); if (!agg) return time; return `${time} · ${agg.cpu.toFixed(0)}% · ${formatMem(agg.memKb)}`; } return ( <> {sessions.map((session) => { const expanded = expandedSessions.has(session.name); const agg = metricsBySession.get(session.name); const paneCount = session.windows.reduce( (sum, w) => sum + w.panes.length, 0, ); return (
onSelectSession ? onSelectSession(session.name) : onToggleSession(session.name) } > 0 ? "bg-accent-brand" : "bg-muted-foreground/40"}`} title={ session.attachedClients > 0 ? t("tmuxMonitor.attached") : t("tmuxMonitor.detached") } /> {session.name} {session.tags.slice(0, MAX_INLINE_TAGS).map((tag) => ( {tag} ))} {session.tags.length > MAX_INLINE_TAGS && ( +{session.tags.length - MAX_INLINE_TAGS} )} {/* Meta label and hover actions stacked in one grid cell */} {!compact && ( {metaLabel(session)} )} e.stopPropagation()} >
0 ? "bg-accent-brand" : "bg-muted-foreground/40"}`} /> {session.name} {session.attachedClients > 0 ? t("tmuxMonitor.attached") : t("tmuxMonitor.detached")}
{t("tmuxMonitor.statusActivity")}
{formatRelativeTime(session.lastActivity, now, t)}
{t("tmuxMonitor.statusWindows")}
{session.windows.length} / {paneCount}{" "} {t("tmuxMonitor.statusPanes")}
CPU
{agg ? `${agg.cpu.toFixed(1)}%` : "—"}
RAM
{agg ? formatMem(agg.memKb) : "—"}
{agg && agg.gpuMb > 0 && ( <>
GPU
{agg.gpuMb} MB
)} {session.tags.length > 0 && ( <>
{t("tmuxMonitor.statusTags")}
{session.tags.join(", ")}
)}
e.stopPropagation()} > setTimeout(() => onRenameSession(session.name), 0) } > {t("tmuxMonitor.rename")} setTimeout(() => onEditTags(session.name), 0) } > {t("tmuxMonitor.editTags")} setTimeout(() => onKillSession(session.name), 0) } > {t("tmuxMonitor.kill")}
{expanded && session.windows.map((win) => { const winCollapsed = collapsedWindows.has( windowKey(session.name, win.index), ); return (
toggleWindow(session.name, win.index)} > {winCollapsed ? ( ) : ( )} {win.index}: {win.name}
{!winCollapsed && win.panes.map((pane) => { const m = metricsByPane.get(pane.id); const isSelected = selectedPaneId === pane.id; return (
onSelectPane({ paneId: pane.id, sessionName: session.name, windowIndex: win.index, }) } > {pane.index}: {pane.command} {pane.path} {/* Same overlay stack as the session rows: cpu label and hover actions share one grid cell so the buttons never widen the row (they would get clipped on a narrow panel). */} {m && m.cpuPercent > 0 && ( {m.cpuPercent.toFixed(0)}% )}
); })}
); })}
); })} ); }