@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.
- package/package.json +5 -5
- package/src/annotations/annotation-palette.ts +1 -0
- package/src/annotations/components/AnnotationCards.tsx +4 -0
- package/src/annotations/components/DiscussionMarkerRail.tsx +3 -0
- package/src/annotations/discussions.ts +1 -0
- package/src/annotations/thread-selection.ts +6 -0
- package/src/annotations/use-annotation-surface.tsx +97 -0
- package/src/app/App.deleted-scroll.test.tsx +3 -0
- package/src/app/App.harness-settings.test.tsx +77 -0
- package/src/app/App.plan-render.test.tsx +2 -0
- package/src/app/App.plan-review.test.tsx +1 -0
- package/src/app/App.readonly.test.tsx +63 -0
- package/src/app/App.test.tsx +1 -0
- package/src/app/App.tsx +94 -12
- package/src/app/App.visual.test.tsx +1 -0
- package/src/app/App.walk.test.tsx +1 -0
- package/src/app/App.workbench.test.tsx +1 -0
- package/src/app/app-screens.tsx +3 -2
- package/src/app/app-view-model.ts +11 -1
- package/src/app/components/WorkspacePanels.tsx +1 -0
- package/src/app/intent-dispatch.test.ts +1 -0
- package/src/app/intent-dispatch.ts +26 -0
- package/src/app/ready-signal.ts +2 -0
- package/src/app/run.ts +2 -0
- package/src/app/serve.test.ts +4 -0
- package/src/diff/components/AnnotatableFileView.tsx +1 -0
- package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
- package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
- package/src/diff/components/DiffContentView.tsx +25 -0
- package/src/diff/components/diff-content-view-layout.ts +3 -0
- package/src/diff/diff-fold.ts +4 -0
- package/src/diff/diff-hunk-curate.ts +3 -0
- package/src/diff/diff-intraline.ts +6 -0
- package/src/diff/diff-syntax.ts +8 -0
- package/src/diff/split-diff.ts +6 -0
- package/src/diff/view-diff.ts +18 -0
- package/src/diff/walk.ts +3 -0
- package/src/index.ts +1 -0
- package/src/integrations/attribution.ts +2 -0
- package/src/integrations/client-extension-registry.ts +4 -0
- package/src/integrations/clipboard.ts +1 -0
- package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
- package/src/integrations/components/ShareDialog.tsx +28 -0
- package/src/integrations/editor.ts +3 -0
- package/src/integrations/github-identity.test.ts +1 -0
- package/src/integrations/github-identity.ts +1 -0
- package/src/integrations/herdr.ts +2 -0
- package/src/integrations/pasted-image.ts +2 -0
- package/src/integrations/share.ts +46 -1
- package/src/keyboard/components/HandleChipsInput.tsx +4 -0
- package/src/keyboard/components/SlashComposer.tsx +3 -0
- package/src/keyboard/key-bindings.ts +3 -0
- package/src/keyboard/keymap.ts +51 -0
- package/src/keyboard/skills.ts +2 -0
- package/src/keyboard/slash-palette.ts +8 -0
- package/src/keyboard/thread-chords.ts +5 -0
- package/src/keyboard/use-shared-keyboard.ts +1 -0
- package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
- package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
- package/src/markdown/components/ThreadView.tsx +13 -0
- package/src/markdown/mark-runs.ts +4 -0
- package/src/markdown/markdown-editor-syntax-style.ts +1 -0
- package/src/markdown/markdown-grid.ts +6 -0
- package/src/markdown/markdown-highlight.ts +1 -0
- package/src/markdown/thread-code-syntax.ts +3 -0
- package/src/markdown/view-plan.ts +52 -0
- package/src/performance/perf-report.ts +1 -0
- package/src/performance/perf-timings.ts +1 -0
- package/src/prototype/components/PrototypeContentView.tsx +17 -0
- package/src/prototype/prototype-browser.ts +11 -0
- package/src/settings/components/SettingsDialog.tsx +3 -0
- package/src/settings/config.test.ts +79 -1
- package/src/settings/config.ts +91 -1
- package/src/settings/use-settings-dialog.test.tsx +172 -0
- package/src/settings/use-settings-dialog.tsx +115 -1
- package/src/settings/use-thread-harness-setting.test.tsx +87 -0
- package/src/settings/use-thread-harness-setting.ts +86 -0
- package/src/stories/stories.test.tsx +4 -0
- package/src/stories/story.ts +6 -0
- package/src/terminal/ansi-screen.ts +5 -0
- package/src/terminal/components/terminal-pane.ts +12 -0
- package/src/terminal/ghostty-terminal.ts +10 -0
- package/src/terminal/kitty-image.ts +3 -0
- package/src/terminal/pty.ts +9 -0
- package/src/testing/test-support.test.ts +2 -0
- package/src/testing/test-support.ts +16 -0
- package/src/thread/agent-continuation.test.ts +2 -0
- package/src/thread/agent-transcript.ts +7 -0
- package/src/thread/components/AgentConfigControls.test.tsx +41 -0
- package/src/thread/components/AgentConfigControls.tsx +8 -1
- package/src/thread/components/AgentThreadPane.test.tsx +91 -3
- package/src/thread/components/AgentThreadPane.tsx +61 -0
- package/src/thread/components/ThreadTree.tsx +1 -0
- package/src/thread/components/thread-tree.test.ts +1 -0
- package/src/thread/thread-controller.curate.test.ts +2 -0
- package/src/thread/thread-controller.share.test.ts +47 -2
- package/src/thread/thread-controller.ts +129 -4
- package/src/thread/tree-view.ts +4 -0
- package/src/thread/use-thread-agent.test.tsx +1 -0
- package/src/thread/use-thread-agent.ts +5 -0
- package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
- package/src/ui/components/OverlayScrollbar.tsx +5 -0
- package/src/ui/components/PromptDialog.tsx +2 -0
- package/src/ui/components/RootOverlay.tsx +1 -0
- package/src/ui/components/ScrollArea.tsx +2 -0
- package/src/ui/components/primitives/Button.tsx +1 -0
- package/src/ui/components/primitives/Tabs.tsx +1 -0
- package/src/ui/components/primitives/Tree.tsx +4 -0
- package/src/ui/components/primitives/tree-model.ts +7 -0
- package/src/ui/components/syntax-highlight.ts +5 -0
- package/src/ui/components/truncate-title.ts +1 -0
- package/src/ui/use-frame-measure.ts +3 -0
- package/src/ui/use-terminal-virtualizer.ts +6 -0
- package/src/workbench/components/ChangesColumn.tsx +7 -0
- package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
- package/src/workbench/components/EditorGrid.tsx +8 -0
- package/src/workbench/components/FileContentsView.tsx +2 -0
- package/src/workbench/components/GridTabContent.tsx +2 -0
- package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
- package/src/workbench/components/ProjectTreeView.tsx +8 -0
- package/src/workbench/components/TreePane.tsx +2 -0
- package/src/workbench/components/editor-grid.ts +3 -0
- package/src/workbench/components/file-tree.ts +2 -0
- package/src/workbench/use-changes-workbench.ts +5 -0
- package/src/workbench/use-remember-layout.ts +2 -0
- package/src/workbench/use-repo-changes.ts +2 -0
package/src/settings/config.ts
CHANGED
|
@@ -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 {
|
|
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 === "
|
|
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
|
};
|