termcast 1.3.54 → 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 (169) 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 +16 -15
  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/extension-preferences.d.ts.map +1 -1
  28. package/dist/components/extension-preferences.js +7 -8
  29. package/dist/components/extension-preferences.js.map +1 -1
  30. package/dist/components/form/file-autocomplete.js +2 -2
  31. package/dist/components/form/file-autocomplete.js.map +1 -1
  32. package/dist/components/list.d.ts.map +1 -1
  33. package/dist/components/list.js +242 -14
  34. package/dist/components/list.js.map +1 -1
  35. package/dist/e2e-node.d.ts.map +1 -1
  36. package/dist/e2e-node.js +5 -4
  37. package/dist/e2e-node.js.map +1 -1
  38. package/dist/extensions/dev.d.ts.map +1 -1
  39. package/dist/extensions/dev.js +5 -2
  40. package/dist/extensions/dev.js.map +1 -1
  41. package/dist/globals.d.ts.map +1 -1
  42. package/dist/globals.js +2 -1
  43. package/dist/globals.js.map +1 -1
  44. package/dist/internal/error-handler.d.ts.map +1 -1
  45. package/dist/internal/error-handler.js +21 -19
  46. package/dist/internal/error-handler.js.map +1 -1
  47. package/dist/internal/providers.d.ts.map +1 -1
  48. package/dist/internal/providers.js +41 -1
  49. package/dist/internal/providers.js.map +1 -1
  50. package/dist/logger.d.ts.map +1 -1
  51. package/dist/logger.js +31 -29
  52. package/dist/logger.js.map +1 -1
  53. package/dist/platform/browser/cache.d.ts +41 -0
  54. package/dist/platform/browser/cache.d.ts.map +1 -0
  55. package/dist/platform/browser/cache.js +262 -0
  56. package/dist/platform/browser/cache.js.map +1 -0
  57. package/dist/platform/browser/localstorage.d.ts +20 -0
  58. package/dist/platform/browser/localstorage.d.ts.map +1 -0
  59. package/dist/platform/browser/localstorage.js +102 -0
  60. package/dist/platform/browser/localstorage.js.map +1 -0
  61. package/dist/platform/browser/runtime.d.ts +51 -0
  62. package/dist/platform/browser/runtime.d.ts.map +1 -0
  63. package/dist/platform/browser/runtime.js +164 -0
  64. package/dist/platform/browser/runtime.js.map +1 -0
  65. package/dist/platform/bun/sqlite.d.ts +17 -0
  66. package/dist/platform/bun/sqlite.d.ts.map +1 -0
  67. package/dist/platform/bun/sqlite.js +6 -0
  68. package/dist/platform/bun/sqlite.js.map +1 -0
  69. package/dist/platform/node/cache.d.ts +35 -0
  70. package/dist/platform/node/cache.d.ts.map +1 -0
  71. package/dist/platform/node/cache.js +269 -0
  72. package/dist/platform/node/cache.js.map +1 -0
  73. package/dist/platform/node/localstorage.d.ts +17 -0
  74. package/dist/platform/node/localstorage.d.ts.map +1 -0
  75. package/dist/platform/node/localstorage.js +186 -0
  76. package/dist/platform/node/localstorage.js.map +1 -0
  77. package/dist/platform/node/runtime.d.ts +52 -0
  78. package/dist/platform/node/runtime.d.ts.map +1 -0
  79. package/dist/platform/node/runtime.js +230 -0
  80. package/dist/platform/node/runtime.js.map +1 -0
  81. package/dist/platform/node/sqlite.d.ts +27 -0
  82. package/dist/platform/node/sqlite.d.ts.map +1 -0
  83. package/dist/platform/node/sqlite.js +21 -0
  84. package/dist/platform/node/sqlite.js.map +1 -0
  85. package/dist/state.d.ts +5 -0
  86. package/dist/state.d.ts.map +1 -1
  87. package/dist/state.js +6 -28
  88. package/dist/state.js.map +1 -1
  89. package/dist/utils/file-system.d.ts.map +1 -1
  90. package/dist/utils/file-system.js +17 -22
  91. package/dist/utils/file-system.js.map +1 -1
  92. package/dist/utils.d.ts +1 -1
  93. package/dist/utils.d.ts.map +1 -1
  94. package/dist/utils.js +42 -47
  95. package/dist/utils.js.map +1 -1
  96. package/dist/vim-mode.d.ts +40 -0
  97. package/dist/vim-mode.d.ts.map +1 -0
  98. package/dist/vim-mode.js +135 -0
  99. package/dist/vim-mode.js.map +1 -0
  100. package/fonts/Inconsolata.otf +0 -0
  101. package/fonts/SIL Open Font License.txt +41 -0
  102. package/package.json +60 -8
  103. package/src/action-utils.tsx +27 -124
  104. package/src/apis/cache.test.ts +1 -1
  105. package/src/apis/cache.tsx +9 -373
  106. package/src/apis/clipboard.tsx +29 -38
  107. package/src/apis/environment.tsx +25 -52
  108. package/src/apis/localstorage.tsx +8 -214
  109. package/src/app.tsx +16 -15
  110. package/src/cli.tsx +14 -15
  111. package/src/compile.vitest.tsx +2 -2
  112. package/src/components/actions.tsx +19 -1
  113. package/src/components/extension-preferences.tsx +7 -8
  114. package/src/components/form/file-autocomplete.tsx +2 -2
  115. package/src/components/list.tsx +279 -14
  116. package/src/e2e-node.tsx +7 -7
  117. package/src/examples/action-shortcut.vitest.tsx +2 -2
  118. package/src/examples/actions-context.vitest.tsx +1 -1
  119. package/src/examples/bar-graph-weekly.vitest.tsx +10 -36
  120. package/src/examples/detail-metadata-showcase.vitest.tsx +36 -36
  121. package/src/examples/form-basic.vitest.tsx +21 -17
  122. package/src/examples/github.vitest.tsx +4 -4
  123. package/src/examples/graph-bar-chart.vitest.tsx +13 -11
  124. package/src/examples/graph-polymarket.vitest.tsx +2 -2
  125. package/src/examples/graph-row.vitest.tsx +66 -66
  126. package/src/examples/graph-styles.vitest.tsx +12 -12
  127. package/src/examples/internal/simple-scrollbox.vitest.tsx +14 -48
  128. package/src/examples/list-detail-metadata.vitest.tsx +5 -5
  129. package/src/examples/list-fetch-data.vitest.tsx +3 -3
  130. package/src/examples/list-item-accessories.vitest.tsx +2 -2
  131. package/src/examples/list-loading-empty-view.vitest.tsx +1 -1
  132. package/src/examples/list-no-actions.vitest.tsx +2 -2
  133. package/src/examples/list-scrollbox.vitest.tsx +5 -5
  134. package/src/examples/list-spacing-mode.vitest.tsx +3 -3
  135. package/src/examples/list-with-detail.vitest.tsx +68 -68
  136. package/src/examples/list-with-dropdown.vitest.tsx +5 -5
  137. package/src/examples/list-with-sections.vitest.tsx +27 -27
  138. package/src/examples/simple-candle-chart.vitest.tsx +7 -7
  139. package/src/examples/simple-detail-markdown.vitest.tsx +8 -8
  140. package/src/examples/simple-detail-table.vitest.tsx +8 -8
  141. package/src/examples/simple-graph.vitest.tsx +3 -3
  142. package/src/examples/simple-grid.vitest.tsx +14 -14
  143. package/src/examples/simple-heatmap.vitest.tsx +1 -1
  144. package/src/examples/simple-navigation.vitest.tsx +17 -17
  145. package/src/examples/simple-progress-bar.vitest.tsx +1 -1
  146. package/src/examples/store.vitest.tsx +1 -1
  147. package/src/examples/swift-extension.vitest.tsx +2 -2
  148. package/src/examples/table-edge-cases.vitest.tsx +18 -18
  149. package/src/examples/toast-action.vitest.tsx +2 -2
  150. package/src/extensions/dev.tsx +5 -2
  151. package/src/extensions/dev.vitest.tsx +3 -3
  152. package/src/globals.ts +2 -1
  153. package/src/internal/error-handler.tsx +19 -21
  154. package/src/internal/providers.tsx +39 -0
  155. package/src/logger.tsx +38 -41
  156. package/src/platform/browser/cache.ts +327 -0
  157. package/src/platform/browser/localstorage.ts +119 -0
  158. package/src/platform/browser/runtime.ts +209 -0
  159. package/src/platform/bun/sqlite.ts +19 -0
  160. package/src/platform/node/cache.ts +372 -0
  161. package/src/platform/node/localstorage.ts +214 -0
  162. package/src/platform/node/runtime.ts +264 -0
  163. package/src/platform/node/sqlite.ts +43 -0
  164. package/src/state.tsx +17 -28
  165. package/src/utils/file-system.ts +17 -22
  166. package/src/utils.test.tsx +1 -1
  167. package/src/utils.tsx +56 -47
  168. package/src/vim-mode.tsx +153 -0
  169. 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 || ''
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
 
