Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/keymaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ This table favors the shortcuts used by these dotfiles and Herdr defaults. It is
| Herdr | `Ctrl+A r` | Resize mode. |
| Herdr | `Ctrl+A b` | Toggle sidebar. |
| Herdr remote | `Ctrl+V` | Remote image paste. |
| Herdr Desktop | `Ctrl+A` + Herdr TUI key | Same prefix keys as Herdr TUI (`herdr/desktop/keys.json`): `v`, `-`, `x`, `z`, `h/j/k/l`, `c`, `n/p`, `1..9`, `Shift+X/T/N/W/D`, `b`, `s`, `?`, `Option+K/J`, `w` (space/tab picker), `q` (hide the window; terminals keep running), `Ctrl+1..9` (go to the agent of space 1–9 in sidebar order, the one that needs you most; a space without agents is shown). |
| Herdr Desktop | `Ctrl+A` + Herdr TUI key | Same prefix keys as Herdr TUI (`herdr/desktop/keys.json`): `v`, `-`, `x`, `z`, `h/j/k/l`, `c`, `n/p`, `1..9`, `Shift+X/T/N/W/D`, `b`, `s`, `?`, `Option+K/J`, `w` (space/tab picker), `q` (hide the window; terminals keep running), `Ctrl+1..9` (go to the agent of visible space 1–9 in sidebar order, matching the narrow sidebar's numbers; folded machine groups are skipped; the agent that needs you most is focused, or a space without agents is shown). |
| Herdr Desktop | not mapped | No equivalent for TUI pane cycling, pane rename, or resize mode. |

## AI CLIs
Expand Down
36 changes: 23 additions & 13 deletions herdr/desktop/mods/refactoria-spaces/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,21 @@ export default class Spaces extends View {
});
}

