mitsupi 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +28 -18
  4. package/analyze-edits.py +232 -0
  5. package/distributions/README.md +8 -0
  6. package/distributions/mitsupi-common/README.md +11 -0
  7. package/distributions/mitsupi-common/package.json +95 -0
  8. package/distributions/mitsupi-loaded/README.md +13 -0
  9. package/distributions/mitsupi-loaded/package.json +38 -0
  10. package/{pi-extensions → extensions}/answer.ts +11 -11
  11. package/extensions/btw.ts +963 -0
  12. package/{pi-extensions → extensions}/context.ts +2 -2
  13. package/{pi-extensions → extensions}/control.ts +20 -13
  14. package/{pi-extensions → extensions}/files.ts +6 -8
  15. package/{pi-extensions → extensions}/go-to-bed.ts +2 -2
  16. package/{pi-extensions → extensions}/loop.ts +18 -11
  17. package/extensions/multi-edit.ts +871 -0
  18. package/{pi-extensions → extensions}/review.ts +254 -82
  19. package/{pi-extensions → extensions}/session-breakdown.ts +551 -74
  20. package/extensions/split-fork.ts +130 -0
  21. package/{pi-extensions → extensions}/todos.ts +41 -34
  22. package/extensions/uv.ts +123 -0
  23. package/intercepted-commands/poetry +8 -1
  24. package/intercepted-commands/python +42 -2
  25. package/intercepted-commands/python3 +42 -2
  26. package/package.json +6 -3
  27. package/skills/google-workspace/SKILL.md +53 -8
  28. package/skills/google-workspace/scripts/auth.js +106 -28
  29. package/skills/google-workspace/scripts/common.js +201 -32
  30. package/skills/google-workspace/scripts/workspace.js +64 -9
  31. package/skills/native-web-search/SKILL.md +2 -0
  32. package/skills/native-web-search/search.mjs +102 -15
  33. package/skills/pi-share/SKILL.md +5 -2
  34. package/skills/pi-share/fetch-session.mjs +46 -15
  35. package/skills/summarize/SKILL.md +4 -3
  36. package/skills/summarize/to-markdown.mjs +3 -1
  37. package/skills/web-browser/SKILL.md +6 -0
  38. package/skills/web-browser/scripts/start.js +75 -27
  39. package/pi-extensions/uv.ts +0 -33
  40. /package/{pi-extensions → extensions}/notify.ts +0 -0
  41. /package/{pi-extensions → extensions}/prompt-editor.ts +0 -0
  42. /package/{pi-extensions → extensions}/whimsical.ts +0 -0
  43. /package/{pi-themes → themes}/nightowl.json +0 -0
@@ -33,11 +33,39 @@ import { createReadStream, type Dirent } from "node:fs";
33
33
  import readline from "node:readline";
34
34
 
35
35
  type ModelKey = string; // `${provider}/${model}`
36
+ type CwdKey = string; // normalized cwd path
37
+ type DowKey = string; // "Mon", "Tue", etc.
38
+ type TodKey = string; // "after-midnight", "morning", "afternoon", "evening", "night"
39
+ type BreakdownView = "model" | "cwd" | "dow" | "tod";
40
+
41
+ const DOW_NAMES: DowKey[] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
42
+
43
+ const TOD_BUCKETS: { key: TodKey; label: string; from: number; to: number }[] = [
44
+ { key: "after-midnight", label: "After midnight (0–5)", from: 0, to: 5 },
45
+ { key: "morning", label: "Morning (6–11)", from: 6, to: 11 },
46
+ { key: "afternoon", label: "Afternoon (12–16)", from: 12, to: 16 },
47
+ { key: "evening", label: "Evening (17–21)", from: 17, to: 21 },
48
+ { key: "night", label: "Night (22–23)", from: 22, to: 23 },
49
+ ];
50
+
51
+ function todBucketForHour(hour: number): TodKey {
52
+ for (const b of TOD_BUCKETS) {
53
+ if (hour >= b.from && hour <= b.to) return b.key;
54
+ }
55
+ return "after-midnight";
56
+ }
57
+
58
+ function todBucketLabel(key: TodKey): string {
59
+ return TOD_BUCKETS.find((b) => b.key === key)?.label ?? key;
60
+ }
36
61
 
