termcast 1.3.50 → 1.3.51

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 (164) 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 +1125 -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/components/detail.d.ts.map +1 -1
  12. package/dist/components/detail.js +20 -17
  13. package/dist/components/detail.js.map +1 -1
  14. package/dist/components/dropdown.d.ts.map +1 -1
  15. package/dist/components/dropdown.js +3 -2
  16. package/dist/components/dropdown.js.map +1 -1
  17. package/dist/components/footer.d.ts +6 -0
  18. package/dist/components/footer.d.ts.map +1 -1
  19. package/dist/components/footer.js +15 -6
  20. package/dist/components/footer.js.map +1 -1
  21. package/dist/components/form/checkbox.d.ts.map +1 -1
  22. package/dist/components/form/checkbox.js +1 -13
  23. package/dist/components/form/checkbox.js.map +1 -1
  24. package/dist/components/form/date-picker.js +2 -2
  25. package/dist/components/form/date-picker.js.map +1 -1
  26. package/dist/components/form/description.js +1 -1
  27. package/dist/components/form/description.js.map +1 -1
  28. package/dist/components/form/dropdown.d.ts.map +1 -1
  29. package/dist/components/form/dropdown.js +19 -3
  30. package/dist/components/form/dropdown.js.map +1 -1
  31. package/dist/components/form/file-picker.d.ts.map +1 -1
  32. package/dist/components/form/file-picker.js +22 -4
  33. package/dist/components/form/file-picker.js.map +1 -1
  34. package/dist/components/form/index.d.ts +3 -1
  35. package/dist/components/form/index.d.ts.map +1 -1
  36. package/dist/components/form/index.js +6 -4
  37. package/dist/components/form/index.js.map +1 -1
  38. package/dist/components/form/password-field.js +3 -3
  39. package/dist/components/form/password-field.js.map +1 -1
  40. package/dist/components/form/text-area.d.ts.map +1 -1
  41. package/dist/components/form/text-area.js +29 -6
  42. package/dist/components/form/text-area.js.map +1 -1
  43. package/dist/components/form/text-field.js +3 -3
  44. package/dist/components/form/text-field.js.map +1 -1
  45. package/dist/components/heatmap.d.ts +80 -0
  46. package/dist/components/heatmap.d.ts.map +1 -0
  47. package/dist/components/heatmap.js +405 -0
  48. package/dist/components/heatmap.js.map +1 -0
  49. package/dist/components/list.d.ts +2 -0
  50. package/dist/components/list.d.ts.map +1 -1
  51. package/dist/components/list.js +80 -52
  52. package/dist/components/list.js.map +1 -1
  53. package/dist/components/markdown.d.ts +7 -0
  54. package/dist/components/markdown.d.ts.map +1 -0
  55. package/dist/components/markdown.js +19 -0
  56. package/dist/components/markdown.js.map +1 -0
  57. package/dist/components/metadata.d.ts.map +1 -1
  58. package/dist/components/metadata.js +4 -1
  59. package/dist/components/metadata.js.map +1 -1
  60. package/dist/components/progress-bar.d.ts +37 -0
  61. package/dist/components/progress-bar.d.ts.map +1 -0
  62. package/dist/components/progress-bar.js +34 -0
  63. package/dist/components/progress-bar.js.map +1 -0
  64. package/dist/components/table.d.ts +3 -2
  65. package/dist/components/table.d.ts.map +1 -1
  66. package/dist/components/table.js +78 -63
  67. package/dist/components/table.js.map +1 -1
  68. package/dist/diagram-parser.d.ts +17 -3
  69. package/dist/diagram-parser.d.ts.map +1 -1
  70. package/dist/diagram-parser.js +17 -3
  71. package/dist/diagram-parser.js.map +1 -1
  72. package/dist/examples/list-slot.d.ts +2 -0
  73. package/dist/examples/list-slot.d.ts.map +1 -0
  74. package/dist/examples/list-slot.js +14 -0
  75. package/dist/examples/list-slot.js.map +1 -0
  76. package/dist/examples/list-with-dropdown.js +2 -4
  77. package/dist/examples/list-with-dropdown.js.map +1 -1
  78. package/dist/examples/simple-heatmap.d.ts +2 -0
  79. package/dist/examples/simple-heatmap.d.ts.map +1 -0
  80. package/dist/examples/simple-heatmap.js +37 -0
  81. package/dist/examples/simple-heatmap.js.map +1 -0
  82. package/dist/examples/simple-progress-bar.d.ts +2 -0
  83. package/dist/examples/simple-progress-bar.d.ts.map +1 -0
  84. package/dist/examples/simple-progress-bar.js +36 -0
  85. package/dist/examples/simple-progress-bar.js.map +1 -0
  86. package/dist/index.d.ts +6 -0
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +6 -0
  89. package/dist/index.js.map +1 -1
  90. package/dist/internal/date-picker-widget.d.ts.map +1 -1
  91. package/dist/internal/date-picker-widget.js +5 -4
  92. package/dist/internal/date-picker-widget.js.map +1 -1
  93. package/dist/internal/navigation.d.ts.map +1 -1
  94. package/dist/internal/navigation.js +7 -2
  95. package/dist/internal/navigation.js.map +1 -1
  96. package/dist/internal/providers.d.ts.map +1 -1
  97. package/dist/internal/providers.js +42 -4
  98. package/dist/internal/providers.js.map +1 -1
  99. package/dist/logger.js +6 -1
  100. package/dist/logger.js.map +1 -1
  101. package/dist/state.d.ts +2 -0
  102. package/dist/state.d.ts.map +1 -1
  103. package/dist/state.js +31 -2
  104. package/dist/state.js.map +1 -1
  105. package/dist/theme.d.ts +1 -0
  106. package/dist/theme.d.ts.map +1 -1
  107. package/dist/theme.js +23 -1
  108. package/dist/theme.js.map +1 -1
  109. package/dist/utils.d.ts.map +1 -1
  110. package/dist/utils.js +6 -1
  111. package/dist/utils.js.map +1 -1
  112. package/package.json +3 -3
  113. package/src/apis/environment.tsx +6 -0
  114. package/src/app.tsx +1487 -0
  115. package/src/assets/default-app-icon.png +0 -0
  116. package/src/cli.tsx +105 -0
  117. package/src/components/detail.tsx +32 -22
  118. package/src/components/dropdown.tsx +3 -2
  119. package/src/components/footer.tsx +37 -7
  120. package/src/components/form/checkbox.tsx +2 -17
  121. package/src/components/form/date-picker.tsx +2 -2
  122. package/src/components/form/description.tsx +1 -1
  123. package/src/components/form/dropdown.tsx +22 -3
  124. package/src/components/form/file-picker.tsx +33 -10
  125. package/src/components/form/index.tsx +10 -6
  126. package/src/components/form/password-field.tsx +3 -3
  127. package/src/components/form/text-area.tsx +31 -6
  128. package/src/components/form/text-field.tsx +3 -3
  129. package/src/components/heatmap.tsx +584 -0
  130. package/src/components/list.tsx +135 -72
  131. package/src/components/markdown.tsx +30 -0
  132. package/src/components/metadata.tsx +9 -2
  133. package/src/components/progress-bar.tsx +112 -0
  134. package/src/components/table.tsx +88 -71
  135. package/src/diagram-parser.tsx +17 -3
  136. package/src/examples/bar-graph-weekly.vitest.tsx +4 -4
  137. package/src/examples/detail-metadata-showcase.vitest.tsx +12 -12
  138. package/src/examples/form-basic.vitest.tsx +117 -16
  139. package/src/examples/graph-bar-chart.vitest.tsx +2 -2
  140. package/src/examples/graph-row.vitest.tsx +10 -10
  141. package/src/examples/internal/descendants-rerender.vitest.tsx +94 -46
  142. package/src/examples/internal/simple-scrollbox.vitest.tsx +38 -14
  143. package/src/examples/list-dropdown-default.vitest.tsx +78 -58
  144. package/src/examples/list-slot.tsx +38 -0
  145. package/src/examples/list-with-detail.vitest.tsx +8 -8
  146. package/src/examples/list-with-dropdown.tsx +2 -2
  147. package/src/examples/list-with-dropdown.vitest.tsx +16 -16
  148. package/src/examples/list-with-sections.vitest.tsx +45 -32
  149. package/src/examples/simple-detail-table.vitest.tsx +2 -2
  150. package/src/examples/simple-file-picker.vitest.tsx +1 -1
  151. package/src/examples/simple-grid.vitest.tsx +27 -53
  152. package/src/examples/simple-heatmap.tsx +63 -0
  153. package/src/examples/simple-heatmap.vitest.tsx +88 -0
  154. package/src/examples/simple-progress-bar.tsx +82 -0
  155. package/src/examples/simple-progress-bar.vitest.tsx +72 -0
  156. package/src/examples/table-edge-cases.vitest.tsx +1 -1
  157. package/src/index.tsx +19 -0
  158. package/src/internal/date-picker-widget.tsx +23 -12
  159. package/src/internal/navigation.tsx +7 -2
  160. package/src/internal/providers.tsx +48 -3
  161. package/src/logger.tsx +6 -1
  162. package/src/state.tsx +38 -2
  163. package/src/theme.tsx +26 -2
  164. 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>',
