@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.
- package/package.json +5 -5
- package/src/App.plan-review.test.tsx +259 -0
- package/src/App.test.tsx +2 -2
- package/src/App.tsx +559 -71
- package/src/keymap.test.ts +10 -3
- package/src/keymap.ts +8 -2
- package/src/run.ts +3 -1
- package/src/session-controller.ts +58 -5
- package/src/view.ts +57 -5
package/src/keymap.test.ts
CHANGED
|
@@ -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
|
-
|
|
130
|
-
["
|
|
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
|
-
|
|
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
|
-
/**
|
|
77
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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)
|
|
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
|
-
|
|
244
|
-
|
|
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 {
|