@cueloop/client 0.1.0-alpha.16 → 0.1.0-alpha.18
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 +138 -139
- 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.test.ts +3 -34
- package/src/herdr.ts +8 -21
- 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 +76 -73
- 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
|
|
@@ -11,22 +11,24 @@ import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
|
|
|
11
11
|
import { DaemonClient } from "@cueloop/daemon/client";
|
|
12
12
|
import {
|
|
13
13
|
cutBlock,
|
|
14
|
+
detectHerdr,
|
|
14
15
|
makeAnchor,
|
|
15
16
|
newAnnotationId,
|
|
16
17
|
parseBlocks,
|
|
17
18
|
resolveAnchor,
|
|
18
19
|
restoreBlock,
|
|
19
20
|
restoreLine,
|
|
21
|
+
returnPaneFor,
|
|
20
22
|
type ReviewSession,
|
|
21
23
|
type VerdictKind,
|
|
22
24
|
} from "@cueloop/schema";
|
|
23
25
|
import { Registry, type Exporter } from "@cueloop/extension-api";
|
|
24
26
|
import { createObsidianExtension, shouldExport, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
25
|
-
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view";
|
|
27
|
+
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
|
|
26
28
|
import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
|
|
27
29
|
import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
|
|
28
30
|
import { editInEditor } from "./editor";
|
|
29
|
-
import { focusHerdrPane
|
|
31
|
+
import { focusHerdrPane } from "./herdr";
|
|
30
32
|
import { persistAutoClose, type AutoClose, type CueloopConfig } from "./config";
|
|
31
33
|
|
|
32
34
|
/**
|
|
@@ -81,8 +83,8 @@ export interface ReviewController {
|
|
|
81
83
|
connect(): void;
|
|
82
84
|
close(): void;
|
|
83
85
|
/** Loaded config parts the controller acts on: auto-close and exporters. */
|
|
84
|
-
applyConfig(
|
|
85
|
-
setStatus(
|
|
86
|
+
applyConfig(config: CueloopConfig): void;
|
|
87
|
+
setStatus(message: string): void;
|
|
86
88
|
/** Derived projections, cached per session identity. */
|
|
87
89
|
display(): DisplayBlock[];
|
|
88
90
|
rows(): DiffRow[];
|
|
@@ -92,14 +94,14 @@ export interface ReviewController {
|
|
|
92
94
|
/** Open a session from the inbox. */
|
|
93
95
|
open(id: string): void;
|
|
94
96
|
/** Cut the block under the cursor, or restore a cut one. */
|
|
95
|
-
cut(
|
|
97
|
+
cut(displayIndex: number): void;
|
|
96
98
|
/** The $EDITOR hand-off on the working copy. */
|
|
97
99
|
edit(): void;
|
|
98
100
|
/**
|
|
99
101
|
* Anchor and store an annotation; both plan and diff anchor constructions.
|
|
100
102
|
* Returns the minted annotation id so the view can select the new card.
|
|
101
103
|
*/
|
|
102
|
-
annotate(kind: "comment" | "suggestion",
|
|
104
|
+
annotate(kind: "comment" | "suggestion", displayIndex: number, start: number, end: number, body: string): string | undefined;
|
|
103
105
|
/** Rewrite a stored annotation's body in place (the rail-card edit). */
|
|
104
106
|
updateAnnotation(id: string, body: string): void;
|
|
105
107
|
removeAnnotation(id: string): void;
|
|
@@ -120,15 +122,15 @@ export interface ReviewController {
|
|
|
120
122
|
optInAutoClose(): void;
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
export function createReviewController(
|
|
124
|
-
return new Controller(
|
|
125
|
+
export function createReviewController(options: ReviewControllerOptions): ReviewController {
|
|
126
|
+
return new Controller(options);
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
class Controller implements ReviewController {
|
|
128
130
|
readonly readOnly: boolean;
|
|
129
131
|
private client: DaemonClient | null = null;
|
|
130
132
|
private closed = false;
|
|
131
|
-
private
|
|
133
|
+
private snapshot: ControllerSnapshot = {
|
|
132
134
|
session: null,
|
|
133
135
|
inbox: null,
|
|
134
136
|
status: "",
|
|
@@ -151,9 +153,9 @@ class Controller implements ReviewController {
|
|
|
151
153
|
files: [],
|
|
152
154
|
};
|
|
153
155
|
|
|
154
|
-
constructor(private readonly
|
|
155
|
-
this.readOnly =
|
|
156
|
-
this.clock =
|
|
156
|
+
constructor(private readonly options: ReviewControllerOptions) {
|
|
157
|
+
this.readOnly = options.readOnly ?? false;
|
|
158
|
+
this.clock = options.clock ?? new SystemClock();
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
subscribe = (listener: () => void): (() => void) => {
|
|
@@ -161,28 +163,28 @@ class Controller implements ReviewController {
|
|
|
161
163
|
return () => this.listeners.delete(listener);
|
|
162
164
|
};
|
|
163
165
|
|
|
164
|
-
getSnapshot = (): ControllerSnapshot => this.
|
|
166
|
+
getSnapshot = (): ControllerSnapshot => this.snapshot;
|
|
165
167
|
|
|
166
168
|
private update(patch: Partial<ControllerSnapshot>): void {
|
|
167
|
-
this.
|
|
168
|
-
for (const
|
|
169
|
+
this.snapshot = { ...this.snapshot, ...patch };
|
|
170
|
+
for (const listener of this.listeners) listener();
|
|
169
171
|
}
|
|
170
172
|
|
|
171
173
|
connect(): void {
|
|
172
174
|
void (async () => {
|
|
173
175
|
try {
|
|
174
|
-
const client = await DaemonClient.connect({ home: this.
|
|
176
|
+
const client = await DaemonClient.connect({ home: this.options.home, autostart: true });
|
|
175
177
|
if (this.closed) return void client.close();
|
|
176
178
|
this.client = client;
|
|
177
|
-
client.onEvent((
|
|
179
|
+
client.onEvent((event) => {
|
|
178
180
|
// another controller/observer changed state: re-fetch
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
-
if (!this.
|
|
181
|
+
const session = this.snapshot.session;
|
|
182
|
+
if (session && event.sessionId === session.id) void this.refreshSession(session.id);
|
|
183
|
+
if (!this.options.sessionId) void this.refreshInbox();
|
|
182
184
|
});
|
|
183
185
|
await client.subscribe();
|
|
184
|
-
if (this.
|
|
185
|
-
this.update({ session: await client.sessionGet(this.
|
|
186
|
+
if (this.options.sessionId) {
|
|
187
|
+
this.update({ session: await client.sessionGet(this.options.sessionId) });
|
|
186
188
|
} else {
|
|
187
189
|
this.update({ inbox: await client.sessionList({ status: "pending" }) });
|
|
188
190
|
}
|
|
@@ -203,28 +205,28 @@ class Controller implements ReviewController {
|
|
|
203
205
|
this.countdown = undefined;
|
|
204
206
|
}
|
|
205
207
|
|
|
206
|
-
applyConfig(
|
|
207
|
-
this.autoClose =
|
|
208
|
-
this.obsidian =
|
|
208
|
+
applyConfig(config: CueloopConfig): void {
|
|
209
|
+
this.autoClose = config.ui.autoClose;
|
|
210
|
+
this.obsidian = config.integrations.obsidian;
|
|
209
211
|
// bundled integrations register through the public extension API
|
|
210
212
|
const registry = new Registry();
|
|
211
|
-
void registry.load("obsidian", createObsidianExtension(
|
|
212
|
-
this.exporters =
|
|
213
|
+
void registry.load("obsidian", createObsidianExtension(config.integrations.obsidian)).then((record) => {
|
|
214
|
+
this.exporters = record.exporters;
|
|
213
215
|
});
|
|
214
216
|
}
|
|
215
217
|
|
|
216
|
-
setStatus(
|
|
217
|
-
this.update({ status:
|
|
218
|
+
setStatus(message: string): void {
|
|
219
|
+
this.update({ status: message });
|
|
218
220
|
}
|
|
219
221
|
|
|
220
222
|
// ── derived projections ─────────────────────
|
|
221
223
|
private ensureDerived(): void {
|
|
222
|
-
const
|
|
223
|
-
if (this.derivedFor ===
|
|
224
|
-
this.derivedFor =
|
|
225
|
-
const rows =
|
|
224
|
+
const session = this.snapshot.session;
|
|
225
|
+
if (this.derivedFor === session) return;
|
|
226
|
+
this.derivedFor = session;
|
|
227
|
+
const rows = session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
|
|
226
228
|
this.derived = {
|
|
227
|
-
display:
|
|
229
|
+
display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
|
|
228
230
|
rows,
|
|
229
231
|
files: walkFiles(rows),
|
|
230
232
|
};
|
|
@@ -246,8 +248,8 @@ class Controller implements ReviewController {
|
|
|
246
248
|
}
|
|
247
249
|
|
|
248
250
|
working(): string {
|
|
249
|
-
const
|
|
250
|
-
return
|
|
251
|
+
const session = this.snapshot.session;
|
|
252
|
+
return session ? (session.workingCopy ?? session.artifact.content) : "";
|
|
251
253
|
}
|
|
252
254
|
|
|
253
255
|
// Refreshes race the connection teardown: an event can arrive while close()
|
|
@@ -270,41 +272,41 @@ class Controller implements ReviewController {
|
|
|
270
272
|
}
|
|
271
273
|
|
|
272
274
|
/** Optimistic apply: the daemon response is the next session snapshot. */
|
|
273
|
-
private apply(
|
|
274
|
-
|
|
275
|
-
this.setStatus(String(
|
|
275
|
+
private apply(mutation: Promise<ReviewSession>): void {
|
|
276
|
+
mutation.then((session) => this.update({ session })).catch((error: unknown) =>
|
|
277
|
+
this.setStatus(String(error instanceof Error ? error.message : error)),
|
|
276
278
|
);
|
|
277
279
|
}
|
|
278
280
|
|
|
279
281
|
// ── verbs ───────────────────────────────────
|
|
280
282
|
open(id: string): void {
|
|
281
283
|
this.locallyViewed.clear();
|
|
282
|
-
const cached = this.
|
|
284
|
+
const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
|
|
283
285
|
if (cached) this.update({ session: cached });
|
|
284
286
|
else void this.refreshSession(id);
|
|
285
287
|
}
|
|
286
288
|
|
|
287
|
-
cut(
|
|
288
|
-
const session = this.
|
|
289
|
+
cut(displayIndex: number): void {
|
|
290
|
+
const session = this.snapshot.session;
|
|
289
291
|
if (!session || session.status === "resolved") return;
|
|
290
|
-
const
|
|
291
|
-
if (!
|
|
292
|
+
const block = this.display()[displayIndex];
|
|
293
|
+
if (!block) return;
|
|
292
294
|
const working = this.working();
|
|
293
|
-
if (
|
|
294
|
-
const line = restoreLine(nextWorkBlock(this.display(),
|
|
295
|
+
if (block.type === "del") {
|
|
296
|
+
const line = restoreLine(nextWorkBlock(this.display(), displayIndex), working.split("\n").length);
|
|
295
297
|
// restoreBlock returns undefined when the block structure round-trips
|
|
296
298
|
// to the submitted revision - the working copy is gone
|
|
297
|
-
const restored = restoreBlock(session.artifact.content, working,
|
|
299
|
+
const restored = restoreBlock(session.artifact.content, working, block.base!, line);
|
|
298
300
|
this.setWorkingCopy(restored);
|
|
299
301
|
this.setStatus("cut restored");
|
|
300
|
-
} else if (
|
|
301
|
-
this.setWorkingCopy(cutBlock(working,
|
|
302
|
+
} else if (block.work) {
|
|
303
|
+
this.setWorkingCopy(cutBlock(working, block.work));
|
|
302
304
|
this.setStatus("block cut - it serializes into the diff");
|
|
303
305
|
}
|
|
304
306
|
}
|
|
305
307
|
|
|
306
308
|
edit(): void {
|
|
307
|
-
const session = this.
|
|
309
|
+
const session = this.snapshot.session;
|
|
308
310
|
if (!session || session.status === "resolved") return;
|
|
309
311
|
try {
|
|
310
312
|
const result = editInEditor(this.working(), "plan.md");
|
|
@@ -334,21 +336,21 @@ class Controller implements ReviewController {
|
|
|
334
336
|
|
|
335
337
|
annotate(
|
|
336
338
|
kind: "comment" | "suggestion",
|
|
337
|
-
|
|
339
|
+
displayIndex: number,
|
|
338
340
|
start: number,
|
|
339
341
|
end: number,
|
|
340
342
|
body: string,
|
|
341
343
|
): string | undefined {
|
|
342
|
-
const session = this.
|
|
344
|
+
const session = this.snapshot.session;
|
|
343
345
|
if (!session) return undefined;
|
|
344
346
|
let anchor;
|
|
345
347
|
if (session.artifact.type === "diff") {
|
|
346
|
-
anchor = { ...diffRowAnchor(this.rows(),
|
|
348
|
+
anchor = { ...diffRowAnchor(this.rows(), displayIndex), blockIndex: displayIndex };
|
|
347
349
|
} else {
|
|
348
350
|
const display = this.display();
|
|
349
|
-
const workBlocks = display.filter((
|
|
350
|
-
const
|
|
351
|
-
anchor = makeAnchor(workBlocks,
|
|
351
|
+
const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
|
|
352
|
+
const workBlockIndex = display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
|
|
353
|
+
anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
|
|
352
354
|
}
|
|
353
355
|
const annotationId = newAnnotationId();
|
|
354
356
|
this.apply(
|
|
@@ -364,7 +366,7 @@ class Controller implements ReviewController {
|
|
|
364
366
|
}
|
|
365
367
|
|
|
366
368
|
updateAnnotation(id: string, body: string): void {
|
|
367
|
-
const session = this.
|
|
369
|
+
const session = this.snapshot.session;
|
|
368
370
|
if (!session) return;
|
|
369
371
|
const existing = session.annotations.find((annotation) => annotation.id === id);
|
|
370
372
|
if (!existing) return;
|
|
@@ -381,14 +383,14 @@ class Controller implements ReviewController {
|
|
|
381
383
|
}
|
|
382
384
|
|
|
383
385
|
removeAnnotation(id: string): void {
|
|
384
|
-
const session = this.
|
|
386
|
+
const session = this.snapshot.session;
|
|
385
387
|
if (!session) return;
|
|
386
388
|
this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
|
|
387
389
|
this.setStatus("annotation deleted");
|
|
388
390
|
}
|
|
389
391
|
|
|
390
392
|
setWorkingCopy(content: string | undefined): void {
|
|
391
|
-
const session = this.
|
|
393
|
+
const session = this.snapshot.session;
|
|
392
394
|
if (!session) return;
|
|
393
395
|
this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
|
|
394
396
|
}
|
|
@@ -399,11 +401,11 @@ class Controller implements ReviewController {
|
|
|
399
401
|
private locallyViewed = new Set<string>();
|
|
400
402
|
|
|
401
403
|
private viewedSet(): Set<string> {
|
|
402
|
-
return new Set([...(this.
|
|
404
|
+
return new Set([...(this.snapshot.session?.viewedPaths ?? []), ...this.locallyViewed]);
|
|
403
405
|
}
|
|
404
406
|
|
|
405
407
|
walkStart(): void {
|
|
406
|
-
const session = this.
|
|
408
|
+
const session = this.snapshot.session;
|
|
407
409
|
if (!session || session.artifact.type !== "diff") return;
|
|
408
410
|
const files = this.files();
|
|
409
411
|
if (files.length === 0) return this.setStatus("nothing to walk - the diff is empty");
|
|
@@ -412,8 +414,8 @@ class Controller implements ReviewController {
|
|
|
412
414
|
}
|
|
413
415
|
|
|
414
416
|
walkForward(): void {
|
|
415
|
-
const walk = this.
|
|
416
|
-
const session = this.
|
|
417
|
+
const walk = this.snapshot.walk;
|
|
418
|
+
const session = this.snapshot.session;
|
|
417
419
|
if (!walk || !session) return;
|
|
418
420
|
const files = this.files();
|
|
419
421
|
const current = files[walk.index];
|
|
@@ -428,17 +430,17 @@ class Controller implements ReviewController {
|
|
|
428
430
|
}
|
|
429
431
|
|
|
430
432
|
walkBack(): void {
|
|
431
|
-
const walk = this.
|
|
433
|
+
const walk = this.snapshot.walk;
|
|
432
434
|
if (!walk) return;
|
|
433
435
|
this.update({ walk: { index: Math.max(0, walk.index - 1) } });
|
|
434
436
|
}
|
|
435
437
|
|
|
436
438
|
walkLeave(): void {
|
|
437
|
-
if (this.
|
|
439
|
+
if (this.snapshot.walk) this.update({ walk: null });
|
|
438
440
|
}
|
|
439
441
|
|
|
440
442
|
submit(verdict: VerdictKind, summary: string): void {
|
|
441
|
-
const session = this.
|
|
443
|
+
const session = this.snapshot.session;
|
|
442
444
|
if (!session) return;
|
|
443
445
|
this.walkLeave();
|
|
444
446
|
this.client!.sessionResolve(session.id, verdict, summary)
|
|
@@ -450,8 +452,8 @@ class Controller implements ReviewController {
|
|
|
450
452
|
const obsidian = this.obsidian;
|
|
451
453
|
const exporter = this.exporters.get("obsidian");
|
|
452
454
|
if (obsidian && exporter && shouldExport(obsidian.exportOn, verdict)) {
|
|
453
|
-
void exporter(resolved).then((
|
|
454
|
-
this.setStatus(
|
|
455
|
+
void exporter(resolved).then((exportResult) => {
|
|
456
|
+
this.setStatus(exportResult.success && exportResult.path ? `exported to ${exportResult.path}` : `export failed: ${exportResult.error ?? "unknown"}`);
|
|
455
457
|
});
|
|
456
458
|
}
|
|
457
459
|
// Hand the reviewer back to the agent. The default is a visible
|
|
@@ -462,7 +464,7 @@ class Controller implements ReviewController {
|
|
|
462
464
|
else if (typeof delay === "number") this.startCounting(delay);
|
|
463
465
|
else this.startCounting(DEFAULT_AUTO_CLOSE);
|
|
464
466
|
})
|
|
465
|
-
.catch((
|
|
467
|
+
.catch((error: unknown) => this.setStatus(String(error instanceof Error ? error.message : error)));
|
|
466
468
|
}
|
|
467
469
|
|
|
468
470
|
// ── completion hand-back ────────────────────
|
|
@@ -474,9 +476,10 @@ class Controller implements ReviewController {
|
|
|
474
476
|
|
|
475
477
|
finishReview(): void {
|
|
476
478
|
this.clearCountdown();
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
479
|
+
const herdr = detectHerdr();
|
|
480
|
+
const pane = returnPaneFor(this.snapshot.session?.artifact.meta.herdrPane);
|
|
481
|
+
if (herdr && pane) focusHerdrPane(herdr.binPath, pane);
|
|
482
|
+
this.options.onExit?.(0);
|
|
480
483
|
}
|
|
481
484
|
|
|
482
485
|
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
|
|