pi-spark 0.12.0 → 0.13.1

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 (60) hide show
  1. package/README.md +86 -61
  2. package/assets/screenshot-credits.png +0 -0
  3. package/assets/screenshot-overview.png +0 -0
  4. package/assets/screenshot-presets.png +0 -0
  5. package/assets/screenshot-recap.png +0 -0
  6. package/assets/screenshot-tools.png +0 -0
  7. package/assets/screenshot-tui.png +0 -0
  8. package/index.ts +31 -0
  9. package/package.json +5 -4
  10. package/{extensions/shared → src}/components/inline-text.ts +1 -1
  11. package/{extensions/shared → src}/config/index.ts +37 -12
  12. package/src/config/schema.ts +30 -0
  13. package/{extensions/shared → src}/events/auto-collect-events.ts +1 -1
  14. package/src/events/index.ts +3 -0
  15. package/{extensions → src/features}/credits/index.ts +5 -7
  16. package/{extensions → src/features}/credits/providers/deepseek.ts +1 -1
  17. package/{extensions → src/features}/credits/providers/fireworks.ts +1 -1
  18. package/{extensions → src/features}/credits/providers/moonshot.ts +1 -1
  19. package/{extensions → src/features}/credits/providers/openai-codex.ts +1 -1
  20. package/{extensions → src/features}/credits/providers/openrouter.ts +1 -1
  21. package/{extensions → src/features}/credits/providers/vercel-ai-gateway.ts +1 -1
  22. package/{extensions → src/features}/editor/index.ts +8 -11
  23. package/{extensions → src/features}/footer/index.ts +7 -9
  24. package/{extensions → src/features}/fullscreen/index.ts +6 -9
  25. package/src/features/model/config.ts +3 -0
  26. package/{extensions/models → src/features/model}/index.ts +5 -5
  27. package/{extensions → src/features}/name/index.ts +6 -8
  28. package/{extensions → src/features}/presets/config.ts +1 -1
  29. package/{extensions → src/features}/presets/index.ts +4 -4
  30. package/{extensions → src/features}/presets/manager.ts +2 -2
  31. package/{extensions → src/features}/recap/config.ts +1 -1
  32. package/{extensions → src/features}/recap/index.ts +4 -6
  33. package/{extensions → src/features}/recap/manager.ts +5 -5
  34. package/{extensions → src/features}/recap/model.ts +2 -2
  35. package/{extensions/shared/format/index.ts → src/utils/format.ts} +29 -0
  36. package/{extensions/shared/usage/index.ts → src/utils/usage.ts} +0 -18
  37. package/assets/credits.png +0 -0
  38. package/assets/screenshot.png +0 -0
  39. package/extensions/credits/utils.ts +0 -52
  40. package/extensions/models/config.ts +0 -3
  41. package/extensions/shared/config/schema.ts +0 -24
  42. package/extensions/shared/events/index.ts +0 -3
  43. /package/{extensions/shared → src}/components/split-line.ts +0 -0
  44. /package/{extensions/shared → src}/config/model.ts +0 -0
  45. /package/{extensions/shared → src}/events/preset.ts +0 -0
  46. /package/{extensions → src/features}/credits/config.ts +0 -0
  47. /package/{extensions → src/features}/credits/manager.ts +0 -0
  48. /package/{extensions → src/features}/credits/providers/fireworks.proto +0 -0
  49. /package/{extensions → src/features}/credits/providers/index.ts +0 -0
  50. /package/{extensions → src/features}/credits/status.ts +0 -0
  51. /package/{extensions → src/features}/credits/types.ts +0 -0
  52. /package/{extensions → src/features}/editor/config.ts +0 -0
  53. /package/{extensions → src/features}/editor/spinner.ts +0 -0
  54. /package/{extensions → src/features}/footer/config.ts +0 -0
  55. /package/{extensions → src/features}/fullscreen/config.ts +0 -0
  56. /package/{extensions → src/features}/fullscreen/filler.ts +0 -0
  57. /package/{extensions → src/features}/name/config.ts +0 -0
  58. /package/{extensions → src/features}/presets/selector.ts +0 -0
  59. /package/{extensions → src/features}/recap/idle.ts +0 -0
  60. /package/{extensions → src/features}/recap/widget.ts +0 -0
package/README.md CHANGED
@@ -2,46 +2,68 @@
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
+ ![Overview](./assets/screenshot-overview.png)
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
+ ## Features
17
10
 
18
- ![Screenshot](./assets/screenshot.png)
11
+ ### Compact TUI: editor, footer, and fullscreen
19
12
 
20
- ## Install
13
+ 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.
21
14
 
22
- Install from npm:
15
+ - 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.
16
+ - The footer shows session information, extension statuses, cost, and context usage on one line.
17
+ - The fullscreen rendering clears the terminal screen and scrollback on session start and exit, and pins the editor and footer to the bottom.
23
18
 
24
- ```bash
25
- pi install npm:pi-spark
26
- ```
19
+ ![Compact TUI](./assets/screenshot-tui.png)
20
+
21
+ ### Credits
27
22
 
28
- For local development from this repo:
23
+ 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.
29
24
 
