termcast 1.3.30 → 1.3.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/dist/apis/cache.d.ts.map +1 -1
  2. package/dist/apis/cache.js +4 -39
  3. package/dist/apis/cache.js.map +1 -1
  4. package/dist/apis/hud.d.ts.map +1 -1
  5. package/dist/apis/hud.js +13 -31
  6. package/dist/apis/hud.js.map +1 -1
  7. package/dist/apis/localstorage.d.ts.map +1 -1
  8. package/dist/apis/localstorage.js +3 -27
  9. package/dist/apis/localstorage.js.map +1 -1
  10. package/dist/apis/toast.d.ts +16 -43
  11. package/dist/apis/toast.d.ts.map +1 -1
  12. package/dist/apis/toast.js +78 -177
  13. package/dist/apis/toast.js.map +1 -1
  14. package/dist/build.d.ts +3 -1
  15. package/dist/build.d.ts.map +1 -1
  16. package/dist/build.js +52 -2
  17. package/dist/build.js.map +1 -1
  18. package/dist/cli.d.ts +1 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +206 -25
  21. package/dist/cli.js.map +1 -1
  22. package/dist/colors.d.ts.map +1 -1
  23. package/dist/colors.js +1 -0
  24. package/dist/colors.js.map +1 -1
  25. package/dist/compile.d.ts +0 -1
  26. package/dist/compile.d.ts.map +1 -1
  27. package/dist/compile.js +18 -23
  28. package/dist/compile.js.map +1 -1
  29. package/dist/components/actions.d.ts.map +1 -1
  30. package/dist/components/actions.js +30 -15
  31. package/dist/components/actions.js.map +1 -1
  32. package/dist/components/animation-tick.d.ts +12 -0
  33. package/dist/components/animation-tick.d.ts.map +1 -0
  34. package/dist/components/animation-tick.js +63 -0
  35. package/dist/components/animation-tick.js.map +1 -0
  36. package/dist/components/detail.d.ts.map +1 -1
  37. package/dist/components/detail.js +10 -13
  38. package/dist/components/detail.js.map +1 -1
  39. package/dist/components/dropdown.d.ts +1 -0
  40. package/dist/components/dropdown.d.ts.map +1 -1
  41. package/dist/components/dropdown.js +27 -26
  42. package/dist/components/dropdown.js.map +1 -1
  43. package/dist/components/extension-preferences.d.ts.map +1 -1
  44. package/dist/components/extension-preferences.js +15 -10
  45. package/dist/components/extension-preferences.js.map +1 -1
  46. package/dist/components/footer.d.ts +13 -0
  47. package/dist/components/footer.d.ts.map +1 -0
  48. package/dist/components/footer.js +106 -0
  49. package/dist/components/footer.js.map +1 -0
  50. package/dist/components/form/file-autocomplete.d.ts +19 -4
  51. package/dist/components/form/file-autocomplete.d.ts.map +1 -1
  52. package/dist/components/form/file-autocomplete.js +56 -55
  53. package/dist/components/form/file-autocomplete.js.map +1 -1
  54. package/dist/components/form/file-picker.d.ts.map +1 -1
  55. package/dist/components/form/file-picker.js +26 -15
  56. package/dist/components/form/file-picker.js.map +1 -1
  57. package/dist/components/form/index.d.ts.map +1 -1
  58. package/dist/components/form/index.js +17 -15
  59. package/dist/components/form/index.js.map +1 -1
  60. package/dist/components/form/with-left-border.d.ts.map +1 -1
  61. package/dist/components/form/with-left-border.js +4 -12
  62. package/dist/components/form/with-left-border.js.map +1 -1
  63. package/dist/components/list.d.ts.map +1 -1
  64. package/dist/components/list.js +126 -86
  65. package/dist/components/list.js.map +1 -1
  66. package/dist/components/loading-bar.d.ts.map +1 -1
  67. package/dist/components/loading-bar.js +5 -22
  68. package/dist/components/loading-bar.js.map +1 -1
  69. package/dist/components/loading-text.d.ts.map +1 -1
  70. package/dist/components/loading-text.js +3 -22
  71. package/dist/components/loading-text.js.map +1 -1
  72. package/dist/components/theme-picker.d.ts +2 -0
  73. package/dist/components/theme-picker.d.ts.map +1 -0
  74. package/dist/components/theme-picker.js +37 -0
  75. package/dist/components/theme-picker.js.map +1 -0
  76. package/dist/descendants.d.ts +6 -0
  77. package/dist/descendants.d.ts.map +1 -1
  78. package/dist/descendants.js +74 -8
  79. package/dist/descendants.js.map +1 -1
  80. package/dist/examples/internal/descendants-rerender.d.ts +14 -0
  81. package/dist/examples/internal/descendants-rerender.d.ts.map +1 -0
  82. package/dist/examples/internal/descendants-rerender.js +145 -0
  83. package/dist/examples/internal/descendants-rerender.js.map +1 -0
  84. package/dist/examples/internal/simple-dialog.js +4 -1
  85. package/dist/examples/internal/simple-dialog.js.map +1 -1
  86. package/dist/examples/internal/simple-scrollbox.js +1 -1
  87. package/dist/examples/internal/simple-scrollbox.js.map +1 -1
  88. package/dist/examples/list-with-dropdown.js +1 -1
  89. package/dist/examples/list-with-dropdown.js.map +1 -1
  90. package/dist/examples/miscellaneous.js +1 -1
  91. package/dist/examples/miscellaneous.js.map +1 -1
  92. package/dist/examples/toast-action.d.ts +2 -0
  93. package/dist/examples/toast-action.d.ts.map +1 -0
  94. package/dist/examples/toast-action.js +76 -0
  95. package/dist/examples/toast-action.js.map +1 -0
  96. package/dist/examples/toast-variations.js +38 -36
  97. package/dist/examples/toast-variations.js.map +1 -1
  98. package/dist/extensions/dev.d.ts +1 -1
  99. package/dist/extensions/dev.d.ts.map +1 -1
  100. package/dist/extensions/dev.js +62 -30
  101. package/dist/extensions/dev.js.map +1 -1
  102. package/dist/extensions/home.d.ts.map +1 -1
  103. package/dist/extensions/home.js +4 -3
  104. package/dist/extensions/home.js.map +1 -1
  105. package/dist/extensions/react-refresh-init.d.ts +5 -0
  106. package/dist/extensions/react-refresh-init.d.ts.map +1 -0
  107. package/dist/extensions/react-refresh-init.js +52 -0
  108. package/dist/extensions/react-refresh-init.js.map +1 -0
  109. package/dist/internal/date-picker-widget.js +1 -1
  110. package/dist/internal/date-picker-widget.js.map +1 -1
  111. package/dist/internal/dialog.d.ts +8 -3
  112. package/dist/internal/dialog.d.ts.map +1 -1
  113. package/dist/internal/dialog.js +37 -53
  114. package/dist/internal/dialog.js.map +1 -1
  115. package/dist/internal/navigation.d.ts +1 -0
  116. package/dist/internal/navigation.d.ts.map +1 -1
  117. package/dist/internal/navigation.js +25 -1
  118. package/dist/internal/navigation.js.map +1 -1
  119. package/dist/internal/providers.d.ts.map +1 -1
  120. package/dist/internal/providers.js +9 -197
  121. package/dist/internal/providers.js.map +1 -1
  122. package/dist/internal/scrollbox.d.ts.map +1 -1
  123. package/dist/internal/scrollbox.js +1 -0
  124. package/dist/internal/scrollbox.js.map +1 -1
  125. package/dist/release.d.ts +1 -0
  126. package/dist/release.d.ts.map +1 -1
  127. package/dist/release.js +16 -9
  128. package/dist/release.js.map +1 -1
  129. package/dist/state.d.ts +27 -1
  130. package/dist/state.d.ts.map +1 -1
  131. package/dist/state.js +6 -0
  132. package/dist/state.js.map +1 -1
  133. package/dist/theme.d.ts +6 -19
  134. package/dist/theme.d.ts.map +1 -1
  135. package/dist/theme.js +76 -45
  136. package/dist/theme.js.map +1 -1
  137. package/dist/themes/aura.json +69 -0
  138. package/dist/themes/ayu.json +80 -0
  139. package/dist/themes/catppuccin-frappe.json +233 -0
  140. package/dist/themes/catppuccin-macchiato.json +233 -0
  141. package/dist/themes/catppuccin.json +112 -0
  142. package/dist/themes/cobalt2.json +228 -0
  143. package/dist/themes/cursor.json +249 -0
  144. package/dist/themes/dracula.json +219 -0
  145. package/dist/themes/everforest.json +241 -0
  146. package/dist/themes/flexoki.json +237 -0
  147. package/dist/themes/github-light.json +56 -0
  148. package/dist/themes/github.json +241 -0
  149. package/dist/themes/gruvbox.json +95 -0
  150. package/dist/themes/kanagawa.json +77 -0
  151. package/dist/themes/lucent-orng.json +227 -0
  152. package/dist/themes/material.json +235 -0
  153. package/dist/themes/matrix.json +77 -0
  154. package/dist/themes/mercury.json +245 -0
  155. package/dist/themes/monokai.json +221 -0
  156. package/dist/themes/nightowl.json +221 -0
  157. package/dist/themes/nord.json +223 -0
  158. package/dist/themes/one-dark.json +84 -0
  159. package/dist/themes/opencode-light.json +62 -0
  160. package/dist/themes/opencode.json +245 -0
  161. package/dist/themes/orng.json +245 -0
  162. package/dist/themes/palenight.json +222 -0
  163. package/dist/themes/rosepine.json +234 -0
  164. package/dist/themes/solarized.json +223 -0
  165. package/dist/themes/synthwave84.json +226 -0
  166. package/dist/themes/termcast.json +226 -0
  167. package/dist/themes/tokyonight.json +243 -0
  168. package/dist/themes/vercel.json +255 -0
  169. package/dist/themes/vesper.json +218 -0
  170. package/dist/themes/zenburn.json +223 -0
  171. package/dist/themes.d.ts +57 -0
  172. package/dist/themes.d.ts.map +1 -0
  173. package/dist/themes.js +181 -0
  174. package/dist/themes.js.map +1 -0
  175. package/dist/utils/run-command.d.ts +2 -1
  176. package/dist/utils/run-command.d.ts.map +1 -1
  177. package/dist/utils/run-command.js +20 -10
  178. package/dist/utils/run-command.js.map +1 -1
  179. package/dist/utils.d.ts +2 -1
  180. package/dist/utils.d.ts.map +1 -1
  181. package/dist/utils.js +90 -17
  182. package/dist/utils.js.map +1 -1
  183. package/dist/watcher.d.ts +3 -0
  184. package/dist/watcher.d.ts.map +1 -0
  185. package/dist/watcher.js +16 -0
  186. package/dist/watcher.js.map +1 -0
  187. package/package.json +16 -10
  188. package/src/apis/cache.tsx +5 -44
  189. package/src/apis/hud.tsx +17 -62
  190. package/src/apis/localstorage.tsx +3 -32
  191. package/src/apis/toast.tsx +91 -275
  192. package/src/build.test.tsx +10 -0
  193. package/src/build.tsx +61 -1
  194. package/src/cli.tsx +365 -103
  195. package/src/colors.tsx +1 -0
  196. package/src/compile.tsx +21 -29
  197. package/src/compile.vitest.tsx +300 -0
  198. package/src/components/actions.tsx +64 -45
  199. package/src/components/animation-tick.tsx +85 -0
  200. package/src/components/detail.tsx +31 -35
  201. package/src/components/dropdown.tsx +32 -21
  202. package/src/components/extension-preferences.tsx +14 -10
  203. package/src/components/footer.tsx +241 -0
  204. package/src/components/form/file-autocomplete.tsx +80 -60
  205. package/src/components/form/file-picker.tsx +37 -25
  206. package/src/components/form/index.tsx +45 -41
  207. package/src/components/form/with-left-border.tsx +4 -14
  208. package/src/components/list.tsx +181 -121
  209. package/src/components/loading-bar.tsx +5 -25
  210. package/src/components/loading-text.tsx +4 -23
  211. package/src/components/theme-picker.tsx +57 -0
  212. package/src/descendants.tsx +98 -9
  213. package/src/examples/actions-dialog-layout.vitest.tsx +112 -0
  214. package/src/examples/file-autocomplete.vitest.tsx +131 -122
  215. package/src/examples/form-basic.vitest.tsx +463 -644
  216. package/src/examples/form-dropdown.vitest.tsx +553 -571
  217. package/src/examples/form-scroll.vitest.tsx +112 -102
  218. package/src/examples/form-tagpicker.vitest.tsx +364 -338
  219. package/src/examples/internal/descendants-rerender.tsx +273 -0
  220. package/src/examples/internal/descendants-rerender.vitest.tsx +194 -0
  221. package/src/examples/internal/simple-dialog.tsx +4 -4
  222. package/src/examples/internal/simple-scrollbox.tsx +2 -2
  223. package/src/examples/internal/simple-scrollbox.vitest.tsx +43 -31
  224. package/src/examples/list-detail-metadata.vitest.tsx +34 -30
  225. package/src/examples/list-dropdown-default.vitest.tsx +84 -72
  226. package/src/examples/list-empty-view.vitest.tsx +93 -0
  227. package/src/examples/list-fetch-data.vitest.tsx +36 -30
  228. package/src/examples/list-scrollbox.vitest.tsx +59 -39
  229. package/src/examples/list-with-detail.vitest.tsx +339 -314
  230. package/src/examples/list-with-dropdown.tsx +1 -0
  231. package/src/examples/list-with-dropdown.vitest.tsx +176 -150
  232. package/src/examples/list-with-sections.vitest.tsx +289 -270
  233. package/src/examples/list-with-toast.vitest.tsx +44 -44
  234. package/src/examples/miscellaneous.tsx +10 -0
  235. package/src/examples/simple-file-picker.vitest.tsx +90 -86
  236. package/src/examples/simple-grid.vitest.tsx +275 -249
  237. package/src/examples/simple-navigation.vitest.tsx +192 -168
  238. package/src/examples/store.vitest.tsx +6 -4
  239. package/src/examples/swift-extension.vitest.tsx +31 -19
  240. package/src/examples/synonyms.vitest.tsx +93 -83
  241. package/src/examples/toast-action.tsx +160 -0
  242. package/src/examples/toast-action.vitest.tsx +404 -0
  243. package/src/examples/toast-variations.tsx +58 -57
  244. package/src/examples/toast-variations.vitest.tsx +186 -166
  245. package/src/extensions/dev.tsx +74 -33
  246. package/src/extensions/dev.vitest.tsx +162 -69
  247. package/src/extensions/home.tsx +5 -6
  248. package/src/extensions/react-refresh-init.tsx +59 -0
  249. package/src/internal/date-picker-widget.tsx +1 -1
  250. package/src/internal/dialog.tsx +59 -83
  251. package/src/internal/navigation.tsx +37 -4
  252. package/src/internal/providers.tsx +27 -315
  253. package/src/internal/scrollbox.tsx +1 -0
  254. package/src/release.tsx +16 -10
  255. package/src/state.tsx +36 -3
  256. package/src/theme.tsx +82 -51
  257. package/src/themes/aura.json +69 -0
  258. package/src/themes/ayu.json +80 -0
  259. package/src/themes/catppuccin-frappe.json +233 -0
  260. package/src/themes/catppuccin-macchiato.json +233 -0
  261. package/src/themes/catppuccin.json +112 -0
  262. package/src/themes/cobalt2.json +228 -0
  263. package/src/themes/cursor.json +249 -0
  264. package/src/themes/dracula.json +219 -0
  265. package/src/themes/everforest.json +241 -0
  266. package/src/themes/flexoki.json +237 -0
  267. package/src/themes/github-light.json +56 -0
  268. package/src/themes/github.json +241 -0
  269. package/src/themes/gruvbox.json +95 -0
  270. package/src/themes/kanagawa.json +77 -0
  271. package/src/themes/lucent-orng.json +227 -0
  272. package/src/themes/material.json +235 -0
  273. package/src/themes/matrix.json +77 -0
  274. package/src/themes/mercury.json +252 -0
  275. package/src/themes/monokai.json +221 -0
  276. package/src/themes/nightowl.json +221 -0
  277. package/src/themes/nord.json +223 -0
  278. package/src/themes/one-dark.json +84 -0
  279. package/src/themes/opencode-light.json +62 -0
  280. package/src/themes/opencode.json +245 -0
  281. package/src/themes/orng.json +245 -0
  282. package/src/themes/palenight.json +222 -0
  283. package/src/themes/rosepine.json +234 -0
  284. package/src/themes/solarized.json +223 -0
  285. package/src/themes/synthwave84.json +226 -0
  286. package/src/themes/termcast.json +227 -0
  287. package/src/themes/tokyonight.json +243 -0
  288. package/src/themes/vercel.json +255 -0
  289. package/src/themes/vesper.json +218 -0
  290. package/src/themes/zenburn.json +223 -0
  291. package/src/themes.ts +291 -0
  292. package/src/utils/run-command.tsx +23 -12
  293. package/src/utils.tsx +115 -18
  294. package/src/watcher.tsx +19 -0
