@cueloop/client 0.1.0-alpha.21 → 0.1.0-alpha.23

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,213 @@
1
+ /**
2
+ * The intent reducer in isolation: with the App component's dependencies
3
+ * mocked, each Intent maps to exactly the controller call or state update it
4
+ * should - the navigation clamps, the annotation wrap-around, the verdict
5
+ * cycle, and the submit-reveal fix, none of which needed a rendered TUI.
6
+ */
7
+
8
+ import { describe, expect, mock, test } from "bun:test";
9
+ import type { ReviewSession } from "@cueloop/schema";
10
+ import { createIntentDispatch, type IntentDispatchDeps } from "./intent-dispatch";
11
+ import type { DisplayBlock } from "./view-plan";
12
+
13
+ function block(text: string): DisplayBlock {
14
+ return { text, kind: "text", work: true } as unknown as DisplayBlock;
15
+ }
16
+
17
+ function sessionWith(annotationIds: string[]): ReviewSession {
18
+ return {
19
+ annotations: annotationIds.map((id) => ({ id })),
20
+ workingCopy: undefined,
21
+ } as unknown as ReviewSession;
22
+ }
23
+
24
+ /** A deps bag where every effect is a mock and every read has a plain default. */
25
+ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
26
+ const controller = {
27
+ setStatus: mock(),
28
+ open: mock(),
29
+ cut: mock(),
30
+ annotate: mock(),
31
+ updateAnnotation: mock(),
32
+ removeAnnotation: mock(),
33
+ walkStart: mock(),
34
+ walkForward: mock(),
35
+ walkBack: mock(),
36
+ walkLeave: mock(),
37
+ submit: mock(),
38
+ share: mock(),
39
+ finishReview: mock(),
40
+ optInAutoClose: mock(),
41
+ dismissCompletion: mock(),
42
+ saveReviewPanel: mock(),
43
+ };
44
+ return {
45
+ controller: controller as unknown as IntentDispatchDeps["controller"],
46
+ onExit: mock(),
47
+ isDiff: false,
48
+ display: [],
49
+ rows: [],
50
+ cursor: 0,
51
+ inbox: null,
52
+ inboxCursor: 0,
53
+ mode: { type: "normal" },
54
+ session: null,
55
+ reviewMode: "expanded",
56
+ reviewWidth: 34,
57
+ terminalWidth: 120,
58
+ focusedAnnotationId: undefined,
59
+ liveInput: { current: "" },
60
+ reviewWidthRef: { current: 34 },
61
+ planSheetRef: { current: null },
62
+ setCursor: mock(),
63
+ setInboxCursor: mock(),
64
+ setMode: mock(),
65
+ setReviewMode: mock(),
66
+ setReviewWidth: mock(),
67
+ setRailTab: mock(),
68
+ setFocusedAnnotationId: mock(),
69
+ setPulsedAnnotationId: mock(),
70
+ selectCardFromDocument: mock(),
71
+ runEditorHandOff: mock(),
72
+ openCardEdit: mock(),
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ describe("move", () => {
78
+ test("down clamps at the last row and up clamps at the first", () => {
79
+ // Arrange
80
+ const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
81
+ const dispatch = createIntentDispatch(deps);
82
+
83
+ // Act
84
+ dispatch({ type: "move", to: "down" });
85
+
86
+ // Assert
87
+ const advance = (deps.setCursor as ReturnType<typeof mock>).mock.calls[0]![0] as (current: number) => number;
88
+ expect(advance(2)).toBe(2); // already at the end, stays
89
+ expect(advance(0)).toBe(1);
90
+ });
91
+
92
+ test("bottom jumps to the last navigable row", () => {
93
+ // Arrange
94
+ const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
95
+ const dispatch = createIntentDispatch(deps);
96
+
97
+ // Act
98
+ dispatch({ type: "move", to: "bottom" });
99
+
100
+ // Assert
101
+ expect(deps.setCursor).toHaveBeenCalledWith(2);
102
+ });
103
+ });
104
+
105
+ describe("annotation navigation", () => {
106
+ test("nextAnnotation wraps from the last annotation back to the first", () => {
107
+ // Arrange
108
+ const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a3" });
109
+ const dispatch = createIntentDispatch(deps);
110
+
111
+ // Act
112
+ dispatch({ type: "nextAnnotation" });
113
+
114
+ // Assert
115
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a1");
116
+ });
117
+
118
+ test("prevAnnotation wraps from the first annotation to the last", () => {
119
+ // Arrange
120
+ const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a1" });
121
+ const dispatch = createIntentDispatch(deps);
122
+
123
+ // Act
124
+ dispatch({ type: "prevAnnotation" });
125
+
126
+ // Assert
127
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
128
+ });
129
+
130
+ test("cycling skips annotations a revision already addressed", () => {
131
+ // Arrange: a2 is addressed, so next from a1 lands on a3
132
+ const session = sessionWith(["a1", "a2", "a3"]);
133
+ (session.annotations[1] as { resolution?: object }).resolution = { revision: 2, source: "agent" };
134
+ const deps = makeDeps({ session, focusedAnnotationId: "a1" });
135
+ const dispatch = createIntentDispatch(deps);
136
+
137
+ // Act
138
+ dispatch({ type: "nextAnnotation" });
139
+
140
+ // Assert
141
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
142
+ });
143
+ });
144
+
145
+ describe("cycleVerdict", () => {
146
+ test("advances through the verdict list in the submit overlay", () => {
147
+ // Arrange
148
+ const deps = makeDeps({ mode: { type: "submit", verdict: "approve", summary: "" } });
149
+ const dispatch = createIntentDispatch(deps);
150
+
151
+ // Act
152
+ dispatch({ type: "cycleVerdict", direction: 1 });
153
+
154
+ // Assert
155
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "request_changes", summary: "" });
156
+ });
157
+ });
158
+
159
+ describe("openSubmit", () => {
160
+ test("force-opens the review rail so the confirm card can never be hidden", () => {
161
+ // Arrange
162
+ const deps = makeDeps({ session: sessionWith([]), reviewMode: "hidden" });
163
+ const dispatch = createIntentDispatch(deps);
164
+
165
+ // Act
166
+ dispatch({ type: "openSubmit" });
167
+
168
+ // Assert
169
+ expect(deps.setReviewMode).toHaveBeenCalledWith("expanded");
170
+ expect(deps.setRailTab).toHaveBeenCalledWith("review");
171
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "approve", summary: "" });
172
+ });
173
+
174
+ test("does nothing without a session", () => {
175
+ // Arrange
176
+ const deps = makeDeps({ session: null });
177
+ const dispatch = createIntentDispatch(deps);
178
+
179
+ // Act
180
+ dispatch({ type: "openSubmit" });
181
+
182
+ // Assert
183
+ expect(deps.setMode).not.toHaveBeenCalled();
184
+ });
185
+ });
186
+
187
+ describe("share", () => {
188
+ test("dispatches to the controller's share", () => {
189
+ // Arrange
190
+ const deps = makeDeps();
191
+ const dispatch = createIntentDispatch(deps);
192
+
193
+ // Act
194
+ dispatch({ type: "share" });
195
+
196
+ // Assert
197
+ expect(deps.controller.share).toHaveBeenCalled();
198
+ });
199
+ });
200
+
201
+ describe("exit", () => {
202
+ test("calls the exit hook with 0", () => {
203
+ // Arrange
204
+ const deps = makeDeps();
205
+ const dispatch = createIntentDispatch(deps);
206
+
207
+ // Act
208
+ dispatch({ type: "exit" });
209
+
210
+ // Assert
211
+ expect(deps.onExit).toHaveBeenCalledWith(0);
212
+ });
213
+ });
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The intent reducer: one effectful handler per keymap Intent, lifted out of
3
+ * the App component so the 30-case grammar can be read and tested on its own.
4
+ * keymap.ts turns keys into Intents (pure); this turns an Intent into the
5
+ * matching controller call or view-state update. App builds the dependency
6
+ * bag once per render and hands it here; nothing in this module reaches back
7
+ * into React beyond the setters it is given.
8
+ */
9
+
10
+ import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
11
+ import { isAddressed, isAgentNote, type ReviewSession, type VerdictKind } from "@cueloop/schema";
12
+ import { displayText, spanKey, startSpan, type DisplayBlock, type SpanState } from "./view-plan";
13
+ import type { DiffRow } from "./view-diff";
14
+ import type { ReviewController } from "./session-controller";
15
+ import type { Intent } from "./keymap";
16
+ import type { PlanSheetHandle } from "./components/PlanSheet";
17
+ import { VERDICTS } from "./components/ConfirmCard";
18
+ import { REVIEW_RESIZE_STEP, cycleReviewPanelMode, resolveReviewWidth, type ReviewPanelMode } from "./review-panel";
19
+
20
+ /** The one overlay/mode the TUI is in; every compose/submit/edit flow is a variant. */
21
+ export type Mode =
22
+ | { type: "normal" }
23
+ | { type: "span"; span: SpanState }
24
+ | { type: "compose"; kind: "comment" | "suggestion"; displayIndex: number; start: number; end: number; text: string }
25
+ | { type: "railEdit"; id: string; text: string }
26
+ | { type: "submit"; verdict: VerdictKind; summary: string };
27
+
28
+ /**
29
+ * Annotations that still count as feedback: agent notes never do, and an
30
+ * annotation a revision already addressed is settled - it neither blocks the
31
+ * verdict default nor re-enters the next feedback document.
32
+ */
33
+ export function reviewerAnnotations(session: ReviewSession) {
34
+ return session.annotations.filter((annotation) => !isAgentNote(annotation) && !isAddressed(annotation));
35
+ }
36
+
37
+ export function defaultVerdict(session: ReviewSession): VerdictKind {
38
+ return reviewerAnnotations(session).length || session.workingCopy !== undefined ? "request_changes" : "approve";
39
+ }
40
+
41
+ /**
42
+ * Everything the reducer reads or writes, supplied fresh each render: the
43
+ * controller and exit hook, the derived reads (view rows, cursor, mode,
44
+ * session), the refs it pokes, the state setters, and the three App-owned
45
+ * selection helpers it composes.
46
+ */
47
+ export interface IntentDispatchDeps {
48
+ controller: ReviewController;
49
+ onExit?: (code: number) => void;
50
+
51
+ isDiff: boolean;
52
+ display: DisplayBlock[];
53
+ rows: DiffRow[];
54
+ cursor: number;
55
+ inbox: ReviewSession[] | null;
56
+ inboxCursor: number;
57
+ mode: Mode;
58
+ session: ReviewSession | null;
59
+ reviewMode: ReviewPanelMode;
60
+ reviewWidth: number;
61
+ terminalWidth: number;
62
+ focusedAnnotationId: string | undefined;
63
+
64
+ liveInput: MutableRefObject<string>;
65
+ reviewWidthRef: MutableRefObject<number>;
66
+ planSheetRef: RefObject<PlanSheetHandle | null>;
67
+
68
+ setCursor: Dispatch<SetStateAction<number>>;
69
+ setInboxCursor: Dispatch<SetStateAction<number>>;
70
+ setMode: Dispatch<SetStateAction<Mode>>;
71
+ setReviewMode: Dispatch<SetStateAction<ReviewPanelMode>>;
72
+ setReviewWidth: Dispatch<SetStateAction<number>>;
73
+ setRailTab: Dispatch<SetStateAction<"review" | "agent">>;
74
+ setFocusedAnnotationId: Dispatch<SetStateAction<string | undefined>>;
75
+ setPulsedAnnotationId: Dispatch<SetStateAction<string | null>>;
76
+
77
+ selectCardFromDocument: (annotationId: string) => void;
78
+ runEditorHandOff: () => void;
79
+ openCardEdit: (annotationId: string) => void;
80
+ }
81
+
82
+ /** Build the intent handler for one render from its dependency bag. */
83
+ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent) => void {
84
+ const {
85
+ controller,
86
+ onExit,
87
+ isDiff,
88
+ display,
89
+ rows,
90
+ cursor,
91
+ inbox,
92
+ inboxCursor,
93
+ mode,
94
+ session,
95
+ reviewMode,
96
+ reviewWidth,
97
+ terminalWidth,
98
+ focusedAnnotationId,
99
+ liveInput,
100
+ reviewWidthRef,
101
+ planSheetRef,
102
+ setCursor,
103
+ setInboxCursor,
104
+ setMode,
105
+ setReviewMode,
106
+ setReviewWidth,
107
+ setRailTab,
108
+ setFocusedAnnotationId,
109
+ setPulsedAnnotationId,
110
+ selectCardFromDocument,
111
+ runEditorHandOff,
112
+ openCardEdit,
113
+ } = deps;
114
+
115
+ return (intent: Intent): void => {
116
+ switch (intent.type) {
117
+ case "exit":
118
+ return void onExit?.(0);
119
+ case "status":
120
+ return controller.setStatus(intent.message);
121
+ case "move": {
122
+ const navigableCount = isDiff ? rows.length : display.length;
123
+ if (intent.to === "down") setCursor((current) => Math.min(navigableCount - 1, current + 1));
124
+ else if (intent.to === "up") setCursor((current) => Math.max(0, current - 1));
125
+ else if (intent.to === "top") setCursor(0);
126
+ else setCursor(navigableCount - 1);
127
+ return;
128
+ }
129
+ case "inboxMove": {
130
+ const navigableCount = inbox?.length ?? 0;
131
+ setInboxCursor((current) => (intent.to === "down" ? Math.min(navigableCount - 1, current + 1) : Math.max(0, current - 1)));
132
+ return;
133
+ }
134
+ case "openSession": {
135
+ const selected = inbox?.[inboxCursor];
136
+ if (selected) controller.open(selected.id);
137
+ return;
138
+ }
139
+ case "startSpan": {
140
+ const block = display[cursor];
141
+ if (!block?.work) return;
142
+ const span = startSpan(cursor, displayText(block));
143
+ if (span) setMode({ type: "span", span });
144
+ return;
145
+ }
146
+ case "spanKey":
147
+ if (mode.type === "span") {
148
+ const span = spanKey(mode.span, intent.name, displayText(display[mode.span.displayIndex]!));
149
+ setMode({ type: "span", span });
150
+ }
151
+ return;
152
+ case "openCompose": {
153
+ liveInput.current = "";
154
+ if (intent.from === "span" && mode.type === "span") {
155
+ setMode({
156
+ type: "compose",
157
+ kind: intent.kind,
158
+ displayIndex: mode.span.displayIndex,
159
+ start: mode.span.start,
160
+ end: mode.span.end,
161
+ text: "",
162
+ });
163
+ } else if (isDiff) {
164
+ const row = rows[cursor];
165
+ if (row) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: row.text.length, text: "" });
166
+ } else {
167
+ // a mouse drag leaves a native selection; it wins over the cursor block
168
+ const native = planSheetRef.current?.readSelection() ?? null;
169
+ if (native) {
170
+ setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
171
+ } else {
172
+ const block = display[cursor];
173
+ if (block) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: displayText(block).length, text: "" });
174
+ }
175
+ }
176
+ return;
177
+ }
178
+ case "openSubmit":
179
+ if (!session) return;
180
+ liveInput.current = "";
181
+ // the confirm card lives in the expanded review rail; a compact or hidden
182
+ // panel would swallow the whole submit flow, so force the rail open (live
183
+ // only - the saved panel preference is left untouched)
184
+ setReviewMode("expanded");
185
+ setRailTab("review");
186
+ return void setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
187
+ case "share":
188
+ return controller.share();
189
+ case "cut":
190
+ return controller.cut(cursor);
191
+ case "edit":
192
+ return runEditorHandOff();
193
+ case "editCard":
194
+ if (focusedAnnotationId) openCardEdit(focusedAnnotationId);
195
+ return;
196
+ case "nextAnnotation":
197
+ case "prevAnnotation": {
198
+ // cycle open cards only - addressed ones are out of the rail
199
+ const annotations = (session?.annotations ?? []).filter((annotation) => !isAddressed(annotation));
200
+ if (!annotations.length) return;
201
+ const focusedIndex = annotations.findIndex((annotation) => annotation.id === focusedAnnotationId);
202
+ const nextIndex =
203
+ focusedIndex === -1
204
+ ? 0
205
+ : (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) % annotations.length;
206
+ return void selectCardFromDocument(annotations[nextIndex]!.id);
207
+ }
208
+ case "walkStart":
209
+ return controller.walkStart();
210
+ case "walkForward":
211
+ return controller.walkForward();
212
+ case "walkBack":
213
+ return controller.walkBack();
214
+ case "walkLeave":
215
+ return controller.walkLeave();
216
+ case "removeAnnotation":
217
+ if (focusedAnnotationId) {
218
+ controller.removeAnnotation(focusedAnnotationId);
219
+ setFocusedAnnotationId(undefined);
220
+ }
221
+ return;
222
+ case "deselect":
223
+ planSheetRef.current?.clearSelection();
224
+ setFocusedAnnotationId(undefined);
225
+ setPulsedAnnotationId(null);
226
+ return;
227
+ case "closeOverlay":
228
+ return void setMode({ type: "normal" });
229
+ case "saveCompose": {
230
+ const body = liveInput.current.trim();
231
+ if (mode.type === "railEdit") {
232
+ if (session && body) controller.updateAnnotation(mode.id, body);
233
+ return void setMode({ type: "normal" });
234
+ }
235
+ if (mode.type !== "compose") return;
236
+ if (session && body) {
237
+ const annotationId = controller.annotate(mode.kind, mode.displayIndex, mode.start, mode.end, body);
238
+ if (annotationId) setFocusedAnnotationId(annotationId);
239
+ }
240
+ return void setMode({ type: "normal" });
241
+ }
242
+ case "submitVerdict":
243
+ if (mode.type === "submit") controller.submit(mode.verdict, liveInput.current);
244
+ return void setMode({ type: "normal" });
245
+ case "cycleVerdict": {
246
+ if (mode.type !== "submit") return;
247
+ const verdictIndex = (VERDICTS.indexOf(mode.verdict) + intent.direction + VERDICTS.length) % VERDICTS.length;
248
+ return void setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
249
+ }
250
+ case "finishReview":
251
+ return controller.finishReview();
252
+ case "optInAutoClose":
253
+ return controller.optInAutoClose();
254
+ case "dismissCompletion":
255
+ return controller.dismissCompletion();
256
+ case "cycleReviewPanel": {
257
+ const next = cycleReviewPanelMode(reviewMode);
258
+ setReviewMode(next);
259
+ return controller.saveReviewPanel({ mode: next });
260
+ }
261
+ case "resizeReviewPanel": {
262
+ if (reviewMode !== "expanded") return;
263
+ const next = resolveReviewWidth(reviewWidth + intent.direction * REVIEW_RESIZE_STEP, terminalWidth);
264
+ reviewWidthRef.current = next;
265
+ setReviewWidth(next);
266
+ return controller.saveReviewPanel({ width: next });
267
+ }
268
+ }
269
+ };
270
+ }
@@ -1,8 +1,4 @@
1
- /**
2
- * Binding resolution and hint generation over @opentui/keymap: config keys
3
- * resolve to action names per layer, and the status-line hints derive from
4
- * getActiveKeys() so rebinds surface in the hint text.
5
- */
1
+ /** Config keys resolve to per-layer actions, and status hints derive from getActiveKeys() so rebinds surface. */
6
2
 
