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

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.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +14 -2
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.identity.test.tsx +78 -0
  6. package/src/App.plan-review.test.tsx +16 -0
  7. package/src/App.share-choice.test.tsx +105 -0
  8. package/src/App.submit-confirm.test.tsx +34 -5
  9. package/src/App.test.tsx +2 -2
  10. package/src/App.toast.test.tsx +3 -1
  11. package/src/App.tsx +180 -5
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  14. package/src/ansi-screen.test.ts +28 -0
  15. package/src/ansi-screen.ts +72 -0
  16. package/src/app-screens.tsx +20 -3
  17. package/src/app-view-model.ts +4 -0
  18. package/src/components/AnnotatableFileView.tsx +10 -2
  19. package/src/components/AnnotationCards.tsx +40 -6
  20. package/src/components/AnsiScreen.stories.tsx +12 -0
  21. package/src/components/AnsiScreen.tsx +28 -0
  22. package/src/components/CompletionOverlay.stories.tsx +15 -1
  23. package/src/components/CompletionOverlay.tsx +22 -5
  24. package/src/components/ConfirmCard.stories.tsx +2 -0
  25. package/src/components/ConfirmCard.tsx +45 -18
  26. package/src/components/ConfirmDialog.tsx +11 -12
  27. package/src/components/DiffContentView.tsx +9 -5
  28. package/src/components/FileContentsView.tsx +1 -1
  29. package/src/components/GridTabContent.tsx +2 -0
  30. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  31. package/src/components/ManageAccessDialog.test.tsx +94 -0
  32. package/src/components/ManageAccessDialog.tsx +122 -0
  33. package/src/components/PromptDialog.test.tsx +2 -2
  34. package/src/components/PromptDialog.tsx +9 -10
  35. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  36. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  37. package/src/components/ShareChoiceDialog.tsx +80 -0
  38. package/src/components/SlashComposer.stories.tsx +37 -0
  39. package/src/components/SlashComposer.tsx +87 -0
  40. package/src/components/ThreadTree.tsx +11 -6
  41. package/src/components/ThreadView.selection.test.tsx +88 -1
  42. package/src/components/ThreadView.slash.test.tsx +0 -1
  43. package/src/components/ThreadView.tsx +71 -8
  44. package/src/components/Toast.tsx +0 -5
  45. package/src/components/WalkWizard.stories.tsx +2 -2
  46. package/src/components/WalkWizard.tsx +10 -12
  47. package/src/components/__snapshots__/stories.test.tsx.snap +277 -61
  48. package/src/components/primitives/Card.tsx +3 -0
  49. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  50. package/src/components/primitives/DialogActions.tsx +39 -0
  51. package/src/components/session-tree.ts +7 -6
  52. package/src/components/stories-app.tsx +13 -4
  53. package/src/components/stories.test.tsx +8 -1
  54. package/src/components/story-tree.test.ts +32 -24
  55. package/src/components/story.ts +32 -15
  56. package/src/config-identity.test.ts +78 -0
  57. package/src/config.ts +44 -1
  58. package/src/diff-intraline.test.ts +75 -2
  59. package/src/diff-intraline.ts +95 -26
  60. package/src/github-identity.test.ts +58 -0
  61. package/src/github-identity.ts +41 -0
  62. package/src/intent-dispatch.test.ts +17 -2
  63. package/src/intent-dispatch.ts +7 -1
  64. package/src/keymap.test.ts +2 -2
  65. package/src/keymap.ts +1 -1
  66. package/src/pasted-image.test.ts +67 -0
  67. package/src/pasted-image.ts +60 -0
  68. package/src/run.ts +31 -19
  69. package/src/serve.ts +1 -1
  70. package/src/share.ts +11 -3
  71. package/src/slash-palette.test.ts +17 -0
  72. package/src/slash-palette.ts +11 -0
  73. package/src/thread-controller.curate.test.ts +1 -0
  74. package/src/thread-controller.derivation.test.ts +62 -0
  75. package/src/thread-controller.live-diff.test.ts +1 -0
  76. package/src/thread-controller.optimistic.test.ts +69 -0
  77. package/src/thread-controller.share.test.ts +36 -2
  78. package/src/thread-controller.switch-cache.test.ts +146 -0
  79. package/src/thread-controller.tree.test.ts +1 -0
  80. package/src/thread-controller.ts +171 -24
  81. package/src/use-annotation-surface.tsx +53 -29
  82. package/src/use-settings-dialog.tsx +24 -0
