termcast 1.3.50 → 1.3.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/dist/apis/environment.d.ts +1 -0
  2. package/dist/apis/environment.d.ts.map +1 -1
  3. package/dist/apis/environment.js +5 -0
  4. package/dist/apis/environment.js.map +1 -1
  5. package/dist/app.d.ts +33 -0
  6. package/dist/app.d.ts.map +1 -0
  7. package/dist/app.js +1125 -0
  8. package/dist/app.js.map +1 -0
  9. package/dist/cli.js +80 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/components/detail.d.ts.map +1 -1
  12. package/dist/components/detail.js +20 -17
  13. package/dist/components/detail.js.map +1 -1
  14. package/dist/components/dropdown.d.ts.map +1 -1
  15. package/dist/components/dropdown.js +3 -2
  16. package/dist/components/dropdown.js.map +1 -1
  17. package/dist/components/footer.d.ts +6 -0
  18. package/dist/components/footer.d.ts.map +1 -1
  19. package/dist/components/footer.js +15 -6
  20. package/dist/components/footer.js.map +1 -1
  21. package/dist/components/form/checkbox.d.ts.map +1 -1
  22. package/dist/components/form/checkbox.js +1 -13
  23. package/dist/components/form/checkbox.js.map +1 -1
  24. package/dist/components/form/date-picker.js +2 -2
  25. package/dist/components/form/date-picker.js.map +1 -1
  26. package/dist/components/form/description.js +1 -1
  27. package/dist/components/form/description.js.map +1 -1
  28. package/dist/components/form/dropdown.d.ts.map +1 -1
  29. package/dist/components/form/dropdown.js +19 -3
  30. package/dist/components/form/dropdown.js.map +1 -1
  31. package/dist/components/form/file-picker.d.ts.map +1 -1
  32. package/dist/components/form/file-picker.js +22 -4
  33. package/dist/components/form/file-picker.js.map +1 -1
  34. package/dist/components/form/index.d.ts +3 -1
  35. package/dist/components/form/index.d.ts.map +1 -1
  36. package/dist/components/form/index.js +6 -4
  37. package/dist/components/form/index.js.map +1 -1
  38. package/dist/components/form/password-field.js +3 -3
  39. package/dist/components/form/password-field.js.map +1 -1
  40. package/dist/components/form/text-area.d.ts.map +1 -1
  41. package/dist/components/form/text-area.js +29 -6
  42. package/dist/components/form/text-area.js.map +1 -1
  43. package/dist/components/form/text-field.js +3 -3
  44. package/dist/components/form/text-field.js.map +1 -1
  45. package/dist/components/heatmap.d.ts +80 -0
  46. package/dist/components/heatmap.d.ts.map +1 -0
  47. package/dist/components/heatmap.js +405 -0
  48. package/dist/components/heatmap.js.map +1 -0
  49. package/dist/components/list.d.ts +2 -0
  50. package/dist/components/list.d.ts.map +1 -1
  51. package/dist/components/list.js +80 -52
  52. package/dist/components/list.js.map +1 -1
  53. package/dist/components/markdown.d.ts +7 -0
  54. package/dist/components/markdown.d.ts.map +1 -0
  55. package/dist/components/markdown.js +19 -0
  56. package/dist/components/markdown.js.map +1 -0
  57. package/dist/components/metadata.d.ts.map +1 -1
  58. package/dist/components/metadata.js +4 -1
  59. package/dist/components/metadata.js.map +1 -1
  60. package/dist/components/progress-bar.d.ts +37 -0
  61. package/dist/components/progress-bar.d.ts.map +1 -0
  62. package/dist/components/progress-bar.js +34 -0
  63. package/dist/components/progress-bar.js.map +1 -0
  64. package/dist/components/table.d.ts +3 -2
  65. package/dist/components/table.d.ts.map +1 -1
  66. package/dist/components/table.js +78 -63
  67. package/dist/components/table.js.map +1 -1
  68. package/dist/diagram-parser.d.ts +17 -3
  69. package/dist/diagram-parser.d.ts.map +1 -1
  70. package/dist/diagram-parser.js +17 -3
  71. package/dist/diagram-parser.js.map +1 -1
  72. package/dist/examples/list-slot.d.ts +2 -0
  73. package/dist/examples/list-slot.d.ts.map +1 -0
  74. package/dist/examples/list-slot.js +14 -0
  75. package/dist/examples/list-slot.js.map +1 -0
  76. package/dist/examples/list-with-dropdown.js +2 -4
  77. package/dist/examples/list-with-dropdown.js.map +1 -1
  78. package/dist/examples/simple-heatmap.d.ts +2 -0
  79. package/dist/examples/simple-heatmap.d.ts.map +1 -0
  80. package/dist/examples/simple-heatmap.js +37 -0
  81. package/dist/examples/simple-heatmap.js.map +1 -0
  82. package/dist/examples/simple-progress-bar.d.ts +2 -0
  83. package/dist/examples/simple-progress-bar.d.ts.map +1 -0
  84. package/dist/examples/simple-progress-bar.js +36 -0
  85. package/dist/examples/simple-progress-bar.js.map +1 -0
  86. package/dist/index.d.ts +6 -0
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +6 -0
  89. package/dist/index.js.map +1 -1
  90. package/dist/internal/date-picker-widget.d.ts.map +1 -1
  91. package/dist/internal/date-picker-widget.js +5 -4
  92. package/dist/internal/date-picker-widget.js.map +1 -1
  93. package/dist/internal/navigation.d.ts.map +1 -1
  94. package/dist/internal/navigation.js +7 -2
  95. package/dist/internal/navigation.js.map +1 -1
  96. package/dist/internal/providers.d.ts.map +1 -1
  97. package/dist/internal/providers.js +42 -4
  98. package/dist/internal/providers.js.map +1 -1
  99. package/dist/logger.js +6 -1
  100. package/dist/logger.js.map +1 -1
  101. package/dist/state.d.ts +2 -0
  102. package/dist/state.d.ts.map +1 -1
  103. package/dist/state.js +31 -2
  104. package/dist/state.js.map +1 -1
  105. package/dist/theme.d.ts +1 -0
  106. package/dist/theme.d.ts.map +1 -1
  107. package/dist/theme.js +23 -1
  108. package/dist/theme.js.map +1 -1
  109. package/dist/utils.d.ts.map +1 -1
  110. package/dist/utils.js +6 -1
  111. package/dist/utils.js.map +1 -1
  112. package/package.json +3 -3
  113. package/src/apis/environment.tsx +6 -0
  114. package/src/app.tsx +1487 -0
  115. package/src/assets/default-app-icon.png +0 -0
  116. package/src/cli.tsx +105 -0
  117. package/src/components/detail.tsx +32 -22
  118. package/src/components/dropdown.tsx +3 -2
  119. package/src/components/footer.tsx +37 -7
  120. package/src/components/form/checkbox.tsx +2 -17
  121. package/src/components/form/date-picker.tsx +2 -2
  122. package/src/components/form/description.tsx +1 -1
  123. package/src/components/form/dropdown.tsx +22 -3
  124. package/src/components/form/file-picker.tsx +33 -10
  125. package/src/components/form/index.tsx +10 -6
  126. package/src/components/form/password-field.tsx +3 -3
  127. package/src/components/form/text-area.tsx +31 -6
  128. package/src/components/form/text-field.tsx +3 -3
  129. package/src/components/heatmap.tsx +584 -0
  130. package/src/components/list.tsx +135 -72
  131. package/src/components/markdown.tsx +30 -0
  132. package/src/components/metadata.tsx +9 -2
  133. package/src/components/progress-bar.tsx +112 -0
  134. package/src/components/table.tsx +88 -71
  135. package/src/diagram-parser.tsx +17 -3
  136. package/src/examples/bar-graph-weekly.vitest.tsx +4 -4
  137. package/src/examples/detail-metadata-showcase.vitest.tsx +12 -12
  138. package/src/examples/form-basic.vitest.tsx +117 -16
  139. package/src/examples/graph-bar-chart.vitest.tsx +2 -2
  140. package/src/examples/graph-row.vitest.tsx +10 -10
  141. package/src/examples/internal/descendants-rerender.vitest.tsx +94 -46
  142. package/src/examples/internal/simple-scrollbox.vitest.tsx +38 -14
  143. package/src/examples/list-dropdown-default.vitest.tsx +78 -58
  144. package/src/examples/list-slot.tsx +38 -0
  145. package/src/examples/list-with-detail.vitest.tsx +8 -8
  146. package/src/examples/list-with-dropdown.tsx +2 -2
  147. package/src/examples/list-with-dropdown.vitest.tsx +16 -16
  148. package/src/examples/list-with-sections.vitest.tsx +45 -32
  149. package/src/examples/simple-detail-table.vitest.tsx +2 -2
  150. package/src/examples/simple-file-picker.vitest.tsx +1 -1
  151. package/src/examples/simple-grid.vitest.tsx +27 -53
  152. package/src/examples/simple-heatmap.tsx +63 -0
  153. package/src/examples/simple-heatmap.vitest.tsx +88 -0
  154. package/src/examples/simple-progress-bar.tsx +82 -0
  155. package/src/examples/simple-progress-bar.vitest.tsx +72 -0
  156. package/src/examples/table-edge-cases.vitest.tsx +1 -1
  157. package/src/index.tsx +19 -0
  158. package/src/internal/date-picker-widget.tsx +23 -12
  159. package/src/internal/navigation.tsx +7 -2
  160. package/src/internal/providers.tsx +48 -3
  161. package/src/logger.tsx +6 -1
  162. package/src/state.tsx +38 -2
  163. package/src/theme.tsx +26 -2
  164. package/src/utils.tsx +6 -1
