@tooee/shell 0.1.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.
Files changed (57) hide show
  1. package/README.md +5 -0
  2. package/dist/CommandPaletteOverlay.d.ts +7 -0
  3. package/dist/CommandPaletteOverlay.d.ts.map +1 -0
  4. package/dist/CommandPaletteOverlay.js +29 -0
  5. package/dist/CommandPaletteOverlay.js.map +1 -0
  6. package/dist/ThemePickerOverlay.d.ts +6 -0
  7. package/dist/ThemePickerOverlay.d.ts.map +1 -0
  8. package/dist/ThemePickerOverlay.js +20 -0
  9. package/dist/ThemePickerOverlay.js.map +1 -0
  10. package/dist/command-palette.d.ts +9 -0
  11. package/dist/command-palette.d.ts.map +1 -0
  12. package/dist/command-palette.js +51 -0
  13. package/dist/command-palette.js.map +1 -0
  14. package/dist/commands.d.ts +18 -0
  15. package/dist/commands.d.ts.map +1 -0
  16. package/dist/commands.js +49 -0
  17. package/dist/commands.js.map +1 -0
  18. package/dist/index.d.ts +12 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +9 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/launch.d.ts +6 -0
  23. package/dist/launch.d.ts.map +1 -0
  24. package/dist/launch.js +12 -0
  25. package/dist/launch.js.map +1 -0
  26. package/dist/modal.d.ts +34 -0
  27. package/dist/modal.d.ts.map +1 -0
  28. package/dist/modal.js +440 -0
  29. package/dist/modal.js.map +1 -0
  30. package/dist/overlay.d.ts +11 -0
  31. package/dist/overlay.d.ts.map +1 -0
  32. package/dist/overlay.js +145 -0
  33. package/dist/overlay.js.map +1 -0
  34. package/dist/provider.d.ts +11 -0
  35. package/dist/provider.d.ts.map +1 -0
  36. package/dist/provider.js +13 -0
  37. package/dist/provider.js.map +1 -0
  38. package/dist/search.d.ts +2 -0
  39. package/dist/search.d.ts.map +1 -0
  40. package/dist/search.js +20 -0
  41. package/dist/search.js.map +1 -0
  42. package/dist/theme-picker.d.ts +16 -0
  43. package/dist/theme-picker.d.ts.map +1 -0
  44. package/dist/theme-picker.js +48 -0
  45. package/dist/theme-picker.js.map +1 -0
  46. package/package.json +49 -0
  47. package/src/CommandPaletteOverlay.tsx +44 -0
  48. package/src/ThemePickerOverlay.tsx +28 -0
  49. package/src/command-palette.ts +73 -0
  50. package/src/commands.ts +60 -0
  51. package/src/index.ts +11 -0
  52. package/src/launch.tsx +15 -0
  53. package/src/modal.ts +504 -0
  54. package/src/overlay.tsx +220 -0
  55. package/src/provider.tsx +46 -0
  56. package/src/search.ts +18 -0
  57. package/src/theme-picker.ts +84 -0
