@cueloop/client 0.1.0-alpha.16 → 0.1.0-alpha.17
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 +5 -5
- package/src/App.plan-review.test.tsx +1 -1
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +16 -16
- package/src/App.test.tsx +1 -1
- package/src/App.tsx +137 -138
- package/src/App.walk.test.tsx +1 -1
- package/src/components/AnnotationCard.tsx +4 -4
- package/src/components/ComposeBar.tsx +2 -2
- package/src/components/DiffSheet.tsx +8 -8
- package/src/components/PlanSheet.stories.tsx +3 -3
- package/src/components/PlanSheet.tsx +31 -31
- package/src/components/WalkWizard.tsx +1 -1
- package/src/components/stories.test.tsx +1 -1
- package/src/components/story-fixtures.ts +1 -1
- package/src/components/story.ts +1 -1
- package/src/components/syntax-highlight.ts +1 -1
- package/src/components/theme-context.tsx +1 -1
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +29 -29
- package/src/config.ts +12 -12
- package/src/editor.ts +1 -1
- package/src/herdr.ts +1 -1
- package/src/key-bindings.ts +1 -1
- package/src/keymap.test.ts +111 -111
- package/src/keymap.ts +64 -64
- package/src/run.ts +3 -3
- package/src/serve.test.ts +6 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.ts +71 -71
- package/src/theme.ts +1 -1
- package/src/view-diff.test.ts +8 -8
- package/src/view-diff.ts +22 -22
- package/src/{view.test.ts → view-plan.test.ts} +48 -48
- package/src/view-plan.ts +362 -0
- package/src/walk.ts +4 -4
- package/src/components/format.ts +0 -6
- package/src/view.ts +0 -341
package/src/serve.test.ts
CHANGED
|
@@ -38,7 +38,7 @@ afterEach(async () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
/** Connect, open a PTY shell, resolve with the bytes seen until `until` matches. */
|
|
41
|
-
function sshCapture(port: number, until: (
|
|
41
|
+
function sshCapture(port: number, until: (frame: string) => boolean, timeoutMs = 8000): Promise<string> {
|
|
42
42
|
return new Promise((resolve, reject) => {
|
|
43
43
|
const conn = new Client();
|
|
44
44
|
let out = "";
|
|
@@ -55,8 +55,8 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
|
|
|
55
55
|
.on("ready", () => {
|
|
56
56
|
conn.shell({ term: "xterm-256color", cols: 100, rows: 30 }, (err, stream) => {
|
|
57
57
|
if (err) return finish(err);
|
|
58
|
-
stream.on("data", (
|
|
59
|
-
out +=
|
|
58
|
+
stream.on("data", (chunk: Buffer) => {
|
|
59
|
+
out += chunk.toString("utf8");
|
|
60
60
|
if (until(out)) finish();
|
|
61
61
|
});
|
|
62
62
|
stream.on("close", () => finish(new Error(`stream closed early; captured:\n${out}`)));
|
|
@@ -78,7 +78,7 @@ describe("cueloop serve", () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
test("an anonymous ssh client gets the observer TUI for the session", async () => {
|
|
81
|
-
const bytes = await sshCapture(handle.port, (
|
|
81
|
+
const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan") && frame.includes("observer"));
|
|
82
82
|
expect(bytes).toContain("cueloop");
|
|
83
83
|
expect(bytes).toContain("Rollout Plan");
|
|
84
84
|
// observer chrome, not the controller's mutating hint bar
|
|
@@ -87,8 +87,8 @@ describe("cueloop serve", () => {
|
|
|
87
87
|
|
|
88
88
|
test("two observers can watch at once", async () => {
|
|
89
89
|
const [a, b] = await Promise.all([
|
|
90
|
-
sshCapture(handle.port, (
|
|
91
|
-
sshCapture(handle.port, (
|
|
90
|
+
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
91
|
+
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
92
92
|
]);
|
|
93
93
|
expect(a).toContain("Rollout Plan");
|
|
94
94
|
expect(b).toContain("Rollout Plan");
|
package/src/serve.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SSH-served TUI
|
|
2
|
+
* SSH-served TUI: `cueloop serve` lets teammates join a
|
|
3
3
|
* review session over plain ssh. Every SSH connection renders <App> in
|
|
4
4
|
* observer mode (readOnly) against the same local daemon; the one writable
|
|
5
5
|
* controller stays the local owner's own `cueloop` TUI.
|
|
@@ -44,8 +44,8 @@ export interface ServeHandle {
|
|
|
44
44
|
close(): Promise<void>;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export async function serveClient(
|
|
48
|
-
const home =
|
|
47
|
+
export async function serveClient(options: ServeOptions = {}): Promise<ServeHandle> {
|
|
48
|
+
const home = options.home ?? cueloopHome();
|
|
49
49
|
const sshDir = join(home, "ssh");
|
|
50
50
|
mkdirSync(sshDir, { recursive: true, mode: 0o700 });
|
|
51
51
|
|
|
@@ -53,15 +53,15 @@ export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle>
|
|
|
53
53
|
// password-less by design; see the trust model in the module comment
|
|
54
54
|
auth: "open",
|
|
55
55
|
hostKey: { path: join(sshDir, "host_key") },
|
|
56
|
-
startupBanner:
|
|
56
|
+
startupBanner: options.banner ?? true,
|
|
57
57
|
idleTimeout: "2h",
|
|
58
|
-
onError:
|
|
58
|
+
onError: options.onError,
|
|
59
59
|
}).serve((session) => {
|
|
60
60
|
const root = createRoot(session.renderer);
|
|
61
61
|
root.render(
|
|
62
62
|
React.createElement(App, {
|
|
63
63
|
home,
|
|
64
|
-
sessionId:
|
|
64
|
+
sessionId: options.sessionId,
|
|
65
65
|
readOnly: true,
|
|
66
66
|
// q disconnects only this observer, never the server
|
|
67
67
|
onExit: () => session.end(),
|
|
@@ -70,6 +70,6 @@ export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle>
|
|
|
70
70
|
session.onClose(() => root.unmount());
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
const info = await server.listen(
|
|
73
|
+
const info = await server.listen(options.port ?? 2222, options.host ?? "127.0.0.1");
|
|
74
74
|
return { ...info, close: () => server.close() };
|
|
75
75
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The review-session controller
|
|
2
|
+
* The review-session controller: every daemon round-trip and mutation
|
|
3
3
|
* verb behind one React-free object. It owns connect/autostart/subscribe,
|
|
4
4
|
* the session/inbox/status/error snapshot, optimistic apply, the mutation
|
|
5
5
|
* verbs (cut/edit/annotate/submit/...), and the post-submit completion
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from "@cueloop/schema";
|
|
23
23
|
import { Registry, type Exporter } from "@cueloop/extension-api";
|
|
24
24
|
import { createObsidianExtension, shouldExport, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
25
|
-
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view";
|
|
25
|
+
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
|
|
26
26
|
import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
|
|
27
27
|
import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
|
|
28
28
|
import { editInEditor } from "./editor";
|
|
@@ -81,8 +81,8 @@ export interface ReviewController {
|
|
|
81
81
|
connect(): void;
|
|
82
82
|
close(): void;
|
|
83
83
|
/** Loaded config parts the controller acts on: auto-close and exporters. */
|
|
84
|
-
applyConfig(
|
|
85
|
-
setStatus(
|
|
84
|
+
applyConfig(config: CueloopConfig): void;
|
|
85
|
+
setStatus(message: string): void;
|
|
86
86
|
/** Derived projections, cached per session identity. */
|
|
87
87
|
display(): DisplayBlock[];
|
|
88
88
|
rows(): DiffRow[];
|
|
@@ -92,14 +92,14 @@ export interface ReviewController {
|
|
|
92
92
|
/** Open a session from the inbox. */
|
|
93
93
|
open(id: string): void;
|
|
94
94
|
/** Cut the block under the cursor, or restore a cut one. */
|
|
95
|
-
cut(
|
|
95
|
+
cut(displayIndex: number): void;
|
|
96
96
|
/** The $EDITOR hand-off on the working copy. */
|
|
97
97
|
edit(): void;
|
|
98
98
|
/**
|
|
99
99
|
* Anchor and store an annotation; both plan and diff anchor constructions.
|
|
100
100
|
* Returns the minted annotation id so the view can select the new card.
|
|
101
101
|
*/
|
|
102
|
-
annotate(kind: "comment" | "suggestion",
|
|
102
|
+
annotate(kind: "comment" | "suggestion", displayIndex: number, start: number, end: number, body: string): string | undefined;
|
|
103
103
|
/** Rewrite a stored annotation's body in place (the rail-card edit). */
|
|
104
104
|
updateAnnotation(id: string, body: string): void;
|
|
105
105
|
removeAnnotation(id: string): void;
|
|
@@ -120,15 +120,15 @@ export interface ReviewController {
|
|
|
120
120
|
optInAutoClose(): void;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
export function createReviewController(
|
|
124
|
-
return new Controller(
|
|
123
|
+
export function createReviewController(options: ReviewControllerOptions): ReviewController {
|
|
124
|
+
return new Controller(options);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
class Controller implements ReviewController {
|
|
128
128
|
readonly readOnly: boolean;
|
|
129
129
|
private client: DaemonClient | null = null;
|
|
130
130
|
private closed = false;
|
|
131
|
-
private
|
|
131
|
+
private snapshot: ControllerSnapshot = {
|
|
132
132
|
session: null,
|
|
133
133
|
inbox: null,
|
|
134
134
|
status: "",
|
|
@@ -151,9 +151,9 @@ class Controller implements ReviewController {
|
|
|
151
151
|
files: [],
|
|
152
152
|
};
|
|
153
153
|
|
|
154
|
-
constructor(private readonly
|
|
155
|
-
this.readOnly =
|
|
156
|
-
this.clock =
|
|
154
|
+
constructor(private readonly options: ReviewControllerOptions) {
|
|
155
|
+
this.readOnly = options.readOnly ?? false;
|
|
156
|
+
this.clock = options.clock ?? new SystemClock();
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
subscribe = (listener: () => void): (() => void) => {
|
|
@@ -161,28 +161,28 @@ class Controller implements ReviewController {
|
|
|
161
161
|
return () => this.listeners.delete(listener);
|
|
162
162
|
};
|
|
163
163
|
|
|
164
|
-
getSnapshot = (): ControllerSnapshot => this.
|
|
164
|
+
getSnapshot = (): ControllerSnapshot => this.snapshot;
|
|
165
165
|
|
|
166
166
|
private update(patch: Partial<ControllerSnapshot>): void {
|
|
167
|
-
this.
|
|
168
|
-
for (const
|
|
167
|
+
this.snapshot = { ...this.snapshot, ...patch };
|
|
168
|
+
for (const listener of this.listeners) listener();
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
connect(): void {
|
|
172
172
|
void (async () => {
|
|
173
173
|
try {
|
|
174
|
-
const client = await DaemonClient.connect({ home: this.
|
|
174
|
+
const client = await DaemonClient.connect({ home: this.options.home, autostart: true });
|
|
175
175
|
if (this.closed) return void client.close();
|
|
176
176
|
this.client = client;
|
|
177
|
-
client.onEvent((
|
|
177
|
+
client.onEvent((event) => {
|
|
178
178
|
// another controller/observer changed state: re-fetch
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
-
if (!this.
|
|
179
|
+
const session = this.snapshot.session;
|
|
180
|
+
if (session && event.sessionId === session.id) void this.refreshSession(session.id);
|
|
181
|
+
if (!this.options.sessionId) void this.refreshInbox();
|
|
182
182
|
});
|
|
183
183
|
await client.subscribe();
|
|
184
|
-
if (this.
|
|
185
|
-
this.update({ session: await client.sessionGet(this.
|
|
184
|
+
if (this.options.sessionId) {
|
|
185
|
+
this.update({ session: await client.sessionGet(this.options.sessionId) });
|
|
186
186
|
} else {
|
|
187
187
|
this.update({ inbox: await client.sessionList({ status: "pending" }) });
|
|
188
188
|
}
|
|
@@ -203,28 +203,28 @@ class Controller implements ReviewController {
|
|
|
203
203
|
this.countdown = undefined;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
applyConfig(
|
|
207
|
-
this.autoClose =
|
|
208
|
-
this.obsidian =
|
|
206
|
+
applyConfig(config: CueloopConfig): void {
|
|
207
|
+
this.autoClose = config.ui.autoClose;
|
|
208
|
+
this.obsidian = config.integrations.obsidian;
|
|
209
209
|
// bundled integrations register through the public extension API
|
|
210
210
|
const registry = new Registry();
|
|
211
|
-
void registry.load("obsidian", createObsidianExtension(
|
|
212
|
-
this.exporters =
|
|
211
|
+
void registry.load("obsidian", createObsidianExtension(config.integrations.obsidian)).then((record) => {
|
|
212
|
+
this.exporters = record.exporters;
|
|
213
213
|
});
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
setStatus(
|
|
217
|
-
this.update({ status:
|
|
216
|
+
setStatus(message: string): void {
|
|
217
|
+
this.update({ status: message });
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
// ── derived projections ─────────────────────
|
|
221
221
|
private ensureDerived(): void {
|
|
222
|
-
const
|
|
223
|
-
if (this.derivedFor ===
|
|
224
|
-
this.derivedFor =
|
|
225
|
-
const rows =
|
|
222
|
+
const session = this.snapshot.session;
|
|
223
|
+
if (this.derivedFor === session) return;
|
|
224
|
+
this.derivedFor = session;
|
|
225
|
+
const rows = session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
|
|
226
226
|
this.derived = {
|
|
227
|
-
display:
|
|
227
|
+
display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
|
|
228
228
|
rows,
|
|
229
229
|
files: walkFiles(rows),
|
|
230
230
|
};
|
|
@@ -246,8 +246,8 @@ class Controller implements ReviewController {
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
working(): string {
|
|
249
|
-
const
|
|
250
|
-
return
|
|
249
|
+
const session = this.snapshot.session;
|
|
250
|
+
return session ? (session.workingCopy ?? session.artifact.content) : "";
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
// Refreshes race the connection teardown: an event can arrive while close()
|
|
@@ -270,41 +270,41 @@ class Controller implements ReviewController {
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
/** Optimistic apply: the daemon response is the next session snapshot. */
|
|
273
|
-
private apply(
|
|
274
|
-
|
|
275
|
-
this.setStatus(String(
|
|
273
|
+
private apply(mutation: Promise<ReviewSession>): void {
|
|
274
|
+
mutation.then((session) => this.update({ session })).catch((error: unknown) =>
|
|
275
|
+
this.setStatus(String(error instanceof Error ? error.message : error)),
|
|
276
276
|
);
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
// ── verbs ───────────────────────────────────
|
|
280
280
|
open(id: string): void {
|
|
281
281
|
this.locallyViewed.clear();
|
|
282
|
-
const cached = this.
|
|
282
|
+
const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
|
|
283
283
|
if (cached) this.update({ session: cached });
|
|
284
284
|
else void this.refreshSession(id);
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
cut(
|
|
288
|
-
const session = this.
|
|
287
|
+
cut(displayIndex: number): void {
|
|
288
|
+
const session = this.snapshot.session;
|
|
289
289
|
if (!session || session.status === "resolved") return;
|
|
290
|
-
const
|
|
291
|
-
if (!
|
|
290
|
+
const block = this.display()[displayIndex];
|
|
291
|
+
if (!block) return;
|
|
292
292
|
const working = this.working();
|
|
293
|
-
if (
|
|
294
|
-
const line = restoreLine(nextWorkBlock(this.display(),
|
|
293
|
+
if (block.type === "del") {
|
|
294
|
+
const line = restoreLine(nextWorkBlock(this.display(), displayIndex), working.split("\n").length);
|
|
295
295
|
// restoreBlock returns undefined when the block structure round-trips
|
|
296
296
|
// to the submitted revision - the working copy is gone
|
|
297
|
-
const restored = restoreBlock(session.artifact.content, working,
|
|
297
|
+
const restored = restoreBlock(session.artifact.content, working, block.base!, line);
|
|
298
298
|
this.setWorkingCopy(restored);
|
|
299
299
|
this.setStatus("cut restored");
|
|
300
|
-
} else if (
|
|
301
|
-
this.setWorkingCopy(cutBlock(working,
|
|
300
|
+
} else if (block.work) {
|
|
301
|
+
this.setWorkingCopy(cutBlock(working, block.work));
|
|
302
302
|
this.setStatus("block cut - it serializes into the diff");
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
edit(): void {
|
|
307
|
-
const session = this.
|
|
307
|
+
const session = this.snapshot.session;
|
|
308
308
|
if (!session || session.status === "resolved") return;
|
|
309
309
|
try {
|
|
310
310
|
const result = editInEditor(this.working(), "plan.md");
|
|
@@ -334,21 +334,21 @@ class Controller implements ReviewController {
|
|
|
334
334
|
|
|
335
335
|
annotate(
|
|
336
336
|
kind: "comment" | "suggestion",
|
|
337
|
-
|
|
337
|
+
displayIndex: number,
|
|
338
338
|
start: number,
|
|
339
339
|
end: number,
|
|
340
340
|
body: string,
|
|
341
341
|
): string | undefined {
|
|
342
|
-
const session = this.
|
|
342
|
+
const session = this.snapshot.session;
|
|
343
343
|
if (!session) return undefined;
|
|
344
344
|
let anchor;
|
|
345
345
|
if (session.artifact.type === "diff") {
|
|
346
|
-
anchor = { ...diffRowAnchor(this.rows(),
|
|
346
|
+
anchor = { ...diffRowAnchor(this.rows(), displayIndex), blockIndex: displayIndex };
|
|
347
347
|
} else {
|
|
348
348
|
const display = this.display();
|
|
349
|
-
const workBlocks = display.filter((
|
|
350
|
-
const
|
|
351
|
-
anchor = makeAnchor(workBlocks,
|
|
349
|
+
const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
|
|
350
|
+
const workBlockIndex = display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
|
|
351
|
+
anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
|
|
352
352
|
}
|
|
353
353
|
const annotationId = newAnnotationId();
|
|
354
354
|
this.apply(
|
|
@@ -364,7 +364,7 @@ class Controller implements ReviewController {
|
|
|
364
364
|
}
|
|
365
365
|
|
|
366
366
|
updateAnnotation(id: string, body: string): void {
|
|
367
|
-
const session = this.
|
|
367
|
+
const session = this.snapshot.session;
|
|
368
368
|
if (!session) return;
|
|
369
369
|
const existing = session.annotations.find((annotation) => annotation.id === id);
|
|
370
370
|
if (!existing) return;
|
|
@@ -381,14 +381,14 @@ class Controller implements ReviewController {
|
|
|
381
381
|
}
|
|
382
382
|
|
|
383
383
|
removeAnnotation(id: string): void {
|
|
384
|
-
const session = this.
|
|
384
|
+
const session = this.snapshot.session;
|
|
385
385
|
if (!session) return;
|
|
386
386
|
this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
|
|
387
387
|
this.setStatus("annotation deleted");
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
setWorkingCopy(content: string | undefined): void {
|
|
391
|
-
const session = this.
|
|
391
|
+
const session = this.snapshot.session;
|
|
392
392
|
if (!session) return;
|
|
393
393
|
this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
|
|
394
394
|
}
|
|
@@ -399,11 +399,11 @@ class Controller implements ReviewController {
|
|
|
399
399
|
private locallyViewed = new Set<string>();
|
|
400
400
|
|
|
401
401
|
private viewedSet(): Set<string> {
|
|
402
|
-
return new Set([...(this.
|
|
402
|
+
return new Set([...(this.snapshot.session?.viewedPaths ?? []), ...this.locallyViewed]);
|
|
403
403
|
}
|
|
404
404
|
|
|
405
405
|
walkStart(): void {
|
|
406
|
-
const session = this.
|
|
406
|
+
const session = this.snapshot.session;
|
|
407
407
|
if (!session || session.artifact.type !== "diff") return;
|
|
408
408
|
const files = this.files();
|
|
409
409
|
if (files.length === 0) return this.setStatus("nothing to walk - the diff is empty");
|
|
@@ -412,8 +412,8 @@ class Controller implements ReviewController {
|
|
|
412
412
|
}
|
|
413
413
|
|
|
414
414
|
walkForward(): void {
|
|
415
|
-
const walk = this.
|
|
416
|
-
const session = this.
|
|
415
|
+
const walk = this.snapshot.walk;
|
|
416
|
+
const session = this.snapshot.session;
|
|
417
417
|
if (!walk || !session) return;
|
|
418
418
|
const files = this.files();
|
|
419
419
|
const current = files[walk.index];
|
|
@@ -428,17 +428,17 @@ class Controller implements ReviewController {
|
|
|
428
428
|
}
|
|
429
429
|
|
|
430
430
|
walkBack(): void {
|
|
431
|
-
const walk = this.
|
|
431
|
+
const walk = this.snapshot.walk;
|
|
432
432
|
if (!walk) return;
|
|
433
433
|
this.update({ walk: { index: Math.max(0, walk.index - 1) } });
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
walkLeave(): void {
|
|
437
|
-
if (this.
|
|
437
|
+
if (this.snapshot.walk) this.update({ walk: null });
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
submit(verdict: VerdictKind, summary: string): void {
|
|
441
|
-
const session = this.
|
|
441
|
+
const session = this.snapshot.session;
|
|
442
442
|
if (!session) return;
|
|
443
443
|
this.walkLeave();
|
|
444
444
|
this.client!.sessionResolve(session.id, verdict, summary)
|
|
@@ -450,8 +450,8 @@ class Controller implements ReviewController {
|
|
|
450
450
|
const obsidian = this.obsidian;
|
|
451
451
|
const exporter = this.exporters.get("obsidian");
|
|
452
452
|
if (obsidian && exporter && shouldExport(obsidian.exportOn, verdict)) {
|
|
453
|
-
void exporter(resolved).then((
|
|
454
|
-
this.setStatus(
|
|
453
|
+
void exporter(resolved).then((exportResult) => {
|
|
454
|
+
this.setStatus(exportResult.success && exportResult.path ? `exported to ${exportResult.path}` : `export failed: ${exportResult.error ?? "unknown"}`);
|
|
455
455
|
});
|
|
456
456
|
}
|
|
457
457
|
// Hand the reviewer back to the agent. The default is a visible
|
|
@@ -462,7 +462,7 @@ class Controller implements ReviewController {
|
|
|
462
462
|
else if (typeof delay === "number") this.startCounting(delay);
|
|
463
463
|
else this.startCounting(DEFAULT_AUTO_CLOSE);
|
|
464
464
|
})
|
|
465
|
-
.catch((
|
|
465
|
+
.catch((error: unknown) => this.setStatus(String(error instanceof Error ? error.message : error)));
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
// ── completion hand-back ────────────────────
|
|
@@ -474,9 +474,9 @@ class Controller implements ReviewController {
|
|
|
474
474
|
|
|
475
475
|
finishReview(): void {
|
|
476
476
|
this.clearCountdown();
|
|
477
|
-
const pane = returnPaneFor(this.
|
|
477
|
+
const pane = returnPaneFor(this.snapshot.session?.artifact.meta.herdrPane);
|
|
478
478
|
if (pane) focusHerdrPane(pane);
|
|
479
|
-
this.
|
|
479
|
+
this.options.onExit?.(0);
|
|
480
480
|
}
|
|
481
481
|
|
|
482
482
|
dismissCompletion(): void {
|
package/src/theme.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Semantic theme tokens
|
|
2
|
+
* Semantic theme tokens. Values carry over from the design-system
|
|
3
3
|
* prototypes (tokens.css). One built-in dark theme for now; the theme
|
|
4
4
|
* registry (palette-derived themes, terminal theme, user overrides) layers
|
|
5
5
|
* on top of these slots without touching consumers.
|
package/src/view-diff.test.ts
CHANGED
|
@@ -22,22 +22,22 @@ index 111..222 100644
|
|
|
22
22
|
describe("diffRows", () => {
|
|
23
23
|
test("flattens files, hunks, and signed lines with line numbers", () => {
|
|
24
24
|
const rows = diffRows(PATCH);
|
|
25
|
-
expect(rows[0]).toMatchObject({
|
|
26
|
-
expect(rows[1]!.
|
|
27
|
-
const del = rows.find((
|
|
25
|
+
expect(rows[0]).toMatchObject({ kind: "file", file: "src/store.ts" });
|
|
26
|
+
expect(rows[1]!.kind).toBe("hunk");
|
|
27
|
+
const del = rows.find((row) => row.kind === "del")!;
|
|
28
28
|
expect(del.text).toContain("private items = [];");
|
|
29
29
|
expect(del.oldLine).toBe(3);
|
|
30
|
-
const adds = rows.filter((
|
|
30
|
+
const adds = rows.filter((row) => row.kind === "add");
|
|
31
31
|
expect(adds[0]!.text).toContain("new Map()");
|
|
32
32
|
expect(adds[0]!.newLine).toBe(3);
|
|
33
33
|
expect(adds[1]!.newLine).toBe(4);
|
|
34
34
|
// two hunks
|
|
35
|
-
expect(rows.filter((
|
|
35
|
+
expect(rows.filter((row) => row.kind === "hunk").length).toBe(2);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
test("context lines carry both line numbers", () => {
|
|
39
39
|
const rows = diffRows(PATCH);
|
|
40
|
-
const ctx = rows.find((
|
|
40
|
+
const ctx = rows.find((row) => row.kind === "ctx")!;
|
|
41
41
|
expect(ctx.oldLine).toBe(1);
|
|
42
42
|
expect(ctx.newLine).toBe(1);
|
|
43
43
|
});
|
|
@@ -46,8 +46,8 @@ describe("diffRows", () => {
|
|
|
46
46
|
describe("diffRowAnchor", () => {
|
|
47
47
|
test("quote is the line, neighbors are the context selectors", () => {
|
|
48
48
|
const rows = diffRows(PATCH);
|
|
49
|
-
const
|
|
50
|
-
const anchor = diffRowAnchor(rows,
|
|
49
|
+
const rowIndex = rows.findIndex((row) => row.text.includes("new Map()"));
|
|
50
|
+
const anchor = diffRowAnchor(rows, rowIndex);
|
|
51
51
|
expect(anchor.quote).toContain("new Map()");
|
|
52
52
|
expect(anchor.prefix.length).toBeGreaterThan(0);
|
|
53
53
|
expect(anchor.suffix.length).toBeGreaterThan(0);
|
package/src/view-diff.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Diff artifact projection
|
|
2
|
+
* Diff artifact projection: flatten @pierre/diffs' parsed
|
|
3
3
|
* patch model into render rows. Line-anchored annotations use the same
|
|
4
4
|
* quote-primary anchors as plans: quote = the line content, prefix/suffix =
|
|
5
5
|
* the neighbor lines - so the whole anchor/feedback pipeline is shared.
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs";
|
|
9
9
|
|
|
10
|
-
export type
|
|
10
|
+
export type DiffRowKind = "file" | "hunk" | "ctx" | "add" | "del";
|
|
11
11
|
|
|
12
12
|
export interface DiffRow {
|
|
13
|
-
|
|
13
|
+
kind: DiffRowKind;
|
|
14
14
|
text: string;
|
|
15
15
|
file: string;
|
|
16
16
|
oldLine?: number;
|
|
@@ -22,35 +22,35 @@ export function diffRows(patchText: string): DiffRow[] {
|
|
|
22
22
|
const patches = parsePatchFiles(patchText);
|
|
23
23
|
for (const patch of patches) {
|
|
24
24
|
for (const file of patch.files) {
|
|
25
|
-
rows.push({
|
|
25
|
+
rows.push({ kind: "file", text: fileLabel(file), file: file.name });
|
|
26
26
|
for (const hunk of file.hunks) {
|
|
27
|
-
rows.push({
|
|
27
|
+
rows.push({ kind: "hunk", text: hunk.hunkSpecs ?? "@@", file: file.name });
|
|
28
28
|
let oldLine = hunk.deletionStart;
|
|
29
29
|
let newLine = hunk.additionStart;
|
|
30
|
-
for (const
|
|
31
|
-
if (
|
|
32
|
-
for (let i = 0; i <
|
|
30
|
+
for (const segment of hunk.hunkContent) {
|
|
31
|
+
if (segment.type === "context") {
|
|
32
|
+
for (let i = 0; i < segment.lines; i++) {
|
|
33
33
|
rows.push({
|
|
34
|
-
|
|
35
|
-
text: file.additionLines[
|
|
34
|
+
kind: "ctx",
|
|
35
|
+
text: file.additionLines[segment.additionLineIndex + i] ?? "",
|
|
36
36
|
file: file.name,
|
|
37
37
|
oldLine: oldLine++,
|
|
38
38
|
newLine: newLine++,
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
} else {
|
|
42
|
-
for (let i = 0; i <
|
|
42
|
+
for (let i = 0; i < segment.deletions; i++) {
|
|
43
43
|
rows.push({
|
|
44
|
-
|
|
45
|
-
text: file.deletionLines[
|
|
44
|
+
kind: "del",
|
|
45
|
+
text: file.deletionLines[segment.deletionLineIndex + i] ?? "",
|
|
46
46
|
file: file.name,
|
|
47
47
|
oldLine: oldLine++,
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
|
-
for (let i = 0; i <
|
|
50
|
+
for (let i = 0; i < segment.additions; i++) {
|
|
51
51
|
rows.push({
|
|
52
|
-
|
|
53
|
-
text: file.additionLines[
|
|
52
|
+
kind: "add",
|
|
53
|
+
text: file.additionLines[segment.additionLineIndex + i] ?? "",
|
|
54
54
|
file: file.name,
|
|
55
55
|
newLine: newLine++,
|
|
56
56
|
});
|
|
@@ -68,14 +68,14 @@ function fileLabel(file: FileDiffMetadata): string {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/** Quote-primary anchor for a diff row: neighbors as context selectors. */
|
|
71
|
-
export function diffRowAnchor(rows: DiffRow[],
|
|
72
|
-
const row = rows[
|
|
73
|
-
const prev = rows[
|
|
74
|
-
const next = rows[
|
|
71
|
+
export function diffRowAnchor(rows: DiffRow[], rowIndex: number): { quote: string; prefix: string; suffix: string } {
|
|
72
|
+
const row = rows[rowIndex]!;
|
|
73
|
+
const prev = rows[rowIndex - 1];
|
|
74
|
+
const next = rows[rowIndex + 1];
|
|
75
75
|
return {
|
|
76
76
|
quote: row.text,
|
|
77
|
-
prefix: prev && (prev.
|
|
78
|
-
suffix: next && (next.
|
|
77
|
+
prefix: prev && (prev.kind === "ctx" || prev.kind === "add" || prev.kind === "del") ? prev.text.slice(-24) : "",
|
|
78
|
+
suffix: next && (next.kind === "ctx" || next.kind === "add" || next.kind === "del") ? next.text.slice(0, 24) : "",
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
|