termcast 1.4.0 → 1.5.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 (144) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/build.js +8 -7
  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/components/bar-graph.d.ts +23 -8
  7. package/dist/components/bar-graph.d.ts.map +1 -1
  8. package/dist/components/bar-graph.js +84 -40
  9. package/dist/components/bar-graph.js.map +1 -1
  10. package/dist/components/dotted-line-graph.d.ts +86 -0
  11. package/dist/components/dotted-line-graph.d.ts.map +1 -0
  12. package/dist/components/dotted-line-graph.js +260 -0
  13. package/dist/components/dotted-line-graph.js.map +1 -0
  14. package/dist/components/extension-preferences.d.ts.map +1 -1
  15. package/dist/components/extension-preferences.js +1 -10
  16. package/dist/components/extension-preferences.js.map +1 -1
  17. package/dist/components/graph.d.ts.map +1 -1
  18. package/dist/components/graph.js +7 -1
  19. package/dist/components/graph.js.map +1 -1
  20. package/dist/components/histogram.d.ts +42 -0
  21. package/dist/components/histogram.d.ts.map +1 -0
  22. package/dist/components/histogram.js +115 -0
  23. package/dist/components/histogram.js.map +1 -0
  24. package/dist/components/horizontal-bar-graph.d.ts +47 -0
  25. package/dist/components/horizontal-bar-graph.d.ts.map +1 -0
  26. package/dist/components/horizontal-bar-graph.js +137 -0
  27. package/dist/components/horizontal-bar-graph.js.map +1 -0
  28. package/dist/components/list.d.ts +2 -0
  29. package/dist/components/list.d.ts.map +1 -1
  30. package/dist/components/list.js +10 -10
  31. package/dist/components/list.js.map +1 -1
  32. package/dist/examples/bar-graph-weekly.js +2 -2
  33. package/dist/examples/bar-graph-weekly.js.map +1 -1
  34. package/dist/examples/charts-showcase-barchart.d.ts +2 -0
  35. package/dist/examples/charts-showcase-barchart.d.ts.map +1 -0
  36. package/dist/examples/charts-showcase-barchart.js +10 -0
  37. package/dist/examples/charts-showcase-barchart.js.map +1 -0
  38. package/dist/examples/charts-showcase-bargraph.d.ts +2 -0
  39. package/dist/examples/charts-showcase-bargraph.d.ts.map +1 -0
  40. package/dist/examples/charts-showcase-bargraph.js +60 -0
  41. package/dist/examples/charts-showcase-bargraph.js.map +1 -0
  42. package/dist/examples/charts-showcase-candle.d.ts +2 -0
  43. package/dist/examples/charts-showcase-candle.d.ts.map +1 -0
  44. package/dist/examples/charts-showcase-candle.js +30 -0
  45. package/dist/examples/charts-showcase-candle.js.map +1 -0
  46. package/dist/examples/charts-showcase-graph.d.ts +2 -0
  47. package/dist/examples/charts-showcase-graph.d.ts.map +1 -0
  48. package/dist/examples/charts-showcase-graph.js +33 -0
  49. package/dist/examples/charts-showcase-graph.js.map +1 -0
  50. package/dist/examples/charts-showcase-heatmap.d.ts +2 -0
  51. package/dist/examples/charts-showcase-heatmap.d.ts.map +1 -0
  52. package/dist/examples/charts-showcase-heatmap.js +36 -0
  53. package/dist/examples/charts-showcase-heatmap.js.map +1 -0
  54. package/dist/examples/charts-showcase-mixed.d.ts +2 -0
  55. package/dist/examples/charts-showcase-mixed.d.ts.map +1 -0
  56. package/dist/examples/charts-showcase-mixed.js +30 -0
  57. package/dist/examples/charts-showcase-mixed.js.map +1 -0
  58. package/dist/examples/charts-showcase-progress.d.ts +2 -0
  59. package/dist/examples/charts-showcase-progress.d.ts.map +1 -0
  60. package/dist/examples/charts-showcase-progress.js +10 -0
  61. package/dist/examples/charts-showcase-progress.js.map +1 -0
  62. package/dist/examples/graph-multi-series.js +1 -1
  63. package/dist/examples/graph-multi-series.js.map +1 -1
  64. package/dist/examples/horizontal-bar-graph-weekly.d.ts +2 -0
  65. package/dist/examples/horizontal-bar-graph-weekly.d.ts.map +1 -0
  66. package/dist/examples/horizontal-bar-graph-weekly.js +67 -0
  67. package/dist/examples/horizontal-bar-graph-weekly.js.map +1 -0
  68. package/dist/examples/simple-dotted-line-graph.d.ts +2 -0
  69. package/dist/examples/simple-dotted-line-graph.d.ts.map +1 -0
  70. package/dist/examples/simple-dotted-line-graph.js +39 -0
  71. package/dist/examples/simple-dotted-line-graph.js.map +1 -0
  72. package/dist/examples/simple-histogram.d.ts +2 -0
  73. package/dist/examples/simple-histogram.d.ts.map +1 -0
  74. package/dist/examples/simple-histogram.js +47 -0
  75. package/dist/examples/simple-histogram.js.map +1 -0
  76. package/dist/index.d.ts +6 -0
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +6 -0
  79. package/dist/index.js.map +1 -1
  80. package/dist/logger.d.ts.map +1 -1
  81. package/dist/logger.js +15 -6
  82. package/dist/logger.js.map +1 -1
  83. package/dist/platform/node/sqlite.d.ts +6 -5
  84. package/dist/platform/node/sqlite.d.ts.map +1 -1
  85. package/dist/platform/node/sqlite.js +30 -14
  86. package/dist/platform/node/sqlite.js.map +1 -1
  87. package/dist/theme.d.ts.map +1 -1
  88. package/dist/theme.js +11 -9
  89. package/dist/theme.js.map +1 -1
  90. package/dist/utils/run-command.d.ts.map +1 -1
  91. package/dist/utils/run-command.js +8 -19
  92. package/dist/utils/run-command.js.map +1 -1
  93. package/dist/utils.d.ts +1 -19
  94. package/dist/utils.d.ts.map +1 -1
  95. package/dist/utils.js +1 -100
  96. package/dist/utils.js.map +1 -1
  97. package/package.json +14 -16
  98. package/src/build.tsx +11 -10
  99. package/src/cli.tsx +3 -40
  100. package/src/compile.vitest.tsx +3 -3
  101. package/src/components/bar-graph.tsx +217 -111
  102. package/src/components/dotted-line-graph.tsx +407 -0
  103. package/src/components/extension-preferences.tsx +2 -12
  104. package/src/components/graph.tsx +5 -1
  105. package/src/components/histogram.tsx +228 -0
  106. package/src/components/horizontal-bar-graph.tsx +279 -0
  107. package/src/components/list.tsx +20 -15
  108. package/src/examples/action-shortcut.vitest.tsx +17 -17
  109. package/src/examples/bar-graph-weekly.tsx +2 -2
  110. package/src/examples/bar-graph-weekly.vitest.tsx +63 -62
  111. package/src/examples/charts-showcase-bargraph.tsx +103 -0
  112. package/src/examples/detail-metadata-showcase.vitest.tsx +13 -18
  113. package/src/examples/form-basic.vitest.tsx +35 -35
  114. package/src/examples/form-dropdown.vitest.tsx +11 -11
  115. package/src/examples/form-scroll.vitest.tsx +1 -1
  116. package/src/examples/form-tagpicker.vitest.tsx +11 -11
  117. package/src/examples/github.vitest.tsx +22 -22
  118. package/src/examples/graph-bar-chart.vitest.tsx +8 -8
  119. package/src/examples/graph-multi-series.tsx +1 -1
  120. package/src/examples/graph-row.vitest.tsx +14 -14
  121. package/src/examples/graph-styles.vitest.tsx +77 -77
  122. package/src/examples/horizontal-bar-graph-weekly.tsx +138 -0
  123. package/src/examples/horizontal-bar-graph-weekly.vitest.tsx +164 -0
  124. package/src/examples/list-detail-metadata.vitest.tsx +4 -4
  125. package/src/examples/list-with-detail.vitest.tsx +46 -46
  126. package/src/examples/simple-candle-chart.vitest.tsx +8 -8
  127. package/src/examples/simple-dotted-line-graph.tsx +53 -0
  128. package/src/examples/simple-dotted-line-graph.vitest.tsx +62 -0
  129. package/src/examples/simple-grid.vitest.tsx +4 -4
  130. package/src/examples/simple-histogram.tsx +90 -0
  131. package/src/examples/simple-navigation.vitest.tsx +4 -4
  132. package/src/examples/swift-extension.vitest.tsx +3 -3
  133. package/src/examples/toast-variations.vitest.tsx +5 -5
  134. package/src/extensions/dev.vitest.tsx +8 -8
  135. package/src/index.tsx +21 -0
  136. package/src/logger.tsx +16 -6
  137. package/src/platform/node/sqlite.ts +29 -13
  138. package/src/theme.tsx +11 -10
  139. package/src/utils/run-command.tsx +10 -19
  140. package/src/utils.tsx +0 -163
  141. package/src/examples/store.tsx +0 -4
  142. package/src/examples/store.vitest.tsx +0 -78
  143. package/src/extensions/home.tsx +0 -227
  144. package/src/extensions/store.tsx +0 -375
