termcast 1.3.50 → 1.3.52

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 (178) hide show
  1. package/dist/apis/environment.d.ts +1 -0
  2. package/dist/apis/environment.d.ts.map +1 -1
  3. package/dist/apis/environment.js +5 -0
  4. package/dist/apis/environment.js.map +1 -1
  5. package/dist/app.d.ts +33 -0
  6. package/dist/app.d.ts.map +1 -0
  7. package/dist/app.js +1130 -0
  8. package/dist/app.js.map +1 -0
  9. package/dist/cli.js +80 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/compile.d.ts.map +1 -1
  12. package/dist/compile.js +5 -2
  13. package/dist/compile.js.map +1 -1
  14. package/dist/components/actions.d.ts +4 -1
  15. package/dist/components/actions.d.ts.map +1 -1
  16. package/dist/components/actions.js +8 -5
  17. package/dist/components/actions.js.map +1 -1
  18. package/dist/components/detail.d.ts.map +1 -1
  19. package/dist/components/detail.js +21 -18
  20. package/dist/components/detail.js.map +1 -1
  21. package/dist/components/dropdown.d.ts.map +1 -1
  22. package/dist/components/dropdown.js +3 -2
  23. package/dist/components/dropdown.js.map +1 -1
  24. package/dist/components/footer.d.ts +6 -0
  25. package/dist/components/footer.d.ts.map +1 -1
  26. package/dist/components/footer.js +15 -6
  27. package/dist/components/footer.js.map +1 -1
  28. package/dist/components/form/checkbox.d.ts.map +1 -1
  29. package/dist/components/form/checkbox.js +1 -13
  30. package/dist/components/form/checkbox.js.map +1 -1
  31. package/dist/components/form/date-picker.js +2 -2
  32. package/dist/components/form/date-picker.js.map +1 -1
  33. package/dist/components/form/description.js +1 -1
  34. package/dist/components/form/description.js.map +1 -1
  35. package/dist/components/form/dropdown.d.ts.map +1 -1
  36. package/dist/components/form/dropdown.js +19 -3
  37. package/dist/components/form/dropdown.js.map +1 -1
  38. package/dist/components/form/file-picker.d.ts.map +1 -1
  39. package/dist/components/form/file-picker.js +22 -4
  40. package/dist/components/form/file-picker.js.map +1 -1
  41. package/dist/components/form/index.d.ts +3 -1
  42. package/dist/components/form/index.d.ts.map +1 -1
  43. package/dist/components/form/index.js +7 -5
  44. package/dist/components/form/index.js.map +1 -1
  45. package/dist/components/form/password-field.js +3 -3
  46. package/dist/components/form/password-field.js.map +1 -1
  47. package/dist/components/form/text-area.d.ts.map +1 -1
  48. package/dist/components/form/text-area.js +29 -6
  49. package/dist/components/form/text-area.js.map +1 -1
  50. package/dist/components/form/text-field.js +3 -3
  51. package/dist/components/form/text-field.js.map +1 -1
  52. package/dist/components/graph.d.ts.map +1 -1
  53. package/dist/components/graph.js +21 -25
  54. package/dist/components/graph.js.map +1 -1
  55. package/dist/components/heatmap.d.ts +80 -0
  56. package/dist/components/heatmap.d.ts.map +1 -0
  57. package/dist/components/heatmap.js +424 -0
  58. package/dist/components/heatmap.js.map +1 -0
  59. package/dist/components/list.d.ts +2 -0
  60. package/dist/components/list.d.ts.map +1 -1
  61. package/dist/components/list.js +91 -58
  62. package/dist/components/list.js.map +1 -1
  63. package/dist/components/markdown.d.ts +7 -0
  64. package/dist/components/markdown.d.ts.map +1 -0
  65. package/dist/components/markdown.js +19 -0
  66. package/dist/components/markdown.js.map +1 -0
  67. package/dist/components/metadata.d.ts.map +1 -1
  68. package/dist/components/metadata.js +4 -1
  69. package/dist/components/metadata.js.map +1 -1
  70. package/dist/components/progress-bar.d.ts +37 -0
  71. package/dist/components/progress-bar.d.ts.map +1 -0
  72. package/dist/components/progress-bar.js +34 -0
  73. package/dist/components/progress-bar.js.map +1 -0
  74. package/dist/components/table.d.ts +3 -2
  75. package/dist/components/table.d.ts.map +1 -1
  76. package/dist/components/table.js +78 -63
  77. package/dist/components/table.js.map +1 -1
  78. package/dist/diagram-parser.d.ts +17 -3
  79. package/dist/diagram-parser.d.ts.map +1 -1
  80. package/dist/diagram-parser.js +17 -3
  81. package/dist/diagram-parser.js.map +1 -1
  82. package/dist/examples/list-slot.d.ts +2 -0
  83. package/dist/examples/list-slot.d.ts.map +1 -0
  84. package/dist/examples/list-slot.js +14 -0
  85. package/dist/examples/list-slot.js.map +1 -0
  86. package/dist/examples/list-with-dropdown.js +2 -4
  87. package/dist/examples/list-with-dropdown.js.map +1 -1
  88. package/dist/examples/simple-heatmap.d.ts +2 -0
  89. package/dist/examples/simple-heatmap.d.ts.map +1 -0
  90. package/dist/examples/simple-heatmap.js +37 -0
  91. package/dist/examples/simple-heatmap.js.map +1 -0
  92. package/dist/examples/simple-progress-bar.d.ts +2 -0
  93. package/dist/examples/simple-progress-bar.d.ts.map +1 -0
  94. package/dist/examples/simple-progress-bar.js +36 -0
  95. package/dist/examples/simple-progress-bar.js.map +1 -0
  96. package/dist/index.d.ts +6 -0
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +6 -0
  99. package/dist/index.js.map +1 -1
  100. package/dist/internal/date-picker-widget.d.ts.map +1 -1
  101. package/dist/internal/date-picker-widget.js +5 -4
  102. package/dist/internal/date-picker-widget.js.map +1 -1
  103. package/dist/internal/navigation.d.ts.map +1 -1
  104. package/dist/internal/navigation.js +7 -2
  105. package/dist/internal/navigation.js.map +1 -1
  106. package/dist/internal/providers.d.ts.map +1 -1
  107. package/dist/internal/providers.js +42 -4
  108. package/dist/internal/providers.js.map +1 -1
  109. package/dist/logger.js +6 -1
  110. package/dist/logger.js.map +1 -1
  111. package/dist/state.d.ts +2 -0
  112. package/dist/state.d.ts.map +1 -1
  113. package/dist/state.js +31 -2
  114. package/dist/state.js.map +1 -1
  115. package/dist/theme.d.ts +1 -0
  116. package/dist/theme.d.ts.map +1 -1
  117. package/dist/theme.js +23 -1
  118. package/dist/theme.js.map +1 -1
  119. package/dist/utils.d.ts.map +1 -1
  120. package/dist/utils.js +6 -1
  121. package/dist/utils.js.map +1 -1
  122. package/package.json +3 -3
  123. package/src/apis/environment.tsx +6 -0
  124. package/src/app.tsx +1492 -0
  125. package/src/assets/default-app-icon.png +0 -0
  126. package/src/cli.tsx +105 -0
  127. package/src/compile.tsx +5 -2
  128. package/src/components/actions.tsx +9 -6
  129. package/src/components/detail.tsx +33 -23
  130. package/src/components/dropdown.tsx +3 -2
  131. package/src/components/footer.tsx +40 -7
  132. package/src/components/form/checkbox.tsx +2 -17
  133. package/src/components/form/date-picker.tsx +2 -2
  134. package/src/components/form/description.tsx +1 -1
  135. package/src/components/form/dropdown.tsx +22 -3
  136. package/src/components/form/file-picker.tsx +33 -10
  137. package/src/components/form/index.tsx +11 -7
  138. package/src/components/form/password-field.tsx +3 -3
  139. package/src/components/form/text-area.tsx +31 -6
  140. package/src/components/form/text-field.tsx +3 -3
  141. package/src/components/graph.tsx +21 -24
  142. package/src/components/heatmap.tsx +602 -0
  143. package/src/components/list.tsx +147 -78
  144. package/src/components/markdown.tsx +30 -0
  145. package/src/components/metadata.tsx +9 -2
  146. package/src/components/progress-bar.tsx +112 -0
  147. package/src/components/table.tsx +88 -71
  148. package/src/diagram-parser.tsx +17 -3
  149. package/src/examples/bar-graph-weekly.vitest.tsx +4 -4
  150. package/src/examples/detail-metadata-showcase.vitest.tsx +12 -12
  151. package/src/examples/form-basic.vitest.tsx +117 -16
  152. package/src/examples/graph-bar-chart.vitest.tsx +7 -7
  153. package/src/examples/graph-row.vitest.tsx +45 -45
  154. package/src/examples/graph-styles.vitest.tsx +19 -19
  155. package/src/examples/internal/descendants-rerender.vitest.tsx +94 -46
  156. package/src/examples/internal/simple-scrollbox.vitest.tsx +38 -14
  157. package/src/examples/list-dropdown-default.vitest.tsx +78 -58
  158. package/src/examples/list-slot.tsx +38 -0
  159. package/src/examples/list-with-detail.vitest.tsx +8 -8
  160. package/src/examples/list-with-dropdown.tsx +2 -2
  161. package/src/examples/list-with-dropdown.vitest.tsx +16 -16
  162. package/src/examples/list-with-sections.vitest.tsx +45 -32
  163. package/src/examples/simple-detail-table.vitest.tsx +2 -2
  164. package/src/examples/simple-file-picker.vitest.tsx +1 -1
  165. package/src/examples/simple-grid.vitest.tsx +27 -53
  166. package/src/examples/simple-heatmap.tsx +63 -0
  167. package/src/examples/simple-heatmap.vitest.tsx +88 -0
  168. package/src/examples/simple-progress-bar.tsx +82 -0
  169. package/src/examples/simple-progress-bar.vitest.tsx +72 -0
  170. package/src/examples/table-edge-cases.vitest.tsx +1 -1
  171. package/src/index.tsx +19 -0
  172. package/src/internal/date-picker-widget.tsx +23 -12
  173. package/src/internal/navigation.tsx +7 -2
  174. package/src/internal/providers.tsx +48 -3
  175. package/src/logger.tsx +6 -1
  176. package/src/state.tsx +38 -2
  177. package/src/theme.tsx +26 -2
  178. package/src/utils.tsx +6 -1
