termcast 1.4.1 → 1.6.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 (180) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/build.js +30 -12
  3. package/dist/build.js.map +1 -1
  4. package/dist/cli.js +0 -40
  5. package/dist/cli.js.map +1 -1
  6. package/dist/compile.d.ts.map +1 -1
  7. package/dist/compile.js +7 -1
  8. package/dist/compile.js.map +1 -1
  9. package/dist/components/bar-graph.d.ts +23 -8
  10. package/dist/components/bar-graph.d.ts.map +1 -1
  11. package/dist/components/bar-graph.js +84 -40
  12. package/dist/components/bar-graph.js.map +1 -1
  13. package/dist/components/dotted-line-graph.d.ts +86 -0
  14. package/dist/components/dotted-line-graph.d.ts.map +1 -0
  15. package/dist/components/dotted-line-graph.js +260 -0
  16. package/dist/components/dotted-line-graph.js.map +1 -0
  17. package/dist/components/extension-preferences.d.ts.map +1 -1
  18. package/dist/components/extension-preferences.js +1 -10
  19. package/dist/components/extension-preferences.js.map +1 -1
  20. package/dist/components/graph.d.ts.map +1 -1
  21. package/dist/components/graph.js +7 -1
  22. package/dist/components/graph.js.map +1 -1
  23. package/dist/components/histogram.d.ts +42 -0
  24. package/dist/components/histogram.d.ts.map +1 -0
  25. package/dist/components/histogram.js +115 -0
  26. package/dist/components/histogram.js.map +1 -0
  27. package/dist/components/horizontal-bar-graph.d.ts +47 -0
  28. package/dist/components/horizontal-bar-graph.d.ts.map +1 -0
  29. package/dist/components/horizontal-bar-graph.js +137 -0
  30. package/dist/components/horizontal-bar-graph.js.map +1 -0
  31. package/dist/components/list.d.ts +9 -0
  32. package/dist/components/list.d.ts.map +1 -1
  33. package/dist/components/list.js +84 -21
  34. package/dist/components/list.js.map +1 -1
  35. package/dist/examples/bar-graph-weekly.js +2 -2
  36. package/dist/examples/bar-graph-weekly.js.map +1 -1
  37. package/dist/examples/charts-showcase-barchart.d.ts +2 -0
  38. package/dist/examples/charts-showcase-barchart.d.ts.map +1 -0
  39. package/dist/examples/charts-showcase-barchart.js +10 -0
  40. package/dist/examples/charts-showcase-barchart.js.map +1 -0
  41. package/dist/examples/charts-showcase-bargraph.d.ts +2 -0
  42. package/dist/examples/charts-showcase-bargraph.d.ts.map +1 -0
  43. package/dist/examples/charts-showcase-bargraph.js +60 -0
  44. package/dist/examples/charts-showcase-bargraph.js.map +1 -0
  45. package/dist/examples/charts-showcase-candle.d.ts +2 -0
  46. package/dist/examples/charts-showcase-candle.d.ts.map +1 -0
  47. package/dist/examples/charts-showcase-candle.js +30 -0
  48. package/dist/examples/charts-showcase-candle.js.map +1 -0
  49. package/dist/examples/charts-showcase-graph.d.ts +2 -0
  50. package/dist/examples/charts-showcase-graph.d.ts.map +1 -0
  51. package/dist/examples/charts-showcase-graph.js +33 -0
  52. package/dist/examples/charts-showcase-graph.js.map +1 -0
  53. package/dist/examples/charts-showcase-heatmap.d.ts +2 -0
  54. package/dist/examples/charts-showcase-heatmap.d.ts.map +1 -0
  55. package/dist/examples/charts-showcase-heatmap.js +36 -0
  56. package/dist/examples/charts-showcase-heatmap.js.map +1 -0
  57. package/dist/examples/charts-showcase-mixed.d.ts +2 -0
  58. package/dist/examples/charts-showcase-mixed.d.ts.map +1 -0
  59. package/dist/examples/charts-showcase-mixed.js +30 -0
  60. package/dist/examples/charts-showcase-mixed.js.map +1 -0
  61. package/dist/examples/charts-showcase-progress.d.ts +2 -0
  62. package/dist/examples/charts-showcase-progress.d.ts.map +1 -0
  63. package/dist/examples/charts-showcase-progress.js +10 -0
  64. package/dist/examples/charts-showcase-progress.js.map +1 -0
  65. package/dist/examples/graph-multi-series.js +1 -1
  66. package/dist/examples/graph-multi-series.js.map +1 -1
  67. package/dist/examples/horizontal-bar-graph-weekly.d.ts +2 -0
  68. package/dist/examples/horizontal-bar-graph-weekly.d.ts.map +1 -0
  69. package/dist/examples/horizontal-bar-graph-weekly.js +67 -0
  70. package/dist/examples/horizontal-bar-graph-weekly.js.map +1 -0
  71. package/dist/examples/list-detail-height-ratchet.d.ts +2 -0
  72. package/dist/examples/list-detail-height-ratchet.d.ts.map +1 -0
  73. package/dist/examples/list-detail-height-ratchet.js +26 -0
  74. package/dist/examples/list-detail-height-ratchet.js.map +1 -0
  75. package/dist/examples/simple-dotted-line-graph.d.ts +2 -0
  76. package/dist/examples/simple-dotted-line-graph.d.ts.map +1 -0
  77. package/dist/examples/simple-dotted-line-graph.js +39 -0
  78. package/dist/examples/simple-dotted-line-graph.js.map +1 -0
  79. package/dist/examples/simple-histogram.d.ts +2 -0
  80. package/dist/examples/simple-histogram.d.ts.map +1 -0
  81. package/dist/examples/simple-histogram.js +47 -0
  82. package/dist/examples/simple-histogram.js.map +1 -0
  83. package/dist/extensions/dev.d.ts.map +1 -1
  84. package/dist/extensions/dev.js +1 -0
  85. package/dist/extensions/dev.js.map +1 -1
  86. package/dist/globals.js +8 -0
  87. package/dist/globals.js.map +1 -1
  88. package/dist/index.d.ts +6 -0
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +6 -0
  91. package/dist/index.js.map +1 -1
  92. package/dist/package-json.d.ts +2 -0
  93. package/dist/package-json.d.ts.map +1 -1
  94. package/dist/package-json.js +20 -17
  95. package/dist/package-json.js.map +1 -1
  96. package/dist/platform/node/sqlite.d.ts +6 -5
  97. package/dist/platform/node/sqlite.d.ts.map +1 -1
  98. package/dist/platform/node/sqlite.js +30 -14
  99. package/dist/platform/node/sqlite.js.map +1 -1
  100. package/dist/profiler.d.ts +2 -0
  101. package/dist/profiler.d.ts.map +1 -0
  102. package/dist/profiler.js +390 -0
  103. package/dist/profiler.js.map +1 -0
  104. package/dist/theme.d.ts.map +1 -1
  105. package/dist/theme.js +11 -9
  106. package/dist/theme.js.map +1 -1
  107. package/dist/utils/run-command.d.ts.map +1 -1
  108. package/dist/utils/run-command.js +8 -19
  109. package/dist/utils/run-command.js.map +1 -1
  110. package/dist/utils.d.ts +1 -19
  111. package/dist/utils.d.ts.map +1 -1
  112. package/dist/utils.js +1 -100
  113. package/dist/utils.js.map +1 -1
  114. package/package.json +18 -21
  115. package/src/build.tsx +38 -15
  116. package/src/cli.tsx +3 -40
  117. package/src/compile.tsx +9 -1
  118. package/src/compile.vitest.tsx +8 -8
  119. package/src/components/bar-graph.tsx +217 -111
  120. package/src/components/dotted-line-graph.tsx +407 -0
  121. package/src/components/extension-preferences.tsx +2 -12
  122. package/src/components/graph.tsx +5 -1
  123. package/src/components/histogram.tsx +228 -0
  124. package/src/components/horizontal-bar-graph.tsx +279 -0
  125. package/src/components/list.tsx +112 -26
  126. package/src/examples/action-shortcut.vitest.tsx +20 -20
  127. package/src/examples/actions-context.vitest.tsx +2 -2
  128. package/src/examples/bar-graph-weekly.tsx +2 -2
  129. package/src/examples/bar-graph-weekly.vitest.tsx +103 -102
  130. package/src/examples/charts-showcase-bargraph.tsx +103 -0
  131. package/src/examples/detail-metadata-showcase.vitest.tsx +12 -12
  132. package/src/examples/form-basic.vitest.tsx +11 -11
  133. package/src/examples/form-dropdown.vitest.tsx +11 -11
  134. package/src/examples/form-scroll.vitest.tsx +1 -1
  135. package/src/examples/form-tagpicker.vitest.tsx +11 -11
  136. package/src/examples/github.vitest.tsx +22 -31
  137. package/src/examples/graph-bar-chart.vitest.tsx +36 -36
  138. package/src/examples/graph-multi-series.tsx +1 -1
  139. package/src/examples/graph-polymarket.vitest.tsx +24 -24
  140. package/src/examples/graph-row.vitest.tsx +14 -14
  141. package/src/examples/graph-styles.vitest.tsx +77 -77
  142. package/src/examples/horizontal-bar-graph-weekly.tsx +138 -0
  143. package/src/examples/horizontal-bar-graph-weekly.vitest.tsx +164 -0
  144. package/src/examples/list-detail-height-ratchet.tsx +48 -0
  145. package/src/examples/list-detail-height-ratchet.vitest.tsx +161 -0
  146. package/src/examples/list-detail-metadata.vitest.tsx +51 -51
  147. package/src/examples/list-dropdown-default.vitest.tsx +27 -27
  148. package/src/examples/list-fetch-data.vitest.tsx +3 -3
  149. package/src/examples/list-loading-empty-view.vitest.tsx +1 -1
  150. package/src/examples/list-no-actions.vitest.tsx +3 -3
  151. package/src/examples/list-scrollbox.vitest.tsx +6 -6
  152. package/src/examples/list-spacing-mode.vitest.tsx +1 -1
  153. package/src/examples/list-with-detail.vitest.tsx +55 -55
  154. package/src/examples/list-with-dropdown.vitest.tsx +6 -6
  155. package/src/examples/list-with-sections.vitest.tsx +20 -20
  156. package/src/examples/list-with-toast.vitest.tsx +4 -4
  157. package/src/examples/simple-candle-chart.vitest.tsx +61 -59
  158. package/src/examples/simple-dotted-line-graph.tsx +53 -0
  159. package/src/examples/simple-dotted-line-graph.vitest.tsx +62 -0
  160. package/src/examples/simple-grid.vitest.tsx +4 -4
  161. package/src/examples/simple-heatmap.vitest.tsx +9 -9
  162. package/src/examples/simple-histogram.tsx +90 -0
  163. package/src/examples/simple-navigation.vitest.tsx +25 -25
  164. package/src/examples/simple-progress-bar.vitest.tsx +7 -7
  165. package/src/examples/swift-extension.vitest.tsx +5 -5
  166. package/src/examples/toast-action.vitest.tsx +4 -4
  167. package/src/extensions/dev.tsx +2 -1
  168. package/src/extensions/dev.vitest.tsx +17 -17
  169. package/src/globals.ts +9 -0
  170. package/src/index.tsx +21 -0
  171. package/src/package-json.tsx +24 -23
  172. package/src/platform/node/sqlite.ts +29 -13
  173. package/src/profiler.tsx +487 -0
  174. package/src/theme.tsx +11 -10
  175. package/src/utils/run-command.tsx +10 -19
  176. package/src/utils.tsx +0 -163
  177. package/src/examples/store.tsx +0 -4
  178. package/src/examples/store.vitest.tsx +0 -78
  179. package/src/extensions/home.tsx +0 -227
  180. package/src/extensions/store.tsx +0 -375
