@cueloop/client 0.1.0-alpha.25 → 0.1.0-alpha.27

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 CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.25",
3
+ "version": "0.1.0-alpha.27",
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.25",
11
- "@cueloop/extension-api": "0.1.0-alpha.25",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.25",
13
- "@cueloop/schema": "0.1.0-alpha.25",
10
+ "@cueloop/daemon": "0.1.0-alpha.27",
11
+ "@cueloop/extension-api": "0.1.0-alpha.27",
12
+ "@cueloop/integration-obsidian": "0.1.0-alpha.27",
13
+ "@cueloop/schema": "0.1.0-alpha.27",
14
14
  "@opentui/core": "^0.5.1",
15
15
  "@opentui/keymap": "0.5.1",
16
16
  "@opentui/react": "^0.5.1",
@@ -27,9 +27,14 @@ The daemon persists sessions to disk atomically.
27
27
  let home: string;
28
28
  let server: DaemonServer;
29
29
  let session: ReviewSession;
30
+ let priorConfig: string | undefined;
30
31
 
31
32
  beforeEach(() => {
32
33
  home = mkdtempSync(join(tmpdir(), "cueloop-plan-review-"));
34
+ // Isolate from the dev's real config so the rail state is the default here,
35
+ // not whatever review_state they last persisted (CI runs clean already).
36
+ priorConfig = process.env.CUELOOP_CONFIG;
37
+ process.env.CUELOOP_CONFIG = join(home, "no-config.toml");
33
38
  server = new DaemonServer({ home, idleExitMs: 0 });
34
39
  server.start();
35
40
  session = server.core.sessionCreate({
@@ -38,6 +43,8 @@ beforeEach(() => {
38
43
  });
39
44
  });
40
45
  afterEach(() => {
46
+ if (priorConfig === undefined) delete process.env.CUELOOP_CONFIG;
47
+ else process.env.CUELOOP_CONFIG = priorConfig;
41
48
  server.stop();
42
49
  rmSync(home, { recursive: true, force: true });
43
50
  });
@@ -130,6 +137,28 @@ describe("edit affordance", () => {
130
137
  });
131
138
  });
132
139
 