@@ -1,71 +1,90 @@
1
1
  import React, { useState } from 'react'
2
2
  import { useQuery } from '@tanstack/react-query'
3
3
  import { Theme } from 'termcast/src/theme'
4
- import { TextareaRenderable } from '@opentui/core'
5
- import { listAllFiles } from '../../utils/file-system'
4
+ import { searchFiles, parsePath } from '../../utils/file-system'
6
5
  import { useKeyboard } from '@opentui/react'
7
6
  import { useIsInFocus } from 'termcast/src/internal/focus-context'
7
+ import { createStore, useStore } from 'zustand'
8
+ import os from 'os'
9
+
10
+ const homedir = os.homedir()
11
+
12
+ interface FileAutocompleteState {
13
+ filter: string
14
+ }
15
+
16
+ /**
17
+ * Creates a local zustand store for sharing state between FilePicker and FileAutocompleteDialog.
18
+ * This is necessary because dialog.push() freezes props at creation time - props won't update.
19
+ * Using zustand allows the dialog to subscribe to state changes reactively.
20
+ */
21
+ export function createFileAutocompleteStore() {
22
+ return createStore<FileAutocompleteState>(() => ({
23
+ filter: '',
24
+ }))
25
+ }
26
+
27
+ export type FileAutocompleteStore = ReturnType<typeof createFileAutocompleteStore>
8
28
 
