@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +4 -2
  3. package/dist/extensions/agent-browser.test.ts +195 -0
  4. package/dist/extensions/agent-browser.ts +158 -0
  5. package/dist/extensions/inline-skills.test.ts +14 -0
  6. package/dist/extensions/inline-skills.ts +14 -7
  7. package/dist/extensions/package.json +2 -1
  8. package/dist/extensions/pi-powerline-footer/.github/workflows/test.yml +27 -0
  9. package/dist/extensions/pi-powerline-footer/CHANGELOG.md +153 -0
  10. package/dist/extensions/pi-powerline-footer/CONTRIBUTING.md +16 -0
  11. package/dist/extensions/pi-powerline-footer/README.md +192 -44
  12. package/dist/extensions/pi-powerline-footer/bash-mode/completion.ts +19 -4
  13. package/dist/extensions/pi-powerline-footer/bash-mode/editor.ts +80 -25
  14. package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +81 -29
  15. package/dist/extensions/pi-powerline-footer/bash-mode/types.ts +1 -1
  16. package/dist/extensions/pi-powerline-footer/cd-command.ts +190 -0
  17. package/dist/extensions/pi-powerline-footer/colors.ts +2 -1
  18. package/dist/extensions/pi-powerline-footer/context-usage.ts +121 -6
  19. package/dist/extensions/pi-powerline-footer/currency-rates.ts +142 -0
  20. package/dist/extensions/pi-powerline-footer/editor-composition.ts +32 -0
  21. package/dist/extensions/pi-powerline-footer/git-status.ts +108 -6
  22. package/dist/extensions/pi-powerline-footer/icons.ts +11 -2
  23. package/dist/extensions/pi-powerline-footer/index.ts +1056 -324
  24. package/dist/extensions/pi-powerline-footer/lifecycle.ts +10 -0
  25. package/dist/extensions/pi-powerline-footer/package.json +10 -6
  26. package/dist/extensions/pi-powerline-footer/paths.ts +22 -0
  27. package/dist/extensions/pi-powerline-footer/powerline-config.ts +297 -15
  28. package/dist/extensions/pi-powerline-footer/presets.ts +6 -25
  29. package/dist/extensions/pi-powerline-footer/queue/store.ts +361 -0
  30. package/dist/extensions/pi-powerline-footer/queue/types.ts +54 -0
  31. package/dist/extensions/pi-powerline-footer/render-scheduler.ts +12 -7
  32. package/dist/extensions/pi-powerline-footer/segments.ts +114 -107
  33. package/dist/extensions/pi-powerline-footer/session-usage.ts +1 -1
  34. package/dist/extensions/pi-powerline-footer/shortcuts.ts +17 -3
  35. package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +189 -17
  36. package/dist/extensions/pi-powerline-footer/tests/cd-command.test.ts +144 -0
  37. package/dist/extensions/pi-powerline-footer/tests/context-usage.test.ts +120 -2
  38. package/dist/extensions/pi-powerline-footer/tests/custom-items.test.ts +245 -22
  39. package/dist/extensions/pi-powerline-footer/tests/editor-composition.test.ts +48 -0
  40. package/dist/extensions/pi-powerline-footer/tests/editor-responsiveness.test.ts +34 -0
  41. package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +39 -0
  42. package/dist/extensions/pi-powerline-footer/tests/git-status.test.ts +92 -0
  43. package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +4 -1
  44. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +33 -197
  45. package/dist/extensions/pi-powerline-footer/tests/mode-aware-trigger.test.ts +79 -0
  46. package/dist/extensions/pi-powerline-footer/tests/paths.test.ts +63 -0
  47. package/dist/extensions/pi-powerline-footer/tests/queue-store.test.ts +195 -0
  48. package/dist/extensions/pi-powerline-footer/tests/quit-cursor.test.ts +14 -0
  49. package/dist/extensions/pi-powerline-footer/tests/remaining-regressions.test.ts +167 -0
  50. package/dist/extensions/pi-powerline-footer/tests/stash-shortcut.test.ts +23 -18
  51. package/dist/extensions/pi-powerline-footer/tests/thinking-segment.test.ts +16 -1
  52. package/dist/extensions/pi-powerline-footer/tests/token-stats.test.ts +249 -0
  53. package/dist/extensions/pi-powerline-footer/tests/usage-display.test.ts +237 -0
  54. package/dist/extensions/pi-powerline-footer/tests/welcome.test.ts +199 -0
  55. package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +191 -100
  56. package/dist/extensions/pi-powerline-footer/theme.ts +32 -18
  57. package/dist/extensions/pi-powerline-footer/token-stats.ts +304 -0
  58. package/dist/extensions/pi-powerline-footer/tsconfig.json +13 -0
  59. package/dist/extensions/pi-powerline-footer/types.ts +74 -29
  60. package/dist/extensions/pi-powerline-footer/welcome-dismiss.ts +34 -0
  61. package/dist/extensions/pi-powerline-footer/welcome.ts +65 -10
  62. package/dist/extensions/pi-powerline-footer/working-vibes.ts +44 -11
  63. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  64. package/dist/extensions/pi-subagents/package.json +1 -1
  65. package/dist/extensions/pi-subagents/src/slash/inline-subagents.ts +57 -24
  66. package/dist/extensions/pi-subagents/test/unit/inline-subagents.test.ts +82 -7
  67. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +1 -1
  68. package/dist/extensions/ponytail/index.js +1 -31
  69. package/dist/extensions/ponytail/test/extension.test.js +5 -22
  70. package/dist/extensions/rtk.test.ts +27 -12
  71. package/dist/extensions/rtk.ts +41 -35
  72. 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 { complete, type Context } from "@earendil-works/pi-ai";
