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

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 (126) 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.readonly.test.tsx +63 -0
  13. package/src/app/App.test.tsx +1 -0
  14. package/src/app/App.tsx +94 -12
  15. package/src/app/App.visual.test.tsx +1 -0
  16. package/src/app/App.walk.test.tsx +1 -0
  17. package/src/app/App.workbench.test.tsx +1 -0
  18. package/src/app/app-screens.tsx +3 -2
  19. package/src/app/app-view-model.ts +11 -1
  20. package/src/app/components/WorkspacePanels.tsx +1 -0
  21. package/src/app/intent-dispatch.test.ts +1 -0
  22. package/src/app/intent-dispatch.ts +26 -0
  23. package/src/app/ready-signal.ts +2 -0
  24. package/src/app/run.ts +2 -0
  25. package/src/app/serve.test.ts +4 -0
  26. package/src/diff/components/AnnotatableFileView.tsx +1 -0
  27. package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
  28. package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
  29. package/src/diff/components/DiffContentView.tsx +25 -0
  30. package/src/diff/components/diff-content-view-layout.ts +3 -0
  31. package/src/diff/diff-fold.ts +4 -0
  32. package/src/diff/diff-hunk-curate.ts +3 -0
  33. package/src/diff/diff-intraline.ts +6 -0
  34. package/src/diff/diff-syntax.ts +8 -0
  35. package/src/diff/split-diff.ts +6 -0
  36. package/src/diff/view-diff.ts +18 -0
  37. package/src/diff/walk.ts +3 -0
  38. package/src/index.ts +1 -0
  39. package/src/integrations/attribution.ts +2 -0
  40. package/src/integrations/client-extension-registry.ts +4 -0
  41. package/src/integrations/clipboard.ts +1 -0
  42. package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
  43. package/src/integrations/components/ShareDialog.tsx +28 -0
  44. package/src/integrations/editor.ts +3 -0
  45. package/src/integrations/github-identity.test.ts +1 -0
  46. package/src/integrations/github-identity.ts +1 -0
  47. package/src/integrations/herdr.ts +2 -0
  48. package/src/integrations/pasted-image.ts +2 -0
  49. package/src/integrations/share.ts +46 -1
  50. package/src/keyboard/components/HandleChipsInput.tsx +4 -0
  51. package/src/keyboard/components/SlashComposer.tsx +3 -0
  52. package/src/keyboard/key-bindings.ts +3 -0
  53. package/src/keyboard/keymap.ts +51 -0
  54. package/src/keyboard/skills.ts +2 -0
  55. package/src/keyboard/slash-palette.ts +8 -0
  56. package/src/keyboard/thread-chords.ts +5 -0
  57. package/src/keyboard/use-shared-keyboard.ts +1 -0
  58. package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
  59. package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
  60. package/src/markdown/components/ThreadView.tsx +13 -0
  61. package/src/markdown/mark-runs.ts +4 -0
  62. package/src/markdown/markdown-editor-syntax-style.ts +1 -0
  63. package/src/markdown/markdown-grid.ts +6 -0
  64. package/src/markdown/markdown-highlight.ts +1 -0
  65. package/src/markdown/thread-code-syntax.ts +3 -0
  66. package/src/markdown/view-plan.ts +52 -0
  67. package/src/performance/perf-report.ts +1 -0
  68. package/src/performance/perf-timings.ts +1 -0
  69. package/src/prototype/components/PrototypeContentView.tsx +17 -0
  70. package/src/prototype/prototype-browser.ts +11 -0
  71. package/src/settings/components/SettingsDialog.tsx +3 -0
  72. package/src/settings/config.test.ts +79 -1
  73. package/src/settings/config.ts +91 -1
  74. package/src/settings/use-settings-dialog.test.tsx +172 -0
  75. package/src/settings/use-settings-dialog.tsx +115 -1
  76. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  77. package/src/settings/use-thread-harness-setting.ts +86 -0
  78. package/src/stories/stories.test.tsx +4 -0
  79. package/src/stories/story.ts +6 -0
  80. package/src/terminal/ansi-screen.ts +5 -0
  81. package/src/terminal/components/terminal-pane.ts +12 -0
  82. package/src/terminal/ghostty-terminal.ts +10 -0
  83. package/src/terminal/kitty-image.ts +3 -0
  84. package/src/terminal/pty.ts +9 -0
  85. package/src/testing/test-support.test.ts +2 -0
  86. package/src/testing/test-support.ts +16 -0
  87. package/src/thread/agent-continuation.test.ts +2 -0
  88. package/src/thread/agent-transcript.ts +7 -0
  89. package/src/thread/components/AgentConfigControls.test.tsx +41 -0
  90. package/src/thread/components/AgentConfigControls.tsx +8 -1
  91. package/src/thread/components/AgentThreadPane.test.tsx +91 -3
  92. package/src/thread/components/AgentThreadPane.tsx +61 -0
  93. package/src/thread/components/ThreadTree.tsx +1 -0
  94. package/src/thread/components/thread-tree.test.ts +1 -0
  95. package/src/thread/thread-controller.curate.test.ts +2 -0
  96. package/src/thread/thread-controller.share.test.ts +47 -2
  97. package/src/thread/thread-controller.ts +129 -4
  98. package/src/thread/tree-view.ts +4 -0
  99. package/src/thread/use-thread-agent.test.tsx +1 -0
  100. package/src/thread/use-thread-agent.ts +5 -0
  101. package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
  102. package/src/ui/components/OverlayScrollbar.tsx +5 -0
  103. package/src/ui/components/PromptDialog.tsx +2 -0
  104. package/src/ui/components/RootOverlay.tsx +1 -0
  105. package/src/ui/components/ScrollArea.tsx +2 -0
  106. package/src/ui/components/primitives/Button.tsx +1 -0
  107. package/src/ui/components/primitives/Tabs.tsx +1 -0
  108. package/src/ui/components/primitives/Tree.tsx +4 -0
  109. package/src/ui/components/primitives/tree-model.ts +7 -0
  110. package/src/ui/components/syntax-highlight.ts +5 -0
  111. package/src/ui/components/truncate-title.ts +1 -0
  112. package/src/ui/use-frame-measure.ts +3 -0
  113. package/src/ui/use-terminal-virtualizer.ts +6 -0
  114. package/src/workbench/components/ChangesColumn.tsx +7 -0
  115. package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
  116. package/src/workbench/components/EditorGrid.tsx +8 -0
  117. package/src/workbench/components/FileContentsView.tsx +2 -0
  118. package/src/workbench/components/GridTabContent.tsx +2 -0
  119. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  120. package/src/workbench/components/ProjectTreeView.tsx +8 -0
  121. package/src/workbench/components/TreePane.tsx +2 -0
  122. package/src/workbench/components/editor-grid.ts +3 -0
  123. package/src/workbench/components/file-tree.ts +2 -0
  124. package/src/workbench/use-changes-workbench.ts +5 -0
  125. package/src/workbench/use-remember-layout.ts +2 -0
  126. package/src/workbench/use-repo-changes.ts +2 -0
