@cueloop/client 0.1.0-alpha.14 → 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.
@@ -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
+ }
@@ -262,11 +262,11 @@ exports[`stories catalog CompletionOverlay/ApprovedPrompt renders, snapshots, an
262
262
 
263
263
 
264
264
 
265
- ✓ review approved
265
+ review approved
266
266
 
267
267
  The agent has your approval and is unblocked.
268
268
 
269
- ⏎ close · a always close after submit (3s) · esc stay
269
+ close [return] · return to plan [esc] · always [a]
270
270
 
271
271
 
272
272
 
@@ -289,13 +289,13 @@ exports[`stories catalog CompletionOverlay/FeedbackCounting renders, snapshots,
289
289
 
290
290
 
291
291
 
292
-
293
- ✎ feedback sent
292
+ feedback sent
294
293
 
295
294
  The agent has your feedback and is unblocked.
295
+ returning to agent/worker-3 on close
296
296
  exported to vault/cueloop/plan.md
297
297
 
298
- closing in 3s - returning to agent/worker-3 · ⏎ now · esc stay
298
+ close [return] · closing in 3s · return to plan [esc] · always [a]
299
299
 
300
300
 
301
301
 
@@ -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
@@ -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> {
@@ -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
  });
@@ -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",
@@ -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
  }
@@ -76,7 +77,7 @@ describe("completion overlay", () => {
76
77
  [prompt, "j", []],
77
78
  [counting, "return", [{ t: "finishReview" }]],
78
79
  [counting, "q", [{ t: "finishReview" }]],
79
- [counting, "a", []],
80
+ [counting, "a", [{ t: "optInAutoClose" }]],
80
81
  [counting, "escape", [{ t: "dismissCompletion" }]],
81
82
  ];
82
83
  for (const [s, name, expected] of table) {
@@ -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
 
@@ -86,10 +92,23 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
86
92
  }
87
93
  if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
88
94
  if (name === "return" || name === "enter" || name === "q") return [{ t: "finishReview" }];
89
- if (name === "a" && state.overlay === "completion-prompt") return [{ t: "optInAutoClose" }];
95
+ if (name === "a") return [{ t: "optInAutoClose" }];
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") {