30
- ```bash
31
- pi install /path/to/pi-spark
32
- ```
25
+ - Supported providers: DeepSeek, Fireworks, Moonshot, OpenAI Codex, OpenRouter, and Vercel AI Gateway.
26
+ - 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
+
28
+ ![Credits](./assets/screenshot-credits.png)
29
+
30
+ ### Presets
31
+
32
+ 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.
33
+
34
+ - Switch interactively with `/preset`, or jump straight to one with `/preset <name>`.
35
+ - Start pi on a given preset with `pi --preset <name>`.
36
+ - 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.
37
+
38
+ ![Presets](./assets/screenshot-presets.png)
39
+
40
+ ### Recap
41
+
42
+ 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).
43
+
44
+ - A recap is generated automatically once the session stays idle past `recap.idle` in `spark.json`.
45
+ - Run `/recap` to generate one manually at any time.
46
+ - The recap can use its own model, configured separately from your working model.
33
47
 
34
- ## Configure
48
+ ![Recap](./assets/screenshot-recap.png)
35
49
 
36
- Spark reads config from `~/.pi/agent/spark.json` and from the current project’s `.pi/spark.json`. Project config overrides matching global fields.
50
+ ### Agent tools: `name` and `model`
37
51
 
38
- All extensions are enabled by default. Set a specific extension to `false` to disable it, for example, `"footer": false` disables the footer extension.
52
+ pi-spark provides the pi coding agent with tools to inspect and manipulate itself. Currently, `name` and `model` tools are included.
39
53
 
40
- Example:
54
+ - `name` sets or updates the current session's name.
55
+ - `model` shows the active model or lists pi models.
56
+
57
+ ![Agent tools](./assets/screenshot-tools.png)
58
+
59
+ ## Configuration
60
+
61
+ pi-spark reads config from `~/.pi/agent/spark.json` and from the current project's `.pi/spark.json`. Project config overrides matching global fields.
62
+
63
+ For example:
41
64
 
42
65
  ```json
43
66
  {
44
- "credits": false,
45
67
  "editor": {
46
68
  "spinner": "dots"
47
69
  },
@@ -67,58 +89,61 @@ Example:
67
89
  }
68
90
  ```
69
91
 
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).
75
-
76
- ### Editor
77
-
78
- - `editor.spinner` controls the working indicator style and can be `dots`, `lights`, `tildes`, or `pulse`.
92
+ ### References
79
93
 
80
- ### Footer
94
+ 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).
81
95
 
82
- - pi-spark replaces the footer with a compact one-line view of session metadata, extension statuses, cost, and context usage.
96
+ | Field | Value (or `false`) | Description |
97
+ | --- | --- | --- |
98
+ | `credits` | `{}` | Shows the active provider's credit balance or rate-limit usage in the status line. |
99
+ | `editor` | `EditorConfig` | Shows a working indicator and the current model on the editor's top border. |
100
+ | `footer` | `{}` | Shows session info, extension statuses, cost, and context usage on one line. |
101
+ | `fullscreen` | `{}` | Clears the screen and scrollback on start and exit, and pins the editor and footer to the bottom. |
102
+ | `model` | `{}` | Exposes the `model` agent tool. |
103
+ | `name` | `{}` | Exposes the `name` agent tool. |
104
+ | `presets` | `{ [name]: Preset }` | Defines named model presets, keyed by name. |
105
+ | `recap` | `RecapConfig` | Generates a session recap when idle or on demand. |
83
106
 
84
- ### Fullscreen
107
+ #### `EditorConfig`
85
108
 
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.
109
+ The `spinner` field is optional and defaults to `tildes`.
87
110
 
88
- ### Models
111
+ | Field | Value | Description |
112
+ | --- | --- | --- |
113
+ | `spinner` | `dots` | `⠋, ⠙, ⠹, ⠸, ⠼, ⠴, ⠦, ⠧, ⠇, ⠏` |
114
+ | | `lights` | `○, ●` |
115
+ | | `tildes` (default) | `∼, ≈, ≋, ≈, ∼` |
116
+ | | `pulse` | `·, •, ●, •, ·` |
89
117
 
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.
118
+ #### `Preset`
93
119
 
94
- ### Name
120
+ Each preset must set all three fields.
95
121
 
96
- - The agent can set or refresh the current session's name and optionally give a reason.
122
+ | Field | Value | Description |
123
+ | --- | --- | --- |
124
+ | `provider` | string | Provider id, e.g. `anthropic`. |
125
+ | `model` | string | Model id, e.g. `claude-opus-4-8`. |
126
+ | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the preset. |
97
127
 
98
- ### Presets
128
+ #### `RecapConfig`
99
129
 
100
- - Each key under `presets` defines a named model preset with `provider`, `model`, and optional `thinkingLevel` fields.
130
+ All fields are optional, including `thinkingLevel`.
101
131
 
102
- Use presets in these ways:
132
+ | Field | Value | Description |
133
+ | --- | --- | --- |
134
+ | `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. |
135
+ | `provider` | string | Provider id for the recap model. |
136
+ | `model` | string | Model id for the recap model. |
137
+ | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the recap model. |
103
138
 
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)
139
+ ### Turn off the features you don't like
107
140
 
108
- ### Recap
141
+ All features are enabled by default. Set a specific feature to `false` in `spark.json` to disable it.
109
142
 
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`):
143
+ For example, to disable the customized footer:
117
144
 
118
145
  ```json
119
146
  {
120
- "defaultProjectTrust": "always"
147
+ "footer": false
121
148
  }
122
149
  ```
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.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
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.1",
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
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