pi-crew 0.10.6 → 0.11.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/CHANGELOG.md +99 -0
- package/dist/index.mjs +34850 -34889
- package/package.json +170 -169
- package/scripts/watch-agent-transcript.mjs +120 -0
- package/src/extension/crew-input-router.ts +2 -0
- package/src/extension/crew-vibes/config.ts +1 -56
- package/src/extension/crew-vibes/figures.ts +5 -84
- package/src/extension/crew-vibes/font-detect.ts +1 -1
- package/src/extension/crew-vibes/index.ts +36 -328
- package/src/extension/crew-vibes/render.ts +2 -58
- package/src/extension/help.ts +1 -0
- package/src/extension/registration/command-registration.ts +5 -1
- package/src/extension/registration/commands/schedules.ts +212 -0
- package/src/extension/registration/commands/shared.ts +56 -1
- package/src/extension/registration/lifecycle-handlers.ts +16 -2
- package/src/extension/registration/schedule-toast-bridge.ts +109 -0
- package/src/extension/registration/viewers.ts +66 -1
- package/src/extension/team-tool/handle-schedule.ts +120 -1
- package/src/runtime/scheduling/scheduler.ts +120 -6
- package/src/runtime/settings-store.ts +28 -2
- package/src/state/coordination/locks.ts +17 -2
- package/src/ui/agents-jobs-browser.ts +785 -0
- package/src/ui/dashboard-panes/agents-pane.ts +7 -4
- package/src/ui/dashboard-panes/schedules-pane.ts +154 -0
- package/src/ui/inline-panel/crew-editor.ts +19 -6
- package/src/ui/inline-panel/index.ts +6 -0
- package/src/ui/key-utils.ts +3 -0
- package/src/ui/keybinding-map.ts +56 -2
- package/src/ui/mascot.ts +2 -1
- package/src/ui/overlays/agent-picker-overlay.ts +3 -3
- package/src/ui/overlays/confirm-overlay.ts +3 -2
- package/src/ui/overlays/mailbox-compose-overlay.ts +4 -3
- package/src/ui/overlays/mailbox-detail-overlay.ts +3 -3
- package/src/ui/run-dashboard.ts +257 -6
- package/src/ui/settings-overlay.ts +3 -3
- package/src/ui/transcript-viewer.ts +3 -2
- package/src/ui/widget/index.ts +61 -88
- package/src/ui/widget/widget-formatters.ts +7 -4
- package/src/ui/widget/widget-renderer.ts +134 -174
- package/src/ui/widget/widget-types.ts +0 -7
- package/src/utils/relative-time.ts +33 -0
- package/src/extension/crew-vibes/footer.ts +0 -313
- package/src/extension/crew-vibes/speed.ts +0 -286
- package/src/ui/dock-footer.ts +0 -49
|
@@ -1,313 +0,0 @@
|
|
|
1
|
-
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { getFooterDockProvider } from "../../ui/dock-footer.ts";
|
|
4
|
-
import { requestRenderTarget } from "../../ui/pi-ui-compat.ts";
|
|
5
|
-
import { asCrewTheme, type CrewTheme } from "../../ui/theme-adapter.ts";
|
|
6
|
-
import { colorWidgetLine, renderLines } from "../../ui/widget/widget-renderer.ts";
|
|
7
|
-
import { truncateToWidth, visibleWidth } from "../../utils/visual.ts";
|
|
8
|
-
import type { CrewVibesConfig } from "./config.ts";
|
|
9
|
-
import type { ProviderUsage } from "./provider-usage.ts";
|
|
10
|
-
import { formatCount, getCapacityUsage, renderCapacity, renderProviderUsage } from "./render.ts";
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Custom footer replacement for crew-vibes.
|
|
14
|
-
*
|
|
15
|
-
* Why this exists: pi joins ALL `setStatus()` entries onto ONE line and
|
|
16
|
-
* right-truncates when the line overflows. The provider quota lives at the
|
|
17
|
-
* tail, so it gets chopped to "W..." whenever the `pi-crew` widget status grows
|
|
18
|
-
* during sub-agent runs. Five prior attempts (right-align padding, dynamic
|
|
19
|
-
* padding, separate keys) all failed because an extension using `setStatus`
|
|
20
|
-
* cannot see the widget's width, other extensions' widths, or pi's real render
|
|
21
|
-
* width. `setFooter` sidesteps all of that: `render(width)` receives the REAL
|
|
22
|
-
* width and we own the line layout, so the meters get a dedicated line that is
|
|
23
|
-
* never truncated by the join.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
/** Live values the footer reads on each render (owned by the extension). */
|
|
27
|
-
export interface CrewVibesFooterSource {
|
|
28
|
-
getConfig(): CrewVibesConfig;
|
|
29
|
-
getQuotaUsage(): ProviderUsage | null;
|
|
30
|
-
getThinkingLevel(): string | undefined;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface CrewVibesFooterDeps {
|
|
34
|
-
tui: unknown;
|
|
35
|
-
theme: unknown;
|
|
36
|
-
footerData: unknown;
|
|
37
|
-
ctx: ExtensionContext;
|
|
38
|
-
source: CrewVibesFooterSource;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface FooterData {
|
|
42
|
-
getGitBranch(): string | null;
|
|
43
|
-
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
44
|
-
getAvailableProviderCount(): number;
|
|
45
|
-
onBranchChange(cb: () => void): () => void;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface FooterComponent {
|
|
49
|
-
render(width: number): string[];
|
|
50
|
-
invalidate(): void;
|
|
51
|
-
dispose(): void;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function num(value: unknown): number {
|
|
55
|
-
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Mirror pi's footer cwd formatting: collapse the home prefix to `~`. */
|
|
59
|
-
function formatCwdForFooter(cwd: string, home: string | undefined): string {
|
|
60
|
-
if (!home) return cwd;
|
|
61
|
-
const resolvedCwd = resolve(cwd);
|
|
62
|
-
const resolvedHome = resolve(home);
|
|
63
|
-
const rel = relative(resolvedHome, resolvedCwd);
|
|
64
|
-
const inside = rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
65
|
-
if (!inside) return cwd;
|
|
66
|
-
return rel === "" ? "~" : `~${sep}${rel}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Mirror pi's status sanitizer (collapse control chars + runs of spaces). */
|
|
70
|
-
function sanitizeStatusText(text: string): string {
|
|
71
|
-
return text
|
|
72
|
-
.replace(/[\r\n\t]/g, " ")
|
|
73
|
-
.replace(/ +/g, " ")
|
|
74
|
-
.trim();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function asFooterData(value: unknown): FooterData | undefined {
|
|
78
|
-
if (!value || typeof value !== "object") return undefined;
|
|
79
|
-
const record = value as Record<string, unknown>;
|
|
80
|
-
if (
|
|
81
|
-
typeof record.getGitBranch !== "function" ||
|
|
82
|
-
typeof record.getExtensionStatuses !== "function" ||
|
|
83
|
-
typeof record.getAvailableProviderCount !== "function" ||
|
|
84
|
-
typeof record.onBranchChange !== "function"
|
|
85
|
-
) {
|
|
86
|
-
return undefined;
|
|
87
|
-
}
|
|
88
|
-
return value as FooterData;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
interface SessionTotals {
|
|
92
|
-
input: number;
|
|
93
|
-
output: number;
|
|
94
|
-
cacheRead: number;
|
|
95
|
-
cacheWrite: number;
|
|
96
|
-
cost: number;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function computeTotals(entries: unknown[]): SessionTotals {
|
|
100
|
-
const totals: SessionTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
101
|
-
for (const entry of entries) {
|
|
102
|
-
const rec = entry as {
|
|
103
|
-
type?: string;
|
|
104
|
-
message?: { role?: string; usage?: Record<string, unknown> & { cost?: { total?: unknown } } };
|
|
105
|
-
};
|
|
106
|
-
if (rec?.type !== "message" || rec.message?.role !== "assistant") continue;
|
|
107
|
-
const usage = rec.message.usage;
|
|
108
|
-
if (!usage) continue;
|
|
109
|
-
totals.input += num(usage.input);
|
|
110
|
-
totals.output += num(usage.output);
|
|
111
|
-
totals.cacheRead += num(usage.cacheRead);
|
|
112
|
-
totals.cacheWrite += num(usage.cacheWrite);
|
|
113
|
-
totals.cost += num(usage.cost?.total);
|
|
114
|
-
}
|
|
115
|
-
return totals;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
class CrewVibesFooter implements FooterComponent {
|
|
119
|
-
private readonly theme: CrewTheme;
|
|
120
|
-
private readonly footerData: FooterData | undefined;
|
|
121
|
-
private readonly ctx: ExtensionContext;
|
|
122
|
-
private readonly source: CrewVibesFooterSource;
|
|
123
|
-
private readonly tui: unknown;
|
|
124
|
-
private readonly unsubscribeBranch: () => void;
|
|
125
|
-
|
|
126
|
-
constructor(deps: CrewVibesFooterDeps) {
|
|
127
|
-
this.theme = asCrewTheme(deps.theme);
|
|
128
|
-
this.footerData = asFooterData(deps.footerData);
|
|
129
|
-
this.ctx = deps.ctx;
|
|
130
|
-
this.source = deps.source;
|
|
131
|
-
this.tui = deps.tui;
|
|
132
|
-
this.unsubscribeBranch = this.footerData ? this.footerData.onBranchChange(() => requestRenderTarget(this.tui)) : () => undefined;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
invalidate(): void {
|
|
136
|
-
/* no-op */
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
dispose(): void {
|
|
140
|
-
this.unsubscribeBranch();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
private buildPwdLine(width: number): string {
|
|
144
|
-
const sm = this.ctx.sessionManager;
|
|
145
|
-
let pwd = formatCwdForFooter(sm.getCwd(), process.env.HOME || process.env.USERPROFILE);
|
|
146
|
-
const branch = this.footerData?.getGitBranch();
|
|
147
|
-
if (branch) pwd = `${pwd} (${branch})`;
|
|
148
|
-
const sessionName = sm.getSessionName?.();
|
|
149
|
-
if (sessionName) pwd = `${pwd} • ${sessionName}`;
|
|
150
|
-
return truncateToWidth(this.theme.fg("dim", pwd), width, this.theme.fg("dim", "..."));
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
private buildStatsLine(width: number): string {
|
|
154
|
-
const theme = this.theme;
|
|
155
|
-
const model = this.ctx.model as { id?: string; provider?: string; reasoning?: unknown; contextWindow?: number } | undefined;
|
|
156
|
-
const totals = computeTotals(this.ctx.sessionManager.getEntries() as unknown[]);
|
|
157
|
-
const contextUsage = this.ctx.getContextUsage?.();
|
|
158
|
-
const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
|
|
159
|
-
const percentValue = contextUsage?.percent ?? 0;
|
|
160
|
-
const percentKnown = contextUsage?.percent !== null && contextUsage?.percent !== undefined;
|
|
161
|
-
|
|
162
|
-
const statsParts: string[] = [];
|
|
163
|
-
if (totals.input) statsParts.push(`↑${formatCount(totals.input)}`);
|
|
164
|
-
if (totals.output) statsParts.push(`↓${formatCount(totals.output)}`);
|
|
165
|
-
if (totals.cacheRead) statsParts.push(`R${formatCount(totals.cacheRead)}`);
|
|
166
|
-
if (totals.cacheWrite) statsParts.push(`W${formatCount(totals.cacheWrite)}`);
|
|
167
|
-
|
|
168
|
-
const usingSubscription = !!(model && this.ctx.modelRegistry?.isUsingOAuth?.(this.ctx.model as never));
|
|
169
|
-
if (totals.cost || usingSubscription) {
|
|
170
|
-
statsParts.push(`$${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// User choice: always show the "(auto)" compaction indicator (pi's default);
|
|
174
|
-
// the toggle state is not observable by extensions.
|
|
175
|
-
const autoIndicator = " (auto)";
|
|
176
|
-
const contextDisplay = percentKnown
|
|
177
|
-
? `${percentValue.toFixed(1)}%/${formatCount(contextWindow)}${autoIndicator}`
|
|
178
|
-
: `?/${formatCount(contextWindow)}${autoIndicator}`;
|
|
179
|
-
const contextColored =
|
|
180
|
-
percentValue > 90
|
|
181
|
-
? theme.fg("error", contextDisplay)
|
|
182
|
-
: percentValue > 70
|
|
183
|
-
? theme.fg("warning", contextDisplay)
|
|
184
|
-
: contextDisplay;
|
|
185
|
-
statsParts.push(contextColored);
|
|
186
|
-
|
|
187
|
-
let statsLeft = statsParts.join(" ");
|
|
188
|
-
let statsLeftWidth = visibleWidth(statsLeft);
|
|
189
|
-
if (statsLeftWidth > width) {
|
|
190
|
-
statsLeft = truncateToWidth(statsLeft, width, "...");
|
|
191
|
-
statsLeftWidth = visibleWidth(statsLeft);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const minPadding = 2;
|
|
195
|
-
const modelName = model?.id || "no-model";
|
|
196
|
-
let rightSideWithoutProvider = modelName;
|
|
197
|
-
if (model?.reasoning) {
|
|
198
|
-
// Prefer the authoritative thinking level from session state
|
|
199
|
-
// (buildSessionContext), which reflects the current effective level
|
|
200
|
-
// including the model default — not just the last thinking_level_select
|
|
201
|
-
// event (which only fires on manual switch).
|
|
202
|
-
let level = this.source.getThinkingLevel();
|
|
203
|
-
try {
|
|
204
|
-
const ctx = this.ctx.sessionManager as { buildSessionContext?: () => { thinkingLevel?: string } };
|
|
205
|
-
if (typeof ctx.buildSessionContext === "function") {
|
|
206
|
-
const resolved = ctx.buildSessionContext()?.thinkingLevel;
|
|
207
|
-
if (resolved) level = resolved;
|
|
208
|
-
}
|
|
209
|
-
} catch {
|
|
210
|
-
// buildSessionContext may throw on empty/early sessions — fall back.
|
|
211
|
-
}
|
|
212
|
-
const finalLevel = level || "off";
|
|
213
|
-
rightSideWithoutProvider = finalLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${finalLevel}`;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
let rightSide = rightSideWithoutProvider;
|
|
217
|
-
const providerCount = this.footerData?.getAvailableProviderCount() ?? 0;
|
|
218
|
-
if (providerCount > 1 && model?.provider) {
|
|
219
|
-
rightSide = `(${model.provider}) ${rightSideWithoutProvider}`;
|
|
220
|
-
if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) rightSide = rightSideWithoutProvider;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const rightSideWidth = visibleWidth(rightSide);
|
|
224
|
-
let statsLine: string;
|
|
225
|
-
if (statsLeftWidth + minPadding + rightSideWidth <= width) {
|
|
226
|
-
statsLine = statsLeft + " ".repeat(width - statsLeftWidth - rightSideWidth) + rightSide;
|
|
227
|
-
} else {
|
|
228
|
-
const availableForRight = width - statsLeftWidth - minPadding;
|
|
229
|
-
if (availableForRight > 0) {
|
|
230
|
-
const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
|
|
231
|
-
const padding = " ".repeat(Math.max(0, width - statsLeftWidth - visibleWidth(truncatedRight)));
|
|
232
|
-
statsLine = statsLeft + padding + truncatedRight;
|
|
233
|
-
} else {
|
|
234
|
-
statsLine = statsLeft;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const dimStatsLeft = theme.fg("dim", statsLeft);
|
|
239
|
-
const dimRemainder = theme.fg("dim", statsLine.slice(statsLeft.length));
|
|
240
|
-
return dimStatsLeft + dimRemainder;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
private buildStatusLine(width: number): string | undefined {
|
|
244
|
-
if (!this.footerData) return undefined;
|
|
245
|
-
const statuses = this.footerData.getExtensionStatuses();
|
|
246
|
-
if (statuses.size === 0) return undefined;
|
|
247
|
-
const joined = Array.from(statuses.entries())
|
|
248
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
249
|
-
.map(([, text]) => sanitizeStatusText(text))
|
|
250
|
-
.join(" ");
|
|
251
|
-
if (!joined) return undefined;
|
|
252
|
-
return truncateToWidth(joined, width, this.theme.fg("dim", "..."));
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
private rightAlign(text: string, width: number): string {
|
|
256
|
-
const w = visibleWidth(text);
|
|
257
|
-
if (w >= width) return truncateToWidth(text, width, "…");
|
|
258
|
-
return " ".repeat(width - w) + text;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** Capacity + provider quota. Uses the REAL render width, so the quota is
|
|
262
|
-
* never chopped. When both do not fit on one line, wrap to two lines
|
|
263
|
-
* (capacity above, quota right-aligned below) per the chosen behavior. */
|
|
264
|
-
private buildMeterLines(width: number): string[] {
|
|
265
|
-
const config = this.source.getConfig();
|
|
266
|
-
if (!config.enabled) return [];
|
|
267
|
-
const capText = config.capacity.enabled ? renderCapacity(this.theme, config.capacity, getCapacityUsage(this.ctx)) : undefined;
|
|
268
|
-
const quotaText = config.capacity.providerUsage ? renderProviderUsage(this.theme, this.source.getQuotaUsage()) : undefined;
|
|
269
|
-
|
|
270
|
-
if (!capText && !quotaText) return [];
|
|
271
|
-
if (capText && !quotaText) return [truncateToWidth(capText, width, "…")];
|
|
272
|
-
if (!capText && quotaText) return [this.rightAlign(quotaText, width)];
|
|
273
|
-
|
|
274
|
-
const cap = capText as string;
|
|
275
|
-
const quota = quotaText as string;
|
|
276
|
-
const capWidth = visibleWidth(cap);
|
|
277
|
-
const quotaWidth = visibleWidth(quota);
|
|
278
|
-
if (capWidth + 1 + quotaWidth <= width) {
|
|
279
|
-
const pad = Math.max(1, width - capWidth - quotaWidth);
|
|
280
|
-
return [cap + " ".repeat(pad) + quota];
|
|
281
|
-
}
|
|
282
|
-
return [truncateToWidth(cap, width, "…"), this.rightAlign(quota, width)];
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/** Dock lines registered by the crew widget (`widgetPlacement: "bottom"`):
|
|
286
|
-
* painted as the very last block of the footer, below the quota/meters.
|
|
287
|
-
* The provider returns RAW lines; we color them with this footer's theme
|
|
288
|
-
* (same per-line palette the widget would have used) and truncate to the
|
|
289
|
-
* real render width. */
|
|
290
|
-
private buildDockLines(width: number): string[] {
|
|
291
|
-
const provider = getFooterDockProvider();
|
|
292
|
-
if (!provider) return [];
|
|
293
|
-
const raw = provider(width);
|
|
294
|
-
if (raw.length === 0) return [];
|
|
295
|
-
return renderLines(
|
|
296
|
-
raw.map((line, index) => colorWidgetLine(line, index, this.theme)),
|
|
297
|
-
width,
|
|
298
|
-
).map((line) => truncateToWidth(line, width, "…"));
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
render(width: number): string[] {
|
|
302
|
-
const lines = [this.buildPwdLine(width), this.buildStatsLine(width)];
|
|
303
|
-
const statusLine = this.buildStatusLine(width);
|
|
304
|
-
if (statusLine) lines.push(statusLine);
|
|
305
|
-
lines.push(...this.buildMeterLines(width));
|
|
306
|
-
lines.push(...this.buildDockLines(width));
|
|
307
|
-
return lines;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export function createCrewVibesFooter(deps: CrewVibesFooterDeps): FooterComponent {
|
|
312
|
-
return new CrewVibesFooter(deps);
|
|
313
|
-
}
|
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
import type { SpeedConfig } from "./config.ts";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Token-speed engine ported from pi-speeed (MIT, attribution in NOTICE.md).
|
|
5
|
-
* Tracks output tok/s with a sliding window and suppresses unreliable
|
|
6
|
-
* burst-only readings. No stats/font handling — just the live + session avg.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export type TokenEvent = { time: number; tokens: number };
|
|
10
|
-
|
|
11
|
-
const COMPACTION_THRESHOLD = 5000;
|
|
12
|
-
|
|
13
|
-
export type EngineConfig = Pick<SpeedConfig, "slidingWindowMs" | "minReliableDurationMs" | "maxDisplayTokS">;
|
|
14
|
-
|
|
15
|
-
export function estimateTokensFromDelta(text: string): number {
|
|
16
|
-
if (!text) return 0;
|
|
17
|
-
const matches = text.match(/\w+|[^\s\w]/g);
|
|
18
|
-
return matches ? matches.length : 0;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export class TokenSpeedEngine {
|
|
22
|
-
private _isStreaming = false;
|
|
23
|
-
private _tokenCount = 0;
|
|
24
|
-
private _startTime = 0;
|
|
25
|
-
private _endTime = 0;
|
|
26
|
-
private _events: TokenEvent[] = [];
|
|
27
|
-
private _windowStartIndex = 0;
|
|
28
|
-
private _lastStableTokS = 0;
|
|
29
|
-
private _lastUsageOutput = 0;
|
|
30
|
-
private _config: EngineConfig;
|
|
31
|
-
|
|
32
|
-
constructor(config: EngineConfig) {
|
|
33
|
-
this._config = config;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
updateConfig(config: EngineConfig): void {
|
|
37
|
-
this._config = config;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
get isStreaming(): boolean {
|
|
41
|
-
return this._isStreaming;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
get tokenCount(): number {
|
|
45
|
-
return this._tokenCount;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
get elapsedMs(): number {
|
|
49
|
-
if (this._startTime === 0) return 0;
|
|
50
|
-
return this._isStreaming ? Date.now() - this._startTime : this._endTime - this._startTime;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
get avgTokS(): number {
|
|
54
|
-
const elapsedSec = this.elapsedMs / 1000;
|
|
55
|
-
if (elapsedSec <= 0) return 0;
|
|
56
|
-
return this._tokenCount / elapsedSec;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
sanitizeTokS(value: number | null, durationMs = this.elapsedMs): number | null {
|
|
60
|
-
if (value === null || !Number.isFinite(value) || value <= 0) return null;
|
|
61
|
-
if (durationMs < this._config.minReliableDurationMs) return null;
|
|
62
|
-
if (value > this._config.maxDisplayTokS) return null;
|
|
63
|
-
return value;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
get tokS(): number {
|
|
67
|
-
const candidate = this.rawTokS;
|
|
68
|
-
const stable = this.sanitizeTokS(candidate);
|
|
69
|
-
if (stable !== null) this._lastStableTokS = stable;
|
|
70
|
-
return this._lastStableTokS;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
get rawTokS(): number {
|
|
74
|
-
if (this.elapsedMs < this._config.slidingWindowMs) return this.avgTokS;
|
|
75
|
-
if (!this._isStreaming) return this.avgTokS;
|
|
76
|
-
|
|
77
|
-
const now = Date.now();
|
|
78
|
-
const windowStart = now - this._config.slidingWindowMs;
|
|
79
|
-
|
|
80
|
-
while (this._windowStartIndex < this._events.length && this._events[this._windowStartIndex].time < windowStart) {
|
|
81
|
-
this._windowStartIndex++;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (this._windowStartIndex >= this._events.length) return this.avgTokS;
|
|
85
|
-
|
|
86
|
-
let windowTokenCount = 0;
|
|
87
|
-
for (let i = this._windowStartIndex; i < this._events.length; i++) {
|
|
88
|
-
windowTokenCount += this._events[i].tokens;
|
|
89
|
-
}
|
|
90
|
-
if (windowTokenCount === 0) return this.avgTokS;
|
|
91
|
-
|
|
92
|
-
const windowDuration = (now - this._events[this._windowStartIndex].time) / 1000;
|
|
93
|
-
if (windowDuration <= 0) return 0;
|
|
94
|
-
|
|
95
|
-
return windowTokenCount / windowDuration;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
start(): void {
|
|
99
|
-
this._tokenCount = 0;
|
|
100
|
-
this._isStreaming = true;
|
|
101
|
-
this._startTime = Date.now();
|
|
102
|
-
this._endTime = this._startTime;
|
|
103
|
-
this._events = [];
|
|
104
|
-
this._windowStartIndex = 0;
|
|
105
|
-
this._lastStableTokS = 0;
|
|
106
|
-
this._lastUsageOutput = 0;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
stop(): void {
|
|
110
|
-
this._isStreaming = false;
|
|
111
|
-
this._endTime = Date.now();
|
|
112
|
-
this._events = [];
|
|
113
|
-
this._windowStartIndex = 0;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
recordDelta(delta: string, usageOutput?: number): void {
|
|
117
|
-
if (!this._isStreaming) return;
|
|
118
|
-
if (usageOutput !== undefined && usageOutput > 0) {
|
|
119
|
-
// usageOutput may be cumulative across message_update events. Track the
|
|
120
|
-
// delta to avoid inflating _tokenCount by N× the true total.
|
|
121
|
-
const increment = usageOutput - this._lastUsageOutput;
|
|
122
|
-
this._lastUsageOutput = usageOutput;
|
|
123
|
-
this.recordTokens(Math.max(0, increment));
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
this.recordTokens(estimateTokensFromDelta(delta));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
reconcileTotal(tokens: number): void {
|
|
130
|
-
if (tokens > 0) this._tokenCount = tokens;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
recordTokens(tokens: number): void {
|
|
134
|
-
if (!this._isStreaming || tokens <= 0) return;
|
|
135
|
-
this._tokenCount += tokens;
|
|
136
|
-
this._events.push({ time: Date.now(), tokens });
|
|
137
|
-
if (this._windowStartIndex >= COMPACTION_THRESHOLD) this.compact();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
private compact(): void {
|
|
141
|
-
if (this._windowStartIndex === 0) return;
|
|
142
|
-
this._events = this._events.slice(this._windowStartIndex);
|
|
143
|
-
this._windowStartIndex = 0;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export type CompletedMessageSpeed = {
|
|
148
|
-
outputTokens: number;
|
|
149
|
-
durationMs: number;
|
|
150
|
-
tokS: number | null;
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
function isSuccessfulStop(stopReason: string | undefined): boolean {
|
|
154
|
-
return stopReason !== "error" && stopReason !== "aborted";
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export class SpeedTracker {
|
|
158
|
-
private readonly engine: TokenSpeedEngine;
|
|
159
|
-
private lastStableTokS: number | null = null;
|
|
160
|
-
private sessionOutputTokens = 0;
|
|
161
|
-
private sessionDurationMs = 0;
|
|
162
|
-
|
|
163
|
-
constructor(config: SpeedConfig) {
|
|
164
|
-
this.engine = new TokenSpeedEngine(config);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
updateConfig(config: SpeedConfig): void {
|
|
168
|
-
this.engine.updateConfig(config);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
get isStreaming(): boolean {
|
|
172
|
-
return this.engine.isStreaming;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
get lastTokS(): number | null {
|
|
176
|
-
return this.lastStableTokS;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
resetSession(): void {
|
|
180
|
-
this.sessionOutputTokens = 0;
|
|
181
|
-
this.sessionDurationMs = 0;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
startMessage(): void {
|
|
185
|
-
this.engine.start();
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
recordDelta(delta: string, usageOutput?: number): void {
|
|
189
|
-
this.engine.recordDelta(delta, usageOutput);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
stopMessage(): void {
|
|
193
|
-
if (this.engine.isStreaming) this.engine.stop();
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
liveTokS(): number | null {
|
|
197
|
-
const speed = this.engine.tokS;
|
|
198
|
-
return speed > 0 ? speed : this.lastStableTokS;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
sessionAvgTokS(): number | null {
|
|
202
|
-
return this.sessionDurationMs > 0 ? this.sessionOutputTokens / (this.sessionDurationMs / 1000) : null;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
finishMessage(outputTokens: number, stopReason: string | undefined): CompletedMessageSpeed | null {
|
|
206
|
-
if (!this.engine.isStreaming) return null;
|
|
207
|
-
|
|
208
|
-
this.engine.reconcileTotal(outputTokens);
|
|
209
|
-
const durationMs = this.engine.elapsedMs;
|
|
210
|
-
const tokens = this.engine.tokenCount;
|
|
211
|
-
const rawAvgTokS = durationMs > 0 ? tokens / (durationMs / 1000) : null;
|
|
212
|
-
const tokS = this.engine.sanitizeTokS(rawAvgTokS, durationMs);
|
|
213
|
-
this.lastStableTokS = tokS;
|
|
214
|
-
this.engine.stop();
|
|
215
|
-
|
|
216
|
-
if (tokS !== null && isSuccessfulStop(stopReason)) {
|
|
217
|
-
this.sessionOutputTokens += tokens;
|
|
218
|
-
this.sessionDurationMs += durationMs;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return { outputTokens: tokens, durationMs, tokS };
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export class SpeedAnimator {
|
|
226
|
-
private from: number | null = null;
|
|
227
|
-
private target: number | null = null;
|
|
228
|
-
private startedAt = 0;
|
|
229
|
-
private durationMs: number;
|
|
230
|
-
|
|
231
|
-
constructor(durationMs: number) {
|
|
232
|
-
this.durationMs = durationMs;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
updateDuration(durationMs: number): void {
|
|
236
|
-
this.durationMs = durationMs;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
reset(value: number | null = null, now = Date.now()): void {
|
|
240
|
-
this.from = value;
|
|
241
|
-
this.target = value;
|
|
242
|
-
this.startedAt = now;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
setTarget(target: number | null, now = Date.now()): number | null {
|
|
246
|
-
if (target === null) {
|
|
247
|
-
this.reset(null, now);
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const current = this.value(now);
|
|
252
|
-
if (current === null) {
|
|
253
|
-
this.reset(target, now);
|
|
254
|
-
return target;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (this.target !== null && Math.abs(target - this.target) < 0.05) return current;
|
|
258
|
-
|
|
259
|
-
this.from = current;
|
|
260
|
-
this.target = target;
|
|
261
|
-
this.startedAt = now;
|
|
262
|
-
return current;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
value(now = Date.now()): number | null {
|
|
266
|
-
if (this.target === null) return null;
|
|
267
|
-
if (this.from === null || this.durationMs <= 0) return this.target;
|
|
268
|
-
|
|
269
|
-
const progress = Math.max(0, Math.min(1, (now - this.startedAt) / this.durationMs));
|
|
270
|
-
if (progress >= 1) {
|
|
271
|
-
this.from = this.target;
|
|
272
|
-
return this.target;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return this.from + (this.target - this.from) * progress;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
isAnimating(now = Date.now()): boolean {
|
|
279
|
-
return (
|
|
280
|
-
this.target !== null &&
|
|
281
|
-
this.from !== null &&
|
|
282
|
-
Math.abs(this.target - this.from) >= 0.05 &&
|
|
283
|
-
now - this.startedAt < this.durationMs
|
|
284
|
-
);
|
|
285
|
-
}
|
|
286
|
-
}
|
package/src/ui/dock-footer.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dock-footer.ts — dependency-free bridge between the crew dock and the
|
|
3
|
-
* crew-vibes footer.
|
|
4
|
-
*
|
|
5
|
-
* When `ui.widgetPlacement` is `"bottom"`, the dock's rendered lines are
|
|
6
|
-
* produced by the crew widget but PAINTED by the crew-vibes footer, below the
|
|
7
|
-
* quota/meter lines — the very bottom of the terminal. The two halves are
|
|
8
|
-
* mounted by different modules (widget/index.ts owns the widget lifecycle,
|
|
9
|
-
* crew-vibes/footer.ts owns the footer), so the connection lives here as a
|
|
10
|
-
* tiny observable registry with no imports:
|
|
11
|
-
* - the widget registers a per-render line provider;
|
|
12
|
-
* - crew-vibes flips the "sink" flag when its footer is (un)installed;
|
|
13
|
-
* - the footer pulls the provider's lines on every render.
|
|
14
|
-
*
|
|
15
|
-
* Safety: the widget falls back to pi's `belowEditor` widget slot whenever no
|
|
16
|
-
* footer sink is active (crew-vibes disabled), so `"bottom"` never silently
|
|
17
|
-
* hides the dock.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
export type FooterDockLinesProvider = (width: number) => string[];
|
|
21
|
-
|
|
22
|
-
let dockProvider: FooterDockLinesProvider | undefined;
|
|
23
|
-
let sinkActive = false;
|
|
24
|
-
|
|
25
|
-
/** Register the dock line provider (widget side). Pass `undefined` to detach. */
|
|
26
|
-
export function setFooterDockProvider(provider: FooterDockLinesProvider | undefined): void {
|
|
27
|
-
dockProvider = provider;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Current dock line provider, or undefined when the dock is not in the footer. */
|
|
31
|
-
export function getFooterDockProvider(): FooterDockLinesProvider | undefined {
|
|
32
|
-
return dockProvider;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Mark whether a footer sink (crew-vibes) is currently installed. */
|
|
36
|
-
export function setFooterDockSinkActive(active: boolean): void {
|
|
37
|
-
sinkActive = active;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** True while crew-vibes' custom footer is installed and can host the dock. */
|
|
41
|
-
export function isFooterDockSinkActive(): boolean {
|
|
42
|
-
return sinkActive;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Test isolation. */
|
|
46
|
-
export function resetFooterDockRegistry(): void {
|
|
47
|
-
dockProvider = undefined;
|
|
48
|
-
sinkActive = false;
|
|
49
|
-
}
|