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.
- package/CHANGELOG.md +755 -711
- package/Killeros.ts +56 -55
- package/LICENSE +21 -21
- package/README.md +381 -359
- package/killeros/activity.ts +111 -111
- package/killeros/auto-compaction.ts +384 -384
- package/killeros/bounded-text.ts +25 -25
- package/killeros/change-receipt.ts +676 -676
- package/killeros/codex-fast-state.ts +55 -55
- package/killeros/codex-fast.ts +37 -37
- package/killeros/commands.ts +259 -259
- package/killeros/display.ts +60 -60
- package/killeros/errors.ts +19 -19
- package/killeros/footer.ts +540 -540
- package/killeros/goal-command.ts +37 -26
- package/killeros/goal-interface.ts +666 -585
- package/killeros/goal-runtime.ts +519 -522
- package/killeros/goal-settlement.ts +368 -368
- package/killeros/goal-state.ts +753 -724
- package/killeros/handoff.ts +327 -327
- package/killeros/hooks.ts +443 -470
- package/killeros/notifications.ts +135 -135
- package/killeros/passive-git-status.ts +391 -408
- package/killeros/personal-instructions.ts +122 -122
- package/killeros/question-ui.ts +269 -269
- package/killeros/question.ts +242 -242
- package/killeros/runtime.ts +156 -155
- package/killeros/safe-terminal-text.ts +6 -6
- package/killeros/secret-detector.ts +19 -19
- package/killeros/settings.ts +145 -145
- package/killeros/shell-ui.ts +418 -418
- package/killeros/variants.ts +13 -13
- package/killeros/worked-for.ts +502 -502
- package/package.json +71 -69
- package/themes/killeros.json +87 -87
package/killeros/commands.ts
CHANGED
|
@@ -1,259 +1,259 @@
|
|
|
1
|
-
import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
-
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
4
|
-
|
|
5
|
-
async function confirmNewSession(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
6
|
-
if (!ctx.hasUI) return true;
|
|
7
|
-
return ctx.ui.confirm("Start new session", "Start a new session and leave the current history?");
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function registerAliases(pi: ExtensionAPI): void {
|
|
11
|
-
const startNewSession = async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
12
|
-
if (!await confirmNewSession(ctx)) return;
|
|
13
|
-
if (!ctx.isIdle()) ctx.abort();
|
|
14
|
-
await ctx.waitForIdle();
|
|
15
|
-
await ctx.newSession();
|
|
16
|
-
};
|
|
17
|
-
pi.registerCommand("clear", { description: "Start a new session after confirmation", handler: startNewSession });
|
|
18
|
-
pi.registerCommand("exit", {
|
|
19
|
-
description: "Quit Pi gracefully",
|
|
20
|
-
handler: async (_args, ctx) => {
|
|
21
|
-
if (!ctx.isIdle()) ctx.abort();
|
|
22
|
-
ctx.shutdown();
|
|
23
|
-
},
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface CommandInfo {
|
|
28
|
-
name: string;
|
|
29
|
-
description?: string;
|
|
30
|
-
category: "Built-in" | "Extension" | "Prompt" | "Skill";
|
|
31
|
-
syntaxHint?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface SlashCommandToken {
|
|
35
|
-
name: string;
|
|
36
|
-
start: number;
|
|
37
|
-
end: number;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface SlashCommandResolver {
|
|
41
|
-
clearFallbackCommands(): void;
|
|
42
|
-
updateFallbackCommands(items: readonly AutocompleteItem[]): void;
|
|
43
|
-
getCommandCatalog(baseSuggestions?: readonly AutocompleteItem[]): ReadonlyMap<string, CommandInfo>;
|
|
44
|
-
isValidCommand(name: string): boolean;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
|
|
48
|
-
{ name: "settings", description: "Open settings menu" },
|
|
49
|
-
{ name: "model", description: "Select model" },
|
|
50
|
-
{ name: "scoped-models", description: "Configure models for Ctrl+P cycling" },
|
|
51
|
-
{ name: "export", description: "Export the current session" },
|
|
52
|
-
{ name: "import", description: "Import and resume a JSONL session" },
|
|
53
|
-
{ name: "share", description: "Share the session as a secret GitHub gist" },
|
|
54
|
-
{ name: "copy", description: "Copy the last agent message" },
|
|
55
|
-
{ name: "name", description: "Set the session display name" },
|
|
56
|
-
{ name: "session", description: "Show session usage and stats" },
|
|
57
|
-
{ name: "changelog", description: "Show changelog entries" },
|
|
58
|
-
{ name: "hotkeys", description: "Show keyboard shortcuts" },
|
|
59
|
-
{ name: "fork", description: "Fork from a previous user message" },
|
|
60
|
-
{ name: "clone", description: "Duplicate the session at the current position" },
|
|
61
|
-
{ name: "tree", description: "Navigate the session tree" },
|
|
62
|
-
{ name: "trust", description: "Save the project trust decision" },
|
|
63
|
-
{ name: "login", description: "Configure provider authentication" },
|
|
64
|
-
{ name: "logout", description: "Remove provider authentication" },
|
|
65
|
-
{ name: "new", description: "Start a new session" },
|
|
66
|
-
{ name: "compact", description: "Compact the session context" },
|
|
67
|
-
{ name: "resume", description: "Resume a different session" },
|
|
68
|
-
{ name: "reload", description: "Reload extensions and resources" },
|
|
69
|
-
{ name: "quit", description: "Quit Pi" },
|
|
70
|
-
];
|
|
71
|
-
|
|
72
|
-
const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
|
|
73
|
-
"auto-compact": "/auto-compact [status|on|off|<percent>]",
|
|
74
|
-
goal: "/goal [objective|pause|resume|clear]",
|
|
75
|
-
handoff: "/handoff [next-session focus]",
|
|
76
|
-
model: "/model [provider/model]",
|
|
77
|
-
"scoped-models": "/scoped-models",
|
|
78
|
-
login: "/login [provider]",
|
|
79
|
-
export: "/export [filename]",
|
|
80
|
-
import: "/import [path]",
|
|
81
|
-
name: "/name [session-name]",
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
interface TaggedAutocompleteItem extends AutocompleteItem {
|
|
85
|
-
killerosCommand?: string;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const SLASH_COMMAND_PREFIX_PATTERN = /(?:^|[ \t])\/([^\s/]*)$/u;
|
|
89
|
-
const SLASH_COMMAND_TOKEN_PATTERN = /(?:^|[ \t])\/([^\s/]+)(?=$|[ \t])/gu;
|
|
90
|
-
|
|
91
|
-
function safeCommandName(name: string): string | undefined {
|
|
92
|
-
const safe = safeTerminalText(name).replaceAll("\n", "").trim();
|
|
93
|
-
return safe === name && safe && !/[\s/]/u.test(safe) ? safe : undefined;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function getSlashCommandPrefix(line: string): { prefix: string; slashIndex: number } | undefined {
|
|
97
|
-
const match = SLASH_COMMAND_PREFIX_PATTERN.exec(line);
|
|
98
|
-
if (!match || match.index === undefined) return undefined;
|
|
99
|
-
const prefix = match[1] ?? "";
|
|
100
|
-
const slashIndex = match.index + (match[0].startsWith("/") ? 0 : 1);
|
|
101
|
-
return { prefix, slashIndex };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function findSlashCommandTokens(line: string): SlashCommandToken[] {
|
|
105
|
-
const tokens: SlashCommandToken[] = [];
|
|
106
|
-
for (const match of line.matchAll(SLASH_COMMAND_TOKEN_PATTERN)) {
|
|
107
|
-
const name = match[1];
|
|
108
|
-
if (name === undefined || match.index === undefined) continue;
|
|
109
|
-
const start = match.index + (match[0].startsWith("/") ? 0 : 1);
|
|
110
|
-
tokens.push({ name, start, end: start + name.length + 1 });
|
|
111
|
-
}
|
|
112
|
-
return tokens;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function commandNameFromAutocompleteItem(item: AutocompleteItem): string {
|
|
116
|
-
const name = (item.value || item.label).replace(/^\//u, "").trim().split(/\s+/u)[0] ?? "";
|
|
117
|
-
return safeCommandName(name) ?? "";
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function createSlashCommandResolver(
|
|
121
|
-
pi: Pick<ExtensionAPI, "getCommands">,
|
|
122
|
-
): SlashCommandResolver {
|
|
123
|
-
let fallbackCommands = new Map<string, string | undefined>();
|
|
124
|
-
|
|
125
|
-
const getCommandCatalog = (baseSuggestions: readonly AutocompleteItem[] = []): ReadonlyMap<string, CommandInfo> => {
|
|
126
|
-
const commands = new Map<string, CommandInfo>();
|
|
127
|
-
BUILTIN_COMMANDS.forEach((command) => commands.set(command.name, {
|
|
128
|
-
...command,
|
|
129
|
-
category: "Built-in",
|
|
130
|
-
syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
|
|
131
|
-
}));
|
|
132
|
-
|
|
133
|
-
for (const command of pi.getCommands()) {
|
|
134
|
-
const name = safeCommandName(command.name);
|
|
135
|
-
if (!name) continue;
|
|
136
|
-
const category: CommandInfo["category"] = command.source === "skill"
|
|
137
|
-
? "Skill"
|
|
138
|
-
: command.source === "prompt"
|
|
139
|
-
? "Prompt"
|
|
140
|
-
: "Extension";
|
|
141
|
-
commands.set(name, {
|
|
142
|
-
name,
|
|
143
|
-
description: command.description === undefined
|
|
144
|
-
? undefined
|
|
145
|
-
: safeTerminalText(command.description).replaceAll("\n", " "),
|
|
146
|
-
category,
|
|
147
|
-
syntaxHint: COMMAND_SYNTAX_HINTS[name],
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const baseCommands = baseSuggestions.length > 0
|
|
152
|
-
? new Map(baseSuggestions.map((item) => [commandNameFromAutocompleteItem(item), item.description] as const))
|
|
153
|
-
: fallbackCommands;
|
|
154
|
-
for (const [name, description] of baseCommands) {
|
|
155
|
-
if (name && !commands.has(name)) {
|
|
156
|
-
commands.set(name, {
|
|
157
|
-
name,
|
|
158
|
-
description: description === undefined ? undefined : safeTerminalText(description).replaceAll("\n", " "),
|
|
159
|
-
category: "Built-in",
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return commands;
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
return {
|
|
167
|
-
clearFallbackCommands() {
|
|
168
|
-
fallbackCommands = new Map<string, string | undefined>();
|
|
169
|
-
},
|
|
170
|
-
updateFallbackCommands(items) {
|
|
171
|
-
fallbackCommands = new Map(
|
|
172
|
-
items.map((item) => [commandNameFromAutocompleteItem(item), item.description] as const)
|
|
173
|
-
.filter(([name]) => Boolean(name)),
|
|
174
|
-
);
|
|
175
|
-
},
|
|
176
|
-
getCommandCatalog,
|
|
177
|
-
isValidCommand(name) {
|
|
178
|
-
return getCommandCatalog().has(name);
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function scoreCommandMatch(name: string, prefix: string): number {
|
|
184
|
-
if (!prefix) return 1;
|
|
185
|
-
const normalizedName = name.toLowerCase();
|
|
186
|
-
const normalizedPrefix = prefix.toLowerCase();
|
|
187
|
-
if (normalizedName.startsWith(normalizedPrefix)) return 100;
|
|
188
|
-
if (normalizedName.split(/[:\-_]/).some((token) => token.startsWith(normalizedPrefix))) return 80;
|
|
189
|
-
if (normalizedName.includes(normalizedPrefix)) return 50;
|
|
190
|
-
return 0;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export function registerSlashAutocomplete(
|
|
194
|
-
pi: ExtensionAPI,
|
|
195
|
-
resolver: SlashCommandResolver = createSlashCommandResolver(pi),
|
|
196
|
-
): SlashCommandResolver {
|
|
197
|
-
const usage = new Map<string, number>();
|
|
198
|
-
pi.on("session_start", (_event, ctx) => {
|
|
199
|
-
if (ctx.mode !== "tui") return;
|
|
200
|
-
resolver.clearFallbackCommands();
|
|
201
|
-
ctx.ui.addAutocompleteProvider((current) => ({
|
|
202
|
-
triggerCharacters: ["/"],
|
|
203
|
-
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
204
|
-
const line = lines[cursorLine] ?? "";
|
|
205
|
-
const beforeCursor = line.slice(0, cursorCol);
|
|
206
|
-
const prefixMatch = getSlashCommandPrefix(beforeCursor);
|
|
207
|
-
if (!prefixMatch) return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
208
|
-
|
|
209
|
-
const prefix = prefixMatch.prefix.toLowerCase();
|
|
210
|
-
const baseSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
211
|
-
resolver.updateFallbackCommands(baseSuggestions?.items ?? []);
|
|
212
|
-
const commands = resolver.getCommandCatalog(baseSuggestions?.items ?? []);
|
|
213
|
-
|
|
214
|
-
const ranked = [...commands.values()]
|
|
215
|
-
.map((command) => ({
|
|
216
|
-
command,
|
|
217
|
-
score: scoreCommandMatch(command.name, prefix) + Math.min((usage.get(command.name) ?? 0) * 2, 15),
|
|
218
|
-
}))
|
|
219
|
-
.filter(({ command }) => scoreCommandMatch(command.name, prefix) > 0)
|
|
220
|
-
.sort((left, right) => right.score - left.score || left.command.name.localeCompare(right.command.name));
|
|
221
|
-
if (!ranked.length) return baseSuggestions;
|
|
222
|
-
|
|
223
|
-
return {
|
|
224
|
-
prefix: `/${prefix}`,
|
|
225
|
-
items: ranked.map(({ command }): TaggedAutocompleteItem => {
|
|
226
|
-
const syntax = command.syntaxHint ? `${command.syntaxHint} — ` : "";
|
|
227
|
-
return {
|
|
228
|
-
value: `/${command.name} `,
|
|
229
|
-
label: `/${command.name}`,
|
|
230
|
-
description: `[${command.category}] ${syntax}${command.description ?? ""}`.trim(),
|
|
231
|
-
killerosCommand: command.name,
|
|
232
|
-
};
|
|
233
|
-
}),
|
|
234
|
-
};
|
|
235
|
-
},
|
|
236
|
-
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
237
|
-
const commandName = "killerosCommand" in item && typeof item.killerosCommand === "string"
|
|
238
|
-
? item.killerosCommand
|
|
239
|
-
: undefined;
|
|
240
|
-
if (!commandName) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
241
|
-
usage.set(commandName, (usage.get(commandName) ?? 0) + 1);
|
|
242
|
-
const line = lines[cursorLine] ?? "";
|
|
243
|
-
const beforeCursor = line.slice(0, cursorCol);
|
|
244
|
-
const afterCursor = line.slice(cursorCol);
|
|
245
|
-
const prefixMatch = getSlashCommandPrefix(beforeCursor);
|
|
246
|
-
if (!prefixMatch) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
247
|
-
const slashIndex = prefixMatch.slashIndex;
|
|
248
|
-
const newBefore = beforeCursor.slice(0, slashIndex) + item.value;
|
|
249
|
-
const nextLines = [...lines];
|
|
250
|
-
nextLines[cursorLine] = newBefore + afterCursor;
|
|
251
|
-
return { lines: nextLines, cursorLine, cursorCol: newBefore.length };
|
|
252
|
-
},
|
|
253
|
-
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
254
|
-
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
255
|
-
},
|
|
256
|
-
}));
|
|
257
|
-
});
|
|
258
|
-
return resolver;
|
|
259
|
-
}
|
|
1
|
+
import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
4
|
+
|
|
5
|
+
async function confirmNewSession(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
6
|
+
if (!ctx.hasUI) return true;
|
|
7
|
+
return ctx.ui.confirm("Start new session", "Start a new session and leave the current history?");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function registerAliases(pi: ExtensionAPI): void {
|
|
11
|
+
const startNewSession = async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
12
|
+
if (!await confirmNewSession(ctx)) return;
|
|
13
|
+
if (!ctx.isIdle()) ctx.abort();
|
|
14
|
+
await ctx.waitForIdle();
|
|
15
|
+
await ctx.newSession();
|
|
16
|
+
};
|
|
17
|
+
pi.registerCommand("clear", { description: "Start a new session after confirmation", handler: startNewSession });
|
|
18
|
+
pi.registerCommand("exit", {
|
|
19
|
+
description: "Quit Pi gracefully",
|
|
20
|
+
handler: async (_args, ctx) => {
|
|
21
|
+
if (!ctx.isIdle()) ctx.abort();
|
|
22
|
+
ctx.shutdown();
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CommandInfo {
|
|
28
|
+
name: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
category: "Built-in" | "Extension" | "Prompt" | "Skill";
|
|
31
|
+
syntaxHint?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SlashCommandToken {
|
|
35
|
+
name: string;
|
|
36
|
+
start: number;
|
|
37
|
+
end: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SlashCommandResolver {
|
|
41
|
+
clearFallbackCommands(): void;
|
|
42
|
+
updateFallbackCommands(items: readonly AutocompleteItem[]): void;
|
|
43
|
+
getCommandCatalog(baseSuggestions?: readonly AutocompleteItem[]): ReadonlyMap<string, CommandInfo>;
|
|
44
|
+
isValidCommand(name: string): boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
|
|
48
|
+
{ name: "settings", description: "Open settings menu" },
|
|
49
|
+
{ name: "model", description: "Select model" },
|
|
50
|
+
{ name: "scoped-models", description: "Configure models for Ctrl+P cycling" },
|
|
51
|
+
{ name: "export", description: "Export the current session" },
|
|
52
|
+
{ name: "import", description: "Import and resume a JSONL session" },
|
|
53
|
+
{ name: "share", description: "Share the session as a secret GitHub gist" },
|
|
54
|
+
{ name: "copy", description: "Copy the last agent message" },
|
|
55
|
+
{ name: "name", description: "Set the session display name" },
|
|
56
|
+
{ name: "session", description: "Show session usage and stats" },
|
|
57
|
+
{ name: "changelog", description: "Show changelog entries" },
|
|
58
|
+
{ name: "hotkeys", description: "Show keyboard shortcuts" },
|
|
59
|
+
{ name: "fork", description: "Fork from a previous user message" },
|
|
60
|
+
{ name: "clone", description: "Duplicate the session at the current position" },
|
|
61
|
+
{ name: "tree", description: "Navigate the session tree" },
|
|
62
|
+
{ name: "trust", description: "Save the project trust decision" },
|
|
63
|
+
{ name: "login", description: "Configure provider authentication" },
|
|
64
|
+
{ name: "logout", description: "Remove provider authentication" },
|
|
65
|
+
{ name: "new", description: "Start a new session" },
|
|
66
|
+
{ name: "compact", description: "Compact the session context" },
|
|
67
|
+
{ name: "resume", description: "Resume a different session" },
|
|
68
|
+
{ name: "reload", description: "Reload extensions and resources" },
|
|
69
|
+
{ name: "quit", description: "Quit Pi" },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
|
|
73
|
+
"auto-compact": "/auto-compact [status|on|off|<percent>]",
|
|
74
|
+
goal: "/goal [objective|pause|resume|clear]",
|
|
75
|
+
handoff: "/handoff [next-session focus]",
|
|
76
|
+
model: "/model [provider/model]",
|
|
77
|
+
"scoped-models": "/scoped-models",
|
|
78
|
+
login: "/login [provider]",
|
|
79
|
+
export: "/export [filename]",
|
|
80
|
+
import: "/import [path]",
|
|
81
|
+
name: "/name [session-name]",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
interface TaggedAutocompleteItem extends AutocompleteItem {
|
|
85
|
+
killerosCommand?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SLASH_COMMAND_PREFIX_PATTERN = /(?:^|[ \t])\/([^\s/]*)$/u;
|
|
89
|
+
const SLASH_COMMAND_TOKEN_PATTERN = /(?:^|[ \t])\/([^\s/]+)(?=$|[ \t])/gu;
|
|
90
|
+
|
|
91
|
+
function safeCommandName(name: string): string | undefined {
|
|
92
|
+
const safe = safeTerminalText(name).replaceAll("\n", "").trim();
|
|
93
|
+
return safe === name && safe && !/[\s/]/u.test(safe) ? safe : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getSlashCommandPrefix(line: string): { prefix: string; slashIndex: number } | undefined {
|
|
97
|
+
const match = SLASH_COMMAND_PREFIX_PATTERN.exec(line);
|
|
98
|
+
if (!match || match.index === undefined) return undefined;
|
|
99
|
+
const prefix = match[1] ?? "";
|
|
100
|
+
const slashIndex = match.index + (match[0].startsWith("/") ? 0 : 1);
|
|
101
|
+
return { prefix, slashIndex };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function findSlashCommandTokens(line: string): SlashCommandToken[] {
|
|
105
|
+
const tokens: SlashCommandToken[] = [];
|
|
106
|
+
for (const match of line.matchAll(SLASH_COMMAND_TOKEN_PATTERN)) {
|
|
107
|
+
const name = match[1];
|
|
108
|
+
if (name === undefined || match.index === undefined) continue;
|
|
109
|
+
const start = match.index + (match[0].startsWith("/") ? 0 : 1);
|
|
110
|
+
tokens.push({ name, start, end: start + name.length + 1 });
|
|
111
|
+
}
|
|
112
|
+
return tokens;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function commandNameFromAutocompleteItem(item: AutocompleteItem): string {
|
|
116
|
+
const name = (item.value || item.label).replace(/^\//u, "").trim().split(/\s+/u)[0] ?? "";
|
|
117
|
+
return safeCommandName(name) ?? "";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createSlashCommandResolver(
|
|
121
|
+
pi: Pick<ExtensionAPI, "getCommands">,
|
|
122
|
+
): SlashCommandResolver {
|
|
123
|
+
let fallbackCommands = new Map<string, string | undefined>();
|
|
124
|
+
|
|
125
|
+
const getCommandCatalog = (baseSuggestions: readonly AutocompleteItem[] = []): ReadonlyMap<string, CommandInfo> => {
|
|
126
|
+
const commands = new Map<string, CommandInfo>();
|
|
127
|
+
BUILTIN_COMMANDS.forEach((command) => commands.set(command.name, {
|
|
128
|
+
...command,
|
|
129
|
+
category: "Built-in",
|
|
130
|
+
syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
for (const command of pi.getCommands()) {
|
|
134
|
+
const name = safeCommandName(command.name);
|
|
135
|
+
if (!name) continue;
|
|
136
|
+
const category: CommandInfo["category"] = command.source === "skill"
|
|
137
|
+
? "Skill"
|
|
138
|
+
: command.source === "prompt"
|
|
139
|
+
? "Prompt"
|
|
140
|
+
: "Extension";
|
|
141
|
+
commands.set(name, {
|
|
142
|
+
name,
|
|
143
|
+
description: command.description === undefined
|
|
144
|
+
? undefined
|
|
145
|
+
: safeTerminalText(command.description).replaceAll("\n", " "),
|
|
146
|
+
category,
|
|
147
|
+
syntaxHint: COMMAND_SYNTAX_HINTS[name],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const baseCommands = baseSuggestions.length > 0
|
|
152
|
+
? new Map(baseSuggestions.map((item) => [commandNameFromAutocompleteItem(item), item.description] as const))
|
|
153
|
+
: fallbackCommands;
|
|
154
|
+
for (const [name, description] of baseCommands) {
|
|
155
|
+
if (name && !commands.has(name)) {
|
|
156
|
+
commands.set(name, {
|
|
157
|
+
name,
|
|
158
|
+
description: description === undefined ? undefined : safeTerminalText(description).replaceAll("\n", " "),
|
|
159
|
+
category: "Built-in",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return commands;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
clearFallbackCommands() {
|
|
168
|
+
fallbackCommands = new Map<string, string | undefined>();
|
|
169
|
+
},
|
|
170
|
+
updateFallbackCommands(items) {
|
|
171
|
+
fallbackCommands = new Map(
|
|
172
|
+
items.map((item) => [commandNameFromAutocompleteItem(item), item.description] as const)
|
|
173
|
+
.filter(([name]) => Boolean(name)),
|
|
174
|
+
);
|
|
175
|
+
},
|
|
176
|
+
getCommandCatalog,
|
|
177
|
+
isValidCommand(name) {
|
|
178
|
+
return getCommandCatalog().has(name);
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function scoreCommandMatch(name: string, prefix: string): number {
|
|
184
|
+
if (!prefix) return 1;
|
|
185
|
+
const normalizedName = name.toLowerCase();
|
|
186
|
+
const normalizedPrefix = prefix.toLowerCase();
|
|
187
|
+
if (normalizedName.startsWith(normalizedPrefix)) return 100;
|
|
188
|
+
if (normalizedName.split(/[:\-_]/).some((token) => token.startsWith(normalizedPrefix))) return 80;
|
|
189
|
+
if (normalizedName.includes(normalizedPrefix)) return 50;
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function registerSlashAutocomplete(
|
|
194
|
+
pi: ExtensionAPI,
|
|
195
|
+
resolver: SlashCommandResolver = createSlashCommandResolver(pi),
|
|
196
|
+
): SlashCommandResolver {
|
|
197
|
+
const usage = new Map<string, number>();
|
|
198
|
+
pi.on("session_start", (_event, ctx) => {
|
|
199
|
+
if (ctx.mode !== "tui") return;
|
|
200
|
+
resolver.clearFallbackCommands();
|
|
201
|
+
ctx.ui.addAutocompleteProvider((current) => ({
|
|
202
|
+
triggerCharacters: ["/"],
|
|
203
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
204
|
+
const line = lines[cursorLine] ?? "";
|
|
205
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
206
|
+
const prefixMatch = getSlashCommandPrefix(beforeCursor);
|
|
207
|
+
if (!prefixMatch) return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
208
|
+
|
|
209
|
+
const prefix = prefixMatch.prefix.toLowerCase();
|
|
210
|
+
const baseSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
211
|
+
resolver.updateFallbackCommands(baseSuggestions?.items ?? []);
|
|
212
|
+
const commands = resolver.getCommandCatalog(baseSuggestions?.items ?? []);
|
|
213
|
+
|
|
214
|
+
const ranked = [...commands.values()]
|
|
215
|
+
.map((command) => ({
|
|
216
|
+
command,
|
|
217
|
+
score: scoreCommandMatch(command.name, prefix) + Math.min((usage.get(command.name) ?? 0) * 2, 15),
|
|
218
|
+
}))
|
|
219
|
+
.filter(({ command }) => scoreCommandMatch(command.name, prefix) > 0)
|
|
220
|
+
.sort((left, right) => right.score - left.score || left.command.name.localeCompare(right.command.name));
|
|
221
|
+
if (!ranked.length) return baseSuggestions;
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
prefix: `/${prefix}`,
|
|
225
|
+
items: ranked.map(({ command }): TaggedAutocompleteItem => {
|
|
226
|
+
const syntax = command.syntaxHint ? `${command.syntaxHint} — ` : "";
|
|
227
|
+
return {
|
|
228
|
+
value: `/${command.name} `,
|
|
229
|
+
label: `/${command.name}`,
|
|
230
|
+
description: `[${command.category}] ${syntax}${command.description ?? ""}`.trim(),
|
|
231
|
+
killerosCommand: command.name,
|
|
232
|
+
};
|
|
233
|
+
}),
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
237
|
+
const commandName = "killerosCommand" in item && typeof item.killerosCommand === "string"
|
|
238
|
+
? item.killerosCommand
|
|
239
|
+
: undefined;
|
|
240
|
+
if (!commandName) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
241
|
+
usage.set(commandName, (usage.get(commandName) ?? 0) + 1);
|
|
242
|
+
const line = lines[cursorLine] ?? "";
|
|
243
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
244
|
+
const afterCursor = line.slice(cursorCol);
|
|
245
|
+
const prefixMatch = getSlashCommandPrefix(beforeCursor);
|
|
246
|
+
if (!prefixMatch) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
247
|
+
const slashIndex = prefixMatch.slashIndex;
|
|
248
|
+
const newBefore = beforeCursor.slice(0, slashIndex) + item.value;
|
|
249
|
+
const nextLines = [...lines];
|
|
250
|
+
nextLines[cursorLine] = newBefore + afterCursor;
|
|
251
|
+
return { lines: nextLines, cursorLine, cursorCol: newBefore.length };
|
|
252
|
+
},
|
|
253
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
254
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
255
|
+
},
|
|
256
|
+
}));
|
|
257
|
+
});
|
|
258
|
+
return resolver;
|
|
259
|
+
}
|
package/killeros/display.ts
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
import os from "node:os";
|
|
2
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
4
|
-
|
|
5
|
-
/** Formats a terminal-safe, single-line project path with home abbreviation. */
|
|
6
|
-
export function formatCwd(cwd: string): string {
|
|
7
|
-
const safeCwd = safeTerminalText(cwd).replaceAll("\n", "");
|
|
8
|
-
const home = safeTerminalText(process.env.HOME || process.env.USERPROFILE || os.homedir()).replaceAll("\n", "");
|
|
9
|
-
if (!home) return safeCwd;
|
|
10
|
-
const normalizedHome = home.replace(/[\\/]+$/, "");
|
|
11
|
-
const normalizedCwd = safeCwd.replace(/[\\/]+$/, "");
|
|
12
|
-
const comparedHome =
|
|
13
|
-
const comparedCwd =
|
|
14
|
-
if (comparedCwd === comparedHome) return "~";
|
|
15
|
-
const separator = normalizedCwd.slice(normalizedHome.length, normalizedHome.length + 1);
|
|
16
|
-
return comparedCwd.startsWith(comparedHome) && (separator === "/" || separator === "\\")
|
|
17
|
-
? `~${normalizedCwd.slice(normalizedHome.length)}`
|
|
18
|
-
: safeCwd;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function padRight(text: string, width: number): string {
|
|
22
|
-
if (width <= 0) return "";
|
|
23
|
-
const clipped = truncateToWidth(text, width, "");
|
|
24
|
-
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function formatTime(milliseconds: number): string {
|
|
28
|
-
if (!Number.isFinite(milliseconds)) return "0s";
|
|
29
|
-
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
30
|
-
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
31
|
-
|
|
32
|
-
const seconds = totalSeconds % 60;
|
|
33
|
-
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
34
|
-
if (totalMinutes < 60) return `${totalMinutes}m ${seconds.toString().padStart(2, "0")}s`;
|
|
35
|
-
|
|
36
|
-
const hours = Math.floor(totalMinutes / 60);
|
|
37
|
-
const minutes = totalMinutes % 60;
|
|
38
|
-
return `${hours}h ${minutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function formatTokens(value: number): string {
|
|
42
|
-
if (!Number.isFinite(value)) return "0";
|
|
43
|
-
const rounded = Math.round(Math.max(0, value));
|
|
44
|
-
if (rounded < 1_000) return `${rounded}`;
|
|
45
|
-
if (rounded >= 1_000_000) {
|
|
46
|
-
const precision = rounded >= 10_000_000 ? 0 : 1;
|
|
47
|
-
return `${Number((rounded / 1_000_000).toFixed(precision))}M`;
|
|
48
|
-
}
|
|
49
|
-
const precision = rounded >= 100_000 ? 0 : 1;
|
|
50
|
-
const inK = Number((rounded / 1_000).toFixed(precision));
|
|
51
|
-
if (inK >= 1_000) return `${Number((rounded / 1_000_000).toFixed(1))}M`;
|
|
52
|
-
return `${inK}k`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Resolves a lowercase model id for display, falling back to the name. */
|
|
56
|
-
export function modelDisplayName(model: { name?: string; id?: string }): string {
|
|
57
|
-
const id = safeTerminalText(model.id ?? "").replaceAll("\n", "").trim();
|
|
58
|
-
if (id) return id.toLowerCase();
|
|
59
|
-
return safeTerminalText(model.name ?? "").replaceAll("\n", "").trim().toLowerCase();
|
|
60
|
-
}
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
4
|
+
|
|
5
|
+
/** Formats a terminal-safe, single-line project path with home abbreviation. */
|
|
6
|
+
export function formatCwd(cwd: string): string {
|
|
7
|
+
const safeCwd = safeTerminalText(cwd).replaceAll("\n", "");
|
|
8
|
+
const home = safeTerminalText(process.env.HOME || process.env.USERPROFILE || os.homedir()).replaceAll("\n", "");
|
|
9
|
+
if (!home) return safeCwd;
|
|
10
|
+
const normalizedHome = home.replace(/[\\/]+$/, "");
|
|
11
|
+
const normalizedCwd = safeCwd.replace(/[\\/]+$/, "");
|
|
12
|
+
const comparedHome = normalizedHome.toLowerCase();
|
|
13
|
+
const comparedCwd = normalizedCwd.toLowerCase();
|
|
14
|
+
if (comparedCwd === comparedHome) return "~";
|
|
15
|
+
const separator = normalizedCwd.slice(normalizedHome.length, normalizedHome.length + 1);
|
|
16
|
+
return comparedCwd.startsWith(comparedHome) && (separator === "/" || separator === "\\")
|
|
17
|
+
? `~${normalizedCwd.slice(normalizedHome.length)}`
|
|
18
|
+
: safeCwd;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function padRight(text: string, width: number): string {
|
|
22
|
+
if (width <= 0) return "";
|
|
23
|
+
const clipped = truncateToWidth(text, width, "");
|
|
24
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatTime(milliseconds: number): string {
|
|
28
|
+
if (!Number.isFinite(milliseconds)) return "0s";
|
|
29
|
+
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
30
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
31
|
+
|
|
32
|
+
const seconds = totalSeconds % 60;
|
|
33
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
34
|
+
if (totalMinutes < 60) return `${totalMinutes}m ${seconds.toString().padStart(2, "0")}s`;
|
|
35
|
+
|
|
36
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
37
|
+
const minutes = totalMinutes % 60;
|
|
38
|
+
return `${hours}h ${minutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function formatTokens(value: number): string {
|
|
42
|
+
if (!Number.isFinite(value)) return "0";
|
|
43
|
+
const rounded = Math.round(Math.max(0, value));
|
|
44
|
+
if (rounded < 1_000) return `${rounded}`;
|
|
45
|
+
if (rounded >= 1_000_000) {
|
|
46
|
+
const precision = rounded >= 10_000_000 ? 0 : 1;
|
|
47
|
+
return `${Number((rounded / 1_000_000).toFixed(precision))}M`;
|
|
48
|
+
}
|
|
49
|
+
const precision = rounded >= 100_000 ? 0 : 1;
|
|
50
|
+
const inK = Number((rounded / 1_000).toFixed(precision));
|
|
51
|
+
if (inK >= 1_000) return `${Number((rounded / 1_000_000).toFixed(1))}M`;
|
|
52
|
+
return `${inK}k`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolves a lowercase model id for display, falling back to the name. */
|
|
56
|
+
export function modelDisplayName(model: { name?: string; id?: string }): string {
|
|
57
|
+
const id = safeTerminalText(model.id ?? "").replaceAll("\n", "").trim();
|
|
58
|
+
if (id) return id.toLowerCase();
|
|
59
|
+
return safeTerminalText(model.name ?? "").replaceAll("\n", "").trim().toLowerCase();
|
|
60
|
+
}
|