@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.35

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.
Files changed (94) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +130 -4
  4. package/src/App.export.test.tsx +22 -4
  5. package/src/App.plan-review.test.tsx +127 -27
  6. package/src/App.readonly.test.tsx +31 -8
  7. package/src/App.rendering.test.tsx +12 -3
  8. package/src/App.review-panel.test.tsx +11 -3
  9. package/src/App.submit-confirm.test.tsx +12 -7
  10. package/src/App.test.tsx +46 -9
  11. package/src/App.toast.test.tsx +10 -3
  12. package/src/App.tsx +400 -100
  13. package/src/App.walk.test.tsx +30 -10
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +28 -9
  17. package/src/components/AnnotationCard.tsx +53 -38
  18. package/src/components/Breadcrumb.tsx +7 -1
  19. package/src/components/CodeBlock.stories.tsx +19 -2
  20. package/src/components/CodeBlock.tsx +24 -9
  21. package/src/components/CompletionOverlay.tsx +13 -3
  22. package/src/components/ConfirmCard.stories.tsx +22 -3
  23. package/src/components/ConfirmCard.tsx +22 -5
  24. package/src/components/ConfirmDialog.tsx +9 -1
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +18 -2
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +184 -98
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.tsx +19 -3
  32. package/src/components/MarkerPopover.stories.tsx +58 -0
  33. package/src/components/MarkerPopover.tsx +102 -0
  34. package/src/components/MenuBar.stories.tsx +5 -1
  35. package/src/components/MenuBar.tsx +18 -2
  36. package/src/components/PlanSheet.stories.tsx +34 -2
  37. package/src/components/PlanSheet.tsx +141 -32
  38. package/src/components/PromptDialog.tsx +18 -2
  39. package/src/components/ReviewPanel.stories.tsx +13 -3
  40. package/src/components/ReviewPanel.tsx +8 -4
  41. package/src/components/ReviewRail.stories.tsx +145 -72
  42. package/src/components/ReviewRail.tsx +177 -38
  43. package/src/components/SettingsDialog.stories.tsx +51 -3
  44. package/src/components/SettingsDialog.tsx +8 -3
  45. package/src/components/SettingsRows.tsx +19 -3
  46. package/src/components/Toast.tsx +17 -2
  47. package/src/components/WalkWizard.stories.tsx +13 -2
  48. package/src/components/WalkWizard.tsx +9 -3
  49. package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
  50. package/src/components/diff-sheet-layout.test.ts +127 -0
  51. package/src/components/diff-sheet-layout.ts +167 -0
  52. package/src/components/primitives/Button.tsx +5 -1
  53. package/src/components/primitives/Dialog.tsx +9 -1
  54. package/src/components/primitives/Tabs.tsx +24 -4
  55. package/src/components/stories-app.tsx +34 -6
  56. package/src/components/story-fixtures.ts +11 -3
  57. package/src/components/story.ts +3 -1
  58. package/src/components/syntax-highlight.ts +47 -12
  59. package/src/components/theme-context.stories.tsx +6 -1
  60. package/src/components/theme-context.tsx +7 -1
  61. package/src/config.test.ts +178 -2
  62. package/src/config.ts +121 -23
  63. package/src/diff-hunk-curate.test.ts +315 -0
  64. package/src/diff-hunk-curate.ts +233 -0
  65. package/src/diff-intraline.test.ts +164 -0
  66. package/src/diff-intraline.ts +132 -0
  67. package/src/diff-syntax.test.ts +96 -0
  68. package/src/diff-syntax.ts +154 -0
  69. package/src/editor.test.ts +13 -4
  70. package/src/editor.ts +31 -5
  71. package/src/herdr.ts +8 -2
  72. package/src/index.ts +9 -1
  73. package/src/integrations.ts +3 -1
  74. package/src/intent-dispatch.test.ts +193 -9
  75. package/src/intent-dispatch.ts +151 -18
  76. package/src/key-bindings.test.ts +19 -9
  77. package/src/key-bindings.ts +36 -14
  78. package/src/keymap.test.ts +171 -26
  79. package/src/keymap.ts +83 -16
  80. package/src/review-panel.test.ts +3 -1
  81. package/src/review-panel.ts +9 -2
  82. package/src/serve.test.ts +11 -3
  83. package/src/session-controller.curate.test.ts +253 -0
  84. package/src/session-controller.share.test.ts +65 -17
  85. package/src/session-controller.ts +301 -26
  86. package/src/share.ts +41 -8
  87. package/src/test-support.ts +16 -6
  88. package/src/theme-presets.ts +168 -0
  89. package/src/theme.ts +1 -3
  90. package/src/view-diff.ts +15 -4
  91. package/src/view-plan.test.ts +40 -6
  92. package/src/view-plan.ts +71 -28
  93. package/src/walk.test.ts +7 -2
  94. package/src/walk.ts +7 -2
