@selesai/code 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +4 -2
- package/dist/extensions/agent-browser.test.ts +195 -0
- package/dist/extensions/agent-browser.ts +158 -0
- package/dist/extensions/inline-skills.test.ts +14 -0
- package/dist/extensions/inline-skills.ts +14 -7
- package/dist/extensions/package.json +2 -1
- package/dist/extensions/pi-powerline-footer/.github/workflows/test.yml +27 -0
- package/dist/extensions/pi-powerline-footer/CHANGELOG.md +153 -0
- package/dist/extensions/pi-powerline-footer/CONTRIBUTING.md +16 -0
- package/dist/extensions/pi-powerline-footer/README.md +192 -44
- package/dist/extensions/pi-powerline-footer/bash-mode/completion.ts +19 -4
- package/dist/extensions/pi-powerline-footer/bash-mode/editor.ts +80 -25
- package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +81 -29
- package/dist/extensions/pi-powerline-footer/bash-mode/types.ts +1 -1
- package/dist/extensions/pi-powerline-footer/cd-command.ts +190 -0
- package/dist/extensions/pi-powerline-footer/colors.ts +2 -1
- package/dist/extensions/pi-powerline-footer/context-usage.ts +121 -6
- package/dist/extensions/pi-powerline-footer/currency-rates.ts +142 -0
- package/dist/extensions/pi-powerline-footer/editor-composition.ts +32 -0
- package/dist/extensions/pi-powerline-footer/git-status.ts +108 -6
- package/dist/extensions/pi-powerline-footer/icons.ts +11 -2
- package/dist/extensions/pi-powerline-footer/index.ts +1056 -324
- package/dist/extensions/pi-powerline-footer/lifecycle.ts +10 -0
- package/dist/extensions/pi-powerline-footer/package.json +10 -6
- package/dist/extensions/pi-powerline-footer/paths.ts +22 -0
- package/dist/extensions/pi-powerline-footer/powerline-config.ts +297 -15
- package/dist/extensions/pi-powerline-footer/presets.ts +6 -25
- package/dist/extensions/pi-powerline-footer/queue/store.ts +361 -0
- package/dist/extensions/pi-powerline-footer/queue/types.ts +54 -0
- package/dist/extensions/pi-powerline-footer/render-scheduler.ts +12 -7
- package/dist/extensions/pi-powerline-footer/segments.ts +114 -107
- package/dist/extensions/pi-powerline-footer/session-usage.ts +1 -1
- package/dist/extensions/pi-powerline-footer/shortcuts.ts +17 -3
- package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +189 -17
- package/dist/extensions/pi-powerline-footer/tests/cd-command.test.ts +144 -0
- package/dist/extensions/pi-powerline-footer/tests/context-usage.test.ts +120 -2
- package/dist/extensions/pi-powerline-footer/tests/custom-items.test.ts +245 -22
- package/dist/extensions/pi-powerline-footer/tests/editor-composition.test.ts +48 -0
- package/dist/extensions/pi-powerline-footer/tests/editor-responsiveness.test.ts +34 -0
- package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +39 -0
- package/dist/extensions/pi-powerline-footer/tests/git-status.test.ts +92 -0
- package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +4 -1
- package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +33 -197
- package/dist/extensions/pi-powerline-footer/tests/mode-aware-trigger.test.ts +79 -0
- package/dist/extensions/pi-powerline-footer/tests/paths.test.ts +63 -0
- package/dist/extensions/pi-powerline-footer/tests/queue-store.test.ts +195 -0
- package/dist/extensions/pi-powerline-footer/tests/quit-cursor.test.ts +14 -0
- package/dist/extensions/pi-powerline-footer/tests/remaining-regressions.test.ts +167 -0
- package/dist/extensions/pi-powerline-footer/tests/stash-shortcut.test.ts +23 -18
- package/dist/extensions/pi-powerline-footer/tests/thinking-segment.test.ts +16 -1
- package/dist/extensions/pi-powerline-footer/tests/token-stats.test.ts +249 -0
- package/dist/extensions/pi-powerline-footer/tests/usage-display.test.ts +237 -0
- package/dist/extensions/pi-powerline-footer/tests/welcome.test.ts +199 -0
- package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +191 -100
- package/dist/extensions/pi-powerline-footer/theme.ts +32 -18
- package/dist/extensions/pi-powerline-footer/token-stats.ts +304 -0
- package/dist/extensions/pi-powerline-footer/tsconfig.json +13 -0
- package/dist/extensions/pi-powerline-footer/types.ts +74 -29
- package/dist/extensions/pi-powerline-footer/welcome-dismiss.ts +34 -0
- package/dist/extensions/pi-powerline-footer/welcome.ts +65 -10
- package/dist/extensions/pi-powerline-footer/working-vibes.ts +44 -11
- package/dist/extensions/pi-subagents/package-lock.json +2 -2
- package/dist/extensions/pi-subagents/package.json +1 -1
- package/dist/extensions/pi-subagents/src/slash/inline-subagents.ts +57 -24
- package/dist/extensions/pi-subagents/test/unit/inline-subagents.test.ts +82 -7
- package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +1 -1
- package/dist/extensions/ponytail/index.js +1 -31
- package/dist/extensions/ponytail/test/extension.test.js +5 -22
- package/dist/extensions/rtk.test.ts +27 -12
- package/dist/extensions/rtk.ts +41 -35
- package/package.json +1 -1
|
@@ -2,13 +2,35 @@
|
|
|
2
2
|
// AI-generated contextual working messages that match a user's preferred theme/vibe.
|
|
3
3
|
// Uses module-level state (matching powerline-footer pattern).
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import type { AssistantMessage, Context, Model, ProviderStreamOptions } from "@earendil-works/pi-ai";
|
|
6
|
+
import { getSettingsPath, type ExtensionContext } from "@selesai/code";
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
8
8
|
import { join, dirname } from "node:path";
|
|
9
|
+
import { getAgentPath } from "./paths.ts";
|
|
9
10
|
|
|
10
11
|
type VibeMode = "generate" | "file";
|
|
11
12
|
|
|
13
|
+
// Extension-registered providers live in the model registry only: their custom `api` values
|
|
14
|
+
// are absent from pi-ai's global api table, so streaming has to go through the provider.
|
|
15
|
+
// Credential-derived base URLs are resolved per request, mirroring ModelRuntime.prepareRequest;
|
|
16
|
+
// getApiKeyAndHeaders() covers the rest of the request auth but never reports a base URL.
|
|
17
|
+
async function completeVibe(
|
|
18
|
+
providerId: string,
|
|
19
|
+
model: Model<string>,
|
|
20
|
+
context: Context,
|
|
21
|
+
options: ProviderStreamOptions,
|
|
22
|
+
): Promise<AssistantMessage> {
|
|
23
|
+
const registry = extensionCtx?.modelRegistry;
|
|
24
|
+
const provider = registry?.getProvider(providerId);
|
|
25
|
+
if (!registry || !provider) {
|
|
26
|
+
throw new Error(`Provider not registered: ${providerId}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const baseUrl = (await registry.getProviderAuth(providerId))?.auth.baseUrl;
|
|
30
|
+
const requestModel = baseUrl ? { ...model, baseUrl } : model;
|
|
31
|
+
return provider.stream(requestModel, context, options).result();
|
|
32
|
+
}
|
|
33
|
+
|
|
12
34
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
13
35
|
// Constants
|
|
14
36
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -202,7 +224,7 @@ function saveModelConfig(): boolean {
|
|
|
202
224
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
203
225
|
|
|
204
226
|
function getVibesDir(): string {
|
|
205
|
-
return
|
|
227
|
+
return getAgentPath("vibes");
|
|
206
228
|
}
|
|
207
229
|
|
|
208
230
|
function toVibeFileSlug(theme: string): string {
|
|
@@ -395,8 +417,7 @@ async function generateVibe(
|
|
|
395
417
|
}
|
|
396
418
|
|
|
397
419
|
const aiContext = buildAiContext(buildVibePrompt(ctx));
|
|
398
|
-
|
|
399
|
-
const response = await complete(model, aiContext, { apiKey: auth.apiKey, headers: auth.headers, signal });
|
|
420
|
+
const response = await completeVibe(provider, model, aiContext, { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal });
|
|
400
421
|
|
|
401
422
|
const textContent = response.content.find(c => c.type === "text");
|
|
402
423
|
if (!textContent?.text && response.stopReason === "error" && response.errorMessage) {
|
|
@@ -593,11 +614,23 @@ export function getVibeFileCount(theme: string): number {
|
|
|
593
614
|
return vibes.length;
|
|
594
615
|
}
|
|
595
616
|
|
|
596
|
-
export
|
|
597
|
-
success:
|
|
598
|
-
count:
|
|
599
|
-
|
|
600
|
-
|
|
617
|
+
export type GenerateVibesResult =
|
|
618
|
+
| { success: true; count: number; filePath: string }
|
|
619
|
+
| { success: false; count: 0; filePath: string; error: string };
|
|
620
|
+
|
|
621
|
+
export function parseVibeGenerateArgs(args: readonly string[]): { theme: string; count: number } | null {
|
|
622
|
+
if (args.length === 0) return null;
|
|
623
|
+
|
|
624
|
+
const last = args.at(-1);
|
|
625
|
+
const parsedCount = last && /^\d+$/.test(last) ? Number.parseInt(last, 10) : Number.NaN;
|
|
626
|
+
const hasCount = Number.isFinite(parsedCount) && args.length > 1;
|
|
627
|
+
const theme = hasCount ? args.slice(0, -1).join(" ") : args.join(" ");
|
|
628
|
+
if (!theme) return null;
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
theme,
|
|
632
|
+
count: hasCount ? Math.min(Math.max(Math.floor(parsedCount), 1), 500) : 100,
|
|
633
|
+
};
|
|
601
634
|
}
|
|
602
635
|
|
|
603
636
|
export async function generateVibesBatch(
|
|
@@ -641,7 +674,7 @@ export async function generateVibesBatch(
|
|
|
641
674
|
try {
|
|
642
675
|
// Use longer timeout for batch generation (30 seconds)
|
|
643
676
|
const signal = AbortSignal.timeout(30000);
|
|
644
|
-
const response = await
|
|
677
|
+
const response = await completeVibe(provider, model, aiContext, { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal });
|
|
645
678
|
|
|
646
679
|
const textContent = response.content.find(c => c.type === "text");
|
|
647
680
|
if (!textContent?.text) {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"@earendil-works/pi-agent-core": "0.81.0",
|
|
21
21
|
"@earendil-works/pi-ai": "0.81.0",
|
|
22
22
|
"@earendil-works/pi-tui": "0.81.0",
|
|
23
|
-
"@selesai/code": "0.
|
|
23
|
+
"@selesai/code": "0.8.0",
|
|
24
24
|
"@types/node": "24.13.3",
|
|
25
25
|
"typescript": "5.9.3"
|
|
26
26
|
},
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"../../..": {
|
|
49
49
|
"name": "@selesai/code",
|
|
50
|
-
"version": "0.
|
|
50
|
+
"version": "0.8.0",
|
|
51
51
|
"dev": true,
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@earendil-works/pi-agent-core": "0.83.0",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"devDependencies": {
|
|
98
98
|
"@earendil-works/pi-agent-core": "0.81.0",
|
|
99
99
|
"@earendil-works/pi-ai": "0.81.0",
|
|
100
|
-
"@selesai/code": "0.
|
|
100
|
+
"@selesai/code": "0.8.0",
|
|
101
101
|
"@earendil-works/pi-tui": "0.81.0",
|
|
102
102
|
"@types/node": "24.13.3",
|
|
103
103
|
"typescript": "5.9.3"
|
|
@@ -1,31 +1,62 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@selesai/code";
|
|
2
2
|
import type { AutocompleteItem, AutocompleteProvider } from "@earendil-works/pi-tui";
|
|
3
|
-
import { discoverAgents, resolveAgentName } from "../agents/agents.ts";
|
|
3
|
+
import { discoverAgents, resolveAgentName, type AgentConfig } from "../agents/agents.ts";
|
|
4
4
|
import type { SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
|
|
5
5
|
import type { SubagentState } from "../shared/types.ts";
|
|
6
6
|
import { launchSlashSubagent } from "./slash-commands.ts";
|
|
7
7
|
|
|
8
|
-
/** `#agent-name`
|
|
9
|
-
const INLINE_AGENT_TOKEN_RE =
|
|
8
|
+
/** `#agent-name` anywhere in a message (start, middle, or end), like `$skill-name`. */
|
|
9
|
+
const INLINE_AGENT_TOKEN_RE = /(^|[^a-z0-9_-])#([a-z0-9]+(?:-[a-z0-9]+)*)(?![a-z0-9_-])/i;
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Parse an inline subagent invocation from raw input.
|
|
13
|
-
* Returns the (lowercased) agent name
|
|
14
|
-
*
|
|
13
|
+
* Returns the (lowercased) agent name, the task text after the mention, and
|
|
14
|
+
* whether the mention is at the start of the input (leading whitespace counts).
|
|
15
|
+
* Returns null when the text contains no `#agent-name` token.
|
|
15
16
|
*/
|
|
16
|
-
export function parseInlineSubagentInput(text: string): { agentName: string; task: string } | null {
|
|
17
|
-
const
|
|
18
|
-
const match = trimmed.match(INLINE_AGENT_TOKEN_RE);
|
|
17
|
+
export function parseInlineSubagentInput(text: string): { agentName: string; task: string; atStart: boolean } | null {
|
|
18
|
+
const match = text.match(INLINE_AGENT_TOKEN_RE);
|
|
19
19
|
if (!match) return null;
|
|
20
|
-
|
|
20
|
+
const index = match.index ?? 0;
|
|
21
|
+
return {
|
|
22
|
+
agentName: (match[2] ?? "").toLowerCase(),
|
|
23
|
+
task: text.slice(index + match[0].length).trim(),
|
|
24
|
+
atStart: text.slice(0, index).trim() === "",
|
|
25
|
+
};
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
/** Text before the cursor: `#` optionally followed by a partial agent name (
|
|
28
|
+
/** Text before the cursor: `#` optionally followed by a partial agent name (anywhere on the line). */
|
|
24
29
|
function getInlineAgentToken(textBeforeCursor: string): string | undefined {
|
|
25
|
-
const match = textBeforeCursor.match(
|
|
30
|
+
const match = textBeforeCursor.match(/(?:^|[^a-z0-9_-])#([a-z0-9-]*)$/i);
|
|
26
31
|
return match ? match[1] ?? "" : undefined;
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
// Agent discovery walks builtin+user+project+package agent dirs and node_modules,
|
|
35
|
+
// which is far too slow to run synchronously on every `#` keystroke (it blocks the
|
|
36
|
+
// event loop and the editor drops results while typing). Cache per cwd with a short
|
|
37
|
+
// TTL so the picker is responsive; a failed discovery is cached as empty so a single
|
|
38
|
+
// malformed agent file cannot keep blocking the editor (nor reject its shared
|
|
39
|
+
// autocomplete request chain, which would permanently kill all autocomplete).
|
|
40
|
+
const AGENT_DISCOVERY_CACHE_TTL_MS = 5_000;
|
|
41
|
+
const agentDiscoveryCache = new Map<string, { expiry: number; agents: AgentConfig[] }>();
|
|
42
|
+
|
|
43
|
+
function discoverAgentsForPicker(cwd: string): AgentConfig[] {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const cached = agentDiscoveryCache.get(cwd);
|
|
46
|
+
if (cached && cached.expiry > now) {
|
|
47
|
+
return cached.agents;
|
|
48
|
+
}
|
|
49
|
+
let agents: AgentConfig[] = [];
|
|
50
|
+
try {
|
|
51
|
+
agents = discoverAgents(cwd, "both").agents;
|
|
52
|
+
} catch {
|
|
53
|
+
// Never let agent discovery break autocomplete; the editor's shared request
|
|
54
|
+
// chain stays poisoned forever after a single rejected getSuggestions.
|
|
55
|
+
}
|
|
56
|
+
agentDiscoveryCache.set(cwd, { expiry: now + AGENT_DISCOVERY_CACHE_TTL_MS, agents });
|
|
57
|
+
return agents;
|
|
58
|
+
}
|
|
59
|
+
|
|
29
60
|
export function createInlineSubagentAutocompleteProvider(
|
|
30
61
|
state: SubagentState,
|
|
31
62
|
current: AutocompleteProvider,
|
|
@@ -33,16 +64,17 @@ export function createInlineSubagentAutocompleteProvider(
|
|
|
33
64
|
return {
|
|
34
65
|
triggerCharacters: ["#"],
|
|
35
66
|
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
36
|
-
//
|
|
37
|
-
|
|
67
|
+
// Like the $ skill picker, # works anywhere on the current line, not just
|
|
68
|
+
// at the start of the message.
|
|
69
|
+
if (!state.baseCwd) {
|
|
38
70
|
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
39
71
|
}
|
|
40
|
-
const token = getInlineAgentToken((lines[
|
|
72
|
+
const token = getInlineAgentToken((lines[cursorLine] ?? "").slice(0, cursorCol));
|
|
41
73
|
if (token === undefined) {
|
|
42
74
|
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
43
75
|
}
|
|
44
76
|
|
|
45
|
-
const items: AutocompleteItem[] =
|
|
77
|
+
const items: AutocompleteItem[] = discoverAgentsForPicker(state.baseCwd)
|
|
46
78
|
.filter((agent) => agent.name.includes(token.toLowerCase()))
|
|
47
79
|
.map((agent) => ({
|
|
48
80
|
value: `#${agent.name}`,
|
|
@@ -64,9 +96,10 @@ export function createInlineSubagentAutocompleteProvider(
|
|
|
64
96
|
}
|
|
65
97
|
|
|
66
98
|
/**
|
|
67
|
-
* `#agent-name [task]` inline subagent invocation: typing `#`
|
|
68
|
-
* message suggests installed agents; sending
|
|
69
|
-
* (same path as `/run agent task`) instead of
|
|
99
|
+
* `#agent-name [task]` inline subagent invocation: typing `#` anywhere in a
|
|
100
|
+
* message suggests installed agents; sending a message that contains
|
|
101
|
+
* `#agent-name` runs that agent (same path as `/run agent task`) instead of
|
|
102
|
+
* sending the text to the main agent.
|
|
70
103
|
*/
|
|
71
104
|
export function registerInlineSubagentInvocation(pi: ExtensionAPI, state: SubagentState): void {
|
|
72
105
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -82,12 +115,12 @@ export function registerInlineSubagentInvocation(pi: ExtensionAPI, state: Subage
|
|
|
82
115
|
|
|
83
116
|
const agents = discoverAgents(state.baseCwd, "both").agents;
|
|
84
117
|
const resolved = resolveAgentName(parsed.agentName, agents);
|
|
85
|
-
if (resolved.error) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
ctx.ui.notify?.(`Unknown subagent: #${parsed.agentName}`, "warning");
|
|
118
|
+
if (resolved.error || !resolved.agent) {
|
|
119
|
+
// A #mention that does not resolve only consumes the input when it is an
|
|
120
|
+
// explicit invocation at the start of the message. Mid-message mentions
|
|
121
|
+
// (e.g. "issue #42") must never swallow the user's text.
|
|
122
|
+
if (!parsed.atStart) return { action: "continue" };
|
|
123
|
+
ctx.ui.notify?.(resolved.error ?? `Unknown subagent: #${parsed.agentName}`, "warning");
|
|
91
124
|
return { action: "handled" };
|
|
92
125
|
}
|
|
93
126
|
|
|
@@ -1,12 +1,67 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { after, describe, it } from "node:test";
|
|
6
|
+
import { createInlineSubagentAutocompleteProvider, parseInlineSubagentInput } from "../../src/slash/inline-subagents.ts";
|
|
7
|
+
|
|
8
|
+
const baseProvider = () => ({
|
|
9
|
+
getSuggestions: async () => null,
|
|
10
|
+
applyCompletion: () => ({}),
|
|
11
|
+
shouldTriggerFileCompletion: () => true,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("createInlineSubagentAutocompleteProvider", () => {
|
|
15
|
+
const cwd = mkdtempSync(join(tmpdir(), "inline-subagents-"));
|
|
16
|
+
after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
17
|
+
const state = { baseCwd: cwd } as never;
|
|
18
|
+
|
|
19
|
+
it("suggests installed agents after # at message start", async () => {
|
|
20
|
+
const provider = createInlineSubagentAutocompleteProvider(state, baseProvider());
|
|
21
|
+
const result = await provider.getSuggestions(["#"], 0, 1, {} as never);
|
|
22
|
+
assert.ok(result);
|
|
23
|
+
assert.equal(result.prefix, "#");
|
|
24
|
+
// Builtin agents always exist, so the picker must not be empty.
|
|
25
|
+
assert.ok(result.items.length > 0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("suggests agents for # anywhere in the message, like the $ skill picker", async () => {
|
|
29
|
+
const base = baseProvider();
|
|
30
|
+
const provider = createInlineSubagentAutocompleteProvider(state, base);
|
|
31
|
+
|
|
32
|
+
// # mid-line.
|
|
33
|
+
const mid = await provider.getSuggestions(["see #arch"], 0, 9, {} as never);
|
|
34
|
+
assert.ok(mid);
|
|
35
|
+
assert.equal(mid.prefix, "#arch");
|
|
36
|
+
assert.ok(mid.items.some((item) => item.value === "#architect"));
|
|
37
|
+
|
|
38
|
+
// # on a later line.
|
|
39
|
+
const later = await provider.getSuggestions(["", "#arch"], 1, 5, {} as never);
|
|
40
|
+
assert.ok(later);
|
|
41
|
+
assert.equal(later.prefix, "#arch");
|
|
42
|
+
assert.ok(later.items.some((item) => item.value === "#architect"));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("falls back to the base provider without a # token or before baseCwd", async () => {
|
|
46
|
+
const base = baseProvider();
|
|
47
|
+
const provider = createInlineSubagentAutocompleteProvider(state, base);
|
|
48
|
+
|
|
49
|
+
// No # token on the current line.
|
|
50
|
+
assert.equal(await provider.getSuggestions(["plain text"], 0, 10, {} as never), null);
|
|
51
|
+
// # mid-word (no boundary before it) is not a mention.
|
|
52
|
+
assert.equal(await provider.getSuggestions(["pre#arch"], 0, 9, {} as never), null);
|
|
53
|
+
// No baseCwd yet (session_start not processed) falls back too.
|
|
54
|
+
const noCwd = createInlineSubagentAutocompleteProvider({ baseCwd: "" } as never, base);
|
|
55
|
+
assert.equal(await noCwd.getSuggestions(["#"], 0, 1, {} as never), null);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
4
58
|
|
|
5
59
|
describe("parseInlineSubagentInput", () => {
|
|
6
60
|
it("parses #agent-name with a task", () => {
|
|
7
61
|
assert.deepEqual(parseInlineSubagentInput("#architect review the plan"), {
|
|
8
62
|
agentName: "architect",
|
|
9
63
|
task: "review the plan",
|
|
64
|
+
atStart: true,
|
|
10
65
|
});
|
|
11
66
|
});
|
|
12
67
|
|
|
@@ -14,24 +69,42 @@ describe("parseInlineSubagentInput", () => {
|
|
|
14
69
|
assert.deepEqual(parseInlineSubagentInput("#batch-grill-me interview the user"), {
|
|
15
70
|
agentName: "batch-grill-me",
|
|
16
71
|
task: "interview the user",
|
|
72
|
+
atStart: true,
|
|
17
73
|
});
|
|
18
74
|
});
|
|
19
75
|
|
|
20
76
|
it("parses #agent-name with no task", () => {
|
|
21
|
-
assert.deepEqual(parseInlineSubagentInput("#architect"), { agentName: "architect", task: "" });
|
|
77
|
+
assert.deepEqual(parseInlineSubagentInput("#architect"), { agentName: "architect", task: "", atStart: true });
|
|
22
78
|
});
|
|
23
79
|
|
|
24
80
|
it("is case-insensitive and lowercases the agent name", () => {
|
|
25
|
-
assert.deepEqual(parseInlineSubagentInput("#Architect plan"), { agentName: "architect", task: "plan" });
|
|
81
|
+
assert.deepEqual(parseInlineSubagentInput("#Architect plan"), { agentName: "architect", task: "plan", atStart: true });
|
|
26
82
|
});
|
|
27
83
|
|
|
28
84
|
it("allows leading whitespace", () => {
|
|
29
|
-
assert.deepEqual(parseInlineSubagentInput(" #architect plan"), {
|
|
85
|
+
assert.deepEqual(parseInlineSubagentInput(" #architect plan"), {
|
|
86
|
+
agentName: "architect",
|
|
87
|
+
task: "plan",
|
|
88
|
+
atStart: true,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("parses #agent-name mid-message and at the end, taking the text after the mention as the task", () => {
|
|
93
|
+
assert.deepEqual(parseInlineSubagentInput("can you #architect review the plan"), {
|
|
94
|
+
agentName: "architect",
|
|
95
|
+
task: "review the plan",
|
|
96
|
+
atStart: false,
|
|
97
|
+
});
|
|
98
|
+
assert.deepEqual(parseInlineSubagentInput("let's get #architect"), {
|
|
99
|
+
agentName: "architect",
|
|
100
|
+
task: "",
|
|
101
|
+
atStart: false,
|
|
102
|
+
});
|
|
30
103
|
});
|
|
31
104
|
|
|
32
|
-
it("returns null
|
|
105
|
+
it("returns null without a #mention", () => {
|
|
33
106
|
assert.equal(parseInlineSubagentInput("review the plan"), null);
|
|
34
|
-
assert.equal(parseInlineSubagentInput("
|
|
107
|
+
assert.equal(parseInlineSubagentInput("pre#architect"), null);
|
|
35
108
|
assert.equal(parseInlineSubagentInput("#"), null);
|
|
36
109
|
assert.equal(parseInlineSubagentInput("#-architect"), null);
|
|
37
110
|
});
|
|
@@ -40,6 +113,7 @@ describe("parseInlineSubagentInput", () => {
|
|
|
40
113
|
assert.deepEqual(parseInlineSubagentInput("#architectreview"), {
|
|
41
114
|
agentName: "architectreview",
|
|
42
115
|
task: "",
|
|
116
|
+
atStart: true,
|
|
43
117
|
});
|
|
44
118
|
});
|
|
45
119
|
|
|
@@ -47,6 +121,7 @@ describe("parseInlineSubagentInput", () => {
|
|
|
47
121
|
assert.deepEqual(parseInlineSubagentInput("#architect review the plan "), {
|
|
48
122
|
agentName: "architect",
|
|
49
123
|
task: "review the plan",
|
|
124
|
+
atStart: true,
|
|
50
125
|
});
|
|
51
126
|
});
|
|
52
127
|
});
|
|
@@ -26,7 +26,7 @@ const expectedHostPeerRanges = {
|
|
|
26
26
|
const expectedHostDevVersions = {
|
|
27
27
|
"@earendil-works/pi-agent-core": "0.81.0",
|
|
28
28
|
"@earendil-works/pi-ai": "0.81.0",
|
|
29
|
-
"@selesai/code": "0.
|
|
29
|
+
"@selesai/code": "0.8.0",
|
|
30
30
|
"@earendil-works/pi-tui": "0.81.0",
|
|
31
31
|
} satisfies Record<(typeof hostPeerPackages)[number], string>;
|
|
32
32
|
|
|
@@ -56,25 +56,6 @@ export { writeDefaultMode };
|
|
|
56
56
|
export default function ponytailExtension(pi) {
|
|
57
57
|
let currentMode = DEFAULT_MODE;
|
|
58
58
|
let configuredDefaultMode = getDefaultMode();
|
|
59
|
-
let isActive = false;
|
|
60
|
-
let lastCtx = null;
|
|
61
|
-
|
|
62
|
-
// -- Status bar --
|
|
63
|
-
function syncStatus(ctx) {
|
|
64
|
-
if (ctx) lastCtx = ctx;
|
|
65
|
-
const c = ctx || lastCtx;
|
|
66
|
-
if (!c?.ui?.setStatus || !c.ui.theme?.fg) return;
|
|
67
|
-
const theme = c.ui.theme;
|
|
68
|
-
if (currentMode === "off") {
|
|
69
|
-
c.ui.setStatus("ponytail", "");
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
const levelIcons = { lite: "🌿", full: "⚡", ultra: "🔥" };
|
|
73
|
-
const icon = levelIcons[currentMode] || "";
|
|
74
|
-
const label = currentMode.toUpperCase();
|
|
75
|
-
const indicator = isActive ? theme.fg("accent", "●") : theme.fg("dim", "○");
|
|
76
|
-
c.ui.setStatus("ponytail", indicator + " 🐴 " + theme.fg("muted", "ponytail: ") + theme.fg("text", icon + " " + label));
|
|
77
|
-
}
|
|
78
59
|
|
|
79
60
|
const setMode = (mode, ctx) => {
|
|
80
61
|
const normalized = normalizePersistedMode(mode);
|
|
@@ -82,7 +63,6 @@ export default function ponytailExtension(pi) {
|
|
|
82
63
|
|
|
83
64
|
currentMode = normalized;
|
|
84
65
|
pi.appendEntry("ponytail-mode", { mode: normalized });
|
|
85
|
-
syncStatus(ctx);
|
|
86
66
|
ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info");
|
|
87
67
|
};
|
|
88
68
|
|
|
@@ -168,20 +148,10 @@ export default function ponytailExtension(pi) {
|
|
|
168
148
|
const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
|
|
169
149
|
configuredDefaultMode = getDefaultMode();
|
|
170
150
|
currentMode = resolveSessionMode(entries, configuredDefaultMode);
|
|
171
|
-
|
|
151
|
+
ctx?.ui?.setStatus?.("ponytail", undefined);
|
|
172
152
|
ctx?.ui?.notify?.(`Ponytail loaded: ${currentMode}`, "info");
|
|
173
153
|
});
|
|
174
154
|
|
|
175
|
-
pi.on("agent_start", async (_event, ctx) => {
|
|
176
|
-
isActive = true;
|
|
177
|
-
syncStatus(ctx);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
pi.on("agent_end", async (_event, ctx) => {
|
|
181
|
-
isActive = false;
|
|
182
|
-
syncStatus(ctx);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
155
|
pi.on("before_agent_start", async (event) => {
|
|
186
156
|
if (!currentMode || currentMode === "off") return;
|
|
187
157
|
return { systemPrompt: `${event.systemPrompt}\n\n${getPonytailInstructions(currentMode, { compact: true })}` };
|
|
@@ -152,32 +152,15 @@ test("a request mentioning normal mode stays active", async () => withTempConfig
|
|
|
152
152
|
assert.match(result.systemPrompt, /PONYTAIL ULTRA:/);
|
|
153
153
|
}));
|
|
154
154
|
|
|
155
|
-
test("
|
|
156
|
-
const { events } = createPiHarness();
|
|
155
|
+
test("session_start clears the legacy status without rendering a replacement", async () => withTempConfig(async () => {
|
|
156
|
+
const { commands, events } = createPiHarness();
|
|
157
157
|
const statusWrites = [];
|
|
158
158
|
const ctx = createCommandContext({
|
|
159
|
-
|
|
160
|
-
ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_color, text) => text } },
|
|
159
|
+
ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }) },
|
|
161
160
|
});
|
|
162
161
|
|
|
163
162
|
await events.get("session_start")({ reason: "resume" }, ctx);
|
|
164
|
-
await
|
|
165
|
-
|
|
166
|
-
assert.equal(statusWrites.at(-2).key, "ponytail");
|
|
167
|
-
assert.match(statusWrites.at(-2).text, /○.*ULTRA/);
|
|
168
|
-
assert.match(statusWrites.at(-1).text, /●.*ULTRA/);
|
|
169
|
-
}));
|
|
170
|
-
|
|
171
|
-
test("status bar stays silent when ui lacks a theme", async () => withTempConfig(async () => {
|
|
172
|
-
const { events } = createPiHarness();
|
|
173
|
-
const calls = [];
|
|
174
|
-
const ctx = createCommandContext({
|
|
175
|
-
sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] },
|
|
176
|
-
ui: { notify() {}, setStatus: (_key, text) => calls.push(text) }, // setStatus present, theme absent
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
await events.get("session_start")({ reason: "resume" }, ctx);
|
|
180
|
-
await events.get("agent_start")({}, ctx);
|
|
163
|
+
await commands.get("ponytail").handler("ultra", ctx);
|
|
181
164
|
|
|
182
|
-
assert.deepEqual(
|
|
165
|
+
assert.deepEqual(statusWrites, [{ key: "ponytail", text: undefined }]);
|
|
183
166
|
}));
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const { ensureToolMock } = vi.hoisted(() => ({ ensureToolMock: vi.fn() }));
|
|
8
|
+
|
|
9
|
+
vi.mock("@selesai/code", async (importOriginal) => ({
|
|
10
|
+
...(await importOriginal<typeof import("@selesai/code")>()),
|
|
11
|
+
ensureTool: ensureToolMock,
|
|
12
|
+
}));
|
|
13
|
+
|
|
6
14
|
import rtkExtension from "./rtk.ts";
|
|
7
15
|
|
|
8
16
|
// The fake-binary tests need a POSIX shell shim. Windows CI cannot spawn
|
|
@@ -55,10 +63,9 @@ function makePi() {
|
|
|
55
63
|
return { pi: pi as any, handlers };
|
|
56
64
|
}
|
|
57
65
|
|
|
58
|
-
function getToolCall(handlers: Map<string, Function[]>): Function {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return toolCall;
|
|
66
|
+
async function getToolCall(handlers: Map<string, Function[]>): Promise<Function> {
|
|
67
|
+
await vi.waitFor(() => expect(handlers.has("tool_call")).toBe(true));
|
|
68
|
+
return handlers.get("tool_call")![0]!;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
let fakeDir: string;
|
|
@@ -70,6 +77,11 @@ beforeAll(() => {
|
|
|
70
77
|
process.env.PATH = `${join(fakeDir, "bin")}${oldPath ? ":" + oldPath : ""}`;
|
|
71
78
|
});
|
|
72
79
|
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
ensureToolMock.mockReset();
|
|
82
|
+
ensureToolMock.mockResolvedValue("rtk");
|
|
83
|
+
});
|
|
84
|
+
|
|
73
85
|
afterEach(() => {
|
|
74
86
|
vi.restoreAllMocks();
|
|
75
87
|
});
|
|
@@ -83,28 +95,29 @@ afterAll(() => {
|
|
|
83
95
|
posixOnly("rtk extension", () => {
|
|
84
96
|
it("registers a tool_call hook and rewrites compatible bash commands", async () => {
|
|
85
97
|
const { pi, handlers } = makePi();
|
|
86
|
-
|
|
98
|
+
rtkExtension(pi);
|
|
87
99
|
|
|
88
100
|
const event: any = { type: "tool_call", toolName: "bash", toolCallId: "c1", input: { command: "git status" } };
|
|
89
|
-
await getToolCall(handlers)(event, { signal: undefined });
|
|
101
|
+
await (await getToolCall(handlers))(event, { signal: undefined });
|
|
90
102
|
expect(event.input.command).toBe("rtk git status");
|
|
91
103
|
});
|
|
92
104
|
|
|
93
105
|
it("skips commands that already start with rtk", async () => {
|
|
94
106
|
const { pi, handlers } = makePi();
|
|
95
|
-
|
|
107
|
+
rtkExtension(pi);
|
|
96
108
|
|
|
97
109
|
const event: any = { type: "tool_call", toolName: "bash", toolCallId: "c1", input: { command: "rtk git status" } };
|
|
98
|
-
await getToolCall(handlers)(event, { signal: undefined });
|
|
110
|
+
await (await getToolCall(handlers))(event, { signal: undefined });
|
|
99
111
|
expect(event.input.command).toBe("rtk git status");
|
|
100
112
|
});
|
|
101
113
|
|
|
102
|
-
it("does not register a hook when rtk is disabled",
|
|
114
|
+
it("does not register a hook when rtk is disabled", () => {
|
|
103
115
|
process.env.RTK_DISABLED = "1";
|
|
104
116
|
try {
|
|
105
117
|
const { pi, handlers } = makePi();
|
|
106
|
-
|
|
118
|
+
rtkExtension(pi);
|
|
107
119
|
expect(handlers.has("tool_call")).toBe(false);
|
|
120
|
+
expect(ensureToolMock).not.toHaveBeenCalled();
|
|
108
121
|
} finally {
|
|
109
122
|
delete process.env.RTK_DISABLED;
|
|
110
123
|
}
|
|
@@ -115,8 +128,10 @@ posixOnly("rtk extension", () => {
|
|
|
115
128
|
const previousPath = process.env.PATH;
|
|
116
129
|
process.env.PATH = `${join(wrongDir, "bin")}${previousPath ? ":" + previousPath : ""}`;
|
|
117
130
|
try {
|
|
131
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
118
132
|
const { pi, handlers } = makePi();
|
|
119
|
-
|
|
133
|
+
rtkExtension(pi);
|
|
134
|
+
await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("rtk gain failed")));
|
|
120
135
|
expect(handlers.has("tool_call")).toBe(false);
|
|
121
136
|
} finally {
|
|
122
137
|
if (previousPath) process.env.PATH = previousPath;
|