killeros 3.0.6 → 3.0.8

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.
@@ -1,418 +1,418 @@
1
- import { execFile } from "node:child_process";
2
- import { readFileSync } from "node:fs";
3
- import {
4
- CustomEditor,
5
- VERSION as PI_VERSION,
6
- type ExtensionAPI,
7
- type ExtensionContext,
8
- type KeybindingsManager,
9
- type Theme,
10
- } from "@earendil-works/pi-coding-agent";
11
- import {
12
- CURSOR_MARKER,
13
- stripTerminalSequences,
14
- truncateToWidth,
15
- wrapTextWithAnsi,
16
- type EditorTheme,
17
- type TUI,
18
- } from "@earendil-works/pi-tui";
19
- import { formatCwd, modelDisplayName, padRight } from "./display.ts";
20
- import { colorDirectory } from "./footer.ts";
21
- import {
22
- createSlashCommandResolver,
23
- findSlashCommandTokens,
24
- type SlashCommandResolver,
25
- } from "./commands.ts";
26
- import { reportError } from "./errors.ts";
27
- import { passiveGitCommand, passiveGitEnv } from "./passive-git-status.ts";
28
-
29
- function readPackageVersion(path: string | URL): string | undefined {
30
- try {
31
- const value: unknown = JSON.parse(readFileSync(path, "utf8"));
32
- return typeof value === "object"
33
- && value !== null
34
- && "version" in value
35
- && typeof value.version === "string"
36
- ? value.version
37
- : undefined;
38
- } catch {
39
- return undefined;
40
- }
41
- }
42
-
43
- const KILLEROS_VERSION = readPackageVersion(new URL("../package.json", import.meta.url));
44
-
45
- const STARTUP_TIPS = [
46
- "Press Shift+Enter to insert a line break without sending.",
47
- "Run /thinking to tune the model's reasoning depth.",
48
- "Type / to browse every command available in this session.",
49
- "Run /notification to enable a terminal bell when work settles.",
50
- "Run /goal <objective> to keep long-running work moving across turns.",
51
- "Run /handoff [focus] to continue work in a fresh linked session.",
52
- "Run /codex-fast to toggle priority requests for Codex models.",
53
- "Run /clear to start a fresh session after confirmation.",
54
- "Ask the agent to show a short choice list when you need to decide.",
55
- ] as const;
56
-
57
- const EDITOR_SUGGESTIONS = [
58
- 'Try "how does <filepath> work?"',
59
- 'Try "find edge cases in <filepath>"',
60
- 'Try "simplify <filepath> without changing behavior"',
61
- 'Try "write tests for <filepath>"',
62
- 'Try "trace this failure to its first bad state"',
63
- 'Try "review this diff against <spec>"',
64
- 'Try "explain why this test fails"',
65
- 'Try "find the smallest safe fix for <issue>"',
66
- 'Try "map the data flow through <feature>"',
67
- 'Try "draft an implementation plan for <feature>"',
68
- ] as const;
69
-
70
- export function resolveGitBranch(cwd: string, trusted = true): Promise<string | undefined> {
71
- if (!trusted) return Promise.resolve(undefined);
72
- let gitCommand: string | undefined;
73
- try {
74
- gitCommand = passiveGitCommand(cwd);
75
- } catch {
76
- return Promise.resolve(undefined);
77
- }
78
- if (!gitCommand) return Promise.resolve(undefined);
79
- return new Promise((resolve) => {
80
- execFile(
81
- gitCommand,
82
- ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"],
83
- {
84
- encoding: "utf8",
85
- env: passiveGitEnv(),
86
- maxBuffer: 64 * 1024,
87
- timeout: 500,
88
- windowsHide: true,
89
- },
90
- (error, stdout) => {
91
- if (error) {
92
- resolve(undefined);
93
- return;
94
- }
95
- const branch = stdout.trim();
96
- resolve(branch ? branch === "HEAD" ? "detached" : branch : undefined);
97
- },
98
- );
99
- });
100
- }
101
-
102
- function shuffledDeck(values: readonly string[]): string[] {
103
- const deck = [...values];
104
- for (let index = deck.length - 1; index > 0; index -= 1) {
105
- const swapIndex = Math.floor(Math.random() * (index + 1));
106
- const current = deck[index];
107
- const swap = deck[swapIndex];
108
- if (current === undefined || swap === undefined) continue;
109
- deck[index] = swap;
110
- deck[swapIndex] = current;
111
- }
112
- return deck;
113
- }
114
-
115
- let tipDeck: string[] = [];
116
- let editorSuggestionDeck: string[] = [];
117
-
118
- function nextStartupTip(): string {
119
- if (tipDeck.length === 0) tipDeck = shuffledDeck(STARTUP_TIPS);
120
- return tipDeck.pop() ?? STARTUP_TIPS[0];
121
- }
122
-
123
- function nextEditorSuggestion(): string {
124
- if (editorSuggestionDeck.length === 0) editorSuggestionDeck = shuffledDeck(EDITOR_SUGGESTIONS);
125
- return editorSuggestionDeck.pop() ?? EDITOR_SUGGESTIONS[0];
126
- }
127
-
128
- const MASTHEAD_MARK = ["██████", "▓▓▓▓▓▓", "░░░░░░"];
129
- const MASTHEAD_GAP = " ";
130
-
131
- class PiStartupHeader {
132
- private readonly pi: ExtensionAPI;
133
- private readonly ctx: ExtensionContext;
134
- private readonly tip: string;
135
- private readonly tui: TUI;
136
- private branch: string | undefined;
137
- private disposed = false;
138
-
139
- constructor(pi: ExtensionAPI, ctx: ExtensionContext, tip: string, tui: TUI) {
140
- this.pi = pi;
141
- this.ctx = ctx;
142
- this.tip = tip;
143
- this.tui = tui;
144
- void resolveGitBranch(ctx.cwd, ctx.isProjectTrusted()).then((branch) => {
145
- if (this.disposed) return;
146
- this.branch = branch;
147
- this.tui.requestRender();
148
- });
149
- }
150
-
151
- private tipLines(width: number, theme: Theme): string[] {
152
- const text = theme.italic(`${theme.fg("text", theme.bold("Tip:"))}${theme.fg("dim", ` ${this.tip}`)}`);
153
- return wrapTextWithAnsi(text, width);
154
- }
155
-
156
- render(width: number): string[] {
157
- if (width <= 0) return [];
158
- const theme = this.ctx.ui.theme;
159
- if (width < 28) return [truncateToWidth(theme.fg("text", theme.bold("KillerOS")), width, "")];
160
-
161
- const textWidth = Math.max(0, width - MASTHEAD_MARK[0].length - MASTHEAD_GAP.length);
162
- const identity = `${theme.fg("text", "Pi")}${theme.fg("dim", ` ${PI_VERSION} | `)}${theme.fg("text", "KillerOS")}${KILLEROS_VERSION ? theme.fg("dim", ` ${KILLEROS_VERSION}`) : ""}`;
163
- const thinkingLevel = this.pi.getThinkingLevel();
164
- const reasoning = this.ctx.model?.reasoning === false ? "no reasoning" : thinkingLevel;
165
- const modelName = this.ctx.model ? modelDisplayName(this.ctx.model) || "unknown model" : "no model";
166
- const agent = `${theme.fg("dim", "model: ")}${theme.fg("text", `${modelName} ${reasoning}`)}`;
167
- const directory = formatCwd(this.ctx.cwd);
168
- const repository = `${theme.fg("dim", "directory: ")}${theme.fg("text", directory)}${this.branch ? ` ${colorDirectory(this.branch)}` : ""}`;
169
- const rows = [identity, agent, repository].map((text, index) =>
170
- `${theme.fg("accent", MASTHEAD_MARK[index] ?? "")}${MASTHEAD_GAP}${truncateToWidth(text, textWidth, "…")}`,
171
- );
172
- return [...rows, "", ...this.tipLines(width, theme)];
173
- }
174
-
175
- invalidate(): void {}
176
- dispose(): void {
177
- this.disposed = true;
178
- }
179
- }
180
-
181
- function stripAnsi(text: string): string {
182
- return stripTerminalSequences(text).trim();
183
- }
184
-
185
- function isBorderLine(line: string): boolean {
186
- const unstyled = stripAnsi(line);
187
- return /^[─━═]+$/.test(unstyled) || /^───\s*[↓↑]/.test(unstyled) || /^─{3,}/.test(unstyled);
188
- }
189
-
190
- function isScrolledTopBorder(line: string): boolean {
191
- const unstyled = stripAnsi(line);
192
- return unstyled.includes("↑");
193
- }
194
-
195
- interface RenderChunk {
196
- text: string;
197
- plainStart: number;
198
- isAnsi: boolean;
199
- }
200
-
201
- function extractTerminalSequence(text: string, position: number): { code: string; length: number } | undefined {
202
- if (text[position] !== "\x1B") return undefined;
203
- const next = text[position + 1];
204
- if (next === "[") {
205
- let end = position + 2;
206
- while (end < text.length && !/[\x40-\x7E]/u.test(text[end] ?? "")) end += 1;
207
- if (end < text.length) return { code: text.slice(position, end + 1), length: end + 1 - position };
208
- return undefined;
209
- }
210
- if (next === "]" || next === "_") {
211
- let end = position + 2;
212
- while (end < text.length) {
213
- if (text[end] === "\x07") return { code: text.slice(position, end + 1), length: end + 1 - position };
214
- if (text[end] === "\x1B" && text[end + 1] === "\\") {
215
- return { code: text.slice(position, end + 2), length: end + 2 - position };
216
- }
217
- end += 1;
218
- }
219
- return undefined;
220
- }
221
- if (next !== undefined) return { code: text.slice(position, position + 2), length: 2 };
222
- return undefined;
223
- }
224
-
225
- /** Styles valid slash-command tokens while preserving embedded terminal control sequences. */
226
- export function highlightSlashCommands(
227
- line: string,
228
- isValidCommand: (name: string) => boolean,
229
- styleCommand: (token: string) => string,
230
- ): string {
231
- const chunks: RenderChunk[] = [];
232
- let plain = "";
233
- let index = 0;
234
- while (index < line.length) {
235
- const ansi = extractTerminalSequence(line, index);
236
- if (ansi) {
237
- chunks.push({ text: ansi.code, plainStart: plain.length, isAnsi: true });
238
- index += ansi.length;
239
- continue;
240
- }
241
-
242
- const start = index;
243
- const plainStart = plain.length;
244
- while (index < line.length && !extractTerminalSequence(line, index)) {
245
- plain += line[index] ?? "";
246
- index += 1;
247
- }
248
- chunks.push({
249
- text: line.slice(start, index),
250
- plainStart,
251
- isAnsi: false,
252
- });
253
- }
254
-
255
- const tokens = findSlashCommandTokens(plain).filter((token) => isValidCommand(token.name));
256
- if (tokens.length === 0) return line;
257
-
258
- return chunks.map((chunk) => {
259
- if (chunk.isAnsi) return chunk.text;
260
- let output = "";
261
- let offset = 0;
262
- while (offset < chunk.text.length) {
263
- const plainIndex = chunk.plainStart + offset;
264
- const token = tokens.find(({ start, end }) => plainIndex >= start && plainIndex < end);
265
- if (!token) {
266
- const nextTokenStart = tokens.find(({ start }) => start > plainIndex)?.start;
267
- const end = nextTokenStart === undefined
268
- ? chunk.text.length
269
- : Math.min(chunk.text.length, nextTokenStart - chunk.plainStart);
270
- output += chunk.text.slice(offset, end);
271
- offset = end;
272
- continue;
273
- }
274
-
275
- const tokenEnd = Math.min(chunk.text.length, token.end - chunk.plainStart);
276
- output += styleCommand(chunk.text.slice(offset, tokenEnd));
277
- offset = tokenEnd;
278
- }
279
- return output;
280
- }).join("");
281
- }
282
-
283
- class PiCodeEditor extends CustomEditor {
284
- private readonly appKeybindings: KeybindingsManager;
285
- private readonly runtimeTheme: Theme;
286
- private readonly suggestion: string;
287
- private readonly commandResolver: SlashCommandResolver;
288
-
289
- constructor(
290
- tui: TUI,
291
- theme: EditorTheme,
292
- appKeybindings: KeybindingsManager,
293
- runtimeTheme: Theme,
294
- suggestion: string,
295
- commandResolver: SlashCommandResolver,
296
- ) {
297
- super(tui, theme, appKeybindings);
298
- this.appKeybindings = appKeybindings;
299
- this.runtimeTheme = runtimeTheme;
300
- this.suggestion = suggestion;
301
- this.commandResolver = commandResolver;
302
- }
303
-
304
- override handleInput(data: string): void {
305
- const isShiftEnter = data === "\x1B[13;2u"
306
- || data === "\x1B[13;2~"
307
- || data === "\x1B[27;2;13~"
308
- || data === "\x1B\r"
309
- || data === "\x1B\n"
310
- || this.appKeybindings.matches(data, "tui.input.newLine");
311
- if (isShiftEnter) {
312
- this.insertTextAtCursor("\n");
313
- return;
314
- }
315
- super.handleInput(data);
316
- }
317
-
318
- override render(width: number): string[] {
319
- if (width <= 0) return [];
320
- const innerWidth = Math.max(1, width - 2);
321
- const lines = super.render(innerWidth);
322
- if (lines.length < 2) return ["", ...lines.map((line) => truncateToWidth(line, width, ""))];
323
- let bottomBorderIndex = lines.length - 1;
324
- for (let index = lines.length - 1; index >= 1; index -= 1) {
325
- if (isBorderLine(lines[index] ?? "")) {
326
- bottomBorderIndex = index;
327
- break;
328
- }
329
- }
330
-
331
- const dim = (text: string): string => this.runtimeTheme.fg("dim", text);
332
- const rendered: string[] = [];
333
- const top = stripAnsi(lines[0] ?? "");
334
- const isScrolledHeader = isScrolledTopBorder(lines[0] ?? "");
335
- if (isScrolledHeader) {
336
- const count = top.match(/↑\s*(\d+)/)?.[1] ?? "";
337
- rendered.push(truncateToWidth(dim(` ↑ ${count} more`), width, ""));
338
- }
339
-
340
- for (let index = 1; index < bottomBorderIndex; index += 1) {
341
- const isPromptLine = index === 1 && !isScrolledHeader;
342
- const prefix = isPromptLine ? this.runtimeTheme.fg("text", "❯\u00A0") : " ";
343
- let content = lines[index] ?? "";
344
- if (isPromptLine && this.getText() === "") {
345
- const first = this.suggestion.slice(0, 1);
346
- const rest = this.suggestion.slice(1);
347
- const cursorMarker = this.focused ? CURSOR_MARKER : "";
348
- content = `${cursorMarker}\x1B[7m${dim(first)}\x1B[27m${dim(rest)}`;
349
- }
350
- if (this.getText() !== "") {
351
- content = highlightSlashCommands(
352
- content,
353
- (name) => this.commandResolver.isValidCommand(name),
354
- (token) => this.runtimeTheme.fg("mdLink", token),
355
- );
356
- }
357
- rendered.push(`${prefix}${padRight(content, innerWidth)}`);
358
- }
359
-
360
- const bottom = stripAnsi(lines[bottomBorderIndex] ?? "");
361
- if (bottom.includes("↓")) {
362
- const count = bottom.match(/↓\s*(\d+)/)?.[1] ?? "";
363
- rendered.push(truncateToWidth(dim(` ↓ ${count} more`), width, ""));
364
- }
365
-
366
- for (let index = bottomBorderIndex + 1; index < lines.length; index += 1) {
367
- rendered.push(` ${padRight(lines[index] ?? "", innerWidth)}`);
368
- }
369
- return [this.runtimeTheme.fg("borderMuted", "─".repeat(width)), ...rendered.map((line) => truncateToWidth(line, width, ""))];
370
- }
371
- }
372
-
373
- const ACTIVITY_FRAMES = [
374
- "⠋", "⠙", "⠹", "⠸", "⠼",
375
- "⠴", "⠦", "⠧", "⠇", "⠏",
376
- ] as const;
377
- const ACTIVITY_FRAME_INTERVAL_MS = 80;
378
-
379
- let killerosEditorFactory: ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
380
-
381
- export function registerShellUi(
382
- pi: ExtensionAPI,
383
- commandResolver: SlashCommandResolver = createSlashCommandResolver(pi),
384
- ): void {
385
- let activeHeader: PiStartupHeader | undefined;
386
-
387
- pi.on("session_start", (_event, ctx) => {
388
- if (ctx.mode !== "tui") return;
389
- try {
390
- ctx.ui.setTheme("killeros");
391
- const startupTip = nextStartupTip();
392
- ctx.ui.setHeader((tui) => {
393
- activeHeader?.dispose();
394
- activeHeader = new PiStartupHeader(pi, ctx, startupTip, tui);
395
- return activeHeader;
396
- });
397
- ctx.ui.setWorkingIndicator({
398
- frames: ACTIVITY_FRAMES.map((frame) => ctx.ui.theme.fg("accent", frame)),
399
- intervalMs: ACTIVITY_FRAME_INTERVAL_MS,
400
- });
401
- ctx.ui.setHiddenThinkingLabel("└ Thinking…");
402
- const existingEditorFactory = ctx.ui.getEditorComponent?.();
403
- if (!existingEditorFactory || existingEditorFactory === killerosEditorFactory) {
404
- const editorSuggestion = nextEditorSuggestion();
405
- killerosEditorFactory = (tui, editorTheme, keybindings) =>
406
- new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, editorSuggestion, commandResolver);
407
- ctx.ui.setEditorComponent(killerosEditorFactory);
408
- }
409
- } catch (error) {
410
- reportError(ctx, "Killeros UI failed to initialize", error);
411
- }
412
- });
413
-
414
- pi.on("session_shutdown", () => {
415
- activeHeader?.dispose();
416
- activeHeader = undefined;
417
- });
418
- }
1
+ import { execFile } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import {
4
+ CustomEditor,
5
+ VERSION as PI_VERSION,
6
+ type ExtensionAPI,
7
+ type ExtensionContext,
8
+ type KeybindingsManager,
9
+ type Theme,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ CURSOR_MARKER,
13
+ stripTerminalSequences,
14
+ truncateToWidth,
15
+ wrapTextWithAnsi,
16
+ type EditorTheme,
17
+ type TUI,
18
+ } from "@earendil-works/pi-tui";
19
+ import { formatCwd, modelDisplayName, padRight } from "./display.ts";
20
+ import { colorDirectory } from "./footer.ts";
21
+ import {
22
+ createSlashCommandResolver,
23
+ findSlashCommandTokens,
24
+ type SlashCommandResolver,
25
+ } from "./commands.ts";
26
+ import { reportError } from "./errors.ts";
27
+ import { passiveGitCommand, passiveGitEnv } from "./passive-git-status.ts";
28
+
29
+ function readPackageVersion(path: string | URL): string | undefined {
30
+ try {
31
+ const value: unknown = JSON.parse(readFileSync(path, "utf8"));
32
+ return typeof value === "object"
33
+ && value !== null
34
+ && "version" in value
35
+ && typeof value.version === "string"
36
+ ? value.version
37
+ : undefined;
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ const KILLEROS_VERSION = readPackageVersion(new URL("../package.json", import.meta.url));
44
+
45
+ const STARTUP_TIPS = [
46
+ "Press Shift+Enter to insert a line break without sending.",
47
+ "Run /thinking to tune the model's reasoning depth.",
48
+ "Type / to browse every command available in this session.",
49
+ "Run /notification to enable a terminal bell when work settles.",
50
+ "Run /goal <objective> to keep long-running work moving across turns.",
51
+ "Run /handoff [focus] to continue work in a fresh linked session.",
52
+ "Run /codex-fast to toggle priority requests for Codex models.",
53
+ "Run /clear to start a fresh session after confirmation.",
54
+ "Ask the agent to show a short choice list when you need to decide.",
55
+ ] as const;
56
+
57
+ const EDITOR_SUGGESTIONS = [
58
+ 'Try "how does <filepath> work?"',
59
+ 'Try "find edge cases in <filepath>"',
60
+ 'Try "simplify <filepath> without changing behavior"',
61
+ 'Try "write tests for <filepath>"',
62
+ 'Try "trace this failure to its first bad state"',
63
+ 'Try "review this diff against <spec>"',
64
+ 'Try "explain why this test fails"',
65
+ 'Try "find the smallest safe fix for <issue>"',
66
+ 'Try "map the data flow through <feature>"',
67
+ 'Try "draft an implementation plan for <feature>"',
68
+ ] as const;
69
+
70
+ export function resolveGitBranch(cwd: string, trusted = true): Promise<string | undefined> {
71
+ if (!trusted) return Promise.resolve(undefined);
72
+ let gitCommand: string | undefined;
73
+ try {
74
+ gitCommand = passiveGitCommand(cwd);
75
+ } catch {
76
+ return Promise.resolve(undefined);
77
+ }
78
+ if (!gitCommand) return Promise.resolve(undefined);
79
+ return new Promise((resolve) => {
80
+ execFile(
81
+ gitCommand,
82
+ ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"],
83
+ {
84
+ encoding: "utf8",
85
+ env: passiveGitEnv(),
86
+ maxBuffer: 64 * 1024,
87
+ timeout: 500,
88
+ windowsHide: true,
89
+ },
90
+ (error, stdout) => {
91
+ if (error) {
92
+ resolve(undefined);
93
+ return;
94
+ }
95
+ const branch = stdout.trim();
96
+ resolve(branch ? branch === "HEAD" ? "detached" : branch : undefined);
97
+ },
98
+ );
99
+ });
100
+ }
101
+
102
+ function shuffledDeck(values: readonly string[]): string[] {
103
+ const deck = [...values];
104
+ for (let index = deck.length - 1; index > 0; index -= 1) {
105
+ const swapIndex = Math.floor(Math.random() * (index + 1));
106
+ const current = deck[index];
107
+ const swap = deck[swapIndex];
108
+ if (current === undefined || swap === undefined) continue;
109
+ deck[index] = swap;
110
+ deck[swapIndex] = current;
111
+ }
112
+ return deck;
113
+ }
114
+
115
+ let tipDeck: string[] = [];
116
+ let editorSuggestionDeck: string[] = [];
117
+
118
+ function nextStartupTip(): string {
119
+ if (tipDeck.length === 0) tipDeck = shuffledDeck(STARTUP_TIPS);
120
+ return tipDeck.pop() ?? STARTUP_TIPS[0];
121
+ }
122
+
123
+ function nextEditorSuggestion(): string {
124
+ if (editorSuggestionDeck.length === 0) editorSuggestionDeck = shuffledDeck(EDITOR_SUGGESTIONS);
125
+ return editorSuggestionDeck.pop() ?? EDITOR_SUGGESTIONS[0];
126
+ }
127
+
128
+ const MASTHEAD_MARK = ["██████", "▓▓▓▓▓▓", "░░░░░░"];
129
+ const MASTHEAD_GAP = " ";
130
+
131
+ class PiStartupHeader {
132
+ private readonly pi: ExtensionAPI;
133
+ private readonly ctx: ExtensionContext;
134
+ private readonly tip: string;
135
+ private readonly tui: TUI;
136
+ private branch: string | undefined;
137
+ private disposed = false;
138
+
139
+ constructor(pi: ExtensionAPI, ctx: ExtensionContext, tip: string, tui: TUI) {
140
+ this.pi = pi;
141
+ this.ctx = ctx;
142
+ this.tip = tip;
143
+ this.tui = tui;
144
+ void resolveGitBranch(ctx.cwd, ctx.isProjectTrusted()).then((branch) => {
145
+ if (this.disposed) return;
146
+ this.branch = branch;
147
+ this.tui.requestRender();
148
+ });
149
+ }
150
+
151
+ private tipLines(width: number, theme: Theme): string[] {
152
+ const text = theme.italic(`${theme.fg("text", theme.bold("Tip:"))}${theme.fg("dim", ` ${this.tip}`)}`);
153
+ return wrapTextWithAnsi(text, width);
154
+ }
155
+
156
+ render(width: number): string[] {
157
+ if (width <= 0) return [];
158
+ const theme = this.ctx.ui.theme;
159
+ if (width < 28) return [truncateToWidth(theme.fg("text", theme.bold("KillerOS")), width, "")];
160
+
161
+ const textWidth = Math.max(0, width - MASTHEAD_MARK[0].length - MASTHEAD_GAP.length);
162
+ const identity = `${theme.fg("text", "Pi")}${theme.fg("dim", ` ${PI_VERSION} | `)}${theme.fg("text", "KillerOS")}${KILLEROS_VERSION ? theme.fg("dim", ` ${KILLEROS_VERSION}`) : ""}`;
163
+ const thinkingLevel = this.pi.getThinkingLevel();
164
+ const reasoning = this.ctx.model?.reasoning === false ? "no reasoning" : thinkingLevel;
165
+ const modelName = this.ctx.model ? modelDisplayName(this.ctx.model) || "unknown model" : "no model";
166
+ const agent = `${theme.fg("dim", "model: ")}${theme.fg("text", `${modelName} ${reasoning}`)}`;
167
+ const directory = formatCwd(this.ctx.cwd);
168
+ const repository = `${theme.fg("dim", "directory: ")}${theme.fg("text", directory)}${this.branch ? ` ${colorDirectory(this.branch)}` : ""}`;
169
+ const rows = [identity, agent, repository].map((text, index) =>
170
+ `${theme.fg("accent", MASTHEAD_MARK[index] ?? "")}${MASTHEAD_GAP}${truncateToWidth(text, textWidth, "…")}`,
171
+ );
172
+ return [...rows, "", ...this.tipLines(width, theme)];
173
+ }
174
+
175
+ invalidate(): void {}
176
+ dispose(): void {
177
+ this.disposed = true;
178
+ }
179
+ }
180
+
181
+ function stripAnsi(text: string): string {
182
+ return stripTerminalSequences(text).trim();
183
+ }
184
+
185
+ function isBorderLine(line: string): boolean {
186
+ const unstyled = stripAnsi(line);
187
+ return /^[─━═]+$/.test(unstyled) || /^───\s*[↓↑]/.test(unstyled) || /^─{3,}/.test(unstyled);
188
+ }
189
+
190
+ function isScrolledTopBorder(line: string): boolean {
191
+ const unstyled = stripAnsi(line);
192
+ return unstyled.includes("↑");
193
+ }
194
+
195
+ interface RenderChunk {
196
+ text: string;
197
+ plainStart: number;
198
+ isAnsi: boolean;
199
+ }
200
+
201
+ function extractTerminalSequence(text: string, position: number): { code: string; length: number } | undefined {
202
+ if (text[position] !== "\x1B") return undefined;
203
+ const next = text[position + 1];
204
+ if (next === "[") {
205
+ let end = position + 2;
206
+ while (end < text.length && !/[\x40-\x7E]/u.test(text[end] ?? "")) end += 1;
207
+ if (end < text.length) return { code: text.slice(position, end + 1), length: end + 1 - position };
208
+ return undefined;
209
+ }
210
+ if (next === "]" || next === "_") {
211
+ let end = position + 2;
212
+ while (end < text.length) {
213
+ if (text[end] === "\x07") return { code: text.slice(position, end + 1), length: end + 1 - position };
214
+ if (text[end] === "\x1B" && text[end + 1] === "\\") {
215
+ return { code: text.slice(position, end + 2), length: end + 2 - position };
216
+ }
217
+ end += 1;
218
+ }
219
+ return undefined;
220
+ }
221
+ if (next !== undefined) return { code: text.slice(position, position + 2), length: 2 };
222
+ return undefined;
223
+ }
224
+
225
+ /** Styles valid slash-command tokens while preserving embedded terminal control sequences. */
226
+ export function highlightSlashCommands(
227
+ line: string,
228
+ isValidCommand: (name: string) => boolean,
229
+ styleCommand: (token: string) => string,
230
+ ): string {
231
+ const chunks: RenderChunk[] = [];
232
+ let plain = "";
233
+ let index = 0;
234
+ while (index < line.length) {
235
+ const ansi = extractTerminalSequence(line, index);
236
+ if (ansi) {
237
+ chunks.push({ text: ansi.code, plainStart: plain.length, isAnsi: true });
238
+ index += ansi.length;
239
+ continue;
240
+ }
241
+
242
+ const start = index;
243
+ const plainStart = plain.length;
244
+ while (index < line.length && !extractTerminalSequence(line, index)) {
245
+ plain += line[index] ?? "";
246
+ index += 1;
247
+ }
248
+ chunks.push({
249
+ text: line.slice(start, index),
250
+ plainStart,
251
+ isAnsi: false,
252
+ });
253
+ }
254
+
255
+ const tokens = findSlashCommandTokens(plain).filter((token) => isValidCommand(token.name));
256
+ if (tokens.length === 0) return line;
257
+
258
+ return chunks.map((chunk) => {
259
+ if (chunk.isAnsi) return chunk.text;
260
+ let output = "";
261
+ let offset = 0;
262
+ while (offset < chunk.text.length) {
263
+ const plainIndex = chunk.plainStart + offset;
264
+ const token = tokens.find(({ start, end }) => plainIndex >= start && plainIndex < end);
265
+ if (!token) {
266
+ const nextTokenStart = tokens.find(({ start }) => start > plainIndex)?.start;
267
+ const end = nextTokenStart === undefined
268
+ ? chunk.text.length
269
+ : Math.min(chunk.text.length, nextTokenStart - chunk.plainStart);
270
+ output += chunk.text.slice(offset, end);
271
+ offset = end;
272
+ continue;
273
+ }
274
+
275
+ const tokenEnd = Math.min(chunk.text.length, token.end - chunk.plainStart);
276
+ output += styleCommand(chunk.text.slice(offset, tokenEnd));
277
+ offset = tokenEnd;
278
+ }
279
+ return output;
280
+ }).join("");
281
+ }
282
+
283
+ class PiCodeEditor extends CustomEditor {
284
+ private readonly appKeybindings: KeybindingsManager;
285
+ private readonly runtimeTheme: Theme;
286
+ private readonly suggestion: string;
287
+ private readonly commandResolver: SlashCommandResolver;
288
+
289
+ constructor(
290
+ tui: TUI,
291
+ theme: EditorTheme,
292
+ appKeybindings: KeybindingsManager,
293
+ runtimeTheme: Theme,
294
+ suggestion: string,
295
+ commandResolver: SlashCommandResolver,
296
+ ) {
297
+ super(tui, theme, appKeybindings);
298
+ this.appKeybindings = appKeybindings;
299
+ this.runtimeTheme = runtimeTheme;
300
+ this.suggestion = suggestion;
301
+ this.commandResolver = commandResolver;
302
+ }
303
+
304
+ override handleInput(data: string): void {
305
+ const isShiftEnter = data === "\x1B[13;2u"
306
+ || data === "\x1B[13;2~"
307
+ || data === "\x1B[27;2;13~"
308
+ || data === "\x1B\r"
309
+ || data === "\x1B\n"
310
+ || this.appKeybindings.matches(data, "tui.input.newLine");
311
+ if (isShiftEnter) {
312
+ this.insertTextAtCursor("\n");
313
+ return;
314
+ }
315
+ super.handleInput(data);
316
+ }
317
+
318
+ override render(width: number): string[] {
319
+ if (width <= 0) return [];
320
+ const innerWidth = Math.max(1, width - 2);
321
+ const lines = super.render(innerWidth);
322
+ if (lines.length < 2) return ["", ...lines.map((line) => truncateToWidth(line, width, ""))];
323
+ let bottomBorderIndex = lines.length - 1;
324
+ for (let index = lines.length - 1; index >= 1; index -= 1) {
325
+ if (isBorderLine(lines[index] ?? "")) {
326
+ bottomBorderIndex = index;
327
+ break;
328
+ }
329
+ }
330
+
331
+ const dim = (text: string): string => this.runtimeTheme.fg("dim", text);
332
+ const rendered: string[] = [];
333
+ const top = stripAnsi(lines[0] ?? "");
334
+ const isScrolledHeader = isScrolledTopBorder(lines[0] ?? "");
335
+ if (isScrolledHeader) {
336
+ const count = top.match(/↑\s*(\d+)/)?.[1] ?? "";
337
+ rendered.push(truncateToWidth(dim(` ↑ ${count} more`), width, ""));
338
+ }
339
+
340
+ for (let index = 1; index < bottomBorderIndex; index += 1) {
341
+ const isPromptLine = index === 1 && !isScrolledHeader;
342
+ const prefix = isPromptLine ? this.runtimeTheme.fg("text", "❯\u00A0") : " ";
343
+ let content = lines[index] ?? "";
344
+ if (isPromptLine && this.getText() === "") {
345
+ const first = this.suggestion.slice(0, 1);
346
+ const rest = this.suggestion.slice(1);
347
+ const cursorMarker = this.focused ? CURSOR_MARKER : "";
348
+ content = `${cursorMarker}\x1B[7m${dim(first)}\x1B[27m${dim(rest)}`;
349
+ }
350
+ if (this.getText() !== "") {
351
+ content = highlightSlashCommands(
352
+ content,
353
+ (name) => this.commandResolver.isValidCommand(name),
354
+ (token) => this.runtimeTheme.fg("mdLink", token),
355
+ );
356
+ }
357
+ rendered.push(`${prefix}${padRight(content, innerWidth)}`);
358
+ }
359
+
360
+ const bottom = stripAnsi(lines[bottomBorderIndex] ?? "");
361
+ if (bottom.includes("↓")) {
362
+ const count = bottom.match(/↓\s*(\d+)/)?.[1] ?? "";
363
+ rendered.push(truncateToWidth(dim(` ↓ ${count} more`), width, ""));
364
+ }
365
+
366
+ for (let index = bottomBorderIndex + 1; index < lines.length; index += 1) {
367
+ rendered.push(` ${padRight(lines[index] ?? "", innerWidth)}`);
368
+ }
369
+ return [this.runtimeTheme.fg("borderMuted", "─".repeat(width)), ...rendered.map((line) => truncateToWidth(line, width, ""))];
370
+ }
371
+ }
372
+
373
+ const ACTIVITY_FRAMES = [
374
+ "⠋", "⠙", "⠹", "⠸", "⠼",
375
+ "⠴", "⠦", "⠧", "⠇", "⠏",
376
+ ] as const;
377
+ const ACTIVITY_FRAME_INTERVAL_MS = 80;
378
+
379
+ let killerosEditorFactory: ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
380
+
381
+ export function registerShellUi(
382
+ pi: ExtensionAPI,
383
+ commandResolver: SlashCommandResolver = createSlashCommandResolver(pi),
384
+ ): void {
385
+ let activeHeader: PiStartupHeader | undefined;
386
+
387
+ pi.on("session_start", (_event, ctx) => {
388
+ if (ctx.mode !== "tui") return;
389
+ try {
390
+ ctx.ui.setTheme("killeros");
391
+ const startupTip = nextStartupTip();
392
+ ctx.ui.setHeader((tui) => {
393
+ activeHeader?.dispose();
394
+ activeHeader = new PiStartupHeader(pi, ctx, startupTip, tui);
395
+ return activeHeader;
396
+ });
397
+ ctx.ui.setWorkingIndicator({
398
+ frames: ACTIVITY_FRAMES.map((frame) => ctx.ui.theme.fg("accent", frame)),
399
+ intervalMs: ACTIVITY_FRAME_INTERVAL_MS,
400
+ });
401
+ ctx.ui.setHiddenThinkingLabel("└ Thinking…");
402
+ const existingEditorFactory = ctx.ui.getEditorComponent?.();
403
+ if (!existingEditorFactory || existingEditorFactory === killerosEditorFactory) {
404
+ const editorSuggestion = nextEditorSuggestion();
405
+ killerosEditorFactory = (tui, editorTheme, keybindings) =>
406
+ new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, editorSuggestion, commandResolver);
407
+ ctx.ui.setEditorComponent(killerosEditorFactory);
408
+ }
409
+ } catch (error) {
410
+ reportError(ctx, "Killeros UI failed to initialize", error);
411
+ }
412
+ });
413
+
414
+ pi.on("session_shutdown", () => {
415
+ activeHeader?.dispose();
416
+ activeHeader = undefined;
417
+ });
418
+ }