@cueloop/client 0.1.0-alpha.102 → 0.1.0-alpha.103

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 (122) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/annotation-palette.ts +1 -0
  3. package/src/annotations/components/AnnotationCards.tsx +4 -0
  4. package/src/annotations/components/DiscussionMarkerRail.tsx +3 -0
  5. package/src/annotations/discussions.ts +1 -0
  6. package/src/annotations/thread-selection.ts +6 -0
  7. package/src/annotations/use-annotation-surface.tsx +97 -0
  8. package/src/app/App.deleted-scroll.test.tsx +3 -0
  9. package/src/app/App.harness-settings.test.tsx +77 -0
  10. package/src/app/App.plan-render.test.tsx +2 -0
  11. package/src/app/App.plan-review.test.tsx +1 -0
  12. package/src/app/App.test.tsx +1 -0
  13. package/src/app/App.tsx +67 -1
  14. package/src/app/App.visual.test.tsx +1 -0
  15. package/src/app/App.walk.test.tsx +1 -0
  16. package/src/app/App.workbench.test.tsx +1 -0
  17. package/src/app/app-screens.tsx +1 -0
  18. package/src/app/app-view-model.ts +7 -0
  19. package/src/app/components/WorkspacePanels.tsx +1 -0
  20. package/src/app/intent-dispatch.test.ts +1 -0
  21. package/src/app/intent-dispatch.ts +26 -0
  22. package/src/app/ready-signal.ts +2 -0
  23. package/src/app/run.ts +2 -0
  24. package/src/app/serve.test.ts +4 -0
  25. package/src/diff/components/AnnotatableFileView.tsx +1 -0
  26. package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
  27. package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
  28. package/src/diff/components/DiffContentView.tsx +25 -0
  29. package/src/diff/components/diff-content-view-layout.ts +3 -0
  30. package/src/diff/diff-fold.ts +4 -0
  31. package/src/diff/diff-hunk-curate.ts +3 -0
  32. package/src/diff/diff-intraline.ts +6 -0
  33. package/src/diff/diff-syntax.ts +8 -0
  34. package/src/diff/split-diff.ts +6 -0
  35. package/src/diff/view-diff.ts +18 -0
  36. package/src/diff/walk.ts +3 -0
  37. package/src/integrations/attribution.ts +2 -0
  38. package/src/integrations/client-extension-registry.ts +4 -0
  39. package/src/integrations/clipboard.ts +1 -0
  40. package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
  41. package/src/integrations/components/ShareDialog.tsx +28 -0
  42. package/src/integrations/editor.ts +3 -0
  43. package/src/integrations/github-identity.test.ts +1 -0
  44. package/src/integrations/github-identity.ts +1 -0
  45. package/src/integrations/herdr.ts +2 -0
  46. package/src/integrations/pasted-image.ts +2 -0
  47. package/src/integrations/share.ts +4 -0
  48. package/src/keyboard/components/HandleChipsInput.tsx +4 -0
  49. package/src/keyboard/components/SlashComposer.tsx +3 -0
  50. package/src/keyboard/key-bindings.ts +3 -0
  51. package/src/keyboard/keymap.ts +51 -0
  52. package/src/keyboard/skills.ts +2 -0
  53. package/src/keyboard/slash-palette.ts +8 -0
  54. package/src/keyboard/thread-chords.ts +5 -0
  55. package/src/keyboard/use-shared-keyboard.ts +1 -0
  56. package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
  57. package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
  58. package/src/markdown/components/ThreadView.tsx +13 -0
  59. package/src/markdown/mark-runs.ts +4 -0
  60. package/src/markdown/markdown-editor-syntax-style.ts +1 -0
  61. package/src/markdown/markdown-grid.ts +6 -0
  62. package/src/markdown/markdown-highlight.ts +1 -0
  63. package/src/markdown/thread-code-syntax.ts +3 -0
  64. package/src/markdown/view-plan.ts +52 -0
  65. package/src/performance/perf-report.ts +1 -0
  66. package/src/performance/perf-timings.ts +1 -0
  67. package/src/prototype/components/PrototypeContentView.tsx +17 -0
  68. package/src/prototype/prototype-browser.ts +11 -0
  69. package/src/settings/components/SettingsDialog.tsx +3 -0
  70. package/src/settings/config.test.ts +41 -0
  71. package/src/settings/config.ts +44 -0
  72. package/src/settings/use-settings-dialog.test.tsx +98 -0
  73. package/src/settings/use-settings-dialog.tsx +38 -1
  74. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  75. package/src/settings/use-thread-harness-setting.ts +86 -0
  76. package/src/stories/stories.test.tsx +4 -0
  77. package/src/stories/story.ts +6 -0
  78. package/src/terminal/ansi-screen.ts +5 -0
  79. package/src/terminal/components/terminal-pane.ts +12 -0
  80. package/src/terminal/ghostty-terminal.ts +10 -0
  81. package/src/terminal/kitty-image.ts +3 -0
  82. package/src/terminal/pty.ts +9 -0
  83. package/src/testing/test-support.test.ts +2 -0
  84. package/src/testing/test-support.ts +16 -0
  85. package/src/thread/agent-continuation.test.ts +2 -0
  86. package/src/thread/agent-transcript.ts +7 -0
  87. package/src/thread/components/AgentConfigControls.tsx +1 -0
  88. package/src/thread/components/AgentThreadPane.test.tsx +31 -0
  89. package/src/thread/components/AgentThreadPane.tsx +12 -0
  90. package/src/thread/components/ThreadTree.tsx +1 -0
  91. package/src/thread/components/thread-tree.test.ts +1 -0
  92. package/src/thread/thread-controller.curate.test.ts +2 -0
  93. package/src/thread/thread-controller.ts +123 -0
  94. package/src/thread/tree-view.ts +4 -0
  95. package/src/thread/use-thread-agent.test.tsx +1 -0
  96. package/src/thread/use-thread-agent.ts +5 -0
  97. package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
  98. package/src/ui/components/OverlayScrollbar.tsx +5 -0
  99. package/src/ui/components/PromptDialog.tsx +2 -0
  100. package/src/ui/components/RootOverlay.tsx +1 -0
  101. package/src/ui/components/ScrollArea.tsx +2 -0
  102. package/src/ui/components/primitives/Button.tsx +1 -0
  103. package/src/ui/components/primitives/Tabs.tsx +1 -0
  104. package/src/ui/components/primitives/Tree.tsx +4 -0
  105. package/src/ui/components/primitives/tree-model.ts +7 -0
  106. package/src/ui/components/syntax-highlight.ts +5 -0
  107. package/src/ui/components/truncate-title.ts +1 -0
  108. package/src/ui/use-frame-measure.ts +3 -0
  109. package/src/ui/use-terminal-virtualizer.ts +6 -0
  110. package/src/workbench/components/ChangesColumn.tsx +7 -0
  111. package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
  112. package/src/workbench/components/EditorGrid.tsx +8 -0
  113. package/src/workbench/components/FileContentsView.tsx +2 -0
  114. package/src/workbench/components/GridTabContent.tsx +2 -0
  115. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  116. package/src/workbench/components/ProjectTreeView.tsx +8 -0
  117. package/src/workbench/components/TreePane.tsx +2 -0
  118. package/src/workbench/components/editor-grid.ts +3 -0
  119. package/src/workbench/components/file-tree.ts +2 -0
  120. package/src/workbench/use-changes-workbench.ts +5 -0
  121. package/src/workbench/use-remember-layout.ts +2 -0
  122. package/src/workbench/use-repo-changes.ts +2 -0
