termcast 1.3.32 → 1.3.34

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 (327) hide show
  1. package/dist/action-utils.d.ts.map +1 -1
  2. package/dist/action-utils.js +8 -0
  3. package/dist/action-utils.js.map +1 -1
  4. package/dist/apis/cache.d.ts +1 -2
  5. package/dist/apis/cache.d.ts.map +1 -1
  6. package/dist/apis/cache.js +138 -54
  7. package/dist/apis/cache.js.map +1 -1
  8. package/dist/apis/clipboard.d.ts.map +1 -1
  9. package/dist/apis/clipboard.js +4 -0
  10. package/dist/apis/clipboard.js.map +1 -1
  11. package/dist/apis/oauth.d.ts.map +1 -1
  12. package/dist/apis/oauth.js +31 -4
  13. package/dist/apis/oauth.js.map +1 -1
  14. package/dist/build.d.ts +0 -1
  15. package/dist/build.d.ts.map +1 -1
  16. package/dist/build.js +30 -51
  17. package/dist/build.js.map +1 -1
  18. package/dist/cli.js +31 -14
  19. package/dist/cli.js.map +1 -1
  20. package/dist/compile.d.ts.map +1 -1
  21. package/dist/compile.js +5 -1
  22. package/dist/compile.js.map +1 -1
  23. package/dist/components/actions.d.ts +14 -0
  24. package/dist/components/actions.d.ts.map +1 -1
  25. package/dist/components/actions.js +151 -59
  26. package/dist/components/actions.js.map +1 -1
  27. package/dist/components/alert.d.ts.map +1 -1
  28. package/dist/components/alert.js +6 -5
  29. package/dist/components/alert.js.map +1 -1
  30. package/dist/components/animation-tick.d.ts +1 -1
  31. package/dist/components/animation-tick.js +1 -1
  32. package/dist/components/animation-tick.js.map +1 -1
  33. package/dist/components/detail.d.ts +5 -31
  34. package/dist/components/detail.d.ts.map +1 -1
  35. package/dist/components/detail.js +36 -52
  36. package/dist/components/detail.js.map +1 -1
  37. package/dist/components/dropdown.d.ts +1 -1
  38. package/dist/components/dropdown.d.ts.map +1 -1
  39. package/dist/components/dropdown.js +50 -22
  40. package/dist/components/dropdown.js.map +1 -1
  41. package/dist/components/footer.d.ts.map +1 -1
  42. package/dist/components/footer.js +19 -18
  43. package/dist/components/footer.js.map +1 -1
  44. package/dist/components/form/checkbox.d.ts.map +1 -1
  45. package/dist/components/form/checkbox.js +12 -11
  46. package/dist/components/form/checkbox.js.map +1 -1
  47. package/dist/components/form/date-picker.d.ts.map +1 -1
  48. package/dist/components/form/date-picker.js +7 -22
  49. package/dist/components/form/date-picker.js.map +1 -1
  50. package/dist/components/form/description.d.ts +1 -1
  51. package/dist/components/form/description.d.ts.map +1 -1
  52. package/dist/components/form/description.js +6 -5
  53. package/dist/components/form/description.js.map +1 -1
  54. package/dist/components/form/dropdown.d.ts.map +1 -1
  55. package/dist/components/form/dropdown.js +53 -50
  56. package/dist/components/form/dropdown.js.map +1 -1
  57. package/dist/components/form/file-autocomplete.d.ts.map +1 -1
  58. package/dist/components/form/file-autocomplete.js +5 -4
  59. package/dist/components/form/file-autocomplete.js.map +1 -1
  60. package/dist/components/form/file-picker.d.ts.map +1 -1
  61. package/dist/components/form/file-picker.js +23 -22
  62. package/dist/components/form/file-picker.js.map +1 -1
  63. package/dist/components/form/form-end.d.ts.map +1 -1
  64. package/dist/components/form/form-end.js +6 -4
  65. package/dist/components/form/form-end.js.map +1 -1
  66. package/dist/components/form/form-field-wrapper.d.ts +15 -0
  67. package/dist/components/form/form-field-wrapper.d.ts.map +1 -0
  68. package/dist/components/form/form-field-wrapper.js +29 -0
  69. package/dist/components/form/form-field-wrapper.js.map +1 -0
  70. package/dist/components/form/index.d.ts.map +1 -1
  71. package/dist/components/form/index.js +31 -30
  72. package/dist/components/form/index.js.map +1 -1
  73. package/dist/components/form/password-field.d.ts.map +1 -1
  74. package/dist/components/form/password-field.js +7 -6
  75. package/dist/components/form/password-field.js.map +1 -1
  76. package/dist/components/form/separator.d.ts.map +1 -1
  77. package/dist/components/form/separator.js +3 -2
  78. package/dist/components/form/separator.js.map +1 -1
  79. package/dist/components/form/tagpicker.d.ts.map +1 -1
  80. package/dist/components/form/tagpicker.js +2 -1
  81. package/dist/components/form/tagpicker.js.map +1 -1
  82. package/dist/components/form/text-area.d.ts.map +1 -1
  83. package/dist/components/form/text-area.js +7 -6
  84. package/dist/components/form/text-area.js.map +1 -1
  85. package/dist/components/form/text-field.d.ts.map +1 -1
  86. package/dist/components/form/text-field.js +7 -6
  87. package/dist/components/form/text-field.js.map +1 -1
  88. package/dist/components/form/use-form-navigation.d.ts.map +1 -1
  89. package/dist/components/form/use-form-navigation.js +4 -4
  90. package/dist/components/form/use-form-navigation.js.map +1 -1
  91. package/dist/components/form/with-left-border.d.ts +15 -0
  92. package/dist/components/form/with-left-border.d.ts.map +1 -1
  93. package/dist/components/form/with-left-border.js +21 -9
  94. package/dist/components/form/with-left-border.js.map +1 -1
  95. package/dist/components/icon.d.ts +14 -0
  96. package/dist/components/icon.d.ts.map +1 -1
  97. package/dist/components/icon.js +60 -0
  98. package/dist/components/icon.js.map +1 -1
  99. package/dist/components/image.d.ts +47 -2
  100. package/dist/components/image.d.ts.map +1 -1
  101. package/dist/components/image.js +46 -7
  102. package/dist/components/image.js.map +1 -1
  103. package/dist/components/list.d.ts +5 -0
  104. package/dist/components/list.d.ts.map +1 -1
  105. package/dist/components/list.js +188 -132
  106. package/dist/components/list.js.map +1 -1
  107. package/dist/components/loading-bar.d.ts.map +1 -1
  108. package/dist/components/loading-bar.js +4 -3
  109. package/dist/components/loading-bar.js.map +1 -1
  110. package/dist/components/metadata.d.ts +70 -0
  111. package/dist/components/metadata.d.ts.map +1 -0
  112. package/dist/components/metadata.js +82 -0
  113. package/dist/components/metadata.js.map +1 -0
  114. package/dist/components/theme-picker.d.ts.map +1 -1
  115. package/dist/components/theme-picker.js +3 -2
  116. package/dist/components/theme-picker.js.map +1 -1
  117. package/dist/descendants-v2.d.ts +60 -0
  118. package/dist/descendants-v2.d.ts.map +1 -0
  119. package/dist/descendants-v2.js +144 -0
  120. package/dist/descendants-v2.js.map +1 -0
  121. package/dist/examples/actions-context.d.ts +2 -0
  122. package/dist/examples/actions-context.d.ts.map +1 -0
  123. package/dist/examples/actions-context.js +33 -0
  124. package/dist/examples/actions-context.js.map +1 -0
  125. package/dist/examples/form-basic.d.ts.map +1 -1
  126. package/dist/examples/form-basic.js +1 -1
  127. package/dist/examples/form-basic.js.map +1 -1
  128. package/dist/examples/form-dropdown.js +1 -1
  129. package/dist/examples/form-dropdown.js.map +1 -1
  130. package/dist/examples/internal/custom-action-renderables.d.ts +70 -0
  131. package/dist/examples/internal/custom-action-renderables.d.ts.map +1 -0
  132. package/dist/examples/internal/custom-action-renderables.js +163 -0
  133. package/dist/examples/internal/custom-action-renderables.js.map +1 -0
  134. package/dist/examples/internal/custom-dropdown.d.ts +99 -0
  135. package/dist/examples/internal/custom-dropdown.d.ts.map +1 -0
  136. package/dist/examples/internal/custom-dropdown.js +270 -0
  137. package/dist/examples/internal/custom-dropdown.js.map +1 -0
  138. package/dist/examples/internal/custom-renderable-form.d.ts +43 -0
  139. package/dist/examples/internal/custom-renderable-form.d.ts.map +1 -0
  140. package/dist/examples/internal/custom-renderable-form.js +284 -0
  141. package/dist/examples/internal/custom-renderable-form.js.map +1 -0
  142. package/dist/examples/internal/custom-renderable-list-default-search.d.ts +2 -0
  143. package/dist/examples/internal/custom-renderable-list-default-search.d.ts.map +1 -0
  144. package/dist/examples/internal/custom-renderable-list-default-search.js +16 -0
  145. package/dist/examples/internal/custom-renderable-list-default-search.js.map +1 -0
  146. package/dist/examples/internal/custom-renderable-list-v2-default-search.d.ts +2 -0
  147. package/dist/examples/internal/custom-renderable-list-v2-default-search.d.ts.map +1 -0
  148. package/dist/examples/internal/custom-renderable-list-v2-default-search.js +24 -0
  149. package/dist/examples/internal/custom-renderable-list-v2-default-search.js.map +1 -0
  150. package/dist/examples/internal/custom-renderable-list-v2.d.ts +189 -0
  151. package/dist/examples/internal/custom-renderable-list-v2.d.ts.map +1 -0
  152. package/dist/examples/internal/custom-renderable-list-v2.js +708 -0
  153. package/dist/examples/internal/custom-renderable-list-v2.js.map +1 -0
  154. package/dist/examples/internal/custom-renderable-list.d.ts +72 -0
  155. package/dist/examples/internal/custom-renderable-list.d.ts.map +1 -0
  156. package/dist/examples/internal/custom-renderable-list.js +544 -0
  157. package/dist/examples/internal/custom-renderable-list.js.map +1 -0
  158. package/dist/examples/internal/rhf-custom-ref.js +5 -4
  159. package/dist/examples/internal/rhf-custom-ref.js.map +1 -1
  160. package/dist/examples/internal/scrollbox-with-descendants.js +4 -2
  161. package/dist/examples/internal/scrollbox-with-descendants.js.map +1 -1
  162. package/dist/examples/list-controlled-search.d.ts +2 -0
  163. package/dist/examples/list-controlled-search.d.ts.map +1 -0
  164. package/dist/examples/list-controlled-search.js +12 -0
  165. package/dist/examples/list-controlled-search.js.map +1 -0
  166. package/dist/examples/list-detail-metadata.js +1 -1
  167. package/dist/examples/list-detail-metadata.js.map +1 -1
  168. package/dist/examples/simple-image-mask.d.ts +8 -0
  169. package/dist/examples/simple-image-mask.d.ts.map +1 -0
  170. package/dist/examples/simple-image-mask.js +12 -0
  171. package/dist/examples/simple-image-mask.js.map +1 -0
  172. package/dist/examples/toast-variations.js +1 -1
  173. package/dist/examples/toast-variations.js.map +1 -1
  174. package/dist/extensions/dev.d.ts.map +1 -1
  175. package/dist/extensions/dev.js +3 -2
  176. package/dist/extensions/dev.js.map +1 -1
  177. package/dist/extensions/react-refresh-init.d.ts.map +1 -1
  178. package/dist/extensions/react-refresh-init.js +4 -3
  179. package/dist/extensions/react-refresh-init.js.map +1 -1
  180. package/dist/index.d.ts +3 -2
  181. package/dist/index.d.ts.map +1 -1
  182. package/dist/index.js +1 -1
  183. package/dist/index.js.map +1 -1
  184. package/dist/internal/date-picker-widget.d.ts.map +1 -1
  185. package/dist/internal/date-picker-widget.js +2 -1
  186. package/dist/internal/date-picker-widget.js.map +1 -1
  187. package/dist/internal/dialog.d.ts +6 -0
  188. package/dist/internal/dialog.d.ts.map +1 -1
  189. package/dist/internal/dialog.js +59 -18
  190. package/dist/internal/dialog.js.map +1 -1
  191. package/dist/internal/navigation.d.ts.map +1 -1
  192. package/dist/internal/navigation.js +8 -1
  193. package/dist/internal/navigation.js.map +1 -1
  194. package/dist/internal/offscreen.d.ts +3 -0
  195. package/dist/internal/offscreen.d.ts.map +1 -1
  196. package/dist/internal/offscreen.js +5 -0
  197. package/dist/internal/offscreen.js.map +1 -1
  198. package/dist/internal/providers.d.ts.map +1 -1
  199. package/dist/internal/providers.js +20 -3
  200. package/dist/internal/providers.js.map +1 -1
  201. package/dist/internal/scrollbox.d.ts.map +1 -1
  202. package/dist/internal/scrollbox.js +3 -2
  203. package/dist/internal/scrollbox.js.map +1 -1
  204. package/dist/logger.d.ts.map +1 -1
  205. package/dist/logger.js +4 -0
  206. package/dist/logger.js.map +1 -1
  207. package/dist/preload.js +5 -17
  208. package/dist/preload.js.map +1 -1
  209. package/dist/state.d.ts +4 -0
  210. package/dist/state.d.ts.map +1 -1
  211. package/dist/state.js +4 -0
  212. package/dist/state.js.map +1 -1
  213. package/dist/test-border-overlay.d.ts +2 -0
  214. package/dist/test-border-overlay.d.ts.map +1 -0
  215. package/dist/test-border-overlay.js +7 -0
  216. package/dist/test-border-overlay.js.map +1 -0
  217. package/dist/test-layout-2.d.ts +2 -0
  218. package/dist/test-layout-2.d.ts.map +1 -0
  219. package/dist/test-layout-2.js +5 -0
  220. package/dist/test-layout-2.js.map +1 -0
  221. package/dist/test-layout.d.ts +2 -0
  222. package/dist/test-layout.d.ts.map +1 -0
  223. package/dist/test-layout.js +7 -0
  224. package/dist/test-layout.js.map +1 -0
  225. package/dist/theme.d.ts +1 -2
  226. package/dist/theme.d.ts.map +1 -1
  227. package/dist/theme.js +5 -9
  228. package/dist/theme.js.map +1 -1
  229. package/dist/utils/run-command.d.ts +1 -1
  230. package/dist/utils/run-command.d.ts.map +1 -1
  231. package/dist/utils/run-command.js +27 -7
  232. package/dist/utils/run-command.js.map +1 -1
  233. package/dist/utils.d.ts +1 -0
  234. package/dist/utils.d.ts.map +1 -1
  235. package/dist/utils.js +44 -23
  236. package/dist/utils.js.map +1 -1
  237. package/dist/watcher.d.ts.map +1 -1
  238. package/dist/watcher.js +24 -4
  239. package/dist/watcher.js.map +1 -1
  240. package/package.json +14 -12
  241. package/src/action-utils.tsx +10 -0
  242. package/src/apis/cache.test.ts +35 -3
  243. package/src/apis/cache.tsx +184 -59
  244. package/src/apis/clipboard.tsx +5 -0
  245. package/src/apis/oauth.tsx +33 -4
  246. package/src/build.tsx +35 -58
  247. package/src/cli.tsx +156 -134
  248. package/src/compile.tsx +6 -3
  249. package/src/compile.vitest.tsx +33 -15
  250. package/src/components/actions.tsx +230 -99
  251. package/src/components/alert.tsx +11 -10
  252. package/src/components/animation-tick.tsx +1 -1
  253. package/src/components/detail.tsx +56 -151
  254. package/src/components/dropdown.tsx +70 -36
  255. package/src/components/footer.tsx +58 -33
  256. package/src/components/form/checkbox.tsx +30 -32
  257. package/src/components/form/date-picker.tsx +27 -47
  258. package/src/components/form/description.tsx +19 -18
  259. package/src/components/form/dropdown.tsx +95 -103
  260. package/src/components/form/file-autocomplete.tsx +9 -8
  261. package/src/components/form/file-picker.tsx +46 -46
  262. package/src/components/form/form-end.tsx +6 -4
  263. package/src/components/form/index.tsx +38 -48
  264. package/src/components/form/password-field.tsx +25 -27
  265. package/src/components/form/separator.tsx +3 -2
  266. package/src/components/form/tagpicker.tsx +2 -1
  267. package/src/components/form/text-area.tsx +25 -30
  268. package/src/components/form/text-field.tsx +25 -27
  269. package/src/components/form/use-form-navigation.tsx +4 -5
  270. package/src/components/form/with-left-border.tsx +48 -10
  271. package/src/components/icon.tsx +69 -0
  272. package/src/components/image.tsx +60 -7
  273. package/src/components/list.tsx +270 -202
  274. package/src/components/loading-bar.tsx +4 -3
  275. package/src/components/metadata.tsx +217 -0
  276. package/src/components/theme-picker.tsx +3 -2
  277. package/src/examples/actions-context.tsx +63 -0
  278. package/src/examples/actions-context.vitest.tsx +110 -0
  279. package/src/examples/actions-dialog-layout.vitest.tsx +2 -1
  280. package/src/examples/file-autocomplete.vitest.tsx +15 -15
  281. package/src/examples/form-basic.tsx +12 -0
  282. package/src/examples/form-basic.vitest.tsx +74 -74
  283. package/src/examples/form-dropdown.tsx +8 -0
  284. package/src/examples/form-dropdown.vitest.tsx +364 -421
  285. package/src/examples/form-tagpicker.vitest.tsx +56 -54
  286. package/src/examples/github.vitest.tsx +252 -0
  287. package/src/examples/internal/rhf-custom-ref.tsx +16 -15
  288. package/src/examples/internal/scrollbox-with-descendants.tsx +4 -2
  289. package/src/examples/internal/simple-dialog.tsx +1 -1
  290. package/src/examples/internal/simple-scrollbox.vitest.tsx +14 -9
  291. package/src/examples/list-controlled-search.tsx +28 -0
  292. package/src/examples/list-controlled-search.vitest.tsx +49 -0
  293. package/src/examples/list-detail-metadata.tsx +8 -5
  294. package/src/examples/list-detail-metadata.vitest.tsx +22 -22
  295. package/src/examples/list-dropdown-default.vitest.tsx +12 -12
  296. package/src/examples/list-scrollbox.vitest.tsx +52 -38
  297. package/src/examples/list-with-detail.vitest.tsx +45 -41
  298. package/src/examples/list-with-dropdown.vitest.tsx +5 -5
  299. package/src/examples/list-with-sections.vitest.tsx +65 -12
  300. package/src/examples/list-with-toast.vitest.tsx +4 -4
  301. package/src/examples/simple-file-picker.vitest.tsx +12 -12
  302. package/src/examples/simple-grid.vitest.tsx +53 -53
  303. package/src/examples/simple-image-mask.tsx +58 -0
  304. package/src/examples/simple-navigation.vitest.tsx +19 -19
  305. package/src/examples/store.vitest.tsx +1 -1
  306. package/src/examples/swift-extension.vitest.tsx +4 -2
  307. package/src/examples/synonyms.vitest.tsx +31 -9
  308. package/src/examples/toast-action.vitest.tsx +8 -8
  309. package/src/examples/toast-variations.tsx +1 -1
  310. package/src/examples/toast-variations.vitest.tsx +69 -134
  311. package/src/extensions/dev.tsx +3 -2
  312. package/src/extensions/dev.vitest.tsx +65 -28
  313. package/src/extensions/react-refresh-init.tsx +4 -3
  314. package/src/index.tsx +3 -1
  315. package/src/internal/date-picker-widget.tsx +2 -1
  316. package/src/internal/dialog.tsx +100 -28
  317. package/src/internal/navigation.tsx +8 -1
  318. package/src/internal/offscreen.tsx +10 -0
  319. package/src/internal/providers.tsx +34 -8
  320. package/src/internal/scrollbox.tsx +4 -2
  321. package/src/logger.tsx +4 -0
  322. package/src/preload.tsx +5 -17
  323. package/src/state.tsx +12 -0
  324. package/src/theme.tsx +6 -9
  325. package/src/utils/run-command.tsx +32 -8
  326. package/src/utils.tsx +58 -23
  327. package/src/watcher.tsx +26 -6
