@kabmo-labs/kabmo 0.8.0 → 0.9.0

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 (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -55,6 +55,8 @@ function makeCallbacks(overrides: Partial<OmpAgentCallbacksDeps> = {}): OmpAgent
55
55
  appSettings: {},
56
56
  enqueueExtensionDialog: () => {},
57
57
  withdrawExtensionDialog: () => {},
58
+ reloadMessages: () => {},
59
+ needsReloadRef: { current: false },
58
60
  ...overrides,
59
61
  };
60
62
  return createOmpAgentCallbacks(deps);
@@ -133,6 +135,40 @@ describe('createOmpAgentCallbacks run boundaries', () => {
133
135
  expect(verbs).toEqual(['Thinking']);
134
136
  });
135
137
 
138
+ test('a run that lost frames re-reads its transcript when it settles', () => {
139
+ // A reconnect while the run was STILL streaming cannot reload: its rows are
140
+ // the stream's, and replacing them mid-turn is the lagging-snapshot bug.
141
+ // But the segments that finished during the outage have no other way back —
142
+ // their `message_end` went into the dead socket, and `agent_end` only
143
+ // materializes turns that stopped abnormally. So the loss is remembered and
144
+ // the read happens at the settle.
145
+ const reads: number[] = [];
146
+ const needsReloadRef = { current: true };
147
+ const callbacks = recordingCallbacks({
148
+ reloadMessages: () => { reads.push(1); },
149
+ needsReloadRef,
150
+ });
151
+
152
+ callbacks.onAgentEnd?.({});
153
+
154
+ expect(reads.length).toBe(1);
155
+ // Consumed: a later run end must not re-read a transcript nothing lost.
156
+ expect(needsReloadRef.current).toBe(false);
157
+ callbacks.onAgentEnd?.({});
158
+ expect(reads.length).toBe(1);
159
+ });
160
+
161
+ test('an ordinary run end does NOT re-read the transcript', () => {
162
+ // The read is a full parse of the session JSONL; a run that never lost a
163
+ // frame must not pay it.
164
+ const reads: number[] = [];
165
+ const callbacks = recordingCallbacks({ reloadMessages: () => { reads.push(1); } });
166
+
167
+ callbacks.onAgentEnd?.({});
168
+
169
+ expect(reads).toEqual([]);
170
+ });
171
+
136
172
  test('a real omp frame sequence drives the boundary once per run', () => {
137
173
  const callbacks = recordingCallbacks();
138
174
  const deps = makeFoldDeps(callbacks);
@@ -51,6 +51,14 @@ export interface OmpAgentCallbacksDeps {
51
51
  enqueueExtensionDialog: (request: ExtensionUiDialogRequest) => void;
52
52
  /** Drop a queued request omp withdrew (`method: 'cancel'`). */
53
53
  withdrawExtensionDialog: (targetId: string) => void;
54
+ /** Re-read the committed transcript (see `lost-run.ts`). Used at `agent_end`
55
+ * when frames were lost mid-run: the segments that finished during the
56
+ * outage have no other way back. */
57
+ reloadMessages: () => void;
58
+ /** Set when a run lost frames while STILL STREAMING. Its earlier segments can
59
+ * only be recovered by a full read, which is safe only once the run is over —
60
+ * so the read happens here, at the settle. */
61
+ needsReloadRef: { current: boolean };
54
62
  }
55
63
 
56
64
  export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCallbacks {
@@ -70,6 +78,8 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
70
78
  appSettings,
71
79
  enqueueExtensionDialog,
72
80
  withdrawExtensionDialog,
81
+ reloadMessages,
82
+ needsReloadRef,
73
83
  } = deps;
74
84
 
75
85
  bindStreamingCoalescer(setLocalMessages, () => scrollToBottom('smooth'));
@@ -245,6 +255,14 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
245
255
  // placeholder until the realtime scan surfaces the row.
246
256
  const sid = adoptedSessionIdRef.current ?? sessionIdRef.current;
247
257
  if (sid) refreshSessionMeta(sid);
258
+ // A run that lost frames while still streaming: its earlier segments (a
259
+ // tool card, a thinking block, an earlier answer) have no other way back.
260
+ // Read now — the run is over, so a full transcript read cannot clobber a
261
+ // live row — and `reloadMessages` retries while omp has not flushed.
262
+ if (needsReloadRef.current) {
263
+ needsReloadRef.current = false;
264
+ reloadMessages();
265
+ }
248
266
  },
249
267
  onModelChanged: () => {
250
268
  // omp's model_changed frame carries no payload — re-read the session
@@ -56,6 +56,8 @@ function deps(overrides: Partial<OmpAgentCallbacksDeps> = {}): OmpAgentCallbacks
56
56
  appSettings: {},
57
57
  enqueueExtensionDialog: () => {},
58
58
  withdrawExtensionDialog: () => {},
59
+ reloadMessages: () => {},
60
+ needsReloadRef: { current: false },
59
61
  ...overrides,
60
62
  };
61
63
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The comment tokens `toggleComment` reads out of `languageData`.
8
+ *
9
+ * A wrong token is not a cosmetic bug: `//` inserted into YAML is a syntax
10
+ * error, and a `//` inserted into CSS comments out nothing while breaking the
11
+ * rule it was added to. The three shapes are asserted, plus the default that
12
+ * every C-family grammar relies on.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+
17
+ import { commentSyntaxFor } from '@/shared/lib/code/editor/comment-syntax';
18
+
19
+ describe('commentSyntaxFor', () => {
20
+ test('defaults to a line comment for the C-family', () => {
21
+ expect(commentSyntaxFor('typescript')).toEqual({ line: '//' });
22
+ expect(commentSyntaxFor('rust')).toEqual({ line: '//' });
23
+ expect(commentSyntaxFor('not-a-language')).toEqual({ line: '//' });
24
+ });
25
+
26
+ test('names the languages whose line comment is not `//`', () => {
27
+ expect(commentSyntaxFor('python')).toEqual({ line: '#' });
28
+ expect(commentSyntaxFor('yaml')).toEqual({ line: '#' });
29
+ expect(commentSyntaxFor('sql')).toEqual({ line: '--' });
30
+ });
31
+
32
+ test('wraps the languages that have only block comments', () => {
33
+ expect(commentSyntaxFor('css')).toEqual({ block: ['/*', '*/'] });
34
+ expect(commentSyntaxFor('html')).toEqual({ block: ['<!--', '-->'] });
35
+ expect(commentSyntaxFor('markdown')).toEqual({ block: ['<!--', '-->'] });
36
+ });
37
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * How each language writes a line comment.
8
+ *
9
+ * The only thing the editor still needs from the old text-command layer:
10
+ * CodeMirror's `toggleComment` reads its tokens out of the `languageData` facet
11
+ * and needs no syntax tree, so this table IS the whole of the comment feature.
12
+ * Everything else the old module did (line moves, duplicates, deletes, inserts,
13
+ * indent, occurrences, edit replication) is a CM6 command now.
14
+ *
15
+ * The default is `//`, which is right for every C-family grammar. The exceptions
16
+ * are the ones a wrong token would visibly break — `#` in YAML is a syntax
17
+ * error, not a comment — and a language that has ONLY block comments gets the
18
+ * wrapper, because a `//` inserted into CSS comments out nothing and breaks the
19
+ * rule it was added to.
20
+ */
21
+
22
+ /** A line-comment syntax: a prefix per line, or a wrapper for block-only languages. */
23
+ export type CommentSyntax = { line: string } | { block: readonly [string, string] };
24
+
25
+ const COMMENT_SYNTAX: Record<string, CommentSyntax> = {
26
+ python: { line: '#' },
27
+ bash: { line: '#' },
28
+ yaml: { line: '#' },
29
+ toml: { line: '#' },
30
+ ruby: { line: '#' },
31
+ perl: { line: '#' },
32
+ r: { line: '#' },
33
+ makefile: { line: '#' },
34
+ dockerfile: { line: '#' },
35
+ ini: { line: '#' },
36
+ properties: { line: '#' },
37
+ elixir: { line: '#' },
38
+ graphql: { line: '#' },
39
+ nix: { line: '#' },
40
+ sql: { line: '--' },
41
+ lua: { line: '--' },
42
+ haskell: { line: '--' },
43
+ css: { block: ['/*', '*/'] },
44
+ scss: { block: ['/*', '*/'] },
45
+ less: { block: ['/*', '*/'] },
46
+ sass: { block: ['/*', '*/'] },
47
+ stylus: { block: ['/*', '*/'] },
48
+ html: { block: ['<!--', '-->'] },
49
+ xml: { block: ['<!--', '-->'] },
50
+ markdown: { block: ['<!--', '-->'] },
51
+ };
52
+
53
+ export function commentSyntaxFor(language: string): CommentSyntax {
54
+ return COMMENT_SYNTAX[language] ?? { line: '//' };
55
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Replacement expansion: what one match becomes, and how a template is read.
8
+ *
9
+ * Each rule is asserted against the engine's own `String.replace` where one
10
+ * exists, because the search panel's replace PREVIEW and the write that lands
11
+ * must agree — a preview that painted `$1` over a file that would receive the
12
+ * captured group is exactly the drift this covers.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+
17
+ import {
18
+ expandReplacement,
19
+ replaceAllMatches,
20
+ replaceMatch,
21
+ } from '@/shared/lib/code/editor/find-replace';
22
+ import type { FindOptions } from '@/shared/lib/code/editor/find-scan';
23
+ import { findMatches } from '@/shared/lib/code/editor/find-scan';
24
+
25
+ const PLAIN: FindOptions = { matchCase: false, wholeWord: false, isRegex: false };
26
+
27
+ describe('replaceMatch', () => {
28
+ test('literal replacement inserts the field verbatim, dollar signs included', () => {
29
+ const text = 'const a = 1;';
30
+ const result = replaceMatch(text, { start: 6, end: 7 }, 'a', '$&x', PLAIN);
31
+ expect(result?.text).toBe('const $&x = 1;');
32
+ expect(result?.caret).toBe(6 + 3);
33
+ });
34
+
35
+ test('regex replacement expands capture groups like String.replace', () => {
36
+ const text = 'key = value';
37
+ const result = replaceMatch(text, { start: 6, end: 11 }, '(\\w+)', '[$1]', { ...PLAIN, isRegex: true });
38
+ expect(result?.text).toBe('key = [value]');
39
+ expect(result?.caret).toBe(13);
40
+ });
41
+
42
+ test('refuses a match that is no longer at its offset', () => {
43
+ expect(replaceMatch('abc', { start: 5, end: 7 }, 'a', 'z', PLAIN)).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe('replaceAllMatches', () => {
48
+ test('literal replace-all rewrites every occurrence', () => {
49
+ expect(replaceAllMatches('a a a', 'a', 'b', PLAIN)).toBe('b b b');
50
+ });
51
+
52
+ test('regex replace-all agrees with the engine', () => {
53
+ const text = 'foo1 bar2';
54
+ const options: FindOptions = { ...PLAIN, isRegex: true };
55
+ expect(replaceAllMatches(text, '([a-z]+)(\\d)', '$2-$1', options)).toBe(text.replace(/([a-z]+)(\d)/g, '$2-$1'));
56
+ });
57
+
58
+ test('whole-word replace-all leaves substrings alone', () => {
59
+ expect(replaceAllMatches('cat category', 'cat', 'dog', { ...PLAIN, wholeWord: true })).toBe('dog category');
60
+ });
61
+
62
+ test('returns null for an unusable query', () => {
63
+ expect(replaceAllMatches('abc', '(', 'x', { ...PLAIN, isRegex: true })).toBeNull();
64
+ expect(replaceAllMatches('abc', '', 'x', PLAIN)).toBeNull();
65
+ });
66
+ });
67
+
68
+ /**
69
+ * The write path and the match list must be the SAME answer.
70
+ *
71
+ * They were not, and every case below was silent: the scan runs through
72
+ * CodeMirror's `RegExpCursor` (`gm` + `u`) while the replacement compiled the
73
+ * query itself (`g`/`gi`), so the count in the widget and the bytes written
74
+ * disagreed about anchors, unicode property escapes and which escapes are even
75
+ * legal. `find-scan.ts` now states the flags once (`BASE_FLAGS`), and these
76
+ * assert the two ends together rather than one engine in isolation.
77
+ */
78
+ describe('the write path agrees with the scan', () => {
79
+ const REGEX: FindOptions = { ...PLAIN, isRegex: true };
80
+
81
+ test('an anchored pattern rewrites every line the scan counted', () => {
82
+ const text = 'foo\nbar\nfoo';
83
+ expect(findMatches(text, '^foo', REGEX).matches).toHaveLength(2);
84
+ expect(replaceAllMatches(text, '^foo', 'X', REGEX)).toBe('X\nbar\nX');
85
+
86
+ expect(findMatches(text, 'foo$', REGEX).matches).toHaveLength(2);
87
+ expect(replaceAllMatches(text, 'foo$', 'X', REGEX)).toBe('X\nbar\nX');
88
+ });
89
+
90
+ test('a unicode property escape finds AND replaces', () => {
91
+ const text = 'a1 b2';
92
+ expect(findMatches(text, '\\p{L}\\d', REGEX).matches).toHaveLength(2);
93
+ expect(replaceAllMatches(text, '\\p{L}\\d', 'X', REGEX)).toBe('X X');
94
+ expect(replaceMatch(text, { start: 0, end: 2 }, '\\p{L}\\d', 'X', REGEX)?.text).toBe('X b2');
95
+ });
96
+
97
+ test('an escape the scan calls invalid is refused by the write too', () => {
98
+ // `\-` is a legal identity escape without `u` and a syntax error with it.
99
+ expect(findMatches('a-b', '\\-', REGEX).invalid).toBe(true);
100
+ expect(replaceAllMatches('a-b', '\\-', 'X', REGEX)).toBeNull();
101
+ });
102
+
103
+ test('a zero-width match is replaced by nothing, exactly as it is uncounted', () => {
104
+ // The widget says "No results" for `^`; inserting at every line start under
105
+ // it was the write contradicting the count it was shown beside.
106
+ expect(findMatches('a\nb', '^', REGEX).matches).toEqual([]);
107
+ expect(replaceAllMatches('a\nb', '^', 'X', REGEX)).toBe('a\nb');
108
+ expect(replaceAllMatches('abc', 'x*', 'X', REGEX)).toBe('abc');
109
+ });
110
+ });
111
+
112
+ describe('expandReplacement', () => {
113
+ /** Indexed by group number, `[0]` unused — the shape `groupsOf` produces. */
114
+ const groups = { full: 'ab12', numbered: [undefined, 'a', 'b'] as const, named: { tail: '12' } };
115
+
116
+ test('expands $&, $1 and $<name>', () => {
117
+ expect(expandReplacement('$&|$1|$2|$<tail>', groups)).toBe('ab12|a|b|12');
118
+ });
119
+
120
+ test('$$ is a literal dollar and an unknown group is empty', () => {
121
+ expect(expandReplacement('$$$9', groups)).toBe('$');
122
+ });
123
+
124
+ test('a missing two-digit group falls back to the one-digit group plus the digit', () => {
125
+ // The engine's own reading of `$12` with two groups: `$1` then a literal `2`.
126
+ expect('x'.replace(/(x)/, '$12')).toBe('x2');
127
+ expect(expandReplacement('$12', groups)).toBe('a2');
128
+ });
129
+
130
+ test('a template without a dollar is returned as-is', () => {
131
+ expect(expandReplacement('plain', groups)).toBe('plain');
132
+ });
133
+ });
134
+
@@ -3,117 +3,23 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
- import { lineIndexAt, lineStartOffset, type TextRange } from '@/shared/lib/code/editor/lines';
7
-
8
- export { lineIndexAt, lineStartOffset };
9
-
10
6
  /**
11
- * Find and replace over one document buffer — the pure half of the editor's
12
- * find widget.
7
+ * Replacement expansion, split from the scan.
13
8
  *
14
- * Everything here is offset arithmetic on a string: what the query compiles
15
- * to, which ranges match, where the caret lands after a replacement, and how
16
- * `$1` is expanded. The widget, the hook that owns its state and the editor
17
- * surface that paints the matches all read these results, so none of them
18
- * re-implements a rule and the count in the widget cannot disagree with the
19
- * ranges on screen.
20
- */
21
-
22
- export interface FindOptions {
23
- matchCase: boolean;
24
- wholeWord: boolean;
25
- isRegex: boolean;
26
- }
27
-
28
- /** Half-open `[start, end)` offset range in the document. */
29
- export type FindMatch = TextRange;
30
-
31
- export interface FindResult {
32
- matches: FindMatch[];
33
- /** The document holds more matches than the cap; the list stops at it. */
34
- truncated: boolean;
35
- /** A regex query that does not compile — a different answer from "no matches". */
36
- invalid: boolean;
37
- }
38
-
39
- /**
40
- * Ceiling on collected matches. A one-character query over a large file matches
41
- * tens of thousands of times and every match costs a Range in the highlight
42
- * registry, so past this the list stops and the widget reports `N+`.
43
- */
44
- export const MAX_FIND_MATCHES = 5_000;
45
-
46
- export const DEFAULT_FIND_OPTIONS: FindOptions = {
47
- matchCase: false,
48
- wholeWord: false,
49
- isRegex: false,
50
- };
51
-
52
- /**
53
- * The pattern a query compiles to, or null when the query is empty or does not
54
- * compile. A literal query is escaped here rather than at the call site so the
55
- * match scan and the replacement splice can never disagree about what the
56
- * pattern was.
9
+ * The scan (`find-scan.ts`) answers "where does this query match"; this answers
10
+ * "what does one match become". They are separate because the search panel's
11
+ * replace PREVIEW and the write that actually lands must read ONE expansion
12
+ * rule — a row that painted `$1` literally over a file that would receive the
13
+ * captured group is the drift this module exists to prevent — while the scan has
14
+ * a different consumer (the find bar's match list and count).
57
15
  *
58
- * Whole-word is a `\b`-anchored non-capturing group rather than a bare wrap so
59
- * an alternation (`foo|bar`) keeps its own precedence.
16
+ * The template grammar is `String.prototype.replace`'s for a string replacement:
17
+ * `$$`, `$&`, `$1`–`$99` and `$<name>`. The prefix/suffix forms (`` $` `` /
18
+ * `$'`) are deliberately left literal — nothing in the UI offers them, and
19
+ * leaving the two characters alone is the honest reading.
60
20
  */
61
- export function buildFindRegex(query: string, options: FindOptions): RegExp | null {
62
- if (!query) return null;
63
- const source = options.isRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
64
- const wrapped = options.wholeWord ? `\\b(?:${source})\\b` : source;
65
- try {
66
- return new RegExp(wrapped, options.matchCase ? 'g' : 'gi');
67
- } catch {
68
- return null;
69
- }
70
- }
71
21
 
72
- /**
73
- * Every match of `query` in `text`, in document order.
74
- *
75
- * Zero-width matches (`^`, `\b`, `a*`) are skipped: they have nothing to
76
- * highlight and nothing to replace, and stepping past them is what keeps the
77
- * scan terminating.
78
- */
79
- export function findMatches(text: string, query: string, options: FindOptions): FindResult {
80
- const regex = buildFindRegex(query, options);
81
- if (!query) return { matches: [], truncated: false, invalid: false };
82
- if (!regex) return { matches: [], truncated: false, invalid: true };
83
-
84
- const matches: FindMatch[] = [];
85
- let match: RegExpExecArray | null;
86
- while ((match = regex.exec(text)) !== null) {
87
- if (match[0].length === 0) {
88
- regex.lastIndex += 1;
89
- continue;
90
- }
91
- matches.push({ start: match.index, end: match.index + match[0].length });
92
- if (matches.length >= MAX_FIND_MATCHES) return { matches, truncated: true, invalid: false };
93
- }
94
- return { matches, truncated: false, invalid: false };
95
- }
96
-
97
- /**
98
- * The match to call "current" for a caret/anchor at `offset`: the first one
99
- * that ends after it, so a caret sitting inside a match still counts as being
100
- * on that match. Wraps to the first match when the anchor is past the last one,
101
- * and reports -1 for an empty list.
102
- */
103
- export function matchIndexAtOrAfter(matches: readonly FindMatch[], offset: number): number {
104
- if (matches.length === 0) return -1;
105
- for (let index = 0; index < matches.length; index++) {
106
- if (matches[index].end > offset) return index;
107
- }
108
- return 0;
109
- }
110
-
111
- /** Step one match forward (`1`) or back (`-1`), wrapping at either end. */
112
- export function stepMatchIndex(index: number, count: number, direction: 1 | -1): number {
113
- if (count <= 0) return -1;
114
- if (index < 0) return direction === 1 ? 0 : count - 1;
115
- return (index + direction + count) % count;
116
- }
22
+ import { buildFindRegex, type FindMatch, type FindOptions } from '@/shared/lib/code/editor/find-scan';
117
23
 
118
24
  /** What a replacement template is expanded against. */
119
25
  export interface MatchGroups {
@@ -210,7 +116,16 @@ export function replaceMatch(
210
116
  };
211
117
  }
212
118
 
213
- /** Replace every match. Null when the query is empty or does not compile. */
119
+ /**
120
+ * Replace every match. Null when the query is empty or does not compile.
121
+ *
122
+ * A ZERO-WIDTH match is left alone, exactly as the scan leaves it out of the
123
+ * list: `^`, `$`, `\b` and `a*` have nothing to highlight and nothing to
124
+ * replace, so a `^` query reads "No results" in the widget — and rewriting every
125
+ * line start under it was the write disagreeing with the count it was shown
126
+ * next to. (A non-empty literal query can never match zero characters, so this
127
+ * only ever applies to regex mode.)
128
+ */
214
129
  export function replaceAllMatches(
215
130
  text: string,
216
131
  query: string,
@@ -221,6 +136,7 @@ export function replaceAllMatches(
221
136
  if (!regex) return null;
222
137
  if (!options.isRegex) return text.replace(regex, () => replacement);
223
138
  return text.replace(regex, (...args: unknown[]) => {
139
+ if (String(args[0]).length === 0) return '';
224
140
  // A replacer's arguments are `(match, …groups, offset, string, groups?)`;
225
141
  // rebuilding an exec result from them keeps one expansion rule for both
226
142
  // the single and the bulk path.
@@ -4,28 +4,27 @@
4
4
  */
5
5
 
6
6
  /**
7
- * The find widget's arithmetic: what a query matches, where the caret lands
8
- * after a replacement, and how `$1` is expanded. These are the rules the widget
9
- * count, the on-screen highlights and the written buffer all read, so each is
10
- * asserted against the engine's own `String.replace` behaviour where one
11
- * exists.
7
+ * The find widget's scan: what a query matches and how the caret anchors to it.
8
+ *
9
+ * The scan runs on CodeMirror's `RegExpCursor`, so these cases are the CONTRACT
10
+ * that cursor has to keep — the same ordered list, the same case/whole-word/regex
11
+ * options, a broken pattern reported as `invalid` rather than as "no matches",
12
+ * and the cap. They are asserted here rather than through a live editor because
13
+ * the widget, the count and the painted marks all read this one function.
12
14
  */
13
15
 
14
16
  import { describe, expect, test } from 'bun:test';
15
17
 
16
18
  import {
17
19
  buildFindRegex,
18
- expandReplacement,
19
20
  findMatches,
20
21
  lineIndexAt,
21
22
  lineStartOffset,
22
23
  matchIndexAtOrAfter,
23
24
  MAX_FIND_MATCHES,
24
- replaceAllMatches,
25
- replaceMatch,
26
25
  stepMatchIndex,
27
26
  type FindOptions,
28
- } from '@/shared/lib/code/editor/find';
27
+ } from '@/shared/lib/code/editor/find-scan';
29
28
 
30
29
  const PLAIN: FindOptions = { matchCase: false, wholeWord: false, isRegex: false };
31
30
 
@@ -83,6 +82,17 @@ describe('findMatches', () => {
83
82
  expect(result.invalid).toBe(false);
84
83
  });
85
84
 
85
+ test('finds a match that begins at a line start, and one that spans lines', () => {
86
+ // The cursor searches line by line, so both edges are where it can lose a
87
+ // match: a pattern anchored at a line start, and one whose `\n` forces the
88
+ // whole-document path.
89
+ expect(findMatches('one\ntwo\ntwo', 'two', PLAIN).matches).toEqual([
90
+ { start: 4, end: 7 },
91
+ { start: 8, end: 11 },
92
+ ]);
93
+ expect(findMatches('one\ntwo', 'one\ntwo', { ...PLAIN, isRegex: true }).matches).toEqual([{ start: 0, end: 7 }]);
94
+ });
95
+
86
96
  test('caps the collected matches and says so', () => {
87
97
  const text = 'a'.repeat(MAX_FIND_MATCHES + 10);
88
98
  const result = findMatches(text, 'a', PLAIN);
@@ -104,70 +114,6 @@ describe('buildFindRegex', () => {
104
114
  });
105
115
  });
106
116
 
107
- describe('replaceMatch', () => {
108
- test('literal replacement inserts the field verbatim, dollar signs included', () => {
109
- const text = 'const a = 1;';
110
- const result = replaceMatch(text, { start: 6, end: 7 }, 'a', '$&x', PLAIN);
111
- expect(result?.text).toBe('const $&x = 1;');
112
- expect(result?.caret).toBe(6 + 3);
113
- });
114
-
115
- test('regex replacement expands capture groups like String.replace', () => {
116
- const text = 'key = value';
117
- const result = replaceMatch(text, { start: 6, end: 11 }, '(\\w+)', '[$1]', { ...PLAIN, isRegex: true });
118
- expect(result?.text).toBe('key = [value]');
119
- expect(result?.caret).toBe(13);
120
- });
121
-
122
- test('refuses a match that is no longer at its offset', () => {
123
- expect(replaceMatch('abc', { start: 5, end: 7 }, 'a', 'z', PLAIN)).toBeNull();
124
- });
125
- });
126
-
127
- describe('replaceAllMatches', () => {
128
- test('literal replace-all rewrites every occurrence', () => {
129
- expect(replaceAllMatches('a a a', 'a', 'b', PLAIN)).toBe('b b b');
130
- });
131
-
132
- test('regex replace-all agrees with the engine', () => {
133
- const text = 'foo1 bar2';
134
- const options: FindOptions = { ...PLAIN, isRegex: true };
135
- expect(replaceAllMatches(text, '([a-z]+)(\\d)', '$2-$1', options)).toBe(text.replace(/([a-z]+)(\d)/g, '$2-$1'));
136
- });
137
-
138
- test('whole-word replace-all leaves substrings alone', () => {
139
- expect(replaceAllMatches('cat category', 'cat', 'dog', { ...PLAIN, wholeWord: true })).toBe('dog category');
140
- });
141
-
142
- test('returns null for an unusable query', () => {
143
- expect(replaceAllMatches('abc', '(', 'x', { ...PLAIN, isRegex: true })).toBeNull();
144
- expect(replaceAllMatches('abc', '', 'x', PLAIN)).toBeNull();
145
- });
146
- });
147
-
148
- describe('expandReplacement', () => {
149
- /** Indexed by group number, `[0]` unused — the shape `groupsOf` produces. */
150
- const groups = { full: 'ab12', numbered: [undefined, 'a', 'b'] as const, named: { tail: '12' } };
151
-
152
- test('expands $&, $1 and $<name>', () => {
153
- expect(expandReplacement('$&|$1|$2|$<tail>', groups)).toBe('ab12|a|b|12');
154
- });
155
-
156
- test('$$ is a literal dollar and an unknown group is empty', () => {
157
- expect(expandReplacement('$$$9', groups)).toBe('$');
158
- });
159
-
160
- test('a missing two-digit group falls back to the one-digit group plus the digit', () => {
161
- // The engine's own reading of `$12` with two groups: `$1` then a literal `2`.
162
- expect('x'.replace(/(x)/, '$12')).toBe('x2');
163
- expect(expandReplacement('$12', groups)).toBe('a2');
164
- });
165
-
166
- test('a template without a dollar is returned as-is', () => {
167
- expect(expandReplacement('plain', groups)).toBe('plain');
168
- });
169
- });
170
-
171
117
  describe('caret anchoring', () => {
172
118
  test('lineIndexAt and lineStartOffset are inverses for every line start', () => {
173
119
  const text = 'one\ntwo\n\nfour';