decorated-pi 0.5.4 → 0.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 (68) hide show
  1. package/.codegraph/daemon.pid +6 -0
  2. package/AGENTS.md +92 -0
  3. package/README.md +53 -64
  4. package/commands/dp-model.ts +23 -0
  5. package/commands/dp-settings.ts +23 -0
  6. package/commands/mcp-status.ts +62 -0
  7. package/commands/retry.ts +19 -0
  8. package/commands/usage.ts +544 -0
  9. package/hooks/compaction.ts +204 -0
  10. package/hooks/externalize.ts +70 -0
  11. package/hooks/image-vision.ts +132 -0
  12. package/hooks/inject-agents-md.ts +164 -0
  13. package/hooks/mcp.ts +340 -0
  14. package/hooks/normalize-codeblocks.ts +88 -0
  15. package/hooks/pi-tool-filter.ts +28 -0
  16. package/{extensions/safety → hooks/redact}/types.ts +1 -8
  17. package/hooks/redact.ts +104 -0
  18. package/{extensions → hooks}/rtk.ts +92 -115
  19. package/hooks/session-title.ts +54 -0
  20. package/hooks/skeleton.ts +212 -0
  21. package/hooks/smart-at.ts +318 -0
  22. package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
  23. package/{extensions → hooks}/wakatime.ts +120 -122
  24. package/index.ts +155 -1
  25. package/package.json +5 -4
  26. package/{extensions/settings.ts → settings.ts} +32 -0
  27. package/{extensions → tools}/lsp/client.ts +0 -25
  28. package/{extensions → tools}/lsp/format.ts +2 -99
  29. package/{extensions → tools}/lsp/index.ts +1 -1
  30. package/{extensions → tools}/lsp/servers.ts +1 -1
  31. package/{extensions → tools}/lsp/tools.ts +1 -66
  32. package/{extensions → tools}/lsp/types.ts +0 -11
  33. package/tools/mcp/builtin/codegraph.ts +45 -0
  34. package/tools/mcp/builtin/context7.ts +10 -0
  35. package/tools/mcp/builtin/exa.ts +10 -0
  36. package/tools/mcp/builtin/index.ts +19 -0
  37. package/tools/mcp/cache.ts +124 -0
  38. package/{extensions → tools}/mcp/client.ts +2 -2
  39. package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
  40. package/tools/mcp/index.ts +44 -0
  41. package/tools/mcp/tool-definition.ts +112 -0
  42. package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
  43. package/{extensions/io.ts → tools/patch/index.ts} +29 -205
  44. package/tsconfig.json +10 -1
  45. package/ui/mcp-status.ts +202 -0
  46. package/ui/model-picker.ts +162 -0
  47. package/ui/module-settings.ts +83 -0
  48. package/ui/usage.ts +396 -0
  49. package/extensions/index.ts +0 -154
  50. package/extensions/io-tool-output.ts +0 -33
  51. package/extensions/mcp/index.ts +0 -435
  52. package/extensions/model-integration.ts +0 -531
  53. package/extensions/providers/ark-coding.ts +0 -75
  54. package/extensions/providers/index.ts +0 -9
  55. package/extensions/providers/ollama-cloud.ts +0 -101
  56. package/extensions/providers/qianfan-coding.ts +0 -71
  57. package/extensions/safety/index.ts +0 -102
  58. package/extensions/session-title.ts +0 -40
  59. package/extensions/slash.ts +0 -458
  60. package/extensions/smart-at.ts +0 -481
  61. package/extensions/subdir-agents.ts +0 -151
  62. /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
  63. /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
  64. /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
  65. /package/{extensions → tools}/lsp/env.ts +0 -0
  66. /package/{extensions → tools}/lsp/manager.ts +0 -0
  67. /package/{extensions → tools}/lsp/prompt.ts +0 -0
  68. /package/{extensions → tools}/lsp/protocol.ts +0 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Model Picker — used by /dp-model command.
