termcast 1.3.50 → 1.3.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/dist/apis/environment.d.ts +1 -0
  2. package/dist/apis/environment.d.ts.map +1 -1
  3. package/dist/apis/environment.js +5 -0
  4. package/dist/apis/environment.js.map +1 -1
  5. package/dist/app.d.ts +33 -0
  6. package/dist/app.d.ts.map +1 -0
  7. package/dist/app.js +1130 -0
  8. package/dist/app.js.map +1 -0
  9. package/dist/cli.js +80 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/compile.d.ts.map +1 -1
  12. package/dist/compile.js +5 -2
  13. package/dist/compile.js.map +1 -1
  14. package/dist/components/actions.d.ts +4 -1
  15. package/dist/components/actions.d.ts.map +1 -1
  16. package/dist/components/actions.js +8 -5
  17. package/dist/components/actions.js.map +1 -1
  18. package/dist/components/detail.d.ts.map +1 -1
  19. package/dist/components/detail.js +21 -18
  20. package/dist/components/detail.js.map +1 -1
  21. package/dist/components/dropdown.d.ts.map +1 -1
  22. package/dist/components/dropdown.js +3 -2
  23. package/dist/components/dropdown.js.map +1 -1
  24. package/dist/components/footer.d.ts +6 -0
  25. package/dist/components/footer.d.ts.map +1 -1
  26. package/dist/components/footer.js +15 -6
  27. package/dist/components/footer.js.map +1 -1
  28. package/dist/components/form/checkbox.d.ts.map +1 -1
  29. package/dist/components/form/checkbox.js +1 -13
  30. package/dist/components/form/checkbox.js.map +1 -1
  31. package/dist/components/form/date-picker.js +2 -2
  32. package/dist/components/form/date-picker.js.map +1 -1
  33. package/dist/components/form/description.js +1 -1
  34. package/dist/components/form/description.js.map +1 -1
  35. package/dist/components/form/dropdown.d.ts.map +1 -1
  36. package/dist/components/form/dropdown.js +19 -3
  37. package/dist/components/form/dropdown.js.map +1 -1
  38. package/dist/components/form/file-picker.d.ts.map +1 -1
  39. package/dist/components/form/file-picker.js +22 -4
  40. package/dist/components/form/file-picker.js.map +1 -1
  41. package/dist/components/form/index.d.ts +3 -1
  42. package/dist/components/form/index.d.ts.map +1 -1
  43. package/dist/components/form/index.js +7 -5
  44. package/dist/components/form/index.js.map +1 -1
  45. package/dist/components/form/password-field.js +3 -3
  46. package/dist/components/form/password-field.js.map +1 -1
  47. package/dist/components/form/text-area.d.ts.map +1 -1
  48. package/dist/components/form/text-area.js +29 -6
  49. package/dist/components/form/text-area.js.map +1 -1
  50. package/dist/components/form/text-field.js +3 -3
  51. package/dist/components/form/text-field.js.map +1 -1
  52. package/dist/components/graph.d.ts.map +1 -1
  53. package/dist/components/graph.js +21 -25
  54. package/dist/components/graph.js.map +1 -1
  55. package/dist/components/heatmap.d.ts +80 -0
  56. package/dist/components/heatmap.d.ts.map +1 -0
  57. package/dist/components/heatmap.js +424 -0
  58. package/dist/components/heatmap.js.map +1 -0
  59. package/dist/components/list.d.ts +2 -0
  60. package/dist/components/list.d.ts.map +1 -1
  61. package/dist/components/list.js +91 -58
  62. package/dist/components/list.js.map +1 -1
  63. package/dist/components/markdown.d.ts +7 -0
  64. package/dist/components/markdown.d.ts.map +1 -0
  65. package/dist/components/markdown.js +19 -0
  66. package/dist/components/markdown.js.map +1 -0
  67. package/dist/components/metadata.d.ts.map +1 -1
  68. package/dist/components/metadata.js +4 -1
  69. package/dist/components/metadata.js.map +1 -1
  70. package/dist/components/progress-bar.d.ts +37 -0
  71. package/dist/components/progress-bar.d.ts.map +1 -0
  72. package/dist/components/progress-bar.js +34 -0
  73. package/dist/components/progress-bar.js.map +1 -0
  74. package/dist/components/table.d.ts +3 -2
  75. package/dist/components/table.d.ts.map +1 -1
  76. package/dist/components/table.js +78 -63
  77. package/dist/components/table.js.map +1 -1
  78. package/dist/diagram-parser.d.ts +17 -3
  79. package/dist/diagram-parser.d.ts.map +1 -1
  80. package/dist/diagram-parser.js +17 -3
  81. package/dist/diagram-parser.js.map +1 -1
  82. package/dist/examples/list-slot.d.ts +2 -0
  83. package/dist/examples/list-slot.d.ts.map +1 -0
  84. package/dist/examples/list-slot.js +14 -0
  85. package/dist/examples/list-slot.js.map +1 -0
  86. package/dist/examples/list-with-dropdown.js +2 -4
  87. package/dist/examples/list-with-dropdown.js.map +1 -1
  88. package/dist/examples/simple-heatmap.d.ts +2 -0
  89. package/dist/examples/simple-heatmap.d.ts.map +1 -0
  90. package/dist/examples/simple-heatmap.js +37 -0
  91. package/dist/examples/simple-heatmap.js.map +1 -0
  92. package/dist/examples/simple-progress-bar.d.ts +2 -0
  93. package/dist/examples/simple-progress-bar.d.ts.map +1 -0
  94. package/dist/examples/simple-progress-bar.js +36 -0
  95. package/dist/examples/simple-progress-bar.js.map +1 -0
  96. package/dist/index.d.ts +6 -0
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +6 -0
  99. package/dist/index.js.map +1 -1
  100. package/dist/internal/date-picker-widget.d.ts.map +1 -1
  101. package/dist/internal/date-picker-widget.js +5 -4
  102. package/dist/internal/date-picker-widget.js.map +1 -1
  103. package/dist/internal/navigation.d.ts.map +1 -1
  104. package/dist/internal/navigation.js +7 -2
  105. package/dist/internal/navigation.js.map +1 -1
  106. package/dist/internal/providers.d.ts.map +1 -1
  107. package/dist/internal/providers.js +42 -4
  108. package/dist/internal/providers.js.map +1 -1
  109. package/dist/logger.js +6 -1
  110. package/dist/logger.js.map +1 -1
  111. package/dist/state.d.ts +2 -0
  112. package/dist/state.d.ts.map +1 -1
  113. package/dist/state.js +31 -2
  114. package/dist/state.js.map +1 -1
  115. package/dist/theme.d.ts +1 -0
  116. package/dist/theme.d.ts.map +1 -1
  117. package/dist/theme.js +23 -1
  118. package/dist/theme.js.map +1 -1
  119. package/dist/utils.d.ts.map +1 -1
  120. package/dist/utils.js +6 -1
  121. package/dist/utils.js.map +1 -1
  122. package/package.json +3 -3
  123. package/src/apis/environment.tsx +6 -0
  124. package/src/app.tsx +1492 -0
  125. package/src/assets/default-app-icon.png +0 -0
  126. package/src/cli.tsx +105 -0
  127. package/src/compile.tsx +5 -2
  128. package/src/components/actions.tsx +9 -6
  129. package/src/components/detail.tsx +33 -23
  130. package/src/components/dropdown.tsx +3 -2
  131. package/src/components/footer.tsx +40 -7
  132. package/src/components/form/checkbox.tsx +2 -17
  133. package/src/components/form/date-picker.tsx +2 -2
  134. package/src/components/form/description.tsx +1 -1
  135. package/src/components/form/dropdown.tsx +22 -3
  136. package/src/components/form/file-picker.tsx +33 -10
  137. package/src/components/form/index.tsx +11 -7
  138. package/src/components/form/password-field.tsx +3 -3
  139. package/src/components/form/text-area.tsx +31 -6
  140. package/src/components/form/text-field.tsx +3 -3
  141. package/src/components/graph.tsx +21 -24
  142. package/src/components/heatmap.tsx +602 -0
  143. package/src/components/list.tsx +147 -78
  144. package/src/components/markdown.tsx +30 -0
  145. package/src/components/metadata.tsx +9 -2
  146. package/src/components/progress-bar.tsx +112 -0
  147. package/src/components/table.tsx +88 -71
  148. package/src/diagram-parser.tsx +17 -3
  149. package/src/examples/bar-graph-weekly.vitest.tsx +4 -4
  150. package/src/examples/detail-metadata-showcase.vitest.tsx +12 -12
  151. package/src/examples/form-basic.vitest.tsx +117 -16
  152. package/src/examples/graph-bar-chart.vitest.tsx +7 -7
  153. package/src/examples/graph-row.vitest.tsx +45 -45
  154. package/src/examples/graph-styles.vitest.tsx +19 -19
  155. package/src/examples/internal/descendants-rerender.vitest.tsx +94 -46
  156. package/src/examples/internal/simple-scrollbox.vitest.tsx +38 -14
  157. package/src/examples/list-dropdown-default.vitest.tsx +78 -58
  158. package/src/examples/list-slot.tsx +38 -0
  159. package/src/examples/list-with-detail.vitest.tsx +8 -8
  160. package/src/examples/list-with-dropdown.tsx +2 -2
  161. package/src/examples/list-with-dropdown.vitest.tsx +16 -16
  162. package/src/examples/list-with-sections.vitest.tsx +45 -32
  163. package/src/examples/simple-detail-table.vitest.tsx +2 -2
  164. package/src/examples/simple-file-picker.vitest.tsx +1 -1
  165. package/src/examples/simple-grid.vitest.tsx +27 -53
  166. package/src/examples/simple-heatmap.tsx +63 -0
  167. package/src/examples/simple-heatmap.vitest.tsx +88 -0
  168. package/src/examples/simple-progress-bar.tsx +82 -0
  169. package/src/examples/simple-progress-bar.vitest.tsx +72 -0
  170. package/src/examples/table-edge-cases.vitest.tsx +1 -1
  171. package/src/index.tsx +19 -0
  172. package/src/internal/date-picker-widget.tsx +23 -12
  173. package/src/internal/navigation.tsx +7 -2
  174. package/src/internal/providers.tsx +48 -3
  175. package/src/logger.tsx +6 -1
  176. package/src/state.tsx +38 -2
  177. package/src/theme.tsx +26 -2
  178. package/src/utils.tsx +6 -1
