@cueloop/client 0.1.0-alpha.15 → 0.1.0-alpha.16
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.tsx +67 -12
- package/src/App.walk.test.tsx +208 -0
- package/src/components/ConfirmCard.stories.tsx +15 -0
- package/src/components/ConfirmCard.tsx +8 -1
- 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/config.ts +1 -0
- package/src/key-bindings.test.ts +18 -1
- package/src/key-bindings.ts +22 -1
- package/src/keymap.test.ts +37 -0
- package/src/keymap.ts +27 -2
- package/src/session-controller.ts +76 -2
- package/src/theme.ts +19 -0
- package/src/walk.test.ts +110 -0
- package/src/walk.ts +65 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guided walk's focused card wizard (#83): 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
|
`;
|
package/src/config.ts
CHANGED
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
|
@@ -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",
|
package/src/keymap.test.ts
CHANGED
|
@@ -20,6 +20,7 @@ function state(patch: Partial<KeyState> = {}): KeyState {
|
|
|
20
20
|
annotationCount: 1,
|
|
21
21
|
hasFocusedAnnotation: true,
|
|
22
22
|
cursorAnnotatable: true,
|
|
23
|
+
walkAtEnd: false,
|
|
23
24
|
...patch,
|
|
24
25
|
};
|
|
25
26
|
}
|
|
@@ -228,6 +229,42 @@ describe("diff mode", () => {
|
|
|
228
229
|
});
|
|
229
230
|
});
|
|
230
231
|
|
|
232
|
+
describe("guided walk", () => {
|
|
233
|
+
test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
|
|
234
|
+
expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ t: "walkStart" }]);
|
|
235
|
+
expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
|
|
236
|
+
{ t: "status", msg: "review submitted - read-only" },
|
|
237
|
+
]);
|
|
238
|
+
expect(reduceKey(state({ view: "diff", readOnly: true }), key("w"))).toEqual([
|
|
239
|
+
{ t: "status", msg: "observer - read-only" },
|
|
240
|
+
]);
|
|
241
|
+
expect(reduceKey(state(), key("w"))).toEqual([{ t: "status", msg: "the guided walk is a diff-review mode" }]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const walking = state({ view: "diff", overlay: "walk" });
|
|
245
|
+
const table: [string, Intent[]][] = [
|
|
246
|
+
["]", [{ t: "walkForward" }]],
|
|
247
|
+
["[", [{ t: "walkBack" }]],
|
|
248
|
+
["escape", [{ t: "walkLeave" }]],
|
|
249
|
+
["q", [{ t: "exit" }]],
|
|
250
|
+
// mid-walk return does nothing: submit lives on the end card only
|
|
251
|
+
["return", []],
|
|
252
|
+
["j", []],
|
|
253
|
+
["c", []],
|
|
254
|
+
];
|
|
255
|
+
for (const [name, expected] of table) {
|
|
256
|
+
test(`walking ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
257
|
+
expect(reduceKey(walking, key(name))).toEqual(expected);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
test("the end card's return leaves the walk and opens the submit confirm", () => {
|
|
262
|
+
const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
|
|
263
|
+
expect(reduceKey(atEnd, key("return"))).toEqual([{ t: "walkLeave" }, { t: "openSubmit" }]);
|
|
264
|
+
expect(reduceKey(atEnd, key("enter"))).toEqual([{ t: "walkLeave" }, { t: "openSubmit" }]);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
231
268
|
describe("duplicated-branch collapse", () => {
|
|
232
269
|
test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
|
|
233
270
|
for (const patch of [
|
package/src/keymap.ts
CHANGED
|
@@ -23,6 +23,10 @@ export type Intent =
|
|
|
23
23
|
| { t: "editCard" }
|
|
24
24
|
| { t: "nextAnn" }
|
|
25
25
|
| { t: "prevAnn" }
|
|
26
|
+
| { t: "walkStart" }
|
|
27
|
+
| { t: "walkForward" }
|
|
28
|
+
| { t: "walkBack" }
|
|
29
|
+
| { t: "walkLeave" }
|
|
26
30
|
| { t: "removeAnnotation" }
|
|
27
31
|
| { t: "deselect" }
|
|
28
32
|
| { t: "closeOverlay" }
|
|
@@ -43,10 +47,12 @@ export interface KeyState {
|
|
|
43
47
|
keys: Record<string, string[]>;
|
|
44
48
|
readOnly: boolean;
|
|
45
49
|
/** Layer that owns keys before the grammar runs. */
|
|
46
|
-
overlay: "none" | "compose" | "submit" | "completion-prompt" | "completion-counting";
|
|
50
|
+
overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
|
|
47
51
|
view: "inbox" | "plan" | "diff";
|
|
48
52
|
/** Plan-only span selection sub-mode. */
|
|
49
53
|
spanMode: boolean;
|
|
54
|
+
/** The walk cursor sits on the end card - return offers the submit action. */
|
|
55
|
+
walkAtEnd: boolean;
|
|
50
56
|
resolved: boolean;
|
|
51
57
|
hasInboxItems: boolean;
|
|
52
58
|
annotationCount: number;
|
|
@@ -56,7 +62,7 @@ export interface KeyState {
|
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/** Verbs that write session state; an observer never reaches their handlers. */
|
|
59
|
-
const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit"]);
|
|
65
|
+
const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk"]);
|
|
60
66
|
|
|
61
67
|
const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
|
|
62
68
|
|
|
@@ -90,6 +96,19 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
90
96
|
if (name === "escape") return [{ t: "dismissCompletion" }];
|
|
91
97
|
return [];
|
|
92
98
|
}
|
|
99
|
+
// the walk wizard owns its keys while active: ] advances (marking the
|
|
100
|
+
// current file viewed), [ steps back, escape leaves keeping progress, and
|
|
101
|
+
// return on the end card hands over to the submit confirm
|
|
102
|
+
if (state.overlay === "walk") {
|
|
103
|
+
if (name === "]") return [{ t: "walkForward" }];
|
|
104
|
+
if (name === "[") return [{ t: "walkBack" }];
|
|
105
|
+
if (name === "escape") return [{ t: "walkLeave" }];
|
|
106
|
+
if ((name === "return" || name === "enter") && state.walkAtEnd) {
|
|
107
|
+
return [{ t: "walkLeave" }, { t: "openSubmit" }];
|
|
108
|
+
}
|
|
109
|
+
if (name === "q") return [{ t: "exit" }];
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
93
112
|
const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
|
|
94
113
|
if (action === "quit") return [{ t: "exit" }];
|
|
95
114
|
// the ONE read-only rule: any mutating attempt answers instead of acting
|
|
@@ -114,6 +133,11 @@ function inboxGrammar(state: KeyState, name: string): Intent[] {
|
|
|
114
133
|
function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
115
134
|
const nav = navIntent(action);
|
|
116
135
|
if (nav) return nav;
|
|
136
|
+
if (action === "walk") {
|
|
137
|
+
// marking viewed writes the session record, so a resolved review answers
|
|
138
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
139
|
+
return [{ t: "walkStart" }];
|
|
140
|
+
}
|
|
117
141
|
if (action === "comment") {
|
|
118
142
|
if (state.resolved) return status("review submitted - read-only");
|
|
119
143
|
if (!state.cursorAnnotatable) return status("move to a code line to comment");
|
|
@@ -139,6 +163,7 @@ function spanGrammar(name: string): Intent[] {
|
|
|
139
163
|
function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
|
|
140
164
|
const nav = navIntent(action);
|
|
141
165
|
if (nav) return nav;
|
|
166
|
+
if (action === "walk") return status("the guided walk is a diff-review mode");
|
|
142
167
|
if (name === "escape") return [{ t: "deselect" }];
|
|
143
168
|
if (action === "span") return state.cursorAnnotatable ? [{ t: "startSpan" }] : [];
|
|
144
169
|
if (action === "comment" || action === "suggest") {
|
|
@@ -24,6 +24,7 @@ import { Registry, type Exporter } from "@cueloop/extension-api";
|
|
|
24
24
|
import { createObsidianExtension, shouldExport, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
25
25
|
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view";
|
|
26
26
|
import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
|
|
27
|
+
import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
|
|
27
28
|
import { editInEditor } from "./editor";
|
|
28
29
|
import { focusHerdrPane, returnPaneFor } from "./herdr";
|
|
29
30
|
import { persistAutoClose, type AutoClose, type CueloopConfig } from "./config";
|
|
@@ -53,6 +54,12 @@ export interface ControllerSnapshot {
|
|
|
53
54
|
* hand-off - the reconciliation banner count. 0 = no banner.
|
|
54
55
|
*/
|
|
55
56
|
editOrphanCount: number;
|
|
57
|
+
/**
|
|
58
|
+
* The guided walk's cursor (diff sessions): which wizard step is on
|
|
59
|
+
* screen. index === file count is the end card. null = not walking; the
|
|
60
|
+
* viewed set itself rides the session record, so leaving loses nothing.
|
|
61
|
+
*/
|
|
62
|
+
walk: { index: number } | null;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
export interface ReviewControllerOptions {
|
|
@@ -79,6 +86,8 @@ export interface ReviewController {
|
|
|
79
86
|
/** Derived projections, cached per session identity. */
|
|
80
87
|
display(): DisplayBlock[];
|
|
81
88
|
rows(): DiffRow[];
|
|
89
|
+
/** The walk's step list, derived from the diff rows. */
|
|
90
|
+
files(): WalkFile[];
|
|
82
91
|
working(): string;
|
|
83
92
|
/** Open a session from the inbox. */
|
|
84
93
|
open(id: string): void;
|
|
@@ -95,6 +104,13 @@ export interface ReviewController {
|
|
|
95
104
|
updateAnnotation(id: string, body: string): void;
|
|
96
105
|
removeAnnotation(id: string): void;
|
|
97
106
|
setWorkingCopy(content: string | undefined): void;
|
|
107
|
+
/** Enter the guided walk at the first unviewed file (diff sessions). */
|
|
108
|
+
walkStart(): void;
|
|
109
|
+
/** Mark the current file viewed (persists with the session) and advance. */
|
|
110
|
+
walkForward(): void;
|
|
111
|
+
walkBack(): void;
|
|
112
|
+
/** Leave the walk; the viewed set stays on the session record. */
|
|
113
|
+
walkLeave(): void;
|
|
98
114
|
/** Resolve the review, run the export, start the completion hand-back. */
|
|
99
115
|
submit(verdict: VerdictKind, summary: string): void;
|
|
100
116
|
/** Close the review and, inside herdr, bounce focus back to the agent. */
|
|
@@ -119,6 +135,7 @@ class Controller implements ReviewController {
|
|
|
119
135
|
error: null,
|
|
120
136
|
completion: { phase: "idle" },
|
|
121
137
|
editOrphanCount: 0,
|
|
138
|
+
walk: null,
|
|
122
139
|
};
|
|
123
140
|
private listeners = new Set<() => void>();
|
|
124
141
|
private autoClose: AutoClose = "off";
|
|
@@ -128,7 +145,11 @@ class Controller implements ReviewController {
|
|
|
128
145
|
private countdown: TimerHandle | undefined;
|
|
129
146
|
/** Projections keyed by session identity so renders reuse one computation. */
|
|
130
147
|
private derivedFor: ReviewSession | null = null;
|
|
131
|
-
private derived: { display: DisplayBlock[]; rows: DiffRow[]
|
|
148
|
+
private derived: { display: DisplayBlock[]; rows: DiffRow[]; files: WalkFile[] } = {
|
|
149
|
+
display: [],
|
|
150
|
+
rows: [],
|
|
151
|
+
files: [],
|
|
152
|
+
};
|
|
132
153
|
|
|
133
154
|
constructor(private readonly opts: ReviewControllerOptions) {
|
|
134
155
|
this.readOnly = opts.readOnly ?? false;
|
|
@@ -201,9 +222,11 @@ class Controller implements ReviewController {
|
|
|
201
222
|
const s = this.snap.session;
|
|
202
223
|
if (this.derivedFor === s) return;
|
|
203
224
|
this.derivedFor = s;
|
|
225
|
+
const rows = s && s.artifact.type === "diff" ? diffRows(s.artifact.content) : [];
|
|
204
226
|
this.derived = {
|
|
205
227
|
display: s ? buildDisplay(s.artifact.content, s.workingCopy) : [],
|
|
206
|
-
rows
|
|
228
|
+
rows,
|
|
229
|
+
files: walkFiles(rows),
|
|
207
230
|
};
|
|
208
231
|
}
|
|
209
232
|
|
|
@@ -217,6 +240,11 @@ class Controller implements ReviewController {
|
|
|
217
240
|
return this.derived.rows;
|
|
218
241
|
}
|
|
219
242
|
|
|
243
|
+
files(): WalkFile[] {
|
|
244
|
+
this.ensureDerived();
|
|
245
|
+
return this.derived.files;
|
|
246
|
+
}
|
|
247
|
+
|
|
220
248
|
working(): string {
|
|
221
249
|
const s = this.snap.session;
|
|
222
250
|
return s ? (s.workingCopy ?? s.artifact.content) : "";
|
|
@@ -250,6 +278,7 @@ class Controller implements ReviewController {
|
|
|
250
278
|
|
|
251
279
|
// ── verbs ───────────────────────────────────
|
|
252
280
|
open(id: string): void {
|
|
281
|
+
this.locallyViewed.clear();
|
|
253
282
|
const cached = this.snap.inbox?.find((s) => s.id === id);
|
|
254
283
|
if (cached) this.update({ session: cached });
|
|
255
284
|
else void this.refreshSession(id);
|
|
@@ -364,9 +393,54 @@ class Controller implements ReviewController {
|
|
|
364
393
|
this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
|
|
365
394
|
}
|
|
366
395
|
|
|
396
|
+
// ── the guided walk ─────────────────────────
|
|
397
|
+
// Marks sent but possibly not yet reflected in the session snapshot; the
|
|
398
|
+
// union keeps the walk title truthful and rapid advances from losing marks.
|
|
399
|
+
private locallyViewed = new Set<string>();
|
|
400
|
+
|
|
401
|
+
private viewedSet(): Set<string> {
|
|
402
|
+
return new Set([...(this.snap.session?.viewedPaths ?? []), ...this.locallyViewed]);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
walkStart(): void {
|
|
406
|
+
const session = this.snap.session;
|
|
407
|
+
if (!session || session.artifact.type !== "diff") return;
|
|
408
|
+
const files = this.files();
|
|
409
|
+
if (files.length === 0) return this.setStatus("nothing to walk - the diff is empty");
|
|
410
|
+
// resume at the first unviewed file; a finished walk reopens on the end card
|
|
411
|
+
this.update({ walk: { index: firstUnviewedIndex(files, this.viewedSet()) } });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
walkForward(): void {
|
|
415
|
+
const walk = this.snap.walk;
|
|
416
|
+
const session = this.snap.session;
|
|
417
|
+
if (!walk || !session) return;
|
|
418
|
+
const files = this.files();
|
|
419
|
+
const current = files[walk.index];
|
|
420
|
+
if (!current) return; // already on the end card
|
|
421
|
+
// advancing IS the viewed mark: the step is complete once you move past
|
|
422
|
+
// it; the daemon verb merges, so only the new path travels
|
|
423
|
+
if (!this.viewedSet().has(current.path)) {
|
|
424
|
+
this.locallyViewed.add(current.path);
|
|
425
|
+
this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
|
|
426
|
+
}
|
|
427
|
+
this.update({ walk: { index: walk.index + 1 } });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
walkBack(): void {
|
|
431
|
+
const walk = this.snap.walk;
|
|
432
|
+
if (!walk) return;
|
|
433
|
+
this.update({ walk: { index: Math.max(0, walk.index - 1) } });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
walkLeave(): void {
|
|
437
|
+
if (this.snap.walk) this.update({ walk: null });
|
|
438
|
+
}
|
|
439
|
+
|
|
367
440
|
submit(verdict: VerdictKind, summary: string): void {
|
|
368
441
|
const session = this.snap.session;
|
|
369
442
|
if (!session) return;
|
|
443
|
+
this.walkLeave();
|
|
370
444
|
this.client!.sessionResolve(session.id, verdict, summary)
|
|
371
445
|
.then((resolved) => {
|
|
372
446
|
// The completion overlay heading already states the verdict, so the
|