@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.33

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.
Files changed (48) hide show
  1. package/package.json +5 -5
  2. package/src/App.diff.test.tsx +1 -1
  3. package/src/App.export.test.tsx +1 -1
  4. package/src/App.plan-review.test.tsx +6 -5
  5. package/src/App.readonly.test.tsx +5 -6
  6. package/src/App.rendering.test.tsx +2 -1
  7. package/src/App.review-panel.test.tsx +16 -12
  8. package/src/App.submit-confirm.test.tsx +2 -6
  9. package/src/App.test.tsx +4 -4
  10. package/src/App.toast.test.tsx +68 -0
  11. package/src/App.tsx +153 -49
  12. package/src/App.walk.test.tsx +1 -1
  13. package/src/components/AnnotationCard.stories.tsx +1 -1
  14. package/src/components/AnnotationCard.tsx +18 -13
  15. package/src/components/ConfirmCard.tsx +1 -0
  16. package/src/components/ConfirmDialog.tsx +1 -1
  17. package/src/components/DiffSheet.stories.tsx +18 -0
  18. package/src/components/DiffSheet.tsx +38 -12
  19. package/src/components/InboxList.tsx +1 -1
  20. package/src/components/KeybindsDialog.stories.tsx +31 -0
  21. package/src/components/KeybindsDialog.tsx +42 -0
  22. package/src/components/MenuBar.stories.tsx +33 -0
  23. package/src/components/MenuBar.tsx +58 -0
  24. package/src/components/PlanSheet.stories.tsx +1 -1
  25. package/src/components/PlanSheet.tsx +21 -70
  26. package/src/components/PromptDialog.tsx +1 -1
  27. package/src/components/ReviewPanel.stories.tsx +4 -16
  28. package/src/components/ReviewPanel.tsx +10 -29
  29. package/src/components/ReviewRail.stories.tsx +39 -0
  30. package/src/components/ReviewRail.tsx +62 -57
  31. package/src/components/SettingsDialog.tsx +4 -3
  32. package/src/components/SettingsRows.stories.tsx +1 -1
  33. package/src/components/SettingsRows.tsx +1 -1
  34. package/src/components/Toast.stories.tsx +12 -0
  35. package/src/components/Toast.tsx +29 -0
  36. package/src/components/__snapshots__/stories.test.tsx.snap +598 -566
  37. package/src/components/primitives/Button.stories.tsx +2 -2
  38. package/src/components/primitives/Dialog.tsx +4 -1
  39. package/src/components/primitives/Tabs.tsx +19 -38
  40. package/src/key-bindings.test.ts +4 -4
  41. package/src/key-bindings.ts +41 -2
  42. package/src/serve.test.ts +2 -2
  43. package/src/session-controller.share.test.ts +13 -0
  44. package/src/session-controller.ts +19 -1
  45. package/src/test-support.ts +87 -23
  46. package/src/version.ts +5 -0
  47. package/src/components/ComposeBar.stories.tsx +0 -18
  48. package/src/components/ComposeBar.tsx +0 -54
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.32",
3
+ "version": "0.1.0-alpha.33",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@cueloop/daemon": "0.1.0-alpha.32",
11
- "@cueloop/extension-api": "0.1.0-alpha.32",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.32",
13
- "@cueloop/schema": "0.1.0-alpha.32",
10
+ "@cueloop/daemon": "0.1.0-alpha.33",
11
+ "@cueloop/extension-api": "0.1.0-alpha.33",
12
+ "@cueloop/integration-obsidian": "0.1.0-alpha.33",
13
+ "@cueloop/schema": "0.1.0-alpha.33",
14
14
  "@opentui/core": "^0.5.2",
15
15
  "@opentui/keymap": "0.5.2",
16
16
  "@opentui/react": "^0.5.2",