@@ -0,0 +1,279 @@
1
+ /**
2
+ * HorizontalBarGraph renders multi-series horizontal stacked bars.
3
+ *
4
+ * Each row is one category, usually a time bucket. The left chart area grows to
5
+ * fill available space while the right legend uses only the width needed for
6
+ * colored series rows and percentages.
7
+ */
8
+
9
+ import React, { ReactNode, useMemo } from 'react'
10
+ import { BoxProps } from '@opentui/react'
11
+ import { Color, resolveColor } from 'termcast/src/colors'
12
+ import { getThemePalette, useTheme } from 'termcast/src/theme'
13
+
14
+ export interface HorizontalBarGraphSeriesProps {
15
+ /** One value per row/category position. */
16
+ data: number[]
17
+ /** Series label shown in the legend. */
18
+ title?: string
19
+ /** Override the auto-assigned color. */
20
+ color?: Color.ColorLike
21
+ }
22
+
23
+ export interface HorizontalBarGraphProps extends BoxProps {
24
+ /** Row labels, one per bar position. Usually time buckets. */
25
+ labels?: string[]
26
+ /** Maximum chart rows to render. Defaults to all rows. */
27
+ height?: number
28
+ /** Character used for bar cells. Matches Histogram by default. */
29
+ barCharacter?: string
30
+ /** Show header row. Default true. */
31
+ showHeader?: boolean
32
+ /** Show right-side legend. Default true when any series has a title. */
33
+ showLegend?: boolean
34
+ /** Max display width for labels; longer labels are truncated. Default 16. */
35
+ maxLabelWidth?: number
36
+ /** Header text for the category column. Default "category". */
37
+ categoryTitle?: string
38
+ /** Header text for the bar column. Default "distribution". */
39
+ distributionTitle?: string
40
+ /** Header text for the legend column. Default "legend". */
41
+ legendTitle?: string
42
+ /** HorizontalBarGraph.Series children. */
43
+ children: ReactNode
44
+ }
45
+
46
+ interface HorizontalBarGraphType {
47
+ (props: HorizontalBarGraphProps): any
48
+ Series: (props: HorizontalBarGraphSeriesProps) => any
49
+ }
50
+
51
+ function truncateLabel(label: string, maxLabelWidth: number): string {
52
+ if (label.length <= maxLabelWidth) {
53
+ return label
54
+ }
55
+ return label.slice(0, maxLabelWidth - 1) + '…'
56
+ }
57
+
58
+ function padRight(value: string, width: number): string {
59
+ if (value.length >= width) {
60
+ return value
61
+ }
62
+ return value + ' '.repeat(width - value.length)
63
+ }
64
+
65
+ function padLeft(value: string, width: number): string {
66
+ if (value.length >= width) {
67
+ return value
68
+ }
69
+ return ' '.repeat(width - value.length) + value
70
+ }
71
+
72
+ function formatPercentage(value: number, total: number): string {
73
+ if (total <= 0) {
74
+ return '0%'
75
+ }
76
+ return `${Math.round((value / total) * 100)}%`
77
+ }
78
+
79
+ const HorizontalBarGraphSeries = (_props: HorizontalBarGraphSeriesProps): any => {
80
+ return null
81
+ }
82
+
83
+ const HorizontalBarGraph: HorizontalBarGraphType = (props) => {
84
+ const theme = useTheme()
85
+ const {
86
+ labels = [],
87
+ height,
88
+ barCharacter = '╻',
89
+ showHeader = true,
90
+ showLegend,
91
+ maxLabelWidth = 16,
92
+ categoryTitle = 'category',
93
+ distributionTitle = 'distribution',
94
+ legendTitle = 'legend',
95
+ children,
96
+ ...rest
97
+ } = props
98
+
99
+ const palette = getThemePalette(theme)
100
+
101
+ const seriesList = useMemo<Array<{ data: number[]; color: string; title?: string }>>(() => {
102
+ const childArray = React.Children.toArray(children)
103
+ return childArray
104
+ .filter(React.isValidElement)
105
+ .map((child, index) => {
106
+ const childProps = child.props as HorizontalBarGraphSeriesProps
107
+ return {
108
+ data: childProps.data,
109
+ title: childProps.title,
110
+ color: resolveColor(childProps.color) || palette[index % palette.length]!,
111
+ }
112
+ })
113
+ .filter((series) => {
114
+ return Array.isArray(series.data)
115
+ })
116
+ }, [children, palette])
117
+
118
+ const rowCount = useMemo(() => {
119
+ return Math.max(labels.length, ...seriesList.map((series) => series.data.length), 0)
120
+ }, [labels, seriesList])
121
+
122
+ const rows = useMemo<Array<{ label: string; total: number; values: number[] }>>(() => {
123
+ return Array.from({ length: rowCount }, (_, rowIndex) => {
124
+ const values = seriesList.map((series) => {
125
+ return series.data[rowIndex] || 0
126
+ })
127
+ const total = values.reduce((sum, value) => {
128
+ return sum + value
129
+ }, 0)
130
+ return {
131
+ label: labels[rowIndex] ?? String(rowIndex + 1),
132
+ total,
133
+ values,
134
+ }
135
+ })
136
+ }, [labels, rowCount, seriesList])
137
+
138
+ const visibleRows = useMemo(() => {
139
+ return rows.slice(0, height ?? rows.length)
140
+ }, [height, rows])
141
+
142
+ const maxTotal = useMemo(() => {
143
+ return Math.max(0, ...rows.map((row) => row.total))
144
+ }, [rows])
145
+
146
+ const legendRows = useMemo<Array<{ title: string; color: string; percentage: string; total: number }>>(() => {
147
+ const grandTotal = rows.reduce((sum, row) => {
148
+ return sum + row.total
149
+ }, 0)
150
+ return seriesList
151
+ .map((series, seriesIndex) => {
152
+ const seriesTotal = series.data.reduce((sum, value) => {
153
+ return sum + value
154
+ }, 0)
155
+ return {
156
+ title: series.title ?? `Series ${seriesIndex + 1}`,
157
+ color: series.color,
158
+ percentage: formatPercentage(seriesTotal, grandTotal),
159
+ total: seriesTotal,
160
+ }
161
+ })
162
+ .filter((row) => {
163
+ return row.title.length > 0
164
+ })
165
+ .sort((a, b) => {
166
+ return b.total - a.total
167
+ })
168
+ }, [rows, seriesList])
169
+
170
+ const legendVisible = showLegend ?? seriesList.some((series) => {
171
+ return Boolean(series.title)
172
+ })
173
+
174
+ if (seriesList.length === 0 || visibleRows.length === 0 || maxTotal === 0) {
175
+ return null
176
+ }
177
+
178
+ const displayLabels = visibleRows.map((row) => {
179
+ return truncateLabel(row.label, maxLabelWidth)
180
+ })
181
+ const displayCategoryTitle = truncateLabel(categoryTitle, maxLabelWidth)
182
+ const labelWidth = Math.max(5, displayCategoryTitle.length, ...displayLabels.map((label) => label.length))
183
+
184
+ const legendGap = 2
185
+ const legendTitleWidth = Math.max(6, ...legendRows.map((row) => row.title.length))
186
+ const legendPercentageWidth = Math.max(3, ...legendRows.map((row) => row.percentage.length))
187
+ const legendWidth = legendVisible ? legendGap + 2 + legendTitleWidth + 2 + legendPercentageWidth : 0
188
+ const headerHeight = showHeader ? 2 : 0
189
+ const chartHeight = headerHeight + visibleRows.length
190
+
191
+ return (
192
+ <box flexDirection="column" width="100%" flexShrink={0} {...rest}>
193
+ {showHeader && (
194
+ <>
195
+ <box flexDirection="row" height={1} flexShrink={0}>
196
+ <box width={labelWidth + 2} flexShrink={0} overflow="hidden">
197
+ <text fg={theme.textMuted} wrapMode="none">{padRight(displayCategoryTitle, labelWidth)} </text>
198
+ </box>
199
+ <box flexGrow={1} flexShrink={1} overflow="hidden">
200
+ <text fg={theme.textMuted} wrapMode="none">{distributionTitle}</text>
201
+ </box>
202
+ {legendVisible && (
203
+ <box width={legendWidth} flexShrink={0} overflow="hidden">
204
+ <text fg={theme.textMuted} wrapMode="none">{' '.repeat(legendGap)}{legendTitle}</text>
205
+ </box>
206
+ )}
207
+ </box>
208
+ <box flexDirection="row" height={1} flexShrink={0}>
209
+ <box width={labelWidth + 2} flexShrink={0} overflow="hidden">
210
+ <text fg={theme.borderSubtle} wrapMode="none">{'─'.repeat(labelWidth)} </text>
211
+ </box>
212
+ <box flexGrow={1} flexShrink={1} overflow="hidden">
213
+ <text fg={theme.borderSubtle} wrapMode="none">{'─'.repeat(200)}</text>
214
+ </box>
215
+ {legendVisible && (
216
+ <box width={legendWidth} flexShrink={0} overflow="hidden">
217
+ <text fg={theme.borderSubtle} wrapMode="none">{' '.repeat(legendGap)}{'─'.repeat(legendWidth - legendGap)}</text>
218
+ </box>
219
+ )}
220
+ </box>
221
+ </>
222
+ )}
223
+
224
+ <box flexDirection="row" height={chartHeight - headerHeight} flexShrink={0}>
225
+ <box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
226
+ {visibleRows.map((row, rowIndex) => {
227
+ return (
228
+ <box key={row.label} flexDirection="row" height={1} flexShrink={0} overflow="hidden">
229
+ <box width={labelWidth + 2} flexShrink={0} overflow="hidden">
230
+ <text fg={theme.text} wrapMode="none">
231
+ {padRight(displayLabels[rowIndex]!, labelWidth)}
232
+ </text>
233
+ </box>
234
+ <box flexDirection="row" flexGrow={1} flexShrink={1} overflow="hidden">
235
+ {row.values.map((value, seriesIndex) => {
236
+ if (value <= 0) {
237
+ return null
238
+ }
239
+ const series = seriesList[seriesIndex]!
240
+ return (
241
+ <box key={seriesIndex} flexGrow={value} flexBasis={0} flexShrink={1} overflow="hidden">
242
+ <box position="absolute" width="100%" height="100%" overflow="hidden">
243
+ <text fg={series.color} wrapMode="none">{barCharacter.repeat(200)}</text>
244
+ </box>
245
+ </box>
246
+ )
247
+ })}
248
+ {maxTotal > row.total && (
249
+ <box flexGrow={maxTotal - row.total} flexBasis={0} flexShrink={1} />
250
+ )}
251
+ </box>
252
+ </box>
253
+ )
254
+ })}
255
+ </box>
256
+
257
+ {legendVisible && (
258
+ <box flexDirection="column" width={legendWidth} flexShrink={0} overflow="hidden">
259
+ {legendRows.map((row) => {
260
+ return (
261
+ <box key={row.title} height={1} flexShrink={0} flexDirection="row" overflow="hidden">
262
+ <box width={legendGap} flexShrink={0} />
263
+ <text fg={row.color} wrapMode="none">● </text>
264
+ <text fg={theme.textMuted} wrapMode="none">
265
+ {padRight(row.title, legendTitleWidth)} {padLeft(row.percentage, legendPercentageWidth)}
266
+ </text>
267
+ </box>
268
+ )
269
+ })}
270
+ </box>
271
+ )}
272
+ </box>
273
+ </box>
274
+ )
275
+ }
276
+
277
+ HorizontalBarGraph.Series = HorizontalBarGraphSeries
278
+
279
+ export { HorizontalBarGraph }
@@ -6,7 +6,7 @@ import {
6
6
  TextareaRenderable,
7
7
  } from '@opentui/core'
