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

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.103",
3
+ "version": "0.1.0-alpha.105",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -27,15 +27,15 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.103",
31
- "@cueloop/extension-api": "0.1.0-alpha.103",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.103",
33
- "@cueloop/schema": "0.1.0-alpha.103",
34
- "@opentui/core": "^0.5.14",
35
- "@opentui/keymap": "0.5.14",
36
- "@opentui/react": "^0.5.14",
37
- "@opentui/ssh": "0.5.14",
38
- "@pierre/diffs": "^1.5.1",
30
+ "@cueloop/daemon": "0.1.0-alpha.105",
31
+ "@cueloop/extension-api": "0.1.0-alpha.105",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.105",
33
+ "@cueloop/schema": "0.1.0-alpha.105",
34
+ "@opentui/core": "^0.5.17",
35
+ "@opentui/keymap": "0.5.17",
36
+ "@opentui/react": "^0.5.17",
37
+ "@opentui/ssh": "0.5.17",
38
+ "@pierre/diffs": "^1.5.2",
39
39
  "@tanstack/virtual-core": "^3.17.11",
40
40
  "diff": "^9",
41
41
  "puppeteer-core": "^25.12.0",
@@ -5,6 +5,8 @@ import { mkdtempSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
+ import { DaemonClient } from "@cueloop/daemon/client";
9
+ import { NERD } from "../ui/components/primitives/icons";
8
10
  import { DaemonServer } from "@cueloop/daemon";
9
11
  import { makeAnchor, parseBlocks, type Thread } from "@cueloop/schema";
10
12
  import { App } from "./App";
@@ -208,3 +210,64 @@ describe("observer navigation still works", () => {
208
210
  expect(snapshot().annotations).toBe(1);
209
211
  });
210
212
  });
213
+
214
+ for (const role of ["collaborator", "observer"] as const) {
215
+ test(`${role} shared view shows a conversation snapshot and has no Thread actions menu`, async () => {
216
+ const client = await DaemonClient.connect({ home });
217
+ const shared = {
218
+ ...session,
219
+ artifact: { ...session.artifact, content: "" },
220
+ agentSnapshot: {
221
+ threadId: session.id,
222
+ phase: { kind: "idle" as const },
223
+ tools: [],
224
+ comments: [],
225
+ messages: [
226
+ {
227
+ id: "prompt",
228
+ role: "user" as const,
229
+ text: "Shared question",
230
+ complete: true,
231
+ revision: 1,
232
+ },
233
+ {
234
+ id: "answer",
235
+ role: "agent" as const,
236
+ text: "Shared conversation answer",
237
+ complete: true,
238
+ revision: 1,
239
+ },
240
+ ],
241
+ },
242
+ };
243
+
244
+ client.sessionGet = async () => shared;
245
+ client.sessionList = async () => [shared];
246
+ const setup = await renderReadyApp(
247
+ <App
248
+ home={home}
249
+ sessionId={shared.id}
250
+ role={role}
251
+ openClient={() => Promise.resolve(client)}
252
+ />,
253
+ { width: 120, height: 32 },
254
+ );
255
+
256
+ try {
257
+ await waitForText(setup, "Shared conversation answer");
258
+ expect(setup.captureCharFrame()).toContain("Shared question");
259
+ const row = setup
260
+ .captureCharFrame()
261
+ .split("\n")
262
+ .findIndex((line) => line.slice(0, 30).includes("Migration Plan"));
263
+
264
+ expect(row).toBeGreaterThanOrEqual(0);
265
+ await setup.mockMouse.moveTo(12, row);
266
+ expect(setup.captureCharFrame()).not.toContain(NERD.kebab);
267
+ expect(setup.captureCharFrame()).not.toContain(" edit ");
268
+ } finally {
269
+ setup.renderer.destroy();
270
+ client.close();
271
+ }
272
+ });
273
+ }
package/src/app/App.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { THREAD_MODEL_DEFAULTS } from "@cueloop/schema";
1
2
  import { useThreadHarnessSetting } from "../settings/use-thread-harness-setting";