7
3
  import { describe, expect, test } from "bun:test";
8
4
  import { DEFAULT_KEYS } from "./config";
@@ -43,28 +39,40 @@ describe("grammar-layer resolution", () => {
43
39
 
44
40
  describe("mode layers own their keys", () => {
45
41
  test("span mode resolves the span verbs, not the grammar", () => {
42
+ // Arrange
46
43
  const resolver = bindings({ spanMode: true });
44
+
45
+ // Assert
47
46
  expect(resolver.resolveAction({ name: "l", shift: false })).toBe("span_grow");
48
47
  expect(resolver.resolveAction({ name: "c", shift: false })).toBe("span_comment");
49
48
  expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("span_cancel");
50
49
  });
51
50
 
52
51
  test("the compose overlay only knows save and cancel", () => {
52
+ // Arrange
53
53
  const resolver = bindings({ overlay: "compose" });
54
+
55
+ // Assert
54
56
  expect(resolver.resolveAction({ name: "return", shift: false })).toBe("save_compose");
55
57
  expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("cancel_overlay");
56
58
  expect(resolver.resolveAction({ name: "j", shift: false })).toBeUndefined();
57
59
  });
58
60
 
59
61
  test("the submit overlay adds the verdict arrows", () => {
62
+ // Arrange
60
63
  const resolver = bindings({ overlay: "submit" });
64
+
65
+ // Assert
61
66
  expect(resolver.resolveAction({ name: "left", shift: false })).toBe("cycle_verdict_left");
62
67
  expect(resolver.resolveAction({ name: "right", shift: false })).toBe("cycle_verdict_right");
63
68
  expect(resolver.resolveAction({ name: "return", shift: false })).toBe("submit_verdict");
64
69
  });
65
70
 
66
71
  test("the walk overlay owns the bracket stepping and leaves the grammar keys dead", () => {
72
+ // Arrange
67
73
  const resolver = bindings({ overlay: "walk" });
74
+
75
+ // Assert
68
76
  expect(resolver.resolveAction({ name: "]", shift: false })).toBe("walk_next");
69
77
  expect(resolver.resolveAction({ name: "[", shift: false })).toBe("walk_prev");
70
78
  expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("walk_leave");
@@ -110,9 +118,23 @@ describe("getActiveKeys-generated status hints", () => {
110
118
  expect(bindings().statusHint("read-only")).toBe("observer - read-only · j/k move · n/p annotations · q quit");
111
119
  });
112
120
 
121
+ test("collaborator hint annotates but shows no plan edit or submit", () => {
122
+ // Arrange / Act
123
+ const hint = bindings().statusHint("collaborator");
124
+
125
+ // Assert
126
+ expect(hint).toBe("j/k move · v span · drag selects · c comment · s suggest · n/p annotations · q quit");
127
+ expect(hint).not.toContain("cut");
128
+ expect(hint).not.toContain("edit");
129
+ expect(hint).not.toContain("submit");
130
+ });
131
+
113
132
  test("a rebound key shows its real binding in the hint", () => {
133
+ // Arrange
114
134
  const resolver = new KeyBindings({ ...DEFAULT_KEYS, comment: ["m"] });
115
135
  resolver.setContext({ overlay: "none", spanMode: false });
136
+
137
+ // Assert
116
138
  expect(resolver.resolveAction({ name: "m", shift: false })).toBe("comment");
117
139
  expect(resolver.resolveAction({ name: "c", shift: false })).toBeUndefined();
118
140
  expect(resolver.statusHint("normal")).toContain("m comment");
@@ -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" | "walk" | "read-only";
27
+ export type HintMode = "normal" | "collaborator" | "card" | "span" | "compose" | "submit" | "walk" | "read-only";
28
28
 
29
29
  type HintEntry =
30
30
  | { text: string }
@@ -43,6 +43,16 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
43
43
  { commands: ["submit"], label: "submit" },
44
44
  { commands: ["quit"], label: "quit" },
45
45
  ],
46
+ // A share viewer: annotate and navigate, no plan edit / verdict submit.
47
+ collaborator: [
48
+ { commands: ["down", "up"], label: "move" },
49
+ { commands: ["span"], label: "span" },
50
+ { text: "drag selects" },
51
+ { commands: ["comment"], label: "comment" },
52
+ { commands: ["suggest"], label: "suggest" },
53
+ { commands: ["next_annotation", "prev_annotation"], label: "annotations" },
54
+ { commands: ["quit"], label: "quit" },
55
+ ],
46
56
  card: [
47
57
  { text: "card" },
48
58
  { commands: ["edit"], label: "edit" },