140
+ describe("attribution", () => {
141
+ test("a collaborator's note shows their name in the card border", async () => {
142
+ // Arrange - a pulled collaborator note carries an author fingerprint
143
+ const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
144
+ const blocks = parseBlocks(PLAN);
145
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("The daemon persists"));
146
+ server.core.sessionAnnotate(session.id, {
147
+ id: "collab-1",
148
+ kind: "comment",
149
+ anchor: makeAnchor(blocks, contextBlockIndex, 0, 10),
150
+ body: "who owns retries?",
151
+ author: "SHA256:1a2b3c4d5e6f",
152
+ });
153
+
154
+ // Act
155
+ const setup = await renderApp();
156
+
157
+ // Assert - the short author handle rides the card border
158
+ await waitForText(setup, "1a2b3c4d");
159
+ });
160
+ });
161
+
133
162
  describe("selection feeds the annotation quote", () => {
134
163
  test("v anchors, l extends, c composes with the char-precise quote", async () => {
135
164
  // Arrange
@@ -335,6 +364,29 @@ describe("the document selects, the rail edits", () => {
335
364
  );
336
365
  });
337
366
 
367
+ test("activating a collaborator's card opens rename, not a body edit", async () => {
368
+ // Arrange - a pulled collaborator note carries an author fingerprint
369
+ const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
370
+ const blocks = parseBlocks(PLAN);
371
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("The daemon persists"));
372
+ server.core.sessionAnnotate(session.id, {
373
+ id: "collab-1",
374
+ kind: "comment",
375
+ anchor: makeAnchor(blocks, contextBlockIndex, 0, 10),
376
+ body: "who owns retries?",
377
+ author: "SHA256:1a2b3c4d5e6f",
378
+ });
379
+ const setup = await renderApp();
380
+ await waitForText(setup, "1a2b3c4d");
381
+
382
+ // Act - focus the collaborator card, then activate it
383
+ await press(setup, "n");
384
+ await press(setup, "e");
385
+
386
+ // Assert - the rename prompt opens; the body never becomes an editor
387
+ await waitForText(setup, "Rename author");
388
+ });
389
+
338
390
  test("x deletes the selected card and un-paints the document highlight", async () => {
339
391
  // Arrange
340
392
  const setup = await renderApp();
package/src/App.tsx CHANGED
@@ -16,7 +16,7 @@ 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 } from "./config";
19
+ import { DEFAULT_KEYS, loadConfig, persistAuthorName } 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";
@@ -39,6 +39,8 @@ import {
39
39
  } from "./review-panel";
40
40
  import { CompletionOverlay } from "./components/CompletionOverlay";
41
41
  import { InboxList } from "./components/InboxList";
42
+ import { ConfirmDialog } from "./components/ConfirmDialog";
43
+ import { PromptDialog } from "./components/PromptDialog";
42
44
  import { ComposeBar } from "./components/ComposeBar";
43
45
  import { WalkWizard } from "./components/WalkWizard";
44
46
 
@@ -67,9 +69,14 @@ export interface AppProps {
67
69
  * viewer: annotates, but cannot edit the plan or submit an agent verdict.
68
70
  */
69
71
  role?: "owner" | "observer" | "collaborator";
72
+ /**
73
+ * A collaborator's own SSH fingerprint. On first open of a share it seeds the
74
+ * name prompt so their notes attribute to a name, not a fingerprint.
75
+ */
76
+ selfAuthor?: string;
70
77
  }
71
78
 
72
- export function App({ home, sessionId, readOnly = false, onExit, clock, openClient, role = "owner" }: AppProps): React.ReactNode {
79
+ export function App({ home, sessionId, readOnly = false, onExit, clock, openClient, role = "owner", selfAuthor }: AppProps): React.ReactNode {
73
80
  // Observer stays fully read-only; a collaborator writes annotations but not
74
81
  // the plan or a verdict. `observer` is what the controller and every write
75
82
  // gate key off; the two capability flags carve out the collaborator's middle.
@@ -126,6 +133,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
126
133
  const keysRef = useRef(DEFAULT_KEYS);
127
134
  const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
128
135
  const [theme, setTheme] = useState(DARK);
136
+ const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
129
137
  useEffect(() => {
130
138
  const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
131
139
  keysRef.current = config.keys;
@@ -134,6 +142,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
134
142
  setReviewMode(config.ui.reviewState);
135
143
  setReviewWidth(config.ui.reviewWidth);
136
144
  reviewWidthRef.current = config.ui.reviewWidth;
145
+ setAuthorNames(config.authors);
137
146
  controller.applyConfig(config);
138
147
  }, [session?.workspace.repoRoot, controller, keyBindings]);
139
148
  useEffect(
@@ -143,6 +152,16 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
143
152
  [],
144
153
  );
145
154
 
155
+ // First open of a share: ask the collaborator for a display name once, unless
156
+ // a past visit already recorded one. esc skips and their notes read anonymous.
157
+ const promptedSelfRef = useRef(false);
158
+ useEffect(() => {
159
+ if (promptedSelfRef.current || role !== "collaborator" || !selfAuthor || !session) return;
160
+ promptedSelfRef.current = true;
161
+ const known = session.participants?.find((participant) => participant.id === selfAuthor)?.name;
162
+ if (!known) setMode({ type: "nameSelf", text: "" });
163
+ }, [role, selfAuthor, session]);
164
+
146
165
  // ── derived view model ──────────────────────
147
166
  const display = controller.display();
148
167
  const rows = controller.rows();
@@ -204,9 +223,15 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
204
223
 
205
224
  const openCardEdit = (annotationId: string): void => {
206
225
  if (observer) return controller.setStatus("observer - read-only");
207
- if (resolved) return controller.setStatus("review submitted - read-only");
208
226
  const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
209
227
  if (!annotation) return;
228
+ // a collaborator's note is theirs to word: activating it (click or e) renames
229
+ // the author rather than editing the body the planner does not own
230
+ if (annotation.author) {
231
+ setFocusedAnnotationId(annotationId);
232
+ return void setMode({ type: "rename", authorId: annotation.author, text: authorNames[annotation.author] ?? "" });
233
+ }
234
+ if (resolved) return controller.setStatus("review submitted - read-only");
210
235
  liveInput.current = annotation.body;
211
236
  setMode({ type: "railEdit", id: annotation.id, text: annotation.body });
212
237
  };
@@ -237,6 +262,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
237
262
  reviewWidth,
238
263
  terminalWidth,
239
264
  focusedAnnotationId,
265
+ authorNames,
266
+ renameAuthor: (id: string, name: string) => {
267
+ persistAuthorName(id, name);
268
+ setAuthorNames((prev) => ({ ...prev, [id]: name }));
269
+ },
240
270
  liveInput,
241
271
  reviewWidthRef,
242
272
  planSheetRef,
@@ -258,6 +288,10 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
258
288
  ? "compose"
259
289
  : mode.type === "submit"
260
290
  ? "submit"
291
+ : mode.type === "confirmDelete"
292
+ ? "confirm"
293
+ : mode.type === "rename" || mode.type === "nameSelf"
294
+ ? "prompt"
261
295
  : completion.phase === "prompt"
262
296
  ? "completion-prompt"
263
297
  : completion.phase === "counting"
@@ -307,9 +341,25 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
307
341
  );
308
342
  }
309
343
  if (!session && inbox) {
344
+ const confirming = mode.type === "confirmDelete" ? mode : null;
310
345
  return (
311
346
  <ThemeProvider theme={theme}>
312
- <InboxList inbox={inbox} cursor={inboxCursor} />
347
+ <InboxList
348
+ inbox={inbox}
349
+ cursor={inboxCursor}
350
+ onRequestDelete={(id, title) => setMode({ type: "confirmDelete", sessionId: id, title })}
351
+ />
352
+ <ConfirmDialog
353
+ isOpen={confirming !== null}
354
+ title=" Delete plan "
355
+ message={confirming ? `Delete "${confirming.title}"? This removes the plan and its review.` : ""}
356
+ onConfirm={() => {
357
+ if (confirming) controller.deleteSession(confirming.sessionId);
358
+ setMode({ type: "normal" });
359
+ }}
360
+ onCancel={() => setMode({ type: "normal" })}
361
+ theme={theme}
362
+ />
313
363
  </ThemeProvider>
314
364
  );
315
365
  }
@@ -513,6 +563,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
513
563
  railRef={railRef}
514
564
  rail={{
515
565
  session: activeSession,
566
+ authorNames,
516
567
  selectedId: focusedAnnotationId,
517
568
  resolvedIds: isDiff ? null : resolvedIds,
518
569
  railTab,
@@ -557,6 +608,28 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
557
608
  onBack={() => dispatch({ type: "walkBack" })}
558
609
  />
559
610
  ) : null}
611
+ {mode.type === "rename" ? (
612
+ <PromptDialog
613
+ isOpen
614
+ title=" Rename author "
615
+ label="Display name for this collaborator:"
616
+ value={mode.text}
617
+ placeholder="their name"
618
+ onInput={(text) => setMode({ ...mode, text })}
619
+ theme={theme}
620
+ />
621
+ ) : null}
622
+ {mode.type === "nameSelf" ? (
623
+ <PromptDialog
624
+ isOpen
625
+ title=" Welcome "
626
+ label="Your name (optional) - it attributes the notes you leave:"
627
+ value={mode.text}
628
+ placeholder="your name"
629
+ onInput={(text) => setMode({ ...mode, text })}
630
+ theme={theme}
631
+ />
632
+ ) : null}
560
633
  </box>
561
634
  </ThemeProvider>
562
635
  );
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Identity } from "@cueloop/schema";
3
+ import { ANONYMOUS_LABEL, resolveDisplayName, shortHandle } from "./attribution";
4
+
5
+ describe(shortHandle, () => {
6
+ test("shortens an ssh fingerprint to a stable handle", () => {
7
+ // Arrange / Act / Assert
8
+ expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe("1a2b3c4d");
9
+ expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe(shortHandle("SHA256:1a2b3c4d5e6f"));
10
+ });
11
+ });
12
+
13
+ describe(resolveDisplayName, () => {
14
+ const named: Identity[] = [{ id: "SHA256:abc", provider: "ssh", name: "Al" }];
15
+ const unnamed: Identity[] = [{ id: "SHA256:abc", provider: "ssh" }];
16
+
17
+ test("a local rename override wins over everything", () => {
18
+ // Arrange / Act
19
+ const label = resolveDisplayName("SHA256:abc", named, { "SHA256:abc": "Alex" });
20
+
21
+ // Assert
22
+ expect(label).toBe("Alex");
23
+ });
24
+
25
+ test("falls back to name, then anonymous, then the short handle", () => {
26
+ // Arrange / Act / Assert
27
+ expect(resolveDisplayName("SHA256:abc", named, {})).toBe("Al");
28
+ expect(resolveDisplayName("SHA256:abc", unnamed, {})).toBe(ANONYMOUS_LABEL);
29
+ expect(resolveDisplayName("SHA256:1a2b3c4d5e", [], {})).toBe("1a2b3c4d");
30
+ });
31
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Attribution labels for annotations. An annotation's `author` is an identity
3
+ * id (an SSH fingerprint today). The rail resolves it against the share's
4
+ * participant registry and the planner's local rename overrides; ADR 0006.
5
+ */
6
+
7
+ import type { Identity } from "@cueloop/schema";
8
+
9
+ const FINGERPRINT_PREFIX = "SHA256:";
10
+
11
+ /** Shown for a known participant who chose not to give a name. */
12
+ export const ANONYMOUS_LABEL = "-- anonymous --";
13
+
14
+ /** A short, stable handle from a raw author id when no name is known. */
15
+ export function shortHandle(authorId: string): string {
16
+ const base = authorId.startsWith(FINGERPRINT_PREFIX) ? authorId.slice(FINGERPRINT_PREFIX.length) : authorId;
17
+ return base.slice(0, 8) || authorId;
18
+ }
19
+
20
+ /**
21
+ * The rail label for an author id: a local rename wins, then the collaborator's
22
+ * own name, then anonymous for a known-but-unnamed participant, then a short
23
+ * handle for an id we hold no identity for.
24
+ */
25
+ export function resolveDisplayName(authorId: string, participants: Identity[] | undefined, overrides: Record<string, string>): string {
26
+ const override = overrides[authorId];
27
+ if (override) return override;
28
+ const identity = participants?.find((participant) => participant.id === authorId);
29
+ if (identity?.name) return identity.name;
30
+ if (identity) return ANONYMOUS_LABEL;
31
+ return shortHandle(authorId);
32
+ }
@@ -14,7 +14,8 @@ import React, { useEffect, useRef, useState } from "react";
14
14
  import type { KeyBinding, TextareaRenderable } from "@opentui/core";
15
15
  import type { Theme } from "../theme";
16
16
  import { useComponentTheme } from "./theme-context";
17
- import { Card } from "./primitives/Card";
17
+ import { Card, cardHeight } from "./primitives/Card";
18
+ import { FRAME_BORDER_STYLE } from "./primitives/frame";
18
19
  import { Button } from "./primitives/Button";
19
20
  import { Toolbar } from "./primitives/Toolbar";
20
21
  import { truncateToSingleLine } from "./truncate-text";
@@ -31,6 +32,8 @@ export interface AnnotationSaved {
31
32
  isSelected: boolean;
32
33
  isOrphan: boolean;
33
34
  isBlocking: boolean;
35
+ /** Resolved author display name; own notes carry none. Set = name-in-border. */
36
+ authorLabel?: string;
34
37
  /** Non-null while the card body is being rewritten in place. */
35
38
  editing: AnnotationDraft | null;
36
39
  onPress: () => void;
@@ -166,23 +169,39 @@ export function AnnotationCard({ id, kind, quote, draft, saved, theme }: Annotat
166
169
  );
167
170
  }
168
171
  const card = saved!;
172
+ // A collaborator's note (author set) renders as a named, bordered card; own notes stay borderless.
173
+ const authored = card.authorLabel !== undefined && card.authorLabel !== "";
174
+ const indent = authored ? "" : " ";
175
+ const marker = card.isSelected ? "▸ " : indent;
176
+ // Fixed content rows (a bordered box must declare a height or it collapses): header + quote + body, or the editor.
177
+ const contentRows = card.editing ? editorRowCount + 3 : 3;
178
+ // The border + its side padding eat this much content width, so text truncates shorter.
179
+ const borderInset = authored ? 4 : 0;
169
180
  return (
170
181
  <box
171
182
  id={id}
172
- style={{ flexDirection: "column", marginBottom: 1, backgroundColor: card.isSelected ? tokens.elevated : undefined }}
183
+ title={authored ? ` ${card.authorLabel} ` : undefined}
184
+ style={{
185
+ flexDirection: "column",
186
+ marginBottom: 1,
187
+ backgroundColor: card.isSelected ? tokens.elevated : undefined,
188
+ ...(authored
189
+ ? { height: cardHeight(contentRows), border: true, borderStyle: FRAME_BORDER_STYLE, borderColor: tokens.blue, paddingLeft: 1, paddingRight: 1 }
190
+ : {}),
191
+ }}
173
192
  onMouseUp={card.onPress}
174
193
  >
175
194
  <text fg={card.isSelected ? tokens.text : kindColor}>
176
- {card.isSelected ? "▸ " : " "}
195
+ {marker}
177
196
  {kind.toUpperCase()}
178
197
  {card.isBlocking ? <span fg={tokens.red}> · BLOCKING</span> : null}
179
198
  <span fg={tokens.textDim}>{card.isOrphan ? " · ORPHANED" : " · pending"}</span>
180
199
  </text>
181
- <text fg={tokens.textDim}> "{truncateToSingleLine(quote, 26)}"</text>
200
+ <text fg={tokens.textDim}>{indent}"{truncateToSingleLine(quote, 26 - borderInset)}"</text>
182
201
  {card.editing ? (
183
202
  <DraftEditor draft={card.editing} rows={editorRowCount} onRowsChange={setEditorRowCount} theme={theme} />
184
203
  ) : (
185
- <text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}> {truncateToSingleLine(card.body, 28)}</text>
204
+ <text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}>{indent}{truncateToSingleLine(card.body, 28 - borderInset)}</text>
186
205
  )}
187
206
  </box>
188
207
  );
@@ -0,0 +1,21 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ConfirmDialog } from "./ConfirmDialog";
5
+
6
+ export const meta: StoryMeta = { title: "ConfirmDialog" };
7
+
8
+ const callbacks = { onConfirm: () => {}, onCancel: () => {} };
9
+
10
+ export const DeletePlan: Story = {
11
+ render: () => (
12
+ <ConfirmDialog
13
+ isOpen
14
+ title=" Delete plan "
15
+ message={'Delete "Auth rollout"? This removes the plan and its review.'}
16
+ {...callbacks}
17
+ />
18
+ ),
19
+ expectedColors: [DARK.backdrop, DARK.panel, DARK.accent],
20
+ size: { width: 64, height: 16 },
21
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A centered yes/no confirmation on the shared Dialog overlay. Presentational:
3
+ * the key grammar (⏎ confirm, esc cancel) is driven by the reducer; the buttons
4
+ * carry the same intents for the mouse. Used for destructive actions like the
5
+ * inbox delete.
6
+ */
7
+
8
+ import React from "react";
9
+ import { useTerminalDimensions } from "@opentui/react";
10
+ import type { Theme } from "../theme";
11
+ import { useComponentTheme } from "./theme-context";
12
+ import { Dialog } from "./primitives/Dialog";
13
+ import { Button } from "./primitives/Button";
14
+ import { Toolbar } from "./primitives/Toolbar";
15
+
16
+ export interface ConfirmDialogProps {
17
+ isOpen: boolean;
18
+ title?: string;
19
+ message: string;
20
+ confirmLabel?: string;
21
+ cancelLabel?: string;
22
+ onConfirm: () => void;
23
+ onCancel: () => void;
24
+ theme?: Theme;
25
+ }
26
+
27
+ export function ConfirmDialog({
28
+ isOpen,
29
+ title,
30
+ message,
31
+ confirmLabel = "Delete",
32
+ cancelLabel = "Cancel",
33
+ onConfirm,
34
+ onCancel,
35
+ theme,
36
+ }: ConfirmDialogProps): React.ReactNode {
37
+ const tokens = useComponentTheme(theme);
38
+ const { width: terminalWidth } = useTerminalDimensions();
39
+ if (!isOpen) return null;
40
+ return (
41
+ <Dialog isOpen title={title} width={Math.min(54, terminalWidth - 6)} height={7} theme={theme}>
42
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
43
+ <text fg={tokens.text}>{message}</text>
44
+ <box style={{ flexGrow: 1 }} />
45
+ <Toolbar>
46
+ <Button variant="solid" onPress={onConfirm} theme={theme}>
47
+ {` ${confirmLabel} ⏎ `}
48
+ </Button>
49
+ <Button onPress={onCancel} theme={theme}>
50
+ {` ${cancelLabel} esc `}
51
+ </Button>
52
+ </Toolbar>
53
+ </box>
54
+ </Dialog>
55
+ );
56
+ }
@@ -1,6 +1,8 @@
1
1
  /**
2
- * The pending-review inbox: a cursor-driven session list. Selection stays
3
- * with the keyboard grammar; the list only renders the snapshot.
2
+ * The pending-review inbox: a cursor-driven session list. Selection stays with
3
+ * the keyboard grammar; the list only renders the snapshot. The selected row
4
+ * carries a [delete] word-button (click, or `d` in the grammar) that asks the
5
+ * caller to confirm removal.
4
6
  */
5
7
 
6
8
  import React from "react";
@@ -8,14 +10,17 @@ import type { ReviewSession } from "@cueloop/schema";
8
10
  import type { Theme } from "../theme";
9
11
  import { useComponentTheme } from "./theme-context";
10
12
  import { StatusBar } from "./primitives/StatusBar";
13
+ import { Button } from "./primitives/Button";
11
14
 
12
15
  export interface InboxListProps {
13
16
  inbox: ReviewSession[];
14
17
  cursor: number;
18
+ /** Ask to delete a plan (the selected row's [delete] button). */
19
+ onRequestDelete?: (id: string, title: string) => void;
15
20
  theme?: Theme;
16
21
  }
17
22
 
18
- export function InboxList({ inbox, cursor, theme }: InboxListProps): React.ReactNode {
23
+ export function InboxList({ inbox, cursor, onRequestDelete, theme }: InboxListProps): React.ReactNode {
19
24
  const tokens = useComponentTheme(theme);
20
25
  return (
21
26
  <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: tokens.bg, padding: 1 }}>
@@ -24,19 +29,27 @@ export function InboxList({ inbox, cursor, theme }: InboxListProps): React.React
24
29
  {inbox.length === 0 ? (
25
30
  <text fg={tokens.textDim}>nothing waiting for review</text>
26
31
  ) : (
27
- inbox.map((session, index) => (
28
- <text
29
- key={session.id}
30
- fg={index === cursor ? tokens.text : tokens.textMuted}
31
- bg={index === cursor ? tokens.cursorBg : undefined}
32
- >
33
- {index === cursor ? "▸ " : " "}
34
- {session.artifact.meta.title ?? session.id} · {session.workspace.branch} · {session.artifact.type}
35
- </text>
36
- ))
32
+ inbox.map((session, index) => {
33
+ const selected = index === cursor;
34
+ const title = session.artifact.meta.title ?? session.id;
35
+ return (
36
+ <box key={session.id} style={{ flexDirection: "row", backgroundColor: selected ? tokens.cursorBg : undefined }}>
37
+ <text fg={selected ? tokens.text : tokens.textMuted}>
38
+ {selected ? "▸ " : " "}
39
+ {title} · {session.workspace.branch} · {session.artifact.type}
40
+ </text>
41
+ <box style={{ flexGrow: 1 }} />
42
+ {selected && onRequestDelete ? (
43
+ <Button onPress={() => onRequestDelete(session.id, title)} theme={theme}>
44
+ {" [delete] "}
45
+ </Button>
46
+ ) : null}
47
+ </box>
48
+ );
49
+ })
37
50
  )}
38
51
  <box style={{ flexGrow: 1 }} />
39
- <StatusBar theme={theme}>j/k move · ⏎ open · q quit</StatusBar>
52
+ <StatusBar theme={theme}>j/k move · ⏎ open · d delete · q quit</StatusBar>
40
53
  </box>
41
54
  );
42
55
  }
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { PromptDialog } from "./PromptDialog";
5
+
6
+ export const meta: StoryMeta = { title: "PromptDialog" };
7
+
8
+ export const RenameAuthor: Story = {
9
+ render: () => (
10
+ <PromptDialog
11
+ isOpen
12
+ title=" Rename author "
13
+ label="Display name for this collaborator:"
14
+ value="Robin"
15
+ placeholder="their name"
16
+ onInput={() => {}}
17
+ />
18
+ ),
19
+ expectedColors: [DARK.backdrop, DARK.panel, DARK.accent],
20
+ size: { width: 64, height: 16 },
21
+ };
22
+
23
+ export const NameYourself: Story = {
24
+ render: () => (
25
+ <PromptDialog
26
+ isOpen
27
+ title=" Welcome "
28
+ label="Your name (optional) - it attributes the notes you leave:"
29
+ value=""
30
+ placeholder="your name"
31
+ onInput={() => {}}
32
+ />
33
+ ),
34
+ expectedColors: [DARK.backdrop, DARK.panel],
35
+ size: { width: 64, height: 16 },
36
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * A centered single-field text prompt on the shared Dialog overlay. The focused
3
+ * input owns typing/cursor natively; the reducer's `prompt` overlay drives ⏎
4
+ * save / esc cancel (like the composer). Used to rename an author and to ask a
5
+ * collaborator for their name.
6
+ */
7
+
8
+ import React, { useEffect, useRef } from "react";
9
+ import { useTerminalDimensions } from "@opentui/react";
10
+ import type { KeyBinding, TextareaRenderable } from "@opentui/core";
11
+ import type { Theme } from "../theme";
12
+ import { useComponentTheme } from "./theme-context";
13
+ import { Dialog } from "./primitives/Dialog";
14
+
15
+ // ⏎ submits (suppresses the textarea's default newline); the grammar owns save.
16
+ const PROMPT_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
17
+
18
+ export interface PromptDialogProps {
19
+ isOpen: boolean;
20
+ title?: string;
21
+ label: string;
22
+ value: string;
23
+ placeholder?: string;
24
+ onInput: (text: string) => void;
25
+ theme?: Theme;
26
+ }
27
+
28
+ export function PromptDialog({ isOpen, title, label, value, placeholder, onInput, theme }: PromptDialogProps): React.ReactNode {
29
+ const tokens = useComponentTheme(theme);
30
+ const { width: terminalWidth } = useTerminalDimensions();
31
+ const inputRef = useRef<TextareaRenderable | null>(null);
32
+ useEffect(() => {
33
+ // open with the caret after the seeded value, like a text field
34
+ if (inputRef.current) inputRef.current.cursorOffset = value.length;
35
+ // eslint-disable-next-line react-hooks/exhaustive-deps
36
+ }, []);
37
+ if (!isOpen) return null;
38
+ return (
39
+ <Dialog isOpen title={title} width={Math.min(54, terminalWidth - 6)} height={7} theme={theme}>
40
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
41
+ <text fg={tokens.textDim}>{label}</text>
42
+ <textarea
43
+ ref={inputRef}
44
+ focused
45
+ initialValue={value}
46
+ placeholder={placeholder}
47
+ keyBindings={PROMPT_KEY_BINDINGS}
48
+ onContentChange={() => onInput(inputRef.current?.plainText ?? "")}
49
+ style={{
50
+ height: 1,
51
+ backgroundColor: tokens.elevated,
52
+ focusedBackgroundColor: tokens.elevated,
53
+ textColor: tokens.text,
54
+ focusedTextColor: tokens.text,
55
+ }}
56
+ />
57
+ <box style={{ flexGrow: 1 }} />
58
+ <text fg={tokens.textDim}>⏎ save · esc cancel</text>
59
+ </box>
60
+ </Dialog>
61
+ );
62
+ }
@@ -9,6 +9,7 @@ export const meta: StoryMeta = { title: "ReviewPanel" };
9
9
 
10
10
  const rail = {
11
11
  session: fixturePlanSession(),
12
+ authorNames: {},
12
13
  selectedId: FIXTURE_ANNOTATIONS[0]!.id,
13
14
  resolvedIds: new Set(FIXTURE_ANNOTATIONS.map((annotation) => annotation.id)),
14
15
  railTab: "review" as const,
@@ -23,6 +23,7 @@ export const AnnotationStack: Story = {
23
23
  <RailFrame>
24
24
  <ReviewRail
25
25
  session={fixturePlanSession()}
26
+ authorNames={{}}
26
27
  selectedId={FIXTURE_ANNOTATIONS[0]!.id}
27
28
  resolvedIds={new Set(FIXTURE_ANNOTATIONS.map((annotation) => annotation.id))}
28
29
  railTab="review"
@@ -42,6 +43,7 @@ export const AgentTab: Story = {
42
43
  <RailFrame>
43
44
  <ReviewRail
44
45
  session={fixturePlanSession()}
46
+ authorNames={{}}
45
47
  resolvedIds={new Set()}
46
48
  railTab="agent"
47
49
  pendingCount={2}
@@ -59,6 +61,7 @@ export const SubmitConfirmOpen: Story = {
59
61
  <RailFrame>
60
62
  <ReviewRail
61
63
  session={fixturePlanSession()}
64
+ authorNames={{}}
62
65
  resolvedIds={new Set()}
63
66
  railTab="review"
64
67
  pendingCount={2}
@@ -89,6 +92,7 @@ export const Resolved: Story = {
89
92
  status: "resolved",
90
93
  verdict: { kind: "approve", summary: "", feedback: "", resolvedAt: "2026-01-01T00:00:00Z" },
91
94
  })}
95
+ authorNames={{}}
92
96
  resolvedIds={new Set()}
93
97
  railTab="review"
94
98
  pendingCount={0}
@@ -15,6 +15,7 @@ import { Tabs, Tab, TabList } from "./primitives/Tabs";
15
15
  import { Button } from "./primitives/Button";
16
16
  import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
17
17
  import { ConfirmCard, type ConfirmCardProps } from "./ConfirmCard";
18
+ import { resolveDisplayName } from "../attribution";
18
19
 
19
20
  export type RailTab = "review" | "agent";
20
21
 
@@ -29,6 +30,8 @@ export interface ReviewRailHandle {
29
30
 
30
31
  export interface ReviewRailProps {
31
32
  session: ReviewSession;
33
+ /** Planner-local author renames (config [authors]); override the registry name. */
34
+ authorNames: Record<string, string>;
32
35
  selectedId?: string;
33
36
  /** Ids whose anchor resolved; null = orphan display off (diff view). */
34
37
  resolvedIds: Set<string> | null;
@@ -58,6 +61,7 @@ export function annotationBlocking(annotation: Annotation): boolean {
58
61
  export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function ReviewRail(
59
62
  {
60
63
  session,
64
+ authorNames,
61
65
  selectedId,
62
66
  resolvedIds,
63
67
  railTab,
@@ -121,6 +125,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
121
125
  isSelected: annotation.id === selectedId,
122
126
  isOrphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
123
127
  isBlocking: annotationBlocking(annotation),
128
+ authorLabel: annotation.author ? resolveDisplayName(annotation.author, session.participants, authorNames) : undefined,
124
129
  editing:
125
130
  cardEdit && cardEdit.id === annotation.id
126
131
  ? {
@@ -467,7 +467,7 @@ exports[`stories catalog InboxList/Empty renders, snapshots, and carries its col
467
467
  nothing waiting for review
468
468
 
469
469
 
470
- j/k move · ⏎ open · q quit
470
+ j/k move · ⏎ open · d delete · q quit
471
471
 
472
472
  "
473
473
  `;
@@ -483,7 +483,7 @@ exports[`stories catalog InboxList/TwoPending renders, snapshots, and carries it
483
483
 
484
484
 
485
485
 
486
- j/k move · ⏎ open · q quit
486
+ j/k move · ⏎ open · d delete · q quit
487
487
 
488
488
  "
489
489
  `;
@@ -1455,5 +1455,65 @@ exports[`stories catalog ReviewPanel/Hidden renders, snapshots, and carries its
1455
1455
 
1456
1456
 
1457
1457
 
1458
+ "
1459
+ `;
1460
+
1461
+ exports[`stories catalog ConfirmDialog/DeletePlan renders, snapshots, and carries its colors: ConfirmDialog/DeletePlan 1`] = `
1462
+ "
1463
+
1464
+
1465
+
1466
+
1467
+ ╭─ Delete plan ──────────────────────────────────────╮
1468
+ │ │
1469
+ │ Delete "Auth rollout"? This removes the plan and │
1470
+ │ its review. │
1471
+ │ │
1472
+ │ Delete ⏎ Cancel esc │
1473
+ ╰────────────────────────────────────────────────────╯
1474
+
1475
+
1476
+
1477
+
1478
+ "
1479
+ `;
1480
+
1481
+ exports[`stories catalog PromptDialog/NameYourself renders, snapshots, and carries its colors: PromptDialog/NameYourself 1`] = `
1482
+ "
1483
+
1484
+
1485
+
1486
+
1487
+ ╭─ Welcome ──────────────────────────────────────────╮
1488
+ │ │
1489
+ │ Your name (optional) - it attributes the notes │
1490
+ │ you leave: │
1491
+ │ your name │
1492
+ │ ⏎ save · esc cancel │
1493
+ ╰────────────────────────────────────────────────────╯
1494
+
1495
+
1496
+
1497
+
1498
+ "
1499
+ `;
1500
+
1501
+ exports[`stories catalog PromptDialog/RenameAuthor renders, snapshots, and carries its colors: PromptDialog/RenameAuthor 1`] = `
1502
+ "
1503
+
1504
+
1505
+
1506
+
1507
+ ╭─ Rename author ────────────────────────────────────╮
1508
+ │ │
1509
+ │ Display name for this collaborator: │
1510
+ │ Robin │
1511
+ │ │
1512
+ │ ⏎ save · esc cancel │
1513
+ ╰────────────────────────────────────────────────────╯
1514
+
1515
+
1516
+
1517
+
1458
1518
  "
1459
1519
  `;
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { DEFAULT_KEYS, actionFor, loadConfig, persistReviewState, persistReviewWidth } from "./config";
5
+ import { DEFAULT_KEYS, actionFor, loadConfig, persistAuthorName, persistReviewState, persistReviewWidth } from "./config";
6
6
  import { REVIEW_DEFAULT_WIDTH, REVIEW_MAX_WIDTH } from "./review-panel";
7
7
  import { DARK } from "./theme";
8
8
 
@@ -251,3 +251,32 @@ describe("actionFor", () => {
251
251
  expect(actionFor(DEFAULT_KEYS, "zz", false)).toBeUndefined();
252
252
  });
253
253
  });
254
+
255
+ describe("[authors] rename map", () => {
256
+ test("loads quoted fingerprint keys", () => {
257
+ // Arrange
258
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-authors-"));
259
+ const path = join(dir, "config.toml");
260
+ writeFileSync(path, `[authors]\n"SHA256:abc+def/gh" = "Alex"\n`);
261
+
262
+ // Act
263
+ const config = loadConfig({ userConfigPath: path });
264
+
265
+ // Assert
266
+ expect(config.authors["SHA256:abc+def/gh"]).toBe("Alex");
267
+ rmSync(dir, { recursive: true, force: true });
268
+ });
269
+
270
+ test("persistAuthorName round-trips and updates a fingerprint id in place", () => {
271
+ // Arrange
272
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-authors2-"));
273
+ const path = join(dir, "config.toml");
274
+
275
+ // Act / Assert
276
+ persistAuthorName("SHA256:abc+def/gh", "Alex", path);
277
+ expect(loadConfig({ userConfigPath: path }).authors["SHA256:abc+def/gh"]).toBe("Alex");
278
+ persistAuthorName("SHA256:abc+def/gh", "Alexa", path);
279
+ expect(loadConfig({ userConfigPath: path }).authors["SHA256:abc+def/gh"]).toBe("Alexa");
280
+ rmSync(dir, { recursive: true, force: true });
281
+ });
282
+ });
package/src/config.ts CHANGED
@@ -34,6 +34,8 @@ export interface CueloopConfig {
34
34
  * collapse mode and expanded-rail width, persisted so they survive restarts.
35
35
  */
36
36
  ui: { autoClose: AutoClose; editor?: string; reviewState: ReviewPanelMode; reviewWidth: number };
37
+ /** Planner-local author renames: identity id → display name ([authors] table). */
38
+ authors: Record<string, string>;
37
39
  integrations: IntegrationsConfig;
38
40
  }
39
41
 
@@ -51,6 +53,7 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
51
53
  next_annotation: ["n"],
52
54
  prev_annotation: ["p"],
53
55
  delete_annotation: ["backspace"],
56
+ rename: ["r"],
54
57
  submit: ["return", "enter"],
55
58
  share: ["S"],
56
59
  quit: ["q"],
@@ -70,8 +73,15 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
70
73
  keys: { ...base.keys },
71
74
  theme: { ...base.theme },
72
75
  ui: { ...base.ui },
76
+ authors: { ...base.authors },
73
77
  integrations: { obsidian: { ...base.integrations.obsidian } },
74
78
  };
79
+ const authors = raw["authors"] as Record<string, unknown> | undefined;
80
+ if (authors) {
81
+ for (const [id, value] of Object.entries(authors)) {
82
+ if (typeof value === "string") out.authors[id] = value;
83
+ }
84
+ }
75
85
  const keys = raw["keys"] as KeymapConfig | undefined;
76
86
  if (keys) {
77
87
  for (const [action, combo] of Object.entries(keys)) {
@@ -120,6 +130,7 @@ export function loadConfig(options: { repoRoot?: string; userConfigPath?: string
120
130
  keys: { ...DEFAULT_KEYS },
121
131
  theme: { ...DARK },
122
132
  ui: { autoClose: "off", reviewState: "expanded", reviewWidth: REVIEW_DEFAULT_WIDTH },
133
+ authors: {},
123
134
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
124
135
  };
125
136
  const userPath = userConfigPathFrom(options.userConfigPath);
@@ -187,3 +198,28 @@ export function persistReviewWidth(width: number, userConfigPath?: string): void
187
198
  export function persistReviewState(state: ReviewPanelMode, userConfigPath?: string): void {
188
199
  persistUiSetting("review_state", `"${state}"`, userConfigPath);
189
200
  }
201
+
202
+ function escapeRegExp(text: string): string {
203
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
204
+ }
205
+
206
+ /**
207
+ * Persist an author rename into the `[authors]` table. The identity id (an SSH
208
+ * fingerprint) is not a bare TOML key, so it is written quoted.
209
+ */
210
+ export function persistAuthorName(id: string, name: string, userConfigPath?: string): void {
211
+ const path = userConfigPathFrom(userConfigPath);
212
+ let text = existsSync(path) ? readFileSync(path, "utf8") : "";
213
+ const key = `"${id.replace(/"/g, '\\"')}"`;
214
+ const value = `"${name.replace(/"/g, '\\"')}"`;
215
+ const assignment = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*=.*$`, "m");
216
+ if (assignment.test(text)) {
217
+ text = text.replace(assignment, `$1${key} = ${value}`);
218
+ } else if (/^\[authors\]/m.test(text)) {
219
+ text = text.replace(/^\[authors\]\s*$/m, `[authors]\n${key} = ${value}`);
220
+ } else {
221
+ text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[authors]\n${key} = ${value}\n`;
222
+ }
223
+ mkdirSync(dirname(path), { recursive: true });
224
+ writeFileSync(path, text);
225
+ }
@@ -26,6 +26,8 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
26
26
  const controller = {
27
27
  setStatus: mock(),
28
28
  open: mock(),
29
+ deleteSession: mock(),
30
+ setSelfName: mock(),
29
31
  cut: mock(),
30
32
  annotate: mock(),
31
33
  updateAnnotation: mock(),
@@ -56,6 +58,8 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
56
58
  reviewWidth: 34,
57
59
  terminalWidth: 120,
58
60
  focusedAnnotationId: undefined,
61
+ authorNames: {},
62
+ renameAuthor: mock(),
59
63
  liveInput: { current: "" },
60
64
  reviewWidthRef: { current: 34 },
61
65
  planSheetRef: { current: null },
@@ -211,3 +215,94 @@ describe("exit", () => {
211
215
  expect(deps.onExit).toHaveBeenCalledWith(0);
212
216
  });
213
217
  });
218
+
219
+ describe("inbox delete", () => {
220
+ test("requestDeleteSession opens the confirm on the cursor row", () => {
221
+ // Arrange
222
+ const inbox = [{ id: "ses_1", artifact: { meta: { title: "Plan A" } } }] as never;
223
+ const deps = makeDeps({ inbox, inboxCursor: 0 });
224
+
225
+ // Act
226
+ createIntentDispatch(deps)({ type: "requestDeleteSession" });
227
+
228
+ // Assert
229
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "confirmDelete", sessionId: "ses_1", title: "Plan A" });
230
+ });
231
+
232
+ test("confirmDialog deletes the session and closes", () => {
233
+ // Arrange
234
+ const deps = makeDeps({ mode: { type: "confirmDelete", sessionId: "ses_1", title: "Plan A" } });
235
+
236
+ // Act
237
+ createIntentDispatch(deps)({ type: "confirmDialog" });
238
+
239
+ // Assert
240
+ expect(deps.controller.deleteSession).toHaveBeenCalledWith("ses_1");
241
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
242
+ });
243
+ });
244
+
245
+ describe("rename author", () => {
246
+ test("openRename seeds the prompt with the author's current local name", () => {
247
+ // Arrange
248
+ const session = { annotations: [{ id: "a1", author: "SHA256:x" }] } as never;
249
+ const deps = makeDeps({ session, focusedAnnotationId: "a1", authorNames: { "SHA256:x": "Alex" } });
250
+
251
+ // Act
252
+ createIntentDispatch(deps)({ type: "openRename" });
253
+
254
+ // Assert
255
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "rename", authorId: "SHA256:x", text: "Alex" });
256
+ });
257
+
258
+ test("openRename on your own note does nothing but explain", () => {
259
+ // Arrange
260
+ const session = { annotations: [{ id: "a1" }] } as never;
261
+ const deps = makeDeps({ session, focusedAnnotationId: "a1" });
262
+
263
+ // Act
264
+ createIntentDispatch(deps)({ type: "openRename" });
265
+
266
+ // Assert
267
+ expect(deps.controller.setStatus).toHaveBeenCalled();
268
+ expect(deps.setMode).not.toHaveBeenCalled();
269
+ });
270
+
271
+ test("confirmDialog in rename mode persists the trimmed name and closes", () => {
272
+ // Arrange
273
+ const deps = makeDeps({ mode: { type: "rename", authorId: "SHA256:x", text: " Alex " } });
274
+
275
+ // Act
276
+ createIntentDispatch(deps)({ type: "confirmDialog" });
277
+
278
+ // Assert
279
+ expect(deps.renameAuthor).toHaveBeenCalledWith("SHA256:x", "Alex");
280
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
281
+ });
282
+ });
283
+
284
+ describe("collaborator self-name", () => {
285
+ test("confirmDialog in nameSelf mode records the trimmed name and closes", () => {
286
+ // Arrange
287
+ const deps = makeDeps({ mode: { type: "nameSelf", text: " Robin " } });
288
+
289
+ // Act
290
+ createIntentDispatch(deps)({ type: "confirmDialog" });
291
+
292
+ // Assert
293
+ expect(deps.controller.setSelfName).toHaveBeenCalledWith("Robin");
294
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
295
+ });
296
+
297
+ test("an empty name still closes - the collaborator stays anonymous", () => {
298
+ // Arrange
299
+ const deps = makeDeps({ mode: { type: "nameSelf", text: " " } });
300
+
301
+ // Act
302
+ createIntentDispatch(deps)({ type: "confirmDialog" });
303
+
304
+ // Assert
305
+ expect(deps.controller.setSelfName).toHaveBeenCalledWith("");
306
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
307
+ });
308
+ });
@@ -23,7 +23,10 @@ export type Mode =
23
23
  | { type: "span"; span: SpanState }
24
24
  | { type: "compose"; kind: "comment" | "suggestion"; displayIndex: number; start: number; end: number; text: string }
25
25
  | { type: "railEdit"; id: string; text: string }
26
- | { type: "submit"; verdict: VerdictKind; summary: string };
26
+ | { type: "submit"; verdict: VerdictKind; summary: string }
27
+ | { type: "confirmDelete"; sessionId: string; title: string }
28
+ | { type: "rename"; authorId: string; text: string }
29
+ | { type: "nameSelf"; text: string };
27
30
 
28
31
  /**
29
32
  * Annotations that still count as feedback: agent notes never do, and an
@@ -60,6 +63,10 @@ export interface IntentDispatchDeps {
60
63
  reviewWidth: number;
61
64
  terminalWidth: number;
62
65
  focusedAnnotationId: string | undefined;
66
+ /** Planner-local author renames, for seeding the rename prompt. */
67
+ authorNames: Record<string, string>;
68
+ /** Persist an author rename and update the live overrides (App-owned). */
69
+ renameAuthor: (id: string, name: string) => void;
63
70
 
64
71
  liveInput: MutableRefObject<string>;
65
72
  reviewWidthRef: MutableRefObject<number>;
@@ -96,6 +103,8 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
96
103
  reviewWidth,
97
104
  terminalWidth,
98
105
  focusedAnnotationId,
106
+ authorNames,
107
+ renameAuthor,
99
108
  liveInput,
100
109
  reviewWidthRef,
101
110
  planSheetRef,
@@ -136,6 +145,22 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
136
145
  if (selected) controller.open(selected.id);
137
146
  return;
138
147
  }
148
+ case "requestDeleteSession": {
149
+ const selected = inbox?.[inboxCursor];
150
+ if (selected) setMode({ type: "confirmDelete", sessionId: selected.id, title: selected.artifact.meta.title ?? selected.id });
151
+ return;
152
+ }
153
+ case "openRename": {
154
+ const focused = session?.annotations.find((annotation) => annotation.id === focusedAnnotationId);
155
+ if (!focused?.author) return void controller.setStatus("that is your own note - nothing to rename");
156
+ return void setMode({ type: "rename", authorId: focused.author, text: authorNames[focused.author] ?? "" });
157
+ }
158
+ case "confirmDialog": {
159
+ if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
160
+ else if (mode.type === "rename") renameAuthor(mode.authorId, mode.text.trim());
161
+ else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
162
+ return void setMode({ type: "normal" });
163
+ }
139
164
  case "startSpan": {
140
165
  const block = display[cursor];
141
166
  if (!block?.work) return;
@@ -437,3 +437,41 @@ describe("read-only filter", () => {
437
437
  expect(reduceKey(keyState, key("s"))).toEqual([{ type: "status", message: "observer - read-only" }]);
438
438
  });
439
439
  });
440
+
441
+ describe("inbox delete", () => {
442
+ test("d on a session requests delete", () => {
443
+ // Arrange / Act / Assert
444
+ expect(reduceKey(state({ view: "inbox" }), key("d"))).toEqual([{ type: "requestDeleteSession" }]);
445
+ });
446
+ });
447
+
448
+ describe("confirm overlay", () => {
449
+ // Arrange
450
+ const keyState = state({ overlay: "confirm" });
451
+ const table: [string, Intent[]][] = [
452
+ ["return", [{ type: "confirmDialog" }]],
453
+ ["enter", [{ type: "confirmDialog" }]],
454
+ ["escape", [{ type: "closeOverlay" }]],
455
+ ["j", []],
456
+ ["d", []],
457
+ ];
458
+ for (const [name, expected] of table) {
459
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
460
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
461
+ });
462
+ }
463
+ });
464
+
465
+ describe("rename grammar", () => {
466
+ test("r on a focused note opens rename", () => {
467
+ // Arrange / Act / Assert
468
+ expect(reduceKey(state({ hasFocusedAnnotation: true }), key("r"))).toEqual([{ type: "openRename" }]);
469
+ });
470
+
471
+ test("prompt overlay routes ⏎ to confirm and esc to cancel", () => {
472
+ // Arrange / Act / Assert
473
+ expect(reduceKey(state({ overlay: "prompt" }), key("return"))).toEqual([{ type: "confirmDialog" }]);
474
+ expect(reduceKey(state({ overlay: "prompt" }), key("escape"))).toEqual([{ type: "closeOverlay" }]);
475
+ expect(reduceKey(state({ overlay: "prompt" }), key("a"))).toEqual([]);
476
+ });
477
+ });
package/src/keymap.ts CHANGED
@@ -14,6 +14,9 @@ export type Intent =
14
14
  | { type: "move"; to: "down" | "up" | "top" | "bottom" }
15
15
  | { type: "inboxMove"; to: "down" | "up" }
16
16
  | { type: "openSession" }
17
+ | { type: "requestDeleteSession" }
18
+ | { type: "openRename" }
19
+ | { type: "confirmDialog" }
17
20
  | { type: "startSpan" }
18
21
  | { type: "spanKey"; name: string }
19
22
  | { type: "openCompose"; kind: "comment" | "suggestion"; from: "cursor" | "span" }
@@ -61,7 +64,7 @@ export interface KeyState {
61
64
  /** Owner-only: publish the plan as a share. A collaborator never re-shares. */
62
65
  canShare?: boolean;
63
66
  /** Layer that owns keys before the grammar runs. */
64
- overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
67
+ overlay: "none" | "walk" | "compose" | "submit" | "confirm" | "prompt" | "completion-prompt" | "completion-counting";
65
68
  view: "inbox" | "plan" | "diff";
66
69
  /** Plan-only span selection sub-mode. */
67
70
  spanMode: boolean;
@@ -111,6 +114,18 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
111
114
  }
112
115
  return [];
113
116
  }
117
+ // a modal confirm owns the keys: ⏎ commits the action, escape backs out
118
+ if (state.overlay === "confirm") {
119
+ if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
120
+ if (name === "escape") return [{ type: "closeOverlay" }];
121
+ return [];
122
+ }
123
+ // a text prompt: the focused input owns typing; only ⏎ save and esc route here
124
+ if (state.overlay === "prompt") {
125
+ if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
126
+ if (name === "escape") return [{ type: "closeOverlay" }];
127
+ return [];
128
+ }
114
129
  if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
115
130
  if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
116
131
  if (name === "a") return [{ type: "optInAutoClose" }];
@@ -168,6 +183,7 @@ function inboxGrammar(state: KeyState, name: string): Intent[] {
168
183
  if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
169
184
  if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
170
185
  if (name === "return" || name === "enter") return [{ type: "openSession" }];
186
+ if (name === "d") return [{ type: "requestDeleteSession" }];
171
187
  return [];
172
188
  }
173
189
 
@@ -243,6 +259,10 @@ function annotationCluster(state: KeyState, action: string | undefined): Intent[
243
259
  if (state.resolved || !state.hasFocusedAnnotation) return [];
244
260
  return [{ type: "removeAnnotation" }];
245
261
  }
262
+ if (action === "rename") {
263
+ if (!state.hasFocusedAnnotation) return status("select a collaborator's note to rename them");
264
+ return [{ type: "openRename" }];
265
+ }
246
266
  if (action === "submit") {
247
267
  // a collaborator's notes union back as they go; there is no verdict to submit
248
268
  if (state.canSubmitVerdict === false) return status("shared view - your notes save as you go; q to leave");
@@ -46,9 +46,9 @@ function fakeClient(session: ReviewSession): SessionClient {
46
46
  sessionList: async () => [session],
47
47
  sessionAnnotate: mock(async () => session),
48
48
  sessionSetShareId: mock(async (_id: string, shareId: string) => ((session.shareId = shareId), session)),
49
- sessionMergeAnnotations: mock(async (_id: string, incoming: Annotation[]) => {
49
+ sessionMergeShared: mock(async (_id: string, incoming: { annotations: Annotation[] }) => {
50
50
  const known = new Set(session.annotations.map((existing) => existing.id));
51
- for (const note of incoming) if (!known.has(note.id)) session.annotations.push(note);
51
+ for (const note of incoming.annotations) if (!known.has(note.id)) session.annotations.push(note);
52
52
  return session;
53
53
  }),
54
54
  close: () => {},
@@ -83,9 +83,12 @@ describe("share", () => {
83
83
  });
84
84
 
85
85
  describe("pullShared", () => {
86
- test("unions collaborator notes in when the plan was shared", async () => {
86
+ test("unions collaborator notes and identities in when the plan was shared", async () => {
87
87
  // Arrange
88
- remote = sessionFixture({ annotations: [annotation("a1", "SHA256:mate")] });
88
+ remote = sessionFixture({
89
+ annotations: [annotation("a1", "SHA256:mate")],
90
+ participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
91
+ });
89
92
  const { controller, client } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }));
90
93
 
91
94
  // Act
@@ -94,7 +97,10 @@ describe("pullShared", () => {
94
97
 
95
98
  // Assert
96
99
  expect(pullShare).toHaveBeenCalledWith("p_abc123xy");
97
- expect(client.sessionMergeAnnotations).toHaveBeenCalledWith("ses_1", remote.annotations);
100
+ expect(client.sessionMergeShared).toHaveBeenCalledWith("ses_1", {
101
+ annotations: remote.annotations,
102
+ participants: remote.participants,
103
+ });
98
104
  });
99
105
 
100
106
  test("does nothing for a plan that was never shared", async () => {
@@ -108,7 +114,7 @@ describe("pullShared", () => {
108
114
 
109
115
  // Assert
110
116
  expect(pullShare).not.toHaveBeenCalled();
111
- expect(client.sessionMergeAnnotations).not.toHaveBeenCalled();
117
+ expect(client.sessionMergeShared).not.toHaveBeenCalled();
112
118
  });
113
119
  });
114
120
 
@@ -104,6 +104,10 @@ export interface ReviewController {
104
104
  working(): string;
105
105
  /** Open a session from the inbox. */
106
106
  open(id: string): void;
107
+ /** Delete a session for good (inbox delete); the inbox refreshes on the event. */
108
+ deleteSession(id: string): void;
109
+ /** Record the viewer's own name into the share's participant registry (collaborator self-naming). */
110
+ setSelfName(name: string): void;
107
111
  /** Cut the block under the cursor, or restore a cut one. */
108
112
  cut(displayIndex: number): void;
109
113
  /** The $EDITOR hand-off on the working copy. */
@@ -311,6 +315,19 @@ class Controller implements ReviewController {
311
315
  else void this.refreshSession(id);
312
316
  }
313
317
 
318
+ deleteSession(id: string): void {
319
+ this.client
320
+ ?.sessionDelete(id)
321
+ .then(() => this.setStatus("plan deleted"))
322
+ .catch((error: unknown) => this.setStatus(`delete failed: ${error instanceof Error ? error.message : String(error)}`));
323
+ }
324
+
325
+ setSelfName(name: string): void {
326
+ const session = this.snapshot.session;
327
+ if (!session) return;
328
+ this.apply(this.client!.sessionSetSelfName(session.id, name));
329
+ }
330
+
314
331
  cut(displayIndex: number): void {
315
332
  const session = this.snapshot.session;
316
333
  if (!session || session.status === "resolved") return;
@@ -514,7 +531,9 @@ class Controller implements ReviewController {
514
531
  if (!session?.shareId || !this.client) return Promise.resolve();
515
532
  const client = this.client;
516
533
  return pullShare(session.shareId)
517
- .then((remote) => client.sessionMergeAnnotations(session.id, collaboratorAnnotations(remote)))
534
+ .then((remote) =>
535
+ client.sessionMergeShared(session.id, { annotations: collaboratorAnnotations(remote), participants: remote.participants }),
536
+ )
518
537
  .then(() => {})
519
538
  .catch(() => {});
520
539
  }