@buyi1net/pi-toolkit 0.0.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/LICENSE +21 -0
- package/README.md +45 -0
- package/assembler.ts +127 -0
- package/config.ts +201 -0
- package/i18n.ts +693 -0
- package/index.ts +99 -0
- package/menu/items.ts +264 -0
- package/menu/panels.ts +91 -0
- package/menu/settings-list.ts +84 -0
- package/menu/theme.ts +31 -0
- package/menu/toolkit-menu.ts +139 -0
- package/module.ts +156 -0
- package/modules/eyes/chain.ts +173 -0
- package/modules/eyes/config.ts +319 -0
- package/modules/eyes/index.ts +161 -0
- package/modules/eyes/menu.ts +828 -0
- package/modules/eyes/pi-model-backend.ts +245 -0
- package/modules/eyes/resilience.ts +161 -0
- package/modules/eyes/vision-bridge.ts +232 -0
- package/modules/eyes/vision-cache.ts +86 -0
- package/modules/eyes/vision-json.ts +19 -0
- package/modules/eyes/vision-preprocess.ts +192 -0
- package/modules/eyes/vision-probe.ts +21 -0
- package/modules/eyes/vision-prompt.ts +90 -0
- package/modules/eyes/vision-tool.ts +59 -0
- package/modules/eyes/vision-types.ts +33 -0
- package/modules/index.ts +10 -0
- package/modules/subagents/agents/researcher.md +51 -0
- package/modules/subagents/agents/scout.md +40 -0
- package/modules/subagents/agents/worker.md +79 -0
- package/modules/subagents/config.json.example +6 -0
- package/modules/subagents/config.ts +144 -0
- package/modules/subagents/index.ts +91 -0
- package/modules/subagents/menu.ts +388 -0
- package/modules/subagents/src/activity.ts +511 -0
- package/modules/subagents/src/agents.ts +126 -0
- package/modules/subagents/src/command.ts +37 -0
- package/modules/subagents/src/dependencies.ts +246 -0
- package/modules/subagents/src/diagnostics.ts +13 -0
- package/modules/subagents/src/display.ts +94 -0
- package/modules/subagents/src/headless.ts +342 -0
- package/modules/subagents/src/herdr.ts +203 -0
- package/modules/subagents/src/index.ts +2798 -0
- package/modules/subagents/src/inspect-tool.ts +839 -0
- package/modules/subagents/src/launch-config.ts +196 -0
- package/modules/subagents/src/layout-budget.ts +26 -0
- package/modules/subagents/src/list-tool.ts +292 -0
- package/modules/subagents/src/message-tool.ts +808 -0
- package/modules/subagents/src/names.ts +17 -0
- package/modules/subagents/src/pane-layout.ts +49 -0
- package/modules/subagents/src/params.ts +145 -0
- package/modules/subagents/src/renderers.ts +181 -0
- package/modules/subagents/src/result.ts +43 -0
- package/modules/subagents/src/retention.ts +114 -0
- package/modules/subagents/src/route-error.ts +212 -0
- package/modules/subagents/src/routing.ts +235 -0
- package/modules/subagents/src/runtime-registry.ts +159 -0
- package/modules/subagents/src/session.ts +801 -0
- package/modules/subagents/src/status.ts +513 -0
- package/modules/subagents/src/stop-tool.ts +235 -0
- package/modules/subagents/src/subagent-done.ts +590 -0
- package/modules/subagents/src/subagent-tool.ts +1155 -0
- package/modules/subagents/src/surface.ts +355 -0
- package/modules/subagents/src/team-dispatch-tool.ts +219 -0
- package/modules/subagents/src/team.ts +232 -0
- package/modules/subagents/src/tmux.ts +210 -0
- package/modules/subagents/src/tools/safe-bash.ts +72 -0
- package/modules/subagents/src/types.ts +131 -0
- package/modules/tui/adapter/provider-usage.ts +143 -0
- package/modules/tui/config.ts +50 -0
- package/modules/tui/index.ts +120 -0
- package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
- package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
- package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
- package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
- package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
- package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
- package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
- package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
- package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
- package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
- package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
- package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
- package/modules/tui/kernel/usage-core.ts +2 -0
- package/modules/tui/kernel/usage-node.ts +2 -0
- package/modules/tui/menu.ts +418 -0
- package/modules/tui/plugin/editor.ts +396 -0
- package/modules/tui/plugin/footer.ts +161 -0
- package/modules/tui/plugin/index.ts +30 -0
- package/modules/tui/plugin/lifecycle.ts +637 -0
- package/modules/tui/plugin/package-order.ts +169 -0
- package/modules/tui/plugin/screen-transition.ts +203 -0
- package/modules/tui/plugin/settings-config.ts +297 -0
- package/modules/tui/plugin/status-sources.ts +49 -0
- package/modules/tui/plugin/transition-gate.ts +261 -0
- package/modules/tui/renderer/custom-header.ts +157 -0
- package/modules/tui/renderer/editor.ts +70 -0
- package/modules/tui/renderer/header.ts +72 -0
- package/modules/tui/renderer/icons.ts +149 -0
- package/modules/tui/renderer/pi-installer-logo.ts +194 -0
- package/modules/tui/status/auto-compaction.ts +67 -0
- package/modules/tui/status/project-status.ts +655 -0
- package/modules/tui/status/provider-status.ts +120 -0
- package/modules/tui/status/runtime-status.ts +307 -0
- package/modules/tui/status/session-status.ts +325 -0
- package/modules/tui/status/status-config.ts +95 -0
- package/modules/tui/status/status-segments.ts +263 -0
- package/modules/tui/status/turn-telemetry.ts +466 -0
- package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
- package/modules/tui/themes/UPSTREAM.md +7 -0
- package/modules/tui/themes/catppuccin-latte.json +80 -0
- package/modules/tui/themes/catppuccin-mocha.json +79 -0
- package/modules/tui/themes/crimson-noir.json +85 -0
- package/modules/tui/themes/dracula.json +79 -0
- package/modules/tui/themes/everforest-dark.json +85 -0
- package/modules/tui/themes/gruvbox-dark.json +85 -0
- package/modules/tui/themes/gruvbox-light.json +85 -0
- package/modules/tui/themes/matrix.json +85 -0
- package/modules/tui/themes/nord.json +85 -0
- package/modules/tui/themes/one-dark.json +85 -0
- package/modules/tui/themes/rose-pine-dawn.json +85 -0
- package/modules/tui/themes/rose-pine.json +85 -0
- package/modules/tui/themes/solarized-dark.json +85 -0
- package/modules/tui/themes/solarized-light.json +85 -0
- package/modules/tui/themes/tokyo-night-storm.json +79 -0
- package/modules/tui/themes/tokyo-night.json +79 -0
- package/package.json +28 -0
- package/services.ts +38 -0
- package/toolkit.ts +147 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContextUsage,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
Theme,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { resolveGlyphs, type IconGlyphs } from "../renderer/icons.ts";
|
|
8
|
+
import { sanitizeSingleLine } from "./project-status.ts";
|
|
9
|
+
import {
|
|
10
|
+
cacheHitStatusColor,
|
|
11
|
+
compactionStatusColor,
|
|
12
|
+
contextUsageStatusColor,
|
|
13
|
+
turnStatusColor,
|
|
14
|
+
type StatusSegment,
|
|
15
|
+
} from "./status-segments.ts";
|
|
16
|
+
|
|
17
|
+
const SEPARATOR = " · ";
|
|
18
|
+
const DEFAULT_GLYPHS = resolveGlyphs("unicode");
|
|
19
|
+
|
|
20
|
+
export type SessionStatusSegmentId = "session" | "tokens" | "cache" | "cost";
|
|
21
|
+
export type EditorUsageSegmentId = "tokens" | "cache" | "context";
|
|
22
|
+
|
|
23
|
+
export interface SessionStatusSnapshot {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
sessionName?: string;
|
|
26
|
+
inputTokens: number;
|
|
27
|
+
outputTokens: number;
|
|
28
|
+
cacheReadTokens: number;
|
|
29
|
+
cacheWriteTokens: number;
|
|
30
|
+
/** 最新 assistant 消息的缓存命中率,口径与 Pi 原生 Footer 一致。 */
|
|
31
|
+
cacheHitPercent?: number;
|
|
32
|
+
cost: number;
|
|
33
|
+
turns: number;
|
|
34
|
+
compactions: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface UsageValue {
|
|
38
|
+
input: number;
|
|
39
|
+
output: number;
|
|
40
|
+
cacheRead: number;
|
|
41
|
+
cacheWrite: number;
|
|
42
|
+
cost: { total: number };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StatusLineSegment {
|
|
46
|
+
id: string;
|
|
47
|
+
text: string;
|
|
48
|
+
compactText?: string;
|
|
49
|
+
priority: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface RenderState {
|
|
53
|
+
segment: StatusLineSegment;
|
|
54
|
+
text: string;
|
|
55
|
+
compacted: boolean;
|
|
56
|
+
hidden: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeAmount(value: number): number {
|
|
60
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function addUsage(snapshot: SessionStatusSnapshot, usage: UsageValue): void {
|
|
64
|
+
snapshot.inputTokens += safeAmount(usage.input);
|
|
65
|
+
snapshot.outputTokens += safeAmount(usage.output);
|
|
66
|
+
snapshot.cacheReadTokens += safeAmount(usage.cacheRead);
|
|
67
|
+
snapshot.cacheWriteTokens += safeAmount(usage.cacheWrite);
|
|
68
|
+
snapshot.cost += safeAmount(usage.cost.total);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function collectSessionStatus(
|
|
72
|
+
sessionManager: ExtensionContext["sessionManager"],
|
|
73
|
+
): SessionStatusSnapshot {
|
|
74
|
+
const entries = sessionManager.getEntries();
|
|
75
|
+
const branchEntries = sessionManager.getBranch?.() ?? entries;
|
|
76
|
+
const snapshot: SessionStatusSnapshot = {
|
|
77
|
+
sessionId: sanitizeSingleLine(sessionManager.getSessionId()),
|
|
78
|
+
sessionName: sanitizeSingleLine(sessionManager.getSessionName() ?? "") || undefined,
|
|
79
|
+
inputTokens: 0,
|
|
80
|
+
outputTokens: 0,
|
|
81
|
+
cacheReadTokens: 0,
|
|
82
|
+
cacheWriteTokens: 0,
|
|
83
|
+
cost: 0,
|
|
84
|
+
turns: branchEntries.filter((entry) => entry.type === "message" && entry.message.role === "user").length,
|
|
85
|
+
compactions: branchEntries.filter((entry) => entry.type === "compaction").length,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// 命中率只取最新 assistant 请求,口径与 Pi 原生 Footer 一致;
|
|
89
|
+
// toolResult / compaction 的 usage 不参与 CH。
|
|
90
|
+
let latestCacheHitPercent: number | undefined;
|
|
91
|
+
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
let usage: UsageValue | undefined;
|
|
94
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
95
|
+
usage = entry.message.usage as UsageValue | undefined;
|
|
96
|
+
if (usage) {
|
|
97
|
+
const promptTokens = safeAmount(usage.input) + safeAmount(usage.cacheRead) + safeAmount(usage.cacheWrite);
|
|
98
|
+
latestCacheHitPercent = promptTokens > 0
|
|
99
|
+
? (safeAmount(usage.cacheRead) / promptTokens) * 100
|
|
100
|
+
: undefined;
|
|
101
|
+
}
|
|
102
|
+
} else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
|
|
103
|
+
usage = entry.message.usage as UsageValue;
|
|
104
|
+
} else if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage) {
|
|
105
|
+
usage = entry.usage as UsageValue;
|
|
106
|
+
}
|
|
107
|
+
if (usage) addUsage(snapshot, usage);
|
|
108
|
+
}
|
|
109
|
+
snapshot.cacheHitPercent = latestCacheHitPercent;
|
|
110
|
+
|
|
111
|
+
return snapshot;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function formatTokenCount(count: number): string {
|
|
115
|
+
if (count < 1_000) return String(count);
|
|
116
|
+
if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
117
|
+
if (count < 1_000_000) return `${Math.round(count / 1_000)}k`;
|
|
118
|
+
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
119
|
+
return `${Math.round(count / 1_000_000)}M`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatContextTokenCount(count: number): string {
|
|
123
|
+
if (count < 1_000) return String(count);
|
|
124
|
+
if (count < 1_000_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
125
|
+
if (count < 1_000_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
126
|
+
return `${(count / 1_000_000_000).toFixed(1).replace(/\.0$/, "")}B`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatContextUsageTokenCount(count: number): string {
|
|
130
|
+
return safeAmount(count) === 0 ? "0k" : formatContextTokenCount(count);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatCacheHitPercent(percent: number | undefined): string {
|
|
134
|
+
const safePercent = Number.isFinite(percent) && percent !== undefined && percent >= 0
|
|
135
|
+
? percent
|
|
136
|
+
: 0;
|
|
137
|
+
return safePercent.toFixed(1).replace(/\.0$/, "");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function buildEditorUsageSegments(
|
|
141
|
+
snapshot: SessionStatusSnapshot,
|
|
142
|
+
contextUsage: ContextUsage | undefined,
|
|
143
|
+
contextWindowFallback: number | undefined,
|
|
144
|
+
theme: Theme,
|
|
145
|
+
glyphs: IconGlyphs,
|
|
146
|
+
segments: readonly EditorUsageSegmentId[],
|
|
147
|
+
autoCompactionEnabled = false,
|
|
148
|
+
): StatusSegment[] {
|
|
149
|
+
// 统计组一体:↑↓ R 默认灰色,只有全局缓存命中率 CH 保留档位色
|
|
150
|
+
const statsParts = [
|
|
151
|
+
theme.fg("muted", `${glyphs.inputTokens}${formatTokenCount(safeAmount(snapshot.inputTokens))}`),
|
|
152
|
+
theme.fg("muted", `${glyphs.outputTokens}${formatTokenCount(safeAmount(snapshot.outputTokens))}`),
|
|
153
|
+
theme.fg("muted", `R${safeAmount(snapshot.cacheReadTokens) > 0 ? formatTokenCount(snapshot.cacheReadTokens) : "0k"}`),
|
|
154
|
+
];
|
|
155
|
+
const cacheHit = theme.fg(
|
|
156
|
+
cacheHitStatusColor(snapshot.cacheHitPercent),
|
|
157
|
+
`CH${formatCacheHitPercent(snapshot.cacheHitPercent)}%`,
|
|
158
|
+
);
|
|
159
|
+
const statsSeparator = theme.fg("muted", " · ");
|
|
160
|
+
const statsText = `${statsParts.join(" ")}${statsSeparator}${cacheHit}`;
|
|
161
|
+
const statsCompact = `${statsParts[2]!}${statsSeparator}${cacheHit}`;
|
|
162
|
+
const contextWindow = contextUsage?.contextWindow ?? contextWindowFallback;
|
|
163
|
+
const contextTokens = contextUsage?.tokens ?? 0;
|
|
164
|
+
const contextValue = contextWindow
|
|
165
|
+
? `${formatContextUsageTokenCount(contextTokens)}/${formatContextTokenCount(contextWindow)}`
|
|
166
|
+
: `${formatContextUsageTokenCount(contextTokens)}/?`;
|
|
167
|
+
const contextText = `${glyphs.context} ${contextValue}`;
|
|
168
|
+
const stats = {
|
|
169
|
+
id: "tokens",
|
|
170
|
+
text: statsText,
|
|
171
|
+
compactText: statsCompact,
|
|
172
|
+
priority: 4,
|
|
173
|
+
};
|
|
174
|
+
const byId: Readonly<Record<EditorUsageSegmentId, StatusSegment>> = {
|
|
175
|
+
// tokens 与 cache 已合并为统计组,两个段 id 任一启用即显示
|
|
176
|
+
tokens: stats,
|
|
177
|
+
cache: stats,
|
|
178
|
+
context: {
|
|
179
|
+
id: "context",
|
|
180
|
+
text: theme.fg(contextUsageStatusColor(contextUsage?.percent), contextText),
|
|
181
|
+
priority: 0,
|
|
182
|
+
required: true,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
const showConversationCounts = segments.includes("tokens")
|
|
186
|
+
|| segments.includes("cache")
|
|
187
|
+
|| snapshot.turns > 0
|
|
188
|
+
|| snapshot.compactions > 0;
|
|
189
|
+
const turnStatus: StatusSegment = {
|
|
190
|
+
id: "turns",
|
|
191
|
+
text: snapshot.turns > 0 ? theme.fg(turnStatusColor(snapshot.turns), `${glyphs.turns} T${snapshot.turns}`) : "",
|
|
192
|
+
priority: 6,
|
|
193
|
+
};
|
|
194
|
+
const compactionStatus: StatusSegment = {
|
|
195
|
+
id: "compactions",
|
|
196
|
+
text: theme.fg(
|
|
197
|
+
compactionStatusColor(snapshot.compactions),
|
|
198
|
+
`${glyphs.compaction} ${autoCompactionEnabled ? "Auto" : "Off"}${snapshot.compactions > 0 ? `(C${snapshot.compactions})` : ""}`,
|
|
199
|
+
),
|
|
200
|
+
priority: 5,
|
|
201
|
+
};
|
|
202
|
+
const seen = new Set<string>();
|
|
203
|
+
return segments.flatMap((segment) => {
|
|
204
|
+
const status = byId[segment];
|
|
205
|
+
if (!status.text || seen.has(status.id)) return [];
|
|
206
|
+
seen.add(status.id);
|
|
207
|
+
if (segment !== "context") return [status];
|
|
208
|
+
const extras: StatusSegment[] = [compactionStatus];
|
|
209
|
+
const ordered = showConversationCounts
|
|
210
|
+
? [turnStatus, status, ...extras]
|
|
211
|
+
: [status, ...extras];
|
|
212
|
+
return ordered.filter((extra) => extra.text);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function buildSegments(
|
|
217
|
+
snapshot: SessionStatusSnapshot,
|
|
218
|
+
theme: Theme,
|
|
219
|
+
glyphs: IconGlyphs,
|
|
220
|
+
): Readonly<Record<SessionStatusSegmentId, StatusLineSegment>> {
|
|
221
|
+
const sessionLabel = snapshot.sessionName ?? snapshot.sessionId.slice(0, 8);
|
|
222
|
+
const input = snapshot.inputTokens > 0
|
|
223
|
+
? theme.fg("text", `${glyphs.inputTokens} ${formatTokenCount(snapshot.inputTokens)}`)
|
|
224
|
+
: "";
|
|
225
|
+
const compactInput = snapshot.inputTokens > 0
|
|
226
|
+
? theme.fg("text", `${glyphs.inputTokens}${formatTokenCount(snapshot.inputTokens)}`)
|
|
227
|
+
: "";
|
|
228
|
+
const output = snapshot.outputTokens > 0
|
|
229
|
+
? theme.fg("success", `${glyphs.outputTokens} ${formatTokenCount(snapshot.outputTokens)}`)
|
|
230
|
+
: "";
|
|
231
|
+
const compactOutput = snapshot.outputTokens > 0
|
|
232
|
+
? theme.fg("success", `${glyphs.outputTokens}${formatTokenCount(snapshot.outputTokens)}`)
|
|
233
|
+
: "";
|
|
234
|
+
const cacheParts = [
|
|
235
|
+
snapshot.cacheReadTokens > 0 ? `R${formatTokenCount(snapshot.cacheReadTokens)}` : "",
|
|
236
|
+
snapshot.cacheWriteTokens > 0 ? `W${formatTokenCount(snapshot.cacheWriteTokens)}` : "",
|
|
237
|
+
snapshot.cacheHitPercent !== undefined
|
|
238
|
+
? `CH${snapshot.cacheHitPercent.toFixed(1)}%`
|
|
239
|
+
: "",
|
|
240
|
+
].filter(Boolean);
|
|
241
|
+
const compactCache = snapshot.cacheHitPercent !== undefined
|
|
242
|
+
? `${Math.round(snapshot.cacheHitPercent)}%`
|
|
243
|
+
: formatTokenCount(snapshot.cacheReadTokens + snapshot.cacheWriteTokens);
|
|
244
|
+
const costValue = `$${snapshot.cost.toFixed(3)}`;
|
|
245
|
+
const costText = glyphs.cost === "$" ? costValue : `${glyphs.cost} ${costValue}`;
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
session: {
|
|
249
|
+
id: "session",
|
|
250
|
+
text: sessionLabel ? theme.fg("accent", `${glyphs.session} ${sessionLabel}`) : "",
|
|
251
|
+
compactText: sessionLabel ? theme.fg("accent", `${glyphs.session}${sessionLabel}`) : "",
|
|
252
|
+
priority: 4,
|
|
253
|
+
},
|
|
254
|
+
tokens: {
|
|
255
|
+
id: "tokens",
|
|
256
|
+
text: [input, output].filter(Boolean).join(" "),
|
|
257
|
+
compactText: [compactInput, compactOutput].filter(Boolean).join(" "),
|
|
258
|
+
priority: 1,
|
|
259
|
+
},
|
|
260
|
+
cache: {
|
|
261
|
+
id: "cache",
|
|
262
|
+
text: cacheParts.length > 0
|
|
263
|
+
? theme.fg("muted", `${glyphs.cache} ${cacheParts.join(" ")}`)
|
|
264
|
+
: "",
|
|
265
|
+
compactText: cacheParts.length > 0
|
|
266
|
+
? theme.fg("muted", `${glyphs.cache}${compactCache}`)
|
|
267
|
+
: "",
|
|
268
|
+
priority: 3,
|
|
269
|
+
},
|
|
270
|
+
cost: {
|
|
271
|
+
id: "cost",
|
|
272
|
+
text: snapshot.cost > 0 ? theme.fg("warning", costText) : "",
|
|
273
|
+
priority: 2,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function renderStates(states: readonly RenderState[], separator: string): string {
|
|
279
|
+
return states.filter((state) => !state.hidden && state.text).map((state) => state.text).join(separator);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function renderStatusLineSegments(
|
|
283
|
+
segments: readonly StatusLineSegment[],
|
|
284
|
+
width: number,
|
|
285
|
+
separator = SEPARATOR,
|
|
286
|
+
): string {
|
|
287
|
+
if (width <= 0) return "";
|
|
288
|
+
const states: RenderState[] = segments.map((segment) => ({
|
|
289
|
+
segment,
|
|
290
|
+
text: segment.text,
|
|
291
|
+
compacted: false,
|
|
292
|
+
hidden: !segment.text,
|
|
293
|
+
}));
|
|
294
|
+
let line = renderStates(states, separator);
|
|
295
|
+
|
|
296
|
+
while (visibleWidth(line) > width) {
|
|
297
|
+
const state = states
|
|
298
|
+
.filter((candidate) => !candidate.hidden)
|
|
299
|
+
.sort((left, right) => right.segment.priority - left.segment.priority)[0];
|
|
300
|
+
if (!state) return "";
|
|
301
|
+
const compact = state.segment.compactText;
|
|
302
|
+
if (!state.compacted && compact && compact !== state.text) {
|
|
303
|
+
state.text = compact;
|
|
304
|
+
state.compacted = true;
|
|
305
|
+
} else {
|
|
306
|
+
state.hidden = true;
|
|
307
|
+
}
|
|
308
|
+
line = renderStates(states, separator);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return line;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function renderSessionStatusLine(
|
|
315
|
+
snapshot: SessionStatusSnapshot,
|
|
316
|
+
width: number,
|
|
317
|
+
theme: Theme,
|
|
318
|
+
glyphs: IconGlyphs = DEFAULT_GLYPHS,
|
|
319
|
+
segments: readonly SessionStatusSegmentId[] = ["tokens", "cost"],
|
|
320
|
+
extraSegments: readonly StatusLineSegment[] = [],
|
|
321
|
+
): string {
|
|
322
|
+
const byId = buildSegments(snapshot, theme, glyphs);
|
|
323
|
+
const ordered = [...new Set(segments)].map((segment) => byId[segment]);
|
|
324
|
+
return renderStatusLineSegments([...ordered, ...extraSegments], width);
|
|
325
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ProjectStatusSegmentId } from "./project-status.ts";
|
|
2
|
+
import type { EditorUsageSegmentId } from "./session-status.ts";
|
|
3
|
+
|
|
4
|
+
export type StatusPresetName = "minimal" | "default" | "full";
|
|
5
|
+
export type EditorLeftSegmentId = "provider" | "model" | "thinking" | "balance" | "subscription" | "duration";
|
|
6
|
+
export type FooterExtraSegmentId = "extensions";
|
|
7
|
+
export type StatusSegmentId =
|
|
8
|
+
| EditorLeftSegmentId
|
|
9
|
+
| EditorUsageSegmentId
|
|
10
|
+
| ProjectStatusSegmentId
|
|
11
|
+
| FooterExtraSegmentId;
|
|
12
|
+
|
|
13
|
+
export interface ResolvedStatusSettings {
|
|
14
|
+
preset: StatusPresetName;
|
|
15
|
+
editorLeft: EditorLeftSegmentId[];
|
|
16
|
+
footerUsage: EditorUsageSegmentId[];
|
|
17
|
+
footerPrimary: ProjectStatusSegmentId[];
|
|
18
|
+
footerExtra: FooterExtraSegmentId[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StatusSettingsOverride {
|
|
22
|
+
preset?: StatusPresetName;
|
|
23
|
+
segments?: readonly StatusSegmentId[] | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PRESET_SEGMENTS: Readonly<Record<StatusPresetName, readonly StatusSegmentId[]>> = {
|
|
27
|
+
minimal: ["model", "context", "project", "git"],
|
|
28
|
+
default: ["provider", "model", "thinking", "balance", "subscription", "tokens", "cache", "context", "project", "git", "duration", "extensions"],
|
|
29
|
+
full: ["provider", "model", "thinking", "balance", "subscription", "tokens", "cache", "context", "project", "git", "duration", "runtime", "extensions"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const VALID_PRESETS = new Set<StatusPresetName>(["minimal", "default", "full"]);
|
|
33
|
+
const VALID_SEGMENTS = new Set<StatusSegmentId>(PRESET_SEGMENTS.full);
|
|
34
|
+
const EDITOR_LEFT = new Set<StatusSegmentId>(["provider", "model", "thinking", "balance", "subscription", "duration"]);
|
|
35
|
+
const FOOTER_USAGE = new Set<StatusSegmentId>(["tokens", "cache", "context"]);
|
|
36
|
+
const FOOTER_PRIMARY = new Set<StatusSegmentId>(["project", "git", "runtime"]);
|
|
37
|
+
const FOOTER_EXTRA = new Set<StatusSegmentId>(["extensions"]);
|
|
38
|
+
|
|
39
|
+
export const STATUS_PRESET_NAMES: readonly StatusPresetName[] = ["minimal", "default", "full"];
|
|
40
|
+
export const STATUS_SEGMENT_IDS: readonly StatusSegmentId[] = PRESET_SEGMENTS.full;
|
|
41
|
+
|
|
42
|
+
export function statusPresetSegments(preset: StatusPresetName): StatusSegmentId[] {
|
|
43
|
+
return [...PRESET_SEGMENTS[preset]];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readStatusPreset(
|
|
47
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
48
|
+
): StatusPresetName {
|
|
49
|
+
const candidate = env.PI_UI_STATUS_PRESET?.trim().toLowerCase() as StatusPresetName | undefined;
|
|
50
|
+
return candidate && VALID_PRESETS.has(candidate) ? candidate : "default";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readSegmentOrder(
|
|
54
|
+
preset: StatusPresetName,
|
|
55
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
56
|
+
): StatusSegmentId[] {
|
|
57
|
+
const raw = env.PI_UI_STATUS_SEGMENTS?.trim();
|
|
58
|
+
if (!raw) return [...PRESET_SEGMENTS[preset]];
|
|
59
|
+
|
|
60
|
+
const seen = new Set<StatusSegmentId>();
|
|
61
|
+
for (const token of raw.split(",")) {
|
|
62
|
+
const segment = token.trim().toLowerCase() as StatusSegmentId;
|
|
63
|
+
if (VALID_SEGMENTS.has(segment)) seen.add(segment);
|
|
64
|
+
}
|
|
65
|
+
return seen.size > 0 ? [...seen] : [...PRESET_SEGMENTS[preset]];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeSegmentOrder(segments: readonly StatusSegmentId[]): StatusSegmentId[] {
|
|
69
|
+
const seen = new Set<StatusSegmentId>();
|
|
70
|
+
for (const segment of segments) {
|
|
71
|
+
if (VALID_SEGMENTS.has(segment)) seen.add(segment);
|
|
72
|
+
}
|
|
73
|
+
return [...seen];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveStatusSettings(
|
|
77
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
78
|
+
override: StatusSettingsOverride = {},
|
|
79
|
+
): ResolvedStatusSettings {
|
|
80
|
+
const preset = env.PI_UI_STATUS_PRESET?.trim()
|
|
81
|
+
? readStatusPreset(env)
|
|
82
|
+
: override.preset ?? "default";
|
|
83
|
+
const segments = env.PI_UI_STATUS_SEGMENTS?.trim()
|
|
84
|
+
? readSegmentOrder(preset, env)
|
|
85
|
+
: override.segments === undefined || override.segments === null
|
|
86
|
+
? statusPresetSegments(preset)
|
|
87
|
+
: normalizeSegmentOrder(override.segments);
|
|
88
|
+
return {
|
|
89
|
+
preset,
|
|
90
|
+
editorLeft: segments.filter((segment): segment is EditorLeftSegmentId => EDITOR_LEFT.has(segment)),
|
|
91
|
+
footerUsage: segments.filter((segment): segment is EditorUsageSegmentId => FOOTER_USAGE.has(segment)),
|
|
92
|
+
footerPrimary: segments.filter((segment): segment is ProjectStatusSegmentId => FOOTER_PRIMARY.has(segment)),
|
|
93
|
+
footerExtra: segments.filter((segment): segment is FooterExtraSegmentId => FOOTER_EXTRA.has(segment)),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
const SEGMENT_SEPARATOR = " · ";
|
|
5
|
+
const EDITOR_STATUS_CHROME_WIDTH = 11;
|
|
6
|
+
|
|
7
|
+
export interface StatusSegment {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly text: string;
|
|
10
|
+
readonly compactText?: string;
|
|
11
|
+
/** 数值越大越早压缩或隐藏。 */
|
|
12
|
+
readonly priority: number;
|
|
13
|
+
readonly required?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EditorStatusLayout {
|
|
17
|
+
left: string;
|
|
18
|
+
right: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SegmentState {
|
|
22
|
+
readonly segment: StatusSegment;
|
|
23
|
+
readonly side: "left" | "right";
|
|
24
|
+
readonly order: number;
|
|
25
|
+
text: string;
|
|
26
|
+
compacted: boolean;
|
|
27
|
+
hidden: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderSide(states: readonly SegmentState[], side: SegmentState["side"]): string {
|
|
31
|
+
return states
|
|
32
|
+
.filter((state) => state.side === side && !state.hidden && state.text)
|
|
33
|
+
.map((state) => state.text)
|
|
34
|
+
.join(side === "right" ? " " : SEGMENT_SEPARATOR);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function layoutWidth(layout: EditorStatusLayout): number {
|
|
38
|
+
const gap = layout.left && layout.right ? 1 : 0;
|
|
39
|
+
return visibleWidth(layout.left) + visibleWidth(layout.right) + gap;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function renderLayout(states: readonly SegmentState[]): EditorStatusLayout {
|
|
43
|
+
return {
|
|
44
|
+
left: renderSide(states, "left"),
|
|
45
|
+
right: renderSide(states, "right"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nextReduction(states: readonly SegmentState[]): SegmentState | undefined {
|
|
50
|
+
return states
|
|
51
|
+
.filter((state) => {
|
|
52
|
+
if (state.hidden) return false;
|
|
53
|
+
const compact = state.segment.compactText;
|
|
54
|
+
return (!state.compacted && compact !== undefined && compact !== state.text) || !state.segment.required;
|
|
55
|
+
})
|
|
56
|
+
.sort((left, right) =>
|
|
57
|
+
right.segment.priority - left.segment.priority || left.order - right.order,
|
|
58
|
+
)[0];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function reduceState(state: SegmentState): void {
|
|
62
|
+
const compact = state.segment.compactText;
|
|
63
|
+
if (!state.compacted && compact !== undefined && compact !== state.text) {
|
|
64
|
+
state.text = compact;
|
|
65
|
+
state.compacted = true;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
state.hidden = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function truncateRequiredLayout(layout: EditorStatusLayout, budget: number): EditorStatusLayout {
|
|
72
|
+
if (budget <= 0) return { left: "", right: "" };
|
|
73
|
+
if (!layout.left) return { left: "", right: truncateToWidth(layout.right, budget, "") };
|
|
74
|
+
if (!layout.right) return { left: truncateToWidth(layout.left, budget, "…"), right: "" };
|
|
75
|
+
|
|
76
|
+
const rightBudget = Math.min(visibleWidth(layout.right), Math.max(1, Math.floor(budget * 0.4)));
|
|
77
|
+
const right = truncateToWidth(layout.right, rightBudget, "");
|
|
78
|
+
const leftBudget = Math.max(0, budget - visibleWidth(right) - 1);
|
|
79
|
+
return {
|
|
80
|
+
left: truncateToWidth(layout.left, leftBudget, "…"),
|
|
81
|
+
right,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function layoutEditorStatus(
|
|
86
|
+
left: readonly StatusSegment[],
|
|
87
|
+
right: readonly StatusSegment[],
|
|
88
|
+
terminalWidth: number,
|
|
89
|
+
): EditorStatusLayout {
|
|
90
|
+
const budget = Math.max(0, terminalWidth - EDITOR_STATUS_CHROME_WIDTH);
|
|
91
|
+
const states: SegmentState[] = [
|
|
92
|
+
...left.map((segment, order) => ({
|
|
93
|
+
segment,
|
|
94
|
+
side: "left" as const,
|
|
95
|
+
order,
|
|
96
|
+
text: segment.text,
|
|
97
|
+
compacted: false,
|
|
98
|
+
hidden: !segment.text,
|
|
99
|
+
})),
|
|
100
|
+
...right.map((segment, order) => ({
|
|
101
|
+
segment,
|
|
102
|
+
side: "right" as const,
|
|
103
|
+
order: left.length + order,
|
|
104
|
+
text: segment.text,
|
|
105
|
+
compacted: false,
|
|
106
|
+
hidden: !segment.text,
|
|
107
|
+
})),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
let layout = renderLayout(states);
|
|
111
|
+
while (layoutWidth(layout) > budget) {
|
|
112
|
+
const candidate = nextReduction(states);
|
|
113
|
+
if (!candidate) break;
|
|
114
|
+
reduceState(candidate);
|
|
115
|
+
layout = renderLayout(states);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return layoutWidth(layout) <= budget ? layout : truncateRequiredLayout(layout, budget);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type TurnTimerState = "idle" | "working" | "done";
|
|
122
|
+
|
|
123
|
+
const THINKING_COLORS: Readonly<Record<string, ThemeColor>> = {
|
|
124
|
+
off: "thinkingOff",
|
|
125
|
+
minimal: "thinkingMinimal",
|
|
126
|
+
low: "thinkingLow",
|
|
127
|
+
medium: "thinkingMedium",
|
|
128
|
+
high: "thinkingHigh",
|
|
129
|
+
xhigh: "thinkingXhigh",
|
|
130
|
+
max: "thinkingMax",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export function thinkingStatusColor(level: string | undefined): ThemeColor {
|
|
134
|
+
return THINKING_COLORS[level ?? "off"] ?? "thinkingOff";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function cacheHitStatusColor(percent: number | null | undefined): ThemeColor {
|
|
138
|
+
if (percent === null || percent === undefined || !Number.isFinite(percent) || percent < 0) return "muted";
|
|
139
|
+
if (percent < 30) return "error";
|
|
140
|
+
if (percent < 70) return "warning";
|
|
141
|
+
if (percent < 90) return "accent";
|
|
142
|
+
return "success";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function contextUsageStatusColor(percent: number | null | undefined): ThemeColor {
|
|
146
|
+
if (percent === null || percent === undefined || !Number.isFinite(percent) || percent <= 0) return "muted";
|
|
147
|
+
if (percent <= 10) return "success";
|
|
148
|
+
if (percent <= 30) return "accent";
|
|
149
|
+
if (percent <= 60) return "warning";
|
|
150
|
+
return "error";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function turnStatusColor(turns: number): ThemeColor {
|
|
154
|
+
if (!Number.isFinite(turns) || turns <= 10) return "muted";
|
|
155
|
+
if (turns <= 20) return "success";
|
|
156
|
+
if (turns < 40) return "warning";
|
|
157
|
+
return "error";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function compactionStatusColor(compactions: number): ThemeColor {
|
|
161
|
+
if (!Number.isFinite(compactions) || compactions <= 0) return "muted";
|
|
162
|
+
if (compactions === 1) return "success";
|
|
163
|
+
if (compactions === 2) return "accent";
|
|
164
|
+
if (compactions === 3) return "warning";
|
|
165
|
+
return "error";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function durationStatusColor(state: TurnTimerState): ThemeColor {
|
|
169
|
+
if (state === "working") return "accent";
|
|
170
|
+
if (state === "done") return "success";
|
|
171
|
+
return "dim";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface TurnTimerSnapshot {
|
|
175
|
+
state: TurnTimerState;
|
|
176
|
+
elapsedMs: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function formatElapsed(elapsedMs: number): string {
|
|
180
|
+
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
|
181
|
+
const seconds = totalSeconds % 60;
|
|
182
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
183
|
+
if (totalMinutes < 1) return `${seconds}s`;
|
|
184
|
+
const minutes = totalMinutes % 60;
|
|
185
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
186
|
+
if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
|
187
|
+
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export class TurnTimerController {
|
|
191
|
+
private readonly requestRender: () => void;
|
|
192
|
+
private readonly intervalMs: number;
|
|
193
|
+
private readonly now: () => number;
|
|
194
|
+
private startedAt: number | undefined;
|
|
195
|
+
private completedElapsedMs: number | undefined;
|
|
196
|
+
private interval: ReturnType<typeof setInterval> | undefined;
|
|
197
|
+
private disposed = false;
|
|
198
|
+
|
|
199
|
+
constructor(
|
|
200
|
+
requestRender: () => void,
|
|
201
|
+
intervalMs = 1000,
|
|
202
|
+
now: () => number = Date.now,
|
|
203
|
+
completedElapsedMs?: number,
|
|
204
|
+
) {
|
|
205
|
+
this.requestRender = requestRender;
|
|
206
|
+
this.intervalMs = intervalMs;
|
|
207
|
+
this.now = now;
|
|
208
|
+
if (Number.isFinite(completedElapsedMs) && (completedElapsedMs ?? -1) >= 0) {
|
|
209
|
+
this.completedElapsedMs = completedElapsedMs;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
start(): void {
|
|
214
|
+
if (this.disposed) return;
|
|
215
|
+
this.stopInterval();
|
|
216
|
+
this.startedAt = this.now();
|
|
217
|
+
this.completedElapsedMs = undefined;
|
|
218
|
+
this.interval = setInterval(() => this.requestRender(), this.intervalMs);
|
|
219
|
+
this.interval.unref();
|
|
220
|
+
this.requestRender();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
end(): number | undefined {
|
|
224
|
+
if (this.disposed || this.startedAt === undefined) return undefined;
|
|
225
|
+
this.completedElapsedMs = Math.max(0, this.now() - this.startedAt);
|
|
226
|
+
this.startedAt = undefined;
|
|
227
|
+
this.stopInterval();
|
|
228
|
+
this.requestRender();
|
|
229
|
+
return this.completedElapsedMs;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
restore(elapsedMs: number): void {
|
|
233
|
+
if (this.disposed || !Number.isFinite(elapsedMs) || elapsedMs < 0) return;
|
|
234
|
+
this.startedAt = undefined;
|
|
235
|
+
this.completedElapsedMs = elapsedMs;
|
|
236
|
+
this.stopInterval();
|
|
237
|
+
this.requestRender();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
getSnapshot(): TurnTimerSnapshot {
|
|
241
|
+
if (this.startedAt !== undefined) {
|
|
242
|
+
return {
|
|
243
|
+
state: "working",
|
|
244
|
+
elapsedMs: Math.max(0, this.now() - this.startedAt),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (this.completedElapsedMs !== undefined) {
|
|
248
|
+
return { state: "done", elapsedMs: this.completedElapsedMs };
|
|
249
|
+
}
|
|
250
|
+
return { state: "idle", elapsedMs: 0 };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
dispose(): void {
|
|
254
|
+
if (this.disposed) return;
|
|
255
|
+
this.disposed = true;
|
|
256
|
+
this.stopInterval();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private stopInterval(): void {
|
|
260
|
+
if (this.interval) clearInterval(this.interval);
|
|
261
|
+
this.interval = undefined;
|
|
262
|
+
}
|
|
263
|
+
}
|