@@ -0,0 +1,602 @@
1
+ /**
2
+ * CalendarHeatmap component for rendering GitHub-style contribution/journal grids.
3
+ *
4
+ * Uses a custom opentui Renderable for performance: cells are drawn directly
5
+ * to OptimizedBuffer with month grouping, day labels, and legend support.
6
+ *
7
+ * Cell intensity is encoded by mixing a primary and secondary color.
8
+ */
9
+
10
+ import React from 'react'
11
+ import { Renderable, RGBA } from '@opentui/core'
12
+ import type { OptimizedBuffer, RenderContext, RenderableOptions } from '@opentui/core'
13
+ import { extend } from '@opentui/react'
14
+ import { Color, resolveColor } from 'termcast/src/colors'
15
+ import { useTheme } from 'termcast/src/theme'
16
+
17
+ const GRID_ROWS = 7
18
+ const DEFAULT_CELL_CHAR = '■'
19
+ // ◼ (U+25FC) and ■ (U+25A0) look visually identical in terminal fonts, but
20
+ // are different codepoints. AI agents reading text output can distinguish
21
+ // low-intensity cells (◼) from high-intensity (■) without needing color info.
22
+ const HALF_CELL_CHAR = '◼'
23
+ const CELL_STRIDE = 2
24
+ const MONTH_GAP = 1
25
+
26
+ const MONDAY_ROW = 1
27
+ const WEDNESDAY_ROW = 3
28
+ const FRIDAY_ROW = 5
29
+
30
+ const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
31
+
32
+ interface NormalizedPoint {
33
+ date: Date
34
+ value: number
35
+ }
36
+
37
+ interface PreparedWeek {
38
+ weekStart: Date
39
+ monthKey: string
40
+ monthLabel: string
41
+ values: [number, number, number, number, number, number, number]
42
+ }
43
+
44
+ interface MonthSection {
45
+ key: string
46
+ label: string
47
+ weeks: PreparedWeek[]
48
+ }
49
+
50
+ export interface CalendarHeatmapData {
51
+ date: Date | string
52
+ value: number
53
+ }
54
+
55
+ export type CalendarHeatmapCellChar = '◼' | '■' | '█' | '▪'
56
+
57
+ export interface CalendarHeatmapGridOptions extends RenderableOptions {
58
+ data?: CalendarHeatmapData[]
59
+ cellChar?: CalendarHeatmapCellChar
60
+ cellColor?: string
61
+ backgroundColor?: string
62
+ emptyColor?: string
63
+ labelColor?: string
64
+ showMonthLabels?: boolean
65
+ showDayLabels?: boolean
66
+ showLegend?: boolean
67
+ }
68
+
69
+ function normalizeDate(input: Date | string): Date | null {
70
+ if (input instanceof Date) {
71
+ if (Number.isNaN(input.getTime())) {
72
+ return null
73
+ }
74
+ return new Date(input.getFullYear(), input.getMonth(), input.getDate())
75
+ }
76
+
77
+ if (typeof input !== 'string') {
78
+ return null
79
+ }
80
+
81
+ const dateOnlyMatch = input.match(/^(\d{4})-(\d{2})-(\d{2})$/)
82
+ if (dateOnlyMatch) {
83
+ const year = Number(dateOnlyMatch[1])
84
+ const month = Number(dateOnlyMatch[2]) - 1
85
+ const day = Number(dateOnlyMatch[3])
86
+ return new Date(year, month, day)
87
+ }
88
+
89
+ const parsed = new Date(input)
90
+ if (Number.isNaN(parsed.getTime())) {
91
+ return null
92
+ }
93
+
94
+ return new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())
95
+ }
96
+
97
+ function normalizePoint(point: CalendarHeatmapData): NormalizedPoint | null {
98
+ const normalizedDate = normalizeDate(point.date)
99
+ if (!normalizedDate) {
100
+ return null
101
+ }
102
+
103
+ if (!Number.isFinite(point.value)) {
104
+ return null
105
+ }
106
+
107
+ const normalizedValue = Math.max(0, point.value)
108
+ return {
109
+ date: normalizedDate,
110
+ value: normalizedValue,
111
+ }
112
+ }
113
+
114
+ function toDateKey(date: Date): string {
115
+ const year = String(date.getFullYear())
116
+ const month = String(date.getMonth() + 1).padStart(2, '0')
117
+ const day = String(date.getDate()).padStart(2, '0')
118
+ return `${year}-${month}-${day}`
119
+ }
120
+
121
+ function getWeekStart(date: Date): Date {
122
+ const day = date.getDay()
123
+ const weekStart = new Date(date)
124
+ weekStart.setDate(date.getDate() - day)
125
+ return new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate())
126
+ }
127
+
128
+ function createEmptyWeekValues(): [number, number, number, number, number, number, number] {
129
+ return [0, 0, 0, 0, 0, 0, 0]
130
+ }
131
+
132
+ function getSectionGridWidth(section: MonthSection): number {
133
+ return section.weeks.length * CELL_STRIDE
134
+ }
135
+
136
+ function getSectionsGridWidth(sections: MonthSection[]): number {
137
+ if (sections.length === 0) {
138
+ return 0
139
+ }
140
+
141
+ return sections.reduce((total, section, index) => {
142
+ const sectionWidth = getSectionGridWidth(section)
143
+ if (index === 0) {
144
+ return sectionWidth
145
+ }
146
+ return total + MONTH_GAP + sectionWidth
147
+ }, 0)
148
+ }
149
+
150
+ export class CalendarHeatmapRenderable extends Renderable {
151
+ private _data: CalendarHeatmapData[] = []
152
+ private _cellChar: CalendarHeatmapCellChar = DEFAULT_CELL_CHAR
153
+ private _cellColor = '#00AA55'
154
+ private _backgroundColor = '#000000'
155
+ private _emptyColor = '#2B2B2B'
156
+ private _labelColor = '#888888'
157
+ private _showMonthLabels = true
158
+ private _showDayLabels = true
159
+ private _showLegend = true
160
+
161
+ private _sections: MonthSection[] = []
162
+ private _maxValue = 0
163
+
164
+ constructor(ctx: RenderContext, options: CalendarHeatmapGridOptions) {
165
+ super(ctx, options)
166
+
167
+ if (options.data) {
168
+ this._data = options.data
169
+ }
170
+ if (options.cellChar) {
171
+ this._cellChar = options.cellChar
172
+ }
173
+ if (options.cellColor) {
174
+ this._cellColor = options.cellColor
175
+ }
176
+ if (options.backgroundColor) {
177
+ this._backgroundColor = options.backgroundColor
178
+ }
179
+ if (options.emptyColor) {
180
+ this._emptyColor = options.emptyColor
181
+ }
182
+ if (options.labelColor) {
183
+ this._labelColor = options.labelColor
184
+ }
185
+ if (options.showMonthLabels !== undefined) {
186
+ this._showMonthLabels = options.showMonthLabels
187
+ }
188
+ if (options.showDayLabels !== undefined) {
189
+ this._showDayLabels = options.showDayLabels
190
+ }
191
+ if (options.showLegend !== undefined) {
192
+ this._showLegend = options.showLegend
193
+ }
194
+
195
+ this.recomputeData()
196
+ }
197
+
198
+ set data(value: CalendarHeatmapData[]) {
199
+ this._data = value
200
+ this.recomputeData()
201
+ this.requestRender()
202
+ }
203
+
204
+ set cellColor(value: string) {
205
+ this._cellColor = value
206
+ this.requestRender()
207
+ }
208
+
209
+ set cellChar(value: CalendarHeatmapCellChar) {
210
+ this._cellChar = value
211
+ this.requestRender()
212
+ }
213
+
214
+ set backgroundColor(value: string) {
215
+ this._backgroundColor = value
216
+ this.requestRender()
217
+ }
218
+
219
+ set emptyColor(value: string) {
220
+ this._emptyColor = value
221
+ this.requestRender()
222
+ }
223
+
224
+ set labelColor(value: string) {
225
+ this._labelColor = value
226
+ this.requestRender()
227
+ }
228
+
229
+ set showMonthLabels(value: boolean) {
230
+ this._showMonthLabels = value
231
+ this.requestRender()
232
+ }
233
+
234
+ set showDayLabels(value: boolean) {
235
+ this._showDayLabels = value
236
+ this.requestRender()
237
+ }
238
+
239
+ set showLegend(value: boolean) {
240
+ this._showLegend = value
241
+ this.requestRender()
242
+ }
243
+
244
+ private recomputeData(): void {
245
+ const normalizedPoints: NormalizedPoint[] = this._data.reduce<NormalizedPoint[]>((result, point) => {
246
+ const normalized = normalizePoint(point)
247
+ if (!normalized) {
248
+ return result
249
+ }
250
+ result.push(normalized)
251
+ return result
252
+ }, [])
253
+
254
+ normalizedPoints.sort((a, b) => {
255
+ return a.date.getTime() - b.date.getTime()
256
+ })
257
+
258
+ this._maxValue = normalizedPoints.reduce((maxValue, point) => {
259
+ return Math.max(maxValue, point.value)
260
+ }, 0)
261
+
262
+ const valueByDay = new Map<string, number>()
263
+ const dateByDay = new Map<string, Date>()
264
+
265
+ normalizedPoints.forEach((point) => {
266
+ const dayKey = toDateKey(point.date)
267
+ const currentValue = valueByDay.get(dayKey) || 0
268
+ valueByDay.set(dayKey, currentValue + point.value)
269
+ dateByDay.set(dayKey, point.date)
270
+ })
271
+
272
+ const weekMap = new Map<string, PreparedWeek>()
273
+
274
+ const dayEntries = Array.from(valueByDay.entries())
275
+ .reduce<Array<{ date: Date; value: number }>>((result, [dayKey, value]) => {
276
+ const date = dateByDay.get(dayKey)
277
+ if (!date) {
278
+ return result
279
+ }
280
+ result.push({ date, value })
281
+ return result
282
+ }, [])
283
+ .sort((a, b) => {
284
+ return a.date.getTime() - b.date.getTime()
285
+ })
286
+
287
+ dayEntries.forEach((entry) => {
288
+ const date = entry.date
289
+ const weekStart = getWeekStart(date)
290
+ const weekKey = toDateKey(weekStart)
291
+ const monthKey = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}`
292
+ const monthLabel = MONTH_LABELS[weekStart.getMonth()] || ''
293
+
294
+ const existingWeek = weekMap.get(weekKey)
295
+ if (existingWeek) {
296
+ const dayIndex = date.getDay()
297
+ existingWeek.values[dayIndex] = existingWeek.values[dayIndex] + entry.value
298
+ return
299
+ }
300
+
301
+ const values = createEmptyWeekValues()
302
+ values[date.getDay()] = entry.value
303
+ weekMap.set(weekKey, {
304
+ weekStart,
305
+ monthKey,
306
+ monthLabel,
307
+ values,
308
+ })
309
+ })
310
+
311
+ const sortedWeeks = Array.from(weekMap.values()).sort((a, b) => {
312
+ return a.weekStart.getTime() - b.weekStart.getTime()
313
+ })
314
+
315
+ const sections: MonthSection[] = []
316
+ sortedWeeks.forEach((week) => {
317
+ const lastSection = sections[sections.length - 1]
318
+ if (!lastSection || lastSection.key !== week.monthKey) {
319
+ sections.push({
320
+ key: week.monthKey,
321
+ label: week.monthLabel,
322
+ weeks: [week],
323
+ })
324
+ return
325
+ }
326
+
327
+ lastSection.weeks.push(week)
328
+ })
329
+
330
+ this._sections = sections
331
+ }
332
+
333
+ private resolveVisibleSections(availableGridWidth: number): MonthSection[] {
334
+ if (availableGridWidth <= 0 || this._sections.length === 0) {
335
+ return []
336
+ }
337
+
338
+ const visibleFromEnd: MonthSection[] = []
339
+ let usedWidth = 0
340
+
341
+ for (let i = this._sections.length - 1; i >= 0; i--) {
342
+ const section = this._sections[i]!
343
+ const sectionWidth = getSectionGridWidth(section)
344
+ const gapWidth = visibleFromEnd.length > 0 ? MONTH_GAP : 0
345
+ const nextWidth = usedWidth + gapWidth + sectionWidth
346
+ if (nextWidth > availableGridWidth) {
347
+ break
348
+ }
349
+ visibleFromEnd.unshift(section)
350
+ usedWidth = nextWidth
351
+ }
352
+
353
+ if (visibleFromEnd.length > 0) {
354
+ return visibleFromEnd
355
+ }
356
+
357
+ const latestSection = this._sections[this._sections.length - 1]
358
+ if (!latestSection) {
359
+ return []
360
+ }
361
+
362
+ const maxWeeks = Math.max(1, Math.floor(availableGridWidth / CELL_STRIDE))
363
+ const weeks = latestSection.weeks.slice(Math.max(0, latestSection.weeks.length - maxWeeks))
364
+
365
+ return [{
366
+ key: latestSection.key,
367
+ label: latestSection.label,
368
+ weeks,
369
+ }]
370
+ }
371
+
372
+ private valueToLevel(value: number): 0 | 1 | 2 | 3 | 4 {
373
+ if (value <= 0 || this._maxValue <= 0) {
374
+ return 0
375
+ }
376
+
377
+ const normalized = value / this._maxValue
378
+ if (normalized <= 0.25) {
379
+ return 1
380
+ }
381
+ if (normalized <= 0.5) {
382
+ return 2
383
+ }
384
+ if (normalized <= 0.75) {
385
+ return 3
386
+ }
387
+ return 4
388
+ }
389
+
390
+ private mixColors(a: RGBA, b: RGBA, ratio: number): RGBA {
391
+ const clampedRatio = Math.max(0, Math.min(1, ratio))
392
+ return RGBA.fromValues(
393
+ a.r + (b.r - a.r) * clampedRatio,
394
+ a.g + (b.g - a.g) * clampedRatio,
395
+ a.b + (b.b - a.b) * clampedRatio,
396
+ 1,
397
+ )
398
+ }
399
+
400
+ private buildLevelColors(): [RGBA, RGBA, RGBA, RGBA, RGBA] {
401
+ const primary = RGBA.fromHex(this._cellColor)
402
+ const secondary = RGBA.fromHex(this._emptyColor)
403
+
404
+ return [
405
+ secondary,
406
+ this.mixColors(secondary, primary, 0.25),
407
+ this.mixColors(secondary, primary, 0.5),
408
+ this.mixColors(secondary, primary, 0.75),
409
+ primary,
410
+ ]
411
+ }
412
+
413
+ private clearArea(buffer: OptimizedBuffer): void {
414
+ const bg = RGBA.fromHex(this._backgroundColor)
415
+ for (let row = 0; row < this.height; row++) {
416
+ for (let col = 0; col < this.width; col++) {
417
+ buffer.setCell(this.x + col, this.y + row, ' ', bg, bg)
418
+ }
419
+ }
420
+ }
421
+
422
+ protected renderSelf(buffer: OptimizedBuffer): void {
423
+ this.clearArea(buffer)
424
+
425
+ if (this.width <= 0 || this.height <= 0) {
426
+ return
427
+ }
428
+
429
+ if (this._sections.length === 0) {
430
+ return
431
+ }
432
+
433
+ let showMonthLabels = this._showMonthLabels
434
+ let showLegend = this._showLegend
435
+ const showDayLabels = this._showDayLabels
436
+
437
+ let requiredHeight = GRID_ROWS + (showMonthLabels ? 1 : 0) + (showLegend ? 1 : 0)
438
+ if (requiredHeight > this.height && showLegend) {
439
+ showLegend = false
440
+ requiredHeight = GRID_ROWS + (showMonthLabels ? 1 : 0)
441
+ }
442
+ if (requiredHeight > this.height && showMonthLabels) {
443
+ showMonthLabels = false
444
+ requiredHeight = GRID_ROWS
445
+ }
446
+ if (requiredHeight > this.height) {
447
+ return
448
+ }
449
+
450
+ const rightDayLabelsWidth = showDayLabels ? 5 : 0
451
+ const availableGridWidth = this.width - rightDayLabelsWidth
452
+ if (availableGridWidth <= 0) {
453
+ return
454
+ }
455
+
456
+ const visibleSections = this.resolveVisibleSections(availableGridWidth)
457
+ if (visibleSections.length === 0) {
458
+ return
459
+ }
460
+
461
+ const gridWidth = getSectionsGridWidth(visibleSections)
462
+ const gridStartX = this.x
463
+ const monthLabelsRow = this.y
464
+ const gridStartY = this.y + (showMonthLabels ? 1 : 0)
465
+
466
+ const levelColors = this.buildLevelColors()
467
+ const cellBackground = RGBA.fromHex(this._backgroundColor)
468
+ const labelColor = RGBA.fromHex(this._labelColor)
469
+
470
+ let cursorX = gridStartX
471
+ visibleSections.forEach((section, sectionIndex) => {
472
+ const sectionStartX = cursorX
473
+
474
+ section.weeks.forEach((week) => {
475
+ week.values.forEach((value, rowIndex) => {
476
+ const level = this.valueToLevel(value)
477
+ if (level === 0) {
478
+ // Skip rendering — leave as background space so AI agents
479
+ // can see which days had no activity from text alone
480
+ return
481
+ }
482
+ const color = levelColors[level]
483
+ // Level 1-2 uses ◼ (U+25FC), level 3-4 uses the full cellChar (■ U+25A0).
484
+ // Visually identical in terminal fonts, but different codepoints for agents.
485
+ const char = level <= 2 ? HALF_CELL_CHAR : this._cellChar
486
+ const y = gridStartY + rowIndex
487
+ buffer.setCell(cursorX, y, char, color, cellBackground)
488
+ })
489
+ cursorX += CELL_STRIDE
490
+ })
491
+
492
+ if (showMonthLabels) {
493
+ const sectionWidth = getSectionGridWidth(section)
494
+ const label = section.label.slice(0, sectionWidth)
495
+ buffer.drawText(label, sectionStartX, monthLabelsRow, labelColor)
496
+ }
497
+
498
+ if (sectionIndex < visibleSections.length - 1) {
499
+ cursorX += MONTH_GAP
500
+ }
501
+ })
502
+
503
+ if (showDayLabels) {
504
+ const labelsX = gridStartX + gridWidth + 1
505
+ buffer.drawText('Mon', labelsX, gridStartY + MONDAY_ROW, labelColor)
506
+ buffer.drawText('Wed', labelsX, gridStartY + WEDNESDAY_ROW, labelColor)
507
+ buffer.drawText('Fri', labelsX, gridStartY + FRIDAY_ROW, labelColor)
508
+ }
509
+
510
+ if (!showLegend) {
511
+ return
512
+ }
513
+
514
+ const legendY = gridStartY + GRID_ROWS
515
+ const legendPrefix = 'Less '
516
+ const legendSuffix = ' More'
517
+ const legendSquaresWidth = 9
518
+ const legendWidth = legendPrefix.length + legendSquaresWidth + legendSuffix.length
519
+ const legendX = Math.max(gridStartX, gridStartX + gridWidth - legendWidth)
520
+
521
+ buffer.drawText(legendPrefix, legendX, legendY, labelColor)
522
+
523
+ let legendCursorX = legendX + legendPrefix.length
524
+ for (let level = 0; level <= 4; level++) {
525
+ if (level === 0) {
526
+ // Space for level 0 (matches the grid: no char for zero activity)
527
+ legendCursorX += 1
528
+ } else {
529
+ const color = levelColors[level]
530
+ const char = level <= 2 ? HALF_CELL_CHAR : this._cellChar
531
+ buffer.setCell(legendCursorX, legendY, char, color, cellBackground)
532
+ legendCursorX += 1
533
+ }
534
+ if (level < 4) {
535
+ legendCursorX += 1
536
+ }
537
+ }
538
+
539
+ buffer.drawText(legendSuffix, legendCursorX, legendY, labelColor)
540
+ }
541
+ }
542
+
543
+ extend({ 'heatmap-grid': CalendarHeatmapRenderable })
544
+
545
+ declare module '@opentui/react' {
546
+ interface OpenTUIComponents {
547
+ 'heatmap-grid': typeof CalendarHeatmapRenderable
548
+ }
549
+ }
550
+
551
+ export interface CalendarHeatmapProps {
552
+ data: CalendarHeatmapData[]
553
+ cellChar?: CalendarHeatmapCellChar
554
+ color?: Color.ColorLike
555
+ emptyColor?: Color.ColorLike
556
+ showMonthLabels?: boolean
557
+ showDayLabels?: boolean
558
+ showLegend?: boolean
559
+ }
560
+
561
+ interface CalendarHeatmapType {
562
+ (props: CalendarHeatmapProps): any
563
+ }
564
+
565
+ const CalendarHeatmap: CalendarHeatmapType = (props) => {
566
+ const theme = useTheme()
567
+ const {
568
+ data,
569
+ cellChar = DEFAULT_CELL_CHAR,
570
+ color,
571
+ emptyColor,
572
+ showMonthLabels = true,
573
+ showDayLabels = true,
574
+ showLegend = true,
575
+ } = props
576
+
577
+ const resolvedColor = resolveColor(color) || theme.primary
578
+ const resolvedEmptyColor = resolveColor(emptyColor) || theme.background
579
+ const computedHeight = GRID_ROWS + (showMonthLabels ? 1 : 0) + (showLegend ? 1 : 0)
580
+
581
+ return (
582
+ <heatmap-grid
583
+ width="100%"
584
+ height={computedHeight}
585
+ data={data}
586
+ cellChar={cellChar}
587
+ cellColor={resolvedColor}
588
+ backgroundColor={theme.background}
589
+ emptyColor={resolvedEmptyColor}
590
+ labelColor={theme.textMuted}
591
+ showMonthLabels={showMonthLabels}
592
+ showDayLabels={showDayLabels}
593
+ showLegend={showLegend}
594
+ />
595
+ )
596
+ }
597
+
598
+ export type HeatmapData = CalendarHeatmapData
599
+ export type HeatmapCellChar = CalendarHeatmapCellChar
600
+ export type HeatmapProps = CalendarHeatmapProps
601
+
602
+ export { CalendarHeatmap, CalendarHeatmap as Heatmap }