@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.
- package/package.json +5 -5
- package/src/App.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +14 -2
- package/src/App.export.test.tsx +1 -2
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-review.test.tsx +16 -0
- package/src/App.share-choice.test.tsx +105 -0
- package/src/App.submit-confirm.test.tsx +34 -5
- package/src/App.test.tsx +2 -2
- package/src/App.toast.test.tsx +3 -1
- package/src/App.tsx +180 -5
- package/src/App.walk.test.tsx +2 -2
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +20 -3
- package/src/app-view-model.ts +4 -0
- package/src/components/AnnotatableFileView.tsx +10 -2
- package/src/components/AnnotationCards.tsx +40 -6
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/CompletionOverlay.stories.tsx +15 -1
- package/src/components/CompletionOverlay.tsx +22 -5
- package/src/components/ConfirmCard.stories.tsx +2 -0
- package/src/components/ConfirmCard.tsx +45 -18
- package/src/components/ConfirmDialog.tsx +11 -12
- package/src/components/DiffContentView.tsx +9 -5
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/GridTabContent.tsx +2 -0
- package/src/components/ManageAccessDialog.stories.tsx +36 -0
- package/src/components/ManageAccessDialog.test.tsx +94 -0
- package/src/components/ManageAccessDialog.tsx +122 -0
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/ShareChoiceDialog.stories.tsx +34 -0
- package/src/components/ShareChoiceDialog.test.tsx +103 -0
- package/src/components/ShareChoiceDialog.tsx +80 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadTree.tsx +11 -6
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -1
- package/src/components/ThreadView.tsx +71 -8
- package/src/components/Toast.tsx +0 -5
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +277 -61
- package/src/components/primitives/Card.tsx +3 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +8 -1
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/config-identity.test.ts +78 -0
- package/src/config.ts +44 -1
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/intent-dispatch.test.ts +17 -2
- package/src/intent-dispatch.ts +7 -1
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +1 -1
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/run.ts +31 -19
- package/src/serve.ts +1 -1
- package/src/share.ts +11 -3
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +1 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +36 -2
- package/src/thread-controller.switch-cache.test.ts +146 -0
- package/src/thread-controller.tree.test.ts +1 -0
- package/src/thread-controller.ts +171 -24
- package/src/use-annotation-surface.tsx +53 -29
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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("
|
|
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
|
|
34
|
+
const app = buildStoryTree(stories)[0]!;
|
|
23
35
|
|
|
24
36
|
// Assert
|
|
25
|
-
expect(
|
|
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(
|
|
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
|
|
49
|
+
const app = buildStoryTree(stories)[0]!;
|
|
38
50
|
|
|
39
51
|
// Assert
|
|
40
|
-
expect(
|
|
41
|
-
expect(childLabels(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|
package/src/components/story.ts
CHANGED
|
@@ -15,7 +15,9 @@ export interface StoryMeta {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export interface Story {
|
|
18
|
-
render
|
|
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 === "
|
|
37
|
-
value
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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(`${
|
|
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
|
|
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
|
+
});
|