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
package/src/compile.tsx CHANGED
@@ -15,6 +15,7 @@ const raycastAliasPlugin: BunPlugin = {
15
15
  build.onResolve({ filter: /^termcast/ }, (args) => ({
16
16
  path: require.resolve(args.path),
17
17
  }))
18
+
18
19
  build.onResolve({ filter: /^react$/ }, () => ({
19
20
  path: require.resolve('react'),
20
21
  }))
@@ -24,6 +25,8 @@ const raycastAliasPlugin: BunPlugin = {
24
25
  build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({
25
26
  path: require.resolve('react/jsx-dev-runtime'),
26
27
  }))
28
+
29
+
27
30
  },
28
31
  }
29
32
 
@@ -34,26 +37,32 @@ export function generateEntryCode({
34
37
  packageJson: import('./package-json').RaycastPackageJson
35
38
  commands: Array<{ name: string; bundledPath: string }>
36
39
  }): string {
37
- const commandImports = commands
38
- .map(
39
- (cmd, i) =>
40
- ` const { default: Command${i} } = await import(${JSON.stringify(cmd.bundledPath)});`,
41
- )
42
- .join('\n')
43
-
40
+ // Generate lazy loaders instead of importing all commands at startup
44
41
  const commandsArray = commands
45
42
  .map(
46
- (cmd, i) => ` {
43
+ (cmd) => ` {
47
44
  name: ${JSON.stringify(cmd.name)},
48
- Component: Command${i},
45
+ loadComponent: () => import(${JSON.stringify(cmd.bundledPath)}).then(m => m.default),
49
46
  }`,
50
47
  )
51
48
  .join(',\n')
52
49
 
53
50
  return `
54
51
  async function main() {
55
- ${commandImports}
52
+ // Set state BEFORE importing commands so module-scope code (e.g. getPreferenceValues) can access extensionPackageJson
53
+ const { useStore } = await import('termcast');
54
+ const os = await import('node:os');
55
+ const path = await import('node:path');
56
+
57
+ const packageJson = ${JSON.stringify(packageJson)};
58
+ const compiledExtensionPath = path.join(os.homedir(), '.termcast', 'compiled', packageJson.name);
56
59
 
60
+ useStore.setState({
61
+ extensionPath: compiledExtensionPath,
62
+ extensionPackageJson: packageJson,
63
+ });
64
+
65
+ // Commands are lazily loaded when selected (not at startup)
57
66
  const compiledCommands = [
58
67
  ${commandsArray}
59
68
  ];
@@ -61,7 +70,7 @@ ${commandsArray}
61
70
  const { startCompiledExtension } = await import('termcast/src/extensions/dev');
62
71
 
63
72
  await startCompiledExtension({
64
- packageJson: ${JSON.stringify(packageJson)},
73
+ packageJson,
65
74
  compiledCommands,
66
75
  skipArgv: 0,
67
76
  });
@@ -112,24 +121,6 @@ export function targetToFileSuffix(target: CompileTarget): string {
112
121
  return parts.join('-') + ext
113
122
  }
114
123
 
115
- // Same as targetToFileSuffix but without .exe extension (for archive names)
116
- export function targetToArchiveSuffix(target: CompileTarget): string {
117
- const os =
118
- target.os === 'win32'
119
- ? 'windows'
120
- : target.os === 'darwin'
121
- ? 'darwin'
122
- : 'linux'
123
- const parts = [os, target.arch]
124
- if (target.abi) {
125
- parts.push(target.abi)
126
- }
127
- if (target.avx2 === false) {
128
- parts.push('baseline')
129
- }
130
- return parts.join('-')
131
- }
132
-
133
124
  export function getArchiveExtension(target: CompileTarget): string {
134
125
  return target.os === 'linux' ? '.tar.gz' : '.zip'
135
126
  }
@@ -232,6 +223,7 @@ export async function compileExtension({
232
223
  },
233
224
  define: {
234
225
  'process.env.VERSION': JSON.stringify(version || ''),
226
+ // Use 'development' to avoid React bundling issues with CJS/ESM interop
235
227
  'process.env.NODE_ENV': JSON.stringify('production'),
236
228
  },
237
229
  plugins: [raycastAliasPlugin, swiftLoaderPlugin],
@@ -0,0 +1,300 @@
1
+ import { test, expect, afterEach, beforeAll } from 'vitest'
2
+ import { launchTerminal, Session } from 'tuistory/src'
3
+ import path from 'node:path'
4
+ import fs from 'node:fs'
5
+ import { execSync } from 'node:child_process'
6
+
7
+ const fixtureDir = path.resolve(__dirname, '../fixtures/simple-extension')
8
+ const distDir = path.join(fixtureDir, 'dist')
9
+ const executablePath = path.join(distDir, 'simple-extension')
10
+
11
+ let session: Session
12
+
13
+ afterEach(() => {
14
+ session?.close()
15
+ })
16
+
17
+ // Compile once before all tests
18
+ function ensureCompiled() {
19
+ if (!fs.existsSync(executablePath)) {
20
+ // Clean dist directory
21
+ if (fs.existsSync(distDir)) {
22
+ fs.rmSync(distDir, { recursive: true, force: true })
23
+ }
24
+
25
+ // Compile using CLI (runs in Bun context)
26
+ execSync(`bun src/cli.tsx compile ${fixtureDir}`, {
27
+ cwd: path.resolve(__dirname, '..'),
28
+ stdio: 'pipe',
29
+ })
30
+ }
31
+
32
+ if (!fs.existsSync(executablePath)) {
33
+ throw new Error(`Compiled executable not found at ${executablePath}`)
34
+ }
35
+ }
36
+
37
+ beforeAll(() => {
38
+ if (fs.existsSync(executablePath)) {
39
+ fs.unlinkSync(executablePath)
40
+ }
41
+ })
42
+
43
+
44
+ test('compile extension and run executable', async () => {
45
+ ensureCompiled()
46
+
47
+ // Run the compiled executable
48
+ session = await launchTerminal({
49
+ command: executablePath,
50
+ args: [],
51
+ cols: 60,
52
+ rows: 16,
53
+ })
54
+
55
+ await session.text({
56
+ waitFor: (text) => /Simple Test Extension/i.test(text),
57
+ timeout: 10000,
58
+ })
59
+
60
+ const snapshot = await session.text()
61
+ expect(snapshot).toMatchInlineSnapshot(`
62
+ "
63
+
64
+
65
+ Simple Test Extension ────────────────────────────────
66
+
67
+ > Search commands...
68
+
69
+ Commands ▲
70
+ ›List Items Displays a simple list with some ite view ▀
71
+ Search Items Search and filter through a list o view
72
+ Google Oauth view
73
+ usePromise Demo Shows how to use the usePromise view ▼
74
+
75
+
76
+ ↵ run command ↑↓ navigate ^k actions
77
+
78
+ "
79
+ `)
80
+ }, 60000)
81
+
82
+ test('compiled executable can run command', async () => {
83
+ ensureCompiled()
84
+
85
+ session = await launchTerminal({
86
+ command: executablePath,
87
+ args: [],
88
+ cols: 60,
89
+ rows: 16,
90
+ })
91
+
92
+ await session.text({
93
+ waitFor: (text) => /Simple Test Extension/i.test(text),
94
+ timeout: 10000,
95
+ })
96
+
97
+ // Select first command (List Items) - enter opens action panel, enter again runs
98
+ await session.press('enter')
99
+ await session.press('enter')
100
+
101
+ await session.text({
102
+ waitFor: (text) => /First Item/i.test(text),
103
+ timeout: 10000,
104
+ })
105
+
106
+ const listSnapshot = await session.text()
107
+ expect(listSnapshot).toMatchInlineSnapshot(`
108
+ "
109
+
110
+
111
+ List Items ───────────────────────────────────────────
112
+
113
+ > Search...
114
+
115
+ Items ▲
116
+ ›▲ First Item This is the first item █
117
+ ▲ Second Item This is the second item
118
+ ▲ Third Item This is the third item
119
+ ▲ Fourth Item This is the fourth item ▼
120
+
121
+
122
+ ✓ Copied to Clipboard First Item
123
+
124
+ "
125
+ `)
126
+ }, 60000)
127
+
128
+ test('compiled executable can navigate back', async () => {
129
+ ensureCompiled()
130
+
131
+ session = await launchTerminal({
132
+ command: executablePath,
133
+ args: [],
134
+ cols: 60,
135
+ rows: 16,
136
+ })
137
+
138
+ await session.text({
139
+ waitFor: (text) => /Simple Test Extension/i.test(text),
140
+ timeout: 10000,
141
+ })
142
+
143
+ // Run first command
144
+ await session.press('enter')
145
+ await session.press('enter')
146
+
147
+ await session.text({
148
+ waitFor: (text) => /First Item/i.test(text),
149
+ timeout: 10000,
150
+ })
151
+
152
+ // Navigate back with escape - this should either:
153
+ // 1. Go back to command list (if push was used)
154
+ // 2. Exit (if replace was used because only one command is visible)
155
+ await session.press('escape')
156
+
157
+ // Wait a bit for navigation to complete
158
+ await session.waitIdle()
159
+
160
+ const backSnapshot = await session.text()
161
+ // If we're back at the command list, we'll see "Simple Test Extension"
162
+ // If the command exited, the terminal might be empty
163
+ expect(backSnapshot).toMatchInlineSnapshot(`
164
+ "
165
+
166
+
167
+ List Items ───────────────────────────────────────────
168
+
169
+ > Search...
170
+
171
+ Items ▲
172
+ ›▲ First Item This is the first item █
173
+ ▲ Second Item This is the second item
174
+ ▲ Third Item This is the third item
175
+ ▲ Fourth Item This is the fourth item ▼
176
+
177
+
178
+ ↵ copy item title ↑↓ navigate ^k actions
179
+
180
+ "
181
+ `)
182
+ }, 60000)
183
+
184
+ test('compiled executable shows error when command throws at root scope', async () => {
185
+ ensureCompiled()
186
+
187
+ session = await launchTerminal({
188
+ command: executablePath,
189
+ args: [],
190
+ cols: 60,
191
+ rows: 20,
192
+ })
193
+
194
+ // With lazy loading, the command list should appear first
195
+ await session.text({
196
+ waitFor: (text) => /Simple Test Extension/i.test(text),
197
+ timeout: 10000,
198
+ })
199
+
200
+ // Filter to the Throw Error command
201
+ await session.type('throw error')
202
+ await session.waitIdle()
203
+
204
+ // Select and run the command
205
+ await session.press('enter')
206
+ await session.press('enter')
207
+
208
+ // Wait for error to be displayed
209
+ await session.text({
210
+ waitFor: (text) => /error/i.test(text),
211
+ timeout: 10000,
212
+ })
213
+
214
+ const errorSnapshot = await session.text()
215
+ expect(errorSnapshot).toMatchInlineSnapshot(`
216
+ "
217
+
218
+
219
+
220
+
221
+ Error: This is a test error thrown at root scope
222
+ at <anonymous> (fixtures/simple-extension/src/th
223
+ at processTicksAndRejections (native:7:39)
224
+
225
+
226
+
227
+
228
+
229
+
230
+
231
+
232
+
233
+
234
+
235
+
236
+ "
237
+ `)
238
+ }, 60000)
239
+
240
+ // Test for single-command extension with root-level error
241
+ const singleErrorFixtureDir = path.resolve(__dirname, '../fixtures/single-error-extension')
242
+ const singleErrorDistDir = path.join(singleErrorFixtureDir, 'dist')
243
+ const singleErrorExecutablePath = path.join(singleErrorDistDir, 'single-error-extension')
244
+
245
+ function ensureSingleErrorCompiled() {
246
+ if (!fs.existsSync(singleErrorExecutablePath)) {
247
+ if (fs.existsSync(singleErrorDistDir)) {
248
+ fs.rmSync(singleErrorDistDir, { recursive: true, force: true })
249
+ }
250
+
251
+ execSync(`bun src/cli.tsx compile ${singleErrorFixtureDir}`, {
252
+ cwd: path.resolve(__dirname, '..'),
253
+ stdio: 'pipe',
254
+ })
255
+ }
256
+
257
+ if (!fs.existsSync(singleErrorExecutablePath)) {
258
+ throw new Error(`Compiled executable not found at ${singleErrorExecutablePath}`)
259
+ }
260
+ }
261
+
262
+ test('single command extension shows error when command throws at root scope', async () => {
263
+ ensureSingleErrorCompiled()
264
+
265
+ session = await launchTerminal({
266
+ command: singleErrorExecutablePath,
267
+ args: [],
268
+ cols: 60,
269
+ rows: 20,
270
+ })
271
+
272
+ // Wait for something to appear
273
+ await session.waitIdle()
274
+
275
+ const errorSnapshot = await session.text()
276
+ expect(errorSnapshot).toMatchInlineSnapshot(`
277
+ "
278
+
279
+
280
+
281
+
282
+ Error
283
+ at <anonymous> (fixtures/single-error-extension/
284
+ at processTicksAndRejections (unknown:7:39)
285
+
286
+
287
+
288
+
289
+
290
+
291
+
292
+
293
+
294
+
295
+
296
+
297
+ "
298
+ `)
299
+ }, 60000)
300
+
@@ -20,6 +20,7 @@ import { useDialog } from 'termcast/src/internal/dialog'
20
20
  import { useNavigation } from 'termcast/src/internal/navigation'
21
21
  import { Dropdown } from 'termcast/src/components/dropdown'
22
22
  import { ExtensionPreferences } from 'termcast/src/components/extension-preferences'
23
+ import { ThemePicker } from 'termcast/src/components/theme-picker'
23
24
  import { useStore } from 'termcast/src/state'
24
25
  import { useIsInFocus } from 'termcast/src/internal/focus-context'
25
26
  import { useIsOffscreen } from 'termcast/src/internal/offscreen'
@@ -209,7 +210,7 @@ Action.Push = (props) => {
209
210
  props.onPush?.()
210
211
  // Push the target to dialog if needed
211
212
  if (props.target) {
212
- dialog.push(props.target, 'center')
213
+ dialog.push({ element: props.target, position: 'center' })
213
214
  }
214
215
  },
215
216
  })
@@ -530,17 +531,29 @@ Action.ToggleQuickLook = (props) => {
530
531
  useActionDescendant({
531
532
  title: props.title || 'Quick Look',
532
533
  shortcut: props.shortcut || { key: 'space' },
533
- execute: () => {
534
- // TODO: Implement Quick Look using macOS qlmanage command
535
- if (props.path) {
536
- logger.log(`Quick Look: ${props.path}`)
534
+ execute: async () => {
535
+ if (!props.path) {
536
+ props.onToggle?.()
537
+ return
537
538
  }
538
- props.onToggle?.()
539
- showToast({
540
- title: 'Quick Look',
541
- message: 'Quick Look not yet implemented',
542
- style: Toast.Style.Failure,
539
+
540
+ if (process.platform !== 'darwin') {
541
+ showToast({
542
+ title: 'Quick Look',
543
+ message: 'Quick Look is only supported on macOS',
544
+ style: Toast.Style.Failure,
545
+ })
546
+ return
547
+ }
548
+
549
+ const { spawn } = await import('node:child_process')
550
+ // qlmanage -p opens Quick Look preview
551
+ const child = spawn('qlmanage', ['-p', props.path], {
552
+ stdio: 'ignore',
553
+ detached: true,
543
554
  })
555
+ child.unref()
556
+ props.onToggle?.()
544
557
  },
545
558
  })
546
559
 
@@ -680,15 +693,16 @@ const ActionPanel: ActionPanelType = (props) => {
680
693
  }
681
694
  }, [descendantsContext.map, dialog, isOffscreen])
682
695
 
683
- // prevent showing actions if no dialog is shown (must be after hooks)
684
- if (!dialog.stack.length && !isOffscreen) return null
696
+ // prevent showing actions if we're not inside an actions dialog (must be after hooks)
697
+ const lastStackItem = dialog.stack[dialog.stack.length - 1]
698
+ if (lastStackItem?.type !== 'actions' && !isOffscreen) return null
685
699
 
686
700
  // ActionPanel renders as Dropdown with children
687
701
  return (
688
702
  <ActionDescendantsProvider value={descendantsContext}>
689
703
  <ActionPanelContext.Provider value={contextValue}>
690
704
  <Dropdown
691
- tooltip={title}
705
+ tooltip={title || 'Actions'}
692
706
  placeholder='Search actions...'
693
707
  filtering
694
708
  onChange={(value) => {
@@ -706,38 +720,43 @@ const ActionPanel: ActionPanelType = (props) => {
706
720
  }}
707
721
  >
708
722
  {children}
709
- {(hasExtensionPrefs || hasCommandPrefs) && (
710
- <ActionPanel.Section title="Settings">
711
- {hasExtensionPrefs && (
712
- <Action
713
- title="Configure Extension..."
714
- shortcut={{ modifiers: ['cmd', 'shift'], key: ',' }}
715
- onAction={() => {
716
- dialog.clear()
717
- push(
718
- <ExtensionPreferences
719
- extensionName={extensionPackageJson!.name}
720
- />,
721
- )
722
- }}
723
- />
724
- )}
725
- {hasCommandPrefs && (
726
- <Action
727
- title="Configure Command..."
728
- onAction={() => {
729
- dialog.clear()
730
- push(
731
- <ExtensionPreferences
732
- extensionName={extensionPackageJson!.name}
733
- commandName={currentCommandName!}
734
- />,
735
- )
736
- }}
737
- />
738
- )}
739
- </ActionPanel.Section>
740
- )}
723
+ <ActionPanel.Section title="Settings">
724
+ {hasExtensionPrefs && (
725
+ <Action
726
+ title={`Configure ${extensionPackageJson!.title}...`}
727
+ shortcut={{ modifiers: ['cmd', 'shift'], key: ',' }}
728
+ onAction={() => {
729
+ dialog.clear()
730
+ push(
731
+ <ExtensionPreferences
732
+ extensionName={extensionPackageJson!.name}
733
+ />,
734
+ )
735
+ }}
736
+ />
737
+ )}
738
+ {hasCommandPrefs && (
739
+ <Action
740
+ title="Configure Command..."
741
+ onAction={() => {
742
+ dialog.clear()
743
+ push(
744
+ <ExtensionPreferences
745
+ extensionName={extensionPackageJson!.name}
746
+ commandName={currentCommandName!}
747
+ />,
748
+ )
749
+ }}
750
+ />
751
+ )}
752
+ <Action
753
+ title="Change Theme..."
754
+ onAction={() => {
755
+ dialog.clear()
756
+ dialog.push({ element: <ThemePicker /> })
757
+ }}
758
+ />
759
+ </ActionPanel.Section>
741
760
  </Dropdown>
742
761
  </ActionPanelContext.Provider>
743
762
  </ActionDescendantsProvider>
@@ -0,0 +1,85 @@
1
+ import React from 'react'
2
+
3
+ /**
4
+ * Shared animation tick system for synchronized loading animations.
5
+ *
6
+ * Components subscribe to a global tick counter that increments every 20ms.
7
+ * Each component can compute its animation state based on the tick value.
8
+ *
9
+ * Intervals (coordinated so animations look synchronized):
10
+ * - LoadingBar: 40ms (2 ticks) - wave animation
11
+ * - LoadingText: 40ms (2 ticks) - wave animation, same speed as bar
12
+ * - Spinner: 200ms (10 ticks) - pulses every 5 wave steps
13
+ */
14
+
15
+ type TickListener = (tick: number) => void
16
+
17
+ let globalTick = 0
18
+ let intervalId: NodeJS.Timeout | null = null
19
+ const listeners = new Set<TickListener>()
20
+
21
+ const BASE_INTERVAL_MS = 20
22
+
23
+ function startGlobalTick() {
24
+ if (intervalId) return
25
+ intervalId = setInterval(() => {
26
+ globalTick++
27
+ listeners.forEach((listener) => {
28
+ listener(globalTick)
29
+ })
30
+ }, BASE_INTERVAL_MS)
31
+ }
32
+
33
+ function stopGlobalTick() {
34
+ if (intervalId) {
35
+ clearInterval(intervalId)
36
+ intervalId = null
37
+ }
38
+ }
39
+
40
+ function subscribe(listener: TickListener) {
41
+ listeners.add(listener)
42
+ if (listeners.size === 1) {
43
+ startGlobalTick()
44
+ }
45
+ return () => {
46
+ listeners.delete(listener)
47
+ if (listeners.size === 0) {
48
+ stopGlobalTick()
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Hook to subscribe to animation ticks.
55
+ * @param divisor - Only triggers re-render when tick is divisible by this value. Pass 0 to disable (no subscription).
56
+ * @returns The current tick value (divided by divisor)
57
+ */
58
+ export function useAnimationTick(divisor: number = 1): number {
59
+ const [tick, setTick] = React.useState(0)
60
+
61
+ React.useEffect(() => {
62
+ // Don't subscribe if divisor is 0 (disabled)
63
+ if (divisor <= 0) {
64
+ setTick(0) // Reset when disabled
65
+ return
66
+ }
67
+
68
+ const unsubscribe = subscribe((currentTick) => {
69
+ if (currentTick % divisor === 0) {
70
+ setTick(Math.floor(currentTick / divisor))
71
+ }
72
+ })
73
+ return unsubscribe
74
+ }, [divisor])
75
+
76
+ return tick
77
+ }
78
+
79
+ // Tick divisors for each component type (base interval is 20ms)
80
+ // Waves share the same speed so they animate in sync
81
+ export const TICK_DIVISORS = {
82
+ LOADING_BAR: 2, // 40ms - wave animation
83
+ LOADING_TEXT: 2, // 40ms - wave animation (same as bar)
84
+ SPINNER: 10, // 200ms - pulses every 5 wave steps
85
+ } as const