termcast 1.3.30 → 1.3.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/dist/apis/cache.d.ts.map +1 -1
  2. package/dist/apis/cache.js +4 -39
  3. package/dist/apis/cache.js.map +1 -1
  4. package/dist/apis/hud.d.ts.map +1 -1
  5. package/dist/apis/hud.js +13 -31
  6. package/dist/apis/hud.js.map +1 -1
  7. package/dist/apis/localstorage.d.ts.map +1 -1
  8. package/dist/apis/localstorage.js +3 -27
  9. package/dist/apis/localstorage.js.map +1 -1
  10. package/dist/apis/toast.d.ts +16 -43
  11. package/dist/apis/toast.d.ts.map +1 -1
  12. package/dist/apis/toast.js +78 -177
  13. package/dist/apis/toast.js.map +1 -1
  14. package/dist/build.d.ts +3 -1
  15. package/dist/build.d.ts.map +1 -1
  16. package/dist/build.js +52 -2
  17. package/dist/build.js.map +1 -1
  18. package/dist/cli.d.ts +1 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +206 -25
  21. package/dist/cli.js.map +1 -1
  22. package/dist/colors.d.ts.map +1 -1
  23. package/dist/colors.js +1 -0
  24. package/dist/colors.js.map +1 -1
  25. package/dist/compile.d.ts +0 -1
  26. package/dist/compile.d.ts.map +1 -1
  27. package/dist/compile.js +18 -23
  28. package/dist/compile.js.map +1 -1
  29. package/dist/components/actions.d.ts.map +1 -1
  30. package/dist/components/actions.js +30 -15
  31. package/dist/components/actions.js.map +1 -1
  32. package/dist/components/animation-tick.d.ts +12 -0
  33. package/dist/components/animation-tick.d.ts.map +1 -0
  34. package/dist/components/animation-tick.js +63 -0
  35. package/dist/components/animation-tick.js.map +1 -0
  36. package/dist/components/detail.d.ts.map +1 -1
  37. package/dist/components/detail.js +10 -13
  38. package/dist/components/detail.js.map +1 -1
  39. package/dist/components/dropdown.d.ts +1 -0
  40. package/dist/components/dropdown.d.ts.map +1 -1
  41. package/dist/components/dropdown.js +27 -26
  42. package/dist/components/dropdown.js.map +1 -1
  43. package/dist/components/extension-preferences.d.ts.map +1 -1
  44. package/dist/components/extension-preferences.js +15 -10
  45. package/dist/components/extension-preferences.js.map +1 -1
  46. package/dist/components/footer.d.ts +13 -0
  47. package/dist/components/footer.d.ts.map +1 -0
  48. package/dist/components/footer.js +106 -0
  49. package/dist/components/footer.js.map +1 -0
  50. package/dist/components/form/file-autocomplete.d.ts +19 -4
  51. package/dist/components/form/file-autocomplete.d.ts.map +1 -1
  52. package/dist/components/form/file-autocomplete.js +56 -55
  53. package/dist/components/form/file-autocomplete.js.map +1 -1
  54. package/dist/components/form/file-picker.d.ts.map +1 -1
  55. package/dist/components/form/file-picker.js +26 -15
  56. package/dist/components/form/file-picker.js.map +1 -1
  57. package/dist/components/form/index.d.ts.map +1 -1
  58. package/dist/components/form/index.js +17 -15
  59. package/dist/components/form/index.js.map +1 -1
  60. package/dist/components/form/with-left-border.d.ts.map +1 -1
  61. package/dist/components/form/with-left-border.js +4 -12
  62. package/dist/components/form/with-left-border.js.map +1 -1
  63. package/dist/components/list.d.ts.map +1 -1
  64. package/dist/components/list.js +126 -86
  65. package/dist/components/list.js.map +1 -1
  66. package/dist/components/loading-bar.d.ts.map +1 -1
  67. package/dist/components/loading-bar.js +5 -22
  68. package/dist/components/loading-bar.js.map +1 -1
  69. package/dist/components/loading-text.d.ts.map +1 -1
  70. package/dist/components/loading-text.js +3 -22
  71. package/dist/components/loading-text.js.map +1 -1
  72. package/dist/components/theme-picker.d.ts +2 -0
  73. package/dist/components/theme-picker.d.ts.map +1 -0
  74. package/dist/components/theme-picker.js +37 -0
  75. package/dist/components/theme-picker.js.map +1 -0
  76. package/dist/descendants.d.ts +6 -0
  77. package/dist/descendants.d.ts.map +1 -1
  78. package/dist/descendants.js +74 -8
  79. package/dist/descendants.js.map +1 -1
  80. package/dist/examples/internal/descendants-rerender.d.ts +14 -0
  81. package/dist/examples/internal/descendants-rerender.d.ts.map +1 -0
  82. package/dist/examples/internal/descendants-rerender.js +145 -0
  83. package/dist/examples/internal/descendants-rerender.js.map +1 -0
  84. package/dist/examples/internal/simple-dialog.js +4 -1
  85. package/dist/examples/internal/simple-dialog.js.map +1 -1
  86. package/dist/examples/internal/simple-scrollbox.js +1 -1
  87. package/dist/examples/internal/simple-scrollbox.js.map +1 -1
  88. package/dist/examples/list-with-dropdown.js +1 -1
  89. package/dist/examples/list-with-dropdown.js.map +1 -1
  90. package/dist/examples/miscellaneous.js +1 -1
  91. package/dist/examples/miscellaneous.js.map +1 -1
  92. package/dist/examples/toast-action.d.ts +2 -0
  93. package/dist/examples/toast-action.d.ts.map +1 -0
  94. package/dist/examples/toast-action.js +76 -0
  95. package/dist/examples/toast-action.js.map +1 -0
  96. package/dist/examples/toast-variations.js +38 -36
  97. package/dist/examples/toast-variations.js.map +1 -1
  98. package/dist/extensions/dev.d.ts +1 -1
  99. package/dist/extensions/dev.d.ts.map +1 -1
  100. package/dist/extensions/dev.js +62 -30
  101. package/dist/extensions/dev.js.map +1 -1
  102. package/dist/extensions/home.d.ts.map +1 -1
  103. package/dist/extensions/home.js +4 -3
  104. package/dist/extensions/home.js.map +1 -1
  105. package/dist/extensions/react-refresh-init.d.ts +5 -0
  106. package/dist/extensions/react-refresh-init.d.ts.map +1 -0
  107. package/dist/extensions/react-refresh-init.js +52 -0
  108. package/dist/extensions/react-refresh-init.js.map +1 -0
  109. package/dist/internal/date-picker-widget.js +1 -1
  110. package/dist/internal/date-picker-widget.js.map +1 -1
  111. package/dist/internal/dialog.d.ts +8 -3
  112. package/dist/internal/dialog.d.ts.map +1 -1
  113. package/dist/internal/dialog.js +37 -53
  114. package/dist/internal/dialog.js.map +1 -1
  115. package/dist/internal/navigation.d.ts +1 -0
  116. package/dist/internal/navigation.d.ts.map +1 -1
  117. package/dist/internal/navigation.js +25 -1
  118. package/dist/internal/navigation.js.map +1 -1
  119. package/dist/internal/providers.d.ts.map +1 -1
  120. package/dist/internal/providers.js +9 -197
  121. package/dist/internal/providers.js.map +1 -1
  122. package/dist/internal/scrollbox.d.ts.map +1 -1
  123. package/dist/internal/scrollbox.js +1 -0
  124. package/dist/internal/scrollbox.js.map +1 -1
  125. package/dist/release.d.ts +1 -0
  126. package/dist/release.d.ts.map +1 -1
  127. package/dist/release.js +16 -9
  128. package/dist/release.js.map +1 -1
  129. package/dist/state.d.ts +27 -1
  130. package/dist/state.d.ts.map +1 -1
  131. package/dist/state.js +6 -0
  132. package/dist/state.js.map +1 -1
  133. package/dist/theme.d.ts +6 -19
  134. package/dist/theme.d.ts.map +1 -1
  135. package/dist/theme.js +76 -45
  136. package/dist/theme.js.map +1 -1
  137. package/dist/themes/aura.json +69 -0
  138. package/dist/themes/ayu.json +80 -0
  139. package/dist/themes/catppuccin-frappe.json +233 -0
  140. package/dist/themes/catppuccin-macchiato.json +233 -0
  141. package/dist/themes/catppuccin.json +112 -0
  142. package/dist/themes/cobalt2.json +228 -0
  143. package/dist/themes/cursor.json +249 -0
  144. package/dist/themes/dracula.json +219 -0
  145. package/dist/themes/everforest.json +241 -0
  146. package/dist/themes/flexoki.json +237 -0
  147. package/dist/themes/github-light.json +56 -0
  148. package/dist/themes/github.json +241 -0
  149. package/dist/themes/gruvbox.json +95 -0
  150. package/dist/themes/kanagawa.json +77 -0
  151. package/dist/themes/lucent-orng.json +227 -0
  152. package/dist/themes/material.json +235 -0
  153. package/dist/themes/matrix.json +77 -0
  154. package/dist/themes/mercury.json +245 -0
  155. package/dist/themes/monokai.json +221 -0
  156. package/dist/themes/nightowl.json +221 -0
  157. package/dist/themes/nord.json +223 -0
  158. package/dist/themes/one-dark.json +84 -0
  159. package/dist/themes/opencode-light.json +62 -0
  160. package/dist/themes/opencode.json +245 -0
  161. package/dist/themes/orng.json +245 -0
  162. package/dist/themes/palenight.json +222 -0
  163. package/dist/themes/rosepine.json +234 -0
  164. package/dist/themes/solarized.json +223 -0
  165. package/dist/themes/synthwave84.json +226 -0
  166. package/dist/themes/termcast.json +226 -0
  167. package/dist/themes/tokyonight.json +243 -0
  168. package/dist/themes/vercel.json +255 -0
  169. package/dist/themes/vesper.json +218 -0
  170. package/dist/themes/zenburn.json +223 -0
  171. package/dist/themes.d.ts +57 -0
  172. package/dist/themes.d.ts.map +1 -0
  173. package/dist/themes.js +181 -0
  174. package/dist/themes.js.map +1 -0
  175. package/dist/utils/run-command.d.ts +2 -1
  176. package/dist/utils/run-command.d.ts.map +1 -1
  177. package/dist/utils/run-command.js +20 -10
  178. package/dist/utils/run-command.js.map +1 -1
  179. package/dist/utils.d.ts +2 -1
  180. package/dist/utils.d.ts.map +1 -1
  181. package/dist/utils.js +90 -17
  182. package/dist/utils.js.map +1 -1
  183. package/dist/watcher.d.ts +3 -0
  184. package/dist/watcher.d.ts.map +1 -0
  185. package/dist/watcher.js +16 -0
  186. package/dist/watcher.js.map +1 -0
  187. package/package.json +16 -10
  188. package/src/apis/cache.tsx +5 -44
  189. package/src/apis/hud.tsx +17 -62
  190. package/src/apis/localstorage.tsx +3 -32
  191. package/src/apis/toast.tsx +91 -275
  192. package/src/build.test.tsx +10 -0
  193. package/src/build.tsx +61 -1
  194. package/src/cli.tsx +365 -103
  195. package/src/colors.tsx +1 -0
  196. package/src/compile.tsx +21 -29
  197. package/src/compile.vitest.tsx +300 -0
  198. package/src/components/actions.tsx +64 -45
  199. package/src/components/animation-tick.tsx +85 -0
  200. package/src/components/detail.tsx +31 -35
  201. package/src/components/dropdown.tsx +32 -21
  202. package/src/components/extension-preferences.tsx +14 -10
  203. package/src/components/footer.tsx +241 -0
  204. package/src/components/form/file-autocomplete.tsx +80 -60
  205. package/src/components/form/file-picker.tsx +37 -25
  206. package/src/components/form/index.tsx +45 -41
  207. package/src/components/form/with-left-border.tsx +4 -14
  208. package/src/components/list.tsx +181 -121
  209. package/src/components/loading-bar.tsx +5 -25
  210. package/src/components/loading-text.tsx +4 -23
  211. package/src/components/theme-picker.tsx +57 -0
  212. package/src/descendants.tsx +98 -9
  213. package/src/examples/actions-dialog-layout.vitest.tsx +112 -0
  214. package/src/examples/file-autocomplete.vitest.tsx +131 -122
  215. package/src/examples/form-basic.vitest.tsx +463 -644
  216. package/src/examples/form-dropdown.vitest.tsx +553 -571
  217. package/src/examples/form-scroll.vitest.tsx +112 -102
  218. package/src/examples/form-tagpicker.vitest.tsx +364 -338
  219. package/src/examples/internal/descendants-rerender.tsx +273 -0
  220. package/src/examples/internal/descendants-rerender.vitest.tsx +194 -0
  221. package/src/examples/internal/simple-dialog.tsx +4 -4
  222. package/src/examples/internal/simple-scrollbox.tsx +2 -2
  223. package/src/examples/internal/simple-scrollbox.vitest.tsx +43 -31
  224. package/src/examples/list-detail-metadata.vitest.tsx +34 -30
  225. package/src/examples/list-dropdown-default.vitest.tsx +84 -72
  226. package/src/examples/list-empty-view.vitest.tsx +93 -0
  227. package/src/examples/list-fetch-data.vitest.tsx +36 -30
  228. package/src/examples/list-scrollbox.vitest.tsx +59 -39
  229. package/src/examples/list-with-detail.vitest.tsx +339 -314
  230. package/src/examples/list-with-dropdown.tsx +1 -0
  231. package/src/examples/list-with-dropdown.vitest.tsx +176 -150
  232. package/src/examples/list-with-sections.vitest.tsx +289 -270
  233. package/src/examples/list-with-toast.vitest.tsx +44 -44
  234. package/src/examples/miscellaneous.tsx +10 -0
  235. package/src/examples/simple-file-picker.vitest.tsx +90 -86
  236. package/src/examples/simple-grid.vitest.tsx +275 -249
  237. package/src/examples/simple-navigation.vitest.tsx +192 -168
  238. package/src/examples/store.vitest.tsx +6 -4
  239. package/src/examples/swift-extension.vitest.tsx +31 -19
  240. package/src/examples/synonyms.vitest.tsx +93 -83
  241. package/src/examples/toast-action.tsx +160 -0
  242. package/src/examples/toast-action.vitest.tsx +404 -0
  243. package/src/examples/toast-variations.tsx +58 -57
  244. package/src/examples/toast-variations.vitest.tsx +186 -166
  245. package/src/extensions/dev.tsx +74 -33
  246. package/src/extensions/dev.vitest.tsx +162 -69
  247. package/src/extensions/home.tsx +5 -6
  248. package/src/extensions/react-refresh-init.tsx +59 -0
  249. package/src/internal/date-picker-widget.tsx +1 -1
  250. package/src/internal/dialog.tsx +59 -83
  251. package/src/internal/navigation.tsx +37 -4
  252. package/src/internal/providers.tsx +27 -315
  253. package/src/internal/scrollbox.tsx +1 -0
  254. package/src/release.tsx +16 -10
  255. package/src/state.tsx +36 -3
  256. package/src/theme.tsx +82 -51
  257. package/src/themes/aura.json +69 -0
  258. package/src/themes/ayu.json +80 -0
  259. package/src/themes/catppuccin-frappe.json +233 -0
  260. package/src/themes/catppuccin-macchiato.json +233 -0
  261. package/src/themes/catppuccin.json +112 -0
  262. package/src/themes/cobalt2.json +228 -0
  263. package/src/themes/cursor.json +249 -0
  264. package/src/themes/dracula.json +219 -0
  265. package/src/themes/everforest.json +241 -0
  266. package/src/themes/flexoki.json +237 -0
  267. package/src/themes/github-light.json +56 -0
  268. package/src/themes/github.json +241 -0
  269. package/src/themes/gruvbox.json +95 -0
  270. package/src/themes/kanagawa.json +77 -0
  271. package/src/themes/lucent-orng.json +227 -0
  272. package/src/themes/material.json +235 -0
  273. package/src/themes/matrix.json +77 -0
  274. package/src/themes/mercury.json +252 -0
  275. package/src/themes/monokai.json +221 -0
  276. package/src/themes/nightowl.json +221 -0
  277. package/src/themes/nord.json +223 -0
  278. package/src/themes/one-dark.json +84 -0
  279. package/src/themes/opencode-light.json +62 -0
  280. package/src/themes/opencode.json +245 -0
  281. package/src/themes/orng.json +245 -0
  282. package/src/themes/palenight.json +222 -0
  283. package/src/themes/rosepine.json +234 -0
  284. package/src/themes/solarized.json +223 -0
  285. package/src/themes/synthwave84.json +226 -0
  286. package/src/themes/termcast.json +227 -0
  287. package/src/themes/tokyonight.json +243 -0
  288. package/src/themes/vercel.json +255 -0
  289. package/src/themes/vesper.json +218 -0
  290. package/src/themes/zenburn.json +223 -0
  291. package/src/themes.ts +291 -0
  292. package/src/utils/run-command.tsx +23 -12
  293. package/src/utils.tsx +115 -18
  294. package/src/watcher.tsx +19 -0