8
8
  import type { MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
9
- import { useKeyboard, flushSync } from '@opentui/react'
9
+ import { useKeyboard, flushSync, useTerminalDimensions } from '@opentui/react'
10
10
  import React, {
11
11
  ReactElement,
12
12
  ReactNode,
@@ -225,8 +225,17 @@ function CurrentItemDetail(props: {
225
225
  }): any {
226
226
  const theme = useTheme()
227
227
  const descendantsMap = useListDescendantsRerender()
228
-
229
- if (!props.isShowingDetail) return null
228
+ const boxRef = React.useRef<BoxRenderable>(null)
229
+ // Grow-only height ratchet: once the detail panel reaches a certain height,
230
+ // it never shrinks below that. This prevents the footer from jumping up
231
+ // when navigating from a tall detail to a short one.
232
+ const maxHeightRef = React.useRef(0)
233
+
234
+ if (!props.isShowingDetail) {
235
+ // Reset ratchet when detail is hidden so next show starts fresh
236
+ maxHeightRef.current = 0
237
+ return null
238
+ }
230
239
 
231
240
  const currentItem = Object.values(descendantsMap)
232
241
  .find((item) => item.index === props.selectedIndex)
@@ -236,6 +245,14 @@ function CurrentItemDetail(props: {
236
245
 
237
246
  return (
238
247
  <box
248
+ ref={boxRef}
249
+ minHeight={maxHeightRef.current || undefined}
250
+ onSizeChange={() => {
251
+ const h = boxRef.current?.height ?? 0
252
+ if (h > maxHeightRef.current) {
253
+ maxHeightRef.current = h
254
+ }
255
+ }}
239
256
  style={{
240
257
  width: '50%',
241
258
  paddingLeft: 1,
@@ -374,6 +391,8 @@ export interface DropdownProps extends SearchBarInterface, CommonProps {
374
391
  storeValue?: boolean
375
392
  value?: string
376
393
  defaultValue?: string
394
+ /** Override the text shown in the search bar for the active selection. When set, this is displayed instead of the selected item's title. Useful for managed state where the display label doesn't match any dropdown item. */
395
+ displayValue?: string
377
396
  onChange?: (newValue: string) => void
378
397
  children?: ReactNode
379
398
  }
@@ -404,6 +423,13 @@ export interface ListProps
404
423
  searchBarPlaceholder?: string
405
424
  selectedItemId?: string
406
425
  isShowingDetail?: boolean
426
+ /**
427
+ * Minimum terminal width in columns required to show the detail panel.
428
+ * When the terminal is narrower than this value, the detail panel is
429
+ * automatically hidden even if `isShowingDetail` is true.
430
+ * @default 80
431
+ */
432
+ detailMinWidth?: number
407
433
  /**
408
434
  * Controls the vertical spacing of list items.
409
435
  * - 'default': Single-line items with title and subtitle on same row
@@ -946,9 +972,9 @@ function ListItemRow(props: {
946
972
  <text flexShrink={0} fg={active ? theme.background : theme.text} attributes={active ? TextAttributes.BOLD : undefined} selectable={false} wrapMode="none">{active ? '›' : ' '}</text>
947
973
  {icon && <text flexShrink={0} fg={active ? theme.background : iconColor || theme.text} selectable={false} wrapMode="none">{getIconEmoji(icon)} </text>}
948
974
  <text
949
- flexShrink={0}
975
+ flexShrink={1}
976
+ truncate
950
977
  fg={active ? theme.background : theme.text}
951
-
952
978
  attributes={TextAttributes.BOLD}
953
979
  selectable={false}
954
980
  wrapMode="none"
@@ -969,9 +995,10 @@ function ListItemRow(props: {
969
995
  </box>
970
996
  {/* Line 2: subtitle indented to align with title */}
971
997
  {subtitle && (
972
- <box style={{ paddingLeft: subtitleIndent }}>
998
+ <box style={{ paddingLeft: subtitleIndent, overflow: 'hidden' }}>
973
999
  <text
974
- flexShrink={0}
1000
+ flexShrink={1}
1001
+ truncate
975
1002
  fg={active ? theme.background : theme.text}
976
1003
  selectable={false}
977
1004
  wrapMode="none"
@@ -1006,11 +1033,12 @@ function ListItemRow(props: {
1006
1033
  onMouseDown={handleMouseDown}
1007
1034
  >
1008
1035
  <box style={{ flexDirection: 'row', flexGrow: 1, flexShrink: 1, overflow: 'hidden', gap: 1 }}>
1009
- <box style={{ flexDirection: 'row', flexShrink: 0 }}>
1036
+ <box style={{ flexDirection: 'row', flexShrink: 1, overflow: 'hidden' }}>
1010
1037
  <text flexShrink={0} fg={active ? theme.background : theme.text} attributes={active ? TextAttributes.BOLD : undefined} selectable={false} wrapMode="none">{active ? '›' : ' '}</text>
1011
1038
  {icon && <text flexShrink={0} fg={active ? theme.background : iconColor || theme.text} selectable={false} wrapMode="none">{getIconEmoji(icon)} </text>}
1012
1039
  <text
1013
- flexShrink={0}
1040
+ flexShrink={1}
1041
+ truncate
1014
1042
  fg={active ? theme.background : theme.text}
1015
1043
  attributes={active ? TextAttributes.BOLD : undefined}
1016
1044
  selectable={false}
@@ -1021,7 +1049,8 @@ function ListItemRow(props: {
1021
1049
  </box>
1022
1050
  {subtitle && (
1023
1051
  <text
1024
- flexShrink={0}
1052
+ flexShrink={3}
1053
+ truncate
1025
1054
  fg={active ? theme.background : theme.textMuted}
1026
1055
  selectable={false}
1027
1056
  wrapMode="none"
@@ -1055,6 +1084,7 @@ export const List: ListType = (props) => {
1055
1084
  isLoading,
1056
1085
  navigationTitle,
1057
1086
  isShowingDetail,
1087
+ detailMinWidth = 80,
1058
1088
  selectedItemId,
1059
1089
  searchBarAccessory,
1060
1090
  logo,
@@ -1065,6 +1095,9 @@ export const List: ListType = (props) => {
1065
1095
  } = props
1066
1096
 
1067
1097
  const theme = useTheme()
1098
+ const { width: terminalWidth } = useTerminalDimensions()
1099
+ const effectiveIsShowingDetail = isShowingDetail && terminalWidth >= detailMinWidth
1100
+
1068
1101
  const currentStackSelectedListIndex = useStore((state) => {
1069
1102
  const stack = state.navigationStack
1070
1103
  const currentItem = stack[stack.length - 1]
@@ -1257,14 +1290,14 @@ export const List: ListType = (props) => {
1257
1290
  setSelectedIndex: setSelectedIndexWithPersistence,
1258
1291
  searchText,
1259
1292
  isFiltering: isFilteringEnabled,
1260
- isShowingDetail,
1293
+ isShowingDetail: effectiveIsShowingDetail,
1261
1294
  customEmptyViewRef,
1262
1295
  isLoading,
1263
1296
  hasDropdown: !!searchBarAccessory,
1264
1297
  spacingMode,
1265
1298
  accessoryTagWidths: accessoryTagsLayout,
1266
1299
  }),
1267
- [isDropdownOpen, selectedIndex, searchText, isFilteringEnabled, isShowingDetail, isLoading, searchBarAccessory, spacingMode, accessoryTagsLayout],
1300
+ [isDropdownOpen, selectedIndex, searchText, isFilteringEnabled, effectiveIsShowingDetail, isLoading, searchBarAccessory, spacingMode, accessoryTagsLayout],
1268
1301
  )
1269
1302
 
1270
1303
  // Handle selectedItemId prop changes (before paint to avoid flash)
@@ -1351,6 +1384,46 @@ export const List: ListType = (props) => {
1351
1384
  }
1352
1385
  }
1353
1386
 
1387
+ // Trigger pagination when the user mouse-scrolls near the bottom of the list.
1388
+ // Only fires on scroll-down so scrolling up near the bottom doesn't spuriously
1389
+ // re-trigger onLoadMore. Uses queueMicrotask because opentui calls onMouseScroll
1390
+ // before ScrollBox.onMouseEvent updates scrollTop in the same call stack;
1391
+ // a microtask runs after the synchronous handler chain finishes.
1392
+ const checkScrollPagination = (event: OpenTUIMouseEvent) => {
1393
+ if (event.scroll?.direction !== 'down') return
1394
+
1395
+ queueMicrotask(() => {
1396
+ const scrollBox = scrollBoxRef.current
1397
+ if (!scrollBox || !props.pagination?.hasMore) return
1398
+
1399
+ // Reset pagination lock when new items arrive (same logic as in move())
1400
+ const items = Object.values(descendantsContext.map.current)
1401
+ .filter((item) => item.index !== -1 && item.props?.visible !== false)
1402
+ if (items.length !== prevItemCountRef.current) {
1403
+ prevItemCountRef.current = items.length
1404
+ paginationCalledRef.current = false
1405
+ }
1406
+
1407
+ if (paginationCalledRef.current) return
1408
+
1409
+ const scrollTop = scrollBox.scrollTop || 0
1410
+ const viewportHeight = scrollBox.viewport?.height || 0
1411
+ const contentHeight = scrollBox.scrollHeight || 0
1412
+
1413
+ // Nothing to paginate if content fits in viewport
1414
+ if (contentHeight <= viewportHeight) return
1415
+
1416
+ // Trigger when within 20% of the bottom (or 3 rows, whichever is larger)
1417
+ const threshold = Math.max(3, Math.floor(viewportHeight * 0.2))
1418
+ const distanceFromBottom = contentHeight - (scrollTop + viewportHeight)
1419
+
1420
+ if (distanceFromBottom <= threshold) {
1421
+ paginationCalledRef.current = true
1422
+ props.pagination.onLoadMore()
1423
+ }
1424
+ })
1425
+ }
1426
+
1354
1427
  const move = (direction: -1 | 1) => {
1355
1428
  // Get all visible items
1356
1429
  const items = Object.values(descendantsContext.map.current)
@@ -1607,7 +1680,8 @@ export const List: ListType = (props) => {
1607
1680
  return
1608
1681
  }
1609
1682
 
1610
- // Ctrl+d for half-page down, Ctrl+u for half-page up
1683
+ // Ctrl+d / Ctrl+u for half-page down/up
1684
+ // Ctrl+f / Ctrl+b for full-page down/up
1611
1685
  if (evt.ctrl && evt.name === 'd') {
1612
1686
  const viewportHeight = scrollBoxRef.current?.viewport?.height || 20
1613
1687
  moveByN(Math.floor(viewportHeight / 2))
@@ -1620,6 +1694,18 @@ export const List: ListType = (props) => {
1620
1694
  evt.stopPropagation()
1621
1695
  return
1622
1696
  }
1697
+ if (evt.ctrl && evt.name === 'f') {
1698
+ const viewportHeight = scrollBoxRef.current?.viewport?.height || 20
1699
+ moveByN(viewportHeight)
1700
+ evt.stopPropagation()
1701
+ return
1702
+ }
1703
+ if (evt.ctrl && evt.name === 'b') {
1704
+ const viewportHeight = scrollBoxRef.current?.viewport?.height || 20
1705
+ moveByN(-viewportHeight)
1706
+ evt.stopPropagation()
1707
+ return
1708
+ }
1623
1709
 
1624
1710
  // / to enter search mode
1625
1711
  if (evt.sequence === '/' && !evt.ctrl && !evt.meta) {
@@ -1794,14 +1880,15 @@ export const List: ListType = (props) => {
1794
1880
  {/* Main content area with optional detail view */}
1795
1881
  <box style={{ flexDirection: 'row', flexGrow: 1, flexShrink: 1 }}>
1796
1882
  {/* List content - render children which will register themselves */}
1797
- <box style={{ width: isShowingDetail ? '50%' : '100%', flexGrow: 1, flexShrink: 1, flexDirection: 'column' }}>
1883
+ <box style={{ width: effectiveIsShowingDetail ? '50%' : '100%', flexGrow: 1, flexShrink: 1, flexDirection: 'column' }}>
1798
1884
  {/* Scrollable list items */}
1799
1885
  <ScrollBox
1800
1886
  ref={scrollBoxRef}
1801
1887
  focused={false}
1802
1888
  flexGrow={1}
1803
1889
  flexShrink={1}
1804
- minHeight={6}
1890
+ minHeight={10}
1891
+ onMouseScroll={checkScrollPagination}
1805
1892
  style={{
1806
1893
  rootOptions: {
1807
1894
  backgroundColor: undefined,
@@ -1831,7 +1918,7 @@ export const List: ListType = (props) => {
1831
1918
  {/* Detail panel on the right */}
1832
1919
  <CurrentItemDetail
1833
1920
  selectedIndex={selectedIndex}
1834
- isShowingDetail={isShowingDetail}
1921
+ isShowingDetail={effectiveIsShowingDetail}
1835
1922
  />
1836
1923
  </box>
1837
1924
  </box>
@@ -1985,8 +2072,7 @@ const ListItem: ListItemType = (props) => {
1985
2072
  }
1986
2073
  }
1987
2074
 
1988
- // Don't show accessories if we're showing detail
1989
- const showAccessories = !props.detail && props.accessories
2075
+ const showAccessories = Boolean(props.accessories)
1990
2076
 
1991
2077
  // Get icon string and color from props.icon (can be string or object with value/tintColor)
1992
2078
  const { iconValue, iconColor } = (() => {
@@ -2127,9 +2213,9 @@ const ListDropdown: ListDropdownType = (props) => {
2127
2213
 
2128
2214
  const { isDropdownOpen, setIsDropdownOpen } = listContext
2129
2215
 
2130
- const setDropdownSelection = (props: { value: string; title: string }) => {
2131
- setDropdownState({ value: props.value, title: props.title })
2132
- useStore.setState({ dropdownFooterLabel: props.title || 'dropdown' })
2216
+ const setDropdownSelection = (selectionProps: { value: string; title: string }) => {
2217
+ setDropdownState({ value: selectionProps.value, title: selectionProps.title })
2218
+ useStore.setState({ dropdownFooterLabel: props.displayValue ?? (selectionProps.title || 'dropdown') })
2133
2219
  }
2134
2220
  // Store both value and title together
2135
2221
  const [dropdownState, setDropdownState] = useState<{
@@ -2168,7 +2254,7 @@ const ListDropdown: ListDropdownType = (props) => {
2168
2254
 
2169
2255
  if (!valueToUse) {
2170
2256
  useStore.setState({
2171
- dropdownFooterLabel: dropdownState.title || 'dropdown',
2257
+ dropdownFooterLabel: props.displayValue ?? (dropdownState.title || 'dropdown'),
2172
2258
  })
2173
2259
  return
2174
2260
  }
@@ -2189,8 +2275,8 @@ const ListDropdown: ListDropdownType = (props) => {
2189
2275
  return
2190
2276
  }
2191
2277
 
2192
- useStore.setState({ dropdownFooterLabel: title || 'dropdown' })
2193
- }, [props.value]) // Run when props.value changes and on mount
2278
+ useStore.setState({ dropdownFooterLabel: props.displayValue ?? (title || 'dropdown') })
2279
+ }, [props.value, props.displayValue]) // Run when props.value or displayValue changes and on mount
2194
2280
 
2195
2281
  const dropdownContextValue = useMemo<DropdownContextValue>(
2196
2282
  () => ({
@@ -2242,8 +2328,8 @@ const ListDropdown: ListDropdownType = (props) => {
2242
2328
  }
2243
2329
  }, [isDropdownOpen, props.children])
2244
2330
 
2245
- // Display the title from our state
2246
- const displayValue = dropdownState.title || 'All'
2331
+ // Display the title from our state, or use the caller's override
2332
+ const displayValue = props.displayValue ?? (dropdownState.title || 'All')
2247
2333
  const openDropdownIfClosed = () => {
2248
2334
  if (!isDropdownOpen) {
2249
2335
  listContext.openDropdown()