Skip to content

Commit 79fe411

Browse files
authored
feat(coding-agents): styled installer UI, arrow-key server picker, required Cloud token (#3242)
- clack/Vercel-style rail renderer (src/install-ui.ts, zero-dep): per-harness step groups keyed on the '<name>: ' log prefix, severity from message phrasing plus run()'s own emoji markers, $HOME shortened to ~, version header, honest outros (partial failures say so instead of 'nothing changed') - arrow-key server picker (❯ pointer, ↑/↓/j/k + Enter, digit shortcuts, Esc/q/Ctrl+C cancels) with the numbered prompt kept as fallback when a raw TTY is unavailable; rows truncate to the terminal width and autowrap is disabled during repaint so narrow terminals don't duplicate lines - fix: the interactive picker never actually waited (v0.0.4/v0.0.5) — process.stdin.isTTY flips fd 0 non-blocking, readSync EAGAINs, and every answer silently became its default. Probe with tty.isatty instead and treat EAGAIN as wait-for-input - fix: configureServer now honors HINDSIGHT_CONFIG like the runtime, so the wizard writes the file sessions actually read - Hindsight Cloud API token is now REQUIRED: interactive re-asks (3 attempts), --server cloud without --api-token refuses up front instead of writing a config that 401s on the first session - installSkill logs with the harness prefix so skill lines group correctly
1 parent d3946f1 commit 79fe411

5 files changed

Lines changed: 774 additions & 50 deletions

File tree

hindsight-integrations/coding-agents/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
createInstallerUi,
4+
fitSelectRow,
5+
selectKeyAction,
6+
splitKeys,
7+
type InstallerUi,
8+
} from "./install-ui";
9+
10+
/** Renderer under a fixed clock and captured output; colors off unless a test opts in. */
11+
function makeUi(command?: string, colors = false): { ui: InstallerUi; lines: string[] } {
12+
const lines: string[] = [];
13+
let tick = 0;
14+
const ui = createInstallerUi({
15+
home: "/home/u",
16+
command,
17+
version: "1.2.3",
18+
harnessNames: ["claude-code", "codex"],
19+
auxNames: ["server"],
20+
colors,
21+
write: (l) => lines.push(l),
22+
now: () => (tick += 250),
23+
});
24+
return { ui, lines };
25+
}
26+
27+
describe("installer UI renderer", () => {
28+
it("frames the run, groups messages by harness prefix, and picks symbols from phrasing", () => {
29+
const { ui, lines } = makeUi("install");
30+
ui.intro();
31+
ui.log("detected: claude-code, codex");
32+
ui.log("claude-code: hooks merged into /home/u/.claude/settings.json");
33+
// A message without a harness prefix must land inside the currently open group.
34+
ui.log("skill installed at /home/u/.claude/skills/hindsight-coding-agent");
35+
ui.log(
36+
"claude-code: could not run `claude mcp add` — register the tools manually:\n" +
37+
' claude mcp add --scope user hindsight -- node "/opt/dist/mcp-server.js"'
38+
);
39+
ui.log("codex: hooks merged into /home/u/.codex/hooks.json");
40+
ui.outro(0);
41+
42+
const out = lines.join("\n");
43+
expect(out).toContain("┌ Hindsight coding agents v1.2.3");
44+
expect(out).toContain("○ detected: claude-code, codex");
45+
expect(out).toContain("◇ claude-code");
46+
expect(out).toContain("◇ codex");
47+
// $HOME shortened to ~ everywhere.
48+
expect(out).toContain("✓ hooks merged into ~/.claude/settings.json");
49+
expect(out).not.toContain("/home/u/");
50+
// Manual-step phrasing renders as a warning, continuation line stays on the rail.
51+
expect(out).toContain("▲ could not run `claude mcp add`");
52+
expect(out).toContain("│ claude mcp add --scope user hindsight");
53+
// The prefixless skill line sits between the two group headers, i.e. inside claude-code's group.
54+
const claudeAt = lines.findIndex((l) => l.includes("◇ claude-code"));
55+
const skillAt = lines.findIndex((l) => l.includes("skill installed"));
56+
const codexAt = lines.findIndex((l) => l.includes("◇ codex"));
57+
expect(claudeAt).toBeLessThan(skillAt);
58+
expect(skillAt).toBeLessThan(codexAt);
59+
expect(out).toMatch(/ {2} Installed 2 agents in \d+\.\ds/);
60+
expect(out).toContain("~/.hindsight/coding-agent.json");
61+
});
62+
63+
it("renders the server-setup step as a group but does not count it as an agent", () => {
64+
const { ui, lines } = makeUi("install");
65+
ui.intro();
66+
ui.log(
67+
"\nWhere should memory live?\n 1) Hindsight Cloud\n 2) Self-hosted\n 3) Local daemon\n"
68+
);
69+
ui.log("server: daemon (/home/u/.hindsight/coding-agent.json)");
70+
ui.log("codex: hooks merged into /home/u/.codex/hooks.json");
71+
ui.outro(0);
72+
const out = lines.join("\n");
73+
// The mode question is an info line with quiet option detail, not a completed action.
74+
expect(out).toContain("○ Where should memory live?");
75+
expect(out).toContain("│ 1) Hindsight Cloud");
76+
expect(out).toContain("◇ server");
77+
expect(out).toContain("✓ daemon (~/.hindsight/coding-agent.json)");
78+
expect(out).toMatch(/Installed 1 agent in/); // codex only — server is a step, not an agent
79+
});
80+
81+
it("adopts run()'s own emoji severity markers instead of double-marking", () => {
82+
const { ui, lines } = makeUi("install");
83+
ui.intro();
84+
ui.log(
85+
"\n❌ codex: `node:sqlite` is unavailable in the node on PATH.\n Upgrade to Node 22.5."
86+
);
87+
ui.log("⚠️ `uv` is not on PATH. The daemon is fetched and run with it.");
88+
ui.outro(1);
89+
const out = lines.join("\n");
90+
// The ❌ line still opens its harness group once the marker is stripped.
91+
expect(out).toContain("◇ codex");
92+
expect(out).toContain("✖ `node:sqlite` is unavailable");
93+
expect(out).not.toContain("❌");
94+
expect(out).toContain("▲ `uv` is not on PATH");
95+
expect(out).not.toContain("⚠️");
96+
});
97+
98+
it("never doubles the rail spacer between intro, groups, and outro", () => {
99+
const { ui, lines } = makeUi("install");
100+
ui.intro();
101+
ui.log("claude-code: hooks merged into /home/u/.claude/settings.json");
102+
ui.outro(0);
103+
for (let i = 1; i < lines.length; i++) {
104+
if (lines[i] === "│") expect(lines[i - 1]).not.toBe("│");
105+
}
106+
});
107+
108+
it("counts a single agent without the plural s", () => {
109+
const { ui, lines } = makeUi("uninstall");
110+
ui.intro();
111+
ui.log("codex: hooks + MCP section + skill removed");
112+
ui.outro(0);
113+
expect(lines.join("\n")).toMatch(/ Uninstalled 1 agent in \d+\.\ds/);
114+
});
115+
116+
it("renders guard-path failures as an error and an aborted outro when nothing was wired", () => {
117+
const { ui, lines } = makeUi("install");
118+
ui.intro();
119+
ui.log('unknown harness "nope" — expected "all" or one of: claude-code, codex');
120+
ui.outro(1);
121+
const out = lines.join("\n");
122+
expect(out).toContain('✖ unknown harness "nope"');
123+
expect(out).toContain("✖ Aborted — nothing was changed.");
124+
expect(out).not.toContain("Installed");
125+
});
126+
127+
it("reports a partial failure honestly once some agents were already wired", () => {
128+
const { ui, lines } = makeUi("install");
129+
ui.intro();
130+
ui.log("codex: hooks merged into /home/u/.codex/hooks.json");
131+
ui.log("\n❌ not installed: devin-cli — this machine can't run it (see above).");
132+
ui.outro(1);
133+
const out = lines.join("\n");
134+
expect(out).toContain("✖ Completed with errors — see above.");
135+
expect(out).not.toContain("nothing was changed");
136+
});
137+
138+
it("closes a bare usage run with just the frame — no success banner", () => {
139+
const { ui, lines } = makeUi(undefined);
140+
ui.intro();
141+
ui.log(
142+
"usage: hindsight-coding-agents <install|uninstall> <all|harness...>\n all every agent"
143+
);
144+
ui.outro(0);
145+
const out = lines.join("\n");
146+
expect(out).toContain("○ usage:");
147+
expect(out).not.toContain("Installed");
148+
expect(lines.at(-2)).toBe("└");
149+
});
150+
151+
it("renders an indented follow-up message as detail lines, not a fresh ✓ item", () => {
152+
const { ui, lines } = makeUi("install");
153+
ui.intro();
154+
ui.log("claude-code: conversation import did not finish — re-run it any time with:");
155+
ui.log(' node "/opt/dist/deepen.js" --repo "/home/u/w" --conversations "/tmp/c.json"');
156+
const detail = lines.at(-1)!;
157+
expect(detail).toContain('node "/opt/dist/deepen.js"');
158+
expect(detail).not.toContain("✓");
159+
expect(detail.startsWith("│ ")).toBe(true);
160+
});
161+
162+
it("names the overridden config path in the outro when one is set", () => {
163+
const lines: string[] = [];
164+
const ui = createInstallerUi({
165+
home: "/home/u",
166+
command: "install",
167+
harnessNames: ["codex"],
168+
configPath: "/tmp/config.json",
169+
colors: false,
170+
write: (l) => lines.push(l),
171+
now: (() => {
172+
let t = 0;
173+
return () => (t += 100);
174+
})(),
175+
});
176+
ui.intro();
177+
ui.log("codex: hooks merged into /home/u/.codex/hooks.json");
178+
ui.outro(0);
179+
expect(lines.join("\n")).toContain("settings live in /tmp/config.json.");
180+
expect(lines.join("\n")).not.toContain("~/.hindsight");
181+
});
182+
183+
it("styles readline prompts onto the rail", () => {
184+
const { ui } = makeUi("install");
185+
expect(ui.prompt("Choose [1-3] (default 1): ")).toBe("│ Choose [1-3] (default 1): ");
186+
});
187+
188+
it("select keys: arrows wrap, vi keys move, Enter submits, digits shortcut, Esc/q/Ctrl+C cancel", () => {
189+
expect(selectKeyAction("\x1b[B", 0, 3)).toEqual({ kind: "move", index: 1 });
190+
expect(selectKeyAction("\x1b[B", 2, 3)).toEqual({ kind: "move", index: 0 }); // wraps down
191+
expect(selectKeyAction("\x1b[A", 0, 3)).toEqual({ kind: "move", index: 2 }); // wraps up
192+
expect(selectKeyAction("j", 0, 3)).toEqual({ kind: "move", index: 1 });
193+
expect(selectKeyAction("k", 1, 3)).toEqual({ kind: "move", index: 0 });
194+
expect(selectKeyAction("\r", 1, 3)).toEqual({ kind: "submit", index: 1 });
195+
expect(selectKeyAction("3", 0, 3)).toEqual({ kind: "submit", index: 2 });
196+
expect(selectKeyAction("9", 0, 3)).toEqual({ kind: "none", index: 0 }); // out of range
197+
expect(selectKeyAction("\x1b", 1, 3)).toEqual({ kind: "cancel", index: 1 });
198+
expect(selectKeyAction("q", 1, 3)).toEqual({ kind: "cancel", index: 1 });
199+
expect(selectKeyAction("\x03", 1, 3)).toEqual({ kind: "cancel", index: 1 });
200+
expect(selectKeyAction("x", 1, 3)).toEqual({ kind: "none", index: 1 });
201+
});
202+
203+
it("splits a burst of keys from one read into individual tokens", () => {
204+
// Key repeat / paste: ↓ then Enter arriving in a single read must act as two keys.
205+
expect(splitKeys("\x1b[B\r")).toEqual(["\x1b[B", "\r"]);
206+
expect(splitKeys("\x1b[A\x1b[A\x1b[B")).toEqual(["\x1b[A", "\x1b[A", "\x1b[B"]);
207+
expect(splitKeys("2\r")).toEqual(["2", "\r"]);
208+
expect(splitKeys("\x1b")).toEqual(["\x1b"]); // a lone Esc stays a cancel key
209+
expect(splitKeys("jk")).toEqual(["j", "k"]);
210+
expect(splitKeys("\x1b[")).toEqual(["\x1b["]); // truncated CSI at chunk end doesn't crash
211+
});
212+
213+
it("fits select rows to the terminal width so a row can never wrap", () => {
214+
const label = "Local daemon (on-device)";
215+
const hint = "runs hindsight-embed here; no account, needs uv + an LLM key";
216+
// Wide terminal: everything fits untouched.
217+
const wide = fitSelectRow(label, hint, 120);
218+
expect(wide).toEqual({ label, hint: ` — ${hint}` });
219+
// Narrow terminal: the hint is truncated with an ellipsis, the label survives.
220+
const narrow = fitSelectRow(label, hint, 60);
221+
expect(narrow.label).toBe(label);
222+
expect(narrow.hint.endsWith("…")).toBe(true);
223+
expect(narrow.label.length + narrow.hint.length).toBeLessThanOrEqual(60 - 6);
224+
// Tiny terminal: even the label is cut, and the hint is dropped entirely.
225+
const tiny = fitSelectRow(label, hint, 20);
226+
expect(tiny.hint).toBe("");
227+
expect(tiny.label.endsWith("…")).toBe(true);
228+
expect(tiny.label.length).toBeLessThanOrEqual(20 - 6);
229+
// No hint at all stays stable.
230+
expect(fitSelectRow("Cloud", undefined, 80)).toEqual({ label: "Cloud", hint: "" });
231+
});
232+
233+
it("emits ANSI only when colors are on", () => {
234+
const on = makeUi("install", true);
235+
on.ui.intro();
236+
on.ui.log("claude-code: hooks merged into /home/u/.claude/settings.json");
237+
on.ui.outro(0);
238+
expect(on.lines.join("\n")).toContain("\x1b[");
239+
240+
const off = makeUi("install", false);
241+
off.ui.intro();
242+
off.ui.log("claude-code: hooks merged into /home/u/.claude/settings.json");
243+
off.ui.outro(0);
244+
expect(off.lines.join("\n")).not.toContain("\x1b[");
245+
});
246+
});

0 commit comments

Comments
 (0)