killeros 2.1.29 → 2.1.31
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 +558 -538
- package/Killeros.ts +53 -53
- package/README.md +112 -112
- package/killeros/activity.ts +110 -110
- package/killeros/auto-compaction.ts +342 -342
- package/killeros/change-receipt.ts +676 -682
- package/killeros/codex-fast-state.ts +47 -47
- package/killeros/codex-fast.ts +36 -36
- package/killeros/commands.ts +259 -259
- package/killeros/display.ts +60 -60
- package/killeros/errors.ts +19 -19
- package/killeros/footer.ts +505 -505
- package/killeros/goal-command.ts +26 -23
- package/killeros/goal-interface.ts +560 -560
- package/killeros/goal-runtime.ts +490 -490
- package/killeros/goal-settlement.ts +348 -348
- package/killeros/goal-state.ts +660 -660
- package/killeros/handoff.ts +326 -284
- package/killeros/hooks.ts +449 -449
- package/killeros/notifications.ts +135 -135
- package/killeros/passive-git-status.ts +346 -346
- package/killeros/personal-instructions.ts +122 -122
- package/killeros/question-ui.ts +590 -590
- package/killeros/question.ts +234 -234
- package/killeros/runtime.ts +150 -150
- package/killeros/secret-detector.ts +19 -19
- package/killeros/settings.ts +141 -50
- package/killeros/shell-ui.ts +418 -418
- package/killeros/variants.ts +13 -13
- package/killeros/worked-for.ts +496 -430
- package/package.json +67 -67
- package/themes/killeros.json +87 -87
package/killeros/footer.ts
CHANGED
|
@@ -1,505 +1,505 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { watch } from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
6
|
-
import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
|
|
7
|
-
import { formatCwd, formatTime, modelDisplayName, padRight } from "./display.ts";
|
|
8
|
-
import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, type PassiveGitRunner } from "./passive-git-status.ts";
|
|
9
|
-
import { goalElapsedMilliseconds } from "./goal-state.ts";
|
|
10
|
-
import type { GoalRuntime, GoalState } from "./runtime.ts";
|
|
11
|
-
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
12
|
-
import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
|
|
13
|
-
|
|
14
|
-
const GIT_STATUS_REFRESH_INTERVAL_MS = 30_000;
|
|
15
|
-
const GIT_STATUS_TIMEOUT_MS = 5_000;
|
|
16
|
-
const GIT_STATUS_WATCH_DEBOUNCE_MS = 250;
|
|
17
|
-
const GIT_STATUS_WATCH_INTERVAL_MS = 5_000;
|
|
18
|
-
const CODEX_PROVIDER = "openai-codex";
|
|
19
|
-
export const colorDirectory = (text: string): string => `\x1B[38;2;240;248;154m${text}\x1B[39m`;
|
|
20
|
-
|
|
21
|
-
export interface GitFileChanges {
|
|
22
|
-
modified: number;
|
|
23
|
-
added: number;
|
|
24
|
-
deleted: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
type GitStatusExecutor = (
|
|
28
|
-
file: string,
|
|
29
|
-
args: string[],
|
|
30
|
-
options: {
|
|
31
|
-
encoding: "utf8";
|
|
32
|
-
env: NodeJS.ProcessEnv;
|
|
33
|
-
maxBuffer: number;
|
|
34
|
-
timeout: number;
|
|
35
|
-
windowsHide: true;
|
|
36
|
-
},
|
|
37
|
-
callback: (error: Error | null, stdout: string) => void,
|
|
38
|
-
) => unknown;
|
|
39
|
-
|
|
40
|
-
/** Resolves changed-file counts without asking Git to inspect worktree content. */
|
|
41
|
-
export async function resolveGitFileChanges(
|
|
42
|
-
cwd: string,
|
|
43
|
-
execute: GitStatusExecutor = execFile,
|
|
44
|
-
trusted = true,
|
|
45
|
-
): Promise<GitFileChanges | undefined> {
|
|
46
|
-
if (!trusted) return undefined;
|
|
47
|
-
try {
|
|
48
|
-
const gitCommand = passiveGitCommand(cwd);
|
|
49
|
-
if (!gitCommand) return undefined;
|
|
50
|
-
const options = {
|
|
51
|
-
encoding: "utf8" as const,
|
|
52
|
-
env: passiveGitEnv(),
|
|
53
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
54
|
-
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
55
|
-
windowsHide: true as const,
|
|
56
|
-
};
|
|
57
|
-
const runAt = (directory: string): PassiveGitRunner => (args) => new Promise((resolve, reject) => {
|
|
58
|
-
execute(gitCommand, ["-C", directory, ...args], options, (error, stdout) => {
|
|
59
|
-
if (error) reject(error);
|
|
60
|
-
else resolve(Buffer.from(stdout));
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
const initialRunner = runAt(cwd);
|
|
64
|
-
const root = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
65
|
-
await initialRunner(["rev-parse", "--path-format=absolute", "--show-toplevel"]),
|
|
66
|
-
).trim();
|
|
67
|
-
if (!path.isAbsolute(root)) return undefined;
|
|
68
|
-
const snapshot = await inspectWorktreeWithoutFilters(root, runAt(root));
|
|
69
|
-
const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
|
|
70
|
-
const deletedObjectIds: string[] = [];
|
|
71
|
-
const addedObjectIds: string[] = [];
|
|
72
|
-
for (const file of snapshot.files.values()) {
|
|
73
|
-
if (!file.mode) {
|
|
74
|
-
changes.deleted += 1;
|
|
75
|
-
if (!file.indexMode && file.headObjectId) deletedObjectIds.push(file.headObjectId);
|
|
76
|
-
} else if (!file.headMode) {
|
|
77
|
-
changes.added += 1;
|
|
78
|
-
if (file.indexObjectId) addedObjectIds.push(file.indexObjectId);
|
|
79
|
-
} else changes.modified += 1;
|
|
80
|
-
}
|
|
81
|
-
for (const objectId of deletedObjectIds) {
|
|
82
|
-
const match = addedObjectIds.indexOf(objectId);
|
|
83
|
-
if (match < 0) continue;
|
|
84
|
-
addedObjectIds.splice(match, 1);
|
|
85
|
-
changes.deleted -= 1;
|
|
86
|
-
changes.added -= 1;
|
|
87
|
-
changes.modified += 1;
|
|
88
|
-
}
|
|
89
|
-
return changes;
|
|
90
|
-
} catch {
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function resolveUncommittedFileCount(cwd: string): Promise<number | undefined> {
|
|
96
|
-
const changes = await resolveGitFileChanges(cwd);
|
|
97
|
-
return changes && changes.modified + changes.added + changes.deleted;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function createGitRefresh<T>(
|
|
101
|
-
cwd: string,
|
|
102
|
-
onResult: (result: T | undefined) => void,
|
|
103
|
-
resolveResult: (cwd: string) => Promise<T | undefined>,
|
|
104
|
-
): { request: () => void; dispose: () => void } {
|
|
105
|
-
let disposed = false;
|
|
106
|
-
let pending = false;
|
|
107
|
-
let queued = false;
|
|
108
|
-
const request = (): void => {
|
|
109
|
-
if (disposed) return;
|
|
110
|
-
if (pending) {
|
|
111
|
-
queued = true;
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
pending = true;
|
|
115
|
-
let result: Promise<T | undefined>;
|
|
116
|
-
try {
|
|
117
|
-
result = resolveResult(cwd);
|
|
118
|
-
} catch {
|
|
119
|
-
result = Promise.resolve(undefined);
|
|
120
|
-
}
|
|
121
|
-
void result.then(
|
|
122
|
-
(value) => { if (!disposed) onResult(value); },
|
|
123
|
-
() => { if (!disposed) onResult(undefined); },
|
|
124
|
-
).finally(() => {
|
|
125
|
-
pending = false;
|
|
126
|
-
if (!disposed && queued) {
|
|
127
|
-
queued = false;
|
|
128
|
-
request();
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
};
|
|
132
|
-
return {
|
|
133
|
-
request,
|
|
134
|
-
dispose() {
|
|
135
|
-
disposed = true;
|
|
136
|
-
queued = false;
|
|
137
|
-
},
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Coalesces Git status requests to one active scan and one queued follow-up. */
|
|
142
|
-
export function createGitStatusRefresh(
|
|
143
|
-
cwd: string,
|
|
144
|
-
onCount: (count: number | undefined) => void,
|
|
145
|
-
resolveCount: (cwd: string) => Promise<number | undefined> = resolveUncommittedFileCount,
|
|
146
|
-
): { request: () => void; dispose: () => void } {
|
|
147
|
-
return createGitRefresh(cwd, onCount, resolveCount);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Coalesces file-change scans and emits only successful results. */
|
|
151
|
-
export function createGitFileChangesRefresh(
|
|
152
|
-
cwd: string,
|
|
153
|
-
onChanges: (changes: GitFileChanges) => void,
|
|
154
|
-
resolveChanges: (cwd: string) => Promise<GitFileChanges | undefined> = resolveGitFileChanges,
|
|
155
|
-
): { request: () => void; dispose: () => void } {
|
|
156
|
-
return createGitRefresh(cwd, (changes) => {
|
|
157
|
-
if (changes !== undefined) onChanges(changes);
|
|
158
|
-
}, resolveChanges);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
type ScheduleFallback = (refresh: () => void, intervalMs: number) => () => void;
|
|
162
|
-
type WatchDirectory = (cwd: string, onChange: () => void, onError: () => void) => () => void;
|
|
163
|
-
type ScheduleDelay = (refresh: () => void, delayMs: number) => () => void;
|
|
164
|
-
|
|
165
|
-
const scheduleFallback: ScheduleFallback = (refresh, intervalMs) => {
|
|
166
|
-
const timer = setInterval(refresh, intervalMs);
|
|
167
|
-
timer.unref?.();
|
|
168
|
-
return () => clearInterval(timer);
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const watchDirectory: WatchDirectory = (cwd, onChange, onError) => {
|
|
172
|
-
const watcher = watch(cwd, { recursive: true }, onChange);
|
|
173
|
-
watcher.on("error", onError);
|
|
174
|
-
return () => watcher.close();
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
const scheduleDelay: ScheduleDelay = (refresh, delayMs) => {
|
|
178
|
-
const timer = setTimeout(refresh, delayMs);
|
|
179
|
-
timer.unref?.();
|
|
180
|
-
return () => clearTimeout(timer);
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
/** Schedules the fallback Git scan independently from footer rendering. */
|
|
184
|
-
export function scheduleGitStatusFallback(
|
|
185
|
-
refresh: () => void,
|
|
186
|
-
schedule: ScheduleFallback = scheduleFallback,
|
|
187
|
-
): () => void {
|
|
188
|
-
return schedule(refresh, GIT_STATUS_REFRESH_INTERVAL_MS);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Throttles filesystem changes and falls back silently when watching is unavailable. */
|
|
192
|
-
export function scheduleGitStatusWatch(
|
|
193
|
-
cwd: string,
|
|
194
|
-
refresh: () => void,
|
|
195
|
-
startWatching: WatchDirectory = watchDirectory,
|
|
196
|
-
schedule: ScheduleDelay = scheduleDelay,
|
|
197
|
-
): () => void {
|
|
198
|
-
let disposed = false;
|
|
199
|
-
let coolingDown = false;
|
|
200
|
-
let queued = false;
|
|
201
|
-
let stopTimer: (() => void) | undefined;
|
|
202
|
-
let stopWatching: (() => void) | undefined;
|
|
203
|
-
const stop = (): void => {
|
|
204
|
-
if (disposed) return;
|
|
205
|
-
disposed = true;
|
|
206
|
-
stopTimer?.();
|
|
207
|
-
stopTimer = undefined;
|
|
208
|
-
stopWatching?.();
|
|
209
|
-
stopWatching = undefined;
|
|
210
|
-
};
|
|
211
|
-
const changed = (): void => {
|
|
212
|
-
if (disposed) return;
|
|
213
|
-
if (stopTimer) {
|
|
214
|
-
if (coolingDown) queued = true;
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
stopTimer = schedule(() => {
|
|
218
|
-
stopTimer = undefined;
|
|
219
|
-
if (disposed) return;
|
|
220
|
-
refresh();
|
|
221
|
-
coolingDown = true;
|
|
222
|
-
stopTimer = schedule(() => {
|
|
223
|
-
stopTimer = undefined;
|
|
224
|
-
coolingDown = false;
|
|
225
|
-
if (queued) {
|
|
226
|
-
queued = false;
|
|
227
|
-
changed();
|
|
228
|
-
}
|
|
229
|
-
}, GIT_STATUS_WATCH_INTERVAL_MS);
|
|
230
|
-
}, GIT_STATUS_WATCH_DEBOUNCE_MS);
|
|
231
|
-
};
|
|
232
|
-
try {
|
|
233
|
-
stopWatching = startWatching(cwd, changed, stop);
|
|
234
|
-
} catch {
|
|
235
|
-
disposed = true;
|
|
236
|
-
}
|
|
237
|
-
return stop;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export function formatCost(usd: number): string {
|
|
241
|
-
if (!Number.isFinite(usd)) return "$—";
|
|
242
|
-
return `$${usd.toFixed(2)}`;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export function contextPercentRemaining(ctx: ExtensionContext): number | null {
|
|
246
|
-
let usage: ReturnType<ExtensionContext["getContextUsage"]>;
|
|
247
|
-
try {
|
|
248
|
-
usage = ctx.getContextUsage();
|
|
249
|
-
} catch {
|
|
250
|
-
return null;
|
|
251
|
-
}
|
|
252
|
-
if (!usage || !Number.isFinite(usage.contextWindow) || usage.contextWindow <= 0) return null;
|
|
253
|
-
if (usage.tokens === null || !Number.isFinite(usage.tokens)) return null;
|
|
254
|
-
|
|
255
|
-
const percentRemaining = ((usage.contextWindow - Math.max(0, usage.tokens)) / usage.contextWindow) * 100;
|
|
256
|
-
return Math.round(Math.max(0, Math.min(100, percentRemaining)));
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
export function formatContextProgress(tokensUsed: number | null, contextWindow: number, theme: Theme): string {
|
|
260
|
-
if (tokensUsed === null || !Number.isFinite(tokensUsed)) return theme.fg("dim", "ctx —%");
|
|
261
|
-
const windowSize = Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : 128_000;
|
|
262
|
-
const used = Math.max(0,
|
|
263
|
-
const
|
|
264
|
-
const color: ThemeColor =
|
|
265
|
-
const action =
|
|
266
|
-
return theme.fg(color, `ctx ${
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function sumSessionCost(ctx: ExtensionContext): number {
|
|
270
|
-
let total = 0;
|
|
271
|
-
for (const entry of ctx.sessionManager.getEntries()) {
|
|
272
|
-
if (entry.type === "message" && entry.message.role === "assistant") total += entry.message.usage.cost.total;
|
|
273
|
-
else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
|
|
274
|
-
total += entry.message.usage.cost.total;
|
|
275
|
-
} else if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage) {
|
|
276
|
-
total += entry.usage.cost.total;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
return total;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const PROVIDER_LABELS: Readonly<Record<string, string>> = {
|
|
283
|
-
"amazon-bedrock": "Amazon Bedrock",
|
|
284
|
-
"azure-openai-responses": "Azure OpenAI",
|
|
285
|
-
"github-copilot": "GitHub Copilot",
|
|
286
|
-
"google-vertex": "Google Vertex",
|
|
287
|
-
"openai-codex": "OpenAI",
|
|
288
|
-
anthropic: "Anthropic",
|
|
289
|
-
deepseek: "DeepSeek",
|
|
290
|
-
google: "Google",
|
|
291
|
-
ollama: "Ollama",
|
|
292
|
-
openai: "OpenAI",
|
|
293
|
-
openrouter: "OpenRouter",
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
function formatProviderName(provider: string): string {
|
|
297
|
-
const normalized = safeTerminalText(provider).replaceAll("\n", "").trim();
|
|
298
|
-
const known = PROVIDER_LABELS[normalized.toLowerCase()];
|
|
299
|
-
if (known) return known.toLowerCase();
|
|
300
|
-
return normalized.toLowerCase().split(/[-_]+/u).filter(Boolean).join(" ") || "unknown provider";
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export function formatModel(
|
|
304
|
-
model: ExtensionContext["model"],
|
|
305
|
-
theme: Theme,
|
|
306
|
-
includeProvider = true,
|
|
307
|
-
showCodexFast = false,
|
|
308
|
-
): string {
|
|
309
|
-
if (!model) return theme.fg("dim", "no model");
|
|
310
|
-
const displayName = modelDisplayName(model) || "unknown model";
|
|
311
|
-
const name = theme.fg("text", displayName);
|
|
312
|
-
const fast = showCodexFast && model.provider === CODEX_PROVIDER ? theme.fg("text", "fast") : "";
|
|
313
|
-
const provider = includeProvider ? theme.fg("dim", formatProviderName(model.provider)) : "";
|
|
314
|
-
return [name, fast, provider].filter(Boolean).join(" ");
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function compactDirectory(cwd: string): string {
|
|
318
|
-
if (cwd === "~" || cwd === "/" || /^[A-Za-z]:[\\/]?$/u.test(cwd)) return cwd;
|
|
319
|
-
const normalized = cwd.replace(/\\/gu, "/").replace(/\/$/u, "");
|
|
320
|
-
const finalSegment = normalized.split("/").at(-1);
|
|
321
|
-
return finalSegment ? `…/${finalSegment}` : cwd;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function joinFooterParts(parts: string[], theme: Theme): string {
|
|
325
|
-
return parts.filter(Boolean).join(theme.fg("dim", " · "));
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function footerRowFits(left: string, right: string, width: number): boolean {
|
|
329
|
-
const contentWidth = visibleWidth(left) + (right ? visibleWidth(right) + 1 : 0);
|
|
330
|
-
return contentWidth + 2 <= width;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function renderFooterRow(left: string, right: string, width: number): string {
|
|
334
|
-
if (width <= 0) return "";
|
|
335
|
-
if (width < 3) return " ".repeat(width);
|
|
336
|
-
|
|
337
|
-
const innerWidth = width - 2;
|
|
338
|
-
if (!right) return ` ${padRight(left, innerWidth)} `;
|
|
339
|
-
|
|
340
|
-
const clippedRight = truncateToWidth(right, innerWidth, "");
|
|
341
|
-
const rightWidth = visibleWidth(clippedRight);
|
|
342
|
-
const leftBudget = Math.max(0, innerWidth - rightWidth - 1);
|
|
343
|
-
const clippedLeft = truncateToWidth(left, leftBudget, "…");
|
|
344
|
-
const gap = " ".repeat(Math.max(0, innerWidth - visibleWidth(clippedLeft) - rightWidth));
|
|
345
|
-
return ` ${clippedLeft}${gap}${clippedRight} `;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function renderFooter(rows: string[], width: number, theme: Theme): string[] {
|
|
349
|
-
return [theme.fg("borderMuted", "─".repeat(width)), ...rows];
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
|
|
353
|
-
if (!state) return "";
|
|
354
|
-
if (state.status === "active") {
|
|
355
|
-
const detail = state.maxTurns === undefined
|
|
356
|
-
? `${state.turns} turn${state.turns === 1 ? "" : "s"} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))}`
|
|
357
|
-
: `${state.turns}/${state.maxTurns} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))}`;
|
|
358
|
-
return `${theme.fg("customMessageLabel", "/goal active")}${theme.fg("dim", ` · ${detail}`)}`;
|
|
359
|
-
}
|
|
360
|
-
if (state.status === "paused") return theme.fg("warning", "/goal paused");
|
|
361
|
-
if (state.status === "blocked") return theme.fg("error", "/goal blocked");
|
|
362
|
-
return "";
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function formatGitFileChanges(changes: GitFileChanges, theme: Theme): string {
|
|
366
|
-
const total = changes.modified + changes.added + changes.deleted;
|
|
367
|
-
if (total === 0) return "";
|
|
368
|
-
return `${theme.fg("dim", `±${total} [`)}${theme.fg("warning", `~${changes.modified}`)} ${theme.fg("success", `+${changes.added}`)} ${theme.fg("error", `−${changes.deleted}`)}${theme.fg("dim", "]")}`;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
|
|
372
|
-
let currentModel: ExtensionContext["model"];
|
|
373
|
-
let thinkingLevel: ThinkingLevel = "off";
|
|
374
|
-
let activeTui: TUI | undefined;
|
|
375
|
-
let cachedSessionCost = 0;
|
|
376
|
-
let sessionCostDirty = true;
|
|
377
|
-
let unsubscribeCodexFast: (() => void) | undefined;
|
|
378
|
-
const resetSessionCost = (): void => {
|
|
379
|
-
cachedSessionCost = 0;
|
|
380
|
-
sessionCostDirty = true;
|
|
381
|
-
};
|
|
382
|
-
const invalidateSessionCost = (): void => {
|
|
383
|
-
sessionCostDirty = true;
|
|
384
|
-
activeTui?.requestRender();
|
|
385
|
-
};
|
|
386
|
-
const getSessionCost = (ctx: ExtensionContext): number => {
|
|
387
|
-
if (sessionCostDirty) {
|
|
388
|
-
cachedSessionCost = sumSessionCost(ctx);
|
|
389
|
-
sessionCostDirty = false;
|
|
390
|
-
}
|
|
391
|
-
return cachedSessionCost;
|
|
392
|
-
};
|
|
393
|
-
goalRuntime.requestRender = () => activeTui?.requestRender();
|
|
394
|
-
|
|
395
|
-
pi.on("session_start", (_event, ctx) => {
|
|
396
|
-
resetSessionCost();
|
|
397
|
-
if (ctx.mode !== "tui") return;
|
|
398
|
-
unsubscribeCodexFast?.();
|
|
399
|
-
unsubscribeCodexFast = subscribeCodexFast(() => activeTui?.requestRender());
|
|
400
|
-
const sessionStart = Date.now();
|
|
401
|
-
currentModel = ctx.model;
|
|
402
|
-
thinkingLevel = pi.getThinkingLevel();
|
|
403
|
-
const cwd = formatCwd(ctx.cwd);
|
|
404
|
-
|
|
405
|
-
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
406
|
-
activeTui = tui;
|
|
407
|
-
let gitFileChanges: GitFileChanges | undefined;
|
|
408
|
-
const gitStatus = createGitFileChangesRefresh(ctx.cwd, (changes) => {
|
|
409
|
-
if (JSON.stringify(changes) === JSON.stringify(gitFileChanges)) return;
|
|
410
|
-
gitFileChanges = changes;
|
|
411
|
-
tui.requestRender();
|
|
412
|
-
}, (cwd) => resolveGitFileChanges(cwd, execFile, ctx.isProjectTrusted()));
|
|
413
|
-
const unsubscribe = footerData.onBranchChange(() => {
|
|
414
|
-
gitStatus.request();
|
|
415
|
-
tui.requestRender();
|
|
416
|
-
});
|
|
417
|
-
gitStatus.request();
|
|
418
|
-
const stopWatch = scheduleGitStatusWatch(ctx.cwd, gitStatus.request);
|
|
419
|
-
const stopFallback = scheduleGitStatusFallback(gitStatus.request);
|
|
420
|
-
return {
|
|
421
|
-
dispose() {
|
|
422
|
-
unsubscribe();
|
|
423
|
-
stopWatch();
|
|
424
|
-
stopFallback();
|
|
425
|
-
gitStatus.dispose();
|
|
426
|
-
if (activeTui === tui) activeTui = undefined;
|
|
427
|
-
},
|
|
428
|
-
invalidate() {},
|
|
429
|
-
render(width: number): string[] {
|
|
430
|
-
if (width <= 0) return [];
|
|
431
|
-
const model = currentModel ?? ctx.model;
|
|
432
|
-
const level = model?.reasoning === false
|
|
433
|
-
? theme.fg("thinkingOff", "no reasoning")
|
|
434
|
-
: theme.fg(LEVEL_COLORS[thinkingLevel], thinkingLevel);
|
|
435
|
-
let usage: ReturnType<ExtensionContext["getContextUsage"]>;
|
|
436
|
-
try {
|
|
437
|
-
usage = ctx.getContextUsage();
|
|
438
|
-
} catch {
|
|
439
|
-
usage = undefined;
|
|
440
|
-
}
|
|
441
|
-
const contextWindow = usage?.contextWindow ?? model?.contextWindow ?? 128_000;
|
|
442
|
-
const context = formatContextProgress(usage?.tokens ?? null, contextWindow, theme);
|
|
443
|
-
const branch = footerData.getGitBranch();
|
|
444
|
-
const signature = formatModel(model, theme, true, isCodexFastEnabled());
|
|
445
|
-
const fullDirectory = colorDirectory(cwd);
|
|
446
|
-
const focusedDirectory = colorDirectory(compactDirectory(cwd));
|
|
447
|
-
const goal = formatGoalFooter(goalRuntime.state, theme);
|
|
448
|
-
const primary = joinFooterParts([
|
|
449
|
-
signature,
|
|
450
|
-
level,
|
|
451
|
-
context,
|
|
452
|
-
], theme);
|
|
453
|
-
const primaryFocused = joinFooterParts([signature, context], theme);
|
|
454
|
-
const session = joinFooterParts([
|
|
455
|
-
theme.fg("dim", formatTime(Date.now() - sessionStart)),
|
|
456
|
-
theme.fg("dim", formatCost(getSessionCost(ctx))),
|
|
457
|
-
], theme);
|
|
458
|
-
const essentialModel = formatModel(model, theme, false, isCodexFastEnabled());
|
|
459
|
-
const primaryRow = footerRowFits(primary, session, width)
|
|
460
|
-
? renderFooterRow(primary, session, width)
|
|
461
|
-
: footerRowFits(primary, "", width)
|
|
462
|
-
? renderFooterRow(primary, "", width)
|
|
463
|
-
: footerRowFits(primaryFocused, "", width)
|
|
464
|
-
? renderFooterRow(primaryFocused, "", width)
|
|
465
|
-
: renderFooterRow(essentialModel, context, width);
|
|
466
|
-
const changes = gitFileChanges ? formatGitFileChanges(gitFileChanges, theme) : "";
|
|
467
|
-
const branchLabel = branch
|
|
468
|
-
? `${theme.fg("dim", branch)}${changes ? `${theme.fg("dim", " · ")}${changes}` : ""}`
|
|
469
|
-
: "";
|
|
470
|
-
const workspaceRight = goal || fullDirectory;
|
|
471
|
-
const secondaryRow = footerRowFits(branchLabel, workspaceRight, width)
|
|
472
|
-
? renderFooterRow(branchLabel, workspaceRight, width)
|
|
473
|
-
: goal
|
|
474
|
-
? renderFooterRow("", goal, width)
|
|
475
|
-
: footerRowFits(branchLabel, focusedDirectory, width)
|
|
476
|
-
? renderFooterRow(branchLabel, focusedDirectory, width)
|
|
477
|
-
: renderFooterRow(branchLabel, "", width);
|
|
478
|
-
return renderFooter([primaryRow, secondaryRow], width, theme);
|
|
479
|
-
},
|
|
480
|
-
};
|
|
481
|
-
});
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
pi.on("model_select", (event) => {
|
|
485
|
-
currentModel = event.model;
|
|
486
|
-
activeTui?.requestRender();
|
|
487
|
-
});
|
|
488
|
-
pi.on("thinking_level_select", (event) => {
|
|
489
|
-
thinkingLevel = event.level;
|
|
490
|
-
activeTui?.requestRender();
|
|
491
|
-
});
|
|
492
|
-
pi.on("turn_end", invalidateSessionCost);
|
|
493
|
-
pi.on("session_compact", invalidateSessionCost);
|
|
494
|
-
pi.on("session_tree", () => {
|
|
495
|
-
resetSessionCost();
|
|
496
|
-
activeTui?.requestRender();
|
|
497
|
-
});
|
|
498
|
-
pi.on("session_shutdown", () => {
|
|
499
|
-
unsubscribeCodexFast?.();
|
|
500
|
-
unsubscribeCodexFast = undefined;
|
|
501
|
-
resetSessionCost();
|
|
502
|
-
activeTui = undefined;
|
|
503
|
-
goalRuntime.requestRender = undefined;
|
|
504
|
-
});
|
|
505
|
-
}
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { watch } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
6
|
+
import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
|
|
7
|
+
import { formatCwd, formatTime, modelDisplayName, padRight } from "./display.ts";
|
|
8
|
+
import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, type PassiveGitRunner } from "./passive-git-status.ts";
|
|
9
|
+
import { goalElapsedMilliseconds } from "./goal-state.ts";
|
|
10
|
+
import type { GoalRuntime, GoalState } from "./runtime.ts";
|
|
11
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
12
|
+
import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
|
|
13
|
+
|
|
14
|
+
const GIT_STATUS_REFRESH_INTERVAL_MS = 30_000;
|
|
15
|
+
const GIT_STATUS_TIMEOUT_MS = 5_000;
|
|
16
|
+
const GIT_STATUS_WATCH_DEBOUNCE_MS = 250;
|
|
17
|
+
const GIT_STATUS_WATCH_INTERVAL_MS = 5_000;
|
|
18
|
+
const CODEX_PROVIDER = "openai-codex";
|
|
19
|
+
export const colorDirectory = (text: string): string => `\x1B[38;2;240;248;154m${text}\x1B[39m`;
|
|
20
|
+
|
|
21
|
+
export interface GitFileChanges {
|
|
22
|
+
modified: number;
|
|
23
|
+
added: number;
|
|
24
|
+
deleted: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type GitStatusExecutor = (
|
|
28
|
+
file: string,
|
|
29
|
+
args: string[],
|
|
30
|
+
options: {
|
|
31
|
+
encoding: "utf8";
|
|
32
|
+
env: NodeJS.ProcessEnv;
|
|
33
|
+
maxBuffer: number;
|
|
34
|
+
timeout: number;
|
|
35
|
+
windowsHide: true;
|
|
36
|
+
},
|
|
37
|
+
callback: (error: Error | null, stdout: string) => void,
|
|
38
|
+
) => unknown;
|
|
39
|
+
|
|
40
|
+
/** Resolves changed-file counts without asking Git to inspect worktree content. */
|
|
41
|
+
export async function resolveGitFileChanges(
|
|
42
|
+
cwd: string,
|
|
43
|
+
execute: GitStatusExecutor = execFile,
|
|
44
|
+
trusted = true,
|
|
45
|
+
): Promise<GitFileChanges | undefined> {
|
|
46
|
+
if (!trusted) return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const gitCommand = passiveGitCommand(cwd);
|
|
49
|
+
if (!gitCommand) return undefined;
|
|
50
|
+
const options = {
|
|
51
|
+
encoding: "utf8" as const,
|
|
52
|
+
env: passiveGitEnv(),
|
|
53
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
54
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
55
|
+
windowsHide: true as const,
|
|
56
|
+
};
|
|
57
|
+
const runAt = (directory: string): PassiveGitRunner => (args) => new Promise((resolve, reject) => {
|
|
58
|
+
execute(gitCommand, ["-C", directory, ...args], options, (error, stdout) => {
|
|
59
|
+
if (error) reject(error);
|
|
60
|
+
else resolve(Buffer.from(stdout));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
const initialRunner = runAt(cwd);
|
|
64
|
+
const root = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
65
|
+
await initialRunner(["rev-parse", "--path-format=absolute", "--show-toplevel"]),
|
|
66
|
+
).trim();
|
|
67
|
+
if (!path.isAbsolute(root)) return undefined;
|
|
68
|
+
const snapshot = await inspectWorktreeWithoutFilters(root, runAt(root));
|
|
69
|
+
const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
|
|
70
|
+
const deletedObjectIds: string[] = [];
|
|
71
|
+
const addedObjectIds: string[] = [];
|
|
72
|
+
for (const file of snapshot.files.values()) {
|
|
73
|
+
if (!file.mode) {
|
|
74
|
+
changes.deleted += 1;
|
|
75
|
+
if (!file.indexMode && file.headObjectId) deletedObjectIds.push(file.headObjectId);
|
|
76
|
+
} else if (!file.headMode) {
|
|
77
|
+
changes.added += 1;
|
|
78
|
+
if (file.indexObjectId) addedObjectIds.push(file.indexObjectId);
|
|
79
|
+
} else changes.modified += 1;
|
|
80
|
+
}
|
|
81
|
+
for (const objectId of deletedObjectIds) {
|
|
82
|
+
const match = addedObjectIds.indexOf(objectId);
|
|
83
|
+
if (match < 0) continue;
|
|
84
|
+
addedObjectIds.splice(match, 1);
|
|
85
|
+
changes.deleted -= 1;
|
|
86
|
+
changes.added -= 1;
|
|
87
|
+
changes.modified += 1;
|
|
88
|
+
}
|
|
89
|
+
return changes;
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function resolveUncommittedFileCount(cwd: string): Promise<number | undefined> {
|
|
96
|
+
const changes = await resolveGitFileChanges(cwd);
|
|
97
|
+
return changes && changes.modified + changes.added + changes.deleted;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function createGitRefresh<T>(
|
|
101
|
+
cwd: string,
|
|
102
|
+
onResult: (result: T | undefined) => void,
|
|
103
|
+
resolveResult: (cwd: string) => Promise<T | undefined>,
|
|
104
|
+
): { request: () => void; dispose: () => void } {
|
|
105
|
+
let disposed = false;
|
|
106
|
+
let pending = false;
|
|
107
|
+
let queued = false;
|
|
108
|
+
const request = (): void => {
|
|
109
|
+
if (disposed) return;
|
|
110
|
+
if (pending) {
|
|
111
|
+
queued = true;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
pending = true;
|
|
115
|
+
let result: Promise<T | undefined>;
|
|
116
|
+
try {
|
|
117
|
+
result = resolveResult(cwd);
|
|
118
|
+
} catch {
|
|
119
|
+
result = Promise.resolve(undefined);
|
|
120
|
+
}
|
|
121
|
+
void result.then(
|
|
122
|
+
(value) => { if (!disposed) onResult(value); },
|
|
123
|
+
() => { if (!disposed) onResult(undefined); },
|
|
124
|
+
).finally(() => {
|
|
125
|
+
pending = false;
|
|
126
|
+
if (!disposed && queued) {
|
|
127
|
+
queued = false;
|
|
128
|
+
request();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
request,
|
|
134
|
+
dispose() {
|
|
135
|
+
disposed = true;
|
|
136
|
+
queued = false;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Coalesces Git status requests to one active scan and one queued follow-up. */
|
|
142
|
+
export function createGitStatusRefresh(
|
|
143
|
+
cwd: string,
|
|
144
|
+
onCount: (count: number | undefined) => void,
|
|
145
|
+
resolveCount: (cwd: string) => Promise<number | undefined> = resolveUncommittedFileCount,
|
|
146
|
+
): { request: () => void; dispose: () => void } {
|
|
147
|
+
return createGitRefresh(cwd, onCount, resolveCount);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Coalesces file-change scans and emits only successful results. */
|
|
151
|
+
export function createGitFileChangesRefresh(
|
|
152
|
+
cwd: string,
|
|
153
|
+
onChanges: (changes: GitFileChanges) => void,
|
|
154
|
+
resolveChanges: (cwd: string) => Promise<GitFileChanges | undefined> = resolveGitFileChanges,
|
|
155
|
+
): { request: () => void; dispose: () => void } {
|
|
156
|
+
return createGitRefresh(cwd, (changes) => {
|
|
157
|
+
if (changes !== undefined) onChanges(changes);
|
|
158
|
+
}, resolveChanges);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type ScheduleFallback = (refresh: () => void, intervalMs: number) => () => void;
|
|
162
|
+
type WatchDirectory = (cwd: string, onChange: () => void, onError: () => void) => () => void;
|
|
163
|
+
type ScheduleDelay = (refresh: () => void, delayMs: number) => () => void;
|
|
164
|
+
|
|
165
|
+
const scheduleFallback: ScheduleFallback = (refresh, intervalMs) => {
|
|
166
|
+
const timer = setInterval(refresh, intervalMs);
|
|
167
|
+
timer.unref?.();
|
|
168
|
+
return () => clearInterval(timer);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const watchDirectory: WatchDirectory = (cwd, onChange, onError) => {
|
|
172
|
+
const watcher = watch(cwd, { recursive: true }, onChange);
|
|
173
|
+
watcher.on("error", onError);
|
|
174
|
+
return () => watcher.close();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const scheduleDelay: ScheduleDelay = (refresh, delayMs) => {
|
|
178
|
+
const timer = setTimeout(refresh, delayMs);
|
|
179
|
+
timer.unref?.();
|
|
180
|
+
return () => clearTimeout(timer);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** Schedules the fallback Git scan independently from footer rendering. */
|
|
184
|
+
export function scheduleGitStatusFallback(
|
|
185
|
+
refresh: () => void,
|
|
186
|
+
schedule: ScheduleFallback = scheduleFallback,
|
|
187
|
+
): () => void {
|
|
188
|
+
return schedule(refresh, GIT_STATUS_REFRESH_INTERVAL_MS);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Throttles filesystem changes and falls back silently when watching is unavailable. */
|
|
192
|
+
export function scheduleGitStatusWatch(
|
|
193
|
+
cwd: string,
|
|
194
|
+
refresh: () => void,
|
|
195
|
+
startWatching: WatchDirectory = watchDirectory,
|
|
196
|
+
schedule: ScheduleDelay = scheduleDelay,
|
|
197
|
+
): () => void {
|
|
198
|
+
let disposed = false;
|
|
199
|
+
let coolingDown = false;
|
|
200
|
+
let queued = false;
|
|
201
|
+
let stopTimer: (() => void) | undefined;
|
|
202
|
+
let stopWatching: (() => void) | undefined;
|
|
203
|
+
const stop = (): void => {
|
|
204
|
+
if (disposed) return;
|
|
205
|
+
disposed = true;
|
|
206
|
+
stopTimer?.();
|
|
207
|
+
stopTimer = undefined;
|
|
208
|
+
stopWatching?.();
|
|
209
|
+
stopWatching = undefined;
|
|
210
|
+
};
|
|
211
|
+
const changed = (): void => {
|
|
212
|
+
if (disposed) return;
|
|
213
|
+
if (stopTimer) {
|
|
214
|
+
if (coolingDown) queued = true;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
stopTimer = schedule(() => {
|
|
218
|
+
stopTimer = undefined;
|
|
219
|
+
if (disposed) return;
|
|
220
|
+
refresh();
|
|
221
|
+
coolingDown = true;
|
|
222
|
+
stopTimer = schedule(() => {
|
|
223
|
+
stopTimer = undefined;
|
|
224
|
+
coolingDown = false;
|
|
225
|
+
if (queued) {
|
|
226
|
+
queued = false;
|
|
227
|
+
changed();
|
|
228
|
+
}
|
|
229
|
+
}, GIT_STATUS_WATCH_INTERVAL_MS);
|
|
230
|
+
}, GIT_STATUS_WATCH_DEBOUNCE_MS);
|
|
231
|
+
};
|
|
232
|
+
try {
|
|
233
|
+
stopWatching = startWatching(cwd, changed, stop);
|
|
234
|
+
} catch {
|
|
235
|
+
disposed = true;
|
|
236
|
+
}
|
|
237
|
+
return stop;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function formatCost(usd: number): string {
|
|
241
|
+
if (!Number.isFinite(usd)) return "$—";
|
|
242
|
+
return `$${usd.toFixed(2)}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function contextPercentRemaining(ctx: ExtensionContext): number | null {
|
|
246
|
+
let usage: ReturnType<ExtensionContext["getContextUsage"]>;
|
|
247
|
+
try {
|
|
248
|
+
usage = ctx.getContextUsage();
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
if (!usage || !Number.isFinite(usage.contextWindow) || usage.contextWindow <= 0) return null;
|
|
253
|
+
if (usage.tokens === null || !Number.isFinite(usage.tokens)) return null;
|
|
254
|
+
|
|
255
|
+
const percentRemaining = ((usage.contextWindow - Math.max(0, usage.tokens)) / usage.contextWindow) * 100;
|
|
256
|
+
return Math.round(Math.max(0, Math.min(100, percentRemaining)));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function formatContextProgress(tokensUsed: number | null, contextWindow: number, theme: Theme): string {
|
|
260
|
+
if (tokensUsed === null || !Number.isFinite(tokensUsed)) return theme.fg("dim", "ctx —%");
|
|
261
|
+
const windowSize = Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : 128_000;
|
|
262
|
+
const used = Math.max(0, tokensUsed);
|
|
263
|
+
const percentRemaining = Math.max(0, Math.min(100, Math.round(((windowSize - used) / windowSize) * 100)));
|
|
264
|
+
const color: ThemeColor = percentRemaining > 50 ? "success" : percentRemaining >= 20 ? "warning" : "error";
|
|
265
|
+
const action = percentRemaining <= 15 ? " · /compact" : "";
|
|
266
|
+
return theme.fg(color, `ctx ${percentRemaining}%${action}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function sumSessionCost(ctx: ExtensionContext): number {
|
|
270
|
+
let total = 0;
|
|
271
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
272
|
+
if (entry.type === "message" && entry.message.role === "assistant") total += entry.message.usage.cost.total;
|
|
273
|
+
else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
|
|
274
|
+
total += entry.message.usage.cost.total;
|
|
275
|
+
} else if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage) {
|
|
276
|
+
total += entry.usage.cost.total;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return total;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const PROVIDER_LABELS: Readonly<Record<string, string>> = {
|
|
283
|
+
"amazon-bedrock": "Amazon Bedrock",
|
|
284
|
+
"azure-openai-responses": "Azure OpenAI",
|
|
285
|
+
"github-copilot": "GitHub Copilot",
|
|
286
|
+
"google-vertex": "Google Vertex",
|
|
287
|
+
"openai-codex": "OpenAI",
|
|
288
|
+
anthropic: "Anthropic",
|
|
289
|
+
deepseek: "DeepSeek",
|
|
290
|
+
google: "Google",
|
|
291
|
+
ollama: "Ollama",
|
|
292
|
+
openai: "OpenAI",
|
|
293
|
+
openrouter: "OpenRouter",
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
function formatProviderName(provider: string): string {
|
|
297
|
+
const normalized = safeTerminalText(provider).replaceAll("\n", "").trim();
|
|
298
|
+
const known = PROVIDER_LABELS[normalized.toLowerCase()];
|
|
299
|
+
if (known) return known.toLowerCase();
|
|
300
|
+
return normalized.toLowerCase().split(/[-_]+/u).filter(Boolean).join(" ") || "unknown provider";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function formatModel(
|
|
304
|
+
model: ExtensionContext["model"],
|
|
305
|
+
theme: Theme,
|
|
306
|
+
includeProvider = true,
|
|
307
|
+
showCodexFast = false,
|
|
308
|
+
): string {
|
|
309
|
+
if (!model) return theme.fg("dim", "no model");
|
|
310
|
+
const displayName = modelDisplayName(model) || "unknown model";
|
|
311
|
+
const name = theme.fg("text", displayName);
|
|
312
|
+
const fast = showCodexFast && model.provider === CODEX_PROVIDER ? theme.fg("text", "fast") : "";
|
|
313
|
+
const provider = includeProvider ? theme.fg("dim", formatProviderName(model.provider)) : "";
|
|
314
|
+
return [name, fast, provider].filter(Boolean).join(" ");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function compactDirectory(cwd: string): string {
|
|
318
|
+
if (cwd === "~" || cwd === "/" || /^[A-Za-z]:[\\/]?$/u.test(cwd)) return cwd;
|
|
319
|
+
const normalized = cwd.replace(/\\/gu, "/").replace(/\/$/u, "");
|
|
320
|
+
const finalSegment = normalized.split("/").at(-1);
|
|
321
|
+
return finalSegment ? `…/${finalSegment}` : cwd;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function joinFooterParts(parts: string[], theme: Theme): string {
|
|
325
|
+
return parts.filter(Boolean).join(theme.fg("dim", " · "));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function footerRowFits(left: string, right: string, width: number): boolean {
|
|
329
|
+
const contentWidth = visibleWidth(left) + (right ? visibleWidth(right) + 1 : 0);
|
|
330
|
+
return contentWidth + 2 <= width;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function renderFooterRow(left: string, right: string, width: number): string {
|
|
334
|
+
if (width <= 0) return "";
|
|
335
|
+
if (width < 3) return " ".repeat(width);
|
|
336
|
+
|
|
337
|
+
const innerWidth = width - 2;
|
|
338
|
+
if (!right) return ` ${padRight(left, innerWidth)} `;
|
|
339
|
+
|
|
340
|
+
const clippedRight = truncateToWidth(right, innerWidth, "");
|
|
341
|
+
const rightWidth = visibleWidth(clippedRight);
|
|
342
|
+
const leftBudget = Math.max(0, innerWidth - rightWidth - 1);
|
|
343
|
+
const clippedLeft = truncateToWidth(left, leftBudget, "…");
|
|
344
|
+
const gap = " ".repeat(Math.max(0, innerWidth - visibleWidth(clippedLeft) - rightWidth));
|
|
345
|
+
return ` ${clippedLeft}${gap}${clippedRight} `;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function renderFooter(rows: string[], width: number, theme: Theme): string[] {
|
|
349
|
+
return [theme.fg("borderMuted", "─".repeat(width)), ...rows];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
|
|
353
|
+
if (!state) return "";
|
|
354
|
+
if (state.status === "active") {
|
|
355
|
+
const detail = state.maxTurns === undefined
|
|
356
|
+
? `${state.turns} turn${state.turns === 1 ? "" : "s"} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))}`
|
|
357
|
+
: `${state.turns}/${state.maxTurns} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))}`;
|
|
358
|
+
return `${theme.fg("customMessageLabel", "/goal active")}${theme.fg("dim", ` · ${detail}`)}`;
|
|
359
|
+
}
|
|
360
|
+
if (state.status === "paused") return theme.fg("warning", "/goal paused");
|
|
361
|
+
if (state.status === "blocked") return theme.fg("error", "/goal blocked");
|
|
362
|
+
return "";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function formatGitFileChanges(changes: GitFileChanges, theme: Theme): string {
|
|
366
|
+
const total = changes.modified + changes.added + changes.deleted;
|
|
367
|
+
if (total === 0) return "";
|
|
368
|
+
return `${theme.fg("dim", `±${total} [`)}${theme.fg("warning", `~${changes.modified}`)} ${theme.fg("success", `+${changes.added}`)} ${theme.fg("error", `−${changes.deleted}`)}${theme.fg("dim", "]")}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
|
|
372
|
+
let currentModel: ExtensionContext["model"];
|
|
373
|
+
let thinkingLevel: ThinkingLevel = "off";
|
|
374
|
+
let activeTui: TUI | undefined;
|
|
375
|
+
let cachedSessionCost = 0;
|
|
376
|
+
let sessionCostDirty = true;
|
|
377
|
+
let unsubscribeCodexFast: (() => void) | undefined;
|
|
378
|
+
const resetSessionCost = (): void => {
|
|
379
|
+
cachedSessionCost = 0;
|
|
380
|
+
sessionCostDirty = true;
|
|
381
|
+
};
|
|
382
|
+
const invalidateSessionCost = (): void => {
|
|
383
|
+
sessionCostDirty = true;
|
|
384
|
+
activeTui?.requestRender();
|
|
385
|
+
};
|
|
386
|
+
const getSessionCost = (ctx: ExtensionContext): number => {
|
|
387
|
+
if (sessionCostDirty) {
|
|
388
|
+
cachedSessionCost = sumSessionCost(ctx);
|
|
389
|
+
sessionCostDirty = false;
|
|
390
|
+
}
|
|
391
|
+
return cachedSessionCost;
|
|
392
|
+
};
|
|
393
|
+
goalRuntime.requestRender = () => activeTui?.requestRender();
|
|
394
|
+
|
|
395
|
+
pi.on("session_start", (_event, ctx) => {
|
|
396
|
+
resetSessionCost();
|
|
397
|
+
if (ctx.mode !== "tui") return;
|
|
398
|
+
unsubscribeCodexFast?.();
|
|
399
|
+
unsubscribeCodexFast = subscribeCodexFast(() => activeTui?.requestRender());
|
|
400
|
+
const sessionStart = Date.now();
|
|
401
|
+
currentModel = ctx.model;
|
|
402
|
+
thinkingLevel = pi.getThinkingLevel();
|
|
403
|
+
const cwd = formatCwd(ctx.cwd);
|
|
404
|
+
|
|
405
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
406
|
+
activeTui = tui;
|
|
407
|
+
let gitFileChanges: GitFileChanges | undefined;
|
|
408
|
+
const gitStatus = createGitFileChangesRefresh(ctx.cwd, (changes) => {
|
|
409
|
+
if (JSON.stringify(changes) === JSON.stringify(gitFileChanges)) return;
|
|
410
|
+
gitFileChanges = changes;
|
|
411
|
+
tui.requestRender();
|
|
412
|
+
}, (cwd) => resolveGitFileChanges(cwd, execFile, ctx.isProjectTrusted()));
|
|
413
|
+
const unsubscribe = footerData.onBranchChange(() => {
|
|
414
|
+
gitStatus.request();
|
|
415
|
+
tui.requestRender();
|
|
416
|
+
});
|
|
417
|
+
gitStatus.request();
|
|
418
|
+
const stopWatch = scheduleGitStatusWatch(ctx.cwd, gitStatus.request);
|
|
419
|
+
const stopFallback = scheduleGitStatusFallback(gitStatus.request);
|
|
420
|
+
return {
|
|
421
|
+
dispose() {
|
|
422
|
+
unsubscribe();
|
|
423
|
+
stopWatch();
|
|
424
|
+
stopFallback();
|
|
425
|
+
gitStatus.dispose();
|
|
426
|
+
if (activeTui === tui) activeTui = undefined;
|
|
427
|
+
},
|
|
428
|
+
invalidate() {},
|
|
429
|
+
render(width: number): string[] {
|
|
430
|
+
if (width <= 0) return [];
|
|
431
|
+
const model = currentModel ?? ctx.model;
|
|
432
|
+
const level = model?.reasoning === false
|
|
433
|
+
? theme.fg("thinkingOff", "no reasoning")
|
|
434
|
+
: theme.fg(LEVEL_COLORS[thinkingLevel], thinkingLevel);
|
|
435
|
+
let usage: ReturnType<ExtensionContext["getContextUsage"]>;
|
|
436
|
+
try {
|
|
437
|
+
usage = ctx.getContextUsage();
|
|
438
|
+
} catch {
|
|
439
|
+
usage = undefined;
|
|
440
|
+
}
|
|
441
|
+
const contextWindow = usage?.contextWindow ?? model?.contextWindow ?? 128_000;
|
|
442
|
+
const context = formatContextProgress(usage?.tokens ?? null, contextWindow, theme);
|
|
443
|
+
const branch = footerData.getGitBranch();
|
|
444
|
+
const signature = formatModel(model, theme, true, isCodexFastEnabled());
|
|
445
|
+
const fullDirectory = colorDirectory(cwd);
|
|
446
|
+
const focusedDirectory = colorDirectory(compactDirectory(cwd));
|
|
447
|
+
const goal = formatGoalFooter(goalRuntime.state, theme);
|
|
448
|
+
const primary = joinFooterParts([
|
|
449
|
+
signature,
|
|
450
|
+
level,
|
|
451
|
+
context,
|
|
452
|
+
], theme);
|
|
453
|
+
const primaryFocused = joinFooterParts([signature, context], theme);
|
|
454
|
+
const session = joinFooterParts([
|
|
455
|
+
theme.fg("dim", formatTime(Date.now() - sessionStart)),
|
|
456
|
+
theme.fg("dim", formatCost(getSessionCost(ctx))),
|
|
457
|
+
], theme);
|
|
458
|
+
const essentialModel = formatModel(model, theme, false, isCodexFastEnabled());
|
|
459
|
+
const primaryRow = footerRowFits(primary, session, width)
|
|
460
|
+
? renderFooterRow(primary, session, width)
|
|
461
|
+
: footerRowFits(primary, "", width)
|
|
462
|
+
? renderFooterRow(primary, "", width)
|
|
463
|
+
: footerRowFits(primaryFocused, "", width)
|
|
464
|
+
? renderFooterRow(primaryFocused, "", width)
|
|
465
|
+
: renderFooterRow(essentialModel, context, width);
|
|
466
|
+
const changes = gitFileChanges ? formatGitFileChanges(gitFileChanges, theme) : "";
|
|
467
|
+
const branchLabel = branch
|
|
468
|
+
? `${theme.fg("dim", branch)}${changes ? `${theme.fg("dim", " · ")}${changes}` : ""}`
|
|
469
|
+
: "";
|
|
470
|
+
const workspaceRight = goal || fullDirectory;
|
|
471
|
+
const secondaryRow = footerRowFits(branchLabel, workspaceRight, width)
|
|
472
|
+
? renderFooterRow(branchLabel, workspaceRight, width)
|
|
473
|
+
: goal
|
|
474
|
+
? renderFooterRow("", goal, width)
|
|
475
|
+
: footerRowFits(branchLabel, focusedDirectory, width)
|
|
476
|
+
? renderFooterRow(branchLabel, focusedDirectory, width)
|
|
477
|
+
: renderFooterRow(branchLabel, "", width);
|
|
478
|
+
return renderFooter([primaryRow, secondaryRow], width, theme);
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
pi.on("model_select", (event) => {
|
|
485
|
+
currentModel = event.model;
|
|
486
|
+
activeTui?.requestRender();
|
|
487
|
+
});
|
|
488
|
+
pi.on("thinking_level_select", (event) => {
|
|
489
|
+
thinkingLevel = event.level;
|
|
490
|
+
activeTui?.requestRender();
|
|
491
|
+
});
|
|
492
|
+
pi.on("turn_end", invalidateSessionCost);
|
|
493
|
+
pi.on("session_compact", invalidateSessionCost);
|
|
494
|
+
pi.on("session_tree", () => {
|
|
495
|
+
resetSessionCost();
|
|
496
|
+
activeTui?.requestRender();
|
|
497
|
+
});
|
|
498
|
+
pi.on("session_shutdown", () => {
|
|
499
|
+
unsubscribeCodexFast?.();
|
|
500
|
+
unsubscribeCodexFast = undefined;
|
|
501
|
+
resetSessionCost();
|
|
502
|
+
activeTui = undefined;
|
|
503
|
+
goalRuntime.requestRender = undefined;
|
|
504
|
+
});
|
|
505
|
+
}
|