killeros 2.0.8 → 2.0.10

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.
@@ -30,6 +30,19 @@ interface CommandInfo {
30
30
  syntaxHint?: string;
31
31
  }
32
32
 
33
+ export interface SlashCommandToken {
34
+ name: string;
35
+ start: number;
36
+ end: number;
37
+ }
38
+
39
+ export interface SlashCommandResolver {
40
+ clearFallbackCommands(): void;
41
+ updateFallbackCommands(items: readonly AutocompleteItem[]): void;
42
+ getCommandCatalog(baseSuggestions?: readonly AutocompleteItem[]): ReadonlyMap<string, CommandInfo>;
43
+ isValidCommand(name: string): boolean;
44
+ }
45
+
33
46
  const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
34
47
  { name: "settings", description: "Open settings menu" },
35
48
  { name: "model", description: "Select model" },
@@ -70,6 +83,87 @@ interface TaggedAutocompleteItem extends AutocompleteItem {
70
83
  killerosCommand?: string;
71
84
  }
72
85
 
86
+ const SLASH_COMMAND_PREFIX_PATTERN = /(?:^|[ \t])\/([^\s/]*)$/u;
87
+ const SLASH_COMMAND_TOKEN_PATTERN = /(?:^|[ \t])\/([^\s/]+)(?=$|[ \t])/gu;
88
+
89
+ export function getSlashCommandPrefix(line: string): { prefix: string; slashIndex: number } | undefined {
90
+ const match = SLASH_COMMAND_PREFIX_PATTERN.exec(line);
91
+ if (!match || match.index === undefined) return undefined;
92
+ const prefix = match[1] ?? "";
93
+ const slashIndex = match.index + (match[0].startsWith("/") ? 0 : 1);
94
+ return { prefix, slashIndex };
95
+ }
96
+
97
+ export function findSlashCommandTokens(line: string): SlashCommandToken[] {
98
+ const tokens: SlashCommandToken[] = [];
99
+ for (const match of line.matchAll(SLASH_COMMAND_TOKEN_PATTERN)) {
100
+ const name = match[1];
101
+ if (name === undefined || match.index === undefined) continue;
102
+ const start = match.index + (match[0].startsWith("/") ? 0 : 1);
103
+ tokens.push({ name, start, end: start + name.length + 1 });
104
+ }
105
+ return tokens;
106
+ }
107
+
108
+ function commandNameFromAutocompleteItem(item: AutocompleteItem): string {
109
+ return (item.value || item.label).replace(/^\//u, "").trim().split(/\s+/u)[0] ?? "";
110
+ }
111
+
112
+ export function createSlashCommandResolver(
113
+ pi: Pick<ExtensionAPI, "getCommands">,
114
+ ): SlashCommandResolver {
115
+ let fallbackCommands = new Map<string, string | undefined>();
116
+
117
+ const getCommandCatalog = (baseSuggestions: readonly AutocompleteItem[] = []): ReadonlyMap<string, CommandInfo> => {
118
+ const commands = new Map<string, CommandInfo>();
119
+ BUILTIN_COMMANDS.forEach((command) => commands.set(command.name, {
120
+ ...command,
121
+ category: "Built-in",
122
+ syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
123
+ }));
124
+
125
+ for (const command of pi.getCommands()) {
126
+ const category: CommandInfo["category"] = command.source === "skill"
127
+ ? "Skill"
128
+ : command.source === "prompt"
129
+ ? "Prompt"
130
+ : "Extension";
131
+ commands.set(command.name, {
132
+ name: command.name,
133
+ description: command.description,
134
+ category,
135
+ syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
136
+ });
137
+ }
138
+
139
+ const baseCommands = baseSuggestions.length > 0
140
+ ? new Map(baseSuggestions.map((item) => [commandNameFromAutocompleteItem(item), item.description] as const))
141
+ : fallbackCommands;
142
+ for (const [name, description] of baseCommands) {
143
+ if (name && !commands.has(name)) {
144
+ commands.set(name, { name, description, category: "Built-in" });
145
+ }
146
+ }
147
+ return commands;
148
+ };
149
+
150
+ return {
151
+ clearFallbackCommands() {
152
+ fallbackCommands = new Map<string, string | undefined>();
153
+ },
154
+ updateFallbackCommands(items) {
155
+ fallbackCommands = new Map(
156
+ items.map((item) => [commandNameFromAutocompleteItem(item), item.description] as const)
157
+ .filter(([name]) => Boolean(name)),
158
+ );
159
+ },
160
+ getCommandCatalog,
161
+ isValidCommand(name) {
162
+ return getCommandCatalog().has(name);
163
+ },
164
+ };
165
+ }
166
+
73
167
  function scoreCommandMatch(name: string, prefix: string): number {
74
168
  if (!prefix) return 1;
75
169
  const normalizedName = name.toLocaleLowerCase();
@@ -80,47 +174,26 @@ function scoreCommandMatch(name: string, prefix: string): number {
80
174
  return 0;
81
175
  }
82
176
 
83
- export function registerSlashAutocomplete(pi: ExtensionAPI): void {
177
+ export function registerSlashAutocomplete(
178
+ pi: ExtensionAPI,
179
+ resolver: SlashCommandResolver = createSlashCommandResolver(pi),
180
+ ): SlashCommandResolver {
84
181
  const usage = new Map<string, number>();
85
182
  pi.on("session_start", (_event, ctx) => {
86
183
  if (ctx.mode !== "tui") return;
184
+ resolver.clearFallbackCommands();
87
185
  ctx.ui.addAutocompleteProvider((current) => ({
88
186
  triggerCharacters: ["/"],
89
187
  async getSuggestions(lines, cursorLine, cursorCol, options) {
90
188
  const line = lines[cursorLine] ?? "";
91
189
  const beforeCursor = line.slice(0, cursorCol);
92
- const match = beforeCursor.match(/(?:^|[ \t])\/([^\s/]*)$/);
93
- if (!match) return current.getSuggestions(lines, cursorLine, cursorCol, options);
190
+ const prefixMatch = getSlashCommandPrefix(beforeCursor);
191
+ if (!prefixMatch) return current.getSuggestions(lines, cursorLine, cursorCol, options);
94
192
 
95
- const prefix = (match[1] ?? "").toLocaleLowerCase();
193
+ const prefix = prefixMatch.prefix.toLocaleLowerCase();
96
194
  const baseSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
97
- const commands = new Map<string, CommandInfo>();
98
- BUILTIN_COMMANDS.forEach((command) => commands.set(command.name, {
99
- ...command,
100
- category: "Built-in",
101
- syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
102
- }));
103
-
104
- for (const command of pi.getCommands()) {
105
- const category: CommandInfo["category"] = command.source === "skill"
106
- ? "Skill"
107
- : command.source === "prompt"
108
- ? "Prompt"
109
- : "Extension";
110
- commands.set(command.name, {
111
- name: command.name,
112
- description: command.description,
113
- category,
114
- syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
115
- });
116
- }
117
-
118
- for (const item of baseSuggestions?.items ?? []) {
119
- const name = (item.value || item.label).replace(/^\//, "").trim().split(/\s+/)[0] ?? "";
120
- if (name && !commands.has(name)) {
121
- commands.set(name, { name, description: item.description, category: "Built-in" });
122
- }
123
- }
195
+ resolver.updateFallbackCommands(baseSuggestions?.items ?? []);
196
+ const commands = resolver.getCommandCatalog(baseSuggestions?.items ?? []);
124
197
 
125
198
  const ranked = [...commands.values()]
126
199
  .map((command) => ({
@@ -151,9 +224,9 @@ export function registerSlashAutocomplete(pi: ExtensionAPI): void {
151
224
  const line = lines[cursorLine] ?? "";
152
225
  const beforeCursor = line.slice(0, cursorCol);
153
226
  const afterCursor = line.slice(cursorCol);
154
- const match = beforeCursor.match(/(?:^|[ \t])\/([^\s/]*)$/);
155
- if (!match || match.index === undefined) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
156
- const slashIndex = match.index + (match[0].startsWith("/") ? 0 : 1);
227
+ const prefixMatch = getSlashCommandPrefix(beforeCursor);
228
+ if (!prefixMatch) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
229
+ const slashIndex = prefixMatch.slashIndex;
157
230
  const newBefore = beforeCursor.slice(0, slashIndex) + item.value;
158
231
  const nextLines = [...lines];
159
232
  nextLines[cursorLine] = newBefore + afterCursor;
@@ -164,4 +237,5 @@ export function registerSlashAutocomplete(pi: ExtensionAPI): void {
164
237
  },
165
238
  }));
166
239
  });
240
+ return resolver;
167
241
  }
package/killeros/goals.ts CHANGED
@@ -264,7 +264,10 @@ function transitionGoal(
264
264
  resumeAfterManualCompaction: options.resumeAfterManualCompaction,
265
265
  };
266
266
  persistGoalState(pi, runtime, event, next);
267
- if (status !== "active") runtime.continuationScheduled = false;
267
+ if (status !== "active") {
268
+ runtime.continuationScheduled = false;
269
+ runtime.automaticCompaction = undefined;
270
+ }
268
271
  return next;
269
272
  }
270
273
 
@@ -272,6 +275,7 @@ function clearGoalExecutionFlags(runtime: GoalRuntime): void {
272
275
  runtime.continuationScheduled = false;
273
276
  runtime.goalTurnInFlight = false;
274
277
  runtime.agentEndObserved = false;
278
+ runtime.automaticCompaction = undefined;
275
279
  runtime.lastStopReason = undefined;
276
280
  runtime.lastError = undefined;
277
281
  }
@@ -333,6 +337,7 @@ export function pauseGoalAfterFailure(
333
337
  syncGoalUpdateTool(pi, runtime);
334
338
  runtime.persistenceRetryNeeded = true;
335
339
  runtime.continuationScheduled = false;
340
+ runtime.automaticCompaction = undefined;
336
341
  runtime.requestRender?.();
337
342
  }
338
343
  if (notify) ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
@@ -359,6 +364,7 @@ function pauseGoalForPossibleManualCompaction(
359
364
  syncGoalUpdateTool(pi, runtime);
360
365
  runtime.persistenceRetryNeeded = true;
361
366
  runtime.continuationScheduled = false;
367
+ runtime.automaticCompaction = undefined;
362
368
  runtime.requestRender?.();
363
369
  }
364
370
  ctx.ui.notify(
@@ -384,6 +390,7 @@ function recoverGoalAfterManualCompaction(
384
390
  return false;
385
391
  }
386
392
  runtime.continuationScheduled = false;
393
+ runtime.automaticCompaction = undefined;
387
394
  ctx.ui.notify("Manual compaction complete. Goal resumed.", "info");
388
395
  setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
389
396
  return true;
@@ -450,6 +457,42 @@ function scheduleGoalContinuation(
450
457
  }
451
458
  }
452
459
 
460
+ function completeAutomaticCompaction(
461
+ pi: ExtensionAPI,
462
+ runtime: GoalRuntime,
463
+ initState: InitRuntime,
464
+ ctx: ExtensionContext,
465
+ ): void {
466
+ if (runtime.automaticCompaction === undefined) return;
467
+ if (runtime.state?.status !== "active" || initState.active) {
468
+ runtime.automaticCompaction = undefined;
469
+ return;
470
+ }
471
+ runtime.automaticCompaction = "completed";
472
+ if (runtime.goalTurnInFlight || !ctx.isIdle()) return;
473
+ runtime.automaticCompaction = undefined;
474
+ setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
475
+ }
476
+
477
+ function failAutomaticCompaction(
478
+ pi: ExtensionAPI,
479
+ runtime: GoalRuntime,
480
+ ctx: ExtensionContext,
481
+ error: unknown,
482
+ ): void {
483
+ if (runtime.automaticCompaction === undefined) return;
484
+ runtime.automaticCompaction = undefined;
485
+ if (runtime.state?.status !== "active") return;
486
+ const reason = error instanceof Error ? error.message : String(error);
487
+ pauseGoalAfterFailure(
488
+ pi,
489
+ runtime,
490
+ ctx,
491
+ `automatic compaction failed: ${reason}`,
492
+ "Automatic continuation is stopped. Run /goal resume after resolving the compaction problem.",
493
+ );
494
+ }
495
+
453
496
  function goalInstructions(state: GoalState, heading: string): string {
454
497
  return [
455
498
  `# ${heading}`,
@@ -598,6 +641,7 @@ export function registerGoal(
598
641
  runtime.continuationHeld = false;
599
642
  runtime.goalTurnInFlight = false;
600
643
  runtime.agentEndObserved = false;
644
+ runtime.automaticCompaction = undefined;
601
645
  runtime.persistenceRetryNeeded = false;
602
646
  runtime.lastStopReason = undefined;
603
647
  runtime.lastError = undefined;
@@ -630,6 +674,7 @@ export function registerGoal(
630
674
  runtime.continuationHeld = false;
631
675
  runtime.goalTurnInFlight = false;
632
676
  runtime.agentEndObserved = false;
677
+ runtime.automaticCompaction = undefined;
633
678
  runtime.persistenceRetryNeeded = false;
634
679
  runtime.lastStopReason = undefined;
635
680
  runtime.lastError = undefined;
@@ -983,7 +1028,12 @@ export function registerGoalSettlement(
983
1028
  pi: ExtensionAPI,
984
1029
  runtime: GoalRuntime,
985
1030
  initState: InitRuntime,
986
- ): void {
1031
+ ): {
1032
+ isActive(ctx: ExtensionContext): boolean;
1033
+ onRequested(): void;
1034
+ onCompleted(ctx: ExtensionContext): void;
1035
+ onFailed(ctx: ExtensionContext, error: unknown): void;
1036
+ } {
987
1037
  pi.on("agent_settled", (_event, ctx) => {
988
1038
  const wasGoalTurn = runtime.goalTurnInFlight;
989
1039
  const continuationWasScheduled = runtime.continuationScheduled;
@@ -1000,6 +1050,7 @@ export function registerGoalSettlement(
1000
1050
  return;
1001
1051
  }
1002
1052
  if (!agentEndObserved) {
1053
+ if (runtime.automaticCompaction !== undefined) return;
1003
1054
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal turn ended without an agent result");
1004
1055
  return;
1005
1056
  }
@@ -1007,6 +1058,12 @@ export function registerGoalSettlement(
1007
1058
  const reason = runtime.lastError || "the agent turn was aborted";
1008
1059
  runtime.lastStopReason = undefined;
1009
1060
  runtime.lastError = undefined;
1061
+ if (runtime.automaticCompaction !== undefined) {
1062
+ if (runtime.automaticCompaction === "completed") {
1063
+ completeAutomaticCompaction(pi, runtime, initState, ctx);
1064
+ }
1065
+ return;
1066
+ }
1010
1067
  pauseGoalForPossibleManualCompaction(pi, runtime, ctx, reason);
1011
1068
  return;
1012
1069
  }
@@ -1023,7 +1080,23 @@ export function registerGoalSettlement(
1023
1080
  });
1024
1081
 
1025
1082
  pi.on("session_compact", (event, ctx) => {
1083
+ if (runtime.automaticCompaction !== undefined) {
1084
+ completeAutomaticCompaction(pi, runtime, initState, ctx);
1085
+ return;
1086
+ }
1026
1087
  if (event.reason !== "manual") return;
1027
1088
  recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
1028
1089
  });
1090
+
1091
+ return {
1092
+ isActive: (ctx: ExtensionContext): boolean => isGoalModeSupported(ctx)
1093
+ && isSavedSession(ctx)
1094
+ && runtime.state?.status === "active"
1095
+ && !initState.active,
1096
+ onRequested: (): void => {
1097
+ if (runtime.state?.status === "active") runtime.automaticCompaction = "pending";
1098
+ },
1099
+ onCompleted: (ctx: ExtensionContext): void => completeAutomaticCompaction(pi, runtime, initState, ctx),
1100
+ onFailed: (ctx: ExtensionContext, error: unknown): void => failAutomaticCompaction(pi, runtime, ctx, error),
1101
+ };
1029
1102
  }
@@ -1,12 +1,10 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { basename, dirname, join } from "node:path";
1
+ import { basename } from "node:path";
4
2
  import type { StopReason } from "@earendil-works/pi-ai";
5
3
  import {
6
- getAgentDir,
7
4
  type ExtensionAPI,
8
5
  type ExtensionContext,
9
6
  } from "@earendil-works/pi-coding-agent";
7
+ import { createKillerosSettingsStore } from "./settings.ts";
10
8
 
11
9
  export interface NotificationPreferenceStore {
12
10
  load(): boolean;
@@ -20,42 +18,14 @@ export interface CompletionNotificationDependencies {
20
18
 
21
19
  export const COMPLETION_BELL_GLYPH = "󰂚";
22
20
 
23
- const defaultSettingsPath = (): string => join(getAgentDir(), "killeros.json");
24
-
25
- type StoredSettings = Record<string, unknown>;
26
-
27
- function readStoredSettings(settingsPath: string): StoredSettings {
28
- try {
29
- const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
30
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
31
- throw new Error("KillerOS settings must contain a JSON object");
32
- }
33
- return parsed as StoredSettings;
34
- } catch (error) {
35
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
36
- throw error;
37
- }
38
- }
39
-
40
21
  export function createNotificationPreferenceStore(
41
- settingsPath = defaultSettingsPath(),
22
+ settingsPath?: string,
42
23
  ): NotificationPreferenceStore {
24
+ const settings = createKillerosSettingsStore(settingsPath);
43
25
  return {
44
- load: () => readStoredSettings(settingsPath).completionSound === true,
26
+ load: () => settings.load().completionSound === true,
45
27
  save: (enabled) => {
46
- const current = readStoredSettings(settingsPath);
47
- mkdirSync(dirname(settingsPath), { recursive: true });
48
- const temporaryPath = `${settingsPath}.${process.pid}.${randomUUID()}.tmp`;
49
- try {
50
- writeFileSync(
51
- temporaryPath,
52
- `${JSON.stringify({ ...current, completionSound: enabled }, null, 2)}\n`,
53
- { encoding: "utf8", mode: 0o600 },
54
- );
55
- renameSync(temporaryPath, settingsPath);
56
- } finally {
57
- rmSync(temporaryPath, { force: true });
58
- }
28
+ settings.update({ completionSound: enabled });
59
29
  },
60
30
  };
61
31
  }
@@ -57,6 +57,7 @@ export interface GoalRuntime {
57
57
  continuationHeld: boolean;
58
58
  goalTurnInFlight: boolean;
59
59
  agentEndObserved: boolean;
60
+ automaticCompaction?: "pending" | "completed";
60
61
  persistenceRetryNeeded: boolean;
61
62
  lastStopReason?: string;
62
63
  lastError?: string;
@@ -73,6 +74,7 @@ export function createGoalRuntime(): GoalRuntime {
73
74
  continuationHeld: false,
74
75
  goalTurnInFlight: false,
75
76
  agentEndObserved: false,
77
+ automaticCompaction: undefined,
76
78
  persistenceRetryNeeded: false,
77
79
  };
78
80
  }
@@ -0,0 +1,47 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ export type KillerosSettings = Record<string, unknown>;
7
+
8
+ export interface KillerosSettingsStore {
9
+ load(): KillerosSettings;
10
+ update(patch: Readonly<Record<string, unknown>>): void;
11
+ }
12
+
13
+ function readStoredSettings(settingsPath: string): KillerosSettings {
14
+ try {
15
+ const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
16
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
17
+ throw new Error("KillerOS settings must contain a JSON object");
18
+ }
19
+ return parsed as KillerosSettings;
20
+ } catch (error) {
21
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
22
+ throw error;
23
+ }
24
+ }
25
+
26
+ export function createKillerosSettingsStore(
27
+ settingsPath = join(getAgentDir(), "killeros.json"),
28
+ ): KillerosSettingsStore {
29
+ return {
30
+ load: () => readStoredSettings(settingsPath),
31
+ update: (patch) => {
32
+ const current = readStoredSettings(settingsPath);
33
+ mkdirSync(dirname(settingsPath), { recursive: true });
34
+ const temporaryPath = `${settingsPath}.${process.pid}.${randomUUID()}.tmp`;
35
+ try {
36
+ writeFileSync(
37
+ temporaryPath,
38
+ `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`,
39
+ { encoding: "utf8", mode: 0o600 },
40
+ );
41
+ renameSync(temporaryPath, settingsPath);
42
+ } finally {
43
+ rmSync(temporaryPath, { force: true });
44
+ }
45
+ },
46
+ };
47
+ }
@@ -10,6 +10,7 @@ import {
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import {
12
12
  CURSOR_MARKER,
13
+ stripTerminalSequences,
13
14
  truncateToWidth,
14
15
  visibleWidth,
15
16
  wrapTextWithAnsi,
@@ -17,6 +18,11 @@ import {
17
18
  type TUI,
18
19
  } from "@earendil-works/pi-tui";
19
20
  import { formatCwd, padRight } from "./display.ts";
21
+ import {
22
+ createSlashCommandResolver,
23
+ findSlashCommandTokens,
24
+ type SlashCommandResolver,
25
+ } from "./commands.ts";
20
26
  import { reportError } from "./errors.ts";
21
27
  import { formatModel } from "./footer.ts";
22
28
  import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
@@ -166,10 +172,8 @@ class PiStartupHeader {
166
172
  }
167
173
  }
168
174
 
169
- const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
170
-
171
175
  function stripAnsi(text: string): string {
172
- return text.replace(ANSI_REGEX, "").trim();
176
+ return stripTerminalSequences(text).trim();
173
177
  }
174
178
 
175
179
  function isBorderLine(line: string): boolean {
@@ -182,10 +186,98 @@ function isScrolledTopBorder(line: string): boolean {
182
186
  return unstyled.includes("↑");
183
187
  }
184
188
 
189
+ interface RenderChunk {
190
+ text: string;
191
+ plainStart: number;
192
+ isAnsi: boolean;
193
+ }
194
+
195
+ function extractTerminalSequence(text: string, position: number): { code: string; length: number } | undefined {
196
+ if (text[position] !== "\x1B") return undefined;
197
+ const next = text[position + 1];
198
+ if (next === "[") {
199
+ let end = position + 2;
200
+ while (end < text.length && !/[\x40-\x7E]/u.test(text[end] ?? "")) end += 1;
201
+ if (end < text.length) return { code: text.slice(position, end + 1), length: end + 1 - position };
202
+ return undefined;
203
+ }
204
+ if (next === "]" || next === "_") {
205
+ let end = position + 2;
206
+ while (end < text.length) {
207
+ if (text[end] === "\x07") return { code: text.slice(position, end + 1), length: end + 1 - position };
208
+ if (text[end] === "\x1B" && text[end + 1] === "\\") {
209
+ return { code: text.slice(position, end + 2), length: end + 2 - position };
210
+ }
211
+ end += 1;
212
+ }
213
+ return undefined;
214
+ }
215
+ if (next !== undefined) return { code: text.slice(position, position + 2), length: 2 };
216
+ return undefined;
217
+ }
218
+
219
+ export function highlightSlashCommands(
220
+ line: string,
221
+ isValidCommand: (name: string) => boolean,
222
+ styleCommand: (token: string) => string,
223
+ ): string {
224
+ const chunks: RenderChunk[] = [];
225
+ let plain = "";
226
+ let index = 0;
227
+ while (index < line.length) {
228
+ const ansi = extractTerminalSequence(line, index);
229
+ if (ansi) {
230
+ chunks.push({ text: ansi.code, plainStart: plain.length, isAnsi: true });
231
+ index += ansi.length;
232
+ continue;
233
+ }
234
+
235
+ const start = index;
236
+ const plainStart = plain.length;
237
+ while (index < line.length && !extractTerminalSequence(line, index)) {
238
+ plain += line[index] ?? "";
239
+ index += 1;
240
+ }
241
+ chunks.push({
242
+ text: line.slice(start, index),
243
+ plainStart,
244
+ isAnsi: false,
245
+ });
246
+ }
247
+
248
+ const tokens = findSlashCommandTokens(plain).filter((token) => isValidCommand(token.name));
249
+ if (tokens.length === 0) return line;
250
+
251
+ return chunks.map((chunk) => {
252
+ if (chunk.isAnsi) return chunk.text;
253
+ let output = "";
254
+ let offset = 0;
255
+ while (offset < chunk.text.length) {
256
+ const plainIndex = chunk.plainStart + offset;
257
+ const token = tokens.find(({ start, end }) => plainIndex >= start && plainIndex < end);
258
+ if (!token) {
259
+ const nextTokenStart = tokens.find(({ start }) => start > plainIndex)?.start;
260
+ const end = nextTokenStart === undefined
261
+ ? chunk.text.length
262
+ : Math.min(chunk.text.length, nextTokenStart - chunk.plainStart);
263
+ output += chunk.text.slice(offset, end);
264
+ offset = end;
265
+ continue;
266
+ }
267
+
268
+ const tokenEnd = Math.min(chunk.text.length, token.end - chunk.plainStart);
269
+ output += styleCommand(chunk.text.slice(offset, tokenEnd));
270
+ offset = tokenEnd;
271
+ }
272
+ return output;
273
+ }).join("");
274
+ }
275
+
185
276
  class PiCodeEditor extends CustomEditor {
186
277
  private readonly appKeybindings: KeybindingsManager;
187
278
  private readonly runtimeTheme: Theme;
188
279
  private readonly suggestion: string;
280
+ private readonly commandResolver: SlashCommandResolver;
189
281
 
190
282
  constructor(
191
283
  tui: TUI,
@@ -193,11 +285,13 @@ class PiCodeEditor extends CustomEditor {
193
285
  appKeybindings: KeybindingsManager,
194
286
  runtimeTheme: Theme,
195
287
  suggestion: string,
288
+ commandResolver: SlashCommandResolver,
196
289
  ) {
197
290
  super(tui, theme, appKeybindings);
198
291
  this.appKeybindings = appKeybindings;
199
292
  this.runtimeTheme = runtimeTheme;
200
293
  this.suggestion = suggestion;
294
+ this.commandResolver = commandResolver;
201
295
  }
202
296
 
203
297
  override handleInput(data: string): void {
@@ -248,6 +342,13 @@ class PiCodeEditor extends CustomEditor {
248
342
  const cursorMarker = this.focused ? CURSOR_MARKER : "";
249
343
  content = `${cursorMarker}\x1B[7m${dim(first)}\x1B[27m${dim(rest)}`;
250
344
  }
345
+ if (this.getText() !== "") {
346
+ content = highlightSlashCommands(
347
+ content,
348
+ (name) => this.commandResolver.isValidCommand(name),
349
+ (token) => this.runtimeTheme.fg("mdLink", token),
350
+ );
351
+ }
251
352
  rendered.push(`${prefix}${padRight(content, innerWidth)}`);
252
353
  }
253
354
 
@@ -272,7 +373,10 @@ const ACTIVITY_FRAME_INTERVAL_MS = 120;
272
373
 
273
374
  let killerosEditorFactory: ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
274
375
 
275
- export function registerShellUi(pi: ExtensionAPI): void {
376
+ export function registerShellUi(
377
+ pi: ExtensionAPI,
378
+ commandResolver: SlashCommandResolver = createSlashCommandResolver(pi),
379
+ ): void {
276
380
  let activeHeader: PiStartupHeader | undefined;
277
381
 
278
382
  pi.on("session_start", (_event, ctx) => {
@@ -294,7 +398,7 @@ export function registerShellUi(pi: ExtensionAPI): void {
294
398
  if (!existingEditorFactory || existingEditorFactory === killerosEditorFactory) {
295
399
  const editorSuggestion = nextEditorSuggestion();
296
400
  killerosEditorFactory = (tui, editorTheme, keybindings) =>
297
- new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, editorSuggestion);
401
+ new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, editorSuggestion, commandResolver);
298
402
  ctx.ui.setEditorComponent(killerosEditorFactory);
299
403
  }
300
404
  } catch (error) {