@@ -33,7 +33,7 @@ export function projectName(workspace: WorkspaceKey): string {
33
33
  export type InboxRow =
34
34
  | { kind: "section"; id: string; label: string }
35
35
  | { kind: "project"; id: string; label: string }
36
- | { kind: "thread"; id: string; session: Thread; selectionIndex: number };
36
+ | { kind: "thread"; id: string; session: Thread; selectionIndex: number; depth: number };
37
37
 
38
38
  export interface GroupedInbox {
39
39
  rows: InboxRow[];
@@ -78,14 +78,14 @@ export function groupInbox(
78
78
 
79
79
  const rows: InboxRow[] = [];
80
80
  const ordered: Thread[] = [];
81
- const pushThread = (session: Thread): void => {
82
- rows.push({ kind: "thread", id: session.id, session, selectionIndex: ordered.length });
81
+ const pushThread = (session: Thread, depth: number): void => {
82
+ rows.push({ kind: "thread", id: session.id, session, selectionIndex: ordered.length, depth });
83
83
  ordered.push(session);
84
84
  };
85
85
 
86
86
  if (pinned.length > 0) {
87
87
  rows.push({ kind: "section", id: "section:pinned", label: "Starred" });
88
- for (const session of pinned) pushThread(session);
88
+ for (const session of pinned) pushThread(session, 1);
89
89
  }
90
90
 
91
91
  const projectEntries = [...projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name));
@@ -93,13 +93,14 @@ export function groupInbox(
93
93
  rows.push({ kind: "section", id: "section:projects", label: "Projects" });
94
94
  for (const [key, project] of projectEntries) {
95
95
  rows.push({ kind: "project", id: `project:${key}`, label: project.name });
96
- for (const session of project.sessions) pushThread(session);
96
+ // a project's threads cascade one level under its name
97
+ for (const session of project.sessions) pushThread(session, 2);
97
98
  }
98
99
  }
99
100
 
100
101
  if (standalone.length > 0) {
101
102
  rows.push({ kind: "section", id: "section:threads", label: "Threads" });
102
- for (const session of standalone) pushThread(session);
103
+ for (const session of standalone) pushThread(session, 1);
103
104
  }
104
105
 
105
106
  return { rows, ordered };
@@ -13,9 +13,10 @@ import { ThemeProvider } from "./theme-context";
13
13
  import { buildStoryTree, loadStories, type LoadedStory } from "./story";
14
14
  import { PaneGrid } from "./PaneGrid";
15
15
  import { ShellHeader } from "./ShellHeader";
16
+ import { AnsiScreen } from "./AnsiScreen";
16
17
  import { Tree } from "./primitives/Tree";
17
18
  import { NERD } from "./primitives/icons";
18
- import { allFolderIds, flattenTree } from "./primitives/tree-model";
19
+ import { flattenTree } from "./primitives/tree-model";
19
20
 
20
21
  interface StoriesAppProps {
21
22
  stories: LoadedStory[];
@@ -30,8 +31,8 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
30
31
  );
31
32
  const firstStoryId = `${stories[0]!.moduleTitle}/${stories[0]!.storyName}`;
32
33
 
33
- const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(() =>
34
- allFolderIds(treeNodes),
34
+ const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(
35
+ () => new Set(treeNodes.filter((node) => node.children !== undefined).map((node) => node.id)),
35
36
  );
36
37
  const [selectedId, setSelectedId] = useState(firstStoryId);
37
38
  const [openedId, setOpenedId] = useState(firstStoryId);
@@ -126,7 +127,15 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
126
127
  <scrollbox style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }} focused={false}>
127
128
  {/* remount per story so component-local state never leaks across */}
128
129
  <box key={openedId} style={{ flexDirection: "column" }}>
129
- {opened.story.render()}
130
+ {opened.story.ansi ? (
131
+ <AnsiScreen
132
+ ansi={opened.story.ansi()}
133
+ cols={opened.story.size?.width ?? 80}
134
+ rows={opened.story.size?.height ?? 24}
135
+ />
136
+ ) : (
137
+ opened.story.render?.()
138
+ )}
130
139
  </box>
131
140
  </scrollbox>
132
141
  ) : (
@@ -5,10 +5,12 @@
5
5
  * stories file fails the harness, so the safety net cannot silently thin.
6
6
  */
7
7
 
8
+ import React from "react";
8
9
  import { describe, expect, test } from "bun:test";
9
10
  import { testRender } from "@opentui/react/test-utils";
10
11
  import { RGBA } from "@opentui/core";
11
12
  import { componentFilesMissingStories, loadStories } from "./story";
13
+ import { AnsiScreen } from "./AnsiScreen";
12
14
  import { allowEventLoopUpdates } from "../test-support";
13
15
 
14
16
  const stories = await loadStories();
@@ -34,9 +36,14 @@ describe("stories catalog", () => {
34
36
  test(`${moduleTitle}/${storyName} renders, snapshots, and carries its colors`, async () => {
35
37
  // Arrange
36
38
  const size = story.size ?? { width: 80, height: 24 };
39
+ const node = story.ansi ? (
40
+ <AnsiScreen ansi={story.ansi()} cols={size.width} rows={size.height} />
41
+ ) : (
42
+ story.render!()
43
+ );
37
44
 
38
45
  // Act
39
- const setup = await testRender(story.render(), size);
46
+ const setup = await testRender(node, size);
40
47
 
41
48
  allowEventLoopUpdates();
42
49
  await setup.waitForVisualIdle();
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { buildStoryTree, type LoadedStory } from "./story";
2
+ import { buildStoryTree, type LoadedStory, type StorySection } from "./story";
3
3
  import type { TreeNode } from "./primitives/tree-model";
4
4
 
5
5
  /** A story object whose render is never invoked in these structural tests. */
6
6
  const STUB = { render: () => null };
7
7
 
8
- function story(moduleTitle: string, storyName: string): LoadedStory {
9
- return { moduleTitle, storyName, story: STUB };
8
+ function story(moduleTitle: string, storyName: string, section: StorySection = "App"): LoadedStory {
9
+ return { section, moduleTitle, storyName, story: STUB };
10
10
  }
11
11
 
12
12
  function childLabels(node: TreeNode): string[] {
@@ -14,19 +14,31 @@ function childLabels(node: TreeNode): string[] {
14
14
  }
15
15
 
16
16
  describe("buildStoryTree", () => {
17
- test("splits moduleTitle on / into nested folders keyed by their path", () => {
17
+ test("groups the top level by section", () => {
18
+ const tree = buildStoryTree([
19
+ story("Primitives/Tree", "Default"),
20
+ story("Collaborator Join", "JoinSplash", "SSH"),
21
+ ]);
22
+
23
+ expect(tree.map((node) => [node.id, node.label])).toEqual([
24
+ ["App", "App"],
25
+ ["SSH", "SSH"],
26
+ ]);
27
+ });
28
+
29
+ test("splits moduleTitle on / into nested folders under the section", () => {
18
30
  // Arrange
19
31
  const stories = [story("Primitives/Tree", "Default"), story("Layout/AppShell", "Default")];
20
32
 
21
33
  // Act
22
- const tree = buildStoryTree(stories);
34
+ const app = buildStoryTree(stories)[0]!;
23
35
 
24
36
  // Assert
25
- expect(tree.map((node) => [node.id, node.label])).toEqual([
26
- ["Primitives", "Primitives"],
27
- ["Layout", "Layout"],
37
+ expect(app.children!.map((node) => [node.id, node.label])).toEqual([
38
+ ["App/Primitives", "Primitives"],
39
+ ["App/Layout", "Layout"],
28
40
  ]);
29
- expect(tree[0]!.children!.map((node) => node.id)).toEqual(["Primitives/Tree"]);
41
+ expect(app.children![0]!.children!.map((node) => node.id)).toEqual(["App/Primitives/Tree"]);
30
42
  });
31
43
 
32
44
  test("groups sibling components under one shared parent folder", () => {
@@ -34,11 +46,11 @@ describe("buildStoryTree", () => {
34
46
  const stories = [story("Primitives/Tree", "Default"), story("Primitives/Card", "Default")];
35
47
 
36
48
  // Act
37
- const tree = buildStoryTree(stories);
49
+ const app = buildStoryTree(stories)[0]!;
38
50
 
39
51
  // Assert
40
- expect(tree).toHaveLength(1);
41
- expect(childLabels(tree[0]!)).toEqual(["Tree", "Card"]);
52
+ expect(app.children).toHaveLength(1);
53
+ expect(childLabels(app.children![0]!)).toEqual(["Tree", "Card"]);
42
54
  });
43
55
 
44
56
  test("nests to the full depth of the moduleTitle path", () => {
@@ -46,13 +58,12 @@ describe("buildStoryTree", () => {
46
58
  const stories = [story("A/B/C", "Only")];
47
59
 
48
60
  // Act
49
- const tree = buildStoryTree(stories);
50
- const a = tree[0]!;
61
+ const a = buildStoryTree(stories)[0]!.children![0]!;
51
62
  const b = a.children![0]!;
52
63
 
53
64
  // Assert
54
- expect([a.id, b.id]).toEqual(["A", "A/B"]);
55
- expect(b.children![0]!.id).toBe("A/B/C");
65
+ expect([a.id, b.id]).toEqual(["App/A", "App/A/B"]);
66
+ expect(b.children![0]!.id).toBe("App/A/B/C");
56
67
  });
57
68
 
58
69
  test("adds each story as a leaf keyed `${moduleTitle}/${storyName}` under its component", () => {
@@ -60,11 +71,10 @@ describe("buildStoryTree", () => {
60
71
  const stories = [story("Primitives/Tree", "Empty"), story("Primitives/Tree", "Deep")];
61
72
 
62
73
  // Act
63
- const tree = buildStoryTree(stories);
64
- const component = tree[0]!.children![0]!;
74
+ const component = buildStoryTree(stories)[0]!.children![0]!.children![0]!;
65
75
 
66
76
  // Assert
67
- expect(component.id).toBe("Primitives/Tree");
77
+ expect(component.id).toBe("App/Primitives/Tree");
68
78
  expect(component.children!.map((leaf) => [leaf.id, leaf.label])).toEqual([
69
79
  ["Primitives/Tree/Empty", "Empty"],
70
80
  ["Primitives/Tree/Deep", "Deep"],
@@ -76,8 +86,7 @@ describe("buildStoryTree", () => {
76
86
  const stories = [story("Primitives/Card", "Card")];
77
87
 
78
88
  // Act
79
- const tree = buildStoryTree(stories);
80
- const hoisted = tree[0]!.children![0]!;
89
+ const hoisted = buildStoryTree(stories)[0]!.children![0]!.children![0]!;
81
90
 
82
91
  // Assert
83
92
  expect(hoisted.children).toBeUndefined();
@@ -90,11 +99,10 @@ describe("buildStoryTree", () => {
90
99
  const stories = [story("Primitives/Card", "Default")];
91
100
 
92
101
  // Act
93
- const tree = buildStoryTree(stories);
94
- const component = tree[0]!.children![0]!;
102
+ const component = buildStoryTree(stories)[0]!.children![0]!.children![0]!;
95
103
 
96
104
  // Assert
97
- expect(component.id).toBe("Primitives/Card");
105
+ expect(component.id).toBe("App/Primitives/Card");
98
106
  expect(component.children!.map((leaf) => leaf.id)).toEqual(["Primitives/Card/Default"]);
99
107
  });
100
108
  });
@@ -15,7 +15,9 @@ export interface StoryMeta {
15
15
  }
16
16
 
17
17
  export interface Story {
18
- render: () => React.ReactNode;
18
+ render?: () => React.ReactNode;
19
+ /** Raw ANSI to paint through AnsiScreen instead of a React tree; exactly one of render or ansi is set. */
20
+ ansi?: () => string;
19
21
  /**
20
22
  * Colors (hex tokens) that must appear among the rendered frame's styled
21
23
  * spans - the color regression net for color-bearing stories.
@@ -25,18 +27,21 @@ export interface Story {
25
27
  size?: { width: number; height: number };
26
28
  }
27
29
 
30
+ export type StorySection = "App" | "SSH";
31
+
28
32
  export interface LoadedStory {
33
+ section: StorySection;
29
34
  moduleTitle: string;
30
35
  storyName: string;
31
36
  story: Story;
32
37
  }
33
38
 
34
39
  export function isStory(value: unknown): value is Story {
40
+ if (typeof value !== "object" || value === null) return false;
41
+
35
42
  return (
36
- typeof value === "object" &&
37
- value !== null &&
38
- "render" in value &&
39
- typeof value.render === "function"
43
+ ("render" in value && typeof value.render === "function") ||
44
+ ("ansi" in value && typeof value.ansi === "function")
40
45
  );
41
46
  }
42
47
 
@@ -49,22 +54,34 @@ function isStoryMeta(value: unknown): value is StoryMeta {
49
54
  );
50
55
  }
51
56
 
52
- /** Import every *.stories.tsx next to the components and flatten the exports. */
53
- export async function loadStories(): Promise<LoadedStory[]> {
54
- const glob = new Bun.Glob("**/*.stories.tsx");
55
- const files = [...glob.scanSync({ cwd: import.meta.dir })].sort();
56
- const loaded: LoadedStory[] = [];
57
+ const GATEWAY_SRC_DIR = `${import.meta.dir}/../../../gateway/src`;
58
+
59
+ async function collectStories(
60
+ dir: string,
61
+ pattern: string,
62
+ section: StorySection,
63
+ loaded: LoadedStory[],
64
+ ): Promise<void> {
65
+ const glob = new Bun.Glob(pattern);
66
+ const files = [...glob.scanSync({ cwd: dir })].sort();
57
67
 
58
68
  for (const file of files) {
59
- const moduleExports = await import(`${import.meta.dir}/${file}`);
69
+ const moduleExports = await import(`${dir}/${file}`);
60
70
  const meta = moduleExports["meta"];
61
- const moduleTitle = isStoryMeta(meta) ? meta.title : file.replace(/\.stories\.tsx$/, "");
71
+ const moduleTitle = isStoryMeta(meta) ? meta.title : file.replace(/\.stories\.tsx?$/, "");
62
72
 
63
73
  for (const [exportName, exported] of Object.entries(moduleExports)) {
64
74
  if (exportName === "meta" || !isStory(exported)) continue;
65
- loaded.push({ moduleTitle, storyName: exportName, story: exported });
75
+ loaded.push({ section, moduleTitle, storyName: exportName, story: exported });
66
76
  }
67
77
  }
78
+ }
79
+
80
+ export async function loadStories(): Promise<LoadedStory[]> {
81
+ const loaded: LoadedStory[] = [];
82
+
83
+ await collectStories(import.meta.dir, "**/*.stories.tsx", "App", loaded);
84
+ await collectStories(GATEWAY_SRC_DIR, "**/*.stories.ts", "SSH", loaded);
68
85
 
69
86
  return loaded;
70
87
  }
@@ -102,11 +119,11 @@ export function buildStoryTree(stories: LoadedStory[]): TreeNode[] {
102
119
  const roots: TreeNode[] = [];
103
120
  const foldersByPath = new Map<string, StoryTreeFolder>();
104
121
 
105
- for (const { moduleTitle, storyName } of stories) {
122
+ for (const { section, moduleTitle, storyName } of stories) {
106
123
  let siblings = roots;
107
124
  let path = "";
108
125
 
109
- for (const segment of moduleTitle.split("/")) {
126
+ for (const segment of [section, ...moduleTitle.split("/")]) {
110
127
  path = path === "" ? segment : `${path}/${segment}`;
111
128
  let folder = foldersByPath.get(path);
112
129
  if (folder === undefined) {
@@ -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);
@@ -1,5 +1,11 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { intralineRunsByRow, wordLevelChanges, type IntralineRun } from "./diff-intraline";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import * as intraline from "./diff-intraline";
3
+ import {
4
+ createIntralineResolver,
5
+ intralineRunsByRow,
6
+ wordLevelChanges,
7
+ type IntralineRun,
8
+ } from "./diff-intraline";
3
9
  import type { DiffRow } from "./view-diff";
4
10
 
5
11
  function line(kind: DiffRow["kind"], text: string): DiffRow {
@@ -163,3 +169,70 @@ describe("intralineRunsByRow", () => {
163
169
  expect(changedText(runsByRow.get(3)!)).toBe("FOUR");
164
170
  });
165
171
  });
172
+
173
+ describe("createIntralineResolver", () => {
174
+ test("resolves each row to the same runs the eager pass would, on demand", () => {
175
+ // Arrange - context around two separate change blocks
176
+ const rows = [
177
+ line("ctx", "header"),
178
+ line("del", "one two"),
179
+ line("add", "one TWO"),
180
+ line("ctx", "middle"),
181
+ line("del", "alpha beta"),
182
+ line("add", "alpha GAMMA"),
183
+ line("ctx", "footer"),
184
+ ];
185
+ const eager = intralineRunsByRow(rows);
186
+
187
+ // Act
188
+ const resolver = createIntralineResolver(rows);
189
+
190
+ // Assert - lazy row-by-row output matches the eager map, including undefined for context rows
191
+ for (let index = 0; index < rows.length; index++) {
192
+ expect(resolver.runsForRow(index)).toEqual(eager.get(index));
193
+ }
194
+ });
195
+
196
+ test("a second call for the same row returns the cached runs", () => {
197
+ const rows = [line("del", "one two"), line("add", "one TWO")];
198
+ const resolver = createIntralineResolver(rows);
199
+
200
+ expect(resolver.runsForRow(0)).toBe(resolver.runsForRow(0));
201
+ });
202
+
203
+ // The performance guard: a virtualized diff must word-diff only the change blocks whose rows are on
204
+ // screen. This is deterministic (a call count, not a wall-clock), so it fails on any machine if a
205
+ // future change makes the resolver eager again - the regression the nav-latency benchmark would show.
206
+ test("word-diffs only the accessed block, not the whole diff", () => {
207
+ // Arrange - 50 independent change blocks, each a single del/add pair separated by context
208
+ const rows: DiffRow[] = [];
209
+
210
+ for (let block = 0; block < 50; block++) {
211
+ rows.push(
212
+ line("ctx", `ctx ${block}`),
213
+ line("del", `old ${block}`),
214
+ line("add", `new ${block}`),
215
+ );
216
+ }
217
+ const spy = spyOn(intraline, "wordLevelChanges");
218
+
219
+ try {
220
+ // Act - the eager pass word-diffs every block
221
+ spy.mockClear();
222
+ intralineRunsByRow(rows);
223
+ const eagerCalls = spy.mock.calls.length;
224
+
225
+ // Act - the resolver word-diffs only the one block whose row is asked for
226
+ spy.mockClear();
227
+ const resolver = createIntralineResolver(rows);
228
+ resolver.runsForRow(1); // a row in block 0
229
+ const oneBlockCalls = spy.mock.calls.length;
230
+
231
+ // Assert - eager pays for all 50 blocks; lazy pays for one
232
+ expect(eagerCalls).toBe(50);
233
+ expect(oneBlockCalls).toBe(1);
234
+ } finally {
235
+ spy.mockRestore();
236
+ }
237
+ });
238
+ });