@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.34

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 (101) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +131 -5
  4. package/src/App.export.test.tsx +23 -5
  5. package/src/App.plan-review.test.tsx +133 -32
  6. package/src/App.readonly.test.tsx +35 -13
  7. package/src/App.rendering.test.tsx +14 -4
  8. package/src/App.review-panel.test.tsx +27 -15
  9. package/src/App.submit-confirm.test.tsx +14 -13
  10. package/src/App.test.tsx +50 -13
  11. package/src/App.toast.test.tsx +75 -0
  12. package/src/App.tsx +505 -101
  13. package/src/App.walk.test.tsx +31 -11
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +29 -10
  17. package/src/components/AnnotationCard.tsx +57 -37
  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 +23 -5
  24. package/src/components/ConfirmDialog.tsx +10 -2
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +35 -1
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +213 -101
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.stories.tsx +31 -0
  32. package/src/components/KeybindsDialog.tsx +58 -0
  33. package/src/components/MarkerPopover.stories.tsx +58 -0
  34. package/src/components/MarkerPopover.tsx +102 -0
  35. package/src/components/MenuBar.stories.tsx +37 -0
  36. package/src/components/MenuBar.tsx +74 -0
  37. package/src/components/PlanSheet.stories.tsx +35 -3
  38. package/src/components/PlanSheet.tsx +160 -100
  39. package/src/components/PromptDialog.tsx +19 -3
  40. package/src/components/ReviewPanel.stories.tsx +17 -19
  41. package/src/components/ReviewPanel.tsx +17 -32
  42. package/src/components/ReviewRail.stories.tsx +159 -47
  43. package/src/components/ReviewRail.tsx +206 -62
  44. package/src/components/SettingsDialog.stories.tsx +51 -3
  45. package/src/components/SettingsDialog.tsx +11 -5
  46. package/src/components/SettingsRows.stories.tsx +1 -1
  47. package/src/components/SettingsRows.tsx +19 -3
  48. package/src/components/Toast.stories.tsx +12 -0
  49. package/src/components/Toast.tsx +44 -0
  50. package/src/components/WalkWizard.stories.tsx +13 -2
  51. package/src/components/WalkWizard.tsx +9 -3
  52. package/src/components/__snapshots__/stories.test.tsx.snap +853 -623
  53. package/src/components/diff-sheet-layout.test.ts +127 -0
  54. package/src/components/diff-sheet-layout.ts +167 -0
  55. package/src/components/primitives/Button.stories.tsx +2 -2
  56. package/src/components/primitives/Button.tsx +5 -1
  57. package/src/components/primitives/Dialog.tsx +12 -1
  58. package/src/components/primitives/Tabs.tsx +40 -39
  59. package/src/components/stories-app.tsx +34 -6
  60. package/src/components/story-fixtures.ts +11 -3
  61. package/src/components/story.ts +3 -1
  62. package/src/components/syntax-highlight.ts +47 -12
  63. package/src/components/theme-context.stories.tsx +6 -1
  64. package/src/components/theme-context.tsx +7 -1
  65. package/src/config.test.ts +178 -2
  66. package/src/config.ts +121 -23
  67. package/src/diff-hunk-curate.test.ts +315 -0
  68. package/src/diff-hunk-curate.ts +233 -0
  69. package/src/diff-intraline.test.ts +164 -0
  70. package/src/diff-intraline.ts +132 -0
  71. package/src/diff-syntax.test.ts +96 -0
  72. package/src/diff-syntax.ts +154 -0
  73. package/src/editor.test.ts +13 -4
  74. package/src/editor.ts +31 -5
  75. package/src/herdr.ts +8 -2
  76. package/src/index.ts +9 -1
  77. package/src/integrations.ts +3 -1
  78. package/src/intent-dispatch.test.ts +193 -9
  79. package/src/intent-dispatch.ts +151 -18
  80. package/src/key-bindings.test.ts +19 -9
  81. package/src/key-bindings.ts +77 -16
  82. package/src/keymap.test.ts +171 -26
  83. package/src/keymap.ts +83 -16
  84. package/src/review-panel.test.ts +3 -1
  85. package/src/review-panel.ts +9 -2
  86. package/src/serve.test.ts +13 -5
  87. package/src/session-controller.curate.test.ts +253 -0
  88. package/src/session-controller.share.test.ts +77 -16
  89. package/src/session-controller.ts +319 -26
  90. package/src/share.ts +41 -8
  91. package/src/test-support.ts +97 -23
  92. package/src/theme-presets.ts +168 -0
  93. package/src/theme.ts +1 -3
  94. package/src/version.ts +5 -0
  95. package/src/view-diff.ts +15 -4
  96. package/src/view-plan.test.ts +40 -6
  97. package/src/view-plan.ts +71 -28
  98. package/src/walk.test.ts +7 -2
  99. package/src/walk.ts +7 -2
  100. package/src/components/ComposeBar.stories.tsx +0 -18
  101. package/src/components/ComposeBar.tsx +0 -54
@@ -61,53 +61,127 @@ export async function typeText(setup: TestRendererSetup, text: string): Promise<
61
61
  await settle(setup);
62
62
  }
63
63
 