6
- import { getAgentDir, getSettingsPath, type ExtensionContext } from "@selesai/code";
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 join(getAgentDir(), "vibes");
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 interface GenerateVibesResult {
597
- success: boolean;
598
- count: number;
599
- filePath: string;
600
- error?: string;
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 complete(model, aiContext, { apiKey: auth.apiKey, headers: auth.headers, signal });
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.7.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.7.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.7.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` at the very start of a message, followed by end-of-line or a space. */
9
- const INLINE_AGENT_TOKEN_RE = /^#([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/i;
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 and the task text, or null when the
14
- * message does not start with a `#agent-name` token.
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 trimmed = text.trimStart();
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
- return { agentName: (match[1] ?? "").toLowerCase(), task: trimmed.slice(match[0].length).trim() };
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 (message start only). */
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(/^#([a-z0-9-]*)$/i);
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
- // Subagent invocations are whole-message actions: only trigger at the start of the first line.
37
- if (cursorLine !== 0 || !state.baseCwd) {
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[0] ?? "").slice(0, cursorCol));
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[] = discoverAgents(state.baseCwd, "both").agents
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 `#` at the start of a
68
- * message suggests installed agents; sending `#agent-name task` runs that agent
69
- * (same path as `/run agent task`) instead of sending the text to the main agent.
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
- ctx.ui.notify?.(resolved.error, "warning");
87
- return { action: "handled" };
88
- }
89
- if (!resolved.agent) {
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 { describe, it } from "node:test";
3
- import { parseInlineSubagentInput } from "../../src/slash/inline-subagents.ts";
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"), { agentName: "architect", task: "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 when the message does not start with #agent", () => {
105
+ it("returns null without a #mention", () => {
33
106
  assert.equal(parseInlineSubagentInput("review the plan"), null);
34
- assert.equal(parseInlineSubagentInput("the #architect review"), null);
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.7.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
- syncStatus(ctx);
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("status bar renders the mode and flips active on agent_start", async () => withTempConfig(async () => {
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
- sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] },
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 events.get("agent_start")({}, ctx);
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(calls, []);
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
- const toolCall = handlers.get("tool_call")?.[0];
60
- if (!toolCall) throw new Error("no tool_call handler registered");
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
- await rtkExtension(pi);
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
- await rtkExtension(pi);
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", async () => {
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
- await rtkExtension(pi);
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
- await rtkExtension(pi);
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;