37
62
  interface ParsedSession {
38
63
  filePath: string;
39
64
  startedAt: Date;
40
65
  dayKeyLocal: string; // YYYY-MM-DD (local)
66
+ cwd: CwdKey | null;
67
+ dow: DowKey;
68
+ tod: TodKey;
41
69
  modelsUsed: Set<ModelKey>;
42
70
  messages: number;
43
71
  tokens: number;
@@ -58,6 +86,14 @@ interface DayAgg {
58
86
  sessionsByModel: Map<ModelKey, number>;
59
87
  messagesByModel: Map<ModelKey, number>;
60
88
  tokensByModel: Map<ModelKey, number>;
89
+ sessionsByCwd: Map<CwdKey, number>;
90
+ messagesByCwd: Map<CwdKey, number>;
91
+ tokensByCwd: Map<CwdKey, number>;
92
+ costByCwd: Map<CwdKey, number>;
93
+ sessionsByTod: Map<TodKey, number>;
94
+ messagesByTod: Map<TodKey, number>;
95
+ tokensByTod: Map<TodKey, number>;
96
+ costByTod: Map<TodKey, number>;
61
97
  }
62
98
 
63
99
  interface RangeAgg {
@@ -71,6 +107,18 @@ interface RangeAgg {
71
107
  modelSessions: Map<ModelKey, number>; // number of sessions where model was used
72
108
  modelMessages: Map<ModelKey, number>;
73
109
  modelTokens: Map<ModelKey, number>;
110
+ cwdCost: Map<CwdKey, number>;
111
+ cwdSessions: Map<CwdKey, number>;
112
+ cwdMessages: Map<CwdKey, number>;
113
+ cwdTokens: Map<CwdKey, number>;
114
+ dowCost: Map<DowKey, number>;
115
+ dowSessions: Map<DowKey, number>;
116
+ dowMessages: Map<DowKey, number>;
117
+ dowTokens: Map<DowKey, number>;
118
+ todCost: Map<TodKey, number>;
119
+ todSessions: Map<TodKey, number>;
120
+ todMessages: Map<TodKey, number>;
121
+ todTokens: Map<TodKey, number>;
74
122
  }
75
123
 
76
124
  interface RGB {
@@ -87,6 +135,19 @@ interface BreakdownData {
87
135
  otherColor: RGB;
88
136
  orderedModels: ModelKey[];
89
137
  };
138
+ cwdPalette: {
139
+ cwdColors: Map<CwdKey, RGB>;
140
+ otherColor: RGB;
141
+ orderedCwds: CwdKey[];
142
+ };
143
+ dowPalette: {
144
+ dowColors: Map<DowKey, RGB>;
145
+ orderedDows: DowKey[];
146
+ };
147
+ todPalette: {
148
+ todColors: Map<TodKey, RGB>;
149
+ orderedTods: TodKey[];
150
+ };
90
151
  }
91
152
 
92
153
  const SESSION_ROOT = path.join(os.homedir(), ".pi", "agent", "sessions");
@@ -189,6 +250,36 @@ function formatUsd(cost: number): string {
189
250
  return `$${cost.toFixed(4)}`;
190
251
  }
191
252
 
253
+ /**
254
+ * Abbreviate a path for display. Strategy:
255
+ * - Replace home dir with ~
256
+ * - If still too long, keep first segment + last N segments with … in between
257
+ * Examples:
258
+ * /Users/mitsuhiko/Development/agent-stuff → ~/Development/agent-stuff
259
+ * /Users/mitsuhiko/Development/minijinja/minijinja-go → ~/…/minijinja/minijinja-go
260
+ */
261
+ function abbreviatePath(p: string, maxWidth = 40): string {
262
+ const home = os.homedir();
263
+ let display = p;
264
+ if (display.startsWith(home)) {
265
+ display = "~" + display.slice(home.length);
266
+ }
267
+ if (display.length <= maxWidth) return display;
268
+
269
+ const parts = display.split("/").filter(Boolean);
270
+ // Always keep the first part (~ or root indicator) and try to keep as many trailing parts as possible
271
+ if (parts.length <= 2) return display;
272
+
273
+ const prefix = parts[0]; // typically "~"
274
+ // Try keeping last N parts, increasing until it fits
275
+ for (let keep = parts.length - 1; keep >= 1; keep--) {
276
+ const tail = parts.slice(parts.length - keep);
277
+ const candidate = prefix + "/…/" + tail.join("/");
278
+ if (candidate.length <= maxWidth || keep === 1) return candidate;
279
+ }
280
+ return display;
281
+ }
282
+
192
283
  function padRight(s: string, n: number): string {
193
284
  const delta = n - s.length;
194
285
  return delta > 0 ? s + " ".repeat(delta) : s;
@@ -383,6 +474,7 @@ async function parseSessionFile(filePath: string, signal?: AbortSignal): Promise
383
474
  const fileName = path.basename(filePath);
384
475
  let startedAt = parseSessionStartFromFilename(fileName);
385
476
  let currentModel: ModelKey | null = null;
477
+ let cwd: CwdKey | null = null;
386
478
 
387
479
  const modelsUsed = new Set<ModelKey>();
388
480
  let messages = 0;
@@ -410,9 +502,14 @@ async function parseSessionFile(filePath: string, signal?: AbortSignal): Promise
410
502
  continue;
411
503
  }
412
504
 
413
- if (!startedAt && obj?.type === "session" && typeof obj?.timestamp === "string") {
414
- const d = new Date(obj.timestamp);
415
- if (Number.isFinite(d.getTime())) startedAt = d;
505
+ if (obj?.type === "session") {
506
+ if (!startedAt && typeof obj?.timestamp === "string") {
507
+ const d = new Date(obj.timestamp);
508
+ if (Number.isFinite(d.getTime())) startedAt = d;
509
+ }
510
+ if (typeof obj?.cwd === "string" && obj.cwd.trim()) {
511
+ cwd = obj.cwd.trim();
512
+ }
416
513
  continue;
417
514
  }
418
515
 
@@ -457,10 +554,15 @@ async function parseSessionFile(filePath: string, signal?: AbortSignal): Promise
457
554
 
458
555
  if (!startedAt) return null;
459
556
  const dayKeyLocal = toLocalDayKey(startedAt);
557
+ const dow = DOW_NAMES[mondayIndex(startedAt)];
558
+ const tod = todBucketForHour(startedAt.getHours());
460
559
  return {
461
560
  filePath,
462
561
  startedAt,
463
562
  dayKeyLocal,
563
+ cwd,
564
+ dow,
565
+ tod,
464
566
  modelsUsed,
465
567
  messages,
466
568
  tokens,
@@ -491,6 +593,14 @@ function buildRangeAgg(days: number, now: Date): RangeAgg {
491
593
  sessionsByModel: new Map(),
492
594
  messagesByModel: new Map(),
493
595
  tokensByModel: new Map(),
596
+ sessionsByCwd: new Map(),
597
+ messagesByCwd: new Map(),
598
+ tokensByCwd: new Map(),
599
+ costByCwd: new Map(),
600
+ sessionsByTod: new Map(),
601
+ messagesByTod: new Map(),
602
+ tokensByTod: new Map(),
603
+ costByTod: new Map(),
494
604
  };
495
605
  outDays.push(day);
496
606
  dayByKey.set(dayKeyLocal, day);
@@ -507,6 +617,18 @@ function buildRangeAgg(days: number, now: Date): RangeAgg {
507
617
  modelSessions: new Map(),
508
618
  modelMessages: new Map(),
509
619
  modelTokens: new Map(),
620
+ cwdCost: new Map(),
621
+ cwdSessions: new Map(),
622
+ cwdMessages: new Map(),
623
+ cwdTokens: new Map(),
624
+ dowCost: new Map(),
625
+ dowSessions: new Map(),
626
+ dowMessages: new Map(),
627
+ dowTokens: new Map(),
628
+ todCost: new Map(),
629
+ todSessions: new Map(),
630
+ todMessages: new Map(),
631
+ todTokens: new Map(),
510
632
  };
511
633
  }
512
634
 
@@ -546,6 +668,37 @@ function addSessionToRange(range: RangeAgg, session: ParsedSession): void {
546
668
  day.costByModel.set(mk, (day.costByModel.get(mk) ?? 0) + cost);
547
669
  range.modelCost.set(mk, (range.modelCost.get(mk) ?? 0) + cost);
548
670
  }
671
+
672
+ // CWD aggregation
673
+ const cwd = session.cwd;
674
+ if (cwd) {
675
+ day.sessionsByCwd.set(cwd, (day.sessionsByCwd.get(cwd) ?? 0) + 1);
676
+ range.cwdSessions.set(cwd, (range.cwdSessions.get(cwd) ?? 0) + 1);
677
+ day.messagesByCwd.set(cwd, (day.messagesByCwd.get(cwd) ?? 0) + session.messages);
678
+ range.cwdMessages.set(cwd, (range.cwdMessages.get(cwd) ?? 0) + session.messages);
679
+ day.tokensByCwd.set(cwd, (day.tokensByCwd.get(cwd) ?? 0) + session.tokens);
680
+ range.cwdTokens.set(cwd, (range.cwdTokens.get(cwd) ?? 0) + session.tokens);
681
+ day.costByCwd.set(cwd, (day.costByCwd.get(cwd) ?? 0) + session.totalCost);
682
+ range.cwdCost.set(cwd, (range.cwdCost.get(cwd) ?? 0) + session.totalCost);
683
+ }
684
+
685
+ // Day-of-week aggregation
686
+ const dow = session.dow;
687
+ range.dowSessions.set(dow, (range.dowSessions.get(dow) ?? 0) + 1);
688
+ range.dowMessages.set(dow, (range.dowMessages.get(dow) ?? 0) + session.messages);
689
+ range.dowTokens.set(dow, (range.dowTokens.get(dow) ?? 0) + session.tokens);
690
+ range.dowCost.set(dow, (range.dowCost.get(dow) ?? 0) + session.totalCost);
691
+
692
+ // Time-of-day aggregation
693
+ const tod = session.tod;
694
+ day.sessionsByTod.set(tod, (day.sessionsByTod.get(tod) ?? 0) + 1);
695
+ day.messagesByTod.set(tod, (day.messagesByTod.get(tod) ?? 0) + session.messages);
696
+ day.tokensByTod.set(tod, (day.tokensByTod.get(tod) ?? 0) + session.tokens);
697
+ day.costByTod.set(tod, (day.costByTod.get(tod) ?? 0) + session.totalCost);
698
+ range.todSessions.set(tod, (range.todSessions.get(tod) ?? 0) + 1);
699
+ range.todMessages.set(tod, (range.todMessages.get(tod) ?? 0) + session.messages);
700
+ range.todTokens.set(tod, (range.todTokens.get(tod) ?? 0) + session.tokens);
701
+ range.todCost.set(tod, (range.todCost.get(tod) ?? 0) + session.totalCost);
549
702
  }
550
703
 
551
704
  function sortMapByValueDesc<K extends string>(m: Map<K, number>): Array<{ key: K; value: number }> {
@@ -583,26 +736,117 @@ function choosePaletteFromLast30Days(range30: RangeAgg, topN = 4): {
583
736
  };
584
737
  }
585
738
 
739
+ function chooseCwdPaletteFromLast30Days(range30: RangeAgg, topN = 4): {
740
+ cwdColors: Map<CwdKey, RGB>;
741
+ otherColor: RGB;
742
+ orderedCwds: CwdKey[];
743
+ } {
744
+ const costSum = [...range30.cwdCost.values()].reduce((a, b) => a + b, 0);
745
+ const popularity =
746
+ costSum > 0
747
+ ? range30.cwdCost
748
+ : range30.totalTokens > 0
749
+ ? range30.cwdTokens
750
+ : range30.totalMessages > 0
751
+ ? range30.cwdMessages
752
+ : range30.cwdSessions;
753
+
754
+ const sorted = sortMapByValueDesc(popularity);
755
+ const orderedCwds = sorted.slice(0, topN).map((x) => x.key);
756
+ const cwdColors = new Map<CwdKey, RGB>();
757
+ for (let i = 0; i < orderedCwds.length; i++) {
758
+ cwdColors.set(orderedCwds[i], PALETTE[i % PALETTE.length]);
759
+ }
760
+ return {
761
+ cwdColors,
762
+ otherColor: { r: 160, g: 160, b: 160 },
763
+ orderedCwds,
764
+ };
765
+ }
766
+
767
+ // Fixed palette for day-of-week: weekdays get cool tones, weekend gets warm
768
+ const DOW_PALETTE: RGB[] = [
769
+ { r: 47, g: 129, b: 247 }, // Mon – blue
770
+ { r: 64, g: 196, b: 99 }, // Tue – green
771
+ { r: 163, g: 113, b: 247 }, // Wed – purple
772
+ { r: 47, g: 175, b: 200 }, // Thu – teal
773
+ { r: 100, g: 200, b: 150 }, // Fri – mint
774
+ { r: 255, g: 159, b: 10 }, // Sat – orange
775
+ { r: 244, g: 67, b: 54 }, // Sun – red
776
+ ];
777
+
778
+ function buildDowPalette(): { dowColors: Map<DowKey, RGB>; orderedDows: DowKey[] } {
779
+ const dowColors = new Map<DowKey, RGB>();
780
+ for (let i = 0; i < DOW_NAMES.length; i++) {
781
+ dowColors.set(DOW_NAMES[i], DOW_PALETTE[i]);
782
+ }
783
+ return { dowColors, orderedDows: [...DOW_NAMES] };
784
+ }
785
+
786
+ // Fixed palette for time-of-day buckets
787
+ const TOD_PALETTE: Map<TodKey, RGB> = new Map([
788
+ ["after-midnight", { r: 100, g: 60, b: 180 }], // deep purple
789
+ ["morning", { r: 255, g: 200, b: 50 }], // golden yellow
790
+ ["afternoon", { r: 64, g: 196, b: 99 }], // green
791
+ ["evening", { r: 47, g: 129, b: 247 }], // blue
792
+ ["night", { r: 60, g: 40, b: 140 }], // dark indigo
793
+ ]);
794
+
795
+ function buildTodPalette(): { todColors: Map<TodKey, RGB>; orderedTods: TodKey[] } {
796
+ const todColors = new Map<TodKey, RGB>();
797
+ const orderedTods: TodKey[] = [];
798
+ for (const b of TOD_BUCKETS) {
799
+ const c = TOD_PALETTE.get(b.key);
800
+ if (c) todColors.set(b.key, c);
801
+ orderedTods.push(b.key);
802
+ }
803
+ return { todColors, orderedTods };
804
+ }
805
+
586
806
  function dayMixedColor(
587
807
  day: DayAgg,
588
- modelColors: Map<ModelKey, RGB>,
808
+ colorMap: Map<string, RGB>,
589
809
  otherColor: RGB,
590
810
  mode: MeasurementMode,
811
+ view: BreakdownView = "model",
591
812
  ): RGB {
592
813
  const parts: Array<{ color: RGB; weight: number }> = [];
593
814
  let otherWeight = 0;
594
815
 
595
- let map: Map<ModelKey, number>;
596
- if (mode === "tokens") {
597
- map = day.tokens > 0 ? day.tokensByModel : day.messages > 0 ? day.messagesByModel : day.sessionsByModel;
598
- } else if (mode === "messages") {
599
- map = day.messages > 0 ? day.messagesByModel : day.sessionsByModel;
816
+ let map: Map<string, number>;
817
+ if (view === "dow") {
818
+ // For dow, each day IS a single dow use the dow color directly
819
+ const dowKey = DOW_NAMES[mondayIndex(day.date)];
820
+ const c = colorMap.get(dowKey);
821
+ return c ?? otherColor;
822
+ } else if (view === "tod") {
823
+ if (mode === "tokens") {
824
+ map = day.tokens > 0 ? day.tokensByTod : day.messages > 0 ? day.messagesByTod : day.sessionsByTod;
825
+ } else if (mode === "messages") {
826
+ map = day.messages > 0 ? day.messagesByTod : day.sessionsByTod;
827
+ } else {
828
+ map = day.sessionsByTod;
829
+ }
830
+ } else if (view === "cwd") {
831
+ if (mode === "tokens") {
832
+ map = day.tokens > 0 ? day.tokensByCwd : day.messages > 0 ? day.messagesByCwd : day.sessionsByCwd;
833
+ } else if (mode === "messages") {
834
+ map = day.messages > 0 ? day.messagesByCwd : day.sessionsByCwd;
835
+ } else {
836
+ map = day.sessionsByCwd;
837
+ }
600
838
  } else {
601
- map = day.sessionsByModel;
839
+ if (mode === "tokens") {
840
+ map = day.tokens > 0 ? day.tokensByModel : day.messages > 0 ? day.messagesByModel : day.sessionsByModel;
841
+ } else if (mode === "messages") {
842
+ map = day.messages > 0 ? day.messagesByModel : day.sessionsByModel;
843
+ } else {
844
+ map = day.sessionsByModel;
845
+ }
602
846
  }
603
847
 
604
848
  for (const [mk, w] of map.entries()) {
605
- const c = modelColors.get(mk);
849
+ const c = colorMap.get(mk);
606
850
  if (c) parts.push({ color: c, weight: w });
607
851
  else otherWeight += w;
608
852
  }
@@ -644,10 +888,11 @@ function weeksForRange(range: RangeAgg): number {
644
888
 
645
889
  function renderGraphLines(
646
890
  range: RangeAgg,
647
- modelColors: Map<ModelKey, RGB>,
891
+ colorMap: Map<string, RGB>,
648
892
  otherColor: RGB,
649
893
  mode: MeasurementMode,
650
894
  options?: { cellWidth?: number; gap?: number },
895
+ view: BreakdownView = "model",
651
896
  ): string[] {
652
897
  const days = range.days;
653
898
  const start = days[0].date;
@@ -701,7 +946,7 @@ function renderGraphLines(
701
946
  continue;
702
947
  }
703
948
 
704
- const hue = dayMixedColor(day, modelColors, otherColor, mode);
949
+ const hue = dayMixedColor(day, colorMap, otherColor, mode, view);
705
950
  let t = denom > 0 ? Math.log1p(value) / denom : 0;
706
951
  t = clamp01(t);
707
952
  const minVisible = 0.2;
@@ -812,6 +1057,166 @@ function renderModelTable(range: RangeAgg, mode: MeasurementMode, maxRows = 8):
812
1057
  return lines;
813
1058
  }
814
1059
 
1060
+ function renderCwdTable(range: RangeAgg, mode: MeasurementMode, maxRows = 8): string[] {
1061
+ const metric = graphMetricForRange(range, mode);
1062
+ const kind = metric.kind;
1063
+
1064
+ let perCwd: Map<CwdKey, number>;
1065
+ let total = 0;
1066
+ let label = kind;
1067
+
1068
+ if (kind === "tokens") {
1069
+ perCwd = range.cwdTokens;
1070
+ total = range.totalTokens;
1071
+ } else if (kind === "messages") {
1072
+ perCwd = range.cwdMessages;
1073
+ total = range.totalMessages;
1074
+ } else {
1075
+ perCwd = range.cwdSessions;
1076
+ total = range.sessions;
1077
+ }
1078
+
1079
+ const sorted = sortMapByValueDesc(perCwd);
1080
+ const rows = sorted.slice(0, maxRows);
1081
+
1082
+ const valueWidth = kind === "tokens" ? 10 : 8;
1083
+ const displayPaths = rows.map((r) => abbreviatePath(r.key, 40));
1084
+ const cwdWidth = Math.min(42, Math.max("directory".length, ...displayPaths.map((p) => p.length)));
1085
+
1086
+ const lines: string[] = [];
1087
+ lines.push(`${padRight("directory", cwdWidth)} ${padLeft(label, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`);
1088
+ lines.push(`${"-".repeat(cwdWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`);
1089
+
1090
+ for (let i = 0; i < rows.length; i++) {
1091
+ const r = rows[i];
1092
+ const value = perCwd.get(r.key) ?? 0;
1093
+ const cost = range.cwdCost.get(r.key) ?? 0;
1094
+ const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
1095
+ lines.push(
1096
+ `${padRight(displayPaths[i].slice(0, cwdWidth), cwdWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
1097
+ );
1098
+ }
1099
+
1100
+ if (sorted.length === 0) {
1101
+ lines.push(dim("(no directory data found)"));
1102
+ }
1103
+
1104
+ return lines;
1105
+ }
1106
+
1107
+ function dowMetricForRange(
1108
+ range: RangeAgg,
1109
+ mode: MeasurementMode,
1110
+ ): { kind: "sessions" | "messages" | "tokens"; perDow: Map<DowKey, number>; total: number } {
1111
+ const metric = graphMetricForRange(range, mode);
1112
+ const kind = metric.kind;
1113
+
1114
+ if (kind === "tokens") {
1115
+ return { kind, perDow: range.dowTokens, total: range.totalTokens };
1116
+ }
1117
+ if (kind === "messages") {
1118
+ return { kind, perDow: range.dowMessages, total: range.totalMessages };
1119
+ }
1120
+ return { kind, perDow: range.dowSessions, total: range.sessions };
1121
+ }
1122
+
1123
+ function renderDowDistributionLines(
1124
+ range: RangeAgg,
1125
+ mode: MeasurementMode,
1126
+ dowColors: Map<DowKey, RGB>,
1127
+ width: number,
1128
+ ): string[] {
1129
+ const { kind, perDow, total } = dowMetricForRange(range, mode);
1130
+ const dayWidth = 3;
1131
+ const pctWidth = 4; // "100%"
1132
+ const valueWidth = kind === "tokens" ? 10 : 8;
1133
+ const showValue = width >= dayWidth + 1 + 10 + 1 + pctWidth + 1 + valueWidth;
1134
+ const fixedWidth = dayWidth + 1 + 1 + pctWidth + (showValue ? 1 + valueWidth : 0);
1135
+ const barWidth = Math.max(1, width - fixedWidth);
1136
+ const fallbackColor: RGB = { r: 160, g: 160, b: 160 };
1137
+
1138
+ const lines: string[] = [];
1139
+ for (const dow of DOW_NAMES) {
1140
+ const value = perDow.get(dow) ?? 0;
1141
+ const share = total > 0 ? value / total : 0;
1142
+ let filled = share > 0 ? Math.round(share * barWidth) : 0;
1143
+ if (share > 0) filled = Math.max(1, filled);
1144
+ filled = Math.min(barWidth, filled);
1145
+ const empty = Math.max(0, barWidth - filled);
1146
+
1147
+ const color = dowColors.get(dow) ?? fallbackColor;
1148
+ const filledBar = filled > 0 ? ansiFg(color, "█".repeat(filled)) : "";
1149
+ const emptyBar = empty > 0 ? ansiFg(EMPTY_CELL_BG, "█".repeat(empty)) : "";
1150
+ const pct = padLeft(`${Math.round(share * 100)}%`, pctWidth);
1151
+
1152
+ let line = `${padRight(dow, dayWidth)} ${filledBar}${emptyBar} ${pct}`;
1153
+ if (showValue) line += ` ${padLeft(formatCount(value), valueWidth)}`;
1154
+ lines.push(line);
1155
+ }
1156
+
1157
+ return lines;
1158
+ }
1159
+
1160
+ function renderDowTable(range: RangeAgg, mode: MeasurementMode): string[] {
1161
+ const { kind, perDow, total } = dowMetricForRange(range, mode);
1162
+ const valueWidth = kind === "tokens" ? 10 : 8;
1163
+ const dowWidth = 5; // "day "
1164
+
1165
+ const lines: string[] = [];
1166
+ lines.push(`${padRight("day", dowWidth)} ${padLeft(kind, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`);
1167
+ lines.push(`${"-".repeat(dowWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`);
1168
+
1169
+ // Always show in Mon–Sun order
1170
+ for (const dow of DOW_NAMES) {
1171
+ const value = perDow.get(dow) ?? 0;
1172
+ const cost = range.dowCost.get(dow) ?? 0;
1173
+ const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
1174
+ lines.push(
1175
+ `${padRight(dow, dowWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
1176
+ );
1177
+ }
1178
+
1179
+ return lines;
1180
+ }
1181
+
1182
+ function renderTodTable(range: RangeAgg, mode: MeasurementMode): string[] {
1183
+ const metric = graphMetricForRange(range, mode);
1184
+ const kind = metric.kind;
1185
+
1186
+ let perTod: Map<TodKey, number>;
1187
+ let total = 0;
1188
+
1189
+ if (kind === "tokens") {
1190
+ perTod = range.todTokens;
1191
+ total = range.totalTokens;
1192
+ } else if (kind === "messages") {
1193
+ perTod = range.todMessages;
1194
+ total = range.totalMessages;
1195
+ } else {
1196
+ perTod = range.todSessions;
1197
+ total = range.sessions;
1198
+ }
1199
+
1200
+ const valueWidth = kind === "tokens" ? 10 : 8;
1201
+ const todWidth = 22; // widest label
1202
+
1203
+ const lines: string[] = [];
1204
+ lines.push(`${padRight("time of day", todWidth)} ${padLeft(kind, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`);
1205
+ lines.push(`${"-".repeat(todWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`);
1206
+
1207
+ // Always show in chronological order
1208
+ for (const b of TOD_BUCKETS) {
1209
+ const value = perTod.get(b.key) ?? 0;
1210
+ const cost = range.todCost.get(b.key) ?? 0;
1211
+ const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
1212
+ lines.push(
1213
+ `${padRight(b.label, todWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
1214
+ );
1215
+ }
1216
+
1217
+ return lines;
1218
+ }
1219
+
815
1220
  function renderLeftRight(left: string, right: string, width: number): string {
816
1221
  const leftW = visibleWidth(left);
817
1222
  if (width <= 0) return "";
@@ -888,7 +1293,10 @@ async function computeBreakdown(
888
1293
  onProgress?.({ phase: "finalize", currentFile: undefined });
889
1294
 
890
1295
  const palette = choosePaletteFromLast30Days(ranges.get(30)!, 4);
891
- return { generatedAt: now, ranges, palette };
1296
+ const cwdPalette = chooseCwdPaletteFromLast30Days(ranges.get(30)!, 4);
1297
+ const dowPalette = buildDowPalette();
1298
+ const todPalette = buildTodPalette();
1299
+ return { generatedAt: now, ranges, palette, cwdPalette, dowPalette, todPalette };
892
1300
  }
893
1301
 
894
1302
  class BreakdownComponent implements Component {
@@ -897,6 +1305,7 @@ class BreakdownComponent implements Component {
897
1305
  private onDone: () => void;
898
1306
  private rangeIndex = 1; // default 30d
899
1307
  private measurement: MeasurementMode = "sessions";
1308
+ private view: BreakdownView = "model";
900
1309
  private cachedWidth?: number;
901
1310
  private cachedLines?: string[];
902
1311
 
@@ -941,6 +1350,16 @@ class BreakdownComponent implements Component {
941
1350
  if (matchesKey(data, Key.left) || data.toLowerCase() === "h") prev();
942
1351
  if (matchesKey(data, Key.right) || data.toLowerCase() === "l") next();
943
1352
 
1353
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.down) || data.toLowerCase() === "j" || data.toLowerCase() === "k") {
1354
+ const views: BreakdownView[] = ["model", "cwd", "dow", "tod"];
1355
+ const idx = views.indexOf(this.view);
1356
+ const dir = matchesKey(data, Key.up) || data.toLowerCase() === "k" ? -1 : 1;
1357
+ this.view = views[(idx + views.length + dir) % views.length] ?? "model";
1358
+ this.invalidate();
1359
+ this.tui.requestRender();
1360
+ return;
1361
+ }
1362
+
944
1363
  if (data === "1") {
945
1364
  this.rangeIndex = 0;
946
1365
  this.invalidate();
@@ -976,79 +1395,137 @@ class BreakdownComponent implements Component {
976
1395
  return selected ? bold(`[${label}]`) : dim(` ${label} `);
977
1396
  };
978
1397
 
1398
+ const viewTab = (v: BreakdownView, label: string): string => {
1399
+ const selected = v === this.view;
1400
+ return selected ? bold(`[${label}]`) : dim(` ${label} `);
1401
+ };
1402
+
979
1403
  const header =
980
- `${bold("Session breakdown")} ${tab(7, 0)} ${tab(30, 1)} ${tab(90, 2)} ` +
981
- `${metricTab("sessions", "sess")} ${metricTab("messages", "msg")} ${metricTab("tokens", "tok")}`;
1404
+ `${bold("Session breakdown")} ${tab(7, 0)}${tab(30, 1)}${tab(90, 2)} ` +
1405
+ `${metricTab("sessions", "sess")}${metricTab("messages", "msg")}${metricTab("tokens", "tok")} ` +
1406
+ `${viewTab("model", "model")}${viewTab("cwd", "cwd")}${viewTab("dow", "dow")}${viewTab("tod", "tod")}`;
1407
+
1408
+ // Choose colors and legend based on current view
1409
+ let activeColorMap: Map<string, RGB>;
1410
+ let activeOtherColor: RGB = { r: 160, g: 160, b: 160 };
1411
+ const legendItems: string[] = [];
1412
+
1413
+ if (this.view === "model") {
1414
+ activeColorMap = this.data.palette.modelColors;
1415
+ activeOtherColor = this.data.palette.otherColor;
1416
+ for (const mk of this.data.palette.orderedModels) {
1417
+ const c = activeColorMap.get(mk);
1418
+ if (c) legendItems.push(`${ansiFg(c, "█")} ${displayModelName(mk)}`);
1419
+ }
1420
+ legendItems.push(`${ansiFg(activeOtherColor, "█")} other`);
1421
+ } else if (this.view === "cwd") {
1422
+ activeColorMap = this.data.cwdPalette.cwdColors;
1423
+ activeOtherColor = this.data.cwdPalette.otherColor;
1424
+ for (const cwd of this.data.cwdPalette.orderedCwds) {
1425
+ const c = activeColorMap.get(cwd);
1426
+ if (c) legendItems.push(`${ansiFg(c, "█")} ${abbreviatePath(cwd, 30)}`);
1427
+ }
1428
+ legendItems.push(`${ansiFg(activeOtherColor, "█")} other`);
1429
+ } else if (this.view === "dow") {
1430
+ activeColorMap = this.data.dowPalette.dowColors;
1431
+ for (const dow of this.data.dowPalette.orderedDows) {
1432
+ const c = activeColorMap.get(dow);
1433
+ if (c) legendItems.push(`${ansiFg(c, "█")} ${dow}`);
1434
+ }
1435
+ } else {
1436
+ activeColorMap = this.data.todPalette.todColors;
1437
+ for (const tod of this.data.todPalette.orderedTods) {
1438
+ const c = activeColorMap.get(tod);
1439
+ if (c) legendItems.push(`${ansiFg(c, "█")} ${todBucketLabel(tod)}`);
1440
+ }
1441
+ }
982
1442
 
983
- const legendItems = renderLegendItems(
984
- this.data.palette.modelColors,
985
- this.data.palette.orderedModels,
986
- this.data.palette.otherColor,
987
- );
1443
+ const graphDescriptor = this.view === "dow" ? `share of ${metric.kind} by weekday` : `${metric.kind}/day`;
1444
+ const summary = rangeSummary(range, selectedDays, metric.kind) + dim(` (graph: ${graphDescriptor})`);
988
1445
 
989
- const summary = rangeSummary(range, selectedDays, metric.kind) + dim(` (graph: ${metric.kind}/day)`);
990
-
991
- const maxScale = selectedDays === 7 ? 4 : selectedDays === 30 ? 3 : 2;
992
- const weeks = weeksForRange(range);
993
- const leftMargin = 4; // "Mon " (or 4 spaces)
994
- const gap = 1;
995
- const graphArea = Math.max(1, width - leftMargin);
996
- // Each week column uses: cellWidth + gap. Last column also gets gap (fine; we truncate anyway).
997
- const idealCellWidth = Math.floor((graphArea + gap) / Math.max(1, weeks)) - gap;
998
- const cellWidth = Math.min(maxScale, Math.max(1, idealCellWidth));
999
-
1000
- const graphLines = renderGraphLines(
1001
- range,
1002
- this.data.palette.modelColors,
1003
- this.data.palette.otherColor,
1004
- this.measurement,
1005
- { cellWidth, gap },
1006
- );
1007
- const tableLines = renderModelTable(range, metric.kind, 8);
1446
+ let graphLines: string[];
1447
+ if (this.view === "dow") {
1448
+ graphLines = renderDowDistributionLines(range, this.measurement, this.data.dowPalette.dowColors, width);
1449
+ } else {
1450
+ const maxScale = selectedDays === 7 ? 4 : selectedDays === 30 ? 3 : 2;
1451
+ const weeks = weeksForRange(range);
1452
+ const leftMargin = 4; // "Mon " (or 4 spaces)
1453
+ const gap = 1;
1454
+ const graphArea = Math.max(1, width - leftMargin);
1455
+ // Each week column uses: cellWidth + gap. Last column also gets gap (fine; we truncate anyway).
1456
+ const idealCellWidth = Math.floor((graphArea + gap) / Math.max(1, weeks)) - gap;
1457
+ const cellWidth = Math.min(maxScale, Math.max(1, idealCellWidth));
1458
+
1459
+ graphLines = renderGraphLines(
1460
+ range,
1461
+ activeColorMap,
1462
+ activeOtherColor,
1463
+ this.measurement,
1464
+ { cellWidth, gap },
1465
+ this.view,
1466
+ );
1467
+ }
1468
+ const tableLines =
1469
+ this.view === "model" ? renderModelTable(range, metric.kind, 8)
1470
+ : this.view === "cwd" ? renderCwdTable(range, metric.kind, 8)
1471
+ : this.view === "dow" ? renderDowTable(range, metric.kind)
1472
+ : renderTodTable(range, metric.kind);
1008
1473
 
1009
1474
  const lines: string[] = [];
1010
1475
  lines.push(truncateToWidth(header, width));
1011
- lines.push(truncateToWidth(dim("←/→ range · tab metric · q to close"), width));
1476
+ lines.push(truncateToWidth(dim("←/→ range · ↑/↓ view · tab metric · q to close"), width));
1012
1477
  lines.push("");
1013
1478
  lines.push(truncateToWidth(summary, width));
1014
1479
  lines.push("");
1015
1480
 
1016
- // Render legend on the RIGHT of the graph if there is space.
1017
- const graphWidth = Math.max(0, ...graphLines.map((l) => visibleWidth(l)));
1018
- const sep = 2;
1019
- const legendWidth = width - graphWidth - sep;
1020
- const showSideLegend = legendWidth >= 22;
1021
-
1022
- if (showSideLegend) {
1023
- const legendBlock: string[] = [];
1024
- legendBlock.push(dim("Top models (30d palette):"));
1025
- legendBlock.push(...legendItems);
1026
- // Fit into 7 rows (same as graph). If too many, show a final "+N more" line.
1027
- const maxLegendRows = graphLines.length;
1028
- let legendLines = legendBlock.slice(0, maxLegendRows);
1029
- if (legendBlock.length > maxLegendRows) {
1030
- const remaining = legendBlock.length - (maxLegendRows - 1);
1031
- legendLines = [...legendBlock.slice(0, maxLegendRows - 1), dim(`+${remaining} more`)];
1032
- }
1033
- while (legendLines.length < graphLines.length) legendLines.push("");
1481
+ if (this.view === "dow") {
1482
+ for (const gl of graphLines) lines.push(truncateToWidth(gl, width));
1483
+ } else {
1484
+ // Render legend on the RIGHT of the graph if there is space.
1485
+ const graphWidth = Math.max(0, ...graphLines.map((l) => visibleWidth(l)));
1486
+ const sep = 2;
1487
+ const legendWidth = width - graphWidth - sep;
1488
+ const showSideLegend = legendWidth >= 22;
1489
+
1490
+ if (showSideLegend) {
1491
+ const legendBlock: string[] = [];
1492
+ const legendTitle =
1493
+ this.view === "model" ? "Top models (30d palette):"
1494
+ : this.view === "cwd" ? "Top directories (30d palette):"
1495
+ : "Time of day:";
1496
+ legendBlock.push(dim(legendTitle));
1497
+ legendBlock.push(...legendItems);
1498
+ // Fit into 7 rows (same as graph). If too many, show a final "+N more" line.
1499
+ const maxLegendRows = graphLines.length;
1500
+ let legendLines = legendBlock.slice(0, maxLegendRows);
1501
+ if (legendBlock.length > maxLegendRows) {
1502
+ const remaining = legendBlock.length - (maxLegendRows - 1);
1503
+ legendLines = [...legendBlock.slice(0, maxLegendRows - 1), dim(`+${remaining} more`)];
1504
+ }
1505
+ while (legendLines.length < graphLines.length) legendLines.push("");
1034
1506
 
1035
- const padRightAnsi = (s: string, target: number): string => {
1036
- const w = visibleWidth(s);
1037
- return w >= target ? s : s + " ".repeat(target - w);
1038
- };
1507
+ const padRightAnsi = (s: string, target: number): string => {
1508
+ const w = visibleWidth(s);
1509
+ return w >= target ? s : s + " ".repeat(target - w);
1510
+ };
1039
1511
 
1040
- for (let i = 0; i < graphLines.length; i++) {
1041
- const left = padRightAnsi(graphLines[i] ?? "", graphWidth);
1042
- const right = truncateToWidth(legendLines[i] ?? "", Math.max(0, legendWidth));
1043
- lines.push(truncateToWidth(left + " ".repeat(sep) + right, width));
1512
+ for (let i = 0; i < graphLines.length; i++) {
1513
+ const left = padRightAnsi(graphLines[i] ?? "", graphWidth);
1514
+ const right = truncateToWidth(legendLines[i] ?? "", Math.max(0, legendWidth));
1515
+ lines.push(truncateToWidth(left + " ".repeat(sep) + right, width));
1516
+ }
1517
+ } else {
1518
+ // Fallback: graph only (legend will be shown below).
1519
+ for (const gl of graphLines) lines.push(truncateToWidth(gl, width));
1520
+ lines.push("");
1521
+ // Compact legend below, left-aligned.
1522
+ const legendTitleBelow =
1523
+ this.view === "model" ? "Top models (30d palette):"
1524
+ : this.view === "cwd" ? "Top directories (30d palette):"
1525
+ : "Time of day:";
1526
+ lines.push(truncateToWidth(dim(legendTitleBelow), width));
1527
+ for (const it of legendItems) lines.push(truncateToWidth(it, width));
1044
1528
  }
1045
- } else {
1046
- // Fallback: graph only (legend will be shown below).
1047
- for (const gl of graphLines) lines.push(truncateToWidth(gl, width));
1048
- lines.push("");
1049
- // Compact legend below, left-aligned.
1050
- lines.push(truncateToWidth(dim("Top models (30d palette):"), width));
1051
- for (const it of legendItems) lines.push(truncateToWidth(it, width));
1052
1529
  }
1053
1530
 
1054
1531
  lines.push("");