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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +24 -0
- package/README.md +28 -18
- package/analyze-edits.py +232 -0
- package/distributions/README.md +8 -0
- package/distributions/mitsupi-common/README.md +11 -0
- package/distributions/mitsupi-common/package.json +95 -0
- package/distributions/mitsupi-loaded/README.md +13 -0
- package/distributions/mitsupi-loaded/package.json +38 -0
- package/{pi-extensions → extensions}/answer.ts +11 -11
- package/extensions/btw.ts +963 -0
- package/{pi-extensions → extensions}/context.ts +2 -2
- package/{pi-extensions → extensions}/control.ts +20 -13
- package/{pi-extensions → extensions}/files.ts +6 -8
- package/{pi-extensions → extensions}/go-to-bed.ts +2 -2
- package/{pi-extensions → extensions}/loop.ts +18 -11
- package/extensions/multi-edit.ts +871 -0
- package/{pi-extensions → extensions}/review.ts +254 -82
- package/{pi-extensions → extensions}/session-breakdown.ts +551 -74
- package/extensions/split-fork.ts +130 -0
- package/{pi-extensions → extensions}/todos.ts +41 -34
- package/extensions/uv.ts +123 -0
- package/intercepted-commands/poetry +8 -1
- package/intercepted-commands/python +42 -2
- package/intercepted-commands/python3 +42 -2
- package/package.json +6 -3
- package/skills/google-workspace/SKILL.md +53 -8
- package/skills/google-workspace/scripts/auth.js +106 -28
- package/skills/google-workspace/scripts/common.js +201 -32
- package/skills/google-workspace/scripts/workspace.js +64 -9
- package/skills/native-web-search/SKILL.md +2 -0
- package/skills/native-web-search/search.mjs +102 -15
- package/skills/pi-share/SKILL.md +5 -2
- package/skills/pi-share/fetch-session.mjs +46 -15
- package/skills/summarize/SKILL.md +4 -3
- package/skills/summarize/to-markdown.mjs +3 -1
- package/skills/web-browser/SKILL.md +6 -0
- package/skills/web-browser/scripts/start.js +75 -27
- package/pi-extensions/uv.ts +0 -33
- /package/{pi-extensions → extensions}/notify.ts +0 -0
- /package/{pi-extensions → extensions}/prompt-editor.ts +0 -0
- /package/{pi-extensions → extensions}/whimsical.ts +0 -0
- /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 (
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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<
|
|
596
|
-
if (
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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)}
|
|
981
|
-
`${metricTab("sessions", "sess")}
|
|
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
|
|
984
|
-
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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("");
|