@cueloop/client 0.1.0-alpha.59 → 0.1.0-alpha.60

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 (98) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +7 -0
  3. package/src/App.diff.test.tsx +11 -0
  4. package/src/App.export.test.tsx +5 -0
  5. package/src/App.plan-review.test.tsx +42 -0
  6. package/src/App.prototype.test.tsx +11 -0
  7. package/src/App.readonly.test.tsx +10 -0
  8. package/src/App.rendering.test.tsx +4 -0
  9. package/src/App.review-panel.test.tsx +7 -0
  10. package/src/App.submit-confirm.test.tsx +11 -0
  11. package/src/App.test.tsx +13 -0
  12. package/src/App.toast.test.tsx +2 -0
  13. package/src/App.tsx +27 -0
  14. package/src/App.walk.test.tsx +10 -0
  15. package/src/app-key-state.ts +1 -0
  16. package/src/app-screens.tsx +4 -0
  17. package/src/app-view-model.ts +21 -0
  18. package/src/attribution.ts +4 -0
  19. package/src/clipboard.ts +3 -0
  20. package/src/components/AnnotationCard.tsx +9 -0
  21. package/src/components/Breadcrumb.tsx +1 -0
  22. package/src/components/CodeBlock.tsx +1 -0
  23. package/src/components/CompletionOverlay.tsx +1 -0
  24. package/src/components/ConfirmCard.tsx +2 -0
  25. package/src/components/ConfirmDialog.tsx +2 -0
  26. package/src/components/DiffSheet.scroll.test.tsx +12 -0
  27. package/src/components/DiffSheet.syntax.test.tsx +3 -0
  28. package/src/components/DiffSheet.tsx +19 -0
  29. package/src/components/InboxList.tsx +2 -0
  30. package/src/components/KeybindsDialog.tsx +1 -0
  31. package/src/components/MarkerPopover.tsx +1 -0
  32. package/src/components/MenuBar.tsx +1 -0
  33. package/src/components/PlanSheet.tsx +26 -0
  34. package/src/components/PromptDialog.tsx +2 -0
  35. package/src/components/PrototypeSheet.tsx +27 -0
  36. package/src/components/ReviewPanel.tsx +2 -0
  37. package/src/components/ReviewRail.tsx +1 -0
  38. package/src/components/SettingsDialog.tsx +6 -0
  39. package/src/components/SettingsRows.tsx +4 -0
  40. package/src/components/Toast.tsx +1 -0
  41. package/src/components/agent-launcher.test.tsx +1 -0
  42. package/src/components/agent-launcher.tsx +2 -0
  43. package/src/components/diff-sheet-layout.test.ts +1 -0
  44. package/src/components/diff-sheet-layout.ts +17 -0
  45. package/src/components/plan-sheet-run-style.ts +4 -0
  46. package/src/components/primitives/Button.tsx +1 -0
  47. package/src/components/primitives/Card.tsx +1 -0
  48. package/src/components/primitives/Dialog.tsx +2 -0
  49. package/src/components/primitives/StatusBar.tsx +1 -0
  50. package/src/components/primitives/Tabs.tsx +4 -0
  51. package/src/components/quick-actions-editor.tsx +2 -0
  52. package/src/components/stories-app.tsx +1 -0
  53. package/src/components/stories.test.tsx +4 -0
  54. package/src/components/story.ts +6 -0
  55. package/src/components/syntax-highlight.ts +10 -0
  56. package/src/components/terminal-pane.ts +12 -0
  57. package/src/components/theme-context.tsx +1 -0
  58. package/src/components/truncate-text.ts +1 -0
  59. package/src/config.test.ts +18 -0
  60. package/src/config.ts +27 -0
  61. package/src/diff-hunk-curate.ts +20 -0
  62. package/src/diff-intraline.test.ts +1 -0
  63. package/src/diff-intraline.ts +14 -0
  64. package/src/diff-syntax.ts +22 -0
  65. package/src/editor.test.ts +6 -0
  66. package/src/editor.ts +12 -0
  67. package/src/ghostty-terminal.test.ts +2 -0
  68. package/src/ghostty-terminal.ts +8 -0
  69. package/src/herdr.test.ts +2 -0
  70. package/src/herdr.ts +3 -0
  71. package/src/integrations.ts +2 -0
  72. package/src/intent-dispatch.test.ts +6 -0
  73. package/src/intent-dispatch.ts +37 -0
  74. package/src/key-bindings.test.ts +4 -0
  75. package/src/key-bindings.ts +22 -0
  76. package/src/keymap.test.ts +13 -0
  77. package/src/keymap.ts +43 -0
  78. package/src/kitty-image.ts +9 -0
  79. package/src/prototype-browser.test.ts +1 -0
  80. package/src/prototype-browser.ts +22 -0
  81. package/src/pty.test.ts +3 -0
  82. package/src/pty.ts +15 -0
  83. package/src/run.ts +1 -0
  84. package/src/serve.test.ts +1 -0
  85. package/src/serve.ts +3 -0
  86. package/src/session-controller.curate.test.ts +7 -0
  87. package/src/session-controller.share.test.ts +11 -0
  88. package/src/session-controller.ts +79 -0
  89. package/src/share.ts +7 -0
  90. package/src/test-support.test.ts +5 -0
  91. package/src/test-support.ts +10 -0
  92. package/src/theme-presets.ts +1 -0
  93. package/src/use-settings-dialog.tsx +12 -0
  94. package/src/view-diff.test.ts +3 -0
  95. package/src/view-diff.ts +5 -0
  96. package/src/view-plan.test.ts +7 -0
  97. package/src/view-plan.ts +59 -0
  98. package/src/walk.ts +4 -0