@@ -126,6 +126,7 @@ function terminalColorScheme(textColor: string): "dark" | "light" {
126
126
  const hex = textColor.replace("#", "");
127
127
 
128
128
  if (hex.length < 6) return "dark";
129
+
129
130
  const r = parseInt(hex.slice(0, 2), 16);
130
131
  const g = parseInt(hex.slice(2, 4), 16);
131
132
  const b = parseInt(hex.slice(4, 6), 16);
@@ -181,12 +182,14 @@ function PrototypeContentViewImpl({
181
182
  // harness), but the launch trigger still runs.
182
183
  useEffect(() => {
183
184
  if (!renderer) return;
185
+
184
186
  const rendererOutput = v.safeParse(v.object({ writeOut: v.function() }), renderer);
185
187
  const rawWrite = rendererOutput.success ? rendererOutput.output.writeOut : null;
186
188
  const write = rawWrite ? (chunk: string) => rawWrite.call(renderer, chunk) : null;
187
189
  const medium = resolveTransmitMedium();
188
190
  const paint = (): void => {
189
191
  if (!write) return;
192
+
190
193
  // an app overlay is covering the sheet: pull the image so it does not show
191
194
  // through the overlay's cells, and re-transmit once the overlay closes
192
195
  if (hiddenRef.current) {
@@ -197,10 +200,12 @@ function PrototypeContentViewImpl({
197
200
 
198
201
  return;
199
202
  }
203
+
200
204
  const region = regionOf(regionRef.current);
201
205
  const png = pngRef.current;
202
206
 
203
207
  if (!region || !png) return;
208
+
204
209
  if (transmittedRef.current !== png) {
205
210
  transmitKittyImage(write, png, region, PROTOTYPE_IMAGE_ID, medium);
206
211
  transmittedRef.current = png;
@@ -219,6 +224,7 @@ function PrototypeContentViewImpl({
219
224
 
220
225
  return () => {
221
226
  renderer.off("frame", onFrame);
227
+
222
228
  if (write) deleteKittyImage(write, PROTOTYPE_IMAGE_ID);
223
229
  };
224
230
  }, [renderer]);
@@ -241,11 +247,13 @@ function PrototypeContentViewImpl({
241
247
  const caps = renderer?.capabilities;
242
248
 
243
249
  if (launchedRef.current || !region) return;
250
+
244
251
  if (caps && caps.kitty_graphics === false) {
245
252
  setStatus("unsupported");
246
253
 
247
254
  return;
248
255
  }
256
+
249
257
  launchedRef.current = true;
250
258
  const capture = captureConfig(region, renderer!);
251
259
 
@@ -266,6 +274,7 @@ function PrototypeContentViewImpl({
266
274
 
267
275
  return;
268
276
  }
277
+
269
278
  browserRef.current = browser;
270
279
  setPng(await browser.screenshot());
271
280
  setStatus("ready");
@@ -304,6 +313,7 @@ function PrototypeContentViewImpl({
304
313
  const region = regionOf(regionRef.current);
305
314
 
306
315
  if (status !== "ready" || !browser || !region) return;
316
+
307
317
  const geometry = {
308
318
  x: region.column,
309
319
  y: region.row,
@@ -313,10 +323,12 @@ function PrototypeContentViewImpl({
313
323
  const cssPoint = imageCellToCss(event, geometry, viewportRef.current);
314
324
 
315
325
  if (!cssPoint) return;
326
+
316
327
  void (async () => {
317
328
  const element = await browser.elementAt(cssPoint.x, cssPoint.y);
318
329
 
319
330
  if (!element) return;
331
+
320
332
  const cell = cssBoxToCell(element.box, geometry, viewportRef.current);
321
333
  const topRaw = cell.row - geometry.y;
322
334
 
@@ -326,6 +338,7 @@ function PrototypeContentViewImpl({
326
338
  top: Math.max(0, topRaw < OVERLAY_ROWS ? topRaw + 1 : topRaw - OVERLAY_ROWS),
327
339
  regionColumns: geometry.width,
328
340
  });
341
+
329
342
  // selecting an element opens the compose card directly, the same edit
330
343
  // primitive plan and diff use - there is no intermediate marker toolbar
331
344
  if (canComment) {
@@ -344,6 +357,7 @@ function PrototypeContentViewImpl({
344
357
  const browser = browserRef.current;
345
358
 
346
359
  if (status !== "ready" || !browser) return;
360
+
347
361
  const delta = event.scroll?.direction === "up" ? -SCROLL_STEP : SCROLL_STEP;
348
362
 
349
363
  // a selection would drift once the page scrolls under it, so drop it
@@ -356,7 +370,9 @@ function PrototypeContentViewImpl({
356
370
 
357
371
  const commit = (body: string): void => {
358
372
  if (!canComment) return;
373
+
359
374
  if (selected && body.trim()) onCommentElement(selected, body.trim());
375
+
360
376
  clearSelection();
361
377
  };
362
378
 
@@ -435,6 +451,7 @@ export const PrototypeContentView = React.memo(PrototypeContentViewImpl);
435
451
 
436
452
  function statusLine(status: SheetStatus, errorMessage: string): string {
437
453
  if (status === "loading") return "rendering prototype…";
454
+
438
455
  if (status === "unsupported")
439
456
  return "prototype preview needs a graphics terminal (kitty or ghostty)";
440
457
 
@@ -114,6 +114,7 @@ async function warmBrowser(executablePath: string | undefined): Promise<Puppetee
114
114
  const cached = sharedBrowser ? await sharedBrowser.catch(() => null) : null;
115
115
 
116
116
  if (cached && cached.connected) return cached;
117
+
117
118
  const puppeteer = (await import("puppeteer-core")).default;
118
119
  const launched = puppeteer.launch({
119
120
  executablePath: executablePath ?? chromeExecutable(),
@@ -124,6 +125,7 @@ async function warmBrowser(executablePath: string | undefined): Promise<Puppetee
124
125
  sharedBrowser = launched;
125
126
  const browser = await launched.catch((error) => {
126
127
  if (sharedBrowser === launched) sharedBrowser = null;
128
+
127
129
  throw error;
128
130
  });
129
131
 
@@ -143,7 +145,9 @@ export async function closePrototypeBrowser(): Promise<void> {
143
145
  const pending = sharedBrowser;
144
146
 
145
147
  sharedBrowser = null;
148
+
146
149
  if (!pending) return;
150
+
147
151
  const browser = await pending.catch(() => null);
148
152
 
149
153
  await browser?.close().catch(() => undefined);
@@ -233,8 +237,10 @@ function elementAtScript(x: number, y: number) {
233
237
  while (current && current !== document.body) {
234
238
  if (isNamed(current)) {
235
239
  if (current.childElementCount > 1) return current;
240
+
236
241
  namedFallback = namedFallback ?? current;
237
242
  }
243
+
238
244
  current = current.parentElement;
239
245
  }
240
246
 
@@ -251,6 +257,7 @@ function elementAtScript(x: number, y: number) {
251
257
  parts.unshift(part + "#" + CSS.escape(current.id));
252
258
  break;
253
259
  }
260
+
254
261
  const parent: Element | null = current.parentElement;
255
262
 
256
263
  if (parent) {
@@ -259,6 +266,7 @@ function elementAtScript(x: number, y: number) {
259
266
 
260
267
  if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
261
268
  }
269
+
262
270
  parts.unshift(part);
263
271
  current = current.parentElement;
264
272
  }
@@ -282,10 +290,12 @@ function elementAtScript(x: number, y: number) {
282
290
  const hit = document.elementFromPoint(x, y);
283
291
 
284
292
  if (!(hit instanceof Element)) return null;
293
+
285
294
  const node = focusableTarget(hit) ?? componentRoot(hit);
286
295
  const selector = selectorFor(node);
287
296
 
288
297
  if (!selector) return null;
298
+
289
299
  const text = (node.textContent || "").replace(/\s+/g, " ").trim();
290
300
  const tag = node.tagName.toLowerCase();
291
301
  const quote = text
@@ -313,6 +323,7 @@ function chromeExecutable(): string {
313
323
  if (process.platform === "darwin") {
314
324
  return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
315
325
  }
326
+
316
327
  if (process.platform === "win32") {
317
328
  return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
318
329
  }
@@ -92,10 +92,12 @@ export function SettingsDialog({
92
92
  const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
93
93
 
94
94
  if (!isOpen) return null;
95
+
95
96
  const category =
96
97
  categories.find((candidate) => candidate.id === activeCategoryId) ?? categories[0];
97
98
 
98
99
  if (!category) return null;
100
+
99
101
  const onKeybinds = category.id === KEYBINDS_CATEGORY_ID;
100
102
 
101
103
  return (
@@ -143,6 +145,7 @@ export function SettingsDialog({
143
145
  />
144
146
  );
145
147
  }
148
+
146
149
  if (row.kind === "cycle") {
147
150
  return (
148
151
  <CycleRow
@@ -674,3 +674,44 @@ test("Thread agents default off and require a boolean experimental TOML flag", (
674
674
  rmSync(dir, { recursive: true, force: true });
675
675
  }
676
676
  });
677
+
678
+ test("Thread harness preference persists without changing experimental or other settings", async () => {
679
+ const { persistThreadHarness } = await import("./config");
680
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-thread-config-"));
681
+ const path = join(dir, "config.toml");
682
+
683
+ try {
684
+ writeFileSync(path, '[experimental]\nthread_agent = true\n[ui]\ntheme = "cueloop"\n');
685
+ persistThreadHarness("fx", path);
686
+ const config = loadConfig({ userConfigPath: path });
687
+
688
+ expect(config.thread.harness).toBe("fx");
689
+ expect(config.experimental.threadAgent).toBe(true);
690
+ expect(config.ui.theme).toBe("cueloop");
691
+ persistThreadHarness("pi", path);
692
+ expect(loadConfig({ userConfigPath: path }).thread.harness).toBe("pi");
693
+ } finally {
694
+ rmSync(dir, { recursive: true, force: true });
695
+ }
696
+ });
697
+
698
+ test("a malformed Thread section does not discard other preferences", () => {
699
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-invalid-thread-config-"));
700
+ const path = join(dir, "config.toml");
701
+
702
+ try {
703
+ for (const section of ['thread = "fx"\n', '[thread]\nharness = "typo"\n']) {
704
+ writeFileSync(
705
+ path,
706
+ `${section}[experimental]\nthread_agent = true\n[ui]\ntheme = "cueloop"\n`,
707
+ );
708
+ const config = loadConfig({ userConfigPath: path });
709
+
710
+ expect(config.thread.harness).toBe("pi");
711
+ expect(config.experimental.threadAgent).toBe(true);
712
+ expect(config.ui.theme).toBe("cueloop");
713
+ }
714
+ } finally {
715
+ rmSync(dir, { recursive: true, force: true });
716
+ }
717
+ });
@@ -104,6 +104,7 @@ export function resolveQuickAction(
104
104
  const index = Number(actionRef);
105
105
 
106
106
  if (Number.isInteger(index) && index >= 1 && index <= actions.length) return actions[index - 1];
107
+
107
108
  const wanted = actionRef.trim().toLowerCase();
108
109
 
109
110
  return actions.find((action) => action.prompt.toLowerCase() === wanted);
@@ -127,6 +128,7 @@ export interface CueloopConfig {
127
128
  actions: QuickAction[];
128
129
  skillsPath: string;
129
130
  review: ReviewConfig;
131
+ thread: { harness: "pi" | "fx" };
130
132
  integrations: IntegrationsConfig;
131
133
  experimental: ExperimentalConfig;
132
134
  }
@@ -189,6 +191,10 @@ const ConfigDocumentSchema = v.object({
189
191
  experimental: v.optional(v.unknown()),
190
192
  skills: v.fallback(v.optional(SkillsSectionSchema), undefined),
191
193
  review: v.optional(v.unknown()),
194
+ thread: v.fallback(
195
+ v.optional(v.object({ harness: v.fallback(v.optional(v.picklist(["pi", "fx"])), undefined) })),
196
+ undefined,
197
+ ),
192
198
  });
193
199
 
194
200
  const IdentitySchema = v.object({
@@ -258,12 +264,14 @@ function parseToml(text: string) {
258
264
 
259
265
  function parseActions(entries: readonly unknown[] | undefined): QuickAction[] | undefined {
260
266
  if (!entries) return undefined;
267
+
261
268
  const actions: QuickAction[] = [];
262
269
 
263
270
  for (const entry of entries) {
264
271
  const result = v.safeParse(QuickActionSchema, entry);
265
272
 
266
273
  if (!result.success) continue;
274
+
267
275
  const { prompt, metadata } = result.output;
268
276
 
269
277
  actions.push(metadata?.trim() ? { prompt, metadata } : { prompt });
@@ -277,21 +285,30 @@ function mergeObsidian(
277
285
  obsidian: v.InferOutput<typeof ObsidianSchema>,
278
286
  ): void {
279
287
  if (obsidian.vault !== undefined) target.vault = obsidian.vault;
288
+
280
289
  if (obsidian.folder !== undefined) target.folder = obsidian.folder;
290
+
281
291
  if (obsidian.filenameFormat !== undefined) target.filenameFormat = obsidian.filenameFormat;
292
+
282
293
  if (obsidian.separator !== undefined) target.separator = obsidian.separator;
294
+
283
295
  if (obsidian.exportOn !== undefined) target.exportOn = obsidian.exportOn;
284
296
  }
285
297
 
286
298
  /** Fold a parsed `[ui]` table onto the accumulated ui config, field by present field. */
287
299
  function applyUi(ui: CueloopConfig["ui"], parsed: v.InferOutput<typeof UiSchema>): void {
288
300
  if (parsed.auto_close !== undefined) ui.autoClose = parsed.auto_close;
301
+
289
302
  if (parsed.editor?.trim()) ui.editor = parsed.editor.trim();
303
+
290
304
  // "unified" is the pre-rename spelling of "stacked"; keep loading it so an upgrade never flips the layout
291
305
  if (parsed.diff_view !== undefined)
292
306
  ui.diffView = parsed.diff_view === "unified" ? "stacked" : parsed.diff_view;
307
+
293
308
  if (parsed.default_message !== undefined) ui.defaultMessage = parsed.default_message;
309
+
294
310
  if (parsed.pins !== undefined) ui.pins = parsed.pins;
311
+
295
312
  if (parsed.layout !== undefined) {
296
313
  ui.layout = {
297
314
  threads: parsed.layout.threads ?? true,
@@ -303,6 +320,7 @@ function applyUi(ui: CueloopConfig["ui"], parsed: v.InferOutput<typeof UiSchema>
303
320
 
304
321
  function applyReview(review: ReviewConfig, parsed: v.InferOutput<typeof ReviewSchema>): void {
305
322
  if (parsed.skill?.trim()) review.skill = parsed.skill.trim();
323
+
306
324
  if (parsed.workspace !== undefined) review.workspace = parsed.workspace;
307
325
  }
308
326
 
@@ -321,6 +339,7 @@ function layer(
321
339
  actions: [...base.actions],
322
340
  skillsPath: base.skillsPath,
323
341
  review: { ...base.review },
342
+ thread: { ...base.thread },
324
343
  integrations: { obsidian: { ...base.integrations.obsidian } },
325
344
  experimental: { ...base.experimental },
326
345
  };
@@ -334,7 +353,9 @@ function layer(
334
353
  const review = v.safeParse(ReviewSchema, raw.review);
335
354
 
336
355
  if (actions) out.actions = actions;
356
+
337
357
  out.skillsPath = skillsPathFrom(raw.skills, base.skillsPath);
358
+
338
359
  if (authors.success) {
339
360
  for (const [id, value] of Object.entries(authors.output)) {
340
361
  const name = v.safeParse(v.string(), value);
@@ -342,6 +363,7 @@ function layer(
342
363
  if (name.success) out.authors[id] = name.output;
343
364
  }
344
365
  }
366
+
345
367
  if (keys.success) {
346
368
  for (const [action, value] of Object.entries(keys.output)) {
347
369
  const combo = v.safeParse(KeyComboSchema, value);
@@ -350,18 +372,26 @@ function layer(
350
372
  out.keys[action] = Array.isArray(combo.output) ? combo.output : [combo.output];
351
373
  }
352
374
  }
375
+
353
376
  // The reviewer identity is a personal credential; a repo must never forge a verified name.
354
377
  if (allowIdentity && identity.success) {
355
378
  if (identity.output.name !== undefined) out.identity.name = identity.output.name;
379
+
356
380
  if (identity.output.provider !== undefined) out.identity.provider = identity.output.provider;
357
381
  }
382
+
358
383
  if (ui.success) applyUi(out.ui, ui.output);
384
+
359
385
  if (integrations.success && integrations.output.obsidian) {
360
386
  mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
361
387
  }
388
+
362
389
  if (experimental.success) applyExperimental(out.experimental, experimental.output);
390
+
363
391
  if (review.success) applyReview(out.review, review.output);
364
392
 
393
+ if (raw.thread?.harness) out.thread.harness = raw.thread.harness;
394
+
365
395
  return out;
366
396
  }
367
397
 
@@ -384,6 +414,7 @@ export function loadConfig(
384
414
  actions: [...DEFAULT_QUICK_ACTIONS],
385
415
  skillsPath: join(homedir(), ".agents", "skills"),
386
416
  review: { skill: "code-review", workspace: "worktree" },
417
+ thread: { harness: "pi" },
387
418
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
388
419
  experimental: { prototypePixels: false, threadAgent: false },
389
420
  };
@@ -400,6 +431,7 @@ export function loadConfig(
400
431
  options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined,
401
432
  ]) {
402
433
  if (!path || !existsSync(path)) continue;
434
+
403
435
  try {
404
436
  const raw = parseToml(readFileSync(path, "utf8"));
405
437
 
@@ -409,6 +441,7 @@ export function loadConfig(
409
441
  const parsedThemeOverrides = v.safeParse(ThemeOverridesSchema, raw.theme);
410
442
 
411
443
  if (rawTheme && isThemeName(rawTheme)) themeName = rawTheme;
444
+
412
445
  if (parsedThemeOverrides.success) {
413
446
  for (const [token, value] of Object.entries(parsedThemeOverrides.output)) {
414
447
  const override = v.safeParse(v.string(), value);
@@ -461,6 +494,7 @@ function persistUiSetting(key: string, rendered: string, userConfigPath?: string
461
494
  let text = "";
462
495
 
463
496
  if (existsSync(path)) text = readFileSync(path, "utf8");
497
+
464
498
  const assignment = new RegExp(`^(\\s*)${key}\\s*=.*$`, "m");
465
499
 
466
500
  if (assignment.test(text)) {
@@ -470,6 +504,7 @@ function persistUiSetting(key: string, rendered: string, userConfigPath?: string
470
504
  } else {
471
505
  text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\n${key} = ${rendered}\n`;
472
506
  }
507
+
473
508
  mkdirSync(dirname(path), { recursive: true });
474
509
  writeFileSync(path, text);
475
510
  }
@@ -500,6 +535,7 @@ function persistTableSetting(
500
535
  } else {
501
536
  text = `${text.trimEnd()}${text.trim() ? "\n\n" : ""}[${table}]\n${key} = ${rendered}\n`;
502
537
  }
538
+
503
539
  mkdirSync(dirname(path), { recursive: true });
504
540
  writeFileSync(path, text);
505
541
  }
@@ -548,6 +584,7 @@ export function persistIdentity(identity: IdentityConfig, userConfigPath?: strin
548
584
  } else {
549
585
  text = text.trimEnd() + (text.trim() ? "\n\n" : "") + block;
550
586
  }
587
+
551
588
  mkdirSync(dirname(path), { recursive: true });
552
589
  writeFileSync(path, text);
553
590
  }
@@ -614,6 +651,7 @@ export function persistAuthorName(id: string, name: string, userConfigPath?: str
614
651
  } else {
615
652
  text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[authors]\n${key} = ${value}\n`;
616
653
  }
654
+
617
655
  mkdirSync(dirname(path), { recursive: true });
618
656
  writeFileSync(path, text);
619
657
  }
@@ -624,5 +662,11 @@ function applyExperimental(
624
662
  raw: v.InferOutput<typeof ExperimentalSchema>,
625
663
  ): void {
626
664
  if (raw.prototype_pixels !== undefined) config.prototypePixels = raw.prototype_pixels;
665
+
627
666
  if (raw.thread_agent !== undefined) config.threadAgent = raw.thread_agent;
628
667
  }
668
+
669
+ /** The preferred harness affects new Threads; existing Threads require a handoff. */
670
+ export function persistThreadHarness(harness: "pi" | "fx", userConfigPath?: string): void {
671
+ persistTableSetting("thread", "harness", tomlString(harness), userConfigPath);
672
+ }
@@ -0,0 +1,98 @@
1
+ import { expect, test } from "bun:test";
2
+ import React, { useState } from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { DARK } from "../appearance/theme";
5
+ import { settle, locateText } from "../testing/test-support";
6
+ import { SettingsDialog } from "./components/SettingsDialog";
7
+ import { useSettingsDialog } from "./use-settings-dialog";
8
+
9
+ function TestThreadSettings({
10
+ enabled,
11
+ onSwitch,
12
+ }: {
13
+ enabled: boolean;
14
+ onSwitch: (harness: string) => void;
15
+ }) {
16
+ const [harness, setHarness] = useState<"pi" | "fx">("pi");
17
+ const model = useSettingsDialog({
18
+ theme: DARK,
19
+ appearance: "dark",
20
+ autoClose: "off",
21
+ setAutoClose() {},
22
+ diffView: "split",
23
+ setDiffView() {},
24
+ themeName: "cueloop",
25
+ setThemeName() {},
26
+ themeOverrides: {},
27
+ setTheme() {},
28
+ quickActions: [],
29
+ setQuickActions() {},
30
+ setMenuDialog() {},
31
+ identityProvider: "typed",
32
+ onSyncGithubIdentity() {},
33
+ onRenameDisplayName() {},
34
+ reviewSkill: "code-review",
35
+ reviewWorkspace: "worktree",
36
+ setReviewWorkspace() {},
37
+ canSwitchHarness: enabled,
38
+ threadHarness: harness,
39
+ onSwitchHarness(next) {
40
+ setHarness(next);
41
+ onSwitch(next);
42
+ },
43
+ });
44
+
45
+ return (
46
+ <SettingsDialog
47
+ isOpen
48
+ version="test"
49
+ categories={model.settingsCategories}
50
+ values={model.settingsValues}
51
+ keybindsSections={[]}
52
+ activeCategoryId={model.settingsNav.categoryId}
53
+ activeRowIndex={model.settingsNav.rowIndex}
54
+ activeZone={model.settingsNav.zone}
55
+ onCategorySelect={model.onCategorySelect}
56
+ onRowActivate={(row) => model.cycleSetting(row.key)}
57
+ onClose={() => {}}
58
+ />
59
+ );
60
+ }
61
+
62
+ test("Thread settings switches harness by mouse and disappears without owner capability", async () => {
63
+ const changes: string[] = [];
64
+ const setup = await testRender(
65
+ <TestThreadSettings enabled onSwitch={(next) => changes.push(next)} />,
66
+ { width: 90, height: 28 },
67
+ );
68
+
69
+ try {
70
+ await settle(setup);
71
+ const category = locateText(setup, "Thread");
72
+
73
+ await setup.mockMouse.click(category.column, category.row);
74
+ await settle(setup);
75
+ const row = locateText(setup, "Harness");
76
+
77
+ await setup.mockMouse.click(row.column, row.row);
78
+ await settle(setup);
79
+ expect(changes).toEqual(["fx"]);
80
+ expect(setup.captureCharFrame()).toContain("fx");
81
+ await setup.mockMouse.click(row.column, row.row);
82
+ await settle(setup);
83
+ expect(changes).toEqual(["fx", "pi"]);
84
+ } finally {
85
+ setup.renderer.destroy();
86
+ }
87
+ const disabled = await testRender(<TestThreadSettings enabled={false} onSwitch={() => {}} />, {
88
+ width: 90,
89
+ height: 28,
90
+ });
91
+
92
+ try {
93
+ await settle(disabled);
94
+ expect(disabled.captureCharFrame()).not.toContain("Thread");
95
+ } finally {
96
+ disabled.renderer.destroy();
97
+ }
98
+ });
@@ -57,8 +57,10 @@ function moveNavZone(
57
57
  rowIndex: 0,
58
58
  zone: "nav",
59
59
  };
60
+
60
61
  if (UP_KEYS.has(name))
61
62
  return { categoryId: categories[Math.max(0, categoryIndex - 1)]!.id, rowIndex: 0, zone: "nav" };
63
+
62
64
  if (ENTER_BODY_KEYS.has(name))
63
65
  return { categoryId: categories[categoryIndex]!.id, rowIndex: 0, zone: "body" };
64
66
 
@@ -67,7 +69,9 @@ function moveNavZone(
67
69
 
68
70
  function moveBodyRow(name: string, nav: SettingsNav, rowCount: number): SettingsNav | null {
69
71
  if (DOWN_KEYS.has(name)) return { ...nav, rowIndex: Math.min(rowCount - 1, nav.rowIndex + 1) };
72
+
70
73
  if (UP_KEYS.has(name)) return { ...nav, rowIndex: Math.max(0, nav.rowIndex - 1) };
74
+
71
75
  if (BACK_TO_NAV_KEYS.has(name)) return { ...nav, zone: "nav" };
72
76
 
73
77
  return null;
@@ -94,6 +98,9 @@ export function useSettingsDialog(params: {
94
98
  reviewSkill: string;
95
99
  reviewWorkspace: ReviewWorkspaceMode;
96
100
  setReviewWorkspace: Dispatch<SetStateAction<ReviewWorkspaceMode>>;
101
+ threadHarness?: "pi" | "fx";
102
+ canSwitchHarness?: boolean;
103
+ onSwitchHarness?: (harness: "pi" | "fx") => void;
97
104
  }): SettingsDialogModel {
98
105
  const {
99
106
  theme,
@@ -116,6 +123,9 @@ export function useSettingsDialog(params: {
116
123
  reviewSkill,
117
124
  reviewWorkspace,
118
125
  setReviewWorkspace,
126
+ threadHarness,
127
+ canSwitchHarness,
128
+ onSwitchHarness,
119
129
  } = params;
120
130
 
121
131
  // open focused on the left nav, so up/down browses categories until l/tab/enter enters the body
@@ -134,6 +144,7 @@ export function useSettingsDialog(params: {
134
144
  const editActionPrompt = (index: number, prompt: string): void => {
135
145
  // a blank title fails the config schema and would vanish on reload, so never persist one
136
146
  if (prompt.trim().length === 0) return;
147
+
137
148
  commitActions(
138
149
  quickActions.map((action, actionIndex) =>
139
150
  actionIndex === index ? { ...action, prompt } : action,
@@ -214,6 +225,22 @@ export function useSettingsDialog(params: {
214
225
  },
215
226
  ],
216
227
  },
228
+ ...(canSwitchHarness
229
+ ? [
230
+ {
231
+ id: "thread",
232
+ name: "Thread",
233
+ rows: [
234
+ {
235
+ key: "threadHarness",
236
+ label: "Harness",
237
+ kind: "cycle" as const,
238
+ options: ["pi", "fx"],
239
+ },
240
+ ],
241
+ },
242
+ ]
243
+ : []),
217
244
  {
218
245
  id: "actions",
219
246
  name: "Actions",
@@ -252,9 +279,12 @@ export function useSettingsDialog(params: {
252
279
  syncGithub: "enter to sync",
253
280
  reviewSkill,
254
281
  reviewWorkspace,
282
+ threadHarness: threadHarness ?? "pi",
255
283
  };
256
284
  const cycleSetting = (rowKey: string): void => {
257
- if (rowKey === "autoClose") {
285
+ if (rowKey === "threadHarness") {
286
+ onSwitchHarness?.(threadHarness === "fx" ? "pi" : "fx");
287
+ } else if (rowKey === "autoClose") {
258
288
  const next: AutoClose = autoClose === "off" ? 3 : autoClose === 3 ? 10 : "off";
259
289
 
260
290
  setAutoClose(next);
@@ -291,6 +321,7 @@ export function useSettingsDialog(params: {
291
321
  const onCategorySelect = (categoryId: string): void => {
292
322
  // ignore a select that names no real category, so the key handler never dereferences nothing
293
323
  if (!settingsCategories.some((category) => category.id === categoryId)) return;
324
+
294
325
  setActionsExpandedIndex(null);
295
326
  setSettingsNav({ categoryId, rowIndex: 0, zone: "body" });
296
327
  };
@@ -302,7 +333,9 @@ export function useSettingsDialog(params: {
302
333
 
303
334
  return;
304
335
  }
336
+
305
337
  if (name === "escape") return void setMenuDialog(null);
338
+
306
339
  const categoryIndex = settingsCategories.findIndex(
307
340
  (category) => category.id === settingsNav.categoryId,
308
341
  );
@@ -315,11 +348,13 @@ export function useSettingsDialog(params: {
315
348
 
316
349
  return;
317
350
  }
351
+
318
352
  // the Actions category is a list of quick actions plus a trailing "add" row
319
353
  if (category.id === "actions") {
320
354
  const moved = moveBodyRow(name, settingsNav, quickActions.length + 1);
321
355
 
322
356
  if (moved) return void setSettingsNav(moved);
357
+
323
358
  if (ACTIVATE_KEYS.has(name)) {
324
359
  if (settingsNav.rowIndex === quickActions.length) addAction();
325
360
  else {
@@ -330,9 +365,11 @@ export function useSettingsDialog(params: {
330
365
 
331
366
  return;
332
367
  }
368
+
333
369
  const moved = moveBodyRow(name, settingsNav, category.rows.length);
334
370
 
335
371
  if (moved) return void setSettingsNav(moved);
372
+
336
373
  if (name === "return" || name === "space")
337
374
  cycleSetting(category.rows[settingsNav.rowIndex]!.key);
338
375
  };