9
29
  export interface FileAutocompleteDialogProps {
30
+ /**
31
+ * NOTE: Do not pass frequently-changing values as props here.
32
+ * dialog.push() freezes props at creation time - they won't update.
33
+ * Use the `store` prop for reactive state instead.
34
+ */
35
+ store: FileAutocompleteStore
10
36
  onSelect: (path: string) => void
11
37
  onClose: () => void
12
- inputRef: React.RefObject<TextareaRenderable | null>
38
+ onNavigate: (path: string) => void
13
39
  canChooseFiles?: boolean
14
40
  canChooseDirectories?: boolean
15
41
  initialDirectory?: string
16
42
  }
17
43
 
18
- function fuzzyMatch(text: string, query: string): boolean {
19
- if (!query) return true
20
- const lowerText = text.toLowerCase()
21
- const lowerQuery = query.toLowerCase()
22
-
23
- let queryIndex = 0
24
- for (let i = 0; i < lowerText.length && queryIndex < lowerQuery.length; i++) {
25
- if (lowerText[i] === lowerQuery[queryIndex]) {
26
- queryIndex++
27
- }
28
- }
29
- return queryIndex === lowerQuery.length
30
- }
31
-
32
44
  export const FileAutocompleteDialog = ({
45
+ store,
33
46
  onSelect,
34
47
  onClose,
35
- inputRef,
48
+ onNavigate,
36
49
  canChooseFiles = true,
37
50
  canChooseDirectories = false,
38
51
  initialDirectory,
39
52
  }: FileAutocompleteDialogProps): any => {
40
53
  const [selectedIndex, setSelectedIndex] = useState(0)
41
- const [filter, setFilter] = useState(() => inputRef.current?.plainText || '')
42
54
  const isInFocus = useIsInFocus()
55
+
56
+ // Subscribe to filter from zustand store (reactive)
57
+ const filter = useStore(store, (s) => s.filter)
58
+
59
+ // Reset selection when filter changes
60
+ const prevFilterRef = React.useRef(filter)
61
+ if (prevFilterRef.current !== filter) {
62
+ prevFilterRef.current = filter
63
+ setSelectedIndex(0)
64
+ }
43
65
 
44
- const { data: allFiles = [], isLoading } = useQuery({
45
- queryKey: ['file-list', initialDirectory, canChooseFiles, canChooseDirectories],
66
+ // Parse the filter to extract base path and prefix
67
+ // When filter is empty, use initialDirectory with empty prefix to show all files
68
+ const { basePath, prefix } = filter
69
+ ? parsePath(filter)
70
+ : { basePath: initialDirectory || '.', prefix: '' }
71
+
72
+ const { data: files = [], isLoading } = useQuery({
73
+ queryKey: ['file-search', basePath, prefix, canChooseFiles],
46
74
  queryFn: async () => {
47
- // Always include directories in the listing, filter afterwards
48
- const files = await listAllFiles({
49
- basePath: initialDirectory || '.',
50
- maxDepth: 4,
51
- maxFiles: 500,
52
- includeDirectories: true,
53
- })
75
+ const items = await searchFiles(basePath, prefix)
54
76
 
55
- // Filter based on canChooseFiles/canChooseDirectories
56
- return files.filter((f) => {
57
- const isDir = f.endsWith('/')
58
- if (isDir) {
59
- return canChooseDirectories
77
+ // Always show directories for navigation, filter files based on canChooseFiles
78
+ return items.filter((item) => {
79
+ if (item.isDirectory) {
80
+ return true
60
81
  }
61
82
  return canChooseFiles
62
83
  })
63
84
  },
64
85
  })
65
86
 
66
- // Filter files based on current input
67
- const filteredFiles = allFiles.filter((file) => fuzzyMatch(file, filter))
68
- const visibleFiles = filteredFiles.slice(0, 10)
87
+ const visibleFiles = files.slice(0, 10)
69
88
 
70
89
  useKeyboard((evt) => {
71
90
  if (!isInFocus) return
@@ -77,32 +96,33 @@ export const FileAutocompleteDialog = ({
77
96
  } else if (evt.name === 'return' || evt.name === 'tab') {
78
97
  const selected = visibleFiles[selectedIndex]
79
98
  if (selected) {
80
- const path = selected.endsWith('/') ? selected.slice(0, -1) : selected
81
- inputRef.current?.setText(path)
82
- onSelect(path)
83
- onClose()
84
- }
85
- } else if (evt.name === 'backspace') {
86
- if (filter.length > 0) {
87
- const newFilter = filter.slice(0, -1)
88
- setFilter(newFilter)
89
- setSelectedIndex(0)
99
+ if (selected.isDirectory) {
100
+ if (canChooseDirectories) {
101
+ // Select directory
102
+ onSelect(selected.path)
103
+ onClose()
104
+ } else {
105
+ // Navigate into directory
106
+ onNavigate(selected.path + '/')
107
+ }
108
+ } else {
109
+ // Select file
110
+ onSelect(selected.path)
111
+ onClose()
112
+ }
90
113
  }
91
- } else if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
92
- // Single character input
93
- const newFilter = filter + evt.name
94
- setFilter(newFilter)
95
- setSelectedIndex(0)
96
114
  }
97
115
  })
98
116
 
99
117
  const hintText = '↑↓ navigate ⏎/tab select esc close'
100
118
 
101
119
  return (
102
- <box flexDirection='column' paddingLeft={1} paddingRight={1}>
120
+ <box flexDirection='column' paddingLeft={1} paddingRight={1} overflow='hidden'>
103
121
  <box flexDirection='row'>
104
- <text fg={Theme.textMuted}>Filter: </text>
105
- <text fg={Theme.primary}>{filter || '(type to filter)'}</text>
122
+ <text fg={Theme.textMuted} wrapMode='none'>Filter: </text>
123
+ <text fg={Theme.primary} wrapMode='none'>
124
+ {filter ? filter.replace(homedir, '~') : '(type to filter)'}
125
+ </text>
106
126
  </box>
107
127
  <box height={1} />
108
128
  {isLoading ? (
@@ -111,23 +131,23 @@ export const FileAutocompleteDialog = ({
111
131
  <text fg={Theme.textMuted}>No files found</text>
112
132
  ) : (
113
133
  <>
114
- {visibleFiles.map((file, index) => {
115
- const isDir = file.endsWith('/')
116
- const icon = isDir ? '📁 ' : '📄 '
134
+ {visibleFiles.map((item, index) => {
135
+ const icon = item.isDirectory ? '' : '▪ '
117
136
  return (
118
137
  <text
119
- key={file}
138
+ key={item.path}
120
139
  fg={index === selectedIndex ? Theme.background : Theme.text}
121
140
  bg={index === selectedIndex ? Theme.primary : Theme.backgroundPanel}
141
+ wrapMode='none'
122
142
  >
123
- {' '}{icon}{file}
143
+ {' '}{icon}{item.name}{item.isDirectory ? '/' : ''}
124
144
  </text>
125
145
  )
126
146
  })}
127
147
  </>
128
148
  )}
129
149
  <box height={1} />
130
- <text fg={Theme.textMuted}>{hintText}</text>
150
+ <text fg={Theme.textMuted} wrapMode='none'>{hintText}</text>
131
151
  </box>
132
152
  )
133
153
  }
@@ -7,7 +7,7 @@ import { useFormContext, Controller } from 'react-hook-form'
7
7
  import { useFocusContext, useFormFieldDescendant } from './index'
8
8
  import { useKeyboard } from '@opentui/react'
9
9
  import { useIsInFocus } from 'termcast/src/internal/focus-context'
10
- import { FileAutocompleteDialog } from './file-autocomplete'
10
+ import { FileAutocompleteDialog, createFileAutocompleteStore } from './file-autocomplete'
11
11
  import { useFormNavigationHelpers } from './use-form-navigation'
12
12
  import { useDialog } from 'termcast/src/internal/dialog'
13
13
  import { LoadingText } from 'termcast/src/components/loading-text'
@@ -68,38 +68,47 @@ const FilePickerField = ({
68
68
  const inputRef = React.useRef<TextareaRenderable>(null)
69
69
  const dialog = useDialog()
70
70
 
71
+ // Create store once for sharing state with dialog
72
+ const [store] = React.useState(() => createFileAutocompleteStore())
73
+
71
74
  const showAutocomplete = () => {
72
75
  if (dialog.stack.length > 0) return
73
76
 
74
- const handleSelect = (path: string) => {
75
- const currentFiles = field.value || []
76
- const newFiles =
77
- props.allowMultipleSelection !== false ? [...currentFiles, path] : [path]
78
- field.onChange(newFiles)
79
- if (props.onChange) {
80
- props.onChange(newFiles)
81
- }
82
- inputRef.current?.setText('')
83
- dialog.clear()
84
- }
85
-
86
- dialog.push(
87
- <FileAutocompleteDialog
88
- onSelect={handleSelect}
89
- onClose={() => {
90
- dialog.clear()
91
- }}
92
- inputRef={inputRef}
93
- canChooseFiles={props.canChooseFiles}
94
- canChooseDirectories={props.canChooseDirectories}
95
- initialDirectory={props.initialDirectory}
96
- />
97
- )
77
+ dialog.push({
78
+ element: (
79
+ <FileAutocompleteDialog
80
+ store={store}
81
+ onSelect={(path) => {
82
+ const currentFiles = field.value || []
83
+ const newFiles =
84
+ props.allowMultipleSelection !== false ? [...currentFiles, path] : [path]
85
+ field.onChange(newFiles)
86
+ if (props.onChange) {
87
+ props.onChange(newFiles)
88
+ }
89
+ inputRef.current?.setText('')
90
+ store.setState({ filter: '' })
91
+ dialog.clear()
92
+ }}
93
+ onNavigate={(path) => {
94
+ inputRef.current?.setText(path)
95
+ store.setState({ filter: path })
96
+ }}
97
+ onClose={() => {
98
+ dialog.clear()
99
+ }}
100
+ canChooseFiles={props.canChooseFiles}
101
+ canChooseDirectories={props.canChooseDirectories}
102
+ initialDirectory={props.initialDirectory}
103
+ />
104
+ ),
105
+ })
98
106
  }
99
107
 
100
108
  // Handle Enter key and left arrow for removing last file
101
109
  useKeyboard((evt) => {
102
110
  if (!isFocused || !isInFocus) return
111
+ if (dialog.stack.length > 0) return // Let autocomplete handle keys
103
112
 
104
113
  // Left arrow removes last selected file when input is empty
105
114
  if (evt.name === 'left') {
@@ -132,6 +141,7 @@ const FilePickerField = ({
132
141
  props.onChange(newFiles)
133
142
  }
134
143
  inputRef.current?.setText('')
144
+ store.setState({ filter: '' })
135
145
  }
136
146
  }
137
147
  })
@@ -167,9 +177,11 @@ const FilePickerField = ({
167
177
  initialValue=""
168
178
  placeholder={props.placeholder || 'Enter file path...'}
169
179
  focused={isFocused}
180
+ showCursor={dialog.stack.length === 0}
170
181
  onMouseDown={() => setFocusedField(props.id)}
171
182
  onContentChange={() => {
172
183
  const value = inputRef.current?.plainText || ''
184
+ store.setState({ filter: value })
173
185
  if (value && isFocused) {
174
186
  showAutocomplete()
175
187
  }
@@ -14,6 +14,7 @@ import { InFocus, useIsInFocus } from 'termcast/src/internal/focus-context'
14
14
  import { useDialog } from 'termcast/src/internal/dialog'
15
15
  import { Theme } from 'termcast/src/theme'
16
16
  import { useStore } from 'termcast/src/state'
17
+ import { Footer } from 'termcast/src/components/footer'
17
18
  import {
18
19
  TextAttributes,
19
20
  ScrollBoxRenderable,
@@ -105,37 +106,29 @@ function FormFooter(): any {
105
106
  const hasToast = useStore((s) => s.toast !== null)
106
107
 
107
108
  const content = hasToast ? null : (
108
- <>
109
- <text fg={Theme.text} attributes={TextAttributes.BOLD}>
110
- ctrl
111
- </text>
112
- <text fg={Theme.textMuted}> submit</text>
113
- <text fg={Theme.text} attributes={TextAttributes.BOLD}>
114
- {' '}tab
115
- </text>
116
- <text fg={Theme.textMuted}> navigate</text>
117
- <text fg={Theme.text} attributes={TextAttributes.BOLD}>
118
- {' '}^k
119
- </text>
120
- <text fg={Theme.textMuted}> actions</text>
121
- </>
122
- )
123
-
124
- return (
125
- <box
126
- border={false}
127
- height={1}
128
- style={{
129
- paddingLeft: 1,
130
- paddingRight: 1,
131
- paddingTop: 1,
132
- marginTop: 1,
133
- flexDirection: 'row',
134
- }}
135
- >
136
- {content}
109
+ <box style={{ flexDirection: 'row', gap: 3 }}>
110
+ <box style={{ flexDirection: 'row', gap: 1 }}>
111
+ <text flexShrink={0} fg={Theme.text} attributes={TextAttributes.BOLD}>
112
+ ctrl ↵
113
+ </text>
114
+ <text flexShrink={0} fg={Theme.textMuted}>submit</text>
115
+ </box>
116
+ <box style={{ flexDirection: 'row', gap: 1 }}>
117
+ <text flexShrink={0} fg={Theme.text} attributes={TextAttributes.BOLD}>
118
+ tab
119
+ </text>
120
+ <text flexShrink={0} fg={Theme.textMuted}>navigate</text>
121
+ </box>
122
+ <box style={{ flexDirection: 'row', gap: 1 }}>
123
+ <text flexShrink={0} fg={Theme.text} attributes={TextAttributes.BOLD}>
124
+ ^k
125
+ </text>
126
+ <text flexShrink={0} fg={Theme.textMuted}>actions</text>
127
+ </box>
137
128
  </box>
138
129
  )
130
+
131
+ return <Footer>{content}</Footer>
139
132
  }
140
133
 
141
134
  import type { TextFieldProps, TextFieldRef } from './text-field'
@@ -334,31 +327,31 @@ export const Form: FormType = ((props) => {
334
327
  return
335
328
  }
336
329
 
337
- if (evt.name === 'k' && evt.ctrl && props.actions) {
338
- // Ctrl+K shows actions (always show sheet)
339
- dialog.push(
330
+ if (evt.name === 'k' && evt.ctrl) {
331
+ // Ctrl+K shows actions (always show panel, even without actions)
332
+ dialog.pushActions(
340
333
  <FormSubmitContext.Provider value={submitContextValue}>
341
- {props.actions}
334
+ {props.actions || <ActionPanel />}
342
335
  </FormSubmitContext.Provider>,
343
- 'bottom-right',
336
+ 'center',
344
337
  )
345
338
  } else if (evt.name === 'return' && evt.ctrl && props.actions) {
346
339
  // Ctrl+Return executes first action directly
347
340
  useStore.setState({ shouldAutoExecuteFirstAction: true })
348
- dialog.push(
341
+ dialog.pushActions(
349
342
  <FormSubmitContext.Provider value={submitContextValue}>
350
343
  {props.actions}
351
344
  </FormSubmitContext.Provider>,
352
- 'bottom-right',
345
+ 'center',
353
346
  )
354
347
  } else if (evt.name === 'return' && evt.meta && props.actions) {
355
348
  // Cmd+Return also executes first action directly
356
349
  useStore.setState({ shouldAutoExecuteFirstAction: true })
357
- dialog.push(
350
+ dialog.pushActions(
358
351
  <FormSubmitContext.Provider value={submitContextValue}>
359
352
  {props.actions}
360
353
  </FormSubmitContext.Provider>,
361
- 'bottom-right',
354
+ 'center',
362
355
  )
363
356
  }
364
357
  })
@@ -376,12 +369,23 @@ export const Form: FormType = ((props) => {
376
369
  <FormProvider {...methods}>
377
370
  <FormSubmitContext.Provider value={submitContextValue}>
378
371
  <FormScrollContext.Provider value={scrollContextValue}>
379
- <FocusContext.Provider value={{ focusedField, setFocusedField, isLoading: isLoading || false }}>
380
- <box flexDirection='row' flexGrow={1} justifyContent='center'>
372
+ <FocusContext.Provider
373
+ value={{
374
+ focusedField,
375
+ setFocusedField,
376
+ isLoading: isLoading || false,
377
+ }}
378
+ >
379
+ <box
380
+ flexDirection='row'
381
+ flexGrow={1}
382
+ paddingTop={2}
383
+ justifyContent='center'
384
+ >
381
385
  <box flexGrow={0} flexDirection='column'>
382
386
  <ScrollBox
383
387
  ref={scrollBoxRef}
384
- flexGrow={1}
388
+ // flexGrow={1}
385
389
  style={{
386
390
  rootOptions: {
387
391
  maxWidth: FORM_MAX_WIDTH,
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
- import { TextAttributes } from '@opentui/core'
3
2
  import { Theme } from 'termcast/src/theme'
4
3
  import { colord } from 'colord'
4
+ import { useAnimationTick, TICK_DIVISORS } from 'termcast/src/components/animation-tick'
5
5
 
6
6
  const spinnerFrames = [
7
7
  { char: ' ', color: Theme.accent },
@@ -11,18 +11,8 @@ const spinnerFrames = [
11
11
  ]
12
12
 
13
13
  function Spinner(): any {
14
- const [index, setIndex] = React.useState(0)
15
-
16
- React.useEffect(() => {
17
- const interval = setInterval(() => {
18
- setIndex((i) => (i + 1) % spinnerFrames.length)
19
- }, 200)
20
- return () => {
21
- clearInterval(interval)
22
- }
23
- }, [])
24
-
25
- const frame = spinnerFrames[index]
14
+ const tick = useAnimationTick(TICK_DIVISORS.SPINNER)
15
+ const frame = spinnerFrames[tick % spinnerFrames.length]
26
16
  return (
27
17
  <text flexShrink={0} fg={frame.color}>
28
18
  <b>{frame.char}</b>
@@ -75,7 +65,7 @@ export const WithLeftBorder = ({
75
65
  paddingLeft={paddingLeft}
76
66
  border={['left']}
77
67
  // borderStyle={isFocused ? 'heavy' : 'single'}
78
- borderColor={isFocused ? Theme.accent : undefined}
68
+ borderColor={isFocused ? Theme.accent : Theme.text}
79
69
  flexShrink={0}
80
70
  flexDirection='row'
81
71
  >