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.
- package/.codegraph/daemon.pid +6 -0
- package/AGENTS.md +92 -0
- package/README.md +53 -64
- package/commands/dp-model.ts +23 -0
- package/commands/dp-settings.ts +23 -0
- package/commands/mcp-status.ts +62 -0
- package/commands/retry.ts +19 -0
- package/commands/usage.ts +544 -0
- package/hooks/compaction.ts +204 -0
- package/hooks/externalize.ts +70 -0
- package/hooks/image-vision.ts +132 -0
- package/hooks/inject-agents-md.ts +164 -0
- package/hooks/mcp.ts +340 -0
- package/hooks/normalize-codeblocks.ts +88 -0
- package/hooks/pi-tool-filter.ts +28 -0
- package/{extensions/safety → hooks/redact}/types.ts +1 -8
- package/hooks/redact.ts +104 -0
- package/{extensions → hooks}/rtk.ts +92 -115
- package/hooks/session-title.ts +54 -0
- package/hooks/skeleton.ts +212 -0
- package/hooks/smart-at.ts +318 -0
- package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
- package/{extensions → hooks}/wakatime.ts +120 -122
- package/index.ts +155 -1
- package/package.json +5 -4
- package/{extensions/settings.ts → settings.ts} +32 -0
- package/{extensions → tools}/lsp/client.ts +0 -25
- package/{extensions → tools}/lsp/format.ts +2 -99
- package/{extensions → tools}/lsp/index.ts +1 -1
- package/{extensions → tools}/lsp/servers.ts +1 -1
- package/{extensions → tools}/lsp/tools.ts +1 -66
- package/{extensions → tools}/lsp/types.ts +0 -11
- package/tools/mcp/builtin/codegraph.ts +45 -0
- package/tools/mcp/builtin/context7.ts +10 -0
- package/tools/mcp/builtin/exa.ts +10 -0
- package/tools/mcp/builtin/index.ts +19 -0
- package/tools/mcp/cache.ts +124 -0
- package/{extensions → tools}/mcp/client.ts +2 -2
- package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
- package/tools/mcp/index.ts +44 -0
- package/tools/mcp/tool-definition.ts +112 -0
- package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
- package/{extensions/io.ts → tools/patch/index.ts} +29 -205
- package/tsconfig.json +10 -1
- package/ui/mcp-status.ts +202 -0
- package/ui/model-picker.ts +162 -0
- package/ui/module-settings.ts +83 -0
- package/ui/usage.ts +396 -0
- package/extensions/index.ts +0 -154
- package/extensions/io-tool-output.ts +0 -33
- package/extensions/mcp/index.ts +0 -435
- package/extensions/model-integration.ts +0 -531
- package/extensions/providers/ark-coding.ts +0 -75
- package/extensions/providers/index.ts +0 -9
- package/extensions/providers/ollama-cloud.ts +0 -101
- package/extensions/providers/qianfan-coding.ts +0 -71
- package/extensions/safety/index.ts +0 -102
- package/extensions/session-title.ts +0 -40
- package/extensions/slash.ts +0 -458
- package/extensions/smart-at.ts +0 -481
- package/extensions/subdir-agents.ts +0 -151
- /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
- /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
- /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
- /package/{extensions → tools}/lsp/env.ts +0 -0
- /package/{extensions → tools}/lsp/manager.ts +0 -0
- /package/{extensions → tools}/lsp/prompt.ts +0 -0
- /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
|
+
}
|