@cueloop/client 0.1.0-alpha.8 → 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.
@@ -126,14 +126,15 @@ describe("plan normal mode", () => {
126
126
  ["v", false, [{ t: "startSpan" }]],
127
127
  ["c", false, [{ t: "openCompose", kind: "comment", from: "cursor" }]],
128
128
  ["s", false, [{ t: "openCompose", kind: "suggestion", from: "cursor" }]],
129
- ["x", false, [{ t: "cut" }]],
130
- ["e", false, [{ t: "edit" }]],
129
+ // the default state has a selected card, so Cut and edit act on the card
130
+ ["x", false, [{ t: "removeAnnotation" }]],
131
+ ["e", false, [{ t: "editCard" }]],
131
132
  ["n", false, [{ t: "nextAnn" }]],
132
133
  ["p", false, [{ t: "prevAnn" }]],
133
134
  ["backspace", false, [{ t: "removeAnnotation" }]],
134
135
  ["return", false, [{ t: "openSubmit" }]],
135
136
  ["q", false, [{ t: "exit" }]],
136
- ["escape", false, []],
137
+ ["escape", false, [{ t: "deselect" }]],
137
138
  ["z", false, []],
138
139
  ];
139
140
  for (const [name, shift, expected] of table) {
@@ -164,6 +165,12 @@ describe("plan normal mode", () => {
164
165
  expect(reduceKey(none, key("p"))).toEqual([{ t: "status", msg: "no annotations" }]);
165
166
  expect(reduceKey(none, key("backspace"))).toEqual([]);
166
167
  });
168
+
169
+ test("without a selected card x cuts the block and e opens the editor", () => {
170
+ const unfocused = state({ hasFocusedAnnotation: false });
171
+ expect(reduceKey(unfocused, key("x"))).toEqual([{ t: "cut" }]);
172
+ expect(reduceKey(unfocused, key("e"))).toEqual([{ t: "edit" }]);
173
+ });
167
174
  });
168
175
 