package/src/share.ts CHANGED
@@ -6,7 +6,12 @@
6
6
  * Share button, so the two share exactly the same path.
7
7
  */
8
8
 
9
- import { DEFAULT_SHARE_HOST, DEFAULT_SHARE_PORT, SHARE_UPLOAD_USER, packSessionBlob } from "@cueloop/daemon/share-blob";
9
+ import {
10
+ DEFAULT_SHARE_HOST,
11
+ DEFAULT_SHARE_PORT,
12
+ SHARE_UPLOAD_USER,
13
+ packSessionBlob,
14
+ } from "@cueloop/daemon/share-blob";
10
15
  import type { Annotation, ReviewSession } from "@cueloop/schema";
11
16
  import { copyToClipboard } from "./clipboard";
12
17
 
@@ -28,9 +33,17 @@ export function shareIdFromLine(line: string): string | undefined {
28
33
  }
29
34
 
30
35
  /** Upload the session to the gateway and copy the resulting ssh line. */
31
- export async function publishShare(session: ReviewSession, target: ShareTarget = {}): Promise<ShareResult> {
32
- const { stdout, stderr, code } = await runShareSsh("cueloop-share", packSessionBlob(session), target);
33
- if (code !== 0) throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
36
+ export async function publishShare(
37
+ session: ReviewSession,
38
+ target: ShareTarget = {},
39
+ ): Promise<ShareResult> {
40
+ const { stdout, stderr, code } = await runShareSsh(
41
+ "cueloop-share",
42
+ packSessionBlob(session),
43
+ target,
44
+ );
45
+ if (code !== 0)
46
+ throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
34
47
  const line = stdout.trim();
35
48
  if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
36
49
  const copied = await copyToClipboard(line);
@@ -58,17 +71,37 @@ export function collaboratorAnnotations(session: ReviewSession): Annotation[] {
58
71
  * them on their next refresh. Owner-gated at the gateway; the notes stay
59
72
  * unauthored (the planner's), unioned by id.
60
73
  */
61
- export async function pushShare(shareId: string, annotations: Array<Omit<Annotation, "createdAt">>, target: ShareTarget = {}): Promise<void> {
62
- const { stderr, code } = await runShareSsh("cueloop-push", Buffer.from(JSON.stringify({ shareId, annotations })), target);
74
+ export async function pushShare(
75
+ shareId: string,
76
+ annotations: Array<Omit<Annotation, "createdAt">>,
77
+ target: ShareTarget = {},
78
+ ): Promise<void> {
79
+ const { stderr, code } = await runShareSsh(
80
+ "cueloop-push",
81
+ Buffer.from(JSON.stringify({ shareId, annotations })),
82
+ target,
83
+ );
63
84
  if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
64
85
  }
65
86
 
66
87
  /** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
67
- async function runShareSsh(command: string, stdin: Buffer, target: ShareTarget): Promise<{ stdout: string; stderr: string; code: number }> {
88
+ async function runShareSsh(
89
+ command: string,
90
+ stdin: Buffer,
91
+ target: ShareTarget,
92
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
68
93
  const host = target.host ?? DEFAULT_SHARE_HOST;
69
94
  const port = target.port ?? DEFAULT_SHARE_PORT;
70
95
  const proc = Bun.spawn(
71
- ["ssh", "-p", String(port), "-o", "StrictHostKeyChecking=accept-new", `${SHARE_UPLOAD_USER}@${host}`, command],
96
+ [
97
+ "ssh",
98
+ "-p",
99
+ String(port),
100
+ "-o",
101
+ "StrictHostKeyChecking=accept-new",
102
+ `${SHARE_UPLOAD_USER}@${host}`,
103
+ command,
104
+ ],
72
105
  { stdin, stdout: "pipe", stderr: "pipe" },
73
106
  );
74
107
  const [stdout, stderr, code] = await Promise.all([
@@ -113,7 +113,7 @@ async function waitForFramePredicate(
113
113
  if (predicate(frame)) return frame;
114
114
  const now = Date.now();
115
115
  if (now > deadline) {
116
- const frozen = frameChanges <= 1 ? " — render never advanced (frozen)" : "";
116
+ const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
117
117
  throw new Error(
118
118
  `waitFor ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
119
119
  );
@@ -121,7 +121,7 @@ async function waitForFramePredicate(
121
121
  if (now - lastLog >= PROGRESS_LOG_MS) {
122
122
  lastLog = now;
123
123
  console.error(
124
- `[waitFor] still waiting for ${label} — ${now - start}ms, ${polls} polls, ${frameChanges} frame changes\n${frameTail(frame)}`,
124
+ `[waitFor] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes\n${frameTail(frame)}`,
125
125
  );
126
126
  }
127
127
  await yieldEventLoop();
@@ -153,14 +153,16 @@ export async function waitForState(
153
153
  if (predicate()) return;
154
154
  const now = Date.now();
155
155
  if (now > deadline) {
156
- const frozen = frameChanges <= 1 ? " — render never advanced (frozen)" : "";
156
+ const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
157
157
  throw new Error(
158
158
  `waitForState ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
159
159
  );
160
160
  }
161
161
  if (now - lastLog >= PROGRESS_LOG_MS) {
162
162
  lastLog = now;
163
- console.error(`[waitForState] still waiting for ${label} — ${now - start}ms, ${polls} polls, ${frameChanges} frame changes`);
163
+ console.error(
164
+ `[waitForState] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes`,
165
+ );
164
166
  }
165
167
  await yieldEventLoop();
166
168
  }
@@ -168,10 +170,18 @@ export async function waitForState(
168
170
 
169
171
  /** Wait until the frame contains the needle (daemon round-trips included). */
170
172
  export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
171
- return waitForFramePredicate(setup, (frame) => frame.includes(needle), `text ${JSON.stringify(needle)}`);
173
+ return waitForFramePredicate(
174
+ setup,
175
+ (frame) => frame.includes(needle),
176
+ `text ${JSON.stringify(needle)}`,
177
+ );
172
178
  }
173
179
 
174
180
  /** Wait until the frame no longer contains the needle. */
175
181
  export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
176
- return waitForFramePredicate(setup, (frame) => !frame.includes(needle), `text-gone ${JSON.stringify(needle)}`);
182
+ return waitForFramePredicate(
183
+ setup,
184
+ (frame) => !frame.includes(needle),
185
+ `text-gone ${JSON.stringify(needle)}`,
186
+ );
177
187
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Named theme presets selectable from Settings and `[ui] theme`. The branded
3
+ * "cueloop" default keeps the transparent terminal-through look; each of the
4
+ * well-known palettes paints its own opaque background so it reads exactly like
5
+ * the published theme. Every preset fills the full Theme token contract, so a
6
+ * `[theme]` per-token override still layers cleanly on top of the chosen base.
7
+ *
8
+ * Each palette maps its first-party spec onto cueloop's semantic slots. Where a
9
+ * palette has no dedicated green (Rosé Pine), additions use its positive accent
10
+ * by that palette's own convention.
11
+ */
12
+
13
+ import { DARK, type Theme } from "./theme";
14
+
15
+ /** Selectable theme name; the persisted `[ui] theme` value and cycle key. */
16
+ export type ThemeName =
17
+ | "cueloop"
18
+ | "rose-pine-moon"
19
+ | "catppuccin-mocha"
20
+ | "tokyo-night"
21
+ | "gruvbox-dark"
22
+ | "nord";
23
+
24
+ /** The branded default; the base every layered config starts from. */
25
+ export const DEFAULT_THEME_NAME: ThemeName = "cueloop";
26
+
27
+ /** Rosé Pine Moon - rosepinetheme.com. Foam stands in for additions (no green in the palette). */
28
+ const ROSE_PINE_MOON: Theme = {
29
+ background: "#232136",
30
+ panel: "#232136",
31
+ elevated: "#2a273f",
32
+ border: "#44415a",
33
+ text: "#e0def4",
34
+ textMuted: "#908caa",
35
+ textDim: "#6e6a86",
36
+ accent: "#ea9a97",
37
+ accentInk: "#232136",
38
+ green: "#9ccfd8",
39
+ red: "#eb6f92",
40
+ blue: "#3e8fb0",
41
+ cursorBackground: "#382a2f",
42
+ markCommentBackground: "#382a37",
43
+ insertedForeground: "#9ccfd8",
44
+ deletedForeground: "#eb6f92",
45
+ backdrop: "transparent",
46
+ };
47
+
48
+ /** Catppuccin Mocha - catppuccin.com. Mauve is the signature accent. */
49
+ const CATPPUCCIN_MOCHA: Theme = {
50
+ background: "#1e1e2e",
51
+ panel: "#1e1e2e",
52
+ elevated: "#313244",
53
+ border: "#45475a",
54
+ text: "#cdd6f4",
55
+ textMuted: "#a6adc8",
56
+ textDim: "#6c7086",
57
+ accent: "#cba6f7",
58
+ accentInk: "#1e1e2e",
59
+ green: "#a6e3a1",
60
+ red: "#f38ba8",
61
+ blue: "#89b4fa",
62
+ cursorBackground: "#2e2740",
63
+ markCommentBackground: "#33222b",
64
+ insertedForeground: "#a6e3a1",
65
+ deletedForeground: "#f38ba8",
66
+ backdrop: "transparent",
67
+ };
68
+
69
+ /** Tokyo Night - the folke "night" variant. Magenta is the signature accent. */
70
+ const TOKYO_NIGHT: Theme = {
71
+ background: "#1a1b26",
72
+ panel: "#1a1b26",
73
+ elevated: "#292e42",
74
+ border: "#3b4261",
75
+ text: "#c0caf5",
76
+ textMuted: "#a9b1d6",
77
+ textDim: "#565f89",
78
+ accent: "#bb9af7",
79
+ accentInk: "#1a1b26",
80
+ green: "#9ece6a",
81
+ red: "#f7768e",
82
+ blue: "#7aa2f7",
83
+ cursorBackground: "#2b2740",
84
+ markCommentBackground: "#2f2230",
85
+ insertedForeground: "#9ece6a",
86
+ deletedForeground: "#f7768e",
87
+ backdrop: "transparent",
88
+ };
89
+
90
+ /** Gruvbox Dark - morhetz/gruvbox, medium contrast. Orange is the warm signature accent. */
91
+ const GRUVBOX_DARK: Theme = {
92
+ background: "#282828",
93
+ panel: "#282828",
94
+ elevated: "#3c3836",
95
+ border: "#504945",
96
+ text: "#ebdbb2",
97
+ textMuted: "#d5c4a1",
98
+ textDim: "#928374",
99
+ accent: "#fe8019",
100
+ accentInk: "#282828",
101
+ green: "#b8bb26",
102
+ red: "#fb4934",
103
+ blue: "#83a598",
104
+ cursorBackground: "#3a2f20",
105
+ markCommentBackground: "#3a2823",
106
+ insertedForeground: "#b8bb26",
107
+ deletedForeground: "#fb4934",
108
+ backdrop: "transparent",
109
+ };
110
+
111
+ /** Nord - nordtheme.com, the official 16-color spec. Frost cyan is the signature accent. */
112
+ const NORD: Theme = {
113
+ background: "#2e3440",
114
+ panel: "#2e3440",
115
+ elevated: "#3b4252",
116
+ border: "#434c5e",
117
+ text: "#eceff4",
118
+ textMuted: "#d8dee9",
119
+ textDim: "#4c566a",
120
+ accent: "#88c0d0",
121
+ accentInk: "#2e3440",
122
+ green: "#a3be8c",
123
+ red: "#bf616a",
124
+ blue: "#81a1c1",
125
+ cursorBackground: "#2a3540",
126
+ markCommentBackground: "#3a2f33",
127
+ insertedForeground: "#a3be8c",
128
+ deletedForeground: "#bf616a",
129
+ backdrop: "transparent",
130
+ };
131
+
132
+ /** All selectable themes by name; "cueloop" is the branded transparent default. */
133
+ export const THEME_PRESETS: Record<ThemeName, Theme> = {
134
+ cueloop: DARK,
135
+ "rose-pine-moon": ROSE_PINE_MOON,
136
+ "catppuccin-mocha": CATPPUCCIN_MOCHA,
137
+ "tokyo-night": TOKYO_NIGHT,
138
+ "gruvbox-dark": GRUVBOX_DARK,
139
+ nord: NORD,
140
+ };
141
+
142
+ /** Cycle order for the Settings theme row; the registry's insertion order, branded default first. */
143
+ export const THEME_NAMES: ThemeName[] = Object.keys(THEME_PRESETS) as ThemeName[];
144
+
145
+ /** Human labels for the Settings cycle row, keyed by the persisted name. */
146
+ export const THEME_LABELS: Record<ThemeName, string> = {
147
+ cueloop: "cueloop",
148
+ "rose-pine-moon": "Rosé Pine Moon",
149
+ "catppuccin-mocha": "Catppuccin Mocha",
150
+ "tokyo-night": "Tokyo Night",
151
+ "gruvbox-dark": "Gruvbox Dark",
152
+ nord: "Nord",
153
+ };
154
+
155
+ /** Resolve a persisted theme name to its token set; unknown names fall back to the branded default. */
156
+ export function themeForName(name: string): Theme {
157
+ return THEME_PRESETS[name as ThemeName] ?? DARK;
158
+ }
159
+
160
+ /** A preset with the user's `[theme]` per-token overrides layered on top - the live-switch equivalent of config load. */
161
+ export function composeTheme(name: string, overrides: Partial<Theme>): Theme {
162
+ return { ...themeForName(name), ...overrides };
163
+ }
164
+
165
+ /** True when the name is a known preset (guards a persisted `[ui] theme` value). */
166
+ export function isThemeName(name: string): name is ThemeName {
167
+ return name in THEME_PRESETS;
168
+ }
package/src/theme.ts CHANGED
@@ -20,7 +20,6 @@ export interface Theme {
20
20
  blue: string;
21
21
  cursorBackground: string;
22
22
  markCommentBackground: string;
23
- markSuggestionBackground: string;
24
23
  insertedForeground: string;
25
24
  deletedForeground: string;
26
25
  /** Layer behind centered dialogs; transparent keeps the session visible. */
@@ -62,8 +61,7 @@ export const DARK: Theme = {
62
61
  blue: "#84a6e8",
63
62
  cursorBackground: "#33262a",
64
63
  markCommentBackground: "#3d2a2e",
65
- markSuggestionBackground: "#20351f",
66
64
  insertedForeground: "#62d96b",
67
65
  deletedForeground: "#ff6b6b",
68
66
  backdrop: "transparent",
69
- }
67
+ };
package/src/view-diff.ts CHANGED
@@ -64,18 +64,29 @@ export function diffRows(patchText: string): DiffRow[] {
64
64
  }
65
65
 
66
66
  function fileLabel(file: FileDiffMetadata): string {
67
- return file.prevName && file.prevName !== file.name ? `${file.prevName} → ${file.name}` : file.name;
67
+ return file.prevName && file.prevName !== file.name
68
+ ? `${file.prevName} → ${file.name}`
69
+ : file.name;
68
70
  }
69
71
 
70
72
  /** Quote-primary anchor for a diff row: neighbors as context selectors. */
71
- export function diffRowAnchor(rows: DiffRow[], rowIndex: number): { quote: string; prefix: string; suffix: string } {
73
+ export function diffRowAnchor(
74
+ rows: DiffRow[],
75
+ rowIndex: number,
76
+ ): { quote: string; prefix: string; suffix: string } {
72
77
  const row = rows[rowIndex]!;
73
78
  const prev = rows[rowIndex - 1];
74
79
  const next = rows[rowIndex + 1];
75
80
  return {
76
81
  quote: row.text,
77
- prefix: prev && (prev.kind === "ctx" || prev.kind === "add" || prev.kind === "del") ? prev.text.slice(-24) : "",
78
- suffix: next && (next.kind === "ctx" || next.kind === "add" || next.kind === "del") ? next.text.slice(0, 24) : "",
82
+ prefix:
83
+ prev && (prev.kind === "ctx" || prev.kind === "add" || prev.kind === "del")
84
+ ? prev.text.slice(-24)
85
+ : "",
86
+ suffix:
87
+ next && (next.kind === "ctx" || next.kind === "add" || next.kind === "del")
88
+ ? next.text.slice(0, 24)
89
+ : "",
79
90
  };
80
91
  }
81
92
 
@@ -7,6 +7,7 @@ import {
7
7
  nextWorkBlock,
8
8
  overlayMarks,
9
9
  renderedOffsetFor,
10
+ spanFromRange,
10
11
  spanKey,
11
12
  startSpan,
12
13
  workRangeForRendered,
@@ -53,8 +54,12 @@ describe("buildDisplay reconciliation", () => {
53
54
  const display = buildDisplay(BASE, working);
54
55
 
55
56
  // Assert
56
- expect(display.some((block) => block.type === "del" && block.base!.text === "second item")).toBe(true);
57
- expect(display.some((block) => block.type === "add" && block.work!.text.includes("closing note"))).toBe(true);
57
+ expect(
58
+ display.some((block) => block.type === "del" && block.base!.text === "second item"),
59
+ ).toBe(true);
60
+ expect(
61
+ display.some((block) => block.type === "add" && block.work!.text.includes("closing note")),
62
+ ).toBe(true);
58
63
  expect(display.some((block) => block.type === "mod")).toBe(false);
59
64
  });
60
65
 
@@ -76,8 +81,14 @@ describe("blockRuns + overlayMarks", () => {
76
81
  const runs = blockRuns(block, true);
77
82
 
78
83
  // Assert
79
- const oldSide = runs.filter((run) => run.role !== "ins").map((run) => run.text).join("");
80
- const newSide = runs.filter((run) => run.role !== "del").map((run) => run.text).join("");
84
+ const oldSide = runs
85
+ .filter((run) => run.role !== "ins")
86
+ .map((run) => run.text)
87
+ .join("");
88
+ const newSide = runs
89
+ .filter((run) => run.role !== "del")
90
+ .map((run) => run.text)
91
+ .join("");
81
92
  expect(oldSide).toBe("plain old text here");
82
93
  expect(newSide).toBe("plain new text here");
83
94
  });
@@ -91,7 +102,10 @@ describe("blockRuns + overlayMarks", () => {
91
102
  const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
92
103
 
93
104
  // Assert
94
- const markedText = marked.filter((run) => run.role === "mark-comment").map((run) => run.text).join("");
105
+ const markedText = marked
106
+ .filter((run) => run.role === "mark-comment")
107
+ .map((run) => run.text)
108
+ .join("");
95
109
  expect(markedText).toBe("words");
96
110
  // del run text untouched
97
111
  expect(marked.some((run) => run.role === "del" && run.text === "old")).toBe(true);
@@ -102,7 +116,9 @@ describe("blockRuns + overlayMarks", () => {
102
116
  const block = buildDisplay("keep\n", "keep added\n")[0]!;
103
117
 
104
118
  // Act
105
- const runs = overlayMarks(blockRuns(block, true), [{ start: 0, end: 10, role: "mark-comment" }]);
119
+ const runs = overlayMarks(blockRuns(block, true), [
120
+ { start: 0, end: 10, role: "mark-comment" },
121
+ ]);
106
122
 
107
123
  // Assert
108
124
  expect(runs.some((run) => run.role === "ins")).toBe(true);
@@ -230,6 +246,24 @@ describe("span mode", () => {
230
246
  // Assert
231
247
  expect(text.slice(span.start, span.end)).toBe("two three four");
232
248
  });
249
+
250
+ test("spanFromRange snaps a mid-word mouse drag to whole words", () => {
251
+ // Arrange - a drag from inside "two" to inside "three"
252
+ const dragStart = text.indexOf("two") + 1;
253
+ const dragEnd = text.indexOf("three") + 2;
254
+
255
+ // Act
256
+ const span = spanFromRange(0, text, dragStart, dragEnd)!;
257
+
258
+ // Assert - snaps outward to cover both touched words, ready for w/b/l/h
259
+ expect(text.slice(span.start, span.end)).toBe("two three");
260
+ expect(spanKey(span, "l", text)).toMatchObject({ wordEnd: 3 });
261
+ });
262
+
263
+ test("spanFromRange returns null when the range covers no word", () => {
264
+ // Assert - a range past the last word has nothing to select
265
+ expect(spanFromRange(0, text, text.length, text.length)).toBeNull();
266
+ });
233
267
  });
234
268
 
235
269
  describe("cut / restore round-trip", () => {
package/src/view-plan.ts CHANGED
@@ -12,10 +12,10 @@ import {
12
12
  lcsDiff,
13
13
  parseBlocks,
14
14
  resolveAnchor,
15
- wordDiff,
16
15
  type Annotation,
17
16
  type Block,
18
17
  } from "@cueloop/schema";
18
+ import { wordLevelChanges } from "./diff-intraline";
19
19
 
20
20
  // ── display model ─────────────────────────────
21
21
 
@@ -62,7 +62,11 @@ export function buildDisplay(baseContent: string, workingContent?: string): Disp
62
62
  }
63
63
  const workBlocks = parseBlocks(workingContent);
64
64
  const signature = (block: Block) => block.kind + "\0" + block.text;
65
- const ops = lcsDiff(baseBlocks, workBlocks, (baseBlock, workBlock) => signature(baseBlock) === signature(workBlock));
65
+ const ops = lcsDiff(
66
+ baseBlocks,
67
+ workBlocks,
68
+ (baseBlock, workBlock) => signature(baseBlock) === signature(workBlock),
69
+ );
66
70
  const display: DisplayBlock[] = [];
67
71
  let opIndex = 0;
68
72
  while (opIndex < ops.length) {
@@ -93,18 +97,34 @@ export function buildDisplay(baseContent: string, workingContent?: string): Disp
93
97
  deletedIndex++;
94
98
  addedIndex++;
95
99
  } else if (deletedBlocks.length - deletedIndex >= addedBlocks.length - addedIndex) {
96
- display.push({ type: "del", kind: deletedBlocks[deletedIndex]!.kind, base: deletedBlocks[deletedIndex]! });
100
+ display.push({
101
+ type: "del",
102
+ kind: deletedBlocks[deletedIndex]!.kind,
103
+ base: deletedBlocks[deletedIndex]!,
104
+ });
97
105
  deletedIndex++;
98
106
  } else {
99
- display.push({ type: "add", kind: addedBlocks[addedIndex]!.kind, work: addedBlocks[addedIndex]! });
107
+ display.push({
108
+ type: "add",
109
+ kind: addedBlocks[addedIndex]!.kind,
110
+ work: addedBlocks[addedIndex]!,
111
+ });
100
112
  addedIndex++;
101
113
  }
102
114
  }
103
115
  while (deletedIndex < deletedBlocks.length) {
104
- display.push({ type: "del", kind: deletedBlocks[deletedIndex]!.kind, base: deletedBlocks[deletedIndex++]! });
116
+ display.push({
117
+ type: "del",
118
+ kind: deletedBlocks[deletedIndex]!.kind,
119
+ base: deletedBlocks[deletedIndex++]!,
120
+ });
105
121
  }
106
122
  while (addedIndex < addedBlocks.length) {
107
- display.push({ type: "add", kind: addedBlocks[addedIndex]!.kind, work: addedBlocks[addedIndex++]! });
123
+ display.push({
124
+ type: "add",
125
+ kind: addedBlocks[addedIndex]!.kind,
126
+ work: addedBlocks[addedIndex++]!,
127
+ });
108
128
  }
109
129
  }
110
130
  numberOrderedListItems(display);
@@ -121,14 +141,7 @@ function numberOrderedListItems(display: DisplayBlock[]): void {
121
141
 
122
142
  // ── marks (annotation highlights + keyboard span) ──
123
143
 
124
- export type RunRole =
125
- | "plain"
126
- | "ins"
127
- | "del"
128
- | "mark-comment"
129
- | "mark-suggestion"
130
- | "kspan"
131
- | "mark-focus";
144
+ export type RunRole = "plain" | "ins" | "del" | "mark-comment" | "kspan" | "mark-focus";
132
145
 
133
146
  export interface StyleRun {
134
147
  text: string;
@@ -172,12 +185,7 @@ export function marksByDisplay(
172
185
  marks.push({
173
186
  start: resolved.start,
174
187
  end: resolved.end,
175
- role:
176
- annotation.id === focusedId
177
- ? "mark-focus"
178
- : annotation.kind === "suggestion"
179
- ? "mark-suggestion"
180
- : "mark-comment",
188
+ role: annotation.id === focusedId ? "mark-focus" : "mark-comment",
181
189
  annotationId: annotation.id,
182
190
  });
183
191
  marksByIndex.set(entry.displayIndex, marks);
@@ -188,15 +196,19 @@ export function marksByDisplay(
188
196
  /** Base runs for a display block: plain text, or word-diff for mod blocks. */
189
197
  export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
190
198
  if (block.type === "mod" && markup) {
191
- const ops = wordDiff(block.base!.text, block.work!.text);
199
+ const changes = wordLevelChanges(block.base!.text, block.work!.text);
192
200
  const runs: StyleRun[] = [];
193
201
  let workOffset = 0;
194
- for (const op of ops) {
195
- if (op.kind === "del") {
196
- runs.push({ text: op.oldValue!, role: "del", start: null });
202
+ for (const change of changes) {
203
+ if (change.kind === "removed") {
204
+ runs.push({ text: change.text, role: "del", start: null });
197
205
  } else {
198
- runs.push({ text: op.newValue!, role: op.kind === "add" ? "ins" : "plain", start: workOffset });
199
- workOffset += op.newValue!.length;
206
+ runs.push({
207
+ text: change.text,
208
+ role: change.kind === "added" ? "ins" : "plain",
209
+ start: workOffset,
210
+ });
211
+ workOffset += change.text.length;
200
212
  }
201
213
  }
202
214
  return runs;
@@ -294,7 +306,10 @@ export function workRangeForRendered(
294
306
  }
295
307
 
296
308
  /** Line delta between two revision contents, for the sheet-header summary. */
297
- export function revisionDelta(previousContent: string, nextContent: string): { added: number; removed: number } {
309
+ export function revisionDelta(
310
+ previousContent: string,
311
+ nextContent: string,
312
+ ): { added: number; removed: number } {
298
313
  const ops = lcsDiff(previousContent.split("\n"), nextContent.split("\n"));
299
314
  let added = 0;
300
315
  let removed = 0;
@@ -319,7 +334,8 @@ export function wordRanges(text: string): [number, number][] {
319
334
  const ranges: [number, number][] = [];
320
335
  const wordPattern = /\S+/g;
321
336
  let match: RegExpExecArray | null;
322
- while ((match = wordPattern.exec(text))) ranges.push([match.index, match.index + match[0]!.length]);
337
+ while ((match = wordPattern.exec(text)))
338
+ ranges.push([match.index, match.index + match[0]!.length]);
323
339
  return ranges;
324
340
  }
325
341
 
@@ -329,6 +345,33 @@ export function startSpan(displayIndex: number, text: string): SpanState | null
329
345
  return { displayIndex, wordIndex: 0, wordEnd: 0, start: words[0]![0], end: words[0]![1] };
330
346
  }
331
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
+
332
375
  export function spanKey(span: SpanState, key: string, text: string): SpanState {
333
376
  const words = wordRanges(text);
334
377
  const nextSpan = { ...span };
package/src/walk.test.ts CHANGED
@@ -72,7 +72,9 @@ describe("firstUnviewedIndex", () => {
72
72
  });
73
73
 
74
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(files.length);
75
+ expect(firstUnviewedIndex(files, new Set(["src/a.ts", "src/b.ts", "src/c.ts"]))).toBe(
76
+ files.length,
77
+ );
76
78
  });
77
79
  });
78
80
 
@@ -93,7 +95,10 @@ describe("noteForFile", () => {
93
95
 
94
96
  test("finds the note anchored at the file path", () => {
95
97
  // Arrange
96
- const annotations = [note("src/a.ts", "Renames b and adds c."), note("src/b.ts", "Swaps the line.")];
98
+ const annotations = [
99
+ note("src/a.ts", "Renames b and adds c."),
100
+ note("src/b.ts", "Swaps the line."),
101
+ ];
97
102
 
98
103
  // Assert
99
104
  expect(noteForFile(annotations, "src/b.ts")).toBe("Swaps the line.");
package/src/walk.ts CHANGED
@@ -35,7 +35,10 @@ export function walkFiles(rows: DiffRow[]): WalkFile[] {
35
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.kind === "add" ? "+" : "-", text: row.text.replace(/\n$/, "") });
38
+ file.preview.push({
39
+ sign: row.kind === "add" ? "+" : "-",
40
+ text: row.text.replace(/\n$/, ""),
41
+ });
39
42
  }
40
43
  }
41
44
  return files;
@@ -61,5 +64,7 @@ export function viewedCount(files: WalkFile[], viewedPaths: ReadonlySet<string>)
61
64
  * quote is the file path (the note contract in the shared review core).
62
65
  */
63
66
  export function noteForFile(annotations: Annotation[], path: string): string | undefined {
64
- return annotations.find((annotation) => annotation.kind === "note" && annotation.anchor.quote === path)?.body;
67
+ return annotations.find(
68
+ (annotation) => annotation.kind === "note" && annotation.anchor.quote === path,
69
+ )?.body;
65
70
  }