@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.41
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/native/build.sh +56 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/shim.c +107 -0
- package/package.json +34 -24
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +632 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +220 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +288 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +228 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +56 -0
- package/src/components/ConfirmCard.tsx +135 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +389 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +220 -0
- package/src/components/ReviewRail.tsx +324 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +21 -0
- package/src/components/agent-launcher.tsx +180 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +48 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +174 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/ghostty-terminal.test.ts +45 -0
- package/src/ghostty-terminal.ts +168 -0
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +492 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +424 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +900 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +36 -15
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.test.ts +294 -0
- package/src/view-plan.ts +408 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
package/src/view-plan.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan projection pipeline: parse → display blocks (base vs working
|
|
3
|
+
* copy reconciled) → styled runs. Pure functions - the TUI renders the runs
|
|
4
|
+
* (the native text primitive wraps them), tests assert on them directly.
|
|
5
|
+
* One planning layer: rendering, navigation, and selection all derive from
|
|
6
|
+
* this module; the rendered/work offset mappings keep the native selection
|
|
7
|
+
* addressable over word-diffed blocks.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
isAddressed,
|
|
12
|
+
lcsDiff,
|
|
13
|
+
parseBlocks,
|
|
14
|
+
resolveAnchor,
|
|
15
|
+
type Annotation,
|
|
16
|
+
type Block,
|
|
17
|
+
} from "@cueloop/schema";
|
|
18
|
+
import { wordLevelChanges } from "./diff-intraline";
|
|
19
|
+
|
|
20
|
+
// ── display model ─────────────────────────────
|
|
21
|
+
|
|
22
|
+
export type DisplayType = "same" | "mod" | "del" | "add";
|
|
23
|
+
|
|
24
|
+
export interface DisplayBlock {
|
|
25
|
+
type: DisplayType;
|
|
26
|
+
kind: Block["kind"];
|
|
27
|
+
/** Block in the working copy (absent for del). */
|
|
28
|
+
work?: Block;
|
|
29
|
+
/** Block in the submitted revision (absent for add). */
|
|
30
|
+
base?: Block;
|
|
31
|
+
orderedItemNumber?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function displayText(block: DisplayBlock): string {
|
|
35
|
+
return (block.work ?? block.base)!.text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Word-overlap similarity gate: an unrelated cut + insert must not merge. */
|
|
39
|
+
function similar(baseBlock: Block, workBlock: Block): boolean {
|
|
40
|
+
if (baseBlock.kind !== workBlock.kind) return false;
|
|
41
|
+
const words = (text: string) => new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
|
|
42
|
+
const baseWords = words(baseBlock.text);
|
|
43
|
+
const workWords = words(workBlock.text);
|
|
44
|
+
let intersection = 0;
|
|
45
|
+
for (const word of baseWords) if (workWords.has(word)) intersection++;
|
|
46
|
+
const union = baseWords.size + workWords.size - intersection;
|
|
47
|
+
return union === 0 ? true : intersection / union >= 0.3;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Reconcile base blocks vs working blocks into the display list. */
|
|
51
|
+
export function buildDisplay(baseContent: string, workingContent?: string): DisplayBlock[] {
|
|
52
|
+
const baseBlocks = parseBlocks(baseContent);
|
|
53
|
+
if (workingContent === undefined) {
|
|
54
|
+
const display: DisplayBlock[] = baseBlocks.map((block) => ({
|
|
55
|
+
type: "same",
|
|
56
|
+
kind: block.kind,
|
|
57
|
+
work: block,
|
|
58
|
+
base: block,
|
|
59
|
+
}));
|
|
60
|
+
numberOrderedListItems(display);
|
|
61
|
+
return display;
|
|
62
|
+
}
|
|
63
|
+
const workBlocks = parseBlocks(workingContent);
|
|
64
|
+
const signature = (block: Block) => block.kind + "\0" + block.text;
|
|
65
|
+
const ops = lcsDiff(
|
|
66
|
+
baseBlocks,
|
|
67
|
+
workBlocks,
|
|
68
|
+
(baseBlock, workBlock) => signature(baseBlock) === signature(workBlock),
|
|
69
|
+
);
|
|
70
|
+
const display: DisplayBlock[] = [];
|
|
71
|
+
let opIndex = 0;
|
|
72
|
+
while (opIndex < ops.length) {
|
|
73
|
+
const op = ops[opIndex]!;
|
|
74
|
+
if (op.kind === "ctx") {
|
|
75
|
+
display.push({ type: "same", kind: op.newValue!.kind, work: op.newValue, base: op.oldValue });
|
|
76
|
+
opIndex++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const deletedBlocks: Block[] = [];
|
|
80
|
+
const addedBlocks: Block[] = [];
|
|
81
|
+
while (opIndex < ops.length && ops[opIndex]!.kind !== "ctx") {
|
|
82
|
+
const changeOp = ops[opIndex]!;
|
|
83
|
+
if (changeOp.kind === "del") deletedBlocks.push(changeOp.oldValue!);
|
|
84
|
+
else addedBlocks.push(changeOp.newValue!);
|
|
85
|
+
opIndex++;
|
|
86
|
+
}
|
|
87
|
+
let deletedIndex = 0;
|
|
88
|
+
let addedIndex = 0;
|
|
89
|
+
while (deletedIndex < deletedBlocks.length && addedIndex < addedBlocks.length) {
|
|
90
|
+
if (similar(deletedBlocks[deletedIndex]!, addedBlocks[addedIndex]!)) {
|
|
91
|
+
display.push({
|
|
92
|
+
type: "mod",
|
|
93
|
+
kind: addedBlocks[addedIndex]!.kind,
|
|
94
|
+
base: deletedBlocks[deletedIndex]!,
|
|
95
|
+
work: addedBlocks[addedIndex]!,
|
|
96
|
+
});
|
|
97
|
+
deletedIndex++;
|
|
98
|
+
addedIndex++;
|
|
99
|
+
} else if (deletedBlocks.length - deletedIndex >= addedBlocks.length - addedIndex) {
|
|
100
|
+
display.push({
|
|
101
|
+
type: "del",
|
|
102
|
+
kind: deletedBlocks[deletedIndex]!.kind,
|
|
103
|
+
base: deletedBlocks[deletedIndex]!,
|
|
104
|
+
});
|
|
105
|
+
deletedIndex++;
|
|
106
|
+
} else {
|
|
107
|
+
display.push({
|
|
108
|
+
type: "add",
|
|
109
|
+
kind: addedBlocks[addedIndex]!.kind,
|
|
110
|
+
work: addedBlocks[addedIndex]!,
|
|
111
|
+
});
|
|
112
|
+
addedIndex++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
while (deletedIndex < deletedBlocks.length) {
|
|
116
|
+
display.push({
|
|
117
|
+
type: "del",
|
|
118
|
+
kind: deletedBlocks[deletedIndex]!.kind,
|
|
119
|
+
base: deletedBlocks[deletedIndex++]!,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
while (addedIndex < addedBlocks.length) {
|
|
123
|
+
display.push({
|
|
124
|
+
type: "add",
|
|
125
|
+
kind: addedBlocks[addedIndex]!.kind,
|
|
126
|
+
work: addedBlocks[addedIndex++]!,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
numberOrderedListItems(display);
|
|
131
|
+
return display;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function numberOrderedListItems(display: DisplayBlock[]): void {
|
|
135
|
+
let runLength = 0;
|
|
136
|
+
for (const block of display) {
|
|
137
|
+
if (block.kind === "oli") block.orderedItemNumber = ++runLength;
|
|
138
|
+
else runLength = 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── marks (annotation highlights + keyboard span) ──
|
|
143
|
+
|
|
144
|
+
export type RunRole = "plain" | "ins" | "del" | "mark-comment" | "kspan" | "mark-focus";
|
|
145
|
+
|
|
146
|
+
export interface StyleRun {
|
|
147
|
+
text: string;
|
|
148
|
+
role: RunRole;
|
|
149
|
+
/** Offset of this run in the block's working text; null for del runs. */
|
|
150
|
+
start: number | null;
|
|
151
|
+
annotationId?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface Mark {
|
|
155
|
+
start: number;
|
|
156
|
+
end: number;
|
|
157
|
+
role: RunRole;
|
|
158
|
+
annotationId?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve annotations against the working blocks and group marks per display
|
|
163
|
+
* index. Working blocks are the blocks of (workingCopy ?? artifact) - the
|
|
164
|
+
* same list buildDisplay derived work entries from.
|
|
165
|
+
*/
|
|
166
|
+
export function marksByDisplay(
|
|
167
|
+
annotations: Annotation[],
|
|
168
|
+
display: DisplayBlock[],
|
|
169
|
+
focusedId?: string,
|
|
170
|
+
): Map<number, Mark[]> {
|
|
171
|
+
// block list in "work space": display entries that carry a work block, in order
|
|
172
|
+
const workEntries = display
|
|
173
|
+
.map((block, displayIndex) => ({ block, displayIndex }))
|
|
174
|
+
.filter((entry) => entry.block.work !== undefined);
|
|
175
|
+
const workBlocks = workEntries.map((entry) => entry.block.work!);
|
|
176
|
+
const marksByIndex = new Map<number, Mark[]>();
|
|
177
|
+
for (const annotation of annotations) {
|
|
178
|
+
// an addressed annotation keeps its record but paints no highlight
|
|
179
|
+
if (isAddressed(annotation)) continue;
|
|
180
|
+
const resolved = resolveAnchor(annotation.anchor, workBlocks);
|
|
181
|
+
if (!resolved) continue;
|
|
182
|
+
const entry = workEntries[resolved.blockIndex];
|
|
183
|
+
if (!entry) continue;
|
|
184
|
+
const marks = marksByIndex.get(entry.displayIndex) ?? [];
|
|
185
|
+
marks.push({
|
|
186
|
+
start: resolved.start,
|
|
187
|
+
end: resolved.end,
|
|
188
|
+
role: annotation.id === focusedId ? "mark-focus" : "mark-comment",
|
|
189
|
+
annotationId: annotation.id,
|
|
190
|
+
});
|
|
191
|
+
marksByIndex.set(entry.displayIndex, marks);
|
|
192
|
+
}
|
|
193
|
+
return marksByIndex;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Base runs for a display block: plain text, or word-diff for mod blocks. */
|
|
197
|
+
export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
|
|
198
|
+
if (block.type === "mod" && markup) {
|
|
199
|
+
const changes = wordLevelChanges(block.base!.text, block.work!.text);
|
|
200
|
+
const runs: StyleRun[] = [];
|
|
201
|
+
let workOffset = 0;
|
|
202
|
+
for (const change of changes) {
|
|
203
|
+
if (change.kind === "removed") {
|
|
204
|
+
runs.push({ text: change.text, role: "del", start: null });
|
|
205
|
+
} else {
|
|
206
|
+
runs.push({
|
|
207
|
+
text: change.text,
|
|
208
|
+
role: change.kind === "added" ? "ins" : "plain",
|
|
209
|
+
start: workOffset,
|
|
210
|
+
});
|
|
211
|
+
workOffset += change.text.length;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return runs;
|
|
215
|
+
}
|
|
216
|
+
return [{ text: displayText(block), role: "plain", start: 0 }];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Split runs at mark boundaries; marks only bind to runs with offsets. */
|
|
220
|
+
export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
|
|
221
|
+
if (!marks.length) return runs;
|
|
222
|
+
const splitRuns: StyleRun[] = [];
|
|
223
|
+
for (const run of runs) {
|
|
224
|
+
if (run.start === null) {
|
|
225
|
+
splitRuns.push(run);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const bounds = new Set<number>([0, run.text.length]);
|
|
229
|
+
for (const mark of marks) {
|
|
230
|
+
const boundStart = Math.max(0, mark.start - run.start);
|
|
231
|
+
const boundEnd = Math.min(run.text.length, mark.end - run.start);
|
|
232
|
+
if (boundStart < boundEnd) {
|
|
233
|
+
bounds.add(boundStart);
|
|
234
|
+
bounds.add(boundEnd);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const runStart = run.start;
|
|
238
|
+
const boundaries = [...bounds].sort((left, right) => left - right);
|
|
239
|
+
for (let boundaryIndex = 0; boundaryIndex < boundaries.length - 1; boundaryIndex++) {
|
|
240
|
+
const sliceStart = boundaries[boundaryIndex]!;
|
|
241
|
+
const sliceEnd = boundaries[boundaryIndex + 1]!;
|
|
242
|
+
if (sliceStart >= sliceEnd) continue;
|
|
243
|
+
const absoluteStart = runStart + sliceStart;
|
|
244
|
+
const mark = marks.find(
|
|
245
|
+
(candidate) => candidate.start <= absoluteStart && runStart + sliceEnd <= candidate.end,
|
|
246
|
+
);
|
|
247
|
+
splitRuns.push({
|
|
248
|
+
text: run.text.slice(sliceStart, sliceEnd),
|
|
249
|
+
// a mark never overrides ins/del emphasis; it rides on plain text
|
|
250
|
+
role: mark && run.role === "plain" ? mark.role : run.role,
|
|
251
|
+
start: absoluteStart,
|
|
252
|
+
annotationId: mark?.annotationId ?? run.annotationId,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return splitRuns;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Rendered-text offset of a work-text offset within a block's runs. The
|
|
261
|
+
* rendered text is every run's text in order (inline del runs included);
|
|
262
|
+
* only positioned runs carry work offsets. Drives the renderer's native
|
|
263
|
+
* selection from keyboard span offsets - the quote-anchor path depends on
|
|
264
|
+
* this mapping staying exact.
|
|
265
|
+
*/
|
|
266
|
+
export function renderedOffsetFor(runs: StyleRun[], workOffset: number): number | null {
|
|
267
|
+
let rendered = 0;
|
|
268
|
+
for (const run of runs) {
|
|
269
|
+
if (run.start !== null && workOffset >= run.start && workOffset < run.start + run.text.length) {
|
|
270
|
+
return rendered + (workOffset - run.start);
|
|
271
|
+
}
|
|
272
|
+
rendered += run.text.length;
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Work-text range covered by a rendered-text selection [start, end). Del runs
|
|
279
|
+
* and other unpositioned text inside the selection contribute nothing; the
|
|
280
|
+
* result is the tightest work range whose text the selection touched.
|
|
281
|
+
*/
|
|
282
|
+
export function workRangeForRendered(
|
|
283
|
+
runs: StyleRun[],
|
|
284
|
+
renderedStart: number,
|
|
285
|
+
renderedEnd: number,
|
|
286
|
+
): { start: number; end: number } | null {
|
|
287
|
+
let rendered = 0;
|
|
288
|
+
let range: { start: number; end: number } | null = null;
|
|
289
|
+
for (const run of runs) {
|
|
290
|
+
const runRenderedStart = rendered;
|
|
291
|
+
const runRenderedEnd = rendered + run.text.length;
|
|
292
|
+
rendered = runRenderedEnd;
|
|
293
|
+
if (run.start === null) continue;
|
|
294
|
+
const overlapStart = Math.max(renderedStart, runRenderedStart);
|
|
295
|
+
const overlapEnd = Math.min(renderedEnd, runRenderedEnd);
|
|
296
|
+
if (overlapEnd <= overlapStart) continue;
|
|
297
|
+
const workStart = run.start + (overlapStart - runRenderedStart);
|
|
298
|
+
const workEnd = run.start + (overlapEnd - runRenderedStart);
|
|
299
|
+
if (!range) range = { start: workStart, end: workEnd };
|
|
300
|
+
else {
|
|
301
|
+
range.start = Math.min(range.start, workStart);
|
|
302
|
+
range.end = Math.max(range.end, workEnd);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return range;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Line delta between two revision contents, for the sheet-header summary. */
|
|
309
|
+
export function revisionDelta(
|
|
310
|
+
previousContent: string,
|
|
311
|
+
nextContent: string,
|
|
312
|
+
): { added: number; removed: number } {
|
|
313
|
+
const ops = lcsDiff(previousContent.split("\n"), nextContent.split("\n"));
|
|
314
|
+
let added = 0;
|
|
315
|
+
let removed = 0;
|
|
316
|
+
for (const op of ops) {
|
|
317
|
+
if (op.kind === "add") added++;
|
|
318
|
+
else if (op.kind === "del") removed++;
|
|
319
|
+
}
|
|
320
|
+
return { added, removed };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── keyboard span mode ────────────────────────
|
|
324
|
+
|
|
325
|
+
export interface SpanState {
|
|
326
|
+
displayIndex: number;
|
|
327
|
+
wordIndex: number;
|
|
328
|
+
wordEnd: number;
|
|
329
|
+
start: number;
|
|
330
|
+
end: number;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function wordRanges(text: string): [number, number][] {
|
|
334
|
+
const ranges: [number, number][] = [];
|
|
335
|
+
const wordPattern = /\S+/g;
|
|
336
|
+
let match: RegExpExecArray | null;
|
|
337
|
+
while ((match = wordPattern.exec(text)))
|
|
338
|
+
ranges.push([match.index, match.index + match[0]!.length]);
|
|
339
|
+
return ranges;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function startSpan(displayIndex: number, text: string): SpanState | null {
|
|
343
|
+
const words = wordRanges(text);
|
|
344
|
+
if (!words.length) return null;
|
|
345
|
+
return { displayIndex, wordIndex: 0, wordEnd: 0, start: words[0]![0], end: words[0]![1] };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Build a word-granular span from a raw char range (a mouse drag-selection),
|
|
350
|
+
* snapping to every word the range overlaps so w/b/l/h keep working after. Null
|
|
351
|
+
* when the range covers no word.
|
|
352
|
+
*/
|
|
353
|
+
export function spanFromRange(
|
|
354
|
+
displayIndex: number,
|
|
355
|
+
text: string,
|
|
356
|
+
start: number,
|
|
357
|
+
end: number,
|
|
358
|
+
): SpanState | null {
|
|
359
|
+
const words = wordRanges(text);
|
|
360
|
+
const wordIndex = words.findIndex((word) => word[1] > start);
|
|
361
|
+
if (wordIndex === -1) return null;
|
|
362
|
+
let wordEnd = wordIndex;
|
|
363
|
+
for (let index = wordIndex; index < words.length && words[index]![0] < end; index++) {
|
|
364
|
+
wordEnd = index;
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
displayIndex,
|
|
368
|
+
wordIndex,
|
|
369
|
+
wordEnd,
|
|
370
|
+
start: words[wordIndex]![0],
|
|
371
|
+
end: words[wordEnd]![1],
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function spanKey(span: SpanState, key: string, text: string): SpanState {
|
|
376
|
+
const words = wordRanges(text);
|
|
377
|
+
const nextSpan = { ...span };
|
|
378
|
+
const width = nextSpan.wordEnd - nextSpan.wordIndex;
|
|
379
|
+
if (key === "l") nextSpan.wordEnd = Math.min(words.length - 1, nextSpan.wordEnd + 1);
|
|
380
|
+
else if (key === "h") nextSpan.wordEnd = Math.max(nextSpan.wordIndex, nextSpan.wordEnd - 1);
|
|
381
|
+
else if (key === "w") {
|
|
382
|
+
const slidTo = Math.min(words.length - 1 - width, nextSpan.wordIndex + 1);
|
|
383
|
+
nextSpan.wordIndex = slidTo;
|
|
384
|
+
nextSpan.wordEnd = slidTo + width;
|
|
385
|
+
} else if (key === "b") {
|
|
386
|
+
const slidTo = Math.max(0, nextSpan.wordIndex - 1);
|
|
387
|
+
nextSpan.wordIndex = slidTo;
|
|
388
|
+
nextSpan.wordEnd = slidTo + width;
|
|
389
|
+
} else if (key === "$") nextSpan.wordEnd = words.length - 1;
|
|
390
|
+
else if (key === "0") {
|
|
391
|
+
nextSpan.wordEnd = width;
|
|
392
|
+
nextSpan.wordIndex = 0;
|
|
393
|
+
} else return span;
|
|
394
|
+
nextSpan.start = words[nextSpan.wordIndex]![0];
|
|
395
|
+
nextSpan.end = words[nextSpan.wordEnd]![1];
|
|
396
|
+
return nextSpan;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── working-copy block operations ─────────────
|
|
400
|
+
|
|
401
|
+
/** The next work block after a display index (a del block re-enters before it). */
|
|
402
|
+
export function nextWorkBlock(display: DisplayBlock[], displayIndex: number): Block | undefined {
|
|
403
|
+
for (let candidateIndex = displayIndex + 1; candidateIndex < display.length; candidateIndex++) {
|
|
404
|
+
const workBlock = display[candidateIndex]!.work;
|
|
405
|
+
if (workBlock) return workBlock;
|
|
406
|
+
}
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
package/src/walk.test.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Pure walk projections: file steps, resume index, viewed counts, notes. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { Annotation } from "@cueloop/schema";
|
|
5
|
+
import { diffRows } from "./view-diff";
|
|
6
|
+
import { firstUnviewedIndex, noteForFile, viewedCount, walkFiles, WALK_PREVIEW_ROWS } from "./walk";
|
|
7
|
+
|
|
8
|
+
const PATCH = `diff --git a/src/a.ts b/src/a.ts
|
|
9
|
+
index 111..222 100644
|
|
10
|
+
--- a/src/a.ts
|
|
11
|
+
+++ b/src/a.ts
|
|
12
|
+
@@ -1,3 +1,4 @@
|
|
13
|
+
export const a = 1;
|
|
14
|
+
-export const b = 2;
|
|
15
|
+
+export const b = 3;
|
|
16
|
+
+export const c = 4;
|
|
17
|
+
diff --git a/src/b.ts b/src/b.ts
|
|
18
|
+
index 333..444 100644
|
|
19
|
+
--- a/src/b.ts
|
|
20
|
+
+++ b/src/b.ts
|
|
21
|
+
@@ -1,2 +1,2 @@
|
|
22
|
+
// header
|
|
23
|
+
-old line
|
|
24
|
+
+new line
|
|
25
|
+
diff --git a/src/c.ts b/src/c.ts
|
|
26
|
+
index 555..666 100644
|
|
27
|
+
--- a/src/c.ts
|
|
28
|
+
+++ b/src/c.ts
|
|
29
|
+
@@ -1,1 +1,8 @@
|
|
30
|
+
-gone
|
|
31
|
+
+one
|
|
32
|
+
+two
|
|
33
|
+
+three
|
|
34
|
+
+four
|
|
35
|
+
+five
|
|
36
|
+
+six
|
|
37
|
+
+seven
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const rows = diffRows(PATCH);
|
|
41
|
+
const files = walkFiles(rows);
|
|
42
|
+
|
|
43
|
+
describe("walkFiles", () => {
|
|
44
|
+
test("one step per changed file, in patch order, with counted stats", () => {
|
|
45
|
+
expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
46
|
+
expect(files[0]).toMatchObject({ added: 2, removed: 1 });
|
|
47
|
+
expect(files[1]).toMatchObject({ added: 1, removed: 1 });
|
|
48
|
+
expect(files[2]).toMatchObject({ added: 7, removed: 1 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("previews carry signed changed lines and cap at the preview budget", () => {
|
|
52
|
+
expect(files[0]!.preview).toEqual([
|
|
53
|
+
{ sign: "-", text: "export const b = 2;" },
|
|
54
|
+
{ sign: "+", text: "export const b = 3;" },
|
|
55
|
+
{ sign: "+", text: "export const c = 4;" },
|
|
56
|
+
]);
|
|
57
|
+
expect(files[2]!.preview.length).toBe(WALK_PREVIEW_ROWS);
|
|
58
|
+
expect(files[2]!.preview[0]).toEqual({ sign: "-", text: "gone" });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("firstUnviewedIndex", () => {
|
|
63
|
+
test("starts at the first file when nothing is viewed", () => {
|
|
64
|
+
expect(firstUnviewedIndex(files, new Set())).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("skips viewed files - a resumed review lands on its first gap", () => {
|
|
68
|
+
expect(firstUnviewedIndex(files, new Set(["src/a.ts"]))).toBe(1);
|
|
69
|
+
expect(firstUnviewedIndex(files, new Set(["src/a.ts", "src/b.ts"]))).toBe(2);
|
|
70
|
+
// a viewed file later in the list does not block the earlier gap
|
|
71
|
+
expect(firstUnviewedIndex(files, new Set(["src/c.ts"]))).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("all viewed resumes on the end card, not the first file", () => {
|
|
75
|
+
expect(firstUnviewedIndex(files, new Set(["src/a.ts", "src/b.ts", "src/c.ts"]))).toBe(
|
|
76
|
+
files.length,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("viewedCount", () => {
|
|
82
|
+
test("counts only paths present in the current file set", () => {
|
|
83
|
+
expect(viewedCount(files, new Set(["src/a.ts", "src/gone-in-rev-2.ts"]))).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("noteForFile", () => {
|
|
88
|
+
const note = (path: string, body: string): Annotation => ({
|
|
89
|
+
id: `a_${path}`,
|
|
90
|
+
kind: "note",
|
|
91
|
+
anchor: { quote: path, prefix: "", suffix: "" },
|
|
92
|
+
body,
|
|
93
|
+
createdAt: "now",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("finds the note anchored at the file path", () => {
|
|
97
|
+
// Arrange
|
|
98
|
+
const annotations = [
|
|
99
|
+
note("src/a.ts", "Renames b and adds c."),
|
|
100
|
+
note("src/b.ts", "Swaps the line."),
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
// Assert
|
|
104
|
+
expect(noteForFile(annotations, "src/b.ts")).toBe("Swaps the line.");
|
|
105
|
+
expect(noteForFile(annotations, "src/c.ts")).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("ignores non-note annotations that happen to quote the path", () => {
|
|
109
|
+
// Arrange
|
|
110
|
+
const comment: Annotation = {
|
|
111
|
+
id: "a_1",
|
|
112
|
+
kind: "comment",
|
|
113
|
+
anchor: { quote: "src/a.ts", prefix: "", suffix: "" },
|
|
114
|
+
body: "not a note",
|
|
115
|
+
createdAt: "now",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Assert
|
|
119
|
+
expect(noteForFile([comment], "src/a.ts")).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
});
|
package/src/walk.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guided-walk projections: pure helpers over the diff row model - no
|
|
3
|
+
* IO, no React. The walk steps through the changed files in patch order;
|
|
4
|
+
* which files were seen lives on the session record (viewedPaths), which
|
|
5
|
+
* file is on screen is view state in the session controller.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Annotation } from "@cueloop/schema";
|
|
9
|
+
import type { DiffRow } from "./view-diff";
|
|
10
|
+
|
|
11
|
+
/** One wizard step: a changed file with its stats and a short diff preview. */
|
|
12
|
+
export interface WalkFile {
|
|
13
|
+
path: string;
|
|
14
|
+
added: number;
|
|
15
|
+
removed: number;
|
|
16
|
+
/** The first changed lines, signed (+/-), for the card's diff preview. */
|
|
17
|
+
preview: { sign: "+" | "-"; text: string }[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Preview lines per card: enough to recognize the change, never a full diff. */
|
|
21
|
+
export const WALK_PREVIEW_ROWS = 5;
|
|
22
|
+
|
|
23
|
+
/** Collapse the diff rows into one walk step per changed file, in patch order. */
|
|
24
|
+
export function walkFiles(rows: DiffRow[]): WalkFile[] {
|
|
25
|
+
const files: WalkFile[] = [];
|
|
26
|
+
const byPath = new Map<string, WalkFile>();
|
|
27
|
+
for (const row of rows) {
|
|
28
|
+
let file = byPath.get(row.file);
|
|
29
|
+
if (!file) {
|
|
30
|
+
file = { path: row.file, added: 0, removed: 0, preview: [] };
|
|
31
|
+
byPath.set(row.file, file);
|
|
32
|
+
files.push(file);
|
|
33
|
+
}
|
|
34
|
+
if (row.kind !== "add" && row.kind !== "del") continue;
|
|
35
|
+
if (row.kind === "add") file.added += 1;
|
|
36
|
+
else file.removed += 1;
|
|
37
|
+
if (file.preview.length < WALK_PREVIEW_ROWS) {
|
|
38
|
+
file.preview.push({
|
|
39
|
+
sign: row.kind === "add" ? "+" : "-",
|
|
40
|
+
text: row.text.replace(/\n$/, ""),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return files;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where the walk resumes: the first file not yet marked viewed. All files
|
|
49
|
+
* viewed = the end card (index === files.length), so a finished walk reopens
|
|
50
|
+
* on its summary instead of restarting.
|
|
51
|
+
*/
|
|
52
|
+
export function firstUnviewedIndex(files: WalkFile[], viewedPaths: ReadonlySet<string>): number {
|
|
53
|
+
const index = files.findIndex((file) => !viewedPaths.has(file.path));
|
|
54
|
+
return index === -1 ? files.length : index;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Viewed count against the CURRENT file set - stale paths never inflate it. */
|
|
58
|
+
export function viewedCount(files: WalkFile[], viewedPaths: ReadonlySet<string>): number {
|
|
59
|
+
return files.filter((file) => viewedPaths.has(file.path)).length;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The agent's note for a file: an annotation with kind "note" whose anchor
|
|
64
|
+
* quote is the file path (the note contract in the shared review core).
|
|
65
|
+
*/
|
|
66
|
+
export function noteForFile(annotations: Annotation[], path: string): string | undefined {
|
|
67
|
+
return annotations.find(
|
|
68
|
+
(annotation) => annotation.kind === "note" && annotation.anchor.quote === path,
|
|
69
|
+
)?.body;
|
|
70
|
+
}
|