@tooee/choose 0.1.22 → 0.2.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.
Files changed (50) hide show
  1. package/README.md +28 -2
  2. package/dist/Choose.d.ts +14 -17
  3. package/dist/Choose.d.ts.map +1 -1
  4. package/dist/Choose.js +42 -266
  5. package/dist/Choose.js.map +1 -1
  6. package/dist/ChooseFilter.d.ts +12 -0
  7. package/dist/ChooseFilter.d.ts.map +1 -0
  8. package/dist/ChooseFilter.js +9 -0
  9. package/dist/ChooseFilter.js.map +1 -0
  10. package/dist/ChooseHighlightedText.d.ts +9 -0
  11. package/dist/ChooseHighlightedText.d.ts.map +1 -0
  12. package/dist/ChooseHighlightedText.js +30 -0
  13. package/dist/ChooseHighlightedText.js.map +1 -0
  14. package/dist/ChooseList.d.ts +28 -0
  15. package/dist/ChooseList.d.ts.map +1 -0
  16. package/dist/ChooseList.js +51 -0
  17. package/dist/ChooseList.js.map +1 -0
  18. package/dist/ChooseOverlay.d.ts +34 -5
  19. package/dist/ChooseOverlay.d.ts.map +1 -1
  20. package/dist/ChooseOverlay.js +29 -200
  21. package/dist/ChooseOverlay.js.map +1 -1
  22. package/dist/ChoosePanel.d.ts +30 -0
  23. package/dist/ChoosePanel.d.ts.map +1 -0
  24. package/dist/ChoosePanel.js +25 -0
  25. package/dist/ChoosePanel.js.map +1 -0
  26. package/dist/index.d.ts +13 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +6 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/source.d.ts +5 -0
  31. package/dist/source.d.ts.map +1 -0
  32. package/dist/source.js +12 -0
  33. package/dist/source.js.map +1 -0
  34. package/dist/types.d.ts +2 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/use-choose.d.ts +80 -0
  37. package/dist/use-choose.d.ts.map +1 -0
  38. package/dist/use-choose.js +365 -0
  39. package/dist/use-choose.js.map +1 -0
  40. package/package.json +11 -11
  41. package/src/Choose.tsx +68 -355
  42. package/src/ChooseFilter.tsx +45 -0
  43. package/src/ChooseHighlightedText.tsx +42 -0
  44. package/src/ChooseList.tsx +137 -0
  45. package/src/ChooseOverlay.tsx +73 -307
  46. package/src/ChoosePanel.tsx +123 -0
  47. package/src/index.ts +26 -1
  48. package/src/source.ts +12 -0
  49. package/src/types.ts +6 -0
  50. package/src/use-choose.ts +479 -0
package/src/Choose.tsx CHANGED
@@ -1,272 +1,72 @@
1
- import { useState, useEffect, useRef, useCallback, useMemo } from "react"
2
- import type { ScrollBoxRenderable } from "@opentui/core"
3
- import { useKeyboard } from "@opentui/react"
1
+ import { useImperativeHandle, type Ref } from "react"
4
2
  import { AppLayout } from "@tooee/layout"
5
3
  import { useHasOverlay, useHasModalOverlay } from "@tooee/overlays"
6
4
  import { ThemePicker, useTheme } from "@tooee/themes"
7
5
  import { useThemeCommands, useQuitCommand } from "@tooee/shell"
