giggles 0.7.0 → 0.7.1

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.
@@ -69,7 +69,7 @@ function useKeybindingRegistry(focus) {
69
69
  }
70
70
 
71
71
  // src/core/focus/useFocusNode.ts
72
- import { useContext as useContext2, useEffect as useEffect2, useId as useId2, useMemo, useSyncExternalStore as useSyncExternalStore2 } from "react";
72
+ import { useContext as useContext2, useId as useId2, useLayoutEffect, useMemo, useSyncExternalStore as useSyncExternalStore2 } from "react";
73
73
  function useFocusNode(options) {
74
74
  var _a;
75
75
  const id = useId2();
@@ -78,24 +78,26 @@ function useFocusNode(options) {
78
78
  const parentId = ((_a = options == null ? void 0 : options.parent) == null ? void 0 : _a.id) ?? contextParentId;
79
79
  const focusKey = options == null ? void 0 : options.focusKey;
80
80
  const subscribe = useMemo(() => store.subscribe.bind(store), [store]);
81
- useEffect2(() => {
82
- store.registerNode(id, parentId, focusKey);
81
+ store.registerNode(id, parentId, focusKey, true);
82
+ useLayoutEffect(() => {
83
+ store.registerNode(id, parentId, focusKey, true);
84
+ store.flush();
83
85
  return () => {
84
86
  store.unregisterNode(id);
85
87
  };
86
- }, [id, parentId, focusKey, store]);
88
+ }, [id, store]);
87
89
  const hasFocus = useSyncExternalStore2(subscribe, () => store.isFocused(id));
88
90
  return { id, hasFocus };
89
91
  }
90
92
 
91
93
  // src/core/input/FocusTrap.tsx
92
- import { useEffect as useEffect3, useRef as useRef2 } from "react";
94
+ import { useEffect as useEffect2, useRef as useRef2 } from "react";
93
95
  import { jsx } from "react/jsx-runtime";
94
96
  function FocusTrap({ children }) {
95
97
  const { id } = useFocusNode();
96
98
  const store = useStore();
97
99
  const previousFocusRef = useRef2(store.getFocusedId());
98
- useEffect3(() => {
100
+ useEffect2(() => {
99
101
  const previousFocus = previousFocusRef.current;
100
102
  store.setTrap(id);
101
103
  store.focusFirstChild(id);
@@ -121,7 +123,7 @@ function InputRouter({ children }) {
121
123
  }
122
124
 
123
125
  // src/core/focus/useFocusScope.ts
124
- import { useCallback, useContext as useContext3, useEffect as useEffect4, useId as useId3, useMemo as useMemo2, useSyncExternalStore as useSyncExternalStore3 } from "react";
126
+ import { useCallback, useContext as useContext3, useEffect as useEffect3, useId as useId3, useLayoutEffect as useLayoutEffect2, useMemo as useMemo2, useSyncExternalStore as useSyncExternalStore3 } from "react";
125
127
  function useFocusScope(options) {
126
128
  var _a;
127
129
  const id = useId3();
@@ -131,12 +133,14 @@ function useFocusScope(options) {
131
133
  const parentId = ((_a = options == null ? void 0 : options.parent) == null ? void 0 : _a.id) ?? contextParentId;
132
134
  const focusKey = options == null ? void 0 : options.focusKey;
133
135
  const subscribe = useMemo2(() => store.subscribe.bind(store), [store]);
134
- useEffect4(() => {
135
- store.registerNode(id, parentId, focusKey);
136
+ store.registerNode(id, parentId, focusKey, true);
137
+ useLayoutEffect2(() => {
138
+ store.registerNode(id, parentId, focusKey, true);
139
+ store.flush();
136
140
  return () => {
137
141
  store.unregisterNode(id);
138
142
  };
139
- }, [id, parentId, focusKey, store]);
143
+ }, [id, store]);
140
144
  const hasFocus = useSyncExternalStore3(subscribe, () => store.isFocused(id));
141
145
  const isPassive = useSyncExternalStore3(subscribe, () => store.isPassive(id));
142
146
  const next = useCallback(() => store.navigateSibling("next", true, id), [store, id]);
@@ -149,12 +153,12 @@ function useFocusScope(options) {
149
153
  const focusChildShallow = useCallback((key) => store.focusChildByKey(id, key, true), [store, id]);
150
154
  const resolvedBindings = typeof (options == null ? void 0 : options.keybindings) === "function" ? options.keybindings({ next, prev, nextShallow, prevShallow, escape, drillIn, focusChild, focusChildShallow }) : (options == null ? void 0 : options.keybindings) ?? {};
151
155
  store.registerKeybindings(id, keybindingRegistrationId, resolvedBindings);
152
- useEffect4(() => {
156
+ useEffect3(() => {
153
157
  return () => {
154
158
  store.unregisterKeybindings(id, keybindingRegistrationId);
155
159
  };
156
160
  }, [id, keybindingRegistrationId, store]);
157
- useEffect4(() => {
161
+ useEffect3(() => {
158
162
  if (!store.hasFocusScopeComponent(id)) {
159
163
  throw new GigglesError(
160
164
  "useFocusScope() was called but no <FocusScope handle={scope}> was rendered. Every useFocusScope() call requires a corresponding <FocusScope> in the render output \u2014 without it, child components register under the wrong parent scope and keyboard navigation silently breaks."
@@ -177,11 +181,11 @@ function useFocusScope(options) {
177
181
  }
178
182
 
179
183
  // src/core/focus/FocusScope.tsx
180
- import { useEffect as useEffect5 } from "react";
184
+ import { useEffect as useEffect4 } from "react";
181
185
  import { jsx as jsx3 } from "react/jsx-runtime";
182
186
  function FocusScope({ handle, children }) {
183
187
  const store = useStore();
184
- useEffect5(() => {
188
+ useEffect4(() => {
185
189
  store.registerFocusScopeComponent(handle.id);
186
190
  return () => store.unregisterFocusScopeComponent(handle.id);
187
191
  }, [handle.id, store]);
@@ -224,7 +228,7 @@ var FocusStore = class {
224
228
  listeners = /* @__PURE__ */ new Set();
225
229
  version = 0;
226
230
  // nodeId → registrationId → BindingRegistration
227
- // Keybindings register synchronously during render; nodes register in useEffect.
231
+ // Both keybindings and nodes register synchronously during render.
228
232
  // A keybinding may exist for a node that has not yet appeared in the node tree —
229
233
  // this is safe because dispatch only walks nodes in the active branch path.
230
234
  keybindings = /* @__PURE__ */ new Map();
@@ -237,20 +241,43 @@ var FocusStore = class {
237
241
  this.listeners.add(listener);
238
242
  return () => this.listeners.delete(listener);
239
243
  }
244
+ dirty = false;
240
245
  notify() {
241
246
  this.version++;
247
+ this.dirty = false;
242
248
  for (const listener of this.listeners) {
243
249
  listener();
244
250
  }
245
251
  }
252
+ // Mark the store as changed without notifying subscribers. Used when the
253
+ // store is mutated during React's render phase (e.g. silent node registration)
254
+ // where calling notify() would trigger subscription callbacks unsafely.
255
+ // Call flush() in a useLayoutEffect to deliver the notification before paint.
256
+ markDirty() {
257
+ this.dirty = true;
258
+ }
259
+ flush() {
260
+ if (this.dirty) {
261
+ this.notify();
262
+ }
263
+ }
246
264
  getVersion() {
247
265
  return this.version;
248
266
  }
249
267
  // ---------------------------------------------------------------------------
250
268
  // Registration
251
269
  // ---------------------------------------------------------------------------
252
- registerNode(id, parentId, focusKey) {
253
- const node = { id, parentId, childrenIds: [] };
270
+ registerNode(id, parentId, focusKey, silent = false) {
271
+ const existing = this.nodes.get(id);
272
+ if (existing && existing.parentId === parentId) {
273
+ if (focusKey !== existing.focusKey) {
274
+ this.updateFocusKey(id, parentId, existing.focusKey, focusKey);
275
+ existing.focusKey = focusKey;
276
+ this.markDirty();
277
+ }
278
+ return;
279
+ }
280
+ const node = { id, parentId, childrenIds: [], focusKey };
254
281
  this.nodes.set(id, node);
255
282
  this.parentMap.set(id, parentId);
256
283
  if (parentId) {
@@ -260,7 +287,11 @@ var FocusStore = class {
260
287
  parent.childrenIds.push(id);
261
288
  if (wasEmpty && this.pendingFocusFirstChild.has(parentId)) {
262
289
  this.pendingFocusFirstChild.delete(parentId);
263
- this.focusNode(id);
290
+ if (silent) {
291
+ this.setFocusedIdSilently(id);
292
+ } else {
293
+ this.focusNode(id);
294
+ }
264
295
  }
265
296
  }
266
297
  }
@@ -276,9 +307,17 @@ var FocusStore = class {
276
307
  this.keyIndex.get(parentId).set(focusKey, id);
277
308
  }
278
309
  if (this.nodes.size === 1) {
279
- this.focusNode(id);
310
+ if (silent) {
311
+ this.setFocusedIdSilently(id);
312
+ } else {
313
+ this.focusNode(id);
314
+ }
315
+ }
316
+ if (silent) {
317
+ this.markDirty();
318
+ } else {
319
+ this.notify();
280
320
  }
281
- this.notify();
282
321
  }
283
322
  unregisterNode(id) {
284
323
  const node = this.nodes.get(id);
@@ -328,18 +367,18 @@ var FocusStore = class {
328
367
  const oldFocusedId = this.focusedId;
329
368
  if (oldFocusedId === id) return;
330
369
  this.focusedId = id;
331
- for (const passiveId of this.passiveSet) {
332
- const wasAncestor = this.isAncestorOf(passiveId, oldFocusedId);
333
- const isAncestor = this.isAncestorOf(passiveId, id);
334
- if (wasAncestor && !isAncestor) {
335
- this.passiveSet.delete(passiveId);
336
- }
337
- if (isAncestor && id !== passiveId) {
338
- this.passiveSet.delete(passiveId);
339
- }
340
- }
370
+ this.clearPassiveOnFocusChange(oldFocusedId, id);
341
371
  this.notify();
342
372
  }
373
+ // Set focusedId without notifying subscribers. Safe to call during React's
374
+ // render phase. Clears passive flags the same way focusNode() does.
375
+ setFocusedIdSilently(id) {
376
+ const oldFocusedId = this.focusedId;
377
+ if (oldFocusedId === id) return;
378
+ this.focusedId = id;
379
+ this.clearPassiveOnFocusChange(oldFocusedId, id);
380
+ this.markDirty();
381
+ }
343
382
  focusFirstChild(parentId) {
344
383
  const parent = this.nodes.get(parentId);
345
384
  if (parent && parent.childrenIds.length > 0) {
@@ -604,6 +643,38 @@ var FocusStore = class {
604
643
  }
605
644
  return false;
606
645
  }
646
+ // Clear passive flags when focus transitions between nodes.
647
+ clearPassiveOnFocusChange(oldFocusedId, newFocusedId) {
648
+ for (const passiveId of this.passiveSet) {
649
+ const wasAncestor = this.isAncestorOf(passiveId, oldFocusedId);
650
+ const isAncestor = this.isAncestorOf(passiveId, newFocusedId);
651
+ if (wasAncestor && !isAncestor) {
652
+ this.passiveSet.delete(passiveId);
653
+ }
654
+ if (isAncestor && newFocusedId !== passiveId) {
655
+ this.passiveSet.delete(passiveId);
656
+ }
657
+ }
658
+ }
659
+ // Update the keyIndex when a node's focusKey changes during a re-render.
660
+ updateFocusKey(id, parentId, oldKey, newKey) {
661
+ if (!parentId) return;
662
+ if (oldKey) {
663
+ const parentKeys = this.keyIndex.get(parentId);
664
+ if (parentKeys) {
665
+ parentKeys.delete(oldKey);
666
+ if (parentKeys.size === 0) {
667
+ this.keyIndex.delete(parentId);
668
+ }
669
+ }
670
+ }
671
+ if (newKey) {
672
+ if (!this.keyIndex.has(parentId)) {
673
+ this.keyIndex.set(parentId, /* @__PURE__ */ new Map());
674
+ }
675
+ this.keyIndex.get(parentId).set(newKey, id);
676
+ }
677
+ }
607
678
  };
608
679
 
609
680
  export {
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  useKeybindingRegistry,
15
15
  useKeybindings,
16
16
  useStore
17
- } from "./chunk-74PBSWEK.js";
17
+ } from "./chunk-HHDMTIXE.js";
18
18
  import {
19
19
  ThemeProvider,
20
20
  useTheme
@@ -68,7 +68,7 @@ function Screen(_props) {
68
68
  }
69
69
 
70
70
  // src/core/router/ScreenEntry.tsx
71
- import { useEffect as useEffect2, useMemo, useRef as useRef2 } from "react";
71
+ import { useLayoutEffect, useMemo, useRef as useRef2 } from "react";
72
72
  import { Box as Box2 } from "ink";
73
73
 
74
74
  // src/core/router/NavigationContext.tsx
@@ -99,7 +99,7 @@ function ScreenEntry({
99
99
  const store = useStore();
100
100
  const lastFocusedChildRef = useRef2(null);
101
101
  const wasTopRef = useRef2(isTop);
102
- useEffect2(() => {
102
+ useLayoutEffect(() => {
103
103
  if (!wasTopRef.current && isTop) {
104
104
  const saved = restoreFocus ? lastFocusedChildRef.current : null;
105
105
  if (saved) {
@@ -12,9 +12,10 @@ type CommandPaletteRenderProps = {
12
12
  type CommandPaletteProps = {
13
13
  onClose?: () => void;
14
14
  interactive?: boolean;
15
+ maxVisible?: number;
15
16
  render?: (props: CommandPaletteRenderProps) => React.ReactNode;
16
17
  };
17
- declare function CommandPalette({ onClose, interactive, render }: CommandPaletteProps): react_jsx_runtime.JSX.Element;
18
+ declare function CommandPalette({ onClose, interactive, maxVisible, render }: CommandPaletteProps): react_jsx_runtime.JSX.Element;
18
19
 
19
20
  type TextInputRenderProps = {
20
21
  value: string;
package/dist/ui/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  useFocusNode,
5
5
  useKeybindingRegistry,
6
6
  useKeybindings
7
- } from "../chunk-74PBSWEK.js";
7
+ } from "../chunk-HHDMTIXE.js";
8
8
  import {
9
9
  CodeBlock
10
10
  } from "../chunk-SKSDNDQF.js";
@@ -13,9 +13,125 @@ import {
13
13
  } from "../chunk-C77VBSPK.js";
14
14
 
15
15
  // src/ui/CommandPalette.tsx
16
- import { useState } from "react";
16
+ import { useState as useState2 } from "react";
17
+ import { Box as Box3, Text as Text2 } from "ink";
18
+
19
+ // src/ui/VirtualList.tsx
20
+ import React, { useEffect, useRef, useState } from "react";
21
+ import { Box as Box2 } from "ink";
22
+
23
+ // src/ui/Paginator.tsx
17
24
  import { Box, Text } from "ink";
18
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
25
+ import { jsx, jsxs } from "react/jsx-runtime";
26
+ function Paginator({ total, offset, visible, gap = 0, style = "arrows", position }) {
27
+ const theme = useTheme();
28
+ if (style === "none" || total <= visible) return null;
29
+ const hasAbove = offset > 0;
30
+ const hasBelow = offset + visible < total;
31
+ if (style === "arrows") {
32
+ if (position === "above" && hasAbove) return /* @__PURE__ */ jsx(Text, { color: theme.accentColor, children: "\u2191" });
33
+ if (position === "below" && hasBelow) return /* @__PURE__ */ jsx(Text, { color: theme.accentColor, children: "\u2193" });
34
+ return null;
35
+ }
36
+ if (style === "dots") {
37
+ if (position === "above") return null;
38
+ const totalPages = Math.ceil(total / visible);
39
+ const maxOffset = Math.max(1, total - visible);
40
+ const currentPage = Math.round(offset / maxOffset * (totalPages - 1));
41
+ return /* @__PURE__ */ jsx(Text, { children: Array.from({ length: totalPages }, (_, i) => /* @__PURE__ */ jsxs(Text, { dimColor: i !== currentPage, children: [
42
+ "\u25CF",
43
+ i < totalPages - 1 ? " " : ""
44
+ ] }, i)) });
45
+ }
46
+ if (style === "counter") {
47
+ if (position === "above") return null;
48
+ return /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
49
+ offset + 1,
50
+ "\u2013",
51
+ Math.min(offset + visible, total),
52
+ " of ",
53
+ total
54
+ ] });
55
+ }
56
+ const totalLines = visible + gap * (visible - 1);
57
+ const thumbSize = Math.max(1, Math.round(visible / total * totalLines));
58
+ const maxThumbOffset = totalLines - thumbSize;
59
+ const maxScrollOffset = total - visible;
60
+ const thumbOffset = maxScrollOffset === 0 ? 0 : Math.round(offset / maxScrollOffset * maxThumbOffset);
61
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: Array.from({ length: totalLines }, (_, i) => {
62
+ const isThumb = i >= thumbOffset && i < thumbOffset + thumbSize;
63
+ return /* @__PURE__ */ jsx(Text, { color: isThumb ? theme.accentColor : void 0, dimColor: !isThumb, children: isThumb ? "\u2588" : "\u2502" }, i);
64
+ }) });
65
+ }
66
+
67
+ // src/ui/VirtualList.tsx
68
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
69
+ function VirtualList({
70
+ items,
71
+ highlightIndex,
72
+ scrollOffset: controlledOffset,
73
+ gap = 0,
74
+ maxVisible,
75
+ paginatorStyle = "dots",
76
+ render
77
+ }) {
78
+ const [internalOffset, setInternalOffset] = useState(0);
79
+ const offsetRef = useRef(0);
80
+ const windowed = maxVisible != null && items.length > maxVisible;
81
+ let offset = 0;
82
+ if (windowed) {
83
+ const maxOffset = Math.max(0, items.length - maxVisible);
84
+ if (controlledOffset != null) {
85
+ offset = Math.min(controlledOffset, maxOffset);
86
+ } else {
87
+ offset = Math.min(internalOffset, maxOffset);
88
+ if (highlightIndex != null && highlightIndex >= 0) {
89
+ if (highlightIndex < offset) {
90
+ offset = highlightIndex;
91
+ } else if (highlightIndex >= offset + maxVisible) {
92
+ offset = highlightIndex - maxVisible + 1;
93
+ }
94
+ }
95
+ }
96
+ }
97
+ offsetRef.current = offset;
98
+ useEffect(() => {
99
+ if (windowed && offsetRef.current !== internalOffset) {
100
+ setInternalOffset(offsetRef.current);
101
+ }
102
+ }, [windowed, highlightIndex, controlledOffset, internalOffset]);
103
+ if (!windowed) {
104
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", gap, children: items.map((item, index) => /* @__PURE__ */ jsx2(React.Fragment, { children: render({ item, index }) }, index)) });
105
+ }
106
+ const visible = items.slice(offset, offset + maxVisible);
107
+ const paginatorProps = {
108
+ total: items.length,
109
+ offset,
110
+ visible: maxVisible,
111
+ gap,
112
+ style: paginatorStyle
113
+ };
114
+ if (paginatorStyle === "scrollbar") {
115
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "row", gap: 1, children: [
116
+ /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", gap, flexGrow: 1, children: visible.map((item, i) => {
117
+ const index = offset + i;
118
+ return /* @__PURE__ */ jsx2(React.Fragment, { children: render({ item, index }) }, index);
119
+ }) }),
120
+ /* @__PURE__ */ jsx2(Paginator, { ...paginatorProps })
121
+ ] });
122
+ }
123
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", gap, children: [
124
+ /* @__PURE__ */ jsx2(Paginator, { ...paginatorProps, position: "above" }),
125
+ visible.map((item, i) => {
126
+ const index = offset + i;
127
+ return /* @__PURE__ */ jsx2(React.Fragment, { children: render({ item, index }) }, index);
128
+ }),
129
+ /* @__PURE__ */ jsx2(Paginator, { ...paginatorProps, position: "below" })
130
+ ] });
131
+ }
132
+
133
+ // src/ui/CommandPalette.tsx
134
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
19
135
  var EMPTY_KEY = {
20
136
  upArrow: false,
21
137
  downArrow: false,
@@ -48,11 +164,15 @@ function fuzzyMatch(name, query) {
48
164
  }
49
165
  return qi === lowerQuery.length;
50
166
  }
51
- function Inner({ onClose, render }) {
167
+ function Inner({
168
+ onClose,
169
+ maxVisible = 10,
170
+ render
171
+ }) {
52
172
  const focus = useFocusNode();
53
173
  const theme = useTheme();
54
- const [query, setQuery] = useState("");
55
- const [selectedIndex, setSelectedIndex] = useState(0);
174
+ const [query, setQuery] = useState2("");
175
+ const [selectedIndex, setSelectedIndex] = useState2(0);
56
176
  const registry = useKeybindingRegistry();
57
177
  const named = registry.all.filter((cmd) => cmd.name != null);
58
178
  const filtered = named.filter((cmd) => fuzzyMatch(cmd.name, query));
@@ -69,8 +189,8 @@ function Inner({ onClose, render }) {
69
189
  const cmd = filtered[clampedIndex];
70
190
  if (cmd) onSelect(cmd);
71
191
  },
72
- left: () => setSelectedIndex((i) => (i - 1 + filtered.length) % filtered.length),
73
- right: () => setSelectedIndex((i) => (i + 1) % filtered.length),
192
+ up: () => setSelectedIndex((i) => (i - 1 + filtered.length) % filtered.length),
193
+ down: () => setSelectedIndex((i) => (i + 1) % filtered.length),
74
194
  backspace: () => {
75
195
  setQuery((q) => q.slice(0, -1));
76
196
  setSelectedIndex(0);
@@ -86,27 +206,37 @@ function Inner({ onClose, render }) {
86
206
  }
87
207
  );
88
208
  if (render) {
89
- return /* @__PURE__ */ jsx(Fragment, { children: render({ query, filtered, selectedIndex: clampedIndex, onSelect }) });
209
+ return /* @__PURE__ */ jsx3(Fragment, { children: render({ query, filtered, selectedIndex: clampedIndex, onSelect }) });
90
210
  }
91
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
92
- query.length > 0 && /* @__PURE__ */ jsxs(Text, { children: [
93
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "> " }),
94
- /* @__PURE__ */ jsx(Text, { children: query }),
95
- /* @__PURE__ */ jsx(Text, { inverse: true, children: " " })
211
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
212
+ /* @__PURE__ */ jsxs3(Text2, { children: [
213
+ /* @__PURE__ */ jsx3(Text2, { dimColor: true, children: "> " }),
214
+ query.length > 0 ? /* @__PURE__ */ jsx3(Text2, { children: query }) : /* @__PURE__ */ jsx3(Text2, { dimColor: true, children: "Search commands\u2026" }),
215
+ /* @__PURE__ */ jsx3(Text2, { inverse: true, children: " " })
96
216
  ] }),
97
- filtered.length === 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: "No commands found" }) : /* @__PURE__ */ jsx(Box, { flexWrap: "wrap", children: filtered.map((cmd, index) => {
98
- const highlighted = index === clampedIndex;
99
- const keyColor = highlighted ? theme.hintHighlightColor : theme.hintColor;
100
- const labelColor = highlighted ? theme.hintHighlightDimColor : theme.hintDimColor;
101
- return /* @__PURE__ */ jsxs(Text, { children: [
102
- /* @__PURE__ */ jsx(Text, { color: keyColor, bold: true, children: cmd.key }),
103
- /* @__PURE__ */ jsxs(Text, { color: labelColor, children: [
104
- " ",
105
- cmd.name
106
- ] }),
107
- index < filtered.length - 1 && /* @__PURE__ */ jsx(Text, { color: theme.hintDimColor, children: " \u2022 " })
108
- ] }, `${cmd.nodeId}-${cmd.key}`);
109
- }) })
217
+ /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", marginTop: 1, children: filtered.length === 0 ? /* @__PURE__ */ jsx3(Text2, { dimColor: true, children: "No commands found" }) : /* @__PURE__ */ jsx3(
218
+ VirtualList,
219
+ {
220
+ items: filtered,
221
+ highlightIndex: clampedIndex,
222
+ maxVisible,
223
+ paginatorStyle: "scrollbar",
224
+ render: ({ item: cmd, index }) => {
225
+ const highlighted = index === clampedIndex;
226
+ const keyColor = highlighted ? theme.hintHighlightColor : theme.hintColor;
227
+ const labelColor = highlighted ? theme.hintHighlightDimColor : theme.hintDimColor;
228
+ return /* @__PURE__ */ jsxs3(Text2, { children: [
229
+ /* @__PURE__ */ jsx3(Text2, { dimColor: true, children: highlighted ? theme.indicator + " " : " " }),
230
+ /* @__PURE__ */ jsx3(Text2, { color: keyColor, bold: true, children: cmd.key }),
231
+ /* @__PURE__ */ jsxs3(Text2, { color: labelColor, children: [
232
+ " ",
233
+ cmd.name
234
+ ] })
235
+ ] });
236
+ }
237
+ }
238
+ ) }),
239
+ /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text2, { dimColor: true, children: "\u2191\u2193 navigate \xB7 \u21B5 run \xB7 esc close" }) })
110
240
  ] });
111
241
  }
112
242
  function HintsBar() {
@@ -114,31 +244,31 @@ function HintsBar() {
114
244
  const theme = useTheme();
115
245
  const commands = registry.available.filter((cmd) => cmd.name != null);
116
246
  if (commands.length === 0) return null;
117
- return /* @__PURE__ */ jsx(Box, { flexWrap: "wrap", children: commands.map((cmd, index) => /* @__PURE__ */ jsxs(Text, { children: [
118
- /* @__PURE__ */ jsx(Text, { color: theme.hintColor, bold: true, children: cmd.key }),
119
- /* @__PURE__ */ jsxs(Text, { color: theme.hintDimColor, children: [
247
+ return /* @__PURE__ */ jsx3(Box3, { flexWrap: "wrap", children: commands.map((cmd, index) => /* @__PURE__ */ jsxs3(Text2, { children: [
248
+ /* @__PURE__ */ jsx3(Text2, { color: theme.hintColor, bold: true, children: cmd.key }),
249
+ /* @__PURE__ */ jsxs3(Text2, { color: theme.hintDimColor, children: [
120
250
  " ",
121
251
  cmd.name
122
252
  ] }),
123
- index < commands.length - 1 && /* @__PURE__ */ jsx(Text, { color: theme.hintDimColor, children: " \u2022 " })
253
+ index < commands.length - 1 && /* @__PURE__ */ jsx3(Text2, { color: theme.hintDimColor, children: " \u2022 " })
124
254
  ] }, `${cmd.nodeId}-${cmd.key}`)) });
125
255
  }
126
256
  var noop = () => {
127
257
  };
128
- function CommandPalette({ onClose, interactive = true, render }) {
258
+ function CommandPalette({ onClose, interactive = true, maxVisible, render }) {
129
259
  if (!interactive) {
130
- return /* @__PURE__ */ jsx(HintsBar, {});
260
+ return /* @__PURE__ */ jsx3(HintsBar, {});
131
261
  }
132
- return /* @__PURE__ */ jsx(FocusTrap, { children: /* @__PURE__ */ jsx(Inner, { onClose: onClose ?? noop, render }) });
262
+ return /* @__PURE__ */ jsx3(FocusTrap, { children: /* @__PURE__ */ jsx3(Inner, { onClose: onClose ?? noop, maxVisible, render }) });
133
263
  }
134
264
 
135
265
  // src/ui/TextInput.tsx
136
- import { useReducer, useRef } from "react";
137
- import { Text as Text2 } from "ink";
138
- import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
266
+ import { useReducer, useRef as useRef2 } from "react";
267
+ import { Text as Text3 } from "ink";
268
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
139
269
  function TextInput({ label, value, onChange, onSubmit, placeholder, render, focusKey }) {
140
270
  const focus = useFocusNode({ focusKey });
141
- const cursorRef = useRef(value.length);
271
+ const cursorRef = useRef2(value.length);
142
272
  const [, forceRender] = useReducer((c) => c + 1, 0);
143
273
  const cursor = Math.min(cursorRef.current, value.length);
144
274
  cursorRef.current = cursor;
@@ -191,7 +321,7 @@ function TextInput({ label, value, onChange, onSubmit, placeholder, render, focu
191
321
  const cursorChar = value[cursor] ?? " ";
192
322
  const after = value.slice(cursor + 1);
193
323
  if (render) {
194
- return /* @__PURE__ */ jsx2(Fragment2, { children: render({ value, focused: focus.hasFocus, before, cursorChar, after, placeholder }) });
324
+ return /* @__PURE__ */ jsx4(Fragment2, { children: render({ value, focused: focus.hasFocus, before, cursorChar, after, placeholder }) });
195
325
  }
196
326
  const displayValue = value.length > 0 ? value : placeholder ?? "";
197
327
  const isPlaceholder = value.length === 0;
@@ -199,144 +329,28 @@ function TextInput({ label, value, onChange, onSubmit, placeholder, render, focu
199
329
  const isPlaceholderVisible = value.length === 0 && placeholder != null;
200
330
  const activeCursorChar = isPlaceholderVisible ? placeholder[0] ?? " " : cursorChar;
201
331
  const activeAfter = isPlaceholderVisible ? placeholder.slice(1) : after;
202
- return /* @__PURE__ */ jsxs2(Text2, { children: [
203
- label != null && /* @__PURE__ */ jsxs2(Text2, { bold: true, children: [
332
+ return /* @__PURE__ */ jsxs4(Text3, { children: [
333
+ label != null && /* @__PURE__ */ jsxs4(Text3, { bold: true, children: [
204
334
  label,
205
335
  " "
206
336
  ] }),
207
337
  before,
208
- /* @__PURE__ */ jsx2(Text2, { inverse: true, children: activeCursorChar }),
209
- isPlaceholderVisible ? /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: activeAfter }) : activeAfter
338
+ /* @__PURE__ */ jsx4(Text3, { inverse: true, children: activeCursorChar }),
339
+ isPlaceholderVisible ? /* @__PURE__ */ jsx4(Text3, { dimColor: true, children: activeAfter }) : activeAfter
210
340
  ] });
211
341
  }
212
- return /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
213
- label != null && /* @__PURE__ */ jsxs2(Text2, { children: [
342
+ return /* @__PURE__ */ jsxs4(Text3, { dimColor: true, children: [
343
+ label != null && /* @__PURE__ */ jsxs4(Text3, { children: [
214
344
  label,
215
345
  " "
216
346
  ] }),
217
- isPlaceholder ? /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: displayValue }) : displayValue
347
+ isPlaceholder ? /* @__PURE__ */ jsx4(Text3, { dimColor: true, children: displayValue }) : displayValue
218
348
  ] });
219
349
  }
220
350
 
221
351
  // src/ui/Select.tsx
222
352
  import React3, { useEffect as useEffect2, useState as useState3 } from "react";
223
353
  import { Box as Box4, Text as Text4 } from "ink";
224
-
225
- // src/ui/VirtualList.tsx
226
- import React2, { useEffect, useRef as useRef2, useState as useState2 } from "react";
227
- import { Box as Box3 } from "ink";
228
-
229
- // src/ui/Paginator.tsx
230
- import { Box as Box2, Text as Text3 } from "ink";
231
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
232
- function Paginator({ total, offset, visible, gap = 0, style = "arrows", position }) {
233
- const theme = useTheme();
234
- if (style === "none" || total <= visible) return null;
235
- const hasAbove = offset > 0;
236
- const hasBelow = offset + visible < total;
237
- if (style === "arrows") {
238
- if (position === "above" && hasAbove) return /* @__PURE__ */ jsx3(Text3, { color: theme.accentColor, children: "\u2191" });
239
- if (position === "below" && hasBelow) return /* @__PURE__ */ jsx3(Text3, { color: theme.accentColor, children: "\u2193" });
240
- return null;
241
- }
242
- if (style === "dots") {
243
- if (position === "above") return null;
244
- const totalPages = Math.ceil(total / visible);
245
- const maxOffset = Math.max(1, total - visible);
246
- const currentPage = Math.round(offset / maxOffset * (totalPages - 1));
247
- return /* @__PURE__ */ jsx3(Text3, { children: Array.from({ length: totalPages }, (_, i) => /* @__PURE__ */ jsxs3(Text3, { dimColor: i !== currentPage, children: [
248
- "\u25CF",
249
- i < totalPages - 1 ? " " : ""
250
- ] }, i)) });
251
- }
252
- if (style === "counter") {
253
- if (position === "above") return null;
254
- return /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
255
- offset + 1,
256
- "\u2013",
257
- Math.min(offset + visible, total),
258
- " of ",
259
- total
260
- ] });
261
- }
262
- const totalLines = visible + gap * (visible - 1);
263
- const thumbSize = Math.max(1, Math.round(visible / total * totalLines));
264
- const maxThumbOffset = totalLines - thumbSize;
265
- const maxScrollOffset = total - visible;
266
- const thumbOffset = maxScrollOffset === 0 ? 0 : Math.round(offset / maxScrollOffset * maxThumbOffset);
267
- return /* @__PURE__ */ jsx3(Box2, { flexDirection: "column", children: Array.from({ length: totalLines }, (_, i) => {
268
- const isThumb = i >= thumbOffset && i < thumbOffset + thumbSize;
269
- return /* @__PURE__ */ jsx3(Text3, { color: isThumb ? theme.accentColor : void 0, dimColor: !isThumb, children: isThumb ? "\u2588" : "\u2502" }, i);
270
- }) });
271
- }
272
-
273
- // src/ui/VirtualList.tsx
274
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
275
- function VirtualList({
276
- items,
277
- highlightIndex,
278
- scrollOffset: controlledOffset,
279
- gap = 0,
280
- maxVisible,
281
- paginatorStyle = "dots",
282
- render
283
- }) {
284
- const [internalOffset, setInternalOffset] = useState2(0);
285
- const offsetRef = useRef2(0);
286
- const windowed = maxVisible != null && items.length > maxVisible;
287
- let offset = 0;
288
- if (windowed) {
289
- const maxOffset = Math.max(0, items.length - maxVisible);
290
- if (controlledOffset != null) {
291
- offset = Math.min(controlledOffset, maxOffset);
292
- } else {
293
- offset = Math.min(internalOffset, maxOffset);
294
- if (highlightIndex != null && highlightIndex >= 0) {
295
- if (highlightIndex < offset) {
296
- offset = highlightIndex;
297
- } else if (highlightIndex >= offset + maxVisible) {
298
- offset = highlightIndex - maxVisible + 1;
299
- }
300
- }
301
- }
302
- }
303
- offsetRef.current = offset;
304
- useEffect(() => {
305
- if (windowed && offsetRef.current !== internalOffset) {
306
- setInternalOffset(offsetRef.current);
307
- }
308
- }, [windowed, highlightIndex, controlledOffset, internalOffset]);
309
- if (!windowed) {
310
- return /* @__PURE__ */ jsx4(Box3, { flexDirection: "column", gap, children: items.map((item, index) => /* @__PURE__ */ jsx4(React2.Fragment, { children: render({ item, index }) }, index)) });
311
- }
312
- const visible = items.slice(offset, offset + maxVisible);
313
- const paginatorProps = {
314
- total: items.length,
315
- offset,
316
- visible: maxVisible,
317
- gap,
318
- style: paginatorStyle
319
- };
320
- if (paginatorStyle === "scrollbar") {
321
- return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "row", gap: 1, children: [
322
- /* @__PURE__ */ jsx4(Box3, { flexDirection: "column", gap, flexGrow: 1, children: visible.map((item, i) => {
323
- const index = offset + i;
324
- return /* @__PURE__ */ jsx4(React2.Fragment, { children: render({ item, index }) }, index);
325
- }) }),
326
- /* @__PURE__ */ jsx4(Paginator, { ...paginatorProps })
327
- ] });
328
- }
329
- return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", gap, children: [
330
- /* @__PURE__ */ jsx4(Paginator, { ...paginatorProps, position: "above" }),
331
- visible.map((item, i) => {
332
- const index = offset + i;
333
- return /* @__PURE__ */ jsx4(React2.Fragment, { children: render({ item, index }) }, index);
334
- }),
335
- /* @__PURE__ */ jsx4(Paginator, { ...paginatorProps, position: "below" })
336
- ] });
337
- }
338
-
339
- // src/ui/Select.tsx
340
354
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
341
355
  function Select({
342
356
  options,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "giggles",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",