@@ -72,7 +72,7 @@ describe("diff review", () => {
72
72
  await press(setup, "c");
73
73
 
74
74
  // Assert
75
- await waitForText(setup, "COMMENT ON");
75
+ await waitForText(setup, 'comment on "');
76
76
 
77
77
  // Act
78
78
  await setup.mockInput.typeText("Map needs an eviction story.");
@@ -52,7 +52,7 @@ describe("obsidian export on resolve", () => {
52
52
  await press(setup, "enter"); // open the submit overlay, keep the default verdict (approve)
53
53
 
54
54
  // Assert
55
- await waitForText(setup, "verdict");
55
+ await waitForText(setup, "[Approve]");
56
56
 
57
57
  // Act
58
58
  await press(setup, "enter"); // submit
@@ -221,7 +221,7 @@ describe("inline compose keeps the anchor painted", () => {
221
221
  // Assert
222
222
  // compose open: the anchor is painted selection-style. The box can render
223
223
  // a frame before the anchor repaint settles, so wait on the color itself.
224
- await waitForText(setup, "Save ⏎");
224
+ await waitForText(setup, "Save");
225
225
  expect(setup.captureCharFrame()).toContain("Cancel esc");
226
226
  await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(T.accent));
227
227
  expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
@@ -250,7 +250,7 @@ describe("inline compose keeps the anchor painted", () => {
250
250
  // renderApp plus this many frame-waits grazes the 5s default on a loaded CI
251
251
  // runner, and the whole-suite publish lane has timed even 15s out; give the
252
252
  // heaviest frame-wait chain generous headroom so runner load cannot flake it.
253
- }, 30_000);
253
+ }, 60_000);
254
254
  });
255
255
 
256
256
  describe("compose newline convention", () => {
@@ -432,7 +432,7 @@ describe("addressed annotations leave the open list", () => {
432
432
  const frame = setup.captureCharFrame();
433
433
  expect(frame).toContain("✓ 1 addressed by revision");
434
434
  expect(frame).not.toContain("settled note");
435
- expect(frame).toContain("Review (1)"); // the addressed card no longer counts as pending
435
+ expect(frame).toContain("still open note"); // the open card survives; the addressed one does not
436
436
  expect(backgroundsOf(setup, "The daemon")).not.toContain(T.markCommentBackground); // no highlight paint
437
437
  });
438
438
  });
@@ -444,7 +444,8 @@ describe("sheet header", () => {
444
444
 
445
445
  // Assert
446
446
  const frame = setup.captureCharFrame();
447
- expect(frame).toContain("submitted by agent/worker-3 · revision 1");
447
+ expect(frame).toContain("submitted by agent/worker-3");
448
+ expect(frame).toContain("rev 1");
448
449
  expect(frame).toContain("Edit");
449
450
  expect(frame).toContain("Share");
450
451
  });
@@ -453,7 +454,7 @@ describe("sheet header", () => {
453
454
  // Arrange
454
455
  const setup = await renderApp();
455
456
  const lines = setup.captureCharFrame().split("\n");
456
- const tabRow = lines.findIndex((line) => line.includes("Review (0)"));
457
+ const tabRow = lines.findIndex((line) => line.includes("Review") && line.includes("Agent"));
457
458
  const tabColumn = lines[tabRow]!.indexOf("Agent");
458
459
 
459
460
  // Act
@@ -60,15 +60,14 @@ function snapshot() {
60
60
  }
61
61
 
62
62
  describe("observer rendering", () => {
63
- test("shows the observer badge and read-only hint bar", async () => {
63
+ test("shows the observer badge and the bottom menu", async () => {
64
64
  // Arrange
65
65
  const setup = await renderObserver();
66
66
 
67
67
  // Assert
68
68
  const frame = setup.captureCharFrame();
69
69
  expect(frame).toContain("· observer");
70
- expect(frame).toContain("observer - read-only · j/k move");
71
- expect(frame).not.toContain("⏎ submit");
70
+ expect(frame).toContain("menu");
72
71
  });
73
72
  });
74
73
 
@@ -88,8 +87,8 @@ describe("observer verbs are blocked", () => {
88
87
  await press(setup, key);
89
88
  const frame = await waitForText(setup, "observer - read-only");
90
89
  // no overlay opened: compose/submit bars never appear
91
- expect(frame).not.toContain("COMMENT ON");
92
- expect(frame).not.toContain("SUGGEST REPLACEMENT");
90
+ expect(frame).not.toContain('comment on "');
91
+ expect(frame).not.toContain('suggest replacement for "');
93
92
  expect(frame).not.toContain("verdict ←/→");
94
93
  expect(snapshot()).toEqual(before);
95
94
  });
@@ -109,7 +108,7 @@ describe("observer verbs are blocked", () => {
109
108
  // Assert
110
109
  const frame = setup.captureCharFrame();
111
110
  expect(frame).toContain("observer - read-only");
112
- expect(frame).not.toContain("COMMENT ON");
111
+ expect(frame).not.toContain('comment on "');
113
112
  expect(snapshot().annotations).toBe(0);
114
113
  });
115
114
  });
@@ -80,7 +80,8 @@ describe("code blocks", () => {
80
80
  describe("block spacing", () => {
81
81
  // the review panel's full-height divider paints a `│` on every row, so read
82
82
  // the plan column (everything left of the divider) before asserting spacing
83
- const planColumn = (line: string): string => line.split("│")[0]!.trimEnd();
83
+ // the plan body is framed: content sits between the left border and the next │
84
+ const planColumn = (line: string): string => line.split("│")[1]?.trimEnd() ?? "";
84
85
 
85
86
  test("a code block never glues to the list item above it", async () => {
86
87
  // Arrange
@@ -10,7 +10,7 @@ import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
12
  import { loadConfig } from "./config";
13
- import { isolateUserConfig, press, waitForText, waitForTextGone } from "./test-support";
13
+ import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
14
14
 
15
15
  const PLAN = "# Plan\n\nShip the thing.\n";
16
16
 
@@ -47,26 +47,30 @@ describe("review panel", () => {
47
47
  test("b cycles expanded -> compact -> hidden -> expanded", async () => {
48
48
  // Arrange
49
49
  const setup = await renderApp();
50
+ // the plan frame widens as the rail shrinks: expanded < compact < hidden.
51
+ // its width is the column of its top-right corner (the first ╮ on the row).
52
+ const planWidth = () => setup.captureCharFrame().split("\n").find((line) => line.includes("╮"))?.indexOf("╮") ?? 0;
50
53
 
51
- // Assert
52
- expect(setup.captureCharFrame()).toContain("Submit review"); // expanded rail
54
+ // Assert: expanded shows the submit button and leaves room for the rail
55
+ expect(setup.captureCharFrame()).toContain("Submit review");
56
+ const expandedWidth = planWidth();
53
57
 
54
58
  // Act
55
59
  await press(setup, "b");
56
60
 
57
- // Assert
58
- await waitForText(setup, "«");
59
- let frame = setup.captureCharFrame();
60
- expect(frame).toContain("«"); // compact strip shows the expand chevron
61
- expect(frame).not.toContain("Submit review");
61
+ // Assert: compact drops the submit button and widens the plan (rail is a strip)
62
+ await waitForTextGone(setup, "Submit review");
63
+ const compactWidth = planWidth();
64
+ expect(compactWidth).toBeGreaterThan(expandedWidth);
62
65
 
63
66
  // Act
64
67
  await press(setup, "b");
68
+ await settle(setup);
65
69
 
66
- // Assert
67
- frame = await waitForTextGone(setup, "«");
68
- expect(frame).not.toContain("«"); // hidden: no strip, no divider
69
- expect(frame).not.toContain("│");
70
+ // Assert: hidden spans the plan full width, no rail column
71
+ const hiddenWidth = planWidth();
72
+ expect(hiddenWidth).toBeGreaterThan(compactWidth);
73
+ expect(hiddenWidth).toBeGreaterThan(115);
70
74
 
71
75
  // Act
72
76
  await press(setup, "b");
@@ -74,7 +74,7 @@ describe("rail submit confirm", () => {
74
74
  const setup = await renderApp();
75
75
 
76
76
  // Assert
77
- expect(setup.captureCharFrame()).toContain("Submit review (0)");
77
+ expect(setup.captureCharFrame()).toContain("Submit review");
78
78
 
79
79
  // Act
80
80
  await press(setup, "enter");
@@ -88,10 +88,6 @@ describe("rail submit confirm", () => {
88
88
  expect(frame).toContain("[Approve]"); // nothing pending: approve default
89
89
  expect(frame).toContain(" Submit ");
90
90
  expect(frame).toContain(" Cancel ");
91
- // key hints live in the status line, not on the buttons
92
- expect(frame).toContain("verdict ←/→ · ⏎ submit · esc cancel");
93
- // the bottom bar stayed a one-line hint: no detached verdict bar
94
- expect(frame).not.toContain("Submit review (0) on ⏎");
95
91
  });
96
92
 
97
93
  test("left/right cycles the verdict selector in the card", async () => {
@@ -136,7 +132,7 @@ describe("rail submit confirm", () => {
136
132
  // a bare ESC settles after the parser's escape-sequence window
137
133
  const frame = await waitForTextGone(setup, "[Approve]");
138
134
  expect(frame).not.toContain("0 annotations");
139
- expect(frame).toContain("Submit review (0)");
135
+ expect(frame).toContain("Submit review");
140
136
  });
141
137
 
142
138
  test("enter in the card resolves the session through the controller", async () => {
package/src/App.test.tsx CHANGED
@@ -65,8 +65,8 @@ describe("plan rendering", () => {
65
65
  expect(frame).toContain("Context");
66
66
  expect(frame).toContain("persists sessions to disk atomically");
67
67
  expect(frame).toContain("- move the store");
68
- expect(frame).toContain("Review (0)");
69
- expect(frame).toContain("Submit review (0)");
68
+ expect(frame).toContain("Review");
69
+ expect(frame).toContain("Submit review");
70
70
  });
71
71
  });
72
72
 
@@ -179,7 +179,7 @@ describe("submit", () => {
179
179
  await press(setup, "c");
180
180
  await type(setup, "Needs a phase list.");
181
181
  await press(setup, "enter");
182
- await waitForText(setup, "Review (1)");
182
+ await waitForText(setup, "Needs a phase list");
183
183
 
184
184
  // Act
185
185
  await press(setup, "enter"); // open submit
@@ -234,6 +234,6 @@ describe("inbox", () => {
234
234
  await press(setup, "enter");
235
235
 
236
236
  // Assert
237
- await waitForText(setup, "Submit review (0)");
237
+ await waitForText(setup, "Submit review");
238
238
  });
239
239
  });
@@ -0,0 +1,68 @@
1
+ /** The share toast is non-modal: while it is up, escape still cancels an open overlay. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import type { ReviewSession } from "@cueloop/schema";
11
+
12
+ // the controller reaches the gateway through "./share"; stub it so `S` yields a toast
13
+ const publishShare = mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true }));
14
+ mock.module("./share", () => ({
15
+ publishShare,
16
+ pullShare: mock(async () => null),
17
+ pushShare: mock(async () => {}),
18
+ shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
19
+ collaboratorAnnotations: () => [],
20
+ }));
21
+
22
+ const { App } = await import("./App");
23
+ const { isolateUserConfig, press, waitForText, waitForTextGone } = await import("./test-support");
24
+
25
+ const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
26
+
27
+ let home: string;
28
+ let restoreUserConfig: () => void;
29
+ let server: DaemonServer;
30
+ let session: ReviewSession;
31
+
32
+ beforeEach(() => {
33
+ home = mkdtempSync(join(tmpdir(), "cueloop-toast-"));
34
+ restoreUserConfig = isolateUserConfig(home);
35
+ server = new DaemonServer({ home, idleExitMs: 0 });
36
+ server.start();
37
+ session = server.core.sessionCreate({
38
+ workspace: { repoRoot: "/repo", branch: "main" },
39
+ artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
40
+ });
41
+ });
42
+ afterEach(() => {
43
+ restoreUserConfig();
44
+ server.stop();
45
+ rmSync(home, { recursive: true, force: true });
46
+ });
47
+
48
+ describe("share toast", () => {
49
+ test("escape cancels an open composer even while the toast is up", async () => {
50
+ // Arrange
51
+ const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 32 });
52
+ await waitForText(setup, "cueloop");
53
+
54
+ // Act: share raises the toast, then open a composer under it
55
+ await press(setup, "S");
56
+ await waitForText(setup, "share link copied");
57
+ await press(setup, "j");
58
+ await press(setup, "j");
59
+ await press(setup, "c");
60
+ await waitForText(setup, 'comment on "');
61
+
62
+ // Act: escape must reach the composer, not get eaten by the toast
63
+ await press(setup, "escape");
64
+
65
+ // Assert: the composer closed on the first escape
66
+ await waitForTextGone(setup, 'comment on "');
67
+ });
68
+ });
package/src/App.tsx CHANGED
@@ -16,21 +16,27 @@ import { type VerdictKind } from "@cueloop/schema";
16
16
  import { displayText, marksByDisplay, type Mark } from "./view-plan";
17
17
  import { noteForFile, viewedCount } from "./walk";
18
18
  import { DARK, dimmedTheme } from "./theme";
19
- import { DEFAULT_KEYS, loadConfig, persistAuthorName } from "./config";
19
+ import { DEFAULT_KEYS, loadConfig, persistAuthorName, persistAutoClose, type AutoClose } from "./config";
20
20
  import { returnPaneFor } from "@cueloop/schema";
21
21
  import { createReviewController } from "./session-controller";
22
22
  import type { SessionClient } from "@cueloop/daemon/client";
23
23
  import { createIntentDispatch, reviewerAnnotations, type Mode } from "./intent-dispatch";
24
24
  import { reduceKey, type KeyState } from "./keymap";
25
- import { KeyBindings, type HintMode } from "./key-bindings";
25
+ import { KeyBindings } from "./key-bindings";
26
26
  import { ThemeProvider } from "./components/theme-context";
27
- import { StatusBar } from "./components/primitives/StatusBar";
27
+ import { Button } from "./components/primitives/Button";
28
+ import { Toolbar } from "./components/primitives/Toolbar";
29
+ import { MenuBar } from "./components/MenuBar";
30
+ import { KeybindsDialog } from "./components/KeybindsDialog";
31
+ import { SettingsDialog, type SettingsCategory } from "./components/SettingsDialog";
32
+ import { CLIENT_VERSION } from "./version";
28
33
  import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
29
34
  import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
30
35
  import { DiffSheet } from "./components/DiffSheet";
31
36
  import { annotationBlocking, type ReviewRailHandle } from "./components/ReviewRail";
32
37
  import { ReviewPanel } from "./components/ReviewPanel";
33
38
  import {
39
+ REVIEW_COMPACT_WIDTH,
34
40
  REVIEW_DEFAULT_WIDTH,
35
41
  resolveReviewWidth,
36
42
  toggleReviewPanelMode,
@@ -41,14 +47,11 @@ import { CompletionOverlay } from "./components/CompletionOverlay";
41
47
  import { InboxList } from "./components/InboxList";
42
48
  import { ConfirmDialog } from "./components/ConfirmDialog";
43
49
  import { PromptDialog } from "./components/PromptDialog";
44
- import { ComposeBar } from "./components/ComposeBar";
45
50
  import { WalkWizard } from "./components/WalkWizard";
51
+ import { Toast } from "./components/Toast";
46
52
 
47
- /**
48
- * The breadcrumb header and the status bar each occupy one terminal row; the
49
- * review layout (plan column, divider, rail) gets the rows that remain.
50
- */
51
- const CHROME_ROWS = 2;
53
+ /** A toast clears itself after this idle; esc dismisses it sooner. */
54
+ const TOAST_DISMISS_MS = 4000;
52
55
 
53
56
  export interface AppProps {
54
57
  home?: string;
@@ -94,7 +97,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
94
97
  controller.connect();
95
98
  return () => controller.close();
96
99
  }, [controller]);
97
- const { session, inbox, status, error, completion, editOrphanCount, walk } = useSyncExternalStore(
100
+ const { session, inbox, status, toast, error, completion, editOrphanCount, walk } = useSyncExternalStore(
98
101
  controller.subscribe,
99
102
  controller.getSnapshot,
100
103
  controller.getSnapshot,
@@ -106,12 +109,21 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
106
109
  return controller.startSharePoll();
107
110
  }, [isOwner, session?.id, session?.shareId, controller]);
108
111
  const renderer = useRenderer();
109
- const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
112
+ const { width: terminalWidth } = useTerminalDimensions();
110
113
 
111
114
  // ── view state ──────────────────────────────
112
115
  const [cursor, setCursor] = useState(0);
113
116
  const [inboxCursor, setInboxCursor] = useState(0);
114
117
  const [mode, setMode] = useState<Mode>({ type: "normal" });
118
+ // the bottom-left menu drop-up and the centered dialog it opens
119
+ const [menuOpen, setMenuOpen] = useState(false);
120
+ const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
121
+ const [autoClose, setAutoClose] = useState<AutoClose>("off");
122
+ const [settingsNav, setSettingsNav] = useState<{ categoryId: string; rowIndex: number; zone: "nav" | "body" }>({
123
+ categoryId: "general",
124
+ rowIndex: 0,
125
+ zone: "body",
126
+ });
115
127
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
116
128
  const [railTab, setRailTab] = useState<"review" | "agent">("review");
117
129
  // review panel layout: mode + expanded width are client view state, loaded
@@ -143,6 +155,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
143
155
  setReviewWidth(config.ui.reviewWidth);
144
156
  reviewWidthRef.current = config.ui.reviewWidth;
145
157
  setAuthorNames(config.authors);
158
+ setAutoClose(config.ui.autoClose);
146
159
  controller.applyConfig(config);
147
160
  }, [session?.workspace.repoRoot, controller, keyBindings]);
148
161
  useEffect(
@@ -152,6 +165,12 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
152
165
  [],
153
166
  );
154
167
 
168
+ useEffect(() => {
169
+ if (!toast) return;
170
+ const timer = setTimeout(() => controller.dismissToast(), TOAST_DISMISS_MS);
171
+ return () => clearTimeout(timer);
172
+ }, [toast, controller]);
173
+
155
174
  // First open of a share: ask the collaborator for a display name once, unless
156
175
  // a past visit already recorded one. esc skips and their notes read anonymous.
157
176
  const promptedSelfRef = useRef(false);
@@ -300,7 +319,57 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
300
319
  ? "walk"
301
320
  : "none";
302
321
 
322
+ // ── settings dialog: config-backed model, navigation, persistence ──
323
+ const settingsCategories: SettingsCategory[] = [
324
+ {
325
+ id: "general",
326
+ name: "General",
327
+ description: "submission behaviour",
328
+ rows: [{ key: "autoClose", label: "Auto-close on submit", kind: "cycle", options: ["off", "3s", "10s"] }],
329
+ },
330
+ {
331
+ id: "display",
332
+ name: "Display",
333
+ description: "the review panel",
334
+ rows: [{ key: "reviewPanel", label: "Review panel", kind: "cycle", options: ["expanded", "compact", "hidden"] }],
335
+ },
336
+ ];
337
+ const settingsValues = { autoClose: autoClose === "off" ? "off" : `${autoClose}s`, reviewPanel: reviewMode };
338
+ const cycleSetting = (rowKey: string): void => {
339
+ if (rowKey === "autoClose") {
340
+ const next: AutoClose = autoClose === "off" ? 3 : autoClose === 3 ? 10 : "off";
341
+ setAutoClose(next);
342
+ persistAutoClose(next);
343
+ } else if (rowKey === "reviewPanel") {
344
+ const order: ReviewPanelMode[] = ["expanded", "compact", "hidden"];
345
+ const next = order[(order.indexOf(reviewMode) + 1) % order.length]!;
346
+ setReviewMode(next);
347
+ controller.saveReviewPanel({ mode: next });
348
+ }
349
+ };
350
+ const handleSettingsKey = (name: string): void => {
351
+ if (name === "escape") return void setMenuDialog(null);
352
+ const categoryIndex = settingsCategories.findIndex((category) => category.id === settingsNav.categoryId);
353
+ const category = settingsCategories[categoryIndex]!;
354
+ if (settingsNav.zone === "nav") {
355
+ if (name === "j" || name === "down") setSettingsNav({ categoryId: settingsCategories[Math.min(settingsCategories.length - 1, categoryIndex + 1)]!.id, rowIndex: 0, zone: "nav" });
356
+ else if (name === "k" || name === "up") setSettingsNav({ categoryId: settingsCategories[Math.max(0, categoryIndex - 1)]!.id, rowIndex: 0, zone: "nav" });
357
+ else if (name === "l" || name === "tab" || name === "return") setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: 0 }));
358
+ return;
359
+ }
360
+ if (name === "j" || name === "down") setSettingsNav((state) => ({ ...state, rowIndex: Math.min(category.rows.length - 1, state.rowIndex + 1) }));
361
+ else if (name === "k" || name === "up") setSettingsNav((state) => ({ ...state, rowIndex: Math.max(0, state.rowIndex - 1) }));
362
+ else if (name === "h" || name === "tab") setSettingsNav((state) => ({ ...state, zone: "nav" }));
363
+ else if (name === "return" || name === "space") cycleSetting(category.rows[settingsNav.rowIndex]!.key);
364
+ };
365
+
303
366
  useKeyboard((key) => {
367
+ if (menuDialog === "settings") return void handleSettingsKey(key.name);
368
+ if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
369
+ if (menuOpen) return void (key.name === "escape" && setMenuOpen(false));
370
+ // the toast is non-modal: escape only dismisses it when nothing else owns
371
+ // escape, so an open overlay (compose, submit, prompt, walk) still cancels
372
+ if (toast && key.name === "escape" && overlay === "none" && mode.type !== "span") return controller.dismissToast();
304
373
  const state: KeyState = {
305
374
  keys: keysRef.current,
306
375
  readOnly: observer,
@@ -398,6 +467,24 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
398
467
  }
399
468
  : null;
400
469
 
470
+ const diffComposeState =
471
+ mode.type === "compose" && isDiff
472
+ ? {
473
+ kind: mode.kind,
474
+ rowIndex: mode.displayIndex,
475
+ quote: rows[mode.displayIndex]?.text.replace(/\n$/, "") ?? "",
476
+ draft: {
477
+ text: mode.text,
478
+ onInput: (text: string) => {
479
+ liveInput.current = text;
480
+ setMode({ ...mode, text });
481
+ },
482
+ onSave: () => dispatch({ type: "saveCompose" }),
483
+ onCancel: () => dispatch({ type: "closeOverlay" }),
484
+ },
485
+ }
486
+ : null;
487
+
401
488
  const activeSpan =
402
489
  mode.type === "span"
403
490
  ? { displayIndex: mode.span.displayIndex, start: mode.span.start, end: mode.span.end }
@@ -484,32 +571,24 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
484
571
  }
485
572
  : null;
486
573
 
574
+ // width the review panel occupies (divider + rail), so the header can reserve
575
+ // it and keep Edit/Share pinned to the plan sheet's right edge, not the rail's
576
+ const railFootprint =
577
+ reviewMode === "hidden"
578
+ ? 0
579
+ : 1 + (reviewMode === "compact" ? REVIEW_COMPACT_WIDTH : resolveReviewWidth(reviewWidth, terminalWidth));
580
+
581
+ // status badges sit right after the product word so they survive a header
582
+ // that is too narrow for the whole trail (the rail can eat the width)
487
583
  const headerItems: BreadcrumbItem[] = [
488
584
  { label: "cueloop", tone: "accent" },
489
- { label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`, tone: "dim" },
490
585
  ...(resolved ? [{ label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
491
586
  ...(observer ? [{ label: "observer", tone: "dim" as const }] : []),
492
587
  ...(role === "collaborator" ? [{ label: "shared · your notes save as you go", tone: "dim" as const }] : []),
493
- ...(status ? [{ label: status, tone: "accent" as const }] : []),
588
+ { label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`, tone: "dim" },
589
+ { label: `submitted by ${activeSession.artifact.meta.agent ?? "unknown"}`, tone: "dim" },
494
590
  ];
495
591
 
496
- keyBindings.setContext({ overlay, spanMode: mode.type === "span" });
497
- const hintMode: HintMode = observer
498
- ? "read-only"
499
- : mode.type === "submit"
500
- ? "submit"
501
- : mode.type === "span"
502
- ? "span"
503
- : mode.type === "compose" || mode.type === "railEdit"
504
- ? "compose"
505
- : walking
506
- ? "walk"
507
- : focusedAnnotationId !== undefined
508
- ? "card"
509
- : role === "collaborator"
510
- ? "collaborator"
511
- : "normal";
512
-
513
592
  return (
514
593
  <ThemeProvider theme={theme}>
515
594
  <box
@@ -526,7 +605,19 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
526
605
  controller.saveReviewPanel({ width: reviewWidthRef.current });
527
606
  }}
528
607
  >
529
- <Breadcrumb items={headerItems} />
608
+ <box style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}>
609
+ <box style={{ flexGrow: 1, flexDirection: "row", paddingRight: 1 }}>
610
+ <Breadcrumb items={headerItems} />
611
+ <box style={{ flexGrow: 1 }} />
612
+ {isOwner && !isDiff && !resolved ? (
613
+ <Toolbar>
614
+ <Button onPress={onEditRequest} theme={theme}>{" Edit "}</Button>
615
+ <Button onPress={onShareRequest} theme={theme}>{" Share "}</Button>
616
+ </Toolbar>
617
+ ) : null}
618
+ </box>
619
+ <box style={{ width: railFootprint }} />
620
+ </box>
530
621
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
531
622
  {isDiff ? (
532
623
  // the sheet dims to reading-quiet colors while the wizard has focus
@@ -535,6 +626,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
535
626
  cursor={cursor}
536
627
  annotations={activeSession.annotations}
537
628
  focusedAnnotationId={focusedAnnotationId}
629
+ compose={diffComposeState}
538
630
  theme={walking ? dimmedTheme(theme) : undefined}
539
631
  />
540
632
  ) : (
@@ -548,16 +640,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
548
640
  compose={composeState}
549
641
  editOrphanCount={editOrphanCount}
550
642
  onLineActivate={onLineActivate}
551
- onEditRequest={onEditRequest}
552
- onShareRequest={onShareRequest}
553
- canEdit={isOwner}
554
643
  />
555
644
  )}
556
645
  <ReviewPanel
557
646
  mode={reviewMode}
558
647
  width={resolveReviewWidth(reviewWidth, terminalWidth)}
559
- height={terminalHeight - CHROME_ROWS}
560
- dragging={dividerDragging}
561
648
  onDividerGrab={onDividerGrab}
562
649
  onToggle={onToggleReviewPanel}
563
650
  railRef={railRef}
@@ -577,19 +664,21 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
577
664
  }}
578
665
  />
579
666
  </box>
580
- {mode.type === "compose" && isDiff ? (
581
- <ComposeBar
582
- kind={mode.kind}
583
- quote={rows[mode.displayIndex]?.text ?? ""}
584
- text={mode.text}
585
- onInput={(text) => {
586
- liveInput.current = text;
587
- setMode({ ...mode, text });
588
- }}
589
- />
590
- ) : (
591
- <StatusBar>{keyBindings.statusHint(hintMode)}</StatusBar>
592
- )}
667
+ <MenuBar
668
+ open={menuOpen}
669
+ version={CLIENT_VERSION}
670
+ status={status}
671
+ onToggle={() => setMenuOpen((isOpen) => !isOpen)}
672
+ onSettings={() => {
673
+ setMenuOpen(false);
674
+ setMenuDialog("settings");
675
+ }}
676
+ onKeybinds={() => {
677
+ setMenuOpen(false);
678
+ setMenuDialog("keybinds");
679
+ }}
680
+ theme={theme}
681
+ />
593
682
  {walking && walk !== null ? (
594
683
  <WalkWizard
595
684
  files={walkFileList}
@@ -630,6 +719,21 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
630
719
  theme={theme}
631
720
  />
632
721
  ) : null}
722
+ {toast ? <Toast title={toast.title} body={toast.body} theme={theme} /> : null}
723
+ {menuDialog === "keybinds" ? <KeybindsDialog sections={keyBindings.cheatsheet()} theme={theme} /> : null}
724
+ {menuDialog === "settings" ? (
725
+ <SettingsDialog
726
+ isOpen
727
+ categories={settingsCategories}
728
+ values={settingsValues}
729
+ activeCategoryId={settingsNav.categoryId}
730
+ activeRowIndex={settingsNav.rowIndex}
731
+ activeZone={settingsNav.zone}
732
+ onCategorySelect={(id) => setSettingsNav({ categoryId: id, rowIndex: 0, zone: "body" })}
733
+ onRowActivate={(row) => cycleSetting(row.key)}
734
+ theme={theme}
735
+ />
736
+ ) : null}
633
737
  </box>
634
738
  </ThemeProvider>
635
739
  );
@@ -190,7 +190,7 @@ describe("the guided walk", () => {
190
190
  // two full App boots with daemon round-trips need more than the default
191
191
  // budget, and the whole-suite publish lane has timed even 15s out; give the
192
192
  // double-boot chain generous headroom so runner load cannot flake it.
193
- }, 30_000);
193
+ }, 60_000);
194
194
 
195
195
  test("the agent-note block renders only for files carrying a note", async () => {
196
196
  // Arrange