@cueloop/client 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -0
- package/src/App.diff.test.tsx +99 -0
- package/src/App.export.test.tsx +80 -0
- package/src/App.readonly.test.tsx +148 -0
- package/src/App.test.tsx +217 -0
- package/src/App.tsx +0 -0
- package/src/config.test.ts +116 -0
- package/src/config.ts +113 -0
- package/src/editor.ts +34 -0
- package/src/index.ts +2 -0
- package/src/run.ts +28 -0
- package/src/serve.test.ts +96 -0
- package/src/serve.ts +75 -0
- package/src/theme.ts +46 -0
- package/src/view-diff.test.ts +55 -0
- package/src/view-diff.ts +86 -0
- package/src/view.test.ts +157 -0
- package/src/view.ts +0 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DEFAULT_KEYS, actionFor, loadConfig } from "./config";
|
|
6
|
+
import { DARK } from "./theme";
|
|
7
|
+
|
|
8
|
+
describe("loadConfig", () => {
|
|
9
|
+
test("defaults when no file exists", () => {
|
|
10
|
+
const c = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
11
|
+
expect(c.keys["comment"]).toEqual(["c"]);
|
|
12
|
+
expect(c.theme.accent).toBe(DARK.accent);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("user config rebinds actions and overrides theme tokens", () => {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-"));
|
|
17
|
+
const path = join(dir, "config.toml");
|
|
18
|
+
writeFileSync(
|
|
19
|
+
path,
|
|
20
|
+
`[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
|
|
21
|
+
);
|
|
22
|
+
try {
|
|
23
|
+
const c = loadConfig({ userConfigPath: path });
|
|
24
|
+
expect(c.keys["comment"]).toEqual(["a"]);
|
|
25
|
+
expect(c.keys["submit"]).toEqual(["return", "S"]);
|
|
26
|
+
expect(c.keys["cut"]).toEqual(["x"]); // untouched defaults survive
|
|
27
|
+
expect(c.theme.accent).toBe("#ff0000");
|
|
28
|
+
expect(c.theme.green).toBe(DARK.green);
|
|
29
|
+
} finally {
|
|
30
|
+
rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("repo config layers over user config", () => {
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg2-"));
|
|
36
|
+
const user = join(dir, "user.toml");
|
|
37
|
+
const repoRoot = join(dir, "repo");
|
|
38
|
+
writeFileSync(user, `[keys]\ncomment = "a"\n`);
|
|
39
|
+
Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
|
|
40
|
+
writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
|
|
41
|
+
try {
|
|
42
|
+
const c = loadConfig({ userConfigPath: user, repoRoot });
|
|
43
|
+
expect(c.keys["comment"]).toEqual(["z"]);
|
|
44
|
+
} finally {
|
|
45
|
+
rmSync(dir, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("broken config never blocks a review", () => {
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg3-"));
|
|
51
|
+
const path = join(dir, "config.toml");
|
|
52
|
+
writeFileSync(path, "not [valid toml");
|
|
53
|
+
try {
|
|
54
|
+
const c = loadConfig({ userConfigPath: path });
|
|
55
|
+
expect(c.keys["comment"]).toEqual(["c"]);
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(dir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("integrations.obsidian config", () => {
|
|
63
|
+
test("defaults when the section is absent", () => {
|
|
64
|
+
const c = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
65
|
+
expect(c.integrations.obsidian.vault).toBeUndefined();
|
|
66
|
+
expect(c.integrations.obsidian.folder).toBe("cueloop");
|
|
67
|
+
expect(c.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
|
|
68
|
+
expect(c.integrations.obsidian.separator).toBe("space");
|
|
69
|
+
expect(c.integrations.obsidian.exportOn).toBe("manual");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("user config sets the section; invalid enum values are ignored", () => {
|
|
73
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs-"));
|
|
74
|
+
const path = join(dir, "config.toml");
|
|
75
|
+
writeFileSync(
|
|
76
|
+
path,
|
|
77
|
+
`[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
|
|
78
|
+
);
|
|
79
|
+
try {
|
|
80
|
+
const c = loadConfig({ userConfigPath: path });
|
|
81
|
+
expect(c.integrations.obsidian.vault).toBe("/notes/vault");
|
|
82
|
+
expect(c.integrations.obsidian.folder).toBe("plans");
|
|
83
|
+
expect(c.integrations.obsidian.exportOn).toBe("approve");
|
|
84
|
+
expect(c.integrations.obsidian.separator).toBe("space"); // invalid value falls back
|
|
85
|
+
expect(c.keys["comment"]).toEqual(["c"]); // other sections untouched
|
|
86
|
+
} finally {
|
|
87
|
+
rmSync(dir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("repo config layers over user config for the section", () => {
|
|
92
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs2-"));
|
|
93
|
+
const user = join(dir, "user.toml");
|
|
94
|
+
const repoRoot = join(dir, "repo");
|
|
95
|
+
writeFileSync(user, `[integrations.obsidian]\nvault = "/user/vault"\nexportOn = "resolve"\n`);
|
|
96
|
+
Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
|
|
97
|
+
writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
|
|
98
|
+
try {
|
|
99
|
+
const c = loadConfig({ userConfigPath: user, repoRoot });
|
|
100
|
+
expect(c.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
|
|
101
|
+
expect(c.integrations.obsidian.exportOn).toBe("resolve");
|
|
102
|
+
expect(c.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
|
|
103
|
+
} finally {
|
|
104
|
+
rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("actionFor", () => {
|
|
110
|
+
test("resolves keys and shifted keys to actions", () => {
|
|
111
|
+
expect(actionFor(DEFAULT_KEYS, "j", false)).toBe("down");
|
|
112
|
+
expect(actionFor(DEFAULT_KEYS, "g", true)).toBe("bottom"); // G
|
|
113
|
+
expect(actionFor(DEFAULT_KEYS, "return", false)).toBe("submit");
|
|
114
|
+
expect(actionFor(DEFAULT_KEYS, "zz", false)).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layered TOML config (#20): built-in defaults → user config → trusted repo
|
|
3
|
+
* config → env. Sections: [keys] action = "combo" (every action rebindable),
|
|
4
|
+
* [theme] per-token overrides, [integrations.obsidian] notes-vault export.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
11
|
+
import { DARK, type Theme } from "./theme";
|
|
12
|
+
|
|
13
|
+
export interface KeymapConfig {
|
|
14
|
+
[action: string]: string | string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IntegrationsConfig {
|
|
18
|
+
obsidian: ObsidianConfig;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CueloopConfig {
|
|
22
|
+
keys: Record<string, string[]>;
|
|
23
|
+
theme: Theme;
|
|
24
|
+
integrations: IntegrationsConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Every action in the grammar, with its default binding(s). */
|
|
28
|
+
export const DEFAULT_KEYS: Record<string, string[]> = {
|
|
29
|
+
down: ["j", "down"],
|
|
30
|
+
up: ["k", "up"],
|
|
31
|
+
top: ["g"],
|
|
32
|
+
bottom: ["G"],
|
|
33
|
+
span: ["v"],
|
|
34
|
+
comment: ["c"],
|
|
35
|
+
suggest: ["s"],
|
|
36
|
+
cut: ["x"],
|
|
37
|
+
edit: ["e"],
|
|
38
|
+
next_annotation: ["n"],
|
|
39
|
+
prev_annotation: ["p"],
|
|
40
|
+
delete_annotation: ["backspace"],
|
|
41
|
+
submit: ["return", "enter"],
|
|
42
|
+
quit: ["q"],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function parseToml(text: string): Record<string, unknown> {
|
|
46
|
+
// Bun ships a native TOML parser
|
|
47
|
+
return Bun.TOML.parse(text) as Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig {
|
|
51
|
+
const out: CueloopConfig = {
|
|
52
|
+
keys: { ...base.keys },
|
|
53
|
+
theme: { ...base.theme },
|
|
54
|
+
integrations: { obsidian: { ...base.integrations.obsidian } },
|
|
55
|
+
};
|
|
56
|
+
const keys = raw["keys"] as KeymapConfig | undefined;
|
|
57
|
+
if (keys) {
|
|
58
|
+
for (const [action, combo] of Object.entries(keys)) {
|
|
59
|
+
out.keys[action] = Array.isArray(combo) ? combo : [combo];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const theme = raw["theme"] as Partial<Record<keyof Theme, string>> | undefined;
|
|
63
|
+
if (theme) {
|
|
64
|
+
for (const [token, value] of Object.entries(theme)) {
|
|
65
|
+
if (token in out.theme && typeof value === "string") {
|
|
66
|
+
(out.theme as unknown as Record<string, string>)[token] = value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const integrations = raw["integrations"] as Record<string, unknown> | undefined;
|
|
71
|
+
const obsidian = integrations?.["obsidian"] as Record<string, unknown> | undefined;
|
|
72
|
+
if (obsidian) {
|
|
73
|
+
const o = out.integrations.obsidian;
|
|
74
|
+
if (typeof obsidian["vault"] === "string") o.vault = obsidian["vault"];
|
|
75
|
+
if (typeof obsidian["folder"] === "string") o.folder = obsidian["folder"];
|
|
76
|
+
if (typeof obsidian["filenameFormat"] === "string") o.filenameFormat = obsidian["filenameFormat"];
|
|
77
|
+
const sep = obsidian["separator"];
|
|
78
|
+
if (sep === "space" || sep === "dash" || sep === "underscore") o.separator = sep;
|
|
79
|
+
const on = obsidian["exportOn"];
|
|
80
|
+
if (on === "approve" || on === "resolve" || on === "manual") o.exportOn = on;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function loadConfig(opts: { repoRoot?: string; userConfigPath?: string } = {}): CueloopConfig {
|
|
86
|
+
let config: CueloopConfig = {
|
|
87
|
+
keys: { ...DEFAULT_KEYS },
|
|
88
|
+
theme: { ...DARK },
|
|
89
|
+
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
90
|
+
};
|
|
91
|
+
const userPath =
|
|
92
|
+
opts.userConfigPath ??
|
|
93
|
+
process.env.CUELOOP_CONFIG ??
|
|
94
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml");
|
|
95
|
+
for (const path of [userPath, opts.repoRoot ? join(opts.repoRoot, ".cueloop", "config.toml") : undefined]) {
|
|
96
|
+
if (!path || !existsSync(path)) continue;
|
|
97
|
+
try {
|
|
98
|
+
config = layer(config, parseToml(readFileSync(path, "utf8")));
|
|
99
|
+
} catch {
|
|
100
|
+
// a broken config never blocks a review; defaults win
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return config;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Reverse lookup: key name (+shift) → action, per the loaded keymap. */
|
|
107
|
+
export function actionFor(keys: Record<string, string[]>, name: string, shift: boolean): string | undefined {
|
|
108
|
+
const wanted = shift && name.length === 1 ? name.toUpperCase() : name;
|
|
109
|
+
for (const [action, combos] of Object.entries(keys)) {
|
|
110
|
+
if (combos.includes(wanted)) return action;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
package/src/editor.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-level edit mode = the $EDITOR hand-off (#22, #11): write the working
|
|
3
|
+
* copy to a temp file, run the user's editor on it, read the result back.
|
|
4
|
+
* Tests point CUELOOP_EDITOR at a non-interactive command.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export interface EditResult {
|
|
12
|
+
content: string;
|
|
13
|
+
changed: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function editInEditor(content: string, filename = "plan.md"): EditResult {
|
|
17
|
+
const editor = process.env.CUELOOP_EDITOR ?? process.env.VISUAL ?? process.env.EDITOR;
|
|
18
|
+
if (!editor) throw new Error("no editor: set $EDITOR (or $CUELOOP_EDITOR)");
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-edit-"));
|
|
20
|
+
const path = join(dir, filename);
|
|
21
|
+
writeFileSync(path, content);
|
|
22
|
+
try {
|
|
23
|
+
const proc = Bun.spawnSync([...editor.split(" "), path], {
|
|
24
|
+
stdin: "inherit",
|
|
25
|
+
stdout: "inherit",
|
|
26
|
+
stderr: "inherit",
|
|
27
|
+
});
|
|
28
|
+
if (proc.exitCode !== 0) throw new Error(`editor exited ${proc.exitCode}`);
|
|
29
|
+
const next = readFileSync(path, "utf8");
|
|
30
|
+
return { content: next, changed: next !== content };
|
|
31
|
+
} finally {
|
|
32
|
+
rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
package/src/run.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Real-terminal entry: render the App and resolve when the user quits. */
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { createCliRenderer } from "@opentui/core";
|
|
5
|
+
import { createRoot } from "@opentui/react";
|
|
6
|
+
import { App } from "./App";
|
|
7
|
+
|
|
8
|
+
export interface RunClientOptions {
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
home?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function runClient(opts: RunClientOptions): Promise<number> {
|
|
14
|
+
const renderer = await createCliRenderer();
|
|
15
|
+
return new Promise<number>((resolve) => {
|
|
16
|
+
createRoot(renderer).render(
|
|
17
|
+
React.createElement(App, {
|
|
18
|
+
home: opts.home,
|
|
19
|
+
sessionId: opts.sessionId,
|
|
20
|
+
onExit: (code: number) => {
|
|
21
|
+
renderer.destroy();
|
|
22
|
+
resolve(code);
|
|
23
|
+
process.exit(code);
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH smoke test: serveClient on an ephemeral localhost port, a programmatic
|
|
3
|
+
* ssh2 client connects with no credentials (the tunnel-of-trust model),
|
|
4
|
+
* requests a PTY shell, and the first frames carry the observer TUI.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { Client } from "ssh2";
|
|
12
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
13
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
14
|
+
import { serveClient, type ServeHandle } from "./serve";
|
|
15
|
+
|
|
16
|
+
const PLAN = "# Rollout Plan\n\nShip the store move behind a flag.\n";
|
|
17
|
+
|
|
18
|
+
let home: string;
|
|
19
|
+
let daemon: DaemonServer;
|
|
20
|
+
let session: ReviewSession;
|
|
21
|
+
let handle: ServeHandle;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-serve-"));
|
|
25
|
+
daemon = new DaemonServer({ home, idleExitMs: 0 });
|
|
26
|
+
daemon.start();
|
|
27
|
+
session = daemon.core.sessionCreate({
|
|
28
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
29
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Rollout Plan", planPath: "plan.md" } },
|
|
30
|
+
});
|
|
31
|
+
handle = await serveClient({ port: 0, sessionId: session.id, home, banner: false });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await handle.close();
|
|
36
|
+
daemon.stop();
|
|
37
|
+
rmSync(home, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Connect, open a PTY shell, resolve with the bytes seen until `until` matches. */
|
|
41
|
+
function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 8000): Promise<string> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const conn = new Client();
|
|
44
|
+
let out = "";
|
|
45
|
+
let done = false;
|
|
46
|
+
const finish = (err?: Error) => {
|
|
47
|
+
if (done) return;
|
|
48
|
+
done = true;
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
conn.end();
|
|
51
|
+
err ? reject(err) : resolve(out);
|
|
52
|
+
};
|
|
53
|
+
const timer = setTimeout(() => finish(new Error(`timed out; captured:\n${out}`)), timeoutMs);
|
|
54
|
+
conn
|
|
55
|
+
.on("ready", () => {
|
|
56
|
+
conn.shell({ term: "xterm-256color", cols: 100, rows: 30 }, (err, stream) => {
|
|
57
|
+
if (err) return finish(err);
|
|
58
|
+
stream.on("data", (d: Buffer) => {
|
|
59
|
+
out += d.toString("utf8");
|
|
60
|
+
if (until(out)) finish();
|
|
61
|
+
});
|
|
62
|
+
stream.on("close", () => finish(new Error(`stream closed early; captured:\n${out}`)));
|
|
63
|
+
});
|
|
64
|
+
})
|
|
65
|
+
.on("error", (err) => finish(err))
|
|
66
|
+
// password-less: the server runs auth "open", so `none` auth succeeds
|
|
67
|
+
.connect({ host: "127.0.0.1", port, username: "observer" });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("cueloop serve", () => {
|
|
72
|
+
test("listens on localhost, persists a host key under CUELOOP_HOME/ssh", () => {
|
|
73
|
+
expect(handle.host).toBe("127.0.0.1");
|
|
74
|
+
expect(handle.port).toBeGreaterThan(0);
|
|
75
|
+
expect(handle.fingerprints.length).toBe(1);
|
|
76
|
+
expect(handle.fingerprints[0]).toStartWith("SHA256:");
|
|
77
|
+
expect(existsSync(join(home, "ssh", "host_key"))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("an anonymous ssh client gets the observer TUI for the session", async () => {
|
|
81
|
+
const bytes = await sshCapture(handle.port, (s) => s.includes("Rollout Plan") && s.includes("observer"));
|
|
82
|
+
expect(bytes).toContain("cueloop");
|
|
83
|
+
expect(bytes).toContain("Rollout Plan");
|
|
84
|
+
// observer chrome, not the controller's mutating hint bar
|
|
85
|
+
expect(bytes).toContain("observer - read-only");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("two observers can watch at once", async () => {
|
|
89
|
+
const [a, b] = await Promise.all([
|
|
90
|
+
sshCapture(handle.port, (s) => s.includes("Rollout Plan")),
|
|
91
|
+
sshCapture(handle.port, (s) => s.includes("Rollout Plan")),
|
|
92
|
+
]);
|
|
93
|
+
expect(a).toContain("Rollout Plan");
|
|
94
|
+
expect(b).toContain("Rollout Plan");
|
|
95
|
+
});
|
|
96
|
+
});
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH-served TUI (#22 share path): `cueloop serve` lets teammates join a
|
|
3
|
+
* review session over plain ssh. Every SSH connection renders <App> in
|
|
4
|
+
* observer mode (readOnly) against the same local daemon; the one writable
|
|
5
|
+
* controller stays the local owner's own `cueloop` TUI.
|
|
6
|
+
*
|
|
7
|
+
* Trust model - tunnel of trust, no passwords, no keys:
|
|
8
|
+
* the server authenticates nobody ("open" auth). Access control is the act of
|
|
9
|
+
* sharing the address deliberately: it binds to 127.0.0.1 by default, so a
|
|
10
|
+
* remote teammate reaches it only through a channel the owner opened on
|
|
11
|
+
* purpose (an SSH tunnel, a tailnet address via --host). Anyone who can reach
|
|
12
|
+
* the port can watch - and only watch: observers cannot mutate the session.
|
|
13
|
+
* The host key is generated once under CUELOOP_HOME/ssh/host_key (0600) so
|
|
14
|
+
* fingerprints stay stable across restarts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdirSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import React from "react";
|
|
20
|
+
import { createRoot } from "@opentui/react";
|
|
21
|
+
import { createServer } from "@opentui/ssh";
|
|
22
|
+
import { cueloopHome } from "@cueloop/daemon";
|
|
23
|
+
import { App } from "./App";
|
|
24
|
+
|
|
25
|
+
export interface ServeOptions {
|
|
26
|
+
/** TCP port for the SSH listener; 0 picks an ephemeral port. Default 2222. */
|
|
27
|
+
port?: number;
|
|
28
|
+
/** Bind address. Default 127.0.0.1 - widen deliberately (e.g. a tailnet IP). */
|
|
29
|
+
host?: string;
|
|
30
|
+
/** Open every connection on this session; omit for the inbox. */
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
/** CUELOOP_HOME override (daemon socket + ssh host key live under it). */
|
|
33
|
+
home?: string;
|
|
34
|
+
/** Silence the @opentui/ssh startup banner (tests). Default true. */
|
|
35
|
+
banner?: boolean;
|
|
36
|
+
onError?: (err: unknown) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ServeHandle {
|
|
40
|
+
host: string;
|
|
41
|
+
port: number;
|
|
42
|
+
/** SHA256 host-key fingerprints, for out-of-band verification. */
|
|
43
|
+
fingerprints: string[];
|
|
44
|
+
close(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle> {
|
|
48
|
+
const home = opts.home ?? cueloopHome();
|
|
49
|
+
const sshDir = join(home, "ssh");
|
|
50
|
+
mkdirSync(sshDir, { recursive: true, mode: 0o700 });
|
|
51
|
+
|
|
52
|
+
const server = createServer({
|
|
53
|
+
// password-less by design; see the trust model in the module comment
|
|
54
|
+
auth: "open",
|
|
55
|
+
hostKey: { path: join(sshDir, "host_key") },
|
|
56
|
+
startupBanner: opts.banner ?? true,
|
|
57
|
+
idleTimeout: "2h",
|
|
58
|
+
onError: opts.onError,
|
|
59
|
+
}).serve((session) => {
|
|
60
|
+
const root = createRoot(session.renderer);
|
|
61
|
+
root.render(
|
|
62
|
+
React.createElement(App, {
|
|
63
|
+
home,
|
|
64
|
+
sessionId: opts.sessionId,
|
|
65
|
+
readOnly: true,
|
|
66
|
+
// q disconnects only this observer, never the server
|
|
67
|
+
onExit: () => session.end(),
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
session.onClose(() => root.unmount());
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const info = await server.listen(opts.port ?? 2222, opts.host ?? "127.0.0.1");
|
|
74
|
+
return { ...info, close: () => server.close() };
|
|
75
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic theme tokens (#22). Values carry over from the design-system
|
|
3
|
+
* prototypes (tokens.css). One built-in dark theme for now; the theme
|
|
4
|
+
* registry (palette-derived themes, terminal theme, user overrides) layers
|
|
5
|
+
* on top of these slots without touching consumers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface Theme {
|
|
9
|
+
bg: string;
|
|
10
|
+
panel: string;
|
|
11
|
+
elevated: string;
|
|
12
|
+
border: string;
|
|
13
|
+
text: string;
|
|
14
|
+
textMuted: string;
|
|
15
|
+
textDim: string;
|
|
16
|
+
accent: string;
|
|
17
|
+
accentInk: string;
|
|
18
|
+
green: string;
|
|
19
|
+
red: string;
|
|
20
|
+
blue: string;
|
|
21
|
+
cursorBg: string;
|
|
22
|
+
markCommentBg: string;
|
|
23
|
+
markSuggestionBg: string;
|
|
24
|
+
insFg: string;
|
|
25
|
+
delFg: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const DARK: Theme = {
|
|
29
|
+
bg: "#1f1f29",
|
|
30
|
+
panel: "#1a1a23",
|
|
31
|
+
elevated: "#23232f",
|
|
32
|
+
border: "#3a3a4a",
|
|
33
|
+
text: "#e4e6ec",
|
|
34
|
+
textMuted: "#b1b6c4",
|
|
35
|
+
textDim: "#6b7280",
|
|
36
|
+
accent: "#f5a3a3",
|
|
37
|
+
accentInk: "#2a1416",
|
|
38
|
+
green: "#62d96b",
|
|
39
|
+
red: "#ff6b6b",
|
|
40
|
+
blue: "#84a6e8",
|
|
41
|
+
cursorBg: "#33262a",
|
|
42
|
+
markCommentBg: "#3d2a2e",
|
|
43
|
+
markSuggestionBg: "#20351f",
|
|
44
|
+
insFg: "#62d96b",
|
|
45
|
+
delFg: "#ff6b6b",
|
|
46
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { diffRowAnchor, diffRows } from "./view-diff";
|
|
3
|
+
|
|
4
|
+
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
5
|
+
index 111..222 100644
|
|
6
|
+
--- a/src/store.ts
|
|
7
|
+
+++ b/src/store.ts
|
|
8
|
+
@@ -1,5 +1,6 @@
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
export class Store {
|
|
11
|
+
- private items = [];
|
|
12
|
+
+ private items = new Map();
|
|
13
|
+
+ private ready = false;
|
|
14
|
+
constructor() {}
|
|
15
|
+
@@ -20,3 +21,3 @@
|
|
16
|
+
export function helper() {
|
|
17
|
+
- return 1;
|
|
18
|
+
+ return 2;
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
describe("diffRows", () => {
|
|
23
|
+
test("flattens files, hunks, and signed lines with line numbers", () => {
|
|
24
|
+
const rows = diffRows(PATCH);
|
|
25
|
+
expect(rows[0]).toMatchObject({ t: "file", file: "src/store.ts" });
|
|
26
|
+
expect(rows[1]!.t).toBe("hunk");
|
|
27
|
+
const del = rows.find((r) => r.t === "del")!;
|
|
28
|
+
expect(del.text).toContain("private items = [];");
|
|
29
|
+
expect(del.oldLine).toBe(3);
|
|
30
|
+
const adds = rows.filter((r) => r.t === "add");
|
|
31
|
+
expect(adds[0]!.text).toContain("new Map()");
|
|
32
|
+
expect(adds[0]!.newLine).toBe(3);
|
|
33
|
+
expect(adds[1]!.newLine).toBe(4);
|
|
34
|
+
// two hunks
|
|
35
|
+
expect(rows.filter((r) => r.t === "hunk").length).toBe(2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("context lines carry both line numbers", () => {
|
|
39
|
+
const rows = diffRows(PATCH);
|
|
40
|
+
const ctx = rows.find((r) => r.t === "ctx")!;
|
|
41
|
+
expect(ctx.oldLine).toBe(1);
|
|
42
|
+
expect(ctx.newLine).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("diffRowAnchor", () => {
|
|
47
|
+
test("quote is the line, neighbors are the context selectors", () => {
|
|
48
|
+
const rows = diffRows(PATCH);
|
|
49
|
+
const idx = rows.findIndex((r) => r.text.includes("new Map()"));
|
|
50
|
+
const anchor = diffRowAnchor(rows, idx);
|
|
51
|
+
expect(anchor.quote).toContain("new Map()");
|
|
52
|
+
expect(anchor.prefix.length).toBeGreaterThan(0);
|
|
53
|
+
expect(anchor.suffix.length).toBeGreaterThan(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/view-diff.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff artifact projection (#18 slice 3): flatten @pierre/diffs' parsed
|
|
3
|
+
* patch model into render rows. Line-anchored annotations use the same
|
|
4
|
+
* quote-primary anchors as plans: quote = the line content, prefix/suffix =
|
|
5
|
+
* the neighbor lines - so the whole anchor/feedback pipeline is shared.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs";
|
|
9
|
+
|
|
10
|
+
export type DiffRowType = "file" | "hunk" | "ctx" | "add" | "del";
|
|
11
|
+
|
|
12
|
+
export interface DiffRow {
|
|
13
|
+
t: DiffRowType;
|
|
14
|
+
text: string;
|
|
15
|
+
file: string;
|
|
16
|
+
oldLine?: number;
|
|
17
|
+
newLine?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function diffRows(patchText: string): DiffRow[] {
|
|
21
|
+
const rows: DiffRow[] = [];
|
|
22
|
+
const patches = parsePatchFiles(patchText);
|
|
23
|
+
for (const patch of patches) {
|
|
24
|
+
for (const file of patch.files) {
|
|
25
|
+
rows.push({ t: "file", text: fileLabel(file), file: file.name });
|
|
26
|
+
for (const hunk of file.hunks) {
|
|
27
|
+
rows.push({ t: "hunk", text: hunk.hunkSpecs ?? "@@", file: file.name });
|
|
28
|
+
let oldLine = hunk.deletionStart;
|
|
29
|
+
let newLine = hunk.additionStart;
|
|
30
|
+
for (const seg of hunk.hunkContent) {
|
|
31
|
+
if (seg.type === "context") {
|
|
32
|
+
for (let i = 0; i < seg.lines; i++) {
|
|
33
|
+
rows.push({
|
|
34
|
+
t: "ctx",
|
|
35
|
+
text: file.additionLines[seg.additionLineIndex + i] ?? "",
|
|
36
|
+
file: file.name,
|
|
37
|
+
oldLine: oldLine++,
|
|
38
|
+
newLine: newLine++,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
for (let i = 0; i < seg.deletions; i++) {
|
|
43
|
+
rows.push({
|
|
44
|
+
t: "del",
|
|
45
|
+
text: file.deletionLines[seg.deletionLineIndex + i] ?? "",
|
|
46
|
+
file: file.name,
|
|
47
|
+
oldLine: oldLine++,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i < seg.additions; i++) {
|
|
51
|
+
rows.push({
|
|
52
|
+
t: "add",
|
|
53
|
+
text: file.additionLines[seg.additionLineIndex + i] ?? "",
|
|
54
|
+
file: file.name,
|
|
55
|
+
newLine: newLine++,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return rows;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fileLabel(file: FileDiffMetadata): string {
|
|
67
|
+
return file.prevName && file.prevName !== file.name ? `${file.prevName} → ${file.name}` : file.name;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Quote-primary anchor for a diff row: neighbors as context selectors. */
|
|
71
|
+
export function diffRowAnchor(rows: DiffRow[], idx: number): { quote: string; prefix: string; suffix: string } {
|
|
72
|
+
const row = rows[idx]!;
|
|
73
|
+
const prev = rows[idx - 1];
|
|
74
|
+
const next = rows[idx + 1];
|
|
75
|
+
return {
|
|
76
|
+
quote: row.text,
|
|
77
|
+
prefix: prev && (prev.t === "ctx" || prev.t === "add" || prev.t === "del") ? prev.text.slice(-24) : "",
|
|
78
|
+
suffix: next && (next.t === "ctx" || next.t === "add" || next.t === "del") ? next.text.slice(0, 24) : "",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Location label for the rail and feedback: file:newLine (or old for del). */
|
|
83
|
+
export function diffRowLocation(row: DiffRow): string {
|
|
84
|
+
const line = row.newLine ?? row.oldLine;
|
|
85
|
+
return line !== undefined ? `${row.file}:${line}` : row.file;
|
|
86
|
+
}
|