@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.
- package/package.json +5 -5
- package/src/App.focus.test.ts +147 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.regression.test.tsx +94 -0
- package/src/App.share-choice.test.tsx +105 -0
- package/src/App.test.tsx +11 -8
- package/src/App.toast.test.tsx +3 -1
- package/src/App.tsx +648 -221
- package/src/App.visual.test.tsx +57 -0
- package/src/App.workbench.test.tsx +41 -22
- package/src/app-screens.tsx +181 -123
- package/src/app-view-model.ts +1 -0
- package/src/components/AnnotatableFileView.tsx +9 -1
- package/src/components/AnnotationCards.tsx +22 -4
- package/src/components/AppShell.brand.test.tsx +1 -1
- package/src/components/AppShell.tsx +14 -1
- package/src/components/ChangesColumn.tsx +40 -12
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/ConfirmDialog.tsx +8 -1
- package/src/components/DiffContentView.tsx +12 -2
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tsx +84 -26
- package/src/components/GridTabContent.tsx +12 -1
- package/src/components/ManageAccessDialog.stories.tsx +36 -0
- package/src/components/ManageAccessDialog.test.tsx +94 -0
- package/src/components/ManageAccessDialog.tsx +120 -0
- package/src/components/PanelColumn.tsx +3 -0
- package/src/components/ProjectTreeView.test.tsx +39 -12
- package/src/components/ProjectTreeView.tsx +38 -10
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +16 -3
- package/src/components/RootOverlay.stories.tsx +1 -0
- package/src/components/RootOverlay.tsx +19 -6
- package/src/components/SettingsDialog.stories.tsx +3 -3
- package/src/components/SettingsDialog.tsx +3 -4
- package/src/components/ShareChoiceDialog.stories.tsx +34 -0
- package/src/components/ShareChoiceDialog.test.tsx +103 -0
- package/src/components/ShareChoiceDialog.tsx +82 -0
- package/src/components/ThreadFooter.test.tsx +12 -7
- package/src/components/ThreadFooter.tsx +18 -4
- package/src/components/ThreadTree.tsx +79 -36
- package/src/components/ThreadView.slash.test.tsx +36 -0
- package/src/components/ThreadView.tsx +12 -2
- package/src/components/WelcomePlayground.tsx +2 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +147 -52
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +5 -0
- package/src/components/primitives/icons.ts +1 -1
- package/src/components/quick-actions-editor.stories.tsx +2 -0
- package/src/components/quick-actions-editor.test.tsx +35 -1
- package/src/components/quick-actions-editor.tsx +23 -22
- package/src/components/session-tree.test.ts +3 -3
- package/src/components/session-tree.ts +1 -1
- package/src/config-identity.test.ts +78 -0
- package/src/config.ts +45 -1
- package/src/diff-intraline.ts +26 -5
- 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.ts +1 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/run.ts +22 -2
- package/src/share.ts +10 -2
- package/src/thread-chords.test.ts +33 -1
- package/src/thread-chords.ts +114 -29
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +1 -0
- package/src/thread-controller.share.test.ts +36 -2
- package/src/thread-controller.tree.test.ts +1 -0
- package/src/thread-controller.ts +18 -3
- package/src/use-annotation-surface.tsx +32 -2
- package/src/use-frame-measure.ts +4 -1
- package/src/use-settings-dialog.tsx +33 -7
- package/src/visual/contrast.test.ts +35 -0
- 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
|
-
/**
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}>{
|
|
136
|
+
<text fg={isSelected ? tokens.text : tokens.textMuted}>{action.prompt}</text>
|
|
132
137
|
</box>
|
|
133
138
|
{action.metadata ? (
|
|
134
|
-
<box
|
|
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
|
|
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">(
|
|
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"
|
|
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
|
|
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 -
|
|
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("
|
|
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: "
|
|
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);
|
package/src/diff-intraline.ts
CHANGED
|
@@ -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(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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,
|
|
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("
|
|
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.
|
|
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", () => {
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -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.
|
|
391
|
+
deps.setMode({ type: "shareChoice", index: 0 });
|
|
386
392
|
}
|
|
387
393
|
|
|
388
394
|
function handleCut(_intent: IntentOfType<"cut">, deps: IntentDispatchDeps): void {
|