@cueloop/client 0.1.0-alpha.76 → 0.1.0-alpha.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +5 -5
  2. package/src/App.identity.test.tsx +78 -0
  3. package/src/App.share-choice.test.tsx +105 -0
  4. package/src/App.toast.test.tsx +3 -1
  5. package/src/App.tsx +176 -5
  6. package/src/app-screens.tsx +13 -0
  7. package/src/app-view-model.ts +1 -0
  8. package/src/components/AnnotatableFileView.tsx +9 -1
  9. package/src/components/AnnotationCards.tsx +22 -4
  10. package/src/components/DiffContentView.tsx +5 -1
  11. package/src/components/GridTabContent.tsx +2 -0
  12. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  13. package/src/components/ManageAccessDialog.test.tsx +94 -0
  14. package/src/components/ManageAccessDialog.tsx +120 -0
  15. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  16. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  17. package/src/components/ShareChoiceDialog.tsx +82 -0
  18. package/src/components/ThreadView.tsx +5 -1
  19. package/src/components/__snapshots__/stories.test.tsx.snap +93 -7
  20. package/src/config-identity.test.ts +78 -0
  21. package/src/config.ts +44 -1
  22. package/src/github-identity.test.ts +58 -0
  23. package/src/github-identity.ts +41 -0
  24. package/src/intent-dispatch.test.ts +17 -2
  25. package/src/intent-dispatch.ts +7 -1
  26. package/src/keymap.ts +1 -0
  27. package/src/share.ts +10 -2
  28. package/src/thread-controller.curate.test.ts +1 -0
  29. package/src/thread-controller.live-diff.test.ts +1 -0
  30. package/src/thread-controller.share.test.ts +36 -2
  31. package/src/thread-controller.tree.test.ts +1 -0
  32. package/src/thread-controller.ts +14 -0
  33. package/src/use-annotation-surface.tsx +10 -2
  34. package/src/use-settings-dialog.tsx +24 -0
@@ -0,0 +1,78 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadConfig, persistIdentity } from "./config";
6
+
7
+ function withTempConfig(run: (path: string) => void): void {
8
+ const directory = mkdtempSync(join(tmpdir(), "cueloop-config-"));
9
+
10
+ try {
11
+ run(join(directory, "config.toml"));
12
+ } finally {
13
+ rmSync(directory, { recursive: true, force: true });
14
+ }
15
+ }
16
+
17
+ test("defaults identity to a typed provider with no name", () => {
18
+ withTempConfig((path) => {
19
+ expect(loadConfig({ userConfigPath: path }).identity).toEqual({ provider: "typed" });
20
+ });
21
+ });
22
+
23
+ test("persists and reloads a GitHub-synced identity", () => {
24
+ withTempConfig((path) => {
25
+ persistIdentity({ name: "Markus M", provider: "github" }, path);
26
+
27
+ expect(loadConfig({ userConfigPath: path }).identity).toEqual({
28
+ name: "Markus M",
29
+ provider: "github",
30
+ });
31
+ });
32
+ });
33
+
34
+ test("rewrites the identity block in place on a re-sync", () => {
35
+ withTempConfig((path) => {
36
+ persistIdentity({ name: "typed name", provider: "typed" }, path);
37
+ persistIdentity({ name: "markus", provider: "github" }, path);
38
+
39
+ expect(loadConfig({ userConfigPath: path }).identity).toEqual({
40
+ name: "markus",
41
+ provider: "github",
42
+ });
43
+ });
44
+ });
45
+
46
+ test("a display name containing a bracket survives a re-sync without corrupting the config", () => {
47
+ withTempConfig((path) => {
48
+ persistIdentity({ name: "Alice [Smith]", provider: "typed" }, path);
49
+ persistIdentity({ name: "Bob", provider: "github" }, path);
50
+
51
+ expect(loadConfig({ userConfigPath: path }).identity).toEqual({
52
+ name: "Bob",
53
+ provider: "github",
54
+ });
55
+ });
56
+ });
57
+
58
+ test("repository config cannot forge the reviewer identity", () => {
59
+ withTempConfig((userPath) => {
60
+ persistIdentity({ name: "Me", provider: "typed" }, userPath);
61
+ const repoRoot = mkdtempSync(join(tmpdir(), "cueloop-repo-"));
62
+
63
+ try {
64
+ mkdirSync(join(repoRoot, ".cueloop"), { recursive: true });
65
+ writeFileSync(
66
+ join(repoRoot, ".cueloop", "config.toml"),
67
+ '[identity]\nname = "Forged"\nprovider = "github"\n',
68
+ );
69
+
70
+ expect(loadConfig({ userConfigPath: userPath, repoRoot }).identity).toEqual({
71
+ name: "Me",
72
+ provider: "typed",
73
+ });
74
+ } finally {
75
+ rmSync(repoRoot, { recursive: true, force: true });
76
+ }
77
+ });
78
+ });
package/src/config.ts CHANGED
@@ -114,6 +114,8 @@ export interface CueloopConfig {
114
114
  };
