termcast 1.3.53 → 1.4.0

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 (196) hide show
  1. package/dist/action-utils.d.ts.map +1 -1
  2. package/dist/action-utils.js +17 -132
  3. package/dist/action-utils.js.map +1 -1
  4. package/dist/apis/cache.d.ts +8 -30
  5. package/dist/apis/cache.d.ts.map +1 -1
  6. package/dist/apis/cache.js +9 -271
  7. package/dist/apis/cache.js.map +1 -1
  8. package/dist/apis/clipboard.d.ts +4 -2
  9. package/dist/apis/clipboard.d.ts.map +1 -1
  10. package/dist/apis/clipboard.js +18 -31
  11. package/dist/apis/clipboard.js.map +1 -1
  12. package/dist/apis/environment.d.ts.map +1 -1
  13. package/dist/apis/environment.js +14 -49
  14. package/dist/apis/environment.js.map +1 -1
  15. package/dist/apis/localstorage.d.ts +7 -12
  16. package/dist/apis/localstorage.d.ts.map +1 -1
  17. package/dist/apis/localstorage.js +7 -184
  18. package/dist/apis/localstorage.js.map +1 -1
  19. package/dist/app.d.ts.map +1 -1
  20. package/dist/app.js +46 -20
  21. package/dist/app.js.map +1 -1
  22. package/dist/cli.js +7 -6
  23. package/dist/cli.js.map +1 -1
  24. package/dist/components/actions.d.ts.map +1 -1
  25. package/dist/components/actions.js +13 -2
  26. package/dist/components/actions.js.map +1 -1
  27. package/dist/components/candle-chart.d.ts +110 -0
  28. package/dist/components/candle-chart.d.ts.map +1 -0
  29. package/dist/components/candle-chart.js +295 -0
  30. package/dist/components/candle-chart.js.map +1 -0
  31. package/dist/components/extension-preferences.d.ts.map +1 -1
  32. package/dist/components/extension-preferences.js +7 -8
  33. package/dist/components/extension-preferences.js.map +1 -1
  34. package/dist/components/form/file-autocomplete.js +2 -2
  35. package/dist/components/form/file-autocomplete.js.map +1 -1
  36. package/dist/components/list.d.ts.map +1 -1
  37. package/dist/components/list.js +242 -14
  38. package/dist/components/list.js.map +1 -1
  39. package/dist/components/table.d.ts +2 -0
  40. package/dist/components/table.d.ts.map +1 -1
  41. package/dist/components/table.js +41 -4
  42. package/dist/components/table.js.map +1 -1
  43. package/dist/e2e-node.d.ts.map +1 -1
  44. package/dist/e2e-node.js +5 -4
  45. package/dist/e2e-node.js.map +1 -1
  46. package/dist/examples/simple-candle-chart-data.d.ts +9064 -0
  47. package/dist/examples/simple-candle-chart-data.d.ts.map +1 -0
  48. package/dist/examples/simple-candle-chart-data.js +12683 -0
  49. package/dist/examples/simple-candle-chart-data.js.map +1 -0
  50. package/dist/examples/simple-candle-chart.d.ts +2 -0
  51. package/dist/examples/simple-candle-chart.d.ts.map +1 -0
  52. package/dist/examples/simple-candle-chart.js +125 -0
  53. package/dist/examples/simple-candle-chart.js.map +1 -0
  54. package/dist/extensions/dev.d.ts.map +1 -1
  55. package/dist/extensions/dev.js +5 -2
  56. package/dist/extensions/dev.js.map +1 -1
  57. package/dist/globals.d.ts.map +1 -1
  58. package/dist/globals.js +2 -1
  59. package/dist/globals.js.map +1 -1
  60. package/dist/index.d.ts +2 -0
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +2 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/internal/error-handler.d.ts.map +1 -1
  65. package/dist/internal/error-handler.js +21 -19
  66. package/dist/internal/error-handler.js.map +1 -1
  67. package/dist/internal/providers.d.ts.map +1 -1
  68. package/dist/internal/providers.js +41 -1
  69. package/dist/internal/providers.js.map +1 -1
  70. package/dist/logger.d.ts.map +1 -1
  71. package/dist/logger.js +31 -29
  72. package/dist/logger.js.map +1 -1
  73. package/dist/platform/browser/cache.d.ts +41 -0
  74. package/dist/platform/browser/cache.d.ts.map +1 -0
  75. package/dist/platform/browser/cache.js +262 -0
  76. package/dist/platform/browser/cache.js.map +1 -0
  77. package/dist/platform/browser/localstorage.d.ts +20 -0
  78. package/dist/platform/browser/localstorage.d.ts.map +1 -0
  79. package/dist/platform/browser/localstorage.js +102 -0
  80. package/dist/platform/browser/localstorage.js.map +1 -0
  81. package/dist/platform/browser/runtime.d.ts +51 -0
  82. package/dist/platform/browser/runtime.d.ts.map +1 -0
  83. package/dist/platform/browser/runtime.js +164 -0
  84. package/dist/platform/browser/runtime.js.map +1 -0
  85. package/dist/platform/bun/sqlite.d.ts +17 -0
  86. package/dist/platform/bun/sqlite.d.ts.map +1 -0
  87. package/dist/platform/bun/sqlite.js +6 -0
  88. package/dist/platform/bun/sqlite.js.map +1 -0
  89. package/dist/platform/node/cache.d.ts +35 -0
  90. package/dist/platform/node/cache.d.ts.map +1 -0
  91. package/dist/platform/node/cache.js +269 -0
  92. package/dist/platform/node/cache.js.map +1 -0
  93. package/dist/platform/node/localstorage.d.ts +17 -0
  94. package/dist/platform/node/localstorage.d.ts.map +1 -0
  95. package/dist/platform/node/localstorage.js +186 -0
  96. package/dist/platform/node/localstorage.js.map +1 -0
  97. package/dist/platform/node/runtime.d.ts +52 -0
  98. package/dist/platform/node/runtime.d.ts.map +1 -0
  99. package/dist/platform/node/runtime.js +230 -0
  100. package/dist/platform/node/runtime.js.map +1 -0
  101. package/dist/platform/node/sqlite.d.ts +27 -0
  102. package/dist/platform/node/sqlite.d.ts.map +1 -0
  103. package/dist/platform/node/sqlite.js +21 -0
  104. package/dist/platform/node/sqlite.js.map +1 -0
  105. package/dist/state.d.ts +5 -0
  106. package/dist/state.d.ts.map +1 -1
  107. package/dist/state.js +6 -28
  108. package/dist/state.js.map +1 -1
  109. package/dist/utils/file-system.d.ts.map +1 -1
  110. package/dist/utils/file-system.js +17 -22
  111. package/dist/utils/file-system.js.map +1 -1
  112. package/dist/utils.d.ts +1 -1
  113. package/dist/utils.d.ts.map +1 -1
  114. package/dist/utils.js +42 -47
  115. package/dist/utils.js.map +1 -1
  116. package/dist/vim-mode.d.ts +40 -0
  117. package/dist/vim-mode.d.ts.map +1 -0
  118. package/dist/vim-mode.js +135 -0
  119. package/dist/vim-mode.js.map +1 -0
  120. package/fonts/Inconsolata.otf +0 -0
  121. package/fonts/SIL Open Font License.txt +41 -0
  122. package/package.json +60 -8
  123. package/src/action-utils.tsx +27 -124
  124. package/src/apis/cache.test.ts +1 -1
  125. package/src/apis/cache.tsx +9 -373
  126. package/src/apis/clipboard.tsx +29 -38
  127. package/src/apis/environment.tsx +25 -52
  128. package/src/apis/localstorage.tsx +8 -214
  129. package/src/app.tsx +51 -20
  130. package/src/cli.tsx +14 -15
  131. package/src/compile.vitest.tsx +2 -2
  132. package/src/components/actions.tsx +19 -1
  133. package/src/components/candle-chart.tsx +410 -0
  134. package/src/components/extension-preferences.tsx +7 -8
  135. package/src/components/form/file-autocomplete.tsx +2 -2
  136. package/src/components/list.tsx +279 -14
  137. package/src/components/table.tsx +46 -4
  138. package/src/e2e-node.tsx +7 -7
  139. package/src/examples/action-shortcut.vitest.tsx +2 -2
  140. package/src/examples/actions-context.vitest.tsx +1 -1
  141. package/src/examples/bar-graph-weekly.vitest.tsx +10 -36
  142. package/src/examples/detail-metadata-showcase.vitest.tsx +36 -36
  143. package/src/examples/form-basic.vitest.tsx +21 -17
  144. package/src/examples/github.vitest.tsx +4 -4
  145. package/src/examples/graph-bar-chart.vitest.tsx +13 -11
  146. package/src/examples/graph-polymarket.vitest.tsx +2 -2
  147. package/src/examples/graph-row.vitest.tsx +66 -66
  148. package/src/examples/graph-styles.vitest.tsx +12 -12
  149. package/src/examples/internal/simple-scrollbox.vitest.tsx +14 -48
  150. package/src/examples/list-detail-metadata.vitest.tsx +5 -5
  151. package/src/examples/list-fetch-data.vitest.tsx +3 -3
  152. package/src/examples/list-item-accessories.vitest.tsx +2 -2
  153. package/src/examples/list-loading-empty-view.vitest.tsx +1 -1
  154. package/src/examples/list-no-actions.vitest.tsx +2 -2
  155. package/src/examples/list-scrollbox.vitest.tsx +5 -5
  156. package/src/examples/list-spacing-mode.vitest.tsx +3 -3
  157. package/src/examples/list-with-detail.vitest.tsx +68 -68
  158. package/src/examples/list-with-dropdown.vitest.tsx +5 -5
  159. package/src/examples/list-with-sections.vitest.tsx +27 -27
  160. package/src/examples/simple-candle-chart-data.ts +12683 -0
  161. package/src/examples/simple-candle-chart.tsx +363 -0
  162. package/src/examples/simple-candle-chart.vitest.tsx +269 -0
  163. package/src/examples/simple-detail-markdown.vitest.tsx +8 -8
  164. package/src/examples/simple-detail-table.vitest.tsx +10 -10
  165. package/src/examples/simple-graph.vitest.tsx +3 -3
  166. package/src/examples/simple-grid.vitest.tsx +14 -14
  167. package/src/examples/simple-heatmap.vitest.tsx +1 -1
  168. package/src/examples/simple-navigation.vitest.tsx +17 -17
  169. package/src/examples/simple-progress-bar.vitest.tsx +1 -1
  170. package/src/examples/simple-table-wrap.vitest.tsx +19 -19
  171. package/src/examples/store.vitest.tsx +1 -1
  172. package/src/examples/swift-extension.vitest.tsx +2 -2
  173. package/src/examples/table-edge-cases.vitest.tsx +18 -18
  174. package/src/examples/table-flex-grow.vitest.tsx +8 -8
  175. package/src/examples/toast-action.vitest.tsx +2 -2
  176. package/src/extensions/dev.tsx +5 -2
  177. package/src/extensions/dev.vitest.tsx +3 -3
  178. package/src/globals.ts +2 -1
  179. package/src/index.tsx +7 -0
  180. package/src/internal/error-handler.tsx +19 -21
  181. package/src/internal/providers.tsx +39 -0
  182. package/src/logger.tsx +38 -41
  183. package/src/platform/browser/cache.ts +327 -0
  184. package/src/platform/browser/localstorage.ts +119 -0
  185. package/src/platform/browser/runtime.ts +209 -0
  186. package/src/platform/bun/sqlite.ts +19 -0
  187. package/src/platform/node/cache.ts +372 -0
  188. package/src/platform/node/localstorage.ts +214 -0
  189. package/src/platform/node/runtime.ts +264 -0
  190. package/src/platform/node/sqlite.ts +43 -0
  191. package/src/state.tsx +17 -28
  192. package/src/utils/file-system.ts +17 -22
  193. package/src/utils.test.tsx +1 -1
  194. package/src/utils.tsx +56 -47
  195. package/src/vim-mode.tsx +153 -0
  196. package/src/apis/sqlite.ts +0 -14
