@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,222 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey, Spacer, Text, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { BorderBox } from "@mohndoe/pi-tui-extras";
|
|
4
|
+
import type { TextDef } from "@mohndoe/pi-tui-extras/src";
|
|
5
|
+
import { ColorPalette, langPalette, modelPalette } from "../colorPalette";
|
|
6
|
+
import { Languages } from "../tabs/Languages";
|
|
7
|
+
import { Models } from "../tabs/Models";
|
|
8
|
+
import { Overview } from "../tabs/Overview";
|
|
9
|
+
import { Projects } from "../tabs/Projects";
|
|
10
|
+
import { Usage } from "../tabs/Usage";
|
|
11
|
+
import type { StatsSummary, TimeRange } from "../types";
|
|
12
|
+
import { RangeSelector } from "./RangeSelector";
|
|
13
|
+
import { TabBar } from "./TabBar";
|
|
14
|
+
|
|
15
|
+
function stylizedRangeTitle(theme: Theme, label: string): string {
|
|
16
|
+
return theme.fg("accent", label) + " " + theme.fg("muted", "[r]");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class Dashboard extends BorderBox {
|
|
20
|
+
/** Rows consumed by header, spacers, dividers, tab bar, and footer (non-content chrome). */
|
|
21
|
+
private static readonly CHROME_ROWS = 3;
|
|
22
|
+
|
|
23
|
+
private tabBar: TabBar;
|
|
24
|
+
private onClose: (() => void) | null = null;
|
|
25
|
+
private tabs: Component[] = [];
|
|
26
|
+
private langPalette: ColorPalette;
|
|
27
|
+
private modelPalette: ColorPalette;
|
|
28
|
+
private contentHeight = 0;
|
|
29
|
+
|
|
30
|
+
private rangeLabelTitle: TextDef;
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private summaries: Map<TimeRange, StatsSummary>,
|
|
34
|
+
private theme: Theme,
|
|
35
|
+
private tui: TUI,
|
|
36
|
+
updateLabel: string | null,
|
|
37
|
+
private rangeSelector: RangeSelector,
|
|
38
|
+
onClose?: () => void,
|
|
39
|
+
) {
|
|
40
|
+
// BorderBox footer with update label (styled to match current DashboardPopup look)
|
|
41
|
+
const footers = updateLabel
|
|
42
|
+
? [{ text: theme.fg("muted", theme.italic(updateLabel)), align: "right" as const }]
|
|
43
|
+
: [];
|
|
44
|
+
|
|
45
|
+
const rangeLabelTitle: TextDef = {
|
|
46
|
+
text: stylizedRangeTitle(theme, rangeSelector.selectedLabel),
|
|
47
|
+
align: "right",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
super({
|
|
51
|
+
titles: [
|
|
52
|
+
{ text: theme.bold("Pi Atlas") + theme.fg("dim", " · v0.1"), align: "left" },
|
|
53
|
+
rangeLabelTitle,
|
|
54
|
+
],
|
|
55
|
+
footers,
|
|
56
|
+
borderStyle: "singleRounded",
|
|
57
|
+
borderFn: (s: string) => theme.fg("text", s),
|
|
58
|
+
padding: { left: 1, right: 1, top: 1 },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.rangeLabelTitle = rangeLabelTitle;
|
|
62
|
+
|
|
63
|
+
this.onClose = onClose ?? null;
|
|
64
|
+
this.langPalette = langPalette;
|
|
65
|
+
this.modelPalette = modelPalette;
|
|
66
|
+
this.tabBar = new TabBar(["Overview", "Languages", "Models", "Projects", "Usage"], theme, 0);
|
|
67
|
+
this.contentHeight = this.computeContentHeight();
|
|
68
|
+
this.buildTabs();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Compute the available content height from current terminal dimensions.
|
|
72
|
+
* Total popup = 80% of terminal. Subtract chrome rows (inside border) and
|
|
73
|
+
* 2 border lines (top + bottom from BorderBox). */
|
|
74
|
+
private computeContentHeight(): number {
|
|
75
|
+
const termHeight = this.tui.terminal.rows;
|
|
76
|
+
const dashRows = Math.floor(termHeight * 0.8);
|
|
77
|
+
return Math.max(5, dashRows - Dashboard.CHROME_ROWS - 2);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private get currentSummary(): StatsSummary {
|
|
81
|
+
return this.summaries.get(this.rangeSelector.selectedValue) ?? this.summaries.get("All")!;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private buildTabs(): void {
|
|
85
|
+
const contentHeight = this.contentHeight;
|
|
86
|
+
const summary = this.currentSummary;
|
|
87
|
+
const rangeKey = this.rangeSelector.selectedValue;
|
|
88
|
+
|
|
89
|
+
// Invalidate old tabs — cleans up marquee timers, caches, etc.
|
|
90
|
+
for (const tab of this.tabs) {
|
|
91
|
+
tab.invalidate?.();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.tabs = [
|
|
95
|
+
new Overview(summary, rangeKey, this.theme, contentHeight),
|
|
96
|
+
new Languages(summary.languages, this.theme, this.langPalette, this.tui, contentHeight),
|
|
97
|
+
new Models(summary.models, this.theme, this.modelPalette, this.tui, contentHeight),
|
|
98
|
+
new Projects(summary.projects, this.theme, this.tui, contentHeight),
|
|
99
|
+
new Usage(
|
|
100
|
+
summary.tools,
|
|
101
|
+
{
|
|
102
|
+
total: summary.totalTokens,
|
|
103
|
+
input: summary.totalInputTokens,
|
|
104
|
+
output: summary.totalOutputTokens,
|
|
105
|
+
cacheRead: summary.totalCacheReadTokens,
|
|
106
|
+
cacheWrite: summary.totalCacheWriteTokens,
|
|
107
|
+
},
|
|
108
|
+
this.theme,
|
|
109
|
+
this.tui,
|
|
110
|
+
contentHeight,
|
|
111
|
+
),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
override render(width: number): string[] {
|
|
116
|
+
// Clear BorderBox render cache so timer-driven child updates
|
|
117
|
+
// (e.g. marquee scrolling) are reflected in the output.
|
|
118
|
+
// Direct property access works because TS private is compile-time only.
|
|
119
|
+
// TODO: fix marquee animation
|
|
120
|
+
// this.borderCache = null;
|
|
121
|
+
this.clear();
|
|
122
|
+
|
|
123
|
+
// width - border*2 - padding*2
|
|
124
|
+
const innerWidth = width - 2 - 2;
|
|
125
|
+
|
|
126
|
+
this.addChild(this.tabBar);
|
|
127
|
+
this.addChild(
|
|
128
|
+
new Text(this.theme.fg("borderMuted", "─".repeat(Math.max(innerWidth, 60))), 0, 0),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const allEmpty = [...this.summaries.values()].every((s) => s.sessionCount === 0);
|
|
132
|
+
|
|
133
|
+
if (allEmpty) {
|
|
134
|
+
this.addChild(new Spacer(1));
|
|
135
|
+
this.addChild(
|
|
136
|
+
new Text(this.theme.fg("muted", "No sessions found in ~/.pi/agent/sessions"), 1, 0),
|
|
137
|
+
);
|
|
138
|
+
this.addChild(new Spacer(1));
|
|
139
|
+
} else if (this.currentSummary.sessionCount === 0) {
|
|
140
|
+
this.addChild(new Spacer(1));
|
|
141
|
+
this.addChild(new Text(this.theme.fg("muted", "No data for this time range"), 1, 0));
|
|
142
|
+
this.addChild(new Spacer(1));
|
|
143
|
+
} else {
|
|
144
|
+
const activeTab = this.tabs[this.tabBar.activeIndex];
|
|
145
|
+
if (activeTab) {
|
|
146
|
+
this.addChild(activeTab);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.addChild(
|
|
151
|
+
new Text(this.theme.fg("borderMuted", "─".repeat(Math.max(innerWidth, 60))), 0, 0),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const controls = this.theme.fg("dim", "Esc/q close ←→ tabs r range ↑↓ scroll");
|
|
155
|
+
this.addChild(new Text(controls, 0, 0));
|
|
156
|
+
|
|
157
|
+
// Recompute content height — rebuild tabs if terminal was resized
|
|
158
|
+
const newContentHeight = this.computeContentHeight();
|
|
159
|
+
if (newContentHeight !== this.contentHeight) {
|
|
160
|
+
this.contentHeight = newContentHeight;
|
|
161
|
+
this.buildTabs();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return super.render(width);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
override handleInput(data: string): void {
|
|
168
|
+
// Invalidate BorderBox render cache so next render() picks up state changes.
|
|
169
|
+
this.invalidate();
|
|
170
|
+
|
|
171
|
+
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
|
|
172
|
+
this.onClose?.();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Tab bar input (left/right)
|
|
177
|
+
if (matchesKey(data, "left") || matchesKey(data, "right")) {
|
|
178
|
+
this.tabBar.handleInput(data);
|
|
179
|
+
this.tabBar.invalidate();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// r key: cycle range with wrap-around
|
|
184
|
+
if (matchesKey(data, "r")) {
|
|
185
|
+
const previousRange = this.rangeSelector.selectedIndex;
|
|
186
|
+
this.rangeSelector.handleInput(data);
|
|
187
|
+
|
|
188
|
+
if (previousRange !== this.rangeSelector.selectedIndex) {
|
|
189
|
+
this.rangeLabelTitle.text = stylizedRangeTitle(
|
|
190
|
+
this.theme,
|
|
191
|
+
this.rangeSelector.selectedLabel,
|
|
192
|
+
);
|
|
193
|
+
this.buildTabs();
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// enter: consumed (no-op)
|
|
199
|
+
if (matchesKey(data, "enter")) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// up/down: dispatch to table tabs, consumed on Overview
|
|
204
|
+
if (matchesKey(data, "up") || matchesKey(data, "down")) {
|
|
205
|
+
const tabIndex = this.tabBar.activeIndex;
|
|
206
|
+
if (tabIndex >= 1) {
|
|
207
|
+
this.tabs[tabIndex]?.handleInput?.(data);
|
|
208
|
+
this.tabs[tabIndex]?.invalidate?.();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
override invalidate(): void {
|
|
215
|
+
super.invalidate();
|
|
216
|
+
this.tabBar.invalidate();
|
|
217
|
+
this.rangeSelector.invalidate();
|
|
218
|
+
for (const tab of this.tabs) {
|
|
219
|
+
tab.invalidate?.();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Component, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { BorderBox } from "@mohndoe/pi-tui-extras";
|
|
4
|
+
import { RangeSelector } from "./RangeSelector";
|
|
5
|
+
|
|
6
|
+
const RANGE_BOX_WIDTH = 17;
|
|
7
|
+
|
|
8
|
+
export class Header implements Component {
|
|
9
|
+
private rangeBox: BorderBox;
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
theme: Theme,
|
|
13
|
+
private rangeSelector: RangeSelector,
|
|
14
|
+
) {
|
|
15
|
+
this.rangeBox = new BorderBox({
|
|
16
|
+
titles: [{ text: "Range (r)", align: "left" }],
|
|
17
|
+
borderStyle: "single",
|
|
18
|
+
borderFn: (s: string) => theme.fg("dim", s),
|
|
19
|
+
});
|
|
20
|
+
this.rangeBox.addChild(this.rangeSelector);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
render(width: number): string[] {
|
|
24
|
+
const title = "";
|
|
25
|
+
const version = "";
|
|
26
|
+
|
|
27
|
+
const boxLines = this.rangeBox.render(RANGE_BOX_WIDTH);
|
|
28
|
+
const leftWidth = width - RANGE_BOX_WIDTH;
|
|
29
|
+
|
|
30
|
+
const line1 = title + " ".repeat(Math.max(0, leftWidth - visibleWidth(title)));
|
|
31
|
+
const line2 = version + " ".repeat(Math.max(0, leftWidth - visibleWidth(version)));
|
|
32
|
+
const line3 = " ".repeat(leftWidth);
|
|
33
|
+
|
|
34
|
+
return [line1 + boxLines[0], line2 + boxLines[1], line3 + boxLines[2]];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
invalidate(): void {
|
|
38
|
+
this.rangeBox.invalidate();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { formatCost, formatNumber } from "../format";
|
|
4
|
+
import { GridRow } from "./shared/GridRow";
|
|
5
|
+
import { StatCard } from "./StatCard";
|
|
6
|
+
|
|
7
|
+
export interface KpiData {
|
|
8
|
+
totalCost: number;
|
|
9
|
+
sessionCount: number;
|
|
10
|
+
totalMessages: number;
|
|
11
|
+
totalTokens: number;
|
|
12
|
+
daysActive: number;
|
|
13
|
+
avgCostPerDay: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class KpiCards implements Component {
|
|
17
|
+
private theme: Theme;
|
|
18
|
+
private row: GridRow;
|
|
19
|
+
|
|
20
|
+
constructor(kpis: KpiData, theme: Theme) {
|
|
21
|
+
this.theme = theme;
|
|
22
|
+
|
|
23
|
+
const colPcts = [16, 16, 16, 16, 16, 17];
|
|
24
|
+
|
|
25
|
+
const totalCostCard = new StatCard(
|
|
26
|
+
{
|
|
27
|
+
label: { text: "Total cost" },
|
|
28
|
+
value: {
|
|
29
|
+
text: this.theme.bold(formatCost(kpis.totalCost)),
|
|
30
|
+
color: "success",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
this.theme,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const sessionsCard = new StatCard(
|
|
37
|
+
{
|
|
38
|
+
label: { text: "Sessions" },
|
|
39
|
+
value: {
|
|
40
|
+
text: this.theme.bold(formatNumber(kpis.sessionCount)),
|
|
41
|
+
color: "accent",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
this.theme,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const messagesCard = new StatCard(
|
|
48
|
+
{
|
|
49
|
+
label: { text: "Messages" },
|
|
50
|
+
value: {
|
|
51
|
+
text: this.theme.bold(formatNumber(kpis.totalMessages)),
|
|
52
|
+
color: "borderAccent",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
this.theme,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const activeDaysCard = new StatCard(
|
|
59
|
+
{
|
|
60
|
+
label: { text: "Active days" },
|
|
61
|
+
value: {
|
|
62
|
+
text: this.theme.bold(formatNumber(kpis.daysActive)),
|
|
63
|
+
color: "warning",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
this.theme,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const avgDayCard = new StatCard(
|
|
70
|
+
{
|
|
71
|
+
label: { text: "Avg/Day" },
|
|
72
|
+
value: {
|
|
73
|
+
text: this.theme.bold(formatCost(kpis.avgCostPerDay)),
|
|
74
|
+
color: "border",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
this.theme,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const tokensCard = new StatCard(
|
|
81
|
+
{
|
|
82
|
+
label: { text: "Tokens" },
|
|
83
|
+
value: {
|
|
84
|
+
text: this.theme.bold(formatNumber(kpis.totalTokens)),
|
|
85
|
+
color: "error",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
this.theme,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
this.row = new GridRow(
|
|
92
|
+
[totalCostCard, tokensCard, messagesCard, sessionsCard, activeDaysCard, avgDayCard],
|
|
93
|
+
colPcts,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
render(width: number): string[] {
|
|
98
|
+
return [...this.row.render(width)];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
invalidate(): void {
|
|
102
|
+
this.row.invalidate();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export class LoadingView implements Component {
|
|
4
|
+
private progress = 0;
|
|
5
|
+
private message: string;
|
|
6
|
+
private cachedLines: string[] | null = null;
|
|
7
|
+
private cachedWidth = -1;
|
|
8
|
+
private tui: { requestRender: () => void } | null;
|
|
9
|
+
|
|
10
|
+
constructor(message = "Parsing session logs...", tui?: { requestRender: () => void }) {
|
|
11
|
+
this.message = message;
|
|
12
|
+
this.tui = tui ?? null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
setProgress(p: number): void {
|
|
16
|
+
this.progress = p;
|
|
17
|
+
this.invalidate();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
render(width: number): string[] {
|
|
21
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
22
|
+
|
|
23
|
+
const barW = Math.min(40, width - 10);
|
|
24
|
+
const filled = Math.round((this.progress / 100) * barW);
|
|
25
|
+
const bar = "█".repeat(filled) + "░".repeat(barW - filled);
|
|
26
|
+
|
|
27
|
+
const lines = ["", ` ${this.message}`, ` [${bar}] ${this.progress}%`, ""];
|
|
28
|
+
|
|
29
|
+
this.cachedLines = lines;
|
|
30
|
+
this.cachedWidth = width;
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
invalidate(): void {
|
|
35
|
+
this.cachedLines = null;
|
|
36
|
+
this.cachedWidth = -1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A component that displays text with a marquee scroll effect when the text
|
|
6
|
+
* overflows the available width. Text scrolls left at 1 character per tick
|
|
7
|
+
* (150ms), wrapping around when reaching the end.
|
|
8
|
+
*
|
|
9
|
+
* Each instance manages its own tick counter and animation timer. When a TUI
|
|
10
|
+
* reference is provided, the timer calls tui.requestRender() to animate.
|
|
11
|
+
*/
|
|
12
|
+
export class MarqueeText implements Component {
|
|
13
|
+
private tickCounter = 0;
|
|
14
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
15
|
+
private closed = false;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private text: string,
|
|
19
|
+
private tui: TUI,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
render(width: number): string[] {
|
|
23
|
+
if (this.text.length <= width) {
|
|
24
|
+
this.stopTimer();
|
|
25
|
+
return [this.text];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Start animation timer on first render with overflow
|
|
29
|
+
if (!this.timer) {
|
|
30
|
+
this.startTimer();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Gap separates end of text from its beginning when wrapping
|
|
34
|
+
const gap = " ".repeat(5);
|
|
35
|
+
const extended = this.text + gap;
|
|
36
|
+
const offset = this.tickCounter % extended.length;
|
|
37
|
+
const visible = (extended + extended).slice(offset, offset + width);
|
|
38
|
+
return [visible];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Advance the tick counter by one (for testing or external control) */
|
|
42
|
+
advance(): void {
|
|
43
|
+
this.tickCounter++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reset the marquee to start position and restart animation */
|
|
47
|
+
reset(): void {
|
|
48
|
+
this.tickCounter = 0;
|
|
49
|
+
this.stopTimer();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Stop the animation timer and mark instance as closed. */
|
|
53
|
+
destroy(): void {
|
|
54
|
+
this.closed = true;
|
|
55
|
+
this.stopTimer();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
invalidate(): void {
|
|
59
|
+
// No internal cache to clear
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private startTimer(): void {
|
|
63
|
+
this.timer = setInterval(() => {
|
|
64
|
+
if (this.closed) {
|
|
65
|
+
this.stopTimer();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this.tickCounter++;
|
|
69
|
+
this.tui.requestRender();
|
|
70
|
+
}, 150);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private stopTimer(): void {
|
|
74
|
+
if (this.timer) {
|
|
75
|
+
clearInterval(this.timer);
|
|
76
|
+
this.timer = undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { matchesKey, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { TimeRange } from "../types";
|
|
4
|
+
|
|
5
|
+
export interface RangeOption {
|
|
6
|
+
label: string;
|
|
7
|
+
value: TimeRange;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class RangeSelector implements Component {
|
|
11
|
+
selectedIndex: number;
|
|
12
|
+
private cachedLines: string[] | null = null;
|
|
13
|
+
private cachedWidth = -1;
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private theme: Theme,
|
|
17
|
+
private ranges: RangeOption[] = [
|
|
18
|
+
{ label: "Today", value: "1d" },
|
|
19
|
+
{ label: "Last 7 days", value: "7d" },
|
|
20
|
+
{ label: "Last 30 days", value: "30d" },
|
|
21
|
+
{ label: "All time", value: "All" },
|
|
22
|
+
],
|
|
23
|
+
selectedIndex = 0,
|
|
24
|
+
) {
|
|
25
|
+
this.selectedIndex = selectedIndex;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get selectedValue(): TimeRange {
|
|
29
|
+
if (this.selectedRangeOption) return this.selectedRangeOption.value;
|
|
30
|
+
return "All";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get selectedRangeOption(): RangeOption {
|
|
34
|
+
return this.ranges[this.selectedIndex] ?? this.ranges[0]!;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get selectedLabel(): string {
|
|
38
|
+
if (this.selectedRangeOption) return this.selectedRangeOption.label;
|
|
39
|
+
return "All time";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
render(width: number): string[] {
|
|
43
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
44
|
+
|
|
45
|
+
this.cachedLines = [this.theme.fg("accent", this.selectedLabel)];
|
|
46
|
+
this.cachedWidth = width;
|
|
47
|
+
return this.cachedLines;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
handleInput(data: string): void {
|
|
51
|
+
// enter is a no-op (selection is consumed) but must not propagate farther
|
|
52
|
+
if (matchesKey(data, "enter")) return;
|
|
53
|
+
|
|
54
|
+
if (matchesKey(data, "r")) {
|
|
55
|
+
this.selectedIndex = (this.selectedIndex + 1) % this.ranges.length;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
invalidate(): void {
|
|
60
|
+
this.cachedLines = null;
|
|
61
|
+
this.cachedWidth = -1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ChalkInstance } from "chalk";
|
|
4
|
+
import { UsageRow } from "./UsageRow";
|
|
5
|
+
|
|
6
|
+
export interface RankedBarItem {
|
|
7
|
+
name: string;
|
|
8
|
+
primaryValue: number;
|
|
9
|
+
mainValueText: string;
|
|
10
|
+
secondaryValueText?: string;
|
|
11
|
+
color: ChalkInstance;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class RankedBarList implements Component {
|
|
15
|
+
private cachedLines: string[] | null = null;
|
|
16
|
+
private cachedWidth = -1;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private items: RankedBarItem[],
|
|
20
|
+
private theme: Theme,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
render(width: number): string[] {
|
|
24
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
25
|
+
return this.cachedLines;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const lines: string[] = [];
|
|
29
|
+
|
|
30
|
+
if (this.items.length === 0) {
|
|
31
|
+
this.cachedLines = [];
|
|
32
|
+
this.cachedWidth = width;
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const total = this.items.reduce((sum, item) => sum + item.primaryValue, 0);
|
|
37
|
+
|
|
38
|
+
const highestItem = total > 0 ? (this.items[0]!.primaryValue * 100) / total : 0;
|
|
39
|
+
|
|
40
|
+
for (const item of this.items) {
|
|
41
|
+
let pct = 0;
|
|
42
|
+
let barPct = 0;
|
|
43
|
+
if (total > 0) {
|
|
44
|
+
pct = (item.primaryValue * 100) / total;
|
|
45
|
+
barPct = (pct * 100) / highestItem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const row = new UsageRow(
|
|
49
|
+
{
|
|
50
|
+
name: item.name,
|
|
51
|
+
mainValueText: item.mainValueText,
|
|
52
|
+
secondaryValueText: item.secondaryValueText,
|
|
53
|
+
pct,
|
|
54
|
+
barPct,
|
|
55
|
+
},
|
|
56
|
+
item.color,
|
|
57
|
+
this.theme,
|
|
58
|
+
);
|
|
59
|
+
lines.push(...row.render(width));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.cachedLines = lines;
|
|
63
|
+
this.cachedWidth = width;
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
invalidate(): void {
|
|
68
|
+
this.cachedLines = null;
|
|
69
|
+
this.cachedWidth = -1;
|
|
70
|
+
}
|
|
71
|
+
}
|