termcast 1.3.50 → 1.3.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/dist/apis/environment.d.ts +1 -0
  2. package/dist/apis/environment.d.ts.map +1 -1
  3. package/dist/apis/environment.js +5 -0
  4. package/dist/apis/environment.js.map +1 -1
  5. package/dist/app.d.ts +33 -0
  6. package/dist/app.d.ts.map +1 -0
  7. package/dist/app.js +1130 -0
  8. package/dist/app.js.map +1 -0
  9. package/dist/cli.js +80 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/compile.d.ts.map +1 -1
  12. package/dist/compile.js +5 -2
  13. package/dist/compile.js.map +1 -1
  14. package/dist/components/actions.d.ts +4 -1
  15. package/dist/components/actions.d.ts.map +1 -1
  16. package/dist/components/actions.js +8 -5
  17. package/dist/components/actions.js.map +1 -1
  18. package/dist/components/detail.d.ts.map +1 -1
  19. package/dist/components/detail.js +21 -18
  20. package/dist/components/detail.js.map +1 -1
  21. package/dist/components/dropdown.d.ts.map +1 -1
  22. package/dist/components/dropdown.js +3 -2
  23. package/dist/components/dropdown.js.map +1 -1
  24. package/dist/components/footer.d.ts +6 -0
  25. package/dist/components/footer.d.ts.map +1 -1
  26. package/dist/components/footer.js +15 -6
  27. package/dist/components/footer.js.map +1 -1
  28. package/dist/components/form/checkbox.d.ts.map +1 -1
  29. package/dist/components/form/checkbox.js +1 -13
  30. package/dist/components/form/checkbox.js.map +1 -1
  31. package/dist/components/form/date-picker.js +2 -2
  32. package/dist/components/form/date-picker.js.map +1 -1
  33. package/dist/components/form/description.js +1 -1
  34. package/dist/components/form/description.js.map +1 -1
  35. package/dist/components/form/dropdown.d.ts.map +1 -1
  36. package/dist/components/form/dropdown.js +19 -3
  37. package/dist/components/form/dropdown.js.map +1 -1
  38. package/dist/components/form/file-picker.d.ts.map +1 -1
  39. package/dist/components/form/file-picker.js +22 -4
  40. package/dist/components/form/file-picker.js.map +1 -1
  41. package/dist/components/form/index.d.ts +3 -1
  42. package/dist/components/form/index.d.ts.map +1 -1
  43. package/dist/components/form/index.js +7 -5
  44. package/dist/components/form/index.js.map +1 -1
  45. package/dist/components/form/password-field.js +3 -3
  46. package/dist/components/form/password-field.js.map +1 -1
  47. package/dist/components/form/text-area.d.ts.map +1 -1
  48. package/dist/components/form/text-area.js +29 -6
  49. package/dist/components/form/text-area.js.map +1 -1
  50. package/dist/components/form/text-field.js +3 -3
  51. package/dist/components/form/text-field.js.map +1 -1
  52. package/dist/components/graph.d.ts.map +1 -1
  53. package/dist/components/graph.js +21 -25
  54. package/dist/components/graph.js.map +1 -1
  55. package/dist/components/heatmap.d.ts +80 -0
  56. package/dist/components/heatmap.d.ts.map +1 -0
  57. package/dist/components/heatmap.js +424 -0
  58. package/dist/components/heatmap.js.map +1 -0
  59. package/dist/components/list.d.ts +2 -0
  60. package/dist/components/list.d.ts.map +1 -1
  61. package/dist/components/list.js +91 -58
  62. package/dist/components/list.js.map +1 -1
  63. package/dist/components/markdown.d.ts +7 -0
  64. package/dist/components/markdown.d.ts.map +1 -0
  65. package/dist/components/markdown.js +19 -0
  66. package/dist/components/markdown.js.map +1 -0
  67. package/dist/components/metadata.d.ts.map +1 -1
  68. package/dist/components/metadata.js +4 -1
  69. package/dist/components/metadata.js.map +1 -1
  70. package/dist/components/progress-bar.d.ts +37 -0
  71. package/dist/components/progress-bar.d.ts.map +1 -0
  72. package/dist/components/progress-bar.js +34 -0
  73. package/dist/components/progress-bar.js.map +1 -0
  74. package/dist/components/table.d.ts +3 -2
  75. package/dist/components/table.d.ts.map +1 -1
  76. package/dist/components/table.js +78 -63
  77. package/dist/components/table.js.map +1 -1
  78. package/dist/diagram-parser.d.ts +17 -3
  79. package/dist/diagram-parser.d.ts.map +1 -1
  80. package/dist/diagram-parser.js +17 -3
  81. package/dist/diagram-parser.js.map +1 -1
  82. package/dist/examples/list-slot.d.ts +2 -0
  83. package/dist/examples/list-slot.d.ts.map +1 -0
  84. package/dist/examples/list-slot.js +14 -0
  85. package/dist/examples/list-slot.js.map +1 -0
  86. package/dist/examples/list-with-dropdown.js +2 -4
  87. package/dist/examples/list-with-dropdown.js.map +1 -1
  88. package/dist/examples/simple-heatmap.d.ts +2 -0
  89. package/dist/examples/simple-heatmap.d.ts.map +1 -0
  90. package/dist/examples/simple-heatmap.js +37 -0
  91. package/dist/examples/simple-heatmap.js.map +1 -0
  92. package/dist/examples/simple-progress-bar.d.ts +2 -0
  93. package/dist/examples/simple-progress-bar.d.ts.map +1 -0
  94. package/dist/examples/simple-progress-bar.js +36 -0
  95. package/dist/examples/simple-progress-bar.js.map +1 -0
  96. package/dist/index.d.ts +6 -0
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +6 -0
  99. package/dist/index.js.map +1 -1
  100. package/dist/internal/date-picker-widget.d.ts.map +1 -1
  101. package/dist/internal/date-picker-widget.js +5 -4
  102. package/dist/internal/date-picker-widget.js.map +1 -1
  103. package/dist/internal/navigation.d.ts.map +1 -1
  104. package/dist/internal/navigation.js +7 -2
  105. package/dist/internal/navigation.js.map +1 -1
  106. package/dist/internal/providers.d.ts.map +1 -1
  107. package/dist/internal/providers.js +42 -4
  108. package/dist/internal/providers.js.map +1 -1
  109. package/dist/logger.js +6 -1
  110. package/dist/logger.js.map +1 -1
  111. package/dist/state.d.ts +2 -0
  112. package/dist/state.d.ts.map +1 -1
  113. package/dist/state.js +31 -2
  114. package/dist/state.js.map +1 -1
  115. package/dist/theme.d.ts +1 -0
  116. package/dist/theme.d.ts.map +1 -1
  117. package/dist/theme.js +23 -1
  118. package/dist/theme.js.map +1 -1
  119. package/dist/utils.d.ts.map +1 -1
  120. package/dist/utils.js +6 -1
  121. package/dist/utils.js.map +1 -1
  122. package/package.json +3 -3
  123. package/src/apis/environment.tsx +6 -0
  124. package/src/app.tsx +1492 -0
  125. package/src/assets/default-app-icon.png +0 -0
  126. package/src/cli.tsx +105 -0
  127. package/src/compile.tsx +5 -2
  128. package/src/components/actions.tsx +9 -6
  129. package/src/components/detail.tsx +33 -23
  130. package/src/components/dropdown.tsx +3 -2
  131. package/src/components/footer.tsx +40 -7
  132. package/src/components/form/checkbox.tsx +2 -17
  133. package/src/components/form/date-picker.tsx +2 -2
  134. package/src/components/form/description.tsx +1 -1
  135. package/src/components/form/dropdown.tsx +22 -3
  136. package/src/components/form/file-picker.tsx +33 -10
  137. package/src/components/form/index.tsx +11 -7
  138. package/src/components/form/password-field.tsx +3 -3
  139. package/src/components/form/text-area.tsx +31 -6
  140. package/src/components/form/text-field.tsx +3 -3
  141. package/src/components/graph.tsx +21 -24
  142. package/src/components/heatmap.tsx +602 -0
  143. package/src/components/list.tsx +147 -78
  144. package/src/components/markdown.tsx +30 -0
  145. package/src/components/metadata.tsx +9 -2
  146. package/src/components/progress-bar.tsx +112 -0
  147. package/src/components/table.tsx +88 -71
  148. package/src/diagram-parser.tsx +17 -3
  149. package/src/examples/bar-graph-weekly.vitest.tsx +4 -4
  150. package/src/examples/detail-metadata-showcase.vitest.tsx +12 -12
  151. package/src/examples/form-basic.vitest.tsx +117 -16
  152. package/src/examples/graph-bar-chart.vitest.tsx +7 -7
  153. package/src/examples/graph-row.vitest.tsx +45 -45
  154. package/src/examples/graph-styles.vitest.tsx +19 -19
  155. package/src/examples/internal/descendants-rerender.vitest.tsx +94 -46
  156. package/src/examples/internal/simple-scrollbox.vitest.tsx +38 -14
  157. package/src/examples/list-dropdown-default.vitest.tsx +78 -58
  158. package/src/examples/list-slot.tsx +38 -0
  159. package/src/examples/list-with-detail.vitest.tsx +8 -8
  160. package/src/examples/list-with-dropdown.tsx +2 -2
  161. package/src/examples/list-with-dropdown.vitest.tsx +16 -16
  162. package/src/examples/list-with-sections.vitest.tsx +45 -32
  163. package/src/examples/simple-detail-table.vitest.tsx +2 -2
  164. package/src/examples/simple-file-picker.vitest.tsx +1 -1
  165. package/src/examples/simple-grid.vitest.tsx +27 -53
  166. package/src/examples/simple-heatmap.tsx +63 -0
  167. package/src/examples/simple-heatmap.vitest.tsx +88 -0
  168. package/src/examples/simple-progress-bar.tsx +82 -0
  169. package/src/examples/simple-progress-bar.vitest.tsx +72 -0
  170. package/src/examples/table-edge-cases.vitest.tsx +1 -1
  171. package/src/index.tsx +19 -0
  172. package/src/internal/date-picker-widget.tsx +23 -12
  173. package/src/internal/navigation.tsx +7 -2
  174. package/src/internal/providers.tsx +48 -3
  175. package/src/logger.tsx +6 -1
  176. package/src/state.tsx +38 -2
  177. package/src/theme.tsx +26 -2
  178. package/src/utils.tsx +6 -1
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  BoxRenderable,
3
+ MouseButton,
3
4
  ScrollBoxRenderable,
