killeros 1.0.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/Killeros.ts +1265 -0
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/package.json +49 -0
package/Killeros.ts
ADDED
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import {
|
|
3
|
+
CustomEditor,
|
|
4
|
+
DynamicBorder,
|
|
5
|
+
VERSION,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
type ExtensionCommandContext,
|
|
8
|
+
type ExtensionContext,
|
|
9
|
+
type KeybindingsManager,
|
|
10
|
+
type Theme,
|
|
11
|
+
type ThemeColor,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
Container,
|
|
15
|
+
Editor,
|
|
16
|
+
Key,
|
|
17
|
+
matchesKey,
|
|
18
|
+
SelectList,
|
|
19
|
+
Text,
|
|
20
|
+
truncateToWidth,
|
|
21
|
+
visibleWidth,
|
|
22
|
+
wrapTextWithAnsi,
|
|
23
|
+
type AutocompleteItem,
|
|
24
|
+
type EditorTheme,
|
|
25
|
+
type TUI,
|
|
26
|
+
} from "@earendil-works/pi-tui";
|
|
27
|
+
import { Type } from "typebox";
|
|
28
|
+
|
|
29
|
+
const BRAND_RGB = "215;119;87";
|
|
30
|
+
const LEFT_PANEL_WIDTH = 42;
|
|
31
|
+
const LOGO_CELL = "███";
|
|
32
|
+
const LOGO_ANIMATION_INTERVAL_MS = 120;
|
|
33
|
+
const TIP_ROTATION_INTERVAL_MS = 5_000;
|
|
34
|
+
const FOOTER_REFRESH_INTERVAL_MS = 1_000;
|
|
35
|
+
|
|
36
|
+
const brand = (text: string): string => `\x1B[38;2;${BRAND_RGB}m${text}\x1B[39m`;
|
|
37
|
+
|
|
38
|
+
interface LogoFrame {
|
|
39
|
+
phase: number;
|
|
40
|
+
active: "left" | "top" | "right" | "none";
|
|
41
|
+
ax: number;
|
|
42
|
+
ay: number;
|
|
43
|
+
flash: boolean;
|
|
44
|
+
white: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const LOGO_FRAMES: LogoFrame[] = [
|
|
48
|
+
...Array.from({ length: 4 }, (_, ay): LogoFrame => ({ phase: 0, active: "left", ax: 2, ay, flash: false, white: false })),
|
|
49
|
+
...Array.from({ length: 3 }, (_, ay): LogoFrame => ({ phase: 1, active: "top", ax: 2, ay, flash: false, white: false })),
|
|
50
|
+
...Array.from({ length: 5 }, (_, ay): LogoFrame => ({ phase: 2, active: "right", ax: 5, ay, flash: false, white: false })),
|
|
51
|
+
{ phase: 3, active: "none", ax: 0, ay: 0, flash: false, white: false },
|
|
52
|
+
{ phase: 3, active: "none", ax: 0, ay: 0, flash: true, white: false },
|
|
53
|
+
{ phase: 3, active: "none", ax: 0, ay: 0, flash: false, white: false },
|
|
54
|
+
{ phase: 3, active: "none", ax: 0, ay: 0, flash: true, white: false },
|
|
55
|
+
{ phase: 4, active: "none", ax: 0, ay: 0, flash: false, white: false },
|
|
56
|
+
{ phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: false },
|
|
57
|
+
{ phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: true },
|
|
58
|
+
{ phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: false },
|
|
59
|
+
{ phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: true },
|
|
60
|
+
{ phase: 6, active: "none", ax: 0, ay: 0, flash: false, white: false },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function extractProvider(model: ExtensionContext["model"]): string {
|
|
64
|
+
return model?.provider ?? "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatCwd(cwd: string): string {
|
|
68
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
69
|
+
if (!home) return cwd;
|
|
70
|
+
const normalizedHome = home.replace(/[\\/]+$/, "");
|
|
71
|
+
const normalizedCwd = cwd.replace(/[\\/]+$/, "");
|
|
72
|
+
if (normalizedCwd === normalizedHome) return "~";
|
|
73
|
+
const separator = normalizedCwd.slice(normalizedHome.length, normalizedHome.length + 1);
|
|
74
|
+
return normalizedCwd.startsWith(normalizedHome) && (separator === "/" || separator === "\\")
|
|
75
|
+
? `~${normalizedCwd.slice(normalizedHome.length)}`
|
|
76
|
+
: cwd;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function center(text: string, width: number): string {
|
|
80
|
+
if (width <= 0) return "";
|
|
81
|
+
const textWidth = visibleWidth(text);
|
|
82
|
+
if (textWidth >= width) return truncateToWidth(text, width, "");
|
|
83
|
+
return `${" ".repeat(Math.floor((width - textWidth) / 2))}${text}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function padRight(text: string, width: number): string {
|
|
87
|
+
if (width <= 0) return "";
|
|
88
|
+
const clipped = truncateToWidth(text, width, "");
|
|
89
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hasCell(y: number, x: number, cells: string): boolean {
|
|
93
|
+
return cells.split(" ").includes(`${y},${x}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hasPiece(y: number, x: number, py: number, px: number, cells: string): boolean {
|
|
97
|
+
return cells.split(" ").some((item) => {
|
|
98
|
+
const [dy, dx] = item.split(",").map(Number);
|
|
99
|
+
return y === py + dy && x === px + dx;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type LogoColor = "cyan" | "red" | "green" | "orange" | "flash" | "white" | "brand" | "panel";
|
|
104
|
+
|
|
105
|
+
function colorCell(color: LogoColor): string {
|
|
106
|
+
switch (color) {
|
|
107
|
+
case "cyan": return `\x1B[36m${LOGO_CELL}\x1B[39m`;
|
|
108
|
+
case "red": return `\x1B[31m${LOGO_CELL}\x1B[39m`;
|
|
109
|
+
case "green": return `\x1B[32m${LOGO_CELL}\x1B[39m`;
|
|
110
|
+
case "orange":
|
|
111
|
+
case "flash": return `\x1B[33m${LOGO_CELL}\x1B[39m`;
|
|
112
|
+
case "white": return `\x1B[97m${LOGO_CELL}\x1B[39m`;
|
|
113
|
+
case "brand": return brand(LOGO_CELL);
|
|
114
|
+
default: return " ".repeat(LOGO_CELL.length);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function logoCellColor(frame: LogoFrame, y: number, x: number): LogoColor {
|
|
119
|
+
if (frame.white) {
|
|
120
|
+
return hasCell(y, x, "3,2 3,3 3,4 4,2 4,4 5,2 5,3 5,5 6,2 6,5") ? "white" : "panel";
|
|
121
|
+
}
|
|
122
|
+
if (frame.flash && y === 6 && x >= 1 && x <= 6) return "flash";
|
|
123
|
+
if (frame.active === "left" && hasPiece(y, x, frame.ay, frame.ax, "0,0 1,0 1,1 2,0")) return "red";
|
|
124
|
+
if (frame.active === "top" && hasPiece(y, x, frame.ay, frame.ax, "0,0 0,1 0,2 1,2")) return "cyan";
|
|
125
|
+
if (frame.active === "right" && hasPiece(y, x, frame.ay, frame.ax, "0,0 1,0 2,0 2,1")) return "green";
|
|
126
|
+
if (frame.phase === 6) {
|
|
127
|
+
return hasCell(y, x, "3,2 3,3 3,4 4,4 4,2 5,2 5,3 5,5 6,2 6,5") ? "brand" : "panel";
|
|
128
|
+
}
|
|
129
|
+
if (frame.phase === 4) {
|
|
130
|
+
if (hasCell(y, x, "2,2 2,3 2,4 3,4")) return "cyan";
|
|
131
|
+
if (hasCell(y, x, "3,2 4,2 4,3 5,2")) return "red";
|
|
132
|
+
if (hasCell(y, x, "4,5 5,5")) return "green";
|
|
133
|
+
return "panel";
|
|
134
|
+
}
|
|
135
|
+
if (frame.phase >= 5) {
|
|
136
|
+
if (hasCell(y, x, "3,2 3,3 3,4 4,4")) return "cyan";
|
|
137
|
+
if (hasCell(y, x, "4,2 5,2 5,3 6,2")) return "red";
|
|
138
|
+
if (hasCell(y, x, "5,5 6,5")) return "green";
|
|
139
|
+
return "panel";
|
|
140
|
+
}
|
|
141
|
+
if (frame.phase <= 3 && hasCell(y, x, "6,1 6,2 6,3 6,4")) return "orange";
|
|
142
|
+
if (frame.phase >= 2 && hasCell(y, x, "2,2 2,3 2,4 3,4")) return "cyan";
|
|
143
|
+
if (frame.phase >= 1 && hasCell(y, x, "3,2 4,2 4,3 5,2")) return "red";
|
|
144
|
+
if (frame.phase >= 3 && hasCell(y, x, "4,5 5,5 6,5 6,6")) return "green";
|
|
145
|
+
return "panel";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function piLogoFrame(frameIndex: number): string[] {
|
|
149
|
+
const frame = LOGO_FRAMES[frameIndex % LOGO_FRAMES.length]!;
|
|
150
|
+
const lines: string[] = [];
|
|
151
|
+
for (let y = 1; y <= 7; y += 1) {
|
|
152
|
+
let line = "";
|
|
153
|
+
for (let x = 1; x <= 8; x += 1) line += colorCell(logoCellColor(frame, y, x));
|
|
154
|
+
lines.push(line);
|
|
155
|
+
}
|
|
156
|
+
return lines;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function borderLine(left: string, label: string, right: string, width: number): string {
|
|
160
|
+
if (width <= 0) return "";
|
|
161
|
+
if (width === 1) return brand(truncateToWidth(left, 1, ""));
|
|
162
|
+
const edgeWidth = visibleWidth(left) + visibleWidth(right);
|
|
163
|
+
if (width <= edgeWidth) return brand(truncateToWidth(left + right, width, ""));
|
|
164
|
+
const available = width - edgeWidth;
|
|
165
|
+
const clippedLabel = truncateToWidth(label, Math.max(0, available - 2), "");
|
|
166
|
+
const labelWidth = visibleWidth(clippedLabel);
|
|
167
|
+
if (labelWidth === 0 || available < labelWidth + 2) {
|
|
168
|
+
return `${brand(left)}${brand("─".repeat(available))}${brand(right)}`;
|
|
169
|
+
}
|
|
170
|
+
const fill = available - labelWidth - 2;
|
|
171
|
+
const before = Math.min(3, fill);
|
|
172
|
+
const after = fill - before;
|
|
173
|
+
return `${brand(left)}${brand("─".repeat(before))} ${clippedLabel} ${brand("─".repeat(after))}${brand(right)}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function boxedLine(content: string, width: number): string {
|
|
177
|
+
if (width <= 0) return "";
|
|
178
|
+
if (width <= 2) return truncateToWidth(content, width, "");
|
|
179
|
+
return `${brand("│")}${padRight(content, width - 2)}${brand("│")}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function twoColumn(left: string, right: string, leftWidth: number, rightWidth: number): string {
|
|
183
|
+
return `${padRight(left, leftWidth)} ${brand("│")} ${padRight(right, rightWidth)}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const TIP_SETS = [
|
|
187
|
+
[
|
|
188
|
+
"",
|
|
189
|
+
"Shortcuts & Commands",
|
|
190
|
+
"/variants — model reasoning",
|
|
191
|
+
"/compact — compress context",
|
|
192
|
+
"/model — choose a model",
|
|
193
|
+
"────────────────────────",
|
|
194
|
+
"Keybindings",
|
|
195
|
+
"Shift+Enter — new line",
|
|
196
|
+
"Esc — cancel generation",
|
|
197
|
+
"Ctrl+C — interrupt agent",
|
|
198
|
+
],
|
|
199
|
+
[
|
|
200
|
+
"",
|
|
201
|
+
"Session",
|
|
202
|
+
"/new — start a session",
|
|
203
|
+
"/name — name this session",
|
|
204
|
+
"/session — usage and stats",
|
|
205
|
+
"────────────────────────",
|
|
206
|
+
"Workflow",
|
|
207
|
+
"Give Pi a goal and constraints",
|
|
208
|
+
"Ask it to run the relevant tests",
|
|
209
|
+
"Review changes before committing",
|
|
210
|
+
],
|
|
211
|
+
[
|
|
212
|
+
"",
|
|
213
|
+
"Useful Commands",
|
|
214
|
+
"/copy — copy last response",
|
|
215
|
+
"/tree — navigate branches",
|
|
216
|
+
"/reload — reload resources",
|
|
217
|
+
"────────────────────────",
|
|
218
|
+
"Extension locations",
|
|
219
|
+
"Global: ~/.pi/agent/extensions",
|
|
220
|
+
"Project: .pi/extensions",
|
|
221
|
+
"Reload after making changes",
|
|
222
|
+
],
|
|
223
|
+
[
|
|
224
|
+
"",
|
|
225
|
+
"Navigation",
|
|
226
|
+
"Up/Down — command history",
|
|
227
|
+
"Tab — autocomplete",
|
|
228
|
+
"Ctrl+L — clear the screen",
|
|
229
|
+
"────────────────────────",
|
|
230
|
+
"Good defaults",
|
|
231
|
+
"Keep edits scoped",
|
|
232
|
+
"Test after refactoring",
|
|
233
|
+
"Verify output before committing",
|
|
234
|
+
],
|
|
235
|
+
] as const;
|
|
236
|
+
|
|
237
|
+
function getTipLines(index: number, theme: Theme): string[] {
|
|
238
|
+
const selected = TIP_SETS[index % TIP_SETS.length] ?? TIP_SETS[0];
|
|
239
|
+
return selected.map((line, lineIndex) => {
|
|
240
|
+
if (lineIndex === 1 || lineIndex === 6) return brand(theme.bold(line));
|
|
241
|
+
if (line.startsWith("─")) return brand(line);
|
|
242
|
+
if (line.startsWith("/")) {
|
|
243
|
+
const [command, ...rest] = line.split(" ");
|
|
244
|
+
return `${theme.fg("accent", command ?? "")}${theme.fg("dim", ` ${rest.join(" ")}`)}`;
|
|
245
|
+
}
|
|
246
|
+
return theme.fg(lineIndex > 6 ? "muted" : "dim", line);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
class PiStartupHeader {
|
|
251
|
+
private frame = 0;
|
|
252
|
+
private tipIndex = 0;
|
|
253
|
+
private animationTimer?: ReturnType<typeof setInterval>;
|
|
254
|
+
private tipTimer?: ReturnType<typeof setInterval>;
|
|
255
|
+
private disposed = false;
|
|
256
|
+
|
|
257
|
+
constructor(
|
|
258
|
+
private readonly pi: ExtensionAPI,
|
|
259
|
+
private readonly ctx: ExtensionContext,
|
|
260
|
+
private readonly tui: TUI,
|
|
261
|
+
) {
|
|
262
|
+
this.animationTimer = setInterval(() => {
|
|
263
|
+
if (this.disposed) return;
|
|
264
|
+
if (this.frame >= LOGO_FRAMES.length - 1) {
|
|
265
|
+
this.stopAnimation();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
this.frame += 1;
|
|
269
|
+
this.tui.requestRender();
|
|
270
|
+
if (this.frame >= LOGO_FRAMES.length - 1) this.stopAnimation();
|
|
271
|
+
}, LOGO_ANIMATION_INTERVAL_MS);
|
|
272
|
+
this.animationTimer.unref?.();
|
|
273
|
+
|
|
274
|
+
this.tipTimer = setInterval(() => {
|
|
275
|
+
if (this.disposed) return;
|
|
276
|
+
this.tipIndex = (this.tipIndex + 1) % TIP_SETS.length;
|
|
277
|
+
this.tui.requestRender();
|
|
278
|
+
}, TIP_ROTATION_INTERVAL_MS);
|
|
279
|
+
this.tipTimer.unref?.();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private stopAnimation(): void {
|
|
283
|
+
if (!this.animationTimer) return;
|
|
284
|
+
clearInterval(this.animationTimer);
|
|
285
|
+
this.animationTimer = undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
render(width: number): string[] {
|
|
289
|
+
if (width <= 0) return [];
|
|
290
|
+
const theme = this.ctx.ui.theme;
|
|
291
|
+
if (width < 16) return [truncateToWidth(theme.fg("accent", `Pi v${VERSION}`), width, "")];
|
|
292
|
+
|
|
293
|
+
const innerWidth = width - 2;
|
|
294
|
+
const isTwoColumn = innerWidth >= 64;
|
|
295
|
+
const provider = extractProvider(this.ctx.model);
|
|
296
|
+
const rawModel = this.ctx.model?.id ?? this.ctx.model?.name;
|
|
297
|
+
const model = rawModel
|
|
298
|
+
? provider && !rawModel.startsWith(`${provider}/`) ? `${provider}/${rawModel}` : rawModel
|
|
299
|
+
: "Default model";
|
|
300
|
+
const effort = this.pi.getThinkingLevel();
|
|
301
|
+
const cwd = formatCwd(this.ctx.cwd);
|
|
302
|
+
const leftWidth = isTwoColumn ? Math.min(LEFT_PANEL_WIDTH, Math.floor(innerWidth * 0.55)) : innerWidth;
|
|
303
|
+
const rightWidth = isTwoColumn ? innerWidth - leftWidth - 3 : 0;
|
|
304
|
+
const logoLines = leftWidth >= 24
|
|
305
|
+
? piLogoFrame(this.frame).map((line) => center(line, leftWidth))
|
|
306
|
+
: ["", "", center(brand(theme.bold("Pi Coding Agent")), leftWidth), "", "", "", ""];
|
|
307
|
+
const modelText = leftWidth < 34 ? `${model} (${effort})` : `${model} with ${effort} effort`;
|
|
308
|
+
const leftLines = [
|
|
309
|
+
...logoLines,
|
|
310
|
+
center(theme.bold("Let's build something great"), leftWidth),
|
|
311
|
+
center(theme.fg("muted", truncateToWidth(modelText, leftWidth, "…")), leftWidth),
|
|
312
|
+
center(theme.fg("dim", truncateToWidth(cwd, leftWidth, "…")), leftWidth),
|
|
313
|
+
];
|
|
314
|
+
const tipLines = isTwoColumn ? getTipLines(this.tipIndex, theme) : [];
|
|
315
|
+
const lines = [borderLine("╭", `${brand("Pi")} v${VERSION}`, "╮", width)];
|
|
316
|
+
for (let index = 0; index < leftLines.length; index += 1) {
|
|
317
|
+
const content = isTwoColumn
|
|
318
|
+
? twoColumn(leftLines[index] ?? "", tipLines[index] ?? "", leftWidth, rightWidth)
|
|
319
|
+
: padRight(leftLines[index] ?? "", leftWidth);
|
|
320
|
+
lines.push(boxedLine(content, width));
|
|
321
|
+
}
|
|
322
|
+
lines.push(borderLine("╰", "", "╯", width));
|
|
323
|
+
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
invalidate(): void {}
|
|
327
|
+
|
|
328
|
+
dispose(): void {
|
|
329
|
+
if (this.disposed) return;
|
|
330
|
+
this.disposed = true;
|
|
331
|
+
this.stopAnimation();
|
|
332
|
+
if (this.tipTimer) {
|
|
333
|
+
clearInterval(this.tipTimer);
|
|
334
|
+
this.tipTimer = undefined;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
340
|
+
|
|
341
|
+
function stripAnsi(text: string): string {
|
|
342
|
+
return text.replace(ANSI_REGEX, "").trim();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function isBorderLine(line: string): boolean {
|
|
346
|
+
const unstyled = stripAnsi(line);
|
|
347
|
+
return /^[─━═]+$/.test(unstyled) || /^───\s*[↓↑]/.test(unstyled) || /^─{3,}/.test(unstyled);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
class PiCodeEditor extends CustomEditor {
|
|
351
|
+
constructor(tui: TUI, theme: EditorTheme, private readonly appKeybindings: KeybindingsManager) {
|
|
352
|
+
super(tui, theme, appKeybindings);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
override handleInput(data: string): void {
|
|
356
|
+
const isShiftEnter = data === "\x1B[13;2u"
|
|
357
|
+
|| data === "\x1B[13;2~"
|
|
358
|
+
|| data === "\x1B[27;2;13~"
|
|
359
|
+
|| data === "\x1B\r"
|
|
360
|
+
|| data === "\x1B\n"
|
|
361
|
+
|| this.appKeybindings.matches(data, "tui.input.newLine");
|
|
362
|
+
if (isShiftEnter) {
|
|
363
|
+
this.insertTextAtCursor("\n");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
super.handleInput(data);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
override render(width: number): string[] {
|
|
370
|
+
if (width < 4) return super.render(width);
|
|
371
|
+
const innerWidth = width - 2;
|
|
372
|
+
const lines = super.render(innerWidth);
|
|
373
|
+
if (lines.length < 2) return lines.map((line) => truncateToWidth(line, width, ""));
|
|
374
|
+
|
|
375
|
+
const gray = (text: string): string => `\x1B[90m${text}\x1B[39m`;
|
|
376
|
+
let bottomBorderIndex = -1;
|
|
377
|
+
for (let index = lines.length - 1; index >= 1; index -= 1) {
|
|
378
|
+
if (isBorderLine(lines[index] ?? "")) {
|
|
379
|
+
bottomBorderIndex = index;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (bottomBorderIndex < 0) bottomBorderIndex = lines.length - 1;
|
|
384
|
+
|
|
385
|
+
const framed: string[] = [];
|
|
386
|
+
const top = stripAnsi(lines[0] ?? "");
|
|
387
|
+
const isScrolledHeader = top.includes("↑");
|
|
388
|
+
if (isScrolledHeader) {
|
|
389
|
+
const count = top.match(/↑\s*(\d+)/)?.[1] ?? "";
|
|
390
|
+
const indicator = `${gray("─── ↑ ")}${count}${gray(" more ")}${gray("─".repeat(Math.max(0, width - 12 - count.length)))}`;
|
|
391
|
+
framed.push(truncateToWidth(indicator, width, ""));
|
|
392
|
+
} else {
|
|
393
|
+
framed.push(gray("─".repeat(width)));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
for (let index = 1; index < bottomBorderIndex; index += 1) {
|
|
397
|
+
const prefix = index === 1 && !isScrolledHeader ? gray("❯ ") : " ";
|
|
398
|
+
framed.push(`${prefix}${padRight(lines[index] ?? "", innerWidth)}`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const bottom = stripAnsi(lines[bottomBorderIndex] ?? "");
|
|
402
|
+
if (bottom.includes("↓")) {
|
|
403
|
+
const count = bottom.match(/↓\s*(\d+)/)?.[1] ?? "";
|
|
404
|
+
const indicator = `${gray("─── ↓ ")}${count}${gray(" more ")}${gray("─".repeat(Math.max(0, width - 12 - count.length)))}`;
|
|
405
|
+
framed.push(truncateToWidth(indicator, width, ""));
|
|
406
|
+
} else {
|
|
407
|
+
framed.push(gray("─".repeat(width)));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
for (let index = bottomBorderIndex + 1; index < lines.length; index += 1) {
|
|
411
|
+
framed.push(` ${padRight(lines[index] ?? "", innerWidth)}`);
|
|
412
|
+
}
|
|
413
|
+
return framed.map((line) => truncateToWidth(line, width, ""));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function reportError(ctx: ExtensionContext, area: string, error: unknown): void {
|
|
418
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
419
|
+
ctx.ui.notify(`${area}: ${message}`, "error");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function registerShellUi(pi: ExtensionAPI): void {
|
|
423
|
+
let activeHeader: PiStartupHeader | undefined;
|
|
424
|
+
|
|
425
|
+
pi.on("session_start", (_event, ctx) => {
|
|
426
|
+
if (ctx.mode !== "tui") return;
|
|
427
|
+
try {
|
|
428
|
+
ctx.ui.setHeader((tui) => {
|
|
429
|
+
activeHeader?.dispose();
|
|
430
|
+
activeHeader = new PiStartupHeader(pi, ctx, tui);
|
|
431
|
+
return activeHeader;
|
|
432
|
+
});
|
|
433
|
+
ctx.ui.setWorkingIndicator({
|
|
434
|
+
frames: ["◐", "◓", "◑", "◒"].map((frame) => ctx.ui.theme.fg("accent", frame)),
|
|
435
|
+
intervalMs: 120,
|
|
436
|
+
});
|
|
437
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => new PiCodeEditor(tui, theme, keybindings));
|
|
438
|
+
} catch (error) {
|
|
439
|
+
reportError(ctx, "Killeros UI failed to initialize", error);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
pi.on("session_shutdown", () => {
|
|
444
|
+
activeHeader?.dispose();
|
|
445
|
+
activeHeader = undefined;
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export const CONCISE_SYSTEM_PROMPT = `
|
|
450
|
+
# Concise output rules
|
|
451
|
+
1. Start with the answer or next action; omit conversational preambles.
|
|
452
|
+
2. Use numbered steps only when order matters, with one bounded action per step.
|
|
453
|
+
3. Finish the primary task before mentioning optional follow-up work.
|
|
454
|
+
4. State failures directly and include the recovery action.
|
|
455
|
+
5. Keep lists focused; group long inventories under clear headings.
|
|
456
|
+
6. Do not invent time estimates, completion claims, or facts.
|
|
457
|
+
7. Preserve exact code, commands, paths, quoted text, warnings, and user-requested formats.
|
|
458
|
+
8. Omit recap sections and generic closing pleasantries.
|
|
459
|
+
`.trim();
|
|
460
|
+
|
|
461
|
+
export function isConcisedEnabled(): boolean {
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function registerConcisePrompt(pi: ExtensionAPI): void {
|
|
466
|
+
pi.on("before_agent_start", (event) => ({
|
|
467
|
+
systemPrompt: `${event.systemPrompt}\n\n${CONCISE_SYSTEM_PROMPT}`,
|
|
468
|
+
}));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const OptionSchema = Type.Object({
|
|
472
|
+
label: Type.String({ minLength: 1, maxLength: 200, description: "Display label for the option" }),
|
|
473
|
+
description: Type.Optional(Type.String({ maxLength: 500, description: "Optional detail shown for the selected option" })),
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
const QuestionParams = Type.Object({
|
|
477
|
+
question: Type.String({ minLength: 1, maxLength: 1_000, description: "The question to ask the user" }),
|
|
478
|
+
options: Type.Array(OptionSchema, {
|
|
479
|
+
minItems: 1,
|
|
480
|
+
maxItems: 9,
|
|
481
|
+
description: "Between 1 and 9 options for the user to choose from",
|
|
482
|
+
}),
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
interface DisplayOption {
|
|
486
|
+
label: string;
|
|
487
|
+
description?: string;
|
|
488
|
+
originalIndex: number;
|
|
489
|
+
isOther: boolean;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
interface QuestionDetails {
|
|
493
|
+
question: string;
|
|
494
|
+
options: string[];
|
|
495
|
+
answer: string | null;
|
|
496
|
+
selectedIndex?: number;
|
|
497
|
+
wasCustom?: boolean;
|
|
498
|
+
cancelled?: boolean;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
type QuestionSelection =
|
|
502
|
+
| { kind: "selected"; answer: string; originalIndex: number }
|
|
503
|
+
| { kind: "custom"; answer: string }
|
|
504
|
+
| { kind: "cancelled" }
|
|
505
|
+
| { kind: "aborted" };
|
|
506
|
+
|
|
507
|
+
const customInputHistory: string[] = [];
|
|
508
|
+
|
|
509
|
+
function rememberCustomInput(value: string): void {
|
|
510
|
+
const existingIndex = customInputHistory.indexOf(value);
|
|
511
|
+
if (existingIndex >= 0) customInputHistory.splice(existingIndex, 1);
|
|
512
|
+
customInputHistory.push(value);
|
|
513
|
+
if (customInputHistory.length > 100) customInputHistory.shift();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function isPrintableInput(data: string): boolean {
|
|
517
|
+
return data.length > 0 && !/[\u0000-\u001F\u007F]/u.test(data);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function registerQuestionTool(pi: ExtensionAPI): void {
|
|
521
|
+
pi.registerTool<typeof QuestionParams, QuestionDetails>({
|
|
522
|
+
name: "question",
|
|
523
|
+
label: "Question",
|
|
524
|
+
description: "Ask one interactive multiple-choice question. Provide 1-9 concise options. The user can filter options or type a custom answer.",
|
|
525
|
+
promptSnippet: "Ask the user one multiple-choice question when a decision is required to proceed",
|
|
526
|
+
promptGuidelines: [
|
|
527
|
+
"Use question only when user input is required to choose between concrete alternatives; do not use question for rhetorical or optional follow-up prompts.",
|
|
528
|
+
],
|
|
529
|
+
parameters: QuestionParams,
|
|
530
|
+
executionMode: "sequential",
|
|
531
|
+
|
|
532
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
533
|
+
if (ctx.mode !== "tui") throw new Error("The question tool requires interactive TUI mode");
|
|
534
|
+
if (signal?.aborted) throw new Error("Question cancelled before it opened");
|
|
535
|
+
|
|
536
|
+
const options: DisplayOption[] = [
|
|
537
|
+
...params.options.map((option, index) => ({
|
|
538
|
+
label: option.label,
|
|
539
|
+
description: option.description,
|
|
540
|
+
originalIndex: index + 1,
|
|
541
|
+
isOther: false,
|
|
542
|
+
})),
|
|
543
|
+
{
|
|
544
|
+
label: "Type a custom answer",
|
|
545
|
+
originalIndex: params.options.length + 1,
|
|
546
|
+
isOther: true,
|
|
547
|
+
},
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
let finishFromAbort: (() => void) | undefined;
|
|
551
|
+
const resultPromise = ctx.ui.custom<QuestionSelection>((tui, theme, _keybindings, done) => {
|
|
552
|
+
let optionIndex = 0;
|
|
553
|
+
let editMode = false;
|
|
554
|
+
let filterQuery = "";
|
|
555
|
+
let historyIndex = -1;
|
|
556
|
+
let savedDraft = "";
|
|
557
|
+
let cachedWidth: number | undefined;
|
|
558
|
+
let cachedLines: string[] | undefined;
|
|
559
|
+
let completed = false;
|
|
560
|
+
|
|
561
|
+
const finish = (selection: QuestionSelection): void => {
|
|
562
|
+
if (completed) return;
|
|
563
|
+
completed = true;
|
|
564
|
+
done(selection);
|
|
565
|
+
};
|
|
566
|
+
finishFromAbort = () => finish({ kind: "aborted" });
|
|
567
|
+
|
|
568
|
+
const editorTheme: EditorTheme = {
|
|
569
|
+
borderColor: (text) => theme.fg("accent", text),
|
|
570
|
+
selectList: {
|
|
571
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
572
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
573
|
+
description: (text) => theme.fg("muted", text),
|
|
574
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
575
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
const editor = new Editor(tui, editorTheme);
|
|
579
|
+
|
|
580
|
+
const filteredOptions = (): DisplayOption[] => {
|
|
581
|
+
const query = filterQuery.trim().toLocaleLowerCase();
|
|
582
|
+
return options.filter((option) => option.isOther
|
|
583
|
+
|| query.length === 0
|
|
584
|
+
|| option.label.toLocaleLowerCase().includes(query)
|
|
585
|
+
|| option.description?.toLocaleLowerCase().includes(query));
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const invalidate = (): void => {
|
|
589
|
+
cachedWidth = undefined;
|
|
590
|
+
cachedLines = undefined;
|
|
591
|
+
editor.invalidate();
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const refresh = (): void => {
|
|
595
|
+
invalidate();
|
|
596
|
+
tui.requestRender();
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
editor.onSubmit = (value) => {
|
|
600
|
+
const answer = value.trim();
|
|
601
|
+
if (answer) {
|
|
602
|
+
rememberCustomInput(answer);
|
|
603
|
+
finish({ kind: "custom", answer });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
editMode = false;
|
|
607
|
+
editor.setText("");
|
|
608
|
+
historyIndex = -1;
|
|
609
|
+
savedDraft = "";
|
|
610
|
+
refresh();
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const enterCustomMode = (): void => {
|
|
614
|
+
editMode = true;
|
|
615
|
+
historyIndex = -1;
|
|
616
|
+
savedDraft = "";
|
|
617
|
+
refresh();
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const handleInput = (data: string): void => {
|
|
621
|
+
if (editMode) {
|
|
622
|
+
if (matchesKey(data, Key.escape)) {
|
|
623
|
+
editMode = false;
|
|
624
|
+
editor.setText("");
|
|
625
|
+
historyIndex = -1;
|
|
626
|
+
savedDraft = "";
|
|
627
|
+
refresh();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (matchesKey(data, Key.up) && customInputHistory.length > 0) {
|
|
631
|
+
if (historyIndex < 0) {
|
|
632
|
+
savedDraft = editor.getText();
|
|
633
|
+
historyIndex = customInputHistory.length - 1;
|
|
634
|
+
} else if (historyIndex > 0) {
|
|
635
|
+
historyIndex -= 1;
|
|
636
|
+
}
|
|
637
|
+
editor.setText(customInputHistory[historyIndex] ?? "");
|
|
638
|
+
refresh();
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (matchesKey(data, Key.down) && historyIndex >= 0) {
|
|
642
|
+
if (historyIndex < customInputHistory.length - 1) {
|
|
643
|
+
historyIndex += 1;
|
|
644
|
+
editor.setText(customInputHistory[historyIndex] ?? "");
|
|
645
|
+
} else {
|
|
646
|
+
historyIndex = -1;
|
|
647
|
+
editor.setText(savedDraft);
|
|
648
|
+
}
|
|
649
|
+
refresh();
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
editor.handleInput(data);
|
|
653
|
+
refresh();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const visibleOptions = filteredOptions();
|
|
658
|
+
if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
|
|
659
|
+
if (matchesKey(data, Key.up)) {
|
|
660
|
+
optionIndex = Math.max(0, optionIndex - 1);
|
|
661
|
+
refresh();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (matchesKey(data, Key.down)) {
|
|
665
|
+
optionIndex = Math.min(visibleOptions.length - 1, optionIndex + 1);
|
|
666
|
+
refresh();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (matchesKey(data, Key.enter)) {
|
|
670
|
+
const selected = visibleOptions[optionIndex];
|
|
671
|
+
if (!selected) return;
|
|
672
|
+
if (selected.isOther) enterCustomMode();
|
|
673
|
+
else finish({ kind: "selected", answer: selected.label, originalIndex: selected.originalIndex });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (matchesKey(data, Key.escape)) {
|
|
677
|
+
if (filterQuery) {
|
|
678
|
+
filterQuery = "";
|
|
679
|
+
optionIndex = 0;
|
|
680
|
+
refresh();
|
|
681
|
+
} else {
|
|
682
|
+
finish({ kind: "cancelled" });
|
|
683
|
+
}
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (matchesKey(data, Key.backspace)) {
|
|
687
|
+
if (filterQuery) {
|
|
688
|
+
filterQuery = Array.from(filterQuery).slice(0, -1).join("");
|
|
689
|
+
optionIndex = 0;
|
|
690
|
+
refresh();
|
|
691
|
+
}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (/^[1-9]$/.test(data)) {
|
|
695
|
+
const selected = visibleOptions[Number(data) - 1];
|
|
696
|
+
if (!selected) return;
|
|
697
|
+
if (selected.isOther) enterCustomMode();
|
|
698
|
+
else finish({ kind: "selected", answer: selected.label, originalIndex: selected.originalIndex });
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (isPrintableInput(data)) {
|
|
702
|
+
filterQuery += data;
|
|
703
|
+
optionIndex = 0;
|
|
704
|
+
refresh();
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
const render = (width: number): string[] => {
|
|
709
|
+
const renderWidth = Math.max(1, width);
|
|
710
|
+
if (cachedLines && cachedWidth === renderWidth) return cachedLines;
|
|
711
|
+
const lines: string[] = [];
|
|
712
|
+
const addWrapped = (text: string): void => {
|
|
713
|
+
lines.push(...wrapTextWithAnsi(text, renderWidth));
|
|
714
|
+
};
|
|
715
|
+
const addWrappedWithPrefix = (prefix: string, text: string): void => {
|
|
716
|
+
const prefixWidth = visibleWidth(prefix);
|
|
717
|
+
if (prefixWidth >= renderWidth) {
|
|
718
|
+
addWrapped(prefix + text);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
|
|
722
|
+
const continuation = " ".repeat(prefixWidth);
|
|
723
|
+
wrapped.forEach((line, index) => lines.push(`${index === 0 ? prefix : continuation}${line}`));
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
|
|
727
|
+
addWrappedWithPrefix(" ", theme.fg("text", params.question));
|
|
728
|
+
lines.push("");
|
|
729
|
+
if (!editMode && filterQuery) {
|
|
730
|
+
addWrappedWithPrefix(" ", `${theme.fg("muted", "Filter: ")}${theme.fg("accent", filterQuery)}`);
|
|
731
|
+
lines.push("");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const visibleOptions = filteredOptions();
|
|
735
|
+
if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
|
|
736
|
+
visibleOptions.forEach((option, index) => {
|
|
737
|
+
const selected = index === optionIndex;
|
|
738
|
+
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
|
739
|
+
const color: ThemeColor = selected ? "accent" : "text";
|
|
740
|
+
addWrappedWithPrefix(prefix, theme.fg(color, `${index + 1}. ${option.label}`));
|
|
741
|
+
if (selected && option.description) {
|
|
742
|
+
addWrappedWithPrefix(" ", theme.fg("muted", option.description));
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
if (editMode) {
|
|
747
|
+
lines.push("");
|
|
748
|
+
addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:"));
|
|
749
|
+
editor.render(Math.max(1, renderWidth - 2)).forEach((line) => lines.push(` ${line}`));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
lines.push("");
|
|
753
|
+
const hint = editMode
|
|
754
|
+
? `Enter submit • Esc options${customInputHistory.length ? " • ↑↓ history" : ""}`
|
|
755
|
+
: filterQuery
|
|
756
|
+
? "1-9 select • ↑↓ navigate • Enter select • Esc clear filter"
|
|
757
|
+
: "1-9 select • type to filter • ↑↓ navigate • Enter select • Esc cancel";
|
|
758
|
+
addWrappedWithPrefix(" ", theme.fg("dim", hint));
|
|
759
|
+
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
|
|
760
|
+
cachedWidth = renderWidth;
|
|
761
|
+
cachedLines = lines.map((line) => truncateToWidth(line, renderWidth, ""));
|
|
762
|
+
return cachedLines;
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
let focused = false;
|
|
766
|
+
return {
|
|
767
|
+
get focused(): boolean { return focused; },
|
|
768
|
+
set focused(value: boolean) {
|
|
769
|
+
focused = value;
|
|
770
|
+
editor.focused = value;
|
|
771
|
+
},
|
|
772
|
+
render,
|
|
773
|
+
handleInput,
|
|
774
|
+
invalidate,
|
|
775
|
+
};
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const abortHandler = (): void => finishFromAbort?.();
|
|
779
|
+
signal?.addEventListener("abort", abortHandler, { once: true });
|
|
780
|
+
if (signal?.aborted) abortHandler();
|
|
781
|
+
let result: QuestionSelection;
|
|
782
|
+
try {
|
|
783
|
+
result = await resultPromise;
|
|
784
|
+
} finally {
|
|
785
|
+
signal?.removeEventListener("abort", abortHandler);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const simpleOptions = params.options.map((option) => option.label);
|
|
789
|
+
if (result.kind === "aborted") throw new Error("Question cancelled because the agent operation was aborted");
|
|
790
|
+
if (result.kind === "cancelled") {
|
|
791
|
+
return {
|
|
792
|
+
content: [{ type: "text", text: "User cancelled the question" }],
|
|
793
|
+
details: { question: params.question, options: simpleOptions, answer: null, cancelled: true },
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
if (result.kind === "custom") {
|
|
797
|
+
return {
|
|
798
|
+
content: [{ type: "text", text: `User wrote: ${result.answer}` }],
|
|
799
|
+
details: { question: params.question, options: simpleOptions, answer: result.answer, wasCustom: true },
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
return {
|
|
803
|
+
content: [{ type: "text", text: `User selected: ${result.answer}` }],
|
|
804
|
+
details: {
|
|
805
|
+
question: params.question,
|
|
806
|
+
options: simpleOptions,
|
|
807
|
+
answer: result.answer,
|
|
808
|
+
selectedIndex: result.originalIndex,
|
|
809
|
+
wasCustom: false,
|
|
810
|
+
},
|
|
811
|
+
};
|
|
812
|
+
},
|
|
813
|
+
|
|
814
|
+
renderCall(args, theme) {
|
|
815
|
+
let text = `${theme.fg("toolTitle", theme.bold("question "))}${theme.fg("muted", args.question)}`;
|
|
816
|
+
if (args.options.length) {
|
|
817
|
+
const numbered = [...args.options.map((option) => option.label), "Type a custom answer"]
|
|
818
|
+
.map((option, index) => `${index + 1}. ${option}`);
|
|
819
|
+
text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
|
|
820
|
+
}
|
|
821
|
+
return new Text(text, 0, 0);
|
|
822
|
+
},
|
|
823
|
+
|
|
824
|
+
renderResult(result, _options, theme) {
|
|
825
|
+
const details = result.details;
|
|
826
|
+
if (!details) {
|
|
827
|
+
const first = result.content[0];
|
|
828
|
+
return new Text(first?.type === "text" ? first.text : "", 0, 0);
|
|
829
|
+
}
|
|
830
|
+
if (details.cancelled || details.answer === null) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
831
|
+
if (details.wasCustom) {
|
|
832
|
+
return new Text(`${theme.fg("success", "✓ ")}${theme.fg("muted", "(wrote) ")}${theme.fg("accent", details.answer)}`, 0, 0);
|
|
833
|
+
}
|
|
834
|
+
return new Text(`${theme.fg("success", "✓ ")}${theme.fg("accent", details.answer)}`, 0, 0);
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function confirmNewSession(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
840
|
+
if (!ctx.hasUI) return true;
|
|
841
|
+
return ctx.ui.confirm("Start new session", "Start a new session and leave the current history?");
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function registerAliases(pi: ExtensionAPI): void {
|
|
845
|
+
const startNewSession = async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
846
|
+
await ctx.waitForIdle();
|
|
847
|
+
if (!await confirmNewSession(ctx)) return;
|
|
848
|
+
await ctx.newSession();
|
|
849
|
+
};
|
|
850
|
+
pi.registerCommand("clear", { description: "Start a new session after confirmation", handler: startNewSession });
|
|
851
|
+
pi.registerCommand("quit", {
|
|
852
|
+
description: "Quit Pi gracefully",
|
|
853
|
+
handler: async (_args, ctx) => ctx.shutdown(),
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
interface CommandInfo {
|
|
858
|
+
name: string;
|
|
859
|
+
description?: string;
|
|
860
|
+
category: "Built-in" | "Extension" | "Prompt" | "Skill";
|
|
861
|
+
syntaxHint?: string;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
|
|
865
|
+
{ name: "settings", description: "Open settings menu" },
|
|
866
|
+
{ name: "model", description: "Select model" },
|
|
867
|
+
{ name: "scoped-models", description: "Configure models for Ctrl+P cycling" },
|
|
868
|
+
{ name: "export", description: "Export the current session" },
|
|
869
|
+
{ name: "import", description: "Import and resume a JSONL session" },
|
|
870
|
+
{ name: "share", description: "Share the session as a secret GitHub gist" },
|
|
871
|
+
{ name: "copy", description: "Copy the last agent message" },
|
|
872
|
+
{ name: "name", description: "Set the session display name" },
|
|
873
|
+
{ name: "session", description: "Show session usage and stats" },
|
|
874
|
+
{ name: "changelog", description: "Show changelog entries" },
|
|
875
|
+
{ name: "hotkeys", description: "Show keyboard shortcuts" },
|
|
876
|
+
{ name: "fork", description: "Fork from a previous user message" },
|
|
877
|
+
{ name: "clone", description: "Duplicate the session at the current position" },
|
|
878
|
+
{ name: "tree", description: "Navigate the session tree" },
|
|
879
|
+
{ name: "trust", description: "Save the project trust decision" },
|
|
880
|
+
{ name: "login", description: "Configure provider authentication" },
|
|
881
|
+
{ name: "logout", description: "Remove provider authentication" },
|
|
882
|
+
{ name: "new", description: "Start a new session" },
|
|
883
|
+
{ name: "compact", description: "Compact the session context" },
|
|
884
|
+
{ name: "resume", description: "Resume a different session" },
|
|
885
|
+
{ name: "reload", description: "Reload extensions and resources" },
|
|
886
|
+
{ name: "quit", description: "Quit Pi" },
|
|
887
|
+
];
|
|
888
|
+
|
|
889
|
+
const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
|
|
890
|
+
variants: "/variants [level]",
|
|
891
|
+
model: "/model [provider/model]",
|
|
892
|
+
"scoped-models": "/scoped-models",
|
|
893
|
+
login: "/login [provider]",
|
|
894
|
+
logout: "/logout [provider]",
|
|
895
|
+
export: "/export [filename]",
|
|
896
|
+
import: "/import [path]",
|
|
897
|
+
name: "/name [session-name]",
|
|
898
|
+
fork: "/fork [name]",
|
|
899
|
+
clone: "/clone [name]",
|
|
900
|
+
resume: "/resume [session-id]",
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
interface TaggedAutocompleteItem extends AutocompleteItem {
|
|
904
|
+
killerosCommand?: string;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function scoreCommandMatch(name: string, prefix: string): number {
|
|
908
|
+
if (!prefix) return 1;
|
|
909
|
+
const normalizedName = name.toLocaleLowerCase();
|
|
910
|
+
const normalizedPrefix = prefix.toLocaleLowerCase();
|
|
911
|
+
if (normalizedName.startsWith(normalizedPrefix)) return 100;
|
|
912
|
+
if (normalizedName.split(/[:\-_]/).some((token) => token.startsWith(normalizedPrefix))) return 80;
|
|
913
|
+
if (normalizedName.includes(normalizedPrefix)) return 50;
|
|
914
|
+
return 0;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function registerSlashAutocomplete(pi: ExtensionAPI): void {
|
|
918
|
+
const usage = new Map<string, number>();
|
|
919
|
+
pi.on("session_start", (_event, ctx) => {
|
|
920
|
+
if (ctx.mode !== "tui") return;
|
|
921
|
+
ctx.ui.addAutocompleteProvider((current) => ({
|
|
922
|
+
triggerCharacters: ["/"],
|
|
923
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
924
|
+
const line = lines[cursorLine] ?? "";
|
|
925
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
926
|
+
const match = beforeCursor.match(/(?:^|[ \t])\/([^\s/]*)$/);
|
|
927
|
+
if (!match) return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
928
|
+
|
|
929
|
+
const prefix = (match[1] ?? "").toLocaleLowerCase();
|
|
930
|
+
const baseSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
931
|
+
const commands = new Map<string, CommandInfo>();
|
|
932
|
+
BUILTIN_COMMANDS.forEach((command) => commands.set(command.name, {
|
|
933
|
+
...command,
|
|
934
|
+
category: "Built-in",
|
|
935
|
+
syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
|
|
936
|
+
}));
|
|
937
|
+
|
|
938
|
+
for (const command of pi.getCommands()) {
|
|
939
|
+
const category: CommandInfo["category"] = command.source === "skill"
|
|
940
|
+
? "Skill"
|
|
941
|
+
: command.source === "prompt"
|
|
942
|
+
? "Prompt"
|
|
943
|
+
: "Extension";
|
|
944
|
+
commands.set(command.name, {
|
|
945
|
+
name: command.name,
|
|
946
|
+
description: command.description,
|
|
947
|
+
category,
|
|
948
|
+
syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
for (const item of baseSuggestions?.items ?? []) {
|
|
953
|
+
const name = (item.value || item.label).replace(/^\//, "").trim().split(/\s+/)[0] ?? "";
|
|
954
|
+
if (name && !commands.has(name)) {
|
|
955
|
+
commands.set(name, { name, description: item.description, category: "Built-in" });
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const ranked = [...commands.values()]
|
|
960
|
+
.map((command) => ({
|
|
961
|
+
command,
|
|
962
|
+
score: scoreCommandMatch(command.name, prefix) + Math.min((usage.get(command.name) ?? 0) * 2, 15),
|
|
963
|
+
}))
|
|
964
|
+
.filter(({ command }) => scoreCommandMatch(command.name, prefix) > 0)
|
|
965
|
+
.sort((left, right) => right.score - left.score || left.command.name.localeCompare(right.command.name));
|
|
966
|
+
if (!ranked.length) return baseSuggestions;
|
|
967
|
+
|
|
968
|
+
return {
|
|
969
|
+
prefix: `/${prefix}`,
|
|
970
|
+
items: ranked.map(({ command }): TaggedAutocompleteItem => {
|
|
971
|
+
const syntax = command.syntaxHint ? `${command.syntaxHint} — ` : "";
|
|
972
|
+
return {
|
|
973
|
+
value: `/${command.name} `,
|
|
974
|
+
label: `/${command.name}`,
|
|
975
|
+
description: `[${command.category}] ${syntax}${command.description ?? ""}`.trim(),
|
|
976
|
+
killerosCommand: command.name,
|
|
977
|
+
};
|
|
978
|
+
}),
|
|
979
|
+
};
|
|
980
|
+
},
|
|
981
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
982
|
+
const tagged = item as TaggedAutocompleteItem;
|
|
983
|
+
if (!tagged.killerosCommand) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
984
|
+
usage.set(tagged.killerosCommand, (usage.get(tagged.killerosCommand) ?? 0) + 1);
|
|
985
|
+
const line = lines[cursorLine] ?? "";
|
|
986
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
987
|
+
let afterCursor = line.slice(cursorCol);
|
|
988
|
+
const match = beforeCursor.match(/(?:^|[ \t])\/([^\s/]*)$/);
|
|
989
|
+
if (!match || match.index === undefined) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
990
|
+
const slashIndex = match.index + (match[0].startsWith("/") ? 0 : 1);
|
|
991
|
+
const newBefore = beforeCursor.slice(0, slashIndex) + item.value;
|
|
992
|
+
if (item.value.endsWith(" ") && afterCursor.startsWith(" ")) afterCursor = afterCursor.trimStart();
|
|
993
|
+
const nextLines = [...lines];
|
|
994
|
+
nextLines[cursorLine] = newBefore + afterCursor;
|
|
995
|
+
return { lines: nextLines, cursorLine, cursorCol: newBefore.length };
|
|
996
|
+
},
|
|
997
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
998
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
999
|
+
},
|
|
1000
|
+
}));
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
1005
|
+
|
|
1006
|
+
const ALL_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
1007
|
+
const LEVEL_LABELS: Readonly<Record<ThinkingLevel, string>> = {
|
|
1008
|
+
off: "Off",
|
|
1009
|
+
minimal: "Minimal",
|
|
1010
|
+
low: "Low",
|
|
1011
|
+
medium: "Medium",
|
|
1012
|
+
high: "High",
|
|
1013
|
+
xhigh: "Extra High",
|
|
1014
|
+
max: "Maximum",
|
|
1015
|
+
};
|
|
1016
|
+
const LEVEL_DESCRIPTIONS: Readonly<Record<ThinkingLevel, string>> = {
|
|
1017
|
+
off: "No extended reasoning",
|
|
1018
|
+
minimal: "Brief reasoning",
|
|
1019
|
+
low: "Light reasoning",
|
|
1020
|
+
medium: "Balanced reasoning",
|
|
1021
|
+
high: "Deep reasoning",
|
|
1022
|
+
xhigh: "Extensive reasoning",
|
|
1023
|
+
max: "Maximum supported reasoning",
|
|
1024
|
+
};
|
|
1025
|
+
const LEVEL_COLORS: Readonly<Record<ThinkingLevel, ThemeColor>> = {
|
|
1026
|
+
off: "thinkingOff",
|
|
1027
|
+
minimal: "thinkingMinimal",
|
|
1028
|
+
low: "thinkingLow",
|
|
1029
|
+
medium: "thinkingMedium",
|
|
1030
|
+
high: "thinkingHigh",
|
|
1031
|
+
xhigh: "thinkingXhigh",
|
|
1032
|
+
max: "thinkingMax",
|
|
1033
|
+
};
|
|
1034
|
+
const LEVEL_ALIASES: Readonly<Record<string, ThinkingLevel>> = {
|
|
1035
|
+
quick: "minimal",
|
|
1036
|
+
fast: "minimal",
|
|
1037
|
+
light: "low",
|
|
1038
|
+
balanced: "medium",
|
|
1039
|
+
deep: "high",
|
|
1040
|
+
maximum: "max",
|
|
1041
|
+
none: "off",
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
function isThinkingLevel(value: string): value is ThinkingLevel {
|
|
1045
|
+
return (ALL_LEVELS as readonly string[]).includes(value);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function resolveThinkingLevel(input: string): ThinkingLevel | undefined {
|
|
1049
|
+
const normalized = input.trim().toLocaleLowerCase();
|
|
1050
|
+
return isThinkingLevel(normalized) ? normalized : LEVEL_ALIASES[normalized];
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function supportedLevels(model: ExtensionContext["model"]): ThinkingLevel[] {
|
|
1054
|
+
if (!model?.reasoning) return ["off"];
|
|
1055
|
+
return ALL_LEVELS.filter((level) => {
|
|
1056
|
+
const mapped = model.thinkingLevelMap?.[level];
|
|
1057
|
+
if (mapped === null) return false;
|
|
1058
|
+
return level !== "xhigh" && level !== "max" || mapped !== undefined;
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function modelLabel(model: ExtensionContext["model"]): string {
|
|
1063
|
+
return model ? `${model.provider}/${model.id}` : "unknown model";
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function registerVariants(pi: ExtensionAPI): void {
|
|
1067
|
+
const setLevel = (ctx: ExtensionContext, level: ThinkingLevel): void => {
|
|
1068
|
+
const supported = supportedLevels(ctx.model);
|
|
1069
|
+
if (!supported.includes(level)) {
|
|
1070
|
+
ctx.ui.notify(`${LEVEL_LABELS[level]} is not supported by ${modelLabel(ctx.model)}. Supported: ${supported.join(", ")}`, "warning");
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
pi.setThinkingLevel(level);
|
|
1074
|
+
ctx.ui.notify(`Thinking: ${LEVEL_LABELS[level]}`, "info");
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
pi.registerCommand("variants", {
|
|
1078
|
+
description: "Set reasoning level: off, minimal, low, medium, high, xhigh, or max",
|
|
1079
|
+
handler: async (args, ctx) => {
|
|
1080
|
+
if (args.trim()) {
|
|
1081
|
+
const level = resolveThinkingLevel(args);
|
|
1082
|
+
if (!level) {
|
|
1083
|
+
ctx.ui.notify(`Unknown reasoning level "${args.trim()}". Use: ${ALL_LEVELS.join(", ")}`, "error");
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
setLevel(ctx, level);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (ctx.mode !== "tui") {
|
|
1090
|
+
ctx.ui.notify("Use /variants <level> outside TUI mode", "error");
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const supported = supportedLevels(ctx.model);
|
|
1095
|
+
if (supported.length === 1) {
|
|
1096
|
+
ctx.ui.notify(`${modelLabel(ctx.model)} does not support extended reasoning`, "info");
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const current = pi.getThinkingLevel() as ThinkingLevel;
|
|
1100
|
+
const items = supported.map((level) => ({
|
|
1101
|
+
value: level,
|
|
1102
|
+
label: level === current ? `${LEVEL_LABELS[level]} ← current` : LEVEL_LABELS[level],
|
|
1103
|
+
description: LEVEL_DESCRIPTIONS[level],
|
|
1104
|
+
}));
|
|
1105
|
+
const selected = await ctx.ui.custom<ThinkingLevel | null>((tui, theme, _keybindings, done) => {
|
|
1106
|
+
const container = new Container();
|
|
1107
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
1108
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Thinking variants")), 1, 0));
|
|
1109
|
+
container.addChild(new Text(theme.fg("dim", `Model: ${modelLabel(ctx.model)}`), 1, 0));
|
|
1110
|
+
container.addChild(new Text("", 0, 0));
|
|
1111
|
+
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
1112
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
1113
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
1114
|
+
description: (text) => theme.fg("muted", text),
|
|
1115
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
1116
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
1117
|
+
});
|
|
1118
|
+
selectList.onSelect = (item) => done(isThinkingLevel(item.value) ? item.value : null);
|
|
1119
|
+
selectList.onCancel = () => done(null);
|
|
1120
|
+
container.addChild(selectList);
|
|
1121
|
+
container.addChild(new Text("", 0, 0));
|
|
1122
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • Enter select • Esc cancel"), 1, 0));
|
|
1123
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
1124
|
+
return {
|
|
1125
|
+
render: (width) => container.render(width).map((line) => truncateToWidth(line, width, "")),
|
|
1126
|
+
invalidate: () => container.invalidate(),
|
|
1127
|
+
handleInput: (data) => {
|
|
1128
|
+
selectList.handleInput(data);
|
|
1129
|
+
tui.requestRender();
|
|
1130
|
+
},
|
|
1131
|
+
};
|
|
1132
|
+
});
|
|
1133
|
+
if (selected) setLevel(ctx, selected);
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
export function formatCost(usd: number): string {
|
|
1139
|
+
if (!Number.isFinite(usd)) return "$—";
|
|
1140
|
+
return `$${usd.toFixed(2)}`;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
export function resolveShortcutHint(): string {
|
|
1144
|
+
return process.env.PI_SHORTCUT_HINT?.trim() || "/variants";
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function formatTime(milliseconds: number): string {
|
|
1148
|
+
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
1149
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
1150
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
1151
|
+
if (minutes < 60) return `${minutes}m`;
|
|
1152
|
+
return `${Math.floor(minutes / 60)}h${minutes % 60}m`;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function formatTokens(value: number): string {
|
|
1156
|
+
const amount = Math.max(0, value);
|
|
1157
|
+
if (amount < 1_000) return `${Math.round(amount)}`;
|
|
1158
|
+
if (amount >= 1_000_000) return `${(amount / 1_000_000).toFixed(amount >= 10_000_000 ? 0 : 1)}M`;
|
|
1159
|
+
return `${(amount / 1_000).toFixed(amount >= 100_000 ? 0 : 1)}k`;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
export function formatContextProgress(tokensUsed: number | null, contextWindow: number, theme: Theme): string {
|
|
1163
|
+
if (tokensUsed === null) return theme.fg("dim", "[░░░░░░░░░░] —");
|
|
1164
|
+
const windowSize = contextWindow > 0 ? contextWindow : 128_000;
|
|
1165
|
+
const remaining = Math.max(0, Math.min(windowSize, windowSize - Math.max(0, tokensUsed)));
|
|
1166
|
+
const percentLeft = Math.max(0, Math.min(100, Math.round((remaining / windowSize) * 100)));
|
|
1167
|
+
const filled = Math.round((percentLeft / 100) * 10);
|
|
1168
|
+
const bar = "█".repeat(filled) + "░".repeat(10 - filled);
|
|
1169
|
+
const color: ThemeColor = percentLeft < 20 ? "error" : percentLeft <= 50 ? "warning" : "success";
|
|
1170
|
+
const warning = percentLeft < 15 ? " ⚠ /compact" : "";
|
|
1171
|
+
return theme.fg(color, `[${bar}] ${percentLeft}% left (${formatTokens(remaining)})${warning}`);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function sumSessionCost(ctx: ExtensionContext): number {
|
|
1175
|
+
let total = 0;
|
|
1176
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
1177
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
1178
|
+
total += entry.message.usage.cost.total;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return total;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function formatModel(model: ExtensionContext["model"], theme: Theme): string {
|
|
1185
|
+
if (!model) return theme.fg("dim", "no model");
|
|
1186
|
+
return `${theme.fg("dim", `${model.provider}/`)}${theme.fg("accent", model.id)}`;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function registerFooter(pi: ExtensionAPI): void {
|
|
1190
|
+
let currentModel: ExtensionContext["model"];
|
|
1191
|
+
let thinkingLevel: ThinkingLevel = "off";
|
|
1192
|
+
let activeTui: TUI | undefined;
|
|
1193
|
+
|
|
1194
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1195
|
+
if (ctx.mode !== "tui") return;
|
|
1196
|
+
const sessionStart = Date.now();
|
|
1197
|
+
currentModel = ctx.model;
|
|
1198
|
+
thinkingLevel = pi.getThinkingLevel() as ThinkingLevel;
|
|
1199
|
+
const cwd = formatCwd(ctx.cwd);
|
|
1200
|
+
|
|
1201
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
1202
|
+
activeTui = tui;
|
|
1203
|
+
const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
|
|
1204
|
+
const refreshTimer = setInterval(() => tui.requestRender(), FOOTER_REFRESH_INTERVAL_MS);
|
|
1205
|
+
refreshTimer.unref?.();
|
|
1206
|
+
return {
|
|
1207
|
+
dispose() {
|
|
1208
|
+
unsubscribe();
|
|
1209
|
+
clearInterval(refreshTimer);
|
|
1210
|
+
if (activeTui === tui) activeTui = undefined;
|
|
1211
|
+
},
|
|
1212
|
+
invalidate() {},
|
|
1213
|
+
render(width: number): string[] {
|
|
1214
|
+
if (width <= 0) return [];
|
|
1215
|
+
const model = currentModel ?? ctx.model;
|
|
1216
|
+
const level = model?.reasoning === false
|
|
1217
|
+
? theme.fg("thinkingOff", "no reasoning")
|
|
1218
|
+
: theme.fg(LEVEL_COLORS[thinkingLevel], LEVEL_LABELS[thinkingLevel]);
|
|
1219
|
+
const usage = ctx.getContextUsage();
|
|
1220
|
+
const context = formatContextProgress(usage?.tokens ?? null, usage?.contextWindow ?? 128_000, theme);
|
|
1221
|
+
const branch = footerData.getGitBranch();
|
|
1222
|
+
const parts = [
|
|
1223
|
+
formatModel(model, theme),
|
|
1224
|
+
level,
|
|
1225
|
+
context,
|
|
1226
|
+
branch ? theme.fg("dim", branch) : "",
|
|
1227
|
+
theme.fg("dim", formatTime(Date.now() - sessionStart)),
|
|
1228
|
+
];
|
|
1229
|
+
const cost = sumSessionCost(ctx);
|
|
1230
|
+
if (cost > 0) parts.push(theme.fg("dim", formatCost(cost)));
|
|
1231
|
+
const hint = resolveShortcutHint();
|
|
1232
|
+
if (hint) parts.push(theme.fg("dim", hint));
|
|
1233
|
+
const separator = theme.fg("dim", "·");
|
|
1234
|
+
const left = ` ${parts.filter(Boolean).join(` ${separator} `)} `;
|
|
1235
|
+
const rightBudget = Math.max(0, width - visibleWidth(left) - 1);
|
|
1236
|
+
const right = theme.fg("dim", truncateToWidth(cwd, rightBudget, "…"));
|
|
1237
|
+
const gap = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
|
|
1238
|
+
return [truncateToWidth(left + gap + right, width, "")];
|
|
1239
|
+
},
|
|
1240
|
+
};
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
pi.on("model_select", (event) => {
|
|
1245
|
+
currentModel = event.model;
|
|
1246
|
+
activeTui?.requestRender();
|
|
1247
|
+
});
|
|
1248
|
+
pi.on("thinking_level_select", (event) => {
|
|
1249
|
+
thinkingLevel = event.level;
|
|
1250
|
+
activeTui?.requestRender();
|
|
1251
|
+
});
|
|
1252
|
+
pi.on("session_shutdown", () => {
|
|
1253
|
+
activeTui = undefined;
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
export default function Killeros(pi: ExtensionAPI): void {
|
|
1258
|
+
registerShellUi(pi);
|
|
1259
|
+
registerConcisePrompt(pi);
|
|
1260
|
+
registerQuestionTool(pi);
|
|
1261
|
+
registerAliases(pi);
|
|
1262
|
+
registerSlashAutocomplete(pi);
|
|
1263
|
+
registerFooter(pi);
|
|
1264
|
+
registerVariants(pi);
|
|
1265
|
+
}
|