tau-all-agent 0.2.0 → 0.3.1

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 (44) hide show
  1. package/extensions/answer.ts +115 -85
  2. package/extensions/branch-term/index.ts +9 -5
  3. package/extensions/btw.ts +70 -36
  4. package/extensions/insights.ts +70 -56
  5. package/extensions/loop.ts +14 -52
  6. package/extensions/memory.ts +65 -33
  7. package/extensions/notify.ts +5 -3
  8. package/extensions/review/fix.ts +10 -16
  9. package/extensions/review/git.ts +7 -1
  10. package/extensions/review/index.ts +2 -2
  11. package/extensions/review/models.ts +12 -2
  12. package/extensions/review/review.ts +3 -5
  13. package/extensions/review/runtime.ts +9 -8
  14. package/extensions/review/triage.ts +2 -4
  15. package/extensions/sandbox/README.md +7 -0
  16. package/extensions/sandbox/bash.ts +83 -85
  17. package/extensions/sandbox/command.ts +15 -1
  18. package/extensions/sandbox/config.ts +26 -15
  19. package/extensions/sandbox/index.ts +21 -11
  20. package/extensions/sandbox/permissions/network.ts +8 -3
  21. package/extensions/sandbox/permissions/unsandboxed.ts +1 -240
  22. package/extensions/sandbox/runtime.ts +54 -20
  23. package/extensions/subagent/README.md +58 -1
  24. package/extensions/subagent/config.ts +118 -0
  25. package/extensions/subagent/index.ts +54 -22
  26. package/extensions/telegram/README.md +2 -0
  27. package/extensions/telegram/assistant-result.d.mts +4 -1
  28. package/extensions/telegram/assistant-result.mjs +31 -16
  29. package/extensions/telegram/daemon.mjs +146 -194
  30. package/extensions/telegram/index.ts +11 -7
  31. package/extensions/tool-display-mode.ts +29 -50
  32. package/extensions/usage/README.md +5 -0
  33. package/extensions/usage/index.ts +341 -349
  34. package/extensions/websearch/README.md +8 -1
  35. package/extensions/websearch/config.ts +2 -1
  36. package/extensions/websearch/index.ts +18 -9
  37. package/extensions/websearch/providers/{openai-codex.pi.ts → openai.pi.ts} +51 -16
  38. package/extensions/websearch/providers/shared.ts +10 -3
  39. package/extensions/websearch/types.ts +2 -1
  40. package/extensions/worktree.ts +33 -89
  41. package/package.json +1 -1
  42. package/skills/oracle/SKILL.md +14 -8
  43. package/skills/oracle/scripts/oracle +43 -19
  44. package/skills/update-changelog/SKILL.md +8 -4
@@ -3,14 +3,22 @@ import {
3
3
  getAgentDir,
4
4
  type ExtensionAPI,
5
5
  type ExtensionContext,
6
+ type Theme,
6
7
  } from "@earendil-works/pi-coding-agent";
7
8
  import {
9
+ colorToOkhsl,
8
10
  Key,
9
11
  matchesKey,
12
+ mixColors,
13
+ okhslColor,
14
+ rgbColor,
15
+ type Color,
10
16
  type Component,
17
+ type KeybindingsManager,
11
18
  type TUI,
12
19
  truncateToWidth,
13
20
  visibleWidth,
21
+ wrapTextWithAnsi,
14
22
  } from "@earendil-works/pi-tui";
15
23
  import { createHash } from "node:crypto";
16
24
  import { createReadStream, type Dirent } from "node:fs";
@@ -52,37 +60,6 @@ const TOD_BUCKETS: { key: TodKey; label: string; from: number; to: number }[] =
52
60
  { key: "night", label: "Night (22–23)", from: 22, to: 23 },
53
61
  ];
54
62
 
55
- const DEFAULT_BG: RGB = { r: 13, g: 17, b: 23 };
56
- const EMPTY_CELL_BG: RGB = { r: 22, g: 27, b: 34 };
57
- const ERROR_COLOR: RGB = { r: 244, g: 67, b: 54 };
58
- const MUTED_COLOR: RGB = { r: 160, g: 160, b: 160 };
59
-
60
- const PALETTE: RGB[] = [
61
- { r: 54, g: 179, b: 166 },
62
- { r: 82, g: 146, b: 247 },
63
- { r: 171, g: 123, b: 245 },
64
- { r: 232, g: 180, b: 72 },
65
- { r: 244, g: 67, b: 54 },
66
- ];
67
-
68
- const DOW_PALETTE: RGB[] = [
69
- { r: 47, g: 129, b: 247 },
70
- { r: 64, g: 196, b: 99 },
71
- { r: 163, g: 113, b: 247 },
72
- { r: 47, g: 175, b: 200 },
73
- { r: 100, g: 200, b: 150 },
74
- { r: 255, g: 159, b: 10 },
75
- { r: 244, g: 67, b: 54 },
76
- ];
77
-
78
- const TOD_PALETTE: Map<TodKey, RGB> = new Map([
79
- ["after-midnight", { r: 100, g: 60, b: 180 }],
80
- ["morning", { r: 255, g: 200, b: 50 }],
81
- ["afternoon", { r: 64, g: 196, b: 99 }],
82
- ["evening", { r: 47, g: 129, b: 247 }],
83
- ["night", { r: 60, g: 40, b: 140 }],
84
- ]);
85
-
86
63
  type HistoryProviderId = string;
87
64
  type ModelKey = string;
88
65
  type CwdKey = string;
