pi-spark 0.12.0 → 0.13.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 (53) hide show
  1. package/README.md +74 -61
  2. package/index.ts +31 -0
  3. package/package.json +5 -4
  4. package/{extensions/shared → src}/components/inline-text.ts +1 -1
  5. package/{extensions/shared → src}/config/index.ts +37 -12
  6. package/src/config/schema.ts +30 -0
  7. package/{extensions/shared → src}/events/auto-collect-events.ts +1 -1
  8. package/src/events/index.ts +3 -0
  9. package/{extensions → src/features}/credits/index.ts +5 -7
  10. package/{extensions → src/features}/credits/providers/deepseek.ts +1 -1
  11. package/{extensions → src/features}/credits/providers/fireworks.ts +1 -1
  12. package/{extensions → src/features}/credits/providers/moonshot.ts +1 -1
  13. package/{extensions → src/features}/credits/providers/openai-codex.ts +1 -1
  14. package/{extensions → src/features}/credits/providers/openrouter.ts +1 -1
  15. package/{extensions → src/features}/credits/providers/vercel-ai-gateway.ts +1 -1
  16. package/{extensions → src/features}/editor/index.ts +8 -11
  17. package/{extensions → src/features}/footer/index.ts +7 -9
  18. package/{extensions → src/features}/fullscreen/index.ts +6 -9
  19. package/src/features/model/config.ts +3 -0
  20. package/{extensions/models → src/features/model}/index.ts +5 -5
  21. package/{extensions → src/features}/name/index.ts +6 -8
  22. package/{extensions → src/features}/presets/config.ts +1 -1
  23. package/{extensions → src/features}/presets/index.ts +4 -4
  24. package/{extensions → src/features}/presets/manager.ts +2 -2
  25. package/{extensions → src/features}/recap/config.ts +1 -1
  26. package/{extensions → src/features}/recap/index.ts +4 -6
  27. package/{extensions → src/features}/recap/manager.ts +5 -5
  28. package/{extensions → src/features}/recap/model.ts +2 -2
  29. package/{extensions/shared/format/index.ts → src/utils/format.ts} +29 -0
  30. package/{extensions/shared/usage/index.ts → src/utils/usage.ts} +0 -18
  31. package/assets/credits.png +0 -0
  32. package/extensions/credits/utils.ts +0 -52
  33. package/extensions/models/config.ts +0 -3
  34. package/extensions/shared/config/schema.ts +0 -24
  35. package/extensions/shared/events/index.ts +0 -3
  36. /package/{extensions/shared → src}/components/split-line.ts +0 -0
  37. /package/{extensions/shared → src}/config/model.ts +0 -0
  38. /package/{extensions/shared → src}/events/preset.ts +0 -0
  39. /package/{extensions → src/features}/credits/config.ts +0 -0
  40. /package/{extensions → src/features}/credits/manager.ts +0 -0
  41. /package/{extensions → src/features}/credits/providers/fireworks.proto +0 -0
  42. /package/{extensions → src/features}/credits/providers/index.ts +0 -0
  43. /package/{extensions → src/features}/credits/status.ts +0 -0
  44. /package/{extensions → src/features}/credits/types.ts +0 -0
  45. /package/{extensions → src/features}/editor/config.ts +0 -0
  46. /package/{extensions → src/features}/editor/spinner.ts +0 -0
  47. /package/{extensions → src/features}/footer/config.ts +0 -0
  48. /package/{extensions → src/features}/fullscreen/config.ts +0 -0
  49. /package/{extensions → src/features}/fullscreen/filler.ts +0 -0
  50. /package/{extensions → src/features}/name/config.ts +0 -0
  51. /package/{extensions → src/features}/presets/selector.ts +0 -0
  52. /package/{extensions → src/features}/recap/idle.ts +0 -0
  53. /package/{extensions → src/features}/recap/widget.ts +0 -0
package/README.md CHANGED
@@ -2,46 +2,56 @@
2
2
 
3
3
  # pi-spark
4
4
 