@@ -39,6 +39,8 @@ import { ActionPanel, matchesShortcut } from 'termcast/src/components/actions'
39
39
  import { getInteractiveHoverBackground, useTheme } from 'termcast/src/theme'
40
40
  import { Markdown } from 'termcast/src/components/markdown'
41
41
  import { CommonProps } from 'termcast/src/utils'
42
+ import { getMatchingCommands, executeVimCommand } from 'termcast/src/vim-mode'
43
+ import { ThemePicker } from 'termcast/src/components/theme-picker'
42
44
 
43
45
  export { Color }
44
46
 
@@ -84,6 +86,9 @@ function ListFooter(): any {
84
86
  const dropdownFooterLabel = useStore((s) => s.dropdownFooterLabel)
85
87
  const dropdownTooltip = useStore((s) => s.dropdownTooltip)
86
88
  const hasToast = useStore((s) => s.toast !== null)
89
+ const inputMode = useStore((s) => s.inputMode)
90
+ const vimInputSubMode = useStore((s) => s.vimInputSubMode)
91
+ const vimCommandText = useStore((s) => s.vimCommandText)
87
92
  const listContext = useContext(ListContext)
88
93
  const isShowingDetail = listContext?.isShowingDetail ?? false
89
94
  const hasDropdown = listContext?.hasDropdown ?? false
@@ -94,6 +99,23 @@ function ListFooter(): any {
94
99
  }
95
100
  }