@@ -1,5 +1,10 @@
1
+ // CRITICAL: Import react-refresh-init FIRST before @opentui/react!
2
+ // This ensures the devtools hook exists and can intercept injectIntoDevTools calls
3
+ import { RefreshRuntime, hasRefreshCapability, getRendererInternals } from './react-refresh-init'
4
+
1
5
  import fs from 'node:fs'
2
6
  import path from 'node:path'
7
+ import os from 'node:os'
3
8
  import React from 'react'
4
9
  import { createRoot } from '@opentui/react'
5
10
  import { createCliRenderer } from '@opentui/core'
@@ -9,6 +14,8 @@ import { useNavigation } from 'termcast/src/internal/navigation'
9
14
  import { TermcastProvider } from 'termcast/src/internal/providers'
10
15
  import { showToast, Toast } from 'termcast/src/apis/toast'
11
16
  import { Icon } from 'termcast'
17
+ import { Theme } from 'termcast/src/theme'
18
+ import { logger } from '../logger'
12
19
  import { getCommandsWithFiles, CommandWithFile, RaycastPackageJson } from '../package-json'
13
20
  import { buildExtensionCommands } from '../build'
14
21
  import {
@@ -18,9 +25,17 @@ import {
18
25
  handleHelpFlag,
19
26
  } from '../utils/run-command'
20
27
 
28
+ function CommandLoadError({ error }: { error: Error }): any {
29
+ return (
30
+ <box padding={2}>
31
+ <text fg={Theme.error} wrapMode='none'>{error.stack}</text>
32
+ </box>
33
+ )
34
+ }
35
+
21
36
  interface BundledCommand extends CommandWithFile {
22
37
  bundledPath: string
23
- Component?: (props: any) => any
38
+ loadComponent?: () => Promise<(props: any) => any>
24
39
  }
25
40
 
26
41
  function ExtensionCommandsList({
@@ -32,19 +47,17 @@ function ExtensionCommandsList({
32
47
  commands: BundledCommand[]
33
48
  skipArgv?: number
34
49
  }): any {
35
- const { push } = useNavigation()
50
+ const { push, replace } = useNavigation()
51
+ const [loadError, setLoadError] = React.useState<Error | null>(null)
36
52
 
37
53
  const visibleCommands = commands.filter((cmd) => cmd.mode !== 'menu-bar')
54
+ const isSingleCommand = visibleCommands.length === 1
38
55
 
39
- const handleCommandSelect = async (command: BundledCommand) => {
56
+ const handleCommandSelect = async (command: BundledCommand, useReplace = false) => {
40
57
  clearCommandArguments()
41
58
 
42
- if (!command.bundledPath && !command.Component) {
43
- await showToast({
44
- style: Toast.Style.Failure,
45
- title: 'Command not built',
46
- message: `Command ${command.name} was not built successfully`,
47
- })
59
+ if (!command.bundledPath && !command.loadComponent) {
60
+ setLoadError(new Error(`Command ${command.name} was not built successfully`))
48
61
  return
49
62
  }
50
63
 
@@ -54,15 +67,12 @@ function ExtensionCommandsList({
54
67
  extensionName: packageJson.name,
55
68
  packageJson,
56
69
  bundledPath: command.bundledPath,
57
- Component: command.Component,
58
- push,
70
+ loadComponent: command.loadComponent,
71
+ push: useReplace ? replace : push,
72
+ replace,
59
73
  })
60
74
  } catch (error: any) {
61
- await showToast({
62
- style: Toast.Style.Failure,
63
- title: 'Failed to load command',
64
- message: error.message || String(error),
65
- })
75
+ setLoadError(error)
66
76
  }
67
77
  }
68
78
 
@@ -75,7 +85,8 @@ function ExtensionCommandsList({
75
85
  if (commandName) {
76
86
  const command = visibleCommands.find((cmd) => cmd.name === commandName)
77
87
  if (command) {
78
- handleCommandSelect(command)
88
+ // Use replace so ESC at root exits instead of going back to command list
89
+ handleCommandSelect(command, true)
79
90
  } else {
80
91
  showToast({
81
92
  style: Toast.Style.Failure,
@@ -87,12 +98,18 @@ function ExtensionCommandsList({
87
98
  }
88
99
  }
89
100
 
90
- if (visibleCommands.length === 1) {
91
- handleCommandSelect(visibleCommands[0])
101
+ if (isSingleCommand) {
102
+ // Use replace so ESC at root exits instead of going back to command list
103
+ handleCommandSelect(visibleCommands[0], true)
92
104
  }
93
105
  }, [])
94
106
 
95
- if (visibleCommands.length === 1) {
107
+ // Show error screen for single command that failed to load
108
+ if (loadError) {
109
+ return <CommandLoadError error={loadError} />
110
+ }
111
+
112
+ if (isSingleCommand) {
96
113
  return null
97
114
  }
98
115
 
@@ -113,7 +130,7 @@ function ExtensionCommandsList({
113
130
  }
114
131
  accessories={[
115
132
  { text: command.mode },
116
- ...(command.bundledPath
133
+ ...(command.bundledPath || command.loadComponent
117
134
  ? []
118
135
  : [
119
136
  {
@@ -178,11 +195,12 @@ export async function startDevMode({
178
195
  // Parse the package.json to get extension metadata
179
196
  const { packageJson } = getCommandsWithFiles({ packageJsonPath })
180
197
 
181
- // Build and set initial devElement
198
+ // Build and set initial devElement with hot reload support
182
199
  const { commands } = await buildExtensionCommands({
183
200
  extensionPath: resolvedPath,
184
201
  format: 'esm',
185
202
  target: 'bun',
203
+ hotReload: true,
186
204
  })
187
205
 
188
206
  // Handle --help before rendering
@@ -209,9 +227,9 @@ export async function startDevMode({
209
227
 
210
228
  function App(): any {
211
229
  const devElement = useStore((state) => state.devElement)
212
- const devRebuildCount = useStore((state) => state.devRebuildCount)
213
-
214
- return <TermcastProvider key={String(devRebuildCount)}>{devElement}</TermcastProvider>
230
+ // REMOVED: key={devRebuildCount} - we want to preserve the React tree!
231
+ // React Refresh will update components in-place without remounting
232
+ return <TermcastProvider>{devElement}</TermcastProvider>
215
233
  }
216
234
 
217
235
  const renderer = await createCliRenderer({
@@ -230,7 +248,7 @@ export async function startCompiledExtension({
230
248
  packageJson: RaycastPackageJson
231
249
  compiledCommands: Array<{
232
250
  name: string
233
- Component: (props: any) => any
251
+ loadComponent: () => Promise<(props: any) => any>
234
252
  }>
235
253
  skipArgv?: number
236
254
  }): Promise<void> {
@@ -248,7 +266,7 @@ export async function startCompiledExtension({
248
266
  filePath: '',
249
267
  exists: true,
250
268
  bundledPath: '',
251
- Component: compiled.Component,
269
+ loadComponent: compiled.loadComponent,
252
270
  }
253
271
  })
254
272
 
@@ -263,9 +281,13 @@ export async function startCompiledExtension({
263
281
  skipArgv,
264
282
  })
265
283
 
284
+ // For compiled extensions, use ~/.termcast/compiled/{name} as extensionPath
285
+ // This is where data.db and cache will be stored
286
+ const compiledExtensionPath = path.join(os.homedir(), '.termcast', 'compiled', packageJson.name)
287
+
266
288
  useStore.setState({
267
289
  ...useStore.getInitialState(),
268
- extensionPath: '', // No filesystem path for compiled extensions
290
+ extensionPath: compiledExtensionPath,
269
291
  extensionPackageJson: packageJson,
270
292
  devElement: (
271
293
  <ExtensionCommandsList packageJson={packageJson} commands={commands} skipArgv={skipArgv} />
@@ -296,28 +318,47 @@ export async function triggerRebuild({
296
318
  extensionPath,
297
319
  format: 'esm',
298
320
  target: 'bun',
321
+ hotReload: true,
299
322
  })
300
323
 
301
324
  // Re-parse package.json in case it changed
302
325
  const packageJsonPath = path.join(extensionPath, 'package.json')
303
326
  const { packageJson } = getCommandsWithFiles({ packageJsonPath })
304
327
 
305
- // Update the devElement with new commands and increment rebuild count
306
328
  const state = useStore.getState()
329
+ const newRebuildCount = state.devRebuildCount + 1
330
+
331
+ // Re-import all command modules with cache bust
332
+ // This triggers $RefreshReg$ calls which register new component versions
333
+ // TODO maybe we can skip importing all command modules here. only the one being used instead
334
+ for (const cmd of commands) {
335
+ if (cmd.bundledPath) {
336
+ try {
337
+ await import(`${cmd.bundledPath}?v=${newRebuildCount}`)
338
+ } catch (err) {
339
+ logger.error(`Failed to reimport ${cmd.name}:`, err)
340
+ }
341
+ }
342
+ }
307
343
 
344
+ // Trigger React Refresh - this updates components in-place!
345
+ // The reconciler will find all fibers using updated "families"
346
+ // and schedule re-renders with the new implementations
347
+ RefreshRuntime.performReactRefresh()
348
+
349
+ // Update state WITHOUT resetting navigation/dialog stacks
308
350
  useStore.setState({
309
351
  extensionPackageJson: packageJson,
352
+ devRebuildCount: newRebuildCount,
310
353
  devElement: (
311
354
  <ExtensionCommandsList
312
355
  packageJson={packageJson}
313
356
  commands={commands}
314
357
  />
315
358
  ),
316
- devRebuildCount: state.devRebuildCount + 1,
317
- navigationStack: [], // Reset navigation so NavigationProvider re-initializes with new devElement
318
- dialogStack: [], // Clear any open dialogs/toasts on rebuild
319
- toast: null,
320
359
  })
360
+
361
+ // TODO show a green dot in the corner to notify HMR happened
321
362
  } catch (error: any) {
322
363
  await showToast({
323
364
  style: Toast.Style.Failure,
@@ -30,18 +30,20 @@ test('dev command shows extension commands list', async () => {
30
30
  "
31
31
 
32
32
 
33
- Simple Test Extension ────────────────────────────────
33
+ Simple Test Extension ────────────────────────────────
34
34
 
35
- Search commands...
35
+ > Search commands...
36
36
 
37
- Commands ▲
38
- ›List Items Displays a simple list with some ite view ▀
39
- Search Items Search and filter through a list o view
40
- Google Oauth view
41
- usePromise Demo Shows how to use the usePromise view ▼
37
+ Commands ▲
38
+ ›List Items Displays a simple list with some ite view ▀
39
+ Search Items Search and filter through a list o view
40
+ Google Oauth view
41
+ usePromise Demo Shows how to use the usePromise view ▼
42
42
 
43
43
 
44
- ↵ run command ↑↓ navigate ^k actions"
44
+ ↵ run command ↑↓ navigate ^k actions
45
+
46
+ "
45
47
  `)
46
48
  }, 30000)
47
49
 
@@ -63,18 +65,20 @@ test('selecting command with arguments shows arguments form', async () => {
63
65
  "
64
66
 
65
67
 
66
- Simple Test Extension ────────────────────────────────
68
+ Simple Test Extension ────────────────────────────────
67
69
 
68
- Search commands...
70
+ > Search commands...
69
71
 
70
- usePromise Demo Shows how to use the usePromise view ▲
71
- Show State Shows the current application state view
72
- ›With Arguments Demonstrates command arguments ( view
73
- Quick Action Copies current timestamp to cli no-view
74
-
72
+ usePromise Demo Shows how to use the usePromise view ▲
73
+ Show State Shows the current application state view
74
+ ›With Arguments Demonstrates command arguments ( view
75
+ Quick Action Copies current timestamp to cli no-view
76
+ Throw Error Command that throws an error at roo view
75
77
 
76
78
 
77
- ↵ run command ↑↓ navigate ^k actions"
79
+ ↵ run command ↑↓ navigate ^k actions
80
+
81
+ "
78
82
  `)
79
83
 
80
84
  // Select the command to show arguments form (enter opens action panel, enter again runs)
@@ -91,19 +95,20 @@ test('selecting command with arguments shows arguments form', async () => {
91
95
  "
92
96
 
93
97
 
94
- ■ With Arguments █
95
- │ Enter the arguments to run this command.
96
-
97
- ◇ Search query
98
- │ Search query
99
-
100
- ◇ Secret key
101
- │ Secret key
102
- ◇ Category
103
- │ Category
104
98
 
105
99
 
106
- ctrl ↵ submit tab navigate ^k actions"
100
+ ■ With Arguments ▀
101
+ │ Enter the arguments to run this command.
102
+
103
+ ◇ Search query
104
+ │ Search query
105
+
106
+ ◇ Secret key
107
+
108
+
109
+ ctrl ↵ submit tab navigate ^k actions
110
+
111
+ "
107
112
  `)
108
113
  }, 30000)
109
114
 
@@ -135,19 +140,20 @@ test('can fill arguments and run command', async () => {
135
140
  "
136
141
 
137
142
 
138
- ■ With Arguments █
139
- │ Enter the arguments to run this command.
140
-
141
- ◇ Search query
142
- │ Search query
143
-
144
- ◇ Secret key
145
- │ Secret key
146
- ◇ Category
147
- │ Category
148
143
 
149
144
 
150
- ctrl ↵ submit tab navigate ^k actions"
145
+ ■ With Arguments ▀
146
+ │ Enter the arguments to run this command.
147
+
148
+ ◇ Search query
149
+ │ Search query
150
+
151
+ ◇ Secret key
152
+
153
+
154
+ ctrl ↵ submit tab navigate ^k actions
155
+
156
+ "
151
157
  `)
152
158
 
153
159
  // Submit the form with Alt+Enter (opens action panel), then Enter (selects submit)
@@ -164,20 +170,20 @@ test('can fill arguments and run command', async () => {
164
170
  "
165
171
 
166
172
 
167
- Command Arguments Demo ───────────────────────────────
173
+ Command Arguments Demo ───────────────────────────────
168
174
 
169
- Search...
175
+ > Search...
170
176
 
171
- Received Arguments
172
- ›▼ Search Query (empty)
173
- ▼ Secret Key (empty)
174
- ▼ Category (empty)
177
+ Received Arguments
178
+ ›▼ Search Query (empty)
179
+ ▼ Secret Key (empty)
180
+ ▼ Category (empty)
175
181
 
176
182
 
177
- ┌───────────────────────┐
178
- ✓ Copied to Clipboard
179
- │ (empty) │
180
- └───────────────────────┘"
183
+
184
+ ✓ Copied to Clipboard (empty)
185
+
186
+ "
181
187
  `)
182
188
  }, 30000)
183
189
 
@@ -201,24 +207,28 @@ test('can run simple view command without arguments', async () => {
201
207
  "
202
208
 
203
209
 
204
- List Items ───────────────────────────────────────────
210
+ List Items ───────────────────────────────────────────
205
211
 
206
- Search...
212
+ > Search...
207
213
 
208
- Items ▲
209
- ›▲ First Item This is the first item █
210
- ▲ Second Item This is the second item
211
- ▲ Third Item This is the third item
212
- ▲ Fourth Item This is the fourth item
213
- ▲ Fifth Item This is the fifth item ▼
214
- ┌───────────────────────┐
215
- ✓ Copied to Clipboard
216
- │ First Item │
217
- └───────────────────────┘"
214
+ Items ▲
215
+ ›▲ First Item This is the first item █
216
+ ▲ Second Item This is the second item
217
+ ▲ Third Item This is the third item
218
+ ▲ Fourth Item This is the fourth item
219
+
220
+
221
+ ✓ Copied to Clipboard First Item
222
+
223
+ "
218
224
  `)
219
225
  }, 30000)
220
226
 
221
227
  test('hot reload updates TUI when source file changes', async () => {
228
+ // This test verifies that React Refresh hot reload is working.
229
+ // When a source file changes, the component content should update in-place
230
+ // without needing to manually exit and re-enter the command.
231
+
222
232
  const hotReloadFixtureDir = path.resolve(__dirname, '../../fixtures/hot-reload-extension')
223
233
  const sourceFilePath = path.join(hotReloadFixtureDir, 'src/detail-view.tsx')
224
234
  const fs = await import('node:fs')
@@ -260,29 +270,112 @@ test('hot reload updates TUI when source file changes', async () => {
260
270
  const newContent = originalContent.replace('MARKER_VALUE', `UPDATED_${randomNumber}`)
261
271
  fs.writeFileSync(sourceFilePath, newContent)
262
272
 
263
- // Wait for rebuild - navigation resets to commands list
273
+ // Wait for rebuild toast to appear
274
+ await hotReloadSession.waitIdle()
275
+ await new Promise((resolve) => setTimeout(resolve, 3000))
276
+
277
+ const afterReloadSnapshot = await hotReloadSession.text()
278
+
279
+ // React Refresh is working! The component content IS updated in-place.
280
+ // The new content (UPDATED_xxx) should be visible, old content (MARKER_VALUE) should NOT be visible.
281
+ expect(afterReloadSnapshot).toContain(`UPDATED_${randomNumber}`) // New content IS visible
282
+ expect(afterReloadSnapshot).not.toContain('MARKER_VALUE') // Old content NOT visible
283
+ } finally {
284
+ // Restore original content
285
+ fs.writeFileSync(sourceFilePath, originalContent)
286
+ hotReloadSession?.close()
287
+ }
288
+ }, 60000)
289
+
290
+ test('hot reload with navigation - preserves navigation and updates content', async () => {
291
+ // This test verifies React Refresh works with navigation:
292
+ // 1. Navigation stack is preserved (we stay on detail view)
293
+ // 2. Component content IS updated (new code runs)
294
+ // 3. Component state is preserved if hook signature unchanged
295
+
296
+ const hotReloadFixtureDir = path.resolve(__dirname, '../../fixtures/hot-reload-extension')
297
+ const sourceFilePath = path.join(hotReloadFixtureDir, 'src/list-with-navigation.tsx')
298
+ const fs = await import('node:fs')
299
+
300
+ // Read original content to restore later
301
+ const originalContent = fs.readFileSync(sourceFilePath, 'utf-8')
302
+
303
+ // Start a new session for this test
304
+ const hotReloadSession = await launchTerminal({
305
+ command: 'bun',
306
+ args: ['src/cli.tsx', 'dev', hotReloadFixtureDir],
307
+ cols: 70,
308
+ rows: 20,
309
+ })
310
+
311
+ try {
312
+ // Wait for the extension to load
264
313
  await hotReloadSession.text({
265
- waitFor: (text) => /Hot Reload Test/i.test(text) && /Detail View/i.test(text),
266
- timeout: 5000,
314
+ waitFor: (text) => /Hot Reload Test/i.test(text) && /List With Navigation/i.test(text),
315
+ timeout: 10000,
267
316
  })
317
+
318
+ // Navigate to "List With Navigation" command (second item)
319
+ await hotReloadSession.press('down')
268
320
  await hotReloadSession.waitIdle()
269
321
 
270
- // Run the command again to see updated content
322
+ // Run the command (enter opens action panel, enter again runs)
271
323
  await hotReloadSession.press('enter')
272
324
  await hotReloadSession.press('enter')
273
325
  await hotReloadSession.waitIdle()
274
326
 
275
- // Wait for the updated content
327
+ // Wait for the list to show OR the detail view (enter might auto-execute first action)
276
328
  await hotReloadSession.text({
277
- waitFor: (text) => text.includes(`UPDATED_${randomNumber}`),
329
+ waitFor: (text) => /Item One/i.test(text),
278
330
  timeout: 10000,
279
331
  })
280
332
 
281
- const updatedSnapshot = await hotReloadSession.text()
282
- expect(updatedSnapshot).toContain(`UPDATED_${randomNumber}`)
333
+ // Check if we're on the list or already on detail view
334
+ let currentSnapshot = await hotReloadSession.text()
335
+
336
+ if (currentSnapshot.includes('Click to see details')) {
337
+ // We're on the list - push to detail view
338
+ await hotReloadSession.press('enter')
339
+ await hotReloadSession.waitIdle()
340
+
341
+ // Wait for the detail view with the marker
342
+ await hotReloadSession.text({
343
+ waitFor: (text) => /NAV_MARKER_VALUE/i.test(text) && /Item One Details/i.test(text),
344
+ timeout: 10000,
345
+ })
346
+ }
347
+ // else: we're already on the detail view (auto-executed)
348
+
349
+ const detailSnapshot = await hotReloadSession.text()
350
+ expect(detailSnapshot).toContain('Item One Details')
351
+ expect(detailSnapshot).toContain('NAV_MARKER_VALUE')
352
+ expect(detailSnapshot).toContain('Counter: 0')
353
+
354
+ // Generate a random number for the update
355
+ const randomNumber = Math.floor(Math.random() * 1000000)
356
+
357
+ // Update the source file with the random number
358
+ const newContent = originalContent.replace('NAV_MARKER_VALUE', `UPDATED_NAV_${randomNumber}`)
359
+ fs.writeFileSync(sourceFilePath, newContent)
360
+
361
+ // Wait for rebuild to complete
362
+ await hotReloadSession.waitIdle()
363
+ await new Promise((resolve) => setTimeout(resolve, 4000)) // Give rebuild time
364
+
365
+ const afterReloadSnapshot = await hotReloadSession.text()
366
+
367
+ // React Refresh is working:
368
+ // - Navigation is preserved (still on detail view)
369
+ // - Content IS updated (new marker value visible)
370
+ // - State should be preserved if hook signature unchanged
371
+
372
+ expect(afterReloadSnapshot).toContain('Item One Details')
373
+ expect(afterReloadSnapshot).toContain(`UPDATED_NAV_${randomNumber}`) // New content IS visible
374
+ expect(afterReloadSnapshot).not.toContain('NAV_MARKER_VALUE') // Old content NOT visible
375
+ expect(afterReloadSnapshot).toContain('Counter: 0') // State preserved (hook signature unchanged)
283
376
  } finally {
284
377
  // Restore original content
285
378
  fs.writeFileSync(sourceFilePath, originalContent)
286
379
  hotReloadSession?.close()
287
380
  }
288
- }, 60000)
381
+ }, 90000)
@@ -18,7 +18,7 @@ interface ExtensionCommand {
18
18
  extensionDir?: string
19
19
  command: any
20
20
  bundledPath?: string
21
- Component?: () => any
21
+ loadComponent?: () => Promise<(props: any) => any>
22
22
  packageJson?: any
23
23
  }
24
24
 
@@ -34,7 +34,7 @@ const builtinExtensions: ExtensionCommand[] = [
34
34
  mode: 'view',
35
35
  icon: 'Store',
36
36
  },
37
- Component: Store,
37
+ loadComponent: async () => Store,
38
38
  },
39
39
  ]
40
40
 
@@ -45,22 +45,21 @@ function ExtensionsList({
45
45
  allCommands: ExtensionCommand[]
46
46
  initialSearchQuery?: string
47
47
  }): any {
48
- const { push } = useNavigation()
48
+ const { push, replace } = useNavigation()
49
49
  const [searchText, setSearchText] = React.useState(initialSearchQuery)
50
50
 
51
51
  const handleCommandSelect = async (item: ExtensionCommand) => {
52
52
  clearCommandArguments()
53
53
 
54
-
55
-
56
54
  try {
57
55
  await runCommand({
58
56
  command: item.command,
59
57
  extensionName: item.extensionName,
60
58
  packageJson: item.packageJson,
61
59
  bundledPath: item.bundledPath,
62
- Component: item.Component,
60
+ loadComponent: item.loadComponent,
63
61
  push,
62
+ replace,
64
63
  })
65
64
  } catch (error: any) {
66
65
  await showToast({
@@ -0,0 +1,59 @@
1
+ // This module MUST be imported before @opentui/react to ensure the devtools
2
+ // hook exists before the reconciler tries to call injectIntoDevTools()
3
+
4
+ // Store captured renderer internals for manual refresh triggering
5
+ let capturedRendererInternals: any = null
6
+ let RefreshRuntime: typeof import('react-refresh/runtime') | null = null
7
+
8
+ // Initialize React Refresh BEFORE any React rendering
9
+ // This must happen before @opentui/react is loaded
10
+ function initializeReactRefresh() {
11
+ if (!RefreshRuntime) {
12
+ return
13
+ }
14
+
15
+ // Inject into the global devtools hook
16
+ // This sets up __REACT_DEVTOOLS_GLOBAL_HOOK__ which the reconciler uses
17
+ RefreshRuntime.injectIntoGlobalHook(globalThis)
18
+
19
+ const hook = (globalThis as any).__REACT_DEVTOOLS_GLOBAL_HOOK__
20
+ if (hook) {
21
+ // Intercept the inject call to capture renderer internals
22
+ // This is called by react-reconciler when injectIntoDevTools is called
23
+ const originalInject = hook.inject
24
+ hook.inject = (renderer: any) => {
25
+ // Capture the renderer internals - we need scheduleRefresh
26
+ capturedRendererInternals = renderer
27
+ // Call the original inject
28
+ return originalInject.call(hook, renderer)
29
+ }
30
+ }
31
+
32
+ // Set up the globals that the babel transform expects
33
+ // These are called by the transformed code to register components
34
+ ;(globalThis as any).$RefreshReg$ = (type: any, id: string) => {
35
+ RefreshRuntime.register(type, id)
36
+ }
37
+ ;(globalThis as any).$RefreshSig$ = () => {
38
+ return RefreshRuntime.createSignatureFunctionForTransform()
39
+ }
40
+ }
41
+
42
+ // Only load react-refresh in non-production
43
+ if (process.env.NODE_ENV !== 'production') {
44
+ // Dynamic import to avoid bundling in production
45
+ RefreshRuntime = require('react-refresh/runtime')
46
+ initializeReactRefresh()
47
+ }
48
+
49
+ // Get captured renderer internals
50
+ export function getRendererInternals() {
51
+ return capturedRendererInternals
52
+ }
53
+
54
+ // Check if we have a valid renderer with refresh capabilities
55
+ export function hasRefreshCapability() {
56
+ return !!(capturedRendererInternals?.scheduleRefresh)
57
+ }
58
+
59
+ export { RefreshRuntime }
@@ -471,7 +471,7 @@ export function DatePickerWidget({
471
471
  enableColors && isCursor
472
472
  ? Theme.primary
473
473
  : isValue
474
- ? Theme.selectedMuted
474
+ ? Theme.secondary
475
475
  : enableColors && isToday
476
476
  ? Theme.backgroundPanel
477
477
  : undefined,