killeros 1.5.8 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFile } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import {
4
4
  CustomEditor,
@@ -11,6 +11,7 @@ import {
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import {
13
13
  Container,
14
+ CURSOR_MARKER,
14
15
  Text,
15
16
  truncateToWidth,
16
17
  visibleWidth,
@@ -18,16 +19,14 @@ import {
18
19
  type EditorTheme,
19
20
  type TUI,
20
21
  } from "@earendil-works/pi-tui";
22
+ import { availableCommandNames } from "./commands.ts";
21
23
  import { formatCwd, padRight } from "./display.ts";
22
24
  import { reportError } from "./errors.ts";
23
25
  import { formatModel } from "./footer.ts";
24
26
  import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
25
27
 
26
- const COMMAND_BLUE_RGB = "120;169;255";
27
28
  const COMPACT_HEADER_MAX_WIDTH = 52;
28
29
 
29
- const commandBlue = (text: string): string => `\x1B[38;2;${COMMAND_BLUE_RGB}m${text}\x1B[39m`;
30
-
31
30
  function readPackageVersion(path: string | URL): string | undefined {
32
31
  try {
33
32
  const value = JSON.parse(readFileSync(path, "utf8")) as { version?: unknown };
@@ -45,20 +44,27 @@ const STARTUP_TIPS = [
45
44
  "Type / to browse every command available in this session.",
46
45
  ] as const;
47
46
 
48
- function resolveGitBranch(cwd: string): string | undefined {
49
- try {
50
- const branch = execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
51
- encoding: "utf8",
52
- maxBuffer: 64 * 1024,
53
- stdio: ["ignore", "pipe", "ignore"],
54
- timeout: 500,
55
- windowsHide: true,
56
- }).trim();
57
- if (!branch) return undefined;
58
- return branch === "HEAD" ? "detached" : branch;
59
- } catch {
60
- return undefined;
61
- }
47
+ export function resolveGitBranch(cwd: string): Promise<string | undefined> {
48
+ return new Promise((resolve) => {
49
+ execFile(
50
+ "git",
51
+ ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"],
52
+ {
53
+ encoding: "utf8",
54
+ maxBuffer: 64 * 1024,
55
+ timeout: 500,
56
+ windowsHide: true,
57
+ },
58
+ (error, stdout) => {
59
+ if (error) {
60
+ resolve(undefined);
61
+ return;
62
+ }
63
+ const branch = stdout.trim();
64
+ resolve(branch ? branch === "HEAD" ? "detached" : branch : undefined);
65
+ },
66
+ );
67
+ });
62
68
  }
63
69
 
64
70
  function shuffledTips(): string[] {
@@ -78,14 +84,21 @@ function compactBoxLine(content: string, width: number, theme: Theme): string {
78
84
  class PiStartupHeader {
79
85
  private readonly pi: ExtensionAPI;
80
86
  private readonly ctx: ExtensionContext;
81
- private readonly branch: string | undefined;
82
87
  private readonly tip: string;
88
+ private readonly tui: TUI;
89
+ private branch: string | undefined;
90
+ private disposed = false;
83
91
 
84
- constructor(pi: ExtensionAPI, ctx: ExtensionContext, tip: string) {
92
+ constructor(pi: ExtensionAPI, ctx: ExtensionContext, tip: string, tui: TUI) {
85
93
  this.pi = pi;
86
94
  this.ctx = ctx;
87
- this.branch = resolveGitBranch(ctx.cwd);
88
95
  this.tip = tip;
96
+ this.tui = tui;
97
+ void resolveGitBranch(ctx.cwd).then((branch) => {
98
+ if (this.disposed) return;
99
+ this.branch = branch;
100
+ this.tui.requestRender();
101
+ });
89
102
  }
90
103
 
91
104
  private tipLines(width: number, theme: Theme): string[] {
@@ -113,7 +126,7 @@ class PiStartupHeader {
113
126
  const repository = this.branch
114
127
  ? `${directory} ${theme.fg("dim", `· ${this.branch}`)}`
115
128
  : directory;
116
- const modelCommand = commandBlue("/model");
129
+ const modelCommand = theme.fg("mdLink", "/model");
117
130
  const agentWidth = Math.max(0, innerWidth - visibleWidth(modelCommand) - 1);
118
131
  const agentCommand = `${truncateToWidth(agent, agentWidth, "…")} ${modelCommand}`;
119
132
  const border = (left: string, right: string): string => theme.fg("dim", `${left}${"─".repeat(panelWidth - 2)}${right}`);
@@ -131,10 +144,119 @@ class PiStartupHeader {
131
144
  }
132
145
 
133
146
  invalidate(): void {}
134
- dispose(): void {}
147
+ dispose(): void {
148
+ this.disposed = true;
149
+ }
135
150
  }
136
151
 
137
152
  const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
153
+ const ANSI_SEQUENCE_AT_START = /^\x1b\[[0-?]*[ -/]*[@-~]/u;
154
+ const COMMAND_TOKEN_PATTERN = /(^|[ \t])(\/[A-Za-z0-9:_-]*)/gu;
155
+
156
+ function controlSequenceAt(text: string, index: number): string | undefined {
157
+ if (text.startsWith(CURSOR_MARKER, index)) return CURSOR_MARKER;
158
+ return text.slice(index).match(ANSI_SEQUENCE_AT_START)?.[0];
159
+ }
160
+
161
+ interface CommandToken {
162
+ text: string;
163
+ start: number;
164
+ end: number;
165
+ valid: boolean;
166
+ }
167
+
168
+ interface EditorVisualLine {
169
+ logicalLine: number;
170
+ startCol: number;
171
+ length: number;
172
+ }
173
+
174
+ function commandTokens(text: string, normalizedNames: readonly string[]): CommandToken[] {
175
+ return [...text.matchAll(COMMAND_TOKEN_PATTERN)].map((match) => {
176
+ const token = match[2] ?? "";
177
+ const prefix = token.slice(1).toLocaleLowerCase();
178
+ const start = (match.index ?? 0) + (match[1]?.length ?? 0);
179
+ return {
180
+ text: token,
181
+ start,
182
+ end: start + token.length,
183
+ valid: normalizedNames.some((name) => name.startsWith(prefix)),
184
+ };
185
+ });
186
+ }
187
+
188
+ function highlightTextRanges(
189
+ text: string,
190
+ ranges: Array<{ start: number; end: number }>,
191
+ color: (value: string) => string,
192
+ ): string {
193
+ if (ranges.length === 0) return text;
194
+
195
+ let output = "";
196
+ let buffer = "";
197
+ let bufferHighlighted: boolean | undefined;
198
+ let plainIndex = 0;
199
+ const flush = (): void => {
200
+ if (!buffer) return;
201
+ output += bufferHighlighted ? color(buffer) : buffer;
202
+ buffer = "";
203
+ };
204
+
205
+ for (let index = 0; index < text.length;) {
206
+ const control = controlSequenceAt(text, index);
207
+ if (control) {
208
+ flush();
209
+ output += control;
210
+ index += control.length;
211
+ continue;
212
+ }
213
+
214
+ const highlighted = ranges.some((range) => plainIndex >= range.start && plainIndex < range.end);
215
+ if (bufferHighlighted !== highlighted) {
216
+ flush();
217
+ bufferHighlighted = highlighted;
218
+ }
219
+ buffer += text[index];
220
+ plainIndex += 1;
221
+ index += 1;
222
+ }
223
+ flush();
224
+ return output;
225
+ }
226
+
227
+ function highlightEditorLines(
228
+ lines: string[],
229
+ sourceLines: string[],
230
+ visualLines: EditorVisualLine[],
231
+ scrollOffset: number,
232
+ commandNames: ReadonlySet<string>,
233
+ color: (value: string) => string,
234
+ ): { lines: string[]; bottomBorderIndex: number } {
235
+ let bottomBorderIndex = -1;
236
+ for (let index = lines.length - 1; index >= 1; index -= 1) {
237
+ if (isBorderLine(lines[index] ?? "")) {
238
+ bottomBorderIndex = index;
239
+ break;
240
+ }
241
+ }
242
+ if (bottomBorderIndex < 0) bottomBorderIndex = lines.length - 1;
243
+
244
+ const normalizedNames = [...commandNames].map((name) => name.toLocaleLowerCase());
245
+ for (let index = 1; index < bottomBorderIndex; index += 1) {
246
+ const visualLine = visualLines[scrollOffset + index - 1];
247
+ if (!visualLine) continue;
248
+ const visibleStart = visualLine.startCol;
249
+ const visibleEnd = visibleStart + visualLine.length;
250
+ const ranges = commandTokens(sourceLines[visualLine.logicalLine] ?? "", normalizedNames)
251
+ .filter((token) => token.valid && token.start < visibleEnd && token.end > visibleStart)
252
+ .map((token) => ({
253
+ start: Math.max(token.start, visibleStart) - visibleStart,
254
+ end: Math.min(token.end, visibleEnd) - visibleStart,
255
+ }));
256
+ lines[index] = highlightTextRanges(lines[index] ?? "", ranges, color);
257
+ }
258
+ return { lines, bottomBorderIndex };
259
+ }
138
260
 
139
261
  function stripAnsi(text: string): string {
140
262
  return text.replace(ANSI_REGEX, "").trim();
@@ -145,12 +267,27 @@ function isBorderLine(line: string): boolean {
145
267
  return /^[─━═]+$/.test(unstyled) || /^───\s*[↓↑]/.test(unstyled) || /^─{3,}/.test(unstyled);
146
268
  }
147
269
 
270
+ function isScrolledTopBorder(line: string): boolean {
271
+ const unstyled = stripAnsi(line);
272
+ return unstyled.includes("↑") || unstyled.includes(".");
273
+ }
274
+
148
275
  class PiCodeEditor extends CustomEditor {
149
276
  private readonly appKeybindings: KeybindingsManager;
150
-
151
- constructor(tui: TUI, theme: EditorTheme, appKeybindings: KeybindingsManager) {
277
+ private readonly runtimeTheme: Theme;
278
+ private readonly getCommandNames: () => ReadonlySet<string>;
279
+
280
+ constructor(
281
+ tui: TUI,
282
+ theme: EditorTheme,
283
+ appKeybindings: KeybindingsManager,
284
+ runtimeTheme: Theme,
285
+ getCommandNames: () => ReadonlySet<string>,
286
+ ) {
152
287
  super(tui, theme, appKeybindings);
153
288
  this.appKeybindings = appKeybindings;
289
+ this.runtimeTheme = runtimeTheme;
290
+ this.getCommandNames = getCommandNames;
154
291
  }
155
292
 
156
293
  override handleInput(data: string): void {
@@ -167,25 +304,42 @@ class PiCodeEditor extends CustomEditor {
167
304
  super.handleInput(data);
168
305
  }
169
306
 
307
+ private renderWithCommandHighlighting(
308
+ width: number,
309
+ color: (value: string) => string,
310
+ ): { lines: string[]; bottomBorderIndex: number } {
311
+ const lines = super.render(width);
312
+ const internals = this as unknown as {
313
+ lastWidth: number;
314
+ scrollOffset: number;
315
+ buildVisualLineMap: (layoutWidth: number) => EditorVisualLine[];
316
+ };
317
+ return highlightEditorLines(
318
+ lines,
319
+ this.getLines(),
320
+ internals.buildVisualLineMap(internals.lastWidth),
321
+ internals.scrollOffset,
322
+ this.getCommandNames(),
323
+ color,
324
+ );
325
+ }
326
+
170
327
  override render(width: number): string[] {
171
- if (width < 4) return super.render(width);
328
+ if (width <= 0) return [];
329
+ const colorCommand = (value: string): string => this.runtimeTheme.fg("mdLink", value);
330
+ if (width < 4) {
331
+ return this.renderWithCommandHighlighting(width, colorCommand)
332
+ .lines.map((line) => truncateToWidth(line, width, ""));
333
+ }
172
334
  const innerWidth = width - 2;
173
- const lines = super.render(innerWidth);
335
+ const highlighted = this.renderWithCommandHighlighting(innerWidth, colorCommand);
336
+ const { lines, bottomBorderIndex } = highlighted;
174
337
  if (lines.length < 2) return lines.map((line) => truncateToWidth(line, width, ""));
175
338
 
176
- const gray = (text: string): string => `\x1B[90m${text}\x1B[39m`;
177
- let bottomBorderIndex = -1;
178
- for (let index = lines.length - 1; index >= 1; index -= 1) {
179
- if (isBorderLine(lines[index] ?? "")) {
180
- bottomBorderIndex = index;
181
- break;
182
- }
183
- }
184
- if (bottomBorderIndex < 0) bottomBorderIndex = lines.length - 1;
185
-
339
+ const gray = (text: string): string => this.runtimeTheme.fg("dim", text);
186
340
  const framed: string[] = [];
187
341
  const top = stripAnsi(lines[0] ?? "");
188
- const isScrolledHeader = top.includes("");
342
+ const isScrolledHeader = isScrolledTopBorder(lines[0] ?? "");
189
343
  if (isScrolledHeader) {
190
344
  const count = top.match(/↑\s*(\d+)/)?.[1] ?? "";
191
345
  const indicator = `${gray("─── ↑ ")}${count}${gray(" more ")}${gray("─".repeat(Math.max(0, width - 12 - count.length)))}`;
@@ -219,34 +373,50 @@ const ACTIVITY_WORDS = ["Brewing", "Pondering", "Tinkering", "Wrangling", "Noodl
219
373
 
220
374
  export function registerShellUi(pi: ExtensionAPI): void {
221
375
  let activeHeader: PiStartupHeader | undefined;
222
- let activityWordIndex = 0;
376
+ let activityDeck: string[] = [];
377
+ let lastActivityWord: string | undefined;
378
+ let activityTimer: ReturnType<typeof setInterval> | undefined;
223
379
  let tipDeck: string[] = [];
224
380
  const nextStartupTip = (): string => {
225
381
  if (tipDeck.length === 0) tipDeck = shuffledTips();
226
382
  return tipDeck.pop() ?? STARTUP_TIPS[0];
227
383
  };
384
+ const refillActivityDeck = (): void => {
385
+ activityDeck = [...ACTIVITY_WORDS];
386
+ for (let index = activityDeck.length - 1; index > 0; index -= 1) {
387
+ const swapIndex = Math.floor(Math.random() * (index + 1));
388
+ [activityDeck[index], activityDeck[swapIndex]] = [activityDeck[swapIndex]!, activityDeck[index]!];
389
+ }
390
+ if (activityDeck.length > 1 && activityDeck.at(-1) === lastActivityWord) {
391
+ [activityDeck[0], activityDeck[activityDeck.length - 1]] = [activityDeck.at(-1)!, activityDeck[0]!];
392
+ }
393
+ };
394
+ const nextActivityWord = (): string => {
395
+ if (activityDeck.length === 0) refillActivityDeck();
396
+ const word = activityDeck.pop() ?? ACTIVITY_WORDS[0];
397
+ lastActivityWord = word;
398
+ return word;
399
+ };
400
+ const clearActivityTimer = (): void => {
401
+ if (activityTimer) clearInterval(activityTimer);
402
+ activityTimer = undefined;
403
+ };
228
404
 
229
405
  pi.on("session_start", (_event, ctx) => {
230
406
  if (ctx.mode !== "tui") return;
231
407
  try {
232
408
  ctx.ui.setTheme("killeros");
233
409
  const startupTip = nextStartupTip();
234
- ctx.ui.setHeader(() => {
410
+ ctx.ui.setHeader((tui) => {
235
411
  activeHeader?.dispose();
236
- activeHeader = new PiStartupHeader(pi, ctx, startupTip);
412
+ activeHeader = new PiStartupHeader(pi, ctx, startupTip, tui);
237
413
  return activeHeader;
238
414
  });
239
- ctx.ui.setWorkingIndicator({
240
- frames: [
241
- ctx.ui.theme.fg("dim", "✻"),
242
- ctx.ui.theme.fg("muted", "✻"),
243
- ctx.ui.theme.fg("accent", "✻"),
244
- ctx.ui.theme.fg("muted", "✻"),
245
- ],
246
- intervalMs: 180,
247
- });
415
+ clearActivityTimer();
416
+ ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "✻")] });
248
417
  ctx.ui.setHiddenThinkingLabel("└ Thinking…");
249
- ctx.ui.setEditorComponent((tui, theme, keybindings) => new PiCodeEditor(tui, theme, keybindings));
418
+ ctx.ui.setEditorComponent((tui, editorTheme, keybindings) =>
419
+ new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, () => availableCommandNames(pi)));
250
420
  } catch (error) {
251
421
  reportError(ctx, "Killeros UI failed to initialize", error);
252
422
  }
@@ -254,17 +424,23 @@ export function registerShellUi(pi: ExtensionAPI): void {
254
424
 
255
425
  pi.on("agent_start", (_event, ctx) => {
256
426
  if (ctx.mode !== "tui") return;
257
- ctx.ui.setWorkingMessage(`${ACTIVITY_WORDS[activityWordIndex]}…`);
258
- activityWordIndex = (activityWordIndex + 1) % ACTIVITY_WORDS.length;
427
+ clearActivityTimer();
428
+ const updateWorkingWord = (): void => ctx.ui.setWorkingMessage(`${nextActivityWord()}…`);
429
+ updateWorkingWord();
430
+ activityTimer = setInterval(updateWorkingWord, 2_500);
431
+ activityTimer.unref?.();
259
432
  });
260
433
 
261
434
  pi.on("agent_end", (_event, ctx) => {
435
+ clearActivityTimer();
262
436
  if (ctx.mode === "tui") ctx.ui.setWorkingMessage();
263
437
  });
264
438
 
265
439
  pi.on("session_shutdown", () => {
440
+ clearActivityTimer();
266
441
  activeHeader?.dispose();
267
442
  activeHeader = undefined;
268
- activityWordIndex = 0;
443
+ activityDeck = [];
444
+ lastActivityWord = undefined;
269
445
  });
270
446
  }
@@ -1,4 +1,4 @@
1
- import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import { DynamicBorder, keyHint, type ExtensionAPI, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
3
3
 
4
4
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
@@ -119,7 +119,11 @@ export function registerVariants(pi: ExtensionAPI): void {
119
119
  selectList.onCancel = () => done(null);
120
120
  container.addChild(selectList);
121
121
  container.addChild(new Text("", 0, 0));
122
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate • Enter select • Esc cancel"), 1, 0));
122
+ container.addChild(new Text(
123
+ theme.fg("dim", `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "cancel")}`),
124
+ 1,
125
+ 0,
126
+ ));
123
127
  container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
124
128
  return {
125
129
  render: (width) => container.render(width).map((line) => truncateToWidth(line, width, "")),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "1.5.8",
3
+ "version": "2.0.1",
4
4
  "description": "A production-hardened TUI and workflow extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -22,10 +22,6 @@
22
22
  "files": [
23
23
  "Killeros.ts",
24
24
  "killeros/*.ts",
25
- "subagents.ts",
26
- "subagent-lifecycle.ts",
27
- "subagent-process.ts",
28
- "subagent-ui.ts",
29
25
  "themes/killeros.json",
30
26
  "README.md",
31
27
  "CHANGELOG.md"
@@ -49,7 +45,6 @@
49
45
  "@earendil-works/pi-ai": ">=0.82.1",
50
46
  "@earendil-works/pi-coding-agent": ">=0.82.1",
51
47
  "@earendil-works/pi-tui": ">=0.82.1",
52
- "pi-web-access": ">=0.17.1",
53
48
  "typebox": ">=1.1.38 <2"
54
49
  },
55
50
  "devDependencies": {
@@ -4,6 +4,7 @@
4
4
  "vars": {
5
5
  "coral": "#d77757",
6
6
  "coralBright": "#e58b6d",
7
+ "commandBlue": "#78a9ff",
7
8
  "canvas": "#0a0a0a",
8
9
  "surface": "#121212",
9
10
  "surfaceRaised": "#1a1a1a",
@@ -43,7 +44,7 @@
43
44
  "toolOutput": "muted",
44
45
 
45
46
  "mdHeading": "coralBright",
46
- "mdLink": "coralBright",
47
+ "mdLink": "commandBlue",
47
48
  "mdLinkUrl": "dim",
48
49
  "mdCode": "coralBright",
49
50
  "mdCodeBlock": "text",
@@ -68,8 +69,8 @@
68
69
  "syntaxPunctuation": "muted",
69
70
 
70
71
  "thinkingOff": "dim",
71
- "thinkingMinimal": "#78685f",
72
- "thinkingLow": "#98705f",
72
+ "thinkingMinimal": "#927f74",
73
+ "thinkingLow": "#a27b6a",
73
74
  "thinkingMedium": "#b27762",
74
75
  "thinkingHigh": "coral",
75
76
  "thinkingXhigh": "#d58272",