termcast 1.3.30 → 1.3.32

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 (294) hide show
  1. package/dist/apis/cache.d.ts.map +1 -1
  2. package/dist/apis/cache.js +4 -39
  3. package/dist/apis/cache.js.map +1 -1
  4. package/dist/apis/hud.d.ts.map +1 -1
  5. package/dist/apis/hud.js +13 -31
  6. package/dist/apis/hud.js.map +1 -1
  7. package/dist/apis/localstorage.d.ts.map +1 -1
  8. package/dist/apis/localstorage.js +3 -27
  9. package/dist/apis/localstorage.js.map +1 -1
  10. package/dist/apis/toast.d.ts +16 -43
  11. package/dist/apis/toast.d.ts.map +1 -1
  12. package/dist/apis/toast.js +78 -177
  13. package/dist/apis/toast.js.map +1 -1
  14. package/dist/build.d.ts +3 -1
  15. package/dist/build.d.ts.map +1 -1
  16. package/dist/build.js +52 -2
  17. package/dist/build.js.map +1 -1
  18. package/dist/cli.d.ts +1 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +206 -25
  21. package/dist/cli.js.map +1 -1
  22. package/dist/colors.d.ts.map +1 -1
  23. package/dist/colors.js +1 -0
  24. package/dist/colors.js.map +1 -1
  25. package/dist/compile.d.ts +0 -1
  26. package/dist/compile.d.ts.map +1 -1
  27. package/dist/compile.js +18 -23
  28. package/dist/compile.js.map +1 -1
  29. package/dist/components/actions.d.ts.map +1 -1
  30. package/dist/components/actions.js +30 -15
  31. package/dist/components/actions.js.map +1 -1
  32. package/dist/components/animation-tick.d.ts +12 -0
  33. package/dist/components/animation-tick.d.ts.map +1 -0
  34. package/dist/components/animation-tick.js +63 -0
  35. package/dist/components/animation-tick.js.map +1 -0
  36. package/dist/components/detail.d.ts.map +1 -1
  37. package/dist/components/detail.js +10 -13
  38. package/dist/components/detail.js.map +1 -1
  39. package/dist/components/dropdown.d.ts +1 -0
  40. package/dist/components/dropdown.d.ts.map +1 -1
  41. package/dist/components/dropdown.js +27 -26
  42. package/dist/components/dropdown.js.map +1 -1
  43. package/dist/components/extension-preferences.d.ts.map +1 -1
  44. package/dist/components/extension-preferences.js +15 -10
  45. package/dist/components/extension-preferences.js.map +1 -1
  46. package/dist/components/footer.d.ts +13 -0
  47. package/dist/components/footer.d.ts.map +1 -0
  48. package/dist/components/footer.js +106 -0
  49. package/dist/components/footer.js.map +1 -0
  50. package/dist/components/form/file-autocomplete.d.ts +19 -4
  51. package/dist/components/form/file-autocomplete.d.ts.map +1 -1
  52. package/dist/components/form/file-autocomplete.js +56 -55
  53. package/dist/components/form/file-autocomplete.js.map +1 -1
  54. package/dist/components/form/file-picker.d.ts.map +1 -1
  55. package/dist/components/form/file-picker.js +26 -15
  56. package/dist/components/form/file-picker.js.map +1 -1
  57. package/dist/components/form/index.d.ts.map +1 -1
  58. package/dist/components/form/index.js +17 -15
  59. package/dist/components/form/index.js.map +1 -1
  60. package/dist/components/form/with-left-border.d.ts.map +1 -1
  61. package/dist/components/form/with-left-border.js +4 -12
  62. package/dist/components/form/with-left-border.js.map +1 -1
  63. package/dist/components/list.d.ts.map +1 -1
  64. package/dist/components/list.js +126 -86
  65. package/dist/components/list.js.map +1 -1
  66. package/dist/components/loading-bar.d.ts.map +1 -1
  67. package/dist/components/loading-bar.js +5 -22
  68. package/dist/components/loading-bar.js.map +1 -1
  69. package/dist/components/loading-text.d.ts.map +1 -1
  70. package/dist/components/loading-text.js +3 -22
  71. package/dist/components/loading-text.js.map +1 -1
  72. package/dist/components/theme-picker.d.ts +2 -0
  73. package/dist/components/theme-picker.d.ts.map +1 -0
  74. package/dist/components/theme-picker.js +37 -0
  75. package/dist/components/theme-picker.js.map +1 -0
  76. package/dist/descendants.d.ts +6 -0
  77. package/dist/descendants.d.ts.map +1 -1
  78. package/dist/descendants.js +74 -8
  79. package/dist/descendants.js.map +1 -1
  80. package/dist/examples/internal/descendants-rerender.d.ts +14 -0
  81. package/dist/examples/internal/descendants-rerender.d.ts.map +1 -0
  82. package/dist/examples/internal/descendants-rerender.js +145 -0
  83. package/dist/examples/internal/descendants-rerender.js.map +1 -0
  84. package/dist/examples/internal/simple-dialog.js +4 -1
  85. package/dist/examples/internal/simple-dialog.js.map +1 -1
  86. package/dist/examples/internal/simple-scrollbox.js +1 -1
  87. package/dist/examples/internal/simple-scrollbox.js.map +1 -1
  88. package/dist/examples/list-with-dropdown.js +1 -1
  89. package/dist/examples/list-with-dropdown.js.map +1 -1
  90. package/dist/examples/miscellaneous.js +1 -1
  91. package/dist/examples/miscellaneous.js.map +1 -1
  92. package/dist/examples/toast-action.d.ts +2 -0
  93. package/dist/examples/toast-action.d.ts.map +1 -0
  94. package/dist/examples/toast-action.js +76 -0
  95. package/dist/examples/toast-action.js.map +1 -0
  96. package/dist/examples/toast-variations.js +38 -36
  97. package/dist/examples/toast-variations.js.map +1 -1
  98. package/dist/extensions/dev.d.ts +1 -1
  99. package/dist/extensions/dev.d.ts.map +1 -1
  100. package/dist/extensions/dev.js +62 -30
  101. package/dist/extensions/dev.js.map +1 -1
  102. package/dist/extensions/home.d.ts.map +1 -1
  103. package/dist/extensions/home.js +4 -3
  104. package/dist/extensions/home.js.map +1 -1
  105. package/dist/extensions/react-refresh-init.d.ts +5 -0
  106. package/dist/extensions/react-refresh-init.d.ts.map +1 -0
  107. package/dist/extensions/react-refresh-init.js +52 -0
  108. package/dist/extensions/react-refresh-init.js.map +1 -0
  109. package/dist/internal/date-picker-widget.js +1 -1
  110. package/dist/internal/date-picker-widget.js.map +1 -1
  111. package/dist/internal/dialog.d.ts +8 -3
  112. package/dist/internal/dialog.d.ts.map +1 -1
  113. package/dist/internal/dialog.js +37 -53
  114. package/dist/internal/dialog.js.map +1 -1
  115. package/dist/internal/navigation.d.ts +1 -0
  116. package/dist/internal/navigation.d.ts.map +1 -1
  117. package/dist/internal/navigation.js +25 -1
  118. package/dist/internal/navigation.js.map +1 -1
  119. package/dist/internal/providers.d.ts.map +1 -1
  120. package/dist/internal/providers.js +9 -197
  121. package/dist/internal/providers.js.map +1 -1
  122. package/dist/internal/scrollbox.d.ts.map +1 -1
  123. package/dist/internal/scrollbox.js +1 -0
  124. package/dist/internal/scrollbox.js.map +1 -1
  125. package/dist/release.d.ts +1 -0
  126. package/dist/release.d.ts.map +1 -1
  127. package/dist/release.js +16 -9
  128. package/dist/release.js.map +1 -1
  129. package/dist/state.d.ts +27 -1
  130. package/dist/state.d.ts.map +1 -1
  131. package/dist/state.js +6 -0
  132. package/dist/state.js.map +1 -1
  133. package/dist/theme.d.ts +6 -19
  134. package/dist/theme.d.ts.map +1 -1
  135. package/dist/theme.js +76 -45
  136. package/dist/theme.js.map +1 -1
  137. package/dist/themes/aura.json +69 -0
  138. package/dist/themes/ayu.json +80 -0
  139. package/dist/themes/catppuccin-frappe.json +233 -0
  140. package/dist/themes/catppuccin-macchiato.json +233 -0
  141. package/dist/themes/catppuccin.json +112 -0
  142. package/dist/themes/cobalt2.json +228 -0
  143. package/dist/themes/cursor.json +249 -0
  144. package/dist/themes/dracula.json +219 -0
  145. package/dist/themes/everforest.json +241 -0
  146. package/dist/themes/flexoki.json +237 -0
  147. package/dist/themes/github-light.json +56 -0
  148. package/dist/themes/github.json +241 -0
  149. package/dist/themes/gruvbox.json +95 -0
  150. package/dist/themes/kanagawa.json +77 -0
  151. package/dist/themes/lucent-orng.json +227 -0
  152. package/dist/themes/material.json +235 -0
  153. package/dist/themes/matrix.json +77 -0
  154. package/dist/themes/mercury.json +245 -0
  155. package/dist/themes/monokai.json +221 -0
  156. package/dist/themes/nightowl.json +221 -0
  157. package/dist/themes/nord.json +223 -0
  158. package/dist/themes/one-dark.json +84 -0
  159. package/dist/themes/opencode-light.json +62 -0
  160. package/dist/themes/opencode.json +245 -0
  161. package/dist/themes/orng.json +245 -0
  162. package/dist/themes/palenight.json +222 -0
  163. package/dist/themes/rosepine.json +234 -0
  164. package/dist/themes/solarized.json +223 -0
  165. package/dist/themes/synthwave84.json +226 -0
  166. package/dist/themes/termcast.json +226 -0
  167. package/dist/themes/tokyonight.json +243 -0
  168. package/dist/themes/vercel.json +255 -0
  169. package/dist/themes/vesper.json +218 -0
  170. package/dist/themes/zenburn.json +223 -0
  171. package/dist/themes.d.ts +57 -0
  172. package/dist/themes.d.ts.map +1 -0
  173. package/dist/themes.js +181 -0
  174. package/dist/themes.js.map +1 -0
  175. package/dist/utils/run-command.d.ts +2 -1
  176. package/dist/utils/run-command.d.ts.map +1 -1
  177. package/dist/utils/run-command.js +20 -10
  178. package/dist/utils/run-command.js.map +1 -1
  179. package/dist/utils.d.ts +2 -1
  180. package/dist/utils.d.ts.map +1 -1
  181. package/dist/utils.js +90 -17
  182. package/dist/utils.js.map +1 -1
  183. package/dist/watcher.d.ts +3 -0
  184. package/dist/watcher.d.ts.map +1 -0
  185. package/dist/watcher.js +16 -0
  186. package/dist/watcher.js.map +1 -0
  187. package/package.json +16 -10
  188. package/src/apis/cache.tsx +5 -44
  189. package/src/apis/hud.tsx +17 -62
  190. package/src/apis/localstorage.tsx +3 -32
  191. package/src/apis/toast.tsx +91 -275
  192. package/src/build.test.tsx +10 -0
  193. package/src/build.tsx +61 -1
  194. package/src/cli.tsx +365 -103
  195. package/src/colors.tsx +1 -0
  196. package/src/compile.tsx +21 -29
  197. package/src/compile.vitest.tsx +300 -0
  198. package/src/components/actions.tsx +64 -45
  199. package/src/components/animation-tick.tsx +85 -0
  200. package/src/components/detail.tsx +31 -35
  201. package/src/components/dropdown.tsx +32 -21
  202. package/src/components/extension-preferences.tsx +14 -10
  203. package/src/components/footer.tsx +241 -0
  204. package/src/components/form/file-autocomplete.tsx +80 -60
  205. package/src/components/form/file-picker.tsx +37 -25
  206. package/src/components/form/index.tsx +45 -41
  207. package/src/components/form/with-left-border.tsx +4 -14
  208. package/src/components/list.tsx +181 -121
  209. package/src/components/loading-bar.tsx +5 -25
  210. package/src/components/loading-text.tsx +4 -23
  211. package/src/components/theme-picker.tsx +57 -0
  212. package/src/descendants.tsx +98 -9
  213. package/src/examples/actions-dialog-layout.vitest.tsx +112 -0
  214. package/src/examples/file-autocomplete.vitest.tsx +131 -122
  215. package/src/examples/form-basic.vitest.tsx +463 -644
  216. package/src/examples/form-dropdown.vitest.tsx +553 -571
  217. package/src/examples/form-scroll.vitest.tsx +112 -102
  218. package/src/examples/form-tagpicker.vitest.tsx +364 -338
  219. package/src/examples/internal/descendants-rerender.tsx +273 -0
  220. package/src/examples/internal/descendants-rerender.vitest.tsx +194 -0
  221. package/src/examples/internal/simple-dialog.tsx +4 -4
  222. package/src/examples/internal/simple-scrollbox.tsx +2 -2
  223. package/src/examples/internal/simple-scrollbox.vitest.tsx +43 -31
  224. package/src/examples/list-detail-metadata.vitest.tsx +34 -30
  225. package/src/examples/list-dropdown-default.vitest.tsx +84 -72
  226. package/src/examples/list-empty-view.vitest.tsx +93 -0
  227. package/src/examples/list-fetch-data.vitest.tsx +36 -30
  228. package/src/examples/list-scrollbox.vitest.tsx +59 -39
  229. package/src/examples/list-with-detail.vitest.tsx +339 -314
  230. package/src/examples/list-with-dropdown.tsx +1 -0
  231. package/src/examples/list-with-dropdown.vitest.tsx +176 -150
  232. package/src/examples/list-with-sections.vitest.tsx +289 -270
  233. package/src/examples/list-with-toast.vitest.tsx +44 -44
  234. package/src/examples/miscellaneous.tsx +10 -0
  235. package/src/examples/simple-file-picker.vitest.tsx +90 -86
  236. package/src/examples/simple-grid.vitest.tsx +275 -249
  237. package/src/examples/simple-navigation.vitest.tsx +192 -168
  238. package/src/examples/store.vitest.tsx +6 -4
  239. package/src/examples/swift-extension.vitest.tsx +31 -19
  240. package/src/examples/synonyms.vitest.tsx +93 -83
  241. package/src/examples/toast-action.tsx +160 -0
  242. package/src/examples/toast-action.vitest.tsx +404 -0
  243. package/src/examples/toast-variations.tsx +58 -57
  244. package/src/examples/toast-variations.vitest.tsx +186 -166
  245. package/src/extensions/dev.tsx +74 -33
  246. package/src/extensions/dev.vitest.tsx +162 -69
  247. package/src/extensions/home.tsx +5 -6
  248. package/src/extensions/react-refresh-init.tsx +59 -0
  249. package/src/internal/date-picker-widget.tsx +1 -1
  250. package/src/internal/dialog.tsx +59 -83
  251. package/src/internal/navigation.tsx +37 -4
  252. package/src/internal/providers.tsx +27 -315
  253. package/src/internal/scrollbox.tsx +1 -0
  254. package/src/release.tsx +16 -10
  255. package/src/state.tsx +36 -3
  256. package/src/theme.tsx +82 -51
  257. package/src/themes/aura.json +69 -0
  258. package/src/themes/ayu.json +80 -0
  259. package/src/themes/catppuccin-frappe.json +233 -0
  260. package/src/themes/catppuccin-macchiato.json +233 -0
  261. package/src/themes/catppuccin.json +112 -0
  262. package/src/themes/cobalt2.json +228 -0
  263. package/src/themes/cursor.json +249 -0
  264. package/src/themes/dracula.json +219 -0
  265. package/src/themes/everforest.json +241 -0
  266. package/src/themes/flexoki.json +237 -0
  267. package/src/themes/github-light.json +56 -0
  268. package/src/themes/github.json +241 -0
  269. package/src/themes/gruvbox.json +95 -0
  270. package/src/themes/kanagawa.json +77 -0
  271. package/src/themes/lucent-orng.json +227 -0
  272. package/src/themes/material.json +235 -0
  273. package/src/themes/matrix.json +77 -0
  274. package/src/themes/mercury.json +252 -0
  275. package/src/themes/monokai.json +221 -0
  276. package/src/themes/nightowl.json +221 -0
  277. package/src/themes/nord.json +223 -0
  278. package/src/themes/one-dark.json +84 -0
  279. package/src/themes/opencode-light.json +62 -0
  280. package/src/themes/opencode.json +245 -0
  281. package/src/themes/orng.json +245 -0
  282. package/src/themes/palenight.json +222 -0
  283. package/src/themes/rosepine.json +234 -0
  284. package/src/themes/solarized.json +223 -0
  285. package/src/themes/synthwave84.json +226 -0
  286. package/src/themes/termcast.json +227 -0
  287. package/src/themes/tokyonight.json +243 -0
  288. package/src/themes/vercel.json +255 -0
  289. package/src/themes/vesper.json +218 -0
  290. package/src/themes/zenburn.json +223 -0
  291. package/src/themes.ts +291 -0
  292. package/src/utils/run-command.tsx +23 -12
  293. package/src/utils.tsx +115 -18
  294. package/src/watcher.tsx +19 -0
