@cueloop/client 0.1.0-alpha.75 → 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 (82) hide show
  1. package/package.json +5 -5
  2. package/src/App.focus.test.ts +147 -0
  3. package/src/App.identity.test.tsx +78 -0
  4. package/src/App.regression.test.tsx +94 -0
  5. package/src/App.share-choice.test.tsx +105 -0
  6. package/src/App.test.tsx +11 -8
  7. package/src/App.toast.test.tsx +3 -1
  8. package/src/App.tsx +648 -221
  9. package/src/App.visual.test.tsx +57 -0
  10. package/src/App.workbench.test.tsx +41 -22
  11. package/src/app-screens.tsx +181 -123
  12. package/src/app-view-model.ts +1 -0
  13. package/src/components/AnnotatableFileView.tsx +9 -1
  14. package/src/components/AnnotationCards.tsx +22 -4
  15. package/src/components/AppShell.brand.test.tsx +1 -1
  16. package/src/components/AppShell.tsx +14 -1
  17. package/src/components/ChangesColumn.tsx +40 -12
  18. package/src/components/ChangesFileTree.test.tsx +61 -0
  19. package/src/components/ConfirmDialog.tsx +8 -1
  20. package/src/components/DiffContentView.tsx +12 -2
  21. package/src/components/DiscussionMarkerRail.tsx +2 -2
  22. package/src/components/EditorGrid.tsx +84 -26
  23. package/src/components/GridTabContent.tsx +12 -1
  24. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  25. package/src/components/ManageAccessDialog.test.tsx +94 -0
  26. package/src/components/ManageAccessDialog.tsx +120 -0
  27. package/src/components/PanelColumn.tsx +3 -0
  28. package/src/components/ProjectTreeView.test.tsx +39 -12
  29. package/src/components/ProjectTreeView.tsx +38 -10
  30. package/src/components/PromptDialog.test.tsx +61 -0
  31. package/src/components/PromptDialog.tsx +16 -3
  32. package/src/components/RootOverlay.stories.tsx +1 -0
  33. package/src/components/RootOverlay.tsx +19 -6
  34. package/src/components/SettingsDialog.stories.tsx +3 -3
  35. package/src/components/SettingsDialog.tsx +3 -4
  36. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  37. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  38. package/src/components/ShareChoiceDialog.tsx +82 -0
  39. package/src/components/ThreadFooter.test.tsx +12 -7
  40. package/src/components/ThreadFooter.tsx +18 -4
  41. package/src/components/ThreadTree.tsx +79 -36
  42. package/src/components/ThreadView.slash.test.tsx +36 -0
  43. package/src/components/ThreadView.tsx +12 -2
  44. package/src/components/WelcomePlayground.tsx +2 -0
  45. package/src/components/__snapshots__/stories.test.tsx.snap +147 -52
  46. package/src/components/menu-control.stories.tsx +35 -0
  47. package/src/components/menu-control.tsx +43 -0
  48. package/src/components/primitives/Dialog.test.tsx +37 -0
  49. package/src/components/primitives/Dialog.tsx +5 -0
  50. package/src/components/primitives/icons.ts +1 -1
  51. package/src/components/quick-actions-editor.stories.tsx +2 -0
  52. package/src/components/quick-actions-editor.test.tsx +35 -1
  53. package/src/components/quick-actions-editor.tsx +23 -22
  54. package/src/components/session-tree.test.ts +3 -3
  55. package/src/components/session-tree.ts +1 -1
  56. package/src/config-identity.test.ts +78 -0
  57. package/src/config.ts +45 -1
  58. package/src/diff-intraline.ts +26 -5
  59. package/src/github-identity.test.ts +58 -0
  60. package/src/github-identity.ts +41 -0
  61. package/src/intent-dispatch.test.ts +17 -2
  62. package/src/intent-dispatch.ts +7 -1
  63. package/src/keymap.ts +1 -0
  64. package/src/perf/otlp-trace.test.ts +116 -0
  65. package/src/perf/otlp-trace.ts +130 -0
  66. package/src/perf/perf-report.ts +17 -0
  67. package/src/perf/perf-timings.test.ts +18 -0
  68. package/src/perf/perf-timings.ts +38 -0
  69. package/src/run.ts +22 -2
  70. package/src/share.ts +10 -2
  71. package/src/thread-chords.test.ts +33 -1
  72. package/src/thread-chords.ts +114 -29
  73. package/src/thread-controller.curate.test.ts +1 -0
  74. package/src/thread-controller.live-diff.test.ts +1 -0
  75. package/src/thread-controller.share.test.ts +36 -2
  76. package/src/thread-controller.tree.test.ts +1 -0
  77. package/src/thread-controller.ts +18 -3
  78. package/src/use-annotation-surface.tsx +32 -2
  79. package/src/use-frame-measure.ts +4 -1
  80. package/src/use-settings-dialog.tsx +33 -7
  81. package/src/visual/contrast.test.ts +35 -0
  82. package/src/visual/contrast.ts +33 -0
