@cueloop/client 0.1.0-alpha.56 → 0.1.0-alpha.59

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/src/keymap.ts CHANGED
@@ -122,65 +122,8 @@ function status(message: string): Intent[] {
122
122
  */
123
123
  export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: string): Intent[] {
124
124
  const name = key.name;
125
- // compose/submit overlays own the keys via focused inputs; only escape,
126
- // return, and verdict arrows route through the grammar
127
- if (state.overlay === "compose" || state.overlay === "submit") {
128
- if (name === "escape") return [{ type: "closeOverlay" }];
129
- if (name === "return" || name === "enter") {
130
- // In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
131
- // focused textarea owns that; only a bare ⏎ saves. The submit overlay
132
- // keeps its plain ⏎ submit.
133
- if (state.overlay === "compose") {
134
- if (key.shift || key.meta) return [];
135
- return [{ type: "saveCompose" }];
136
- }
137
- return [{ type: "submitVerdict" }];
138
- }
139
- if (state.overlay === "submit" && (name === "left" || name === "right")) {
140
- return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
141
- }
142
- return [];
143
- }
144
- // a modal confirm owns the keys: ⏎ commits the action, escape backs out
145
- if (state.overlay === "confirm") {
146
- if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
147
- if (name === "escape") return [{ type: "closeOverlay" }];
148
- return [];
149
- }
150
- // a text prompt: the focused input owns typing; only ⏎ save and esc route here
151
- if (state.overlay === "prompt") {
152
- if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
153
- if (name === "escape") return [{ type: "closeOverlay" }];
154
- return [];
155
- }
156
- // the quick-actions list owns its keys: j/k move, ⏎ picks the highlighted
157
- // action (inserting its preset comment), escape returns to the span toolbar
158
- if (state.overlay === "spanActions") {
159
- if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
160
- if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
161
- if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
162
- if (name === "escape") return [{ type: "closeSpanActions" }];
163
- return [];
164
- }
165
- if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
166
- if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
167
- if (name === "a") return [{ type: "optInAutoClose" }];
168
- if (name === "escape") return [{ type: "dismissCompletion" }];
169
- return [];
170
- }
171
- // the walk wizard owns its keys while active: ] advances (marking the
172
- // current file viewed), [ steps back, escape leaves keeping progress, and
173
- // return on the end card hands over to the submit confirm
174
- if (state.overlay === "walk") {
175
- if (name === "]") return [{ type: "walkForward" }];
176
- if (name === "[") return [{ type: "walkBack" }];
177
- if (name === "escape") return [{ type: "walkLeave" }];
178
- if ((name === "return" || name === "enter") && state.walkAtEnd) {
179
- return [{ type: "walkLeave" }, { type: "openSubmit" }];
180
- }
181
- if (name === "q") return [{ type: "exit" }];
182
- return [];
183
- }
125
+ const overlayGrammar = overlayGrammars[state.overlay];
126
+ if (overlayGrammar) return overlayGrammar(state, key);
184
127
  const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
185
128
  if (action === "quit") return [{ type: "exit" }];
186
129
  // the ONE read-only rule: any mutating attempt answers instead of acting
@@ -208,6 +151,85 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
208
151
  return planGrammar(state, action, name);
209
152
  }
210
153
 