@@ -4,7 +4,7 @@ import {
4
4
  TextAttributes,
5
5
  TextareaRenderable,
6
6
  } from '@opentui/core'
7
- import { useKeyboard, flushSync, useRenderer } from '@opentui/react'
7
+ import { useKeyboard, flushSync } from '@opentui/react'
8
8
  import React, {
9
9
  ReactElement,
10
10
  ReactNode,
@@ -21,9 +21,10 @@ import { LoadingBar } from 'termcast/src/components/loading-bar'
21
21
  import { LoadingText } from 'termcast/src/components/loading-text'
22
22
  import { Spinner } from 'termcast/src/components/spinner'
23
23
  import { useAnimationTick, TICK_DIVISORS } from 'termcast/src/components/animation-tick'
24
- import { Footer } from 'termcast/src/components/footer'
24
+ import { Footer, Hoverable } from 'termcast/src/components/footer'
25
25
  import { createDescendants } from 'termcast/src/descendants'
26
26
  import { useStore } from 'termcast/src/state'
27
+ import { showToast, Toast } from 'termcast/src/apis/toast'
27
28
  import { useDialog } from 'termcast/src/internal/dialog'
28
29
  import { useIsInFocus } from 'termcast/src/internal/focus-context'
29
30
  import { useNavigationPending } from 'termcast/src/internal/navigation'
@@ -33,8 +34,8 @@ import { ScrollBox } from 'termcast/src/internal/scrollbox'
33
34
  import { Color, resolveColor } from 'termcast/src/colors'
34
35
  import { getIconEmoji, getIconValue } from 'termcast/src/components/icon'
35
36
  import { ActionPanel, matchesShortcut } from 'termcast/src/components/actions'
36
- import { useTheme, markdownSyntaxStyle } from 'termcast/src/theme'
37
- import { createMarkdownRenderNode } from 'termcast/src/markdown-utils'
37
+ import { getInteractiveHoverBackground, useTheme } from 'termcast/src/theme'
38
+ import { Markdown } from 'termcast/src/components/markdown'
38
39
  import { CommonProps } from 'termcast/src/utils'
39
40
 
40
41
  export { Color }
@@ -78,20 +79,32 @@ interface ActionsInterface {
78
79
  function ListFooter(): any {
79
80
  const theme = useTheme()
80
81
  const firstActionTitle = useStore((s) => s.firstActionTitle)
82
+ const dropdownFooterLabel = useStore((s) => s.dropdownFooterLabel)
83
+ const dropdownTooltip = useStore((s) => s.dropdownTooltip)
81
84
  const hasToast = useStore((s) => s.toast !== null)
82
85
  const listContext = useContext(ListContext)
83
86
  const isShowingDetail = listContext?.isShowingDetail ?? false
84
87
  const hasDropdown = listContext?.hasDropdown ?? false
88
+ const isDropdownOpen = listContext?.isDropdownOpen ?? false
89
+ const openDropdownIfClosed = () => {
90
+ if (!isDropdownOpen) {
91
+ listContext?.openDropdown()
92
+ }
93
+ }
85
94
 
86
95
  const content = hasToast ? null : (
87
96
  <box style={{ flexDirection: 'row', gap: 3, flexShrink: 0 }}>
88
97
  {firstActionTitle && (
89
- <box style={{ flexDirection: 'row', gap: 1, flexShrink: 0 }}>
98
+ <Hoverable
99
+ onMouseDown={() => {
100
+ useStore.setState({ shouldAutoExecuteFirstAction: true })
101
+ }}
102
+ >
90
103
  <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
91
104
 
92
105
  </text>
93
106
  <text flexShrink={0} fg={theme.textMuted}>{firstActionTitle.toLowerCase()}</text>
94
- </box>
107
+ </Hoverable>
95
108
  )}
96
109
  <box style={{ flexDirection: 'row', gap: 1, flexShrink: 0 }}>
97
110
  <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
@@ -99,20 +112,26 @@ function ListFooter(): any {
99
112
  </text>
100
113
  <text flexShrink={0} fg={theme.textMuted}>navigate</text>
101
114
  </box>
115
+ <Hoverable
116
+ onMouseDown={() => {
117
+ useStore.setState({ showActionsDialog: true })
118
+ }}
119
+ >
120
+ <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
121
+ ^k
122
+ </text>
123
+ <text flexShrink={0} fg={theme.textMuted}>actions</text>
124
+ </Hoverable>
102
125
  {hasDropdown && (
103
- <box style={{ flexDirection: 'row', gap: 1, flexShrink: 0 }}>
126
+ <Hoverable onMouseDown={openDropdownIfClosed}>
104
127
  <text flexShrink={0} fg={theme.text} attributes={TextAttributes.BOLD}>
105
128
  ^p
106
129
  </text>
107
- <text flexShrink={0} fg={theme.textMuted}>dropdown</text>
108
- </box>
130
+ <text flexShrink={0} fg={theme.textMuted}>
131
+ {(dropdownTooltip || dropdownFooterLabel || 'dropdown').toLowerCase()}
132
+ </text>
133
+ </Hoverable>
109
134
  )}
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
135
  </box>
117
136
  )
118
137
 
@@ -337,6 +356,8 @@ export interface ListProps
337
356
  children?: ReactNode
338
357
  onSelectionChange?: (id: string | null) => void
339
358
  searchBarAccessory?: ReactElement<DropdownProps> | null
359
+ /** Custom ReactNode rendered on the right edge of the search bar row, after the dropdown if present. */
360
+ logo?: ReactNode
340
361
  searchText?: string
341
362
  enableFiltering?: boolean
342
363
  searchBarPlaceholder?: string
@@ -752,7 +773,23 @@ function ListItemRow(props: {
752
773
  const accessoryTagWidths = listCtx?.accessoryTagWidths
753
774
  const isRelaxed = spacingMode === 'relaxed'
754
775
  const { title, subtitle, icon, iconColor, accessories, active, ref } = props
776
+ const hoverBackgroundColor = getInteractiveHoverBackground(theme)
755
777
  const [isHovered, setIsHovered] = useState(false)
778
+ const handleMouseMove = () => {
779
+ setIsHovered(true)
780
+ // Select item on hover
781
+ if (!active && props.index !== undefined && props.index !== -1 && listCtx?.setSelectedIndex) {
782
+ listCtx.setSelectedIndex(props.index)
783
+ }
784
+ }
785
+
786
+ const handleMouseDown = () => {
787
+ props.onMouseDown?.()
788
+ }
789
+
790
+ const handleMouseOut = () => {
791
+ setIsHovered(false)
792
+ }
756
793
 
757
794
  const accessoryElements: ReactNode[] = []
758
795
  if (accessories) {
@@ -851,20 +888,16 @@ function ListItemRow(props: {
851
888
  backgroundColor: active
852
889
  ? theme.primary
853
890
  : isHovered
854
- ? theme.backgroundPanel
891
+ ? hoverBackgroundColor
855
892
  : undefined,
856
893
  paddingLeft: 0,
857
894
  paddingRight: 1,
858
895
  marginBottom: 1,
859
896
  }}
860
897
  border={false}
861
- onMouseMove={() => {
862
- setIsHovered(true)
863
- }}
864
- onMouseOut={() => {
865
- setIsHovered(false)
866
- }}
867
- onMouseDown={props.onMouseDown}
898
+ onMouseMove={handleMouseMove}
899
+ onMouseOut={handleMouseOut}
900
+ onMouseDown={handleMouseDown}
868
901
  >
869
902
  {/* Line 1: marker + icon + title + accessories */}
870
903
  <box style={{ flexDirection: 'row', justifyContent: 'space-between', gap: 1 }}>
@@ -920,20 +953,16 @@ function ListItemRow(props: {
920
953
  backgroundColor: active
921
954
  ? theme.primary
922
955
  : isHovered
923
- ? theme.backgroundPanel
956
+ ? hoverBackgroundColor
924
957
  : undefined,
925
958
  paddingLeft: 0,
926
959
  paddingRight: 1,
927
960
  gap: 1,
928
961
  }}
929
962
  border={false}
930
- onMouseMove={() => {
931
- setIsHovered(true)
932
- }}
933
- onMouseOut={() => {
934
- setIsHovered(false)
935
- }}
936
- onMouseDown={props.onMouseDown}
963
+ onMouseMove={handleMouseMove}
964
+ onMouseOut={handleMouseOut}
965
+ onMouseDown={handleMouseDown}
937
966
  >
938
967
  <box style={{ flexDirection: 'row', flexGrow: 1, flexShrink: 1, overflow: 'hidden', gap: 1 }}>
939
968
  <box style={{ flexDirection: 'row', flexShrink: 0 }}>
@@ -987,6 +1016,7 @@ export const List: ListType = (props) => {
987
1016
  isShowingDetail,
988
1017
  selectedItemId,
989
1018
  searchBarAccessory,
1019
+ logo,
990
1020
  spacingMode = 'default',
991
1021
  accessoryTagsLayout,
992
1022
  throttle,
@@ -1410,7 +1440,7 @@ export const List: ListType = (props) => {
1410
1440
  <box style={{ flexDirection: 'column', flexGrow: 1 }}>
1411
1441
  {/* Cannot mount focused actions here - would need to be handled differently */}
1412
1442
 
1413
- {navigationTitle && (
1443
+ {(navigationTitle || (logo && searchBarAccessory)) && (
1414
1444
  <box
1415
1445
  border={false}
1416
1446
  style={{
@@ -1419,12 +1449,25 @@ export const List: ListType = (props) => {
1419
1449
  paddingLeft: 1,
1420
1450
  paddingRight: 1,
1421
1451
  overflow: 'hidden',
1452
+ flexDirection: 'row',
1453
+ alignItems: 'center',
1422
1454
  }}
1423
1455
  >
1424
- <LoadingBar
1425
- title={navigationTitle}
1426
- isLoading={isLoading || navigationPending}
1427
- />
1456
+ {navigationTitle ? (
1457
+ <box flexGrow={1} flexShrink={1} overflow='hidden'>
1458
+ <LoadingBar
1459
+ title={navigationTitle}
1460
+ isLoading={isLoading || navigationPending}
1461
+ />
1462
+ </box>
1463
+ ) : (
1464
+ <box flexGrow={1} />
1465
+ )}
1466
+ {logo ? (
1467
+ <box flexShrink={0} paddingLeft={1}>
1468
+ {logo}
1469
+ </box>
1470
+ ) : null}
1428
1471
  </box>
1429
1472
  )}
1430
1473
 
@@ -1473,6 +1516,11 @@ export const List: ListType = (props) => {
1473
1516
  />
1474
1517
  </box>
1475
1518
  {searchBarAccessory}
1519
+ {!navigationTitle && !searchBarAccessory && logo ? (
1520
+ <box flexShrink={0} paddingLeft={2}>
1521
+ {logo}
1522
+ </box>
1523
+ ) : null}
1476
1524
  </box>
1477
1525
  </box>
1478
1526
 
@@ -1649,18 +1697,19 @@ const ListItem: ListItemType = (props) => {
1649
1697
  // Don't render if not visible
1650
1698
  if (!isVisible) return null
1651
1699
 
1652
- // Handle mouse click on item
1700
+ // Handle mouse click on item — always select and execute first action.
1701
+ // flushSync ensures React commits the new selectedIndex before Zustand
1702
+ // triggers auto-execute, so ActionPanel picks up the clicked item's actions.
1653
1703
  const handleMouseDown = () => {
1654
1704
  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)
1705
+ if (!isActive && listContext.setSelectedIndex) {
1706
+ const setIdx = listContext.setSelectedIndex
1707
+ flushSync(() => {
1708
+ setIdx(index)
1709
+ })
1710
+ }
1711
+ if (props.actions) {
1712
+ useStore.setState({ shouldAutoExecuteFirstAction: true })
1664
1713
  }
1665
1714
  }
1666
1715
  }
@@ -1706,17 +1755,7 @@ const ListItem: ListItemType = (props) => {
1706
1755
  )
1707
1756
  }
1708
1757
 
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
1758
 
1716
- return (
1717
- <markdown content={markdown} syntaxStyle={markdownSyntaxStyle} conceal renderNode={renderNode} />
1718
- )
1719
- }
1720
1759
 
1721
1760
  const ListItemDetail: ListItemDetailType = (props) => {
1722
1761
  const theme = useTheme()
@@ -1747,7 +1786,7 @@ const ListItemDetail: ListItemDetailType = (props) => {
1747
1786
  >
1748
1787
  <box gap={1} style={{ flexDirection: 'column' }}>
1749
1788
  {markdown && markdown.trim().length > 0 && (
1750
- <ListMarkdownContent markdown={markdown} />
1789
+ <Markdown content={markdown} />
1751
1790
  )}
1752
1791
  {metadata}
1753
1792
  </box>
@@ -1807,6 +1846,7 @@ ListItem.Detail = ListItemDetail
1807
1846
  const ListDropdown: ListDropdownType = (props) => {
1808
1847
  const theme = useTheme()
1809
1848
  const listContext = useContext(ListContext)
1849
+ const hoverBackgroundColor = getInteractiveHoverBackground(theme)
1810
1850
  const [isHovered, setIsHovered] = useState(false)
1811
1851
 
1812
1852
  // If not inside a List, just render nothing (for type safety)
@@ -1815,6 +1855,11 @@ const ListDropdown: ListDropdownType = (props) => {
1815
1855
  }
1816
1856
 
1817
1857
  const { isDropdownOpen, setIsDropdownOpen } = listContext
1858
+
1859
+ const setDropdownSelection = (props: { value: string; title: string }) => {
1860
+ setDropdownState({ value: props.value, title: props.title })
1861
+ useStore.setState({ dropdownFooterLabel: props.title || 'dropdown' })
1862
+ }
1818
1863
  // Store both value and title together
1819
1864
  const [dropdownState, setDropdownState] = useState<{
1820
1865
  value: string
@@ -1827,6 +1872,11 @@ const ListDropdown: ListDropdownType = (props) => {
1827
1872
  const dialog = useDialog()
1828
1873
  const inFocus = useIsInFocus()
1829
1874
 
1875
+ // Store dropdown tooltip in zustand for footer display
1876
+ useLayoutEffect(() => {
1877
+ useStore.setState({ dropdownTooltip: props.tooltip || '' })
1878
+ }, [props.tooltip])
1879
+
1830
1880
  // Update value and find its title
1831
1881
  useLayoutEffect(() => {
1832
1882
  const valueToUse =
@@ -1840,12 +1890,17 @@ const ListDropdown: ListDropdownType = (props) => {
1840
1890
 
1841
1891
  if (items.length > 0) {
1842
1892
  const firstItem = items[0].props as DropdownItemDescendant
1843
- setDropdownState({ value: firstItem.value, title: firstItem.title })
1893
+ setDropdownSelection({ value: firstItem.value, title: firstItem.title })
1844
1894
  return
1845
1895
  }
1846
1896
  }
1847
1897
 
1848
- if (!valueToUse) return
1898
+ if (!valueToUse) {
1899
+ useStore.setState({
1900
+ dropdownFooterLabel: dropdownState.title || 'dropdown',
1901
+ })
1902
+ return
1903
+ }
1849
1904
 
1850
1905
  // Try to find the title for this value
1851
1906
  let title = valueToUse
@@ -1859,8 +1914,11 @@ const ListDropdown: ListDropdownType = (props) => {
1859
1914
 
1860
1915
  // Only update if something changed
1861
1916
  if (dropdownState.value !== valueToUse || dropdownState.title !== title) {
1862
- setDropdownState({ value: valueToUse, title })
1917
+ setDropdownSelection({ value: valueToUse, title })
1918
+ return
1863
1919
  }
1920
+
1921
+ useStore.setState({ dropdownFooterLabel: title || 'dropdown' })
1864
1922
  }, [props.value]) // Run when props.value changes and on mount
1865
1923
 
1866
1924
  const dropdownContextValue = useMemo<DropdownContextValue>(
@@ -1889,7 +1947,7 @@ const ListDropdown: ListDropdownType = (props) => {
1889
1947
  break
1890
1948
  }
1891
1949
  }
1892
- setDropdownState({ value: newValue, title })
1950
+ setDropdownSelection({ value: newValue, title })
1893
1951
  setIsDropdownOpen(false)
1894
1952
  dialog.clear()
1895
1953
  if (props.onChange) {
@@ -1912,6 +1970,11 @@ const ListDropdown: ListDropdownType = (props) => {
1912
1970
 
1913
1971
  // Display the title from our state
1914
1972
  const displayValue = dropdownState.title || 'All'
1973
+ const openDropdownIfClosed = () => {
1974
+ if (!isDropdownOpen) {
1975
+ listContext.openDropdown()
1976
+ }
1977
+ }
1915
1978
 
1916
1979
  return (
1917
1980
  <DropdownDescendantsProvider value={descendantsContext}>
@@ -1926,27 +1989,25 @@ const ListDropdown: ListDropdownType = (props) => {
1926
1989
  // minWidth: value.length + 4,
1927
1990
  flexDirection: 'row',
1928
1991
  flexShrink: 0,
1929
- backgroundColor: isHovered ? theme.backgroundPanel : undefined,
1992
+ backgroundColor: isHovered ? hoverBackgroundColor : undefined,
1930
1993
  }}
1931
1994
  onMouseMove={() => setIsHovered(true)}
1932
1995
  onMouseOut={() => setIsHovered(false)}
1933
- onMouseDown={() => {
1934
- // Open dropdown when clicked
1935
- if (!isDropdownOpen) {
1936
- listContext.openDropdown()
1937
- }
1938
- }}
1996
+ onMouseDown={openDropdownIfClosed}
1939
1997
  >
1940
1998
  {/*<text >^p </text>*/}
1941
1999
  {listContext.isLoading ? (
1942
- <LoadingText isLoading color={isHovered ? theme.text : theme.textMuted}>
1943
- {displayValue || 'Loading...'}
1944
- </LoadingText>
2000
+ <box onMouseDown={openDropdownIfClosed}>
2001
+ <LoadingText isLoading color={isHovered ? theme.text : theme.textMuted}>
2002
+ {displayValue || 'Loading...'}
2003
+ </LoadingText>
2004
+ </box>
1945
2005
  ) : (
1946
2006
  <text
1947
2007
  flexShrink={0}
1948
2008
  fg={isHovered ? theme.text : theme.textMuted}
1949
2009
  selectable={false}
2010
+ onMouseDown={openDropdownIfClosed}
1950
2011
  >
1951
2012
  {displayValue}
1952
2013
  </text>
@@ -1955,6 +2016,7 @@ const ListDropdown: ListDropdownType = (props) => {
1955
2016
  flexShrink={0}
1956
2017
  fg={isHovered ? theme.text : theme.textMuted}
1957
2018
  selectable={false}
2019
+ onMouseDown={openDropdownIfClosed}
1958
2020
  >
1959
2021
  {' '}
1960
2022
 
@@ -1967,6 +2029,7 @@ const ListDropdown: ListDropdownType = (props) => {
1967
2029
 
1968
2030
  ListDropdown.Item = (props) => {
1969
2031
  const theme = useTheme()
2032
+ const hoverBackgroundColor = getInteractiveHoverBackground(theme)
1970
2033
  const dropdownContext = useContext(DropdownContext)
1971
2034
  const [isHovered, setIsHovered] = useState(false)
1972
2035
 
@@ -2032,7 +2095,7 @@ ListDropdown.Item = (props) => {
2032
2095
  backgroundColor: isActive
2033
2096
  ? theme.primary
2034
2097
  : isHovered
2035
- ? theme.backgroundPanel
2098
+ ? hoverBackgroundColor
2036
2099
  : undefined,
2037
2100
  paddingLeft: isActive ? 0 : 1,
2038
2101
  paddingRight: 1,
@@ -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 }