pi-spark 0.14.7 → 0.15.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # pi-spark
4
4
 
5
- [Pi](https://pi.dev/) package that polishes your daily experience and keeps you at the frontier of agentic workflows.
5
+ [Pi](https://pi.dev/) package that polishes your daily experience.
6
6
 
7
7
  ![Overview](./assets/screenshot-overview.png)
8
8
 
@@ -61,23 +61,6 @@ pi-spark generates a short recap of the current session after it goes idle, or o
61
61
 
62
62
  ![Recap](./assets/screenshot-recap.png)
63
63
 
64
- ### Agent tools: `pi` and `web`
65
-
66
- pi-spark adds two agent tools:
67
-
68
- The `pi` tool lets the pi coding agent inspect and manipulate itself (~740 tokens).
69
-
70
- - `models` lists and searches the model catalog.
71
- - `name` sets or updates the current session's name.
72
- - `whoami` shows the current pi state, including session name, active model, and thinking level.
73
-
74
- The `web` tool gives the agent live web access, backed by the free [Exa MCP](https://exa.ai/mcp) (~350 tokens). No separate MCP setup or API key is needed.
75
-
76
- - `search` finds current information across the web and returns ready-to-use content.
77
- - `fetch` reads the full content of known URLs as clean markdown, and can batch several URLs in one call.
78
-
79
- ![Agent tools](./assets/screenshot-tools.png)
80
-
81
64
  ## Configuration
82
65
 
83
66
  pi-spark reads config from `~/.pi/agent/spark.json` and from the current project's `.pi/spark.json`. Project config overrides matching global fields.
@@ -121,10 +104,8 @@ All fields are optional. Each top-level feature runs with the defaults below unl
121
104
  | `editor` | `EditorConfig` | Shows a working indicator and the current model on the editor's top border. |
122
105
  | `footer` | `{}` | Shows session info, extension statuses, cost, and context usage on one line. |
123
106
  | `fullscreen` | `{}` | Clears the screen and scrollback on start and exit, and pins the editor and footer to the bottom. |
124
- | `pi` | `{}` | Exposes the `pi` agent tool (`models`, `name`, `whoami` actions). |
125
107
  | `presets` | `{ [name]: Preset }` | Defines named model presets, keyed by name. |
126
108
  | `recap` | `RecapConfig` | Generates a session recap when idle or on demand. |
127
- | `web` | `{}` | Exposes the `web` agent tool (`search`, `fetch` actions) via Exa's hosted MCP server. |
128
109
 
129
110
  #### `EditorConfig`
130
111
 
Binary file
package/index.ts CHANGED
@@ -3,10 +3,8 @@ import { registerCredits } from "./src/features/credits";
3
3
  import { registerEditor } from "./src/features/editor";
4
4
  import { registerFooter } from "./src/features/footer";
5
5
  import { registerFullscreen } from "./src/features/fullscreen";
6
- import { registerPi } from "./src/features/pi";
7
6
  import { registerPresets } from "./src/features/presets";
8
7
  import { registerRecap } from "./src/features/recap";
9
- import { registerWeb } from "./src/features/web";
10
8
 
11
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
10
 
@@ -24,8 +22,6 @@ export default function (pi: ExtensionAPI) {
24
22
  registerEditor(pi, events);
25
23
  registerFooter(pi);
26
24
  registerFullscreen(pi);
27
- registerPi(pi);
28
25
  registerPresets(pi);
29
26
  registerRecap(pi);
30
- registerWeb(pi);
31
27
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.14.7",
4
- "description": "Pi package that polishes your daily experience and keeps you at the frontier of agentic workflows.",
3
+ "version": "0.15.0",
4
+ "description": "Pi package that polishes your daily experience.",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
7
7
  "pi-package"
@@ -31,8 +31,7 @@
31
31
  "dependencies": {
32
32
  "@grpc/grpc-js": "^1.14.4",
33
33
  "@grpc/proto-loader": "^0.8.1",
34
- "@modelcontextprotocol/sdk": "^1.29.0",
35
- "liqe": "^3.8.7",
34
+ "defu": "^6.1.7",
36
35
  "ms": "4.0.0-nightly.202508271359",
37
36
  "zod": "^4.4.3"
38
37
  },
@@ -1,15 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { defu } from "defu";
4
5
 
5
6
  import { featureSchemas } from "./schema";
6
7
 
7
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
9
  import type { SparkConfig } from "./schema";
9
10
 
10
- type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
11
- type JsonObject = { [key: string]: JsonValue };
12
-
13
11
  const cache = new Map<string, SparkConfig>();
14
12
 
15
13
  /** Load and validate spark.json once per session lifecycle; later calls return the cached result. */
@@ -18,8 +16,10 @@ export function loadConfig(ctx: ExtensionContext, fileName: string = "spark.json
18
16
  const cached = cache.get(key);
19
17
  if (cached) return cached;
20
18
 
21
- const rawValue = loadMergedJson(getConfigPaths(ctx.cwd, fileName)) ?? {};
22
- const raw = isPlainObject(rawValue) ? rawValue : {};
19
+ // Deep-merge the global file under the project file, so project settings win at scalar
20
+ // leaves while deep objects (e.g., `recap.model`) combine across both.
21
+ const [globalPath, projectPath] = getConfigPaths(ctx.cwd, fileName);
22
+ const raw = defu(readRawConfig(projectPath) ?? {}, readRawConfig(globalPath) ?? {});
23
23
 
24
24
  // Validate each feature independently so a single invalid field disables only that feature
25
25
  // (falling back to its enabled defaults) instead of taking down the whole config.
@@ -46,12 +46,11 @@ export function loadConfig(ctx: ExtensionContext, fileName: string = "spark.json
46
46
  }
47
47
 
48
48
  config[field] = {};
49
- const detail = result.error.issues.map((issue) => `${[field, ...issue.path].join(".")}: ${issue.message}`).join("; ");
50
- errors.push(detail);
49
+ errors.push(result.error.issues.map((issue) => `${[field, ...issue.path].join(".")}: ${issue.message}`).join("; "));
51
50
  }
52
51
 
53
52
  if (errors.length > 0) {
54
- ctx.ui.notify(`Invalid spark config, using defaults for: ${errors.join("; ")}`, "error");
53
+ ctx.ui.notify(`Invalid pi-spark config: ${errors.join("; ")}`, "error");
55
54
  }
56
55
 
57
56
  cache.set(key, config as SparkConfig);
@@ -59,46 +58,13 @@ export function loadConfig(ctx: ExtensionContext, fileName: string = "spark.json
59
58
  }
60
59
 
61
60
  function getConfigPaths(cwd: string, fileName: string): [globalPath: string, projectPath: string] {
62
- return [join(getAgentDir(), fileName), join(cwd, ".pi", fileName)];
63
- }
64
-
65
- function loadMergedJson(paths: string[]): JsonObject | undefined {
66
- let merged: JsonObject | undefined;
67
- paths.forEach((path) => {
68
- const value = readJsonFile(path);
69
- if (value === undefined) return;
70
-
71
- merged = mergeConfig(merged, value);
72
- });
73
-
74
- return merged;
61
+ return [join(getAgentDir(), fileName), join(cwd, CONFIG_DIR_NAME, fileName)];
75
62
  }
76
63
 
77
- function readJsonFile(path: string): JsonObject | undefined {
64
+ function readRawConfig(path: string): Record<string, unknown> | undefined {
78
65
  try {
79
- return JSON.parse(readFileSync(path, "utf8")) as JsonObject;
66
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
80
67
  } catch {
81
68
  return undefined;
82
69
  }
83
70
  }
84
-
85
- function mergeConfig(base: JsonObject | undefined, override: JsonObject): JsonObject {
86
- if (base === undefined) return override;
87
- if (!isPlainObject(base) || !isPlainObject(override)) return override;
88
-
89
- const result: Record<string, JsonValue> = { ...base };
90
- Object.entries(override).forEach(([key, overrideValue]) => {
91
- const baseValue = base[key];
92
- if (isPlainObject(baseValue) && isPlainObject(overrideValue)) {
93
- result[key] = { ...baseValue, ...overrideValue };
94
- } else {
95
- result[key] = overrideValue;
96
- }
97
- });
98
-
99
- return result;
100
- }
101
-
102
- function isPlainObject(value: unknown): value is Record<string, JsonValue> {
103
- return !!value && typeof value === "object" && !Array.isArray(value);
104
- }
@@ -4,10 +4,8 @@ import { creditsConfigSchema } from "../features/credits/config";
4
4
  import { editorConfigSchema } from "../features/editor/config";
5
5
  import { footerConfigSchema } from "../features/footer/config";
6
6
  import { fullscreenConfigSchema } from "../features/fullscreen/config";
7
- import { piConfigSchema } from "../features/pi/config";
8
7
  import { presetsConfigSchema } from "../features/presets/config";
9
8
  import { recapConfigSchema } from "../features/recap/config";
10
- import { webConfigSchema } from "../features/web/config";
11
9
 
12
10
  /**
13
11
  * Raw option shape for each feature. The enable/disable/default policy lives in `loadConfig`:
@@ -19,10 +17,8 @@ export const featureSchemas = {
19
17
  editor: editorConfigSchema,
20
18
  footer: footerConfigSchema,
21
19
  fullscreen: fullscreenConfigSchema,
22
- pi: piConfigSchema,
23
20
  presets: presetsConfigSchema,
24
21
  recap: recapConfigSchema,
25
- web: webConfigSchema,
26
22
  } as const;
27
23
 
28
24
  /** Resolved config for every feature; `false` means the feature is disabled. */
@@ -10,7 +10,7 @@ interface MoonshotBalanceResponse {
10
10
 
11
11
  /**
12
12
  * For Moonshot, the international and China-mainland accounts live on separate hosts and bill in
13
- * different currencies (USD vs CNY), which the endpoint does not report, so each pi provider ID
13
+ * different currencies (USD vs CNY), which the endpoint does not report, so each Pi provider ID
14
14
  * fixes both host and currency.
15
15
  */
16
16
  function createMoonshotProvider(id: string, host: string, currency: string): CreditsProvider {
@@ -14,7 +14,7 @@ export function renderCredits(theme: Theme, label: string, credits: Credits): st
14
14
  }
15
15
 
16
16
  export function renderError(theme: Theme, label: string, message: string): string {
17
- return theme.fg("error", `${label} credits unavailable: ${message}`);
17
+ return theme.fg("error", `${label} ${message}`);
18
18
  }
19
19
 
20
20
  function renderWindows(theme: Theme, credits: Extract<Credits, { type: "windows" }>): string {
@@ -16,7 +16,7 @@ export interface CreditsLane {
16
16
  percent: number | undefined;
17
17
  }
18
18
 
19
- /** A credits source for a pi provider, shown in the status line while that provider is active. */
19
+ /** A credits source for a Pi provider, shown in the status line while that provider is active. */
20
20
  export interface CreditsProvider {
21
21
  readonly id: Provider;
22
22
  readonly label: string;
@@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url";
5
5
  import { SplitLine } from "../../components/split-line";
6
6
  import { loadConfig } from "../../config";
7
7
  import { formatContextUsage, formatCost, formatCwd, linkText, sanitizeText } from "../../utils/format";
8
+ import { isFreeModel } from "../../utils/model";
8
9
  import { getEntryUsage } from "../../utils/usage";
9
10
 
10
11
  import type { ExtensionContext, ExtensionAPI, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
@@ -63,6 +64,10 @@ class FooterComponent implements Component {
63
64
 
64
65
  private getStyledCostText(): string {
65
66
  const totalCost = this.ctx.sessionManager.getBranch().reduce((acc, entry) => acc + (getEntryUsage(entry)?.cost.total ?? 0), 0);
67
+
68
+ // Hide cost when it's zero and the current model is free.
69
+ if (totalCost === 0 && this.ctx.model && isFreeModel(this.ctx.model)) return "";
70
+
66
71
  const costText = formatCost(totalCost);
67
72
 
68
73
  if (totalCost > 20) return this.theme.fg("warning", costText);
@@ -22,7 +22,7 @@ export class BottomFiller implements Component {
22
22
  this.measuring = true;
23
23
 
24
24
  // Re-assert `clearOnShrink` every render pass; the TUI reads it after `render()` returns,
25
- // so this wins deterministically over pi's reset to the settings value on startup/reload.
25
+ // so this wins deterministically over Pi's reset to the settings value on startup/reload.
26
26
  this.tui.setClearOnShrink(true);
27
27
 
28
28
  const rows = this.tui.terminal.rows;
@@ -95,7 +95,7 @@ export class PresetManager {
95
95
  if (!preset) return;
96
96
 
97
97
  return this.keys.find((key) => {
98
- const p = this.presets[key]!;
98
+ const p = this.presets[key];
99
99
 
100
100
  return p.provider === preset.provider && p.model === preset.model && p.thinkingLevel === preset.thinkingLevel;
101
101
  });
@@ -1,6 +1,6 @@
1
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
2
 
3
- import type { ImageContent, Provider, TextContent } from "@earendil-works/pi-ai";
3
+ import type { Provider } from "@earendil-works/pi-ai";
4
4
  import type { ContextUsage } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  export function formatModel(provider?: Provider, model?: string, thinkingLevel?: string): string {
@@ -28,11 +28,6 @@ export function formatCost(cost: number): string {
28
28
  return `$${cost.toFixed(2)}`;
29
29
  }
30
30
 
31
- /** Format a duration in ms as `1.2s`, like the built-in bash tool. */
32
- export function formatDuration(ms: number): string {
33
- return `${(ms / 1000).toFixed(1)}s`;
34
- }
35
-
36
31
  export function formatCwd(cwd: string, home: string): string {
37
32
  const resolvedCwd = resolve(cwd);
38
33
  const resolvedHome = resolve(home);
@@ -67,14 +62,6 @@ export function sanitizeText(text: string): string {
67
62
  .trim();
68
63
  }
69
64
 
70
- /** Join the text blocks of tool result content into a single newline-separated string. */
71
- export function joinTextContent(content: (TextContent | ImageContent)[]): string {
72
- return content
73
- .filter((block) => block.type === "text")
74
- .map((block) => block.text)
75
- .join("\n");
76
- }
77
-
78
65
  /** Coerce a possibly-stringified numeric value to a finite number, or `undefined`. */
79
66
  export function toNumber(value?: string | number | null): number | undefined {
80
67
  if (value === undefined || value === null) return undefined;
@@ -0,0 +1,6 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+
3
+ /** Check if a model has zero costs for all categories. */
4
+ export function isFreeModel(model: Model<Api>): boolean {
5
+ return model.cost.input === 0 && model.cost.output === 0 && model.cost.cacheRead === 0 && model.cost.cacheWrite === 0;
6
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Usage } from "@earendil-works/pi-ai";
2
2
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
3
 
4
- /** Structural type guard for the pi `Usage` shape. */
4
+ /** Structural type guard for the Pi `Usage` shape. */
5
5
  export function isUsage(value: unknown): value is Usage {
6
6
  if (typeof value !== "object" || value === null) return false;
7
7
 
Binary file
@@ -1,165 +0,0 @@
1
- import { keyHint, truncateHead } from "@earendil-works/pi-coding-agent";
2
- import { Container, Spacer, Text } from "@earendil-works/pi-tui";
3
- import { filter, parse } from "liqe";
4
- import { Type } from "typebox";
5
-
6
- import { defineAction } from "../registry";
7
- import { formatListNotice, toMetadata, toModelRow } from "../model";
8
-
9
- import type { TruncationResult } from "@earendil-works/pi-coding-agent";
10
- import type { ModelMetadata, ModelRow } from "../model";
11
-
12
- const LIST_MAX_LINES = 200;
13
- const COLLAPSED_MAX_LINES = 10;
14
-
15
- interface ModelsDetails {
16
- action: "models";
17
- models: ModelMetadata[];
18
- total: number;
19
- truncation?: TruncationResult;
20
- }
21
-
22
- export const modelsAction = defineAction({
23
- name: "models",
24
- summary: `lists and searches the model catalog (one JSON object per line, truncated to ${LIST_MAX_LINES} models)`,
25
- fields: {
26
- query: Type.Optional(Type.String({
27
- description:
28
- "For \"models\": Filter models with a Liqe (Lucene-like) query. Bare terms match any " +
29
- "field, and unquoted terms are case-insensitive substrings. The syntax supports " +
30
- "AND/OR/NOT, grouping, wildcards, and numeric comparisons; quote values containing " +
31
- "special characters (e.g., id:\"deepseek/deepseek-v4\"). Filterable fields are id, name, " +
32
- "provider, api, reasoning (boolean), input (modalities), cost.input and cost.output " +
33
- "(USD per 1M tokens), contextWindow, maxTokens, thinkingLevels, and available " +
34
- "(boolean, true when auth is configured). Examples: \"claude available:true\", " +
35
- "\"provider:openrouter cost.input:<1\".",
36
- })),
37
- offset: Type.Optional(Type.Number({ description: "For \"models\": Start from this model number (1-indexed)." })),
38
- limit: Type.Optional(Type.Number({ description: "For \"models\": Return at most this many models." })),
39
- },
40
- promptGuidelines: [
41
- "Use the pi tool's \"models\" action when you need metadata of pi models; include available:true in the query unless unavailable models are needed.",
42
- ],
43
- renderParams(args, theme) {
44
- const params: string[] = [];
45
-
46
- const query = args.query?.trim();
47
- if (query) params.push(theme.fg("muted", query));
48
-
49
- if (args.offset !== undefined || args.limit !== undefined) {
50
- const start = args.offset ?? 1;
51
- const end = args.limit !== undefined ? start + args.limit - 1 : "end";
52
- params.push(theme.fg("warning", `${start}-${end}`));
53
- }
54
-
55
- return params;
56
- },
57
- renderResult(result, { expanded }, theme, context) {
58
- const details = result.details as ModelsDetails | undefined;
59
-
60
- const container = new Container();
61
- container.addChild(new Spacer(1));
62
-
63
- if (!details) {
64
- container.addChild(new Text(theme.fg("muted", "No models found."), 0, 0));
65
- return container;
66
- }
67
-
68
- const addRows = (rows: ModelRow[]) => {
69
- const widths = {
70
- label: Math.max(...rows.map((row) => row.label.length)),
71
- cost: Math.max(...rows.map((row) => row.cost.length)),
72
- context: Math.max(...rows.map((row) => row.context.length)),
73
- };
74
-
75
- for (const row of rows) {
76
- const cells = [
77
- row.label.padEnd(widths.label),
78
- row.cost.padStart(widths.cost),
79
- row.context.padStart(widths.context),
80
- ].join(" ");
81
- const noAuthHint = row.available ? "" : theme.fg("dim", " (no auth)");
82
- container.addChild(new Text(theme.fg("muted", cells) + noAuthHint, 0, 0));
83
- }
84
- };
85
-
86
- const filtered = Boolean(context.args.query?.trim());
87
- const models = details.models ?? [];
88
- const total = details.total ?? models.length;
89
-
90
- if (models.length > 0) {
91
- const maxRows = expanded ? models.length : Math.min(models.length, COLLAPSED_MAX_LINES);
92
- addRows(models.slice(0, maxRows).map((model) => toModelRow(model)));
93
-
94
- const hiddenRows = models.length - maxRows;
95
- if (hiddenRows > 0) container.addChild(new Text(theme.fg("dim", `... (${hiddenRows} more, `) + keyHint("app.tools.expand", "to expand") + theme.fg("dim", ")"), 0, 0));
96
- container.addChild(new Spacer(1));
97
- }
98
-
99
- if (details.truncation?.truncated) {
100
- const startIndex = context.args.offset ? Math.max(0, context.args.offset - 1) : 0;
101
- const endDisplay = startIndex + models.length;
102
- const notice = formatListNotice(true, startIndex, endDisplay, total);
103
- if (notice) {
104
- container.addChild(new Text(theme.fg("warning", notice), 0, 0));
105
- container.addChild(new Spacer(1));
106
- }
107
- }
108
-
109
- const summary = `${models.length !== total ? `${models.length} of ` : ""}${total} ${filtered ? "matched " : ""}model${total === 1 ? "" : "s"} listed.`;
110
- container.addChild(new Text(theme.fg("muted", total > 0 ? summary : `No models ${filtered ? "matched" : "found"}.`), 0, 0));
111
-
112
- return container;
113
- },
114
- async execute(args, { ctx }) {
115
- const queryText = args.query?.trim();
116
- const filtered = Boolean(queryText);
117
-
118
- let listQuery = null;
119
- if (queryText) {
120
- try {
121
- listQuery = parse(queryText);
122
- } catch (error) {
123
- throw new Error(`Invalid Liqe query ${JSON.stringify(queryText)}: ${error instanceof Error ? error.message : String(error)}`);
124
- }
125
- }
126
-
127
- const models = ctx.modelRegistry.getAll().map((model) => toMetadata(model, ctx.modelRegistry.hasConfiguredAuth(model)));
128
- const matched = listQuery ? filter(listQuery, models) : models;
129
- const total = matched.length;
130
-
131
- if (total === 0) {
132
- return {
133
- content: [{ type: "text", text: `No models ${filtered ? "matched" : "found"}.` }],
134
- details: { action: "models", models: [], total } satisfies ModelsDetails,
135
- };
136
- }
137
-
138
- // Convert from 1-indexed offset to 0-indexed array access.
139
- const startIndex = args.offset ? Math.max(0, args.offset - 1) : 0;
140
- if (startIndex >= total) {
141
- throw new Error(`Offset ${args.offset} is beyond the end of the list (${total} models total)`);
142
- }
143
-
144
- const endIndex = args.limit !== undefined ? Math.min(startIndex + args.limit, total) : total;
145
- const selected = matched.slice(startIndex, endIndex);
146
-
147
- // JSONL: one compact object per line, so truncation cuts at record boundaries.
148
- const truncation = truncateHead(selected.map((model) => JSON.stringify(model)).join("\n"), {
149
- maxLines: LIST_MAX_LINES,
150
- maxBytes: Infinity,
151
- });
152
-
153
- const delivered = truncation.truncated ? selected.slice(0, truncation.outputLines) : selected;
154
- const endDisplay = startIndex + delivered.length;
155
-
156
- let text = truncation.content;
157
- const notice = formatListNotice(truncation.truncated, startIndex, endDisplay, total);
158
- if (notice) text += `\n\n${notice}`;
159
-
160
- return {
161
- content: [{ type: "text", text }],
162
- details: { action: "models", models: delivered, total, truncation } satisfies ModelsDetails,
163
- };
164
- },
165
- });
@@ -1,58 +0,0 @@
1
- import { Text } from "@earendil-works/pi-tui";
2
- import { Type } from "typebox";
3
-
4
- import { sanitizeText } from "../../../utils/format";
5
- import { defineAction } from "../registry";
6
-
7
- interface NameDetails {
8
- action: "name";
9
- changed: boolean;
10
- previous: string | null;
11
- }
12
-
13
- export const nameAction = defineAction({
14
- name: "name",
15
- summary: "sets or updates the current session's name",
16
- fields: {
17
- name: Type.String({
18
- minLength: 1,
19
- maxLength: 120,
20
- description:
21
- "For \"name\": Use a short, recognizable phrase in sentence case, ideally <= 72 characters " +
22
- "(e.g., \"Refactor auth module\", \"Debug flaky CI pipeline\"). Do not use " +
23
- "surrounding quotes, trailing punctuation, or generic prefixes like \"Chat about\".",
24
- }),
25
- },
26
- required: ["name"],
27
- promptGuidelines: [
28
- "Use the pi tool's \"name\" action to give the current session a concise, recognizable name, especially after a long, vague, or pasted opening prompt, or after a substantial shift in the conversation's focus.",
29
- ],
30
- renderParams(args, theme) {
31
- return [theme.fg("muted", sanitizeText(args.name ?? ""))];
32
- },
33
- renderResult() {
34
- // "name" has no success UI; errors are rendered by the registry's fallback.
35
- return new Text("", 0, 0);
36
- },
37
- async execute(args, { pi }) {
38
- const name = sanitizeText(args.name);
39
- if (!name) throw new Error("Session name was empty after normalization; provide a short, non-empty phrase");
40
-
41
- const previous = pi.getSessionName() ?? null;
42
- if (previous === name) {
43
- const details: NameDetails = { action: "name", changed: false, previous };
44
- return {
45
- content: [{ type: "text", text: `Session is already named "${name}". Nothing changed.` }],
46
- details,
47
- };
48
- }
49
-
50
- pi.setSessionName(name);
51
-
52
- const details: NameDetails = { action: "name", changed: true, previous };
53
- return {
54
- content: [{ type: "text", text: previous ? `Renamed session from "${previous}" to "${name}".` : `Named session "${name}".` }],
55
- details,
56
- };
57
- },
58
- });
@@ -1,61 +0,0 @@
1
- import { Container, Spacer, Text } from "@earendil-works/pi-tui";
2
-
3
- import { defineAction } from "../registry";
4
- import { toMetadata, toModelRow } from "../model";
5
-
6
- import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
7
- import type { ModelMetadata } from "../model";
8
-
9
- interface WhoamiDetails {
10
- action: "whoami";
11
- sessionName: string | null;
12
- model: ModelMetadata | null;
13
- thinkingLevel: ModelThinkingLevel;
14
- }
15
-
16
- export const whoamiAction = defineAction({
17
- name: "whoami",
18
- summary: "shows the current pi state, including session name, active model, and thinking level",
19
- fields: {},
20
- promptGuidelines: [
21
- "Use the pi tool's \"whoami\" action when you need the current session's name or the active model and thinking level.",
22
- ],
23
- renderResult(result, _options, theme) {
24
- const details = result.details as WhoamiDetails | undefined;
25
-
26
- const container = new Container();
27
- container.addChild(new Spacer(1));
28
-
29
- if (!details) {
30
- container.addChild(new Text(theme.fg("muted", "No pi state available."), 0, 0));
31
- return container;
32
- }
33
-
34
- const formatLine = (label: string, value: string) => `${label.padEnd(7)} ${value}`;
35
-
36
- if (details.sessionName) {
37
- container.addChild(new Text(theme.fg("muted", formatLine("session", details.sessionName)), 0, 0));
38
- }
39
-
40
- if (details.model) {
41
- const row = toModelRow(details.model, details.thinkingLevel);
42
- const cells = [row.label, row.cost, row.context].join(" ");
43
- container.addChild(new Text(theme.fg("muted", formatLine("model", cells)), 0, 0));
44
- }
45
-
46
- return container;
47
- },
48
- async execute(_args, { pi, ctx }) {
49
- const state: WhoamiDetails = {
50
- action: "whoami",
51
- sessionName: pi.getSessionName() ?? null,
52
- model: ctx.model ? toMetadata(ctx.model, true) : null,
53
- thinkingLevel: pi.getThinkingLevel(),
54
- };
55
-
56
- return {
57
- content: [{ type: "text", text: JSON.stringify(state) }],
58
- details: state,
59
- };
60
- },
61
- });
@@ -1,3 +0,0 @@
1
- import * as z from "zod";
2
-
3
- export const piConfigSchema = z.object({});
@@ -1,23 +0,0 @@
1
- import { nameAction } from "./actions/name";
2
- import { modelsAction } from "./actions/models";
3
- import { whoamiAction } from "./actions/whoami";
4
- import { registerPiTool } from "./registry";
5
- import { loadConfig } from "../../config";
6
-
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
-
9
- /** Self-management actions exposed by the pi tool, ordered alphabetically by action name. */
10
- const ACTIONS = [
11
- modelsAction,
12
- nameAction,
13
- whoamiAction,
14
- ];
15
-
16
- export function registerPi(pi: ExtensionAPI): void {
17
- pi.on("session_start", (_event, ctx) => {
18
- const config = loadConfig(ctx).pi;
19
- if (!config) return;
20
-
21
- registerPiTool(pi, ACTIONS);
22
- });
23
- }
@@ -1,56 +0,0 @@
1
- import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
2
-
3
- import { formatModel, formatTokens } from "../../utils/format";
4
-
5
- import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
6
-
7
- /** Pi's built-in default thinking level, clamped per model. Not exported by pi's public API. */
8
- const DEFAULT_THINKING_LEVEL: ModelThinkingLevel = "medium";
9
-
10
- export type ModelMetadata = Omit<Model<Api>, "headers" | "compat"> & {
11
- thinkingLevels: ModelThinkingLevel[];
12
- defaultThinkingLevel: ModelThinkingLevel;
13
- available: boolean;
14
- };
15
-
16
- export type ModelRow = {
17
- label: string;
18
- cost: string;
19
- context: string;
20
- available: boolean;
21
- };
22
-
23
- export function toMetadata(model: Model<Api>, available: boolean): ModelMetadata {
24
- const { headers: _headers, compat: _compat, ...metadata } = model;
25
-
26
- return {
27
- ...metadata,
28
- thinkingLevels: getSupportedThinkingLevels(model),
29
- defaultThinkingLevel: clampThinkingLevel(model, DEFAULT_THINKING_LEVEL),
30
- available,
31
- };
32
- }
33
-
34
- export function toModelRow(model: ModelMetadata, thinkingLevel?: ModelThinkingLevel): ModelRow {
35
- return {
36
- label: formatModel(model.provider, model.id, thinkingLevel),
37
- cost: `$${formatPrice(model.cost.input)}/$${formatPrice(model.cost.output)}`,
38
- context: formatTokens(model.contextWindow),
39
- available: model.available,
40
- };
41
- }
42
-
43
- /** Round to at most 2 decimals and trim float noise: 0.7999... -> 0.8, 0.0983 -> 0.1, 15 -> 15. */
44
- function formatPrice(value: number): string {
45
- return String(parseFloat(value.toFixed(2)));
46
- }
47
-
48
- /** Notice line describing truncation or remaining pages, shared by the text result and the TUI. */
49
- export function formatListNotice(truncated: boolean, startIndex: number, endDisplay: number, total: number): string | undefined {
50
- const nextOffset = endDisplay + 1;
51
-
52
- if (truncated) return `[Truncated: showing models ${startIndex + 1}-${endDisplay} of ${total}. Use offset=${nextOffset} to continue.]`;
53
- if (endDisplay < total) return `[${total - endDisplay} more models in list. Use offset=${nextOffset} to continue.]`;
54
-
55
- return undefined;
56
- }
@@ -1,28 +0,0 @@
1
- import { defineActionFor, registerComposedTool } from "../../utils/tool";
2
-
3
- import type { Action } from "../../utils/tool";
4
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
-
6
- interface PiActionContext {
7
- pi: ExtensionAPI;
8
- ctx: ExtensionContext;
9
- }
10
-
11
- export const defineAction = defineActionFor<PiActionContext>();
12
-
13
- export function registerPiTool(pi: ExtensionAPI, actions: Action<PiActionContext, any, any>[]): void {
14
- registerComposedTool<PiActionContext>(pi, {
15
- name: "pi",
16
- label: "pi",
17
- descriptionIntro:
18
- "Inspect and adjust the current pi session and model state. This tool groups " +
19
- "self-management actions over the running pi instance:",
20
- descriptionOutro: "Use this tool to read or change pi's own state instead of guessing.",
21
- promptSnippet: "Inspect and adjust the current pi session and model state",
22
- generalGuidelines: [
23
- "The pi tool operates only on pi's own session and model state; it does not read or modify the user's project, files, or task.",
24
- ],
25
- actions,
26
- createContext: (ctx) => ({ pi, ctx }),
27
- });
28
- }
@@ -1,47 +0,0 @@
1
- import { Type } from "typebox";
2
-
3
- import { defineAction } from "../registry";
4
- import { renderWebResult } from "../render";
5
-
6
- interface FetchDetails {
7
- action: "fetch";
8
- }
9
-
10
- export const fetchAction = defineAction({
11
- name: "fetch",
12
- summary: "reads the full content of known URLs as clean markdown",
13
- showTiming: true,
14
- fields: {
15
- urls: Type.Optional(Type.Array(Type.String(), {
16
- description: "For \"fetch\": List the URLs to read. Batch multiple URLs in one call.",
17
- })),
18
- maxCharacters: Type.Optional(Type.Number({
19
- description: "For \"fetch\": Extract at most this many characters per page (default 3000).",
20
- })),
21
- },
22
- promptGuidelines: [
23
- "Use the web tool's \"fetch\" action to read full content from known URLs, batching multiple URLs in one call, especially when search highlights are insufficient.",
24
- ],
25
- renderParams(args, theme) {
26
- const params: string[] = [];
27
-
28
- if (args.urls && args.urls.length > 0) params.push(theme.fg("muted", args.urls.join(", ")));
29
- if (args.maxCharacters !== undefined) params.push(theme.fg("warning", `<= ${args.maxCharacters} chars`));
30
-
31
- return params;
32
- },
33
- renderResult(result, { expanded }, theme) {
34
- return renderWebResult(result, expanded, theme);
35
- },
36
- async execute(args, { exa }, signal) {
37
- if (!(args.urls && args.urls.length > 0)) {
38
- throw new Error("The \"fetch\" action requires a non-empty \"urls\" array");
39
- }
40
-
41
- const requestArgs: Record<string, unknown> = { urls: args.urls };
42
- if (args.maxCharacters !== undefined) requestArgs.maxCharacters = args.maxCharacters;
43
-
44
- const content = await exa.call("web_fetch_exa", requestArgs, signal);
45
- return { content, details: { action: "fetch" } satisfies FetchDetails };
46
- },
47
- });
@@ -1,50 +0,0 @@
1
- import { Type } from "typebox";
2
-
3
- import { defineAction } from "../registry";
4
- import { renderWebResult } from "../render";
5
-
6
- interface SearchDetails {
7
- action: "search";
8
- }
9
-
10
- export const searchAction = defineAction({
11
- name: "search",
12
- summary: "finds current information across the web and returns ready-to-use content",
13
- showTiming: true,
14
- fields: {
15
- query: Type.Optional(Type.String({
16
- description:
17
- "For \"search\": Provide the search query. Use a semantically rich description of the " +
18
- "ideal page, not just keywords. Optionally include category:<type> (company, people) " +
19
- "to focus results.",
20
- })),
21
- numResults: Type.Optional(Type.Number({
22
- description: "For \"search\": Return at most this many search results (default 10).",
23
- })),
24
- },
25
- promptGuidelines: [
26
- "Use the web tool's \"search\" action for current information, news, facts, people, or companies; describe the ideal page rather than keywords (e.g., \"blog post comparing React and Vue performance\").",
27
- ],
28
- renderParams(args, theme) {
29
- const params: string[] = [];
30
-
31
- const query = args.query?.trim();
32
- if (query) params.push(theme.fg("muted", query));
33
- if (args.numResults !== undefined) params.push(theme.fg("warning", `${args.numResults} results`));
34
-
35
- return params;
36
- },
37
- renderResult(result, { expanded }, theme) {
38
- return renderWebResult(result, expanded, theme);
39
- },
40
- async execute(args, { exa }, signal) {
41
- const query = args.query?.trim();
42
- if (!query) throw new Error("The \"search\" action requires a non-empty \"query\"");
43
-
44
- const requestArgs: Record<string, unknown> = { query };
45
- if (args.numResults !== undefined) requestArgs.numResults = args.numResults;
46
-
47
- const content = await exa.call("web_search_exa", requestArgs, signal);
48
- return { content, details: { action: "search" } satisfies SearchDetails };
49
- },
50
- });
@@ -1,77 +0,0 @@
1
- import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
- import type { TextContent } from "@earendil-works/pi-ai";
3
-
4
- /** Exa's hosted MCP endpoint (Streamable HTTP). No API key required for the free tier. */
5
- const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
6
-
7
- /** Collapse an MCP tool call result's content into plain text content for the model. */
8
- function sanitizeContent(content: unknown): TextContent[] {
9
- const blocks = (Array.isArray(content) ? content : []) as { type: string; text?: string}[];
10
- const result: TextContent[] = blocks.map((block) =>
11
- block.type === "text" && typeof block.text === "string"
12
- ? { type: "text", text: block.text }
13
- : { type: "text", text: JSON.stringify(block) },
14
- );
15
-
16
- return result;
17
- }
18
-
19
- /**
20
- * Thin, session-scoped wrapper over Exa's remote MCP server using the official MCP client.
21
- *
22
- * The MCP connection (one `initialize` handshake) is established lazily on the first call and
23
- * reused for the rest of the session; `close()` tears it down on `session_shutdown`.
24
- */
25
- export class ExaClient {
26
- private client: Client | undefined;
27
- private connecting: Promise<Client> | undefined;
28
-
29
- async call(toolName: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<TextContent[]> {
30
- const client = await this.getClient();
31
- const result = await client.callTool({ name: toolName, arguments: args }, undefined, signal ? { signal } : undefined);
32
- const content = sanitizeContent(result.content);
33
-
34
- if (result.isError && content.length > 0) {
35
- const text = content.map((block) => block.text).join("\n");
36
- throw new Error(`Exa request failed: ${text}`);
37
- }
38
-
39
- if (result.isError) throw new Error(`Exa request failed: no content returned`);
40
- if (content.length === 0) throw new Error(`No content returned`);
41
-
42
- return content;
43
- }
44
-
45
- async close(): Promise<void> {
46
- const current = this.client;
47
- this.client = undefined;
48
- this.connecting = undefined;
49
- await current?.close().catch(() => {});
50
- }
51
-
52
- private getClient(): Promise<Client> {
53
- if (this.client) return Promise.resolve(this.client);
54
-
55
- if (!this.connecting) {
56
- this.connecting = (async () => {
57
- // The MCP SDK is heavy to import (~40 ms cold); load it lazily so startup never pays for
58
- // it unless a web action actually runs.
59
- const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([
60
- import("@modelcontextprotocol/sdk/client/index.js"),
61
- import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
62
- ]);
63
-
64
- const next = new Client({ name: "pi-spark", version: "0" });
65
- const transport = new StreamableHTTPClientTransport(new URL(EXA_MCP_URL));
66
- await next.connect(transport as Parameters<Client["connect"]>[0]);
67
- this.client = next;
68
- return next;
69
- })().catch((error) => {
70
- this.connecting = undefined;
71
- throw error;
72
- });
73
- }
74
-
75
- return this.connecting;
76
- }
77
- }
@@ -1,3 +0,0 @@
1
- import * as z from "zod";
2
-
3
- export const webConfigSchema = z.object({});
@@ -1,28 +0,0 @@
1
- import { ExaClient } from "./client";
2
- import { fetchAction } from "./actions/fetch";
3
- import { searchAction } from "./actions/search";
4
- import { registerWebTool } from "./registry";
5
- import { loadConfig } from "../../config";
6
-
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
-
9
- /** Web actions exposed by the web tool, ordered to match the description (search before fetch). */
10
- const ACTIONS = [
11
- searchAction,
12
- fetchAction,
13
- ];
14
-
15
- export function registerWeb(pi: ExtensionAPI): void {
16
- const exa = new ExaClient();
17
-
18
- pi.on("session_start", (_event, ctx) => {
19
- const config = loadConfig(ctx).web;
20
- if (!config) return;
21
-
22
- registerWebTool(pi, exa, ACTIONS);
23
- });
24
-
25
- pi.on("session_shutdown", () => {
26
- exa.close();
27
- });
28
- }
@@ -1,22 +0,0 @@
1
- import { defineActionFor, registerComposedTool } from "../../utils/tool";
2
-
3
- import type { ExaClient } from "./client";
4
- import type { Action } from "../../utils/tool";
5
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
-
7
- interface WebActionContext {
8
- exa: ExaClient;
9
- }
10
-
11
- export const defineAction = defineActionFor<WebActionContext>();
12
-
13
- export function registerWebTool(pi: ExtensionAPI, exa: ExaClient, actions: Action<WebActionContext, any, any>[]): void {
14
- registerComposedTool<WebActionContext>(pi, {
15
- name: "web",
16
- label: "web",
17
- descriptionIntro: "Access the live web via Exa:",
18
- promptSnippet: "Search the web and fetch page content via Exa",
19
- actions,
20
- createContext: () => ({ exa }),
21
- });
22
- }
@@ -1,34 +0,0 @@
1
- import { keyHint } from "@earendil-works/pi-coding-agent";
2
- import { Container, Spacer, Text } from "@earendil-works/pi-tui";
3
-
4
- import { joinTextContent } from "../../utils/format";
5
-
6
- import type { AgentToolResult, Theme } from "@earendil-works/pi-coding-agent";
7
-
8
- const COLLAPSED_MAX_LINES = 10;
9
-
10
- /**
11
- * Render a web result's text content with collapse-to-expand behavior. Shared by the `search` and
12
- * `fetch` actions, which both return plain text content. Errors are handled by the registry's
13
- * fallback renderer, so this only covers the success path.
14
- */
15
- export function renderWebResult(result: AgentToolResult<unknown>, expanded: boolean, theme: Theme): Container {
16
- const container = new Container();
17
- container.addChild(new Spacer(1));
18
-
19
- const text = joinTextContent(result.content);
20
- const lines = text.length > 0 ? text.split("\n") : [];
21
-
22
- if (lines.length === 0) {
23
- container.addChild(new Text(theme.fg("muted", "No content returned."), 0, 0));
24
- return container;
25
- }
26
-
27
- const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_MAX_LINES);
28
- container.addChild(new Text(theme.fg("muted", lines.slice(0, maxLines).join("\n")), 0, 0));
29
-
30
- const hidden = lines.length - maxLines;
31
- if (hidden > 0) container.addChild(new Text(theme.fg("dim", `... (${hidden} more lines, `) + keyHint("app.tools.expand", "to expand") + theme.fg("dim", ")"), 0, 0));
32
-
33
- return container;
34
- }
package/src/utils/tool.ts DELETED
@@ -1,221 +0,0 @@
1
- import { StringEnum } from "@earendil-works/pi-ai";
2
- import { Container, Spacer, Text } from "@earendil-works/pi-tui";
3
- import { Type } from "typebox";
4
-
5
- import { formatDuration, joinTextContent } from "./format";
6
-
7
- import type { AgentToolResult, AgentToolUpdateCallback, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
8
- import type { Component } from "@earendil-works/pi-tui";
9
- import type { Static, TObject, TProperties, TSchema } from "typebox";
10
-
11
- interface ActionDetails {
12
- action: string;
13
- }
14
-
15
- /**
16
- * One action of a composed tool. The registry merges all actions' `fields` into one flat schema
17
- * (provider-safe, unlike a discriminated union) and dispatches by `action`; `C` is the per-call
18
- * context from `createContext`.
19
- */
20
- export interface Action<C, F extends TProperties = TProperties, D extends ActionDetails = ActionDetails> {
21
- name: D["action"];
22
- summary: string;
23
- fields: F;
24
- /** Fields required at runtime (the flat schema makes all fields optional). */
25
- required?: (keyof F & string)[];
26
- promptGuidelines?: string[];
27
- /** Show bash-style elapsed/took timing for this action. */
28
- showTiming?: boolean;
29
- /** Styled segments shown after the `<tool> <action>` prefix. */
30
- renderParams?: (args: Static<TObject<F>>, theme: Theme) => string[];
31
- /** Only handles successful output; error output is handled centrally. */
32
- renderResult?: NonNullable<ToolDefinition<TObject<F>, D>["renderResult"]>;
33
- execute(args: Static<TObject<F>>, context: C, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback<D> | undefined): Promise<AgentToolResult<D>>;
34
- }
35
-
36
- /** Identity helper bound to context `C`, so actions infer their field/details types while sharing one context shape. */
37
- export function defineActionFor<C>() {
38
- return <F extends TProperties, D extends ActionDetails>(action: Action<C, F, D>): Action<C, F, D> => action;
39
- }
40
-
41
- interface ComposedToolConfig<C> {
42
- name: string;
43
- label: string;
44
- descriptionIntro: string;
45
- descriptionOutro?: string;
46
- promptSnippet: string;
47
- generalGuidelines?: string[];
48
- actions: Action<C, any, any>[];
49
- createContext(ctx: ExtensionContext): C;
50
- }
51
-
52
- export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolConfig<C>): void {
53
- const byName = new Map<string, Action<C, any, any>>();
54
- const mergedFields: TProperties = {};
55
-
56
- for (const action of config.actions) {
57
- if (byName.has(action.name)) throw new Error(`Duplicate "${config.name}" action "${action.name}"`);
58
- byName.set(action.name, action);
59
-
60
- for (const [key, schema] of Object.entries(action.fields)) {
61
- if (key === "action") throw new Error(`"${config.name}" action "${action.name}" must not define a field named "action"`);
62
- if (key in mergedFields) throw new Error(`"${config.name}" action "${action.name}" redefines field "${key}"; field names must be unique across actions`);
63
- mergedFields[key] = Type.Optional(schema as TSchema);
64
- }
65
- }
66
-
67
- const summaries = config.actions.map((action) => `"${action.name}" ${action.summary}`).join("; ");
68
- const description = config.descriptionOutro
69
- ? `${config.descriptionIntro} ${summaries}. ${config.descriptionOutro}`
70
- : `${config.descriptionIntro} ${summaries}.`;
71
-
72
- const parameters = Type.Object({
73
- action: StringEnum(config.actions.map((action) => action.name), { description: `The ${config.name} action to run.` }),
74
- ...mergedFields,
75
- });
76
-
77
- const activeTiming = new ActiveTiming();
78
- const noTiming = new NoTiming();
79
- const timingFor = (action: string | undefined): Timing => (byName.get(action ?? "")?.showTiming ? activeTiming : noTiming);
80
-
81
- const renderActionResult: NonNullable<ToolDefinition["renderResult"]> = (result, options, theme, context) => {
82
- const details = result.details as ActionDetails | undefined;
83
-
84
- if (context.isError || !details) {
85
- const output = joinTextContent(result.content);
86
- return new Text(context.isError && output ? theme.fg("error", "\n" + output) : "", 0, 0);
87
- }
88
-
89
- const action = byName.get(details.action);
90
- if (action?.renderResult) return action.renderResult(result as AgentToolResult<ActionDetails>, options, theme, context as never);
91
-
92
- return new Text(joinTextContent(result.content), 0, 0);
93
- };
94
-
95
- pi.registerTool({
96
- name: config.name,
97
- label: config.label,
98
- description,
99
- promptSnippet: config.promptSnippet,
100
- promptGuidelines: [...(config.generalGuidelines ?? []), ...config.actions.flatMap((action) => action.promptGuidelines ?? [])],
101
- parameters,
102
- renderCall(args, theme, context) {
103
- const segments = [theme.bold(theme.fg("toolTitle", config.name)), theme.fg("accent", args.action)];
104
- const params = byName.get(args.action)?.renderParams?.(args, theme);
105
- if (params) segments.push(...params);
106
-
107
- return timingFor(context.args.action).renderCall(new Text(segments.join(" "), 0, 0), theme, context);
108
- },
109
- renderResult(result, options, theme, context) {
110
- const inner = renderActionResult(result, options, theme, context);
111
-
112
- return timingFor(context.args.action).renderResult(inner, options, theme, context);
113
- },
114
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
115
- const action = byName.get(params.action);
116
- if (!action) throw new Error(`Unknown ${config.name} action "${params.action}"`);
117
-
118
- for (const field of action.required ?? []) {
119
- if ((params as Record<string, unknown>)[field] === undefined) {
120
- throw new Error(`The "${params.action}" action requires "${field}"`);
121
- }
122
- }
123
-
124
- return action.execute(params as any, config.createContext(ctx), signal, onUpdate);
125
- },
126
- });
127
- }
128
-
129
- /** A tuple type without its first element. */
130
- type DropFirst<T extends readonly unknown[]> = T extends readonly [unknown, ...infer R] ? R : never;
131
-
132
- /** A renderer's params after the leading args/result. */
133
- type RenderCallTail = DropFirst<Parameters<NonNullable<ToolDefinition["renderCall"]>>>;
134
- type RenderResultTail = DropFirst<Parameters<NonNullable<ToolDefinition["renderResult"]>>>;
135
-
136
- /**
137
- * Bash-style timing for a tool row: a live "Elapsed" ticker while running, a final "Took" once
138
- * settled. `renderCall`/`renderResult` wrap the built component in place of the args/result.
139
- */
140
- interface Timing {
141
- renderCall(inner: Component, ...rest: RenderCallTail): Component;
142
- renderResult(inner: Component, ...rest: RenderResultTail): Component;
143
- }
144
-
145
- /** Per-row timing state, kept in `ToolRenderContext.state` so the timing classes stay stateless. */
146
- interface TimingState {
147
- startedAt?: number;
148
- endedAt?: number;
149
- interval?: ReturnType<typeof setInterval>;
150
- hasResult?: boolean;
151
- }
152
-
153
- class ActiveTiming implements Timing {
154
- // No result yet: `renderCall` owns the live "Elapsed" ticker.
155
- renderCall(inner: Component, ...[theme, context]: RenderCallTail): Component {
156
- const state = context.state as TimingState;
157
-
158
- if (context.executionStarted && state.startedAt === undefined) {
159
- state.startedAt = Date.now();
160
- delete state.endedAt;
161
- delete state.hasResult;
162
- }
163
-
164
- // Not started, or final (`renderResult` owns the "Took" line): render nothing here.
165
- if (state.startedAt === undefined || !context.isPartial) return inner;
166
-
167
- state.interval ??= setInterval(() => context.invalidate(), 1000);
168
-
169
- // `renderResult` runs later in the same render pass, so decide visibility at render time:
170
- // hide once any result has arrived.
171
- return this.withTimingLine(inner, "Elapsed", Date.now() - state.startedAt, theme, () => !state.hasResult);
172
- }
173
-
174
- // A result exists: `renderResult` owns the line — "Elapsed" while streaming, "Took" once final.
175
- renderResult(inner: Component, ...[options, theme, context]: RenderResultTail): Component {
176
- const state = context.state as TimingState;
177
- state.hasResult = true;
178
-
179
- const isRunning = options.isPartial && !context.isError;
180
- if (!isRunning) this.settle(state);
181
- if (state.startedAt === undefined) return inner;
182
-
183
- const endTime = state.endedAt ?? Date.now();
184
- return this.withTimingLine(inner, isRunning ? "Elapsed" : "Took", endTime - state.startedAt, theme);
185
- }
186
-
187
- private settle(state: TimingState): void {
188
- if (state.startedAt !== undefined) {
189
- state.endedAt ??= Date.now();
190
- }
191
-
192
- if (state.interval) {
193
- clearInterval(state.interval);
194
- delete state.interval;
195
- }
196
- }
197
-
198
- private withTimingLine(content: Component, label: string, ms: number, theme: Theme, visible?: () => boolean): Component {
199
- const container = new Container();
200
- container.addChild(content);
201
-
202
- container.addChild(new Spacer(1));
203
- container.addChild(new Text(theme.fg("muted", `${label} ${formatDuration(ms)}`), 0, 0));
204
-
205
- if (!visible) return container;
206
-
207
- return {
208
- invalidate: () => container.invalidate(),
209
- render: (width) => (visible() ? container.render(width) : content.render(width)),
210
- };
211
- }
212
- }
213
-
214
- class NoTiming implements Timing {
215
- renderCall(inner: Component): Component {
216
- return inner;
217
- }
218
- renderResult(inner: Component): Component {
219
- return inner;
220
- }
221
- }