@@ -0,0 +1,407 @@
1
+ /**
2
+ * DottedLineGraph renders metric-style dotted line charts with braille subcells.
3
+ *
4
+ * Each terminal cell contains a 2×4 braille grid, so diagonal and step changes
5
+ * can move by half columns and quarter rows instead of snapping to full cells.
6
+ */
7
+
8
+ import React, { ReactNode, useMemo } from 'react'
9
+ import { Renderable, RGBA } from '@opentui/core'
10
+ import type { OptimizedBuffer, RenderableOptions, RenderContext } from '@opentui/core'
11
+ import { extend } from '@opentui/react'
12
+ import { Color, resolveColor } from 'termcast/src/colors'
13
+ import { getThemePalette, useTheme } from 'termcast/src/theme'
14
+
15
+ const BRAILLE_BITS: number[][] = [
16
+ [1, 8],
17
+ [2, 16],
18
+ [4, 32],
19
+ [64, 128],
20
+ ]
21
+
22
+ export interface DottedLineGraphSeriesData {
23
+ data: number[]
24
+ color: string
25
+ }
26
+
27
+ export interface DottedLineGraphPlotOptions extends RenderableOptions {
28
+ series?: DottedLineGraphSeriesData[]
29
+ xLabels?: string[]
30
+ yMin?: number
31
+ yMax?: number
32
+ yTicks?: number
33
+ yFormat?: (value: number) => string
34
+ axisColor?: string
35
+ dotSpacing?: number
36
+ }
37
+
38
+ class DottedLineGraphPlotRenderable extends Renderable {
39
+ private _series: DottedLineGraphSeriesData[] = []
40
+ private _xLabels: string[] = []
41
+ private _yMin = 0
42
+ private _yMax = 100
43
+ private _yTicks = 5
44
+ private _yFormat: (value: number) => string = (value) => {
45
+ return value >= 1000 ? value.toFixed(0) : value.toFixed(1)
46
+ }
47
+ private _axisColor = '#666666'
48
+ private _dotSpacing = 2
49
+
50
+ constructor(ctx: RenderContext, options: DottedLineGraphPlotOptions) {
51
+ super(ctx, options)
52
+ if (options.series) this._series = options.series
53
+ if (options.xLabels) this._xLabels = options.xLabels
54
+ if (options.yMin !== undefined) this._yMin = options.yMin
55
+ if (options.yMax !== undefined) this._yMax = options.yMax
56
+ if (options.yTicks !== undefined) this._yTicks = options.yTicks
57
+ if (options.yFormat) this._yFormat = options.yFormat
58
+ if (options.axisColor) this._axisColor = options.axisColor
59
+ if (options.dotSpacing !== undefined) this._dotSpacing = options.dotSpacing
60
+
61
+ this.computeLayout = this.computeLayout.bind(this)
62
+ this.drawAxes = this.drawAxes.bind(this)
63
+ this.drawSeries = this.drawSeries.bind(this)
64
+ this.setDot = this.setDot.bind(this)
65
+ }
66
+
67
+ set series(value: DottedLineGraphSeriesData[]) { this._series = value; this.requestRender() }
68
+ set xLabels(value: string[]) { this._xLabels = value; this.requestRender() }
69
+ set yMin(value: number) { this._yMin = value; this.requestRender() }
70
+ set yMax(value: number) { this._yMax = value; this.requestRender() }
71
+ set yTicks(value: number) { this._yTicks = value; this.requestRender() }
72
+ set yFormat(value: (value: number) => string) { this._yFormat = value; this.requestRender() }
73
+ set axisColor(value: string) { this._axisColor = value; this.requestRender() }
74
+ set dotSpacing(value: number) { this._dotSpacing = value; this.requestRender() }
75
+
76
+ private computeLayout(): {
77
+ plotX: number
78
+ plotY: number
79
+ plotW: number
80
+ plotH: number
81
+ yAxisWidth: number
82
+ yLabels: string[]
83
+ } | null {
84
+ if (this.width <= 0 || this.height <= 0) return null
85
+
86
+ const safeTicks = Math.max(2, Math.floor(this._yTicks))
87
+ const yLabels = Array.from({ length: safeTicks }, (_, index) => {
88
+ const value = this._yMin + (this._yMax - this._yMin) * (1 - index / (safeTicks - 1))
89
+ return this._yFormat(value)
90
+ })
91
+ const yAxisWidth = Math.max(...yLabels.map((label) => {
92
+ return label.length
93
+ }))
94
+ const plotX = this.x + yAxisWidth + 1
95
+ const plotY = this.y
96
+ const plotW = this.width - yAxisWidth - 1
97
+ const plotH = this.height - 1
98
+
99
+ if (plotW <= 0 || plotH <= 0) return null
100
+ return { plotX, plotY, plotW, plotH, yAxisWidth, yLabels }
101
+ }
102
+
103
+ private drawAxes(buffer: OptimizedBuffer, layout: {
104
+ plotX: number
105
+ plotY: number
106
+ plotW: number
107
+ plotH: number
108
+ yAxisWidth: number
109
+ yLabels: string[]
110
+ }): void {
111
+ const { plotX, plotY, plotW, plotH, yAxisWidth, yLabels } = layout
112
+ const axisColor = RGBA.fromHex(this._axisColor)
113
+ const labelRows = new Set<number>()
114
+
115
+ yLabels.forEach((label, index) => {
116
+ const row = Math.round(plotY + (index / Math.max(1, yLabels.length - 1)) * (plotH - 1))
117
+ labelRows.add(row)
118
+ buffer.drawText(label, this.x + yAxisWidth - label.length, row, axisColor)
119
+ buffer.drawText('│', this.x + yAxisWidth, row, axisColor)
120
+ })
121
+
122
+ for (let row = plotY; row < plotY + plotH; row++) {
123
+ if (!labelRows.has(row)) {
124
+ buffer.drawText('│', this.x + yAxisWidth, row, axisColor)
125
+ }
126
+ }
127
+
128
+ // X-axis labels (skip labels that would overlap the previous one)
129
+ let occupiedEnd = -1
130
+ this._xLabels.forEach((label, index) => {
131
+ if (!label) return
132
+ const labelX = plotX + Math.round((index / Math.max(1, this._xLabels.length - 1)) * (plotW - 1))
133
+ const centeredX = Math.max(plotX, Math.min(labelX - Math.floor(label.length / 2), plotX + plotW - label.length))
134
+ if (centeredX <= occupiedEnd) return
135
+ buffer.drawText(label, centeredX, plotY + plotH, axisColor)
136
+ occupiedEnd = centeredX + label.length
137
+ })
138
+ }
139
+
140
+ private setDot({
141
+ px,
142
+ py,
143
+ plotW,
144
+ plotH,
145
+ cellBits,
146
+ cellColors,
147
+ color,
148
+ }: {
149
+ px: number
150
+ py: number
151
+ plotW: number
152
+ plotH: number
153
+ cellBits: Uint8Array
154
+ cellColors: RGBA[]
155
+ color: RGBA
156
+ }): void {
157
+ if (px < 0 || py < 0 || px >= plotW * 2 || py >= plotH * 4) return
158
+
159
+ const cellX = Math.floor(px / 2)
160
+ const cellY = Math.floor(py / 4)
161
+ const cellIndex = cellY * plotW + cellX
162
+ cellBits[cellIndex] = cellBits[cellIndex]! | BRAILLE_BITS[py % 4]![px % 2]!
163
+ cellColors[cellIndex] = color
164
+ }
165
+
166
+ private drawSeries({
167
+ series,
168
+ plotW,
169
+ plotH,
170
+ cellBits,
171
+ cellColors,
172
+ }: {
173
+ series: DottedLineGraphSeriesData
174
+ plotW: number
175
+ plotH: number
176
+ cellBits: Uint8Array
177
+ cellColors: RGBA[]
178
+ }): void {
179
+ const yRange = this._yMax - this._yMin
180
+ if (series.data.length === 0 || yRange === 0) return
181
+
182
+ const color = RGBA.fromHex(series.color)
183
+ const virtualW = plotW * 2
184
+ const virtualH = plotH * 4
185
+ const dotSpacing = Math.max(1, Math.floor(this._dotSpacing))
186
+ const toPoint = (value: number, index: number) => {
187
+ const normalized = (value - this._yMin) / yRange
188
+ return {
189
+ x: Math.round((index / Math.max(1, series.data.length - 1)) * (virtualW - 1)),
190
+ y: Math.round((1 - normalized) * (virtualH - 1)),
191
+ }
192
+ }
193
+
194
+ if (series.data.length === 1) {
195
+ const point = toPoint(series.data[0]!, 0)
196
+ this.setDot({ px: point.x, py: point.y, plotW, plotH, cellBits, cellColors, color })
197
+ return
198
+ }
199
+
200
+ series.data.slice(0, -1).forEach((value, index) => {
201
+ const start = toPoint(value, index)
202
+ const end = toPoint(series.data[index + 1]!, index + 1)
203
+ const dx = Math.abs(end.x - start.x)
204
+ const dy = Math.abs(end.y - start.y)
205
+ const sx = start.x < end.x ? 1 : -1
206
+ const sy = start.y < end.y ? 1 : -1
207
+ let error = dx - dy
208
+ let x = start.x
209
+ let y = start.y
210
+ let step = index === 0 ? 0 : 1
211
+
212
+ while (true) {
213
+ if (step % dotSpacing === 0 || (x === end.x && y === end.y)) {
214
+ this.setDot({ px: x, py: y, plotW, plotH, cellBits, cellColors, color })
215
+ }
216
+ if (x === end.x && y === end.y) break
217
+
218
+ const error2 = error * 2
219
+ if (error2 > -dy) { error -= dy; x += sx }
220
+ if (error2 < dx) { error += dx; y += sy }
221
+ step++
222
+ }
223
+ })
224
+ }
225
+
226
+ protected renderSelf(buffer: OptimizedBuffer): void {
227
+ const layout = this.computeLayout()
228
+ if (!layout) return
229
+
230
+ this.drawAxes(buffer, layout)
231
+ if (this._series.length === 0 || this._yMax === this._yMin) return
232
+
233
+ const transparent = RGBA.fromValues(0, 0, 0, 0)
234
+ const cellCount = layout.plotW * layout.plotH
235
+ const cellBits = new Uint8Array(cellCount)
236
+ const cellColors = Array.from({ length: cellCount }, () => {
237
+ return transparent
238
+ })
239
+
240
+ this._series.forEach((series) => {
241
+ this.drawSeries({ series, plotW: layout.plotW, plotH: layout.plotH, cellBits, cellColors })
242
+ })
243
+
244
+ for (let y = 0; y < layout.plotH; y++) {
245
+ for (let x = 0; x < layout.plotW; x++) {
246
+ const cellIndex = y * layout.plotW + x
247
+ const bits = cellBits[cellIndex]!
248
+ if (bits === 0) continue
249
+ buffer.setCell(layout.plotX + x, layout.plotY + y, String.fromCharCode(0x2800 + bits), cellColors[cellIndex]!, transparent)
250
+ }
251
+ }
252
+ }
253
+ }
254
+
255
+ extend({ 'dotted-line-graph-plot': DottedLineGraphPlotRenderable })
256
+
257
+ declare module '@opentui/react' {
258
+ interface OpenTUIComponents {
259
+ 'dotted-line-graph-plot': typeof DottedLineGraphPlotRenderable
260
+ }
261
+ }
262
+
263
+ export interface DottedLineGraphSeriesProps {
264
+ /** Y-values for this metric line */
265
+ data: number[]
266
+ /** Override the auto-assigned color */
267
+ color?: Color.ColorLike
268
+ /** Series label shown in the legend */
269
+ title?: string
270
+ }
271
+
272
+ export interface DottedLineGraphProps {
273
+ /** Height of the plot area in terminal rows (default: 10) */
274
+ height?: number
275
+ /** X-axis labels */
276
+ xLabels?: string[]
277
+ /** Manual Y-axis range [min, max] (default: auto from data) */
278
+ yRange?: [number, number]
279
+ /** Number of Y-axis tick labels (default: 4) */
280
+ yTicks?: number
281
+ /** Custom Y-axis label formatter */
282
+ yFormat?: (value: number) => string
283
+ /** Gap between dots in virtual braille pixels (default: 2) */
284
+ dotSpacing?: number
285
+ /** Show compact legend (default: true when any series has a title) */
286
+ showLegend?: boolean
287
+ /** DottedLineGraph.Series children */
288
+ children: ReactNode
289
+ }
290
+
291
+ interface DottedLineGraphType {
292
+ (props: DottedLineGraphProps): any
293
+ Series: (props: DottedLineGraphSeriesProps) => any
294
+ }
295
+
296
+ const DottedLineGraphSeries = (_props: DottedLineGraphSeriesProps): any => {
297
+ return null
298
+ }
299
+
300
+ function formatDefaultTick(value: number): string {
301
+ return value >= 1000 ? value.toFixed(0) : value.toFixed(1)
302
+ }
303
+
304
+ const DottedLineGraph: DottedLineGraphType = (props) => {
305
+ const theme = useTheme()
306
+ const {
307
+ height = 10,
308
+ xLabels = [],
309
+ yRange,
310
+ yTicks = 4,
311
+ yFormat,
312
+ dotSpacing = 2,
313
+ showLegend,
314
+ children,
315
+ } = props
316
+ const palette = getThemePalette(theme)
317
+
318
+ const series = useMemo<Array<DottedLineGraphSeriesData & { title?: string }>>(() => {
319
+ return React.Children.toArray(children)
320
+ .filter(React.isValidElement)
321
+ .map((child, index) => {
322
+ const childProps = child.props as DottedLineGraphSeriesProps
323
+ return {
324
+ data: childProps.data,
325
+ color: resolveColor(childProps.color) || palette[index % palette.length]!,
326
+ title: childProps.title,
327
+ }
328
+ })
329
+ .filter((item) => {
330
+ return Array.isArray(item.data)
331
+ })
332
+ }, [children, palette])
333
+
334
+ const computedYRange = useMemo<[number, number]>(() => {
335
+ if (yRange) return yRange
336
+
337
+ const values = series.flatMap((item) => {
338
+ return item.data
339
+ })
340
+ if (values.length === 0) return [0, 100]
341
+
342
+ const min = Math.min(...values)
343
+ const max = Math.max(...values)
344
+ const padding = max === min ? Math.max(1, Math.abs(max) * 0.1) : (max - min) * 0.08
345
+ return [min - padding, max + padding]
346
+ }, [series, yRange])
347
+
348
+ const legendRows = series.filter((item) => {
349
+ return Boolean(item.title)
350
+ })
351
+ const legendVisible = showLegend ?? legendRows.length > 0
352
+ const plotSeries = series.map((item) => {
353
+ return { data: item.data, color: item.color }
354
+ })
355
+ const legendPaddingLeft = useMemo(() => {
356
+ const safeTicks = Math.max(2, Math.floor(yTicks))
357
+ const tickFormat = yFormat || formatDefaultTick
358
+ const yLabels = Array.from({ length: safeTicks }, (_, index) => {
359
+ const value = computedYRange[0] + (computedYRange[1] - computedYRange[0]) * (1 - index / (safeTicks - 1))
360
+ return tickFormat(value)
361
+ })
362
+ return Math.max(...yLabels.map((label) => {
363
+ return label.length
364
+ })) + 1
365
+ }, [computedYRange, yFormat, yTicks])
366
+
367
+ if (series.length === 0) return null
368
+
369
+ return (
370
+ <box flexDirection="column" width="100%" flexShrink={0}>
371
+ <dotted-line-graph-plot
372
+ width="100%"
373
+ height={height + (xLabels.length > 0 ? 1 : 0)}
374
+ series={plotSeries}
375
+ xLabels={xLabels}
376
+ yMin={computedYRange[0]}
377
+ yMax={computedYRange[1]}
378
+ yTicks={yTicks}
379
+ yFormat={yFormat}
380
+ axisColor={theme.textMuted}
381
+ dotSpacing={dotSpacing}
382
+ />
383
+ {legendVisible ? (
384
+ <box height={1} width="100%" flexShrink={0} overflow="hidden" flexDirection="row">
385
+ <box width={legendPaddingLeft} flexShrink={0} />
386
+ <box flexGrow={1} flexShrink={1} overflow="hidden">
387
+ <text wrapMode="none">
388
+ {legendRows.map((item, index) => {
389
+ const separator = index < legendRows.length - 1 ? ' ' : ''
390
+ return (
391
+ <React.Fragment key={index}>
392
+ <span fg={item.color}>●</span>
393
+ <span fg={theme.textMuted}> {item.title}{separator}</span>
394
+ </React.Fragment>
395
+ )
396
+ })}
397
+ </text>
398
+ </box>
399
+ </box>
400
+ ) : null}
401
+ </box>
402
+ )
403
+ }
404
+
405
+ DottedLineGraph.Series = DottedLineGraphSeries
406
+
407
+ export { DottedLineGraph }
@@ -7,7 +7,7 @@ import { ActionPanel, Action } from './actions'
7
7
  import { LocalStorage } from 'termcast/src/apis/localstorage'
