The menu on an agent's terminal page used to open the subagent on a tab of its own. It now starts a group of this terminal and the subagent's, split to the right; in a group's pane it splits that pane. A pane's layout key is the agent, or `agent/subagent` for a subagent's pane, so the layout operations stay as they are and a destroyed agent takes its subagent panes with it. A subagent already in the group shows a marker and does nothing when chosen, and maximize opens a subagent pane's terminal on its own.
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
// 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<string>,
|
|
): 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<string>): Node | null {
|
|
if (typeof value !== "object" || value === null) return null;
|
|
const v = value as Record<string, unknown>;
|
|
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<string>();
|
|
for (const raw of value) {
|
|
if (typeof raw !== "object" || raw === null) continue;
|
|
const g = raw as Record<string, unknown>;
|
|
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]);
|
|
}
|