@@ -1,5 +1,6 @@
1
- import React, { useState, useEffect, useRef } from 'react'
1
+ import React from 'react'
2
2
  import { colord } from 'colord'
3
+ import { useAnimationTick, TICK_DIVISORS } from 'termcast/src/components/animation-tick'
3
4
 
4
5
  interface LoadingTextProps {
5
6
  children: string
@@ -21,8 +22,7 @@ function generateWaveColors(baseColor: string): string[] {
21
22
 
22
23
  export function LoadingText(props: LoadingTextProps): any {
23
24
  const { children, isLoading = false, color = '#FFC000' } = props
24
- const [position, setPosition] = useState(0)
25
- const intervalRef = useRef<NodeJS.Timeout | null>(null)
25
+ const tick = useAnimationTick(isLoading ? TICK_DIVISORS.LOADING_TEXT : 0)
26
26
 
27
27
  const characters = children.split('')
28
28
  const waveColors = generateWaveColors(color)
@@ -30,26 +30,7 @@ export function LoadingText(props: LoadingTextProps): any {
30
30
  // Add padding at the end to create a delay before the next loop
31
31
  const endPadding = 10
32
32
  const totalLength = characters.length + waveWidth + endPadding
33
-
34
- useEffect(() => {
35
- if (isLoading) {
36
- intervalRef.current = setInterval(() => {
37
- setPosition((prev) => (prev + 1) % totalLength)
38
- }, 30)
39
- } else {
40
- if (intervalRef.current) {
41
- clearInterval(intervalRef.current)
42
- intervalRef.current = null
43
- }
44
- setPosition(0)
45
- }
46
-
47
- return () => {
48
- if (intervalRef.current) {
49
- clearInterval(intervalRef.current)
50
- }
51
- }
52
- }, [isLoading, totalLength])
33
+ const position = isLoading ? tick % totalLength : 0
53
34
 
54
35
  const getCharacterColor = (index: number): string => {
55
36
  if (!isLoading) {
@@ -0,0 +1,57 @@
1
+ import React, { useState } from 'react'
2
+ import { useKeyboard } from '@opentui/react'
3
+ import { Theme, persistTheme } from 'termcast/src/theme'
4
+ import { themeNames } from '../themes'
5
+ import { useStore } from 'termcast/src/state'
6
+ import { useDialog } from 'termcast/src/internal/dialog'
7
+ import { useIsInFocus } from 'termcast/src/internal/focus-context'
8
+ import { Dropdown } from 'termcast/src/components/dropdown'
9
+
10
+ export function ThemePicker(): any {
11
+ const dialog = useDialog()
12
+ const inFocus = useIsInFocus()
13
+ const currentThemeName = useStore((state) => state.currentThemeName)
14
+ const [previousTheme] = useState(currentThemeName)
15
+
16
+ const handleSelectionChange = (value: string) => {
17
+ // Preview theme on selection change
18
+ useStore.setState({ currentThemeName: value })
19
+ }
20
+
21
+ const handleChange = (value: string) => {
22
+ // Confirm selection
23
+ useStore.setState({ currentThemeName: value })
24
+ persistTheme(value)
25
+ dialog.clear()
26
+ }
27
+
28
+ useKeyboard((evt) => {
29
+ if (!inFocus) {
30
+ return
31
+ }
32
+ if (evt.name === 'escape') {
33
+ // Revert to previous theme on escape
34
+ useStore.setState({ currentThemeName: previousTheme })
35
+ dialog.clear()
36
+ }
37
+ })
38
+
39
+ return (
40
+ <Dropdown
41
+ tooltip="Change Theme"
42
+ placeholder="Search themes..."
43
+ filtering
44
+ onChange={handleChange}
45
+ onSelectionChange={handleSelectionChange}
46
+ >
47
+ {themeNames.map((name) => (
48
+ <Dropdown.Item
49
+ key={name}
50
+ title={name}
51
+ value={name}
52
+ color={name === previousTheme ? Theme.primary : undefined}
53
+ />
54
+ ))}
55
+ </Dropdown>
56
+ )
57
+ }
@@ -7,9 +7,16 @@ type DescendantMap<T> = { [id: string]: { index: number; props?: T } }
7
7
 
8
8
  export interface DescendantContextType<T> {
9
9
  getIndexForId: (id: string, props?: T) => number
10
- // IMPORTANT! map is not reactive, it cannot be used in render, only in useEffect or useLayoutEffect or other event handlers like useKeyboard
10
+ // IMPORTANT! map is not reactive by default. Use useDescendantsRerender() to opt-in to reactivity.
11
+ // Without that hook, map can only be read in useEffect/useLayoutEffect or event handlers like useKeyboard
11
12
  map: React.RefObject<DescendantMap<T>>
12
13
  reset: () => void
14
+ // For useSyncExternalStore - opt-in reactivity
15
+ subscribe: (callback: () => void) => () => void
16
+ getSnapshot: () => string
17
+ updateSnapshot: () => void
18
+ // Committed map - stable copy for useDescendantsRerender (map.current is cleared on every render)
19
+ committedMap: DescendantMap<T>
13
20
  }
14
21
 
15
22
  const randomId = () => Math.random().toString(36).slice(2, 11)
@@ -26,6 +33,11 @@ export function createDescendants<T = any>() {
26
33
  // On every re-render of children, reset the count
27
34
  props.value.reset()
28
35
 
36
+ // Update snapshot after all children have registered (runs after children's useLayoutEffect)
37
+ React.useLayoutEffect(() => {
38
+ props.value.updateSnapshot()
39
+ })
40
+
29
41
  return (
30
42
  <DescendantContext.Provider value={props.value}>
31
43
  {props.children}
@@ -37,30 +49,71 @@ export function createDescendants<T = any>() {
37
49
  const indexCounter = React.useRef<number>(0)
38
50
  const map = React.useRef<DescendantMap<T>>({})
39
51
 
52
+ // For useSyncExternalStore - opt-in reactivity
53
+ const listeners = React.useRef(new Set<() => void>())
54
+ const snapshotRef = React.useRef('')
55
+ const prevSnapshotRef = React.useRef('')
56
+ // Committed map - stable copy of map.current updated only when snapshot changes
57
+ // This is what useDescendantsRerender returns, since map.current is cleared on every render
58
+ const committedMapRef = React.useRef<DescendantMap<T>>({})
59
+
40
60
  const reset = () => {
61
+ // Save previous snapshot before clearing
62
+ prevSnapshotRef.current = snapshotRef.current
41
63
  indexCounter.current = 0
42
64
  map.current = {}
43
65
  }
44
66
 
45
67
  const getIndexForId = (id: string, props?: T) => {
46
- if (!map.current[id])
68
+ if (!map.current[id]) {
47
69
  map.current[id] = {
48
70
  index: indexCounter.current++,
49
71
  }
72
+ }
50
73
  map.current[id].props = props
51
74
  return map.current[id].index
52
75
  }
53
76
 
54
- // React.useEffect(() => {
55
- // return () => {
56
- // reset()
57
- // }
58
- // }, [])
77
+ // Must be stable (memoized) for useSyncExternalStore
78
+ const subscribe = React.useCallback((callback: () => void) => {
79
+ listeners.current.add(callback)
80
+ return () => {
81
+ listeners.current.delete(callback)
82
+ }
83
+ }, [])
84
+
85
+ // Must be stable for useSyncExternalStore
86
+ const getSnapshot = React.useCallback(() => snapshotRef.current, [])
87
+
88
+ // Called by provider after all children have registered
89
+ const updateSnapshot = React.useCallback(() => {
90
+ const newSnapshot = Object.keys(map.current).sort().join(',')
91
+ snapshotRef.current = newSnapshot
92
+ // Always update committed map so useDescendantsRerender returns fresh data
93
+ // (map.current is cleared by reset() on every render)
94
+ committedMapRef.current = { ...map.current }
95
+ // Only notify if there are listeners AND snapshot changed
96
+ if (listeners.current.size > 0 && newSnapshot !== prevSnapshotRef.current) {
97
+ listeners.current.forEach((cb) => {
98
+ cb()
99
+ })
100
+ }
101
+ }, [])
59
102
 
60
103
  // Do NOT memoize context value, so that we bypass React.memo on any children
61
104
  // We NEED them to re-render, in case stable children were re-ordered
62
105
  // (this creates a new object every render, so children reading the context MUST re-render)
63
- return { getIndexForId, map, reset }
106
+ return {
107
+ getIndexForId,
108
+ map,
109
+ reset,
110
+ subscribe,
111
+ getSnapshot,
112
+ updateSnapshot,
113
+ get committedMap() {
114
+ return committedMapRef.current
115
+ },
116
+ }
64
117
  }
65
118
 
66
119
  /**
@@ -80,7 +133,43 @@ export function createDescendants<T = any>() {
80
133
  return { descendantId, index }
81
134
  }
82
135
 
83
- return { DescendantsProvider, useDescendants, useDescendant }
136
+ /**
137
+ * Opt-in to re-renders when the set of descendant IDs changes.
138
+ * Returns the committed map of descendants, readable during render.
139
+ * Only triggers re-render when descendants are added/removed, not on prop changes.
140
+ */
141
+ function useDescendantsRerender(): DescendantMap<T> {
142
+ const context = React.useContext(DescendantContext)
143
+ if (!context) {
144
+ throw new Error(
145
+ 'useDescendantsRerender must be used within a DescendantsProvider',
146
+ )
147
+ }
148
+
149
+ React.useSyncExternalStore(
150
+ context.subscribe,
151
+ context.getSnapshot,
152
+ context.getSnapshot, // server snapshot
153
+ )
154
+
155
+ return context.committedMap
156
+ }
157
+
158
+ /**
159
+ * Get the live map ref from context. Only read map.current inside useLayoutEffect,
160
+ * as it is cleared during render by reset() and populated by items' useLayoutEffect.
161
+ */
162
+ function useDescendantsMap(): React.RefObject<DescendantMap<T>> {
163
+ const context = React.useContext(DescendantContext)
164
+ if (!context) {
165
+ throw new Error(
166
+ 'useDescendantsMap must be used within a DescendantsProvider',
167
+ )
168
+ }
169
+ return context.map
170
+ }
171
+
172
+ return { DescendantsProvider, useDescendants, useDescendant, useDescendantsRerender, useDescendantsMap }
84
173
  }
85
174
 
86
175
  // EXAMPLE
@@ -0,0 +1,112 @@
1
+ import { test, expect, afterEach, beforeEach } from 'vitest'
2
+
3
+ import { launchTerminal, Session } from 'tuistory/src'
4
+
5
+ let session: Session
6
+
7
+ beforeEach(async () => {
8
+ session = await launchTerminal({
9
+ command: 'bun',
10
+ args: ['src/examples/list-with-sections.tsx'],
11
+ cols: 70,
12
+ rows: 20,
13
+ })
14
+ })
15
+
16
+ afterEach(() => {
17
+ session?.close()
18
+ })
19
+
20
+ test('actions dialog layout shift when opening with ctrl+k', async () => {
21
+ // Wait for list to fully render
22
+ await session.text({
23
+ waitFor: (text) => /search/i.test(text) && /Apple/.test(text),
24
+ })
25
+
26
+ // Capture multiple frames when pressing ctrl+k to verify no layout shift
27
+ // Previously, the dialog would shift position as content mounted incrementally.
28
+ // Now with fixed height on ScrollBox, the dialog appears at stable position immediately.
29
+ const frames = await session.captureFrames(['ctrl', 'k'], {
30
+ frameCount: 10,
31
+ intervalMs: 5,
32
+ })
33
+
34
+ // Filter frames that show the dialog border
35
+ const dialogFrames = frames.filter((frame) => frame.includes('╭'))
36
+
37
+ // Find the line number where dialog border (╭) appears in each frame
38
+ const dialogPositions = dialogFrames.map((frame) => {
39
+ const lines = frame.split('\n')
40
+ return lines.findIndex((line) => line.includes('╭'))
41
+ })
42
+
43
+ console.log('Dialog border positions across frames:', dialogPositions)
44
+
45
+ // Check if all positions are the same (no layout shift)
46
+ const uniquePositions = [...new Set(dialogPositions)]
47
+ const hasLayoutShift = uniquePositions.length > 1
48
+
49
+ if (hasLayoutShift) {
50
+ console.log('LAYOUT SHIFT DETECTED!')
51
+ console.log(`Dialog moves from line ${Math.min(...dialogPositions)} to line ${Math.max(...dialogPositions)}`)
52
+ }
53
+
54
+ // Capture first and last dialog frames to show the shift
55
+ const firstDialogFrame = dialogFrames[0]
56
+ const lastDialogFrame = dialogFrames[dialogFrames.length - 1]
57
+
58
+ // First frame: dialog appears at stable position (same as last frame)
59
+ expect(firstDialogFrame).toMatchInlineSnapshot(`
60
+ "
61
+
62
+
63
+ Simple List Example ────────────────────────────────────────────
64
+
65
+ ╭────────────────────────────────────────────────────────────────╮
66
+ │ │
67
+ │ Actions esc │
68
+ │ │
69
+ │ > Search actions... │
70
+ │ │
71
+ │ ›View Details │
72
+ │ Add to Cart │
73
+ │ │
74
+ │ Settings │
75
+ │ Change Theme... │
76
+ │ │
77
+ │ │
78
+ │ │
79
+ │ ↵ select ↑↓ navigate │
80
+ │ │"
81
+ `)
82
+
83
+ // Last frame: dialog at same position as first frame (no shift)
84
+ expect(lastDialogFrame).toMatchInlineSnapshot(`
85
+ "
86
+
87
+
88
+ Simple List Example ────────────────────────────────────────────
89
+
90
+ ╭────────────────────────────────────────────────────────────────╮
91
+ │ │
92
+ │ Actions esc │
93
+ │ │
94
+ │ > Search actions... │
95
+ │ │
96
+ │ ›View Details │
97
+ │ Add to Cart │
98
+ │ │
99
+ │ Settings │
100
+ │ Change Theme... │
101
+ │ │
102
+ │ │
103
+ │ │
104
+ │ ↵ select ↑↓ navigate │
105
+ │ │"
106
+ `)
107
+
108
+ // The dialog should appear at a stable position immediately.
109
+ // Fixed by using a fixed height on the ScrollBox instead of maxHeight,
110
+ // so the dialog has consistent dimensions from the first render.
111
+ expect(hasLayoutShift).toBe(false)
112
+ }, 15000)