Binary file
package/src/cli.tsx CHANGED
@@ -17,6 +17,8 @@ import './globals'
17
17
  import { startDevMode, triggerRebuild } from './extensions/dev'
18
18
  import { compileExtension } from './compile'
19
19
  import { releaseExtension } from './release'
20
+ import { buildApp } from './app'
21
+ import { themeNames } from './themes'
20
22
  import { runHomeCommand } from './extensions/home'
21
23
  import { showToast, Toast } from './apis/toast'
22
24
  import packageJson from '../package.json'
@@ -257,6 +259,109 @@ cli
257
259
  }
258
260
  })
259
261
 
262
+ cli
263
+ .command(
264
+ 'app build [path]',
265
+ 'Build a standalone desktop app from a termcast extension (macOS .app or Windows folder)',
266
+ )
267
+ .option('--name <name>', 'App display name (default: package.json title)')
268
+ .option('--icon <path>', 'Custom icon PNG path (default: extension icon or bundled default)')
269
+ .option('--bundle-id <id>', 'macOS bundle identifier (default: com.termcast.{name})')
270
+ .option('--release', 'Upload the app zip to the latest GitHub release')
271
+ .option('--entry <file>', 'Custom entry file (instead of auto-generated one)')
272
+ .option('--platform <platform>', 'Target platform: darwin or win32 (default: current OS)')
273
+ .option('--arch <arch>', 'Target architecture: arm64 or x64 (default: current machine)')
274
+ .option('--no-installer', 'Skip NSIS installer generation on Windows')
275
+ .option('--font <family>', 'Font family name (e.g. "Inter Mono", "Fira Code")')
276
+ .option('--font-dir <path>', 'Directory of .ttf/.otf files to bundle (auto-detects fonts/ in extension)')
277
+ .option('--font-size <size>', 'Font size in points (default: 14)')
278
+ .option('--line-height <height>', 'Line height multiplier (default: 1.2)')
279
+ .option('--theme <name>', 'Default theme name (default: nerv)')
280
+ .action(
281
+ async (
282
+ extensionPath: string,
283
+ options: {
284
+ name?: string
285
+ icon?: string
286
+ bundleId?: string
287
+ release?: boolean
288
+ entry?: string
289
+ platform?: string
290
+ arch?: string
291
+ installer?: boolean
292
+ font?: string
293
+ fontDir?: string
294
+ fontSize?: string
295
+ lineHeight?: string
296
+ theme?: string
297
+ },
298
+ ) => {
299
+ extensionPath = path.resolve(extensionPath || process.cwd())
300
+
301
+ if (options.arch && options.arch !== 'arm64' && options.arch !== 'x64') {
302
+ console.error(`Invalid --arch "${options.arch}". Must be "arm64" or "x64".`)
303
+ process.exit(1)
304
+ }
305
+ if (options.platform && options.platform !== 'darwin' && options.platform !== 'win32') {
306
+ console.error(`Invalid --platform "${options.platform}". Must be "darwin" or "win32".`)
307
+ process.exit(1)
308
+ }
309
+
310
+ const fontSize = options.fontSize ? parseFloat(options.fontSize) : undefined
311
+ if (fontSize !== undefined && !Number.isFinite(fontSize)) {
312
+ console.error(`Invalid --font-size "${options.fontSize}". Must be a number.`)
313
+ process.exit(1)
314
+ }
315
+ const lineHeight = options.lineHeight ? parseFloat(options.lineHeight) : undefined
316
+ if (lineHeight !== undefined && !Number.isFinite(lineHeight)) {
317
+ console.error(`Invalid --line-height "${options.lineHeight}". Must be a number.`)
318
+ process.exit(1)
319
+ }
320
+
321
+ if (options.theme && !themeNames.includes(options.theme)) {
322
+ console.error(`Invalid --theme "${options.theme}". Available themes: ${themeNames.join(', ')}`)
323
+ process.exit(1)
324
+ }
325
+
326
+ try {
327
+ const result = await buildApp({
328
+ extensionPath,
329
+ name: options.name,
330
+ icon: options.icon,
331
+ bundleId: options.bundleId,
332
+ release: options.release,
333
+ entry: options.entry,
334
+ platform: options.platform as 'darwin' | 'win32' | undefined,
335
+ arch: options.arch as 'arm64' | 'x64' | undefined,
336
+ // goke parses --no-installer as installer:false
337
+ noInstaller: options.installer === false,
338
+ fontFamily: options.font,
339
+ fontDir: options.fontDir,
340
+ fontSize,
341
+ lineHeight,
342
+ theme: options.theme,
343
+ })
344
+
345
+ const resolvedPlatform = options.platform || process.platform
346
+ console.log(`\nApp built: ${result.appPath}`)
347
+ if (resolvedPlatform === 'win32') {
348
+ if (result.installerPath) {
349
+ console.log(`Installer: ${result.installerPath}`)
350
+ console.log(`Distribute the installer .exe. Users double-click to install.`)
351
+ } else {
352
+ console.log(`Distribute the folder as a zip. Users run: ${result.appName}.exe`)
353
+ }
354
+ } else {
355
+ console.log(`Run it with: open "${result.appPath}"`)
356
+ }
357
+ process.exit(0)
358
+ } catch (error) {
359
+ console.error('App build failed:', error instanceof Error ? error.message : error)
360
+ process.exit(1)
361
+ }
362
+ },
363
+ )
364
+
260
365
  cli