3
+ * Tab between Image and Compact model selection.
4
+ */
5
+
6
+ import { DynamicBorder, keyHint, rawKeyHint, type Theme } from "@earendil-works/pi-coding-agent";
7
+ import { Container, fuzzyFilter, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
8
+ import type { Model } from "@earendil-works/pi-ai";
9
+ import { getImageModelKey, getCompactModelKey, setImageModelKey, setCompactModelKey, formatModelKey, parseModelKey } from "../settings.js";
10
+
11
+ const TAB_IMAGE = 0;
12
+ const TAB_COMPACT = 1;
13
+
14
+ export class ModelPickerComponent extends Container {
15
+ private searchInput: Input;
16
+ private tui: TUI;
17
+ private theme: Theme;
18
+ private registry: any;
19
+ private onDone: () => void;
20
+ private activeTab = TAB_IMAGE;
21
+ private imageKey: string | null;
22
+ private compactKey: string | null;
23
+ private allItems: { label: string; desc: string; model: Model<any> | null; modelName?: string }[] = [];
24
+ private filtered: typeof this.allItems = [];
25
+ private selectedIndex = 0;
26
+ private tabTitle = new Text("", 1, 0);
27
+ private subtitleText: Text;
28
+ private listContainer: Container;
29
+
30
+ constructor(tui: TUI, theme: unknown, registry: any, onDone: () => void) {
31
+ super();
32
+ this.tui = tui;
33
+ this.theme = theme as Theme;
34
+ this.registry = registry;
35
+ this.onDone = onDone;
36
+ this.imageKey = getImageModelKey();
37
+ this.compactKey = getCompactModelKey();
38
+
39
+ this.addChild(new DynamicBorder());
40
+ this.addChild(new Spacer(1));
41
+ this.addChild(this.tabTitle);
42
+ this.subtitleText = new Text("", 1, 0);
43
+ this.addChild(this.subtitleText);
44
+ this.addChild(new Spacer(1));
45
+
46
+ this.searchInput = new Input();
47
+ this.searchInput.onSubmit = () => { const s = this.filtered[this.selectedIndex]; if (s) this.selectModel(s.model); };
48
+ this.addChild(this.searchInput);
49
+ this.addChild(new Spacer(1));
50
+
51
+ this.listContainer = new Container();
52
+ this.addChild(this.listContainer);
53
+ this.addChild(new Spacer(1));
54
+
55
+ this.addChild(new Text(
56
+ rawKeyHint("↑↓", "navigate") + " " + keyHint("tui.input.tab", "switch") + " " +
57
+ keyHint("tui.select.confirm", "select") + " " + keyHint("tui.select.cancel", "cancel"), 1, 0));
58
+ this.addChild(new Spacer(1));
59
+ this.addChild(new DynamicBorder());
60
+
61
+ this.loadModels().then(() => { this.switchTab(TAB_IMAGE); this.tui.requestRender(); });
62
+ }
63
+
64
+ private async loadModels() {
65
+ this.registry.refresh();
66
+ const available = this.registry.getAvailable() as Model<any>[];
67
+ this.allItems = [{ label: "clear", desc: "(unset)", model: null }];
68
+ for (const m of available) {
69
+ this.allItems.push({ label: m.id, desc: `[${m.provider}]`, model: m as Model<any>, modelName: m.name });
70
+ }
71
+ }
72
+
73
+ private currentKey() { return this.activeTab === TAB_IMAGE ? this.imageKey : this.compactKey; }
74
+ private currentKind() { return this.activeTab === TAB_IMAGE ? "image" : "compact"; }
75
+
76
+ private switchTab(tab: number) {
77
+ this.activeTab = tab;
78
+ const key = this.currentKey();
79
+ const [clearItem, ...rest] = this.allItems;
80
+ const items = rest.map(it => {
81
+ const isCurrent = it.model && formatModelKey(it.model) === key;
82
+ return { ...it, desc: `${it.desc}${isCurrent ? " ✓" : ""}` };
83
+ });
84
+ items.sort((a, b) => {
85
+ const aCur = a.model && formatModelKey(a.model) === key;
86
+ const bCur = b.model && formatModelKey(b.model) === key;
87
+ if (aCur && !bCur) return -1; if (!aCur && bCur) return 1; return 0;
88
+ });
89
+ this.filtered = [clearItem, ...items];
90
+ this.selectedIndex = 0;
91
+ if (key) { const ix = this.filtered.findIndex(m => m.model && formatModelKey(m.model) === key); if (ix >= 0) this.selectedIndex = ix; }
92
+ this.searchInput.setValue("");
93
+ this.updateHeader();
94
+ this.updateList();
95
+ }
96
+
97
+ private updateHeader() {
98
+ const t = this.theme;
99
+ const im = this.activeTab === TAB_IMAGE ? t.fg("accent", "●") : "○";
100
+ const cm = this.activeTab === TAB_COMPACT ? t.fg("accent", "●") : "○";
101
+ const il = this.activeTab === TAB_IMAGE ? t.bold("Image") : t.fg("dim", "Image");
102
+ const cl = this.activeTab === TAB_COMPACT ? t.bold("Compact") : t.fg("dim", "Compact");
103
+ this.tabTitle.setText(`${im} ${il} | ${cm} ${cl}`);
104
+ const key = this.currentKey();
105
+ this.subtitleText.setText(key ? t.fg("warning", `Current ${this.currentKind()} model: ${key}`) : t.fg("warning", `No ${this.currentKind()} model set`));
106
+ }
107
+
108
+ handleInput(keyData: string) {
109
+ const kb = getKeybindings();
110
+ if (kb.matches(keyData, "tui.input.tab")) {
111
+ const next = this.activeTab === TAB_IMAGE ? TAB_COMPACT : TAB_IMAGE;
112
+ this.switchTab(next); this.tui.requestRender(); return;
113
+ }
114
+ if (kb.matches(keyData, "tui.select.up")) { this.selectedIndex = this.selectedIndex === 0 ? this.filtered.length - 1 : this.selectedIndex - 1; this.updateList(); return; }
115
+ if (kb.matches(keyData, "tui.select.down")) { this.selectedIndex = this.selectedIndex === this.filtered.length - 1 ? 0 : this.selectedIndex + 1; this.updateList(); return; }
116
+ if (kb.matches(keyData, "tui.select.confirm")) { const s = this.filtered[this.selectedIndex]; if (s) this.selectModel(s.model); return; }
117
+ if (kb.matches(keyData, "tui.select.cancel")) { this.onDone(); return; }
118
+ this.searchInput.handleInput(keyData); this.applyFilter();
119
+ }
120
+
121
+ private applyFilter() {
122
+ const raw = this.searchInput.getValue();
123
+ if (!raw) { this.switchTab(this.activeTab); return; }
124
+ const [clearItem, ...rest] = this.filtered;
125
+ this.filtered = [clearItem, ...fuzzyFilter(rest, raw, ({ label, desc }) => `${label} ${desc}`)];
126
+ this.selectedIndex = 0; this.updateList();
127
+ }
128
+
129
+ private selectModel(model: Model<any> | null) {
130
+ const kind = this.currentKind();
131
+ if (model) {
132
+ if (kind === "image") setImageModelKey(formatModelKey(model));
133
+ else setCompactModelKey(formatModelKey(model));
134
+ } else {
135
+ if (kind === "image") setImageModelKey(null);
136
+ else setCompactModelKey(null);
137
+ }
138
+ if (kind === "image") this.imageKey = model ? formatModelKey(model) : null;
139
+ else this.compactKey = model ? formatModelKey(model) : null;
140
+ this.switchTab(this.activeTab); this.tui.requestRender();
141
+ }
142
+
143
+ private updateList() {
144
+ this.listContainer.clear();
145
+ const t = this.theme;
146
+ const mv = 10;
147
+ const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(mv / 2), Math.max(0, this.filtered.length - mv)));
148
+ const end = Math.min(start + mv, this.filtered.length);
149
+ for (let i = start; i < end; i++) {
150
+ const item = this.filtered[i]; if (!item) continue;
151
+ const isClear = item.model === null;
152
+ const isSel = i === this.selectedIndex;
153
+ const line = isClear
154
+ ? (isSel ? t.fg("accent", "→ ") + t.fg("error", "clear") + t.fg("muted", " (unset)") : " " + t.fg("muted", "clear (unset)"))
155
+ : (isSel ? t.fg("accent", "→ ") + t.fg("accent", item.label) + " " + t.fg("muted", item.desc) : " " + item.label + " " + t.fg("muted", item.desc));
156
+ this.listContainer.addChild(new Text(line, 0, 0));
157
+ }
158
+ if (start > 0 || end < this.filtered.length) this.listContainer.addChild(new Text(t.fg("muted", ` (${this.selectedIndex + 1}/${this.filtered.length})`), 0, 0));
159
+ const sel = this.filtered[this.selectedIndex];
160
+ if (sel?.modelName) { this.listContainer.addChild(new Spacer(1)); this.listContainer.addChild(new Text(t.fg("muted", ` Name: ${sel.modelName}`), 0, 0)); }
161
+ }
162
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Module Settings UI — used by /dp-settings command.
3
+ * Lists all modules with on/off toggle.
4
+ */
5
+
6
+ import type { ExtensionAPI, ExtensionContext, Theme as PiTheme } from "@earendil-works/pi-coding-agent";
7
+ import { Container, SettingsList, Spacer, Text, type TUI, type SettingsListTheme, type Component } from "@earendil-works/pi-tui";
8
+ import { getAllModuleSettings, setModuleEnabled, type ModuleSettings } from "../settings.js";
9
+
10
+ const MODULE_LABELS: Record<keyof ModuleSettings, string> = {
11
+ patch: "patch",
12
+ safety: "Secret Redaction",
13
+ lsp: "LSP",
14
+ "smart-at": "@ overload",
15
+ mcp: "MCP",
16
+ wakatime: "WakaTime",
17
+ "rtk": "RTK",
18
+ "codegraph": "Codegraph",
19
+ };
20
+
21
+ const MODULE_DESCS: Record<keyof ModuleSettings, string> = {
22
+ patch: "Replace edit/write with patch tool (targeted string replacement)",
23
+ safety: "Redact secrets from read / bash output before they enter model context",
24
+ lsp: "Language server diagnostics, hover, definition, references, symbols, rename",
25
+ "smart-at": "Project-aware file search replacing default autocomplete",
26
+ mcp: "MCP client for context7 and exa (zero-config)",
27
+ wakatime: "Send coding activity heartbeats to WakaTime",
28
+ "rtk": "Rewrite bash through system RTK when available",
29
+ "codegraph": "Codegraph MCP server for code structure queries",
30
+ };
31
+
32
+ class DynamicBorder implements Component {
33
+ private colorFn: (str: string) => string;
34
+ constructor(theme: PiTheme) { this.colorFn = (str: string) => theme.fg("border", str); }
35
+ invalidate() {}
36
+ render(width: number): string[] { return [this.colorFn("─".repeat(Math.max(1, width)))]; }
37
+ }
38
+
39
+ function getSettingsListTheme(theme: PiTheme): SettingsListTheme {
40
+ return {
41
+ label: (text: string, selected: boolean) => selected ? theme.fg("accent", text) : text,
42
+ value: (text: string, selected: boolean) => selected ? theme.fg("accent", text) : theme.fg("muted", text),
43
+ description: (text: string) => theme.fg("dim", text),
44
+ cursor: theme.fg("accent", "→ "),
45
+ hint: (text: string) => theme.fg("dim", text),
46
+ };
47
+ }
48
+
49
+ export class ModuleSettingsComponent extends Container {
50
+ private settingsList: SettingsList;
51
+
52
+ constructor(tui: TUI, theme: PiTheme, onDone: () => void) {
53
+ super();
54
+ const modules = getAllModuleSettings();
55
+ const keys = Object.keys(MODULE_LABELS) as (keyof ModuleSettings)[];
56
+ const items = keys.filter(k => modules[k] !== undefined).map(k => ({
57
+ id: k,
58
+ label: MODULE_LABELS[k],
59
+ description: MODULE_DESCS[k],
60
+ currentValue: modules[k] ? "on" : "off",
61
+ values: ["on", "off"],
62
+ }));
63
+
64
+ this.addChild(new DynamicBorder(theme));
65
+
66
+ this.settingsList = new SettingsList(
67
+ items, 10, getSettingsListTheme(theme),
68
+ (id: string, newValue: string) => {
69
+ setModuleEnabled(id as keyof ModuleSettings, newValue === "on");
70
+ tui.requestRender();
71
+ },
72
+ () => onDone(),
73
+ { enableSearch: true },
74
+ );
75
+
76
+ this.addChild(this.settingsList);
77
+ this.addChild(new DynamicBorder(theme));
78
+ }
79
+
80
+ handleInput(data: string) {
81
+ this.settingsList.handleInput(data);
82
+ }
83
+ }
package/ui/usage.ts ADDED
@@ -0,0 +1,396 @@
1
+ /**
2
+ * UsageReportComponent — tree-style usage report with expand/collapse.
3
+ *
4
+ * Renders:
5
+ * 1. Overall table (4 slices: Today / Week / Month / All Time)
6
+ * 2. Per-Model tree (collapsed by default, ↑↓ Enter to expand)
7
+ *
8
+ * Follows ui/mcp-status.ts visual conventions.
9
+ */
10
+
11
+ import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
13
+ import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
14
+ import type {
15
+ Aggregate,
16
+ ColumnId,
17
+ ModelSlice,
18
+ UsageReport,
19
+ } from "../commands/usage.js";
20
+ import {
21
+ formatCell,
22
+ formatCost,
23
+ pickColumns,
24
+ pickModelDisplay,
25
+ } from "../commands/usage.js";
26
+
27
+ // ─── Constants ──────────────────────────────────────────────────────────────
28
+
29
+ const SLICE_LABELS: Array<{ key: keyof UsageReport & string; label: string }> = [
30
+ { key: "currentSession", label: "Session" },
31
+ { key: "today", label: "Today" },
32
+ { key: "thisWeek", label: "This Week" },
33
+ { key: "thisMonth", label: "This Month" },
34
+ { key: "allTime", label: "All Time" },
35
+ ];
36
+
37
+ const COL_HEADERS: Record<ColumnId, string> = {
38
+ input: "↑Prompt",
39
+ output: "↓Out",
40
+ cacheRead: "CacheR",
41
+ cacheWrite: "CacheW",
42
+ hitRate: "CacheHit",
43
+ cost: "Cost",
44
+ };
45
+
46
+ // ─── Class ─────────────────────────────────────────────────────────────────
47
+
48
+ export class UsageReportComponent extends Container {
49
+ private theme: Theme;
50
+ private report: UsageReport;
51
+ private expanded = new Set<string>();
52
+ private selectedIndex = 0;
53
+ private onRebuild: () => Promise<UsageReport | null>;
54
+ private refreshing = false;
55
+ private done: () => void;
56
+ private requestRender: () => void;
57
+ private scrollOffset = 0;
58
+ private readonly MAX_VISIBLE = 6;
59
+
60
+ // child components (maintained across renders)
61
+ private borderTop: DynamicBorder;
62
+ private borderBottom: DynamicBorder;
63
+ private linesComponent: Text;
64
+ private hintComponent: Text;
65
+
66
+ constructor(
67
+ theme: Theme,
68
+ report: UsageReport,
69
+ requestRender: () => void,
70
+ done: () => void,
71
+ onRebuild?: () => Promise<UsageReport | null>,
72
+ ) {
73
+ super();
74
+
75
+ this.theme = theme;
76
+ this.report = report;
77
+ this.done = done;
78
+ this.requestRender = requestRender;
79
+ this.onRebuild = onRebuild ?? (async () => null);
80
+
81
+ this.borderTop = new DynamicBorder((s: string) => theme.fg("border", s));
82
+ this.addChild(this.borderTop);
83
+ this.addChild(new Spacer(1));
84
+
85
+ this.linesComponent = new Text("", 1, 0);
86
+ this.addChild(this.linesComponent);
87
+ this.addChild(new Spacer(1));
88
+
89
+ this.borderBottom = new DynamicBorder((s: string) => theme.fg("border", s));
90
+ this.addChild(this.borderBottom);
91
+
92
+ this.hintComponent = new Text("", 1, 0);
93
+ this.addChild(this.hintComponent);
94
+
95
+ this.renderView();
96
+ }
97
+
98
+ // ─── Input handling ───────────────────────────────────────────────────
99
+
100
+ handleInput(data: string): void {
101
+ const kb = getKeybindings();
102
+
103
+ if (kb.matches(data, "tui.select.cancel") || data === "q") {
104
+ this.done();
105
+ return;
106
+ }
107
+
108
+ if (data === "r") {
109
+ void (async () => {
110
+ if (this.refreshing) return;
111
+ this.refreshing = true;
112
+ // Show rebuilding message
113
+ this.linesComponent.setText(this.theme.fg("muted", " Rebuilding stats..."));
114
+ this.requestRender?.();
115
+ try {
116
+ const newReport = await this.onRebuild();
117
+ if (newReport) {
118
+ this.report = newReport;
119
+ this.selectedIndex = 0;
120
+ this.expanded.clear();
121
+ this.scrollOffset = 0;
122
+ }
123
+ } finally {
124
+ this.refreshing = false;
125
+ this.renderView();
126
+ }
127
+ })();
128
+ return;
129
+ }
130
+
131
+ const modelCount = this.report.byModel.length;
132
+ if (modelCount === 0) return;
133
+
134
+ if (kb.matches(data, "tui.select.up")) {
135
+ if (this.selectedIndex > 0) {
136
+ this.selectedIndex--;
137
+ if (this.selectedIndex < this.scrollOffset) {
138
+ this.scrollOffset = this.selectedIndex;
139
+ }
140
+ this.renderView();
141
+ }
142
+ return;
143
+ }
144
+
145
+ if (kb.matches(data, "tui.select.down")) {
146
+ if (this.selectedIndex < modelCount - 1) {
147
+ this.selectedIndex++;
148
+ if (this.selectedIndex >= this.scrollOffset + this.MAX_VISIBLE) {
149
+ this.scrollOffset = this.selectedIndex - this.MAX_VISIBLE + 1;
150
+ }
151
+ this.renderView();
152
+ }
153
+ return;
154
+ }
155
+
156
+ if (kb.matches(data, "tui.select.confirm") || kb.matches(data, "right" as any)) {
157
+ const m = this.report.byModel[this.selectedIndex];
158
+ if (m) {
159
+ if (this.expanded.has(m.model)) {
160
+ this.expanded.delete(m.model);
161
+ } else {
162
+ this.expanded.add(m.model);
163
+ }
164
+ this.renderView();
165
+ }
166
+ return;
167
+ }
168
+
169
+ if (kb.matches(data, "left" as any)) {
170
+ const m = this.report.byModel[this.selectedIndex];
171
+ if (m && this.expanded.has(m.model)) {
172
+ this.expanded.delete(m.model);
173
+ this.renderView();
174
+ }
175
+ return;
176
+ }
177
+ }
178
+
179
+ // ─── Rendering ────────────────────────────────────────────────────────
180
+
181
+ private renderView(): void {
182
+ const lines: string[] = [];
183
+ const t = this.theme;
184
+ const modelCount = this.report.byModel.length;
185
+ const totalMessages =
186
+ this.report.allTime.turns;
187
+
188
+ // Compute max label width (for alignment)
189
+ const maxLabelLen = Math.max(
190
+ ...SLICE_LABELS.map((s) => s.label.length),
191
+ ...this.report.byModel.map((m) => m.model.length),
192
+ );
193
+ const labelW = Math.min(maxLabelLen, 30);
194
+
195
+ // Title
196
+ const title = t.fg("accent", t.bold("Usage Statistics"));
197
+ lines.push(title);
198
+
199
+ // Subtitle
200
+ const subtitleParts: string[] = [];
201
+ if (modelCount > 0) subtitleParts.push(`${modelCount} model${modelCount !== 1 ? "s" : ""}`);
202
+ if (totalMessages > 0) subtitleParts.push(`${totalMessages} turn${totalMessages !== 1 ? "s" : ""}`);
203
+ const subtitle = t.fg("muted", ` ${subtitleParts.join(" · ")}`);
204
+ lines.push(subtitle);
205
+
206
+ // Overall section
207
+ lines.push("");
208
+ lines.push(t.fg("accent", " Overall"));
209
+
210
+ // Pick columns based on container width (estimate 80 for now; will be clamped)
211
+ const cols = this.computeColumns();
212
+ const colWidths = this.computeColWidths(cols, labelW);
213
+
214
+ // Header row
215
+ const headerParts: string[] = [" ".repeat(labelW)];
216
+ for (const c of cols) {
217
+ headerParts.push(COL_HEADERS[c].padStart(colWidths[c] ?? 0));
218
+ }
219
+ lines.push(" " + headerParts.join(" "));
220
+
221
+ // Divider
222
+ const divLen = labelW + cols.reduce((s, c) => s + (colWidths[c] ?? 0) + 2, 0);
223
+ lines.push(" " + "─".repeat(Math.max(divLen, 1)));
224
+
225
+ // Slice rows
226
+ for (const { key, label } of SLICE_LABELS) {
227
+ const agg = this.report[key as keyof UsageReport] as Aggregate;
228
+ const row = this.formatDataRow(label, labelW, agg, cols, colWidths);
229
+ lines.push(" " + row);
230
+ }
231
+
232
+ // Per-Model section
233
+ if (modelCount > 0) {
234
+ lines.push("");
235
+ lines.push(t.fg("accent", " Per-Model"));
236
+ lines.push(" " + "─".repeat(Math.max(divLen, 1)));
237
+
238
+ // Viewport: only render MAX_VISIBLE models around selectedIndex
239
+ const total = this.report.byModel.length;
240
+ const end = Math.min(this.scrollOffset + this.MAX_VISIBLE, total);
241
+
242
+ if (this.scrollOffset > 0) {
243
+ lines.push(t.fg("muted", ` ... ${this.scrollOffset} more above`));
244
+ }
245
+
246
+ for (let i = this.scrollOffset; i < end; i++) {
247
+ const m = this.report.byModel[i]!;
248
+ const isSelected = i === this.selectedIndex;
249
+ const isExpanded = this.expanded.has(m.model);
250
+
251
+ const cursorRaw = isSelected ? "→ " : " ";
252
+ const markerChar = isExpanded ? "▾" : "▸";
253
+ const modelDisplay = pickModelDisplay(m.model, labelW);
254
+ const cost = formatCost(m.allTime.cost);
255
+
256
+ // Compute padding from raw (uncolored) lengths
257
+ const leftRaw = `${cursorRaw}${markerChar} ${modelDisplay}`;
258
+ const padLen = divLen - leftRaw.length - cost.length + 2;
259
+ const pad = padLen > 0 ? " ".repeat(padLen) : " ";
260
+
261
+ // Apply theme colors
262
+ const cursorStyled = isSelected ? t.fg("accent", cursorRaw) : cursorRaw;
263
+ const modelStyled = isSelected
264
+ ? t.fg("accent", `${markerChar} ${modelDisplay}`)
265
+ : t.fg("muted", `${markerChar} ${modelDisplay}`);
266
+
267
+ lines.push(` ${cursorStyled}${modelStyled}${pad}${cost}`);
268
+
269
+ // Expanded child table
270
+ if (isExpanded) {
271
+ // Child header
272
+ const indent = " ";
273
+ const childHeaderParts: string[] = [" ".repeat(labelW)];
274
+ for (const c of cols) {
275
+ childHeaderParts.push(COL_HEADERS[c].padStart(colWidths[c] ?? 0));
276
+ }
277
+ lines.push(indent + childHeaderParts.join(" "));
278
+
279
+ // Child divider
280
+ lines.push(indent + "─".repeat(Math.max(divLen, 1)));
281
+
282
+ // Child rows for this model
283
+ for (const { key, label } of SLICE_LABELS) {
284
+ const agg = m[key as keyof ModelSlice] as Aggregate;
285
+ const row = this.formatDataRow(label, labelW, agg, cols, colWidths);
286
+ lines.push(indent + row);
287
+ }
288
+ }
289
+ }
290
+
291
+ if (end < total) {
292
+ lines.push(t.fg("muted", ` ... ${total - end} more below`));
293
+ }
294
+ } else {
295
+ lines.push("");
296
+ lines.push(t.fg("muted", " No per-model data yet."));
297
+ }
298
+
299
+ // Empty state
300
+ if (this.report.allTime.turns === 0) {
301
+ this.linesComponent.setText(
302
+ [title, "", t.fg("muted", " No usage data recorded yet."), "", t.fg("dim", " Start a session to begin tracking.")].join("\n"),
303
+ );
304
+ } else {
305
+ lines.push("");
306
+ lines.push(t.fg("dim", " ↑Prompt = all input tokens (user input + context) · CacheR = cached hits · CacheW = created cache"));
307
+ lines.push(t.fg("dim", " ↓Out = model output tokens · CacheHit = CacheR / ↑Prompt"));
308
+
309
+ this.linesComponent.setText(lines.join("\n"));
310
+ }
311
+
312
+ // Hint
313
+ const hintParts: string[] = [];
314
+ if (modelCount > 0) {
315
+ hintParts.push("↑↓ select");
316
+ const sel = this.report.byModel[this.selectedIndex];
317
+ hintParts.push(sel && this.expanded.has(sel.model) ? "Enter collapse" : "Enter expand");
318
+ }
319
+ hintParts.push("q close");
320
+ hintParts.push("r rebuild");
321
+ this.hintComponent.setText(t.fg("dim", " " + hintParts.join(" · ")));
322
+
323
+ this.requestRender();
324
+ }
325
+
326
+ // ─── Column helpers ───────────────────────────────────────────────────
327
+
328
+ private computeColumns(): ColumnId[] {
329
+ // Estimate container width — we don't get width until render is called
330
+ // Use a generous default (80) which covers most terminals
331
+ return pickColumns(80);
332
+ }
333
+
334
+ private computeColWidths(
335
+ cols: ColumnId[],
336
+ labelW: number,
337
+ ): Record<ColumnId, number> {
338
+ const widths: Record<ColumnId, number> = {
339
+ input: 6,
340
+ output: 6,
341
+ cacheRead: 6,
342
+ cacheWrite: 6,
343
+ hitRate: 5,
344
+ cost: 7,
345
+ };
346
+
347
+ // Expand widths based on data
348
+ const expandFromSlice = (agg: Aggregate) => {
349
+ for (const c of cols) {
350
+ const val = formatCell(c, agg);
351
+ if (val.length > (widths[c] ?? 0)) widths[c] = val.length;
352
+ }
353
+ };
354
+
355
+ for (const { key } of SLICE_LABELS) {
356
+ expandFromSlice(this.report[key as keyof UsageReport] as Aggregate);
357
+ }
358
+ for (const m of this.report.byModel) {
359
+ for (const { key } of SLICE_LABELS) {
360
+ expandFromSlice(m[key as keyof ModelSlice] as Aggregate);
361
+ }
362
+ }
363
+
364
+ // Also consider header width
365
+ for (const c of cols) {
366
+ const hdr = COL_HEADERS[c];
367
+ if (hdr && hdr.length > (widths[c] ?? 0)) widths[c] = hdr.length;
368
+ }
369
+
370
+ return widths;
371
+ }
372
+
373
+ private formatDataRow(
374
+ label: string,
375
+ labelW: number,
376
+ agg: Aggregate,
377
+ cols: ColumnId[],
378
+ colWidths: Record<ColumnId, number>,
379
+ ): string {
380
+ let line = label.padEnd(labelW);
381
+ for (const c of cols) {
382
+ line += " " + formatCell(c, agg).padStart(colWidths[c] ?? 0);
383
+ }
384
+ return line;
385
+ }
386
+
387
+ // ─── Lifecycle ────────────────────────────────────────────────────────
388
+
389
+ invalidate(): void {
390
+ // no-op: component is stateless beyond report/model state
391
+ }
392
+
393
+ dispose(): void {
394
+ // no-op
395
+ }
396
+ }