8
- import {
9
- useMode,
10
- useSetMode,
11
- useCommand,
12
- useActions,
13
- useProvideCommandContext,
14
- useCommandContext,
15
- } from "@tooee/commands"
16
- import type { ActionDefinition } from "@tooee/commands"
17
- import type { ChooseItem, ChooseContentProvider, ChooseOptions, ChooseResult } from "./types.js"
18
- import { fuzzyFilter } from "./fuzzy.js"
19
-
20
- declare module "@tooee/commands" {
21
- interface CommandContext {
22
- /** Contributed by Choose: the current selection state. */
23
- choose: {
24
- activeItem: ChooseItem | undefined
25
- selectedItems: ChooseItem[]
26
- filterQuery: string
27
- }
28
- }
29
- }
30
-
31
- interface ChooseProps {
6
+ import { useCommandContext, type ActionDefinition } from "@tooee/commands"
7
+ import { ChooseFilter } from "./ChooseFilter.js"
8
+ import { ChooseList, type ChooseListProps } from "./ChooseList.js"
9
+ import { buildChooseHints } from "./ChoosePanel.js"
10
+ import type { ChooseContentProvider, ChooseOptions, ChooseResult } from "./types.js"
11
+ import { useChoose, type ChooseController } from "./use-choose.js"
12
+
13
+ export interface ChooseProps {
32
14
  contentProvider: ChooseContentProvider
33
15
  options?: ChooseOptions
16
+ /** Legacy name retained for source compatibility. */
34
17
  actions?: ActionDefinition[]
35
- /** @deprecated Use actions instead */
18
+ /** Additive alias used by new chooser compositions. */
19
+ commands?: ActionDefinition[]
20
+ controllerRef?: Ref<ChooseController>
21
+ renderItem?: ChooseListProps["renderItem"]
22
+ /** @deprecated Prefer commands with an id such as `submit`. */
36
23
  onConfirm?: (result: ChooseResult) => void
37
- /** @deprecated Use actions instead */
24
+ /** @deprecated Prefer commands or launch lifecycle handling. */
38
25
  onCancel?: () => void
39
26
  }
40
27
 
41
- export function Choose({ contentProvider, options, actions, onConfirm, onCancel }: ChooseProps) {
28
+ export function Choose({
29
+ contentProvider,
30
+ options,
31
+ actions,
32
+ commands,
33
+ controllerRef,
34
+ renderItem,
35
+ onConfirm,
36
+ onCancel,
37
+ }: ChooseProps) {
42
38
  const { theme } = useTheme()
43
- const [items, setItems] = useState<ChooseItem[]>([])
44
- const [filterQuery, setFilterQuery] = useState("")
45
- const [activeIndex, setActiveIndex] = useState(0)
46
- const [selectedIndices, setSelectedIndices] = useState<Set<number>>(new Set())
47
- const [loading, setLoading] = useState(true)
48
- const [error, setError] = useState<string | null>(null)
49
- const scrollRef = useRef<ScrollBoxRenderable>(null)
50
39
  const { invoke } = useCommandContext()
51
-
40
+ const effectiveCommands = commands ?? actions
52
41
  const multi = options?.multi ?? false
53
42
 
54
- useEffect(() => {
55
- const result = contentProvider.load()
56
- if (result instanceof Promise) {
57
- let active = true
58
- setLoading(true)
59
- setError(null)
60
- result
61
- .then((loaded) => {
62
- // Ignore stale results after contentProvider changed
63
- if (!active) return
64
- setItems(loaded)
65
- setLoading(false)
66
- })
67
- .catch((err: unknown) => {
68
- if (!active) return
69
- setItems([])
70
- setError(err instanceof Error ? err.message : String(err))
71
- setLoading(false)
72
- })
73
- return () => {
74
- active = false
75
- }
76
- }
77
- setItems(result)
78
- setError(null)
79
- setLoading(false)
80
- }, [contentProvider])
81
-
82
- // Derived state: filtered items computed from items + filterQuery (no extra render cycle)
83
- const filteredItems = useMemo(() => fuzzyFilter(items, filterQuery), [items, filterQuery])
84
-
85
- // Reset activeIndex when filter changes (render-time state adjustment)
86
- const [prevFilterQuery, setPrevFilterQuery] = useState("")
87
- if (filterQuery !== prevFilterQuery) {
88
- setPrevFilterQuery(filterQuery)
89
- setActiveIndex(0)
90
- }
91
-
92
43
  const { name: themeName, picker: themePicker } = useThemeCommands()
93
44
  useQuitCommand({ onQuit: () => onCancel?.() })
94
- const mode = useMode()
95
- const setMode = useSetMode()
45
+
96
46
  const hasOverlay = useHasOverlay()
97
47
  const hasModalOverlay = useHasModalOverlay()
98
48
 
99
- // Compute selected items for context
100
- const getSelectedItems = useCallback((): ChooseItem[] => {
101
- if (multi) {
102
- const selected = Array.from(selectedIndices).map((i) => items[i])
103
- if (selected.length === 0 && filteredItems[activeIndex]) {
104
- return [filteredItems[activeIndex].item]
49
+ const choose = useChoose({
50
+ source: contentProvider,
51
+ multi,
52
+ onSubmit: (result) => {
53
+ // Historical standalone behaviour: a command named `submit` wins over
54
+ // the deprecated callback, so existing action-driven CLIs are unchanged.
55
+ if (effectiveCommands?.some((action) => action.id === "submit")) {
56
+ invoke("submit")
57
+ return
105
58
  }
106
- return selected
107
- }
108
- const match = filteredItems[activeIndex]
109
- return match ? [match.item] : []
110
- }, [multi, selectedIndices, items, filteredItems, activeIndex])
111
-
112
- useProvideCommandContext(() => ({
113
- choose: {
114
- activeItem: filteredItems[activeIndex]?.item,
115
- selectedItems: getSelectedItems(),
116
- filterQuery,
59
+ if (multi || result.items.length > 0) onConfirm?.(result)
60
+ else onCancel?.()
117
61
  },
118
- exit: () => onCancel?.(),
119
- }))
120
-
121
- useActions(actions)
122
-
123
- const moveUp = useCallback(() => {
124
- setActiveIndex((i) => Math.max(0, i - 1))
125
- }, [])
126
-
127
- const moveDown = useCallback(() => {
128
- setActiveIndex((i) => Math.min(filteredItems.length - 1, i + 1))
129
- }, [filteredItems.length])
130
-
131
- // Register a/i to return to insert mode from cursor mode
132
- useCommand({
133
- id: "choose:insert-mode-a",
134
- title: "Insert mode",
135
- hotkey: "a",
136
- modes: ["cursor"],
137
- handler: () => setMode("insert"),
138
- hidden: true,
139
- })
140
- useCommand({
141
- id: "choose:insert-mode-i",
142
- title: "Insert mode",
143
- hotkey: "i",
144
- modes: ["cursor"],
145
- handler: () => setMode("insert"),
146
- hidden: true,
147
- })
148
-
149
- // Register j/k for vim-style navigation in cursor mode
150
- useCommand({
151
- id: "choose:move-down",
152
- title: "Move down",
153
- hotkey: "j",
154
- modes: ["cursor"],
155
- handler: moveDown,
156
- hidden: true,
157
- })
158
- useCommand({
159
- id: "choose:move-up",
160
- title: "Move up",
161
- hotkey: "k",
162
- modes: ["cursor"],
163
- handler: moveUp,
164
- hidden: true,
165
- })
166
-
167
- const toggleSelection = useCallback(
168
- (index: number) => {
169
- setSelectedIndices((prev) => {
170
- const next = new Set(prev)
171
- const origIndex = filteredItems[index]?.originalIndex
172
- if (origIndex === undefined) return prev
173
- if (next.has(origIndex)) {
174
- next.delete(origIndex)
175
- } else {
176
- next.add(origIndex)
177
- }
178
- return next
179
- })
180
- },
181
- [filteredItems],
182
- )
183
-
184
- const confirm = useCallback(() => {
185
- // If there's a "submit" action, invoke it via the command system
186
- if (actions?.some((a) => a.id === "submit")) {
187
- invoke("submit")
188
- return
189
- }
190
-
191
- // Legacy: use onConfirm/onCancel callbacks
192
- if (multi) {
193
- const selected = Array.from(selectedIndices).map((i) => items[i])
194
- if (selected.length === 0 && filteredItems[activeIndex]) {
195
- onConfirm?.({ items: [filteredItems[activeIndex].item] })
196
- } else {
197
- onConfirm?.({ items: selected })
198
- }
199
- } else {
200
- const match = filteredItems[activeIndex]
201
- if (match) {
202
- onConfirm?.({ items: [match.item] })
203
- } else {
204
- onCancel?.()
205
- }
206
- }
207
- }, [
208
- multi,
209
- selectedIndices,
210
- items,
211
- filteredItems,
212
- activeIndex,
213
- onConfirm,
214
62
  onCancel,
215
- actions,
216
- invoke,
217
- ])
218
-
219
- useKeyboard((key) => {
220
- // Raw useKeyboard handlers subscribe before the command dispatcher, so
221
- // preventDefault/surface arbitration cannot protect them. While any
222
- // overlay (e.g. the theme picker) is open, its modal command surface owns
223
- // input and this handler must stand down.
224
- if (hasOverlay) return
225
- if (key.name === "escape") {
226
- if (mode === "insert") {
227
- // Switch to cursor mode (allows theme switching, quit, etc.)
228
- setMode("cursor")
229
- } else {
230
- // In cursor mode, escape cancels
231
- onCancel?.()
232
- }
233
- return
234
- }
235
- if (key.name === "return") {
236
- confirm()
237
- return
238
- }
239
- if (key.name === "up" || (key.ctrl && key.name === "p")) {
240
- key.preventDefault()
241
- moveUp()
242
- return
243
- }
244
- if (key.name === "down" || (key.ctrl && key.name === "n")) {
245
- key.preventDefault()
246
- moveDown()
247
- return
248
- }
249
- if (multi && key.name === "tab") {
250
- if (key.shift) {
251
- toggleSelection(activeIndex)
252
- moveUp()
253
- } else {
254
- toggleSelection(activeIndex)
255
- moveDown()
256
- }
257
- return
258
- }
63
+ commands: effectiveCommands,
64
+ suspended: hasOverlay,
259
65
  })
260
66
 
261
- // Auto-scroll to keep active item visible
262
- useEffect(() => {
263
- if (scrollRef.current && filteredItems.length > 0) {
264
- // +1 accounts for the filter input row at the top of scroll content
265
- scrollRef.current.scrollTop = Math.max(0, activeIndex + 1 - 5)
266
- }
267
- }, [activeIndex, filteredItems.length])
67
+ useImperativeHandle(controllerRef, () => choose.controller, [choose.controller])
268
68
 
269
- if (loading) {
69
+ if (choose.state.loading) {
270
70
  return (
271
71
  <box>
272
72
  <text content="Loading..." fg={theme.textMuted} />
@@ -274,21 +74,15 @@ export function Choose({ contentProvider, options, actions, onConfirm, onCancel
274
74
  )
275
75
  }
276
76
 
277
- if (error) {
77
+ if (choose.state.error) {
278
78
  return (
279
79
  <box>
280
- <text content={`Error: ${error}`} fg={theme.error} />
80
+ <text content={`Error: ${choose.state.error}`} fg={theme.error} />
281
81
  </box>
282
82
  )
283
83
  }
284
84
 
285
- const selectedCount = selectedIndices.size
286
- const hintParts =
287
- mode === "insert"
288
- ? ["↑↓ navigate", "Enter confirm", "Esc commands"]
289
- : ["j/k navigate", "i insert", "Esc/q quit", "Enter confirm"]
290
- if (multi && mode === "insert") hintParts.splice(2, 0, "Tab toggle")
291
- const hint = hintParts.join(" ")
85
+ const hints = buildChooseHints(choose.view.mode, { multi })
292
86
 
293
87
  return (
294
88
  <AppLayout
@@ -299,13 +93,22 @@ export function Choose({ contentProvider, options, actions, onConfirm, onCancel
299
93
  }
300
94
  statusBar={{
301
95
  items: [
302
- { label: "Matches:", value: `${filteredItems.length}/${items.length}` },
303
- ...(multi ? [{ label: "Selected:", value: String(selectedCount) }] : []),
96
+ {
97
+ label: "Matches:",
98
+ value: `${choose.state.matches.length}/${choose.state.items.length}`,
99
+ },
100
+ ...(multi
101
+ ? [
102
+ {
103
+ label: "Selected:",
104
+ value: String(choose.state.selectedOriginalIndices.size),
105
+ },
106
+ ]
107
+ : []),
304
108
  { label: "Theme:", value: themeName },
305
- { label: "", value: hint },
109
+ { label: "", value: hints.join(" ") },
306
110
  ],
307
111
  }}
308
- scrollRef={scrollRef}
309
112
  scrollProps={{ focused: false }}
310
113
  overlay={
311
114
  themePicker.isOpen ? (
@@ -319,106 +122,16 @@ export function Choose({ contentProvider, options, actions, onConfirm, onCancel
319
122
  ) : undefined
320
123
  }
321
124
  >
322
- <box flexDirection="column">
323
- {/* Filter input row */}
324
- <box flexDirection="row" height={1} style={{ paddingLeft: 1, paddingRight: 1 }}>
325
- <text content="> " fg={theme.accent} />
326
- <input
327
- focused={mode === "insert"}
328
- placeholder={options?.placeholder ?? "Filter..."}
329
- onInput={setFilterQuery}
330
- backgroundColor="transparent"
331
- textColor={theme.text}
332
- placeholderColor={theme.textMuted}
333
- cursorColor={theme.primary}
334
- style={{ flexGrow: 1 }}
335
- />
336
- <text content={` ${filteredItems.length}/${items.length}`} fg={theme.textMuted} />
337
- </box>
338
-
339
- {/* Empty state message */}
340
- {filteredItems.length === 0 && !loading && options?.emptyMessage && (
341
- <box height={1} style={{ paddingLeft: 2, paddingTop: 1 }}>
342
- <text content={options.emptyMessage} fg={theme.textMuted} />
343
- </box>
344
- )}
345
-
346
- {/* Item list */}
347
- {filteredItems.map((match, idx) => {
348
- const isActive = idx === activeIndex
349
- const isSelected = selectedIndices.has(match.originalIndex)
350
- return (
351
- <box
352
- key={match.originalIndex}
353
- flexDirection="row"
354
- height={1}
355
- backgroundColor={isActive ? theme.backgroundElement : undefined}
356
- style={{ paddingLeft: 1 }}
357
- onMouseDown={(event) => {
358
- // Like the raw keyboard handler above, row clicks must stand
359
- // down while a modal overlay (e.g. the theme picker) is up:
360
- // centered overlays leave clickable margins around them, and
361
- // mouse events route through the hit-grid, bypassing
362
- // command-surface arbitration entirely.
363
- if (hasModalOverlay) return
364
- if (event.button === 0) setActiveIndex(idx)
365
- }}
366
- >
367
- {multi && (
368
- <text
369
- content={isSelected ? "✓ " : " "}
370
- fg={isSelected ? theme.accent : theme.textMuted}
371
- />
372
- )}
373
- {match.item.icon && <text content={`${match.item.icon} `} fg={theme.textMuted} />}
374
- <text fg={isActive ? theme.primary : theme.text}>
375
- {renderHighlightedText(match.item.text, match.positions, theme.warning)}
376
- </text>
377
- {match.item.description && (
378
- <text content={` ${match.item.description}`} fg={theme.textMuted} />
379
- )}
380
- </box>
381
- )
382
- })}
125
+ <box flexDirection="column" style={{ flexGrow: 1 }}>
126
+ <ChooseFilter choose={choose} placeholder={options?.placeholder} />
127
+ <ChooseList
128
+ choose={choose}
129
+ rowClick="activate"
130
+ renderItem={renderItem}
131
+ emptyContent={options?.emptyMessage}
132
+ suspended={hasModalOverlay}
133
+ />
383
134
  </box>
384
135
  </AppLayout>
385
136
  )
386
137
  }
387
-
388
- function renderHighlightedText(text: string, positions: number[], highlightColor: string) {
389
- if (positions.length === 0) {
390
- return text
391
- }
392
-
393
- const posSet = new Set(positions)
394
- const parts: Array<{ text: string; highlight: boolean }> = []
395
- let current = ""
396
- let currentHighlight = false
397
-
398
- for (let i = 0; i < text.length; i++) {
399
- const isHighlight = posSet.has(i)
400
- if (i === 0) {
401
- currentHighlight = isHighlight
402
- current = text[i]
403
- } else if (isHighlight === currentHighlight) {
404
- current += text[i]
405
- } else {
406
- parts.push({ text: current, highlight: currentHighlight })
407
- current = text[i]
408
- currentHighlight = isHighlight
409
- }
410
- }
411
- if (current) {
412
- parts.push({ text: current, highlight: currentHighlight })
413
- }
414
-
415
- return parts.map((part, i) =>
416
- part.highlight ? (
417
- <span key={i} fg={highlightColor}>
418
- {part.text}
419
- </span>
420
- ) : (
421
- part.text
422
- ),
423
- )
424
- }
@@ -0,0 +1,45 @@
1
+ import type { ReactNode } from "react"
2
+ import { CloseButton, useTheme } from "@tooee/themes"
3
+ import type { UseChooseResult } from "./use-choose.js"
4
+
5
+ export interface ChooseFilterProps {
6
+ choose: UseChooseResult
7
+ prompt?: ReactNode
8
+ placeholder?: string
9
+ right?: ReactNode
10
+ onClose?: () => void
11
+ }
12
+
13
+ /** Controlled filter row shared by fullscreen and overlay chooser assemblies. */
14
+ export function ChooseFilter({
15
+ choose,
16
+ prompt = "> ",
17
+ placeholder = "Filter...",
18
+ right,
19
+ onClose,
20
+ }: ChooseFilterProps) {
21
+ const { theme } = useTheme()
22
+ const { state, view } = choose
23
+
24
+ return (
25
+ <box flexDirection="row" height={1} style={{ paddingLeft: 1, paddingRight: 1 }}>
26
+ {typeof prompt === "string" ? <text content={prompt} fg={theme.accent} /> : prompt}
27
+ <input
28
+ ref={view.filterRef}
29
+ focused={view.filterFocused}
30
+ value={state.filterQuery}
31
+ placeholder={placeholder}
32
+ onInput={view.onFilterInput}
33
+ onKeyDown={view.onFilterKeyDown}
34
+ backgroundColor="transparent"
35
+ textColor={theme.text}
36
+ placeholderColor={theme.textMuted}
37
+ cursorColor={theme.primary}
38
+ style={{ flexGrow: 1 }}
39
+ />
40
+ <text content={` ${state.matches.length}/${state.items.length}`} fg={theme.textMuted} />
41
+ {right}
42
+ {onClose && <CloseButton onClose={onClose} />}
43
+ </box>
44
+ )
45
+ }
@@ -0,0 +1,42 @@
1
+ import { Fragment, type ReactNode } from "react"
2
+
3
+ export interface ChooseHighlightedTextProps {
4
+ text: string
5
+ positions: number[]
6
+ highlightColor: string
7
+ }
8
+
9
+ /** Render fuzzy-match characters with one shared segmentation implementation. */
10
+ export function ChooseHighlightedText({
11
+ text,
12
+ positions,
13
+ highlightColor,
14
+ }: ChooseHighlightedTextProps): ReactNode {
15
+ if (positions.length === 0) return text
16
+
17
+ const positionSet = new Set(positions)
18
+ const parts: Array<{ text: string; highlighted: boolean }> = []
19
+ let current = ""
20
+ let currentHighlighted = false
21
+
22
+ for (let index = 0; index < text.length; index++) {
23
+ const highlighted = positionSet.has(index)
24
+ if (index === 0) {
25
+ current = text[index]
26
+ currentHighlighted = highlighted
27
+ } else if (highlighted === currentHighlighted) {
28
+ current += text[index]
29
+ } else {
30
+ parts.push({ text: current, highlighted: currentHighlighted })
31
+ current = text[index]
32
+ currentHighlighted = highlighted
33
+ }
34
+ }
35
+ if (current) parts.push({ text: current, highlighted: currentHighlighted })
36
+
37
+ return parts.map((part, index) => (
38
+ <Fragment key={index}>
39
+ {part.highlighted ? <span fg={highlightColor}>{part.text}</span> : part.text}
40
+ </Fragment>
41
+ ))
42
+ }