5
- [Pi](https://pi.dev/) extensions that polish the daily experience.
5
+ [Pi](https://pi.dev/) package that polishes your daily experience and keeps you at the frontier of agentic workflows.
6
6
 
7
- ## Extensions
7
+ ## Features
8
8
 
9
- - **Credits:** shows the active provider's credit balance or rate-limit usage in the status line.
10
- - **Editor:** replaces the default editor with a compact working indicator (inspired by [Amp](https://ampcode.com/)) and current model info.
11
- - **Footer:** shows session information, extension statuses, cost, and context usage on one line.
12
- - **Fullscreen:** clears the screen and scrollback on session start, pins the editor and footer to the bottom for a full-screen session, and clears again on exit.
13
- - **Models:** exposes a `model` tool so the agent can list the models the user can use currently and inspect the provider, model, and thinking level in use.
14
- - **Name:** exposes a `name` tool so the agent can give the current session a concise, recognizable name in the session selector.
15
- - **Presets:** switches named model presets with `/preset`, `--preset`, and quick cycle shortcuts.
16
- - **Recap:** generates a short idle-session recap and exposes a `/recap` command for manual generation, inspired by [Claude Code's session recap](https://code.claude.com/docs/en/interactive-mode#session-recap).
9
+ ### Compact TUI: editor, footer, and fullscreen
17
10
 
18
- ![Screenshot](./assets/screenshot.png)
11
+ pi-spark ships with custom editor, footer, and fullscreen rendering, replacing the default ones. The compact TUI gives you a calm, immersive experience without distraction.
19
12
 
20
- ## Install
13
+ - The editor shows a working indicator inspired by [Amp](https://ampcode.com/) and the current model on the top border. If you use [presets](#presets), the active preset appears there too.
14
+ - The footer shows session information, extension statuses, cost, and context usage on one line.
15
+ - The fullscreen rendering clears the terminal screen and scrollback on session start and exit, and pins the editor and footer to the bottom.
21
16
 
22
- Install from npm:
17
+ ### Credits
23
18
 
24
- ```bash
25
- pi install npm:pi-spark
26
- ```
19
+ pi-spark shows the active provider's credit balance or rate-limit usage in the status line, so you can keep an eye on what's left without leaving the terminal.
20
+
21
+ - Supported providers: DeepSeek, Fireworks, Moonshot, OpenAI Codex, OpenRouter, and Vercel AI Gateway.
22
+ - Most provider fetching follows [CodexBar](https://github.com/steipete/codexbar). Fireworks is the exception: its balance sits behind an internal gRPC API, reverse-engineered from the `firectl` binary (see [docs/fireworks.md](./docs/fireworks.md)).
27
23
 
28
- For local development from this repo:
24
+ ### Presets
29
25
 
30
- ```bash
31
- pi install /path/to/pi-spark
32
- ```
26
+ pi-spark lets you define named model presets in `spark.json` (see [Configuration](#configuration)), so you can switch between models and thinking levels without retyping provider details. The active preset is shown on the editor's top border.
27
+
28
+ - Switch interactively with `/preset`, or jump straight to one with `/preset <name>`.
29
+ - Start pi on a given preset with `pi --preset <name>`.
30
+ - Cycle presets with `ctrl+super+p` (forward) and `ctrl+shift+super+p` (backward); `super` is `command` on macOS and needs a terminal that forwards it.
31
+
32
+ ### Recap
33
+
34
+ pi-spark generates a short recap of the current session after it goes idle, or on demand, inspired by [Claude Code's session recap](https://code.claude.com/docs/en/interactive-mode#session-recap).
35
+
36
+ - A recap is generated automatically once the session stays idle past `recap.idle` in `spark.json`.
37
+ - Run `/recap` to generate one manually at any time.
38
+ - The recap can use its own model, configured separately from your working model.
39
+
40
+ ### Agent tools: `name` and `model`
33
41
 
34
- ## Configure
42
+ pi-spark provides the pi coding agent with tools to inspect and manipulate itself. Currently, `name` and `model` tools are included.
35
43
 
36
- Spark reads config from `~/.pi/agent/spark.json` and from the current project’s `.pi/spark.json`. Project config overrides matching global fields.
44
+ - `name` sets or updates the current session's name.
45
+ - `model` shows the active model or lists pi models.
37
46
 
38
- All extensions are enabled by default. Set a specific extension to `false` to disable it, for example, `"footer": false` disables the footer extension.
47
+ ## Configuration
39
48
 
40
- Example:
49
+ pi-spark reads config from `~/.pi/agent/spark.json` and from the current project's `.pi/spark.json`. Project config overrides matching global fields.
50
+
51
+ For example:
41
52
 
42
53
  ```json
43
54
  {
44
- "credits": false,
45
55
  "editor": {
46
56
  "spinner": "dots"
47
57
  },
@@ -67,58 +77,61 @@ Example:
67
77
  }
68
78
  ```
69
79
 
70
- ### Credits
71
-
72
- - pi-spark shows the active provider's credit balance or rate-limit usage in the status line.
73
- - Supported providers: DeepSeek, Fireworks, Moonshot, OpenAI Codex, OpenRouter, and Vercel AI Gateway.
74
- - Most provider-specific fetching approaches reference [CodexBar](https://github.com/steipete/codexbar). Fireworks is an exception: its balance lives behind an internal gRPC API, so the approach was reverse-engineered from the `firectl` binary — see [docs/fireworks.md](./docs/fireworks.md).
80
+ ### References
75
81
 
76
- ### Editor
82
+ All fields are optional. Each top-level feature runs with the defaults below unless you [turn it off](#turn-off-the-features-you-dont-like).
77
83
 
78
- - `editor.spinner` controls the working indicator style and can be `dots`, `lights`, `tildes`, or `pulse`.
84
+ | Field | Value (or `false`) | Description |
85
+ | --- | --- | --- |
86
+ | `credits` | `{}` | Shows the active provider's credit balance or rate-limit usage in the status line. |
87
+ | `editor` | `EditorConfig` | Shows a working indicator and the current model on the editor's top border. |
88
+ | `footer` | `{}` | Shows session info, extension statuses, cost, and context usage on one line. |
89
+ | `fullscreen` | `{}` | Clears the screen and scrollback on start and exit, and pins the editor and footer to the bottom. |
90
+ | `model` | `{}` | Exposes the `model` agent tool. |
91
+ | `name` | `{}` | Exposes the `name` agent tool. |
92
+ | `presets` | `{ [name]: Preset }` | Defines named model presets, keyed by name. |
93
+ | `recap` | `RecapConfig` | Generates a session recap when idle or on demand. |
79
94
 
80
- ### Footer
95
+ #### `EditorConfig`
81
96
 
82
- - pi-spark replaces the footer with a compact one-line view of session metadata, extension statuses, cost, and context usage.
97
+ The `spinner` field is optional and defaults to `tildes`.
83
98
 
84
- ### Fullscreen
99
+ | Field | Value | Description |
100
+ | --- | --- | --- |
101
+ | `spinner` | `dots` | `⠋, ⠙, ⠹, ⠸, ⠼, ⠴, ⠦, ⠧, ⠇, ⠏` |
102
+ | | `lights` | `○, ●` |
103
+ | | `tildes` (default) | `∼, ≈, ≋, ≈, ∼` |
104
+ | | `pulse` | `·, •, ●, •, ·` |
85
105
 
86
- - pi-spark clears the screen and scrollback at session start and exit, pins the editor and footer to the bottom, and enables pi's `clearOnShrink` behavior programmatically so pinned UI stays aligned after taller components close.
106
+ #### `Preset`
87
107
 
88
- ### Models
108
+ Each preset must set all three fields.
89
109
 
90
- - The agent can call the `model` tool with two actions:
91
- - `active`: gets the active provider, model, and thinking level.
92
- - `list`: lists models with their metadata, with an optional [Liqe](https://github.com/gajus/liqe) (Lucene-like) `query` over model fields and `offset`/`limit` paging.
110
+ | Field | Value | Description |
111
+ | --- | --- | --- |
112
+ | `provider` | string | Provider id, e.g. `anthropic`. |
113
+ | `model` | string | Model id, e.g. `claude-opus-4-8`. |
114
+ | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the preset. |
93
115
 
94
- ### Name
116
+ #### `RecapConfig`
95
117
 
96
- - The agent can set or refresh the current session's name and optionally give a reason.
118
+ All fields are optional, including `thinkingLevel`.
97
119
 
98
- ### Presets
120
+ | Field | Value | Description |
121
+ | --- | --- | --- |
122
+ | `idle` | number (ms) or duration string | How long the session must stay idle before a recap is generated. Accepts a millisecond number or a [vercel/ms](https://github.com/vercel/ms) string (e.g. `"3m"`); minimum 5000 ms, defaults to 3 minutes. |
123
+ | `provider` | string | Provider id for the recap model. |
124
+ | `model` | string | Model id for the recap model. |
125
+ | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the recap model. |
99
126
 
100
- - Each key under `presets` defines a named model preset with `provider`, `model`, and optional `thinkingLevel` fields.
127
+ ### Turn off the features you don't like
101
128
 
102
- Use presets in these ways:
129
+ All features are enabled by default. Set a specific feature to `false` in `spark.json` to disable it.
103
130
 
104
- - Select interactively with `/preset` or `/preset <key>`
105
- - Start pi with a preset using `pi --preset <key>`
106
- - Cycle presets with `ctrl+super+p` and `ctrl+shift+super+p` (`super` is `command` on macOS)
107
-
108
- ### Recap
109
-
110
- - pi-spark can generate a short recap after the session has been idle or when you run `/recap` manually.
111
- - The `recap.idle` value sets how long the session must stay idle before a recap is generated. It accepts either a millisecond number or a human-readable duration string parsed by [vercel/ms](https://github.com/vercel/ms) (e.g., `"3m"`, `"30s"`, `"2 minutes"`), and must resolve to at least 5000ms.
112
- - The recap model can be customized with `provider`, `model`, and `thinkingLevel`.
113
-
114
- ## Recommended pi settings
115
-
116
- [Pi 0.79.0](https://pi.dev/news/releases/0.79.0) added a project trust dialog that asks before loading project-local resources ([earendil-works/pi#5514](https://github.com/earendil-works/pi/issues/5514)), and [pi 0.79.1](https://pi.dev/news/releases/0.79.1) made the default behavior configurable via `defaultProjectTrust`. To keep startup minimal, set it to `"always"` in `~/.pi/agent/settings.json` (or change it with `/settings`):
131
+ For example, to disable the customized footer:
117
132
 
118
133
  ```json
119
134
  {
120
- "defaultProjectTrust": "always"
135
+ "footer": false
121
136
  }
122
137
  ```
123
-
124
- Project trust is an [input-loading guard](https://pi.dev/docs/latest/security#project-trust), so use `"always"` only if you trust the projects you open.
package/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { autoCollectEvents } from "./src/events";
2
+ import { registerCredits } from "./src/features/credits";
3
+ import { registerEditor } from "./src/features/editor";
4
+ import { registerFooter } from "./src/features/footer";
5
+ import { registerFullscreen } from "./src/features/fullscreen";
6
+ import { registerModel } from "./src/features/model";
7
+ import { registerName } from "./src/features/name";
8
+ import { registerPresets } from "./src/features/presets";
9
+ import { registerRecap } from "./src/features/recap";
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+
13
+ /**
14
+ * pi-spark is a single extension that bundles several features. Each feature registers itself
15
+ * like a standalone extension and reads its config via the shared, cached `loadConfig`. This file
16
+ * is just the registry that wires them together.
17
+ */
18
+ export default function (pi: ExtensionAPI) {
19
+ // Own the event-bus subscription lifecycle here so features never manage cleanup themselves;
20
+ // the collector disposes every subscription on session_shutdown.
21
+ const events = autoCollectEvents(pi);
22
+
23
+ registerCredits(pi);
24
+ registerEditor(pi, events);
25
+ registerFooter(pi);
26
+ registerFullscreen(pi);
27
+ registerModel(pi);
28
+ registerName(pi);
29
+ registerPresets(pi);
30
+ registerRecap(pi);
31
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.12.0",
4
- "description": "Pi extensions that polish the daily experience.",
3
+ "version": "0.13.0",
4
+ "description": "Pi package that polishes your daily experience and keeps you at the frontier of agentic workflows.",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
7
7
  "pi-package"
@@ -13,14 +13,15 @@
13
13
  "license": "MIT",
14
14
  "type": "module",
15
15
  "files": [
16
- "extensions",
16
+ "index.ts",
17
+ "src",
17
18
  "assets",
18
19
  "README.md",
19
20
  "LICENSE"
20
21
  ],
21
22
  "pi": {
22
23
  "extensions": [
23
- "./extensions"
24
+ "./index.ts"
24
25
  ],
25
26
  "image": "https://raw.githubusercontent.com/zlliang/pi-spark/main/assets/cover.png"
26
27
  },
@@ -1,6 +1,6 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
 
3
- import { sanitizeText } from "../format";
3
+ import { sanitizeText } from "../utils/format";
4
4
 
5
5
  import type { Component } from "@earendil-works/pi-tui";
6
6
 
@@ -2,26 +2,51 @@ import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
 
5
- import { configSchemas } from "./schema";
5
+ import { sparkConfigSchema } from "./schema";
6
6
 
7
7
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import type { ConfigField, ConfigValue } from "./schema";
8
+ import type { SparkConfig } from "./schema";
9
9
 
10
10
  type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
11
11
  type JsonObject = { [key: string]: JsonValue };
12
12
 
13
- export function loadConfig<Field extends ConfigField>(ctx: ExtensionContext, field: Field, fileName: string = "spark.json"): ConfigValue<Field> | undefined {
14
- const rawConfig = loadMergedJson(getConfigPaths(ctx.cwd, fileName));
15
- const rawValue = rawConfig?.[field];
16
- if (rawValue === false) return undefined;
17
-
18
- const result = configSchemas[field].safeParse(rawValue === true || rawValue === undefined ? {} : rawValue);
19
- if (result.success) return result.data as ConfigValue<Field>;
13
+ /** All features disabled; used as the fallback when `spark.json` fails validation. */
14
+ const DISABLED_CONFIG: SparkConfig = Object.freeze({
15
+ credits: false,
16
+ editor: false,
17
+ footer: false,
18
+ fullscreen: false,
19
+ model: false,
20
+ name: false,
21
+ presets: false,
22
+ recap: false,
23
+ });
24
+
25
+ const cache = new Map<string, SparkConfig>();
26
+
27
+ /** Load and validate spark.json once per session lifecycle; later calls return the cached result. */
28
+ export function loadConfig(ctx: ExtensionContext, fileName: string = "spark.json"): SparkConfig {
29
+ const key = `${ctx.cwd}\u0000${fileName}`;
30
+ const cached = cache.get(key);
31
+ if (cached) return cached;
32
+
33
+ const rawConfig = loadMergedJson(getConfigPaths(ctx.cwd, fileName)) ?? {};
34
+ const result = sparkConfigSchema.safeParse(rawConfig);
35
+
36
+ if (!result.success) {
37
+ const message = result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
38
+ ctx.ui.notify(`Invalid spark config: ${message}`, "error");
39
+
40
+ const config = DISABLED_CONFIG;
41
+ cache.set(key, config);
42
+
43
+ return config;
44
+ }
20
45
 
21
- const message = result.error.issues.map((issue) => `${[field, ...issue.path].join(".")}: ${issue.message}`).join("; ");
22
- ctx.ui.notify(`Invalid spark config: ${message}`, "error");
46
+ const config = result.data;
47
+ cache.set(key, config);
23
48
 
24
- return undefined;
49
+ return config;
25
50
  }
26
51
 
27
52
  function getConfigPaths(cwd: string, fileName: string): [globalPath: string, projectPath: string] {
@@ -0,0 +1,30 @@
1
+ import * as z from "zod";
2
+
3
+ import { creditsConfigSchema } from "../features/credits/config";
4
+ import { editorConfigSchema } from "../features/editor/config";
5
+ import { footerConfigSchema } from "../features/footer/config";
6
+ import { fullscreenConfigSchema } from "../features/fullscreen/config";
7
+ import { modelConfigSchema } from "../features/model/config";
8
+ import { nameConfigSchema } from "../features/name/config";
9
+ import { presetsConfigSchema } from "../features/presets/config";
10
+ import { recapConfigSchema } from "../features/recap/config";
11
+
12
+ const disabled = z.literal(false);
13
+
14
+ /**
15
+ * Each feature field is `{ ... } | false`: `false` disables the feature, an object configures
16
+ * it, and an omitted field falls back to defaults (`{}`, enabled).
17
+ */
18
+ export const sparkConfigSchema = z.object({
19
+ credits: creditsConfigSchema.or(disabled).default({}),
20
+ editor: editorConfigSchema.or(disabled).default({}),
21
+ footer: footerConfigSchema.or(disabled).default({}),
22
+ fullscreen: fullscreenConfigSchema.or(disabled).default({}),
23
+ model: modelConfigSchema.or(disabled).default({}),
24
+ name: nameConfigSchema.or(disabled).default({}),
25
+ presets: presetsConfigSchema.or(disabled).default({}),
26
+ recap: recapConfigSchema.or(disabled).default({}),
27
+ });
28
+
29
+ /** Resolved config for every feature; `false` means the feature is disabled. */
30
+ export type SparkConfig = z.infer<typeof sparkConfigSchema>;
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, EventBus } from "@earendil-works/pi-coding-agent";
2
2
 
3
- class EventCollector {
3
+ export class EventCollector {
4
4
  private pi: ExtensionAPI;
5
5
  private collected: Set<() => void> = new Set();
6
6
 
@@ -0,0 +1,3 @@
1
+ export { autoCollectEvents, EventCollector } from "./auto-collect-events";
2
+
3
+ export * from "./preset";
@@ -1,6 +1,6 @@
1
1
  import { CreditsManager } from "./manager";
2
- import { isUsage } from "./utils";
3
- import { loadConfig } from "../shared/config";
2
+ import { loadConfig } from "../../config";
3
+ import { isUsage } from "../../utils/usage";
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
@@ -12,14 +12,12 @@ function hasCost(message: AgentMessage): boolean {
12
12
  return usage.cost.total > 0 || usage.input > 0 || usage.output > 0;
13
13
  }
14
14
 
15
- export default function (pi: ExtensionAPI) {
15
+ export function registerCredits(pi: ExtensionAPI): void {
16
16
  let creditsManager: CreditsManager | undefined = undefined;
17
17
 
18
18
  pi.on("session_start", (_event, ctx) => {
19
- if (!ctx.hasUI) return;
20
-
21
- const config = loadConfig(ctx, "credits");
22
- if (!config) return;
19
+ const config = loadConfig(ctx).credits;
20
+ if (!ctx.hasUI || !config) return;
23
21
 
24
22
  creditsManager = new CreditsManager();
25
23
  creditsManager.refresh(ctx);
@@ -1,4 +1,4 @@
1
- import { convertToUSD, toNumber } from "../utils";
1
+ import { convertToUSD, toNumber } from "../../../utils/format";
2
2
 
3
3
  import type { Credits, CreditsProvider } from "../types";
4
4
 
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { credentials, loadPackageDefinition, Metadata } from "@grpc/grpc-js";
4
4
  import { loadSync } from "@grpc/proto-loader";
5
5
 
6
- import { toNumber } from "../utils";
6
+ import { toNumber } from "../../../utils/format";
7
7
 
8
8
  import type { ClientUnaryCall, ServiceClientConstructor, ServiceError } from "@grpc/grpc-js";
9
9
  import type { Credits, CreditsProvider } from "../types";
@@ -1,4 +1,4 @@
1
- import { convertToUSD, toNumber } from "../utils";
1
+ import { convertToUSD, toNumber } from "../../../utils/format";
2
2
 
3
3
  import type { Credits, CreditsProvider } from "../types";
4
4
 
@@ -1,4 +1,4 @@
1
- import { toNumber } from "../utils";
1
+ import { toNumber } from "../../../utils/format";
2
2
 
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { Credits, CreditsProvider } from "../types";
@@ -1,4 +1,4 @@
1
- import { toNumber } from "../utils";
1
+ import { toNumber } from "../../../utils/format";
2
2
 
3
3
  import type { Credits, CreditsProvider } from "../types";
4
4
 
@@ -1,4 +1,4 @@
1
- import { toNumber } from "../utils";
1
+ import { toNumber } from "../../../utils/format";
2
2
 
3
3
  import type { Credits, CreditsProvider } from "../types";
4
4
 
@@ -1,13 +1,14 @@
1
1
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import { Spinner } from "./spinner";
4
- import { SplitLine } from "../shared/components/split-line";
5
- import { loadConfig } from "../shared/config";
6
- import { autoCollectEvents, PRESET_CHANGE, parsePresetChange } from "../shared/events";
7
- import { formatModel } from "../shared/format";
4
+ import { SplitLine } from "../../components/split-line";
5
+ import { loadConfig } from "../../config";
6
+ import { PRESET_CHANGE, parsePresetChange } from "../../events";
7
+ import { formatModel } from "../../utils/format";
8
8
 
9
9
  import type { ExtensionAPI, ExtensionContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
10
10
  import type { TUI, EditorTheme } from "@earendil-works/pi-tui";
11
+ import type { EventCollector } from "../../events";
11
12
 
12
13
  class Editor extends CustomEditor {
13
14
  private pi: ExtensionAPI;
@@ -81,18 +82,14 @@ class Editor extends CustomEditor {
81
82
  }
82
83
  }
83
84
 
84
- export default function (pi: ExtensionAPI) {
85
- const events = autoCollectEvents(pi);
86
-
85
+ export function registerEditor(pi: ExtensionAPI, events: EventCollector): void {
87
86
  let editor: Editor | undefined = undefined;
88
87
  let spinner: Spinner | undefined = undefined;
89
88
  let runningToolCallIds = new Set<string>();
90
89
 
91
90
  pi.on("session_start", (_event, ctx) => {
92
- if (!ctx.hasUI) return;
93
-
94
- const config = loadConfig(ctx, "editor");
95
- if (!config) return;
91
+ const config = loadConfig(ctx).editor;
92
+ if (!ctx.hasUI || !config) return;
96
93
 
97
94
  spinner = new Spinner(config.spinner);
98
95
 
@@ -2,10 +2,10 @@ import { homedir } from "node:os";
2
2
  import { resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
 
5
- import { SplitLine } from "../shared/components/split-line";
6
- import { loadConfig } from "../shared/config";
7
- import { formatContextUsage, formatCost, formatCwd, linkText, sanitizeText } from "../shared/format";
8
- import { getEntryUsage } from "../shared/usage";
5
+ import { SplitLine } from "../../components/split-line";
6
+ import { loadConfig } from "../../config";
7
+ import { formatContextUsage, formatCost, formatCwd, linkText, sanitizeText } from "../../utils/format";
8
+ import { getEntryUsage } from "../../utils/usage";
9
9
 
10
10
  import type { ExtensionContext, ExtensionAPI, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
11
11
  import type { Component } from "@earendil-works/pi-tui";
@@ -80,12 +80,10 @@ class FooterComponent implements Component {
80
80
  }
81
81
  }
82
82
 
83
- export default function (pi: ExtensionAPI) {
83
+ export function registerFooter(pi: ExtensionAPI): void {
84
84
  pi.on("session_start", (_event, ctx) => {
85
- if (!ctx.hasUI) return;
86
-
87
- const config = loadConfig(ctx, "footer");
88
- if (!config) return;
85
+ const config = loadConfig(ctx).footer;
86
+ if (!ctx.hasUI || !config) return;
89
87
 
90
88
  ctx.ui.setFooter((_tui, theme, footerData) => new FooterComponent(ctx, theme, footerData));
91
89
  });
@@ -1,12 +1,11 @@
1
1
  import { VERSION } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
3
 
3
4
  import { BottomFiller } from "./filler";
4
- import { loadConfig } from "../shared/config";
5
- import { sanitizeText } from "../shared/format";
5
+ import { loadConfig } from "../../config";
6
+ import { sanitizeText } from "../../utils/format";
6
7
 
7
8
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
8
- import { truncateToWidth } from "@earendil-works/pi-tui";
9
-
10
9
  import type { TUI } from "@earendil-works/pi-tui";
11
10
  import type { UserMessage } from "@earendil-works/pi-ai";
12
11
 
@@ -33,16 +32,14 @@ function extractText(message: UserMessage): string {
33
32
  return content.filter((block) => block.type === "text").map((block) => block.text).join(" ");
34
33
  }
35
34
 
36
- export default function (pi: ExtensionAPI) {
35
+ export function registerFullscreen(pi: ExtensionAPI): void {
37
36
  let tui: TUI | undefined;
38
37
  let enabled = false;
39
38
  let pendingClear = false;
40
39
 
41
40
  pi.on("session_start", (_event, ctx) => {
42
- if (!ctx.hasUI) return;
43
-
44
- const config = loadConfig(ctx, "fullscreen");
45
- if (!config) return;
41
+ const config = loadConfig(ctx).fullscreen;
42
+ if (!ctx.hasUI || !config) return;
46
43
 
47
44
  enabled = true;
48
45
  pendingClear = true;
@@ -0,0 +1,3 @@
1
+ import * as z from "zod";
2
+
3
+ export const modelConfigSchema = z.object({});
@@ -4,8 +4,8 @@ import { Container, Spacer, Text } from "@earendil-works/pi-tui";
4
4
  import { filter, parse } from "liqe";
5
5
  import { Type } from "typebox";
6
6
 
7
- import { loadConfig } from "../shared/config";
8
- import { formatModel, formatTokens } from "../shared/format";
7
+ import { loadConfig } from "../../config";
8
+ import { formatModel, formatTokens } from "../../utils/format";
9
9
 
10
10
  import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
11
11
  import type { ExtensionAPI, TruncationResult } from "@earendil-works/pi-coding-agent";
@@ -72,9 +72,9 @@ function formatListNotice(truncated: boolean, startIndex: number, endDisplay: nu
72
72
  return undefined;
73
73
  }
74
74
 
75
- export default function (pi: ExtensionAPI) {
75
+ export function registerModel(pi: ExtensionAPI): void {
76
76
  pi.on("session_start", (_event, ctx) => {
77
- const config = loadConfig(ctx, "models");
77
+ const config = loadConfig(ctx).model;
78
78
  if (!config) return;
79
79
 
80
80
  pi.registerTool({
@@ -89,7 +89,7 @@ export default function (pi: ExtensionAPI) {
89
89
  promptSnippet: "Show the active model or list pi models",
90
90
  promptGuidelines: [
91
91
  "Use the model tool when you need pi model metadata, the active model, or the thinking level, rather than guessing.",
92
- "When listing models with the model tool, include available:true in the query unless unavailable models are explicitly needed.",
92
+ "Use the model tool with `available:true` in the query unless unavailable models are explicitly needed.",
93
93
  ],
94
94
  parameters: Type.Object({
95
95
  action: StringEnum(["active", "list"] as const, {
@@ -1,25 +1,23 @@
1
1
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
2
2
  import { Type } from "typebox";
3
3
 
4
- import { loadConfig } from "../shared/config";
5
- import { sanitizeText } from "../shared/format";
4
+ import { loadConfig } from "../../config";
5
+ import { sanitizeText } from "../../utils/format";
6
6
 
7
7
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
8
 
9
- export default function (pi: ExtensionAPI) {
9
+ export function registerName(pi: ExtensionAPI): void {
10
10
  pi.on("session_start", (_event, ctx) => {
11
- if (!ctx.hasUI) return;
12
-
13
- const config = loadConfig(ctx, "name");
11
+ const config = loadConfig(ctx).name;
14
12
  if (!config) return;
15
13
 
16
14
  pi.registerTool({
17
15
  name: "name",
18
16
  label: "name",
19
17
  description:
20
- "Name or rename the current session. The name is a concise label shown in the session " +
18
+ "Set or update the current session's name. The name is a concise label shown in the session " +
21
19
  "selector instead of the first-message preview.",
22
- promptSnippet: "Name or rename the current session",
20
+ promptSnippet: "Set or update the current session's name",
23
21
  promptGuidelines: [
24
22
  "Use the name tool when the session needs a concise, recognizable label, especially after a long, vague, or pasted opening prompt.",
25
23
  "Use the name tool to rename the session only after a substantial shift in the conversation's focus, not for minor follow-ups.",
@@ -1,6 +1,6 @@
1
1
  import * as z from "zod";
2
2
 
3
- import { modelSchema } from "../shared/config/model";
3
+ import { modelSchema } from "../../config/model";
4
4
 
5
5
  export const presetsConfigSchema = z.record(z.string().min(1), modelSchema);
6
6
 
@@ -2,11 +2,11 @@ import { Key } from "@earendil-works/pi-tui";
2
2
 
3
3
  import { PresetManager } from "./manager";
4
4
  import { showPresetSelector } from "./selector";
5
- import { loadConfig } from "../shared/config";
5
+ import { loadConfig } from "../../config";
6
6
 
7
7
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
8
 
9
- export default function (pi: ExtensionAPI) {
9
+ export function registerPresets(pi: ExtensionAPI): void {
10
10
  let presetManager: PresetManager | undefined = undefined;
11
11
 
12
12
  pi.registerFlag("preset", {
@@ -14,10 +14,10 @@ export default function (pi: ExtensionAPI) {
14
14
  type: "string",
15
15
  });
16
16
 
17
- pi.on("session_start", async (event, ctx) => {
17
+ pi.on("session_start", async (_event, ctx) => {
18
+ const config = loadConfig(ctx).presets;
18
19
  const presetFlag = pi.getFlag("preset");
19
20
 
20
- const config = loadConfig(ctx, "presets");
21
21
  if (!config || Object.keys(config).length === 0) {
22
22
  if (presetFlag) ctx.ui.notify("No presets defined in spark.json", "warning");
23
23
  return;
@@ -1,5 +1,5 @@
1
- import { PRESET_CHANGE } from "../shared/events";
2
- import { formatModel } from "../shared/format";
1
+ import { PRESET_CHANGE } from "../../events";
2
+ import { formatModel } from "../../utils/format";
3
3
 
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import type { PresetsConfig, PresetConfig } from "./config";
@@ -1,7 +1,7 @@
1
1
  import * as z from "zod";
2
2
 
3
3
  import { idleTimeoutSchema } from "./idle";
4
- import { optionalModelSchema } from "../shared/config/model";
4
+ import { optionalModelSchema } from "../../config/model";
5
5
 
6
6
  export const recapConfigSchema = optionalModelSchema.extend({
7
7
  idle: idleTimeoutSchema.optional(),
@@ -1,18 +1,16 @@
1
1
  import { IdleListener } from "./idle";
2
2
  import { RecapManager } from "./manager";
3
- import { loadConfig } from "../shared/config";
3
+ import { loadConfig } from "../../config";
4
4
 
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
 
7
- export default function (pi: ExtensionAPI) {
7
+ export function registerRecap(pi: ExtensionAPI): void {
8
8
  let idleListener: IdleListener<ExtensionContext> | undefined = undefined;
9
9
  let recapManager: RecapManager | undefined = undefined;
10
10
 
11
11
  pi.on("session_start", (event, ctx) => {
12
- if (!ctx.hasUI) return;
13
-
14
- const config = loadConfig(ctx, "recap");
15
- if (!config) return;
12
+ const config = loadConfig(ctx).recap;
13
+ if (!ctx.hasUI || !config) return;
16
14
 
17
15
  recapManager = new RecapManager(pi, config);
18
16
 
@@ -3,22 +3,22 @@ import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-a
3
3
 
4
4
  import { resolveRecapModelSettings } from "./model";
5
5
  import { clearRecapWidget, setRecapLoadingWidget, setRecapTextWidget } from "./widget";
6
- import { sanitizeText } from "../shared/format";
6
+ import { sanitizeText } from "../../utils/format";
7
7
 
8
8
  import type { Api, Model, ModelThinkingLevel, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai";
9
9
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
10
  import type { RecapConfig } from "./config";
11
11
 
12
12
  const SYSTEM_PROMPT = [
13
- "You write concise idle-session recaps for a terminal coding agent.",
13
+ "You write concise idle session recaps for a terminal coding agent.",
14
14
  "Use only transcript-supported facts; do not invent progress, intent, files, or next steps.",
15
15
  "Prefer the latest active task if the session changed direction.",
16
16
  "Summarize the user's goal, what was done, current state, and any clearly supported next step.",
17
- "Respond in the conversation's primary language.",
18
- "Output 1-2 plain-text sentences under 80 words. No heading, markdown, bullets, or quotes.",
17
+ "Respond in the conversation's primary language. Address the user in the second person.",
18
+ "Output 1-2 plain-text sentences under 40 words. No heading, markdown, bullets, or quotes.",
19
19
  ].join("\n");
20
20
 
21
- const MAX_TOKENS = 100;
21
+ const MAX_TOKENS = 80;
22
22
  const MAX_CONVERSATION_CHARS = 8_000;
23
23
 
24
24
  export class RecapManager {
@@ -1,10 +1,10 @@
1
1
  import { clampThinkingLevel } from "@earendil-works/pi-ai";
2
2
 
3
- import { formatModel } from "../shared/format";
3
+ import { formatModel } from "../../utils/format";
4
4
 
5
5
  import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
6
6
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
- import type { OptionalModelConfig } from "../shared/config/model";
7
+ import type { OptionalModelConfig } from "../../config/model";
8
8
 
9
9
  const DEFAULT_THINKING_LEVEL: ModelThinkingLevel = "off";
10
10
 
@@ -61,3 +61,32 @@ export function sanitizeText(text: string): string {
61
61
  .replace(/ +/g, " ")
62
62
  .trim();
63
63
  }
64
+
65
+ /** Coerce a possibly-stringified numeric value to a finite number, or `undefined`. */
66
+ export function toNumber(value?: string | number | null): number | undefined {
67
+ if (value === undefined || value === null) return undefined;
68
+ const parsed = typeof value === "number" ? value : Number(value);
69
+ return Number.isFinite(parsed) ? parsed : undefined;
70
+ }
71
+
72
+ const FRANKFURTER_API = "https://api.frankfurter.dev/v2/rate";
73
+
74
+ interface FrankfurterRateResponse {
75
+ rate?: string | number;
76
+ }
77
+
78
+ /** Convert an amount in the given currency to USD via the Frankfurter API. */
79
+ export async function convertToUSD(amount: number | undefined, currency: string | undefined, signal: AbortSignal): Promise<number | undefined> {
80
+ if (amount === undefined) return undefined;
81
+ if (!currency || currency === "USD") return amount;
82
+
83
+ const url = `${FRANKFURTER_API}/${encodeURIComponent(currency)}/USD`;
84
+ const response = await fetch(url, { headers: { Accept: "application/json" }, signal });
85
+ if (!response.ok) throw new Error("currency conversion failed");
86
+
87
+ const payload = (await response.json()) as FrankfurterRateResponse;
88
+ const rate = toNumber(payload.rate);
89
+ if (rate === undefined) throw new Error("currency conversion failed");
90
+
91
+ return amount * rate;
92
+ }
@@ -25,24 +25,6 @@ export function isUsage(value: unknown): value is Usage {
25
25
  );
26
26
  }
27
27
 
28
- /** Sum two `Usage` values into a new one. */
29
- export function addUsage(a: Usage, b: Usage): Usage {
30
- return {
31
- input: a.input + b.input,
32
- output: a.output + b.output,
33
- cacheRead: a.cacheRead + b.cacheRead,
34
- cacheWrite: a.cacheWrite + b.cacheWrite,
35
- totalTokens: a.totalTokens + b.totalTokens,
36
- cost: {
37
- input: a.cost.input + b.cost.input,
38
- output: a.cost.output + b.cost.output,
39
- cacheRead: a.cost.cacheRead + b.cost.cacheRead,
40
- cacheWrite: a.cost.cacheWrite + b.cost.cacheWrite,
41
- total: a.cost.total + b.cost.total,
42
- },
43
- };
44
- }
45
-
46
28
  /**
47
29
  * Extract usage from a session entry.
48
30
  *
Binary file
@@ -1,52 +0,0 @@
1
- import type { Usage } from "@earendil-works/pi-ai";
2
-
3
- /** Structural type guard for the pi `Usage` shape. */
4
- export function isUsage(value: unknown): value is Usage {
5
- if (typeof value !== "object" || value === null) return false;
6
-
7
- const usage = value as Record<string, unknown>;
8
- const hasTokenFields =
9
- typeof usage.input === "number" &&
10
- typeof usage.output === "number" &&
11
- typeof usage.cacheRead === "number" &&
12
- typeof usage.cacheWrite === "number" &&
13
- typeof usage.totalTokens === "number";
14
- if (!hasTokenFields) return false;
15
-
16
- if (typeof usage.cost !== "object" || usage.cost === null) return false;
17
- const cost = usage.cost as Record<string, unknown>;
18
- return (
19
- typeof cost.input === "number" &&
20
- typeof cost.output === "number" &&
21
- typeof cost.cacheRead === "number" &&
22
- typeof cost.cacheWrite === "number" &&
23
- typeof cost.total === "number"
24
- );
25
- }
26
-
27
- export function toNumber(value?: string | number | null): number | undefined {
28
- if (value === undefined || value === null) return undefined;
29
- const parsed = typeof value === "number" ? value : Number(value);
30
- return Number.isFinite(parsed) ? parsed : undefined;
31
- }
32
-
33
- const FRANKFURTER_API = "https://api.frankfurter.dev/v2/rate";
34
-
35
- interface FrankfurterRateResponse {
36
- rate?: string | number;
37
- }
38
-
39
- export async function convertToUSD(amount: number | undefined, currency: string | undefined, signal: AbortSignal): Promise<number | undefined> {
40
- if (amount === undefined) return undefined;
41
- if (!currency || currency === "USD") return amount;
42
-
43
- const url = `${FRANKFURTER_API}/${encodeURIComponent(currency)}/USD`;
44
- const response = await fetch(url, { headers: { Accept: "application/json" }, signal });
45
- if (!response.ok) throw new Error("currency conversion failed");
46
-
47
- const payload = (await response.json()) as FrankfurterRateResponse;
48
- const rate = toNumber(payload.rate);
49
- if (rate === undefined) throw new Error("currency conversion failed");
50
-
51
- return amount * rate;
52
- }
@@ -1,3 +0,0 @@
1
- import * as z from "zod";
2
-
3
- export const modelsConfigSchema = z.object({});
@@ -1,24 +0,0 @@
1
- import * as z from "zod";
2
-
3
- import { creditsConfigSchema } from "../../credits/config";
4
- import { editorConfigSchema } from "../../editor/config";
5
- import { footerConfigSchema } from "../../footer/config";
6
- import { fullscreenConfigSchema } from "../../fullscreen/config";
7
- import { modelsConfigSchema } from "../../models/config";
8
- import { nameConfigSchema } from "../../name/config";
9
- import { presetsConfigSchema } from "../../presets/config";
10
- import { recapConfigSchema } from "../../recap/config";
11
-
12
- export const configSchemas = {
13
- credits: creditsConfigSchema,
14
- editor: editorConfigSchema,
15
- footer: footerConfigSchema,
16
- fullscreen: fullscreenConfigSchema,
17
- models: modelsConfigSchema,
18
- name: nameConfigSchema,
19
- presets: presetsConfigSchema,
20
- recap: recapConfigSchema,
21
- };
22
-
23
- export type ConfigField = keyof typeof configSchemas;
24
- export type ConfigValue<Field extends ConfigField> = z.infer<(typeof configSchemas)[Field]>;
@@ -1,3 +0,0 @@
1
- export { autoCollectEvents } from "./auto-collect-events";
2
-
3
- export * from "./preset";
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes