giggles 0.3.16 → 0.4.0

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/README.md CHANGED
@@ -14,11 +14,11 @@ inspired by the [charmbracelet](https://github.com/charmbracelet) ecosystem, it
14
14
 
15
15
  - no `useInput` hooks scattered across your app — focus, input routing, and keyboard navigation are handled for you
16
16
  - navigate between views with a simple API; the previously focused component is restored when you return
17
- - a full set of hooks and components — `useFocus`, `FocusGroup`, `FocusTrap`, `useNavigation`, and more — for building any interaction pattern without reimplementing the plumbing
17
+ - a full set of hooks and components — `useFocus`, `useFocusNode`, `FocusGroup`, `FocusTrap`, `useNavigation`, and more — for building any interaction pattern without reimplementing the plumbing
18
18
  - built-in keybinding registry so your app can always show users what keys do what, in the current context — context-aware and accessible via a hook
19
19
  - a component library covering most TUI use cases, from text inputs and autocomplete to virtual lists for large datasets — with sensible defaults and render props for full customization
20
20
  - render markdown in the terminal, with full formatting and syntax-highlighted code block and diff support
21
- - hand off terminal control to external programs like `vim` or `less` and reclaim it cleanly when they exit (similar to `tea.ExecProcess` from charmbracelet/bubbletea)
21
+ - hand off terminal control to external programs like `vim` or `less` and reclaim it cleanly when they exit, or spawn processes and stream their output directly into your UI
22
22
  - a consistent look out of the box, customizable from a single theme object
23
23
 
24
24
  ## your first TUI
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  useTheme
3
- } from "./chunk-EVD6YPS3.js";
3
+ } from "./chunk-R5I4YOOP.js";
4
4
 
5
5
  // src/ui/CodeBlock.tsx
6
6
  import { Box, Text } from "ink";
@@ -104,6 +104,7 @@ import { jsx as jsx2 } from "react/jsx-runtime";
104
104
  var FocusContext = createContext2(null);