@@ -48,39 +48,31 @@ test('detail metadata showcase renders markdown and metadata together', async ()
48
48
 
49
49
  Project Update: Q1 2024 Review
50
50
 
51
- This detail view demonstrates markdown content alongside metadata.
52
51
 
52
+ This detail view demonstrates markdown content alongside metadata.
53
53
  ---
54
-
55
54
  Summary
56
55
 
57
- The project has made significant progress this quarter. Key highlights include:
58
56
 
57
+ The project has made significant progress this quarter. Key highlights include:
59
58
  - Completed the new authentication system
60
59
  - Migrated 85% of users to the new platform
61
60
  - Reduced API response time by 40%
62
-
63
-
64
61
  Technical Details
65
62
 
66
- The refactoring effort focused on three main areas:
67
63
 
64
+ The refactoring effort focused on three main areas:
68
65
  1. Database optimization - Indexed frequently queried columns
69
66
  2. Caching layer - Added Redis for session management
70
67
  3. Code cleanup - Removed deprecated endpoints
71
-
72
-
73
68
  Next Steps
74
69
 
75
- We will continue with Phase 2 in the upcoming sprint. The team should prioritize:
76
70
 
71
+ We will continue with Phase 2 in the upcoming sprint. The team should prioritize:
77
72
  - Finishing the remaining user migrations
78
73
  - Implementing the new dashboard
79
74
  - Writing integration tests
80
-
81
-
82
75
  ---
83
-
84
76
  Last updated: January 20, 2024
85
77
 
86
78
  Basic Information
@@ -160,6 +152,14 @@ test('detail metadata showcase renders markdown and metadata together', async ()
160
152
 
161
153
 
162
154
 
155
+
156
+
157
+
158
+
159
+
160
+
161
+
162
+
163
163
 
164
164
  "
165
165
  `)
@@ -219,39 +219,31 @@ test('detail metadata renders long values in column layout', async () => {
219
219
 
220
220
  Project Update: Q1 2024 Review
221
221
 
222
- This detail view demonstrates markdown content alongside metadata.
223
222
 
223
+ This detail view demonstrates markdown content alongside metadata.
224
224
  ---
225
-
226
225
  Summary
227
226
 
228
- The project has made significant progress this quarter. Key highlights include:
229
227
 
228
+ The project has made significant progress this quarter. Key highlights include:
230
229
  - Completed the new authentication system
231
230
  - Migrated 85% of users to the new platform
232
231
  - Reduced API response time by 40%
233
-
234
-
235
232
  Technical Details
236
233
 
237
- The refactoring effort focused on three main areas:
238
234
 
235
+ The refactoring effort focused on three main areas:
239
236
  1. Database optimization - Indexed frequently queried columns
240
237
  2. Caching layer - Added Redis for session management
241
238
  3. Code cleanup - Removed deprecated endpoints
242
-
243
-
244
239
  Next Steps
245
240
 
246
- We will continue with Phase 2 in the upcoming sprint. The team should prioritize:
247
241
 
242
+ We will continue with Phase 2 in the upcoming sprint. The team should prioritize:
248
243
  - Finishing the remaining user migrations
249
244
  - Implementing the new dashboard
250
245
  - Writing integration tests
251
-
252
-
253
246
  ---
254
-
255
247
  Last updated: January 20, 2024
256
248
 
257
249
  Basic Information
@@ -331,6 +323,14 @@ test('detail metadata renders long values in column layout', async () => {
331
323
 
332
324
 
333
325
 
326
+
327
+
328
+
329
+
330
+
331
+
332
+
333
+
334
334
 
335
335
  "
336
336
  `)
@@ -400,39 +400,31 @@ test('detail metadata renders tag lists with multiple items', async () => {
400
400
 
401
401
  Project Update: Q1 2024 Review
402
402
 
403
- This detail view demonstrates markdown content alongside metadata.
404
403
 
404
+ This detail view demonstrates markdown content alongside metadata.
405
405
  ---
406
-
407
406
  Summary
408
407
 
409
- The project has made significant progress this quarter. Key highlights include:
410
408
 
409
+ The project has made significant progress this quarter. Key highlights include:
411
410
  - Completed the new authentication system
412
411
  - Migrated 85% of users to the new platform
413
412
  - Reduced API response time by 40%
414
-
415
-
416
413
  Technical Details
417
414
 
418
- The refactoring effort focused on three main areas:
419
415
 
416
+ The refactoring effort focused on three main areas:
420
417
  1. Database optimization - Indexed frequently queried columns
421
418
  2. Caching layer - Added Redis for session management
422
419
  3. Code cleanup - Removed deprecated endpoints
423
-
424
-
425
420
  Next Steps
426
421
 
427
- We will continue with Phase 2 in the upcoming sprint. The team should prioritize:
428
422
 
423
+ We will continue with Phase 2 in the upcoming sprint. The team should prioritize:
429
424
  - Finishing the remaining user migrations
430
425
  - Implementing the new dashboard
431
426
  - Writing integration tests
432
-
433
-
434
427
  ---
435
-
436
428
  Last updated: January 20, 2024
437
429
 
438
430
  Basic Information
@@ -512,6 +504,14 @@ test('detail metadata renders tag lists with multiple items', async () => {
512
504
 
513
505
 
514
506
 
507
+
508
+
509
+
510
+
511
+
512
+
513
+
514
+
515
515
 
516
516
  "
517
517
  `)