96
101
 
102
+ // When in command mode, show the command input in the footer
103
+ if (vimInputSubMode === 'command' && !hasToast) {
104
+ const matchingCommands = getMatchingCommands(vimCommandText)
105
+ return (
106
+ <Footer hidePoweredBy={isShowingDetail}>
107
+ <box style={{ flexDirection: 'row', gap: 2, flexShrink: 0, flexGrow: 1 }}>
108
+ <text flexShrink={0} fg={theme.text}>:{vimCommandText}</text>
109
+ <text flexShrink={0} fg={theme.textMuted}>
110
+ {matchingCommands.map((cmd) => cmd.name).join(' · ')}
111
+ </text>
112
+ </box>
113
+ </Footer>
114
+ )
115
+ }
116
+
117
+ const isVim = inputMode === 'vim'
118
+
97
119
  const content = hasToast ? null : (
98
120
  <box style={{ flexDirection: 'row', gap: 3, flexShrink: 0 }}>
99
121
  {firstActionTitle && (
@@ -110,10 +132,18 @@ function ListFooter(): any {
110
132
  )}
111
133
  <box style={{ flexDirection: 'row', gap: 1, flexShrink: 0 }}>
112
134
  <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
113
- ↑↓
135
+ {isVim ? 'j/k' : '↑↓'}
114
136
  </text>
115
137
  <text flexShrink={0} fg={theme.textMuted}>navigate</text>
116
138
  </box>
139
+ {isVim && (
140
+ <box style={{ flexDirection: 'row', gap: 1, flexShrink: 0 }}>
141
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
142
+ /
143
+ </text>
144
+ <text flexShrink={0} fg={theme.textMuted}>search</text>
145
+ </box>
146
+ )}
117
147
  <Hoverable
118
148
  onMouseDown={() => {
119
149
  useStore.setState({ showActionsDialog: true })
@@ -134,6 +164,15 @@ function ListFooter(): any {
134
164
  </text>
135
165
  </Hoverable>
136
166
  )}
167
+ {!isVim && (
168
+ <Hoverable
169
+ onMouseDown={() => {
170
+ executeVimCommand('vim')
171
+ }}
172
+ >
173
+ <text flexShrink={0} fg={theme.textMuted}>:vim</text>
174
+ </Hoverable>
175
+ )}
137
176
  </box>
138
177
  )
139
178
 
@@ -1367,18 +1406,237 @@ export const List: ListType = (props) => {
1367
1406
 
1368
1407
  const inFocus = useIsInFocus()
1369
1408
  const dialog = useDialog()
1409
+ const inputMode = useStore((s) => s.inputMode)
1410
+ const vimInputSubMode = useStore((s) => s.vimInputSubMode)
1411
+
1412
+ // Textarea is focused when:
1413
+ // - raycast mode default (always focused for type-to-search)
1414
+ // - vim mode search (user pressed /)
1415
+ // - NOT in command mode (command input lives in footer, not textarea)
1416
+ const searchBarFocused = vimInputSubMode !== 'command' && (inputMode === 'raycast' || vimInputSubMode === 'search')
1417
+
1418
+ // Timestamp-based gg sequence detection in vim mode.
1419
+ // If two 'g' presses happen within 500ms, jump to first item.
1420
+ // Using timestamps instead of setTimeout avoids cleanup on unmount.
1421
+ const lastGPressedAtRef = useRef(0)
1422
+
1423
+ // Helper: jump to first visible item
1424
+ const moveToFirst = () => {
1425
+ const items = Object.values(descendantsContext.map.current)
1426
+ .filter((item) => item.index !== -1 && item.props?.visible !== false)
1427
+ .sort((a, b) => a.index - b.index)
1428
+ if (items[0]) {
1429
+ flushSync(() => {
1430
+ setSelectedIndex(items[0].index)
1431
+ })
1432
+ persistSelectedIndexInCurrentNavigationItem(items[0].index)
1433
+ scrollToItemIfNeeded({ item: items[0], direction: -1 })
1434
+ }
1435
+ }
1436
+
1437
+ // Helper: jump to last visible item
1438
+ const moveToLast = () => {
1439
+ const items = Object.values(descendantsContext.map.current)
1440
+ .filter((item) => item.index !== -1 && item.props?.visible !== false)
1441
+ .sort((a, b) => a.index - b.index)
1442
+ const lastItem = items[items.length - 1]
1443
+ if (lastItem) {
1444
+ flushSync(() => {
1445
+ setSelectedIndex(lastItem.index)
1446
+ })
1447
+ persistSelectedIndexInCurrentNavigationItem(lastItem.index)
1448
+ scrollToItemIfNeeded({ item: lastItem, direction: 1 })
1449
+ }
1450
+ }
1451
+
1452
+ // Helper: move by N items (for half-page jumps)
1453
+ const moveByN = (n: number) => {
1454
+ const items = Object.values(descendantsContext.map.current)
1455
+ .filter((item) => item.index !== -1 && item.props?.visible !== false)
1456
+ .sort((a, b) => a.index - b.index)
1457
+ if (items.length === 0) return
1458
+
1459
+ const currentVisibleIndex = items.findIndex((item) => item.index === selectedIndex)
1460
+ if (currentVisibleIndex === -1) return
1461
+
1462
+ const nextVisibleIndex = Math.max(0, Math.min(items.length - 1, currentVisibleIndex + n))
1463
+ const nextItem = items[nextVisibleIndex]
1464
+ if (nextItem) {
1465
+ flushSync(() => {
1466
+ setSelectedIndex(nextItem.index)
1467
+ })
1468
+ persistSelectedIndexInCurrentNavigationItem(nextItem.index)
1469
+ scrollToItemIfNeeded({ item: nextItem, direction: n > 0 ? 1 : -1 })
1470
+ }
1471
+ }
1370
1472
 
1371
1473
  useKeyboard((evt) => {
1372
1474
  if (!inFocus) return
1373
1475
 
1374
- // Handle Ctrl+P for dropdown
1476
+ const { inputMode, vimInputSubMode } = useStore.getState()
1477
+
1478
+ // ── Command mode: trap all keys ──
1479
+ // When ':' command input is active, all keystrokes are handled here.
1480
+ // Enter executes, Esc cancels, backspace deletes, printable chars append.
1481
+ // Auto-exit command mode if a toast appeared (toast needs its own key handlers).
1482
+ if (vimInputSubMode === 'command') {
1483
+ if (useStore.getState().toast) {
1484
+ useStore.setState({ vimInputSubMode: 'default', vimCommandText: '' })
1485
+ return
1486
+ }
1487
+ evt.stopPropagation()
1488
+
1489
+ if (evt.name === 'return') {
1490
+ const text = useStore.getState().vimCommandText
1491
+ const result = executeVimCommand(text)
1492
+ if (result === 'theme') {
1493
+ dialog.push({ element: <ThemePicker /> })
1494
+ } else if (result === 'actions') {
1495
+ useStore.setState({ showActionsDialog: true })
1496
+ } else if (result === 'filter') {
1497
+ if (searchBarAccessory && !isDropdownOpen) {
1498
+ openDropdown()
1499
+ }
1500
+ }
1501
+ useStore.setState({ vimInputSubMode: 'default', vimCommandText: '' })
1502
+ return
1503
+ }
1504
+ if (evt.name === 'escape') {
1505
+ useStore.setState({ vimInputSubMode: 'default', vimCommandText: '' })
1506
+ return
1507
+ }
1508
+ if (evt.name === 'backspace') {
1509
+ const current = useStore.getState().vimCommandText
1510
+ if (current.length === 0) {
1511
+ // Backspace on empty exits command mode
1512
+ useStore.setState({ vimInputSubMode: 'default' })
1513
+ } else {
1514
+ useStore.setState({ vimCommandText: current.slice(0, -1) })
1515
+ }
1516
+ return
1517
+ }
1518
+ if (evt.name === 'tab') {
1519
+ // Tab-complete: fill in the first matching command
1520
+ const current = useStore.getState().vimCommandText
1521
+ const matches = getMatchingCommands(current)
1522
+ if (matches.length > 0 && matches[0]) {
1523
+ useStore.setState({ vimCommandText: matches[0].name })
1524
+ }
1525
+ return
1526
+ }
1527
+ // Append printable characters (single char, no modifiers except shift)
1528
+ if (evt.sequence.length === 1 && !evt.ctrl && !evt.meta) {
1529
+ useStore.setState({ vimCommandText: useStore.getState().vimCommandText + evt.sequence })
1530
+ }
1531
+ return
1532
+ }
1533
+
1534
+ // ── Vim search mode: Enter confirms, Esc clears and exits ──
1535
+ // Ctrl+K and Ctrl+P still work while searching (fall through to shared handlers below).
1536
+ if (inputMode === 'vim' && vimInputSubMode === 'search') {
1537
+ if (evt.name === 'return') {
1538
+ // Confirm search: keep search text, return to normal mode
1539
+ useStore.setState({ vimInputSubMode: 'default' })
1540
+ evt.stopPropagation()
1541
+ return
1542
+ }
1543
+ if (evt.name === 'escape') {
1544
+ // Clear search and return to normal mode
1545
+ useStore.setState({ vimInputSubMode: 'default' })
1546
+ if (inputRef.current) {
1547
+ inputRef.current.clear()
1548
+ }
1549
+ handleSearchChange('')
1550
+ evt.stopPropagation()
1551
+ return
1552
+ }
1553
+ // Let Ctrl+K, Ctrl+P, and registered shortcuts fall through to shared handlers.
1554
+ // All other keys (text input) are handled by the focused textarea.
1555
+ const isSharedShortcut = (evt.ctrl || evt.super || evt.meta)
1556
+ if (!isSharedShortcut) return
1557
+ }
1558
+
1559
+ // ── Shared: Ctrl+P for dropdown ──
1375
1560
  if (evt.ctrl && evt.name === 'p' && searchBarAccessory && !isDropdownOpen) {
1376
1561
  openDropdown()
1377
1562
  return
1378
1563
  }
1379
1564
 
1380
- // Check if key matches any registered action shortcut
1381
- // This enables direct execution of actions via their shortcuts (e.g., ctrl+r for Refresh)
1565
+ // ── Shared: Ctrl+K / Cmd+K for actions dialog ──
1566
+ if (evt.name === 'k' && (evt.ctrl || evt.super)) {
1567
+ useStore.setState({ showActionsDialog: true })
1568
+ return
1569
+ }
1570
+
1571
+ // ── Vim mode keybindings (only in default sub-mode) ──
1572
+ // Checked before registered action shortcuts so extensions can't hijack
1573
+ // core vim motions like j/k with unmodified single-letter shortcuts.
1574
+ if (inputMode === 'vim' && vimInputSubMode === 'default') {
1575
+ // j/k for navigation
1576
+ if (evt.name === 'j' && !evt.ctrl && !evt.meta) {
1577
+ move(1)
1578
+ evt.stopPropagation()
1579
+ return
1580
+ }
1581
+ if (evt.name === 'k' && !evt.ctrl && !evt.meta) {
1582
+ move(-1)
1583
+ evt.stopPropagation()
1584
+ return
1585
+ }
1586
+
1587
+ // G (shift+g) for last item
1588
+ if (evt.name === 'g' && evt.shift) {
1589
+ moveToLast()
1590
+ evt.stopPropagation()
1591
+ return
1592
+ }
1593
+
1594
+ // g then g for first item (gg sequence).
1595
+ // Two 'g' presses within 500ms triggers jump to first item.
1596
+ if (evt.name === 'g' && !evt.shift && !evt.ctrl && !evt.meta) {
1597
+ const now = Date.now()
1598
+ if (now - lastGPressedAtRef.current < 500) {
1599
+ // Second g within window: jump to first
1600
+ lastGPressedAtRef.current = 0
1601
+ moveToFirst()
1602
+ } else {
1603
+ // First g: record timestamp, wait for second
1604
+ lastGPressedAtRef.current = now
1605
+ }
1606
+ evt.stopPropagation()
1607
+ return
1608
+ }
1609
+
1610
+ // Ctrl+d for half-page down, Ctrl+u for half-page up
1611
+ if (evt.ctrl && evt.name === 'd') {
1612
+ const viewportHeight = scrollBoxRef.current?.viewport?.height || 20
1613
+ moveByN(Math.floor(viewportHeight / 2))
1614
+ evt.stopPropagation()
1615
+ return
1616
+ }
1617
+ if (evt.ctrl && evt.name === 'u') {
1618
+ const viewportHeight = scrollBoxRef.current?.viewport?.height || 20
1619
+ moveByN(-Math.floor(viewportHeight / 2))
1620
+ evt.stopPropagation()
1621
+ return
1622
+ }
1623
+
1624
+ // / to enter search mode
1625
+ if (evt.sequence === '/' && !evt.ctrl && !evt.meta) {
1626
+ useStore.setState({ vimInputSubMode: 'search' })
1627
+ evt.stopPropagation()
1628
+ return
1629
+ }
1630
+
1631
+ // : to enter command mode
1632
+ if (evt.sequence === ':' && !evt.ctrl && !evt.meta) {
1633
+ useStore.setState({ vimInputSubMode: 'command', vimCommandText: '' })
1634
+ evt.stopPropagation()
1635
+ return
1636
+ }
1637
+ }
1638
+
1639
+ // ── Shared: registered action shortcuts ──
1382
1640
  const registeredShortcuts = useStore.getState().registeredActionShortcuts
1383
1641
  for (const { shortcut, execute } of registeredShortcuts) {
1384
1642
  if (matchesShortcut(evt, shortcut)) {
@@ -1393,28 +1651,35 @@ export const List: ListType = (props) => {
1393
1651
  .sort((a, b) => a.index - b.index)
1394
1652
  const currentItem = items.find((item) => item.index === selectedIndex)
1395
1653
 
1396
- // Handle Ctrl+K / Cmd+K to show actions dialog via portal
1397
- // Always open — built-in actions (Change Theme, etc.) are always available
1398
- if (evt.name === 'k' && (evt.ctrl || evt.super)) {
1399
- useStore.setState({ showActionsDialog: true })
1400
- return
1401
- }
1402
-
1654
+ // ── Shared: arrow keys ──
1403
1655
  if (evt.name === 'up') move(-1)
1404
1656
  if (evt.name === 'down') move(1)
1405
- // Handle Enter to auto-execute first action via ActionPanel
1657
+
1658
+ // ── Shared: Enter to auto-execute first action ──
1406
1659
  if (evt.name === 'return') {
1407
1660
  if (!currentItem?.props) return
1408
-
1409
1661
  if (currentItem.props.actions) {
1410
1662
  useStore.setState({ shouldAutoExecuteFirstAction: true })
1411
1663
  }
1664
+ return
1412
1665
  }
1413
1666
  })
1414
1667
 
1415
1668
  const handleSearchChange = (newValue: string) => {
1416
1669
  if (!inFocus) return
1417
1670
 
1671
+ // Intercept ':' in raycast mode: when the search bar was empty and user typed ':',
1672
+ // enter command mode instead of searching. Clear the textarea back to empty.
1673
+ // Only triggers from truly empty state (not from editing "foo" to ":").
1674
+ const wasEmpty = searchText.length === 0
1675
+ if (inputMode === 'raycast' && wasEmpty && newValue === ':') {
1676
+ if (inputRef.current) {
1677
+ inputRef.current.clear()
1678
+ }
1679
+ useStore.setState({ vimInputSubMode: 'command', vimCommandText: '' })
1680
+ return
1681
+ }
1682
+
1418
1683
  // Always update internal state immediately so the textarea and filtering
1419
1684
  // stay responsive even when throttle delays the parent callback
1420
1685
  if (controlledSearchText === undefined) {
@@ -1506,7 +1771,7 @@ export const List: ListType = (props) => {
1506
1771
  { name: 'linefeed', action: 'submit' },
1507
1772
  ]}
1508
1773
  placeholder={searchBarPlaceholder}
1509
- focused={inFocus && !isDropdownOpen}
1774
+ focused={inFocus && !isDropdownOpen && searchBarFocused}
1510
1775
  initialValue={searchText}
1511
1776
  onContentChange={() => {
1512
1777
  const value = inputRef.current?.plainText || ''
@@ -159,6 +159,33 @@ export class TableRenderable extends Renderable {
159
159
  return new StyledText(styledChunks)
160
160
  }
161
161
 
162
+ private getCellContentWidth(content: TableCellContent): number {
163
+ return this.toStyledText(content).chunks.reduce((width, chunk) => {
164
+ return width + chunk.text.length
165
+ }, 0)
166
+ }
167
+
168
+ private getColumnWidths({ colCount }: { colCount: number }): number[] {
169
+ const minColumnWidth = 3
170
+ const maxColumnWidth = 32
171
+ const headerWidths = Array.from({ length: colCount }, (_, col) => {
172
+ const headerContent = this._headers[col] ?? ''
173
+ return this.getCellContentWidth(headerContent) + 2
174
+ })
175
+
176
+ const rowWidths = this._rows.reduce((widths, row) => {
177
+ return widths.map((currentWidth, col) => {
178
+ const cellContent = row[col] ?? ''
179
+ const cellWidth = this.getCellContentWidth(cellContent) + 2
180
+ return Math.max(currentWidth, cellWidth)
181
+ })
182
+ }, headerWidths)
183
+
184
+ return rowWidths.map((width) => {
185
+ return Math.min(maxColumnWidth, Math.max(minColumnWidth, width))
186
+ })
187
+ }
188
+
162
189
  private rebuild(): void {
163
190
  // Remove all existing children (copy array since remove mutates it)
164
191
  const children = [...(this as any)._childrenInLayoutOrder] as Renderable[]
@@ -260,6 +287,8 @@ export class TableRenderable extends Renderable {
260
287
  headerFg: StyleDefinition['fg'],
261
288
  stripeBg: StyleDefinition['fg'],
262
289
  ): void {
290
+ const columnWidths = this.getColumnWidths({ colCount })
291
+
263
292
  if (this._headers.length > 0) {
264
293
  const headerRow = new BoxRenderable(this.ctx, {
265
294
  id: `${this.id}-header-row`,
@@ -267,6 +296,7 @@ export class TableRenderable extends Renderable {
267
296
  backgroundColor: headerBg,
268
297
  })
269
298
  for (let col = 0; col < colCount; col++) {
299
+ const columnWidth = columnWidths[col] ?? 1
270
300
  const headerContent = this._headers[col] ?? ''
271
301
  let headerStyledText = this.toStyledText(headerContent)
272
302
  headerStyledText = this.styledHeaderChunks(headerStyledText, headingStyle, headerFg)
@@ -275,8 +305,9 @@ export class TableRenderable extends Renderable {
275
305
  new TextRenderable(this.ctx, {
276
306
  id: `${this.id}-header-${col}`,
277
307
  content: headerStyledText,
278
- flexGrow: 1,
279
- flexBasis: 0,
308
+ flexGrow: columnWidth,
309
+ flexShrink: 1,
310
+ flexBasis: columnWidth,
280
311
  paddingLeft: 1,
281
312
  paddingRight: 1,
282
313
  }),
@@ -294,6 +325,7 @@ export class TableRenderable extends Renderable {
294
325
  })
295
326
 
296
327
  for (let col = 0; col < colCount; col++) {
328
+ const columnWidth = columnWidths[col] ?? 1
297
329
  const cell = this._rows[row]?.[col] ?? ''
298
330
  const cellContent = this.toStyledText(cell)
299
331
 
@@ -301,8 +333,9 @@ export class TableRenderable extends Renderable {
301
333
  new TextRenderable(this.ctx, {
302
334
  id: `${this.id}-row-${row}-col-${col}`,
303
335
  content: cellContent,
304
- flexGrow: 1,
305
- flexBasis: 0,
336
+ flexGrow: columnWidth,
337
+ flexShrink: 1,
338
+ flexBasis: columnWidth,
306
339
  paddingLeft: 1,
307
340
  paddingRight: 1,
308
341
  }),
@@ -384,6 +417,7 @@ export class TableRenderable extends Renderable {
384
417
  const allRows = (this as any)._childrenInLayoutOrder as Renderable[]
385
418
  const colCount = this._headers.length || this._rows[0]?.length || 0
386
419
  const hasHeaders = this._headers.length > 0
420
+ const columnWidths = this.getColumnWidths({ colCount })
387
421
 
388
422
  if (hasHeaders) {
389
423
  const headerRow = allRows[0]
@@ -391,11 +425,15 @@ export class TableRenderable extends Renderable {
391
425
  headerRow.backgroundColor = headerBg ?? 'transparent'
392
426
  const headerCells = (headerRow as any)._childrenInLayoutOrder as Renderable[]
393
427
  for (let col = 0; col < colCount; col++) {
428
+ const columnWidth = columnWidths[col] ?? 1
394
429
  const headerText = headerCells[col]
395
430
  if (headerText instanceof TextRenderable) {
396
431
  const headerContent = this._headers[col] ?? ''
397
432
  let headerStyledText = this.toStyledText(headerContent)
398
433
  headerStyledText = this.styledHeaderChunks(headerStyledText, headingStyle, headerFg)
434
+ headerText.flexGrow = columnWidth
435
+ headerText.flexShrink = 1
436
+ headerText.flexBasis = columnWidth
399
437
  headerText.content = headerStyledText
400
438
  }
401
439
  }
@@ -412,9 +450,13 @@ export class TableRenderable extends Renderable {
412
450
 
413
451
  const rowCells = (rowBox as any)._childrenInLayoutOrder as Renderable[]
414
452
  for (let col = 0; col < colCount; col++) {
453
+ const columnWidth = columnWidths[col] ?? 1
415
454
  const cellText = rowCells[col]
416
455
  if (cellText instanceof TextRenderable) {
417
456
  const cell = this._rows[row]?.[col] ?? ''
457
+ cellText.flexGrow = columnWidth
458
+ cellText.flexShrink = 1
459
+ cellText.flexBasis = columnWidth
418
460
  cellText.content = this.toStyledText(cell)
419
461
  }
420
462
  }
package/src/e2e-node.tsx CHANGED
@@ -1,11 +1,9 @@
1
- // TODO node-pty has bugs, not all text is shown
2
-
3
- import * as pty from 'node-pty'
1
+ import { spawn as zigSpawn } from 'zigpty'
4
2
  import { Terminal } from '@xterm/headless'
5
3
  import { SerializeAddon } from '@xterm/addon-serialize'
6
4
 
7
5
  export class NodeTuiDriver {
8
- private pty?: pty.IPty
6
+ private pty?: ReturnType<typeof zigSpawn>
9
7
  private term: Terminal
10
8
  private serialize: SerializeAddon
11
9
  private cols: number
@@ -51,16 +49,18 @@ export class NodeTuiDriver {
51
49
  TERM: 'xterm-truecolor',
52
50
  COLORTERM: 'truecolor',
53
51
  }
54
- this.pty = pty.spawn(this.cmd, this.args, {
52
+ this.pty = zigSpawn(this.cmd, this.args, {
55
53
  name: 'xterm-truecolor',
56
54
  cols,
57
55
  rows,
58
56
  cwd,
59
- env: envWithTerm as any,
57
+ env: envWithTerm as Record<string, string>,
60
58
  })
61
59
 
62
60
  this.pty.onData((data) => {
63
- this.term.write(data)
61
+ // zigpty onData can return string or Buffer
62
+ const str = typeof data === 'string' ? data : data.toString()
63
+ this.term.write(str)
64
64
  clearTimeout(this.idleTimer)
65
65
  this.idleTimer = setTimeout(() => {
66
66
  const r = this.idleResolvers.splice(0)
@@ -57,7 +57,7 @@ test('ctrl+r shortcut should trigger Refresh action directly', async () => {
57
57
 
58
58
 
59
59
 
60
- ↵ refresh ↑↓ navigate ^k actions
60
+ ↵ refresh ↑↓ navigate ^k actions :vim
61
61
 
62
62
 
63
63
 
@@ -97,11 +97,11 @@ test('action shortcut is displayed in action panel', async () => {
97
97
  │ │
98
98
  │ Settings │
99
99
  │ Change Theme... │
100
+ │ Enable Vim Mode │
100
101
  │ Toggle Console Logs │
101
102
  │ │
102
103
  │ │
103
104
  │ │
104
- │ │
105
105
  │ ↵ select ↑↓ navigate │
106
106
  │ │"
107
107
  `)
@@ -49,12 +49,12 @@ test('actions preserve React context through portal', async () => {
49
49
  │ │
50
50
  │ Settings │
51
51
  │ Change Theme... │
52
+ │ Enable Vim Mode │
52
53
  │ Toggle Console Logs │
53
54
  │ │
54
55
  │ │
55
56
  │ │
56
57
  │ │
57
- │ │
58
58
  │ ↵ select ↑↓ navigate │
59
59
  │ │"
60
60
  `)
@@ -74,44 +74,18 @@ test('many columns (20) clips with overflow hidden', async () => {
74
74
  session.sendKey('down')
75
75
  session.sendKey('down')
76
76
 
77
- const text = await session.text({
77
+ await session.text({
78
78
  waitFor: (t) => t.includes('›Many Columns'),
79
79
  timeout: 10000,
80
80
  })
81
+ await session.waitIdle()
82
+ const text = await session.text()
81
83
 
82
- expect(text).toMatchInlineSnapshot(`
83
- "
84
-
85
-
86
- BarGraph Showcase ────────────────────────────────────────────────────────
87
-
88
- > Search...
89
-
90
- Weekly Traffic 3 channels across 6 d │ ███
91
- Revenue by Region EMEA / APAC / Amer │ ███
92
- Server Load CPU / Memory / IO │ ███ ███ ███ ███ ███
93
- ›Many Columns (20) Overflow test with │ ███ ███ ███ ███ ███ ███ ██
94
- Many Series (8) Legend overflow test │ ███ ███ ███ ███ ███ ███ ███ ███ ██
95
- Long Labels Labels wider than bar co │ ███ ███ ███ ███ ███ ███ ███ ███ ██
96
- Week 1 vs Week 2 Two graphs in a Row │ ███ ███ ███ ███ ███ ███ ███ ███ ██
97
- │ ███ ███ ███ ███ ███ ███ ███ ███ ██
98
- │ ███ ███ ███ ███ ███ ███ ███ ███ ██
99
- │ D1 D2 D3 D4 D5 D6 D7 D8 D9
100
- ↑↓ navigate ^k actions │ ■ A ■ B
101
-
102
-
103
-
104
-
105
-
106
-
107
-
108
-
109
-
110
-
111
-
112
-
113
- "
114
- `)
84
+ // Bar graph rendering has non-deterministic ANSI highlights, so use toContain checks
85
+ // instead of inline snapshot for the bars area
86
+ expect(text).toContain('›Many Columns')
87
+ expect(text).toContain('BarGraph Showcase')
88
+ expect(text).toContain('███')
115
89
 
116
90
  // Some labels visible, overflow clips the rest
117
91
  expect(text).toContain('D1')
@@ -149,7 +123,7 @@ test('many series (8) legend clips on one line', async () => {
149
123
  │ ███ ███ ███ ███ ███ ███
150
124
  │ ███ ███ ███ ███ ███ ███
151
125
  │ Mon Tue Wed Thu Fri Sat
152
- ↑↓ navigate ^k actions │ ■ Series 1 ■ Series 2 ■ Series 3 ■
126
+ ↑↓ navigate ^k actions :vim │ ■ Series 1 ■ Series 2 ■ Series 3 ■
153
127
 
154
128
 
155
129
 
@@ -202,7 +176,7 @@ test('long labels truncated by overflow hidden', async () => {
202
176
  │ ███ ███ ███ ███ ███ ███
203
177
  │ ███ ███ ███ ███ ███ ███
204
178
  │ Mon Tue Wed Thu Fri Sat
205
- ↑↓ navigate ^k actions │ ■ Views ■ Clicks
179
+ ↑↓ navigate ^k actions :vim │ ■ Views ■ Clicks
206
180
 
207
181
 
208
182