@@ -9,6 +9,7 @@ import React, {
9
9
  ReactElement,
10
10
  ReactNode,
11
11
  createContext,
12
+ useCallback,
12
13
  useContext,
13
14
  useEffect,
14
15
  useLayoutEffect,
@@ -17,6 +18,7 @@ import React, {
17
18
  useState
18
19
  } from 'react'
19
20
  import { LoadingBar } from 'termcast/src/components/loading-bar'
21
+ import { LoadingText } from 'termcast/src/components/loading-text'
20
22
  import { Footer } from 'termcast/src/components/footer'
21
23
  import { createDescendants } from 'termcast/src/descendants'
22
24
  import { useStore } from 'termcast/src/state'
@@ -27,9 +29,9 @@ import { Offscreen } from 'termcast/src/internal/offscreen'
27
29
  import { ScrollBox } from 'termcast/src/internal/scrollbox'
28
30
 
29
31
  import { Color, resolveColor } from 'termcast/src/colors'
30
- import { getIconEmoji } from 'termcast/src/components/icon'
32
+ import { getIconEmoji, getIconValue } from 'termcast/src/components/icon'
31
33
  import { ActionPanel } from 'termcast/src/components/actions'
32
- import { Theme, markdownSyntaxStyle } from 'termcast/src/theme'
34
+ import { useTheme, markdownSyntaxStyle } from 'termcast/src/theme'
33
35
  import { CommonProps } from 'termcast/src/utils'
34
36
 
35
37
  export { Color }
@@ -71,32 +73,42 @@ interface ActionsInterface {
71
73
  }
72
74
 
73
75
  function ListFooter(): any {
76
+ const theme = useTheme()
74
77
  const firstActionTitle = useStore((s) => s.firstActionTitle)
75
78
  const hasToast = useStore((s) => s.toast !== null)
76
79
  const listContext = useContext(ListContext)
77
80
  const isShowingDetail = listContext?.isShowingDetail ?? false
81
+ const hasDropdown = listContext?.hasDropdown ?? false
78
82
 
79
83
  const content = hasToast ? null : (
80
84
  <box style={{ flexDirection: 'row', gap: 3 }}>
81
85
  {firstActionTitle && (
82
86
  <box style={{ flexDirection: 'row', gap: 1 }}>
83
- <text flexShrink={0} fg={Theme.text} attributes={TextAttributes.BOLD}>
87
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
84
88
 
85
89
  </text>
86
- <text flexShrink={0} fg={Theme.textMuted}>{firstActionTitle.toLowerCase()}</text>
90
+ <text flexShrink={0} fg={theme.textMuted}>{firstActionTitle.toLowerCase()}</text>
87
91
  </box>
88
92
  )}
89
93
  <box style={{ flexDirection: 'row', gap: 1 }}>
90
- <text flexShrink={0} fg={Theme.text} attributes={TextAttributes.BOLD}>
94
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
91
95
  ↑↓
92
96
  </text>
93
- <text flexShrink={0} fg={Theme.textMuted}>navigate</text>
97
+ <text flexShrink={0} fg={theme.textMuted}>navigate</text>
94
98
  </box>
99
+ {hasDropdown && (
100
+ <box style={{ flexDirection: 'row', gap: 1 }}>
101
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
102
+ ^p
103
+ </text>
104
+ <text flexShrink={0} fg={theme.textMuted}>dropdown</text>
105
+ </box>
106
+ )}
95
107
  <box style={{ flexDirection: 'row', gap: 1 }}>
96
- <text flexShrink={0} fg={Theme.text} attributes={TextAttributes.BOLD}>
108
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
97
109
  ^k
98
110
  </text>
99
- <text flexShrink={0} fg={Theme.textMuted}>actions</text>
111
+ <text flexShrink={0} fg={theme.textMuted}>actions</text>
100
112
  </box>
101
113
  </box>
102
114
  )
@@ -104,6 +116,38 @@ function ListFooter(): any {
104
116
  return <Footer hidePoweredBy={isShowingDetail}>{content}</Footer>
105
117
  }
106
118
 
119
+ /**
120
+ * Component that subscribes to descendants changes and renders current item's
121
+ * actions offscreen. This ensures actions are captured even when items register
122
+ * after the initial render (preserving context via closures).
123
+ */
124
+ function CurrentItemActionsOffscreen(props: {
125
+ selectedIndex: number
126
+ fallbackActions?: ReactNode
127
+ }): any {
128
+ // Subscribe to descendants changes - this hook triggers re-render when items register
129
+ const descendantsMap = useListDescendantsRerender()
130
+
131
+ // Get current item's actions
132
+ const items = Object.values(descendantsMap)
133
+ .filter((item) => item.index !== -1)
134
+ .sort((a, b) => a.index - b.index)
135
+
136
+ const currentItem = items.find((item) => item.index === props.selectedIndex)
137
+ const actions = currentItem?.props?.actions ?? props.fallbackActions ?? null
138
+
139
+ // Clear first action title when there are no actions
140
+ useLayoutEffect(() => {
141
+ if (!actions) {
142
+ useStore.setState({ firstActionTitle: '' })
143
+ }
144
+ }, [actions])
145
+
146
+ if (!actions) return null
147
+
148
+ return <Offscreen>{actions}</Offscreen>
149
+ }
150
+
107
151
  interface NavigationChildInterface {
108
152
  navigationTitle?: string
109
153
  isLoading?: boolean
@@ -126,7 +170,11 @@ interface PaginationInterface {
126
170
  }
127
171
 
128
172
  export namespace Image {
129
- export type ImageLike = string | { source: string; tintColor?: string }
173
+ export type ImageLike = string | { source: string; tintColor?: string; mask?: ImageMask }
174
+ export enum ImageMask {
175
+ Circle = 'circle',
176
+ RoundedRectangle = 'roundedRectangle',
177
+ }
130
178
  }
131
179
 
132
180
  export type ItemAccessory =
@@ -298,6 +346,9 @@ interface ListContextValue {
298
346
  isFiltering: boolean
299
347
  setCurrentDetail?: (detail: ReactNode) => void
300
348
  isShowingDetail?: boolean
349
+ customEmptyViewRef: React.MutableRefObject<boolean>
350
+ isLoading?: boolean
351
+ hasDropdown?: boolean
301
352
  }
302
353
 
303
354
  const ListContext = createContext<ListContextValue | undefined>(undefined)
@@ -383,6 +434,7 @@ interface ListDropdownDialogProps extends DropdownProps {
383
434
  }
384
435
 
385
436
  function ListDropdownDialog(props: ListDropdownDialogProps): any {
437
+ const theme = useTheme()
386
438
  const [searchText, setSearchTextRaw] = useState('')
387
439
  const [selectedIndex, setSelectedIndex] = useState(0)
388
440
  const inputRef = useRef<TextareaRenderable>(null)
@@ -469,11 +521,11 @@ function ListDropdownDialog(props: ListDropdownDialogProps): any {
469
521
  justifyContent: 'space-between',
470
522
  }}
471
523
  >
472
- <text flexShrink={0} fg={Theme.textMuted}>{props.tooltip}</text>
473
- <text flexShrink={0} fg={Theme.textMuted}>esc</text>
524
+ <text flexShrink={0} fg={theme.textMuted}>{props.tooltip}</text>
525
+ <text flexShrink={0} fg={theme.textMuted}>esc</text>
474
526
  </box>
475
527
  <box style={{ paddingTop: 1, paddingBottom: 1, flexDirection: 'row' }}>
476
- <text flexShrink={0} fg={Theme.textMuted}>&gt; </text>
528
+ <text flexShrink={0} fg={theme.textMuted}>&gt; </text>
477
529
  <textarea
478
530
  ref={inputRef}
479
531
  height={1}
@@ -490,9 +542,9 @@ function ListDropdownDialog(props: ListDropdownDialogProps): any {
490
542
  placeholder={props.placeholder || 'Search...'}
491
543
  focused={inFocus}
492
544
  initialValue={searchText}
493
- focusedBackgroundColor={Theme.backgroundPanel}
494
- cursorColor={Theme.primary}
495
- focusedTextColor={Theme.textMuted}
545
+ focusedBackgroundColor={theme.backgroundPanel}
546
+ cursorColor={theme.primary}
547
+ focusedTextColor={theme.textMuted}
496
548
  />
497
549
  </box>
498
550
  </box>
@@ -517,7 +569,7 @@ function ListDropdownDialog(props: ListDropdownDialogProps): any {
517
569
  </box>
518
570
  {props.isLoading && (
519
571
  <box style={{ paddingLeft: 1 }}>
520
- <text flexShrink={0} fg={Theme.textMuted}>Loading...</text>
572
+ <text flexShrink={0} fg={theme.textMuted}>Loading...</text>
521
573
  </box>
522
574
  )}
523
575
  </box>
@@ -529,21 +581,22 @@ function ListDropdownDialog(props: ListDropdownDialogProps): any {
529
581
  }
530
582
 
531
583
  function DropdownFooter(): any {
584
+ const theme = useTheme()
532
585
  const hasToast = useStore((s) => s.toast !== null)
533
586
 
534
587
  const content = hasToast ? null : (
535
588
  <box style={{ flexDirection: 'row', gap: 3 }}>
536
589
  <box style={{ flexDirection: 'row', gap: 1 }}>
537
- <text flexShrink={0} fg={Theme.text} attributes={TextAttributes.BOLD}>
590
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
538
591
 
539
592
  </text>
540
- <text flexShrink={0} fg={Theme.textMuted}>select</text>
593
+ <text flexShrink={0} fg={theme.textMuted}>select</text>
541
594
  </box>
542
595
  <box style={{ flexDirection: 'row', gap: 1 }}>
543
- <text flexShrink={0} fg={Theme.text} attributes={TextAttributes.BOLD}>
596
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
544
597
  ↑↓
545
598
  </text>
546
- <text flexShrink={0} fg={Theme.textMuted}>navigate</text>
599
+ <text flexShrink={0} fg={theme.textMuted}>navigate</text>
547
600
  </box>
548
601
  </box>
549
602
  )
@@ -568,6 +621,7 @@ function ListItemRow(props: {
568
621
  index?: number
569
622
  ref?: React.Ref<BoxRenderable>
570
623
  }) {
624
+ const theme = useTheme()
571
625
  const { title, subtitle, icon, iconColor, accessories, active, ref } = props
572
626
  const [isHovered, setIsHovered] = useState(false)
573
627
 
@@ -586,7 +640,7 @@ function ListItemRow(props: {
586
640
  <text
587
641
  key={`text-${textValue}`}
588
642
  flexShrink={0}
589
- fg={active ? Theme.background : resolveColor(textColor) || Theme.info}
643
+ fg={active ? theme.background : resolveColor(textColor) || theme.info}
590
644
  wrapMode="none"
591
645
  >
592
646
  {textValue}
@@ -606,7 +660,7 @@ function ListItemRow(props: {
606
660
  <text
607
661
  key={`tag-${tagValue}`}
608
662
  flexShrink={0}
609
- fg={active ? Theme.background : resolveColor(tagColor) || Theme.warning}
663
+ fg={active ? theme.background : resolveColor(tagColor) || theme.warning}
610
664
  wrapMode="none"
611
665
  >
612
666
  [{tagValue}]
@@ -629,7 +683,7 @@ function ListItemRow(props: {
629
683
  <text
630
684
  key={`date-${dateValue.getTime()}`}
631
685
  flexShrink={0}
632
- fg={active ? Theme.background : resolveColor(dateColor) || Theme.success}
686
+ fg={active ? theme.background : resolveColor(dateColor) || theme.success}
633
687
  wrapMode="none"
634
688
  >
635
689
  {formatted}
@@ -647,9 +701,9 @@ function ListItemRow(props: {
647
701
  flexDirection: 'row',
648
702
  justifyContent: 'space-between',
649
703
  backgroundColor: active
650
- ? Theme.primary
704
+ ? theme.primary
651
705
  : isHovered
652
- ? Theme.backgroundPanel
706
+ ? theme.backgroundPanel
653
707
  : undefined,
654
708
  paddingLeft: 0,
655
709
  paddingRight: 1,
@@ -666,11 +720,11 @@ function ListItemRow(props: {
666
720
  >
667
721
  <box style={{ flexDirection: 'row', flexGrow: 1, flexShrink: 1, overflow: 'hidden', gap: 1 }}>
668
722
  <box style={{ flexDirection: 'row', flexShrink: 0 }}>
669
- <text flexShrink={0} fg={active ? Theme.background : Theme.text} attributes={active ? TextAttributes.BOLD : undefined} selectable={false} wrapMode="none">{active ? '›' : ' '}</text>
670
- {icon && <text flexShrink={0} fg={active ? Theme.background : iconColor || Theme.text} selectable={false} wrapMode="none">{getIconEmoji(icon)} </text>}
723
+ <text flexShrink={0} fg={active ? theme.background : theme.text} attributes={active ? TextAttributes.BOLD : undefined} selectable={false} wrapMode="none">{active ? '›' : ' '}</text>
724
+ {icon && <text flexShrink={0} fg={active ? theme.background : iconColor || theme.text} selectable={false} wrapMode="none">{getIconEmoji(icon)} </text>}
671
725
  <text
672
726
  flexShrink={0}
673
- fg={active ? Theme.background : Theme.text}
727
+ fg={active ? theme.background : theme.text}
674
728
  attributes={active ? TextAttributes.BOLD : undefined}
675
729
  selectable={false}
676
730
  wrapMode="none"
@@ -681,7 +735,7 @@ function ListItemRow(props: {
681
735
  {subtitle && (
682
736
  <text
683
737
  flexShrink={0}
684
- fg={active ? Theme.background : Theme.textMuted}
738
+ fg={active ? theme.background : theme.textMuted}
685
739
  selectable={false}
686
740
  wrapMode="none"
687
741
  >
@@ -719,12 +773,30 @@ export const List: ListType = (props) => {
719
773
  ...otherProps
720
774
  } = props
721
775
 
722
- const [internalSearchText, setInternalSearchTextRaw] = useState('')
776
+ const theme = useTheme()
777
+ const [internalSearchText, setInternalSearchText] = useState('')
723
778
  const [selectedIndex, setSelectedIndex] = useState(0)
724
779
  const [isDropdownOpen, setIsDropdownOpen] = useState(false)
725
780
  const [currentDetail, setCurrentDetail] = useState<ReactNode>(null)
726
- const [currentItemActions, setCurrentItemActions] = useState<ReactNode>(null)
781
+
727
782
  const inputRef = useRef<TextareaRenderable>(null)
783
+ const customEmptyViewRef = useRef(false)
784
+
785
+ // Ref callback that registers the textarea in global state for ESC handling
786
+ const setInputRef = useCallback((node: TextareaRenderable | null) => {
787
+ if (!node) return
788
+
789
+ inputRef.current = node
790
+ useStore.setState({ activeSearchInputRef: node })
791
+
792
+ // React 19: return cleanup function for unmount
793
+ return () => {
794
+ if (useStore.getState().activeSearchInputRef === node) {
795
+ useStore.setState({ activeSearchInputRef: null })
796
+ }
797
+ inputRef.current = null
798
+ }
799
+ }, [])
728
800
  const scrollBoxRef = useRef<ScrollBoxRenderable>(null)
729
801
  const descendantsContext = useListDescendants()
730
802
  const navigationPending = useNavigationPending()
@@ -773,12 +845,17 @@ export const List: ListType = (props) => {
773
845
  setIsDropdownOpen(true)
774
846
  }
775
847
 
776
- // Wrapper function that updates search text
777
- const setInternalSearchText = (value: string) => {
778
- // Using flushSync to force descendants to update visibility before querying
779
- flushSync(() => {
780
- setInternalSearchTextRaw(value)
781
- })
848
+ // Sync selection to the first visible item whenever searchText changes.
849
+ // Runs after children's useLayoutEffects (descendants registered) but before paint,
850
+ // so there is no intermediate frame with stale selection.
851
+ // Works for both controlled and uncontrolled searchText.
852
+ const prevSearchTextRef = useRef(searchText)
853
+ useLayoutEffect(() => {
854
+ if (prevSearchTextRef.current === searchText) return
855
+ prevSearchTextRef.current = searchText
856
+
857
+ if (!isFilteringEnabled) return
858
+
782
859
  const items = Object.values(descendantsContext.map.current)
783
860
  .filter((item) => item.index !== -1 && item.props?.visible !== false)
784
861
  .sort((a, b) => a.index - b.index)
@@ -786,7 +863,7 @@ export const List: ListType = (props) => {
786
863
  if (items.length > 0 && items[0]) {
787
864
  setSelectedIndex(items[0].index)
788
865
  }
789
- }
866
+ })
790
867
 
791
868
  const listContextValue = useMemo<ListContextValue>(
792
869
  () => ({
@@ -799,19 +876,22 @@ export const List: ListType = (props) => {
799
876
  isFiltering: isFilteringEnabled,
800
877
  setCurrentDetail,
801
878
  isShowingDetail,
879
+ customEmptyViewRef,
880
+ isLoading,
881
+ hasDropdown: !!searchBarAccessory,
802
882
  }),
803
- [isDropdownOpen, selectedIndex, searchText, isFilteringEnabled, isShowingDetail],
883
+ [isDropdownOpen, selectedIndex, searchText, isFilteringEnabled, isShowingDetail, isLoading, searchBarAccessory],
804
884
  )
805
885
 
806
- // Clear detail when detail view is hidden
807
- useEffect(() => {
886
+ // Clear detail when detail view is hidden (before paint to avoid flash)
887
+ useLayoutEffect(() => {
808
888
  if (!isShowingDetail) {
809
889
  setCurrentDetail(null)
810
890
  }
811
891
  }, [isShowingDetail])
812
892
 
813
- // Handle selectedItemId prop changes
814
- useEffect(() => {
893
+ // Handle selectedItemId prop changes (before paint to avoid flash)
894
+ useLayoutEffect(() => {
815
895
  // Only update selection if selectedItemId is explicitly provided
816
896
  if (selectedItemId !== undefined) {
817
897
  const items = Object.values(descendantsContext.map.current)
@@ -824,7 +904,7 @@ export const List: ListType = (props) => {
824
904
  }
825
905
  }, [selectedItemId])
826
906
 
827
- // Call onSelectionChange when selection changes and track current item's actions
907
+ // Call onSelectionChange when selection changes
828
908
  useEffect(() => {
829
909
  const items = Object.values(descendantsContext.map.current)
830
910
  .filter((item) => item.index !== -1)
@@ -832,21 +912,12 @@ export const List: ListType = (props) => {
832
912
 
833
913
  const currentItem = items.find((item) => item.index === selectedIndex)
834
914
 
835
- // Track current item's actions for footer display
836
- const actions = currentItem?.props?.actions ?? props.actions ?? null
837
- setCurrentItemActions(actions)
838
-
839
- // Clear first action title when there are no actions
840
- if (!actions) {
841
- useStore.setState({ firstActionTitle: '' })
842
- }
843
-
844
915
  // Call onSelectionChange callback if provided
845
916
  if (onSelectionChange) {
846
917
  const selectedId = currentItem?.props?.id ?? null
847
918
  onSelectionChange(selectedId)
848
919
  }
849
- }, [selectedIndex, props.actions])
920
+ }, [selectedIndex])
850
921
 
851
922
  const scrollToItem = (item: { props?: ListItemDescendant }) => {
852
923
  const scrollBox = scrollBoxRef.current
@@ -891,8 +962,10 @@ export const List: ListType = (props) => {
891
962
 
892
963
  const nextItem = items[nextVisibleIndex]
893
964
  if (nextItem) {
965
+ flushSync(() => {
966
+ setSelectedIndex(nextItem.index)
967
+ })
894
968
  scrollToItem(nextItem)
895
- setSelectedIndex(nextItem.index)
896
969
  }
897
970
  }
898
971
 
@@ -914,32 +987,23 @@ export const List: ListType = (props) => {
914
987
  .sort((a, b) => a.index - b.index)
915
988
  const currentItem = items.find((item) => item.index === selectedIndex)
916
989
 
917
- // Handle Ctrl+K to show actions (always show sheet)
990
+ // Handle Ctrl+K to show actions dialog via portal
918
991
  if (evt.name === 'k' && evt.ctrl) {
919
- // Show current item's actions if available
920
- if (currentItem?.props?.actions) {
921
- dialog.pushActions(currentItem.props.actions)
922
- }
923
- // Otherwise show List's own actions
924
- else if (props.actions) {
925
- dialog.pushActions(props.actions)
926
- }
927
- // Otherwise show empty ActionPanel (still has Settings section with Configure Extension, etc.)
928
- else {
929
- dialog.pushActions(<ActionPanel />)
992
+ const hasActions = currentItem?.props?.actions || props.actions
993
+ if (hasActions) {
994
+ useStore.setState({ showActionsDialog: true })
930
995
  }
931
996
  return
932
997
  }
933
998
 
934
999
  if (evt.name === 'up') move(-1)
935
1000
  if (evt.name === 'down') move(1)
936
- // Handle Enter to execute first action directly
1001
+ // Handle Enter to auto-execute first action via ActionPanel
937
1002
  if (evt.name === 'return') {
938
1003
  if (!currentItem?.props) return
939
1004
 
940
1005
  if (currentItem.props.actions) {
941
1006
  useStore.setState({ shouldAutoExecuteFirstAction: true })
942
- dialog.pushActions(currentItem.props.actions)
943
1007
  }
944
1008
  }
945
1009
  })
@@ -1002,9 +1066,9 @@ export const List: ListType = (props) => {
1002
1066
  flexShrink: 1,
1003
1067
  }}
1004
1068
  >
1005
- <text flexShrink={0} fg={Theme.textMuted}>&gt; </text>
1069
+ <text flexShrink={0} fg={theme.textMuted}>&gt; </text>
1006
1070
  <textarea
1007
- ref={inputRef}
1071
+ ref={setInputRef}
1008
1072
  height={1}
1009
1073
  flexGrow={1}
1010
1074
  wrapMode='none'
@@ -1019,9 +1083,9 @@ export const List: ListType = (props) => {
1019
1083
  const value = inputRef.current?.plainText || ''
1020
1084
  handleSearchChange(value)
1021
1085
  }}
1022
- focusedBackgroundColor={Theme.backgroundPanel}
1023
- cursorColor={Theme.primary}
1024
- focusedTextColor={Theme.text}
1086
+ focusedBackgroundColor={theme.backgroundPanel}
1087
+ cursorColor={theme.primary}
1088
+ focusedTextColor={theme.text}
1025
1089
  />
1026
1090
  </box>
1027
1091
  {searchBarAccessory}
@@ -1038,6 +1102,7 @@ export const List: ListType = (props) => {
1038
1102
  focused={false}
1039
1103
  flexGrow={1}
1040
1104
  flexShrink={1}
1105
+ minHeight={6}
1041
1106
  style={{
1042
1107
  rootOptions: {
1043
1108
  backgroundColor: undefined,
@@ -1056,10 +1121,11 @@ export const List: ListType = (props) => {
1056
1121
  {/* Footer with keyboard shortcuts or toast */}
1057
1122
  <ListFooter />
1058
1123
 
1059
- {/* Render current item's actions offscreen to collect first action title */}
1060
- {currentItemActions && (
1061
- <Offscreen>{currentItemActions}</Offscreen>
1062
- )}
1124
+ {/* Render current item's actions offscreen to capture them with context preserved */}
1125
+ <CurrentItemActionsOffscreen
1126
+ selectedIndex={selectedIndex}
1127
+ fallbackActions={props.actions}
1128
+ />
1063
1129
  </box>
1064
1130
 
1065
1131
  {/* Detail panel on the right */}
@@ -1073,7 +1139,7 @@ export const List: ListType = (props) => {
1073
1139
  }}
1074
1140
  border={['left']}
1075
1141
  borderStyle='single'
1076
- borderColor={Theme.border}
1142
+ borderColor={theme.border}
1077
1143
  >
1078
1144
  {currentDetail}
1079
1145
  </box>
@@ -1086,11 +1152,13 @@ export const List: ListType = (props) => {
1086
1152
  }
1087
1153
 
1088
1154
 
1089
- function DefaultEmptyView(): any {
1155
+ // Wrapper component that only renders children when no visible items exist
1156
+ function ShowOnNoItems(props: { children: ReactNode; isCustomEmptyView?: boolean }): any {
1090
1157
  // Subscribe to re-render when items are added/removed
1091
1158
  void useListDescendantsRerender()
1092
1159
  // Get live map ref for reading in useLayoutEffect
1093
1160
  const map = useListDescendantsMap()
1161
+ const listContext = useContext(ListContext)
1094
1162
  const [hasVisibleItems, setHasVisibleItems] = useState(true)
1095
1163
 
1096
1164
  // We must check visibility in useLayoutEffect because:
@@ -1102,27 +1170,36 @@ function DefaultEmptyView(): any {
1102
1170
  useLayoutEffect(() => {
1103
1171
  const items = Object.values(map.current)
1104
1172
  .filter((item) => item.index !== -1 && item.props?.visible !== false)
1105
- setHasVisibleItems(items.length > 0)
1173
+ // For default empty view, also check if custom empty view exists
1174
+ const hasCustomEmptyView = !props.isCustomEmptyView && (listContext?.customEmptyViewRef.current ?? false)
1175
+ setHasVisibleItems(items.length > 0 || hasCustomEmptyView)
1106
1176
  })
1107
1177
 
1108
1178
  if (hasVisibleItems) return null
1109
1179
 
1180
+ return props.children
1181
+ }
1182
+
1183
+ function DefaultEmptyView(): any {
1184
+ const theme = useTheme()
1110
1185
  return (
1111
- <box
1112
- style={{
1113
- flexDirection: 'column',
1114
- alignItems: 'center',
1115
- justifyContent: 'center',
1116
- paddingTop: 2,
1117
- paddingBottom: 2,
1118
- paddingLeft: 2,
1119
- paddingRight: 2,
1120
- }}
1121
- >
1122
- <text flexShrink={0} fg={Theme.textMuted}>
1123
- No items found
1124
- </text>
1125
- </box>
1186
+ <ShowOnNoItems>
1187
+ <box
1188
+ style={{
1189
+ flexDirection: 'column',
1190
+ alignItems: 'center',
1191
+ justifyContent: 'center',
1192
+ paddingTop: 2,
1193
+ paddingBottom: 2,
1194
+ paddingLeft: 2,
1195
+ paddingRight: 2,
1196
+ }}
1197
+ >
1198
+ <text flexShrink={0} fg={theme.textMuted}>
1199
+ No items found
1200
+ </text>
1201
+ </box>
1202
+ </ShowOnNoItems>
1126
1203
  )
1127
1204
  }
1128
1205
 
@@ -1197,8 +1274,8 @@ const ListItem: ListItemType = (props) => {
1197
1274
  const selectedIndex = listContext?.selectedIndex ?? 0
1198
1275
  const isActive = index === selectedIndex
1199
1276
 
1200
- // Update detail when this item becomes active or detail prop changes
1201
- useEffect(() => {
1277
+ // Update detail when this item becomes active or detail prop changes (before paint)
1278
+ useLayoutEffect(() => {
1202
1279
  if (isActive && listContext?.isShowingDetail && listContext?.setCurrentDetail) {
1203
1280
  listContext.setCurrentDetail(props.detail || null)
1204
1281
  }
@@ -1212,7 +1289,10 @@ const ListItem: ListItemType = (props) => {
1212
1289
  if (listContext && index !== -1) {
1213
1290
  // If clicking on already selected item, show actions (like pressing Enter)
1214
1291
  if (isActive) {
1215
- dialog.pushActions(props.actions || <ActionPanel />)
1292
+ // Show actions dialog via portal
1293
+ if (props.actions) {
1294
+ useStore.setState({ showActionsDialog: true })
1295
+ }
1216
1296
  } else if (listContext.setSelectedIndex) {
1217
1297
  // Otherwise just select the item
1218
1298
  listContext.setSelectedIndex(index)
@@ -1262,13 +1342,14 @@ const ListItem: ListItemType = (props) => {
1262
1342
  }
1263
1343
 
1264
1344
  const ListItemDetail: ListItemDetailType = (props) => {
1345
+ const theme = useTheme()
1265
1346
  const { isLoading, markdown, metadata } = props
1266
1347
 
1267
1348
  return (
1268
1349
  <box style={{ flexDirection: 'column', flexGrow: 1 }}>
1269
1350
  {isLoading && (
1270
1351
  <box style={{ paddingBottom: 1 }}>
1271
- <text flexShrink={0} fg={Theme.textMuted}>Loading...</text>
1352
+ <text flexShrink={0} fg={theme.textMuted}>Loading...</text>
1272
1353
  </box>
1273
1354
  )}
1274
1355
 
@@ -1287,16 +1368,16 @@ const ListItemDetail: ListItemDetailType = (props) => {
1287
1368
  },
1288
1369
  }}
1289
1370
  >
1290
- <box style={{ flexDirection: 'column' }}>
1371
+ <box gap={1} style={{ flexDirection: 'column' }}>
1291
1372
  {markdown && (
1292
1373
  <code content={markdown} filetype="markdown" syntaxStyle={markdownSyntaxStyle} drawUnstyledText={false} />
1293
1374
  )}
1294
1375
  {metadata && (
1295
1376
  <box
1296
1377
  style={{ paddingTop: 1 }}
1297
- border={['top']}
1298
- borderStyle='single'
1299
- borderColor={Theme.border}
1378
+ // border={['top']}
1379
+ // borderStyle='single'
1380
+ // borderColor={theme.border}
1300
1381
  >
1301
1382
  {metadata}
1302
1383
  </box>
@@ -1307,65 +1388,32 @@ const ListItemDetail: ListItemDetailType = (props) => {
1307
1388
  )
1308
1389
  }
1309
1390
 
1310
- const ListItemDetailMetadata = (props: MetadataProps) => {
1311
- return (
1312
- <box style={{ flexDirection: 'column' }}>
1313
- {props.children}
1314
- </box>
1315
- )
1316
- }
1317
-
1318
- const ListItemDetailMetadataLabel = (props: { title: string; text?: string; icon?: Image.ImageLike }) => {
1319
- return (
1320
- <box style={{ flexDirection: 'column', paddingBottom: 0.5 }}>
1321
- <text flexShrink={0} fg={Theme.textMuted}>{props.title}:</text>
1322
- {props.text && <text flexShrink={0} fg={Theme.text}>{props.text}</text>}
1323
- </box>
1324
- )
1325
- }
1326
-
1327
- const ListItemDetailMetadataSeparator = () => {
1328
- return (
1329
- <box style={{ paddingBottom: 0.5 }}>
1330
- <text flexShrink={0} fg={Theme.border}>─────────────────</text>
1331
- </box>
1332
- )
1333
- }
1391
+ import { Metadata, MetadataContext } from 'termcast/src/components/metadata'
1392
+ import type { MetadataConfig } from 'termcast/src/components/metadata'
1334
1393
 
1335
- const ListItemDetailMetadataLink = (props: { title: string; target: string; text: string }) => {
1336
- return (
1337
- <box style={{ flexDirection: 'column', paddingBottom: 0.5 }}>
1338
- <text flexShrink={0} fg={Theme.textMuted}>{props.title}:</text>
1339
- <text flexShrink={0} fg={Theme.markdownLink}>{props.text}</text>
1340
- </box>
1341
- )
1394
+ // List.Item.Detail.Metadata config: smaller padding for compact list detail panel
1395
+ const listDetailMetadataConfig: MetadataConfig = {
1396
+ maxValueLen: 20,
1397
+ titleMinWidth: 12,
1398
+ paddingBottom: 0.5,
1399
+ separatorWidth: 17,
1342
1400
  }
1343
1401
 
1344
- const ListItemDetailMetadataTagList = (props: { title: string; children: ReactNode }) => {
1402
+ const ListItemDetailMetadata = (props: MetadataProps) => {
1345
1403
  return (
1346
- <box style={{ flexDirection: 'column', paddingBottom: 0.5 }}>
1347
- <text flexShrink={0} fg={Theme.textMuted}>{props.title}:</text>
1348
- <box style={{ flexDirection: 'row', paddingLeft: 1 }}>
1404
+ <MetadataContext.Provider value={listDetailMetadataConfig}>
1405
+ <box style={{ flexDirection: 'column' }}>
1349
1406
  {props.children}
1350
1407
  </box>
1351
- </box>
1352
- )
1353
- }
1354
-
1355
- const ListItemDetailMetadataTagListItem = (props: { text?: string; color?: Color.ColorLike; icon?: Image.ImageLike; onAction?: () => void }) => {
1356
- return (
1357
- <box style={{ paddingRight: 1 }}>
1358
- <text flexShrink={0} fg={resolveColor(props.color) || Theme.accent}>[{props.text}]</text>
1359
- </box>
1408
+ </MetadataContext.Provider>
1360
1409
  )
1361
1410
  }
1362
1411
 
1363
1412
  ListItemDetail.Metadata = ListItemDetailMetadata as any
1364
- ListItemDetailMetadata.Label = ListItemDetailMetadataLabel as any
1365
- ListItemDetailMetadata.Separator = ListItemDetailMetadataSeparator as any
1366
- ListItemDetailMetadata.Link = ListItemDetailMetadataLink as any
1367
- ListItemDetailMetadata.TagList = ListItemDetailMetadataTagList as any
1368
- ListItemDetailMetadataTagList.Item = ListItemDetailMetadataTagListItem as any
1413
+ ListItemDetailMetadata.Label = Metadata.Label as any
1414
+ ListItemDetailMetadata.Separator = Metadata.Separator as any
1415
+ ListItemDetailMetadata.Link = Metadata.Link as any
1416
+ ListItemDetailMetadata.TagList = Metadata.TagList as any
1369
1417
 
1370
1418
  ListItem.Detail = ListItemDetail
1371
1419
 
@@ -1378,6 +1426,7 @@ ListItem.Detail = ListItemDetail
1378
1426
  * value="" (or your preferred reset value) at the top of your dropdown items.
1379
1427
  */
1380
1428
  const ListDropdown: ListDropdownType = (props) => {
1429
+ const theme = useTheme()
1381
1430
  const listContext = useContext(ListContext)
1382
1431
  const [isHovered, setIsHovered] = useState(false)
1383
1432
 
@@ -1442,8 +1491,8 @@ const ListDropdown: ListDropdownType = (props) => {
1442
1491
  [],
1443
1492
  )
1444
1493
 
1445
- // Open dropdown dialog when triggered
1446
- useEffect(() => {
1494
+ // Open dropdown dialog when triggered (before paint to avoid flash)
1495
+ useLayoutEffect(() => {
1447
1496
  if (isDropdownOpen && !dialog.stack.length) {
1448
1497
  // Pass the children to the dialog to render them there
1449
1498
  dialog.push({
@@ -1499,7 +1548,7 @@ const ListDropdown: ListDropdownType = (props) => {
1499
1548
  // minWidth: value.length + 4,
1500
1549
  flexDirection: 'row',
1501
1550
  flexShrink: 0,
1502
- backgroundColor: isHovered ? Theme.backgroundPanel : undefined,
1551
+ backgroundColor: isHovered ? theme.backgroundPanel : undefined,
1503
1552
  }}
1504
1553
  onMouseMove={() => setIsHovered(true)}
1505
1554
  onMouseOut={() => setIsHovered(false)}
@@ -1511,16 +1560,22 @@ const ListDropdown: ListDropdownType = (props) => {
1511
1560
  }}
1512
1561
  >
1513
1562
  {/*<text >^p </text>*/}
1563
+ {listContext.isLoading ? (
1564
+ <LoadingText isLoading color={isHovered ? theme.text : theme.textMuted}>
1565
+ {displayValue || 'Loading...'}
1566
+ </LoadingText>
1567
+ ) : (
1568
+ <text
1569
+ flexShrink={0}
1570
+ fg={isHovered ? theme.text : theme.textMuted}
1571
+ selectable={false}
1572
+ >
1573
+ {displayValue}
1574
+ </text>
1575
+ )}
1514
1576
  <text
1515
1577
  flexShrink={0}
1516
- fg={isHovered ? Theme.text : Theme.textMuted}
1517
- selectable={false}
1518
- >
1519
- {displayValue}
1520
- </text>
1521
- <text
1522
- flexShrink={0}
1523
- fg={isHovered ? Theme.text : Theme.textMuted}
1578
+ fg={isHovered ? theme.text : theme.textMuted}
1524
1579
  selectable={false}
1525
1580
  >
1526
1581
  {' '}
@@ -1533,6 +1588,7 @@ const ListDropdown: ListDropdownType = (props) => {
1533
1588
  }
1534
1589
 
1535
1590
  ListDropdown.Item = (props) => {
1591
+ const theme = useTheme()
1536
1592
  const dropdownContext = useContext(DropdownContext)
1537
1593
  const [isHovered, setIsHovered] = useState(false)
1538
1594
 
@@ -1596,9 +1652,9 @@ ListDropdown.Item = (props) => {
1596
1652
  style={{
1597
1653
  flexDirection: 'row',
1598
1654
  backgroundColor: isActive
1599
- ? Theme.primary
1655
+ ? theme.primary
1600
1656
  : isHovered
1601
- ? Theme.backgroundPanel
1657
+ ? theme.backgroundPanel
1602
1658
  : undefined,
1603
1659
  paddingLeft: isActive ? 0 : 1,
1604
1660
  paddingRight: 1,
@@ -1611,7 +1667,7 @@ ListDropdown.Item = (props) => {
1611
1667
  >
1612
1668
  <box style={{ flexDirection: 'row' }}>
1613
1669
  {isActive && (
1614
- <text flexShrink={0} fg={Theme.background} selectable={false}>
1670
+ <text flexShrink={0} fg={theme.background} selectable={false}>
1615
1671
  ›{''}
1616
1672
  </text>
1617
1673
  )}
@@ -1619,10 +1675,10 @@ ListDropdown.Item = (props) => {
1619
1675
  flexShrink={0}
1620
1676
  fg={
1621
1677
  isActive
1622
- ? Theme.background
1678
+ ? theme.background
1623
1679
  : isCurrent
1624
- ? Theme.primary
1625
- : Theme.text
1680
+ ? theme.primary
1681
+ : theme.text
1626
1682
  }
1627
1683
  attributes={isActive ? TextAttributes.BOLD : undefined}
1628
1684
  selectable={false}
@@ -1638,6 +1694,7 @@ ListDropdown.Item = (props) => {
1638
1694
  }
1639
1695
 
1640
1696
  ListDropdown.Section = (props) => {
1697
+ const theme = useTheme()
1641
1698
  const parentContext = useContext(DropdownContext)
1642
1699
 
1643
1700
  // If not inside a Dropdown, just render nothing
@@ -1665,7 +1722,7 @@ ListDropdown.Section = (props) => {
1665
1722
  {/* Render section title if we're in the dialog and not searching */}
1666
1723
  {showTitle && (
1667
1724
  <box style={{ paddingTop: 1, paddingLeft: 1 }}>
1668
- <text flexShrink={0} fg={Theme.accent} attributes={TextAttributes.BOLD}>
1725
+ <text flexShrink={0} fg={theme.accent} attributes={TextAttributes.BOLD}>
1669
1726
  {props.title}
1670
1727
  </text>
1671
1728
  </box>
@@ -1679,16 +1736,13 @@ ListDropdown.Section = (props) => {
1679
1736
 
1680
1737
  List.Item = ListItem
1681
1738
  const ListSection = (props: SectionProps) => {
1739
+ const theme = useTheme()
1682
1740
  const parentContext = useContext(ListSectionContext)
1683
1741
  const listContext = useContext(ListContext)
1684
1742
  const searchText = listContext?.searchText || ''
1685
1743
 
1686
- // Don't render empty sections
1687
- if (React.Children.count(props.children) === 0) {
1688
- return null
1689
- }
1690
-
1691
1744
  // Create new context with section title and search text
1745
+ // NOTE: Must be called before any early returns to satisfy React hooks rules
1692
1746
  const sectionContextValue = useMemo(
1693
1747
  () => ({
1694
1748
  ...parentContext,
@@ -1698,6 +1752,11 @@ const ListSection = (props: SectionProps) => {
1698
1752
  [parentContext, props.title, searchText],
1699
1753
  )
1700
1754
 
1755
+ // Don't render empty sections
1756
+ if (React.Children.count(props.children) === 0) {
1757
+ return null
1758
+ }
1759
+
1701
1760
  const isSearching = searchText.trim().length > 0
1702
1761
 
1703
1762
  const children = (
@@ -1719,7 +1778,7 @@ const ListSection = (props: SectionProps) => {
1719
1778
  paddingLeft: 1,
1720
1779
  }}
1721
1780
  >
1722
- <text flexShrink={0} fg={Theme.accent} attributes={TextAttributes.BOLD}>
1781
+ <text flexShrink={0} fg={theme.accent} attributes={TextAttributes.BOLD}>
1723
1782
  {props.title}
1724
1783
  </text>
1725
1784
  </box>
@@ -1731,43 +1790,30 @@ const ListSection = (props: SectionProps) => {
1731
1790
 
1732
1791
  List.Section = ListSection
1733
1792
  List.Dropdown = ListDropdown
1734
- List.EmptyView = (props: EmptyViewProps) => {
1735
- const dialog = useDialog()
1793
+ // Inner component for EmptyView content (needs hooks at top level)
1794
+ function EmptyViewContent(props: EmptyViewProps): any {
1795
+ const theme = useTheme()
1736
1796
  const inFocus = useIsInFocus()
1737
1797
 
1738
1798
  // Handle keyboard for actions
1739
1799
  useKeyboard((evt) => {
1740
1800
  if (!inFocus) return
1741
1801
 
1742
- // Handle Ctrl+K to show actions (always show panel, even without actions)
1802
+ // Handle Ctrl+K to show actions dialog via portal
1743
1803
  if (evt.name === 'k' && evt.ctrl) {
1744
- dialog.pushActions(props.actions || <ActionPanel />)
1804
+ if (props.actions) {
1805
+ useStore.setState({ showActionsDialog: true })
1806
+ }
1745
1807
  return
1746
1808
  }
1747
1809
 
1748
- // Handle Enter to execute first action
1810
+ // Handle Enter to auto-execute first action via ActionPanel
1749
1811
  if (evt.name === 'return' && props.actions) {
1750
1812
  useStore.setState({ shouldAutoExecuteFirstAction: true })
1751
- dialog.pushActions(props.actions)
1752
1813
  }
1753
1814
  })
1754
1815
 
1755
- // Get icon string from ImageLike
1756
- const getIconString = (icon: Image.ImageLike): string | null => {
1757
- if (typeof icon === 'string') {
1758
- return getIconEmoji(icon)
1759
- }
1760
- if (icon && typeof icon === 'object' && 'source' in icon) {
1761
- // For { source: string } or { source: { light, dark } } objects
1762
- const source = icon.source
1763
- if (typeof source === 'string') {
1764
- return getIconEmoji(source)
1765
- }
1766
- }
1767
- return null
1768
- }
1769
-
1770
- const iconEmoji = props.icon ? getIconString(props.icon) : null
1816
+ const iconEmoji = props.icon ? getIconValue(props.icon) || null : null
1771
1817
 
1772
1818
  return (
1773
1819
  <box
@@ -1784,24 +1830,46 @@ List.EmptyView = (props: EmptyViewProps) => {
1784
1830
  }}
1785
1831
  >
1786
1832
  {iconEmoji && (
1787
- <text flexShrink={0} fg={Theme.textMuted} style={{ marginBottom: 1 }}>
1833
+ <text flexShrink={0} fg={theme.textMuted} style={{ marginBottom: 1 }}>
1788
1834
  {iconEmoji}
1789
1835
  </text>
1790
1836
  )}
1791
1837
  {props.title && (
1792
- <text flexShrink={0} fg={Theme.text} attributes={TextAttributes.BOLD}>
1838
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
1793
1839
  {props.title?.replace(/\bRaycast\b/g, 'Termcast').replace(/\braycast\b/g, 'termcast') || ''}
1794
1840
  </text>
1795
1841
  )}
1796
1842
  {props.description && (
1797
- <text flexShrink={0} fg={Theme.textMuted} wrapMode='word'>
1843
+ <text flexShrink={0} fg={theme.textMuted} wrapMode='word'>
1798
1844
  {props.description?.replace(/\bRaycast\b/g, 'Termcast').replace(/\braycast\b/g, 'termcast') || ''}
1799
1845
  </text>
1800
1846
  )}
1847
+ {/* Render actions offscreen to capture them */}
1848
+ {props.actions && <Offscreen>{props.actions}</Offscreen>}
1801
1849
  </box>
1802
1850
  )
1803
1851
  }
1804
1852
 
1853
+ List.EmptyView = (props: EmptyViewProps) => {
1854
+ const listContext = useContext(ListContext)
1855
+
1856
+ // Register that a custom empty view exists
1857
+ useLayoutEffect(() => {
1858
+ if (listContext?.customEmptyViewRef) {
1859
+ listContext.customEmptyViewRef.current = true
1860
+ return () => {
1861
+ listContext.customEmptyViewRef.current = false
1862
+ }
1863
+ }
1864
+ }, [listContext])
1865
+
1866
+ return (
1867
+ <ShowOnNoItems isCustomEmptyView>
1868
+ <EmptyViewContent {...props} />
1869
+ </ShowOnNoItems>
1870
+ )
1871
+ }
1872
+
1805
1873
  export default List
1806
1874
 
1807
1875
  // Grid Component Implementation