@@ -164,13 +141,13 @@ interface RangeAgg {
164
141
 
165
142
  interface ProviderPalette {
166
143
  modelPalette: {
167
- modelColors: Map<ModelKey, RGB>;
168
- otherColor: RGB;
144
+ modelColors: Map<ModelKey, Color>;
145
+ otherColor: Color;
169
146
  orderedModels: ModelKey[];
170
147
  };
171
148
  cwdPalette: {
172
- cwdColors: Map<CwdKey, RGB>;
173
- otherColor: RGB;
149
+ cwdColors: Map<CwdKey, Color>;
150
+ otherColor: Color;
174
151
  orderedCwds: CwdKey[];
175
152
  };
176
153
  }
@@ -188,16 +165,6 @@ interface BreakdownData {
188
165
  liveUsage: Map<SupportedProviderId, LiveUsageState>;
189
166
  allRanges: Map<number, RangeAgg>;
190
167
  providerRangesById: Map<HistoryProviderId, Map<number, RangeAgg>>;
191
- allPalette: ProviderPalette;
192
- providerPalettes: Map<HistoryProviderId, ProviderPalette>;
193
- dowPalette: {
194
- dowColors: Map<DowKey, RGB>;
195
- orderedDows: DowKey[];
196
- };
197
- todPalette: {
198
- todColors: Map<TodKey, RGB>;
199
- orderedTods: TodKey[];
200
- };
201
168
  }
202
169
 
203
170
  type BreakdownProgressPhase = "scan" | "parse" | "finalize";
@@ -227,44 +194,23 @@ function clamp01(value: number): number {
227
194
  return Math.max(0, Math.min(1, value));
228
195
  }
229
196
 
230
- function lerp(a: number, b: number, t: number): number {
231
- return a + (b - a) * t;
232
- }
233
-
234
- function mixRgb(a: RGB, b: RGB, t: number): RGB {
235
- return {
236
- r: Math.round(lerp(a.r, b.r, t)),
237
- g: Math.round(lerp(a.g, b.g, t)),
238
- b: Math.round(lerp(a.b, b.b, t)),
239
- };
240
- }
241
-
242
- function weightedMix(colors: Array<{ color: RGB; weight: number }>): RGB {
197
+ function weightedMix(colors: Array<{ color: Color; weight: number }>, fallback: Color): Color {
243
198
  let total = 0;
244
- let r = 0;
245
- let g = 0;
246
- let b = 0;
199
+ let mixed: Color | undefined;
247
200
  for (const entry of colors) {
248
201
  if (!Number.isFinite(entry.weight) || entry.weight <= 0) continue;
249
202
  total += entry.weight;
250
- r += entry.color.r * entry.weight;
251
- g += entry.color.g * entry.weight;
252
- b += entry.color.b * entry.weight;
203
+ mixed = mixed ? mixColors(mixed, entry.color, entry.weight / total, "srgb") : entry.color;
253
204
  }
254
- if (total <= 0) return EMPTY_CELL_BG;
255
- return { r: Math.round(r / total), g: Math.round(g / total), b: Math.round(b / total) };
256
- }
257
-
258
- function ansiFg(rgb: RGB, text: string): string {
259
- return `\x1b[38;2;${rgb.r};${rgb.g};${rgb.b}m${text}\x1b[0m`;
205
+ return mixed ?? fallback;
260
206
  }
261
207
 
262
- function dim(text: string): string {
263
- return `\x1b[2m${text}\x1b[0m`;
208
+ function emptyChartColor(theme: Theme): Color {
209
+ return mixColors(theme.colors.toolPendingBg, theme.colors.muted, 0.25);
264
210
  }
265
211
 
266
- function bold(text: string): string {
267
- return `\x1b[1m${text}\x1b[0m`;
212
+ function truncateUsageLine(theme: Theme, text: string, width: number): string {
213
+ return truncateToWidth(text, width, theme.style("...", { fg: "text", bg: "toolPendingBg" }));
268
214
  }
269
215
 
270
216
  function formatUsd(value: number): string {
@@ -721,88 +667,29 @@ function metricValuesForKind<K>(
721
667
  return { values: options.sessions, total: options.totalSessions };
722
668
  }
723
669
 
724
- function normalizeHue(value: number): number {
725
- const normalized = value % 360;
726
- return normalized < 0 ? normalized + 360 : normalized;
727
- }
728
-
729
- function rgbToHsl(color: RGB): { h: number; s: number; l: number } {
730
- const r = color.r / 255;
731
- const g = color.g / 255;
732
- const b = color.b / 255;
733
- const max = Math.max(r, g, b);
734
- const min = Math.min(r, g, b);
735
- const lightness = (max + min) / 2;
736
-
737
- if (max === min) {
738
- return { h: 0, s: 0, l: lightness };
739
- }
740
-
741
- const delta = max - min;
742
- const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
743
-
744
- let hue = 0;
745
- if (max === r) hue = (g - b) / delta + (g < b ? 6 : 0);
746
- else if (max === g) hue = (b - r) / delta + 2;
747
- else hue = (r - g) / delta + 4;
748
-
749
- return { h: normalizeHue(hue * 60), s: saturation, l: lightness };
750
- }
751
-
752
- function hslToRgb(hue: number, saturation: number, lightness: number): RGB {
753
- const h = normalizeHue(hue) / 360;
754
- const s = clamp01(saturation);
755
- const l = clamp01(lightness);
756
-
757
- if (s === 0) {
758
- const value = Math.round(l * 255);
759
- return { r: value, g: value, b: value };
760
- }
761
-
762
- const hueToChannel = (p: number, q: number, t: number): number => {
763
- let value = t;
764
- if (value < 0) value += 1;
765
- if (value > 1) value -= 1;
766
- if (value < 1 / 6) return p + (q - p) * 6 * value;
767
- if (value < 1 / 2) return q;
768
- if (value < 2 / 3) return p + (q - p) * (2 / 3 - value) * 6;
769
- return p;
770
- };
771
-
772
- const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
773
- const p = 2 * l - q;
774
-
775
- return {
776
- r: Math.round(hueToChannel(p, q, h + 1 / 3) * 255),
777
- g: Math.round(hueToChannel(p, q, h) * 255),
778
- b: Math.round(hueToChannel(p, q, h - 1 / 3) * 255),
779
- };
780
- }
781
-
782
- function adjustColor(
783
- color: RGB,
784
- adjustments: { hueShift?: number; saturationShift?: number; lightnessShift?: number },
785
- ): RGB {
786
- const hsl = rgbToHsl(color);
787
- return hslToRgb(
788
- hsl.h + (adjustments.hueShift ?? 0),
789
- hsl.s + (adjustments.saturationShift ?? 0),
790
- hsl.l + (adjustments.lightnessShift ?? 0),
791
- );
670
+ function providerColor(theme: Theme, color: RGB): Color {
671
+ const source = colorToOkhsl(rgbColor(color.r, color.g, color.b));
672
+ const reference = colorToOkhsl(theme.colors.accent);
673
+ // Keep the provider hue, but match the active theme's brightness and restraint.
674
+ return okhslColor(source.h, Math.min(source.s, reference.s), reference.l);
792
675
  }
793
676
 
794
- function buildProviderModelPalette(color: RGB): readonly RGB[] {
677
+ function buildProviderModelPalette(theme: Theme, color: RGB): readonly Color[] {
678
+ const base = providerColor(theme, color);
679
+ const { h, s, l } = colorToOkhsl(base);
680
+ const lightnessRange = (theme.appearance === "dark" ? 1 : 0) - l;
795
681
  return [
796
- color,
797
- adjustColor(color, { hueShift: 16, saturationShift: 0.04, lightnessShift: 0.08 }),
798
- adjustColor(color, { hueShift: -12, saturationShift: 0.02, lightnessShift: -0.06 }),
682
+ base,
683
+ okhslColor(h + 16, s * 0.85, l + lightnessRange * 0.3),
684
+ okhslColor(h - 12, s * 0.7, l + lightnessRange * 0.6),
799
685
  ];
800
686
  }
801
687
 
802
688
  function chooseModelPalette(
803
689
  range30: RangeAgg,
690
+ paletteColors: readonly Color[],
691
+ otherColor: Color,
804
692
  topN = 4,
805
- paletteColors: readonly RGB[] = PALETTE,
806
693
  ): ProviderPalette["modelPalette"] {
807
694
  const popularity = preferredPopularityMap({
808
695
  cost: range30.modelCost,
@@ -816,22 +703,27 @@ function chooseModelPalette(
816
703
  const orderedModels = sortMapByValueDesc(popularity)
817
704
  .slice(0, topN)
818
705
  .map((entry) => entry.key);
819
- const modelColors = new Map<ModelKey, RGB>();
706
+ const modelColors = new Map<ModelKey, Color>();
820
707
 
821
708
  for (let index = 0; index < orderedModels.length; index += 1) {
822
709
  const model = orderedModels[index];
823
- const color = paletteColors[index] ?? PALETTE[index % PALETTE.length] ?? MUTED_COLOR;
710
+ const color = paletteColors[index % paletteColors.length] ?? otherColor;
824
711
  if (model) modelColors.set(model, color);
825
712
  }
826
713
 
827
714
  return {
828
715
  modelColors,
829
- otherColor: MUTED_COLOR,
716
+ otherColor,
830
717
  orderedModels,
831
718
  };
832
719
  }
833
720
 
834
- function chooseCwdPalette(range30: RangeAgg, topN = 4): ProviderPalette["cwdPalette"] {
721
+ function chooseCwdPalette(
722
+ range30: RangeAgg,
723
+ paletteColors: readonly Color[],
724
+ otherColor: Color,
725
+ topN = 4,
726
+ ): ProviderPalette["cwdPalette"] {
835
727
  const popularity = preferredPopularityMap({
836
728
  cost: range30.cwdCost,
837
729
  tokens: range30.cwdTokens,
@@ -844,47 +736,51 @@ function chooseCwdPalette(range30: RangeAgg, topN = 4): ProviderPalette["cwdPale
844
736
  const orderedCwds = sortMapByValueDesc(popularity)
845
737
  .slice(0, topN)
846
738
  .map((entry) => entry.key);
847
- const cwdColors = new Map<CwdKey, RGB>();
739
+ const cwdColors = new Map<CwdKey, Color>();
848
740
  for (let index = 0; index < orderedCwds.length; index += 1) {
849
741
  const cwd = orderedCwds[index];
850
- if (cwd) cwdColors.set(cwd, PALETTE[index % PALETTE.length]!);
742
+ if (cwd) cwdColors.set(cwd, paletteColors[index % paletteColors.length] ?? otherColor);
851
743
  }
852
744
 
853
745
  return {
854
746
  cwdColors,
855
- otherColor: MUTED_COLOR,
747
+ otherColor,
856
748
  orderedCwds,
857
749
  };
858
750
  }
859
751
 
860
- function buildDowPalette(): BreakdownData["dowPalette"] {
861
- const dowColors = new Map<DowKey, RGB>();
862
- for (let index = 0; index < DOW_NAMES.length; index += 1) {
863
- const dow = DOW_NAMES[index];
864
- const color = DOW_PALETTE[index];
865
- if (dow && color) dowColors.set(dow, color);
866
- }
867
- return { dowColors, orderedDows: [...DOW_NAMES] };
752
+ function buildDowPalette(theme: Theme): Map<DowKey, Color> {
753
+ const colors = theme.colors;
754
+ const palette = [
755
+ colors.mdLink,
756
+ colors.success,
757
+ colors.accent,
758
+ colors.syntaxVariable,
759
+ mixColors(colors.success, colors.syntaxVariable, 0.5),
760
+ colors.warning,
761
+ colors.error,
762
+ ];
763
+ return new Map(DOW_NAMES.map((day, index) => [day, palette[index]!]));
868
764
  }
869
765
 
870
- function buildTodPalette(): BreakdownData["todPalette"] {
871
- const todColors = new Map<TodKey, RGB>();
872
- const orderedTods: TodKey[] = [];
873
- for (const bucket of TOD_BUCKETS) {
874
- const color = TOD_PALETTE.get(bucket.key);
875
- if (color) todColors.set(bucket.key, color);
876
- orderedTods.push(bucket.key);
877
- }
878
- return { todColors, orderedTods };
766
+ function buildTodPalette(theme: Theme): Map<TodKey, Color> {
767
+ const colors = theme.colors;
768
+ return new Map([
769
+ ["after-midnight", colors.accent],
770
+ ["morning", colors.warning],
771
+ ["afternoon", colors.success],
772
+ ["evening", colors.mdLink],
773
+ ["night", colors.syntaxString],
774
+ ]);
879
775
  }
880
776
 
881
777
  function dayMixedColor(
882
778
  day: DayAgg,
883
- colorMap: Map<string, RGB>,
884
- otherColor: RGB,
779
+ colorMap: Map<string, Color>,
780
+ otherColor: Color,
885
781
  mode: MeasurementMode,
886
782
  view: BreakdownView,
887
- ): RGB {
783
+ ): Color {
888
784
  if (view === "dow") {
889
785
  const dowKey = DOW_NAMES[mondayIndex(day.date)] ?? "Mon";
890
786
  return colorMap.get(dowKey) ?? otherColor;
@@ -932,7 +828,7 @@ function dayMixedColor(
932
828
  : day.sessionsByModel;
933
829
  }
934
830
 
935
- const parts: Array<{ color: RGB; weight: number }> = [];
831
+ const parts: Array<{ color: Color; weight: number }> = [];
936
832
  let otherWeight = 0;
937
833
  for (const [key, weight] of weights.entries()) {
938
834
  const color = colorMap.get(key);
@@ -941,7 +837,7 @@ function dayMixedColor(
941
837
  }
942
838
  if (otherWeight > 0) parts.push({ color: otherColor, weight: otherWeight });
943
839
 
944
- return weightedMix(parts);
840
+ return weightedMix(parts, otherColor);
945
841
  }
946
842
 
947
843
  interface GraphMetric {
@@ -979,9 +875,10 @@ function weeksForRange(range: RangeAgg): number {
979
875
  }
980
876
 
981
877
  function renderGraphLines(
878
+ theme: Theme,
982
879
  range: RangeAgg,
983
- colorMap: Map<string, RGB>,
984
- otherColor: RGB,
880
+ colorMap: Map<string, Color>,
881
+ otherColor: Color,
985
882
  mode: MeasurementMode,
986
883
  options?: { cellWidth?: number; gap?: number },
987
884
  view: BreakdownView = "model",
@@ -1002,6 +899,8 @@ function renderGraphLines(
1002
899
  const gap = Math.max(0, Math.floor(options?.gap ?? 1));
1003
900
  const block = "█".repeat(cellWidth);
1004
901
  const gapString = " ".repeat(gap);
902
+ const emptyColor = emptyChartColor(theme);
903
+ const emptyCell = theme.style(block, { fg: emptyColor });
1005
904
 
1006
905
  const metric = graphMetricForRange(range, mode);
1007
906
  const denom = metric.denom;
@@ -1035,14 +934,14 @@ function renderGraphLines(
1035
934
  : (day?.sessions ?? 0);
1036
935
 
1037
936
  if (!day || value <= 0) {
1038
- line += ansiFg(EMPTY_CELL_BG, block) + columnGap;
937
+ line += emptyCell + columnGap;
1039
938
  continue;
1040
939
  }
1041
940
 
1042
941
  const hue = dayMixedColor(day, colorMap, otherColor, mode, view);
1043
942
  const intensity = 0.2 + 0.8 * clamp01(denom > 0 ? Math.log1p(value) / denom : 0);
1044
- const rgb = mixRgb(DEFAULT_BG, hue, intensity);
1045
- line += ansiFg(rgb, block) + columnGap;
943
+ const color = intensity === 1 ? hue : mixColors(emptyColor, hue, intensity);
944
+ line += theme.style(block, { fg: color }) + columnGap;
1046
945
  }
1047
946
 
1048
947
  lines.push(line);
@@ -1052,9 +951,10 @@ function renderGraphLines(
1052
951
  }
1053
952
 
1054
953
  function renderDowDistributionLines(
954
+ theme: Theme,
1055
955
  range: RangeAgg,
1056
956
  mode: MeasurementMode,
1057
- dowColors: Map<DowKey, RGB>,
957
+ dowColors: Map<DowKey, Color>,
1058
958
  width: number,
1059
959
  ): string[] {
1060
960
  const metric = graphMetricForRange(range, mode);
@@ -1073,6 +973,7 @@ function renderDowDistributionLines(
1073
973
  const showValue = width >= dayWidth + 1 + 10 + 1 + pctWidth + 1 + valueWidth;
1074
974
  const fixedWidth = dayWidth + 1 + 1 + pctWidth + (showValue ? 1 + valueWidth : 0);
1075
975
  const barWidth = Math.max(1, width - fixedWidth);
976
+ const emptyColor = emptyChartColor(theme);
1076
977
 
1077
978
  const lines: string[] = [];
1078
979
  for (const dow of DOW_NAMES) {
@@ -1083,14 +984,13 @@ function renderDowDistributionLines(
1083
984
  filled = Math.min(barWidth, filled);
1084
985
  const empty = Math.max(0, barWidth - filled);
1085
986
 
1086
- const color = dowColors.get(dow) ?? MUTED_COLOR;
1087
- const filledBar = filled > 0 ? ansiFg(color, "█".repeat(filled)) : "";
1088
- const emptyBar = empty > 0 ? ansiFg(EMPTY_CELL_BG, "█".repeat(empty)) : "";
987
+ const color = dowColors.get(dow) ?? theme.colors.muted;
988
+ const filledBar = filled > 0 ? theme.style("█".repeat(filled), { fg: color }) : "";
989
+ const emptyBar = empty > 0 ? theme.style("█".repeat(empty), { fg: emptyColor }) : "";
1089
990
  const pct = padLeft(`${Math.round(share * 100)}%`, pctWidth);
1090
991
 
1091
- let line = `${padRight(dow, dayWidth)} ${filledBar}${emptyBar} ${pct}`;
1092
- if (showValue) line += ` ${padLeft(formatCount(value), valueWidth)}`;
1093
- lines.push(line);
992
+ const stats = `${pct}${showValue ? ` ${padLeft(formatCount(value), valueWidth)}` : ""}`;
993
+ lines.push(`${padRight(dow, dayWidth)} ${filledBar}${emptyBar} ${theme.fg("text", stats)}`);
1094
994
  }
1095
995
 
1096
996
  return lines;
@@ -1103,6 +1003,7 @@ function formatModelLabel(modelKey: string, stripProvider: boolean): string {
1103
1003
  }
1104
1004
 
1105
1005
  function renderModelTable(
1006
+ theme: Theme,
1106
1007
  range: RangeAgg,
1107
1008
  metric: GraphMetric,
1108
1009
  maxRows = 8,
@@ -1142,11 +1043,11 @@ function renderModelTable(
1142
1043
  );
1143
1044
  }
1144
1045
 
1145
- if (sorted.length === 0) lines.push(dim("(no model data found)"));
1046
+ if (sorted.length === 0) lines.push(theme.fg("muted", "(no model data found)"));
1146
1047
  return lines;
1147
1048
  }
1148
1049
 
1149
- function renderCwdTable(range: RangeAgg, metric: GraphMetric, maxRows = 8): string[] {
1050
+ function renderCwdTable(theme: Theme, range: RangeAgg, metric: GraphMetric, maxRows = 8): string[] {
1150
1051
  const { values: perCwd, total } = metricValuesForKind(metric.kind, {
1151
1052
  sessions: range.cwdSessions,
1152
1053
  messages: range.cwdMessages,
@@ -1184,7 +1085,7 @@ function renderCwdTable(range: RangeAgg, metric: GraphMetric, maxRows = 8): stri
1184
1085
  );
1185
1086
  }
1186
1087
 
1187
- if (sorted.length === 0) lines.push(dim("(no directory data found)"));
1088
+ if (sorted.length === 0) lines.push(theme.fg("muted", "(no directory data found)"));
1188
1089
  return lines;
1189
1090
  }
1190
1091
 
@@ -1539,26 +1440,6 @@ async function computeBreakdown(
1539
1440
  );
1540
1441
  for (const file of files) addParsedFile(file);
1541
1442
 
1542
- const allPaletteRange = allRanges.get(30)!;
1543
- const allPalette: ProviderPalette = {
1544
- modelPalette: chooseModelPalette(allPaletteRange),
1545
- cwdPalette: chooseCwdPalette(allPaletteRange),
1546
- };
1547
-
1548
- const providerPalettes = new Map<HistoryProviderId, ProviderPalette>();
1549
- for (const [providerId, ranges] of providerRangesById.entries()) {
1550
- const range30 = ranges.get(30)!;
1551
- const provider = getSupportedProvider(providerId);
1552
- providerPalettes.set(providerId, {
1553
- modelPalette: chooseModelPalette(
1554
- range30,
1555
- 3,
1556
- provider ? buildProviderModelPalette(provider.color) : PALETTE,
1557
- ),
1558
- cwdPalette: chooseCwdPalette(range30),
1559
- });
1560
- }
1561
-
1562
1443
  const liveAvailableProviders = getLiveAvailableProviders(availabilityByProvider);
1563
1444
 
1564
1445
  return {
@@ -1566,14 +1447,11 @@ async function computeBreakdown(
1566
1447
  liveUsage: buildInitialLiveUsage(SUPPORTED_PROVIDERS, availabilityByProvider),
1567
1448
  allRanges,
1568
1449
  providerRangesById,
1569
- allPalette,
1570
- providerPalettes,
1571
- dowPalette: buildDowPalette(),
1572
- todPalette: buildTodPalette(),
1573
1450
  };
1574
1451
  }
1575
1452
 
1576
1453
  function renderUsageMeterBar(
1454
+ theme: Theme,
1577
1455
  providerId: SupportedProviderId,
1578
1456
  meter: LiveUsageMeter,
1579
1457
  width: number,
@@ -1581,9 +1459,12 @@ function renderUsageMeterBar(
1581
1459
  const barWidth = Math.max(10, Math.min(32, width));
1582
1460
  const filled = Math.min(barWidth, Math.max(0, Math.round((barWidth * meter.usedPercent) / 100)));
1583
1461
  const empty = Math.max(0, barWidth - filled);
1584
- const color = getSupportedProvider(providerId)?.color ?? MUTED_COLOR;
1585
- const bar = `${ansiFg(color, "█".repeat(filled))}${ansiFg(EMPTY_CELL_BG, "█".repeat(empty))}`;
1586
- return truncateToWidth(bar, width);
1462
+ const provider = getSupportedProvider(providerId);
1463
+ const color = provider ? providerColor(theme, provider.color) : theme.colors.muted;
1464
+ const bar =
1465
+ theme.style("█".repeat(filled), { fg: color }) +
1466
+ theme.style("█".repeat(empty), { fg: emptyChartColor(theme) });
1467
+ return truncateUsageLine(theme, bar, width);
1587
1468
  }
1588
1469
 
1589
1470
  function formatUsageHeader(
@@ -1600,14 +1481,14 @@ function formatUsageHeader(
1600
1481
  }
1601
1482
 
1602
1483
  function renderUsageHeader(
1484
+ theme: Theme,
1603
1485
  plan: string | undefined,
1604
1486
  fetchedAt: Date | undefined,
1605
- width: number,
1606
1487
  ): string {
1607
1488
  const header = formatUsageHeader(plan, fetchedAt);
1608
- let text = bold(header.title);
1609
- if (header.fetched) text += ` ${dim(header.fetched)}`;
1610
- return truncateToWidth(text, width);
1489
+ let text = theme.bold(header.title);
1490
+ if (header.fetched) text += ` ${theme.fg("muted", header.fetched)}`;
1491
+ return text;
1611
1492
  }
1612
1493
 
1613
1494
  type ResolvedLiveUsageState =
@@ -1641,26 +1522,24 @@ function resolveLiveUsageState(
1641
1522
  return { kind: "ok", snapshot: liveState.snapshot };
1642
1523
  }
1643
1524
 
1644
- function formatLiveUsageItemLine(
1645
- item: LiveUsageItem,
1646
- options?: { dimReset?: boolean; emphasizeStatValue?: boolean },
1647
- ): string {
1525
+ function formatLiveUsageItemLine(item: LiveUsageItem, theme?: Theme): string {
1648
1526
  if (item.kind === "meter") {
1649
1527
  const label = item.windowLabel ? `${item.label} (${item.windowLabel})` : item.label;
1650
1528
  let text = `${label}: ${formatUsedPercent(item.usedPercent)}`;
1651
1529
  if (item.detail) text += ` · ${item.detail}`;
1652
1530
  if (item.resetDescription) {
1653
- const reset = options?.dimReset ? dim(item.resetDescription) : item.resetDescription;
1531
+ const reset = theme ? theme.fg("muted", item.resetDescription) : item.resetDescription;
1654
1532
  text += ` · ${reset}`;
1655
1533
  }
1656
1534
  return text;
1657
1535
  }
1658
1536
 
1659
- const value = options?.emphasizeStatValue ? bold(item.value) : item.value;
1537
+ const value = theme ? theme.bold(item.value) : item.value;
1660
1538
  return `${item.label}: ${value}${item.detail ? ` · ${item.detail}` : ""}`;
1661
1539
  }
1662
1540
 
1663
1541
  function renderLiveUsageLines(
1542
+ theme: Theme,
1664
1543
  tab: BreakdownTab,
1665
1544
  liveState: LiveUsageState | undefined,
1666
1545
  width: number,
@@ -1669,33 +1548,26 @@ function renderLiveUsageLines(
1669
1548
 
1670
1549
  const resolved = resolveLiveUsageState(tab, liveState);
1671
1550
  if (resolved.kind === "loading") {
1672
- return [truncateToWidth(`${bold("Usage")} ${dim(resolved.message)}`, width)];
1551
+ return [`${theme.bold("Usage")} ${theme.fg("muted", resolved.message)}`];
1673
1552
  }
1674
1553
 
1675
1554
  const lines: string[] = [
1676
1555
  renderUsageHeader(
1556
+ theme,
1677
1557
  resolved.kind === "ok" ? resolved.snapshot.plan : undefined,
1678
1558
  resolved.kind === "ok" ? resolved.snapshot.fetchedAt : undefined,
1679
- width,
1680
1559
  ),
1681
1560
  ];
1682
1561
  if (resolved.kind === "message") {
1683
- const message =
1684
- resolved.tone === "error" ? ansiFg(ERROR_COLOR, resolved.message) : dim(resolved.message);
1685
- lines.push(truncateToWidth(message, width));
1562
+ lines.push(theme.fg(resolved.tone, resolved.message));
1686
1563
  return lines;
1687
1564
  }
1688
1565
 
1689
1566
  for (const item of resolved.snapshot.items) {
1690
1567
  lines.push("");
1691
- lines.push(
1692
- truncateToWidth(
1693
- formatLiveUsageItemLine(item, { dimReset: true, emphasizeStatValue: true }),
1694
- width,
1695
- ),
1696
- );
1568
+ lines.push(formatLiveUsageItemLine(item, theme));
1697
1569
  if (item.kind === "meter" && tab.liveProvider) {
1698
- lines.push(renderUsageMeterBar(tab.liveProvider.id, item, width));
1570
+ lines.push(renderUsageMeterBar(theme, tab.liveProvider.id, item, width));
1699
1571
  }
1700
1572
  }
1701
1573
 
@@ -1752,12 +1624,18 @@ class UsageBreakdownComponent implements Component {
1752
1624
  private rangeIndex = 1;
1753
1625
  private measurement: MeasurementMode = "tokens";
1754
1626
  private view: BreakdownView = "model";
1627
+ private renderWidth?: number;
1755
1628
  private cachedWidth?: number;
1756
1629
  private cachedLines?: string[];
1630
+ private scrollOffset = 0;
1631
+ private viewportHeight = 0;
1632
+ private maxScroll = 0;
1757
1633
 
1758
1634
  constructor(
1759
1635
  data: BreakdownData,
1760
1636
  tui: TUI,
1637
+ private readonly theme: Theme,
1638
+ private readonly keybindings: KeybindingsManager,
1761
1639
  onDone: () => void,
1762
1640
  loadLiveUsage: (
1763
1641
  provider: UsageProviderDefinition,
@@ -1771,55 +1649,6 @@ class UsageBreakdownComponent implements Component {
1771
1649
  this.ensureLiveUsageLoaded();
1772
1650
  }
1773
1651
 
1774
- private async close(): Promise<void> {
1775
- if (this.closing) return;
1776
- this.closing = true;
1777
- for (const controller of this.liveUsageControllers.values()) {
1778
- controller.abort();
1779
- }
1780
- await Promise.allSettled(this.liveUsageRequests.values());
1781
- this.onDone();
1782
- }
1783
-
1784
- private ensureLiveUsageLoaded(): void {
1785
- const tab = this.data.tabs[this.tabIndex];
1786
- const provider = tab?.liveProvider;
1787
- if (!provider) return;
1788
-
1789
- const current = this.data.liveUsage.get(provider.id);
1790
- if (current && current.status !== "loading") return;
1791
- if (this.liveUsageRequests.has(provider.id)) return;
1792
-
1793
- const controller = new AbortController();
1794
- this.liveUsageControllers.set(provider.id, controller);
1795
- this.data.liveUsage.set(provider.id, { status: "loading" });
1796
- this.invalidate();
1797
- this.tui.requestRender();
1798
-
1799
- const request = this.loadLiveUsage(provider, controller.signal)
1800
- .then((state) => {
1801
- if (controller.signal.aborted) return;
1802
- this.data.liveUsage.set(provider.id, state);
1803
- })
1804
- .catch((error) => {
1805
- if (controller.signal.aborted) return;
1806
- this.data.liveUsage.set(provider.id, {
1807
- status: "error",
1808
- error: error instanceof Error ? error.message : String(error),
1809
- });
1810
- })
1811
- .finally(() => {
1812
- this.liveUsageRequests.delete(provider.id);
1813
- this.liveUsageControllers.delete(provider.id);
1814
- if (!controller.signal.aborted) {
1815
- this.invalidate();
1816
- this.tui.requestRender();
1817
- }
1818
- });
1819
-
1820
- this.liveUsageRequests.set(provider.id, request);
1821
- }
1822
-
1823
1652
  invalidate(): void {
1824
1653
  this.cachedWidth = undefined;
1825
1654
  this.cachedLines = undefined;
@@ -1836,6 +1665,29 @@ class UsageBreakdownComponent implements Component {
1836
1665
  return;
1837
1666
  }
1838
1667
 
1668
+ const width = Math.min(
1669
+ this.renderWidth ?? this.tui.terminal.columns - 2,
1670
+ this.tui.terminal.columns - 2,
1671
+ );
1672
+ if (width < 24 || this.tui.terminal.rows < 7) return;
1673
+
1674
+ const scrollActions = [
1675
+ ["tui.altScreen.pageUp", -this.viewportHeight],
1676
+ ["tui.altScreen.pageDown", this.viewportHeight],
1677
+ ["tui.altScreen.halfPageUp", -Math.max(1, Math.floor(this.viewportHeight / 2))],
1678
+ ["tui.altScreen.halfPageDown", Math.max(1, Math.floor(this.viewportHeight / 2))],
1679
+ ["tui.altScreen.lineUp", -1],
1680
+ ["tui.altScreen.lineDown", 1],
1681
+ ["tui.altScreen.top", -Infinity],
1682
+ ["tui.altScreen.bottom", Infinity],
1683
+ ] as const;
1684
+ for (const [action, delta] of scrollActions) {
1685
+ if (!this.keybindings.matches(data, action)) continue;
1686
+ this.scrollOffset = Math.max(0, Math.min(this.maxScroll, this.scrollOffset + delta));
1687
+ this.tui.requestRender();
1688
+ return;
1689
+ }
1690
+
1839
1691
  if (
1840
1692
  matchesKey(data, Key.tab) ||
1841
1693
  matchesKey(data, Key.shift("tab")) ||
@@ -1845,6 +1697,7 @@ class UsageBreakdownComponent implements Component {
1845
1697
  const index = Math.max(0, order.indexOf(this.measurement));
1846
1698
  const direction = matchesKey(data, Key.shift("tab")) ? -1 : 1;
1847
1699
  this.measurement = order[(index + order.length + direction) % order.length] ?? "tokens";
1700
+ this.scrollOffset = 0;
1848
1701
  this.invalidate();
1849
1702
  this.tui.requestRender();
1850
1703
  return;
@@ -1858,6 +1711,7 @@ class UsageBreakdownComponent implements Component {
1858
1711
  ) {
1859
1712
  const direction = matchesKey(data, Key.left) || data.toLowerCase() === "h" ? -1 : 1;
1860
1713
  this.rangeIndex = (this.rangeIndex + RANGE_DAYS.length + direction) % RANGE_DAYS.length;
1714
+ this.scrollOffset = 0;
1861
1715
  this.invalidate();
1862
1716
  this.tui.requestRender();
1863
1717
  return;
@@ -1866,6 +1720,7 @@ class UsageBreakdownComponent implements Component {
1866
1720
  if (data === "[" || data === "]" || data.toLowerCase() === "p" || data.toLowerCase() === "n") {
1867
1721
  const direction = data === "[" || data.toLowerCase() === "p" ? -1 : 1;
1868
1722
  this.tabIndex = (this.tabIndex + this.data.tabs.length + direction) % this.data.tabs.length;
1723
+ this.scrollOffset = 0;
1869
1724
  this.ensureLiveUsageLoaded();
1870
1725
  this.invalidate();
1871
1726
  this.tui.requestRender();
@@ -1882,6 +1737,7 @@ class UsageBreakdownComponent implements Component {
1882
1737
  const index = views.indexOf(this.view);
1883
1738
  const direction = matchesKey(data, Key.up) || data.toLowerCase() === "k" ? -1 : 1;
1884
1739
  this.view = views[(index + views.length + direction) % views.length] ?? "model";
1740
+ this.scrollOffset = 0;
1885
1741
  this.invalidate();
1886
1742
  this.tui.requestRender();
1887
1743
  return;
@@ -1890,12 +1746,106 @@ class UsageBreakdownComponent implements Component {
1890
1746
  const rangeByKey: Record<string, number> = { "1": 0, "2": 1, "3": 2 };
1891
1747
  if (Object.hasOwn(rangeByKey, data)) {
1892
1748
  this.rangeIndex = rangeByKey[data]!;
1749
+ this.scrollOffset = 0;
1893
1750
  this.invalidate();
1894
1751
  this.tui.requestRender();
1895
1752
  }
1896
1753
  }
1897
1754
 
1898
1755
  render(width: number): string[] {
1756
+ this.renderWidth = width;
1757
+ const styleLine = (line: string) => {
1758
+ const padding = " ".repeat(Math.max(0, width - visibleWidth(line)));
1759
+ return (
1760
+ this.theme.style(line, { fg: "text", bg: "toolPendingBg" }) +
1761
+ this.theme.bg("toolPendingBg", padding)
1762
+ );
1763
+ };
1764
+ const height = Math.max(0, this.tui.terminal.rows - 2);
1765
+ // Four chrome rows plus at least one document row. Margins are supplied by the overlay.
1766
+ if (height < 5 || width < 24) {
1767
+ this.viewportHeight = 0;
1768
+ return ["q close · resize", "Usage needs 26×7"]
1769
+ .slice(0, height)
1770
+ .map((line) => styleLine(truncateUsageLine(this.theme, line, width)));
1771
+ }
1772
+ const document = this.renderDocument(width);
1773
+ const body = document.slice(2);
1774
+ this.viewportHeight = height - 4;
1775
+ this.maxScroll = Math.max(0, body.length - this.viewportHeight);
1776
+ this.scrollOffset = Math.min(this.scrollOffset, this.maxScroll);
1777
+ const visible = body.slice(this.scrollOffset, this.scrollOffset + this.viewportHeight);
1778
+ const keys = (action: "pageUp" | "pageDown") =>
1779
+ this.keybindings.getKeys(`tui.altScreen.${action}`).join("/") || "unbound";
1780
+ return [
1781
+ ...document.slice(0, 2),
1782
+ ...visible,
1783
+ truncateUsageLine(
1784
+ this.theme,
1785
+ this.theme.fg("muted", `${keys("pageUp")}/${keys("pageDown")} scroll`),
1786
+ width,
1787
+ ),
1788
+ truncateUsageLine(
1789
+ this.theme,
1790
+ this.theme.fg(
1791
+ "muted",
1792
+ `q close · ${this.scrollOffset + 1}–${this.scrollOffset + visible.length}/${body.length}`,
1793
+ ),
1794
+ width,
1795
+ ),
1796
+ ].map(styleLine);
1797
+ }
1798
+
1799
+ private async close(): Promise<void> {
1800
+ if (this.closing) return;
1801
+ this.closing = true;
1802
+ for (const controller of this.liveUsageControllers.values()) {
1803
+ controller.abort();
1804
+ }
1805
+ await Promise.allSettled(this.liveUsageRequests.values());
1806
+ this.onDone();
1807
+ }
1808
+
1809
+ private ensureLiveUsageLoaded(): void {
1810
+ const tab = this.data.tabs[this.tabIndex];
1811
+ const provider = tab?.liveProvider;
1812
+ if (!provider) return;
1813
+
1814
+ const current = this.data.liveUsage.get(provider.id);
1815
+ if (current && current.status !== "loading") return;
1816
+ if (this.liveUsageRequests.has(provider.id)) return;
1817
+
1818
+ const controller = new AbortController();
1819
+ this.liveUsageControllers.set(provider.id, controller);
1820
+ this.data.liveUsage.set(provider.id, { status: "loading" });
1821
+ this.invalidate();
1822
+ this.tui.requestRender();
1823
+
1824
+ const request = this.loadLiveUsage(provider, controller.signal)
1825
+ .then((state) => {
1826
+ if (controller.signal.aborted) return;
1827
+ this.data.liveUsage.set(provider.id, state);
1828
+ })
1829
+ .catch((error) => {
1830
+ if (controller.signal.aborted) return;
1831
+ this.data.liveUsage.set(provider.id, {
1832
+ status: "error",
1833
+ error: error instanceof Error ? error.message : String(error),
1834
+ });
1835
+ })
1836
+ .finally(() => {
1837
+ this.liveUsageRequests.delete(provider.id);
1838
+ this.liveUsageControllers.delete(provider.id);
1839
+ if (!controller.signal.aborted) {
1840
+ this.invalidate();
1841
+ this.tui.requestRender();
1842
+ }
1843
+ });
1844
+
1845
+ this.liveUsageRequests.set(provider.id, request);
1846
+ }
1847
+
1848
+ private renderDocument(width: number): string[] {
1899
1849
  if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
1900
1850
 
1901
1851
  const tab = this.data.tabs[this.tabIndex]!;
@@ -1905,64 +1855,84 @@ class UsageBreakdownComponent implements Component {
1905
1855
  ? this.data.allRanges.get(selectedDays)!
1906
1856
  : (this.data.providerRangesById.get(tab.id)?.get(selectedDays) ??
1907
1857
  getEmptyRange(selectedDays));
1908
- const palette =
1858
+ const theme = this.theme;
1859
+ const colors = theme.colors;
1860
+ const paletteColors = [
1861
+ colors.accent,
1862
+ colors.mdLink,
1863
+ colors.success,
1864
+ colors.warning,
1865
+ colors.error,
1866
+ ];
1867
+ const otherColor = colors.muted;
1868
+ const paletteRange =
1909
1869
  tab.mode === "all"
1910
- ? this.data.allPalette
1911
- : (this.data.providerPalettes.get(tab.id) ?? {
1912
- modelPalette: {
1913
- modelColors: new Map<ModelKey, RGB>(),
1914
- otherColor: MUTED_COLOR,
1915
- orderedModels: [],
1916
- },
1917
- cwdPalette: {
1918
- cwdColors: new Map<CwdKey, RGB>(),
1919
- otherColor: MUTED_COLOR,
1920
- orderedCwds: [],
1921
- },
1922
- });
1870
+ ? this.data.allRanges.get(30)!
1871
+ : (this.data.providerRangesById.get(tab.id)?.get(30) ?? getEmptyRange(30));
1872
+ const palette: ProviderPalette = {
1873
+ modelPalette: chooseModelPalette(
1874
+ paletteRange,
1875
+ tab.liveProvider ? buildProviderModelPalette(theme, tab.liveProvider.color) : paletteColors,
1876
+ otherColor,
1877
+ tab.mode === "all" ? 4 : 3,
1878
+ ),
1879
+ cwdPalette: chooseCwdPalette(paletteRange, paletteColors, otherColor),
1880
+ };
1881
+ const dowColors = buildDowPalette(theme);
1882
+ const todColors = buildTodPalette(theme);
1923
1883
  const liveState = tab.liveProvider ? this.data.liveUsage.get(tab.liveProvider.id) : undefined;
1924
1884
  const metric = graphMetricForRange(range, this.measurement);
1925
1885
 
1926
- const providerBadge = bold(`[${tab.shortLabel}]`);
1886
+ const providerBadge = theme.bold(`[${tab.shortLabel}]`);
1927
1887
  const rangeTab = (days: number, index: number): string =>
1928
- index === this.rangeIndex ? bold(`[${days}d]`) : dim(` ${days}d `);
1888
+ index === this.rangeIndex
1889
+ ? theme.style(`[${days}d]`, { fg: "text", bold: true })
1890
+ : theme.fg("muted", ` ${days}d `);
1929
1891
  const metricTab = (mode: MeasurementMode, label: string): string =>
1930
- mode === this.measurement ? bold(`[${label}]`) : dim(` ${label} `);
1892
+ mode === this.measurement
1893
+ ? theme.style(`[${label}]`, { fg: "text", bold: true })
1894
+ : theme.fg("muted", ` ${label} `);
1931
1895
  const viewTab = (candidate: BreakdownView, label: string): string =>
1932
- candidate === this.view ? bold(`[${label}]`) : dim(` ${label} `);
1896
+ candidate === this.view
1897
+ ? theme.style(`[${label}]`, { fg: "text", bold: true })
1898
+ : theme.fg("muted", ` ${label} `);
1933
1899
 
1934
1900
  const header =
1935
- `${bold("Usage")} ${providerBadge}${dim(` ${this.tabIndex + 1}/${this.data.tabs.length}`)} ` +
1901
+ `${theme.bold("Usage")} ${providerBadge}${theme.fg("muted", ` ${this.tabIndex + 1}/${this.data.tabs.length}`)} ` +
1936
1902
  `${rangeTab(7, 0)}${rangeTab(30, 1)}${rangeTab(90, 2)} ` +
1937
1903
  `${metricTab("sessions", "sess")}${metricTab("messages", "msg")}${metricTab("tokens", "tok")} ` +
1938
1904
  `${viewTab("model", "model")}${viewTab("cwd", "cwd")}${viewTab("dow", "dow")}${viewTab("tod", "tod")}`;
1939
1905
 
1940
1906
  const lines: string[] = [];
1941
- lines.push(truncateToWidth(header, width));
1907
+ const compactHeader = `${theme.bold("Usage")} ${providerBadge} [${selectedDays}d] [${this.measurement === "sessions" ? "sess" : this.measurement === "messages" ? "msg" : "tok"}] [${this.view}]`;
1942
1908
  lines.push(
1943
- truncateToWidth(dim("[/] provider · ←/→ range · ↑/↓ view · tab metric · q to close"), width),
1909
+ truncateUsageLine(theme, visibleWidth(header) <= width ? header : compactHeader, width),
1944
1910
  );
1945
- lines.push(truncateToWidth(`${bold(tab.label)} ${dim(historySemanticsText(tab))}`, width));
1911
+ const navigation = theme.fg(
1912
+ "muted",
1913
+ "[/] provider · ←/→ range · ↑/↓ view · tab metric · q to close",
1914
+ );
1915
+ lines.push(truncateUsageLine(theme, navigation, width));
1916
+ if (visibleWidth(compactHeader) > width) lines.push(compactHeader);
1917
+ if (visibleWidth(navigation) > width) lines.push(navigation);
1918
+ lines.push(`${theme.bold(tab.label)} ${theme.fg("muted", historySemanticsText(tab))}`);
1946
1919
  lines.push("");
1947
1920
 
1948
- const liveLines = renderLiveUsageLines(tab, liveState, width);
1921
+ const liveLines = renderLiveUsageLines(theme, tab, liveState, width);
1949
1922
  if (liveLines.length > 0) {
1950
- for (const line of liveLines) lines.push(truncateToWidth(line, width));
1923
+ lines.push(...liveLines);
1951
1924
  lines.push("");
1952
1925
  }
1953
1926
 
1954
1927
  const graphDescriptor =
1955
1928
  this.view === "dow" ? `share of ${metric.kind} by weekday` : `${metric.kind}/day`;
1956
1929
  lines.push(
1957
- truncateToWidth(
1958
- `${rangeSummary(range, selectedDays, metric.kind)}${dim(` (graph: ${graphDescriptor})`)}`,
1959
- width,
1960
- ),
1930
+ `${rangeSummary(range, selectedDays, metric.kind)}${theme.fg("muted", ` (graph: ${graphDescriptor})`)}`,
1961
1931
  );
1962
1932
  lines.push("");
1963
1933
 
1964
- let activeColorMap: Map<string, RGB>;
1965
- let activeOtherColor = MUTED_COLOR;
1934
+ let activeColorMap: Map<string, Color>;
1935
+ let activeOtherColor = otherColor;
1966
1936
  const legendItems: string[] = [];
1967
1937
  const stripProvider = tab.mode === "provider";
1968
1938
 
@@ -1972,39 +1942,46 @@ class UsageBreakdownComponent implements Component {
1972
1942
  for (const model of palette.modelPalette.orderedModels) {
1973
1943
  const color = activeColorMap.get(model);
1974
1944
  if (color)
1975
- legendItems.push(`${ansiFg(color, "█")} ${formatModelLabel(model, stripProvider)}`);
1945
+ legendItems.push(
1946
+ `${theme.style("█", { fg: color })} ${theme.fg("text", formatModelLabel(model, stripProvider))}`,
1947
+ );
1976
1948
  }
1977
- legendItems.push(`${ansiFg(activeOtherColor, "█")} other`);
1949
+ legendItems.push(
1950
+ `${theme.style("█", { fg: activeOtherColor })} ${theme.fg("text", "other")}`,
1951
+ );
1978
1952
  } else if (this.view === "cwd") {
1979
1953
  activeColorMap = palette.cwdPalette.cwdColors;
1980
1954
  activeOtherColor = palette.cwdPalette.otherColor;
1981
1955
  for (const cwd of palette.cwdPalette.orderedCwds) {
1982
1956
  const color = activeColorMap.get(cwd);
1983
- if (color) legendItems.push(`${ansiFg(color, "█")} ${abbreviatePath(cwd, 30)}`);
1957
+ if (color)
1958
+ legendItems.push(
1959
+ `${theme.style("█", { fg: color })} ${theme.fg("text", abbreviatePath(cwd, 30))}`,
1960
+ );
1984
1961
  }
1985
- legendItems.push(`${ansiFg(activeOtherColor, "█")} other`);
1962
+ legendItems.push(
1963
+ `${theme.style("█", { fg: activeOtherColor })} ${theme.fg("text", "other")}`,
1964
+ );
1986
1965
  } else if (this.view === "dow") {
1987
- activeColorMap = this.data.dowPalette.dowColors;
1988
- for (const dow of this.data.dowPalette.orderedDows) {
1966
+ activeColorMap = dowColors;
1967
+ for (const dow of DOW_NAMES) {
1989
1968
  const color = activeColorMap.get(dow);
1990
- if (color) legendItems.push(`${ansiFg(color, "█")} ${dow}`);
1969
+ if (color) legendItems.push(`${theme.style("█", { fg: color })} ${theme.fg("text", dow)}`);
1991
1970
  }
1992
1971
  } else {
1993
- activeColorMap = this.data.todPalette.todColors;
1994
- for (const tod of this.data.todPalette.orderedTods) {
1972
+ activeColorMap = todColors;
1973
+ for (const { key: tod } of TOD_BUCKETS) {
1995
1974
  const color = activeColorMap.get(tod);
1996
- if (color) legendItems.push(`${ansiFg(color, "█")} ${todBucketLabel(tod)}`);
1975
+ if (color)
1976
+ legendItems.push(
1977
+ `${theme.style("█", { fg: color })} ${theme.fg("text", todBucketLabel(tod))}`,
1978
+ );
1997
1979
  }
1998
1980
  }
1999
1981
 
2000
1982
  let graphLines: string[];
2001
1983
  if (this.view === "dow") {
2002
- graphLines = renderDowDistributionLines(
2003
- range,
2004
- this.measurement,
2005
- this.data.dowPalette.dowColors,
2006
- width,
2007
- );
1984
+ graphLines = renderDowDistributionLines(theme, range, this.measurement, dowColors, width);
2008
1985
  } else {
2009
1986
  const maxScale = selectedDays === 7 ? 4 : selectedDays === 30 ? 3 : 2;
2010
1987
  const weeks = weeksForRange(range);
@@ -2014,6 +1991,7 @@ class UsageBreakdownComponent implements Component {
2014
1991
  const idealCellWidth = Math.floor((graphArea + gap) / Math.max(1, weeks)) - gap;
2015
1992
  const cellWidth = Math.min(maxScale, Math.max(1, idealCellWidth));
2016
1993
  graphLines = renderGraphLines(
1994
+ theme,
2017
1995
  range,
2018
1996
  activeColorMap,
2019
1997
  activeOtherColor,
@@ -2026,15 +2004,15 @@ class UsageBreakdownComponent implements Component {
2026
2004
 
2027
2005
  const tableLines =
2028
2006
  this.view === "model"
2029
- ? renderModelTable(range, metric, 8, { stripProvider })
2007
+ ? renderModelTable(theme, range, metric, 8, { stripProvider })
2030
2008
  : this.view === "cwd"
2031
- ? renderCwdTable(range, metric, 8)
2009
+ ? renderCwdTable(theme, range, metric, 8)
2032
2010
  : this.view === "dow"
2033
2011
  ? renderDowTable(range, metric)
2034
2012
  : renderTodTable(range, metric);
2035
2013
 
2036
2014
  if (this.view === "dow") {
2037
- for (const line of graphLines) lines.push(truncateToWidth(line, width));
2015
+ for (const line of graphLines) lines.push(truncateUsageLine(theme, line, width));
2038
2016
  } else {
2039
2017
  const graphWidth = Math.max(0, ...graphLines.map((line) => visibleWidth(line)));
2040
2018
  const separator = 2;
@@ -2048,12 +2026,15 @@ class UsageBreakdownComponent implements Component {
2048
2026
  : this.view === "cwd"
2049
2027
  ? "Top directories (30d palette):"
2050
2028
  : "Time of day:";
2051
- const legendBlock = [dim(legendTitle), ...legendItems];
2029
+ const legendBlock = [theme.fg("muted", legendTitle), ...legendItems];
2052
2030
  const maxLegendRows = graphLines.length;
2053
2031
  let legendLines = legendBlock.slice(0, maxLegendRows);
2054
2032
  if (legendBlock.length > maxLegendRows) {
2055
2033
  const remaining = legendBlock.length - (maxLegendRows - 1);
2056
- legendLines = [...legendBlock.slice(0, maxLegendRows - 1), dim(`+${remaining} more`)];
2034
+ legendLines = [
2035
+ ...legendBlock.slice(0, maxLegendRows - 1),
2036
+ theme.fg("muted", `+${remaining} more`),
2037
+ ];
2057
2038
  }
2058
2039
  while (legendLines.length < graphLines.length) legendLines.push("");
2059
2040
 
@@ -2066,11 +2047,15 @@ class UsageBreakdownComponent implements Component {
2066
2047
 
2067
2048
  for (let index = 0; index < graphLines.length; index += 1) {
2068
2049
  const left = padRightAnsi(graphLines[index] ?? "", graphWidth);
2069
- const right = truncateToWidth(legendLines[index] ?? "", Math.max(0, legendWidth));
2070
- lines.push(truncateToWidth(left + " ".repeat(separator) + right, width));
2050
+ const right = truncateUsageLine(
2051
+ theme,
2052
+ legendLines[index] ?? "",
2053
+ Math.max(0, legendWidth),
2054
+ );
2055
+ lines.push(truncateUsageLine(theme, left + " ".repeat(separator) + right, width));
2071
2056
  }
2072
2057
  } else {
2073
- for (const line of graphLines) lines.push(truncateToWidth(line, width));
2058
+ for (const line of graphLines) lines.push(truncateUsageLine(theme, line, width));
2074
2059
  lines.push("");
2075
2060
  const legendTitle =
2076
2061
  this.view === "model"
@@ -2078,16 +2063,19 @@ class UsageBreakdownComponent implements Component {
2078
2063
  : this.view === "cwd"
2079
2064
  ? "Top directories (30d palette):"
2080
2065
  : "Time of day:";
2081
- lines.push(truncateToWidth(dim(legendTitle), width));
2082
- for (const item of legendItems) lines.push(truncateToWidth(item, width));
2066
+ lines.push(theme.fg("muted", legendTitle));
2067
+ lines.push(...legendItems);
2083
2068
  }
2084
2069
  }
2085
2070
 
2086
2071
  lines.push("");
2087
- for (const line of tableLines) lines.push(truncateToWidth(line, width));
2072
+ lines.push(...tableLines);
2088
2073
 
2089
2074
  this.cachedWidth = width;
2090
- this.cachedLines = lines;
2075
+ this.cachedLines = [
2076
+ ...lines.slice(0, 2),
2077
+ ...lines.slice(2).flatMap((line) => wrapTextWithAnsi(line, width)),
2078
+ ];
2091
2079
  return this.cachedLines;
2092
2080
  }
2093
2081
  }
@@ -2197,10 +2185,14 @@ export default function usageBreakdownExtension(pi: ExtensionAPI) {
2197
2185
  }
2198
2186
 
2199
2187
  await ctx.ui.custom<void>(
2200
- (tui, _theme, _kb, done) =>
2201
- new UsageBreakdownComponent(data, tui, done, (provider, signal) =>
2188
+ (tui, theme, keybindings, done) =>
2189
+ new UsageBreakdownComponent(data, tui, theme, keybindings, done, (provider, signal) =>
2202
2190
  fetchLiveUsageForProvider(provider, liveUsageAvailability, ctx, signal),
2203
2191
  ),
2192
+ {
2193
+ overlay: true,
2194
+ overlayOptions: { anchor: "bottom-center", width: "100%", maxHeight: "100%", margin: 1 },
2195
+ },
2204
2196
  );
2205
2197
  },
2206
2198
  });