feat: convert tmux monitoring into a plugin (B10)

This commit is contained in:
LukeGus committed 2026-09-23 22:42:56 -05:00
1 parent 79d77678c9
commit 708eedb861
145 files changed
+30308 -4458

No files matched your search

@@ -0,0 +1,65 @@
import { Layers } from "lucide-react";
import {
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import { FakeSwitch, SectionCard, SettingRow } from "@termix/plugin-sdk/ui";
type PluginSettingsForm = Record<string, Record<string, unknown>>;
/**
* The host editor's tmux monitor toggle. Writes into the form's
* pluginSettings for this plugin, which the editor saves through the
* plugin's host settings route after the host itself.
*/
export function HostTmuxMonitorSection({
form,
updateForm,
}: HostEditorSectionProps) {
const { t } = useTranslation();
const settings = ((form?.pluginSettings as PluginSettingsForm | undefined)?.[
"tmux-monitor"
] ?? {}) as Record<string, unknown>;
const enabled = settings.enableTmuxMonitor === true;
const setEnabled = (value: boolean) =>
updateForm((current) => {
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
return {
...current,
pluginSettings: {
...all,
"tmux-monitor": { ...all["tmux-monitor"], enableTmuxMonitor: value },
},
};
});
return (
<SectionCard
title={t("tmuxMonitor.title")}
icon={<Layers className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableTmuxMonitor")}
description={
<>
{t("hosts.enableTmuxMonitorDesc")}{" "}
<a
href="https://docs.termix.site/features/terminal/tmux"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch checked={enabled} onChange={setEnabled} />
</SettingRow>
</div>
</SectionCard>
);
}
@@ -0,0 +1,169 @@
// Interactive view of a tmux pane. Embeds the real SSH terminal (through the
// terminal.view component ssh-terminal registers) and attaches to the
// selected session via the native tmux_attach flow, so the pane is fully
// usable: typing, mouse scrolling and tmux copy-mode all behave exactly like
// a normal terminal tab.
import { useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Activity,
Cpu,
MemoryStick,
RotateCw,
SquareSplitHorizontal,
SquareSplitVertical,
Trash2,
X,
} from "lucide-react";
import { PluginComponent } from "@termix/plugin-sdk/ui";
import type { PluginHostRecord, TabHandle } from "@termix/plugin-sdk/frontend";
import type { TmuxPaneMetrics } from "./api";
import { formatMem } from "./format";
import type { SelectedPane } from "./types";
interface PanePreviewProps {
host: PluginHostRecord;
pane: SelectedPane;
metrics?: TmuxPaneMetrics;
/** Imperative handle of the embedded terminal, used by the parent to nudge
* a refit/redraw after layout-changing tmux actions. */
terminalRef?: React.RefObject<TabHandle | null>;
/** Split the window containing this pane ("h" = new pane to the right,
* "v" = below — tmux -h/-v semantics). */
onSplit: (direction: "h" | "v") => void;
/** Ask for confirmation and kill this pane. */
onKillPane: () => void;
onClose: () => void;
}
function newInstanceId(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
export function PanePreview({
host,
pane,
metrics,
terminalRef,
onSplit,
onKillPane,
onClose,
}: PanePreviewProps) {
const { t } = useTranslation();
// One PTY per mount; the parent remounts this component (via key) when the
// host or session changes. Pane switches within a session go through the
// focus endpoint instead, so the connection is reused.
const instanceIdRef = useRef<string>(newInstanceId());
// Bumping this remounts the embedded terminal with a fresh PTY — the rescue
// hatch when the attached client's rendering gets out of sync.
const [attachNonce, setAttachNonce] = useState(0);
function reattach() {
instanceIdRef.current = newInstanceId();
setAttachNonce((n) => n + 1);
}
// The host's own authType travels in the spread, so a Tailscale host still
// gets the Tailscale-aware PTY path. The SSH port may be non-standard.
const hostRecord = host as unknown as Record<string, unknown>;
const resolvedPort = hostRecord.sshPort ?? hostRecord.port;
const terminalHostConfig = {
...hostRecord,
port: resolvedPort,
sshPort: resolvedPort,
instanceId: instanceIdRef.current,
};
return (
<>
<div className="flex items-center gap-3 border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{pane.sessionName} · {pane.paneId}
</span>
{metrics && (
<>
<span className="flex items-center gap-1">
<Cpu className="size-3" />
{metrics.cpuPercent.toFixed(1)}%
</span>
<span className="flex items-center gap-1">
<MemoryStick className="size-3" />
{formatMem(metrics.memRssKb)}
</span>
{metrics.gpuMemMb > 0 && (
<span className="flex items-center gap-1">
<Activity className="size-3" />
{metrics.gpuMemMb} MB GPU
</span>
)}
{metrics.topCommand && (
<span className="truncate">
{metrics.topCommand} ({metrics.processCount})
</span>
)}
</>
)}
<div className="ml-auto flex items-center gap-2">
<button
className="text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.splitRight")}
aria-label={t("tmuxMonitor.splitRight")}
onClick={() => onSplit("h")}
>
<SquareSplitHorizontal className="size-3.5" />
</button>
<button
className="text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.splitDown")}
aria-label={t("tmuxMonitor.splitDown")}
onClick={() => onSplit("v")}
>
<SquareSplitVertical className="size-3.5" />
</button>
<button
className="text-muted-foreground hover:text-destructive"
title={t("tmuxMonitor.killPane")}
aria-label={t("tmuxMonitor.killPane")}
onClick={onKillPane}
>
<Trash2 className="size-3.5" />
</button>
<div className="h-3.5 border-l border-border" />
<button
className="text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.reattach")}
aria-label={t("tmuxMonitor.reattach")}
onClick={reattach}
>
<RotateCw className="size-3.5" />
</button>
<button
className="text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.closePreview")}
aria-label={t("tmuxMonitor.closePreview")}
onClick={onClose}
>
<X className="size-3.5" />
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<PluginComponent
id="terminal.view"
key={attachNonce}
handleRef={terminalRef}
hostConfig={terminalHostConfig}
isVisible={true}
title={pane.sessionName}
showTitle={false}
splitScreen={false}
tmuxAttachSession={pane.sessionName}
onClose={onClose}
/>
</div>
</>
);
}
@@ -0,0 +1,85 @@
import type { ReactNode } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { X } from "lucide-react";
import type { TmuxSearchMatch, TmuxSearchResult } from "./api";
interface SearchResultsProps {
results: TmuxSearchMatch[];
searching: boolean;
/** The query that produced these results, used to highlight matches. */
query: string;
/** Search-limit info; renders a "partial results" note when truncated. */
limits: Pick<
TmuxSearchResult,
"truncated" | "searchedLines" | "maxPanes"
> | null;
onSelect: (match: TmuxSearchMatch) => void;
onClose: () => void;
}
function highlightMatch(text: string, query: string): ReactNode {
if (!query) return text;
const index = text.toLowerCase().indexOf(query.toLowerCase());
if (index === -1) return text;
return (
<>
{text.slice(0, index)}
<mark className="bg-accent-brand/30 px-0 text-foreground">
{text.slice(index, index + query.length)}
</mark>
{text.slice(index + query.length)}
</>
);
}
export function SearchResults({
results,
searching,
query,
limits,
onSelect,
onClose,
}: SearchResultsProps) {
const { t } = useTranslation();
return (
<div className="max-h-56 overflow-y-auto border-b border-border bg-card">
<div className="flex items-center justify-between gap-2 px-3 py-1.5">
<span className="truncate text-xs text-muted-foreground">
{searching
? t("common.loading")
: t("tmuxMonitor.searchResults", { count: results.length })}
{!searching && limits?.truncated && (
<span className="ml-2 text-muted-foreground/70">
{t("tmuxMonitor.searchTruncated", {
lines: limits.searchedLines,
panes: limits.maxPanes,
})}
</span>
)}
</span>
<button
className="text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.closeSearchResults")}
onClick={onClose}
>
<X className="size-3.5" />
</button>
</div>
{results.map((match, i) => (
<div
key={`${match.paneId}-${match.line}-${i}`}
className="flex cursor-pointer items-baseline gap-2 px-3 py-1 text-xs hover:bg-muted/40"
onClick={() => onSelect(match)}
>
<span className="shrink-0 font-medium text-primary">
{match.sessionName} · {match.paneId}
</span>
<span className="truncate font-mono text-muted-foreground">
{highlightMatch(match.text, query)}
</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,450 @@
// 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<string>;
onToggleSession: (name: string) => void;
selectedPaneId: string | null;
onSelectPane: (pane: SelectedPane) => void;
metricsByPane: Map<string, TmuxPaneMetrics>;
metricsBySession: Map<string, SessionMetricsAgg>;
/** 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;
/** 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,
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<Set<string>>(
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 (
<div key={session.name} className="mb-0.5">
<div
className="group flex cursor-pointer items-center gap-1.5 px-2 py-1 hover:bg-muted/40"
onClick={() => onToggleSession(session.name)}
>
{expanded ? (
<ChevronDown className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)}
<span
className={`size-2 shrink-0 rounded-full ${session.attachedClients > 0 ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
title={
session.attachedClients > 0
? t("tmuxMonitor.attached")
: t("tmuxMonitor.detached")
}
/>
<span className="truncate text-sm font-medium">
{session.name}
</span>
{session.tags.slice(0, MAX_INLINE_TAGS).map((tag) => (
<Badge
key={tag}
variant="secondary"
className="h-4 shrink-0 px-1.5 text-[10px]"
>
{tag}
</Badge>
))}
{session.tags.length > MAX_INLINE_TAGS && (
<Badge
variant="secondary"
className="h-4 shrink-0 px-1.5 text-[10px]"
title={session.tags.join(", ")}
>
+{session.tags.length - MAX_INLINE_TAGS}
</Badge>
)}
{/* Meta label and hover actions stacked in one grid cell */}
<span className="ml-auto grid shrink-0">
{!compact && (
<span className="pointer-events-none col-start-1 row-start-1 justify-self-end self-center text-xs text-muted-foreground transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
{metaLabel(session)}
</span>
)}
<span className="pointer-events-none z-10 col-start-1 row-start-1 flex items-center gap-1.5 justify-self-end opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
<button
className="-m-1 p-1 text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.attachSessionTooltip", {
session: session.name,
})}
aria-label={t("tmuxMonitor.attachSessionTooltip", {
session: session.name,
})}
onClick={(e) => {
e.stopPropagation();
onAttachSession(session.name);
}}
>
<Play className="size-3.5" />
</button>
<button
className="-m-1 p-1 text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.newWindow")}
aria-label={t("tmuxMonitor.newWindow")}
onClick={(e) => {
e.stopPropagation();
onNewWindow(session.name);
}}
>
<Plus className="size-3.5" />
</button>
<Popover>
<PopoverTrigger asChild>
<button
className="-m-1 p-1 text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"
title={t("tmuxMonitor.sessionStats")}
aria-label={t("tmuxMonitor.sessionStats")}
onClick={(e) => e.stopPropagation()}
>
<BarChart2 className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={8}
className="w-56 rounded-none border border-border p-0 shadow-md"
onClick={(e) => e.stopPropagation()}
>
<div className="w-full px-3 py-2">
<div className="mb-1 flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${session.attachedClients > 0 ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<span className="text-xs font-semibold">
{session.name}
</span>
<span className="ml-auto text-[10px] text-muted-foreground">
{session.attachedClients > 0
? t("tmuxMonitor.attached")
: t("tmuxMonitor.detached")}
</span>
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[11px]">
<dt className="text-muted-foreground">
{t("tmuxMonitor.statusActivity")}
</dt>
<dd className="text-right">
{formatRelativeTime(session.lastActivity, now, t)}
</dd>
<dt className="text-muted-foreground">
{t("tmuxMonitor.statusWindows")}
</dt>
<dd className="text-right">
{session.windows.length} / {paneCount}{" "}
{t("tmuxMonitor.statusPanes")}
</dd>
<dt className="text-muted-foreground">CPU</dt>
<dd className="text-right">
{agg ? `${agg.cpu.toFixed(1)}%` : "—"}
</dd>
<dt className="text-muted-foreground">RAM</dt>
<dd className="text-right">
{agg ? formatMem(agg.memKb) : "—"}
</dd>
{agg && agg.gpuMb > 0 && (
<>
<dt className="text-muted-foreground">GPU</dt>
<dd className="text-right">{agg.gpuMb} MB</dd>
</>
)}
{session.tags.length > 0 && (
<>
<dt className="text-muted-foreground">
{t("tmuxMonitor.statusTags")}
</dt>
<dd className="truncate text-right">
{session.tags.join(", ")}
</dd>
</>
)}
</dl>
</div>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="-m-1 p-1 text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"
title={t("tmuxMonitor.moreActions")}
aria-label={t("tmuxMonitor.moreActions")}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-36"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
className="whitespace-nowrap"
onSelect={() =>
setTimeout(() => onRenameSession(session.name), 0)
}
>
<Pencil className="size-3.5" />
{t("tmuxMonitor.rename")}
</DropdownMenuItem>
<DropdownMenuItem
className="whitespace-nowrap"
onSelect={() =>
setTimeout(() => onEditTags(session.name), 0)
}
>
<Tag className="size-3.5" />
{t("tmuxMonitor.editTags")}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="whitespace-nowrap"
onSelect={() =>
setTimeout(() => onKillSession(session.name), 0)
}
>
<Trash2 className="size-3.5" />
{t("tmuxMonitor.kill")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</span>
</span>
</div>
{expanded &&
session.windows.map((win) => {
const winCollapsed = collapsedWindows.has(
windowKey(session.name, win.index),
);
return (
<div key={win.index} className="ml-4">
<div
className="group flex cursor-pointer items-center gap-1 px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted/40"
onClick={() => toggleWindow(session.name, win.index)}
>
{winCollapsed ? (
<ChevronRight className="size-3 shrink-0" />
) : (
<ChevronDown className="size-3 shrink-0" />
)}
<AppWindow className="size-3 shrink-0" />
<span className="truncate">
{win.index}: {win.name}
</span>
<button
className="-m-1 ml-auto shrink-0 p-1 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
title={t("tmuxMonitor.killWindow")}
aria-label={t("tmuxMonitor.killWindow")}
onClick={(e) => {
e.stopPropagation();
onKillWindow(session.name, win.index);
}}
>
<X className="size-3" />
</button>
</div>
{!winCollapsed &&
win.panes.map((pane) => {
const m = metricsByPane.get(pane.id);
const isSelected = selectedPaneId === pane.id;
return (
<div
key={pane.id}
className={`group ml-5 flex cursor-pointer items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/40 ${isSelected ? "bg-accent-brand/10" : ""}`}
onClick={() =>
onSelectPane({
paneId: pane.id,
sessionName: session.name,
windowIndex: win.index,
})
}
>
<SquareTerminal className="size-3 shrink-0" />
<span className="truncate">
{pane.index}: {pane.command}
</span>
<span
className="min-w-0 flex-1 truncate text-muted-foreground/70"
title={pane.path}
>
{pane.path}
</span>
{/* 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). */}
<span className="ml-auto grid shrink-0">
{m && m.cpuPercent > 0 && (
<span className="pointer-events-none col-start-1 row-start-1 justify-self-end self-center text-[10px] text-muted-foreground transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
{m.cpuPercent.toFixed(0)}%
</span>
)}
<span className="pointer-events-none z-10 col-start-1 row-start-1 flex items-center gap-1 justify-self-end opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
<button
className="-m-1 p-1 text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.splitRight")}
aria-label={t("tmuxMonitor.splitRight")}
onClick={(e) => {
e.stopPropagation();
onSplitPane(pane.id, "h");
}}
>
<SquareSplitHorizontal className="size-3" />
</button>
<button
className="-m-1 p-1 text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.splitDown")}
aria-label={t("tmuxMonitor.splitDown")}
onClick={(e) => {
e.stopPropagation();
onSplitPane(pane.id, "v");
}}
>
<SquareSplitVertical className="size-3" />
</button>
<button
className="-m-1 p-1 text-muted-foreground hover:text-destructive"
title={t("tmuxMonitor.killPane")}
aria-label={t("tmuxMonitor.killPane")}
onClick={(e) => {
e.stopPropagation();
onKillPane(pane.id);
}}
>
<X className="size-3" />
</button>
</span>
</span>
</div>
);
})}
</div>
);
})}
</div>
);
})}
</>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,16 @@
import type { StandaloneViewProps } from "@termix/plugin-sdk/frontend";
import { TmuxMonitor } from "./TmuxMonitor";
/** `?view=tmux_monitor` full-screen links. */
function TmuxMonitorApp({ hostId }: StandaloneViewProps) {
const parsed = hostId ? parseInt(hostId, 10) : NaN;
return (
<div className="h-screen w-screen overflow-hidden">
<TmuxMonitor
initialHostId={Number.isFinite(parsed) ? parsed : undefined}
/>
</div>
);
}
export default TmuxMonitorApp;
+185
View File
@@ -0,0 +1,185 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
let client: PluginApiClient | null = null;
/** Set from activate with app.api, cleared on deactivate. */
export function setTmuxMonitorApi(api: PluginApiClient | null): void {
client = api;
}
function api(): PluginApiClient {
if (!client) throw new Error("The Tmux Monitor plugin is not active");
return client;
}
export interface TmuxPane {
id: string;
index: number;
pid: number;
active: boolean;
width: number;
height: number;
command: string;
path: string;
title: string;
}
export interface TmuxWindow {
index: number;
name: string;
active: boolean;
panes: TmuxPane[];
}
export interface TmuxSessionOverview {
name: string;
created: number;
lastActivity: number;
attachedClients: number;
windows: TmuxWindow[];
tags: string[];
}
export interface TmuxOverview {
available: boolean;
sessions: TmuxSessionOverview[];
}
export interface TmuxSearchMatch {
paneId: string;
sessionName: string;
windowIndex: number;
line: number;
text: string;
}
export interface TmuxPaneMetrics {
paneId: string;
sessionName: string;
pid: number;
processCount: number;
cpuPercent: number;
memRssKb: number;
gpuMemMb: number;
topCommand: string | null;
}
export async function getTmuxOverview(hostId: number): Promise<TmuxOverview> {
const response = await api().get<TmuxOverview>(`/${hostId}/overview`);
return response.data;
}
/** Select a pane's window+pane on the server so the attached terminal
* (and any other attached client) switches to it. */
export async function focusTmuxPane(
hostId: number,
paneId: string,
): Promise<void> {
await api().post(`/${hostId}/focus`, { paneId });
}
/** Create a new detached session on the host. Starts the tmux server if
* none is running yet. */
export async function createTmuxSession(
hostId: number,
name: string,
): Promise<void> {
await api().post(`/${hostId}/sessions`, { name });
}
/** Create a new window in an existing session. */
export async function createTmuxWindow(
hostId: number,
sessionName: string,
): Promise<void> {
await api().post(`/${hostId}/windows`, { sessionName });
}
/** Rename a session. Saved tags follow the session to its new name. */
export async function renameTmuxSession(
hostId: number,
sessionName: string,
newName: string,
): Promise<void> {
await api().post(`/${hostId}/rename`, { sessionName, newName });
}
/** Kill a session (terminates all of its windows and processes). */
export async function killTmuxSession(
hostId: number,
sessionName: string,
): Promise<void> {
await api().post(`/${hostId}/kill`, { sessionName });
}
/** Kill a window and every pane in it. */
export async function killTmuxWindow(
hostId: number,
sessionName: string,
windowIndex: number,
): Promise<void> {
await api().post(`/${hostId}/kill-window`, { sessionName, windowIndex });
}
/** Kill a single pane (last pane of a window closes the window). */
export async function killTmuxPane(
hostId: number,
paneId: string,
): Promise<void> {
await api().post(`/${hostId}/kill-pane`, { paneId });
}
/** Split the window containing a pane: "h" adds a pane to the right,
* "v" below (tmux -h/-v semantics). */
export async function splitTmuxPane(
hostId: number,
paneId: string,
direction: "h" | "v",
): Promise<void> {
await api().post(`/${hostId}/split`, { paneId, direction });
}
export interface TmuxSearchResult {
matches: TmuxSearchMatch[];
/** True when a search limit was hit and the results are partial. */
truncated: boolean;
searchedLines: number;
maxPanes: number;
}
export async function searchTmux(
hostId: number,
query: string,
): Promise<TmuxSearchResult> {
const response = await api().get<Partial<TmuxSearchResult>>(
`/${hostId}/search`,
{ params: { q: query } },
);
return {
matches: response.data.matches ?? [],
truncated: response.data.truncated ?? false,
searchedLines: response.data.searchedLines ?? 0,
maxPanes: response.data.maxPanes ?? 0,
};
}
export async function getTmuxMetrics(
hostId: number,
): Promise<TmuxPaneMetrics[]> {
const response = await api().get<{ panes?: TmuxPaneMetrics[] }>(
`/${hostId}/metrics`,
);
return response.data.panes ?? [];
}
export async function setTmuxSessionTags(
hostId: number,
sessionName: string,
tags: string[],
): Promise<string[]> {
const response = await api().put<{ tags?: string[] }>(`/${hostId}/tags`, {
sessionName,
tags,
});
return response.data.tags ?? [];
}
@@ -0,0 +1,22 @@
import type { TranslateFn } from "@termix/plugin-sdk/frontend";
export function formatRelativeTime(
unixSeconds: number,
nowMs: number,
t: TranslateFn,
): string {
if (!unixSeconds) return "";
const diff = Math.max(0, nowMs / 1000 - unixSeconds);
if (diff < 60) return t("tmuxMonitor.timeJustNow");
if (diff < 3600)
return t("tmuxMonitor.timeMinutes", { count: Math.floor(diff / 60) });
if (diff < 86400)
return t("tmuxMonitor.timeHours", { count: Math.floor(diff / 3600) });
return t("tmuxMonitor.timeDays", { count: Math.floor(diff / 86400) });
}
export function formatMem(kb: number): string {
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)} GB`;
if (kb >= 1024) return `${(kb / 1024).toFixed(0)} MB`;
return `${kb} KB`;
}
@@ -0,0 +1,12 @@
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
/** Whether the monitor is enabled for this host, as the host payload's
* pluginSettings carries it. */
export function tmuxMonitorEnabled(
host: PluginHostRecord | null | undefined,
): boolean {
const settings = (
host?.pluginSettings as Record<string, Record<string, unknown>> | undefined
)?.["tmux-monitor"];
return settings?.enableTmuxMonitor === true;
}
@@ -0,0 +1,60 @@
import { Layers } from "lucide-react";
import type {
StandaloneViewProps,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { setTmuxMonitorApi } from "./api";
import { tmuxMonitorEnabled } from "./host-tmux-monitor";
import { HostTmuxMonitorSection } from "./HostTmuxMonitorSection";
import { TmuxMonitor } from "./TmuxMonitor";
import TmuxMonitorApp from "./TmuxMonitorApp";
const VIEW_ID = "tmux_monitor";
function TmuxMonitorTab({ tab, isVisible }: TabProps) {
const hostId = (tab.data?.hostId as number | undefined) ?? undefined;
return <TmuxMonitor initialHostId={hostId} isVisible={isVisible} />;
}
function TmuxMonitorStandalone(props: StandaloneViewProps) {
return <TmuxMonitorApp {...props} />;
}
export function activate(app: TermixApp): void {
setTmuxMonitorApi(app.api);
app.onDispose(() => setTmuxMonitorApi(null));
// A single cross-host browser, like the old core tab: opening it from a
// host row just points the already-open (or newly opened) tab at that host.
app.registerTab(VIEW_ID, TmuxMonitorTab, {
icon: Layers,
titleKey: "nav.tmuxMonitor",
singleton: true,
hostless: true,
persistent: true,
activityTypes: ["tmux_monitor"],
standalone: TmuxMonitorStandalone,
});
app.registerHostAction({
id: "tmux_monitor",
titleKey: "nav.tmuxMonitor",
icon: Layers,
kind: "open",
order: 70,
copyUrlView: VIEW_ID,
when: (host) => !!host.enableSsh && tmuxMonitorEnabled(host),
run: (host, shell) =>
shell.openSingletonTab(VIEW_ID, { data: { hostId: Number(host.id) } }),
});
app.registerHostEditorSection({
id: "tmux-monitor",
group: "ssh",
titleKey: "hosts.tabTmuxMonitor",
icon: Layers,
order: 45,
component: HostTmuxMonitorSection,
});
}
@@ -0,0 +1,5 @@
export interface SelectedPane {
paneId: string;
sessionName: string;
windowIndex: number;
}
@@ -0,0 +1,63 @@
import { useEffect, useRef } from "react";
import { runAdaptivePolling } from "@termix/plugin-sdk/ui";
type AdaptivePollResult = boolean | void;
interface AdaptivePollingPolicy {
minIntervalMs: number;
maxIntervalMs: number;
stablePollsPerStep?: number;
jitterRatio?: number;
maxRequestDutyCycle?: number;
}
export function useAdaptivePolling(
poll: () => AdaptivePollResult | Promise<AdaptivePollResult>,
policy: AdaptivePollingPolicy,
enabled = true,
options: {
runImmediately?: boolean;
onError?: (error: unknown) => void;
} = {},
): void {
const pollRef = useRef(poll);
const errorRef = useRef(options.onError);
pollRef.current = poll;
errorRef.current = options.onError;
const {
minIntervalMs,
maxIntervalMs,
stablePollsPerStep,
jitterRatio,
maxRequestDutyCycle,
} = policy;
const runImmediately = options.runImmediately;
useEffect(
() =>
runAdaptivePolling(
() => pollRef.current(),
{
minIntervalMs,
maxIntervalMs,
stablePollsPerStep,
jitterRatio,
maxRequestDutyCycle,
},
{
enabled: () => enabled,
runImmediately,
onError: (error) => errorRef.current?.(error),
},
),
[
enabled,
jitterRatio,
maxIntervalMs,
minIntervalMs,
runImmediately,
stablePollsPerStep,
maxRequestDutyCycle,
],
);
}