4
5
  TextAttributes,
5
6
  TextareaRenderable,
6
7
  } from '@opentui/core'
7
- import { useKeyboard, flushSync, useRenderer } from '@opentui/react'
8
+ import type { MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
9
+ import { useKeyboard, flushSync } from '@opentui/react'
8
10
  import React, {
9
11
  ReactElement,
10
12
  ReactNode,
@@ -21,9 +23,10 @@ import { LoadingBar } from 'termcast/src/components/loading-bar'
21
23
  import { LoadingText } from 'termcast/src/components/loading-text'
22
24
  import { Spinner } from 'termcast/src/components/spinner'
23
25
  import { useAnimationTick, TICK_DIVISORS } from 'termcast/src/components/animation-tick'
24
- import { Footer } from 'termcast/src/components/footer'
26
+ import { Footer, Hoverable } from 'termcast/src/components/footer'
25
27
  import { createDescendants } from 'termcast/src/descendants'
26
28
  import { useStore } from 'termcast/src/state'
29
+ import { showToast, Toast } from 'termcast/src/apis/toast'
27
30
  import { useDialog } from 'termcast/src/internal/dialog'
28
31
  import { useIsInFocus } from 'termcast/src/internal/focus-context'
29
32
  import { useNavigationPending } from 'termcast/src/internal/navigation'
@@ -33,8 +36,8 @@ import { ScrollBox } from 'termcast/src/internal/scrollbox'
33
36
  import { Color, resolveColor } from 'termcast/src/colors'
34
37
  import { getIconEmoji, getIconValue } from 'termcast/src/components/icon'
35
38
  import { ActionPanel, matchesShortcut } from 'termcast/src/components/actions'
36
- import { useTheme, markdownSyntaxStyle } from 'termcast/src/theme'
37
- import { createMarkdownRenderNode } from 'termcast/src/markdown-utils'
39
+ import { getInteractiveHoverBackground, useTheme } from 'termcast/src/theme'
40
+ import { Markdown } from 'termcast/src/components/markdown'
38
41
  import { CommonProps } from 'termcast/src/utils'
39
42
 
40
43
  export { Color }
@@ -78,20 +81,32 @@ interface ActionsInterface {
78
81
  function ListFooter(): any {
79
82
  const theme = useTheme()
80
83
  const firstActionTitle = useStore((s) => s.firstActionTitle)
84
+ const dropdownFooterLabel = useStore((s) => s.dropdownFooterLabel)
85
+ const dropdownTooltip = useStore((s) => s.dropdownTooltip)
81
86
  const hasToast = useStore((s) => s.toast !== null)
82
87
  const listContext = useContext(ListContext)
83
88
  const isShowingDetail = listContext?.isShowingDetail ?? false
84
89
  const hasDropdown = listContext?.hasDropdown ?? false
90
+ const isDropdownOpen = listContext?.isDropdownOpen ?? false
91
+ const openDropdownIfClosed = () => {
92
+ if (!isDropdownOpen) {
93
+ listContext?.openDropdown()
94
+ }
95
+ }
85
96
 
86
97
  const content = hasToast ? null : (
87
98
  <box style={{ flexDirection: 'row', gap: 3, flexShrink: 0 }}>
88
99
  {firstActionTitle && (
89
- <box style={{ flexDirection: 'row', gap: 1, flexShrink: 0 }}>
100
+ <Hoverable
101
+ onMouseDown={() => {
102
+ useStore.setState({ shouldAutoExecuteFirstAction: true })
103
+ }}
104
+ >
90
105
  <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
91
106
 
92
107
  </text>
93
108
  <text flexShrink={0} fg={theme.textMuted}>{firstActionTitle.toLowerCase()}</text>
94
- </box>
109
+ </Hoverable>
95
110
  )}
96
111
  <box style={{ flexDirection: 'row', gap: 1, flexShrink: 0 }}>
97
112
  <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
@@ -99,20 +114,26 @@ function ListFooter(): any {
99
114
  </text>
100
115
  <text flexShrink={0} fg={theme.textMuted}>navigate</text>
101
116
  </box>
117
+ <Hoverable
118
+ onMouseDown={() => {
119
+ useStore.setState({ showActionsDialog: true })
120
+ }}
121
+ >
122
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
123
+ ^k
124
+ </text>
125
+ <text flexShrink={0} fg={theme.textMuted}>actions</text>
126
+ </Hoverable>
102
127
  {hasDropdown && (
103
- <box style={{ flexDirection: 'row', gap: 1, flexShrink: 0 }}>
128
+ <Hoverable onMouseDown={openDropdownIfClosed}>
104
129
  <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
105
130
  ^p
106
131
  </text>
107
- <text flexShrink={0} fg={theme.textMuted}>dropdown</text>
108
- </box>
132
+ <text flexShrink={0} fg={theme.textMuted}>
133
+ {(dropdownTooltip || dropdownFooterLabel || 'dropdown').toLowerCase()}
134
+ </text>
135
+ </Hoverable>
109
136
  )}
110
- <box style={{ flexDirection: 'row', gap: 1, flexShrink: 0 }}>
111
- <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
112
- ^k
113
- </text>
114
- <text flexShrink={0} fg={theme.textMuted}>actions</text>
115
- </box>
116
137
  </box>
117
138
  )
118
139
 
@@ -337,6 +358,8 @@ export interface ListProps
337
358
  children?: ReactNode
338
359
  onSelectionChange?: (id: string | null) => void
339
360
  searchBarAccessory?: ReactElement<DropdownProps> | null
361
+ /** Custom ReactNode rendered on the right edge of the search bar row, after the dropdown if present. */
362
+ logo?: ReactNode
340
363
  searchText?: string
341
364
  enableFiltering?: boolean
342
365
  searchBarPlaceholder?: string
@@ -742,7 +765,7 @@ function ListItemRow(props: {
742
765
  accessories?: ItemAccessory[]
743
766
  active?: boolean
744
767
  isShowingDetail?: boolean
745
- onMouseDown?: () => void
768
+ onMouseDown?: (evt: OpenTUIMouseEvent) => void
746
769
  index?: number
747
770
  ref?: React.Ref<BoxRenderable>
748
771
  }) {
@@ -752,7 +775,23 @@ function ListItemRow(props: {
752
775
  const accessoryTagWidths = listCtx?.accessoryTagWidths
753
776
  const isRelaxed = spacingMode === 'relaxed'
754
777
  const { title, subtitle, icon, iconColor, accessories, active, ref } = props
778
+ const hoverBackgroundColor = getInteractiveHoverBackground(theme)
755
779
  const [isHovered, setIsHovered] = useState(false)
780
+ const handleMouseMove = () => {
781
+ setIsHovered(true)
782
+ // Select item on hover
783
+ if (!active && props.index !== undefined && props.index !== -1 && listCtx?.setSelectedIndex) {
784
+ listCtx.setSelectedIndex(props.index)
785
+ }
786
+ }
787
+
788
+ const handleMouseDown = (evt: OpenTUIMouseEvent) => {
789
+ props.onMouseDown?.(evt)
790
+ }
791
+
792
+ const handleMouseOut = () => {
793
+ setIsHovered(false)
794
+ }
756
795
 
757
796
  const accessoryElements: ReactNode[] = []
758
797
  if (accessories) {
@@ -851,20 +890,16 @@ function ListItemRow(props: {
851
890
  backgroundColor: active
852
891
  ? theme.primary
853
892
  : isHovered
854
- ? theme.backgroundPanel
893
+ ? hoverBackgroundColor
855
894
  : undefined,
856
895
  paddingLeft: 0,
857
896
  paddingRight: 1,
858
897
  marginBottom: 1,
859
898
  }}
860
899
  border={false}
861
- onMouseMove={() => {
862
- setIsHovered(true)
863
- }}
864
- onMouseOut={() => {
865
- setIsHovered(false)
866
- }}
867
- onMouseDown={props.onMouseDown}
900
+ onMouseMove={handleMouseMove}
901
+ onMouseOut={handleMouseOut}
902
+ onMouseDown={handleMouseDown}
868
903
  >
869
904
  {/* Line 1: marker + icon + title + accessories */}
870
905
  <box style={{ flexDirection: 'row', justifyContent: 'space-between', gap: 1 }}>
@@ -920,20 +955,16 @@ function ListItemRow(props: {
920
955
  backgroundColor: active
921
956
  ? theme.primary
922
957
  : isHovered
923
- ? theme.backgroundPanel
958
+ ? hoverBackgroundColor
924
959
  : undefined,
925
960
  paddingLeft: 0,
926
961
  paddingRight: 1,
927
962
  gap: 1,
928
963
  }}
929
964
  border={false}
930
- onMouseMove={() => {
931
- setIsHovered(true)
932
- }}
933
- onMouseOut={() => {
934
- setIsHovered(false)
935
- }}
936
- onMouseDown={props.onMouseDown}
965
+ onMouseMove={handleMouseMove}
966
+ onMouseOut={handleMouseOut}
967
+ onMouseDown={handleMouseDown}
937
968
  >
938
969
  <box style={{ flexDirection: 'row', flexGrow: 1, flexShrink: 1, overflow: 'hidden', gap: 1 }}>
939
970
  <box style={{ flexDirection: 'row', flexShrink: 0 }}>
@@ -987,6 +1018,7 @@ export const List: ListType = (props) => {
987
1018
  isShowingDetail,
988
1019
  selectedItemId,
989
1020
  searchBarAccessory,
1021
+ logo,
990
1022
  spacingMode = 'default',
991
1023
  accessoryTagsLayout,
992
1024
  throttle,
@@ -1361,9 +1393,9 @@ export const List: ListType = (props) => {
1361
1393
  .sort((a, b) => a.index - b.index)
1362
1394
  const currentItem = items.find((item) => item.index === selectedIndex)
1363
1395
 
1364
- // Handle Ctrl+K to show actions dialog via portal
1396
+ // Handle Ctrl+K / Cmd+K to show actions dialog via portal
1365
1397
  // Always open — built-in actions (Change Theme, etc.) are always available
1366
- if (evt.name === 'k' && evt.ctrl) {
1398
+ if (evt.name === 'k' && (evt.ctrl || evt.super)) {
1367
1399
  useStore.setState({ showActionsDialog: true })
1368
1400
  return
1369
1401
  }
@@ -1410,7 +1442,7 @@ export const List: ListType = (props) => {
1410
1442
  <box style={{ flexDirection: 'column', flexGrow: 1 }}>
1411
1443
  {/* Cannot mount focused actions here - would need to be handled differently */}
1412
1444
 
1413
- {navigationTitle && (
1445
+ {(navigationTitle || (logo && searchBarAccessory)) && (
1414
1446
  <box
1415
1447
  border={false}
1416
1448
  style={{
@@ -1419,12 +1451,25 @@ export const List: ListType = (props) => {
1419
1451
  paddingLeft: 1,
1420
1452
  paddingRight: 1,
1421
1453
  overflow: 'hidden',
1454
+ flexDirection: 'row',
1455
+ alignItems: 'center',
1422
1456
  }}
1423
1457
  >
1424
- <LoadingBar
1425
- title={navigationTitle}
1426
- isLoading={isLoading || navigationPending}
1427
- />
1458
+ {navigationTitle ? (
1459
+ <box flexGrow={1} flexShrink={1} overflow='hidden'>
1460
+ <LoadingBar
1461
+ title={navigationTitle}
1462
+ isLoading={isLoading || navigationPending}
1463
+ />
1464
+ </box>
1465
+ ) : (
1466
+ <box flexGrow={1} />
1467
+ )}
1468
+ {logo ? (
1469
+ <box flexShrink={0} paddingLeft={1}>
1470
+ {logo}
1471
+ </box>
1472
+ ) : null}
1428
1473
  </box>
1429
1474
  )}
1430
1475
 
@@ -1473,6 +1518,11 @@ export const List: ListType = (props) => {
1473
1518
  />
1474
1519
  </box>
1475
1520
  {searchBarAccessory}
1521
+ {!navigationTitle && !searchBarAccessory && logo ? (
1522
+ <box flexShrink={0} paddingLeft={2}>
1523
+ {logo}
1524
+ </box>
1525
+ ) : null}
1476
1526
  </box>
1477
1527
  </box>
1478
1528
 
@@ -1649,18 +1699,23 @@ const ListItem: ListItemType = (props) => {
1649
1699
  // Don't render if not visible
1650
1700
  if (!isVisible) return null
1651
1701
 
1652
- // Handle mouse click on item
1653
- const handleMouseDown = () => {
1702
+ // Handle mouse click on item — left-click selects and executes first action,
1703
+ // right-click selects and opens the actions dialog.
1704
+ // flushSync ensures React commits the new selectedIndex before Zustand
1705
+ // triggers auto-execute, so ActionPanel picks up the clicked item's actions.
1706
+ const handleMouseDown = (evt: OpenTUIMouseEvent) => {
1654
1707
  if (listContext && index !== -1) {
1655
- // If clicking on already selected item, show actions (like pressing Enter)
1656
- if (isActive) {
1657
- // Show actions dialog via portal
1658
- if (props.actions) {
1659
- useStore.setState({ showActionsDialog: true })
1660
- }
1661
- } else if (listContext.setSelectedIndex) {
1662
- // Otherwise just select the item
1663
- listContext.setSelectedIndex(index)
1708
+ if (!isActive && listContext.setSelectedIndex) {
1709
+ const setIdx = listContext.setSelectedIndex
1710
+ flushSync(() => {
1711
+ setIdx(index)
1712
+ })
1713
+ }
1714
+ if (evt.button === MouseButton.RIGHT) {
1715
+ // Right-click opens the actions dialog
1716
+ useStore.setState({ showActionsDialog: true })
1717
+ } else if (props.actions) {
1718
+ useStore.setState({ shouldAutoExecuteFirstAction: true })
1664
1719
  }
1665
1720
  }
1666
1721
  }
@@ -1706,17 +1761,7 @@ const ListItem: ListItemType = (props) => {
1706
1761
  )
1707
1762
  }
1708
1763
 
1709
- // Renders markdown with link URL stripping via renderNode for list detail panel.
1710
- function ListMarkdownContent({ markdown }: { markdown: string }): any {
1711
- const renderer = useRenderer()
1712
- const renderNode = React.useMemo(() => {
1713
- return createMarkdownRenderNode(renderer)
1714
- }, [renderer])
1715
1764
 
1716
- return (
1717
- <markdown content={markdown} syntaxStyle={markdownSyntaxStyle} conceal renderNode={renderNode} />
1718
- )
1719
- }
1720
1765
 
1721
1766
  const ListItemDetail: ListItemDetailType = (props) => {
1722
1767
  const theme = useTheme()
@@ -1747,7 +1792,7 @@ const ListItemDetail: ListItemDetailType = (props) => {
1747
1792
  >
1748
1793
  <box gap={1} style={{ flexDirection: 'column' }}>
1749
1794
  {markdown && markdown.trim().length > 0 && (
1750
- <ListMarkdownContent markdown={markdown} />
1795
+ <Markdown content={markdown} />
1751
1796
  )}
1752
1797
  {metadata}
1753
1798
  </box>
@@ -1807,6 +1852,7 @@ ListItem.Detail = ListItemDetail
1807
1852
  const ListDropdown: ListDropdownType = (props) => {
1808
1853
  const theme = useTheme()
1809
1854
  const listContext = useContext(ListContext)
1855
+ const hoverBackgroundColor = getInteractiveHoverBackground(theme)
1810
1856
  const [isHovered, setIsHovered] = useState(false)
1811
1857
 
1812
1858
  // If not inside a List, just render nothing (for type safety)
@@ -1815,6 +1861,11 @@ const ListDropdown: ListDropdownType = (props) => {
1815
1861
  }
1816
1862
 
1817
1863
  const { isDropdownOpen, setIsDropdownOpen } = listContext
1864
+
1865
+ const setDropdownSelection = (props: { value: string; title: string }) => {
1866
+ setDropdownState({ value: props.value, title: props.title })
1867
+ useStore.setState({ dropdownFooterLabel: props.title || 'dropdown' })
1868
+ }
1818
1869
  // Store both value and title together
1819
1870
  const [dropdownState, setDropdownState] = useState<{
1820
1871
  value: string
@@ -1827,6 +1878,11 @@ const ListDropdown: ListDropdownType = (props) => {
1827
1878
  const dialog = useDialog()
1828
1879
  const inFocus = useIsInFocus()
1829
1880
 
1881
+ // Store dropdown tooltip in zustand for footer display
1882
+ useLayoutEffect(() => {
1883
+ useStore.setState({ dropdownTooltip: props.tooltip || '' })
1884
+ }, [props.tooltip])
1885
+
1830
1886
  // Update value and find its title
1831
1887
  useLayoutEffect(() => {
1832
1888
  const valueToUse =
@@ -1840,12 +1896,17 @@ const ListDropdown: ListDropdownType = (props) => {
1840
1896
 
1841
1897
  if (items.length > 0) {
1842
1898
  const firstItem = items[0].props as DropdownItemDescendant
1843
- setDropdownState({ value: firstItem.value, title: firstItem.title })
1899
+ setDropdownSelection({ value: firstItem.value, title: firstItem.title })
1844
1900
  return
1845
1901
  }
1846
1902
  }
1847
1903
 
1848
- if (!valueToUse) return
1904
+ if (!valueToUse) {
1905
+ useStore.setState({
1906
+ dropdownFooterLabel: dropdownState.title || 'dropdown',
1907
+ })
1908
+ return
1909
+ }
1849
1910
 
1850
1911
  // Try to find the title for this value
1851
1912
  let title = valueToUse
@@ -1859,8 +1920,11 @@ const ListDropdown: ListDropdownType = (props) => {
1859
1920
 
1860
1921
  // Only update if something changed
1861
1922
  if (dropdownState.value !== valueToUse || dropdownState.title !== title) {
1862
- setDropdownState({ value: valueToUse, title })
1923
+ setDropdownSelection({ value: valueToUse, title })
1924
+ return
1863
1925
  }
1926
+
1927
+ useStore.setState({ dropdownFooterLabel: title || 'dropdown' })
1864
1928
  }, [props.value]) // Run when props.value changes and on mount
1865
1929
 
1866
1930
  const dropdownContextValue = useMemo<DropdownContextValue>(
@@ -1889,7 +1953,7 @@ const ListDropdown: ListDropdownType = (props) => {
1889
1953
  break
1890
1954
  }
1891
1955
  }
1892
- setDropdownState({ value: newValue, title })
1956
+ setDropdownSelection({ value: newValue, title })
1893
1957
  setIsDropdownOpen(false)
1894
1958
  dialog.clear()
1895
1959
  if (props.onChange) {
@@ -1912,6 +1976,11 @@ const ListDropdown: ListDropdownType = (props) => {
1912
1976
 
1913
1977
  // Display the title from our state
1914
1978
  const displayValue = dropdownState.title || 'All'
1979
+ const openDropdownIfClosed = () => {
1980
+ if (!isDropdownOpen) {
1981
+ listContext.openDropdown()
1982
+ }
1983
+ }
1915
1984
 
1916
1985
  return (
1917
1986
  <DropdownDescendantsProvider value={descendantsContext}>
@@ -1926,27 +1995,25 @@ const ListDropdown: ListDropdownType = (props) => {
1926
1995
  // minWidth: value.length + 4,
1927
1996
  flexDirection: 'row',
1928
1997
  flexShrink: 0,
1929
- backgroundColor: isHovered ? theme.backgroundPanel : undefined,
1998
+ backgroundColor: isHovered ? hoverBackgroundColor : undefined,
1930
1999
  }}
1931
2000
  onMouseMove={() => setIsHovered(true)}
1932
2001
  onMouseOut={() => setIsHovered(false)}
1933
- onMouseDown={() => {
1934
- // Open dropdown when clicked
1935
- if (!isDropdownOpen) {
1936
- listContext.openDropdown()
1937
- }
1938
- }}
2002
+ onMouseDown={openDropdownIfClosed}
1939
2003
  >
1940
2004
  {/*<text >^p </text>*/}
1941
2005
  {listContext.isLoading ? (
1942
- <LoadingText isLoading color={isHovered ? theme.text : theme.textMuted}>
1943
- {displayValue || 'Loading...'}
1944
- </LoadingText>
2006
+ <box onMouseDown={openDropdownIfClosed}>
2007
+ <LoadingText isLoading color={isHovered ? theme.text : theme.textMuted}>
2008
+ {displayValue || 'Loading...'}
2009
+ </LoadingText>
2010
+ </box>
1945
2011
  ) : (
1946
2012
  <text
1947
2013
  flexShrink={0}
1948
2014
  fg={isHovered ? theme.text : theme.textMuted}
1949
2015
  selectable={false}
2016
+ onMouseDown={openDropdownIfClosed}
1950
2017
  >
1951
2018
  {displayValue}
1952
2019
  </text>
@@ -1955,6 +2022,7 @@ const ListDropdown: ListDropdownType = (props) => {
1955
2022
  flexShrink={0}
1956
2023
  fg={isHovered ? theme.text : theme.textMuted}
1957
2024
  selectable={false}
2025
+ onMouseDown={openDropdownIfClosed}
1958
2026
  >
1959
2027
  {' '}
1960
2028
 
@@ -1967,6 +2035,7 @@ const ListDropdown: ListDropdownType = (props) => {
1967
2035
 
1968
2036
  ListDropdown.Item = (props) => {
1969
2037
  const theme = useTheme()
2038
+ const hoverBackgroundColor = getInteractiveHoverBackground(theme)
1970
2039
  const dropdownContext = useContext(DropdownContext)
1971
2040
  const [isHovered, setIsHovered] = useState(false)
1972
2041
 
@@ -2032,7 +2101,7 @@ ListDropdown.Item = (props) => {
2032
2101
  backgroundColor: isActive
2033
2102
  ? theme.primary
2034
2103
  : isHovered
2035
- ? theme.backgroundPanel
2104
+ ? hoverBackgroundColor
2036
2105
  : undefined,
2037
2106
  paddingLeft: isActive ? 0 : 1,
2038
2107
  paddingRight: 1,
@@ -2177,9 +2246,9 @@ function EmptyViewContent(props: EmptyViewProps): any {
2177
2246
  useKeyboard((evt) => {
2178
2247
  if (!inFocus) return
2179
2248
 
2180
- // Handle Ctrl+K to show actions dialog via portal
2249
+ // Handle Ctrl+K / Cmd+K to show actions dialog via portal
2181
2250
  // Always open — built-in actions (Change Theme, etc.) are always available
2182
- if (evt.name === 'k' && evt.ctrl) {
2251
+ if (evt.name === 'k' && (evt.ctrl || evt.super)) {
2183
2252
  useStore.setState({ showActionsDialog: true })
2184
2253
  return
2185
2254
  }
@@ -0,0 +1,30 @@
1
+ // Standalone Markdown component for rendering themed markdown in terminal UI.
2
+ // Wraps opentui's <markdown> element with termcast's custom renderNode hook
3
+ // (link URL stripping, borderless tables, OSC 8 hyperlinks) and automatic
4
+ // theme-aware syntax highlighting. Accepts BoxProps so it can be composed
5
+ // with Row, CalendarHeatmap, Graph, etc.
6
+
7
+ import { useMemo } from 'react'
8
+ import { useRenderer } from '@opentui/react'
9
+ import type { BoxProps } from '@opentui/react'
10
+ import { markdownSyntaxStyle } from 'termcast/src/theme'
11
+ import { createMarkdownRenderNode } from 'termcast/src/markdown-utils'
12
+
13
+ export interface MarkdownProps extends BoxProps {
14
+ content: string
15
+ }
16
+
17
+ function Markdown({ content, children, ...boxProps }: MarkdownProps): any {
18
+ const renderer = useRenderer()
19
+ const renderNode = useMemo(() => {
20
+ return createMarkdownRenderNode(renderer)
21
+ }, [renderer])
22
+
23
+ return (
24
+ <box {...boxProps}>
25
+ <markdown content={content} syntaxStyle={markdownSyntaxStyle} conceal renderNode={renderNode} />
26
+ </box>
27
+ )
28
+ }
29
+
30
+ export { Markdown }
@@ -13,6 +13,7 @@ import { TextAttributes } from '@opentui/core'
13
13
  import { useTheme } from 'termcast/src/theme'
14
14
  import { Color, resolveColor } from 'termcast/src/colors'
15
15
  import type { ImageLike } from 'termcast/src/components/image'
16
+ import { openInBrowser } from 'termcast/src/action-utils'
16
17
 
17
18
  interface MetadataConfig {
18
19
  /**
@@ -174,8 +175,14 @@ const MetadataLink = (props: LinkProps): any => {
174
175
  <text flexShrink={0} fg={theme.textMuted} style={{ minWidth: config.titleMinWidth }}>
175
176
  {props.title}:{' '}
176
177
  </text>
177
- <text fg={theme.accent} attributes={TextAttributes.UNDERLINE}>
178
- {props.text}
178
+ <text
179
+ fg={theme.accent}
180
+ attributes={TextAttributes.UNDERLINE}
181
+ onMouseDown={() => {
182
+ void openInBrowser(props.target)
183
+ }}
184
+ >
185
+ <a href={props.target}>{props.text}</a>
179
186
  </text>
180
187
  </box>
181
188
  )
@@ -0,0 +1,112 @@
1
+ /**
2
+ * ProgressBar component for rendering usage/progress rows in terminal UIs.
3
+ *
4
+ * Layout:
5
+ * - title row
6
+ * - bar + percentage row (bar grows to fill available width)
7
+ * - optional muted label row
8
+ */
9
+
10
+ import React from 'react'
11
+ import { TextAttributes } from '@opentui/core'
12
+ import type { BoxProps } from '@opentui/react'
13
+ import { useTheme } from 'termcast/src/theme'
14
+ import { Color, resolveColor } from 'termcast/src/colors'
15
+
16
+ export interface ProgressBarProps extends BoxProps {
17
+ /** Main title shown above the bar */
18
+ title: string
19
+ /** Current progress value */
20
+ value: number
21
+ /** Maximum value used to calculate percentage (default: 100) */
22
+ maxValue?: number
23
+ /** Optional muted label below the bar */
24
+ label?: string
25
+ /** Show percentage text at end of bar row (default: true) */
26
+ showPercentage?: boolean
27
+ /** Optional suffix after percentage text (e.g. "used") */
28
+ percentageSuffix?: string
29
+ /** Optional color override for filled segment */
30
+ color?: Color.ColorLike
31
+ /** Optional color override for track segment */
32
+ trackColor?: Color.ColorLike
33
+ /** Optional formatter for percentage value */
34
+ formatPercentage?: (percentage: number) => string
35
+ /** Character used for the filled portion of the bar (default: '█') */
36
+ barCharacter?: string
37
+ /** Character used for the empty/track portion of the bar (default: '░') */
38
+ trackCharacter?: string
39
+ }
40
+
41
+ function clamp({ value, min, max }: { value: number; min: number; max: number }): number {
42
+ if (value < min) {
43
+ return min
44
+ }
45
+ if (value > max) {
46
+ return max
47
+ }
48
+ return value
49
+ }
50
+
51
+ function ProgressBar(props: ProgressBarProps): any {
52
+ const theme = useTheme()
53
+ const {
54
+ title,
55
+ value,
56
+ maxValue = 100,
57
+ label,
58
+ showPercentage = true,
59
+ percentageSuffix,
60
+ color,
61
+ trackColor,
62
+ formatPercentage,
63
+ barCharacter = '█',
64
+ trackCharacter = '░',
65
+ ...rest
66
+ } = props
67
+
68
+ const safeMaxValue = Number.isFinite(maxValue) && maxValue > 0 ? maxValue : 1
69
+ const safeValue = Number.isFinite(value) ? value : 0
70
+ const rawPercentage = (safeValue / safeMaxValue) * 100
71
+ const clampedPercentage = clamp({ value: rawPercentage, min: 0, max: 100 })
72
+
73
+ const filledGrow = clampedPercentage
74
+ const trackGrow = 100 - clampedPercentage
75
+
76
+ const filledColor = resolveColor(color) || theme.accent
77
+ const resolvedTrackColor = resolveColor(trackColor) || theme.conceal
78
+
79
+ const formattedPercentage = formatPercentage
80
+ ? formatPercentage(clampedPercentage)
81
+ : `${Math.round(clampedPercentage)}%`
82
+
83
+ const percentageText = percentageSuffix
84
+ ? `${formattedPercentage} ${percentageSuffix}`
85
+ : formattedPercentage
86
+
87
+ return (
88
+ <box flexDirection="column" width="100%" {...rest}>
89
+ <text fg={theme.text} attributes={TextAttributes.BOLD}>{title}</text>
90
+ <box flexDirection="row" alignItems="center" width="100%" gap={1}>
91
+ <box flexDirection="row" flexGrow={1} overflow="hidden">
92
+ {filledGrow > 0 && (
93
+ <box flexGrow={filledGrow} flexBasis={0} flexShrink={1} overflow="hidden">
94
+ <text fg={filledColor} wrapMode="none">{barCharacter.repeat(300)}</text>
95
+ </box>
96
+ )}
97
+ {trackGrow > 0 && (
98
+ <box flexGrow={trackGrow} flexBasis={0} flexShrink={1} overflow="hidden">
99
+ <text fg={resolvedTrackColor} wrapMode="none">{trackCharacter.repeat(300)}</text>
100
+ </box>
101
+ )}
102
+ </box>
103
+ {showPercentage && (
104
+ <text fg={theme.text} wrapMode="none" flexShrink={0}>{percentageText}</text>
105
+ )}
106
+ </box>
107
+ {label && <text fg={theme.textMuted}>{label}</text>}
108
+ </box>
109
+ )
110
+ }
111
+
112
+ export { ProgressBar }