261
366
  .command(
262
367
  'raycast-pr <prNumber>',
package/src/compile.tsx CHANGED
@@ -6,13 +6,16 @@ import { getCommandsWithFiles } from './package-json'
6
6
  import { swiftLoaderPlugin } from './swift-loader'
7
7
 
8
8
  // compile.tsx lives at termcast/src/compile.tsx, so __dirname is termcast/src/
9
+ // When running from dist/, __dirname is termcast/dist/ — always resolve to src/index.tsx
10
+ // so the plugin works both in dev and from the published package.
9
11
  const termcastRoot = path.resolve(__dirname, '..')
12
+ const termcastEntry = path.join(termcastRoot, 'src', 'index.tsx')
10
13
 
11
14
  const raycastAliasPlugin: BunPlugin = {
12
15
  name: 'raycast-to-termcast',
13
16
  setup(build) {
14
17
  build.onResolve({ filter: /@raycast\/api/ }, () => ({
15
- path: path.join(__dirname, 'index.tsx'),
18
+ path: termcastEntry,
16
19
  }))
17
20
  build.onResolve({ filter: /@raycast\/utils/ }, () => ({
18
21
  path: require.resolve('@termcast/utils'),
@@ -20,7 +23,7 @@ const raycastAliasPlugin: BunPlugin = {
20
23
  // termcast and termcast/* — resolve directly from the package source
21
24
  build.onResolve({ filter: /^termcast/ }, (args) => ({
22
25
  path: args.path === 'termcast'
23
- ? path.join(__dirname, 'index.tsx')
26
+ ? termcastEntry
24
27
  : require.resolve(args.path, { paths: [termcastRoot] }),
25
28
  }))
26
29
  build.onResolve({ filter: /^react(\/|$)/ }, (args) => ({
@@ -700,12 +700,13 @@ function formatShortcut(
700
700
  /**
701
701
  * Check if a keyboard event matches an action shortcut.
702
702
  * Handles modifier mapping:
703
- * - 'cmd' maps to ctrl (terminals can't intercept cmd)
703
+ * - 'cmd' maps to ctrl, super, or hyper (ctrl in normal terminals,
704
+ * super/hyper in standalone apps where WezTerm forwards Cmd via kitty protocol)
704
705
  * - 'alt'/'opt' checks evt.meta (opentui uses meta for alt on Linux/Windows)
705
706
  * and evt.option (opentui uses option for alt on macOS)
706
707
  */
707
708
  export function matchesShortcut(
708
- evt: { name: string; ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean; option?: boolean },
709
+ evt: { name: string; ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean; option?: boolean; super?: boolean; hyper?: boolean },
709
710
  shortcut: { modifiers?: KeyboardKeyModifier[]; key: KeyboardKeyEquivalent },
710
711
  ): boolean {
711
712
  // Check key name matches (case-insensitive)
@@ -715,10 +716,12 @@ export function matchesShortcut(
715
716
 
716
717
  const modifiers = shortcut.modifiers || []
717
718
 
718
- // Map cmd to ctrl (terminals can't intercept cmd)
719
- const needsCtrl = modifiers.some((m) =>
719
+ // Map cmd to ctrl, super, or hyper. In normal terminals cmd arrives as ctrl.
720
+ // In standalone apps WezTerm forwards Cmd as super via kitty protocol.
721
+ const needsCmd = modifiers.some((m) =>
720
722
  ['cmd', 'ctrl', 'control'].includes(m.toLowerCase()),
721
723
  )
724
+ const hasCmd = Boolean(evt.ctrl || evt.super || evt.hyper)
722
725
  // alt/opt in shortcuts - opentui uses meta (Linux/Windows) or option (macOS) for alt key
723
726
  const needsAlt = modifiers.some((m) =>
724
727
  ['alt', 'opt', 'option'].includes(m.toLowerCase()),
@@ -726,14 +729,14 @@ export function matchesShortcut(
726
729
  const needsShift = modifiers.includes('shift')
727
730
 
728
731
  // Check all required modifiers are pressed
729
- if (needsCtrl && !evt.ctrl) return false
732
+ if (needsCmd && !hasCmd) return false
730
733
  // For alt, check both meta and option (opentui platform differences)
731
734
  const hasAlt = evt.alt || evt.meta || evt.option
732
735
  if (needsAlt && !hasAlt) return false
733
736
  if (needsShift && !evt.shift) return false
734
737
 
735
738
  // Check no extra modifiers are pressed (excluding ones that match)
736
- if (evt.ctrl && !needsCtrl) return false
739
+ if (hasCmd && !needsCmd) return false
737
740
  if (hasAlt && !needsAlt) return false
738
741
  if (evt.shift && !needsShift) return false
739
742
 
@@ -1,18 +1,19 @@
1
1
  import React, { ReactNode, useMemo, ReactElement } from 'react'
2
2
  import { TextAttributes } from '@opentui/core'
3
- import { useKeyboard, useTerminalDimensions, useRenderer } from '@opentui/react'
4
- import { useTheme, markdownSyntaxStyle } from 'termcast/src/theme'
3
+ import { useKeyboard, useTerminalDimensions } from '@opentui/react'
4
+ import { useTheme } from 'termcast/src/theme'
5
5
  import { InFocus, useIsInFocus } from 'termcast/src/internal/focus-context'
6
6
  import { ActionPanel, Action } from 'termcast/src/components/actions'
7
- import { Footer } from 'termcast/src/components/footer'
7
+ import { Footer, Hoverable } from 'termcast/src/components/footer'
8
8
 
9
9
  import { useDialog } from 'termcast/src/internal/dialog'
10
+ import { useNavigation } from 'termcast/src/internal/navigation'
10
11
  import { ScrollBox } from 'termcast/src/internal/scrollbox'
11
12
  import { useStore } from 'termcast/src/state'
12
13
  import { Offscreen } from 'termcast/src/internal/offscreen'
13
14
  import { Metadata, MetadataContext, extractTitleLengths, defaultConfig } from 'termcast/src/components/metadata'
14
15
  import type { LabelProps, SeparatorProps, LinkProps, TagListProps, TagListItemProps, MetadataConfig } from 'termcast/src/components/metadata'
15
- import { createMarkdownRenderNode } from 'termcast/src/markdown-utils'
16
+ import { Markdown } from 'termcast/src/components/markdown'
16
17
 
17
18
  interface ActionsInterface {
18
19
  actions?: ReactNode
@@ -101,36 +102,54 @@ DetailMetadata.TagList = Metadata.TagList
101
102
  function DetailFooter({
102
103
  hasActions,
103
104
  firstActionTitle,
105
+ onGoBack,
104
106
  }: {
105
107
  hasActions?: boolean
106
108
  firstActionTitle?: string
109
+ onGoBack?: () => void
107
110
  }): any {
108
111
  const theme = useTheme()
109
112
 
110
113
  return (
111
114
  <Footer paddingLeft={0} paddingRight={0}>
112
115
  <box style={{ flexDirection: 'row', gap: 3 }}>
113
- <box style={{ flexDirection: 'row', gap: 1 }}>
116
+ <Hoverable
117
+ onMouseDown={() => {
118
+ // Defer pop so opentui finishes its mouse event walk before
119
+ // React unmounts the component tree (avoids Yoga WASM crash)
120
+ queueMicrotask(() => {
121
+ onGoBack?.()
122
+ })
123
+ }}
124
+ >
114
125
  <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
115
126
  esc
116
127
  </text>
117
128
  <text flexShrink={0} fg={theme.textMuted}>go back</text>
118
- </box>
129
+ </Hoverable>
119
130
  {hasActions && (
120
- <box style={{ flexDirection: 'row', gap: 1 }}>
131
+ <Hoverable
132
+ onMouseDown={() => {
133
+ useStore.setState({ showActionsDialog: true })
134
+ }}
135
+ >
121
136
  <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
122
137
  ^k
123
138
  </text>
124
139
  <text flexShrink={0} fg={theme.textMuted}>actions</text>
125
- </box>
140
+ </Hoverable>
126
141
  )}
127
142
  {hasActions && firstActionTitle && (
128
- <box style={{ flexDirection: 'row', gap: 1 }}>
143
+ <Hoverable
144
+ onMouseDown={() => {
145
+ useStore.setState({ shouldAutoExecuteFirstAction: true })
146
+ }}
147
+ >
129
148
  <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
130
149
 
131
150
  </text>
132
151
  <text flexShrink={0} fg={theme.textMuted}>{firstActionTitle}</text>
133
- </box>
152
+ </Hoverable>
134
153
  )}
135
154
  </box>
136
155
  </Footer>
@@ -170,23 +189,13 @@ function getFirstActionTitle(actions: ReactNode): string | undefined {
170
189
  return firstTitle
171
190
  }
172
191
 
173
- // Renders markdown with link URL stripping via renderNode.
174
- // Links show only their title text (bold, primary) with click-to-open.
175
- function MarkdownContent({ markdown }: { markdown: string }): any {
176
- const renderer = useRenderer()
177
- const renderNode = useMemo(() => {
178
- return createMarkdownRenderNode(renderer)
179
- }, [renderer])
180
192
 
181
- return (
182
- <markdown content={markdown} syntaxStyle={markdownSyntaxStyle} conceal renderNode={renderNode} />
183
- )
184
- }
185
193
 
186
194
  const Detail: DetailType = (props) => {
187
195
  const { actions } = props
188
196
  const dialog = useDialog()
189
197
  const inFocus = useIsInFocus()
198
+ const navigation = useNavigation()
190
199
 
191
200
  const firstActionTitle = useMemo(() => {
192
201
  return actions ? getFirstActionTitle(actions) : undefined
@@ -195,7 +204,7 @@ const Detail: DetailType = (props) => {
195
204
  useKeyboard((evt) => {
196
205
  if (!inFocus) return
197
206
 
198
- if (evt.name === 'k' && evt.ctrl) {
207
+ if (evt.name === 'k' && (evt.ctrl || evt.super)) {
199
208
  // Always open — built-in actions (Change Theme, etc.) are always available
200
209
  useStore.setState({ showActionsDialog: true })
201
210
  } else if (evt.name === 'return' && actions) {
@@ -224,7 +233,7 @@ const Detail: DetailType = (props) => {
224
233
  }}
225
234
  >
226
235
  {props.markdown && (
227
- <MarkdownContent markdown={props.markdown} />
236
+ <Markdown content={props.markdown} />
228
237
  )}
229
238
  {props.metadata}
230
239
  </box>
@@ -237,6 +246,7 @@ const Detail: DetailType = (props) => {
237
246
  <DetailFooter
238
247
  hasActions={true}
239
248
  firstActionTitle={firstActionTitle}
249
+ onGoBack={navigation.pop}
240
250
  />
241
251
  {/* Always mount ActionPanel offscreen so built-in actions are available */}
242
252
  <Offscreen>{actions || <ActionPanel />}</Offscreen>
@@ -14,7 +14,7 @@ import {
14
14
  ScrollBoxRenderable,
15
15
  TextareaRenderable,
16
16
  } from '@opentui/core'
17
- import { useTheme } from 'termcast/src/theme'
17
+ import { getInteractiveHoverBackground, useTheme } from 'termcast/src/theme'
18
18
  import { getIconValue } from 'termcast/src/components/icon'
19
19
  import { logger } from 'termcast/src/logger'
20
20
  import { useStore } from 'termcast/src/state'
@@ -441,6 +441,7 @@ function ItemOption(props: {
441
441
  elementRef?: React.Ref<any>
442
442
  }) {
443
443
  const theme = useTheme()
444
+ const hoverBackgroundColor = getInteractiveHoverBackground(theme)
444
445
  const [isHovered, setIsHovered] = useState(false)
445
446
 
446
447
  return (
@@ -451,7 +452,7 @@ function ItemOption(props: {
451
452
  backgroundColor: props.active
452
453
  ? theme.primary
453
454
  : isHovered
454
- ? theme.backgroundPanel
455
+ ? hoverBackgroundColor
455
456
  : undefined,
456
457
  paddingLeft: props.active ? 0 : 1,
457
458
  paddingRight: 1,
@@ -13,6 +13,32 @@ import {
13
13
  } from 'termcast/src/state'
14
14
  import { useIsInFocus } from 'termcast/src/internal/focus-context'
15
15
 
16
+ interface HoverableProps {
17
+ children: ReactNode
18
+ onMouseDown?: () => void
19
+ flexShrink?: number
20
+ }
21
+
22
+ // Clickable box that shows a subtle background on hover.
23
+ // Keeps hover state local to each label for minimal re-renders.
24
+ export function Hoverable({ children, onMouseDown, flexShrink = 0 }: HoverableProps): any {
25
+ const [isHovered, setIsHovered] = useState(false)
26
+ const theme = useTheme()
27
+ return (
28
+ <box
29
+ flexDirection='row'
30
+ gap={1}
31
+ flexShrink={flexShrink}
32
+ backgroundColor={isHovered ? theme.backgroundElement : undefined}
33
+ onMouseMove={() => { setIsHovered(true) }}
34
+ onMouseOut={() => { setIsHovered(false) }}
35
+ onMouseDown={onMouseDown}
36
+ >
37
+ {children}
38
+ </box>
39
+ )
40
+ }
41
+
16
42
  interface FooterProps {
17
43
  children?: ReactNode
18
44
  paddingLeft?: number
@@ -117,6 +143,7 @@ function ToastInline({ toast }: { toast: ToastData }): any {
117
143
  overflow='hidden'
118
144
  height={1}
119
145
  backgroundColor={toastBg}
146
+ onMouseDown={() => { toast.onHide() }}
120
147
  >
121
148
  {/* Title box */}
122
149
  <box
@@ -125,6 +152,7 @@ function ToastInline({ toast }: { toast: ToastData }): any {
125
152
  paddingRight={1}
126
153
  overflow='hidden'
127
154
  height={1}
155
+ onMouseDown={() => { toast.onHide() }}
128
156
  >
129
157
  <text flexShrink={0} fg={toastFg} wrapMode='none'>
130
158
  {getIcon()}{' '}
@@ -147,6 +175,7 @@ function ToastInline({ toast }: { toast: ToastData }): any {
147
175
  flexDirection='row'
148
176
  overflow='hidden'
149
177
  height={1}
178
+ onMouseDown={() => { toast.onHide() }}
150
179
  >
151
180
  <text fg={toastFg} flexShrink={1} wrapMode='none'>
152
181
  {toast.message || ''}
@@ -160,12 +189,14 @@ function ToastInline({ toast }: { toast: ToastData }): any {
160
189
  flexShrink={0}
161
190
  overflow='hidden'
162
191
  height={1}
192
+ onMouseDown={() => { toast.onHide() }}
163
193
  >
164
194
  {toast.primaryAction?.title && (
165
195
  <box
166
196
  flexShrink={0}
167
197
  flexDirection='row'
168
- onMouseDown={() => {
198
+ onMouseDown={(evt) => {
199
+ evt.stopPropagation()
169
200
  toast.primaryAction?.onAction()
170
201
  }}
171
202
  >
@@ -186,7 +217,8 @@ function ToastInline({ toast }: { toast: ToastData }): any {
186
217
  <box
187
218
  flexShrink={0}
188
219
  flexDirection='row'
189
- onMouseDown={() => {
220
+ onMouseDown={(evt) => {
221
+ evt.stopPropagation()
190
222
  toast.secondaryAction?.onAction()
191
223
  }}
192
224
  >
@@ -244,21 +276,22 @@ export function Footer({
244
276
  {children}
245
277
  </box>
246
278
  {showPoweredBy && (
247
- <box flexDirection='row' gap={1} flexShrink={0}>
279
+ <Hoverable
280
+ onMouseDown={() => {
281
+ openInBrowser('https://termcast.app')
282
+ }}
283
+ >
248
284
  <text flexShrink={0} fg={theme.textMuted}>
249
285
  powered by
250
286
  </text>
251
287
  <text
252
288
  flexShrink={0}
253
- onMouseDown={() => {
254
- openInBrowser('https://termcast.app')
255
- }}
256
289
  fg={theme.textMuted}
257
290
  attributes={TextAttributes.BOLD}
258
291
  >
259
292
  termcast.app
260
293
  </text>
261
- </box>
294
+ </Hoverable>
262
295
  )}
263
296
  </>
264
297
  )}
@@ -59,19 +59,10 @@ export const Checkbox = (props: CheckboxProps): any => {
59
59
  defaultValue={props.defaultValue || props.value || false}
60
60
  render={({ field, fieldState, formState }) => {
61
61
  return (
62
- <box ref={elementRef} flexDirection='column'>
62
+ <box ref={elementRef} flexDirection='column' onMouseDown={() => { setFocusedField(props.id, { skipScroll: true }); handleToggle() }}>
63
63
  <WithLeftBorder isFocused={isFocused} paddingBottom={1}>
64
64
  <TitleIndicator isFocused={isFocused} isLoading={focusContext.isLoading}>
65
- <box
66
- onMouseDown={() => {
67
- // Always focus the field when clicked
68
- if (!isFocused) {
69
- setFocusedField(props.id)
70
- }
71
- // Always toggle the value when clicked
72
- handleToggle()
73
- }}
74
- >
65
+ <box>
75
66
  <LoadingText
76
67
  isLoading={isFocused && focusContext.isLoading}
77
68
  color={isFocused ? theme.primary : theme.text}
@@ -83,12 +74,6 @@ export const Checkbox = (props: CheckboxProps): any => {
83
74
  <text
84
75
  fg={isFocused ? theme.accent : theme.text}
85
76
  selectable={false}
86
- onMouseDown={() => {
87
- if (!isFocused) {
88
- setFocusedField(props.id)
89
- }
90
- handleToggle()
91
- }}
92
77
  >
93
78
  {field.value ? '●' : '○'} {props.label}
94
79
  </text>
@@ -55,12 +55,12 @@ const DatePickerComponent = (props: DatePickerProps): any => {
55
55
  defaultValue={props.defaultValue || props.value || null}
56
56
  render={({ field, fieldState, formState }) => {
57
57
  return (
58
- <box ref={elementRef} flexDirection='column'>
58
+ <box ref={elementRef} flexDirection='column' onMouseDown={() => { setFocusedField(props.id, { skipScroll: true }) }}>
59
59
  <WithLeftBorder isFocused={isFocused} paddingBottom={1}>
60
60
  <TitleIndicator isFocused={isFocused} isLoading={focusContext.isLoading}>
61
61
  <box
62
62
  onMouseDown={() => {
63
- setFocusedField(props.id)
63
+ setFocusedField(props.id, { skipScroll: true })
64
64
  }}
65
65
  >
66
66
  <LoadingText
@@ -39,7 +39,7 @@ export const Description = (props: DescriptionProps): any => {
39
39
  flexDirection='column'
40
40
  maxWidth={FORM_MAX_WIDTH}
41
41
  onMouseDown={() => {
42
- focusContext.setFocusedField(id)
42
+ focusContext.setFocusedField(id, { skipScroll: true })
43
43
  }}
44
44
  >
45
45
  <WithLeftBorder isFocused={isFocused} paddingBottom={1}>
@@ -268,6 +268,11 @@ const DropdownContent = ({
268
268
  const item = descendantsContext.committedMap[descendantId]
269
269
  const title = item?.props?.title || value
270
270
 
271
+ // Sync focusedIndex so keyboard nav continues from clicked item
272
+ if (item && item.index !== -1) {
273
+ setFocusedIndex(item.index)
274
+ }
275
+
271
276
  if (props.hasMultipleSelection) {
272
277
  const currentValues = Array.isArray(field.value) ? [...field.value] : []
273
278
  const index = currentValues.indexOf(value)
@@ -350,6 +355,20 @@ const DropdownContent = ({
350
355
  }
351
356
  }
352
357
 
358
+ if (itemCount === 0) {
359
+ if (evt.name === 'down') {
360
+ navigateToNext()
361
+ evt.stopPropagation()
362
+ return
363
+ }
364
+
365
+ if (evt.name === 'up') {
366
+ navigateToPrevious()
367
+ evt.stopPropagation()
368
+ return
369
+ }
370
+ }
371
+
353
372
  // Type-ahead search
354
373
  if (
355
374
  evt.name.length === 1 &&
@@ -428,12 +447,12 @@ const DropdownContent = ({
428
447
  return (
429
448
  <FormDropdownDescendantsProvider value={descendantsContext}>
430
449
  <FormDropdownContext.Provider value={contextValue}>
431
- <box ref={elementRef} flexDirection='column'>
450
+ <box ref={elementRef} flexDirection='column' onMouseDown={() => { setFocusedField(props.id, { skipScroll: true }) }}>
432
451
  <WithLeftBorder isFocused={isFocused} paddingBottom={1}>
433
452
  <TitleIndicator isFocused={isFocused} isLoading={focusContext.isLoading}>
434
453
  <box
435
454
  onMouseDown={() => {
436
- setFocusedField(props.id)
455
+ setFocusedField(props.id, { skipScroll: true })
437
456
  }}
438
457
  >
439
458
  <LoadingText
@@ -448,7 +467,7 @@ const DropdownContent = ({
448
467
  fg={selectedTitles.length > 0 ? theme.text : theme.textMuted}
449
468
  selectable={false}
450
469
  onMouseDown={() => {
451
- setFocusedField(props.id)
470
+ setFocusedField(props.id, { skipScroll: true })
452
471
  }}
453
472
  >
454
473
  {selectedTitles.length > 0
@@ -62,7 +62,7 @@ const FilePickerField = ({
62
62
  formState: any
63
63
  props: FilePickerProps
64
64
  isFocused: boolean
65
- setFocusedField: (id: string) => void
65
+ setFocusedField: (id: string, opts?: { skipScroll?: boolean }) => void
66
66
  isFormLoading: boolean
67
67
  }): any => {
68
68
  const theme = useTheme()
@@ -86,6 +86,7 @@ const FilePickerField = ({
86
86
  }, [])
87
87
 
88
88
  const dialog = useDialog()
89
+ const { navigateToPrevious, navigateToNext } = useFormNavigationHelpers(props.id)
89
90
 
90
91
  // Create store once for sharing state with dialog
91
92
  const [store] = React.useState(() => createFileAutocompleteStore())
@@ -129,6 +130,18 @@ const FilePickerField = ({
129
130
  if (!isFocused || !isInFocus) return
130
131
  if (dialog.stack.length > 0) return // Let autocomplete handle keys
131
132
 
133
+ if (evt.name === 'up') {
134
+ navigateToPrevious()
135
+ evt.stopPropagation()
136
+ return
137
+ }
138
+
139
+ if (evt.name === 'down') {
140
+ navigateToNext()
141
+ evt.stopPropagation()
142
+ return
143
+ }
144
+
132
145
  // Left arrow removes last selected file when input is empty
133
146
  if (evt.name === 'left') {
134
147
  const inputValue = inputRef.current?.plainText || ''
@@ -173,10 +186,10 @@ const FilePickerField = ({
173
186
  <TitleIndicator isFocused={isFocused} isLoading={isFormLoading}>
174
187
  <box
175
188
  onMouseDown={() => {
176
- setFocusedField(props.id)
177
- }}
178
- >
179
- <LoadingText
189
+ setFocusedField(props.id, { skipScroll: true })
190
+ }}
191
+ >
192
+ <LoadingText
180
193
  isLoading={isFocused && isFormLoading}
181
194
  color={isFocused ? theme.primary : theme.text}
182
195
  >
@@ -197,7 +210,7 @@ const FilePickerField = ({
197
210
  placeholder={props.placeholder || 'Enter file path...'}
198
211
  focused={isFocused}
199
212
  showCursor={dialog.stack.length === 0}
200
- onMouseDown={() => setFocusedField(props.id)}
213
+ onMouseDown={() => setFocusedField(props.id, { skipScroll: true })}
201
214
  onContentChange={() => {
202
215
  const value = inputRef.current?.plainText || ''
203
216
  store.setState({ filter: value })
@@ -209,9 +222,19 @@ const FilePickerField = ({
209
222
  {selectedFiles.length > 0 && (
210
223
  <box flexDirection='column' marginTop={1}>
211
224
  <text fg={theme.textMuted}>Selected files:</text>
212
- {selectedFiles.map((file: string, index: number) => (
213
- <text key={index} fg={theme.text}>
214
- {file}
225
+ {selectedFiles.map((file: string, idx: number) => (
226
+ <text
227
+ key={idx}
228
+ fg={theme.text}
229
+ onMouseDown={() => {
230
+ const newFiles = selectedFiles.filter((_: string, i: number) => i !== idx)
231
+ field.onChange(newFiles)
232
+ if (props.onChange) {
233
+ props.onChange(newFiles)
234
+ }
235
+ }}
236
+ >
237
+ • {file} ✕
215
238
  </text>
216
239
  ))}
217
240
  </box>
@@ -252,7 +275,7 @@ export const FilePicker = (props: FilePickerProps): any => {
252
275
  defaultValue={props.defaultValue || props.value || []}
253
276
  render={(renderProps) => {
254
277
  return (
255
- <box ref={elementRef} flexDirection='column'>
278
+ <box ref={elementRef} flexDirection='column' onMouseDown={() => { setFocusedField(props.id, { skipScroll: true }) }}>
256
279
  <FilePickerField
257
280
  {...renderProps}
258
281
  props={props}