8
8
  import { useNavigation } from 'termcast/src/internal/navigation'
9
9
  import { logger } from 'termcast/src/logger'
10
- import { getStoreDirectory } from 'termcast/src/utils'
10
+
11
11
  import { useStore } from 'termcast/src/state'
12
12
  import type { RaycastPackageJson } from 'termcast/src/package-json'
13
13
 
@@ -46,7 +46,6 @@ export function ExtensionPreferences({
46
46
  queryKey: ['extension-preferences', extensionName, commandName],
47
47
  queryFn: async () => {
48
48
  try {
49
- // First check extensionPath from state (dev mode), then fall back to store directory
50
49
  const { extensionPath, extensionPackageJson } = useStore.getState()
51
50
 
52
51
  let packageJson: RaycastPackageJson
@@ -58,16 +57,7 @@ export function ExtensionPreferences({
58
57
  // Dev mode with extensionPath - read from disk
59
58
  packageJson = JSON.parse(readFileSync(joinPath(extensionPath, 'package.json')))
60
59
  } else {
61
- // Store extension - read from store directory
62
- const storeDir = getStoreDirectory()
63
- const extensionDir = joinPath(storeDir, extensionName)
64
- const packageJsonPath = joinPath(extensionDir, 'package.json')
65
-
66
- if (!fileExists(packageJsonPath)) {
67
- throw new Error(`Extension ${extensionName} not found`)
68
- }
69
-
70
- packageJson = JSON.parse(readFileSync(packageJsonPath))
60
+ throw new Error(`Extension ${extensionName} not found`)
71
61
  }
72
62
 
73
63
  let prefsToUse: PreferenceManifest[] = []
@@ -171,15 +171,19 @@ export class GraphPlotRenderable extends Renderable {
171
171
  }
172
172
  }
173
173
 
174
- // X-axis labels
174
+ // X-axis labels (skip labels that would overlap the previous one)
175
175
  if (this._xLabels.length > 0) {
176
176
  const xAxisRow = plotY + plotH
177
177
  const labelCount = this._xLabels.length
178
+ let occupiedEnd = -1
178
179
  for (let i = 0; i < labelCount; i++) {
179
180
  const label = this._xLabels[i]!
181
+ if (!label) continue
180
182
  const labelX = plotX + Math.round((i / Math.max(1, labelCount - 1)) * (plotW - 1))
181
183
  const centeredX = Math.max(plotX, Math.min(labelX - Math.floor(label.length / 2), plotX + plotW - label.length))
184
+ if (centeredX <= occupiedEnd) continue
182
185
  buffer.drawText(label, centeredX, xAxisRow, axisRgba)
186
+ occupiedEnd = centeredX + label.length
183
187
  }
184
188
  }
185
189
  }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Histogram component for rendering horizontal distribution tables in the terminal.
3
+ *
4
+ * Each row displays a colored dot, label, count, percentage, and a horizontal
5
+ * bar made of repeated characters. Includes optional header and totals footer.
6
+ *
7
+ * Colors can be set per-item via props, or auto-assigned from the theme palette
8
+ * in row order (cycling when there are more items than palette colors).
9
+ */
10
+
11
+ import React, { ReactNode, useMemo } from 'react'
12
+ import { useTheme, getThemePalette } from 'termcast/src/theme'
13
+ import { Color, resolveColor } from 'termcast/src/colors'
14
+
15
+ // ── Types ────────────────────────────────────────────────────────────
16
+
17
+ export interface HistogramItemProps {
18
+ /** Row label (e.g. "user", "bash", "edit") */
19
+ label: string
20
+ /** Numeric count for this row */
21
+ value: number
22
+ /** Override the auto-assigned color for the dot and bar */
23
+ color?: Color.ColorLike
24
+ }
25
+
26
+ export interface HistogramProps {
27
+ /** Max columns for the distribution bar (default: 30) */
28
+ maxBarWidth?: number
29
+ /** Character used for bar segments (default: "│") */
30
+ barCharacter?: string
31
+ /** Show column header row (default: true) */
32
+ showHeader?: boolean
33
+ /** Show totals footer row (default: true) */
34
+ showTotal?: boolean
35
+ /** Show the percentage column (default: true) */
36
+ showPercentage?: boolean
37
+ /** Max display width for labels; longer labels are truncated (default: 24) */
38
+ maxLabelWidth?: number
39
+ /** Histogram.Item children */
40
+ children: ReactNode
41
+ }
42
+
43
+ interface HistogramType {
44
+ (props: HistogramProps): any
45
+ Item: (props: HistogramItemProps) => any
46
+ }
47
+
48
+ // ── Internal data ────────────────────────────────────────────────────
49
+
50
+ interface ItemData {
51
+ label: string
52
+ /** Display label (possibly truncated) */
53
+ displayLabel: string
54
+ value: number
55
+ color?: string
56
+ }
57
+
58
+ // ── Collect children recursively (handles fragments) ─────────────────
59
+
60
+ function collectItems(children: ReactNode, maxLabelWidth: number): ItemData[] {
61
+ const result: ItemData[] = []
62
+ const flatten = (node: ReactNode) => {
63
+ React.Children.forEach(node, (child) => {
64
+ if (!React.isValidElement(child)) return
65
+ // Traverse fragments
66
+ if (child.type === React.Fragment) {
67
+ const fragmentProps = child.props as { children?: ReactNode }
68
+ flatten(fragmentProps.children)
69
+ return
70
+ }
71
+ const p = child.props as HistogramItemProps
72
+ if (p.label === undefined || p.value === undefined) return
73
+ const displayLabel = p.label.length > maxLabelWidth
74
+ ? p.label.slice(0, maxLabelWidth - 1) + '…'
75
+ : p.label
76
+ result.push({
77
+ label: p.label,
78
+ displayLabel,
79
+ value: p.value,
80
+ color: resolveColor(p.color),
81
+ })
82
+ })
83
+ }
84
+ flatten(children)
85
+ return result
86
+ }
87
+
88
+ // ── Histogram.Item (data-only, renders null like BarChart.Segment) ───
89
+
90
+ const HistogramItem = (_props: HistogramItemProps): any => {
91
+ return null
92
+ }
93
+
94
+ // ── Main Histogram component ─────────────────────────────────────────
95
+
96
+ const Histogram: HistogramType = (props) => {
97
+ const theme = useTheme()
98
+ const {
99
+ maxBarWidth = 30,
100
+ barCharacter = '╻',
101
+ showHeader = true,
102
+ showTotal = true,
103
+ showPercentage = true,
104
+ maxLabelWidth = 24,
105
+ children,
106
+ } = props
107
+
108
+ const palette = getThemePalette(theme)
109
+
110
+ // Collect item data from children (traverses fragments)
111
+ const items = useMemo<ItemData[]>(() => {
112
+ return collectItems(children, maxLabelWidth)
113
+ }, [children, maxLabelWidth])
114
+
115
+ // Resolve colors: explicit prop > row-order palette assignment
116
+ const coloredItems = useMemo(() => {
117
+ return items.map((item, index) => ({
118
+ ...item,
119
+ resolvedColor: item.color || palette[index % palette.length]!,
120
+ }))
121
+ }, [items, palette])
122
+
123
+ const total = useMemo(() => {
124
+ return coloredItems.reduce((sum, item) => sum + item.value, 0)
125
+ }, [coloredItems])
126
+
127
+ const maxValue = useMemo(() => {
128
+ return Math.max(...coloredItems.map((item) => item.value))
129
+ }, [coloredItems])
130
+
131
+ if (coloredItems.length === 0 || total === 0) return null
132
+
133
+ // Compute column widths from data
134
+ const labelWidth = Math.max(...coloredItems.map((item) => item.displayLabel.length), 5)
135
+ const countWidth = Math.max(
136
+ ...coloredItems.map((item) => String(item.value).length),
137
+ String(total).length,
138
+ 5,
139
+ )
140
+ const pctWidth = 4 // "100%" = 4 chars
141
+
142
+ // ── Formatting helpers ───────────────────────────────────────────
143
+
144
+ function padLeft(str: string, width: number): string {
145
+ return str.length >= width ? str : ' '.repeat(width - str.length) + str
146
+ }
147
+
148
+ function padRight(str: string, width: number): string {
149
+ return str.length >= width ? str : str + ' '.repeat(width - str.length)
150
+ }
151
+
152
+ function formatPct(value: number): string {
153
+ const pct = Math.round((value / total) * 100)
154
+ return `${pct}%`
155
+ }
156
+
157
+ function makeBar(value: number): string {
158
+ if (value <= 0 || maxValue <= 0) return ''
159
+ const barLen = Math.max(1, Math.round((value / maxValue) * maxBarWidth))
160
+ return barCharacter.repeat(barLen)
161
+ }
162
+
163
+ // ── Separator line ───────────────────────────────────────────────
164
+
165
+ const percentageWidth = showPercentage ? 2 + pctWidth : 0
166
+ const separatorWidth = 2 + labelWidth + 2 + countWidth + percentageWidth + 2 + maxBarWidth
167
+ const separator = '─'.repeat(separatorWidth)
168
+
169
+ // Build header text as a single string
170
+ const headerText = ` ${padRight('category', labelWidth)} ${padLeft('count', countWidth)}${showPercentage ? ` ${padLeft('%', pctWidth)}` : ''} distribution`
171
+
172
+ // Build total text as a single string
173
+ const totalText = ` ${padRight('total', labelWidth)} ${padLeft(String(total), countWidth)}${showPercentage ? ` ${padLeft('100%', pctWidth)}` : ''}`
174
+
175
+ // Build each data row as a single line string (without dot and bar)
176
+ function buildRowMiddle(item: ItemData & { resolvedColor: string }): string {
177
+ return `${padRight(item.displayLabel, labelWidth)} ${padLeft(String(item.value), countWidth)}${showPercentage ? ` ${padLeft(formatPct(item.value), pctWidth)}` : ''} `
178
+ }
179
+
180
+ return (
181
+ <box flexDirection="column" flexShrink={0}>
182
+ {/* Header row */}
183
+ {showHeader && (
184
+ <>
185
+ <box height={1} flexShrink={0}>
186
+ <text fg={theme.textMuted} wrapMode="none">{headerText}</text>
187
+ </box>
188
+ <box height={1} flexShrink={0}>
189
+ <text fg={theme.borderSubtle} wrapMode="none">{separator}</text>
190
+ </box>
191
+ </>
192
+ )}
193
+
194
+ {/* Data rows: each row is a single <box> with fixed-width prefix + colored bar */}
195
+ {coloredItems.map((item, i) => {
196
+ const middle = buildRowMiddle(item)
197
+ const bar = makeBar(item.value)
198
+ // Prefix width: dot(2) + middle text length
199
+ const prefixWidth = 2 + middle.length
200
+ return (
201
+ <box key={i} flexDirection="row" height={1} flexShrink={0}>
202
+ <box width={prefixWidth} flexShrink={0} flexDirection="row">
203
+ <text fg={item.resolvedColor} wrapMode="none">{'● '}</text>
204
+ <text fg={theme.text} wrapMode="none">{middle}</text>
205
+ </box>
206
+ {bar && <text fg={item.resolvedColor} wrapMode="none">{bar}</text>}
207
+ </box>
208
+ )
209
+ })}
210
+
211
+ {/* Footer: separator + total */}
212
+ {showTotal && (
213
+ <>
214
+ <box height={1} flexShrink={0}>
215
+ <text fg={theme.borderSubtle} wrapMode="none">{separator}</text>
216
+ </box>
217
+ <box height={1} flexShrink={0}>
218
+ <text fg={theme.textMuted} wrapMode="none">{totalText}</text>
219
+ </box>
220
+ </>
221
+ )}
222
+ </box>
223
+ )
224
+ }
225
+
226
+ Histogram.Item = HistogramItem
227
+
228
+ export { Histogram }