154
+ type OverlayGrammar = (state: KeyState, key: KeyInput) => Intent[];
155
+
156
+ const overlayGrammars: Partial<Record<KeyState["overlay"], OverlayGrammar>> = {
157
+ compose: composeOverlayGrammar,
158
+ submit: submitOverlayGrammar,
159
+ confirm: confirmOverlayGrammar,
160
+ prompt: promptOverlayGrammar,
161
+ spanActions: spanActionsOverlayGrammar,
162
+ "completion-prompt": completionOverlayGrammar,
163
+ "completion-counting": completionOverlayGrammar,
164
+ walk: walkOverlayGrammar,
165
+ };
166
+
167
+ function composeOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
168
+ const name = key.name;
169
+ if (name === "escape") return [{ type: "closeOverlay" }];
170
+ if (name === "return" || name === "enter") {
171
+ // In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
172
+ // focused textarea owns that; only a bare ⏎ saves. The submit overlay
173
+ // keeps its plain ⏎ submit.
174
+ if (key.shift || key.meta) return [];
175
+ return [{ type: "saveCompose" }];
176
+ }
177
+ return [];
178
+ }
179
+
180
+ function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
181
+ const name = key.name;
182
+ if (name === "escape") return [{ type: "closeOverlay" }];
183
+ if (name === "return" || name === "enter") return [{ type: "submitVerdict" }];
184
+ if (name === "left" || name === "right") {
185
+ return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
186
+ }
187
+ return [];
188
+ }
189
+
190
+ function confirmOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
191
+ const name = key.name;
192
+ if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
193
+ if (name === "escape") return [{ type: "closeOverlay" }];
194
+ return [];
195
+ }
196
+
197
+ function promptOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
198
+ const name = key.name;
199
+ if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
200
+ if (name === "escape") return [{ type: "closeOverlay" }];
201
+ return [];
202
+ }
203
+
204
+ function spanActionsOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
205
+ const name = key.name;
206
+ if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
207
+ if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
208
+ if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
209
+ if (name === "escape") return [{ type: "closeSpanActions" }];
210
+ return [];
211
+ }
212
+
213
+ function completionOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
214
+ const name = key.name;
215
+ if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
216
+ if (name === "a") return [{ type: "optInAutoClose" }];
217
+ if (name === "escape") return [{ type: "dismissCompletion" }];
218
+ return [];
219
+ }
220
+
221
+ function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
222
+ const name = key.name;
223
+ if (name === "]") return [{ type: "walkForward" }];
224
+ if (name === "[") return [{ type: "walkBack" }];
225
+ if (name === "escape") return [{ type: "walkLeave" }];
226
+ if ((name === "return" || name === "enter") && state.walkAtEnd) {
227
+ return [{ type: "walkLeave" }, { type: "openSubmit" }];
228
+ }
229
+ if (name === "q") return [{ type: "exit" }];
230
+ return [];
231
+ }
232
+
211
233
  /** The review-panel controls: cycle the mode, widen and narrow the rail. */
