@cueloop/client 0.1.0-alpha.15 → 0.1.0-alpha.17
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.plan-review.test.tsx +1 -1
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +16 -16
- package/src/App.test.tsx +1 -1
- package/src/App.tsx +193 -139
- package/src/App.walk.test.tsx +208 -0
- package/src/components/AnnotationCard.tsx +4 -4
- package/src/components/ComposeBar.tsx +2 -2
- package/src/components/ConfirmCard.stories.tsx +15 -0
- package/src/components/ConfirmCard.tsx +8 -1
- package/src/components/DiffSheet.tsx +8 -8
- package/src/components/PlanSheet.stories.tsx +3 -3
- package/src/components/PlanSheet.tsx +31 -31
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +130 -0
- package/src/components/stories.test.tsx +1 -1
- package/src/components/story-fixtures.ts +1 -1
- package/src/components/story.ts +1 -1
- package/src/components/syntax-highlight.ts +1 -1
- package/src/components/theme-context.tsx +1 -1
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +29 -29
- package/src/config.ts +13 -12
- package/src/editor.ts +1 -1
- package/src/herdr.ts +1 -1
- package/src/key-bindings.test.ts +18 -1
- package/src/key-bindings.ts +23 -2
- package/src/keymap.test.ts +138 -101
- package/src/keymap.ts +81 -56
- package/src/run.ts +3 -3
- package/src/serve.test.ts +6 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.ts +140 -66
- package/src/theme.ts +20 -1
- package/src/view-diff.test.ts +8 -8
- package/src/view-diff.ts +22 -22
- package/src/{view.test.ts → view-plan.test.ts} +48 -48
- package/src/view-plan.ts +362 -0
- package/src/walk.test.ts +110 -0
- package/src/walk.ts +65 -0
- package/src/components/format.ts +0 -6
- package/src/view.ts +0 -341
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guided walk's focused card wizard: one card per changed file
|
|
3
|
+
* floating over the dimmed diff, with a plain step count in the title - no
|
|
4
|
+
* progress bar. Below the main card a separate gray-bordered block carries
|
|
5
|
+
* the submitting agent's note for the file, when one exists. Past the last
|
|
6
|
+
* file the end card offers the submit action directly with honest counts.
|
|
7
|
+
* The keys live in the keymap's walk overlay; this component only renders.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from "react";
|
|
11
|
+
import type { Theme } from "../theme";
|
|
12
|
+
import { useComponentTheme } from "./theme-context";
|
|
13
|
+
import type { WalkFile } from "../walk";
|
|
14
|
+
import { viewedCount } from "../walk";
|
|
15
|
+
import { Card } from "./primitives/Card";
|
|
16
|
+
import { Button } from "./primitives/Button";
|
|
17
|
+
import { Toolbar } from "./primitives/Toolbar";
|
|
18
|
+
|
|
19
|
+
export interface WalkWizardProps {
|
|
20
|
+
files: WalkFile[];
|
|
21
|
+
/** The wizard step; index === files.length renders the end card. */
|
|
22
|
+
index: number;
|
|
23
|
+
viewedPaths: ReadonlySet<string>;
|
|
24
|
+
/** The agent's note for the current file; the gray block renders only when set. */
|
|
25
|
+
note?: string;
|
|
26
|
+
/** Terminal width bounds the card width. */
|
|
27
|
+
terminalWidth: number;
|
|
28
|
+
/** End-card submit: leaves the walk and opens the rail confirm. */
|
|
29
|
+
onSubmitRequest: () => void;
|
|
30
|
+
onBack: () => void;
|
|
31
|
+
theme?: Theme;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The card never crowds the terminal edge nor stretches past reading width. */
|
|
35
|
+
function wizardWidth(terminalWidth: number): number {
|
|
36
|
+
return Math.min(72, Math.max(24, terminalWidth - 8));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Note rows from a crude wrap estimate, capped so the block stays a block. */
|
|
40
|
+
function noteRows(note: string, innerWidth: number): number {
|
|
41
|
+
return Math.min(3, Math.max(1, Math.ceil(note.length / Math.max(1, innerWidth))));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function WalkWizard({
|
|
45
|
+
files,
|
|
46
|
+
index,
|
|
47
|
+
viewedPaths,
|
|
48
|
+
note,
|
|
49
|
+
terminalWidth,
|
|
50
|
+
onSubmitRequest,
|
|
51
|
+
onBack,
|
|
52
|
+
theme,
|
|
53
|
+
}: WalkWizardProps): React.ReactNode {
|
|
54
|
+
const tokens = useComponentTheme(theme);
|
|
55
|
+
const width = wizardWidth(terminalWidth);
|
|
56
|
+
const viewed = viewedCount(files, viewedPaths);
|
|
57
|
+
const atEnd = index >= files.length;
|
|
58
|
+
const currentFile = files[index];
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<box
|
|
62
|
+
style={{
|
|
63
|
+
position: "absolute",
|
|
64
|
+
left: 0,
|
|
65
|
+
top: 0,
|
|
66
|
+
width: "100%",
|
|
67
|
+
height: "100%",
|
|
68
|
+
alignItems: "center",
|
|
69
|
+
justifyContent: "center",
|
|
70
|
+
flexDirection: "column",
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<box style={{ width, flexDirection: "column" }}>
|
|
74
|
+
{atEnd || !currentFile ? (
|
|
75
|
+
<Card
|
|
76
|
+
title=" walk complete "
|
|
77
|
+
contentRows={3}
|
|
78
|
+
borderColor={tokens.green}
|
|
79
|
+
backgroundColor={tokens.panel}
|
|
80
|
+
theme={theme}
|
|
81
|
+
>
|
|
82
|
+
<text fg={tokens.text}>
|
|
83
|
+
{viewed === files.length
|
|
84
|
+
? `every file viewed (${viewed}/${files.length})`
|
|
85
|
+
: `${viewed} of ${files.length} files viewed`}
|
|
86
|
+
</text>
|
|
87
|
+
<box style={{ height: 1 }} />
|
|
88
|
+
<Toolbar>
|
|
89
|
+
<Button variant="solid" marginRight={2} onPress={onSubmitRequest} theme={theme}>
|
|
90
|
+
{" Submit review "}
|
|
91
|
+
</Button>
|
|
92
|
+
<Button onPress={onBack} theme={theme}>
|
|
93
|
+
{" [ back "}
|
|
94
|
+
</Button>
|
|
95
|
+
</Toolbar>
|
|
96
|
+
</Card>
|
|
97
|
+
) : (
|
|
98
|
+
<>
|
|
99
|
+
<Card
|
|
100
|
+
title={` file ${index + 1} of ${files.length} · ${viewed} viewed `}
|
|
101
|
+
contentRows={3 + Math.max(1, currentFile.preview.length)}
|
|
102
|
+
borderColor={tokens.accent}
|
|
103
|
+
backgroundColor={tokens.panel}
|
|
104
|
+
theme={theme}
|
|
105
|
+
>
|
|
106
|
+
<text fg={tokens.text}>{currentFile.path}</text>
|
|
107
|
+
<text>
|
|
108
|
+
<span fg={tokens.green}>{`+${currentFile.added}`}</span>
|
|
109
|
+
<span fg={tokens.textDim}>{" "}</span>
|
|
110
|
+
<span fg={tokens.red}>{`-${currentFile.removed}`}</span>
|
|
111
|
+
<span fg={tokens.textDim}>{viewedPaths.has(currentFile.path) ? " · viewed" : ""}</span>
|
|
112
|
+
</text>
|
|
113
|
+
<box style={{ height: 1 }} />
|
|
114
|
+
{currentFile.preview.length === 0 ? (
|
|
115
|
+
<text fg={tokens.textDim}>(no changed lines)</text>
|
|
116
|
+
) : (
|
|
117
|
+
currentFile.preview.map((previewLine, previewIndex) => (
|
|
118
|
+
<text
|
|
119
|
+
key={previewIndex}
|
|
120
|
+
style={{ wrapMode: "none" }}
|
|
121
|
+
fg={previewLine.sign === "+" ? tokens.insFg : tokens.delFg}
|
|
122
|
+
>
|
|
123
|
+
{previewLine.sign + previewLine.text}
|
|
124
|
+
</text>
|
|
125
|
+
))
|
|
126
|
+
)}
|
|
127
|
+
</Card>
|
|
128
|
+
{note !== undefined ? (
|
|
129
|
+
<box style={{ marginTop: 1, flexDirection: "column" }}>
|
|
130
|
+
<Card
|
|
131
|
+
title=" agent note "
|
|
132
|
+
contentRows={noteRows(note, width - 4)}
|
|
133
|
+
borderColor={tokens.border}
|
|
134
|
+
backgroundColor={tokens.panel}
|
|
135
|
+
theme={theme}
|
|
136
|
+
>
|
|
137
|
+
<text fg={tokens.textMuted}>{note}</text>
|
|
138
|
+
</Card>
|
|
139
|
+
</box>
|
|
140
|
+
) : null}
|
|
141
|
+
</>
|
|
142
|
+
)}
|
|
143
|
+
</box>
|
|
144
|
+
</box>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -1184,6 +1184,136 @@ exports[`stories catalog theme-context/ProviderSwap renders, snapshots, and carr
|
|
|
1184
1184
|
|
|
1185
1185
|
|
|
1186
1186
|
|
|
1187
|
+
|
|
1188
|
+
"
|
|
1189
|
+
`;
|
|
1190
|
+
|
|
1191
|
+
exports[`stories catalog ConfirmCard/WithViewedSummary renders, snapshots, and carries its colors: ConfirmCard/WithViewedSummary 1`] = `
|
|
1192
|
+
"╭─ submit review ─────────────────────╮
|
|
1193
|
+
│ 1 annotations · 0 blocking │
|
|
1194
|
+
│ 2/3 files viewed │
|
|
1195
|
+
│ │
|
|
1196
|
+
│ Comment [Approve] Changes │
|
|
1197
|
+
│ │
|
|
1198
|
+
│ summary for the agent (optional) │
|
|
1199
|
+
│ │
|
|
1200
|
+
│ Submit Cancel │
|
|
1201
|
+
╰─────────────────────────────────────╯
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
"
|
|
1207
|
+
`;
|
|
1208
|
+
|
|
1209
|
+
exports[`stories catalog WalkWizard/EndCard renders, snapshots, and carries its colors: WalkWizard/EndCard 1`] = `
|
|
1210
|
+
"
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
╭─ walk complete ──────────────────────────────────────────────────────╮
|
|
1221
|
+
│ every file viewed (3/3) │
|
|
1222
|
+
│ │
|
|
1223
|
+
│ Submit review [ back │
|
|
1224
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
"
|
|
1235
|
+
`;
|
|
1236
|
+
|
|
1237
|
+
exports[`stories catalog WalkWizard/EndCardPartialPass renders, snapshots, and carries its colors: WalkWizard/EndCardPartialPass 1`] = `
|
|
1238
|
+
"
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
╭─ walk complete ──────────────────────────────────────────────────────╮
|
|
1249
|
+
│ 1 of 3 files viewed │
|
|
1250
|
+
│ │
|
|
1251
|
+
│ Submit review [ back │
|
|
1252
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
"
|
|
1263
|
+
`;
|
|
1264
|
+
|
|
1265
|
+
exports[`stories catalog WalkWizard/FirstFile renders, snapshots, and carries its colors: WalkWizard/FirstFile 1`] = `
|
|
1266
|
+
"
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
╭─ file 1 of 3 · 0 viewed ─────────────────────────────────────────────╮
|
|
1275
|
+
│ packages/daemon/src/store.ts │
|
|
1276
|
+
│ +12 -3 │
|
|
1277
|
+
│ │
|
|
1278
|
+
│ +const viewed = new Set<string>(); │
|
|
1279
|
+
│ +record.viewedPaths = [...viewed]; │
|
|
1280
|
+
│ -// viewed tracking lives in the client only │
|
|
1281
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
"
|
|
1291
|
+
`;
|
|
1292
|
+
|
|
1293
|
+
exports[`stories catalog WalkWizard/WithAgentNote renders, snapshots, and carries its colors: WalkWizard/WithAgentNote 1`] = `
|
|
1294
|
+
"
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
╭─ file 2 of 3 · 1 viewed ─────────────────────────────────────────────╮
|
|
1302
|
+
│ packages/client/src/walk.ts │
|
|
1303
|
+
│ +48 -0 │
|
|
1304
|
+
│ │
|
|
1305
|
+
│ +export function firstUnviewedIndex(files, viewed) { │
|
|
1306
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
1307
|
+
|
|
1308
|
+
╭─ agent note ─────────────────────────────────────────────────────────╮
|
|
1309
|
+
│ New module: pure helpers that pick the next unviewed file. No IO, │
|
|
1310
|
+
│ unit tested. │
|
|
1311
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
|
|
1187
1317
|
|
|
1188
1318
|
"
|
|
1189
1319
|
`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The catalog IS the regression suite
|
|
2
|
+
* The catalog IS the regression suite: every exported story renders in
|
|
3
3
|
* a virtual terminal, its char frame snapshots, and its declared colors must
|
|
4
4
|
* appear among the styled spans. A component file without a colocated
|
|
5
5
|
* stories file fails the harness, so the safety net cannot silently thin.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Shared fixture data for the stories catalog: one plan session, one diff. */
|
|
2
2
|
|
|
3
3
|
import type { Annotation, ReviewSession } from "@cueloop/schema";
|
|
4
|
-
import { buildDisplay, marksByDisplay } from "../view";
|
|
4
|
+
import { buildDisplay, marksByDisplay } from "../view-plan";
|
|
5
5
|
import { diffRows } from "../view-diff";
|
|
6
6
|
|
|
7
7
|
export const FIXTURE_PLAN = `# Migration Plan
|
package/src/components/story.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The story contract
|
|
2
|
+
* The story contract. Every component file in components/ ships a
|
|
3
3
|
* colocated *.stories.tsx that exports `meta` plus named Story objects.
|
|
4
4
|
* The catalog IS the regression suite: stories.test.tsx walks every story,
|
|
5
5
|
* snapshots its char frame, and asserts declared colors against the styled
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Code highlighting tokens
|
|
2
|
+
* Code highlighting tokens: the theme's named tokens projected onto
|
|
3
3
|
* tree-sitter capture names for the native code renderable. One SyntaxStyle
|
|
4
4
|
* per theme object, cached, so every code block on screen shares a handle.
|
|
5
5
|
* Unknown languages simply render unstyled - highlighting is an enhancement,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Provider theming for the component system
|
|
2
|
+
* Provider theming for the component system. The context default is the
|
|
3
3
|
* built-in dark theme, so embedding a component (or rendering a story) needs
|
|
4
4
|
* no provider at all. One ThemeProvider swap switches the live theme; every
|
|
5
5
|
* component also accepts an optional `theme` prop that overrides the context
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Truncate text to one line of at most maxLength characters, ellipsis-terminated. */
|
|
2
|
+
|
|
3
|
+
export function truncateToSingleLine(value: string, maxLength: number): string {
|
|
4
|
+
const oneLine = value.replace(/\n/g, " ");
|
|
5
|
+
return oneLine.length > maxLength ? oneLine.slice(0, maxLength - 1) + "…" : oneLine;
|
|
6
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -7,9 +7,9 @@ import { DARK } from "./theme";
|
|
|
7
7
|
|
|
8
8
|
describe("loadConfig", () => {
|
|
9
9
|
test("defaults when no file exists", () => {
|
|
10
|
-
const
|
|
11
|
-
expect(
|
|
12
|
-
expect(
|
|
10
|
+
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
11
|
+
expect(config.keys["comment"]).toEqual(["c"]);
|
|
12
|
+
expect(config.theme.accent).toBe(DARK.accent);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
test("user config rebinds actions and overrides theme tokens", () => {
|
|
@@ -20,12 +20,12 @@ describe("loadConfig", () => {
|
|
|
20
20
|
`[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
|
|
21
21
|
);
|
|
22
22
|
try {
|
|
23
|
-
const
|
|
24
|
-
expect(
|
|
25
|
-
expect(
|
|
26
|
-
expect(
|
|
27
|
-
expect(
|
|
28
|
-
expect(
|
|
23
|
+
const config = loadConfig({ userConfigPath: path });
|
|
24
|
+
expect(config.keys["comment"]).toEqual(["a"]);
|
|
25
|
+
expect(config.keys["submit"]).toEqual(["return", "S"]);
|
|
26
|
+
expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
|
|
27
|
+
expect(config.theme.accent).toBe("#ff0000");
|
|
28
|
+
expect(config.theme.green).toBe(DARK.green);
|
|
29
29
|
} finally {
|
|
30
30
|
rmSync(dir, { recursive: true, force: true });
|
|
31
31
|
}
|
|
@@ -39,8 +39,8 @@ describe("loadConfig", () => {
|
|
|
39
39
|
Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
|
|
40
40
|
writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
|
|
41
41
|
try {
|
|
42
|
-
const
|
|
43
|
-
expect(
|
|
42
|
+
const config = loadConfig({ userConfigPath: user, repoRoot });
|
|
43
|
+
expect(config.keys["comment"]).toEqual(["z"]);
|
|
44
44
|
} finally {
|
|
45
45
|
rmSync(dir, { recursive: true, force: true });
|
|
46
46
|
}
|
|
@@ -51,8 +51,8 @@ describe("loadConfig", () => {
|
|
|
51
51
|
const path = join(dir, "config.toml");
|
|
52
52
|
writeFileSync(path, "not [valid toml");
|
|
53
53
|
try {
|
|
54
|
-
const
|
|
55
|
-
expect(
|
|
54
|
+
const config = loadConfig({ userConfigPath: path });
|
|
55
|
+
expect(config.keys["comment"]).toEqual(["c"]);
|
|
56
56
|
} finally {
|
|
57
57
|
rmSync(dir, { recursive: true, force: true });
|
|
58
58
|
}
|
|
@@ -61,12 +61,12 @@ describe("loadConfig", () => {
|
|
|
61
61
|
|
|
62
62
|
describe("integrations.obsidian config", () => {
|
|
63
63
|
test("defaults when the section is absent", () => {
|
|
64
|
-
const
|
|
65
|
-
expect(
|
|
66
|
-
expect(
|
|
67
|
-
expect(
|
|
68
|
-
expect(
|
|
69
|
-
expect(
|
|
64
|
+
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
65
|
+
expect(config.integrations.obsidian.vault).toBeUndefined();
|
|
66
|
+
expect(config.integrations.obsidian.folder).toBe("cueloop");
|
|
67
|
+
expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
|
|
68
|
+
expect(config.integrations.obsidian.separator).toBe("space");
|
|
69
|
+
expect(config.integrations.obsidian.exportOn).toBe("manual");
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
test("user config sets the section; invalid enum values are ignored", () => {
|
|
@@ -77,12 +77,12 @@ describe("integrations.obsidian config", () => {
|
|
|
77
77
|
`[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
|
|
78
78
|
);
|
|
79
79
|
try {
|
|
80
|
-
const
|
|
81
|
-
expect(
|
|
82
|
-
expect(
|
|
83
|
-
expect(
|
|
84
|
-
expect(
|
|
85
|
-
expect(
|
|
80
|
+
const config = loadConfig({ userConfigPath: path });
|
|
81
|
+
expect(config.integrations.obsidian.vault).toBe("/notes/vault");
|
|
82
|
+
expect(config.integrations.obsidian.folder).toBe("plans");
|
|
83
|
+
expect(config.integrations.obsidian.exportOn).toBe("approve");
|
|
84
|
+
expect(config.integrations.obsidian.separator).toBe("space"); // invalid value falls back
|
|
85
|
+
expect(config.keys["comment"]).toEqual(["c"]); // other sections untouched
|
|
86
86
|
} finally {
|
|
87
87
|
rmSync(dir, { recursive: true, force: true });
|
|
88
88
|
}
|
|
@@ -96,10 +96,10 @@ describe("integrations.obsidian config", () => {
|
|
|
96
96
|
Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
|
|
97
97
|
writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
|
|
98
98
|
try {
|
|
99
|
-
const
|
|
100
|
-
expect(
|
|
101
|
-
expect(
|
|
102
|
-
expect(
|
|
99
|
+
const config = loadConfig({ userConfigPath: user, repoRoot });
|
|
100
|
+
expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
|
|
101
|
+
expect(config.integrations.obsidian.exportOn).toBe("resolve");
|
|
102
|
+
expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
|
|
103
103
|
} finally {
|
|
104
104
|
rmSync(dir, { recursive: true, force: true });
|
|
105
105
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Layered TOML config
|
|
2
|
+
* Layered TOML config: built-in defaults → user config → trusted repo
|
|
3
3
|
* config → env. Sections: [keys] action = "combo" (every action rebindable),
|
|
4
4
|
* [theme] per-token overrides, [integrations.obsidian] notes-vault export.
|
|
5
5
|
*/
|
|
@@ -46,6 +46,7 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
|
|
|
46
46
|
delete_annotation: ["backspace"],
|
|
47
47
|
submit: ["return", "enter"],
|
|
48
48
|
quit: ["q"],
|
|
49
|
+
walk: ["w"],
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
function parseToml(text: string): Record<string, unknown> {
|
|
@@ -82,19 +83,19 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
82
83
|
const integrations = raw["integrations"] as Record<string, unknown> | undefined;
|
|
83
84
|
const obsidian = integrations?.["obsidian"] as Record<string, unknown> | undefined;
|
|
84
85
|
if (obsidian) {
|
|
85
|
-
const
|
|
86
|
-
if (typeof obsidian["vault"] === "string")
|
|
87
|
-
if (typeof obsidian["folder"] === "string")
|
|
88
|
-
if (typeof obsidian["filenameFormat"] === "string")
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
86
|
+
const obsidianConfig = out.integrations.obsidian;
|
|
87
|
+
if (typeof obsidian["vault"] === "string") obsidianConfig.vault = obsidian["vault"];
|
|
88
|
+
if (typeof obsidian["folder"] === "string") obsidianConfig.folder = obsidian["folder"];
|
|
89
|
+
if (typeof obsidian["filenameFormat"] === "string") obsidianConfig.filenameFormat = obsidian["filenameFormat"];
|
|
90
|
+
const separator = obsidian["separator"];
|
|
91
|
+
if (separator === "space" || separator === "dash" || separator === "underscore") obsidianConfig.separator = separator;
|
|
92
|
+
const exportOn = obsidian["exportOn"];
|
|
93
|
+
if (exportOn === "approve" || exportOn === "resolve" || exportOn === "manual") obsidianConfig.exportOn = exportOn;
|
|
93
94
|
}
|
|
94
95
|
return out;
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
export function loadConfig(
|
|
98
|
+
export function loadConfig(options: { repoRoot?: string; userConfigPath?: string } = {}): CueloopConfig {
|
|
98
99
|
let config: CueloopConfig = {
|
|
99
100
|
keys: { ...DEFAULT_KEYS },
|
|
100
101
|
theme: { ...DARK },
|
|
@@ -102,10 +103,10 @@ export function loadConfig(opts: { repoRoot?: string; userConfigPath?: string }
|
|
|
102
103
|
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
103
104
|
};
|
|
104
105
|
const userPath =
|
|
105
|
-
|
|
106
|
+
options.userConfigPath ??
|
|
106
107
|
process.env.CUELOOP_CONFIG ??
|
|
107
108
|
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml");
|
|
108
|
-
for (const path of [userPath,
|
|
109
|
+
for (const path of [userPath, options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined]) {
|
|
109
110
|
if (!path || !existsSync(path)) continue;
|
|
110
111
|
try {
|
|
111
112
|
config = layer(config, parseToml(readFileSync(path, "utf8")));
|
package/src/editor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* File-level edit mode = the $EDITOR hand-off
|
|
2
|
+
* File-level edit mode = the $EDITOR hand-off: write the working
|
|
3
3
|
* copy to a temp file, run the user's editor on it, read the result back.
|
|
4
4
|
* Tests point CUELOOP_EDITOR at a non-interactive command.
|
|
5
5
|
*/
|
package/src/herdr.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* First-class herdr return
|
|
2
|
+
* First-class herdr return: a review opened beside an agent should hand
|
|
3
3
|
* focus back to the agent's pane when it closes, not leave the reviewer on a
|
|
4
4
|
* dead tab. Detection is the ambient env contract; the return target comes
|
|
5
5
|
* from CUELOOP_RETURN_PANE (set by whoever opened this pane) or the session's
|
package/src/key-bindings.test.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
8
8
|
import { DEFAULT_KEYS } from "./config";
|
|
9
9
|
import { KeyBindings } from "./key-bindings";
|
|
10
10
|
|
|
11
|
-
function bindings(context: { overlay?: "none" | "compose" | "submit"; spanMode?: boolean } = {}): KeyBindings {
|
|
11
|
+
function bindings(context: { overlay?: "none" | "walk" | "compose" | "submit"; spanMode?: boolean } = {}): KeyBindings {
|
|
12
12
|
const resolver = new KeyBindings(DEFAULT_KEYS);
|
|
13
13
|
resolver.setContext({ overlay: context.overlay ?? "none", spanMode: context.spanMode ?? false });
|
|
14
14
|
return resolver;
|
|
@@ -62,6 +62,17 @@ describe("mode layers own their keys", () => {
|
|
|
62
62
|
expect(resolver.resolveAction({ name: "right", shift: false })).toBe("cycle_verdict_right");
|
|
63
63
|
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("submit_verdict");
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
test("the walk overlay owns the bracket stepping and leaves the grammar keys dead", () => {
|
|
67
|
+
const resolver = bindings({ overlay: "walk" });
|
|
68
|
+
expect(resolver.resolveAction({ name: "]", shift: false })).toBe("walk_next");
|
|
69
|
+
expect(resolver.resolveAction({ name: "[", shift: false })).toBe("walk_prev");
|
|
70
|
+
expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("walk_leave");
|
|
71
|
+
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("walk_submit");
|
|
72
|
+
expect(resolver.resolveAction({ name: "q", shift: false })).toBe("quit");
|
|
73
|
+
expect(resolver.resolveAction({ name: "j", shift: false })).toBeUndefined();
|
|
74
|
+
expect(resolver.resolveAction({ name: "c", shift: false })).toBeUndefined();
|
|
75
|
+
});
|
|
65
76
|
});
|
|
66
77
|
|
|
67
78
|
describe("getActiveKeys-generated status hints", () => {
|
|
@@ -89,6 +100,12 @@ describe("getActiveKeys-generated status hints", () => {
|
|
|
89
100
|
expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe("verdict ←/→ · ⏎ submit · esc cancel");
|
|
90
101
|
});
|
|
91
102
|
|
|
103
|
+
test("walk hint", () => {
|
|
104
|
+
expect(bindings({ overlay: "walk" }).statusHint("walk")).toBe(
|
|
105
|
+
"walk · ] next (marks viewed) · [ back · esc leave walk · q quit",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
92
109
|
test("read-only hint", () => {
|
|
93
110
|
expect(bindings().statusHint("read-only")).toBe("observer - read-only · j/k move · n/p annotations · q quit");
|
|
94
111
|
});
|
package/src/key-bindings.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Binding resolution over @opentui/keymap
|
|
2
|
+
* Binding resolution over @opentui/keymap: the loaded config keymap
|
|
3
3
|
* registers as layers (grammar, span, compose, submit, completion) on one
|
|
4
4
|
* Keymap instance, key events resolve to action names through its dispatch,
|
|
5
5
|
* and the status-line hints generate from getActiveKeys() - a rebound key
|
|
@@ -24,7 +24,7 @@ export interface KeyLayerContext {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/** Hint templates keyed by the same view states the status line shows. */
|
|
27
|
-
export type HintMode = "normal" | "card" | "span" | "compose" | "submit" | "read-only";
|
|
27
|
+
export type HintMode = "normal" | "card" | "span" | "compose" | "submit" | "walk" | "read-only";
|
|
28
28
|
|
|
29
29
|
type HintEntry =
|
|
30
30
|
| { text: string }
|
|
@@ -70,6 +70,13 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
|
|
|
70
70
|
{ commands: ["submit_verdict"], label: "submit" },
|
|
71
71
|
{ commands: ["cancel_submit"], label: "cancel" },
|
|
72
72
|
],
|
|
73
|
+
walk: [
|
|
74
|
+
{ text: "walk" },
|
|
75
|
+
{ commands: ["walk_next"], label: "next (marks viewed)" },
|
|
76
|
+
{ commands: ["walk_prev"], label: "back" },
|
|
77
|
+
{ commands: ["walk_leave"], label: "leave walk" },
|
|
78
|
+
{ commands: ["quit"], label: "quit" },
|
|
79
|
+
],
|
|
73
80
|
"read-only": [
|
|
74
81
|
{ text: "observer - read-only" },
|
|
75
82
|
{ commands: ["down", "up"], label: "move" },
|
|
@@ -243,6 +250,20 @@ export class KeyBindings {
|
|
|
243
250
|
bindings: SPAN_COMMANDS.map(([key, command]) => ({ key, cmd: command })),
|
|
244
251
|
commands: SPAN_COMMANDS.map(([, command]) => record(command)),
|
|
245
252
|
});
|
|
253
|
+
// the walk wizard: prev/next stepping, leave, and submit from the end card
|
|
254
|
+
this.keymap.registerLayer({
|
|
255
|
+
priority: 20,
|
|
256
|
+
when: () => this.context.overlay === "walk",
|
|
257
|
+
bindings: [
|
|
258
|
+
{ key: "]", cmd: "walk_next" },
|
|
259
|
+
{ key: "[", cmd: "walk_prev" },
|
|
260
|
+
{ key: "escape", cmd: "walk_leave" },
|
|
261
|
+
{ key: "return", cmd: "walk_submit" },
|
|
262
|
+
{ key: "enter", cmd: "walk_submit" },
|
|
263
|
+
{ key: "q", cmd: "quit" },
|
|
264
|
+
],
|
|
265
|
+
commands: [record("walk_next"), record("walk_prev"), record("walk_leave"), record("walk_submit"), record("quit")],
|
|
266
|
+
});
|
|
246
267
|
this.keymap.registerLayer({
|
|
247
268
|
priority: 20,
|
|
248
269
|
when: () => this.context.overlay === "compose",
|