@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.
- 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.test.tsx +1 -0
- package/src/app/App.tsx +67 -1
- 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 +1 -0
- package/src/app/app-view-model.ts +7 -0
- 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/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 +4 -0
- 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 +41 -0
- package/src/settings/config.ts +44 -0
- package/src/settings/use-settings-dialog.test.tsx +98 -0
- package/src/settings/use-settings-dialog.tsx +38 -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.tsx +1 -0
- package/src/thread/components/AgentThreadPane.test.tsx +31 -0
- package/src/thread/components/AgentThreadPane.tsx +12 -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.ts +123 -0
- 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
|
@@ -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
|
+
});
|
package/src/settings/config.ts
CHANGED
|
@@ -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 === "
|
|
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
|
};
|