Skip to content

Commit 2b697b0

Browse files
ralyodioclaude
andauthored
feat(herd): port the sidebar to hqtui, as a folding tree (#546)
"are we using hqtui.com library for this tui? it looks pretty old and outdated. upgrade it to hqtui" moshcode did not use hqtui at all: zero references in src/ or bin/, and the TUI is hand-rolled escape sequences throughout. So this is a port, not a version bump. @profullstack/hqtui joins as a dependency at ^0.7.0, a house package with zero runtime dependencies. SCOPE. The herd sidebar, which is the surface Anthony is looking at when he complains, and nothing else. src/herd-workspace.mjs keeps the tmux half and only the tmux half; src/herd-sidebar.mjs is the new view. The right-hand pane is still a real tmux pane running a real agent, because no renderer can substitute for one. WHAT THE LIBRARY BUYS. Three bugs came out of the hand-rolled shape and none of them were ever about herds: - the click map was a second source of truth. sidebarRows() numbered the lines and renderSidebar() printed them, and when they drifted every click landed on the row below the pointer. hqtui's tree reports the row it drew each node on, so the map is built by the thing that draws it. - there was no hover, so a click had to be spent selecting, which is where the double-click came from. onHoverRow plus hovered is now the whole of it. - the restore path was escape sequences only. hqtui's Terminal restores on SIGINT, SIGTERM and an uncaught error by default. A FOLDING TREE, IN PLACE. The herd is herds containing members, which is a tree, and the expectation for a pane of things-containing-things is that it unfolds in place on ONE click with the row under the pointer lit. Clicking a herd folds it; clicking a member opens it, meaning shown and given the keyboard. Nothing is swapped out for a different screen. Actions are real buttons with onPress. Neither known trap is tripped: collapseBorders only merges where two bordered siblings touch and there is nothing here to merge, so it is off with the reasoning written down; and no panel carries both a title and a subtitle, which is where near-identical panels truncate differently. The tests are the part worth having. renderToScreen draws the real view headlessly and then presses real cells, so "is the thing that says api the thing that opens api" is answered by clicking the cell that says api, rather than by two pieces of code agreeing about a line number. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 82774d3 commit 2b697b0

7 files changed

Lines changed: 582 additions & 518 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@moshcoder/moshpit-dns": "^0.5.0"
3838
},
3939
"dependencies": {
40+
"@profullstack/hqtui": "^0.7.0",
4041
"@profullstack/synconfig": "^0.1.3"
4142
}
4243
}

