@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.9

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.
@@ -0,0 +1,398 @@
1
+ /**
2
+ * The review-session controller (#70): every daemon round-trip and mutation
3
+ * verb behind one React-free object. It owns connect/autostart/subscribe,
4
+ * the session/inbox/status/error snapshot, optimistic apply, the mutation
5
+ * verbs (cut/edit/annotate/submit/...), and the post-submit completion
6
+ * lifecycle including the notes-vault export and the herdr return-focus.
7
+ * App subscribes to snapshots and keeps only view state.
8
+ */
9
+
10
+ import { DaemonClient } from "@cueloop/daemon/client";
11
+ import {
12
+ cutBlock,
13
+ makeAnchor,
14
+ newAnnotationId,
15
+ parseBlocks,
16
+ resolveAnchor,
17
+ restoreBlock,
18
+ restoreLine,
19
+ type ReviewSession,
20
+ type VerdictKind,
21
+ } from "@cueloop/schema";
22
+ import { Registry, type Exporter } from "@cueloop/extension-api";
23
+ import { createObsidianExtension, shouldExport, type ObsidianConfig } from "@cueloop/integration-obsidian";
24
+ import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view";
25
+ import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
26
+ import { editInEditor } from "./editor";
27
+ import { focusHerdrPane, returnPaneFor } from "./herdr";
28
+ import { persistAutoClose, type AutoClose, type CueloopConfig } from "./config";
29
+
30
+ /**
31
+ * Post-submit lifecycle (a review pane should hand you back to the agent,
32
+ * not linger): idle → prompt (auto-close off: offer the choice) or
33
+ * counting (configured delay) → exit. esc dismisses to the resolved view.
34
+ */
35
+ export type Completion =
36
+ | { phase: "idle" }
37
+ | { phase: "prompt" }
38
+ | { phase: "counting"; remaining: number }
39
+ | { phase: "dismissed" };
40
+
41
+ export interface ControllerSnapshot {
42
+ session: ReviewSession | null;
43
+ inbox: ReviewSession[] | null;
44
+ status: string;
45
+ error: string | null;
46
+ completion: Completion;
47
+ /**
48
+ * Annotations whose anchor stopped resolving after the last editor
49
+ * hand-off - the reconciliation banner count. 0 = no banner.
50
+ */
51
+ editOrphanCount: number;
52
+ }
53
+
54
+ export interface ReviewControllerOptions {
55
+ home?: string;
56
+ sessionId?: string;
57
+ /** Observer mode: stored for the key reducer's read-only gate. */
58
+ readOnly?: boolean;
59
+ onExit?: (code: number) => void;
60
+ }
61
+
62
+ export interface ReviewController {
63
+ readonly readOnly: boolean;
64
+ /** Snapshot listeners (stable identity - safe for useSyncExternalStore). */
65
+ subscribe(listener: () => void): () => void;
66
+ getSnapshot(): ControllerSnapshot;
67
+ /** Dial the daemon (autostart), subscribe to events, fetch session or inbox. */
68
+ connect(): void;
69
+ close(): void;
70
+ /** Loaded config parts the controller acts on: auto-close and exporters. */
71
+ applyConfig(cfg: CueloopConfig): void;
72
+ setStatus(msg: string): void;
73
+ /** Derived projections, cached per session identity. */
74
+ display(): DisplayBlock[];
75
+ rows(): DiffRow[];
76
+ working(): string;
77
+ /** Open a session from the inbox. */
78
+ open(id: string): void;
79
+ /** Cut the block under the cursor, or restore a cut one. */
80
+ cut(dispIdx: number): void;
81
+ /** The $EDITOR hand-off on the working copy. */
82
+ edit(): void;
83
+ /**
84
+ * Anchor and store an annotation; both plan and diff anchor constructions.
85
+ * Returns the minted annotation id so the view can select the new card.
86
+ */
87
+ annotate(kind: "comment" | "suggestion", dispIdx: number, start: number, end: number, body: string): string | undefined;
88
+ /** Rewrite a stored annotation's body in place (the rail-card edit). */
89
+ updateAnnotation(id: string, body: string): void;
90
+ removeAnnotation(id: string): void;
91
+ setWorkingCopy(content: string | undefined): void;
92
+ /** Resolve the review, run the export, start the completion hand-back. */
93
+ submit(verdict: VerdictKind, summary: string): void;
94
+ /** Close the review and, inside herdr, bounce focus back to the agent. */
95
+ finishReview(): void;
96
+ dismissCompletion(): void;
97
+ /** From the completion prompt: persist auto-close and start the countdown. */
98
+ optInAutoClose(): void;
99
+ }
100
+
101
+ export function createReviewController(opts: ReviewControllerOptions): ReviewController {
102
+ return new Controller(opts);
103
+ }
104
+
105
+ class Controller implements ReviewController {
106
+ readonly readOnly: boolean;
107
+ private client: DaemonClient | null = null;
108
+ private closed = false;
109
+ private snap: ControllerSnapshot = {
110
+ session: null,
111
+ inbox: null,
112
+ status: "",
113
+ error: null,
114
+ completion: { phase: "idle" },
115
+ editOrphanCount: 0,
116
+ };
117
+ private listeners = new Set<() => void>();
118
+ private autoClose: AutoClose = "off";
119
+ private obsidian: ObsidianConfig | null = null;
120
+ private exporters = new Map<string, Exporter>();
121
+ private countdown: ReturnType<typeof setTimeout> | undefined;
122
+ /** Projections keyed by session identity so renders reuse one computation. */
123
+ private derivedFor: ReviewSession | null = null;
124
+ private derived: { display: DisplayBlock[]; rows: DiffRow[] } = { display: [], rows: [] };
125
+
126
+ constructor(private readonly opts: ReviewControllerOptions) {
127
+ this.readOnly = opts.readOnly ?? false;
128
+ }
129
+
130
+ subscribe = (listener: () => void): (() => void) => {
131
+ this.listeners.add(listener);
132
+ return () => this.listeners.delete(listener);
133
+ };
134
+
135
+ getSnapshot = (): ControllerSnapshot => this.snap;
136
+
137
+ private update(patch: Partial<ControllerSnapshot>): void {
138
+ this.snap = { ...this.snap, ...patch };
139
+ for (const l of this.listeners) l();
140
+ }
141
+
142
+ connect(): void {
143
+ void (async () => {
144
+ try {
145
+ const client = await DaemonClient.connect({ home: this.opts.home, autostart: true });
146
+ if (this.closed) return void client.close();
147
+ this.client = client;
148
+ client.onEvent((e) => {
149
+ // another controller/observer changed state: re-fetch
150
+ const s = this.snap.session;
151
+ if (s && e.sessionId === s.id) void this.refreshSession(s.id);
152
+ if (!this.opts.sessionId) void this.refreshInbox();
153
+ });
154
+ await client.subscribe();
155
+ if (this.opts.sessionId) {
156
+ this.update({ session: await client.sessionGet(this.opts.sessionId) });
157
+ } else {
158
+ this.update({ inbox: await client.sessionList({ status: "pending" }) });
159
+ }
160
+ } catch (err) {
161
+ this.update({ error: err instanceof Error ? err.message : String(err) });
162
+ }
163
+ })();
164
+ }
165
+
166
+ close(): void {
167
+ this.closed = true;
168
+ clearTimeout(this.countdown);
169
+ this.client?.close();
170
+ }
171
+
172
+ applyConfig(cfg: CueloopConfig): void {
173
+ this.autoClose = cfg.ui.autoClose;
174
+ this.obsidian = cfg.integrations.obsidian;
175
+ // bundled integrations register through the public extension API
176
+ const registry = new Registry();
177
+ void registry.load("obsidian", createObsidianExtension(cfg.integrations.obsidian)).then((rec) => {
178
+ this.exporters = rec.exporters;
179
+ });
180
+ }
181
+
182
+ setStatus(msg: string): void {
183
+ this.update({ status: msg });
184
+ }
185
+
186
+ // ── derived projections ─────────────────────
187
+ private ensureDerived(): void {
188
+ const s = this.snap.session;
189
+ if (this.derivedFor === s) return;
190
+ this.derivedFor = s;
191
+ this.derived = {
192
+ display: s ? buildDisplay(s.artifact.content, s.workingCopy) : [],
193
+ rows: s && s.artifact.type === "diff" ? diffRows(s.artifact.content) : [],
194
+ };
195
+ }
196
+
197
+ display(): DisplayBlock[] {
198
+ this.ensureDerived();
199
+ return this.derived.display;
200
+ }
201
+
202
+ rows(): DiffRow[] {
203
+ this.ensureDerived();
204
+ return this.derived.rows;
205
+ }
206
+
207
+ working(): string {
208
+ const s = this.snap.session;
209
+ return s ? (s.workingCopy ?? s.artifact.content) : "";
210
+ }
211
+
212
+ private async refreshSession(id: string): Promise<void> {
213
+ if (this.client) this.update({ session: await this.client.sessionGet(id) });
214
+ }
215
+
216
+ private async refreshInbox(): Promise<void> {
217
+ if (this.client) this.update({ inbox: await this.client.sessionList({ status: "pending" }) });
218
+ }
219
+
220
+ /** Optimistic apply: the daemon response is the next session snapshot. */
221
+ private apply(p: Promise<ReviewSession>): void {
222
+ p.then((session) => this.update({ session })).catch((e: unknown) =>
223
+ this.setStatus(String(e instanceof Error ? e.message : e)),
224
+ );
225
+ }
226
+
227
+ // ── verbs ───────────────────────────────────
228
+ open(id: string): void {
229
+ const cached = this.snap.inbox?.find((s) => s.id === id);
230
+ if (cached) this.update({ session: cached });
231
+ else void this.refreshSession(id);
232
+ }
233
+
234
+ cut(dispIdx: number): void {
235
+ const session = this.snap.session;
236
+ if (!session || session.status === "resolved") return;
237
+ const d = this.display()[dispIdx];
238
+ if (!d) return;
239
+ const working = this.working();
240
+ if (d.type === "del") {
241
+ const line = restoreLine(nextWorkBlock(this.display(), dispIdx), working.split("\n").length);
242
+ // restoreBlock returns undefined when the block structure round-trips
243
+ // to the submitted revision - the working copy is gone
244
+ const restored = restoreBlock(session.artifact.content, working, d.base!, line);
245
+ this.setWorkingCopy(restored);
246
+ this.setStatus("cut restored");
247
+ } else if (d.work) {
248
+ this.setWorkingCopy(cutBlock(working, d.work));
249
+ this.setStatus("block cut - it serializes into the diff");
250
+ }
251
+ }
252
+
253
+ edit(): void {
254
+ const session = this.snap.session;
255
+ if (!session || session.status === "resolved") return;
256
+ try {
257
+ const result = editInEditor(this.working(), "plan.md");
258
+ if (result.changed) {
259
+ this.setWorkingCopy(result.content);
260
+ this.reconcileAnnotations(session, result.content);
261
+ this.setStatus("edits tracked - one diff");
262
+ } else this.setStatus("no changes");
263
+ } catch (err) {
264
+ this.setStatus(err instanceof Error ? err.message : String(err));
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Edit-exit reconciliation: re-resolve every annotation against the edited
270
+ * working copy with the quote-primary cascade. Annotations that stop
271
+ * resolving stay stored (the feedback serializer flags orphaned anchors);
272
+ * the count feeds the one-line banner above the sheet.
273
+ */
274
+ private reconcileAnnotations(session: ReviewSession, editedContent: string): void {
275
+ const editedBlocks = parseBlocks(editedContent);
276
+ const orphanCount = session.annotations.filter(
277
+ (annotation) => resolveAnchor(annotation.anchor, editedBlocks) === null,
278
+ ).length;
279
+ this.update({ editOrphanCount: orphanCount });
280
+ }
281
+
282
+ annotate(
283
+ kind: "comment" | "suggestion",
284
+ dispIdx: number,
285
+ start: number,
286
+ end: number,
287
+ body: string,
288
+ ): string | undefined {
289
+ const session = this.snap.session;
290
+ if (!session) return undefined;
291
+ let anchor;
292
+ if (session.artifact.type === "diff") {
293
+ anchor = { ...diffRowAnchor(this.rows(), dispIdx), blockIndex: dispIdx };
294
+ } else {
295
+ const display = this.display();
296
+ const workBlocks = display.filter((d) => d.work).map((d) => d.work!);
297
+ const workIdx = display.slice(0, dispIdx + 1).filter((d) => d.work).length - 1;
298
+ anchor = makeAnchor(workBlocks, workIdx, start, end);
299
+ }
300
+ const annotationId = newAnnotationId();
301
+ this.apply(
302
+ this.client!.sessionAnnotate(session.id, {
303
+ id: annotationId,
304
+ kind,
305
+ anchor,
306
+ body,
307
+ }),
308
+ );
309
+ this.setStatus(kind === "suggestion" ? "suggestion added - the agent applies it" : "comment added");
310
+ return annotationId;
311
+ }
312
+
313
+ updateAnnotation(id: string, body: string): void {
314
+ const session = this.snap.session;
315
+ if (!session) return;
316
+ const existing = session.annotations.find((annotation) => annotation.id === id);
317
+ if (!existing) return;
318
+ // the daemon's annotate verb upserts by id: same id + anchor, new body
319
+ this.apply(
320
+ this.client!.sessionAnnotate(session.id, {
321
+ id: existing.id,
322
+ kind: existing.kind,
323
+ anchor: existing.anchor,
324
+ body,
325
+ }),
326
+ );
327
+ this.setStatus("annotation updated");
328
+ }
329
+
330
+ removeAnnotation(id: string): void {
331
+ const session = this.snap.session;
332
+ if (!session) return;
333
+ this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
334
+ this.setStatus("annotation deleted");
335
+ }
336
+
337
+ setWorkingCopy(content: string | undefined): void {
338
+ const session = this.snap.session;
339
+ if (!session) return;
340
+ this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
341
+ }
342
+
343
+ submit(verdict: VerdictKind, summary: string): void {
344
+ const session = this.snap.session;
345
+ if (!session) return;
346
+ this.client!.sessionResolve(session.id, verdict, summary)
347
+ .then((resolved) => {
348
+ this.update({ session: resolved, status: `review submitted - ${verdict.replace("_", " ")}` });
349
+ // notes-vault export: guarded by config, default is manual (no-op)
350
+ const obsidian = this.obsidian;
351
+ const exporter = this.exporters.get("obsidian");
352
+ if (obsidian && exporter && shouldExport(obsidian.exportOn, verdict)) {
353
+ void exporter(resolved).then((r) => {
354
+ this.setStatus(r.success && r.path ? `exported to ${r.path}` : `export failed: ${r.error ?? "unknown"}`);
355
+ });
356
+ }
357
+ // hand the reviewer back to the agent: prompt or count down per config;
358
+ // inside herdr with a known return pane, closing is the default
359
+ const delay = this.autoClose;
360
+ const returns = returnPaneFor(resolved.artifact.meta.herdrPane) !== undefined;
361
+ if (delay === 0) this.finishReview();
362
+ else if (typeof delay === "number") this.startCounting(delay);
363
+ else if (returns) this.startCounting(3);
364
+ else this.update({ completion: { phase: "prompt" } });
365
+ })
366
+ .catch((e: unknown) => this.setStatus(String(e instanceof Error ? e.message : e)));
367
+ }
368
+
369
+ // ── completion hand-back ────────────────────
370
+ private startCounting(remaining: number): void {
371
+ if (remaining <= 0) return this.finishReview();
372
+ this.update({ completion: { phase: "counting", remaining } });
373
+ this.countdown = setTimeout(() => this.startCounting(remaining - 1), 1000);
374
+ }
375
+
376
+ finishReview(): void {
377
+ clearTimeout(this.countdown);
378
+ const pane = returnPaneFor(this.snap.session?.artifact.meta.herdrPane);
379
+ if (pane) focusHerdrPane(pane);
380
+ this.opts.onExit?.(0);
381
+ }
382
+
383
+ dismissCompletion(): void {
384
+ clearTimeout(this.countdown);
385
+ this.update({ completion: { phase: "dismissed" } });
386
+ }
387
+
388
+ optInAutoClose(): void {
389
+ // opt in: close in 3s from now on, persisted to the user config
390
+ try {
391
+ persistAutoClose(3);
392
+ } catch {
393
+ // a read-only config dir must not block closing the review
394
+ }
395
+ this.autoClose = 3;
396
+ this.startCounting(3);
397
+ }
398
+ }
package/src/view.ts CHANGED
@@ -218,7 +218,11 @@ export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
218
218
  return out;
219
219
  }
220
220
 
221
- /** Wrap styled runs into lines of at most `width` display cells. */
221
+ /**
222
+ * Wrap styled runs into lines of at most `width` display cells. Split pieces
223
+ * keep exact `start` offsets into the block text, so wrapped lines stay
224
+ * addressable for the native selection primitive.
225
+ */
222
226
  export function wrapRuns(runs: StyleRun[], width: number): StyleRun[][] {
223
227
  const lines: StyleRun[][] = [];
224
228
  let line: StyleRun[] = [];
@@ -231,20 +235,29 @@ export function wrapRuns(runs: StyleRun[], width: number): StyleRun[][] {
231
235
  for (const run of runs) {
232
236
  // split run into word/space tokens; newlines force breaks
233
237
  const tokens = run.text.split(/(\n|\s+)/).filter((t) => t !== "");
238
+ let consumed = 0;
239
+ const startAt = (offsetInRun: number): number | null => (run.start === null ? null : run.start + offsetInRun);
234
240
  for (const tok of tokens) {
235
241
  if (tok === "\n") {
242
+ consumed += 1;
236
243
  pushLine();
237
244
  continue;
238
245
  }
239
246
  if (used + tok.length > width && used > 0 && tok.trim() !== "") pushLine();
240
- if (tok.trim() === "" && used === 0 && lines.length > 0) continue; // no leading spaces after wrap
247
+ if (tok.trim() === "" && used === 0 && lines.length > 0) {
248
+ consumed += tok.length;
249
+ continue; // no leading spaces after wrap
250
+ }
241
251
  let slice = tok;
242
252
  while (slice.length > width) {
243
- line.push({ ...run, text: slice.slice(0, width - used) });
244
- slice = slice.slice(width - used);
253
+ const piece = slice.slice(0, width - used);
254
+ line.push({ ...run, text: piece, start: startAt(consumed) });
255
+ consumed += piece.length;
256
+ slice = slice.slice(piece.length);
245
257
  pushLine();
246
258
  }
247
- line.push({ ...run, text: slice });
259
+ line.push({ ...run, text: slice, start: startAt(consumed) });
260
+ consumed += slice.length;
248
261
  used += slice.length;
249
262
  }
250
263
  }
@@ -252,6 +265,45 @@ export function wrapRuns(runs: StyleRun[], width: number): StyleRun[][] {
252
265
  return lines;
253
266
  }
254
267
 
268
+ /**
269
+ * Locate a block-text offset inside wrapped run lines: the wrapped line index
270
+ * and the column within the line's rendered run text. Drives the renderer's
271
+ * native selection from keyboard span offsets.
272
+ */
273
+ export function locateOffset(lines: StyleRun[][], offset: number): { lineIndex: number; column: number } | null {
274
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
275
+ let column = 0;
276
+ for (const run of lines[lineIndex]!) {
277
+ if (run.start !== null && offset >= run.start && offset < run.start + run.text.length) {
278
+ return { lineIndex, column: column + (offset - run.start) };
279
+ }
280
+ column += run.text.length;
281
+ }
282
+ }
283
+ return null;
284
+ }
285
+
286
+ /**
287
+ * Inline compose box height, shared by layout and render so the row math and
288
+ * the mounted box never drift: top border (title), the single-row input, the
289
+ * Save/Cancel button row, bottom border.
290
+ */
291
+ export function composeBoxHeight(): number {
292
+ return 4;
293
+ }
294
+
295
+ /** Line delta between two revision contents, for the sheet-header summary. */
296
+ export function revisionDelta(previousContent: string, nextContent: string): { added: number; removed: number } {
297
+ const ops = lcsDiff(previousContent.split("\n"), nextContent.split("\n"), (a, b) => a === b);
298
+ let added = 0;
299
+ let removed = 0;
300
+ for (const op of ops) {
301
+ if (op.t === "add") added++;
302
+ else if (op.t === "del") removed++;
303
+ }
304
+ return { added, removed };
305
+ }
306
+
255
307
  // ── keyboard span mode ────────────────────────
256
308
 
257
309
  export interface SpanState {