@mohndoe/pi-atlas 0.1.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.
- package/.pi/extensions/guardrails.json +10 -0
- package/.pi/extensions/guardrails.v0.json +8 -0
- package/AGENTS.md +13 -0
- package/CONTEXT.md +119 -0
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/bun.lock +325 -0
- package/docs/ARCHITECTURE.md +66 -0
- package/docs/adr/0001-global-session-project-map.md +9 -0
- package/docs/adr/0002-precomputed-summaries.md +9 -0
- package/docs/agents/domain.md +42 -0
- package/docs/agents/issue-tracker.md +22 -0
- package/docs/agents/triage-labels.md +14 -0
- package/package.json +49 -0
- package/src/__tests__/cache.test.ts +388 -0
- package/src/__tests__/components.fixtures.ts +54 -0
- package/src/__tests__/compute.fixtures.ts +49 -0
- package/src/__tests__/compute.test.ts +336 -0
- package/src/__tests__/e2e.test.ts +182 -0
- package/src/__tests__/format.test.ts +232 -0
- package/src/__tests__/parser.test.ts +1396 -0
- package/src/cache.ts +178 -0
- package/src/colorPalette.ts +119 -0
- package/src/components/BarChart.ts +288 -0
- package/src/components/Dashboard.ts +222 -0
- package/src/components/Header.ts +40 -0
- package/src/components/KpiCards.ts +104 -0
- package/src/components/LoadingView.ts +38 -0
- package/src/components/MarqueeText.ts +79 -0
- package/src/components/RangeSelector.ts +63 -0
- package/src/components/RankedBarList.ts +71 -0
- package/src/components/SortedTable.ts +221 -0
- package/src/components/StatCard.ts +64 -0
- package/src/components/TabBar.ts +59 -0
- package/src/components/UsageRow.ts +55 -0
- package/src/components/__tests__/Bar.test.ts +66 -0
- package/src/components/__tests__/BarChart.test.ts +224 -0
- package/src/components/__tests__/Dashboard.test.ts +452 -0
- package/src/components/__tests__/KpiCards.test.ts +83 -0
- package/src/components/__tests__/LoadingView.test.ts +26 -0
- package/src/components/__tests__/MarqueeText.test.ts +75 -0
- package/src/components/__tests__/RangeSelector.test.ts +34 -0
- package/src/components/__tests__/RankedBarList.test.ts +110 -0
- package/src/components/__tests__/SortedTable.integration.test.ts +228 -0
- package/src/components/__tests__/SortedTable.test.ts +723 -0
- package/src/components/__tests__/TabBar.test.ts +62 -0
- package/src/components/__tests__/cells.test.ts +193 -0
- package/src/components/cells.ts +108 -0
- package/src/components/shared/Bar.ts +22 -0
- package/src/components/shared/GridRow.ts +22 -0
- package/src/compute.ts +210 -0
- package/src/format.ts +219 -0
- package/src/index.ts +88 -0
- package/src/parser.ts +363 -0
- package/src/tabs/Languages.ts +102 -0
- package/src/tabs/Models.ts +108 -0
- package/src/tabs/Overview.ts +152 -0
- package/src/tabs/Projects.ts +92 -0
- package/src/tabs/Usage.ts +181 -0
- package/src/tabs/__tests__/Languages.test.ts +158 -0
- package/src/tabs/__tests__/Models.test.ts +143 -0
- package/src/tabs/__tests__/Overview.test.ts +92 -0
- package/src/tabs/__tests__/Projects.test.ts +142 -0
- package/src/tabs/__tests__/Usage.test.ts +174 -0
- package/src/types.ts +99 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, Text, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { ColorPalette } from "../colorPalette";
|
|
4
|
+
import { cell, type CellComponent } from "../components/cells";
|
|
5
|
+
import { SortedTable } from "../components/SortedTable";
|
|
6
|
+
import { formatNumber } from "../format";
|
|
7
|
+
import type { LangStat } from "../types";
|
|
8
|
+
import { BorderBox } from "@mohndoe/pi-tui-extras";
|
|
9
|
+
|
|
10
|
+
const EMPTY_MESSAGE = "No language data for this time range";
|
|
11
|
+
|
|
12
|
+
export class Languages extends Container {
|
|
13
|
+
private isEmpty: boolean;
|
|
14
|
+
private theme: Theme;
|
|
15
|
+
private table: SortedTable | null = null;
|
|
16
|
+
private rows: CellComponent[][] = [];
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private languages: LangStat[],
|
|
20
|
+
theme: Theme,
|
|
21
|
+
private palette: ColorPalette,
|
|
22
|
+
private tui: TUI,
|
|
23
|
+
private maxHeight: number,
|
|
24
|
+
) {
|
|
25
|
+
super();
|
|
26
|
+
this.theme = theme;
|
|
27
|
+
this.isEmpty = languages.length === 0;
|
|
28
|
+
this.buildRows();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build row cells once in constructor. Data is stable per Languages instance —
|
|
32
|
+
* a new Languages is created whenever Dashboard.buildTabs() runs (range switch). */
|
|
33
|
+
private buildRows(): void {
|
|
34
|
+
if (this.isEmpty) return;
|
|
35
|
+
const maxLines = Math.max(...this.languages.map((l) => l.lines), 0);
|
|
36
|
+
this.rows = this.languages.map((l) => {
|
|
37
|
+
const barPct = maxLines > 0 ? (l.lines / maxLines) * 100 : 0;
|
|
38
|
+
return [
|
|
39
|
+
cell.marquee(l.language, this.tui),
|
|
40
|
+
cell.bar(barPct, this.palette.getColor(l.language), "transparent"),
|
|
41
|
+
cell.text(this.theme.fg("muted", formatNumber(l.edits))),
|
|
42
|
+
cell.text(this.theme.bold(formatNumber(l.lines))),
|
|
43
|
+
];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
override render(width: number): string[] {
|
|
48
|
+
this.clear();
|
|
49
|
+
|
|
50
|
+
const baseBorderBoxOptions = {
|
|
51
|
+
borderStyle: "singleRounded" as const,
|
|
52
|
+
borderFn: (s: string) => this.theme.fg("border", s),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (!this.isEmpty) {
|
|
56
|
+
const bb = new BorderBox({
|
|
57
|
+
...baseBorderBoxOptions,
|
|
58
|
+
titles: [
|
|
59
|
+
{ text: "Languages", align: "left" },
|
|
60
|
+
{ text: this.theme.fg("dim", formatNumber(this.languages.length)), align: "right" },
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!this.table) {
|
|
65
|
+
this.table = new SortedTable(
|
|
66
|
+
{
|
|
67
|
+
columns: [
|
|
68
|
+
{ header: cell.header("Name"), width: 12 },
|
|
69
|
+
{ header: cell.header("Share %"), width: "fill" },
|
|
70
|
+
{ header: cell.header("Edits"), width: 8 },
|
|
71
|
+
{ header: cell.header("Lines"), width: 14 },
|
|
72
|
+
],
|
|
73
|
+
rows: this.rows,
|
|
74
|
+
maxHeight: this.maxHeight,
|
|
75
|
+
sort: { column: 3, direction: "desc" },
|
|
76
|
+
tui: this.tui,
|
|
77
|
+
},
|
|
78
|
+
this.theme,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
bb.addChild(this.table);
|
|
82
|
+
this.addChild(bb);
|
|
83
|
+
} else {
|
|
84
|
+
const bb = new BorderBox({
|
|
85
|
+
...baseBorderBoxOptions,
|
|
86
|
+
titles: [{ text: "Languages", align: "left" }],
|
|
87
|
+
});
|
|
88
|
+
bb.addChild(new Text(this.theme.fg("muted", EMPTY_MESSAGE)));
|
|
89
|
+
this.addChild(bb);
|
|
90
|
+
}
|
|
91
|
+
return super.render(width);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
handleInput(data: string): void {
|
|
95
|
+
this.table?.handleInput(data);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
override invalidate(): void {
|
|
99
|
+
super.invalidate();
|
|
100
|
+
this.table?.invalidate();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, Text, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { ColorPalette } from "../colorPalette";
|
|
4
|
+
import { cell, type CellComponent } from "../components/cells";
|
|
5
|
+
import { SortedTable } from "../components/SortedTable";
|
|
6
|
+
import { formatCost, formatModelName, formatNumber } from "../format";
|
|
7
|
+
import { type ModelStat } from "../types";
|
|
8
|
+
import { BorderBox } from "@mohndoe/pi-tui-extras";
|
|
9
|
+
|
|
10
|
+
const EMPTY_MESSAGE = "No model data for this time range";
|
|
11
|
+
|
|
12
|
+
export class Models extends Container {
|
|
13
|
+
private isEmpty: boolean;
|
|
14
|
+
private theme: Theme;
|
|
15
|
+
private table: SortedTable | null = null;
|
|
16
|
+
private rows: CellComponent[][] = [];
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private models: ModelStat[],
|
|
20
|
+
theme: Theme,
|
|
21
|
+
private palette: ColorPalette,
|
|
22
|
+
private tui: TUI,
|
|
23
|
+
private maxHeight: number,
|
|
24
|
+
) {
|
|
25
|
+
super();
|
|
26
|
+
this.theme = theme;
|
|
27
|
+
this.isEmpty = models.length === 0;
|
|
28
|
+
this.buildRows();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build row cells once in constructor. Data is stable per Models instance —
|
|
32
|
+
* a new Models is created whenever Dashboard.buildTabs() runs (range switch).
|
|
33
|
+
* Building rows every render would destroy marquee state on each frame. */
|
|
34
|
+
private buildRows(): void {
|
|
35
|
+
if (this.isEmpty) return;
|
|
36
|
+
const totalCost = this.models.reduce((sum, item) => sum + item.cost, 0);
|
|
37
|
+
const maxCost = Math.max(...this.models.map((m) => m.cost), 0);
|
|
38
|
+
this.rows = this.models.map((m) => {
|
|
39
|
+
let pct = 0;
|
|
40
|
+
let barPct = 0;
|
|
41
|
+
if (totalCost > 0) {
|
|
42
|
+
pct = (m.cost * 100) / totalCost;
|
|
43
|
+
barPct = maxCost > 0 ? (m.cost / maxCost) * 100 : 0;
|
|
44
|
+
}
|
|
45
|
+
return [
|
|
46
|
+
cell.marquee(formatModelName(m.model), this.tui),
|
|
47
|
+
cell.text(this.theme.fg("muted", m.provider ?? "Unknown")),
|
|
48
|
+
cell.bar(barPct, this.palette.getColor(m.provider ?? "Unknown"), "transparent"),
|
|
49
|
+
cell.text(this.theme.fg("muted", formatNumber(m.calls))),
|
|
50
|
+
cell.text(m.cost > 0 ? this.theme.bold(formatCost(m.cost)) : this.theme.fg("dim", "Free")),
|
|
51
|
+
];
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override render(width: number): string[] {
|
|
56
|
+
this.clear();
|
|
57
|
+
const baseBorderBoxOptions = {
|
|
58
|
+
borderStyle: "singleRounded" as const,
|
|
59
|
+
borderFn: (s: string) => this.theme.fg("border", s),
|
|
60
|
+
};
|
|
61
|
+
if (!this.isEmpty) {
|
|
62
|
+
const bb = new BorderBox({
|
|
63
|
+
...baseBorderBoxOptions,
|
|
64
|
+
titles: [
|
|
65
|
+
{ text: "Models", align: "left" },
|
|
66
|
+
{ text: this.theme.fg("dim", formatNumber(this.models.length)), align: "right" },
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
if (!this.table) {
|
|
70
|
+
this.table = new SortedTable(
|
|
71
|
+
{
|
|
72
|
+
columns: [
|
|
73
|
+
{ header: cell.header("Name"), width: 32 },
|
|
74
|
+
{ header: cell.header("Provider"), width: 16 },
|
|
75
|
+
{ header: cell.header("Cost %"), width: "fill" },
|
|
76
|
+
{ header: cell.header("Calls"), width: 10 },
|
|
77
|
+
{ header: cell.header("Cost"), width: 12 },
|
|
78
|
+
],
|
|
79
|
+
rows: this.rows,
|
|
80
|
+
maxHeight: this.maxHeight,
|
|
81
|
+
sort: { column: 4, direction: "desc" },
|
|
82
|
+
tui: this.tui,
|
|
83
|
+
},
|
|
84
|
+
this.theme,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
bb.addChild(this.table);
|
|
88
|
+
this.addChild(bb);
|
|
89
|
+
} else {
|
|
90
|
+
const bb = new BorderBox({
|
|
91
|
+
...baseBorderBoxOptions,
|
|
92
|
+
titles: [{ text: "Models", align: "left" }],
|
|
93
|
+
});
|
|
94
|
+
bb.addChild(new Text(this.theme.fg("muted", EMPTY_MESSAGE)));
|
|
95
|
+
this.addChild(bb);
|
|
96
|
+
}
|
|
97
|
+
return super.render(width);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
handleInput(data: string): void {
|
|
101
|
+
this.table?.handleInput(data);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
override invalidate(): void {
|
|
105
|
+
super.invalidate();
|
|
106
|
+
this.table?.invalidate();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { BorderBox } from "@mohndoe/pi-tui-extras";
|
|
4
|
+
import { langPalette, modelPalette } from "../colorPalette";
|
|
5
|
+
import { BarChart } from "../components/BarChart";
|
|
6
|
+
import { KpiCards, type KpiData } from "../components/KpiCards";
|
|
7
|
+
import { GridRow } from "../components/shared/GridRow";
|
|
8
|
+
import { StatCard } from "../components/StatCard";
|
|
9
|
+
import { formatCost, formatNumber } from "../format";
|
|
10
|
+
import { type StatsSummary, type TimeRange } from "../types";
|
|
11
|
+
|
|
12
|
+
const SPACER_HEIGHT = 1;
|
|
13
|
+
const BAR_CHART_MAX_HEIGHT = 18;
|
|
14
|
+
|
|
15
|
+
export class Overview extends Container {
|
|
16
|
+
private kpiCards: KpiCards;
|
|
17
|
+
private barChart: BarChart;
|
|
18
|
+
private topCards: GridRow;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private summary: StatsSummary,
|
|
22
|
+
rangeKey: TimeRange,
|
|
23
|
+
private theme: Theme,
|
|
24
|
+
maxHeight: number,
|
|
25
|
+
) {
|
|
26
|
+
super();
|
|
27
|
+
const kpis: KpiData = {
|
|
28
|
+
totalCost: this.summary.totalCost,
|
|
29
|
+
sessionCount: this.summary.sessionCount,
|
|
30
|
+
totalMessages: this.summary.totalMessages,
|
|
31
|
+
totalTokens: this.summary.totalTokens,
|
|
32
|
+
daysActive: this.summary.daysActive,
|
|
33
|
+
avgCostPerDay: this.summary.avgCostPerDay,
|
|
34
|
+
};
|
|
35
|
+
this.kpiCards = new KpiCards(kpis, this.theme);
|
|
36
|
+
|
|
37
|
+
const topLanguage = summary.languages[0];
|
|
38
|
+
const topModel = summary.models[0];
|
|
39
|
+
const topProject = summary.projects[0];
|
|
40
|
+
|
|
41
|
+
const langBox = new BorderBox({
|
|
42
|
+
titles: [{ text: this.theme.bold("Top Language"), align: "left" }],
|
|
43
|
+
padding: { left: 1, right: 1 },
|
|
44
|
+
borderFn: topLanguage
|
|
45
|
+
? langPalette.getColor(topLanguage.language)
|
|
46
|
+
: (s: string) => this.theme.fg("borderMuted", s),
|
|
47
|
+
});
|
|
48
|
+
langBox.addChild(
|
|
49
|
+
topLanguage
|
|
50
|
+
? new StatCard(
|
|
51
|
+
{
|
|
52
|
+
label: { text: topLanguage.language },
|
|
53
|
+
value: {
|
|
54
|
+
text: this.theme.bold(formatNumber(topLanguage.lines) + " lines"),
|
|
55
|
+
color: "text",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
this.theme,
|
|
59
|
+
)
|
|
60
|
+
: new Text("No data"),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const modelBox = new BorderBox({
|
|
64
|
+
titles: [{ text: this.theme.bold("Top model"), align: "left" }],
|
|
65
|
+
padding: { left: 1, right: 1 },
|
|
66
|
+
borderFn: modelPalette.getColor(topModel?.provider || ""),
|
|
67
|
+
});
|
|
68
|
+
modelBox.addChild(
|
|
69
|
+
topModel
|
|
70
|
+
? new StatCard(
|
|
71
|
+
{
|
|
72
|
+
label: { text: topModel.model },
|
|
73
|
+
value: {
|
|
74
|
+
text: this.theme.bold(formatCost(topModel.cost)),
|
|
75
|
+
color: "text",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
this.theme,
|
|
79
|
+
)
|
|
80
|
+
: new Text("No data."),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const projectBox = new BorderBox({
|
|
84
|
+
titles: [{ text: this.theme.bold("Top project"), align: "left" }],
|
|
85
|
+
padding: { left: 1, right: 1 },
|
|
86
|
+
borderFn: (s: string) => this.theme.fg("borderMuted", s),
|
|
87
|
+
});
|
|
88
|
+
projectBox.addChild(
|
|
89
|
+
topProject
|
|
90
|
+
? new StatCard(
|
|
91
|
+
{
|
|
92
|
+
label: { text: topProject.project },
|
|
93
|
+
value: {
|
|
94
|
+
text: this.theme.bold(formatCost(topProject.cost)),
|
|
95
|
+
color: "text",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
this.theme,
|
|
99
|
+
)
|
|
100
|
+
: new Text("No data."),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
this.topCards = new GridRow([langBox, modelBox, projectBox], [33, 33, 34]);
|
|
104
|
+
|
|
105
|
+
const kpiCardsHeight = this.kpiCards.render(80).length;
|
|
106
|
+
const topCardsHeight = this.topCards.render(80).length;
|
|
107
|
+
|
|
108
|
+
const chartHeight = Math.min(
|
|
109
|
+
BAR_CHART_MAX_HEIGHT,
|
|
110
|
+
maxHeight - kpiCardsHeight - topCardsHeight - SPACER_HEIGHT * 0,
|
|
111
|
+
);
|
|
112
|
+
this.barChart = new BarChart(
|
|
113
|
+
this.summary.dailySpend,
|
|
114
|
+
rangeKey,
|
|
115
|
+
chartHeight,
|
|
116
|
+
this.theme,
|
|
117
|
+
undefined,
|
|
118
|
+
this.summary.hourlySpend,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
override render(width: number): string[] {
|
|
123
|
+
this.clear();
|
|
124
|
+
const kpiBorderBox = new BorderBox({
|
|
125
|
+
borderStyle: "singleRounded",
|
|
126
|
+
padding: { left: 1, right: 1 },
|
|
127
|
+
});
|
|
128
|
+
kpiBorderBox.addChild(this.kpiCards);
|
|
129
|
+
this.addChild(kpiBorderBox);
|
|
130
|
+
|
|
131
|
+
const costBarChartBox = new BorderBox({
|
|
132
|
+
borderStyle: "singleRounded",
|
|
133
|
+
titles: [{ text: this.theme.bold("Cost overtime"), align: "left" }],
|
|
134
|
+
borderFn: (s: string) => this.theme.fg("border", s),
|
|
135
|
+
padding: { left: 1, right: 1, top: 1 },
|
|
136
|
+
});
|
|
137
|
+
costBarChartBox.addChild(this.barChart);
|
|
138
|
+
this.addChild(costBarChartBox);
|
|
139
|
+
|
|
140
|
+
this.addChild(this.topCards);
|
|
141
|
+
|
|
142
|
+
return super.render(width);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
override invalidate(): void {
|
|
146
|
+
super.invalidate();
|
|
147
|
+
this.kpiCards.invalidate();
|
|
148
|
+
this.barChart.invalidate();
|
|
149
|
+
this.topCards.invalidate();
|
|
150
|
+
this.children.forEach((c) => c.invalidate?.());
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, Text, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { BorderBox, type BorderBoxOptions } from "@mohndoe/pi-tui-extras";
|
|
4
|
+
import { cell, type CellComponent } from "../components/cells";
|
|
5
|
+
import { SortedTable } from "../components/SortedTable";
|
|
6
|
+
import { formatCost, formatNumber } from "../format";
|
|
7
|
+
import { type ProjectStat } from "../types";
|
|
8
|
+
|
|
9
|
+
const EMPTY_MESSAGE = "No projects data for this time range";
|
|
10
|
+
|
|
11
|
+
export class Projects extends Container {
|
|
12
|
+
private isEmpty: boolean;
|
|
13
|
+
private theme: Theme;
|
|
14
|
+
private table: SortedTable | null = null;
|
|
15
|
+
private rows: CellComponent[][] = [];
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private projects: ProjectStat[],
|
|
19
|
+
theme: Theme,
|
|
20
|
+
private tui: TUI,
|
|
21
|
+
private maxHeight: number,
|
|
22
|
+
) {
|
|
23
|
+
super();
|
|
24
|
+
this.theme = theme;
|
|
25
|
+
this.isEmpty = projects.length === 0;
|
|
26
|
+
this.buildRows();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build row cells once in constructor. Data is stable per Projects instance. */
|
|
30
|
+
private buildRows(): void {
|
|
31
|
+
if (this.isEmpty) return;
|
|
32
|
+
const maxCost = Math.max(...this.projects.map((p) => p.cost), 0);
|
|
33
|
+
this.rows = this.projects.map((p) => {
|
|
34
|
+
const barPct = maxCost > 0 ? (p.cost / maxCost) * 100 : 0;
|
|
35
|
+
return [
|
|
36
|
+
cell.marquee(p.project, this.tui),
|
|
37
|
+
cell.bar(barPct, (s) => s, "transparent"),
|
|
38
|
+
cell.text(this.theme.fg("muted", formatNumber(p.sessions))),
|
|
39
|
+
cell.text(p.cost > 0 ? this.theme.bold(formatCost(p.cost)) : this.theme.fg("dim", "Free")),
|
|
40
|
+
];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override render(width: number): string[] {
|
|
45
|
+
this.clear();
|
|
46
|
+
|
|
47
|
+
const borderBoxOptions: BorderBoxOptions = {
|
|
48
|
+
borderStyle: "singleRounded",
|
|
49
|
+
borderFn: (s) => this.theme.fg("border", s),
|
|
50
|
+
titles: [{ text: "Projects", align: "left" }],
|
|
51
|
+
};
|
|
52
|
+
let borderBox = new BorderBox(borderBoxOptions);
|
|
53
|
+
if (!this.isEmpty) {
|
|
54
|
+
borderBoxOptions.titles = [
|
|
55
|
+
...borderBoxOptions.titles!,
|
|
56
|
+
{ text: this.theme.fg("dim", formatNumber(this.projects.length)), align: "right" },
|
|
57
|
+
];
|
|
58
|
+
if (!this.table) {
|
|
59
|
+
this.table = new SortedTable(
|
|
60
|
+
{
|
|
61
|
+
columns: [
|
|
62
|
+
{ header: cell.header("Name"), width: 20 },
|
|
63
|
+
{ header: cell.header("Share %"), width: "fill" },
|
|
64
|
+
{ header: cell.header("Sessions"), width: 14 },
|
|
65
|
+
{ header: cell.header("Cost"), width: 8 },
|
|
66
|
+
],
|
|
67
|
+
rows: this.rows,
|
|
68
|
+
maxHeight: this.maxHeight,
|
|
69
|
+
sort: { column: 3, direction: "desc" },
|
|
70
|
+
tui: this.tui,
|
|
71
|
+
},
|
|
72
|
+
this.theme,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
borderBox = new BorderBox(borderBoxOptions);
|
|
76
|
+
borderBox.addChild(this.table);
|
|
77
|
+
} else {
|
|
78
|
+
borderBox.addChild(new Text(this.theme.fg("muted", EMPTY_MESSAGE)));
|
|
79
|
+
}
|
|
80
|
+
this.addChild(borderBox);
|
|
81
|
+
return super.render(width);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
handleInput(data: string): void {
|
|
85
|
+
this.table?.handleInput(data);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
override invalidate(): void {
|
|
89
|
+
super.invalidate();
|
|
90
|
+
this.table?.invalidate();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, Text, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { BorderBox, type BorderBoxOptions } from "@mohndoe/pi-tui-extras";
|
|
4
|
+
import { cell, type CellComponent } from "../components/cells";
|
|
5
|
+
import { GridRow } from "../components/shared/GridRow";
|
|
6
|
+
import { SortedTable } from "../components/SortedTable";
|
|
7
|
+
import { StatCard } from "../components/StatCard";
|
|
8
|
+
import { formatNumber, stripAnsi } from "../format";
|
|
9
|
+
import type { StatsSummary, ToolStat } from "../types";
|
|
10
|
+
|
|
11
|
+
interface TokenUsageStat {
|
|
12
|
+
total: StatsSummary["totalTokens"];
|
|
13
|
+
input: StatsSummary["totalInputTokens"];
|
|
14
|
+
output: StatsSummary["totalOutputTokens"];
|
|
15
|
+
cacheRead: StatsSummary["totalCacheReadTokens"];
|
|
16
|
+
cacheWrite: StatsSummary["totalCacheWriteTokens"];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TOOL_NAME_MAX_LENGTH = 120;
|
|
20
|
+
|
|
21
|
+
const EMPTY_MESSAGE = "No tools data for this time range";
|
|
22
|
+
|
|
23
|
+
export class Usage extends Container {
|
|
24
|
+
private isEmpty: boolean;
|
|
25
|
+
private theme: Theme;
|
|
26
|
+
private tokenUsage: TokenUsageStat;
|
|
27
|
+
private rows: CellComponent[][] = [];
|
|
28
|
+
private table: SortedTable | null = null;
|
|
29
|
+
private tableHeight: number;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
private tools: ToolStat[],
|
|
33
|
+
tokenUsage: TokenUsageStat,
|
|
34
|
+
theme: Theme,
|
|
35
|
+
private tui: TUI,
|
|
36
|
+
maxHeight: number,
|
|
37
|
+
) {
|
|
38
|
+
super();
|
|
39
|
+
this.theme = theme;
|
|
40
|
+
this.tokenUsage = tokenUsage;
|
|
41
|
+
this.isEmpty = tools.length === 0;
|
|
42
|
+
// Tool table gets contentHeight - 4 to account for "Tokens" title + spacer + 2 line overhead
|
|
43
|
+
this.tableHeight = Math.max(3, maxHeight - 4);
|
|
44
|
+
this.buildRows();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Build row cells once in constructor. */
|
|
48
|
+
private buildRows(): void {
|
|
49
|
+
if (this.isEmpty) return;
|
|
50
|
+
const maxCount = Math.max(...this.tools.map((t) => t.count), 0);
|
|
51
|
+
this.rows = this.tools.map((t) => {
|
|
52
|
+
const barPct = maxCount > 0 ? (t.count / maxCount) * 100 : 0;
|
|
53
|
+
return [
|
|
54
|
+
cell.marquee(stripAnsi(t.name).slice(0, TOOL_NAME_MAX_LENGTH), this.tui),
|
|
55
|
+
cell.bar(barPct, (s) => s, "transparent"),
|
|
56
|
+
cell.text(this.theme.bold(formatNumber(t.count))),
|
|
57
|
+
];
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
override render(width: number): string[] {
|
|
62
|
+
this.clear();
|
|
63
|
+
|
|
64
|
+
// Token section: title + stat cards
|
|
65
|
+
const title = this.theme.bold("Tokens");
|
|
66
|
+
const subtitle = this.theme.fg("muted", formatNumber(this.tokenUsage.total));
|
|
67
|
+
const row = new GridRow(
|
|
68
|
+
[
|
|
69
|
+
new StatCard(
|
|
70
|
+
{
|
|
71
|
+
label: {
|
|
72
|
+
text: "Input",
|
|
73
|
+
},
|
|
74
|
+
value: {
|
|
75
|
+
text: this.theme.bold(formatNumber(this.tokenUsage.input)),
|
|
76
|
+
color: "accent",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
this.theme,
|
|
80
|
+
),
|
|
81
|
+
new StatCard(
|
|
82
|
+
{
|
|
83
|
+
label: {
|
|
84
|
+
text: "Output",
|
|
85
|
+
},
|
|
86
|
+
value: {
|
|
87
|
+
text: this.theme.bold(formatNumber(this.tokenUsage.output)),
|
|
88
|
+
color: "accent",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
this.theme,
|
|
93
|
+
),
|
|
94
|
+
new StatCard(
|
|
95
|
+
{
|
|
96
|
+
label: {
|
|
97
|
+
text: "Cache Read",
|
|
98
|
+
},
|
|
99
|
+
value: {
|
|
100
|
+
text: this.theme.bold(formatNumber(this.tokenUsage.cacheRead)),
|
|
101
|
+
color: "accent",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
this.theme,
|
|
105
|
+
),
|
|
106
|
+
new StatCard(
|
|
107
|
+
{
|
|
108
|
+
label: {
|
|
109
|
+
text: "Cache Write",
|
|
110
|
+
},
|
|
111
|
+
value: {
|
|
112
|
+
text: this.theme.bold(formatNumber(this.tokenUsage.cacheWrite)),
|
|
113
|
+
color: "accent",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
this.theme,
|
|
117
|
+
),
|
|
118
|
+
],
|
|
119
|
+
[25, 25, 25, 25],
|
|
120
|
+
);
|
|
121
|
+
const statRowBorderBox = new BorderBox({
|
|
122
|
+
titles: [
|
|
123
|
+
{ text: title, align: "left" },
|
|
124
|
+
{ text: subtitle, align: "right" },
|
|
125
|
+
],
|
|
126
|
+
borderFn: (s: string) => this.theme.fg("border", s),
|
|
127
|
+
padding: { left: 1, right: 1 },
|
|
128
|
+
});
|
|
129
|
+
statRowBorderBox.addChild(row);
|
|
130
|
+
this.addChild(statRowBorderBox);
|
|
131
|
+
|
|
132
|
+
const borderBoxOptions: BorderBoxOptions = {
|
|
133
|
+
borderStyle: "singleRounded",
|
|
134
|
+
borderFn: (s) => this.theme.fg("border", s),
|
|
135
|
+
titles: [{ text: "Tools", align: "left" }],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
let borderBox = new BorderBox(borderBoxOptions);
|
|
139
|
+
|
|
140
|
+
// Tool table section
|
|
141
|
+
if (!this.isEmpty) {
|
|
142
|
+
borderBoxOptions.titles = [
|
|
143
|
+
...borderBoxOptions.titles!,
|
|
144
|
+
{ text: this.theme.fg("dim", formatNumber(this.rows.length)), align: "right" },
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
if (!this.table) {
|
|
148
|
+
this.table = new SortedTable(
|
|
149
|
+
{
|
|
150
|
+
columns: [
|
|
151
|
+
{ header: cell.header("Command"), width: 20 },
|
|
152
|
+
{ header: cell.header("Share %"), width: "fill" },
|
|
153
|
+
{ header: cell.header("Calls"), width: 12 },
|
|
154
|
+
],
|
|
155
|
+
rows: this.rows,
|
|
156
|
+
maxHeight: this.tableHeight,
|
|
157
|
+
sort: { column: 2, direction: "desc" },
|
|
158
|
+
tui: this.tui,
|
|
159
|
+
},
|
|
160
|
+
this.theme,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
borderBox = new BorderBox(borderBoxOptions);
|
|
164
|
+
borderBox.addChild(this.table);
|
|
165
|
+
} else {
|
|
166
|
+
borderBox.addChild(new Text(this.theme.fg("muted", EMPTY_MESSAGE)));
|
|
167
|
+
}
|
|
168
|
+
this.addChild(borderBox);
|
|
169
|
+
|
|
170
|
+
return super.render(width);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
handleInput(data: string): void {
|
|
174
|
+
this.table?.handleInput(data);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
override invalidate(): void {
|
|
178
|
+
super.invalidate();
|
|
179
|
+
this.table?.invalidate();
|
|
180
|
+
}
|
|
181
|
+
}
|