package/src/modal.ts ADDED
@@ -0,0 +1,504 @@
1
+ import { useState, useCallback, useRef, useEffect } from "react"
2
+ import { useCommand, useMode, useSetMode, type Mode } from "@tooee/commands"
3
+ import { copyToClipboard } from "@tooee/clipboard"
4
+ import { useTerminalDimensions } from "@opentui/react"
5
+ import { findMatchingLines } from "./search.js"
6
+
7
+ export interface Position {
8
+ line: number
9
+ col: number
10
+ }
11
+
12
+ export interface ModalNavigationState {
13
+ mode: Mode
14
+ setMode: (mode: Mode) => void
15
+ scrollOffset: number
16
+ cursor: Position | null
17
+ selection: { start: Position; end: Position } | null
18
+ toggledIndices: Set<number>
19
+ searchQuery: string
20
+ searchActive: boolean
21
+ setSearchQuery: (query: string) => void
22
+ matchingLines: number[]
23
+ currentMatchIndex: number
24
+ submitSearch: () => void
25
+ }
26
+
27
+ export interface ModalNavigationOptions {
28
+ totalLines: number
29
+ viewportHeight?: number
30
+ getText?: () => string | undefined
31
+ blockCount?: number
32
+ blockLineMap?: number[]
33
+ /** Offset to subtract from search line numbers to get block indices (for when getText has different line structure than visual) */
34
+ searchLineOffset?: number
35
+ multiSelect?: boolean
36
+ }
37
+
38
+ export function useModalNavigationCommands(opts: ModalNavigationOptions): ModalNavigationState {
39
+ const { height: terminalHeight } = useTerminalDimensions()
40
+ const {
41
+ totalLines,
42
+ viewportHeight = terminalHeight - 2,
43
+ getText,
44
+ blockCount,
45
+ blockLineMap,
46
+ searchLineOffset = 0,
47
+ multiSelect = false,
48
+ } = opts
49
+ const mode = useMode()
50
+ const setMode = useSetMode()
51
+
52
+ const [scrollOffset, setScrollOffset] = useState(0)
53
+ const [cursor, setCursor] = useState<Position | null>(null)
54
+ const [selectionAnchor, setSelectionAnchor] = useState<Position | null>(null)
55
+ const [toggledIndices, setToggledIndices] = useState<Set<number>>(new Set())
56
+ const [searchQuery, setSearchQuery] = useState("")
57
+ const [searchActive, setSearchActive] = useState(false)
58
+ const [matchingLines, setMatchingLines] = useState<number[]>([])
59
+ const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
60
+ const preSearchModeRef = useRef<Mode>("cursor")
61
+
62
+ // Incremental search: recompute matches when query changes while search is active
63
+ const searchQueryRef = useRef(searchQuery)
64
+ searchQueryRef.current = searchQuery
65
+ const matchingLinesRef = useRef(matchingLines)
66
+ matchingLinesRef.current = matchingLines
67
+
68
+ useEffect(() => {
69
+ if (!searchActive) return
70
+ const text = getText?.()
71
+ if (!text || !searchQuery) {
72
+ setMatchingLines([])
73
+ setCurrentMatchIndex(0)
74
+ return
75
+ }
76
+ const matches = findMatchingLines(text, searchQuery)
77
+ setMatchingLines(matches)
78
+ setCurrentMatchIndex(0)
79
+ if (matches.length > 0) {
80
+ // Auto-jump to first match
81
+ const line = matches[0]
82
+ setScrollOffset((offset) => {
83
+ if (line < offset || line >= offset + viewportHeight) {
84
+ return Math.max(0, Math.min(line, Math.max(0, totalLines - viewportHeight)))
85
+ }
86
+ return offset
87
+ })
88
+ if (mode === "cursor" || mode === "select") {
89
+ setCursor((c) => (c ? { line, col: 0 } : c))
90
+ }
91
+ }
92
+ }, [searchQuery, searchActive]) // eslint-disable-line react-hooks/exhaustive-deps
93
+
94
+ const isBlockMode = blockCount != null
95
+ const cursorMax = isBlockMode ? Math.max(0, blockCount - 1) : Math.max(0, totalLines - 1)
96
+ const maxScroll = Math.max(0, totalLines - viewportHeight)
97
+ const maxLine = Math.max(0, totalLines - 1)
98
+
99
+ const clampScroll = useCallback(
100
+ (value: number) => Math.max(0, Math.min(value, maxScroll)),
101
+ [maxScroll],
102
+ )
103
+
104
+ const _clampLine = useCallback(
105
+ (value: number) => Math.max(0, Math.min(value, maxLine)),
106
+ [maxLine],
107
+ )
108
+
109
+ const clampCursor = useCallback(
110
+ (value: number) => Math.max(0, Math.min(value, cursorMax)),
111
+ [cursorMax],
112
+ )
113
+
114
+ useEffect(() => {
115
+ setToggledIndices((prev) => {
116
+ const filtered = Array.from(prev).filter((index) => index <= cursorMax)
117
+ if (filtered.length === prev.size) return prev
118
+ return new Set(filtered)
119
+ })
120
+ }, [cursorMax])
121
+
122
+ // When entering cursor mode, initialize cursor
123
+ const prevMode = useRef<Mode | null>(null)
124
+ useEffect(() => {
125
+ if (mode === "cursor" && prevMode.current !== "cursor" && prevMode.current !== "select") {
126
+ // If there's an active search match, start cursor there; otherwise start at scroll position
127
+ const matches = matchingLinesRef.current
128
+ if (matches.length > 0) {
129
+ const matchLine = matches[currentMatchIndex] ?? matches[0]
130
+ // In block mode, convert search line to block index using offset
131
+ if (isBlockMode) {
132
+ const blockIndex = Math.max(0, matchLine - searchLineOffset)
133
+ setCursor({ line: Math.min(blockIndex, cursorMax), col: 0 })
134
+ } else {
135
+ setCursor({ line: matchLine, col: 0 })
136
+ }
137
+ } else {
138
+ // No search match - start at top (block 0) or scroll position
139
+ setCursor({ line: isBlockMode ? 0 : scrollOffset, col: 0 })
140
+ }
141
+ }
142
+ if (mode === "select" && prevMode.current === "cursor" && cursor) {
143
+ setSelectionAnchor({ ...cursor })
144
+ }
145
+ prevMode.current = mode
146
+ }, [mode]) // eslint-disable-line react-hooks/exhaustive-deps
147
+
148
+ // Scroll to keep cursor visible
149
+ const scrollToCursor = useCallback(
150
+ (cursorIndex: number) => {
151
+ const line = isBlockMode && blockLineMap ? (blockLineMap[cursorIndex] ?? 0) : cursorIndex
152
+ setScrollOffset((offset) => {
153
+ if (line < offset) return line
154
+ if (line >= offset + viewportHeight) return clampScroll(line - viewportHeight + 1)
155
+ return offset
156
+ })
157
+ },
158
+ [viewportHeight, clampScroll, isBlockMode, blockLineMap],
159
+ )
160
+
161
+ // === CURSOR MODE ===
162
+
163
+ useCommand({
164
+ id: "cursor-down",
165
+ title: "Cursor down",
166
+ hotkey: "j",
167
+ modes: ["cursor"],
168
+ handler: () => {
169
+ setCursor((c) => {
170
+ if (!c) return c
171
+ const next = clampCursor(c.line + 1)
172
+ scrollToCursor(next)
173
+ return { line: next, col: 0 }
174
+ })
175
+ },
176
+ })
177
+
178
+ useCommand({
179
+ id: "cursor-toggle",
180
+ title: "Toggle selection",
181
+ hotkey: "tab",
182
+ modes: ["cursor"],
183
+ when: () => multiSelect,
184
+ handler: () => {
185
+ setToggledIndices((prev) => {
186
+ if (!cursor) return prev
187
+ const next = new Set(prev)
188
+ const idx = cursor.line
189
+ if (next.has(idx)) {
190
+ next.delete(idx)
191
+ } else {
192
+ next.add(idx)
193
+ }
194
+ return next
195
+ })
196
+ },
197
+ })
198
+
199
+ useCommand({
200
+ id: "cursor-toggle-up",
201
+ title: "Toggle and move up",
202
+ hotkey: "shift+tab",
203
+ modes: ["cursor"],
204
+ when: () => multiSelect,
205
+ handler: () => {
206
+ setToggledIndices((prev) => {
207
+ if (!cursor) return prev
208
+ const next = new Set(prev)
209
+ const idx = cursor.line
210
+ if (next.has(idx)) {
211
+ next.delete(idx)
212
+ } else {
213
+ next.add(idx)
214
+ }
215
+ return next
216
+ })
217
+ setCursor((c) => {
218
+ if (!c) return c
219
+ const next = clampCursor(c.line - 1)
220
+ scrollToCursor(next)
221
+ return { line: next, col: 0 }
222
+ })
223
+ },
224
+ })
225
+
226
+ useCommand({
227
+ id: "cursor-up",
228
+ title: "Cursor up",
229
+ hotkey: "k",
230
+ modes: ["cursor"],
231
+ handler: () => {
232
+ setCursor((c) => {
233
+ if (!c) return c
234
+ const next = clampCursor(c.line - 1)
235
+ scrollToCursor(next)
236
+ return { line: next, col: 0 }
237
+ })
238
+ },
239
+ })
240
+
241
+ useCommand({
242
+ id: "cursor-half-down",
243
+ title: "Cursor half page down",
244
+ hotkey: "ctrl+d",
245
+ modes: ["cursor"],
246
+ handler: () => {
247
+ setCursor((c) => {
248
+ if (!c) return c
249
+ const step = isBlockMode ? Math.floor(cursorMax / 4) || 1 : Math.floor(viewportHeight / 2)
250
+ const next = clampCursor(c.line + step)
251
+ scrollToCursor(next)
252
+ return { line: next, col: 0 }
253
+ })
254
+ },
255
+ })
256
+
257
+ useCommand({
258
+ id: "cursor-half-up",
259
+ title: "Cursor half page up",
260
+ hotkey: "ctrl+u",
261
+ modes: ["cursor"],
262
+ handler: () => {
263
+ setCursor((c) => {
264
+ if (!c) return c
265
+ const step = isBlockMode ? Math.floor(cursorMax / 4) || 1 : Math.floor(viewportHeight / 2)
266
+ const next = clampCursor(c.line - step)
267
+ scrollToCursor(next)
268
+ return { line: next, col: 0 }
269
+ })
270
+ },
271
+ })
272
+
273
+ useCommand({
274
+ id: "cursor-top",
275
+ title: "Cursor to top",
276
+ hotkey: "g g",
277
+ modes: ["cursor"],
278
+ handler: () => {
279
+ setCursor({ line: 0, col: 0 })
280
+ setScrollOffset(0)
281
+ },
282
+ })
283
+
284
+ useCommand({
285
+ id: "cursor-bottom",
286
+ title: "Cursor to bottom",
287
+ hotkey: "shift+g",
288
+ modes: ["cursor"],
289
+ handler: () => {
290
+ setCursor({ line: cursorMax, col: 0 })
291
+ if (isBlockMode && blockLineMap) {
292
+ const line = blockLineMap[cursorMax] ?? 0
293
+ setScrollOffset(clampScroll(line))
294
+ } else {
295
+ setScrollOffset(maxScroll)
296
+ }
297
+ },
298
+ })
299
+
300
+ useCommand({
301
+ id: "cursor-search-start",
302
+ title: "Search",
303
+ hotkey: "/",
304
+ modes: ["cursor"],
305
+ handler: () => {
306
+ preSearchModeRef.current = mode
307
+ setSearchActive(true)
308
+ setSearchQuery("")
309
+ setMode("insert")
310
+ },
311
+ })
312
+
313
+ useCommand({
314
+ id: "cursor-search-next",
315
+ title: "Next match",
316
+ hotkey: "n",
317
+ modes: ["cursor"],
318
+ when: () => !searchActive,
319
+ handler: () => {
320
+ const matches = matchingLinesRef.current
321
+ if (matches.length === 0) return
322
+ setCurrentMatchIndex((idx) => {
323
+ const next = (idx + 1) % matches.length
324
+ const line = matches[next]
325
+ setCursor({ line, col: 0 })
326
+ scrollToCursor(line)
327
+ return next
328
+ })
329
+ },
330
+ })
331
+
332
+ useCommand({
333
+ id: "cursor-search-prev",
334
+ title: "Previous match",
335
+ hotkey: "shift+n",
336
+ modes: ["cursor"],
337
+ when: () => !searchActive,
338
+ handler: () => {
339
+ const matches = matchingLinesRef.current
340
+ if (matches.length === 0) return
341
+ setCurrentMatchIndex((idx) => {
342
+ const next = (idx - 1 + matches.length) % matches.length
343
+ const line = matches[next]
344
+ setCursor({ line, col: 0 })
345
+ scrollToCursor(line)
346
+ return next
347
+ })
348
+ },
349
+ })
350
+
351
+ // === SELECT MODE ===
352
+
353
+ useCommand({
354
+ id: "select-down",
355
+ title: "Extend selection down",
356
+ hotkey: "j",
357
+ modes: ["select"],
358
+ handler: () => {
359
+ setCursor((c) => {
360
+ if (!c) return c
361
+ const next = clampCursor(c.line + 1)
362
+ scrollToCursor(next)
363
+ return { line: next, col: 0 }
364
+ })
365
+ },
366
+ })
367
+
368
+ useCommand({
369
+ id: "select-up",
370
+ title: "Extend selection up",
371
+ hotkey: "k",
372
+ modes: ["select"],
373
+ handler: () => {
374
+ setCursor((c) => {
375
+ if (!c) return c
376
+ const next = clampCursor(c.line - 1)
377
+ scrollToCursor(next)
378
+ return { line: next, col: 0 }
379
+ })
380
+ },
381
+ })
382
+
383
+ useCommand({
384
+ id: "select-toggle",
385
+ title: "Toggle selection",
386
+ hotkey: "tab",
387
+ modes: ["select"],
388
+ when: () => multiSelect,
389
+ handler: () => {
390
+ setToggledIndices((prev) => {
391
+ if (!cursor) return prev
392
+ const next = new Set(prev)
393
+ const idx = cursor.line
394
+ if (next.has(idx)) {
395
+ next.delete(idx)
396
+ } else {
397
+ next.add(idx)
398
+ }
399
+ return next
400
+ })
401
+ },
402
+ })
403
+
404
+ useCommand({
405
+ id: "select-copy",
406
+ title: "Copy selection",
407
+ hotkey: "y",
408
+ modes: ["select"],
409
+ handler: () => {
410
+ if (!getText || !selectionAnchor || !cursor) return
411
+ const text = getText()
412
+ if (!text) return
413
+
414
+ if (isBlockMode && blockLineMap) {
415
+ // Block-based copy: use blockLineMap to find line ranges
416
+ const startBlock = Math.min(selectionAnchor.line, cursor.line)
417
+ const endBlock = Math.max(selectionAnchor.line, cursor.line)
418
+ const startLine = blockLineMap[startBlock] ?? 0
419
+ const endLine =
420
+ endBlock + 1 < blockLineMap.length
421
+ ? (blockLineMap[endBlock + 1] ?? text.split("\n").length)
422
+ : text.split("\n").length
423
+ const lines = text.split("\n")
424
+ const selected = lines.slice(startLine, endLine).join("\n")
425
+ if (selected) {
426
+ void copyToClipboard(selected)
427
+ }
428
+ } else {
429
+ const lines = text.split("\n")
430
+ const startLine = Math.min(selectionAnchor.line, cursor.line)
431
+ const endLine = Math.max(selectionAnchor.line, cursor.line)
432
+ const selected = lines.slice(startLine, endLine + 1).join("\n")
433
+ if (selected) {
434
+ void copyToClipboard(selected)
435
+ }
436
+ }
437
+ setMode("cursor")
438
+ },
439
+ })
440
+
441
+ useCommand({
442
+ id: "select-cancel",
443
+ title: "Cancel selection",
444
+ hotkey: "escape",
445
+ modes: ["select"],
446
+ handler: () => setMode("cursor"),
447
+ })
448
+
449
+ // === MODE TRANSITIONS ===
450
+
451
+ useCommand({
452
+ id: "enter-select",
453
+ title: "Enter select mode",
454
+ hotkey: "v",
455
+ modes: ["cursor"],
456
+ handler: () => setMode("select"),
457
+ })
458
+
459
+ // === SEARCH CANCEL (any mode) ===
460
+
461
+ useCommand({
462
+ id: "search-cancel",
463
+ title: "Cancel search",
464
+ hotkey: "escape",
465
+ modes: ["cursor", "select", "insert"],
466
+ when: () => searchActive,
467
+ handler: () => {
468
+ setSearchActive(false)
469
+ setSearchQuery("")
470
+ setMatchingLines([])
471
+ setCurrentMatchIndex(0)
472
+ setMode(preSearchModeRef.current)
473
+ },
474
+ })
475
+
476
+ // Compute selection from anchor + cursor
477
+ const selection =
478
+ selectionAnchor && cursor && mode === "select"
479
+ ? {
480
+ start: selectionAnchor.line <= cursor.line ? selectionAnchor : cursor,
481
+ end: selectionAnchor.line <= cursor.line ? cursor : selectionAnchor,
482
+ }
483
+ : null
484
+
485
+ const submitSearch = useCallback(() => {
486
+ setSearchActive(false)
487
+ setMode(preSearchModeRef.current)
488
+ }, [setMode])
489
+
490
+ return {
491
+ mode,
492
+ setMode,
493
+ scrollOffset,
494
+ cursor,
495
+ selection,
496
+ toggledIndices,
497
+ searchQuery,
498
+ searchActive,
499
+ setSearchQuery,
500
+ matchingLines,
501
+ currentMatchIndex,
502
+ submitSearch,
503
+ }
504
+ }
@@ -0,0 +1,220 @@
1
+ import { useState, useCallback, useMemo, useRef } from "react"
2
+ import type { ReactNode } from "react"
3
+ import {
4
+ OverlayControllerContext,
5
+ OverlayStateContext,
6
+ type OverlayId,
7
+ type OverlayCloseReason,
8
+ type OverlayOpenOptions,
9
+ type OverlayRenderer,
10
+ type OverlayHandle,
11
+ type OverlayController,
12
+ } from "@tooee/overlays"
13
+ import { useMode, useSetMode, useProvideCommandContext, useCommand } from "@tooee/commands"
14
+
15
+ declare module "@tooee/commands" {
16
+ interface CommandContext {
17
+ overlay: OverlayController
18
+ }
19
+ }
20
+
21
+ interface OverlayEntry {
22
+ id: OverlayId
23
+ render: OverlayRenderer<any>
24
+ payload: any
25
+ options: OverlayOpenOptions
26
+ prevMode: string
27
+ }
28
+
29
+ export function OverlayProvider({ children }: { children: ReactNode }) {
30
+ const [stack, setStack] = useState<OverlayEntry[]>([])
31
+ const stackRef = useRef(stack)
32
+ stackRef.current = stack
33
+
34
+ const mode = useMode()
35
+ const setMode = useSetMode()
36
+ const modeRef = useRef(mode)
37
+ modeRef.current = mode
38
+
39
+ const removeEntry = useCallback(
40
+ (id: OverlayId, reason: OverlayCloseReason) => {
41
+ const current = stackRef.current
42
+ const idx = current.findIndex((e) => e.id === id)
43
+ if (idx === -1) return
44
+
45
+ const entry = current[idx]
46
+ entry.options.onClose?.(reason)
47
+
48
+ setStack((prev) => {
49
+ const i = prev.findIndex((e) => e.id === id)
50
+ if (i === -1) return prev
51
+ const next = [...prev]
52
+ next.splice(i, 1)
53
+ return next
54
+ })
55
+
56
+ if (entry.options.restoreMode !== false) {
57
+ setMode(entry.prevMode as any)
58
+ }
59
+ },
60
+ [setMode],
61
+ )
62
+
63
+ const open = useCallback(
64
+ <TPayload,>(
65
+ id: OverlayId,
66
+ render: OverlayRenderer<TPayload>,
67
+ payload: TPayload,
68
+ options: OverlayOpenOptions = {},
69
+ ): OverlayHandle<TPayload> => {
70
+ const prevMode = modeRef.current
71
+ const overlayMode = options.mode === undefined ? "insert" : options.mode
72
+
73
+ setStack((prev) => {
74
+ // Remove existing entry with same id if present
75
+ const filtered = prev.filter((e) => e.id !== id)
76
+ const entry: OverlayEntry = {
77
+ id,
78
+ render: render as OverlayRenderer<any>,
79
+ payload,
80
+ options,
81
+ prevMode,
82
+ }
83
+ return [...filtered, entry]
84
+ })
85
+
86
+ if (overlayMode !== null) {
87
+ setMode(overlayMode as any)
88
+ }
89
+
90
+ const handle: OverlayHandle<TPayload> = {
91
+ id,
92
+ close: (reason: OverlayCloseReason = "close") => removeEntry(id, reason),
93
+ update: (next: TPayload | ((prev: TPayload) => TPayload)) => {
94
+ setStack((prev) => {
95
+ const idx = prev.findIndex((e) => e.id === id)
96
+ if (idx === -1) return prev
97
+ const entry = prev[idx]
98
+ const newPayload =
99
+ typeof next === "function" ? (next as (p: TPayload) => TPayload)(entry.payload) : next
100
+ const updated = [...prev]
101
+ updated[idx] = { ...entry, payload: newPayload }
102
+ return updated
103
+ })
104
+ },
105
+ }
106
+
107
+ return handle
108
+ },
109
+ [setMode, removeEntry],
110
+ )
111
+
112
+ const update = useCallback(
113
+ <TPayload,>(id: OverlayId, next: TPayload | ((prev: TPayload) => TPayload)) => {
114
+ setStack((prev) => {
115
+ const idx = prev.findIndex((e) => e.id === id)
116
+ if (idx === -1) return prev
117
+ const entry = prev[idx]
118
+ const newPayload =
119
+ typeof next === "function" ? (next as (p: TPayload) => TPayload)(entry.payload) : next
120
+ const updated = [...prev]
121
+ updated[idx] = { ...entry, payload: newPayload }
122
+ return updated
123
+ })
124
+ },
125
+ [],
126
+ )
127
+
128
+ const show = useCallback(
129
+ (id: OverlayId, content: ReactNode, options?: OverlayOpenOptions) => {
130
+ // Back-compat: show() defaults to no mode change (unlike open() which defaults to "insert")
131
+ open(id, () => content, undefined, { mode: null, ...options })
132
+ },
133
+ [open],
134
+ )
135
+
136
+ const hide = useCallback(
137
+ (id: OverlayId) => {
138
+ removeEntry(id, "close")
139
+ },
140
+ [removeEntry],
141
+ )
142
+
143
+ const closeTop = useCallback(
144
+ (reason: OverlayCloseReason = "close") => {
145
+ const current = stackRef.current
146
+ if (current.length === 0) return
147
+ const top = current[current.length - 1]
148
+ removeEntry(top.id, reason)
149
+ },
150
+ [removeEntry],
151
+ )
152
+
153
+ const isOpen = useCallback((id: OverlayId) => {
154
+ return stackRef.current.some((e) => e.id === id)
155
+ }, [])
156
+
157
+ const topId = stack.length > 0 ? stack[stack.length - 1].id : null
158
+
159
+ const controller = useMemo<OverlayController>(
160
+ () => ({
161
+ open,
162
+ update,
163
+ show,
164
+ hide,
165
+ closeTop,
166
+ isOpen,
167
+ topId,
168
+ }),
169
+ [open, update, show, hide, closeTop, isOpen, topId],
170
+ )
171
+
172
+ useProvideCommandContext(() => ({
173
+ overlay: {
174
+ open: controller.open,
175
+ show: controller.show,
176
+ hide: controller.hide,
177
+ update: controller.update,
178
+ closeTop: controller.closeTop,
179
+ isOpen: controller.isOpen,
180
+ topId: controller.topId,
181
+ },
182
+ }))
183
+
184
+ useCommand({
185
+ id: "overlay.close-top",
186
+ title: "Close overlay",
187
+ hotkey: "Escape",
188
+ modes: ["insert"],
189
+ hidden: true,
190
+ when: () => topId !== null,
191
+ handler: () => closeTop("escape"),
192
+ })
193
+
194
+ // Render the topmost overlay
195
+ const topEntry = stack.length > 0 ? stack[stack.length - 1] : null
196
+ const current = topEntry
197
+ ? topEntry.render({
198
+ id: topEntry.id,
199
+ payload: topEntry.payload,
200
+ isTop: true,
201
+ close: (reason: OverlayCloseReason = "close") => removeEntry(topEntry.id, reason),
202
+ update: (next: any) => update(topEntry.id, next),
203
+ })
204
+ : null
205
+
206
+ const state = useMemo(
207
+ () => ({
208
+ current,
209
+ hasOverlay: stack.length > 0,
210
+ stack: stack.map((e) => e.id),
211
+ }),
212
+ [current, stack],
213
+ )
214
+
215
+ return (
216
+ <OverlayControllerContext value={controller}>
217
+ <OverlayStateContext value={state}>{children}</OverlayStateContext>
218
+ </OverlayControllerContext>
219
+ )
220
+ }