@@ -22,8 +22,10 @@ export interface QuickActionsEditorProps {
22
22
  selectedIndex: number;
23
23
  /** The row whose system-prompt input is open and focused, or null. */
24
24
  expandedIndex: number | null;
25
- /** Select and toggle a row's editor open/closed. */
26
- onToggleExpand: (index: number) => void;
25
+ /** Which field the open editor focuses first - set by the clicked row. */
26
+ expandedField: "prompt" | "metadata";
27
+ /** Select and toggle a row's editor open/closed, focusing the given field. */
28
+ onToggleExpand: (index: number, field: "prompt" | "metadata") => void;
27
29
  onEditPrompt: (index: number, prompt: string) => void;
28
30
  onEditMetadata: (index: number, metadata: string) => void;
29
31
  onReset: () => void;
@@ -35,6 +37,7 @@ export function QuickActionsEditor({
35
37
  actions,
36
38
  selectedIndex,
37
39
  expandedIndex,
40
+ expandedField,
38
41
  onToggleExpand,
39
42
  onEditPrompt,
40
43
  onEditMetadata,
@@ -52,11 +55,9 @@ export function QuickActionsEditor({
52
55
  return (
53
56
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
54
57
  <box style={{ flexDirection: "row" }}>
55
- <box style={{ flexGrow: 1 }}>
56
- <text fg={tokens.textDim}>click a prompt to edit its system prompt</text>
57
- </box>
58
+ <box style={{ flexGrow: 1 }} />
58
59
  <box onMouseUp={onReset}>
59
- <text fg={tokens.textDim}>reset to defaults ▸</text>
60
+ <text fg={tokens.textDim}>reset</text>
60
61
  </box>
61
62
  </box>
62
63
  <box style={{ height: 1 }} />
@@ -68,7 +69,8 @@ export function QuickActionsEditor({
68
69
  action={action}
69
70
  isSelected={index === selectedIndex}
70
71
  isExpanded={index === expandedIndex}
71
- onToggleExpand={() => onToggleExpand(index)}
72
+ initialField={expandedField}
73
+ onToggleExpand={(field) => onToggleExpand(index, field)}
72
74
  onEditPrompt={(prompt) => onEditPrompt(index, prompt)}
73
75
  onEditMetadata={(metadata) => onEditMetadata(index, metadata)}
74
76
  theme={theme}
@@ -91,6 +93,7 @@ function ActionRow({
91
93
  action,
92
94
  isSelected,
93
95
  isExpanded,
96
+ initialField,
94
97
  onToggleExpand,
95
98
  onEditPrompt,
96
99
  onEditMetadata,
@@ -100,7 +103,8 @@ function ActionRow({
100
103
  action: QuickAction;
101
104
  isSelected: boolean;
102
105
  isExpanded: boolean;
103
- onToggleExpand: () => void;
106
+ initialField: "prompt" | "metadata";
107
+ onToggleExpand: (field: "prompt" | "metadata") => void;
104
108
  onEditPrompt: (prompt: string) => void;
105
109
  onEditMetadata: (metadata: string) => void;
106
110
  theme?: Theme;
@@ -113,9 +117,10 @@ function ActionRow({
113
117
  <ActionEditor
114
118
  action={action}
115
119
  isSelected={isSelected}
120
+ initialField={initialField}
116
121
  onEditPrompt={onEditPrompt}
117
122
  onEditMetadata={onEditMetadata}
118
- onDone={onToggleExpand}
123
+ onDone={() => onToggleExpand("prompt")}
119
124
  tokens={tokens}
120
125
  />
121
126
  </box>
@@ -126,12 +131,12 @@ function ActionRow({
126
131
  <box id={rowId} style={{ flexDirection: "column" }}>
127
132
  <box
128
133
  style={{ backgroundColor: isSelected ? tokens.border : undefined }}
129
- onMouseUp={onToggleExpand}
134
+ onMouseUp={() => onToggleExpand("prompt")}
130
135
  >
131
- <text fg={isSelected ? tokens.text : tokens.textMuted}>{`▸ ${action.prompt}`}</text>
136
+ <text fg={isSelected ? tokens.text : tokens.textMuted}>{action.prompt}</text>
132
137
  </box>
133
138
  {action.metadata ? (
134
- <box style={{ paddingLeft: 2 }}>
139
+ <box onMouseUp={() => onToggleExpand("metadata")}>
135
140
  <text fg={tokens.textDim}>{truncateMetadata(action.metadata)}</text>
136
141
  </box>
137
142
  ) : null}
@@ -141,12 +146,13 @@ function ActionRow({
141
146
 
142
147
  /**
143
148
  * The two-field editor for an expanded row: the action title over its system
144
- * prompt. Mounts fresh per expand, so focus starts on the title without a reset
145
- * effect; ⏎ steps title -> description -> close, and a click focuses either.
149
+ * prompt. Mounts fresh per expand, so focus starts on the clicked field without a
150
+ * reset effect; ⏎ steps title -> description -> close, and a click focuses either.
146
151
  */
147
152
  function ActionEditor({
148
153
  action,
149
154
  isSelected,
155
+ initialField,
150
156
  onEditPrompt,
151
157
  onEditMetadata,
152
158
  onDone,
@@ -154,6 +160,7 @@ function ActionEditor({
154
160
  }: {
155
161
  action: QuickAction;
156
162
  isSelected: boolean;
163
+ initialField: "prompt" | "metadata";
157
164
  onEditPrompt: (prompt: string) => void;
158
165
  onEditMetadata: (metadata: string) => void;
159
166
  onDone: () => void;
@@ -161,7 +168,7 @@ function ActionEditor({
161
168
  }): React.ReactNode {
162
169
  const promptRef = useRef<TextareaRenderable | null>(null);
163
170
  const metadataRef = useRef<TextareaRenderable | null>(null);
164
- const [activeField, setActiveField] = useState<"prompt" | "metadata">("prompt");
171
+ const [activeField, setActiveField] = useState<"prompt" | "metadata">(initialField);
165
172
 
166
173
  // the focused field types from its end, not from the caret parked at the start
167
174
  useEffect(() => {
@@ -184,9 +191,6 @@ function ActionEditor({
184
191
  <box
185
192
  style={{ flexDirection: "row", backgroundColor: isSelected ? tokens.border : undefined }}
186
193
  >
187
- <box style={{ width: 2, flexShrink: 0 }}>
188
- <text fg={isSelected ? tokens.text : tokens.textMuted}>{"▾ "}</text>
189
- </box>
190
194
  <textarea
191
195
  ref={promptRef}
192
196
  focused={activeField === "prompt"}
@@ -199,10 +203,7 @@ function ActionEditor({
199
203
  style={fieldStyle}
200
204
  />
201
205
  </box>
202
- <box style={{ flexDirection: "row", paddingLeft: 2 }}>
203
- <box style={{ width: 2, flexShrink: 0 }}>
204
- <text fg={tokens.textDim}>{">"}</text>
205
- </box>
206
+ <box style={{ flexDirection: "row" }}>
206
207
  <textarea
207
208
  ref={metadataRef}
208
209
  focused={activeField === "metadata"}
@@ -112,19 +112,19 @@ describe("groupInbox", () => {
112
112
  expect(rows.some((row) => row.kind === "section" && row.label === "Threads")).toBe(true);
113
113
  });
114
114
 
115
- test("a pinned thread lifts into a Pinned section at the top, out of its normal group", () => {
115
+ test("a pinned thread lifts into a Starred section at the top, out of its normal group", () => {
116
116
  // Arrange - one repo-bound thread, one standalone; pin the repo-bound one
117
117
  const sessions = [session("a", "Pinned one", "root-1"), session("b", "Loose two")];
118
118
 
119
119
  // Act
120
120
  const { rows, ordered } = groupInbox(sessions, new Set(["a"]));
121
121
 
122
- // Assert - Pinned is the first section and holds thread a; a is gone from Projects
122
+ // Assert - Starred is the first section and holds thread a; a is gone from Projects
123
123
  const sections = rows
124
124
  .filter((row) => row.kind === "section")
125
125
  .map((row) => (row.kind === "section" ? row.label : ""));
126
126
 
127
- expect(sections[0]).toBe("Pinned");
127
+ expect(sections[0]).toBe("Starred");
128
128
  expect(sections).not.toContain("Projects"); // its only member was pinned away
129
129
  expect(ordered[0]!.id).toBe("a"); // the cursor walks pinned threads first
130
130
  const pinnedThreadIds = rows.filter((row) => row.kind === "thread").map((row) => row.id);
@@ -84,7 +84,7 @@ export function groupInbox(
84
84
  };
85
85
 
86
86
  if (pinned.length > 0) {
87
- rows.push({ kind: "section", id: "section:pinned", label: "Pinned" });
87
+ rows.push({ kind: "section", id: "section:pinned", label: "Starred" });
88
88
  for (const session of pinned) pushThread(session);
89
89
  }
90
90
 
@@ -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"],
@@ -151,6 +162,7 @@ export const DEFAULT_KEYS: CueloopConfig["keys"] = {
151
162
  share: ["S"],
152
163
  quit: ["q"],
153
164
  walk: ["w"],
165
+ leader: ["ctrl+g"],
154
166
  };
155
167
 
156
168
  const SkillsSectionSchema = v.object({ path: v.optional(v.string()) });
@@ -165,6 +177,7 @@ function skillsPathFrom(
165
177
  const ConfigDocumentSchema = v.object({
166
178
  actions: v.optional(v.array(v.unknown())),
167
179
  authors: v.optional(v.unknown()),
180
+ identity: v.optional(v.unknown()),
168
181
  integrations: v.optional(v.unknown()),
169
182
  keys: v.optional(v.unknown()),
170
183
  theme: v.optional(v.unknown()),
@@ -173,6 +186,11 @@ const ConfigDocumentSchema = v.object({
173
186
  skills: v.fallback(v.optional(SkillsSectionSchema), undefined),
174
187
  });
175
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
+
176
194
  const QuickActionSchema = v.object({
177
195
  prompt: v.pipe(
178
196
  v.string(),
@@ -274,6 +292,7 @@ function applyUi(ui: CueloopConfig["ui"], parsed: v.InferOutput<typeof UiSchema>
274
292
  function layer(
275
293
  base: CueloopConfig,
276
294
  raw: v.InferOutput<typeof ConfigDocumentSchema>,
295
+ allowIdentity: boolean,
277
296
  ): CueloopConfig {
278
297
  const out: CueloopConfig = {
279
298
  keys: { ...base.keys },
@@ -281,6 +300,7 @@ function layer(
281
300
  themeOverrides: { ...base.themeOverrides },
282
301
  ui: { ...base.ui },
283
302
  authors: { ...base.authors },
303
+ identity: { ...base.identity },
284
304
  actions: [...base.actions],
285
305
  skillsPath: base.skillsPath,
286
306
  integrations: { obsidian: { ...base.integrations.obsidian } },
@@ -288,6 +308,7 @@ function layer(
288
308
  };
289
309
  const actions = parseActions(raw.actions);
290
310
  const authors = v.safeParse(AuthorsSchema, raw.authors);
311
+ const identity = v.safeParse(IdentitySchema, raw.identity);
291
312
  const keys = v.safeParse(KeysSchema, raw.keys);
292
313
  const ui = v.safeParse(UiSchema, raw.ui);
293
314
  const integrations = v.safeParse(IntegrationsSchema, raw.integrations);
@@ -310,6 +331,11 @@ function layer(
310
331
  out.keys[action] = Array.isArray(combo.output) ? combo.output : [combo.output];
311
332
  }
312
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
+ }
313
339
  if (ui.success) applyUi(out.ui, ui.output);
314
340
  if (integrations.success && integrations.output.obsidian) {
315
341
  mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
@@ -335,6 +361,7 @@ export function loadConfig(
335
361
  pins: [],
336
362
  },
337
363
  authors: {},
364
+ identity: { provider: "typed" },
338
365
  actions: [...DEFAULT_QUICK_ACTIONS],
339
366
  skillsPath: join(homedir(), ".agents", "skills"),
340
367
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
@@ -356,7 +383,7 @@ export function loadConfig(
356
383
  try {
357
384
  const raw = parseToml(readFileSync(path, "utf8"));
358
385
 
359
- config = layer(config, raw);
386
+ config = layer(config, raw, path === userPath);
360
387
  const ui = v.safeParse(UiSchema, raw.ui);
361
388
  const rawTheme = ui.success ? ui.output.theme : undefined;
362
389
  const parsedThemeOverrides = v.safeParse(ThemeOverridesSchema, raw.theme);
@@ -458,6 +485,23 @@ export function persistActions(actions: QuickAction[], userConfigPath?: string):
458
485
  writeFileSync(path, text);
459
486
  }
460
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
+
461
505
  /** Persist the auto-close choice (`[ui] auto_close`) into the user config. */
462
506
  export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
463
507
  persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
@@ -83,12 +83,30 @@ export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]>
83
83
  /** Lines this similar (word-set overlap) are treated as the same line edited. */
84
84
  const SIMILAR_MIN_WORD_OVERLAP = 0.3;
85
85
 
86
+ /** Lower-cased word set of a line for the similarity gate, cached per distinct line within a block so
87
+ * the LCS grid tokenizes each line once instead of once per comparison. */
88
+ function lineWordSetCache(): (text: string) => ReadonlySet<string> {
89
+ const cache = new Map<string, ReadonlySet<string>>();
90
+
91
+ return (text) => {
92
+ const cached = cache.get(text);
93
+ if (cached !== undefined) return cached;
94
+ const words = new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
95
+ cache.set(text, words);
96
+
97
+ return words;
98
+ };
99
+ }
100
+
86
101
  /** Whether two lines are the same line edited, by case-insensitive word-set
87
102
  * overlap - the matching gate, distinct from the case-sensitive word diff. */
88
- function linesSimilar(oldText: string, newText: string): boolean {
89
- const words = (text: string) => new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
90
- const oldWords = words(oldText);
91
- const newWords = words(newText);
103
+ function linesSimilar(
104
+ wordsOf: (text: string) => ReadonlySet<string>,
105
+ oldText: string,
106
+ newText: string,
107
+ ): boolean {
108
+ const oldWords = wordsOf(oldText);
109
+ const newWords = wordsOf(newText);
92
110
 
93
111
  if (oldWords.size === 0 && newWords.size === 0) return true;
94
112
  let intersection = 0;
@@ -112,8 +130,11 @@ function alignedPairs(deletionTexts: string[], additionTexts: string[]): Array<[
112
130
  const pairs: Array<[number, number]> = [];
113
131
  let deletionOffset = 0;
114
132
  let additionOffset = 0;
133
+ const wordsOf = lineWordSetCache();
115
134
 
116
- for (const op of lcsDiff(deletionTexts, additionTexts, linesSimilar)) {
135
+ for (const op of lcsDiff(deletionTexts, additionTexts, (deletionText, additionText) =>
136
+ linesSimilar(wordsOf, deletionText, additionText),
137
+ )) {
117
138
  if (op.kind === "ctx") {
118
139
  pairs.push([deletionOffset, additionOffset]);
119
140
  deletionOffset++;
@@ -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";