2
3
  import React, {
3
4
  useCallback,
@@ -559,7 +560,11 @@ export function App({
559
560
  servedArtifact,
560
561
  extensionRegistry,
561
562
  }: AppProps): React.ReactNode {
562
- const { observer, isOwner } = computeRoleCapabilities(readOnly, role);
563
+ const { observer, isOwner, canManageThreads } = computeRoleCapabilities(
564
+ readOnly,
565
+ role,
566
+ isSharedThreadConnection(openClient, shareTransport),
567
+ );
563
568
  const controller = useMemo(
564
569
  () =>
565
570
  createReviewController({
@@ -686,6 +691,7 @@ export function App({
686
691
  const [identity, setIdentity] = useState<IdentityConfig>({ provider: "typed" });
687
692
  const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
688
693
  const [skills, setSkills] = useState<SlashItem[]>([]);
694
+ const [threadModelDefaults, setThreadModelDefaults] = useState(THREAD_MODEL_DEFAULTS);
689
695
  const [reviewSkill, setReviewSkill] = useState("code-review");
690
696
  const [reviewWorkspace, setReviewWorkspace] = useState<ReviewWorkspaceMode>("worktree");
691
697
  const paletteNames = useMemo(
@@ -702,6 +708,7 @@ export function App({
702
708
  setPrototypePixels(config.experimental.prototypePixels);
703
709
  setThreadAgentEnabled(config.experimental.threadAgent);
704
710
  setThreadHarness(config.thread.harness);
711
+ setThreadModelDefaults({ pi: config.thread.pi, fx: config.thread.fx });
705
712
  keysRef.current = config.keys;
706
713
  keyBindings.setKeys(config.keys);
707
714
  setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
@@ -798,6 +805,8 @@ export function App({
798
805
  onCategorySelect,
799
806
  openSettings,
800
807
  } = useSettingsDialog({
808
+ threadDefaults: threadModelDefaults,
809
+ setThreadDefaults: setThreadModelDefaults,
801
810
  theme,
802
811
  appearance,
803
812
  autoClose,
@@ -1221,8 +1230,10 @@ export function App({
1221
1230
  onFocusPane={setFocusedPane}
1222
1231
  menuControl={menuControl}
1223
1232
  pinnedIds={pinnedIds}
1224
- onPin={togglePin}
1225
- onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
1233
+ onPin={enabledThreadAction(canManageThreads, togglePin)}
1234
+ onRename={enabledThreadAction(canManageThreads, (id: string, title: string) =>
1235
+ setMode({ type: "renameThread", sessionId: id, text: title }),
1236
+ )}
1226
1237
  quickActions={quickActions}
1227
1238
  diffView={diffView}
1228
1239
  onWelcomeComposingChange={setWelcomeComposing}
@@ -1351,13 +1362,15 @@ export function App({
1351
1362
  width={30}
1352
1363
  onSelect={openThread}
1353
1364
  onCreateThread={createEmptyThread}
1354
- onPin={togglePin}
1355
- onRename={(id, title) =>
1356
- setMode({ type: "renameThread", sessionId: id, text: title })
1357
- }
1358
- onRequestDelete={(id, title) =>
1359
- setMode({ type: "confirmDelete", sessionId: id, title })
1360
- }
1365
+ onPin={enabledThreadAction(canManageThreads, togglePin)}
1366
+ onRename={enabledThreadAction(canManageThreads, (id: string, title: string) =>
1367
+ setMode({ type: "renameThread", sessionId: id, text: title }),
1368
+ )}
1369
+ onRequestDelete={enabledThreadAction(
1370
+ canManageThreads,
1371
+ (id: string, title: string) =>
1372
+ setMode({ type: "confirmDelete", sessionId: id, title }),
1373
+ )}
1361
1374
  theme={theme}
1362
1375
  />
1363
1376
  </scrollbox>
@@ -1684,7 +1697,10 @@ export function App({
1684
1697
  );
1685
1698
  }
1686
1699
 
1687
- function enabledThreadAction(enabled: boolean, action: () => void): (() => void) | undefined {
1700
+ function enabledThreadAction<Arguments extends unknown[]>(
1701
+ enabled: boolean,
1702
+ action: (...args: Arguments) => void,
1703
+ ): ((...args: Arguments) => void) | undefined {
1688
1704
  return enabled ? action : undefined;
1689
1705
  }
1690
1706
 
@@ -175,8 +175,8 @@ export function NoThreadShell(props: {
175
175
  /** App owns the one open-menu id, so the bare shell's menus share App's modal keyboard handling. */
176
176
  menuControl: MenuControlApi;
177
177
  pinnedIds: ReadonlySet<string>;
178
- onPin: (id: string) => void;
179
- onRename: (id: string, title: string) => void;
178
+ onPin?: (id: string) => void;
179
+ onRename?: (id: string, title: string) => void;
180
180
  /** Drives the "/" quick actions and skills in the welcome playground's composer. */
181
181
  quickActions: QuickAction[];
182
182
  diffView: DiffViewMode;
@@ -12,10 +12,13 @@ import type { BreadcrumbItem } from "./components/Breadcrumb";
12
12
  export function computeRoleCapabilities(
13
13
  readOnly: boolean,
14
14
  role: "owner" | "observer" | "collaborator",
15
+ shared = false,
15
16
  ) {
16
17
  const observer = readOnly || role === "observer";
17
18
 
18
- return { observer, isOwner: !observer && role === "owner" };
19
+ const isOwner = !observer && role === "owner";
20
+
21
+ return { observer, isOwner, canManageThreads: isOwner && !shared };
19
22
  }
20
23
 
21
24
  export function deriveReviewFlags(session: Thread | null) {
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  collaboratorAnnotations,
24
24
  mergeFromShare,
25
25
  publishShare,
26
+ snapshotThreadConversation,
26
27
  pullShare,
27
28
  pushShare,
28
29
  shareIdFromLine,
@@ -19,9 +19,10 @@ import {
19
19
  type SharePolicy,
20
20
  type ShareLink,
21
21
  type Thread,
22
+ type ThreadAgentState,
22
23
  } from "@cueloop/schema";
23
24
  import { ThreadRecordSchema } from "@cueloop/daemon/validate";
24
- import type { SharedMerge } from "@cueloop/daemon/client";
25
+ import type { ThreadClient, SharedMerge } from "@cueloop/daemon/client";
25
26
  import * as v from "valibot";
26
27
  import { copyToClipboard } from "./clipboard";
27
28
 
@@ -240,3 +241,43 @@ async function runShareSsh(
240
241
 
241
242
  return { stdout, stderr, code };
242
243
  }
244
+
245
+ /** Freeze the visible conversation without owner session IDs, execution data or delivery receipts. */
246
+ export async function snapshotThreadConversation(
247
+ thread: Thread,
248
+ client: Pick<ThreadClient, "agentGet">,
249
+ ): Promise<Thread> {
250
+ if (!client.agentGet) return thread;
251
+
252
+ let state: ThreadAgentState;
253
+
254
+ try {
255
+ state = await client.agentGet(thread.id);
256
+ } catch (error) {
257
+ if (error instanceof Error && error.message.startsWith("Thread agent is disabled;"))
258
+ return thread;
259
+
260
+ throw error;
261
+ }
262
+
263
+ if (!state.messages.length && !state.submissions?.length) return thread;
264
+
265
+ return {
266
+ ...thread,
267
+ agentSnapshot: {
268
+ threadId: thread.id,
269
+ harness: state.harness ? { id: state.harness.id, label: state.harness.label } : undefined,
270
+ phase: { kind: "idle" },
271
+ messages: state.messages,
272
+ comments: state.comments,
273
+ tools: [],
274
+ submissions: state.submissions?.map(({ id, commentId, prompt, quote, status }) => ({
275
+ id,
276
+ commentId,
277
+ prompt,
278
+ quote,
279
+ status,
280
+ })),
281
+ },
282
+ };
283
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
@@ -12,6 +12,7 @@ import {
12
12
  persistLayout,
13
13
  persistPins,
14
14
  persistReviewSkill,
15
+ persistThreadModelDefault,
15
16
  persistReviewWorkspace,
16
17
  persistTheme,
17
18
  quickActionBody,
@@ -715,3 +716,39 @@ test("a malformed Thread section does not discard other preferences", () => {
715
716
  rmSync(dir, { recursive: true, force: true });
716
717
  }
717
718
  });
719
+
720
+ test("Thread model defaults layer independently and persistence stays in the selected table", () => {
721
+ const directory = mkdtempSync(join(tmpdir(), "cueloop-model-defaults-"));
722
+ const user = join(directory, "user.toml");
723
+ const repository = join(directory, "repo");
724
+
725
+ try {
726
+ const defaults = loadConfig({ userConfigPath: user }).thread;
727
+
728
+ expect(defaults.pi).toEqual({ model: "openai-codex/gpt-6.1-sol", effort: "medium" });
729
+ expect(defaults.fx).toEqual({ model: "grok/grok-4.7", effort: "medium" });
730
+ writeFileSync(
731
+ user,
732
+ '[thread.pi]\nmodel = "openai-codex/custom"\neffort = "high"\n\n[thread.fx]\nmodel = "grok/custom"\neffort = "low"\n',
733
+ );
734
+ persistThreadModelDefault("pi", "effort", "medium", user);
735
+ persistThreadModelDefault("fx", "model", "grok/grok-4.7", user);
736
+
737
+ const config = loadConfig({ userConfigPath: user });
738
+
739
+ expect(config.thread.pi).toEqual({ model: "openai-codex/custom", effort: "medium" });
740
+ expect(config.thread.fx).toEqual({ model: "grok/grok-4.7", effort: "low" });
741
+ mkdirSync(join(repository, ".cueloop"), { recursive: true });
742
+ writeFileSync(
743
+ join(repository, ".cueloop/config.toml"),
744
+ '[thread.pi]\neffort = "high"\n[thread.fx]\nmodel = " "\neffort = "invalid"\n',
745
+ );
746
+
747
+ const layered = loadConfig({ userConfigPath: user, repoRoot: repository });
748
+
749
+ expect(layered.thread.pi).toEqual({ model: "openai-codex/custom", effort: "high" });
750
+ expect(layered.thread.fx).toEqual(config.thread.fx);
751
+ } finally {
752
+ rmSync(directory, { recursive: true, force: true });
753
+ }
754
+ });
@@ -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";
@@ -128,7 +134,7 @@ export interface CueloopConfig {
128
134
  actions: QuickAction[];
129
135
  skillsPath: string;
130
136
  review: ReviewConfig;
131
- thread: { harness: "pi" | "fx" };
137
+ thread: { harness: "pi" | "fx"; pi: ThreadModelDefaults; fx: ThreadModelDefaults };
132
138
  integrations: IntegrationsConfig;
133
139
  experimental: ExperimentalConfig;
134
140
  }
@@ -180,6 +186,11 @@ function skillsPathFrom(
180
186
  return raw?.path?.trim() ? raw.path.trim() : fallback;
181
187
  }
182
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
+
183
194
  const ConfigDocumentSchema = v.object({
184
195
  actions: v.optional(v.array(v.unknown())),
185
196
  authors: v.optional(v.unknown()),
@@ -192,7 +203,13 @@ const ConfigDocumentSchema = v.object({
192
203
  skills: v.fallback(v.optional(SkillsSectionSchema), undefined),
193
204
  review: v.optional(v.unknown()),
194
205
  thread: v.fallback(
195
- v.optional(v.object({ harness: v.fallback(v.optional(v.picklist(["pi", "fx"])), undefined) })),
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
+ ),
196
213
  undefined,
197
214
  ),
198
215
  });
@@ -324,6 +341,21 @@ function applyReview(review: ReviewConfig, parsed: v.InferOutput<typeof ReviewSc
324
341
  if (parsed.workspace !== undefined) review.workspace = parsed.workspace;
325
342
  }
326
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
+
327
359
  function layer(
328
360
  base: CueloopConfig,
329
361
  raw: v.InferOutput<typeof ConfigDocumentSchema>,
@@ -339,7 +371,7 @@ function layer(
339
371
  actions: [...base.actions],
340
372
  skillsPath: base.skillsPath,
341
373
  review: { ...base.review },
342
- thread: { ...base.thread },
374
+ thread: { ...base.thread, pi: { ...base.thread.pi }, fx: { ...base.thread.fx } },
343
375
  integrations: { obsidian: { ...base.integrations.obsidian } },
344
376
  experimental: { ...base.experimental },
345
377
  };
@@ -390,7 +422,7 @@ function layer(
390
422
 
391
423
  if (review.success) applyReview(out.review, review.output);
392
424
 
393
- if (raw.thread?.harness) out.thread.harness = raw.thread.harness;
425
+ applyThreadDefaults(out.thread, raw.thread);
394
426
 
395
427
  return out;
396
428
  }
@@ -414,7 +446,11 @@ export function loadConfig(
414
446
  actions: [...DEFAULT_QUICK_ACTIONS],
415
447
  skillsPath: join(homedir(), ".agents", "skills"),
416
448
  review: { skill: "code-review", workspace: "worktree" },
417
- thread: { harness: "pi" },
449
+ thread: {
450
+ harness: "pi",
451
+ pi: { ...THREAD_MODEL_DEFAULTS.pi },
452
+ fx: { ...THREAD_MODEL_DEFAULTS.fx },
453
+ },
418
454
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
419
455
  experimental: { prototypePixels: false, threadAgent: false },
420
456
  };
@@ -670,3 +706,13 @@ function applyExperimental(
670
706
  export function persistThreadHarness(harness: "pi" | "fx", userConfigPath?: string): void {
671
707
  persistTableSetting("thread", "harness", tomlString(harness), userConfigPath);
672
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
+ }
@@ -1,8 +1,13 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { loadConfig } from "./config";
1
5
  import { expect, test } from "bun:test";
2
6
  import React, { useState } from "react";
7
+ import { useKeyboard } from "@opentui/react";
3
8
  import { testRender } from "@opentui/react/test-utils";
4
9
  import { DARK } from "../appearance/theme";
5
- import { settle, locateText } from "../testing/test-support";
10
+ import { settle, locateText, typeText } from "../testing/test-support";
6
11
  import { SettingsDialog } from "./components/SettingsDialog";
7
12
  import { useSettingsDialog } from "./use-settings-dialog";
8
13
 
@@ -14,7 +19,14 @@ function TestThreadSettings({
14
19
  onSwitch: (harness: string) => void;
15
20
  }) {
16
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
+ });
17
27
  const model = useSettingsDialog({
28
+ threadDefaults,
29
+ setThreadDefaults,
18
30
  theme: DARK,
19
31
  appearance: "dark",
20
32
  autoClose: "off",
@@ -42,6 +54,8 @@ function TestThreadSettings({
42
54
  },
43
55
  });
44
56
 
57
+ useKeyboard((key) => model.handleSettingsKey(key.name));
58
+
45
59
  return (
46
60
  <SettingsDialog
47
61
  isOpen
@@ -96,3 +110,63 @@ test("Thread settings switches harness by mouse and disappears without owner cap
96
110
  disabled.renderer.destroy();
97
111
  }
98
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";
@@ -78,6 +85,8 @@ function moveBodyRow(name: string, nav: SettingsNav, rowCount: number): Settings
78
85
  }
79
86
 
80
87
  export function useSettingsDialog(params: {
88
+ threadDefaults: Record<"pi" | "fx", ThreadModelDefaults>;
89
+ setThreadDefaults: Dispatch<SetStateAction<Record<"pi" | "fx", ThreadModelDefaults>>>;
81
90
  theme: Theme;
82
91
  appearance: Appearance;
83
92
  autoClose: AutoClose;
@@ -128,6 +137,22 @@ export function useSettingsDialog(params: {
128
137
  onSwitchHarness,
129
138
  } = params;
130
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
+
131
156
  // open focused on the left nav, so up/down browses categories until l/tab/enter enters the body
132
157
  const [settingsNav, setSettingsNav] = useState<SettingsNav>({
133
158
  categoryId: "general",
@@ -237,7 +262,33 @@ export function useSettingsDialog(params: {
237
262
  kind: "cycle" as const,
238
263
  options: ["pi", "fx"],
239
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
+ },
240
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,
241
292
  },
242
293
  ]
243
294
  : []),
@@ -280,9 +331,28 @@ export function useSettingsDialog(params: {
280
331
  reviewSkill,
281
332
  reviewWorkspace,
282
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,
283
338
  };
284
339
  const cycleSetting = (rowKey: string): void => {
285
- if (rowKey === "threadHarness") {
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") {
286
356
  onSwitchHarness?.(threadHarness === "fx" ? "pi" : "fx");
287
357
  } else if (rowKey === "autoClose") {
288
358
  const next: AutoClose = autoClose === "off" ? 3 : autoClose === 3 ? 10 : "off";
@@ -327,6 +397,13 @@ export function useSettingsDialog(params: {
327
397
  };
328
398
 
329
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
+
330
407
  // an open system-prompt input owns typing; only esc (close it) escapes here
331
408
  if (actionsExpandedIndex !== null) {
332
409
  if (name === "escape") setActionsExpandedIndex(null);
@@ -110,3 +110,44 @@ test("model and reasoning choices share the existing overlay and close after sel
110
110
  setup.renderer.destroy();
111
111
  }
112
112
  });
113
+
114
+ for (const harness of ["pi", "fx"]) {
115
+ test(`${harness} footer identifies its harness, subscription, model and effort`, async () => {
116
+ const setup = await testRender(
117
+ <AgentConfigControls
118
+ theme={DARK}
119
+ onConfigure={() => {}}
120
+ state={{
121
+ ...initial,
122
+ harness: { id: harness, label: harness },
123
+ configOptions: [
124
+ {
125
+ id: "provider",
126
+ name: "Provider",
127
+ currentValue: harness,
128
+ options: [
129
+ {
130
+ value: harness,
131
+ name: harness === "pi" ? "Codex subscription" : "Grok subscription",
132
+ },
133
+ ],
134
+ },
135
+ ...initial.configOptions!,
136
+ ],
137
+ }}
138
+ />,
139
+ { width: 100, height: 3 },
140
+ );
141
+
142
+ try {
143
+ await waitForText(setup, "First model");
144
+ const frame = setup.captureCharFrame();
145
+
146
+ expect(frame).toContain(harness);
147
+ expect(frame).toContain(harness === "pi" ? "Codex subscription" : "Grok subscription");
148
+ expect(frame).toContain("Medium");
149
+ } finally {
150
+ setup.renderer.destroy();
151
+ }
152
+ });
153
+ }
@@ -18,10 +18,12 @@ export function AgentConfigControls({
18
18
  }): React.ReactNode {
19
19
  return (
20
20
  <box style={{ flexDirection: "row", gap: 2, marginRight: 2 }}>
21
+ {state.harness ? <text fg={theme.textMuted}>{state.harness.label}</text> : null}
21
22
  {state.configOptions
22
23
  ?.filter(
23
24
  (option) =>
24
25
  option.category === "thought_level" ||
26
+ option.id === "effort" ||
25
27
  option.id === "model" ||
26
28
  option.id === "provider",
27
29
  )
@@ -104,7 +106,11 @@ function AgentConfigMenu({
104
106
  return (
105
107
  <box
106
108
  ref={ref}
107
- onMouseUp={disabled ? undefined : () => menu.toggleMenu(`agent-config:${option.id}`)}
109
+ onMouseUp={
110
+ disabled || option.options.length < 2
111
+ ? undefined
112
+ : () => menu.toggleMenu(`agent-config:${option.id}`)
113
+ }
108
114
  >
109
115
  <text fg={disabled ? theme.textDim : theme.textMuted}>
110
116
  {option.options.find((choice) => choice.value === option.currentValue)?.name ??
@@ -9,11 +9,12 @@ import {
9
9
  } from "@cueloop/schema";
10
10
  import { AgentThreadPane, AgentThreadPrototype } from "./AgentThreadPane";
11
11
  import { ThreadFooter } from "./ThreadFooter";
12
- import { ThreadView } from "../../markdown/components/ThreadView";
12
+ import { ThreadView, type ThreadViewProps } from "../../markdown/components/ThreadView";
13
13
  import { buildDisplay, marksByDisplay } from "../../markdown/view-plan";
14
14
  import { DARK } from "../../appearance/theme";
15
15
  import {
16
16
  locateText,
17
+ dragText,
17
18
  waitForText,
18
19
  pressKey,
19
20
  typeText,
@@ -118,6 +119,8 @@ function artifactView(
118
119
  value: Thread,
119
120
  onReply: (id: string, body: string) => void = noop,
120
121
  onUpdateAnnotation: (id: string, body: string) => void = noop,
122
+ onAnnotate: ThreadViewProps["onAnnotate"] = noop,
123
+ observer = false,
121
124
  ) {
122
125
  const display = buildDisplay(value.artifact.content);
123
126
 
@@ -127,8 +130,8 @@ function artifactView(
127
130
  display={display}
128
131
  marks={marksByDisplay(value.annotations, display)}
129
132
  quickActions={[]}
130
- observer={false}
131
- onAnnotate={noop}
133
+ observer={observer}
134
+ onAnnotate={onAnnotate}
132
135
  onReply={onReply}
133
136
  onUpdateAnnotation={onUpdateAnnotation}
134
137
  onExit={noop}
@@ -1189,3 +1192,57 @@ test("shared viewers see pending activity without owner permission controls", as
1189
1192
  setup.renderer.destroy();
1190
1193
  }
1191
1194
  });
1195
+
1196
+ for (const observer of [false, true]) {
1197
+ test(`shared snapshot preserves artifact annotation permissions (observer=${observer})`, async () => {
1198
+ const value = {
1199
+ ...thread,
1200
+ agentSnapshot: {
1201
+ ...empty,
1202
+ messages: [
1203
+ {
1204
+ id: "answer",
1205
+ role: "agent" as const,
1206
+ text: "Frozen conversation",
1207
+ complete: true,
1208
+ revision: 1,
1209
+ },
1210
+ ],
1211
+ },
1212
+ };
1213
+ const annotations: string[] = [];
1214
+ const setup = await testRender(
1215
+ <AgentThreadPrototype
1216
+ enabled={false}
1217
+ observer={observer}
1218
+ pixelPrototype={false}
1219
+ thread={value}
1220
+ focused
1221
+ theme={DARK}
1222
+ onActiveChange={noop}
1223
+ onOpenFile={noop}
1224
+ >
1225
+ {artifactView(
1226
+ value,
1227
+ noop,
1228
+ noop,
1229
+ (_span, body) => {
1230
+ annotations.push(body);
1231
+ },
1232
+ observer,
1233
+ )}
1234
+ </AgentThreadPrototype>,
1235
+ { width: 100, height: 24, kittyKeyboard: true },
1236
+ );
1237
+
1238
+ try {
1239
+ await waitForText(setup, "Frozen conversation");
1240
+ await dragText(setup, "Original artifact", "Original artifact", "Original artifact".length);
1241
+ await typeText(setup, "Artifact feedback");
1242
+ await pressKey(setup, "RETURN", { meta: true });
1243
+ expect(annotations).toEqual(observer ? [] : ["Artifact feedback"]);
1244
+ } finally {
1245
+ setup.renderer.destroy();
1246
+ }
1247
+ });
1248
+ }
@@ -398,6 +398,55 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
398
398
  export function AgentThreadPrototype(
399
399
  props: AgentThreadPaneProps & { enabled: boolean; observer: boolean; pixelPrototype: boolean },
400
400
  ): React.ReactNode {
401
+ if (
402
+ (!props.enabled || props.observer) &&
403
+ props.thread.agentSnapshot &&
404
+ React.isValidElement<ThreadViewProps>(props.children) &&
405
+ props.children.type === ThreadView
406
+ ) {
407
+ const child = props.children.props;
408
+ const display =
409
+ child.display ??
410
+ buildDisplay(
411
+ props.thread.artifact.type === "diff"
412
+ ? (props.thread.artifact.meta.prBrief ?? "")
413
+ : (props.thread.workingCopy ?? props.thread.artifact.content),
414
+ );
415
+ const projection = projectThreadConversation(
416
+ props.thread,
417
+ props.thread.agentSnapshot,
418
+ display,
419
+ child.marks ?? marksByDisplay(props.thread.annotations, display),
420
+ false,
421
+ );
422
+
423
+ return (
424
+ <ThreadView
425
+ {...child}
426
+ session={projection.session}
427
+ display={projection.display}
428
+ marks={projection.marks}
429
+ canAnnotateBlock={(index) =>
430
+ projection.sources[index]?.kind === "artifact" &&
431
+ (child.canAnnotateBlock?.(index) ?? true)
432
+ }
433
+ isAnnotationReadOnly={(id) =>
434
+ !props.thread.annotations.some((entry) => entry.id === id) ||
435
+ Boolean(child.isAnnotationReadOnly?.(id))
436
+ }
437
+ onAnnotate={(span, body) => {
438
+ if (
439
+ projection.sources[span.start.blockIndex]?.kind !== "artifact" ||
440
+ projection.sources[span.end.blockIndex]?.kind !== "artifact"
441
+ )
442
+ return;
443
+
444
+ return child.onAnnotate(span, body);
445
+ }}
446
+ />
447
+ );
448
+ }
449
+
401
450
  if (!props.enabled || props.observer || props.pixelPrototype) return props.children;
402
451
 
403
452
  return <AgentThreadPane {...props} />;
@@ -8,9 +8,16 @@ import {
8
8
  SHARE_RECONNECT_MIN_MS,
9
9
  type ShareTransport,
10
10
  } from "./thread-controller";
11
- import { mergeFromShare, type ShareWatchHandlers } from "../integrations/share";
11
+ import {
12
+ snapshotThreadConversation,
13
+ mergeFromShare,
14
+ type ShareWatchHandlers,
15
+ } from "../integrations/share";
12
16
 
13
- const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
17
+ const publishShare = mock(async (_session: Thread) => ({
18
+ line: "ssh p_abc123xy@cueloop.dev",
19
+ copied: true,
20
+ }));
14
21
  let remote: Thread;
15
22
  const pullShare = mock(async () => remote);
16
23
  const pushShare = mock(
@@ -571,3 +578,41 @@ describe("revoke", () => {
571
578
  expect(client.sessionDelete).toHaveBeenCalledWith(session.id);
572
579
  });
573
580
  });
581
+
582
+ test("share publication captures an empty artifact's separate conversation without execution records", async () => {
583
+ const thread = sessionFixture({ artifact: { type: "plan", content: "", meta: {} } });
584
+ const { controller, client } = await connectedController(thread);
585
+ const state = {
586
+ threadId: thread.id,
587
+ phase: { kind: "idle" as const },
588
+ harness: { id: "pi", label: "pi", sessionId: "private-session" },
589
+ messages: [
590
+ {
591
+ id: "answer",
592
+ role: "agent" as const,
593
+ text: "Visible conversation",
594
+ complete: true,
595
+ revision: 1,
596
+ },
597
+ ],
598
+ comments: [],
599
+ tools: [],
600
+ promptOperations: [
601
+ { operationId: "private-operation", fingerprint: "private-hash", result: [] },
602
+ ],
603
+ };
604
+
605
+ client.agentGet = async () => state;
606
+ publishShare.mockClear();
607
+ controller.share();
608
+ await tick();
609
+ await tick();
610
+ const shared = publishShare.mock.calls[0]![0];
611
+
612
+ expect(shared.agentSnapshot?.messages).toEqual(state.messages);
613
+ expect(shared.agentSnapshot?.harness).toEqual({ id: "pi", label: "pi" });
614
+ expect(shared.agentSnapshot?.promptOperations).toBeUndefined();
615
+ expect(thread.agentSnapshot).toBeUndefined();
616
+ expect(await snapshotThreadConversation(thread, {})).toBe(thread);
617
+ controller.close();
618
+ });
@@ -49,6 +49,7 @@ import {
49
49
  collaboratorAnnotations,
50
50
  mergeFromShare,
51
51
  publishShare,
52
+ snapshotThreadConversation,
52
53
  pullShare,
53
54
  pushShare,
54
55
  revokeShare,
@@ -1764,12 +1765,13 @@ class Controller implements ReviewController {
1764
1765
  }
1765
1766
 
1766
1767
  /** A workbench thread renders live locally; freeze its diff so a remote reviewer gets a stable snapshot. */
1767
- private freezeForShare(session: Thread): Promise<Thread> {
1768
+ private async freezeForShare(session: Thread): Promise<Thread> {
1768
1769
  const repoDiff = this.client?.repoDiff;
1770
+ const shared = repoDiff
1771
+ ? await snapshotWorkbench(session, (root, vcs) => repoDiff.call(this.client, root, vcs))
1772
+ : session;
1769
1773
 
1770
- return repoDiff
1771
- ? snapshotWorkbench(session, (root, vcs) => repoDiff.call(this.client, root, vcs))
1772
- : Promise.resolve(session);
1774
+ return this.client ? snapshotThreadConversation(shared, this.client) : shared;
1773
1775
  }
1774
1776
 
1775
1777
  share(): void {