@@ -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
@@ -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
@@ -165,7 +192,8 @@ function ToastInline({ toast }: { toast: ToastData }): any {
165
192
  <box
166
193
  flexShrink={0}
167
194
  flexDirection='row'
168
- onMouseDown={() => {
195
+ onMouseDown={(evt) => {
196
+ evt.stopPropagation()
169
197
  toast.primaryAction?.onAction()
170
198
  }}
171
199
  >
@@ -186,7 +214,8 @@ function ToastInline({ toast }: { toast: ToastData }): any {
186
214
  <box
187
215
  flexShrink={0}
188
216
  flexDirection='row'
189
- onMouseDown={() => {
217
+ onMouseDown={(evt) => {
218
+ evt.stopPropagation()
190
219
  toast.secondaryAction?.onAction()
191
220
  }}
192
221
  >
@@ -244,21 +273,22 @@ export function Footer({
244
273
  {children}
245
274
  </box>
246
275
  {showPoweredBy && (
247
- <box flexDirection='row' gap={1} flexShrink={0}>
276
+ <Hoverable
277
+ onMouseDown={() => {
278
+ openInBrowser('https://termcast.app')
279
+ }}
280
+ >
248
281
  <text flexShrink={0} fg={theme.textMuted}>
249
282
  powered by
250
283
  </text>
251
284
  <text
252
285
  flexShrink={0}
253
- onMouseDown={() => {
254
- openInBrowser('https://termcast.app')
255
- }}
256
286
  fg={theme.textMuted}
257
287
  attributes={TextAttributes.BOLD}
258
288
  >
259
289
  termcast.app
260
290
  </text>
261
- </box>
291
+ </Hoverable>
262
292
  )}
263
293
  </>
264
294
  )}
@@ -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}
@@ -15,7 +15,7 @@ import { useDialog } from 'termcast/src/internal/dialog'
15
15
  import { Offscreen } from 'termcast/src/internal/offscreen'
16
16
  import { useTheme } from 'termcast/src/theme'
17
17
  import { useStore } from 'termcast/src/state'
18
- import { Footer } from 'termcast/src/components/footer'
18
+ import { Footer, Hoverable } from 'termcast/src/components/footer'
19
19
  import {
20
20
  TextAttributes,
21
21
  ScrollBoxRenderable,
@@ -77,7 +77,7 @@ export const useFormScrollContext = () => {
77
77
  // Context for managing focused field and loading state
78
78
  interface FocusContextValue {
79
79
  focusedField: string | null
80
- setFocusedField: (id: string | null) => void
80
+ setFocusedField: (id: string | null, opts?: { skipScroll?: boolean }) => void
81
81
  isLoading: boolean
82
82
  }
83
83
 
@@ -121,12 +121,16 @@ function FormFooter(): any {
121
121
  </text>
122
122
  <text flexShrink={0} fg={theme.textMuted}>navigate</text>
123
123
  </box>
124
- <box style={{ flexDirection: 'row', gap: 1 }}>
124
+ <Hoverable
125
+ onMouseDown={() => {
126
+ useStore.setState({ showActionsDialog: true })
127
+ }}
128
+ >
125
129
  <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
126
130
  ^k
127
131
  </text>
128
132
  <text flexShrink={0} fg={theme.textMuted}>actions</text>
129
- </box>
133
+ </Hoverable>
130
134
  </box>
131
135
  )
132
136
 
@@ -221,11 +225,11 @@ export const Form: FormType = ((props) => {
221
225
  scrollBox.scrollTo(Math.max(0, targetScrollTop))
222
226
  }
223
227
 
224
- const setFocusedField = (id: string | null) => {
228
+ const setFocusedField = (id: string | null, opts?: { skipScroll?: boolean }) => {
225
229
  flushSync(() => {
226
230
  setFocusedFieldRaw(id)
227
231
  })
228
- if (id) {
232
+ if (id && !opts?.skipScroll) {
229
233
  scrollToField(id)
230
234
  }
231
235
  }
@@ -40,12 +40,12 @@ export const PasswordField = (props: PasswordFieldProps): any => {
40
40
  const displayValue = '*'.repeat(displayLength)
41
41
 
42
42
  return (
43
- <box ref={elementRef} flexDirection="column">
43
+ <box ref={elementRef} flexDirection="column" onMouseDown={() => { setFocusedField(props.id, { skipScroll: true }) }}>
44
44
  <WithLeftBorder isFocused={isFocused} paddingBottom={1}>
45
45
  <TitleIndicator isFocused={isFocused} isLoading={focusContext.isLoading}>
46
46
  <box
47
47
  onMouseDown={() => {
48
- setFocusedField(props.id)
48
+ setFocusedField(props.id, { skipScroll: true })
49
49
  }}
50
50
  >
51
51
  <LoadingText
@@ -83,7 +83,7 @@ export const PasswordField = (props: PasswordFieldProps): any => {
83
83
  placeholder={props.placeholder}
84
84
  focused={isFocused}
85
85
  onMouseDown={() => {
86
- setFocusedField(props.id)
86
+ setFocusedField(props.id, { skipScroll: true })
87
87
  }}
88
88
  />
89
89
  {(fieldState.error || props.error || props.info) && <box height={1} />}