115
115
  /** Planner-local author renames: identity id → display name ([authors] table). */
116
116
  authors: Record<string, string>;
117
+ /** The local reviewer's own display name, and whether it was typed or synced from GitHub ([identity] table). */
118
+ identity: IdentityConfig;
117
119
  /** Marker-popover quick actions ([[actions]] tables); the 5 defaults when unset. */
118
120
  actions: QuickAction[];
119
121
  /** Directory of user-level skills surfaced in the "/" palette ([skills] path); ~/.agents/skills default. */
@@ -128,6 +130,15 @@ export interface ExperimentalConfig {
128
130
  prototypePixels: boolean;
129
131
  }
130
132
 
133
+ /** Where a display name came from: typed by the user, or verified from a GitHub login. */
134
+ export type IdentityProvider = "typed" | "github";
135
+
136
+ export interface IdentityConfig {
137
+ /** The reviewer's own display name; absent until they set or sync one. */
138
+ name?: string;
139
+ provider: IdentityProvider;
140
+ }
141
+
131
142
  /** Every action in the grammar, with its default binding(s). */
132
143
  export const DEFAULT_KEYS: CueloopConfig["keys"] = {
133
144
  down: ["j", "down"],
@@ -166,6 +177,7 @@ function skillsPathFrom(
166
177
  const ConfigDocumentSchema = v.object({
167
178
  actions: v.optional(v.array(v.unknown())),
168
179
  authors: v.optional(v.unknown()),
180
+ identity: v.optional(v.unknown()),
169
181
  integrations: v.optional(v.unknown()),
170
182
  keys: v.optional(v.unknown()),
171
183
  theme: v.optional(v.unknown()),
@@ -174,6 +186,11 @@ const ConfigDocumentSchema = v.object({
174
186
  skills: v.fallback(v.optional(SkillsSectionSchema), undefined),
175
187
  });
176
188
 
189
+ const IdentitySchema = v.object({
190
+ name: v.fallback(v.optional(v.string()), undefined),
191
+ provider: v.fallback(v.optional(v.picklist(["typed", "github"])), undefined),
192
+ });
193
+
177
194
  const QuickActionSchema = v.object({
178
195
  prompt: v.pipe(
179
196
  v.string(),
@@ -275,6 +292,7 @@ function applyUi(ui: CueloopConfig["ui"], parsed: v.InferOutput<typeof UiSchema>
275
292
  function layer(
276
293
  base: CueloopConfig,
277
294
  raw: v.InferOutput<typeof ConfigDocumentSchema>,
295
+ allowIdentity: boolean,
278
296
  ): CueloopConfig {
279
297
  const out: CueloopConfig = {
280
298
  keys: { ...base.keys },
@@ -282,6 +300,7 @@ function layer(
282
300
  themeOverrides: { ...base.themeOverrides },
283
301
  ui: { ...base.ui },
284
302
  authors: { ...base.authors },
303
+ identity: { ...base.identity },
285
304
  actions: [...base.actions],
286
305
  skillsPath: base.skillsPath,
287
306
  integrations: { obsidian: { ...base.integrations.obsidian } },
@@ -289,6 +308,7 @@ function layer(
289
308
  };
290
309
  const actions = parseActions(raw.actions);
291
310
  const authors = v.safeParse(AuthorsSchema, raw.authors);
311
+ const identity = v.safeParse(IdentitySchema, raw.identity);
292
312
  const keys = v.safeParse(KeysSchema, raw.keys);
293
313
  const ui = v.safeParse(UiSchema, raw.ui);
294
314
  const integrations = v.safeParse(IntegrationsSchema, raw.integrations);
@@ -311,6 +331,11 @@ function layer(
311
331
  out.keys[action] = Array.isArray(combo.output) ? combo.output : [combo.output];
312
332
  }
313
333
  }
334
+ // The reviewer identity is a personal credential; a repo must never forge a verified name.
335
+ if (allowIdentity && identity.success) {
336
+ if (identity.output.name !== undefined) out.identity.name = identity.output.name;
337
+ if (identity.output.provider !== undefined) out.identity.provider = identity.output.provider;
338
+ }
314
339
  if (ui.success) applyUi(out.ui, ui.output);
315
340
  if (integrations.success && integrations.output.obsidian) {
316
341
  mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
@@ -336,6 +361,7 @@ export function loadConfig(
336
361
  pins: [],
337
362
  },
338
363
  authors: {},
364
+ identity: { provider: "typed" },
339
365
  actions: [...DEFAULT_QUICK_ACTIONS],
340
366
  skillsPath: join(homedir(), ".agents", "skills"),
341
367
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
@@ -357,7 +383,7 @@ export function loadConfig(
357
383
  try {
358
384
  const raw = parseToml(readFileSync(path, "utf8"));
359
385
 
360
- config = layer(config, raw);
386
+ config = layer(config, raw, path === userPath);
361
387
  const ui = v.safeParse(UiSchema, raw.ui);
362
388
  const rawTheme = ui.success ? ui.output.theme : undefined;
363
389
  const parsedThemeOverrides = v.safeParse(ThemeOverridesSchema, raw.theme);
@@ -459,6 +485,23 @@ export function persistActions(actions: QuickAction[], userConfigPath?: string):
459
485
  writeFileSync(path, text);
460
486
  }
461
487
 
488
+ /** Persist the reviewer's own display name and its provider (`[identity]`) into the user config. */
489
+ export function persistIdentity(identity: IdentityConfig, userConfigPath?: string): void {
490
+ const path = userConfigPathFrom(userConfigPath);
491
+ let text = existsSync(path) ? readFileSync(path, "utf8") : "";
492
+ const nameLine = identity.name === undefined ? "" : `name = ${tomlString(identity.name)}\n`;
493
+ const block = `[identity]\n${nameLine}provider = ${tomlString(identity.provider)}\n`;
494
+
495
+ if (/^\[identity\]/m.test(text)) {
496
+ // Consume the header and its assignment lines only, so a bracket in a quoted name cannot truncate the block.
497
+ text = text.replace(/^\[identity\].*(?:\n(?![[\n]).*)*\n?/m, block);
498
+ } else {
499
+ text = text.trimEnd() + (text.trim() ? "\n\n" : "") + block;
500
+ }
501
+ mkdirSync(dirname(path), { recursive: true });
502
+ writeFileSync(path, text);
503
+ }
504
+
462
505
  /** Persist the auto-close choice (`[ui] auto_close`) into the user config. */
463
506
  export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
464
507
  persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
@@ -0,0 +1,58 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveGithubIdentity } from "./github-identity";
6
+
7
+ const originalBinary = process.env.CUELOOP_GH;
8
+ const tempDirectories: string[] = [];
9
+
10
+ afterEach(() => {
11
+ if (originalBinary === undefined) delete process.env.CUELOOP_GH;
12
+ else process.env.CUELOOP_GH = originalBinary;
13
+ for (const directory of tempDirectories.splice(0)) {
14
+ rmSync(directory, { recursive: true, force: true });
15
+ }
16
+ });
17
+
18
+ function stubGithubCli(script: string): string {
19
+ const directory = mkdtempSync(join(tmpdir(), "gh-stub-"));
20
+
21
+ tempDirectories.push(directory);
22
+ const path = join(directory, "gh");
23
+
24
+ writeFileSync(path, `#!/bin/sh\n${script}\n`);
25
+ chmodSync(path, 0o755);
26
+
27
+ return path;
28
+ }
29
+
30
+ test("reads the GitHub login and name from gh", async () => {
31
+ process.env.CUELOOP_GH = stubGithubCli(`printf '{"login":"markus","name":"Markus M"}'`);
32
+
33
+ expect(await resolveGithubIdentity()).toEqual({ login: "markus", name: "Markus M" });
34
+ });
35
+
36
+ test("drops a null name to undefined", async () => {
37
+ process.env.CUELOOP_GH = stubGithubCli(`printf '{"login":"markus","name":null}'`);
38
+
39
+ expect(await resolveGithubIdentity()).toEqual({ login: "markus" });
40
+ });
41
+
42
+ test("falls back to null when gh exits non-zero (logged out)", async () => {
43
+ process.env.CUELOOP_GH = stubGithubCli(`exit 1`);
44
+
45
+ expect(await resolveGithubIdentity()).toBeNull();
46
+ });
47
+
48
+ test("falls back to null when gh is not installed", async () => {
49
+ process.env.CUELOOP_GH = join(tmpdir(), "cueloop-absent-gh-binary");
50
+
51
+ expect(await resolveGithubIdentity()).toBeNull();
52
+ });
53
+
54
+ test("falls back to null when gh prints an unexpected shape", async () => {
55
+ process.env.CUELOOP_GH = stubGithubCli(`printf 'not json'`);
56
+
57
+ expect(await resolveGithubIdentity()).toBeNull();
58
+ });
@@ -0,0 +1,41 @@
1
+ /** The signed-in GitHub identity from the local gh tool, or null when gh cannot answer. */
2
+
3
+ import * as v from "valibot";
4
+
5
+ export interface GithubIdentity {
6
+ login: string;
7
+ name?: string;
8
+ }
9
+
10
+ const GithubUserSchema = v.object({
11
+ login: v.string(),
12
+ name: v.nullish(v.string()),
13
+ });
14
+
15
+ /** The gh binary is injectable so tests stub it without a real GitHub login. */
16
+ function githubCliBinary(): string {
17
+ return process.env.CUELOOP_GH || "gh";
18
+ }
19
+
20
+ /** Read the authenticated GitHub login and name, or null when gh cannot answer. */
21
+ export async function resolveGithubIdentity(): Promise<GithubIdentity | null> {
22
+ try {
23
+ const githubProcess = Bun.spawn(
24
+ [githubCliBinary(), "api", "user", "--jq", "{login: .login, name: .name}"],
25
+ { stdout: "pipe", stderr: "ignore" },
26
+ );
27
+ const [output, exitCode] = await Promise.all([
28
+ new Response(githubProcess.stdout).text(),
29
+ githubProcess.exited,
30
+ ]);
31
+
32
+ if (exitCode !== 0) return null;
33
+ const parsed = v.safeParse(GithubUserSchema, JSON.parse(output));
34
+
35
+ if (!parsed.success) return null;
36
+
37
+ return { login: parsed.output.login, name: parsed.output.name ?? undefined };
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
@@ -108,6 +108,7 @@ function baseController(): ReviewController {
108
108
  walkLeave: mock(),
109
109
  submit: mock(),
110
110
  share: mock(),
111
+ setShareAccess: mock(),
111
112
  treeRows: mock(() => []),
112
113
  goToEntry: mock(),
113
114
  branch: mock(),
@@ -143,6 +144,7 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
143
144
  quickActions: [],
144
145
  renameAuthor: mock(),
145
146
  renameThread: mock(),
147
+ setLocalIdentityName: mock(),
146
148
  liveInput: { current: "" },
147
149
  setCursor: mock(),
148
150
  setInboxCursor: mock(),
@@ -383,7 +385,7 @@ describe("openSubmit", () => {
383
385
  });
384
386
 
385
387
  describe("share", () => {
386
- test("dispatches to the controller's share", () => {
388
+ test("opens the public/private choice overlay instead of publishing directly", () => {
387
389
  // Arrange
388
390
  const deps = makeDeps();
389
391
  const dispatch = createIntentDispatch(deps);
@@ -392,7 +394,8 @@ describe("share", () => {
392
394
  dispatch({ type: "share" });
393
395
 
394
396
  // Assert
395
- expect(deps.controller.share).toHaveBeenCalled();
397
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "shareChoice", index: 0 });
398
+ expect(deps.controller.share).not.toHaveBeenCalled();
396
399
  });
397
400
  });
398
401
 
@@ -547,6 +550,18 @@ describe("rename author", () => {
547
550
  expect(deps.renameAuthor).toHaveBeenCalledWith("SHA256:x", "Alex");
548
551
  expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
549
552
  });
553
+
554
+ test("confirmDialog in renameSelf mode sets the trimmed local identity name and closes", () => {
555
+ // Arrange
556
+ const deps = makeDeps({ mode: { type: "renameSelf", text: " Robin " } });
557
+
558
+ // Act
559
+ createIntentDispatch(deps)({ type: "confirmDialog" });
560
+
561
+ // Assert
562
+ expect(deps.setLocalIdentityName).toHaveBeenCalledWith("Robin");
563
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
564
+ });
550
565
  });
551
566
 
552
567
  describe("collaborator self-name", () => {
@@ -39,6 +39,8 @@ export type Mode =
39
39
  | { type: "rename"; authorId: string; text: string }
40
40
  | { type: "renameThread"; sessionId: string; text: string }
41
41
  | { type: "nameSelf"; text: string }
42
+ | { type: "renameSelf"; text: string }
43
+ | { type: "shareChoice"; index: number }
42
44
  | { type: "treePrompt"; ask: TreeAsk; entryId?: string; text: string };
43
45
 
44
46
  /** What a tree prompt asks for: a branch name, a checkpoint name, or the summary a move back leaves. */
@@ -98,6 +100,8 @@ export interface IntentDispatchDeps {
98
100
  renameAuthor: (id: string, name: string) => void;
99
101
  /** Rename a thread's title through the daemon. */
100
102
  renameThread: (id: string, title: string) => void;
103
+ /** Set the local reviewer's typed display name and persist it (App-owned). */
104
+ setLocalIdentityName: (name: string) => void;
101
105
 
102
106
  liveInput: MutableRefObject<string>;
103
107
 
@@ -238,6 +242,7 @@ function handleConfirmDialog(
238
242
  else if (mode.type === "rename") deps.renameAuthor(mode.authorId, mode.text.trim());
239
243
  else if (mode.type === "renameThread") deps.renameThread(mode.sessionId, mode.text.trim());
240
244
  else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
245
+ else if (mode.type === "renameSelf") deps.setLocalIdentityName(mode.text.trim());
241
246
  else if (mode.type === "treePrompt") confirmTreePrompt(mode, deps);
242
247
  deps.setMode({ type: "normal" });
243
248
  }
@@ -381,8 +386,9 @@ function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispa
381
386
  deps.setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
382
387
  }
383
388
 
389
+ // share opens the public/private choice overlay; the App owns the choice keys and publishes on pick
384
390
  function handleShare(_intent: IntentOfType<"share">, deps: IntentDispatchDeps): void {
385
- deps.controller.share();
391
+ deps.setMode({ type: "shareChoice", index: 0 });
386
392
  }
387
393
 
388
394
  function handleCut(_intent: IntentOfType<"cut">, deps: IntentDispatchDeps): void {
package/src/keymap.ts CHANGED
@@ -87,6 +87,7 @@ export interface KeyState {
87
87
  | "submit"
88
88
  | "confirm"
89
89
  | "prompt"
90
+ | "shareChoice"
90
91
  | "spanActions"
91
92
  | "completion-prompt"
92
93
  | "completion-counting";
package/src/share.ts CHANGED
@@ -12,7 +12,13 @@ import {
12
12
  SHARE_UPLOAD_USER,
13
13
  packSessionBlob,
14
14
  } from "@cueloop/daemon/share-blob";
15
- import { removalEntries, viewFollowing, type Annotation, type Thread } from "@cueloop/schema";
15
+ import {
16
+ removalEntries,
17
+ viewFollowing,
18
+ type Annotation,
19
+ type ShareAccess,
20
+ type Thread,
21
+ } from "@cueloop/schema";
16
22
  import { ThreadRecordSchema } from "@cueloop/daemon/validate";
17
23
  import type { SharedMerge } from "@cueloop/daemon/client";
18
24
  import * as v from "valibot";
@@ -102,11 +108,13 @@ export function mergeFromShare(remote: Thread): SharedMerge {
102
108
  export async function pushShare(
103
109
  shareId: string,
104
110
  annotations: Array<Omit<Annotation, "createdAt">>,
111
+ access?: ShareAccess,
105
112
  target: ShareTarget = {},
106
113
  ): Promise<void> {
114
+ const payload = access ? { shareId, annotations, access } : { shareId, annotations };
107
115
  const { stderr, code } = await runShareSsh(
108
116
  "cueloop-push",
109
- Buffer.from(JSON.stringify({ shareId, annotations })),
117
+ Buffer.from(JSON.stringify(payload)),
110
118
  target,
111
119
  );
112
120
 
@@ -109,6 +109,7 @@ function fakeClient(initial: Thread, sink: WorkingCopySink): SessionClient {
109
109
 
110
110
  return session;
111
111
  }),
112
+ sessionSetAccess: unimplemented("sessionSetAccess"),
112
113
  sessionNavigate: unimplemented("sessionNavigate"),
113
114
  sessionBranch: unimplemented("sessionBranch"),
114
115
  sessionSwitch: unimplemented("sessionSwitch"),
@@ -73,6 +73,7 @@ function fakeClient(session: Thread): SessionClient {
73
73
  sessionCutBlock: unimplemented("sessionCutBlock"),
74
74
  sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
75
75
  sessionCurate: unimplemented("sessionCurate"),
76
+ sessionSetAccess: unimplemented("sessionSetAccess"),
76
77
  sessionNavigate: unimplemented("sessionNavigate"),
77
78
  sessionBranch: unimplemented("sessionBranch"),
78
79
  sessionSwitch: unimplemented("sessionSwitch"),
@@ -1,6 +1,6 @@
1
1
  import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
2
2
  import { ManualClock } from "@opentui/core/testing";
3
- import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
3
+ import { SCHEMA_VERSION, type Annotation, type ShareAccess, type Thread } from "@cueloop/schema";
4
4
  import type { SessionClient } from "@cueloop/daemon/client";
5
5
  import {
6
6
  createReviewController,
@@ -14,7 +14,11 @@ const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", cop
14
14
  let remote: Thread;
15
15
  const pullShare = mock(async () => remote);
16
16
  const pushShare = mock(
17
- async (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
17
+ async (
18
+ _shareId: string,
19
+ _annotations: Array<Omit<Annotation, "createdAt">>,
20
+ _access?: ShareAccess,
21
+ ) => {},
18
22
  );
19
23
 
20
24
  const shareTransport: ShareTransport = {
@@ -73,6 +77,7 @@ function fakeClient(session: Thread): FakeSessionClient {
73
77
  sessionCutBlock: unimplemented("sessionCutBlock"),
74
78
  sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
75
79
  sessionCurate: unimplemented("sessionCurate"),
80
+ sessionSetAccess: mock(async () => session),
76
81
  sessionNavigate: unimplemented("sessionNavigate"),
77
82
  sessionBranch: unimplemented("sessionBranch"),
78
83
  sessionSwitch: unimplemented("sessionSwitch"),
@@ -149,6 +154,35 @@ describe("share", () => {
149
154
  });
150
155
  });
151
156
 
157
+ describe("setShareAccess", () => {
158
+ test("pushes the allowlist up to the gateway when the plan is already shared", async () => {
159
+ // Arrange
160
+ pushShare.mockClear();
161
+ const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }));
162
+
163
+ // Act
164
+ controller.setShareAccess(["octocat"]);
165
+ await tick();
166
+
167
+ // Assert - the existing link must enforce the new access, so it rides the push
168
+ expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
169
+ expect(pushShare.mock.calls[0]?.[2]).toEqual({ githubLogins: ["octocat"] });
170
+ });
171
+
172
+ test("does not push when the plan was never shared", async () => {
173
+ // Arrange
174
+ pushShare.mockClear();
175
+ const { controller } = await connectedController(sessionFixture());
176
+
177
+ // Act
178
+ controller.setShareAccess(["octocat"]);
179
+ await tick();
180
+
181
+ // Assert
182
+ expect(pushShare).not.toHaveBeenCalled();
183
+ });
184
+ });
185
+
152
186
  describe("pullShared", () => {
153
187
  test("unions collaborator notes and identities in when the plan was shared", async () => {
154
188
  // Arrange
@@ -82,6 +82,7 @@ function fakeClient(session: Thread) {
82
82
  sessionCutBlock: unimplemented("sessionCutBlock"),
83
83
  sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
84
84
  sessionCurate: unimplemented("sessionCurate"),
85
+ sessionSetAccess: unimplemented("sessionSetAccess"),
85
86
  sessionNavigate: mock(pending),
86
87
  sessionBranch: mock(pending),
87
88
  sessionSwitch: mock(pending),
@@ -309,6 +309,8 @@ export interface ReviewController {
309
309
  submit(verdict: VerdictKind, summary: string): void;
310
310
  /** Publish the current session as a share; the ssh line lands on the clipboard. */
311
311
  share(): void;
312
+ /** Replace the private-share allowlist of GitHub logins for the current session. Owner only. */
313
+ setShareAccess(githubLogins: string[]): void;
312
314
  /** The session tree as rows for the rail's Tree tab, cached per session identity. */
313
315
  treeRows(): TreeRow[];
314
316
  /**
@@ -882,6 +884,18 @@ class Controller implements ReviewController {
882
884
  this.applyOptimistic(expected, this.client!.sessionCurate(session.id, rejections));
883
885
  }
884
886
 
887
+ setShareAccess(githubLogins: string[]): void {
888
+ const session = this.snapshot.session;
889
+
890
+ if (!session) return;
891
+ const access = { githubLogins };
892
+ const expected: Thread = { ...session, access };
893
+
894
+ this.applyOptimistic(expected, this.client!.sessionSetAccess(session.id, githubLogins));
895
+ // an already-shared link must enforce the new access, so push it up to the gateway blob
896
+ if (session.shareId) void this.shareTransport.push(session.shareId, [], access).catch(() => {});
897
+ }
898
+
885
899
  rejectedRows(): Set<number> {
886
900
  this.ensureDerived();
887
901
  if (!this.rejections.length) return EMPTY_REJECTED_ROWS;
@@ -15,7 +15,7 @@
15
15
  import React, { useContext, useEffect, useRef, useState } from "react";
16
16
  import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
17
17
  import { useKeyboard } from "@opentui/react";
18
- import type { Thread } from "@cueloop/schema";
18
+ import type { Annotation, Thread } from "@cueloop/schema";
19
19
  import type { Mark } from "./view-plan";
20
20
  import type { QuickAction } from "./config";
21
21
  import type { Theme } from "./theme";
@@ -104,6 +104,8 @@ export interface AnnotationSurfaceOptions {
104
104
  onAnnotate: (span: TextSpan, body: string) => void;
105
105
  onReply: (rootAnnotationId: string, body: string) => void;
106
106
  onUpdateAnnotation: (id: string, body: string) => void;
107
+ /** The author's display name for a comment's hover tooltip; the rail resolves it against the participant registry. */
108
+ resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
107
109
  leaderCombos?: readonly string[];
108
110
  onLeaderCommand?: (key: KeyEvent) => void;
109
111
  onExit: () => void;
@@ -179,6 +181,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
179
181
  onAnnotate,
180
182
  onReply,
181
183
  onUpdateAnnotation,
184
+ resolveAuthorLabel,
182
185
  leaderCombos,
183
186
  onLeaderCommand,
184
187
  onExit,
@@ -735,7 +738,12 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
735
738
  node: editingHere ? (
736
739
  composerNode
737
740
  ) : (
738
- <CommentRow key={annotation.id} annotation={annotation} tokens={tokens} />
741
+ <CommentRow
742
+ key={annotation.id}
743
+ annotation={annotation}
744
+ tokens={tokens}
745
+ authorLabel={resolveAuthorLabel?.(annotation)}
746
+ />
739
747
  ),
740
748
  };
741
749
  });
@@ -83,6 +83,10 @@ export function useSettingsDialog(params: {
83
83
  quickActions: QuickAction[];
84
84
  setQuickActions: Dispatch<SetStateAction<QuickAction[]>>;
85
85
  setMenuDialog: Dispatch<SetStateAction<"keybinds" | "settings" | null>>;
86
+ identityName?: string;
87
+ identityProvider: "typed" | "github";
88
+ onSyncGithubIdentity: () => void;
89
+ onRenameDisplayName: () => void;
86
90
  }): SettingsDialogModel {
87
91
  const {
88
92
  theme,
@@ -98,6 +102,10 @@ export function useSettingsDialog(params: {
98
102
  quickActions,
99
103
  setQuickActions,
100
104
  setMenuDialog,
105
+ identityName,
106
+ identityProvider,
107
+ onSyncGithubIdentity,
108
+ onRenameDisplayName,
101
109
  } = params;
102
110
 
103
111
  const [settingsNav, setSettingsNav] = useState<SettingsNav>({
@@ -157,6 +165,15 @@ export function useSettingsDialog(params: {
157
165
  },
158
166
  ],
159
167
  },
168
+ {
169
+ id: "account",
170
+ name: "Account",
171
+ rows: [
172
+ { key: "displayName", label: "Display name", kind: "text" },
173
+ { key: "identitySource", label: "Source", kind: "text" },
174
+ { key: "syncGithub", label: "Sync from GitHub", kind: "text" },
175
+ ],
176
+ },
160
177
  {
161
178
  id: "appearance",
162
179
  name: "Appearance",
@@ -202,6 +219,9 @@ export function useSettingsDialog(params: {
202
219
  autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
203
220
  diffView: diffView === "split" ? "Split" : "Stacked",
204
221
  theme: THEME_LABELS[themeName],
222
+ displayName: identityName ?? "-- not set --",
223
+ identitySource: identityProvider === "github" ? "GitHub" : "typed",
224
+ syncGithub: "enter to sync",
205
225
  };
206
226
  const cycleSetting = (rowKey: string): void => {
207
227
  if (rowKey === "autoClose") {
@@ -220,6 +240,10 @@ export function useSettingsDialog(params: {
220
240
  setThemeName(next);
221
241
  setTheme(composeTheme(next, themeOverrides, appearance));
222
242
  persistTheme(next);
243
+ } else if (rowKey === "syncGithub") {
244
+ onSyncGithubIdentity();
245
+ } else if (rowKey === "displayName") {
246
+ onRenameDisplayName();
223
247
  }
224
248
  };
225
249