@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.
@@ -10,7 +10,7 @@ import {
10
10
  spanKey,
11
11
  startSpan,
12
12
  workRangeForRendered,
13
- } from "./view";
13
+ } from "./view-plan";
14
14
  import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
15
15
 
16
16
  const BASE = `# Plan
@@ -25,56 +25,56 @@ The daemon persists sessions to disk atomically.
25
25
 
26
26
  describe("buildDisplay reconciliation", () => {
27
27
  test("no working copy: everything is same", () => {
28
- const d = buildDisplay(BASE);
29
- expect(d.every((x) => x.type === "same")).toBe(true);
28
+ const display = buildDisplay(BASE);
29
+ expect(display.every((block) => block.type === "same")).toBe(true);
30
30
  });
31
31
 
32
32
  test("reworded block pairs as mod (similar)", () => {
33
33
  const working = BASE.replace("persists sessions to disk", "writes sessions to disk");
34
- const d = buildDisplay(BASE, working);
35
- const mod = d.find((x) => x.type === "mod")!;
34
+ const display = buildDisplay(BASE, working);
35
+ const mod = display.find((block) => block.type === "mod")!;
36
36
  expect(mod.base!.text).toContain("persists");
37
37
  expect(mod.work!.text).toContain("writes");
38
38
  });
39
39
 
40
40
  test("unrelated cut + insert do NOT merge into a mod", () => {
41
41
  const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
42
- const d = buildDisplay(BASE, working);
43
- expect(d.some((x) => x.type === "del" && x.base!.text === "second item")).toBe(true);
44
- expect(d.some((x) => x.type === "add" && x.work!.text.includes("closing note"))).toBe(true);
45
- expect(d.some((x) => x.type === "mod")).toBe(false);
42
+ const display = buildDisplay(BASE, working);
43
+ expect(display.some((block) => block.type === "del" && block.base!.text === "second item")).toBe(true);
44
+ expect(display.some((block) => block.type === "add" && block.work!.text.includes("closing note"))).toBe(true);
45
+ expect(display.some((block) => block.type === "mod")).toBe(false);
46
46
  });
47
47
 
48
48
  test("ordered list items get running numbers", () => {
49
- const d = buildDisplay("1. one\n2. two\n3. three\n");
50
- expect(d.map((x) => x.oliNum)).toEqual([1, 2, 3]);
49
+ const display = buildDisplay("1. one\n2. two\n3. three\n");
50
+ expect(display.map((block) => block.orderedItemNumber)).toEqual([1, 2, 3]);
51
51
  });
52
52
  });
53
53
 
54
54
  describe("blockRuns + overlayMarks", () => {
55
55
  test("mod blocks emit word-diff runs that reconstruct both sides", () => {
56
- const d = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
57
- const runs = blockRuns(d, true);
58
- const oldSide = runs.filter((r) => r.role !== "ins").map((r) => r.text).join("");
59
- const newSide = runs.filter((r) => r.role !== "del").map((r) => r.text).join("");
56
+ const block = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
57
+ const runs = blockRuns(block, true);
58
+ const oldSide = runs.filter((run) => run.role !== "ins").map((run) => run.text).join("");
59
+ const newSide = runs.filter((run) => run.role !== "del").map((run) => run.text).join("");
60
60
  expect(oldSide).toBe("plain old text here");
61
61
  expect(newSide).toBe("plain new text here");
62
62
  });
63
63
 
64
64
  test("marks bind to working-text offsets and skip del runs", () => {
65
- const d = buildDisplay("old words linger\n", "new words linger\n")[0]!;
66
- const runs = blockRuns(d, true);
65
+ const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
66
+ const runs = blockRuns(block, true);
67
67
  const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
68
- const markedText = marked.filter((r) => r.role === "mark-comment").map((r) => r.text).join("");
68
+ const markedText = marked.filter((run) => run.role === "mark-comment").map((run) => run.text).join("");
69
69
  expect(markedText).toBe("words");
70
70
  // del run text untouched
71
- expect(marked.some((r) => r.role === "del" && r.text === "old")).toBe(true);
71
+ expect(marked.some((run) => run.role === "del" && run.text === "old")).toBe(true);
72
72
  });
73
73
 
74
74
  test("marks never override ins emphasis", () => {
75
- const d = buildDisplay("keep\n", "keep added\n")[0]!;
76
- const runs = overlayMarks(blockRuns(d, true), [{ start: 0, end: 10, role: "mark-comment" }]);
77
- expect(runs.some((r) => r.role === "ins")).toBe(true);
75
+ const block = buildDisplay("keep\n", "keep added\n")[0]!;
76
+ const runs = overlayMarks(blockRuns(block, true), [{ start: 0, end: 10, role: "mark-comment" }]);
77
+ expect(runs.some((run) => run.role === "ins")).toBe(true);
78
78
  });
79
79
  });
80
80
 
@@ -90,8 +90,8 @@ describe("marksByDisplay", () => {
90
90
  createdAt: "",
91
91
  };
92
92
  const map = marksByDisplay([ann], display);
93
- const [dispIdx, marks] = [...map.entries()][0]!;
94
- expect(displayText(display[dispIdx]!)).toContain("atomically");
93
+ const [displayIndex, marks] = [...map.entries()][0]!;
94
+ expect(displayText(display[displayIndex]!)).toContain("atomically");
95
95
  expect(marks[0]!.annotationId).toBe("a1");
96
96
  });
97
97
 
@@ -110,20 +110,20 @@ describe("marksByDisplay", () => {
110
110
 
111
111
  describe("rendered/work offset mapping", () => {
112
112
  test("plain blocks map 1:1 in both directions", () => {
113
- const d = buildDisplay("alpha beta gamma\n")[0]!;
114
- const runs = blockRuns(d, true);
113
+ const block = buildDisplay("alpha beta gamma\n")[0]!;
114
+ const runs = blockRuns(block, true);
115
115
  expect(renderedOffsetFor(runs, 6)).toBe(6);
116
116
  expect(workRangeForRendered(runs, 6, 10)).toEqual({ start: 6, end: 10 });
117
117
  });
118
118
 
119
119
  test("inline del runs shift rendered offsets in word-diffed blocks", () => {
120
120
  // rendered: "old new words linger" - "old " is a del run without offsets
121
- const d = buildDisplay("old words linger\n", "new words linger\n")[0]!;
122
- const runs = blockRuns(d, true);
123
- const workText = displayText(d);
121
+ const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
122
+ const runs = blockRuns(block, true);
123
+ const workText = displayText(block);
124
124
  const wordsAt = workText.indexOf("words");
125
125
  const rendered = renderedOffsetFor(runs, wordsAt)!;
126
- const renderedText = runs.map((r) => r.text).join("");
126
+ const renderedText = runs.map((run) => run.text).join("");
127
127
  expect(renderedText.slice(rendered, rendered + "words".length)).toBe("words");
128
128
  // reading a rendered selection over "words" recovers the work range
129
129
  expect(workRangeForRendered(runs, rendered, rendered + "words".length)).toEqual({
@@ -133,12 +133,12 @@ describe("rendered/work offset mapping", () => {
133
133
  });
134
134
 
135
135
  test("a rendered selection spanning a del run keeps only positioned text", () => {
136
- const d = buildDisplay("old words\n", "new words\n")[0]!;
137
- const runs = blockRuns(d, true);
138
- const renderedText = runs.map((r) => r.text).join("");
136
+ const block = buildDisplay("old words\n", "new words\n")[0]!;
137
+ const runs = blockRuns(block, true);
138
+ const renderedText = runs.map((run) => run.text).join("");
139
139
  // select everything rendered: the work range is the whole work text
140
140
  const range = workRangeForRendered(runs, 0, renderedText.length)!;
141
- expect(displayText(d).slice(range.start, range.end)).toBe(displayText(d));
141
+ expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
142
142
  });
143
143
 
144
144
  test("offsets outside any positioned run map to null", () => {
@@ -151,29 +151,29 @@ describe("rendered/work offset mapping", () => {
151
151
  describe("span mode", () => {
152
152
  const text = "one two three four";
153
153
  test("v selects the first word; l grows; w slides; $ to end", () => {
154
- let s = startSpan(0, text)!;
155
- expect(text.slice(s.start, s.end)).toBe("one");
156
- s = spanKey(s, "l", text);
157
- expect(text.slice(s.start, s.end)).toBe("one two");
158
- s = spanKey(s, "w", text);
159
- expect(text.slice(s.start, s.end)).toBe("two three");
160
- s = spanKey(s, "$", text);
161
- expect(text.slice(s.start, s.end)).toBe("two three four");
154
+ let span = startSpan(0, text)!;
155
+ expect(text.slice(span.start, span.end)).toBe("one");
156
+ span = spanKey(span, "l", text);
157
+ expect(text.slice(span.start, span.end)).toBe("one two");
158
+ span = spanKey(span, "w", text);
159
+ expect(text.slice(span.start, span.end)).toBe("two three");
160
+ span = spanKey(span, "$", text);
161
+ expect(text.slice(span.start, span.end)).toBe("two three four");
162
162
  });
163
163
  });
164
164
 
165
165
  describe("cut / restore round-trip", () => {
166
166
  test("cut removes the block lines; restore puts them back", () => {
167
167
  const blocks = parseBlocks(BASE);
168
- const li = blocks.find((b) => b.text === "second item")!;
169
- const cut = cutBlock(BASE, li);
168
+ const listItem = blocks.find((candidate) => candidate.text === "second item")!;
169
+ const cut = cutBlock(BASE, listItem);
170
170
  expect(cut).not.toContain("second item");
171
171
  const display = buildDisplay(BASE, cut);
172
- const delIdx = display.findIndex((d) => d.type === "del");
173
- const line = restoreLine(nextWorkBlock(display, delIdx), cut.split("\n").length);
172
+ const deletedIndex = display.findIndex((block) => block.type === "del");
173
+ const line = restoreLine(nextWorkBlock(display, deletedIndex), cut.split("\n").length);
174
174
  // undefined = the working copy round-tripped back to the submitted revision
175
- const restored = restoreBlock(BASE, cut, li, line);
175
+ const restored = restoreBlock(BASE, cut, listItem, line);
176
176
  expect(restored).toBeUndefined();
177
- expect(buildDisplay(BASE, restored).every((d) => d.type === "same")).toBe(true);
177
+ expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
178
178
  });
179
179
  });
@@ -0,0 +1,362 @@
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
+ lcsDiff,
12
+ parseBlocks,
13
+ resolveAnchor,
14
+ wordDiff,
15
+ type Annotation,
16
+ type Block,
17
+ } from "@cueloop/schema";
18
+
19
+ // ── display model ─────────────────────────────
20
+
21
+ export type DisplayType = "same" | "mod" | "del" | "add";
22
+
23
+ export interface DisplayBlock {
24
+ type: DisplayType;
25
+ kind: Block["kind"];
26
+ /** Block in the working copy (absent for del). */
27
+ work?: Block;
28
+ /** Block in the submitted revision (absent for add). */
29
+ base?: Block;
30
+ orderedItemNumber?: number;
31
+ }
32
+
33
+ export function displayText(block: DisplayBlock): string {
34
+ return (block.work ?? block.base)!.text;
35
+ }
36
+
37
+ /** Word-overlap similarity gate: an unrelated cut + insert must not merge. */
38
+ function similar(baseBlock: Block, workBlock: Block): boolean {
39
+ if (baseBlock.kind !== workBlock.kind) return false;
40
+ const words = (text: string) => new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
41
+ const baseWords = words(baseBlock.text);
42
+ const workWords = words(workBlock.text);
43
+ let intersection = 0;
44
+ for (const word of baseWords) if (workWords.has(word)) intersection++;
45
+ const union = baseWords.size + workWords.size - intersection;
46
+ return union === 0 ? true : intersection / union >= 0.3;
47
+ }
48
+
49
+ /** Reconcile base blocks vs working blocks into the display list. */
50
+ export function buildDisplay(baseContent: string, workingContent?: string): DisplayBlock[] {
51
+ const baseBlocks = parseBlocks(baseContent);
52
+ if (workingContent === undefined) {
53
+ const display: DisplayBlock[] = baseBlocks.map((block) => ({
54
+ type: "same",
55
+ kind: block.kind,
56
+ work: block,
57
+ base: block,
58
+ }));
59
+ numberOrderedListItems(display);
60
+ return display;
61
+ }
62
+ const workBlocks = parseBlocks(workingContent);
63
+ const signature = (block: Block) => block.kind + "\0" + block.text;
64
+ const ops = lcsDiff(baseBlocks, workBlocks, (baseBlock, workBlock) => signature(baseBlock) === signature(workBlock));
65
+ const display: DisplayBlock[] = [];
66
+ let opIndex = 0;
67
+ while (opIndex < ops.length) {
68
+ const op = ops[opIndex]!;
69
+ if (op.kind === "ctx") {
70
+ display.push({ type: "same", kind: op.newValue!.kind, work: op.newValue, base: op.oldValue });
71
+ opIndex++;
72
+ continue;
73
+ }
74
+ const deletedBlocks: Block[] = [];
75
+ const addedBlocks: Block[] = [];
76
+ while (opIndex < ops.length && ops[opIndex]!.kind !== "ctx") {
77
+ const changeOp = ops[opIndex]!;
78
+ if (changeOp.kind === "del") deletedBlocks.push(changeOp.oldValue!);
79
+ else addedBlocks.push(changeOp.newValue!);
80
+ opIndex++;
81
+ }
82
+ let deletedIndex = 0;
83
+ let addedIndex = 0;
84
+ while (deletedIndex < deletedBlocks.length && addedIndex < addedBlocks.length) {
85
+ if (similar(deletedBlocks[deletedIndex]!, addedBlocks[addedIndex]!)) {
86
+ display.push({
87
+ type: "mod",
88
+ kind: addedBlocks[addedIndex]!.kind,
89
+ base: deletedBlocks[deletedIndex]!,
90
+ work: addedBlocks[addedIndex]!,
91
+ });
92
+ deletedIndex++;
93
+ addedIndex++;
94
+ } else if (deletedBlocks.length - deletedIndex >= addedBlocks.length - addedIndex) {
95
+ display.push({ type: "del", kind: deletedBlocks[deletedIndex]!.kind, base: deletedBlocks[deletedIndex]! });
96
+ deletedIndex++;
97
+ } else {
98
+ display.push({ type: "add", kind: addedBlocks[addedIndex]!.kind, work: addedBlocks[addedIndex]! });
99
+ addedIndex++;
100
+ }
101
+ }
102
+ while (deletedIndex < deletedBlocks.length) {
103
+ display.push({ type: "del", kind: deletedBlocks[deletedIndex]!.kind, base: deletedBlocks[deletedIndex++]! });
104
+ }
105
+ while (addedIndex < addedBlocks.length) {
106
+ display.push({ type: "add", kind: addedBlocks[addedIndex]!.kind, work: addedBlocks[addedIndex++]! });
107
+ }
108
+ }
109
+ numberOrderedListItems(display);
110
+ return display;
111
+ }
112
+
113
+ function numberOrderedListItems(display: DisplayBlock[]): void {
114
+ let runLength = 0;
115
+ for (const block of display) {
116
+ if (block.kind === "oli") block.orderedItemNumber = ++runLength;
117
+ else runLength = 0;
118
+ }
119
+ }
120
+
121
+ // ── marks (annotation highlights + keyboard span) ──
122
+
123
+ export type RunRole =
124
+ | "plain"
125
+ | "ins"
126
+ | "del"
127
+ | "mark-comment"
128
+ | "mark-suggestion"
129
+ | "kspan"
130
+ | "mark-focus";
131
+
132
+ export interface StyleRun {
133
+ text: string;
134
+ role: RunRole;
135
+ /** Offset of this run in the block's working text; null for del runs. */
136
+ start: number | null;
137
+ annotationId?: string;
138
+ }
139
+
140
+ export interface Mark {
141
+ start: number;
142
+ end: number;
143
+ role: RunRole;
144
+ annotationId?: string;
145
+ }
146
+
147
+ /**
148
+ * Resolve annotations against the working blocks and group marks per display
149
+ * index. Working blocks are the blocks of (workingCopy ?? artifact) - the
150
+ * same list buildDisplay derived work entries from.
151
+ */
152
+ export function marksByDisplay(
153
+ annotations: Annotation[],
154
+ display: DisplayBlock[],
155
+ focusedId?: string,
156
+ ): Map<number, Mark[]> {
157
+ // block list in "work space": display entries that carry a work block, in order
158
+ const workEntries = display
159
+ .map((block, displayIndex) => ({ block, displayIndex }))
160
+ .filter((entry) => entry.block.work !== undefined);
161
+ const workBlocks = workEntries.map((entry) => entry.block.work!);
162
+ const marksByIndex = new Map<number, Mark[]>();
163
+ for (const annotation of annotations) {
164
+ const resolved = resolveAnchor(annotation.anchor, workBlocks);
165
+ if (!resolved) continue;
166
+ const entry = workEntries[resolved.blockIndex];
167
+ if (!entry) continue;
168
+ const marks = marksByIndex.get(entry.displayIndex) ?? [];
169
+ marks.push({
170
+ start: resolved.start,
171
+ end: resolved.end,
172
+ role:
173
+ annotation.id === focusedId
174
+ ? "mark-focus"
175
+ : annotation.kind === "suggestion"
176
+ ? "mark-suggestion"
177
+ : "mark-comment",
178
+ annotationId: annotation.id,
179
+ });
180
+ marksByIndex.set(entry.displayIndex, marks);
181
+ }
182
+ return marksByIndex;
183
+ }
184
+
185
+ /** Base runs for a display block: plain text, or word-diff for mod blocks. */
186
+ export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
187
+ if (block.type === "mod" && markup) {
188
+ const ops = wordDiff(block.base!.text, block.work!.text);
189
+ const runs: StyleRun[] = [];
190
+ let workOffset = 0;
191
+ for (const op of ops) {
192
+ if (op.kind === "del") {
193
+ runs.push({ text: op.oldValue!, role: "del", start: null });
194
+ } else {
195
+ runs.push({ text: op.newValue!, role: op.kind === "add" ? "ins" : "plain", start: workOffset });
196
+ workOffset += op.newValue!.length;
197
+ }
198
+ }
199
+ return runs;
200
+ }
201
+ return [{ text: displayText(block), role: "plain", start: 0 }];
202
+ }
203
+
204
+ /** Split runs at mark boundaries; marks only bind to runs with offsets. */
205
+ export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
206
+ if (!marks.length) return runs;
207
+ const splitRuns: StyleRun[] = [];
208
+ for (const run of runs) {
209
+ if (run.start === null) {
210
+ splitRuns.push(run);
211
+ continue;
212
+ }
213
+ const bounds = new Set<number>([0, run.text.length]);
214
+ for (const mark of marks) {
215
+ const boundStart = Math.max(0, mark.start - run.start);
216
+ const boundEnd = Math.min(run.text.length, mark.end - run.start);
217
+ if (boundStart < boundEnd) {
218
+ bounds.add(boundStart);
219
+ bounds.add(boundEnd);
220
+ }
221
+ }
222
+ const runStart = run.start;
223
+ const boundaries = [...bounds].sort((left, right) => left - right);
224
+ for (let boundaryIndex = 0; boundaryIndex < boundaries.length - 1; boundaryIndex++) {
225
+ const sliceStart = boundaries[boundaryIndex]!;
226
+ const sliceEnd = boundaries[boundaryIndex + 1]!;
227
+ if (sliceStart >= sliceEnd) continue;
228
+ const absoluteStart = runStart + sliceStart;
229
+ const mark = marks.find(
230
+ (candidate) => candidate.start <= absoluteStart && runStart + sliceEnd <= candidate.end,
231
+ );
232
+ splitRuns.push({
233
+ text: run.text.slice(sliceStart, sliceEnd),
234
+ // a mark never overrides ins/del emphasis; it rides on plain text
235
+ role: mark && run.role === "plain" ? mark.role : run.role,
236
+ start: absoluteStart,
237
+ annotationId: mark?.annotationId ?? run.annotationId,
238
+ });
239
+ }
240
+ }
241
+ return splitRuns;
242
+ }
243
+
244
+ /**
245
+ * Rendered-text offset of a work-text offset within a block's runs. The
246
+ * rendered text is every run's text in order (inline del runs included);
247
+ * only positioned runs carry work offsets. Drives the renderer's native
248
+ * selection from keyboard span offsets - the quote-anchor path depends on
249
+ * this mapping staying exact.
250
+ */
251
+ export function renderedOffsetFor(runs: StyleRun[], workOffset: number): number | null {
252
+ let rendered = 0;
253
+ for (const run of runs) {
254
+ if (run.start !== null && workOffset >= run.start && workOffset < run.start + run.text.length) {
255
+ return rendered + (workOffset - run.start);
256
+ }
257
+ rendered += run.text.length;
258
+ }
259
+ return null;
260
+ }
261
+
262
+ /**
263
+ * Work-text range covered by a rendered-text selection [start, end). Del runs
264
+ * and other unpositioned text inside the selection contribute nothing; the
265
+ * result is the tightest work range whose text the selection touched.
266
+ */
267
+ export function workRangeForRendered(
268
+ runs: StyleRun[],
269
+ renderedStart: number,
270
+ renderedEnd: number,
271
+ ): { start: number; end: number } | null {
272
+ let rendered = 0;
273
+ let range: { start: number; end: number } | null = null;
274
+ for (const run of runs) {
275
+ const runRenderedStart = rendered;
276
+ const runRenderedEnd = rendered + run.text.length;
277
+ rendered = runRenderedEnd;
278
+ if (run.start === null) continue;
279
+ const overlapStart = Math.max(renderedStart, runRenderedStart);
280
+ const overlapEnd = Math.min(renderedEnd, runRenderedEnd);
281
+ if (overlapEnd <= overlapStart) continue;
282
+ const workStart = run.start + (overlapStart - runRenderedStart);
283
+ const workEnd = run.start + (overlapEnd - runRenderedStart);
284
+ if (!range) range = { start: workStart, end: workEnd };
285
+ else {
286
+ range.start = Math.min(range.start, workStart);
287
+ range.end = Math.max(range.end, workEnd);
288
+ }
289
+ }
290
+ return range;
291
+ }
292
+
293
+ /** Line delta between two revision contents, for the sheet-header summary. */
294
+ export function revisionDelta(previousContent: string, nextContent: string): { added: number; removed: number } {
295
+ const ops = lcsDiff(previousContent.split("\n"), nextContent.split("\n"));
296
+ let added = 0;
297
+ let removed = 0;
298
+ for (const op of ops) {
299
+ if (op.kind === "add") added++;
300
+ else if (op.kind === "del") removed++;
301
+ }
302
+ return { added, removed };
303
+ }
304
+
305
+ // ── keyboard span mode ────────────────────────
306
+
307
+ export interface SpanState {
308
+ displayIndex: number;
309
+ wordIndex: number;
310
+ wordEnd: number;
311
+ start: number;
312
+ end: number;
313
+ }
314
+
315
+ export function wordRanges(text: string): [number, number][] {
316
+ const ranges: [number, number][] = [];
317
+ const wordPattern = /\S+/g;
318
+ let match: RegExpExecArray | null;
319
+ while ((match = wordPattern.exec(text))) ranges.push([match.index, match.index + match[0]!.length]);
320
+ return ranges;
321
+ }
322
+
323
+ export function startSpan(displayIndex: number, text: string): SpanState | null {
324
+ const words = wordRanges(text);
325
+ if (!words.length) return null;
326
+ return { displayIndex, wordIndex: 0, wordEnd: 0, start: words[0]![0], end: words[0]![1] };
327
+ }
328
+
329
+ export function spanKey(span: SpanState, key: string, text: string): SpanState {
330
+ const words = wordRanges(text);
331
+ const nextSpan = { ...span };
332
+ const width = nextSpan.wordEnd - nextSpan.wordIndex;
333
+ if (key === "l") nextSpan.wordEnd = Math.min(words.length - 1, nextSpan.wordEnd + 1);
334
+ else if (key === "h") nextSpan.wordEnd = Math.max(nextSpan.wordIndex, nextSpan.wordEnd - 1);
335
+ else if (key === "w") {
336
+ const slidTo = Math.min(words.length - 1 - width, nextSpan.wordIndex + 1);
337
+ nextSpan.wordIndex = slidTo;
338
+ nextSpan.wordEnd = slidTo + width;
339
+ } else if (key === "b") {
340
+ const slidTo = Math.max(0, nextSpan.wordIndex - 1);
341
+ nextSpan.wordIndex = slidTo;
342
+ nextSpan.wordEnd = slidTo + width;
343
+ } else if (key === "$") nextSpan.wordEnd = words.length - 1;
344
+ else if (key === "0") {
345
+ nextSpan.wordEnd = width;
346
+ nextSpan.wordIndex = 0;
347
+ } else return span;
348
+ nextSpan.start = words[nextSpan.wordIndex]![0];
349
+ nextSpan.end = words[nextSpan.wordEnd]![1];
350
+ return nextSpan;
351
+ }
352
+
353
+ // ── working-copy block operations ─────────────
354
+
355
+ /** The next work block after a display index (a del block re-enters before it). */
356
+ export function nextWorkBlock(display: DisplayBlock[], displayIndex: number): Block | undefined {
357
+ for (let candidateIndex = displayIndex + 1; candidateIndex < display.length; candidateIndex++) {
358
+ const workBlock = display[candidateIndex]!.work;
359
+ if (workBlock) return workBlock;
360
+ }
361
+ return undefined;
362
+ }
package/src/walk.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Guided-walk projections (#83): pure helpers over the diff row model - no
2
+ * Guided-walk projections: pure helpers over the diff row model - no
3
3
  * IO, no React. The walk steps through the changed files in patch order;
4
4
  * which files were seen lives on the session record (viewedPaths), which
5
5
  * file is on screen is view state in the session controller.
@@ -31,11 +31,11 @@ export function walkFiles(rows: DiffRow[]): WalkFile[] {
31
31
  byPath.set(row.file, file);
32
32
  files.push(file);
33
33
  }
34
- if (row.t !== "add" && row.t !== "del") continue;
35
- if (row.t === "add") file.added += 1;
34
+ if (row.kind !== "add" && row.kind !== "del") continue;
35
+ if (row.kind === "add") file.added += 1;
36
36
  else file.removed += 1;
37
37
  if (file.preview.length < WALK_PREVIEW_ROWS) {
38
- file.preview.push({ sign: row.t === "add" ? "+" : "-", text: row.text.replace(/\n$/, "") });
38
+ file.preview.push({ sign: row.kind === "add" ? "+" : "-", text: row.text.replace(/\n$/, "") });
39
39
  }
40
40
  }
41
41
  return files;
@@ -1,6 +0,0 @@
1
- /** Shared text formatting for the component system. */
2
-
3
- export function truncate(value: string, maxLength: number): string {
4
- const oneLine = value.replace(/\n/g, " ");
5
- return oneLine.length > maxLength ? oneLine.slice(0, maxLength - 1) + "…" : oneLine;
6
- }