212
234
  function reviewPanelGrammar(action: string | undefined): Intent[] | null {
213
235
  if (action === "review_cycle") return [{ type: "cycleReviewPanel" }];
@@ -228,7 +228,12 @@ function elementAtScript(x: number, y: number): unknown {
228
228
  };
229
229
  const hit = document.elementFromPoint(x, y);
230
230
  if (!(hit instanceof Element)) return null;
231
- const node = componentRoot(hit);
231
+ // A click on an interactive control anchors to that control - a button in a
232
+ // grid is the target, not the grid. Everything else climbs to its component.
233
+ const control = hit.closest(
234
+ "button, a, [role='button'], input, select, textarea, label, summary",
235
+ );
236
+ const node = control ?? componentRoot(hit);
232
237
  const selector = selectorFor(node);
233
238
  if (!selector) return null;
234
239
  const text = (node.textContent || "").replace(/\s+/g, " ").trim();
@@ -0,0 +1,263 @@
1
+ import React, { useState, type Dispatch, type SetStateAction } from "react";
2
+ import {
3
+ DEFAULT_QUICK_ACTIONS,
4
+ persistActions,
5
+ persistAutoClose,
6
+ persistTheme,
7
+ type AutoClose,
8
+ type QuickAction,
9
+ } from "./config";
10
+ import {
11
+ composeTheme,
12
+ THEME_LABELS,
13
+ THEME_NAMES,
14
+ type Appearance,
15
+ type ThemeName,
16
+ } from "./theme-presets";
17
+ import type { Theme } from "./theme";
18
+ import type { ReviewPanelMode } from "./review-panel";
19
+ import type { SettingsCategory } from "./components/SettingsDialog";
20
+ import { QuickActionsEditor } from "./components/quick-actions-editor";
21
+ import type { ReviewController } from "./session-controller";
22
+
23
+ export interface SettingsNav {
24
+ categoryId: string;
25
+ rowIndex: number;
26
+ zone: "nav" | "body";
27
+ }
28
+
29
+ export interface SettingsDialogModel {
30
+ settingsNav: SettingsNav;
31
+ actionsExpandedIndex: number | null;
32
+ setActionsExpandedIndex: Dispatch<SetStateAction<number | null>>;
33
+ settingsCategories: SettingsCategory[];
34
+ settingsValues: Record<string, string>;
35
+ cycleSetting: (rowKey: string) => void;
36
+ handleSettingsKey: (name: string) => void;
37
+ onCategorySelect: (categoryId: string) => void;
38
+ }
39
+
40
+ const DOWN_KEYS = new Set(["j", "down"]);
41
+ const UP_KEYS = new Set(["k", "up"]);
42
+ const ENTER_BODY_KEYS = new Set(["l", "tab", "return"]);
43
+ const ACTIVATE_KEYS = new Set(["return", "space", "l"]);
44
+
45
+ function moveNavZone(
46
+ name: string,
47
+ categoryIndex: number,
48
+ categories: SettingsCategory[],
49
+ ): SettingsNav | null {
50
+ if (DOWN_KEYS.has(name))
51
+ return {
52
+ categoryId: categories[Math.min(categories.length - 1, categoryIndex + 1)]!.id,
53
+ rowIndex: 0,
54
+ zone: "nav",
55
+ };
56
+ if (UP_KEYS.has(name))
57
+ return { categoryId: categories[Math.max(0, categoryIndex - 1)]!.id, rowIndex: 0, zone: "nav" };
58
+ if (ENTER_BODY_KEYS.has(name))
59
+ return { categoryId: categories[categoryIndex]!.id, rowIndex: 0, zone: "body" };
60
+ return null;
61
+ }
62
+
63
+ function moveBodyRow(name: string, nav: SettingsNav, rowCount: number): SettingsNav | null {
64
+ if (DOWN_KEYS.has(name)) return { ...nav, rowIndex: Math.min(rowCount - 1, nav.rowIndex + 1) };
65
+ if (UP_KEYS.has(name)) return { ...nav, rowIndex: Math.max(0, nav.rowIndex - 1) };
66
+ if (name === "h" || name === "tab") return { ...nav, zone: "nav" };
67
+ return null;
68
+ }
69
+
70
+ export function useSettingsDialog(params: {
71
+ theme: Theme;
72
+ appearance: Appearance;
73
+ autoClose: AutoClose;
74
+ setAutoClose: Dispatch<SetStateAction<AutoClose>>;
75
+ reviewMode: ReviewPanelMode;
76
+ setReviewMode: Dispatch<SetStateAction<ReviewPanelMode>>;
77
+ themeName: ThemeName;
78
+ setThemeName: Dispatch<SetStateAction<ThemeName>>;
79
+ themeOverrides: Partial<Theme>;
80
+ setTheme: Dispatch<SetStateAction<Theme>>;
81
+ quickActions: QuickAction[];
82
+ setQuickActions: Dispatch<SetStateAction<QuickAction[]>>;
83
+ controller: ReviewController;
84
+ setMenuDialog: Dispatch<SetStateAction<"keybinds" | "settings" | null>>;
85
+ }): SettingsDialogModel {
86
+ const {
87
+ theme,
88
+ appearance,
89
+ autoClose,
90
+ setAutoClose,
91
+ reviewMode,
92
+ setReviewMode,
93
+ themeName,
94
+ setThemeName,
95
+ themeOverrides,
96
+ setTheme,
97
+ quickActions,
98
+ setQuickActions,
99
+ controller,
100
+ setMenuDialog,
101
+ } = params;
102
+
103
+ const [settingsNav, setSettingsNav] = useState<SettingsNav>({
104
+ categoryId: "general",
105
+ rowIndex: 0,
106
+ zone: "body",
107
+ });
108
+ const [actionsExpandedIndex, setActionsExpandedIndex] = useState<number | null>(null);
109
+
110
+ const commitActions = (next: QuickAction[]): void => {
111
+ setQuickActions(next);
112
+ persistActions(next);
113
+ };
114
+ const editActionMetadata = (index: number, metadata: string): void =>
115
+ commitActions(
116
+ quickActions.map((action, actionIndex) =>
117
+ actionIndex === index
118
+ ? { ...action, metadata: metadata.trim() ? metadata : undefined }
119
+ : action,
120
+ ),
121
+ );
122
+ const resetActions = (): void => {
123
+ setActionsExpandedIndex(null);
124
+ commitActions(DEFAULT_QUICK_ACTIONS.map((action) => ({ ...action })));
125
+ };
126
+ const addAction = (): void => {
127
+ setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: quickActions.length }));
128
+ commitActions([...quickActions, { prompt: "New action" }]);
129
+ };
130
+
131
+ const settingsCategories: SettingsCategory[] = [
132
+ {
133
+ id: "general",
134
+ name: "General",
135
+ description: "submission behaviour",
136
+ rows: [
137
+ {
138
+ key: "autoClose",
139
+ label: "Auto-close on submit",
140
+ kind: "cycle",
141
+ options: ["off", "3s", "10s"],
142
+ },
143
+ ],
144
+ },
145
+ {
146
+ id: "display",
147
+ name: "Display",
148
+ description: "the review panel",
149
+ rows: [
150
+ {
151
+ key: "reviewPanel",
152
+ label: "Review panel",
153
+ kind: "cycle",
154
+ options: ["expanded", "compact", "hidden"],
155
+ },
156
+ ],
157
+ },
158
+ {
159
+ id: "appearance",
160
+ name: "Appearance",
161
+ description: "the color theme",
162
+ rows: [
163
+ {
164
+ key: "theme",
165
+ label: "Theme",
166
+ kind: "cycle",
167
+ options: THEME_NAMES.map((name) => THEME_LABELS[name]),
168
+ },
169
+ ],
170
+ },
171
+ {
172
+ id: "actions",
173
+ name: "Actions",
174
+ description: "quick-action comments",
175
+ rows: [],
176
+ customBody: (
177
+ <QuickActionsEditor
178
+ actions={quickActions}
179
+ selectedIndex={settingsNav.categoryId === "actions" ? settingsNav.rowIndex : -1}
180
+ expandedIndex={actionsExpandedIndex}
181
+ onToggleExpand={(index) => {
182
+ setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: index }));
183
+ setActionsExpandedIndex((current) => (current === index ? null : index));
184
+ }}
185
+ onEditMetadata={editActionMetadata}
186
+ onReset={resetActions}
187
+ onAdd={addAction}
188
+ theme={theme}
189
+ />
190
+ ),
191
+ },
192
+ ];
193
+ const settingsValues = {
194
+ autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
195
+ reviewPanel: reviewMode,
196
+ theme: THEME_LABELS[themeName],
197
+ };
198
+ const cycleSetting = (rowKey: string): void => {
199
+ if (rowKey === "autoClose") {
200
+ const next: AutoClose = autoClose === "off" ? 3 : autoClose === 3 ? 10 : "off";
201
+ setAutoClose(next);
202
+ persistAutoClose(next);
203
+ } else if (rowKey === "reviewPanel") {
204
+ const order: ReviewPanelMode[] = ["expanded", "compact", "hidden"];
205
+ const next = order[(order.indexOf(reviewMode) + 1) % order.length]!;
206
+ setReviewMode(next);
207
+ controller.saveReviewPanel({ mode: next });
208
+ } else if (rowKey === "theme") {
209
+ const next = THEME_NAMES[(THEME_NAMES.indexOf(themeName) + 1) % THEME_NAMES.length]!;
210
+ setThemeName(next);
211
+ setTheme(composeTheme(next, themeOverrides, appearance));
212
+ persistTheme(next);
213
+ }
214
+ };
215
+
216
+ const onCategorySelect = (categoryId: string): void => {
217
+ setActionsExpandedIndex(null);
218
+ setSettingsNav({ categoryId, rowIndex: 0, zone: "body" });
219
+ };
220
+
221
+ const handleSettingsKey = (name: string): void => {
222
+ // an open system-prompt input owns typing; only esc (close it) escapes here
223
+ if (actionsExpandedIndex !== null) {
224
+ if (name === "escape") setActionsExpandedIndex(null);
225
+ return;
226
+ }
227
+ if (name === "escape") return void setMenuDialog(null);
228
+ const categoryIndex = settingsCategories.findIndex(
229
+ (category) => category.id === settingsNav.categoryId,
230
+ );
231
+ const category = settingsCategories[categoryIndex]!;
232
+ if (settingsNav.zone === "nav") {
233
+ const next = moveNavZone(name, categoryIndex, settingsCategories);
234
+ if (next) setSettingsNav(next);
235
+ return;
236
+ }
237
+ // the Actions category is a list of quick actions plus a trailing "add" row
238
+ if (category.id === "actions") {
239
+ const moved = moveBodyRow(name, settingsNav, quickActions.length + 1);
240
+ if (moved) return void setSettingsNav(moved);
241
+ if (ACTIVATE_KEYS.has(name)) {
242
+ if (settingsNav.rowIndex === quickActions.length) addAction();
243
+ else setActionsExpandedIndex(settingsNav.rowIndex);
244
+ }
245
+ return;
246
+ }
247
+ const moved = moveBodyRow(name, settingsNav, category.rows.length);
248
+ if (moved) return void setSettingsNav(moved);
249
+ if (name === "return" || name === "space")
250
+ cycleSetting(category.rows[settingsNav.rowIndex]!.key);
251
+ };
252
+
253
+ return {
254
+ settingsNav,
255
+ actionsExpandedIndex,
256
+ setActionsExpandedIndex,
257
+ settingsCategories,
258
+ settingsValues,
259
+ cycleSetting,
260
+ handleSettingsKey,
261
+ onCategorySelect,
262
+ };
263
+ }