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.
265 lines
9 KiB
JavaScript
265 lines
9 KiB
JavaScript
// The layout of a group of agent terminals: splitting, removing, resizing, naming
|
|
// and reading a saved group back. Run by `npm test` with Node's type stripping.
|
|
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import {
|
|
agentsIn,
|
|
groupTitle,
|
|
MIN_SHARE,
|
|
pane,
|
|
paneKey,
|
|
parseGroups,
|
|
splitKey,
|
|
pruneMissing,
|
|
removeAgent,
|
|
resizeAt,
|
|
sideAt,
|
|
splitPane,
|
|
groupHref,
|
|
groupIdOf,
|
|
terminalAgentOf,
|
|
} from "../src/agent/terminal/split-layout.ts";
|
|
|
|
const row = (children, sizes) => ({
|
|
kind: "split",
|
|
dir: "row",
|
|
children,
|
|
sizes: sizes ?? children.map(() => 1 / children.length),
|
|
});
|
|
const column = (children, sizes) => ({
|
|
...row(children, sizes),
|
|
dir: "column",
|
|
});
|
|
const near = (got, want) =>
|
|
assert.ok(Math.abs(got - want) < 1e-9, `${got} is not ${want}`);
|
|
|
|
test("dropping on the right edge puts the agent beside the pane, in half its box", () => {
|
|
const got = splitPane(pane("foo"), "foo", "bar", "right");
|
|
assert.deepEqual(got, row([pane("foo"), pane("bar")], [0.5, 0.5]));
|
|
});
|
|
|
|
test("the left and top edges put the agent before the pane, the bottom edge below it", () => {
|
|
assert.deepEqual(agentsIn(splitPane(pane("foo"), "foo", "bar", "left")), [
|
|
"bar",
|
|
"foo",
|
|
]);
|
|
assert.equal(splitPane(pane("foo"), "foo", "bar", "top").dir, "column");
|
|
assert.deepEqual(
|
|
splitPane(pane("foo"), "foo", "bar", "bottom"),
|
|
column([pane("foo"), pane("bar")], [0.5, 0.5]),
|
|
);
|
|
});
|
|
|
|
test("splitting again the same way adds a child and halves only the pane's share", () => {
|
|
const two = row([pane("a"), pane("b")], [0.7, 0.3]);
|
|
const got = splitPane(two, "a", "c", "right");
|
|
assert.equal(got.kind, "split");
|
|
assert.deepEqual(agentsIn(got), ["a", "c", "b"]);
|
|
near(got.sizes[0], 0.35);
|
|
near(got.sizes[1], 0.35);
|
|
near(got.sizes[2], 0.3);
|
|
});
|
|
|
|
test("splitting the other way nests a split inside the pane's box", () => {
|
|
const two = row([pane("a"), pane("b")], [0.6, 0.4]);
|
|
const got = splitPane(two, "b", "c", "bottom");
|
|
assert.deepEqual(got.sizes, [0.6, 0.4]);
|
|
assert.deepEqual(got.children[1], column([pane("b"), pane("c")], [0.5, 0.5]));
|
|
});
|
|
|
|
test("an agent already in the group is moved, not copied, and the gap closes", () => {
|
|
const three = row([pane("a"), pane("b"), pane("c")]);
|
|
const got = splitPane(three, "a", "c", "top");
|
|
assert.deepEqual(agentsIn(got).sort(), ["a", "b", "c"]);
|
|
assert.equal(agentsIn(got).filter((x) => x === "c").length, 1);
|
|
assert.equal(got.kind, "split");
|
|
assert.equal(got.dir, "row");
|
|
assert.deepEqual(got.children[0], column([pane("c"), pane("a")], [0.5, 0.5]));
|
|
});
|
|
|
|
test("dropping an agent on its own pane, or on a pane that is not there, changes nothing", () => {
|
|
const two = row([pane("a"), pane("b")]);
|
|
assert.equal(splitPane(two, "a", "a", "left"), two);
|
|
assert.equal(splitPane(two, "zzz", "c", "left"), two);
|
|
});
|
|
|
|
test("removing an agent hands its share to the others and a lone child replaces its split", () => {
|
|
const three = row([pane("a"), pane("b"), pane("c")], [0.5, 0.25, 0.25]);
|
|
const two = removeAgent(three, "a");
|
|
assert.deepEqual(two, row([pane("b"), pane("c")], [0.5, 0.5]));
|
|
assert.deepEqual(removeAgent(two, "b"), pane("c"));
|
|
assert.equal(removeAgent(pane("c"), "c"), null);
|
|
assert.equal(removeAgent(two, "nobody") !== null, true);
|
|
});
|
|
|
|
test("removing the last pane of a nested split lets its sibling take the place and merge upwards", () => {
|
|
const nested = row([pane("a"), column([pane("b"), pane("c")])], [0.4, 0.6]);
|
|
const got = removeAgent(nested, "c");
|
|
assert.deepEqual(got, row([pane("a"), pane("b")], [0.4, 0.6]));
|
|
// a column that ends up inside a column joins it
|
|
const deep = column([
|
|
row([pane("a"), column([pane("b"), pane("c")])]),
|
|
pane("d"),
|
|
]);
|
|
assert.deepEqual(agentsIn(removeAgent(deep, "a")), ["b", "c", "d"]);
|
|
assert.equal(removeAgent(deep, "a").dir, "column");
|
|
});
|
|
|
|
test("a divider moves share from one neighbour to the other and stops short of zero", () => {
|
|
const two = row([pane("a"), pane("b")], [0.5, 0.5]);
|
|
const moved = resizeAt(two, [], 0, 0.2);
|
|
near(moved.sizes[0], 0.7);
|
|
near(moved.sizes[1], 0.3);
|
|
const far = resizeAt(two, [], 0, 5);
|
|
near(far.sizes[0], 1 - MIN_SHARE);
|
|
near(far.sizes[1], MIN_SHARE);
|
|
const back = resizeAt(two, [], 0, -5);
|
|
near(back.sizes[0], MIN_SHARE);
|
|
assert.equal(resizeAt(two, [], 7, 0.1), two);
|
|
});
|
|
|
|
test("a divider of a nested split is found by its path", () => {
|
|
const nested = row([pane("a"), column([pane("b"), pane("c")])]);
|
|
const got = resizeAt(nested, [1], 0, 0.25);
|
|
assert.deepEqual(got.sizes, [0.5, 0.5]);
|
|
near(got.children[1].sizes[0], 0.75);
|
|
near(got.children[1].sizes[1], 0.25);
|
|
});
|
|
|
|
test("a saved layout loses the agents that no longer exist", () => {
|
|
const three = row([pane("a"), pane("b"), pane("c")]);
|
|
assert.deepEqual(agentsIn(pruneMissing(three, new Set(["a", "c"]))), [
|
|
"a",
|
|
"c",
|
|
]);
|
|
assert.equal(pruneMissing(three, new Set()), null);
|
|
assert.equal(pruneMissing(three, new Set(["a", "b", "c"])), three);
|
|
});
|
|
|
|
test("a group goes by its own name, else by its agents", () => {
|
|
const root = row([pane("foo"), pane("bar")]);
|
|
assert.equal(groupTitle({ id: "1", name: "", root }), "foo + bar");
|
|
assert.equal(groupTitle({ id: "1", name: " ", root }), "foo + bar");
|
|
assert.equal(groupTitle({ id: "1", name: "infra", root }), "infra");
|
|
});
|
|
|
|
test("a saved group is read back, and what is malformed of it is dropped", () => {
|
|
const good = {
|
|
id: "g1",
|
|
name: "infra",
|
|
root: {
|
|
kind: "split",
|
|
dir: "row",
|
|
sizes: [3, 1],
|
|
children: [
|
|
{ kind: "pane", agent: "a" },
|
|
{ kind: "pane", agent: "b" },
|
|
],
|
|
},
|
|
};
|
|
const [got] = parseGroups([good]);
|
|
assert.equal(got.name, "infra");
|
|
near(got.root.sizes[0], 0.75);
|
|
near(got.root.sizes[1], 0.25);
|
|
|
|
assert.deepEqual(parseGroups("nope"), []);
|
|
assert.deepEqual(
|
|
parseGroups([null, 5, { id: "", root: good.root }, { id: "x" }]),
|
|
[],
|
|
);
|
|
// the same agent twice, a bad direction, a bad child, missing sizes
|
|
const odd = parseGroups([
|
|
{
|
|
id: "g2",
|
|
root: {
|
|
kind: "split",
|
|
dir: "row",
|
|
children: [
|
|
{ kind: "pane", agent: "a" },
|
|
{ kind: "pane", agent: "a" },
|
|
{ kind: "pane", agent: "c" },
|
|
7,
|
|
],
|
|
},
|
|
},
|
|
{
|
|
id: "g3",
|
|
root: {
|
|
kind: "split",
|
|
dir: "diagonal",
|
|
children: [{ kind: "pane", agent: "a" }],
|
|
},
|
|
},
|
|
{ id: "g1", root: { kind: "pane", agent: "z" } },
|
|
]);
|
|
assert.deepEqual(
|
|
odd.map((g) => g.id),
|
|
["g2", "g1"],
|
|
);
|
|
assert.deepEqual(agentsIn(odd[0].root), ["a", "c"]);
|
|
near(odd[0].root.sizes[0], 0.5);
|
|
assert.equal(odd[0].name, "");
|
|
});
|
|
|
|
test("a drop falls on the nearest edge of the pane, the diagonals dividing the four", () => {
|
|
assert.equal(sideAt(0.1, 0.5), "left");
|
|
assert.equal(sideAt(0.9, 0.5), "right");
|
|
assert.equal(sideAt(0.5, 0.1), "top");
|
|
assert.equal(sideAt(0.5, 0.9), "bottom");
|
|
assert.equal(sideAt(0.3, 0.1), "top");
|
|
assert.equal(sideAt(0.1, 0.3), "left");
|
|
assert.equal(sideAt(0.9, 0.7), "right");
|
|
});
|
|
|
|
test("a group's header tab and an agent's terminal tab are told apart by their paths", () => {
|
|
assert.equal(groupHref("ab12"), "/agents/group/ab12");
|
|
assert.equal(groupIdOf("/agents/group/ab12"), "ab12");
|
|
assert.equal(groupIdOf("/agents/group/"), null);
|
|
assert.equal(groupIdOf("/agents/iris/terminal"), null);
|
|
assert.equal(terminalAgentOf("/agents/iris/terminal"), "iris");
|
|
assert.equal(terminalAgentOf("/agents/iris/subagents/x/terminal"), null);
|
|
assert.equal(terminalAgentOf("/agents/group/ab12"), null);
|
|
});
|
|
|
|
test("a subagent's pane is keyed by agent/subagent, split beside its agent, and goes with its agent", () => {
|
|
assert.equal(paneKey("a"), "a");
|
|
assert.equal(paneKey("a", "s"), "a/s");
|
|
assert.deepEqual(splitKey("a"), { agent: "a" });
|
|
assert.deepEqual(splitKey("a/s"), { agent: "a", subagent: "s" });
|
|
const two = splitPane(pane("a"), "a", paneKey("a", "s"), "right");
|
|
assert.deepEqual(agentsIn(two), ["a", "a/s"]);
|
|
// The same subagent twice changes nothing but its place.
|
|
assert.deepEqual(agentsIn(splitPane(two, "a", paneKey("a", "s"), "left")), [
|
|
"a/s",
|
|
"a",
|
|
]);
|
|
// The agent a subagent belongs to being gone takes both panes.
|
|
const three = splitPane(two, "a/s", "b", "bottom");
|
|
assert.deepEqual(agentsIn(pruneMissing(three, new Set(["b"]))), ["b"]);
|
|
assert.deepEqual(agentsIn(pruneMissing(three, new Set(["a", "b"]))), [
|
|
"a",
|
|
"a/s",
|
|
"b",
|
|
]);
|
|
assert.equal(groupTitle({ id: "x", name: "", root: two }), "a + a/s");
|
|
});
|
|
|
|
test("a pane already below the floor can grow but never shrinks further, and no share goes negative", () => {
|
|
const tiny = row([pane("a"), pane("b"), pane("c")], [0.005, 0.005, 0.99]);
|
|
const grown = resizeAt(tiny, [], 1, 0.5);
|
|
assert.ok(grown.sizes[1] > 0.005);
|
|
assert.ok(grown.sizes.every((s) => s >= 0));
|
|
near(
|
|
grown.sizes.reduce((x, y) => x + y, 0),
|
|
1,
|
|
);
|
|
const shrunk = resizeAt(tiny, [], 0, -0.5);
|
|
assert.deepEqual(shrunk.sizes, tiny.sizes);
|
|
const both = resizeAt(
|
|
row([pane("a"), pane("b")], [0.005, 0.005]),
|
|
[],
|
|
0,
|
|
0.3,
|
|
);
|
|
assert.ok(both.sizes.every((s) => s >= 0));
|
|
});
|