// The layout of a group of agent terminals, tmux style: a tree whose leaves // are one agent's terminal and whose inner nodes split their box into a row or // a column of children with sizes. Pure data and functions, so `npm test` can // run them; the group page draws the tree and calls these on each drag, drop // and divider move. Every function returns a new tree and leaves its input // alone. /** The edge of a pane a terminal is dropped on or split towards. */ export type Side = "left" | "right" | "top" | "bottom"; export interface Pane { kind: "pane"; agent: string; } export interface Split { kind: "split"; /** `row`: children side by side; `column`: children stacked. */ dir: "row" | "column"; children: Node[]; /** One share of the box per child, adding up to 1. */ sizes: number[]; } export type Node = Pane | Split; /** A saved group: its layout and the name the header tab shows. */ export interface Group { id: string; /** What the operator typed; empty while the group goes by its agents. */ name: string; root: Node; } /** * The smallest share a divider can leave a pane. Not a minimum pane size (the * operator chose none): it only keeps a pane from vanishing and being * impossible to drag back out. */ export const MIN_SHARE = 0.02; export const pane = (agent: string): Pane => ({ kind: "pane", agent }); /** * What a pane goes by in a layout: an agent's name, or `agent/subagent` for * one of that agent's subagents. Neither name holds a `/`. */ export function paneKey(agent: string, subagent?: string): string { return subagent === undefined ? agent : `${agent}/${subagent}`; } /** The agent and, for a subagent's pane, the subagent a pane key names. */ export function splitKey(key: string): { agent: string; subagent?: string } { const at = key.indexOf("/"); return at < 0 ? { agent: key } : { agent: key.slice(0, at), subagent: key.slice(at + 1) }; } /** The agents of a layout, in reading order (left to right, top to bottom). */ export function agentsIn(node: Node): string[] { return node.kind === "pane" ? [node.agent] : node.children.flatMap(agentsIn); } export function contains(node: Node, agent: string): boolean { return agentsIn(node).includes(agent); } function even(n: number): number[] { return Array.from({ length: n }, () => 1 / n); } function dirOf(side: Side): "row" | "column" { return side === "left" || side === "right" ? "row" : "column"; } const before = (side: Side) => side === "left" || side === "top"; /** * Takes `agent` out of the layout. A split left with one child gives way to * that child; the shares of the others are scaled back up to fill the box. * `null` when the layout held nothing else. */ export function removeAgent(node: Node, agent: string): Node | null { if (node.kind === "pane") return node.agent === agent ? null : node; const kept: Node[] = []; const shares: number[] = []; node.children.forEach((child, i) => { const next = removeAgent(child, agent); if (next === null) return; kept.push(next); shares.push(node.sizes[i] ?? 0); }); if (kept.length === 0) return null; if (kept.length === 1) return kept[0] ?? null; const total = shares.reduce((a, b) => a + b, 0); const sizes = total > 0 ? shares.map((s) => s / total) : even(kept.length); // A child that became a split of the same direction joins this one. return flatten({ ...node, children: kept, sizes }); } // A split directly inside a split of the same direction is one split: its // children are lifted up, each keeping its share of the share it had. function flatten(split: Split): Split { const children: Node[] = []; const sizes: number[] = []; split.children.forEach((child, i) => { const share = split.sizes[i] ?? 0; if (child.kind === "split" && child.dir === split.dir) { child.children.forEach((c, j) => { children.push(c); sizes.push(share * (child.sizes[j] ?? 0)); }); } else { children.push(child); sizes.push(share); } }); return { ...split, children, sizes }; } /** * Puts `agent` beside the pane of `target`, on the given side, taking half of * that pane's box. An agent already in the layout is moved, not copied. * Dropping an agent on its own pane changes nothing. A `target` that is not in * the layout leaves it unchanged. */ export function splitPane( root: Node, target: string, agent: string, side: Side, ): Node { if (target === agent || !contains(root, target)) return root; const without = contains(root, agent) ? removeAgent(root, agent) : root; if (without === null) return root; return place(without, target, agent, side); } function place(node: Node, target: string, agent: string, side: Side): Node { const dir = dirOf(side); if (node.kind === "pane") { if (node.agent !== target) return node; const added = pane(agent); return { kind: "split", dir, children: before(side) ? [added, node] : [node, added], sizes: [0.5, 0.5], }; } const at = node.children.findIndex( (c) => c.kind === "pane" && c.agent === target, ); if (at >= 0 && node.dir === dir) { // Beside a pane of a split that already runs this way: one more child, // taking half of the pane's share. const half = (node.sizes[at] ?? 0) / 2; const children = [...node.children]; const sizes = [...node.sizes]; children.splice(before(side) ? at : at + 1, 0, pane(agent)); sizes.splice(before(side) ? at : at + 1, 0, half); sizes[before(side) ? at + 1 : at] = half; return { ...node, children, sizes }; } return { ...node, children: node.children.map((c) => place(c, target, agent, side)), }; } /** * Moves the divider after child `index` of the split at `path` (the child * indexes from the root) by `delta`, a share of the split's box: the child * before it grows by that much and the one after shrinks, neither going below * {@link MIN_SHARE}. */ export function resizeAt( root: Node, path: number[], index: number, delta: number, ): Node { if (root.kind === "pane") return root; const [head, ...rest] = path; if (head !== undefined) { const child = root.children[head]; if (child === undefined) return root; const children = [...root.children]; children[head] = resizeAt(child, rest, index, delta); return { ...root, children }; } const a = root.sizes[index]; const b = root.sizes[index + 1]; if (a === undefined || b === undefined) return root; // A pane already below the floor (repeated splits halve a share) can grow but // not shrink further, so a drag cannot jump or push a share negative. const shift = Math.max( -Math.max(a - MIN_SHARE, 0), Math.min(Math.max(b - MIN_SHARE, 0), delta), ); const sizes = [...root.sizes]; sizes[index] = a + shift; sizes[index + 1] = b - shift; return { ...root, sizes }; } /** * The layout without the agents `existing` lacks: a saved layout can name an * agent that has since been destroyed. `null` when none is left. */ export function pruneMissing( root: Node, existing: ReadonlySet, ): Node | null { let out: Node | null = root; for (const key of agentsIn(root)) { if (out !== null && !existing.has(splitKey(key).agent)) { out = removeAgent(out, key); } } return out; } /** What a group's header tab shows: its own name, or its agents. */ export function groupTitle(group: Group): string { const name = group.name.trim(); return name === "" ? agentsIn(group.root).join(" + ") : name; } // --- saved groups --------------------------------------------------------- function parseNode(value: unknown, seen: Set): Node | null { if (typeof value !== "object" || value === null) return null; const v = value as Record; if (v["kind"] === "pane") { const agent = v["agent"]; // An agent shows once in a group. if (typeof agent !== "string" || agent === "" || seen.has(agent)) { return null; } seen.add(agent); return pane(agent); } if (v["kind"] !== "split") return null; const dir = v["dir"]; const rawChildren = v["children"]; const rawSizes = v["sizes"]; if ((dir !== "row" && dir !== "column") || !Array.isArray(rawChildren)) { return null; } const children: Node[] = []; const shares: number[] = []; rawChildren.forEach((raw, i) => { const child = parseNode(raw, seen); if (child === null) return; const share = Array.isArray(rawSizes) ? rawSizes[i] : undefined; children.push(child); shares.push(typeof share === "number" && share > 0 ? share : 0); }); if (children.length === 0) return null; if (children.length === 1) return children[0] ?? null; const total = shares.reduce((a, b) => a + b, 0); const sizes = total > 0 ? shares.map((s) => s / total) : even(children.length); return flatten({ kind: "split", dir, children, sizes }); } /** * The groups of a saved value (the parsed JSON of what the page stored), what * is well formed of it: anything else is dropped, so a stale or hand-edited * entry cannot break the page. */ export function parseGroups(value: unknown): Group[] { if (!Array.isArray(value)) return []; const groups: Group[] = []; const ids = new Set(); for (const raw of value) { if (typeof raw !== "object" || raw === null) continue; const g = raw as Record; const id = g["id"]; if (typeof id !== "string" || id === "" || ids.has(id)) continue; const root = parseNode(g["root"], new Set()); if (root === null) continue; ids.add(id); groups.push({ id, name: typeof g["name"] === "string" ? g["name"] : "", root, }); } return groups; } // --- dropping --------------------------------------------------------------- /** * The side a drop at (`fx`, `fy`) falls on, each a fraction (0 to 1) of the * pane's width and height from its top left corner: the pane is cut into four * triangles by its diagonals, so the whole pane is a drop zone and the nearest * edge wins. */ export function sideAt(fx: number, fy: number): Side { const dx = fx - 0.5; const dy = fy - 0.5; if (Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? "left" : "right"; return dy < 0 ? "top" : "bottom"; } // --- where a group lives in the header ---------------------------------------- const GROUP_PREFIX = "/agents/group/"; /** The path of a group's page, and so its header tab's `href`. */ export function groupHref(id: string): string { return `${GROUP_PREFIX}${encodeURIComponent(id)}`; } /** The group a header tab's `href` opens, `null` for any other tab. */ export function groupIdOf(href: string): string | null { if (!href.startsWith(GROUP_PREFIX)) return null; const id = href.slice(GROUP_PREFIX.length); return id === "" || id.includes("/") ? null : decodeURIComponent(id); } /** The agent whose own full-screen terminal a header tab's `href` opens. */ export function terminalAgentOf(href: string): string | null { const m = /^\/agents\/([^/]+)\/terminal$/.exec(href); return m?.[1] === undefined || m[1] === "group" ? null : decodeURIComponent(m[1]); }