105
105
  var FocusProvider = ({ children }) => {
106
106
  const nodesRef = useRef2(/* @__PURE__ */ new Map());
107
+ const parentMapRef = useRef2(/* @__PURE__ */ new Map());
107
108
  const pendingFocusFirstChildRef = useRef2(/* @__PURE__ */ new Set());
108
109
  const [focusedId, setFocusedId] = useState(null);
109
110
  const focusNode = useCallback2((id) => {
@@ -141,6 +142,7 @@ var FocusProvider = ({ children }) => {
141
142
  childrenIds: []
142
143
  };
143
144
  nodes.set(id, node);
145
+ parentMapRef.current.set(id, parentId);
144
146
  if (parentId) {
145
147
  const parent = nodes.get(parentId);
146
148
  if (parent && !parent.childrenIds.includes(id)) {
@@ -177,12 +179,25 @@ var FocusProvider = ({ children }) => {
177
179
  pendingFocusFirstChildRef.current.delete(id);
178
180
  setFocusedId((current) => {
179
181
  if (current !== id) return current;
180
- return node.parentId ?? null;
182
+ let candidate = node.parentId;
183
+ while (candidate !== null) {
184
+ if (nodesRef.current.has(candidate)) return candidate;
185
+ candidate = parentMapRef.current.get(candidate) ?? null;
186
+ }
187
+ return null;
181
188
  });
182
189
  }, []);
183
190
  const isFocused = useCallback2(
184
191
  (id) => {
185
- return id === focusedId;
192
+ if (!focusedId) return false;
193
+ const nodes = nodesRef.current;
194
+ let cursor = focusedId;
195
+ while (cursor) {
196
+ if (cursor === id) return true;
197
+ const node = nodes.get(cursor);
198
+ cursor = (node == null ? void 0 : node.parentId) ?? null;
199
+ }
200
+ return false;
186
201
  },
187
202
  [focusedId]
188
203
  );
@@ -220,7 +235,16 @@ var FocusProvider = ({ children }) => {
220
235
  }
221
236
  cursor = (node == null ? void 0 : node.parentId) ?? null;
222
237
  }
223
- if (!currentChildId) return;
238
+ if (!currentChildId) {
239
+ const targetId2 = direction === "next" ? siblings[0] : siblings[siblings.length - 1];
240
+ const target2 = nodes.get(targetId2);
241
+ if (target2 && target2.childrenIds.length > 0) {
242
+ focusFirstChild(targetId2);
243
+ } else {
244
+ focusNode(targetId2);
245
+ }
246
+ return;
247
+ }
224
248
  } else {
225
249
  const currentNode = nodes.get(focusedId);
226
250
  if (!(currentNode == null ? void 0 : currentNode.parentId)) return;
@@ -372,9 +396,9 @@ function InputRouter({ children }) {
372
396
  import { createContext as createContext3 } from "react";
373
397
  var FocusBindContext = createContext3(null);
374
398
 
375
- // src/core/focus/useFocus.ts
399
+ // src/core/focus/useFocusNode.ts
376
400
  import { useContext as useContext3, useEffect as useEffect2, useId } from "react";
377
- var useFocus = (id) => {
401
+ var useFocusNode = (id) => {
378
402
  const nodeId = useId();
379
403
  const parentId = useContext3(FocusNodeContext);
380
404
  const bindContext = useContext3(FocusBindContext);
@@ -400,15 +424,8 @@ var useFocus = (id) => {
400
424
 
401
425
  // src/core/focus/FocusGroup.tsx
402
426
  import { jsx as jsx4 } from "react/jsx-runtime";
403
- function FocusGroup({
404
- children,
405
- direction = "vertical",
406
- value,
407
- wrap = true,
408
- navigable = true,
409
- keybindings: customBindings
410
- }) {
411
- const focus = useFocus();
427
+ function FocusGroup({ children, value, wrap = true, keybindings: customBindings }) {
428
+ const focus = useFocusNode();
412
429
  const { focusNode, navigateSibling } = useFocusContext();
413
430
  const bindMapRef = useRef3(/* @__PURE__ */ new Map());
414
431
  const register = useCallback3((logicalId, nodeId) => {
@@ -429,31 +446,35 @@ function FocusGroup({
429
446
  }
430
447
  }, [value, focusNode]);
431
448
  const bindContextValue = useMemo(() => value ? { register, unregister } : null, [value, register, unregister]);
432
- const navigationKeys = useMemo(() => {
433
- if (!navigable) return {};
434
- const next = () => navigateSibling("next", wrap, focus.id);
435
- const prev = () => navigateSibling("prev", wrap, focus.id);
436
- const base = direction === "vertical" ? {
437
- j: next,
438
- k: prev,
439
- down: next,
440
- up: prev
441
- } : {
442
- l: next,
443
- h: prev,
444
- right: next,
445
- left: prev
446
- };
447
- return { ...base, tab: next, "shift+tab": prev };
448
- }, [navigable, direction, wrap, navigateSibling, focus.id]);
449
- const mergedBindings = useMemo(
450
- () => ({ ...navigationKeys, ...customBindings }),
451
- [navigationKeys, customBindings]
452
- );
453
- useKeybindings(focus, mergedBindings);
449
+ const next = useCallback3(() => navigateSibling("next", wrap, focus.id), [navigateSibling, wrap, focus.id]);
450
+ const prev = useCallback3(() => navigateSibling("prev", wrap, focus.id), [navigateSibling, wrap, focus.id]);
451
+ const escape = useCallback3(() => focusNode(focus.id), [focusNode, focus.id]);
452
+ const resolvedBindings = useMemo(() => {
453
+ if (typeof customBindings === "function") {
454
+ return customBindings({ next, prev, escape });
455
+ }
456
+ return customBindings ?? {};
457
+ }, [customBindings, next, prev, escape]);
458
+ useKeybindings(focus, resolvedBindings);
454
459
  return /* @__PURE__ */ jsx4(FocusNodeContext.Provider, { value: focus.id, children: /* @__PURE__ */ jsx4(FocusBindContext.Provider, { value: bindContextValue, children }) });
455
460
  }
456
461
 
462
+ // src/core/focus/useFocus.ts
463
+ import { useContext as useContext4 } from "react";
464
+ var useFocus = () => {
465
+ const parentId = useContext4(FocusNodeContext);
466
+ const { focusNode, isFocused } = useFocusContext();
467
+ if (parentId === null) {
468
+ return { id: "", focused: false, focus: () => {
469
+ } };
470
+ }
471
+ return {
472
+ id: parentId,
473
+ focused: isFocused(parentId),
474
+ focus: () => focusNode(parentId)
475
+ };
476
+ };
477
+
457
478
  // src/core/focus/useFocusState.ts
458
479
  import { useState as useState2 } from "react";
459
480
  function useFocusState(initial) {
@@ -464,7 +485,7 @@ function useFocusState(initial) {
464
485
  // src/core/input/FocusTrap.tsx
465
486
  import { jsx as jsx5 } from "react/jsx-runtime";
466
487
  function FocusTrap({ children }) {
467
- const { id } = useFocus();
488
+ const { id } = useFocusNode();
468
489
  const { setTrap, clearTrap } = useInputContext();
469
490
  const { focusFirstChild, getFocusedId, focusNode } = useFocusContext();
470
491
  const previousFocusRef = useRef4(getFocusedId());
@@ -492,7 +513,8 @@ export {
492
513
  useKeybindings,
493
514
  useKeybindingRegistry,
494
515
  FocusTrap,
495
- useFocus,
516
+ useFocusNode,
496
517
  FocusGroup,
518
+ useFocus,
497
519
  useFocusState
498
520
  };
@@ -10,6 +10,7 @@ var defaultTheme = {
10
10
  hintHighlightColor: "#D4D4D4",
11
11
  hintHighlightDimColor: "#A0A0A0",
12
12
  indicator: "\u25B6",
13
+ indicatorOpen: "\u25BC",
13
14
  checkedIndicator: "\u25A3",
14
15
  uncheckedIndicator: "\u25FB"
15
16
  };
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ type GigglesTheme = {
18
18
  hintHighlightColor: string;
19
19
  hintHighlightDimColor: string;
20
20
  indicator: string;
21
+ indicatorOpen: string;
21
22
  checkedIndicator: string;
22
23
  uncheckedIndicator: string;
23
24
  };
@@ -49,15 +50,18 @@ type FocusTrapProps = {
49
50
  };
50
51
  declare function FocusTrap({ children }: FocusTrapProps): react_jsx_runtime.JSX.Element;
51
52
 
53
+ type FocusGroupHelpers = {
54
+ next: () => void;
55
+ prev: () => void;
56
+ escape: () => void;
57
+ };
52
58
  type FocusGroupProps = {
53
59
  children: React__default.ReactNode;
54
- direction?: 'vertical' | 'horizontal';
55
60
  value?: string;
56
61
  wrap?: boolean;
57
- navigable?: boolean;
58
- keybindings?: Keybindings;
62
+ keybindings?: Keybindings | ((helpers: FocusGroupHelpers) => Keybindings);
59
63
  };
60
- declare function FocusGroup({ children, direction, value, wrap, navigable, keybindings: customBindings }: FocusGroupProps): react_jsx_runtime.JSX.Element;
64
+ declare function FocusGroup({ children, value, wrap, keybindings: customBindings }: FocusGroupProps): react_jsx_runtime.JSX.Element;
61
65
 
62
66
  type FocusHandle = {
63
67
  id: string;
@@ -65,7 +69,9 @@ type FocusHandle = {
65
69
  focus: () => void;
66
70
  };
67
71
 
68
- declare const useFocus: (id?: string) => FocusHandle;
72
+ declare const useFocus: () => FocusHandle;
73
+
74
+ declare const useFocusNode: (id?: string) => FocusHandle;
69
75
 
70
76
  declare function useFocusState<T extends string>(initial: T): readonly [T, React$1.Dispatch<React$1.SetStateAction<T>>];
71
77
 
@@ -100,4 +106,4 @@ type NavigationContextValue = {
100
106
 
101
107
  declare const useNavigation: () => NavigationContextValue;
102
108
 
103
- export { FocusGroup, type FocusHandle, FocusTrap, GigglesError, GigglesProvider, type GigglesTheme, KeybindingOptions, type KeybindingRegistry, Keybindings, type NavigationContextValue, RegisteredKeybinding, Router, Screen, ThemeProvider, useFocus, useFocusState, useKeybindingRegistry, useKeybindings, useNavigation, useTheme };
109
+ export { FocusGroup, type FocusGroupHelpers, type FocusHandle, FocusTrap, GigglesError, GigglesProvider, type GigglesTheme, KeybindingOptions, type KeybindingRegistry, Keybindings, type NavigationContextValue, RegisteredKeybinding, Router, Screen, ThemeProvider, useFocus, useFocusNode, useFocusState, useKeybindingRegistry, useKeybindings, useNavigation, useTheme };
package/dist/index.js CHANGED
@@ -11,14 +11,15 @@ import {
11
11
  InputRouter,
12
12
  useFocus,
13
13
  useFocusContext,
14
+ useFocusNode,
14
15
  useFocusState,
15
16
  useKeybindingRegistry,
16
17
  useKeybindings
17
- } from "./chunk-CKA5JJ4B.js";
18
+ } from "./chunk-ET2WSMEF.js";
18
19
  import {
19
20
  ThemeProvider,
20
21
  useTheme
21
- } from "./chunk-EVD6YPS3.js";
22
+ } from "./chunk-R5I4YOOP.js";
22
23
 
23
24
  // src/core/GigglesProvider.tsx
24
25
  import { jsx } from "react/jsx-runtime";
@@ -187,6 +188,7 @@ export {
187
188
  Screen,
188
189
  ThemeProvider,
189
190
  useFocus,
191
+ useFocusNode,
190
192
  useFocusState,
191
193
  useKeybindingRegistry,
192
194
  useKeybindings,
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  CodeBlock
3
- } from "../chunk-JTJH45JR.js";
3
+ } from "../chunk-DFB7V4OK.js";
4
4
  import {
5
5
  useTheme
6
- } from "../chunk-EVD6YPS3.js";
6
+ } from "../chunk-R5I4YOOP.js";
7
7
 
8
8
  // src/ui/Markdown.tsx
9
9
  import Table from "cli-table3";
@@ -1,3 +1,4 @@
1
+ import { SpawnOptionsWithoutStdio } from 'child_process';
1
2
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
3
  import { ReactNode } from 'react';
3
4
 
@@ -5,6 +6,25 @@ type TerminalSize = {
5
6
  rows: number;
6
7
  columns: number;
7
8
  };
9
+ type SpawnOptions = SpawnOptionsWithoutStdio & {
10
+ /**
11
+ * Inject FORCE_COLOR=1 and TERM=xterm-256color into the child process
12
+ * environment so tools that detect isTTY emit ANSI color codes.
13
+ */
14
+ pty?: boolean;
15
+ };
16
+ type SpawnOutputLine = {
17
+ type: 'stdout' | 'stderr';
18
+ data: string;
19
+ };
20
+ type SpawnHandle = {
21
+ output: SpawnOutputLine[];
22
+ running: boolean;
23
+ exitCode: number | null;
24
+ error: Error | null;
25
+ run: (command: string, args?: string[], options?: SpawnOptions) => void;
26
+ kill: () => void;
27
+ };
8
28
 
9
29
  declare function useTerminalSize(): TerminalSize;
10
30
 
@@ -22,4 +42,6 @@ declare function useShellOut(): {
22
42
  }>;
23
43
  };
24
44
 
25
- export { AlternateScreen, type TerminalSize, useShellOut, useTerminalFocus, useTerminalSize };
45
+ declare function useSpawn(): SpawnHandle;
46
+
47
+ export { AlternateScreen, type SpawnHandle, type SpawnOptions, type SpawnOutputLine, type TerminalSize, useShellOut, useSpawn, useTerminalFocus, useTerminalSize };
@@ -54,9 +54,88 @@ function useShellOut() {
54
54
  run
55
55
  };
56
56
  }
57
+
58
+ // src/terminal/hooks/useSpawn.ts
59
+ import { spawn } from "child_process";
60
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
61
+ function useSpawn() {
62
+ const [output, setOutput] = useState2([]);
63
+ const [running, setRunning] = useState2(false);
64
+ const [exitCode, setExitCode] = useState2(null);
65
+ const [error, setError] = useState2(null);
66
+ const processRef = useRef2(null);
67
+ const cancelRef = useRef2(null);
68
+ useEffect2(() => {
69
+ return () => {
70
+ var _a, _b;
71
+ (_a = cancelRef.current) == null ? void 0 : _a.call(cancelRef);
72
+ (_b = processRef.current) == null ? void 0 : _b.kill();
73
+ };
74
+ }, []);
75
+ const run = useCallback2((command, args, options) => {
76
+ var _a, _b;
77
+ (_a = cancelRef.current) == null ? void 0 : _a.call(cancelRef);
78
+ (_b = processRef.current) == null ? void 0 : _b.kill();
79
+ processRef.current = null;
80
+ cancelRef.current = null;
81
+ let cancelled = false;
82
+ cancelRef.current = () => {
83
+ cancelled = true;
84
+ };
85
+ const { pty, ...spawnOptions } = options ?? {};
86
+ setOutput([]);
87
+ setRunning(true);
88
+ setExitCode(null);
89
+ setError(null);
90
+ const env = pty ? { ...process.env, FORCE_COLOR: "1", TERM: "xterm-256color", ...spawnOptions.env } : { ...process.env, ...spawnOptions.env };
91
+ let proc;
92
+ try {
93
+ proc = spawn(command, args ?? [], { ...spawnOptions, env, stdio: "pipe" });
94
+ } catch (err) {
95
+ setRunning(false);
96
+ setError(err instanceof Error ? err : new Error(String(err)));
97
+ cancelRef.current = null;
98
+ return;
99
+ }
100
+ processRef.current = proc;
101
+ proc.stdout.on("data", (data) => {
102
+ if (!cancelled) setOutput((prev) => [...prev, { type: "stdout", data: data.toString() }]);
103
+ });
104
+ proc.stderr.on("data", (data) => {
105
+ if (!cancelled) setOutput((prev) => [...prev, { type: "stderr", data: data.toString() }]);
106
+ });
107
+ proc.on("error", (err) => {
108
+ if (!cancelled) {
109
+ cancelled = true;
110
+ setError(err);
111
+ setRunning(false);
112
+ processRef.current = null;
113
+ cancelRef.current = null;
114
+ }
115
+ });
116
+ proc.on("close", (code) => {
117
+ if (!cancelled) {
118
+ setRunning(false);
119
+ setExitCode(code);
120
+ processRef.current = null;
121
+ cancelRef.current = null;
122
+ }
123
+ });
124
+ }, []);
125
+ const kill = useCallback2(() => {
126
+ var _a, _b;
127
+ (_a = cancelRef.current) == null ? void 0 : _a.call(cancelRef);
128
+ cancelRef.current = null;
129
+ (_b = processRef.current) == null ? void 0 : _b.kill();
130
+ processRef.current = null;
131
+ setRunning(false);
132
+ }, []);
133
+ return { output, running, exitCode, error, run, kill };
134
+ }
57
135
  export {
58
136
  AlternateScreen,
59
137
  useShellOut,
138
+ useSpawn,
60
139
  useTerminalFocus,
61
140
  useTerminalSize
62
141
  };
package/dist/ui/index.js CHANGED
@@ -1,16 +1,16 @@
1
1
  import {
2
2
  FocusTrap,
3
3
  GigglesError,
4
- useFocus,
4
+ useFocusNode,
5
5
  useKeybindingRegistry,
6
6
  useKeybindings
7
- } from "../chunk-CKA5JJ4B.js";
7
+ } from "../chunk-ET2WSMEF.js";
8
8
  import {
9
9
  CodeBlock
10
- } from "../chunk-JTJH45JR.js";
10
+ } from "../chunk-DFB7V4OK.js";
11
11
  import {
12
12
  useTheme
13
- } from "../chunk-EVD6YPS3.js";
13
+ } from "../chunk-R5I4YOOP.js";
14
14
 
15
15
  // src/ui/CommandPalette.tsx
16
16
  import { useState } from "react";
@@ -49,7 +49,7 @@ function fuzzyMatch(name, query) {
49
49
  return qi === lowerQuery.length;
50
50
  }
51
51
  function Inner({ onClose, render }) {
52
- const focus = useFocus();
52
+ const focus = useFocusNode();
53
53
  const theme = useTheme();
54
54
  const [query, setQuery] = useState("");
55
55
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -138,7 +138,7 @@ import { useReducer, useRef } from "react";
138
138
  import { Text as Text2 } from "ink";
139
139
  import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
140
140
  function TextInput({ label, value, onChange, onSubmit, placeholder, render }) {
141
- const focus = useFocus();
141
+ const focus = useFocusNode();
142
142
  const cursorRef = useRef(value.length);
143
143
  const [, forceRender] = useReducer((c) => c + 1, 0);
144
144
  const cursor = Math.min(cursorRef.current, value.length);
@@ -360,7 +360,7 @@ function Select({
360
360
  }
361
361
  seen.add(key);
362
362
  }
363
- const focus = useFocus();
363
+ const focus = useFocusNode();
364
364
  const theme = useTheme();
365
365
  const [highlightIndex, setHighlightIndex] = useState3(0);
366
366
  const safeIndex = options.length === 0 ? -1 : Math.min(highlightIndex, options.length - 1);
@@ -447,7 +447,7 @@ function MultiSelect({
447
447
  }
448
448
  seen.add(key);
449
449
  }
450
- const focus = useFocus();
450
+ const focus = useFocusNode();
451
451
  const theme = useTheme();
452
452
  const [highlightIndex, setHighlightIndex] = useState4(0);
453
453
  const safeIndex = options.length === 0 ? -1 : Math.min(highlightIndex, options.length - 1);
@@ -512,7 +512,7 @@ function MultiSelect({
512
512
  import { Text as Text6 } from "ink";
513
513
  import { jsxs as jsxs7 } from "react/jsx-runtime";
514
514
  function Confirm({ message, defaultValue = true, onSubmit }) {
515
- const focus = useFocus();
515
+ const focus = useFocusNode();
516
516
  useKeybindings(focus, {
517
517
  y: () => onSubmit(true),
518
518
  n: () => onSubmit(false),
@@ -564,7 +564,7 @@ function Autocomplete({
564
564
  }
565
565
  seen.add(key);
566
566
  }
567
- const focus = useFocus();
567
+ const focus = useFocusNode();
568
568
  const theme = useTheme();
569
569
  const [query, setQuery] = useState5("");
570
570
  const [highlightIndex, setHighlightIndex] = useState5(0);
@@ -716,7 +716,7 @@ function MeasurableItem({
716
716
  return /* @__PURE__ */ jsx8(Box7, { ref, flexShrink: 0, width: "100%", flexDirection: "column", children });
717
717
  }
718
718
  var Viewport = forwardRef(function Viewport2({ children, height, keybindings: enableKeybindings = true, footer }, ref) {
719
- const focus = useFocus();
719
+ const focus = useFocusNode();
720
720
  const [scrollOffset, setScrollOffset] = useState6(0);
721
721
  const contentHeightRef = useRef4(0);
722
722
  const itemHeightsRef = useRef4({});
@@ -822,7 +822,7 @@ var Viewport = forwardRef(function Viewport2({ children, height, keybindings: en
822
822
  import { Box as Box8, Text as Text8 } from "ink";
823
823
  import { jsx as jsx9, jsxs as jsxs10 } from "react/jsx-runtime";
824
824
  function ModalInner({ children, onClose, title, ...boxProps }) {
825
- const focus = useFocus();
825
+ const focus = useFocusNode();
826
826
  const theme = useTheme();
827
827
  useKeybindings(focus, {
828
828
  escape: onClose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "giggles",
3
- "version": "0.3.16",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",