// Spaces in the order the list shows them: this computer first, then machines by name.
spaceOrder() {
// One display order for labels and shortcuts. Keep source i for drag-and-drop.
spaceGroups() {
const rows = this.ordered();
const remote = [...new Set(rows.map((r) => r.w.machine).filter(Boolean))].sort();
return [null, ...remote].flatMap((m) => rows.filter((r) => (r.w.machine ?? null) === m).map((r) => r.w));
const remote = [...new Set([...rows.map(r => r.w.machine).filter(Boolean), ...this.machines.map(m => m.name)])].sort();
let ordinal = 0;
return [null, ...remote].map((m) => {
const folded = remote.length > 0 && this.folded.has(m);
const mine = rows.filter((r) => (r.w.machine ?? null) === m)
.map((r) => ({ ...r, ordinal: folded ? null : ++ordinal }));
return { m, folded, mine };
});
}

spaceOrder() {
return this.spaceGroups().flatMap(({ folded, mine }) => folded ? [] : mine.map((r) => r.w));
}

focusAgent(n) {
Expand Down Expand Up @@ -308,23 +318,23 @@ export default class Spaces extends View {

// The rows, under a header per machine once one isn't this computer.
grouped(t, collapsed) {
const rows = this.ordered();
const groups = this.spaceGroups();
const draw = (r) => (collapsed ? this.compact(r, t) : this.row(r, t));
const remote = [...new Set([...rows.map(r => r.w.machine).filter(Boolean), ...this.machines.map(m => m.name)])].sort();
const loading = m => {
const layout = machines().find(k => k.name === m)?.layout;
return layout === "loading" || (!layout && this.machines.some(k => k.name === m && k.state === "connected"))
|| spaces().some(s => (s.machine ?? null) === m && !this.shown.has(s.id));
};
const loadingRow = m => div().id(`machine-loading-${m ?? "local"}`).h(28).flex_none().px(8).text_size(12).text_color(t.dim).child(collapsed ? "…" : "Loading…");
if (!remote.length) return loading(null) && !rows.length ? [loadingRow(null)] : rows.map(draw);
const machineNames = [null, ...remote];
return machineNames.flatMap((m) => {
const mine = rows.filter((r) => (r.w.machine ?? null) === m);
if (groups.length === 1) {
const rows = groups[0].mine;
return loading(null) && !rows.length ? [loadingRow(null)] : rows.map(draw);
}
return groups.flatMap(({ m, folded, mine }) => {
const waiting = mine.filter((r) => spaceWaits(r.w, this.seen, agents())).length;
const machine = this.machines.find(k => k.name === m);
const connected = !m || machine?.state === "connected";
const folded = this.folded.has(m);

const head = h_flex().id(`machine-${m ?? "local"}`).h(30).mt(6).mb(2).flex_none().px(collapsed ? 2 : 8).gap(7).items_center().text_size(12).text_color(t.foreground)
.rounded(8).bg(t.raised).border(waiting ? 2 : 1).border_color(waiting ? (this.pulse ? t.blocked : t.raised) : t.raised)
.cursor_pointer().when(!waiting, el => el.hover(s => s.border_color(t.input)))
Expand Down Expand Up @@ -431,7 +441,7 @@ export default class Spaces extends View {
);
}

compact({ w, i }, t) {
compact({ w, ordinal }, t) {
return h_flex()
.id(`space-${w.id}`)
.h(26)
Expand All @@ -441,7 +451,7 @@ export default class Spaces extends View {
.when(w.active, (el) => el.bg(t.raised))
.hover((s) => s.bg(t.hover))
.on_click(() => run("space.select", { id: w.id }))
.child(div().text_color(w.active ? t.foreground : t.muted).child(String(i + 1)))
.child(div().text_color(w.active ? t.foreground : t.muted).child(String(ordinal)))
.child(div().text_color(spaceWaits(w, this.seen, agents()) ? tone(t, w.status) : t.dim).child(spaceWaits(w, this.seen, agents()) ? "●" : "·"));
}

Expand Down
106 changes: 106 additions & 0 deletions tests/herdr_spaces_test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import vm from "node:vm";

// Exercise the mod's rendered rows and commands without the native GPUI runtime.
function fixture(spaces, { git = {}, folded = [], machines = [], agents = [] } = {}) {
const calls = [];
function element() {
const node = { children: [], events: {} };
const proxy = new Proxy(node, {
get(target, key) {
if (key in target) return target[key];
return (...args) => {
if (key === "child") target.children.push(args[0]);
else if (key === "children") target.children.push(...args[0]);
else if (key === "id") target.id = args[0];
else if (key === "when" && args[0]) args[1](proxy);
else if (key.startsWith("on_")) target.events[`${key}${typeof args[0] === "string" ? `_${args[0]}` : ""}`] = args.at(-1);
return proxy;
};
},
});
return proxy;
}
const source = readFileSync(new URL("../herdr/desktop/mods/refactoria-spaces/main.js", import.meta.url), "utf8")
.replace(/^import .*;\n/gm, "")
.replaceAll("export function ", "function ")
.replace("export default class Spaces", "class Spaces");
const context = vm.createContext({
View: class {}, div: element, svg: element, v_flex: element, h_flex: element,
spaces: () => spaces, panes: () => [], machines: () => [], agents: () => agents,
shared: () => ({}), run: (...args) => calls.push(structuredClone(args)),
});
const Spaces = vm.runInContext(`${source}\nSpaces`, context);
const view = new Spaces();
Object.assign(view, { git, shown: new Set(spaces.map(s => s.id)), born: {}, folded: new Set(folded), machines, seen: new Set(), pressed: null });
const theme = new Proxy({}, { get: (_, key) => key });
const rows = (compact = true) => view.grouped(theme, compact).filter(row => row.id?.startsWith("space-"));
const labels = () => rows().map(row => [row.id.slice(6), row.children[0].children[0]]);
function assertTargets(expected) {
assert.deepEqual(structuredClone(labels()), expected.map((id, i) => [id, String(i + 1)]));
calls.length = 0;
for (let i = 1; i <= expected.length + 1; i++) view.focusAgent(i);
assert.deepEqual(calls, expected.map(id => ["space.select", { id }]));
}
return { view, calls, rows, assertTargets };
}

const space = (id, machine = null) => ({ id, name: id, folder: `/${id}`, machine });
const gitKey = id => JSON.stringify([null, `/${id}`]);

test("local-first and alphabetic machine labels match shortcut targets", () => {
fixture([space("remote", "vps"), space("local"), space("alpha", "alpha")])
.assertTargets(["local", "alpha", "remote"]);
});

test("worktree created before main uses its displayed ordinal", () => {
fixture([space("worktree"), space("main")], { git: {
[gitKey("worktree")]: { repo: "repo", linked: true },
[gitKey("main")]: { repo: "repo", linked: false },
} }).assertTargets(["main", "worktree"]);
});

test("folded machine spaces occupy no ordinal and renumber on unfolding", () => {
const f = fixture([space("hidden", "alpha"), space("last", "vps"), space("local")], { folded: ["alpha"] });
f.assertTargets(["local", "last"]);
f.view.folded.delete("alpha");
f.assertTargets(["local", "hidden", "last"]);
});

test("folded local group is skipped only when machine headers exist", () => {
fixture([space("local"), space("remote", "vps")], { folded: [null] }).assertTargets(["remote"]);
fixture([space("local")], { folded: [null] }).assertTargets(["local"]);
fixture([space("local")], { folded: [null], machines: [{ name: "empty" }] }).assertTargets([]);
});

test("spaces waiting to be shown occupy no ordinal", () => {
const f = fixture([space("pending"), space("shown")]);
f.view.shown.delete("pending");
f.assertTargets(["shown"]);
});

test("agent focus still selects by urgency, then most recent change", () => {
const f = fixture([space("local")], { agents: [
{ space: "local", status: "working", seq: 99, pane: "working" },
{ space: "local", status: "blocked", seq: 1, pane: "old" },
{ space: "local", status: "blocked", seq: 2, pane: "new" },
] });
f.view.focusAgent(1);
assert.deepEqual(f.calls, [["pane.focus", { pane: "new" }]]);
});

test("wide rows preserve source indices for drag-and-drop and selection", () => {
const f = fixture([space("remote", "vps"), space("local")]);
const [local, remote] = f.rows(false);
const cx = { notify() {} };
local.events.on_mouse_down_left();
remote.events.on_mouse_up_left({}, cx);
remote.events.on_mouse_down_left();
remote.events.on_mouse_up_left({}, cx);
assert.deepEqual(f.calls, [
["space.move", { id: "local", index: 1 }],
["space.select", { id: "remote" }],
]);
});
Loading