169
176
  describe("span mode", () => {
package/src/keymap.ts CHANGED
@@ -20,9 +20,11 @@ export type Intent =
20
20
  | { t: "openSubmit" }
21
21
  | { t: "cut" }
22
22
  | { t: "edit" }
23
+ | { t: "editCard" }
23
24
  | { t: "nextAnn" }
24
25
  | { t: "prevAnn" }
25
26
  | { t: "removeAnnotation" }
27
+ | { t: "deselect" }
26
28
  | { t: "closeOverlay" }
27
29
  | { t: "saveCompose" }
28
30
  | { t: "submitVerdict" }
@@ -92,7 +94,7 @@ export function reduceKey(state: KeyState, key: KeyInput): Intent[] {
92
94
  if (state.view === "inbox") return inboxGrammar(state, name);
93
95
  if (state.view === "diff") return diffGrammar(state, action);
94
96
  if (state.spanMode) return spanGrammar(name);
95
- return planGrammar(state, action);
97
+ return planGrammar(state, action, name);
96
98
  }
97
99
 
98
100
  function inboxGrammar(state: KeyState, name: string): Intent[] {
@@ -128,9 +130,10 @@ function spanGrammar(name: string): Intent[] {
128
130
  return [];
129
131
  }
130
132
 
131
- function planGrammar(state: KeyState, action: string | undefined): Intent[] {
133
+ function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
132
134
  const nav = navIntent(action);
133
135
  if (nav) return nav;
136
+ if (name === "escape") return [{ t: "deselect" }];
134
137
  if (action === "span") return state.cursorAnnotatable ? [{ t: "startSpan" }] : [];
135
138
  if (action === "comment" || action === "suggest") {
136
139
  if (state.resolved) return status("review submitted - read-only");
@@ -139,6 +142,9 @@ function planGrammar(state: KeyState, action: string | undefined): Intent[] {
139
142
  }
140
143
  if (action === "cut" || action === "edit") {
141
144
  if (state.resolved) return status("review submitted - read-only");
145
+ // the document selects, the rail edits: with a card selected, Cut deletes
146
+ // the annotation and edit rewrites the card body in place
147
+ if (state.hasFocusedAnnotation) return [action === "cut" ? { t: "removeAnnotation" } : { t: "editCard" }];
142
148
  return [{ t: action }];
143
149
  }
144
150
  return annotationCluster(state, action) ?? [];
package/src/run.ts CHANGED
@@ -11,7 +11,9 @@ export interface RunClientOptions {
11
11
  }
12
12
 
13
13
  export async function runClient(opts: RunClientOptions): Promise<number> {
14
- const renderer = await createCliRenderer();
14
+ // mouse movement reporting makes multiplexers forward drags to the app,
15
+ // so the renderer's native selection is the drag driver
16
+ const renderer = await createCliRenderer({ enableMouseMovement: true });
15
17
  return new Promise<number>((resolve) => {
16
18
  createRoot(renderer).render(
17
19
  React.createElement(App, {
@@ -12,6 +12,8 @@ import {
12
12
  cutBlock,
13
13
  makeAnchor,
14
14
  newAnnotationId,
15
+ parseBlocks,
16
+ resolveAnchor,
15
17
  restoreBlock,
16
18
  restoreLine,
17
19
  type ReviewSession,
@@ -42,6 +44,11 @@ export interface ControllerSnapshot {
42
44
  status: string;
43
45
  error: string | null;
44
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;
45
52
  }
46
53
 
47
54
  export interface ReviewControllerOptions {
@@ -73,8 +80,13 @@ export interface ReviewController {
73
80
  cut(dispIdx: number): void;
74
81
  /** The $EDITOR hand-off on the working copy. */
75
82
  edit(): void;
76
- /** Anchor and store an annotation; both plan and diff anchor constructions. */
77
- annotate(kind: "comment" | "suggestion", dispIdx: number, start: number, end: number, body: string): 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;
78
90
  removeAnnotation(id: string): void;
79
91
  setWorkingCopy(content: string | undefined): void;
80
92
  /** Resolve the review, run the export, start the completion hand-back. */
@@ -100,6 +112,7 @@ class Controller implements ReviewController {
100
112
  status: "",
101
113
  error: null,
102
114
  completion: { phase: "idle" },
115
+ editOrphanCount: 0,
103
116
  };
104
117
  private listeners = new Set<() => void>();
105
118
  private autoClose: AutoClose = "off";
@@ -244,6 +257,7 @@ class Controller implements ReviewController {
244
257
  const result = editInEditor(this.working(), "plan.md");
245
258
  if (result.changed) {
246
259
  this.setWorkingCopy(result.content);
260
+ this.reconcileAnnotations(session, result.content);
247
261
  this.setStatus("edits tracked - one diff");
248
262
  } else this.setStatus("no changes");
249
263
  } catch (err) {
@@ -251,9 +265,29 @@ class Controller implements ReviewController {
251
265
  }
252
266
  }
253
267
 
254
- annotate(kind: "comment" | "suggestion", dispIdx: number, start: number, end: number, body: string): void {
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 {
255
289
  const session = this.snap.session;
256
- if (!session) return;
290
+ if (!session) return undefined;
257
291
  let anchor;
258
292
  if (session.artifact.type === "diff") {
259
293
  anchor = { ...diffRowAnchor(this.rows(), dispIdx), blockIndex: dispIdx };
@@ -263,15 +297,34 @@ class Controller implements ReviewController {
263
297
  const workIdx = display.slice(0, dispIdx + 1).filter((d) => d.work).length - 1;
264
298
  anchor = makeAnchor(workBlocks, workIdx, start, end);
265
299
  }
300
+ const annotationId = newAnnotationId();
266
301
  this.apply(
267
302
  this.client!.sessionAnnotate(session.id, {
268
- id: newAnnotationId(),
303
+ id: annotationId,
269
304
  kind,
270
305
  anchor,
271
306
  body,
272
307
  }),
273
308
  );
274
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");
275
328
  }
276
329
 
277
330
  removeAnnotation(id: string): void {
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 {