pi-advisor-flow 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Philip Brembeck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # pi-advisor
2
+
3
+ An on-demand Advisor model flow for autonomous **Pi** coding agents.
4
+
5
+ This extension introduces a strategic "Executor/Advisor" workflow, inspired by Claudes [Advisor](https://code.claude.com/docs/en/advisor).
6
+
7
+ The primary agent (the Executor) acts, writes code, and executes tools. Whenever the Executor encounters high risk, ambiguity, or potential loops, it MUST escalate the scenario to a smarter, second-opinion LLM (the Advisor) for strategic guidance.
8
+
9
+ [Read more about it here](https://philipbrembeck.com/writings/2026/07/blog/2026/07/only-as-much-intelligence-as-you-need).
10
+
11
+ ## Installation
12
+
13
+ Install the package directly into your global Pi agent environment:
14
+
15
+ ### From NPM
16
+
17
+ ```bash
18
+ pi install npm:pi-advisor-flow
19
+ ```
20
+
21
+ ### From Git
22
+
23
+ ```bash
24
+ pi install git:github.com/philipbrembeck/pi-advisor.git
25
+ ```
26
+
27
+ ### From Local Folder (For Development)
28
+
29
+ ```bash
30
+ pi install /path/to/pi-advisor
31
+ ```
32
+
33
+ ## Usage & Commands
34
+
35
+ Once installed, the following commands are available inside the Pi terminal:
36
+
37
+ ### `/advisor`
38
+
39
+ Enables the Advisor flow. Switches the primary model to the configured Executor model and registers the `ask_advisor` tool.
40
+
41
+ - _Example:_ `/advisor executor=anthropic/claude-sonnet-5 advisor=openai/gpt-5.6-sol`
42
+
43
+ ### `/advisor-models`
44
+
45
+ Opens an interactive, scrollable fuzzy-search picker in the TUI to choose:
46
+
47
+ 1. Executor Model & Reasoning Effort
48
+ 2. Advisor Model & Reasoning Effort
49
+
50
+ Saves and persists your configuration to `~/.pi/agent/advisor.json`.
51
+
52
+ ### `/advisor-off`
53
+
54
+ Disables the Advisor flow, removing the `ask_advisor` tool from the active session.
55
+
56
+ ## Local Development
57
+
58
+ `pi-advisor` uses Bun for rapid testing and TypeScript. Standard commands apply:
59
+
60
+ ### 1. Clone the repository
61
+
62
+ ```bash
63
+ git clone git@github.com:philipbrembeck/pi-advisor.git
64
+ cd pi-advisor
65
+ ```
66
+
67
+ ### 2. Install dependencies
68
+
69
+ ```bash
70
+ bun install
71
+ ```
72
+
73
+ ### 3. Run type-checks & tests
74
+
75
+ Verify code-splitting correctness and registration logic:
76
+
77
+ ```bash
78
+ bun test # Run unit tests
79
+ npm run typecheck # Perform strict TS checks
80
+ ```
@@ -0,0 +1,8 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerAdvisorTool } from "../src/tools.js";
3
+ import { registerCommands } from "../src/commands.js";
4
+
5
+ export default function (pi: ExtensionAPI) {
6
+ registerAdvisorTool(pi);
7
+ registerCommands(pi);
8
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "pi-advisor-flow",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+ssh://git@github.com/philipbrembeck/pi-advisor.git"
8
+ },
9
+ "description": "Executor/Advisor flow for autonomous pi coding agents",
10
+ "type": "module",
11
+ "main": "extensions/index.ts",
12
+ "types": "extensions/index.ts",
13
+ "keywords": [
14
+ "pi-package"
15
+ ],
16
+ "files": [
17
+ "extensions",
18
+ "src",
19
+ "README.md"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./extensions/index.ts"
24
+ ]
25
+ },
26
+ "scripts": {
27
+ "test": "bun test",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-ai": "^0.80.7",
32
+ "@earendil-works/pi-coding-agent": "^0.80.7",
33
+ "@earendil-works/pi-tui": "^0.80.7",
34
+ "typebox": "^1.1.38"
35
+ },
36
+ "devDependencies": {
37
+ "@earendil-works/pi-ai": "^0.80.7",
38
+ "@earendil-works/pi-coding-agent": "^0.80.7",
39
+ "@earendil-works/pi-tui": "^0.80.7",
40
+ "typebox": "^1.1.38",
41
+ "@types/node": "^20.11.0",
42
+ "bun-types": "^1.0.0",
43
+ "typescript": "^5.3.3"
44
+ }
45
+ }
@@ -0,0 +1,70 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ advisorRef, advisorEffortRef, executorRef, executorEffortRef,
4
+ setAdvisorRef, setAdvisorEffortRef, setExecutorRef, setExecutorEffortRef,
5
+ loadConfig, saveConfig, parseArgs, splitRef,
6
+ } from "./config.js";
7
+ import { SearchableModelSelector } from "./ui.js";
8
+
9
+ const EFFORT_LEVELS = ["Default (Model Default)", "off", "minimal", "low", "medium", "high", "xhigh", "max"];
10
+
11
+ export const registerCommands = (pi: ExtensionAPI) => {
12
+ const flowEnabled = () => pi.getActiveTools().includes("ask_advisor");
13
+
14
+ pi.registerCommand("advisor", {
15
+ description: "Enable the Executor/Advisor flow and switch to the configured Executor model",
16
+ handler: async (args, ctx) => {
17
+ loadConfig(ctx);
18
+ parseArgs(args);
19
+ const [provider, modelId] = splitRef(executorRef);
20
+ const executor = ctx.modelRegistry.find(provider, modelId);
21
+ if (!executor) return ctx.hasUI ? ctx.ui.notify(`Executor model not found: ${executorRef}`, "error") : undefined;
22
+ const [ap, am] = splitRef(advisorRef);
23
+ if (!ctx.modelRegistry.find(ap, am)) return ctx.hasUI ? ctx.ui.notify(`Advisor model not found: ${advisorRef}`, "error") : undefined;
24
+ if (!(await pi.setModel(executor))) return ctx.hasUI ? ctx.ui.notify(`No API key for Executor ${executorRef}`, "error") : undefined;
25
+ if (executorEffortRef) pi.setThinkingLevel(executorEffortRef as any);
26
+ if (!flowEnabled()) pi.setActiveTools([...pi.getActiveTools(), "ask_advisor"]);
27
+ if (ctx.hasUI) ctx.ui.notify(`Advisor flow ready — Executor: ${executorRef} (thinking: ${executorEffortRef || "default"}) · Advisor: ${advisorRef} (thinking: ${advisorEffortRef || "default"})`, "info");
28
+ },
29
+ });
30
+
31
+ pi.registerCommand("advisor-models", {
32
+ description: "Select and persist the Executor and Advisor models with reasoning levels",
33
+ handler: async (_args, ctx) => {
34
+ if (!ctx.hasUI) return;
35
+ const refs = ctx.modelRegistry.getAvailable().map((m) => `${m.provider}/${m.id}`);
36
+
37
+ const executor = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
38
+ new SearchableModelSelector({ tui, title: "Select Executor Model", allOptions: refs, theme, keybindings, onSelect: done, onCancel: () => done(undefined) })
39
+ );
40
+ if (!executor) return;
41
+
42
+ const executorEffort = await ctx.ui.select("Select Executor Reasoning/Thinking Level", EFFORT_LEVELS);
43
+ if (!executorEffort) return;
44
+
45
+ const advisor = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
46
+ new SearchableModelSelector({ tui, title: "Select Advisor Model", allOptions: refs, theme, keybindings, onSelect: done, onCancel: () => done(undefined) })
47
+ );
48
+ if (!advisor) return;
49
+
50
+ const advisorEffort = await ctx.ui.select("Select Advisor Reasoning/Thinking Level", EFFORT_LEVELS);
51
+ if (!advisorEffort) return;
52
+
53
+ setExecutorRef(executor);
54
+ setAdvisorRef(advisor);
55
+ setExecutorEffortRef(executorEffort === "Default (Model Default)" ? undefined : executorEffort);
56
+ setAdvisorEffortRef(advisorEffort === "Default (Model Default)" ? undefined : advisorEffort);
57
+
58
+ const path = saveConfig(ctx);
59
+ ctx.ui.notify(`Saved Executor + Advisor configurations to ${path}`, "info");
60
+ },
61
+ });
62
+
63
+ pi.registerCommand("advisor-off", {
64
+ description: "Disable on-demand Advisor calls; keep the current model",
65
+ handler: async (_args, ctx) => {
66
+ pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "ask_advisor"));
67
+ if (ctx.hasUI) ctx.ui.notify("Advisor flow disabled. Current model unchanged.", "info");
68
+ },
69
+ });
70
+ };
package/src/config.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONFIG_DIR_NAME, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ export const FALLBACK_EXECUTOR = "aikeys/claude-sonnet-5";
6
+ export const FALLBACK_ADVISOR = "aikeys/claude-fable-5";
7
+
8
+ export let executorRef = FALLBACK_EXECUTOR;
9
+ export let advisorRef = FALLBACK_ADVISOR;
10
+ export let executorEffortRef: string | undefined = undefined;
11
+ export let advisorEffortRef: string | undefined = undefined;
12
+
13
+ export const setExecutorRef = (ref: string) => { executorRef = ref; };
14
+ export const setAdvisorRef = (ref: string) => { advisorRef = ref; };
15
+ export const setExecutorEffortRef = (effort: string | undefined) => { executorEffortRef = effort; };
16
+ export const setAdvisorEffortRef = (effort: string | undefined) => { advisorEffortRef = effort; };
17
+
18
+ export const splitRef = (ref: string): [string, string] => {
19
+ const i = ref.indexOf("/");
20
+ return i === -1 ? ["aikeys", ref] : [ref.slice(0, i), ref.slice(i + 1)];
21
+ };
22
+
23
+ export const configPaths = (ctx: ExtensionContext) => [
24
+ ctx.isProjectTrusted() ? join(ctx.cwd, CONFIG_DIR_NAME, "advisor.json") : null,
25
+ join(getAgentDir(), "advisor.json"),
26
+ ];
27
+
28
+ export const loadConfig = (ctx: ExtensionContext) => {
29
+ executorRef = FALLBACK_EXECUTOR;
30
+ advisorRef = FALLBACK_ADVISOR;
31
+ executorEffortRef = undefined;
32
+ advisorEffortRef = undefined;
33
+ for (const path of configPaths(ctx)) {
34
+ if (!path || !existsSync(path)) continue;
35
+ try {
36
+ const config = JSON.parse(readFileSync(path, "utf8")) as {
37
+ executor?: string;
38
+ advisor?: string;
39
+ executorEffort?: string;
40
+ advisorEffort?: string;
41
+ };
42
+ if (config.executor) executorRef = config.executor;
43
+ if (config.advisor) advisorRef = config.advisor;
44
+ if (config.executorEffort) executorEffortRef = config.executorEffort;
45
+ if (config.advisorEffort) advisorEffortRef = config.advisorEffort;
46
+ return path;
47
+ } catch {
48
+ // Ignore malformed config and keep looking for a valid fallback.
49
+ }
50
+ }
51
+ return null;
52
+ };
53
+
54
+ export const saveConfig = (ctx: ExtensionContext) => {
55
+ const project = join(ctx.cwd, CONFIG_DIR_NAME, "advisor.json");
56
+ const path = ctx.isProjectTrusted() && existsSync(project) ? project : join(getAgentDir(), "advisor.json");
57
+ const data = {
58
+ executor: executorRef,
59
+ advisor: advisorRef,
60
+ executorEffort: executorEffortRef,
61
+ advisorEffort: advisorEffortRef,
62
+ };
63
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
64
+ return path;
65
+ };
66
+
67
+ export const parseArgs = (args: string) => {
68
+ for (const token of args.trim().split(/\s+/).filter(Boolean)) {
69
+ const [key, value] = token.split("=");
70
+ if (key === "executor" && value) executorRef = value;
71
+ if (key === "advisor" && value) advisorRef = value;
72
+ }
73
+ };
@@ -0,0 +1,52 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const textFrom = (content: unknown): string => {
4
+ return (typeof content === "string" ? [content] : Array.isArray(content) ? content : [])
5
+ .map((part: unknown) =>
6
+ typeof part === "string"
7
+ ? part
8
+ : (part as { type?: string; text?: string })?.type === "text"
9
+ ? (part as { text?: string }).text ?? ""
10
+ : ""
11
+ )
12
+ .join("\n")
13
+ .trim();
14
+ };
15
+
16
+ export const recentConversation = (ctx: ExtensionContext, maxChars = 15000): string => {
17
+ const entries: string[] = [];
18
+ for (const entry of ctx.sessionManager.getBranch()) {
19
+ if (entry.type === "message") {
20
+ const msg = entry.message;
21
+ if (msg.role === "user") {
22
+ const text = textFrom(msg.content);
23
+ if (text) entries.push(`User: ${text}`);
24
+ } else if (msg.role === "assistant") {
25
+ const parts: string[] = [];
26
+ const text = textFrom(msg.content);
27
+ if (text) parts.push(text);
28
+ if (Array.isArray(msg.content)) {
29
+ for (const part of msg.content) {
30
+ if (part && typeof part === "object" && part.type === "toolCall") {
31
+ const tc = part as { name?: string; arguments?: unknown };
32
+ const argsStr = JSON.stringify(tc.arguments);
33
+ parts.push(`[Tool Call: ${tc.name}(${argsStr})]`);
34
+ }
35
+ }
36
+ }
37
+ if (parts.length > 0) {
38
+ entries.push(`Executor: ${parts.join("\n")}`);
39
+ }
40
+ } else if (msg.role === "toolResult" || (msg as any).role === "tool") {
41
+ const tr = msg as { toolName?: string; isError?: boolean; content?: unknown };
42
+ const text = textFrom(tr.content);
43
+ const status = tr.isError ? "Error " : "";
44
+ entries.push(`[Tool Result for ${tr.toolName || "unknown"}] (${status}output):\n${text}`);
45
+ }
46
+ } else if (entry.type === "compaction") {
47
+ entries.push(`[System Compaction Summary]: ${entry.summary}`);
48
+ }
49
+ }
50
+ const joined = entries.join("\n\n");
51
+ return joined.length > maxChars ? joined.slice(-maxChars) : joined;
52
+ };
package/src/tools.ts ADDED
@@ -0,0 +1,141 @@
1
+ import { stream, type Message, type AssistantMessage } from "@earendil-works/pi-ai/compat";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Box, Text } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
5
+ import { advisorRef, advisorEffortRef, loadConfig, splitRef } from "./config.js";
6
+ import { recentConversation, textFrom } from "./conversation.js";
7
+
8
+ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+
10
+ export const ADVISOR_SYSTEM = [
11
+ "You are the Advisor: a senior engineer consulted by an autonomous coding agent.",
12
+ "You do not act — you advise. Give concise, high-signal guidance: identify risks,",
13
+ "the smallest correct next step, and wrong assumptions. No preamble.",
14
+ ].join(" ");
15
+
16
+ export const consult = async (
17
+ ctx: ExtensionContext,
18
+ question: string,
19
+ signal?: AbortSignal,
20
+ onChunk?: (thinking: string, text: string) => void,
21
+ ) => {
22
+ loadConfig(ctx);
23
+ const [provider, modelId] = splitRef(advisorRef);
24
+ const model = ctx.modelRegistry.find(provider, modelId);
25
+ if (!model) throw new Error(`Advisor model not found: ${advisorRef}`);
26
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
27
+ if (!auth.ok) throw new Error((auth as { error: string }).error);
28
+ if (!auth.apiKey) throw new Error(`No API key for ${advisorRef}`);
29
+
30
+ const conversation = recentConversation(ctx);
31
+ const messages: Message[] = [{
32
+ role: "user",
33
+ content: [{ type: "text", text: `${conversation ? `<conversation>\n${conversation}\n</conversation>\n\n` : ""}Question from the Executor:\n${question}` }],
34
+ timestamp: Date.now(),
35
+ }];
36
+
37
+ let thinkingText = "";
38
+ let responseText = "";
39
+
40
+ const eventStream = stream(model, { systemPrompt: ADVISOR_SYSTEM, messages }, {
41
+ apiKey: auth.apiKey,
42
+ headers: auth.headers,
43
+ env: auth.env,
44
+ signal,
45
+ reasoning: advisorEffortRef as any,
46
+ });
47
+
48
+ for await (const event of eventStream) {
49
+ if (event.type === "thinking_delta") {
50
+ thinkingText += event.delta;
51
+ onChunk?.(thinkingText, responseText);
52
+ } else if (event.type === "text_delta") {
53
+ responseText += event.delta;
54
+ onChunk?.(thinkingText, responseText);
55
+ }
56
+ }
57
+
58
+ const response = await eventStream.result();
59
+ const lastAssistant = [response].find((m): m is AssistantMessage => m.role === "assistant");
60
+ const advice = lastAssistant?.content
61
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
62
+ .map((part) => part.text)
63
+ .join("\n")
64
+ .trim() || responseText;
65
+
66
+ if (!advice) throw new Error("Advisor returned no advice.");
67
+ return { advice, thinkingText };
68
+ };
69
+
70
+ export const registerAdvisorTool = (pi: ExtensionAPI) => {
71
+ pi.registerTool({
72
+ name: "ask_advisor",
73
+ label: "Ask Advisor",
74
+ description: "Consult the on-demand Advisor model for strategic guidance, a second opinion, or a sanity check.",
75
+ promptSnippet: "Consult the Advisor model for a second opinion",
76
+ promptGuidelines: [
77
+ "You MUST call `ask_advisor` in each of these scenarios:",
78
+ "1. PLAN GATE: You MUST call `ask_advisor` BEFORE selecting or implementing a plan when multiple materially different approaches exist, requirements are ambiguous, or the decision has architectural, security, data-loss, compatibility, or difficult-to-reverse consequences.",
79
+ "2. FAILURE GATE: You MUST call `ask_advisor` after two consecutive materially equivalent failed attempts, when an attempted fix recreates an earlier failure, or when two consecutive actions produce no measurable progress. Do NOT attempt another materially equivalent fix before consulting.",
80
+ "3. COMPLETION GATE: You MUST call `ask_advisor` with the goal, changed files, key decisions, tests performed, results, and remaining risks BEFORE declaring success or calling `goal_complete`. You MAY skip this only for demonstrably trivial, low-risk work with no meaningful trade-offs or failures.",
81
+ "Do NOT use `ask_advisor` for routine decisions outside these three gates.",
82
+ ],
83
+ parameters: Type.Object({ question: Type.String({ description: "The specific question or decision to get advice on." }) }),
84
+ renderShell: "self",
85
+ renderCall(args, theme, context) {
86
+ const box = context.lastComponent instanceof Box ? context.lastComponent : new Box(1, 1);
87
+ box.setBgFn((text) => `\x1b[48;2;25;32;45m${text}\x1b[0m`);
88
+ box.clear();
89
+ if (!context.state.timerId) {
90
+ context.state.timerId = setInterval(() => context.invalidate(), 80);
91
+ }
92
+ const frame = SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
93
+ box.addChild(new Text(`${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", `· ${args.question}`)}`, 0, 0));
94
+ return box;
95
+ },
96
+ renderResult(result, { isPartial }, theme, context) {
97
+ const box = context.lastComponent instanceof Box ? context.lastComponent : new Box(1, 1);
98
+ box.setBgFn((text) => `\x1b[48;2;25;32;45m${text}\x1b[0m`);
99
+ box.clear();
100
+ if (isPartial) {
101
+ if (!context.state.timerId) {
102
+ context.state.timerId = setInterval(() => context.invalidate(), 80);
103
+ }
104
+ const frame = SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
105
+ const d = result.details as { thinking?: string; text?: string } | undefined;
106
+ const lines: string[] = [`${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", "· Working…")}`];
107
+ if (d?.thinking) {
108
+ const snippet = d.thinking.length > 200 ? d.thinking.slice(-200) : d.thinking;
109
+ lines.push(theme.fg("thinkingText", ` 💭 ${snippet.replace(/\n/g, " ")}`));
110
+ }
111
+ if (d?.text) lines.push(theme.fg("text", d.text));
112
+ box.addChild(new Text(lines.join("\n"), 0, 0));
113
+ } else {
114
+ if (context.state.timerId) {
115
+ clearInterval(context.state.timerId);
116
+ delete context.state.timerId;
117
+ }
118
+ const d = result.details as { thinking?: string; text?: string } | undefined;
119
+ const lines: string[] = [theme.fg("warning", theme.bold("◆ ADVISOR"))];
120
+ if (d?.thinking) {
121
+ lines.push(theme.fg("thinkingText", ` 💭 ${d.thinking.replace(/\n/g, " ").slice(0, 300)}${d.thinking.length > 300 ? "…" : ""}`));
122
+ }
123
+ lines.push(textFrom(result.content) || "(Advisor returned no advice.)");
124
+ box.addChild(new Text(lines.join("\n"), 0, 0));
125
+ }
126
+ return box;
127
+ },
128
+ async execute(_id, params, signal, onUpdate, ctx) {
129
+ const { advice, thinkingText } = await consult(ctx, params.question, signal, (t, tx) => {
130
+ onUpdate?.({
131
+ content: [{ type: "text", text: tx }],
132
+ details: { thinking: t, text: tx, advisor: advisorRef, question: params.question },
133
+ });
134
+ });
135
+ return {
136
+ content: [{ type: "text", text: `Advisor (${advisorRef})\n\n${advice}` }],
137
+ details: { thinking: thinkingText, text: advice, advisor: advisorRef, question: params.question },
138
+ };
139
+ },
140
+ });
141
+ };
package/src/ui.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { Input, fuzzyFilter, type Component, type Focusable } from "@earendil-works/pi-tui";
2
+ import { Box, Text } from "@earendil-works/pi-tui";
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+
5
+ export class SearchableModelSelector implements Component, Focusable {
6
+ private tui: any;
7
+ private searchInput: Input;
8
+ private allOptions: string[];
9
+ private filteredOptions: string[];
10
+ private selectedIndex = 0;
11
+ private title: string;
12
+ private onSelect: (value: string) => void;
13
+ private onCancel: () => void;
14
+ private theme: Theme;
15
+ private keybindings: any;
16
+ private _focused = false;
17
+
18
+ public get focused(): boolean { return this._focused; }
19
+ public set focused(val: boolean) {
20
+ this._focused = val;
21
+ this.searchInput.focused = val;
22
+ }
23
+
24
+ constructor(options: {
25
+ tui: any;
26
+ title: string;
27
+ allOptions: string[];
28
+ theme: Theme;
29
+ keybindings: any;
30
+ onSelect: (value: string) => void;
31
+ onCancel: () => void;
32
+ }) {
33
+ this.tui = options.tui;
34
+ this.title = options.title;
35
+ this.allOptions = options.allOptions;
36
+ this.theme = options.theme;
37
+ this.keybindings = options.keybindings;
38
+ this.onSelect = options.onSelect;
39
+ this.onCancel = options.onCancel;
40
+ this.searchInput = new Input();
41
+ this.filteredOptions = this.allOptions;
42
+ }
43
+
44
+ invalidate(): void { this.searchInput.invalidate(); }
45
+
46
+ render(width: number): string[] {
47
+ const lines: string[] = ["═".repeat(width)];
48
+ lines.push(` ${this.theme.fg("accent", this.theme.bold(this.title))}`);
49
+ const inputLines = this.searchInput.render(width - 12);
50
+ lines.push(` ${this.theme.fg("accent", "Search: ")}${inputLines[0] || ""}`);
51
+ lines.push("");
52
+
53
+ const query = this.searchInput.getValue().trim();
54
+ this.filteredOptions = query ? fuzzyFilter(this.allOptions, query, (item) => item) : this.allOptions;
55
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredOptions.length - 1));
56
+
57
+ const maxVisible = 10;
58
+ const total = this.filteredOptions.length;
59
+ if (total === 0) {
60
+ lines.push(" " + this.theme.fg("muted", "No matching models found."));
61
+ } else {
62
+ const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
63
+ const endIndex = Math.min(startIndex + maxVisible, total);
64
+ for (let i = startIndex; i < endIndex; i++) {
65
+ const item = this.filteredOptions[i];
66
+ if (i === this.selectedIndex) lines.push(` ${this.theme.fg("accent", "→ ")}${this.theme.fg("accent", item)}`);
67
+ else lines.push(` ${this.theme.fg("text", item)}`);
68
+ }
69
+ if (total > maxVisible) lines.push(" " + this.theme.fg("muted", ` (${this.selectedIndex + 1}/${total})`));
70
+ }
71
+ lines.push("");
72
+ lines.push(` ${this.theme.fg("muted", "Type to search · ↑↓: navigate · Enter: select · Esc: cancel")}`);
73
+ lines.push("═".repeat(width));
74
+ return lines;
75
+ }
76
+
77
+ handleInput(keyData: string): void {
78
+ const kb = this.keybindings;
79
+ if (kb.matches(keyData, "tui.select.up") || keyData === "\u001b[A") {
80
+ if (this.filteredOptions.length > 0) this.selectedIndex = this.selectedIndex === 0 ? this.filteredOptions.length - 1 : this.selectedIndex - 1;
81
+ this.tui.requestRender();
82
+ } else if (kb.matches(keyData, "tui.select.down") || keyData === "\u001b[B") {
83
+ if (this.filteredOptions.length > 0) this.selectedIndex = this.selectedIndex === this.filteredOptions.length - 1 ? 0 : this.selectedIndex + 1;
84
+ this.tui.requestRender();
85
+ } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n" || keyData === "\r") {
86
+ if (this.filteredOptions.length > 0) this.onSelect(this.filteredOptions[this.selectedIndex]);
87
+ } else if (kb.matches(keyData, "tui.select.cancel") || keyData === "\u001b") {
88
+ this.onCancel();
89
+ } else {
90
+ this.searchInput.handleInput(keyData);
91
+ this.selectedIndex = 0;
92
+ this.tui.requestRender();
93
+ }
94
+ }
95
+ }