‎pnpm-lock.yaml‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/herd-cli.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1766,7 +1766,7 @@ const VERBS = {
17661766
// `ui` is the sidebar workspace; the old modal list lives on as the fallback
17671767
// inside it for machines with no tmux to swap panes on.
17681768
ui: async (argv, options) => (await import("./herd-workspace.mjs")).herdUi(argv, options),
1769-
sidebar: async (argv, options) => (await import("./herd-workspace.mjs")).herdSidebar(options),
1769+
sidebar: async (argv, options) => (await import("./herd-sidebar.mjs")).herdSidebar(options),
17701770
bar: async (argv, options) => (await import("./herd-bar.mjs")).herdBar(options),
17711771
tile: async (argv, options) => (await import("./herd-tile.mjs")).herdTile(argv, options),
17721772
untile: async (argv, options) => (await import("./herd-tile.mjs")).herdUntile(argv, options),

‎src/herd-sidebar.mjs‎

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
// The herd sidebar, on hqtui.
2+
//
3+
// WHAT THIS REPLACES. The sidebar used to be hand-rolled escape sequences: a
4+
// string of rows joined with \r\n, a hand-written SGR mouse parser, a hand-kept
5+
// map of "which line is which row", and a restore path that had to remember
6+
// every mode it had turned on. Three separate bugs came out of that shape and
7+
// none of them were about the herd:
8+
//
9+
// - the click map and the screen were two pieces of code that had to agree,
10+
// and when they drifted every click landed on the row below the pointer;
11+
// - mouse motion was never decoded at all, so there was no hover, so a click
12+
// had to be spent moving the highlight before a second one could open
13+
// anything, which is the double-click Anthony rejected outright;
14+
// - the restore path was escape sequences only, so a throw anywhere left the
15+
// pane in raw mode with the mouse still captured.
16+
//
17+
// hqtui answers all three as properties of the library rather than as things
18+
// this file has to keep getting right. The tree widget reports the row it drew
19+
// each node on, so the click map IS the screen by construction. `onHoverRow`
20+
// plus `hovered` is the hover. And the terminal is restored on SIGINT, SIGTERM
21+
// and an uncaught error by the Terminal itself.
22+
//
23+
// WHAT IT DOES NOT REPLACE. The right-hand side is a real tmux pane running a
24+
// real agent, and no renderer can substitute for that: it has a real cursor, a
25+
// real mouse inside the agent, and its own full-screen UI. So this owns the
26+
// LEFT pane only, and the pane swapping underneath it is still the tmux work in
27+
// herd-workspace.mjs, untouched.
28+
//
29+
// A FOLDING TREE, NOT A LIST THAT GETS REPLACED. The herd is herds containing
30+
// members, which is a tree, and Anthony's standing expectation for a pane of
31+
// things-containing-things is that it unfolds in place on ONE click with the
32+
// row under the pointer lit. Clicking a herd folds it; clicking a member opens
33+
// it. Nothing is ever swapped out for a different screen.
34+
import { spawnSync } from "node:child_process";
35+
36+
import { roster } from "./herd-cli.mjs";
37+
import { tmux } from "./herd.mjs";
38+
import { groupByHerd } from "./herd-ui.mjs";
39+
import { BAR_KEY } from "./herd-bar.mjs";
40+
import { ACTIONS, TARGET, contentPane, focusContent, pinTitles, showMember } from "./herd-workspace.mjs";
41+
42+
/**
43+
* The moshcoding palette, as hqtui colours.
44+
*
45+
* The same hexes src/ui.mjs paints with. They are repeated rather than imported
46+
* because ui.mjs exports painters (string in, escape-wrapped string out) and
47+
* hqtui wants colour values it can put in a cell's attributes; one of the two
48+
* has to be written twice and a hex is the smaller thing to duplicate.
49+
*/
50+
export const PALETTE = {
51+
acid: "#9EF01A",
52+
bone: "#EEF2E8",
53+
ash: "#8B938A",
54+
danger: "#FF4D3D",
55+
amber: "#FFD53D",
56+
};
57+
58+
const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" };
59+
const STATE_COLOR = {
60+
blocked: PALETTE.amber,
61+
working: PALETTE.acid,
62+
done: PALETTE.bone,
63+
gone: PALETTE.danger,
64+
};
65+
66+
/**
67+
* One member's state, as the two columns that sit to the right of its name.
68+
*
69+
* The trailing "?" is PRD 0019's: a state a regex guessed off a screen scrape
70+
* must not look like one the run itself reported. Not on `unknown`, whose mark
71+
* is already "?" and which is self-evidently nobody's report.
72+
*/
73+
export function stateCell(session) {
74+
const mark = MARK[session.state] || "?";
75+
const guess = session.confidence === "inferred" && session.state !== "unknown" ? "?" : "";
76+
return { text: `${mark}${guess}`, width: 2, align: "right", color: STATE_COLOR[session.state] || PALETTE.ash };
77+
}
78+
79+
/**
80+
* The tree, and the flat list of what each of its rows means.
81+
*
82+
* The two are built in one pass and in the same order hqtui flattens an
83+
* expanded tree in (parent, then its children, depth first), so `rows[i]`
84+
* describes the node at flat index `i`. That correspondence is the whole click
85+
* map: the old sidebar kept it by hand and it drifted.
86+
*/
87+
export function herdNodes(sessions, { collapsed = new Set(), showing = null, selected = null } = {}) {
88+
const nodes = [];
89+
const rows = [];
90+
for (const group of groupByHerd(sessions)) {
91+
const expanded = !collapsed.has(group.name);
92+
const node = {
93+
label: `${group.name.toUpperCase()} (${group.members.length})`,
94+
color: PALETTE.ash,
95+
expanded,
96+
children: [],
97+
};
98+
nodes.push(node);
99+
rows.push({ kind: "herd", herd: group.name });
100+
for (const session of group.members) {
101+
node.children.push({
102+
// The marker for "this is the one on screen" is part of the label
103+
// rather than another column: at 26 columns a member has about twenty
104+
// for its name once the tree guides have taken three, and a column
105+
// that is blank on every row but one is not worth one of them.
106+
label: `${session.name === showing ? "▸" : " "}${session.name}`,
107+
color: session.name === selected ? PALETTE.bone : PALETTE.ash,
108+
values: [stateCell(session)],
109+
});
110+
rows.push({ kind: "session", herd: group.name, session });
111+
}
112+
// A herd with nothing in it still has to be foldable, and a node with an
113+
// empty `children` array is drawn as a leaf. Leaving it undefined says the
114+
// same thing and does not lie about being expandable.
115+
if (!node.children.length) delete node.children;
116+
}
117+
return { nodes, rows };
118+
}
119+
120+
/**
121+
* Everything the sidebar's view needs, with nothing in it that touches a
122+
* terminal, so a test can render a frame and click on it.
123+
*
124+
* `hit` maps a screen row inside the tree widget to a flat index. It is filled
125+
* in by the tree's own `onRow` callback as it draws, which is the only place
126+
* that knows where the visible window starts, and read back by `onSelectRow`,
127+
* which is told a row counted from the top of that window.
128+
*/
129+
export function sidebarView(state, handlers = {}) {
130+
const { onOpen = () => {}, onFold = () => {}, onHover = () => {}, onAction = () => {}, onScroll = () => {} } = handlers;
131+
return ({ ui }) => {
132+
const { nodes, rows } = herdNodes(state.sessions, state);
133+
const hit = new Map();
134+
ui.box({ padding: { left: 1, right: 1 } }, (box) => {
135+
box.heading("herd", { size: 1 });
136+
box.tree({
137+
nodes,
138+
guides: true,
139+
guideColor: PALETTE.ash,
140+
selected: rows.findIndex((r) => r.kind === "session" && r.session.name === state.selected),
141+
hovered: state.hovered,
142+
offset: state.offset,
143+
followSelection: true,
144+
scrollbar: true,
145+
onRow: (node, index, y) => hit.set(y, index),
146+
onScroll,
147+
onHoverRow: (visible) => onHover(visible == null ? -1 : (hit.get(visible) ?? -1)),
148+
onSelectRow: (visible) => {
149+
const index = hit.get(visible);
150+
if (index == null) return;
151+
const row = rows[index];
152+
if (!row) return;
153+
// ONE click. A herd folds in place; a member opens. Nothing here
154+
// needs a second press to mean what it looked like it meant.
155+
if (row.kind === "herd") onFold(row.herd);
156+
else onOpen(row.session);
157+
},
158+
});
159+
box.spacer(1);
160+
box.divider({ label: "actions" });
161+
// The shortcut sits in a column of its own rather than two spaces after
162+
// a label, so five rows of different lengths read as a key map instead of
163+
// a ragged edge.
164+
const widest = Math.max(...ACTIONS.map((a) => [...a.label].length));
165+
for (const action of ACTIONS) {
166+
box.button({
167+
label: `${action.label.padEnd(widest + 2)}${action.key}`,
168+
align: "left",
169+
variant: "ghost",
170+
onPress: () => onAction(action.run),
171+
});
172+
}
173+
if (state.error) {
174+
box.spacer(1);
175+
box.text(state.error, { fg: PALETTE.danger, size: 2 });
176+
}
177+
});
178+
ui.statusBar({
179+
items: [{ key: "click", label: "open" }, { key: BAR_KEY, label: "bar" }],
180+
keyStyle: "caps",
181+
size: 1,
182+
});
183+
};
184+
}
185+
186+
/**
187+
* Runs inside the left pane.
188+
*
189+
* `create` is the seam: the real one builds an hqtui App on this terminal, and
190+
* a test passes something that renders headlessly. Everything below it is state
191+
* and tmux calls, which is what this file is actually responsible for.
192+
*/
193+
export async function herdSidebar({
194+
read = roster,
195+
runner = spawnSync,
196+
refreshMs = 2000,
197+
create = null,
198+
} = {}) {
199+
const me = process.env.TMUX_PANE;
200+
const state = {
201+
sessions: [],
202+
collapsed: new Set(),
203+
selected: null,
204+
showing: null,
205+
hovered: -1,
206+
offset: 0,
207+
error: "",
208+
};
209+
210+
// Every tmux call this file makes goes through here. hqtui restores the
211+
// terminal on an uncaught error, so a throw is no longer destructive, but it
212+
// would still end the sidebar; a `join-pane` failing because a pane died
213+
// between two refreshes is ordinary and must cost a line of red instead.
214+
const guard = (what, fn) => {
215+
try { state.error = ""; return fn(); }
216+
catch (thrown) { state.error = `${what}: ${String(thrown?.message || thrown).split("\n")[0]}`; return null; }
217+
};
218+
219+
const reload = () => guard("roster", () => {
220+
state.sessions = read();
221+
if (!state.selected || !state.sessions.some((s) => s.name === state.selected)) {
222+
state.selected = state.sessions[0]?.name || null;
223+
}
224+
state.showing = contentPane({ runner, me })?.title || null;
225+
});
226+
227+
guard("workspace", () => pinTitles(TARGET, { runner }));
228+
reload();
229+
230+
// Open on something rather than an empty right-hand side. Shown but NOT
231+
// focused: the keyboard belongs to the sidebar until someone asks for the
232+
// agent, or the workspace would start with every key going somewhere the
233+
// user has not looked at yet.
234+
if (!state.showing) {
235+
const first = state.sessions.find((s) => s.alive);
236+
if (first && guard("open", () => showMember(first.name, { runner, me }))) state.showing = first.name;
237+
}
238+
239+
const open = (session) => {
240+
state.selected = session.name;
241+
if (!session.alive) { state.error = `${session.name} is not running`; return; }
242+
const shown = guard("open", () => showMember(session.name, { runner, me }));
243+
if (!shown) { state.error = state.error || `could not open ${session.name}`; return; }
244+
state.showing = session.name;
245+
// Showing it and handing it the keyboard are one act, which is what makes
246+
// this one click rather than two.
247+
guard("focus", () => focusContent({ runner, me }));
248+
};
249+
250+
const fold = (herd) => {
251+
if (state.collapsed.has(herd)) state.collapsed.delete(herd);
252+
else state.collapsed.add(herd);
253+
};
254+
255+
const act = async (what) => {
256+
if (what === "detach") { guard("detach", () => tmux(["detach-client"], { runner })); return true; }
257+
if (what === "tile") {
258+
const { herdTile } = await import("./herd-tile.mjs");
259+
await herdTile([], { write: () => {}, spawner: () => ({ on: (e, cb) => e === "exit" && cb(0) }) });
260+
reload();
261+
return false;
262+
}
263+
if (what === "stop") {
264+
const target = state.sessions.find((s) => s.name === state.selected);
265+
if (!target) { state.error = "nothing selected to stop"; return false; }
266+
const { killSession } = await import("./herd.mjs");
267+
guard("stop", () => killSession(target.name, { runner }));
268+
reload();
269+
const next = state.sessions.find((s) => s.alive);
270+
if (next) open(next);
271+
return false;
272+
}
273+
const { herdShell, herdStart } = await import("./herd-cli.mjs");
274+
let created = null;
275+
const capture = (line) => {
276+
const m = /^\S*\s*(\S+)\s+—/.exec(String(line).replace(/\x1b\[[0-9;]*m/g, ""));
277+
if (m) created = m[1];
278+
};
279+
guard("start", () => (what === "shell" ? herdShell([], { write: capture }) : herdStart(["claude", "--agent"], { write: capture })));
280+
reload();
281+
const born = state.sessions.find((s) => s.name === created);
282+
if (born) open(born);
283+
return false;
284+
};
285+
286+
const app = create
287+
? await create()
288+
: await (await import("@profullstack/hqtui")).createApp({
289+
// `q` is the detach action, not a bare quit: leaving the sidebar should
290+
// leave the herd running and say so, which act("detach") does.
291+
quitKeys: ["ctrl+c"],
292+
// A 26-column pane with one column of content. Collapsing only merges
293+
// where two BORDERED siblings touch, so there is nothing here for it to
294+
// merge and turning it on would only cost a repaint.
295+
collapseBorders: false,
296+
mouse: true,
297+
});
298+
299+
const view = sidebarView(state, {
300+
onOpen: (session) => { open(session); app.invalidate(); },
301+
onFold: (herd) => { fold(herd); app.invalidate(); },
302+
onHover: (index) => {
303+
if (index === state.hovered) return; // do not repaint per cell of a drag
304+
state.hovered = index;
305+
app.invalidate();
306+
},
307+
onAction: (what) => { act(what).then((over) => { if (over) app.stop(); else app.invalidate(); }); },
308+
onScroll: (delta) => { state.offset = Math.max(0, state.offset + delta); app.invalidate(); },
309+
});
310+
app.render(view);
311+
312+
app.on("key", (event) => {
313+
const action = ACTIONS.find((a) => a.key === event.key);
314+
if (action) { act(action.run).then((over) => { if (over) app.stop(); else app.invalidate(); }); return; }
315+
const alive = state.sessions.filter((s) => s.alive);
316+
const at = alive.findIndex((s) => s.name === state.selected);
317+
if (event.key === "up" || event.key === "k") state.selected = alive[Math.max(0, at - 1)]?.name || state.selected;
318+
else if (event.key === "down" || event.key === "j") state.selected = alive[Math.min(alive.length - 1, at + 1)]?.name || state.selected;
319+
else if (event.key === "enter" || event.key === "space") {
320+
const chosen = state.sessions.find((s) => s.name === state.selected);
321+
if (chosen) open(chosen);
322+
} else return;
323+
app.invalidate();
324+
});
325+
326+
// A hover left behind when the pointer moves off the tree and onto the
327+
// actions is deliberately not chased. The widget only hears about the pointer
328+
// while it is inside itself, and the alternative is a second hit region over
329+
// the whole pane whose only job is to un-light a row nobody is looking at.
330+
// Moving back over the tree corrects it on the first cell.
331+
const timer = setInterval(() => { reload(); app.invalidate(); }, refreshMs);
332+
try { await app.start(); } finally { clearInterval(timer); }
333+
return 0;
334+
}

0 commit comments

Comments
 (0)