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
@@ -6,13 +6,14 @@ import React, {
6
6
  useRef,
7
7
  useEffect,
8
8
  } from 'react'
9
- import { useKeyboard } from '@opentui/react'
9
+ import { useKeyboard, flushSync } from '@opentui/react'
10
10
  import { useForm, FormProvider } from 'react-hook-form'
11
11
  import { ActionPanel } from 'termcast/src/components/actions'
12
12
  import { logger } from 'termcast/src/logger'
13
13
  import { InFocus, useIsInFocus } from 'termcast/src/internal/focus-context'
14
14
  import { useDialog } from 'termcast/src/internal/dialog'
15
- import { Theme } from 'termcast/src/theme'
15
+ import { Offscreen } from 'termcast/src/internal/offscreen'
16
+ import { useTheme } from 'termcast/src/theme'
16
17
  import { useStore } from 'termcast/src/state'
17
18
  import { Footer } from 'termcast/src/components/footer'
18
19
  import {
@@ -103,27 +104,28 @@ export const useFormSubmit = () => {
103
104
  }
104
105
 
105
106
  function FormFooter(): any {
107
+ const theme = useTheme()
106
108
  const hasToast = useStore((s) => s.toast !== null)
107
109
 
108
110
  const content = hasToast ? null : (
109
111
  <box style={{ flexDirection: 'row', gap: 3 }}>
110
112
  <box style={{ flexDirection: 'row', gap: 1 }}>
111
- <text flexShrink={0} fg={Theme.text} attributes={TextAttributes.BOLD}>
113
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
112
114
  ctrl ↵
113
115
  </text>
114
- <text flexShrink={0} fg={Theme.textMuted}>submit</text>
116
+ <text flexShrink={0} fg={theme.textMuted}>submit</text>
115
117
  </box>
116
118
  <box style={{ flexDirection: 'row', gap: 1 }}>
117
- <text flexShrink={0} fg={Theme.text} attributes={TextAttributes.BOLD}>
119
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
118
120
  tab
119
121
  </text>
120
- <text flexShrink={0} fg={Theme.textMuted}>navigate</text>
122
+ <text flexShrink={0} fg={theme.textMuted}>navigate</text>
121
123
  </box>
122
124
  <box style={{ flexDirection: 'row', gap: 1 }}>
123
- <text flexShrink={0} fg={Theme.text} attributes={TextAttributes.BOLD}>
125
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
124
126
  ^k
125
127
  </text>
126
- <text flexShrink={0} fg={Theme.textMuted}>actions</text>
128
+ <text flexShrink={0} fg={theme.textMuted}>actions</text>
127
129
  </box>
128
130
  </box>
129
131
  )
@@ -197,20 +199,12 @@ export const Form: FormType = ((props) => {
197
199
  const scrollBoxRef = useRef<ScrollBoxRenderable>(null)
198
200
  const descendantsContext = useFormFieldDescendants()
199
201
 
200
- // Helper to get sorted field IDs
201
- const getFieldIds = () => {
202
- return Object.values(descendantsContext.map.current)
203
- .filter((item) => item.index !== -1 && item.props?.id)
204
- .sort((a, b) => a.index - b.index)
205
- .map((item) => item.props!.id)
206
- }
207
-
208
202
  const scrollToField = (fieldId: string) => {
209
203
  const scrollBox = scrollBoxRef.current
210
204
  if (!scrollBox) return
211
205
 
212
- // Find field in descendants map by matching props.id
213
- const field = Object.values(descendantsContext.map.current).find(
206
+ // Find field in committedMap by matching props.id
207
+ const field = Object.values(descendantsContext.committedMap).find(
214
208
  (item) => item.props?.id === fieldId,
215
209
  )
216
210
  const elementRef = field?.props?.elementRef
@@ -228,12 +222,22 @@ export const Form: FormType = ((props) => {
228
222
  }
229
223
 
230
224
  const setFocusedField = (id: string | null) => {
231
- setFocusedFieldRaw(id)
225
+ flushSync(() => {
226
+ setFocusedFieldRaw(id)
227
+ })
232
228
  if (id) {
233
229
  scrollToField(id)
234
230
  }
235
231
  }
236
232
 
233
+ // Helper to get sorted field IDs - uses committedMap for stability
234
+ const getFieldIds = () => {
235
+ return Object.values(descendantsContext.committedMap)
236
+ .filter((item) => item.index !== -1 && item.props?.id)
237
+ .sort((a, b) => a.index - b.index)
238
+ .map((item) => item.props!.id)
239
+ }
240
+
237
241
  // Focus first field helper
238
242
  const focusFirstField = () => {
239
243
  const fieldIds = getFieldIds()
@@ -257,16 +261,15 @@ export const Form: FormType = ((props) => {
257
261
  }
258
262
 
259
263
  // Auto-focus first field when descendants become available
260
- // Runs on every render until a field is focused (handles async loading)
261
264
  useEffect(() => {
262
265
  if (focusedField) return
263
266
 
264
267
  const fieldIds = getFieldIds()
265
268
  if (fieldIds.length > 0) {
266
269
  logger.log(`auto-focusing first field:`, fieldIds[0])
267
- setFocusedFieldRaw(fieldIds[0])
270
+ setFocusedField(fieldIds[0])
268
271
  }
269
- })
272
+ }, [descendantsContext.committedMap])
270
273
 
271
274
  // Get focus state and dialog
272
275
  const inFocus = useIsInFocus()
@@ -328,31 +331,15 @@ export const Form: FormType = ((props) => {
328
331
  }
329
332
 
330
333
  if (evt.name === 'k' && evt.ctrl) {
331
- // Ctrl+K shows actions (always show panel, even without actions)
332
- dialog.pushActions(
333
- <FormSubmitContext.Provider value={submitContextValue}>
334
- {props.actions || <ActionPanel />}
335
- </FormSubmitContext.Provider>,
336
- 'center',
337
- )
338
- } else if (evt.name === 'return' && evt.ctrl && props.actions) {
339
- // Ctrl+Return executes first action directly
340
- useStore.setState({ shouldAutoExecuteFirstAction: true })
341
- dialog.pushActions(
342
- <FormSubmitContext.Provider value={submitContextValue}>
343
- {props.actions}
344
- </FormSubmitContext.Provider>,
345
- 'center',
346
- )
347
- } else if (evt.name === 'return' && evt.meta && props.actions) {
348
- // Cmd+Return also executes first action directly
349
- useStore.setState({ shouldAutoExecuteFirstAction: true })
350
- dialog.pushActions(
351
- <FormSubmitContext.Provider value={submitContextValue}>
352
- {props.actions}
353
- </FormSubmitContext.Provider>,
354
- 'center',
355
- )
334
+ // Ctrl+K shows actions dialog via portal
335
+ if (props.actions) {
336
+ useStore.setState({ showActionsDialog: true })
337
+ }
338
+ } else if ((evt.name === 'return' && evt.ctrl) || (evt.name === 'return' && evt.meta)) {
339
+ // Ctrl+Return or Cmd+Return auto-executes first action via ActionPanel
340
+ if (props.actions) {
341
+ useStore.setState({ shouldAutoExecuteFirstAction: true })
342
+ }
356
343
  }
357
344
  })
358
345
 
@@ -426,6 +413,8 @@ export const Form: FormType = ((props) => {
426
413
  </box>
427
414
  </ScrollBox>
428
415
  <FormFooter />
416
+ {/* Render actions offscreen to capture them with FormSubmitContext */}
417
+ {props.actions && <Offscreen>{props.actions}</Offscreen>}
429
418
  </box>
430
419
  </box>
431
420
  </FocusContext.Provider>
@@ -461,6 +450,7 @@ Form.Description = Description
461
450
 
462
451
  // LinkAccessory component - shows a link in the navigation bar
463
452
  function LinkAccessory(props: LinkAccessoryProps): any {
453
+ const theme = useTheme()
464
454
  return (
465
455
  <box
466
456
  style={{
@@ -471,7 +461,7 @@ function LinkAccessory(props: LinkAccessoryProps): any {
471
461
  }}
472
462
  >
473
463
  <text
474
- fg={Theme.textMuted}
464
+ fg={theme.textMuted}
475
465
  attributes={TextAttributes.UNDERLINE}
476
466
  wrapMode='none'
477
467
  >
@@ -3,8 +3,8 @@ import { BoxRenderable } from '@opentui/core'
3
3
  import { useFormContext, Controller } from 'react-hook-form'
4
4
  import { useFocusContext, useFormFieldDescendant } from './index'
5
5
  import { FormItemProps, FormItemRef } from './types'
6
- import { Theme } from 'termcast/src/theme'
7
- import { WithLeftBorder } from './with-left-border'
6
+ import { useTheme } from 'termcast/src/theme'
7
+ import { WithLeftBorder, TitleIndicator } from './with-left-border'
8
8
  import { useFormNavigation } from './use-form-navigation'
9
9
  import { LoadingText } from 'termcast/src/components/loading-text'
10
10
 
@@ -15,6 +15,7 @@ export interface PasswordFieldProps extends FormItemProps<string> {
15
15
  export type PasswordFieldRef = FormItemRef
16
16
 
17
17
  export const PasswordField = (props: PasswordFieldProps): any => {
18
+ const theme = useTheme()
18
19
  const { control } = useFormContext()
19
20
  const focusContext = useFocusContext()
20
21
  const { focusedField, setFocusedField } = focusContext
@@ -40,21 +41,21 @@ export const PasswordField = (props: PasswordFieldProps): any => {
40
41
 
41
42
  return (
42
43
  <box ref={elementRef} flexDirection="column">
43
- <WithLeftBorder withDiamond isFocused={isFocused} isLoading={focusContext.isLoading}>
44
- <box
45
- onMouseDown={() => {
46
- setFocusedField(props.id)
47
- }}
48
- >
49
- <LoadingText
50
- isLoading={isFocused && focusContext.isLoading}
51
- color={isFocused ? Theme.primary : Theme.text}
44
+ <WithLeftBorder isFocused={isFocused} paddingBottom={1}>
45
+ <TitleIndicator isFocused={isFocused} isLoading={focusContext.isLoading}>
46
+ <box
47
+ onMouseDown={() => {
48
+ setFocusedField(props.id)
49
+ }}
52
50
  >
53
- {props.title || ''}
54
- </LoadingText>
55
- </box>
56
- </WithLeftBorder>
57
- <WithLeftBorder isFocused={isFocused}>
51
+ <LoadingText
52
+ isLoading={isFocused && focusContext.isLoading}
53
+ color={isFocused ? theme.primary : theme.text}
54
+ >
55
+ {props.title || ''}
56
+ </LoadingText>
57
+ </box>
58
+ </TitleIndicator>
58
59
  <input
59
60
  value={displayValue}
60
61
  onInput={(newDisplay: string) => {
@@ -85,19 +86,16 @@ export const PasswordField = (props: PasswordFieldProps): any => {
85
86
  setFocusedField(props.id)
86
87
  }}
87
88
  />
88
- </WithLeftBorder>
89
- {(fieldState.error || props.error) && (
90
- <WithLeftBorder isFocused={isFocused}>
91
- <text fg={Theme.error}>
89
+ {(fieldState.error || props.error || props.info) && <box height={1} />}
90
+ {(fieldState.error || props.error) && (
91
+ <text fg={theme.error}>
92
92
  {fieldState.error?.message || props.error}
93
93
  </text>
94
- </WithLeftBorder>
95
- )}
96
- {props.info && (
97
- <WithLeftBorder isFocused={isFocused}>
98
- <text fg={Theme.textMuted}>{props.info}</text>
99
- </WithLeftBorder>
100
- )}
94
+ )}
95
+ {props.info && (
96
+ <text fg={theme.textMuted}>{props.info}</text>
97
+ )}
98
+ </WithLeftBorder>
101
99
  </box>
102
100
  ) as React.ReactElement
103
101
  }}
@@ -1,13 +1,14 @@
1
1
  import React from 'react'
2
- import { Theme } from 'termcast/src/theme'
2
+ import { useTheme } from 'termcast/src/theme'
3
3
  import { WithLeftBorder } from './with-left-border'
4
4
 
5
5
  export const Separator = (): any => {
6
+ const theme = useTheme()
6
7
  return null
7
8
  return (
8
9
  <>
9
10
  <WithLeftBorder withDiamond isFocused={false}>
10
- <text fg={Theme.border}>{''.repeat(40)}</text>
11
+ <text fg={theme.border}>{''.repeat(40)}</text>
11
12
  </WithLeftBorder>
12
13
  |
13
14
  </>
@@ -6,6 +6,7 @@ import {
6
6
  RefAttributes,
7
7
  } from 'react'
8
8
  import { Image } from 'termcast/src/components/list'
9
+ import { getIconValue } from 'termcast/src/components/icon'
9
10
  import { Dropdown, DropdownProps, DropdownItemProps } from './dropdown'
10
11
 
11
12
  /**
@@ -158,7 +159,7 @@ const TagPickerItem: FunctionComponent<TagPickerItemProps> = (props): any => {
158
159
  <Dropdown.Item
159
160
  value={props.value}
160
161
  title={props.title || props.value}
161
- icon={props.icon as string | undefined}
162
+ icon={getIconValue(props.icon) || undefined}
162
163
  />
163
164
  )
164
165
  }
@@ -3,8 +3,8 @@ import { BoxRenderable, TextareaRenderable } from '@opentui/core'
3
3
  import { useFormContext } from 'react-hook-form'
4
4
  import { useFocusContext, useFormFieldDescendant } from './index'
5
5
  import { FormItemProps, FormItemRef } from './types'
6
- import { Theme } from 'termcast/src/theme'
7
- import { WithLeftBorder } from './with-left-border'
6
+ import { useTheme } from 'termcast/src/theme'
7
+ import { WithLeftBorder, TitleIndicator } from './with-left-border'
8
8
  import { useFormNavigation } from './use-form-navigation'
9
9
  import { createTextareaFormRef } from './form-ref'
10
10
  import { LoadingText } from 'termcast/src/components/loading-text'
@@ -17,6 +17,7 @@ export interface TextAreaProps extends FormItemProps<string> {
17
17
  export type TextAreaRef = FormItemRef
18
18
 
19
19
  export const TextArea = (props: TextAreaProps): any => {
20
+ const theme = useTheme()
20
21
  const { register, formState } = useFormContext()
21
22
  const focusContext = useFocusContext()
22
23
  const { focusedField, setFocusedField } = focusContext
@@ -60,23 +61,22 @@ export const TextArea = (props: TextAreaProps): any => {
60
61
 
61
62
  return (
62
63
  <box ref={elementRef} flexDirection="column">
63
- <WithLeftBorder withDiamond isFocused={isFocused} isLoading={focusContext.isLoading}>
64
- <box
65
- onMouseDown={() => {
66
- setFocusedField(props.id)
67
- }}
68
- >
69
- <LoadingText
70
- isLoading={isFocused && focusContext.isLoading}
71
- color={isFocused ? Theme.primary : Theme.text}
64
+ <WithLeftBorder isFocused={isFocused} paddingBottom={1}>
65
+ <TitleIndicator isFocused={isFocused} isLoading={focusContext.isLoading}>
66
+ <box
67
+ onMouseDown={() => {
68
+ setFocusedField(props.id)
69
+ }}
72
70
  >
73
- {props.title || ''}
74
- </LoadingText>
75
- </box>
76
- </WithLeftBorder>
77
-
78
- <WithLeftBorder isFocused={isFocused}>
79
- <box flexGrow={1}>
71
+ <LoadingText
72
+ isLoading={isFocused && focusContext.isLoading}
73
+ color={isFocused ? theme.primary : theme.text}
74
+ >
75
+ {props.title || ''}
76
+ </LoadingText>
77
+ </box>
78
+ </TitleIndicator>
79
+ <box flexGrow={1} paddingBottom={1}>
80
80
  <textarea
81
81
  ref={handleRef}
82
82
  wrapMode='none'
@@ -90,20 +90,15 @@ export const TextArea = (props: TextAreaProps): any => {
90
90
  }}
91
91
  />
92
92
  </box>
93
- </WithLeftBorder>
94
-
95
- {(fieldError || props.error) && (
96
- <WithLeftBorder isFocused={isFocused}>
97
- <text fg={Theme.error}>
93
+ {(fieldError || props.error) && (
94
+ <text fg={theme.error}>
98
95
  {(fieldError?.message as string) || props.error}
99
96
  </text>
100
- </WithLeftBorder>
101
- )}
102
- {props.info && (
103
- <WithLeftBorder isFocused={isFocused}>
104
- <text fg={Theme.textMuted}>{props.info}</text>
105
- </WithLeftBorder>
106
- )}
97
+ )}
98
+ {props.info && (
99
+ <text fg={theme.textMuted}>{props.info}</text>
100
+ )}
101
+ </WithLeftBorder>
107
102
  </box>
108
103
  )
109
104
  }
@@ -3,8 +3,8 @@ import { BoxRenderable, TextareaRenderable } from '@opentui/core'
3
3
  import { useFormContext } from 'react-hook-form'
4
4
  import { useFocusContext, useFormFieldDescendant } from './index'
5
5
  import { FormItemProps, FormItemRef } from './types'
6
- import { Theme } from 'termcast/src/theme'
7
- import { WithLeftBorder } from './with-left-border'
6
+ import { useTheme } from 'termcast/src/theme'
7
+ import { WithLeftBorder, TitleIndicator } from './with-left-border'
8
8
  import { useFormNavigation } from './use-form-navigation'
9
9
  import { createTextareaFormRef } from './form-ref'
10
10
  import { LoadingText } from 'termcast/src/components/loading-text'
@@ -16,6 +16,7 @@ export interface TextFieldProps extends FormItemProps<string> {
16
16
  export type TextFieldRef = FormItemRef
17
17
 
18
18
  export const TextField = (props: TextFieldProps): any => {
19
+ const theme = useTheme()
19
20
  const { register, formState } = useFormContext()
20
21
  const focusContext = useFocusContext()
21
22
  const { focusedField, setFocusedField } = focusContext
@@ -61,21 +62,21 @@ export const TextField = (props: TextFieldProps): any => {
61
62
 
62
63
  return (
63
64
  <box ref={elementRef} flexDirection="column">
64
- <WithLeftBorder withDiamond isFocused={isFocused} isLoading={focusContext.isLoading}>
65
- <box
66
- onMouseDown={() => {
67
- setFocusedField(props.id)
68
- }}
69
- >
70
- <LoadingText
71
- isLoading={isFocused && focusContext.isLoading}
72
- color={isFocused ? Theme.primary : Theme.text}
65
+ <WithLeftBorder isFocused={isFocused} paddingBottom={1}>
66
+ <TitleIndicator isFocused={isFocused} isLoading={focusContext.isLoading}>
67
+ <box
68
+ onMouseDown={() => {
69
+ setFocusedField(props.id)
70
+ }}
73
71
  >
74
- {props.title || ''}
75
- </LoadingText>
76
- </box>
77
- </WithLeftBorder>
78
- <WithLeftBorder isFocused={isFocused}>
72
+ <LoadingText
73
+ isLoading={isFocused && focusContext.isLoading}
74
+ color={isFocused ? theme.primary : theme.text}
75
+ >
76
+ {props.title || ''}
77
+ </LoadingText>
78
+ </box>
79
+ </TitleIndicator>
79
80
  <textarea
80
81
  ref={handleRef}
81
82
  height={1}
@@ -92,19 +93,16 @@ export const TextField = (props: TextFieldProps): any => {
92
93
  setFocusedField(props.id)
93
94
  }}
94
95
  />
95
- </WithLeftBorder>
96
- {(fieldError || props.error) && (
97
- <WithLeftBorder isFocused={isFocused}>
98
- <text fg={Theme.error}>
96
+ {(fieldError || props.error || props.info) && <box height={1} />}
97
+ {(fieldError || props.error) && (
98
+ <text fg={theme.error}>
99
99
  {(fieldError?.message as string) || props.error}
100
100
  </text>
101
- </WithLeftBorder>
102
- )}
103
- {props.info && (
104
- <WithLeftBorder isFocused={isFocused}>
105
- <text fg={Theme.textMuted}>{props.info}</text>
106
- </WithLeftBorder>
107
- )}
101
+ )}
102
+ {props.info && (
103
+ <text fg={theme.textMuted}>{props.info}</text>
104
+ )}
105
+ </WithLeftBorder>
108
106
  </box>
109
107
  )
110
108
  }
@@ -7,10 +7,10 @@ export function useFormNavigationHelpers(id: string) {
7
7
  const scrollContext = useFormScrollContext()
8
8
  const { setFocusedField } = useFocusContext()
9
9
 
10
- // Get sorted field IDs from descendants
10
+ // Get sorted field IDs from descendants - use committedMap for stability
11
11
  const getFieldIds = () => {
12
12
  if (!scrollContext) return []
13
- const descendants = Object.values(scrollContext.descendantsContext.map.current)
13
+ const descendants = Object.values(scrollContext.descendantsContext.committedMap)
14
14
  .filter((item) => item.index !== -1 && item.props?.id)
15
15
  .sort((a, b) => a.index - b.index)
16
16
  return descendants.map((item) => item.props!.id)
@@ -53,9 +53,8 @@ export function useFormNavigation(
53
53
  const isInFocus = useIsInFocus()
54
54
  const isFocused = focusedField === id
55
55
 
56
- let { handleArrows = true, handleTabs = true } = options || {}
57
-
58
- handleArrows = false
56
+ // handleTabs defaults to false to avoid conflict with global Form handler
57
+ let { handleArrows = true, handleTabs = false } = options || {}
59
58
 
60
59
  const { navigateToPrevious, navigateToNext } = useFormNavigationHelpers(id)
61
60
 
@@ -1,17 +1,16 @@
1
1
  import React from 'react'
2
- import { Theme } from 'termcast/src/theme'
2
+ import { useTheme } from 'termcast/src/theme'
3
3
  import { colord } from 'colord'
4
4
  import { useAnimationTick, TICK_DIVISORS } from 'termcast/src/components/animation-tick'
5
5
 
6
- const spinnerFrames = [
7
- { char: ' ', color: Theme.accent },
8
- { char: '·', color: Theme.accent },
9
- { char: '•', color: colord(Theme.accent).lighten(0.1).toHex() },
10
- // { char: '●', color: colord(Theme.accent).lighten(0.2).toHex() },
11
- ]
12
-
13
6
  function Spinner(): any {
7
+ const theme = useTheme()
14
8
  const tick = useAnimationTick(TICK_DIVISORS.SPINNER)
9
+ const spinnerFrames = [
10
+ { char: ' ', color: theme.accent },
11
+ { char: '·', color: theme.accent },
12
+ { char: '•', color: colord(theme.accent).lighten(0.1).toHex() },
13
+ ]
15
14
  const frame = spinnerFrames[tick % spinnerFrames.length]
16
15
  return (
17
16
  <text flexShrink={0} fg={frame.color}>
@@ -20,6 +19,44 @@ function Spinner(): any {
20
19
  )
21
20
  }
22
21
 
22
+ interface TitleIndicatorProps {
23
+ isFocused: boolean
24
+ isLoading?: boolean
25
+ customCharacter?: { focused: string; unfocused: string }
26
+ children: React.ReactNode
27
+ }
28
+
29
+ /**
30
+ * TitleIndicator renders a title row with a diamond/custom indicator that
31
+ * uses negative margin to overlay the left border character.
32
+ * Must be used inside a WithLeftBorder component.
33
+ */
34
+ export const TitleIndicator = ({
35
+ isFocused,
36
+ isLoading,
37
+ customCharacter,
38
+ children,
39
+ }: TitleIndicatorProps): any => {
40
+ const theme = useTheme()
41
+ const chars = customCharacter || { focused: '◆', unfocused: '◇' }
42
+ const color = isFocused ? theme.accent : theme.text
43
+
44
+ return (
45
+ <box flexDirection='row' marginLeft={-3}>
46
+ {isFocused && isLoading ? (
47
+ <Spinner />
48
+ ) : (
49
+ <text flexShrink={0} fg={color}>
50
+ <b>{isFocused ? chars.focused : chars.unfocused}</b>
51
+ </text>
52
+ )}
53
+ <box flexShrink={0} flexGrow={1} paddingLeft={2}>
54
+ {children}
55
+ </box>
56
+ </box>
57
+ )
58
+ }
59
+
23
60
  interface WithLeftBorderProps {
24
61
  children: React.ReactNode
25
62
  withDiamond?: boolean
@@ -42,9 +79,10 @@ export const WithLeftBorder = ({
42
79
  paddingLeft = 2,
43
80
  paddingTop = 0,
44
81
  }: WithLeftBorderProps): any => {
82
+ const theme = useTheme()
45
83
  if (withDiamond || customCharacter) {
46
84
  const chars = customCharacter || { focused: '◆', unfocused: '◇' }
47
- const color = isFocused ? Theme.accent : Theme.text
85
+ const color = isFocused ? theme.accent : theme.text
48
86
  return (
49
87
  <box flexDirection='row'>
50
88
  {isFocused && isLoading ? (
@@ -65,7 +103,7 @@ export const WithLeftBorder = ({
65
103
  paddingLeft={paddingLeft}
66
104
  border={['left']}
67
105
  // borderStyle={isFocused ? 'heavy' : 'single'}
68
- borderColor={isFocused ? Theme.accent : Theme.text}
106
+ borderColor={isFocused ? theme.accent : theme.text}
69
107
  flexShrink={0}
70
108
  flexDirection='row'
71
109
  >
@@ -529,6 +529,75 @@ export function getIconEmoji(icon: string): string {
529
529
  return icon
530
530
  }
531
531
 
532
+ /**
533
+ * Extract icon string from any icon format.
534
+ * Handles all Raycast icon formats:
535
+ * - string: direct icon name or emoji
536
+ * - { source: string }: image with source
537
+ * - { source: string, tintColor?: string }: image with tint
538
+ * - { source: { light, dark } }: theme-aware source
539
+ * - { value: ImageLike, tooltip: string }: icon with tooltip
540
+ * - { light: string, dark: string }: theme-aware icon directly
541
+ * - { fileIcon: string }: file icon
542
+ *
543
+ * Returns empty string for unhandled formats (never [object Object])
544
+ */
545
+ export function getIconValue(icon: unknown): string {
546
+ if (!icon) {
547
+ return ''
548
+ }
549
+
550
+ if (typeof icon === 'string') {
551
+ return getIconEmoji(icon)
552
+ }
553
+
554
+ if (typeof icon !== 'object') {
555
+ return ''
556
+ }
557
+
558
+ const obj = icon as Record<string, unknown>
559
+
560
+ // Handle { value: ..., tooltip: ... } format
561
+ if ('value' in obj && obj.value) {
562
+ return getIconValue(obj.value)
563
+ }
564
+
565
+ // Handle { source: ... } format
566
+ if ('source' in obj) {
567
+ const source = obj.source
568
+ if (typeof source === 'string') {
569
+ return getIconEmoji(source)
570
+ }
571
+ // Handle { source: { light, dark } }
572
+ if (typeof source === 'object' && source !== null) {
573
+ const sourceObj = source as Record<string, unknown>
574
+ if ('light' in sourceObj && typeof sourceObj.light === 'string') {
575
+ return getIconEmoji(sourceObj.light)
576
+ }
577
+ if ('dark' in sourceObj && typeof sourceObj.dark === 'string') {
578
+ return getIconEmoji(sourceObj.dark)
579
+ }
580
+ }
581
+ return ''
582
+ }
583
+
584
+ // Handle { light, dark } directly on icon
585
+ if ('light' in obj && typeof obj.light === 'string') {
586
+ return getIconEmoji(obj.light)
587
+ }
588
+ if ('dark' in obj && typeof obj.dark === 'string') {
589
+ return getIconEmoji(obj.dark)
590
+ }
591
+
592
+ // Handle { fileIcon: string }
593
+ if ('fileIcon' in obj && typeof obj.fileIcon === 'string') {
594
+ return '📁'
595
+ }
596
+
597
+ // Unknown format - return empty string instead of [object Object]
598
+ return ''
599
+ }
600
+
532
601
  interface IconProps {
533
602
  source: string
534
603
  tintColor?: string