@@ -9,7 +9,13 @@ import { existsSync, readFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
12
- import { MESSAGE_OUTCOMES, type MessageOutcome } from "@cueloop/schema";
12
+ import {
13
+ AGENT_REASONING_EFFORTS,
14
+ THREAD_MODEL_DEFAULTS,
15
+ type ThreadModelDefaults,
16
+ MESSAGE_OUTCOMES,
17
+ type MessageOutcome,
18
+ } from "@cueloop/schema";
13
19
  import { writeFileSync, mkdirSync } from "node:fs";
14
20
  import { dirname } from "node:path";
15
21
  import * as v from "valibot";
@@ -104,6 +110,7 @@ export function resolveQuickAction(
104
110
  const index = Number(actionRef);
105
111
 
106
112
  if (Number.isInteger(index) && index >= 1 && index <= actions.length) return actions[index - 1];
113
+
107
114
  const wanted = actionRef.trim().toLowerCase();
108
115
 
109
116
  return actions.find((action) => action.prompt.toLowerCase() === wanted);
@@ -127,6 +134,7 @@ export interface CueloopConfig {
127
134
  actions: QuickAction[];
128
135
  skillsPath: string;
129
136
  review: ReviewConfig;
137
+ thread: { harness: "pi" | "fx"; pi: ThreadModelDefaults; fx: ThreadModelDefaults };
130
138
  integrations: IntegrationsConfig;
131
139
  experimental: ExperimentalConfig;
132
140
  }
@@ -178,6 +186,11 @@ function skillsPathFrom(
178
186
  return raw?.path?.trim() ? raw.path.trim() : fallback;
179
187
  }
180
188
 
189
+ const ThreadModelDefaultsSchema = v.object({
190
+ model: v.fallback(v.optional(v.pipe(v.string(), v.trim(), v.minLength(1))), undefined),
191
+ effort: v.fallback(v.optional(v.picklist(AGENT_REASONING_EFFORTS)), undefined),
192
+ });
193
+
181
194
  const ConfigDocumentSchema = v.object({
182
195
  actions: v.optional(v.array(v.unknown())),
183
196
  authors: v.optional(v.unknown()),
@@ -189,6 +202,16 @@ const ConfigDocumentSchema = v.object({
189
202
  experimental: v.optional(v.unknown()),
190
203
  skills: v.fallback(v.optional(SkillsSectionSchema), undefined),
191
204
  review: v.optional(v.unknown()),
205
+ thread: v.fallback(
206
+ v.optional(
207
+ v.object({
208
+ harness: v.fallback(v.optional(v.picklist(["pi", "fx"])), undefined),
209
+ pi: v.fallback(v.optional(ThreadModelDefaultsSchema), undefined),
210
+ fx: v.fallback(v.optional(ThreadModelDefaultsSchema), undefined),
211
+ }),
212
+ ),
213
+ undefined,
214
+ ),
192
215
  });
193
216
 
194
217
  const IdentitySchema = v.object({
@@ -258,12 +281,14 @@ function parseToml(text: string) {
258
281
 
259
282
  function parseActions(entries: readonly unknown[] | undefined): QuickAction[] | undefined {
260
283
  if (!entries) return undefined;
284
+
261
285
  const actions: QuickAction[] = [];
262
286
 
263
287
  for (const entry of entries) {
264
288
  const result = v.safeParse(QuickActionSchema, entry);
265
289
 
266
290
  if (!result.success) continue;
291
+
267
292
  const { prompt, metadata } = result.output;
268
293
 
269
294
  actions.push(metadata?.trim() ? { prompt, metadata } : { prompt });
@@ -277,21 +302,30 @@ function mergeObsidian(
277
302
  obsidian: v.InferOutput<typeof ObsidianSchema>,
278
303
  ): void {
279
304
  if (obsidian.vault !== undefined) target.vault = obsidian.vault;
305
+
280
306
  if (obsidian.folder !== undefined) target.folder = obsidian.folder;
307
+
281
308
  if (obsidian.filenameFormat !== undefined) target.filenameFormat = obsidian.filenameFormat;
309
+
282
310
  if (obsidian.separator !== undefined) target.separator = obsidian.separator;
311
+
283
312
  if (obsidian.exportOn !== undefined) target.exportOn = obsidian.exportOn;
284
313
  }
285
314
 
286
315
  /** Fold a parsed `[ui]` table onto the accumulated ui config, field by present field. */
287
316
  function applyUi(ui: CueloopConfig["ui"], parsed: v.InferOutput<typeof UiSchema>): void {
288
317
  if (parsed.auto_close !== undefined) ui.autoClose = parsed.auto_close;
318
+
289
319
  if (parsed.editor?.trim()) ui.editor = parsed.editor.trim();
320
+
290
321
  // "unified" is the pre-rename spelling of "stacked"; keep loading it so an upgrade never flips the layout
291
322
  if (parsed.diff_view !== undefined)
292
323
  ui.diffView = parsed.diff_view === "unified" ? "stacked" : parsed.diff_view;
324
+
293
325
  if (parsed.default_message !== undefined) ui.defaultMessage = parsed.default_message;
326
+
294
327
  if (parsed.pins !== undefined) ui.pins = parsed.pins;
328
+
295
329
  if (parsed.layout !== undefined) {
296
330
  ui.layout = {
297
331
  threads: parsed.layout.threads ?? true,
@@ -303,9 +337,25 @@ function applyUi(ui: CueloopConfig["ui"], parsed: v.InferOutput<typeof UiSchema>
303
337
 
304
338
  function applyReview(review: ReviewConfig, parsed: v.InferOutput<typeof ReviewSchema>): void {
305
339
  if (parsed.skill?.trim()) review.skill = parsed.skill.trim();
340
+
306
341
  if (parsed.workspace !== undefined) review.workspace = parsed.workspace;
307
342
  }
308
343
 
344
+ function applyThreadDefaults(
345
+ thread: CueloopConfig["thread"],
346
+ parsed: v.InferOutput<typeof ConfigDocumentSchema>["thread"],
347
+ ): void {
348
+ if (parsed?.harness) thread.harness = parsed.harness;
349
+
350
+ for (const harness of ["pi", "fx"] as const) {
351
+ const defaults = parsed?.[harness];
352
+
353
+ if (defaults?.model) thread[harness].model = defaults.model;
354
+
355
+ if (defaults?.effort) thread[harness].effort = defaults.effort;
356
+ }
357
+ }
358
+
309
359
  function layer(
310
360
  base: CueloopConfig,
311
361
  raw: v.InferOutput<typeof ConfigDocumentSchema>,
@@ -321,6 +371,7 @@ function layer(
321
371
  actions: [...base.actions],
322
372
  skillsPath: base.skillsPath,
323
373
  review: { ...base.review },
374
+ thread: { ...base.thread, pi: { ...base.thread.pi }, fx: { ...base.thread.fx } },
324
375
  integrations: { obsidian: { ...base.integrations.obsidian } },
325
376
  experimental: { ...base.experimental },
326
377
  };
@@ -334,7 +385,9 @@ function layer(
334
385
  const review = v.safeParse(ReviewSchema, raw.review);
335
386
 
336
387
  if (actions) out.actions = actions;
388
+
337
389
  out.skillsPath = skillsPathFrom(raw.skills, base.skillsPath);
390
+
338
391
  if (authors.success) {
339
392
  for (const [id, value] of Object.entries(authors.output)) {
340
393
  const name = v.safeParse(v.string(), value);
@@ -342,6 +395,7 @@ function layer(
342
395
  if (name.success) out.authors[id] = name.output;
343
396
  }
344
397
  }
398
+
345
399
  if (keys.success) {
346
400
  for (const [action, value] of Object.entries(keys.output)) {
347
401
  const combo = v.safeParse(KeyComboSchema, value);
@@ -350,18 +404,26 @@ function layer(
350
404
  out.keys[action] = Array.isArray(combo.output) ? combo.output : [combo.output];
351
405
  }
352
406
  }
407
+
353
408
  // The reviewer identity is a personal credential; a repo must never forge a verified name.
354
409
  if (allowIdentity && identity.success) {
355
410
  if (identity.output.name !== undefined) out.identity.name = identity.output.name;
411
+
356
412
  if (identity.output.provider !== undefined) out.identity.provider = identity.output.provider;
357
413
  }
414
+
358
415
  if (ui.success) applyUi(out.ui, ui.output);
416
+
359
417
  if (integrations.success && integrations.output.obsidian) {
360
418
  mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
361
419
  }
420
+
362
421
  if (experimental.success) applyExperimental(out.experimental, experimental.output);
422
+
363
423
  if (review.success) applyReview(out.review, review.output);
364
424
 
425
+ applyThreadDefaults(out.thread, raw.thread);
426
+
365
427
  return out;
366
428
  }
367
429
 
@@ -384,6 +446,11 @@ export function loadConfig(
384
446
  actions: [...DEFAULT_QUICK_ACTIONS],
385
447
  skillsPath: join(homedir(), ".agents", "skills"),
386
448
  review: { skill: "code-review", workspace: "worktree" },
449
+ thread: {
450
+ harness: "pi",
451
+ pi: { ...THREAD_MODEL_DEFAULTS.pi },
452
+ fx: { ...THREAD_MODEL_DEFAULTS.fx },
453
+ },
387
454
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
388
455
  experimental: { prototypePixels: false, threadAgent: false },
389
456
  };
@@ -400,6 +467,7 @@ export function loadConfig(
400
467
  options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined,
401
468
  ]) {
402
469
  if (!path || !existsSync(path)) continue;
470
+
403
471
  try {
404
472
  const raw = parseToml(readFileSync(path, "utf8"));
405
473
 
@@ -409,6 +477,7 @@ export function loadConfig(
409
477
  const parsedThemeOverrides = v.safeParse(ThemeOverridesSchema, raw.theme);
410
478
 
411
479
  if (rawTheme && isThemeName(rawTheme)) themeName = rawTheme;
480
+
412
481
  if (parsedThemeOverrides.success) {
413
482
  for (const [token, value] of Object.entries(parsedThemeOverrides.output)) {
414
483
  const override = v.safeParse(v.string(), value);
@@ -461,6 +530,7 @@ function persistUiSetting(key: string, rendered: string, userConfigPath?: string
461
530
  let text = "";
462
531
 
463
532
  if (existsSync(path)) text = readFileSync(path, "utf8");
533
+
464
534
  const assignment = new RegExp(`^(\\s*)${key}\\s*=.*$`, "m");
465
535
 
466
536
  if (assignment.test(text)) {
@@ -470,6 +540,7 @@ function persistUiSetting(key: string, rendered: string, userConfigPath?: string
470
540
  } else {
471
541
  text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\n${key} = ${rendered}\n`;
472
542
  }
543
+
473
544
  mkdirSync(dirname(path), { recursive: true });
474
545
  writeFileSync(path, text);
475
546
  }
@@ -500,6 +571,7 @@ function persistTableSetting(
500
571
  } else {
501
572
  text = `${text.trimEnd()}${text.trim() ? "\n\n" : ""}[${table}]\n${key} = ${rendered}\n`;
502
573
  }
574
+
503
575
  mkdirSync(dirname(path), { recursive: true });
504
576
  writeFileSync(path, text);
505
577
  }
@@ -548,6 +620,7 @@ export function persistIdentity(identity: IdentityConfig, userConfigPath?: strin
548
620
  } else {
549
621
  text = text.trimEnd() + (text.trim() ? "\n\n" : "") + block;
550
622
  }
623
+
551
624
  mkdirSync(dirname(path), { recursive: true });
552
625
  writeFileSync(path, text);
553
626
  }
@@ -614,6 +687,7 @@ export function persistAuthorName(id: string, name: string, userConfigPath?: str
614
687
  } else {
615
688
  text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[authors]\n${key} = ${value}\n`;
616
689
  }
690
+
617
691
  mkdirSync(dirname(path), { recursive: true });
618
692
  writeFileSync(path, text);
619
693
  }
@@ -624,5 +698,21 @@ function applyExperimental(
624
698
  raw: v.InferOutput<typeof ExperimentalSchema>,
625
699
  ): void {
626
700
  if (raw.prototype_pixels !== undefined) config.prototypePixels = raw.prototype_pixels;
701
+
627
702
  if (raw.thread_agent !== undefined) config.threadAgent = raw.thread_agent;
628
703
  }
704
+
705
+ /** The preferred harness affects new Threads; existing Threads require a handoff. */
706
+ export function persistThreadHarness(harness: "pi" | "fx", userConfigPath?: string): void {
707
+ persistTableSetting("thread", "harness", tomlString(harness), userConfigPath);
708
+ }
709
+
710
+ /** Persist one harness's new-Thread defaults without altering other TOML tables. */
711
+ export function persistThreadModelDefault(
712
+ harness: "pi" | "fx",
713
+ key: keyof ThreadModelDefaults,
714
+ value: string,
715
+ userConfigPath?: string,
716
+ ): void {
717
+ persistTableSetting(`thread.${harness}`, key, tomlString(value), userConfigPath);
718
+ }
@@ -0,0 +1,172 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { loadConfig } from "./config";
5
+ import { expect, test } from "bun:test";
6
+ import React, { useState } from "react";
7
+ import { useKeyboard } from "@opentui/react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { DARK } from "../appearance/theme";
10
+ import { settle, locateText, typeText } from "../testing/test-support";
11
+ import { SettingsDialog } from "./components/SettingsDialog";
12
+ import { useSettingsDialog } from "./use-settings-dialog";
13
+
14
+ function TestThreadSettings({
15
+ enabled,
16
+ onSwitch,
17
+ }: {
18
+ enabled: boolean;
19
+ onSwitch: (harness: string) => void;
20
+ }) {
21
+ const [harness, setHarness] = useState<"pi" | "fx">("pi");
22
+ const [threadDefaults, setThreadDefaults] = useState(() => {
23
+ const config = loadConfig();
24
+
25
+ return { pi: config.thread.pi, fx: config.thread.fx };
26
+ });
27
+ const model = useSettingsDialog({
28
+ threadDefaults,
29
+ setThreadDefaults,
30
+ theme: DARK,
31
+ appearance: "dark",
32
+ autoClose: "off",
33
+ setAutoClose() {},
34
+ diffView: "split",
35
+ setDiffView() {},
36
+ themeName: "cueloop",
37
+ setThemeName() {},
38
+ themeOverrides: {},
39
+ setTheme() {},
40
+ quickActions: [],
41
+ setQuickActions() {},
42
+ setMenuDialog() {},
43
+ identityProvider: "typed",
44
+ onSyncGithubIdentity() {},
45
+ onRenameDisplayName() {},
46
+ reviewSkill: "code-review",
47
+ reviewWorkspace: "worktree",
48
+ setReviewWorkspace() {},
49
+ canSwitchHarness: enabled,
50
+ threadHarness: harness,
51
+ onSwitchHarness(next) {
52
+ setHarness(next);
53
+ onSwitch(next);
54
+ },
55
+ });
56
+
57
+ useKeyboard((key) => model.handleSettingsKey(key.name));
58
+
59
+ return (
60
+ <SettingsDialog
61
+ isOpen
62
+ version="test"
63
+ categories={model.settingsCategories}
64
+ values={model.settingsValues}
65
+ keybindsSections={[]}
66
+ activeCategoryId={model.settingsNav.categoryId}
67
+ activeRowIndex={model.settingsNav.rowIndex}
68
+ activeZone={model.settingsNav.zone}
69
+ onCategorySelect={model.onCategorySelect}
70
+ onRowActivate={(row) => model.cycleSetting(row.key)}
71
+ onClose={() => {}}
72
+ />
73
+ );
74
+ }
75
+
76
+ test("Thread settings switches harness by mouse and disappears without owner capability", async () => {
77
+ const changes: string[] = [];
78
+ const setup = await testRender(
79
+ <TestThreadSettings enabled onSwitch={(next) => changes.push(next)} />,
80
+ { width: 90, height: 28 },
81
+ );
82
+
83
+ try {
84
+ await settle(setup);
85
+ const category = locateText(setup, "Thread");
86
+
87
+ await setup.mockMouse.click(category.column, category.row);
88
+ await settle(setup);
89
+ const row = locateText(setup, "Harness");
90
+
91
+ await setup.mockMouse.click(row.column, row.row);
92
+ await settle(setup);
93
+ expect(changes).toEqual(["fx"]);
94
+ expect(setup.captureCharFrame()).toContain("fx");
95
+ await setup.mockMouse.click(row.column, row.row);
96
+ await settle(setup);
97
+ expect(changes).toEqual(["fx", "pi"]);
98
+ } finally {
99
+ setup.renderer.destroy();
100
+ }
101
+ const disabled = await testRender(<TestThreadSettings enabled={false} onSwitch={() => {}} />, {
102
+ width: 90,
103
+ height: 28,
104
+ });
105
+
106
+ try {
107
+ await settle(disabled);
108
+ expect(disabled.captureCharFrame()).not.toContain("Thread");
109
+ } finally {
110
+ disabled.renderer.destroy();
111
+ }
112
+ });
113
+
114
+ test("Thread settings edits model defaults and effort and persists them to TOML", async () => {
115
+ const directory = mkdtempSync(join(tmpdir(), "cueloop-settings-model-"));
116
+ const previous = process.env.CUELOOP_CONFIG;
117
+ const path = join(directory, "config.toml");
118
+
119
+ process.env.CUELOOP_CONFIG = path;
120
+ const setup = await testRender(<TestThreadSettings enabled onSwitch={() => {}} />, {
121
+ width: 100,
122
+ height: 30,
123
+ });
124
+
125
+ try {
126
+ await settle(setup);
127
+ const category = locateText(setup, "Thread");
128
+
129
+ await setup.mockMouse.click(category.column, category.row);
130
+ await settle(setup);
131
+ expect(setup.captureCharFrame()).toContain("gpt-6.1-sol");
132
+ expect(setup.captureCharFrame()).toContain("grok-4.7");
133
+ const effort = locateText(setup, "pi default effort");
134
+
135
+ await setup.mockMouse.click(effort.column, effort.row);
136
+ await settle(setup);
137
+ expect(loadConfig({ userConfigPath: path }).thread.pi.effort).toBe("high");
138
+ const model = locateText(setup, "pi default model");
139
+
140
+ await setup.mockMouse.click(model.column, model.row);
141
+ await settle(setup);
142
+ await typeText(setup, "-custom");
143
+ await settle(setup);
144
+ const save = locateText(setup, "save");
145
+
146
+ await setup.mockMouse.click(save.column, save.row);
147
+ await settle(setup);
148
+ expect(loadConfig({ userConfigPath: path }).thread.pi.model).toBe(
149
+ "openai-codex/gpt-6.1-sol-custom",
150
+ );
151
+ expect(setup.captureCharFrame()).toContain("gpt-6.1-sol-custom");
152
+ const fxModel = locateText(setup, "fx default model");
153
+
154
+ await setup.mockMouse.click(fxModel.column, fxModel.row);
155
+ await settle(setup);
156
+ await typeText(setup, "-cancelled");
157
+ setup.mockInput.pressKey("ESC");
158
+ await settle(setup);
159
+ expect(setup.captureCharFrame()).not.toContain("Default model for new Threads");
160
+ expect(loadConfig({ userConfigPath: path }).thread.fx).toEqual({
161
+ model: "grok/grok-4.7",
162
+ effort: "medium",
163
+ });
164
+ } finally {
165
+ setup.renderer.destroy();
166
+
167
+ if (previous === undefined) delete process.env.CUELOOP_CONFIG;
168
+ else process.env.CUELOOP_CONFIG = previous;
169
+
170
+ rmSync(directory, { recursive: true, force: true });
171
+ }
172
+ });
@@ -1,6 +1,7 @@
1
1
  import React, { useState, type Dispatch, type SetStateAction } from "react";
2
2
  import {
3
3
  DEFAULT_QUICK_ACTIONS,
4
+ persistThreadModelDefault,
4
5
  persistActions,
5
6
  persistAutoClose,
6
7
  persistDiffView,
@@ -18,6 +19,12 @@ import {
18
19
  type Appearance,
19
20
  type ThemeName,
20
21
  } from "../appearance/theme-presets";
22
+ import {
23
+ AGENT_REASONING_EFFORTS,
24
+ type ThreadModelDefaults,
25
+ type AgentReasoningEffort,
26
+ } from "@cueloop/schema";
27
+ import { PromptDialog } from "../ui/components/PromptDialog";
21
28
  import type { Theme } from "../appearance/theme";
22
29
  import type { SettingsCategory } from "./components/SettingsDialog";
23
30
  import { QuickActionsEditor } from "./components/quick-actions-editor";
@@ -57,8 +64,10 @@ function moveNavZone(
57
64
  rowIndex: 0,
58
65
  zone: "nav",
59
66
  };
67
+
60
68
  if (UP_KEYS.has(name))
61
69
  return { categoryId: categories[Math.max(0, categoryIndex - 1)]!.id, rowIndex: 0, zone: "nav" };
70
+
62
71
  if (ENTER_BODY_KEYS.has(name))
63
72
  return { categoryId: categories[categoryIndex]!.id, rowIndex: 0, zone: "body" };
64
73
 
@@ -67,13 +76,17 @@ function moveNavZone(
67
76
 
68
77
  function moveBodyRow(name: string, nav: SettingsNav, rowCount: number): SettingsNav | null {
69
78
  if (DOWN_KEYS.has(name)) return { ...nav, rowIndex: Math.min(rowCount - 1, nav.rowIndex + 1) };
79
+
70
80
  if (UP_KEYS.has(name)) return { ...nav, rowIndex: Math.max(0, nav.rowIndex - 1) };
81
+
71
82
  if (BACK_TO_NAV_KEYS.has(name)) return { ...nav, zone: "nav" };
72
83
 
73
84
  return null;
74
85
  }
75
86
 
76
87
  export function useSettingsDialog(params: {
88
+ threadDefaults: Record<"pi" | "fx", ThreadModelDefaults>;
89
+ setThreadDefaults: Dispatch<SetStateAction<Record<"pi" | "fx", ThreadModelDefaults>>>;
77
90
  theme: Theme;
78
91
  appearance: Appearance;
79
92
  autoClose: AutoClose;
@@ -94,6 +107,9 @@ export function useSettingsDialog(params: {
94
107
  reviewSkill: string;
95
108
  reviewWorkspace: ReviewWorkspaceMode;
96
109
  setReviewWorkspace: Dispatch<SetStateAction<ReviewWorkspaceMode>>;
110
+ threadHarness?: "pi" | "fx";
111
+ canSwitchHarness?: boolean;
112
+ onSwitchHarness?: (harness: "pi" | "fx") => void;
97
113
  }): SettingsDialogModel {
98
114
  const {
99
115
  theme,
@@ -116,8 +132,27 @@ export function useSettingsDialog(params: {
116
132
  reviewSkill,
117
133
  reviewWorkspace,
118
134
  setReviewWorkspace,
135
+ threadHarness,
136
+ canSwitchHarness,
137
+ onSwitchHarness,
119
138
  } = params;
120
139
 
140
+ const { threadDefaults, setThreadDefaults } = params;
141
+ const [modelEditor, setModelEditor] = useState<{ harness: "pi" | "fx"; text: string } | null>(
142
+ null,
143
+ );
144
+
145
+ const saveModelDefault = (): void => {
146
+ if (!modelEditor?.text.trim()) return;
147
+
148
+ const { harness, text } = modelEditor;
149
+ const model = text.trim();
150
+
151
+ persistThreadModelDefault(harness, "model", model);
152
+ setThreadDefaults((defaults) => ({ ...defaults, [harness]: { ...defaults[harness], model } }));
153
+ setModelEditor(null);
154
+ };
155
+
121
156
  // open focused on the left nav, so up/down browses categories until l/tab/enter enters the body
122
157
  const [settingsNav, setSettingsNav] = useState<SettingsNav>({
123
158
  categoryId: "general",
@@ -134,6 +169,7 @@ export function useSettingsDialog(params: {
134
169
  const editActionPrompt = (index: number, prompt: string): void => {
135
170
  // a blank title fails the config schema and would vanish on reload, so never persist one
136
171
  if (prompt.trim().length === 0) return;
172
+
137
173
  commitActions(
138
174
  quickActions.map((action, actionIndex) =>
139
175
  actionIndex === index ? { ...action, prompt } : action,
@@ -214,6 +250,48 @@ export function useSettingsDialog(params: {
214
250
  },
215
251
  ],
216
252
  },
253
+ ...(canSwitchHarness
254
+ ? [
255
+ {
256
+ id: "thread",
257
+ name: "Thread",
258
+ rows: [
259
+ {
260
+ key: "threadHarness",
261
+ label: "Harness",
262
+ kind: "cycle" as const,
263
+ options: ["pi", "fx"],
264
+ },
265
+ { key: "piModel", label: "pi default model", kind: "text" as const },
266
+ {
267
+ key: "piEffort",
268
+ label: "pi default effort",
269
+ kind: "cycle" as const,
270
+ options: [...AGENT_REASONING_EFFORTS],
271
+ },
272
+ { key: "fxModel", label: "fx default model", kind: "text" as const },
273
+ {
274
+ key: "fxEffort",
275
+ label: "fx default effort",
276
+ kind: "cycle" as const,
277
+ options: [...AGENT_REASONING_EFFORTS],
278
+ },
279
+ ],
280
+ customBody: modelEditor ? (
281
+ <PromptDialog
282
+ isOpen
283
+ title=" Default model for new Threads "
284
+ label={`${modelEditor.harness} model (provider/model)`}
285
+ value={modelEditor.text}
286
+ onInput={(text) => setModelEditor({ ...modelEditor, text })}
287
+ onSave={saveModelDefault}
288
+ onCancel={() => setModelEditor(null)}
289
+ theme={theme}
290
+ />
291
+ ) : undefined,
292
+ },
293
+ ]
294
+ : []),
217
295
  {
218
296
  id: "actions",
219
297
  name: "Actions",
@@ -252,9 +330,31 @@ export function useSettingsDialog(params: {
252
330
  syncGithub: "enter to sync",
253
331
  reviewSkill,
254
332
  reviewWorkspace,
333
+ threadHarness: threadHarness ?? "pi",
334
+ piModel: threadDefaults.pi.model.replace(/^[^/]+\//, ""),
335
+ piEffort: threadDefaults.pi.effort,
336
+ fxModel: threadDefaults.fx.model.replace(/^[^/]+\//, ""),
337
+ fxEffort: threadDefaults.fx.effort,
255
338
  };
256
339
  const cycleSetting = (rowKey: string): void => {
257
- if (rowKey === "autoClose") {
340
+ if (rowKey === "piModel" || rowKey === "fxModel") {
341
+ const harness = rowKey === "piModel" ? "pi" : "fx";
342
+
343
+ setModelEditor({ harness, text: threadDefaults[harness].model });
344
+ } else if (rowKey === "piEffort" || rowKey === "fxEffort") {
345
+ const harness = rowKey === "piEffort" ? "pi" : "fx";
346
+ const efforts = AGENT_REASONING_EFFORTS;
347
+ const effort: AgentReasoningEffort =
348
+ efforts[(efforts.indexOf(threadDefaults[harness].effort) + 1) % efforts.length]!;
349
+
350
+ persistThreadModelDefault(harness, "effort", effort);
351
+ setThreadDefaults((defaults) => ({
352
+ ...defaults,
353
+ [harness]: { ...defaults[harness], effort },
354
+ }));
355
+ } else if (rowKey === "threadHarness") {
356
+ onSwitchHarness?.(threadHarness === "fx" ? "pi" : "fx");
357
+ } else if (rowKey === "autoClose") {
258
358
  const next: AutoClose = autoClose === "off" ? 3 : autoClose === 3 ? 10 : "off";
259
359
 
260
360
  setAutoClose(next);
@@ -291,18 +391,28 @@ export function useSettingsDialog(params: {
291
391
  const onCategorySelect = (categoryId: string): void => {
292
392
  // ignore a select that names no real category, so the key handler never dereferences nothing
293
393
  if (!settingsCategories.some((category) => category.id === categoryId)) return;
394
+
294
395
  setActionsExpandedIndex(null);
295
396
  setSettingsNav({ categoryId, rowIndex: 0, zone: "body" });
296
397
  };
297
398
 
298
399
  const handleSettingsKey = (name: string): void => {
400
+ if (modelEditor) {
401
+ if (name === "escape") setModelEditor(null);
402
+ else if (name === "return") saveModelDefault();
403
+
404
+ return;
405
+ }
406
+
299
407
  // an open system-prompt input owns typing; only esc (close it) escapes here
300
408
  if (actionsExpandedIndex !== null) {
301
409
  if (name === "escape") setActionsExpandedIndex(null);
302
410
 
303
411
  return;
304
412
  }
413
+
305
414
  if (name === "escape") return void setMenuDialog(null);
415
+
306
416
  const categoryIndex = settingsCategories.findIndex(
307
417
  (category) => category.id === settingsNav.categoryId,
308
418
  );
@@ -315,11 +425,13 @@ export function useSettingsDialog(params: {
315
425
 
316
426
  return;
317
427
  }
428
+
318
429
  // the Actions category is a list of quick actions plus a trailing "add" row
319
430
  if (category.id === "actions") {
320
431
  const moved = moveBodyRow(name, settingsNav, quickActions.length + 1);
321
432
 
322
433
  if (moved) return void setSettingsNav(moved);
434
+
323
435
  if (ACTIVATE_KEYS.has(name)) {
324
436
  if (settingsNav.rowIndex === quickActions.length) addAction();
325
437
  else {
@@ -330,9 +442,11 @@ export function useSettingsDialog(params: {
330
442
 
331
443
  return;
332
444
  }
445
+
333
446
  const moved = moveBodyRow(name, settingsNav, category.rows.length);
334
447
 
335
448
  if (moved) return void setSettingsNav(moved);
449
+
336
450
  if (name === "return" || name === "space")
337
451
  cycleSetting(category.rows[settingsNav.rowIndex]!.key);
338
452
  };