@@ -21,8 +21,10 @@ import {
21
21
 
22
22
  /** One shared VT library load; null when this platform ships no prebuilt dylib. */
23
23
  let ghosttyFactory: GhosttyTerminalFactory | null | undefined;
24
+
24
25
  function factory(): GhosttyTerminalFactory | null {
25
26
  if (ghosttyFactory === undefined) ghosttyFactory = loadGhosttyTerminals();
27
+
26
28
  return ghosttyFactory;
27
29
  }
28
30
 
@@ -71,8 +73,10 @@ export class TerminalPaneRenderable extends Renderable {
71
73
  /** Start the child + emulator once real layout dimensions are known. */
72
74
  private start(cols: number, rows: number): void {
73
75
  const terminals = factory();
76
+
74
77
  if (!terminals) return;
75
78
  const vt = terminals.create(cols, rows);
79
+
76
80
  if (!vt) return; // no emulator, no visible screen - do not spawn a blind child
77
81
  this.vt = vt;
78
82
  this.cols = cols;
@@ -107,6 +111,7 @@ export class TerminalPaneRenderable extends Renderable {
107
111
  if (width <= 0 || height <= 0) return;
108
112
  if (!this.pty) {
109
113
  this.start(width, height);
114
+
110
115
  return;
111
116
  }
112
117
  if (width === this.cols && height === this.rows) return;
@@ -121,11 +126,13 @@ export class TerminalPaneRenderable extends Renderable {
121
126
  for (let y = 0; y < this.height; y++) {
122
127
  for (let x = 0; x < this.width; x++) {
123
128
  const cell = this.vt.readCell(x, y);
129
+
124
130
  // Skip an empty normal-width cell; the buffer already starts blank.
125
131
  if (cell && cell.width === 0 && cell.codepoint === 0) continue;
126
132
  const char = cell && cell.codepoint ? String.fromCodePoint(cell.codepoint) : " ";
127
133
  let fg = cell ? resolveColor(cell.fg, DEFAULT_FG) : DEFAULT_FG;
128
134
  let bg = cell ? resolveColor(cell.bg, TRANSPARENT) : TRANSPARENT;
135
+
129
136
  if (cell?.inverse) [fg, bg] = [bg, fg];
130
137
  const attributes = cell
131
138
  ? createTextAttributes({
@@ -136,15 +143,18 @@ export class TerminalPaneRenderable extends Renderable {
136
143
  strikethrough: cell.strikethrough,
137
144
  })
138
145
  : 0;
146
+
139
147
  buffer.setCell(this.x + x, this.y + y, char, fg, bg, attributes);
140
148
  }
141
149
  }
142
150
  // cursor is active-area coords; equal to viewport at the bottom (the live
143
151
  // typing case). It can drift if the child scrolls the viewport back.
144
152
  const cursor = this.vt.readCursor();
153
+
145
154
  if (cursor.visible && cursor.x < this.width && cursor.y < this.height) {
146
155
  const under = this.vt.readCell(cursor.x, cursor.y);
147
156
  const char = under && under.codepoint ? String.fromCodePoint(under.codepoint) : " ";
157
+
148
158
  // a block cursor: paint the cell with fg/bg swapped
149
159
  buffer.setCell(this.x + cursor.x, this.y + cursor.y, char, CURSOR_INK, DEFAULT_FG, 0);
150
160
  }
@@ -169,10 +179,12 @@ export class TerminalPaneRenderable extends Renderable {
169
179
  function resolveColor(color: GhosttyColor, fallback: RGBA): RGBA {
170
180
  if (color.kind === "rgb") return RGBA.fromInts(color.r, color.g, color.b, 255);
171
181
  if (color.kind === "palette") return RGBA.fromIndex(color.index);
182
+
172
183
  return fallback;
173
184
  }
174
185
 
175
186
  let registered = false;
187
+
176
188
  /** Register `<terminalPane>` with the OpenTUI React reconciler (idempotent). */
177
189
  export function registerTerminalPane(): void {
178
190
  if (registered) return;
@@ -32,5 +32,6 @@ export function useTheme(): Theme {
32
32
  */
33
33
  export function useComponentTheme(override?: Theme): Theme {
34
34
  const contextTheme = useContext(ThemeContext);
35
+
35
36
  return override ?? contextTheme;
36
37
  }
@@ -2,5 +2,6 @@
2
2
 
3
3
  export function truncateToSingleLine(value: string, maxLength: number): string {
4
4
  const oneLine = value.replace(/\n/g, " ");
5
+
5
6
  return oneLine.length > maxLength ? oneLine.slice(0, maxLength - 1) + "…" : oneLine;
6
7
  }
@@ -33,6 +33,7 @@ describe("loadConfig", () => {
33
33
  // Arrange
34
34
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-"));
35
35
  const path = join(dir, "config.toml");
36
+
36
37
  writeFileSync(
37
38
  path,
38
39
  `[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
@@ -58,6 +59,7 @@ describe("loadConfig", () => {
58
59
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg2-"));
59
60
  const user = join(dir, "user.toml");
60
61
  const repoRoot = join(dir, "repo");
62
+
61
63
  writeFileSync(user, `[keys]\ncomment = "a"\n`);
62
64
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
63
65
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
@@ -77,6 +79,7 @@ describe("loadConfig", () => {
77
79
  // Arrange
78
80
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg3-"));
79
81
  const path = join(dir, "config.toml");
82
+
80
83
  writeFileSync(path, "not [valid toml");
81
84
 
82
85
  try {
@@ -94,6 +97,7 @@ describe("loadConfig", () => {
94
97
  // Arrange
95
98
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg4-"));
96
99
  const path = join(dir, "config.toml");
100
+
97
101
  writeFileSync(path, `[ui]\nauto_close = 3\neditor = "code --wait"\n`);
98
102
 
99
103
  try {
@@ -112,6 +116,7 @@ describe("loadConfig", () => {
112
116
  // Arrange
113
117
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg5-"));
114
118
  const path = join(dir, "config.toml");
119
+
115
120
  writeFileSync(path, `[ui]\neditor = " "\n`);
116
121
 
117
122
  try {
@@ -136,6 +141,7 @@ describe("loadConfig", () => {
136
141
  // Arrange
137
142
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg6-"));
138
143
  const path = join(dir, "config.toml");
144
+
139
145
  writeFileSync(path, `[ui]\nreview_width = 999\nreview_state = "compact"\n`);
140
146
 
141
147
  try {
@@ -154,6 +160,7 @@ describe("loadConfig", () => {
154
160
  // Arrange
155
161
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg7-"));
156
162
  const path = join(dir, "config.toml");
163
+
157
164
  writeFileSync(path, `[ui]\nreview_width = "wide"\nreview_state = "sideways"\n`);
158
165
 
159
166
  try {
@@ -181,6 +188,7 @@ describe("loadConfig", () => {
181
188
  // Arrange
182
189
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme-"));
183
190
  const path = join(dir, "config.toml");
191
+
184
192
  writeFileSync(path, `[ui]\ntheme = "nord"\n`);
185
193
 
186
194
  try {
@@ -199,6 +207,7 @@ describe("loadConfig", () => {
199
207
  // Arrange
200
208
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme2-"));
201
209
  const path = join(dir, "config.toml");
210
+
202
211
  writeFileSync(path, `[ui]\ntheme = "nord"\n\n[theme]\naccent = "#ff0000"\n`);
203
212
 
204
213
  try {
@@ -219,6 +228,7 @@ describe("loadConfig", () => {
219
228
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme-x-"));
220
229
  const user = join(dir, "user.toml");
221
230
  const repoRoot = join(dir, "repo");
231
+
222
232
  writeFileSync(user, `[theme]\naccent = "#ff0000"\n`);
223
233
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
224
234
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[ui]\ntheme = "nord"\n`);
@@ -240,6 +250,7 @@ describe("loadConfig", () => {
240
250
  // Arrange
241
251
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme3-"));
242
252
  const path = join(dir, "config.toml");
253
+
243
254
  writeFileSync(path, `[ui]\ntheme = "solarized-galaxy"\n`);
244
255
 
245
256
  try {
@@ -289,6 +300,7 @@ describe("loadConfig", () => {
289
300
 
290
301
  // Assert
291
302
  const config = loadConfig({ userConfigPath: path });
303
+
292
304
  expect(config.ui.reviewWidth).toBe(42);
293
305
  expect(config.ui.reviewState).toBe("hidden");
294
306
 
@@ -321,6 +333,7 @@ describe("integrations.obsidian config", () => {
321
333
  // Arrange
322
334
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs-"));
323
335
  const path = join(dir, "config.toml");
336
+
324
337
  writeFileSync(
325
338
  path,
326
339
  `[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
@@ -346,6 +359,7 @@ describe("integrations.obsidian config", () => {
346
359
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs2-"));
347
360
  const user = join(dir, "user.toml");
348
361
  const repoRoot = join(dir, "repo");
362
+
349
363
  writeFileSync(user, `[integrations.obsidian]\nvault = "/user/vault"\nexportOn = "resolve"\n`);
350
364
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
351
365
  writeFileSync(
@@ -381,6 +395,7 @@ describe("quick actions ([[actions]])", () => {
381
395
  // Arrange
382
396
  const dir = mkdtempSync(join(tmpdir(), "cueloop-actions-"));
383
397
  const path = join(dir, "config.toml");
398
+
384
399
  writeFileSync(
385
400
  path,
386
401
  `[[actions]]\nprompt = "Add a benchmark"\n\n[[actions]]\nprompt = "Guard the edge case"\nmetadata = "null and empty input"\n`,
@@ -404,6 +419,7 @@ describe("quick actions ([[actions]])", () => {
404
419
  // Arrange
405
420
  const dir = mkdtempSync(join(tmpdir(), "cueloop-actions2-"));
406
421
  const path = join(dir, "config.toml");
422
+
407
423
  writeFileSync(
408
424
  path,
409
425
  `[[actions]]\nmetadata = "orphan without a prompt"\n\n[[actions]]\nprompt = "Keep this one"\n`,
@@ -426,6 +442,7 @@ describe("persistActions", () => {
426
442
  // Arrange
427
443
  const dir = mkdtempSync(join(tmpdir(), "cueloop-persist-actions-"));
428
444
  const path = join(dir, "config.toml");
445
+
429
446
  writeFileSync(path, `[[actions]]\nprompt = "old one"\n\n[ui]\nauto_close = 3\n`);
430
447
 
431
448
  try {
@@ -490,6 +507,7 @@ describe("[authors] rename map", () => {
490
507
  // Arrange
491
508
  const dir = mkdtempSync(join(tmpdir(), "cueloop-authors-"));
492
509
  const path = join(dir, "config.toml");
510
+
493
511
  writeFileSync(path, `[authors]\n"SHA256:abc+def/gh" = "Alex"\n`);
494
512
 
495
513
  // Act
package/src/config.ts CHANGED
@@ -80,8 +80,10 @@ export function resolveQuickAction(
80
80
  actionRef: string,
81
81
  ): QuickAction | undefined {
82
82
  const index = Number(actionRef);
83
+
83
84
  if (Number.isInteger(index) && index >= 1 && index <= actions.length) return actions[index - 1];
84
85
  const wanted = actionRef.trim().toLowerCase();
86
+
85
87
  return actions.find((action) => action.prompt.toLowerCase() === wanted);
86
88
  }
87
89
 
@@ -147,21 +149,26 @@ function parseToml(text: string): Record<string, unknown> {
147
149
  function parseActions(raw: unknown): QuickAction[] | undefined {
148
150
  if (!Array.isArray(raw)) return undefined;
149
151
  const actions: QuickAction[] = [];
152
+
150
153
  for (const entry of raw) {
151
154
  if (!entry || typeof entry !== "object") continue;
152
155
  const table = entry as Record<string, unknown>;
153
156
  const prompt = table["prompt"];
157
+
154
158
  if (typeof prompt !== "string" || !prompt.trim()) continue;
155
159
  const metadata = table["metadata"];
160
+
156
161
  actions.push(
157
162
  typeof metadata === "string" && metadata.trim() ? { prompt, metadata } : { prompt },
158
163
  );
159
164
  }
165
+
160
166
  return actions.length ? actions : undefined;
161
167
  }
162
168
 
163
169
  function mergeAuthors(target: Record<string, string>, raw: unknown): void {
164
170
  const authors = raw as Record<string, unknown> | undefined;
171
+
165
172
  if (!authors) return;
166
173
  for (const [id, value] of Object.entries(authors)) {
167
174
  if (typeof value === "string") target[id] = value;
@@ -170,6 +177,7 @@ function mergeAuthors(target: Record<string, string>, raw: unknown): void {
170
177
 
171
178
  function mergeKeys(target: Record<string, string[]>, raw: unknown): void {
172
179
  const keys = raw as KeymapConfig | undefined;
180
+
173
181
  if (!keys) return;
174
182
  for (const [action, combo] of Object.entries(keys)) {
175
183
  target[action] = Array.isArray(combo) ? combo : [combo];
@@ -186,6 +194,7 @@ function mergeUi(target: CueloopConfig["ui"], raw: unknown): void {
186
194
  const ui = raw as
187
195
  | { auto_close?: unknown; editor?: unknown; review_width?: unknown; review_state?: unknown }
188
196
  | undefined;
197
+
189
198
  if (!ui) return;
190
199
  mergeAutoClose(target, ui.auto_close);
191
200
  if (typeof ui.editor === "string" && ui.editor.trim()) target.editor = ui.editor.trim();
@@ -204,15 +213,18 @@ function mergeUi(target: CueloopConfig["ui"], raw: unknown): void {
204
213
  function mergeObsidian(target: ObsidianConfig, raw: unknown): void {
205
214
  const integrations = raw as Record<string, unknown> | undefined;
206
215
  const obsidian = integrations?.["obsidian"] as Record<string, unknown> | undefined;
216
+
207
217
  if (!obsidian) return;
208
218
  if (typeof obsidian["vault"] === "string") target.vault = obsidian["vault"];
209
219
  if (typeof obsidian["folder"] === "string") target.folder = obsidian["folder"];
210
220
  if (typeof obsidian["filenameFormat"] === "string")
211
221
  target.filenameFormat = obsidian["filenameFormat"];
212
222
  const separator = obsidian["separator"];
223
+
213
224
  if (separator === "space" || separator === "dash" || separator === "underscore")
214
225
  target.separator = separator;
215
226
  const exportOn = obsidian["exportOn"];
227
+
216
228
  if (exportOn === "approve" || exportOn === "resolve" || exportOn === "manual")
217
229
  target.exportOn = exportOn;
218
230
  }
@@ -228,11 +240,13 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
228
240
  integrations: { obsidian: { ...base.integrations.obsidian } },
229
241
  };
230
242
  const actions = parseActions(raw["actions"]);
243
+
231
244
  if (actions) out.actions = actions;
232
245
  mergeAuthors(out.authors, raw["authors"]);
233
246
  mergeKeys(out.keys, raw["keys"]);
234
247
  mergeUi(out.ui, raw["ui"]);
235
248
  mergeObsidian(out.integrations.obsidian, raw["integrations"]);
249
+
236
250
  return out;
237
251
  }
238
252
 
@@ -260,6 +274,7 @@ export function loadConfig(
260
274
  let themeName = DEFAULT_THEME_NAME;
261
275
  const themeOverrides: Partial<Record<keyof Theme, string>> = {};
262
276
  const userPath = userConfigPathFrom(options.userConfigPath);
277
+
263
278
  for (const path of [
264
279
  userPath,
265
280
  options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined,
@@ -267,8 +282,10 @@ export function loadConfig(
267
282
  if (!path || !existsSync(path)) continue;
268
283
  try {
269
284
  const raw = parseToml(readFileSync(path, "utf8"));
285
+
270
286
  config = layer(config, raw);
271
287
  const rawTheme = (raw["ui"] as { theme?: unknown } | undefined)?.theme;
288
+
272
289
  if (typeof rawTheme === "string" && isThemeName(rawTheme)) themeName = rawTheme;
273
290
  collectThemeOverrides(raw["theme"], themeOverrides);
274
291
  } catch {
@@ -278,6 +295,7 @@ export function loadConfig(
278
295
  config.ui.theme = themeName;
279
296
  config.themeOverrides = themeOverrides;
280
297
  config.theme = { ...themeForName(themeName), ...themeOverrides };
298
+
281
299
  return config;
282
300
  }
283
301
 
@@ -296,9 +314,11 @@ export function actionFor(
296
314
  shift: boolean,
297
315
  ): string | undefined {
298
316
  const wanted = shift && name.length === 1 ? name.toUpperCase() : name;
317
+
299
318
  for (const [action, combos] of Object.entries(keys)) {
300
319
  if (combos.includes(wanted)) return action;
301
320
  }
321
+
302
322
  return undefined;
303
323
  }
304
324
 
@@ -319,8 +339,10 @@ function userConfigPathFrom(userConfigPath?: string): string {
319
339
  function persistUiSetting(key: string, rendered: string, userConfigPath?: string): void {
320
340
  const path = userConfigPathFrom(userConfigPath);
321
341
  let text = "";
342
+
322
343
  if (existsSync(path)) text = readFileSync(path, "utf8");
323
344
  const assignment = new RegExp(`^(\\s*)${key}\\s*=.*$`, "m");
345
+
324
346
  if (assignment.test(text)) {
325
347
  text = text.replace(assignment, `$1${key} = ${rendered}`);
326
348
  } else if (/^\[ui\]/m.test(text)) {
@@ -345,15 +367,19 @@ function tomlString(value: string): string {
345
367
  export function persistActions(actions: QuickAction[], userConfigPath?: string): void {
346
368
  const path = userConfigPathFrom(userConfigPath);
347
369
  let text = existsSync(path) ? readFileSync(path, "utf8") : "";
370
+
348
371
  // strip every existing [[actions]] block (header through its key lines)
349
372
  text = text.replace(/^\[\[actions\]\][^[]*/gm, "").trimEnd();
350
373
  const blocks = actions
351
374
  .map((action) => {
352
375
  const lines = [`[[actions]]`, `prompt = ${tomlString(action.prompt)}`];
376
+
353
377
  if (action.metadata) lines.push(`metadata = ${tomlString(action.metadata)}`);
378
+
354
379
  return lines.join("\n");
355
380
  })
356
381
  .join("\n\n");
382
+
357
383
  text = blocks ? `${text ? `${text}\n\n` : ""}${blocks}\n` : `${text}\n`;
358
384
  mkdirSync(dirname(path), { recursive: true });
359
385
  writeFileSync(path, text);
@@ -393,6 +419,7 @@ export function persistAuthorName(id: string, name: string, userConfigPath?: str
393
419
  const key = `"${id.replace(/"/g, '\\"')}"`;
394
420
  const value = `"${name.replace(/"/g, '\\"')}"`;
395
421
  const assignment = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*=.*$`, "m");
422
+
396
423
  if (assignment.test(text)) {
397
424
  text = text.replace(assignment, `$1${key} = ${value}`);
398
425
  } else if (/^\[authors\]/m.test(text)) {
@@ -62,6 +62,7 @@ export function locateLine(
62
62
  lineNumber: number,
63
63
  ): LocatedLine | null {
64
64
  const index = lineNumber - 1;
65
+
65
66
  for (const [hunkIndex, hunk] of model.hunks.entries()) {
66
67
  for (const [contentIndex, content] of hunk.hunkContent.entries()) {
67
68
  const base = side === "addition" ? content.additionLineIndex : content.deletionLineIndex;
@@ -71,11 +72,13 @@ export function locateLine(
71
72
  : side === "addition"
72
73
  ? content.additions
73
74
  : content.deletions;
75
+
74
76
  if (count > 0 && index >= base && index < base + count) {
75
77
  return { hunkIndex, changeIndex: content.type === "change" ? contentIndex : undefined };
76
78
  }
77
79
  }
78
80
  }
81
+
79
82
  return null;
80
83
  }
81
84
 
@@ -87,6 +90,7 @@ function changeSideLine(
87
90
  return { side: "addition", lineNumber: row.newLine };
88
91
  if (row.kind === "del" && row.oldLine !== undefined)
89
92
  return { side: "deletion", lineNumber: row.oldLine };
93
+
90
94
  return null;
91
95
  }
92
96
 
@@ -107,6 +111,7 @@ export function hunkRejectionForRow(
107
111
  : row.oldLine !== undefined
108
112
  ? locateLine(model, "deletion", row.oldLine)
109
113
  : null;
114
+
110
115
  return located ? { path, hunkIndex: located.hunkIndex } : null;
111
116
  }
112
117
 
@@ -120,9 +125,12 @@ export function changeRejectionForRow(
120
125
  row: Pick<DiffRow, "kind" | "oldLine" | "newLine">,
121
126
  ): HunkRejection | null {
122
127
  const change = changeSideLine(row);
128
+
123
129
  if (!change) return null;
124
130
  const located = locateLine(model, change.side, change.lineNumber);
131
+
125
132
  if (!located || located.changeIndex === undefined) return null;
133
+
126
134
  return { path, hunkIndex: located.hunkIndex, changeIndex: located.changeIndex };
127
135
  }
128
136
 
@@ -152,9 +160,12 @@ export function isRowRejected(
152
160
  rejections: HunkRejection[],
153
161
  ): boolean {
154
162
  const change = changeSideLine(row);
163
+
155
164
  if (!change) return false;
156
165
  const located = locateLine(model, change.side, change.lineNumber);
166
+
157
167
  if (!located || located.changeIndex === undefined) return false;
168
+
158
169
  return rejections.some(
159
170
  (rejection) =>
160
171
  rejection.path === path &&
@@ -178,6 +189,7 @@ function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): s
178
189
  .filter((rejection) => rejection.changeIndex === undefined)
179
190
  .map((rejection) => rejection.hunkIndex),
180
191
  );
192
+
181
193
  for (const hunkIndex of [...wholeHunks].sort((a, b) => b - a)) {
182
194
  model = diffAcceptRejectHunk(model, hunkIndex, "reject");
183
195
  }
@@ -186,6 +198,7 @@ function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): s
186
198
  (rejection) => rejection.changeIndex !== undefined && !wholeHunks.has(rejection.hunkIndex),
187
199
  )
188
200
  .sort((a, b) => b.hunkIndex - a.hunkIndex || b.changeIndex! - a.changeIndex!);
201
+
189
202
  for (const rejection of changeRejections) {
190
203
  model = diffAcceptRejectHunk(model, rejection.hunkIndex, {
191
204
  type: "reject",
@@ -193,9 +206,12 @@ function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): s
193
206
  });
194
207
  }
195
208
  const curatedNew = model.additionLines.join("");
209
+
196
210
  if (curatedNew === file.oldContents) return null;
197
211
  const patch = unifiedDiffText(file.oldContents, curatedNew, file.path);
212
+
198
213
  if (patch === null) return null;
214
+
199
215
  return withFileStateHeaders(patch, file.path, file.status, curatedNew);
200
216
  }
201
217
 
@@ -215,6 +231,7 @@ function withFileStateHeaders(
215
231
  if (status === "deleted" && curatedNew === "") {
216
232
  return patch.replace(`+++ b/${path}`, "+++ /dev/null");
217
233
  }
234
+
218
235
  return patch;
219
236
  }
220
237
 
@@ -224,10 +241,13 @@ function withFileStateHeaders(
224
241
  */
225
242
  export function curateDiff(files: DiffFileContents[], rejections: HunkRejection[]): string {
226
243
  const parts: string[] = [];
244
+
227
245
  for (const file of files) {
228
246
  const fileRejections = rejections.filter((rejection) => rejection.path === file.path);
229
247
  const patch = curateFilePatch(file, fileRejections);
248
+
230
249
  if (patch) parts.push(patch);
231
250
  }
251
+
232
252
  return parts.join("\n");
233
253
  }
@@ -76,6 +76,7 @@ describe("intralineRunsByRow", () => {
76
76
  // Assert
77
77
  const deletionRuns = runsByRow.get(1)!;
78
78
  const additionRuns = runsByRow.get(2)!;
79
+
79
80
  expect(joinText(deletionRuns)).toBe("const foo = 1");
80
81
  expect(joinText(additionRuns)).toBe("const foo = 2");
81
82
  expect(changedText(deletionRuns)).toBe("1");
@@ -47,14 +47,17 @@ function stripTrailingNewline(text: string): string {
47
47
  export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]> {
48
48
  const runsByRow = new Map<number, IntralineRun[]>();
49
49
  let index = 0;
50
+
50
51
  while (index < rows.length) {
51
52
  if (rows[index]!.kind !== "del") {
52
53
  index++;
53
54
  continue;
54
55
  }
55
56
  const deletionStart = index;
57
+
56
58
  while (index < rows.length && rows[index]!.kind === "del") index++;
57
59
  const additionStart = index;
60
+
58
61
  while (index < rows.length && rows[index]!.kind === "add") index++;
59
62
  const deletionTexts = rows
60
63
  .slice(deletionStart, additionStart)
@@ -62,15 +65,18 @@ export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]>
62
65
  const additionTexts = rows
63
66
  .slice(additionStart, index)
64
67
  .map((row) => stripTrailingNewline(row.text));
68
+
65
69
  for (const [deletionOffset, additionOffset] of alignedPairs(deletionTexts, additionTexts)) {
66
70
  const changes = wordLevelChanges(
67
71
  deletionTexts[deletionOffset]!,
68
72
  additionTexts[additionOffset]!,
69
73
  );
74
+
70
75
  runsByRow.set(deletionStart + deletionOffset, sideRuns(changes, "removed"));
71
76
  runsByRow.set(additionStart + additionOffset, sideRuns(changes, "added"));
72
77
  }
73
78
  }
79
+
74
80
  return runsByRow;
75
81
  }
76
82
 
@@ -83,10 +89,13 @@ function linesSimilar(oldText: string, newText: string): boolean {
83
89
  const words = (text: string) => new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
84
90
  const oldWords = words(oldText);
85
91
  const newWords = words(newText);
92
+
86
93
  if (oldWords.size === 0 && newWords.size === 0) return true;
87
94
  let intersection = 0;
95
+
88
96
  for (const word of oldWords) if (newWords.has(word)) intersection++;
89
97
  const union = oldWords.size + newWords.size - intersection;
98
+
90
99
  return union === 0 ? true : intersection / union >= SIMILAR_MIN_WORD_OVERLAP;
91
100
  }
92
101
 
@@ -103,6 +112,7 @@ function alignedPairs(deletionTexts: string[], additionTexts: string[]): Array<[
103
112
  const pairs: Array<[number, number]> = [];
104
113
  let deletionOffset = 0;
105
114
  let additionOffset = 0;
115
+
106
116
  for (const op of lcsDiff(deletionTexts, additionTexts, linesSimilar)) {
107
117
  if (op.kind === "ctx") {
108
118
  pairs.push([deletionOffset, additionOffset]);
@@ -114,6 +124,7 @@ function alignedPairs(deletionTexts: string[], additionTexts: string[]): Array<[
114
124
  additionOffset++;
115
125
  }
116
126
  }
127
+
117
128
  return pairs;
118
129
  }
119
130
 
@@ -121,12 +132,15 @@ function alignedPairs(deletionTexts: string[], additionTexts: string[]): Array<[
121
132
  * runs of equal changed-ness coalesced so highlighting stays as few spans. */
122
133
  function sideRuns(changes: WordChange[], side: "added" | "removed"): IntralineRun[] {
123
134
  const runs: IntralineRun[] = [];
135
+
124
136
  for (const change of changes) {
125
137
  if (change.kind !== "common" && change.kind !== side) continue;
126
138
  const changed = change.kind === side;
127
139
  const last = runs[runs.length - 1];
140
+
128
141
  if (last && last.changed === changed) last.text += change.text;
129
142
  else runs.push({ text: change.text, changed });
130
143
  }
144
+
131
145
  return runs;
132
146
  }