64
- const WAIT_DEADLINE_MS = 30_000;
64
+ // Below the 60s per-test budget so a genuinely stuck wait throws its frame
65
+ // error (useful) before bun's bare "timed out" fires. Generous because a
66
+ // contended CI runner starves the render loop and daemon round-trips.
67
+ const WAIT_DEADLINE_MS = 45_000;
68
+
69
+ /** One macrotask turn: hands the event loop back so the in-process daemon can
70
+ * read its socket and apply the write before the next poll. Tight render-pass
71
+ * bursts starve that read on a contended CI runner - the round-trip then never
72
+ * lands and the wait hangs the whole budget. */
73
+ function yieldEventLoop(): Promise<void> {
74
+ return new Promise((resolve) => setTimeout(resolve, 2));
75
+ }
76
+
77
+ /** Log a still-waiting line at most this often, so only genuinely slow (i.e.
78
+ * flaking) waits are noisy; fast waits stay silent. */
79
+ const PROGRESS_LOG_MS = 4000;
80
+
81
+ /** The last render frame's tail, so a progress line shows what is on screen. */
82
+ function frameTail(frame: string, rows = 6): string {
83
+ return frame.split("\n").slice(-rows).join("\n");
84
+ }
65
85
 
66
86
  /**
67
- * waitForFrame gives up as soon as the renderer is idle, but external timers
68
- * (the parser's bare-ESC window, daemon IO, spawned editors) settle on the
69
- * event loop while nothing is scheduled. Between attempts, one real
70
- * event-loop turn plus a render pass lets those land; the deadline bounds
71
- * the whole wait.
87
+ * Poll gently: one render pass, one frame check, one macrotask yield. No
88
+ * multi-pass bursts, so the daemon (same event loop) always gets a turn to
89
+ * process IO between checks. The deadline bounds the whole wait. Slow waits log
90
+ * progress (poll count + frame-change count) so a CI stall shows whether the
91
+ * render froze or the content simply never arrived.
72
92
  */
73
93
  async function waitForFramePredicate(
74
94
  setup: TestRendererSetup,
75
95
  predicate: (frame: string) => boolean,
96
+ label: string,
76
97
  ): Promise<string> {
77
98
  allowEventLoopUpdates();
78
- const deadline = Date.now() + WAIT_DEADLINE_MS;
99
+ const start = Date.now();
100
+ const deadline = start + WAIT_DEADLINE_MS;
101
+ let polls = 0;
102
+ let frameChanges = 0;
103
+ let lastFrame = "";
104
+ let lastLog = start;
79
105
  for (;;) {
80
- try {
81
- return await setup.waitForFrame(predicate, { maxPasses: 50 });
82
- } catch (error) {
83
- if (Date.now() > deadline) throw error;
84
- await new Promise((resolve) => setTimeout(resolve, 1));
85
- await setup.renderOnce();
106
+ await setup.renderOnce();
107
+ const frame = setup.captureCharFrame();
108
+ polls++;
109
+ if (frame !== lastFrame) {
110
+ frameChanges++;
111
+ lastFrame = frame;
86
112
  }
113
+ if (predicate(frame)) return frame;
114
+ const now = Date.now();
115
+ if (now > deadline) {
116
+ const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
117
+ throw new Error(
118
+ `waitFor ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
119
+ );
120
+ }
121
+ if (now - lastLog >= PROGRESS_LOG_MS) {
122
+ lastLog = now;
123
+ console.error(
124
+ `[waitFor] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes\n${frameTail(frame)}`,
125
+ );
126
+ }
127
+ await yieldEventLoop();
87
128
  }
88
129
  }
89
130
 
90
131
  /** Wait until a state predicate holds (daemon round-trips included). */
91
- export async function waitForState(setup: TestRendererSetup, predicate: () => boolean): Promise<void> {
132
+ export async function waitForState(
133
+ setup: TestRendererSetup,
134
+ predicate: () => boolean,
135
+ label = "state",
136
+ ): Promise<void> {
92
137
  allowEventLoopUpdates();
93
- const deadline = Date.now() + WAIT_DEADLINE_MS;
138
+ const start = Date.now();
139
+ const deadline = start + WAIT_DEADLINE_MS;
140
+ let polls = 0;
141
+ let frameChanges = 0;
142
+ let lastFrame = "";
143
+ let lastLog = start;
94
144
  for (;;) {
95
- try {
96
- return await setup.waitFor(predicate, { maxPasses: 50 });
97
- } catch (error) {
98
- if (Date.now() > deadline) throw error;
99
- await new Promise((resolve) => setTimeout(resolve, 1));
100
- await setup.renderOnce();
145
+ if (predicate()) return;
146
+ await setup.renderOnce();
147
+ const frame = setup.captureCharFrame();
148
+ polls++;
149
+ if (frame !== lastFrame) {
150
+ frameChanges++;
151
+ lastFrame = frame;
152
+ }
153
+ if (predicate()) return;
154
+ const now = Date.now();
155
+ if (now > deadline) {
156
+ const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
157
+ throw new Error(
158
+ `waitForState ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
159
+ );
160
+ }
161
+ if (now - lastLog >= PROGRESS_LOG_MS) {
162
+ lastLog = now;
163
+ console.error(
164
+ `[waitForState] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes`,
165
+ );
101
166
  }
167
+ await yieldEventLoop();
102
168
  }
103
169
  }
104
170
 
105
171
  /** Wait until the frame contains the needle (daemon round-trips included). */
106
172
  export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
107
- return waitForFramePredicate(setup, (frame) => frame.includes(needle));
173
+ return waitForFramePredicate(
174
+ setup,
175
+ (frame) => frame.includes(needle),
176
+ `text ${JSON.stringify(needle)}`,
177
+ );
108
178
  }
109
179
 
110
180
  /** Wait until the frame no longer contains the needle. */
111
181
  export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
112
- return waitForFramePredicate(setup, (frame) => !frame.includes(needle));
182
+ return waitForFramePredicate(
183
+ setup,
184
+ (frame) => !frame.includes(needle),
185
+ `text-gone ${JSON.stringify(needle)}`,
186
+ );
113
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/version.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** The published client version, read from the package manifest. */
2
+
3
+ import manifest from "../package.json" with { type: "json" };
4
+
5
+ export const CLIENT_VERSION: string = manifest.version;
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.");