pi-spark 0.20.0 → 0.21.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.
package/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2026-present Zilong Liang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -69,13 +69,19 @@ pi-spark generates a short recap of the current session after it goes idle, or o
69
69
 
70
70
  ### Title
71
71
 
72
- pi-spark names the session automatically after the first exchange, so it's easy to find later in the session selector.
72
+ pi-spark names the session automatically after the first completed turn, so it's easy to find later in the session selector.
73
73
 
74
- - After the first message is sent and the agent replies, pi-spark generates a short title from the conversation and sets it as the session name.
74
+ - Once the agent completes its first response and any tool calls from that turn, pi-spark generates a short title from the conversation and sets it as the session name.
75
75
  - Generation is silent: nothing is shown in the UI, and the usage is recorded in a session entry like the recap.
76
76
  - Sessions that already have a name are left untouched.
77
77
  - The title generation can use its own model, configured separately from your working model.
78
78
 
79
+ ### Scrolling write previews
80
+
81
+ Long, streaming `write` tool calls keep their latest lines visible instead of pinning the preview to the beginning. The omitted-line count appears above the preview, while `ctrl+o` still expands the full content.
82
+
83
+ https://github.com/user-attachments/assets/b7bb77c1-c188-46a2-aba9-6487bdf58fa0
84
+
79
85
  ## Configuration
80
86
 
81
87
  pi-spark reads config from `~/.pi/agent/spark.json` and from the current project's `.pi/spark.json`. Project config overrides matching global fields.
@@ -126,7 +132,8 @@ All fields are optional. Each top-level feature runs with the defaults below unl
126
132
  | `fullscreen` | `{}` | Clears the screen and scrollback on start and exit, and pins the editor and footer to the bottom. |
127
133
  | `presets` | `{ [name]: Preset }` | Defines named model presets, keyed by name. |
128
134
  | `recap` | `RecapConfig` | Generates a session recap when idle or on demand. |
129
- | `title` | `TitleConfig` | Names the session automatically after the first exchange. |
135
+ | `title` | `TitleConfig` | Names the session automatically after the first completed turn. |
136
+ | `write` | `{}` | Keeps the latest lines of streaming `write` tool calls visible. |
130
137
 
131
138
  #### `CreditsConfig`
132
139
 
@@ -149,7 +156,7 @@ All supported providers are enabled by default. Set a provider to `false` to dis
149
156
 
150
157
  #### `EditorConfig`
151
158
 
152
- The `spinner` field is optional and defaults to `tildes`.
159
+ The `spinner` field is optional and defaults to `tildes`. `thinkingLevelIndicator` is also optional and defaults to `border`.
153
160
 
154
161
  | Field | Value | Description |
155
162
  | --- | --- | --- |
@@ -157,6 +164,8 @@ The `spinner` field is optional and defaults to `tildes`.
157
164
  | | `lights` | `○, ●` |
158
165
  | | `tildes` (default) | `∼, ≈, ≋, ≈, ∼` |
159
166
  | | `pulse` | `·, •, ●, •, ·` |
167
+ | `thinkingLevelIndicator` | `border` (default) | Uses the thinking-level color for the editor border. |
168
+ | | `model` | Uses the thinking-level color for the model name and the dim color for the editor border. |
160
169
 
161
170
  #### `FooterConfig`
162
171
 
@@ -213,3 +222,18 @@ For example, to disable the customized footer:
213
222
  "footer": false
214
223
  }
215
224
  ```
225
+
226
+ ## Themes
227
+
228
+ pi-spark ships with two custom themes inspired by [GitHub's VS Code themes](https://github.com/primer/github-vscode-theme):
229
+
230
+ - [`github-light-default`](themes/github-light-default.json)
231
+ - [`github-dark-default`](themes/github-dark-default.json)
232
+
233
+ Select either theme in `/settings`, or configure automatic switching between them:
234
+
235
+ ```json
236
+ {
237
+ "theme": "github-light-default/github-dark-default"
238
+ }
239
+ ```
@@ -89,7 +89,7 @@ The amount is `units + nanos / 1e9` (the standard `google.type.Money` encoding),
89
89
  The provider lives in [`src/features/credits/providers/fireworks.ts`](../src/features/credits/providers/fireworks.ts) and follows the same `CreditsProvider` contract as the HTTP-based providers; only the transport differs.
90
90
 
91
91
  - **Transport.** A single, reused `@grpc/grpc-js` client dials `gateway.fireworks.ai:443` over TLS. The service is declared in [`fireworks.proto`](../src/features/credits/providers/fireworks.proto) — the minimal slice needed, loaded at runtime with `@grpc/proto-loader`. The `.proto` is resolved relative to the module via `import.meta.url`, so it ships and loads from the package directory.
92
- - **Auth.** Each call sends `x-api-key: <key>` in gRPC metadata. The key comes from Pi's `modelRegistry.getApiKeyForProvider("fireworks")` (env `FIREWORKS_API_KEY`), handled by the manager.
92
+ - **Auth.** Each call sends `x-api-key: <key>` in gRPC metadata. The manager resolves provider authentication through Pi's `modelRegistry.getProviderAuth("fireworks")`. For Fireworks, Pi returns the API key sourced from `FIREWORKS_API_KEY`.
93
93
  - **Account resolution.** `ListAccounts` is called once and the resolved `accounts/<id>` is cached per API key, so steady-state refreshes are a single `GetBalance` round trip.
94
94
  - **Cancellation.** The shared `AbortSignal` cancels the in-flight gRPC call, and a defensive deadline bounds each request.
95
95
  - **Result.** `Money` is converted to a dollar amount and returned as `{ type: "balance", remaining }`, which the status line renders like any other balance provider.
package/index.ts CHANGED
@@ -6,6 +6,7 @@ import { registerFullscreen } from "./src/features/fullscreen";
6
6
  import { registerPresets } from "./src/features/presets";
7
7
  import { registerRecap } from "./src/features/recap";
8
8
  import { registerTitle } from "./src/features/title";
9
+ import { registerWrite } from "./src/features/write";
9
10
 
10
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
12
 
@@ -26,4 +27,5 @@ export default function (pi: ExtensionAPI) {
26
27
  registerPresets(pi);
27
28
  registerRecap(pi);
28
29
  registerTitle(pi);
30
+ registerWrite(pi);
29
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "description": "Pi package that polishes your daily experience.",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
@@ -15,6 +15,7 @@
15
15
  "files": [
16
16
  "index.ts",
17
17
  "src",
18
+ "themes",
18
19
  "assets",
19
20
  "docs",
20
21
  "README.md",
@@ -24,6 +25,9 @@
24
25
  "extensions": [
25
26
  "./index.ts"
26
27
  ],
28
+ "themes": [
29
+ "./themes"
30
+ ],
27
31
  "image": "https://raw.githubusercontent.com/zlliang/pi-spark/main/assets/cover.png"
28
32
  },
29
33
  "scripts": {
@@ -33,22 +37,23 @@
33
37
  "@grpc/grpc-js": "^1.14.4",
34
38
  "@grpc/proto-loader": "^0.8.1",
35
39
  "defu": "^6.1.7",
40
+ "ky": "^2.0.2",
36
41
  "parse-duration": "^2.1.8",
37
42
  "pretty-ms": "^9.3.0",
38
43
  "zod": "^4.4.3"
39
44
  },
40
45
  "peerDependencies": {
41
- "@earendil-works/pi-agent-core": ">=0.80.8",
42
- "@earendil-works/pi-ai": ">=0.80.8",
43
- "@earendil-works/pi-coding-agent": ">=0.80.8",
44
- "@earendil-works/pi-tui": ">=0.80.8",
46
+ "@earendil-works/pi-agent-core": ">=0.81.0",
47
+ "@earendil-works/pi-ai": ">=0.81.0",
48
+ "@earendil-works/pi-coding-agent": ">=0.81.0",
49
+ "@earendil-works/pi-tui": ">=0.81.0",
45
50
  "typebox": "*"
46
51
  },
47
52
  "devDependencies": {
48
- "@earendil-works/pi-agent-core": ">=0.80.8",
49
- "@earendil-works/pi-ai": ">=0.80.8",
50
- "@earendil-works/pi-coding-agent": ">=0.80.8",
51
- "@earendil-works/pi-tui": ">=0.80.8",
53
+ "@earendil-works/pi-agent-core": ">=0.81.0",
54
+ "@earendil-works/pi-ai": ">=0.81.0",
55
+ "@earendil-works/pi-coding-agent": ">=0.81.0",
56
+ "@earendil-works/pi-tui": ">=0.81.0",
52
57
  "@types/node": "*",
53
58
  "typebox": "*",
54
59
  "typescript": "^7.0.2"
@@ -7,6 +7,7 @@ import { fullscreenConfigSchema } from "../features/fullscreen/config";
7
7
  import { presetsConfigSchema } from "../features/presets/config";
8
8
  import { recapConfigSchema } from "../features/recap/config";
9
9
  import { titleConfigSchema } from "../features/title/config";
10
+ import { writeConfigSchema } from "../features/write/config";
10
11
 
11
12
  /**
12
13
  * Raw option shape for each feature. The enable/disable/default policy lives in `loadConfig`:
@@ -21,6 +22,7 @@ export const featureSchemas = {
21
22
  presets: presetsConfigSchema,
22
23
  recap: recapConfigSchema,
23
24
  title: titleConfigSchema,
25
+ write: writeConfigSchema,
24
26
  } as const;
25
27
 
26
28
  /** Resolved config for every feature; `false` means the feature is disabled. */
@@ -18,7 +18,7 @@ export function registerCredits(pi: ExtensionAPI): void {
18
18
 
19
19
  pi.on("session_start", (_event, ctx) => {
20
20
  const config = loadConfig(ctx).credits;
21
- if (!ctx.hasUI || !config) return;
21
+ if (ctx.mode !== "tui" || !config) return;
22
22
 
23
23
  const providers = getEnabledProviders(config);
24
24
  if (providers.length === 0) return;
@@ -1,3 +1,4 @@
1
+ import { getAuthToken } from "../../utils/auth";
1
2
  import { findProvider } from "./providers";
2
3
  import { renderCredits, renderError } from "./status";
3
4
 
@@ -5,7 +6,6 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
6
  import type { CreditsProvider } from "./types";
6
7
 
7
8
  const STATUS_KEY = "credits";
8
- const REQUEST_TIMEOUT_MS = 30_000;
9
9
 
10
10
  export class CreditsManager {
11
11
  private inflight: AbortController | undefined = undefined;
@@ -48,15 +48,14 @@ export class CreditsManager {
48
48
 
49
49
  private async fetch(ctx: ExtensionContext, provider: CreditsProvider, signal: AbortSignal): Promise<void> {
50
50
  try {
51
- const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider.id);
51
+ const token = await getAuthToken(ctx.modelRegistry, provider.id);
52
52
  if (signal.aborted) return;
53
- if (!apiKey) {
53
+ if (!token) {
54
54
  ctx.ui.setStatus(STATUS_KEY, undefined);
55
55
  return;
56
56
  }
57
57
 
58
- const signals = AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]);
59
- const credits = await provider.fetch(apiKey, signals);
58
+ const credits = await provider.fetch(token, signal);
60
59
 
61
60
  // The active model may have changed while the request was in flight.
62
61
  if (ctx.model?.provider !== provider.id) return;
@@ -1,4 +1,5 @@
1
1
  import { convertToUSD, toNumber } from "../../../utils/format";
2
+ import { http, withAuth } from "../../../utils/http";
2
3
 
3
4
  import type { Credits, CreditsProvider } from "../types";
4
5
 
@@ -19,15 +20,7 @@ export const deepseekProvider: CreditsProvider = {
19
20
  label: "DeepSeek",
20
21
 
21
22
  async fetch(apiKey, signal): Promise<Credits> {
22
- const headers: Record<string, string> = {
23
- Accept: "application/json",
24
- Authorization: `Bearer ${apiKey}`,
25
- };
26
-
27
- const response = await fetch(URL, { headers, signal });
28
- if (!response.ok) throw new Error("request failed");
29
-
30
- const payload = (await response.json()) as DeepSeekBalanceResponse;
23
+ const payload = await withAuth(http, apiKey).get(URL, { signal }).json<DeepSeekBalanceResponse>();
31
24
  const balance = payload.balance_infos?.find((entry) => entry.currency === "USD") ?? payload.balance_infos?.[0];
32
25
  const remaining = await convertToUSD(toNumber(balance?.total_balance), balance?.currency, signal);
33
26
 
@@ -1,4 +1,5 @@
1
1
  import { toNumber } from "../../../utils/format";
2
+ import { http, withAuth } from "../../../utils/http";
2
3
 
3
4
  import type { Credits, CreditsLane, CreditsProvider } from "../types";
4
5
 
@@ -82,15 +83,7 @@ export const kimiCodeProvider: CreditsProvider = {
82
83
  label: "Kimi Code",
83
84
 
84
85
  async fetch(apiKey, signal): Promise<Credits> {
85
- const headers: Record<string, string> = {
86
- Accept: "application/json",
87
- Authorization: `Bearer ${apiKey}`,
88
- };
89
-
90
- const response = await fetch(URL, { headers, signal });
91
- if (!response.ok) throw new Error("request failed");
92
-
93
- const payload = (await response.json()) as KimiCodeUsageResponse;
86
+ const payload = await withAuth(http, apiKey).get(URL, { signal }).json<KimiCodeUsageResponse>();
94
87
  const lanes: CreditsLane[] = [];
95
88
 
96
89
  payload.limits?.forEach((item) => {
@@ -1,4 +1,5 @@
1
1
  import { convertToUSD, toNumber } from "../../../utils/format";
2
+ import { http, withAuth } from "../../../utils/http";
2
3
 
3
4
  import type { Credits, CreditsProvider } from "../types";
4
5
 
@@ -19,15 +20,7 @@ function createMoonshotProvider(id: "moonshotai" | "moonshotai-cn", host: string
19
20
  label: "Moonshot",
20
21
 
21
22
  async fetch(apiKey, signal): Promise<Credits> {
22
- const headers: Record<string, string> = {
23
- Accept: "application/json",
24
- Authorization: `Bearer ${apiKey}`,
25
- };
26
-
27
- const response = await fetch(`https://${host}/v1/users/me/balance`, { headers, signal });
28
- if (!response.ok) throw new Error("request failed");
29
-
30
- const payload = (await response.json()) as MoonshotBalanceResponse;
23
+ const payload = await withAuth(http, apiKey).get(`https://${host}/v1/users/me/balance`, { signal }).json<MoonshotBalanceResponse>();
31
24
  const remaining = await convertToUSD(toNumber(payload.data?.available_balance), currency, signal);
32
25
 
33
26
  return { type: "balance", remaining };
@@ -3,9 +3,12 @@ import { readStoredCredential } from "@earendil-works/pi-coding-agent";
3
3
  import prettyMilliseconds from "pretty-ms";
4
4
 
5
5
  import { confirmCodexReset, formatAvailableResets, showCodexResetLoader, showCodexResetSelector } from "./openai-codex-panel";
6
+ import { getAuthToken } from "../../../utils/auth";
6
7
  import { toNumber } from "../../../utils/format";
8
+ import { http, withAuth } from "../../../utils/http";
7
9
 
8
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import type { KyInstance } from "ky";
9
12
  import type { CodexResetPanelData } from "./openai-codex-panel";
10
13
  import type { Credits, CreditsLane, CreditsProvider, RefreshCredits } from "../types";
11
14
 
@@ -14,7 +17,6 @@ const BASE_URL = "https://chatgpt.com/backend-api";
14
17
  const USAGE_PATH = "/wham/usage";
15
18
  const RESET_CREDITS_PATH = "/wham/rate-limit-reset-credits";
16
19
  const CONSUME_RESET_PATH = "/wham/rate-limit-reset-credits/consume";
17
- const REQUEST_TIMEOUT_MS = 30_000;
18
20
 
19
21
  interface CodexUsageResponse {
20
22
  rate_limit?: {
@@ -50,14 +52,13 @@ interface ConsumeResetResponse {
50
52
  }
51
53
 
52
54
  async function runCodexReset(ctx: ExtensionContext, refresh: RefreshCredits): Promise<void> {
53
- const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
54
- if (!apiKey) return;
55
+ const token = await getAuthToken(ctx.modelRegistry, PROVIDER);
56
+ if (!token) return;
55
57
 
56
- const headers = buildHeaders(apiKey);
58
+ const client = createClient(token);
57
59
 
58
60
  const credit = await showCodexResetSelector(ctx, async (signal) => {
59
- const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
60
- const [usage, details] = await Promise.all([fetchUsage(headers, requestSignal), fetchResetCredits(headers, requestSignal)]);
61
+ const [usage, details] = await Promise.all([fetchUsage(client, signal), fetchResetCredits(client, signal)]);
61
62
 
62
63
  return {
63
64
  ...details,
@@ -66,63 +67,42 @@ async function runCodexReset(ctx: ExtensionContext, refresh: RefreshCredits): Pr
66
67
  });
67
68
  if (!credit || !(await confirmCodexReset(ctx, credit))) return;
68
69
 
69
- await redeemReset(ctx, headers, credit, refresh);
70
+ await redeemReset(ctx, client, credit, refresh);
70
71
  }
71
72
 
72
- async function redeemReset(ctx: ExtensionContext, headers: Record<string, string>, credit: BankedRateLimitReset, refresh: RefreshCredits): Promise<void> {
73
+ async function redeemReset(ctx: ExtensionContext, client: KyInstance, credit: BankedRateLimitReset, refresh: RefreshCredits): Promise<void> {
73
74
  await showCodexResetLoader(ctx, async () => {
74
- const response = await consumeReset(headers, uuidv7(), credit.id);
75
+ const response = await consumeReset(client, uuidv7(), credit.id);
75
76
  if (response.code === "nothing_to_reset") throw new Error("No Codex rate-limit window currently needs a reset");
76
77
  if (response.code === "no_credit") throw new Error("No Codex resets are available");
77
78
  }).finally(() => refresh(ctx));
78
79
  }
79
80
 
80
- function buildHeaders(apiKey: string): Record<string, string> {
81
- const headers: Record<string, string> = {
82
- Accept: "application/json",
83
- Authorization: `Bearer ${apiKey}`,
84
- };
85
-
81
+ function createClient(apiKey: string): KyInstance {
82
+ const client = withAuth(http, apiKey);
86
83
  const credential = readStoredCredential(PROVIDER);
87
84
  const accountId = credential?.type === "oauth" ? credential.accountId : undefined;
88
- if (typeof accountId === "string" && accountId.length > 0) headers["ChatGPT-Account-ID"] = accountId;
89
-
90
- return headers;
91
- }
92
-
93
- async function requestJson<T>(path: string, headers: Record<string, string>, signal: AbortSignal, init: RequestInit = {}): Promise<T> {
94
- const requestHeaders = new Headers(headers);
95
- new Headers(init.headers).forEach((value, key) => requestHeaders.set(key, value));
96
-
97
- const response = await fetch(`${BASE_URL}${path}`, {
98
- ...init,
99
- headers: requestHeaders,
100
- signal,
101
- });
102
- if (!response.ok) throw new Error(`Codex backend returned ${response.status}`);
103
85
 
104
- return (await response.json()) as T;
86
+ return typeof accountId === "string" && accountId.length > 0
87
+ ? client.extend({ headers: { "ChatGPT-Account-ID": accountId } })
88
+ : client;
105
89
  }
106
90
 
107
- async function fetchUsage(headers: Record<string, string>, signal: AbortSignal): Promise<CodexUsageResponse> {
108
- return requestJson<CodexUsageResponse>(USAGE_PATH, headers, signal);
91
+ async function fetchUsage(client: KyInstance, signal: AbortSignal): Promise<CodexUsageResponse> {
92
+ return client.get(`${BASE_URL}${USAGE_PATH}`, { signal }).json<CodexUsageResponse>();
109
93
  }
110
94
 
111
- async function fetchResetCredits(headers: Record<string, string>, signal: AbortSignal): Promise<RateLimitResetCreditsResponse> {
112
- return requestJson<RateLimitResetCreditsResponse>(RESET_CREDITS_PATH, headers, signal);
95
+ async function fetchResetCredits(client: KyInstance, signal: AbortSignal): Promise<RateLimitResetCreditsResponse> {
96
+ return client.get(`${BASE_URL}${RESET_CREDITS_PATH}`, { signal }).json<RateLimitResetCreditsResponse>();
113
97
  }
114
98
 
115
- async function consumeReset(headers: Record<string, string>, redeemRequestId: string, creditId: string): Promise<ConsumeResetResponse> {
116
- return requestJson<ConsumeResetResponse>(
117
- CONSUME_RESET_PATH,
118
- headers,
119
- AbortSignal.timeout(REQUEST_TIMEOUT_MS),
120
- {
121
- method: "POST",
122
- headers: { "Content-Type": "application/json" },
123
- body: JSON.stringify({ redeem_request_id: redeemRequestId, credit_id: creditId }),
99
+ async function consumeReset(client: KyInstance, redeemRequestId: string, creditId: string): Promise<ConsumeResetResponse> {
100
+ return client.post(`${BASE_URL}${CONSUME_RESET_PATH}`, {
101
+ json: {
102
+ redeem_request_id: redeemRequestId,
103
+ credit_id: creditId
124
104
  },
125
- );
105
+ }).json<ConsumeResetResponse>();
126
106
  }
127
107
 
128
108
  function toCredits(usage: CodexUsageResponse, suffix?: string): Credits {
@@ -153,8 +133,8 @@ export const openaiCodexProvider: CreditsProvider = {
153
133
  label: "Codex",
154
134
 
155
135
  async fetch(apiKey, signal): Promise<Credits> {
156
- const headers = buildHeaders(apiKey);
157
- const usage = await fetchUsage(headers, signal);
136
+ const client = createClient(apiKey);
137
+ const usage = await fetchUsage(client, signal);
158
138
  const availableCount = usage.rate_limit_reset_credits?.available_count ?? 0;
159
139
  const suffix = availableCount > 0 ? `(${formatAvailableResets(availableCount)})` : undefined;
160
140
 
@@ -1,4 +1,5 @@
1
1
  import { toNumber } from "../../../utils/format";
2
+ import { http, withAuth } from "../../../utils/http";
2
3
 
3
4
  import type { Credits, CreditsProvider } from "../types";
4
5
 
@@ -17,15 +18,7 @@ export const openrouterProvider: CreditsProvider = {
17
18
  label: "OpenRouter",
18
19
 
19
20
  async fetch(apiKey, signal): Promise<Credits> {
20
- const headers: Record<string, string> = {
21
- Accept: "application/json",
22
- Authorization: `Bearer ${apiKey}`,
23
- };
24
-
25
- const response = await fetch(URL, { headers, signal });
26
- if (!response.ok) throw new Error("request failed");
27
-
28
- const payload = (await response.json()) as OpenRouterCreditsResponse;
21
+ const payload = await withAuth(http, apiKey).get(URL, { signal }).json<OpenRouterCreditsResponse>();
29
22
  const totalCredits = toNumber(payload.data?.total_credits);
30
23
  const totalUsage = toNumber(payload.data?.total_usage);
31
24
  const remaining = typeof totalCredits === "number" && typeof totalUsage === "number" ? totalCredits - totalUsage : undefined;
@@ -1,4 +1,5 @@
1
1
  import { toNumber } from "../../../utils/format";
2
+ import { http, withAuth } from "../../../utils/http";
2
3
 
3
4
  import type { Credits, CreditsProvider } from "../types";
4
5
 
@@ -14,15 +15,7 @@ export const vercelAiGatewayProvider: CreditsProvider = {
14
15
  label: "Vercel",
15
16
 
16
17
  async fetch(apiKey, signal): Promise<Credits> {
17
- const headers: Record<string, string> = {
18
- Accept: "application/json",
19
- Authorization: `Bearer ${apiKey}`,
20
- };
21
-
22
- const response = await fetch(URL, { headers, signal });
23
- if (!response.ok) throw new Error("request failed");
24
-
25
- const payload = (await response.json()) as VercelCreditsResponse;
18
+ const payload = await withAuth(http, apiKey).get(URL, { signal }).json<VercelCreditsResponse>();
26
19
  const remaining = toNumber(payload.balance);
27
20
 
28
21
  return { type: "balance", remaining };
@@ -1,9 +1,12 @@
1
1
  import * as z from "zod";
2
2
 
3
3
  const spinnerPresetSchema = z.enum(["dots", "lights", "tildes", "pulse"]);
4
+ const thinkingLevelIndicatorSchema = z.enum(["border", "model"]);
4
5
 
5
6
  export type SpinnerPreset = z.infer<typeof spinnerPresetSchema>;
7
+ export type ThinkingLevelIndicator = z.infer<typeof thinkingLevelIndicatorSchema>;
6
8
 
7
9
  export const editorConfigSchema = z.object({
8
10
  spinner: spinnerPresetSchema.optional(),
11
+ thinkingLevelIndicator: thinkingLevelIndicatorSchema.optional(),
9
12
  });
@@ -9,16 +9,18 @@ import { formatModel } from "../../utils/format";
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
11
  import type { EventCollector } from "../../events";
12
+ import type { ThinkingLevelIndicator } from "./config";
12
13
 
13
14
  class Editor extends CustomEditor {
14
15
  private pi: ExtensionAPI;
15
16
  private ctx: ExtensionContext;
16
17
 
17
18
  private spinner: Spinner;
19
+ private thinkingLevelIndicator: ThinkingLevelIndicator;
18
20
  private workingMessage: string | undefined;
19
21
  private slots: { modelBefore: string | undefined };
20
22
 
21
- constructor(pi: ExtensionAPI, ctx: ExtensionContext, tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, spinner: Spinner = new Spinner()) {
23
+ constructor(pi: ExtensionAPI, ctx: ExtensionContext, tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, spinner: Spinner = new Spinner(), thinkingLevelIndicator: ThinkingLevelIndicator = "border") {
22
24
  super(tui, theme, keybindings);
23
25
 
24
26
  this.pi = pi;
@@ -26,6 +28,7 @@ class Editor extends CustomEditor {
26
28
 
27
29
  this.spinner = spinner;
28
30
  this.spinner.setTUI(tui);
31
+ this.thinkingLevelIndicator = thinkingLevelIndicator;
29
32
  this.workingMessage = undefined;
30
33
  this.slots = { modelBefore: undefined };
31
34
  }
@@ -41,6 +44,12 @@ class Editor extends CustomEditor {
41
44
  }
42
45
 
43
46
  render(width: number): string[] {
47
+ // Pi reapplies the thinking-level border color when the level changes. Use the dim color here
48
+ // while preserving Bash mode's dedicated border color.
49
+ if (this.thinkingLevelIndicator === "model" && !this.getText().trimStart().startsWith("!")) {
50
+ this.borderColor = (text) => this.ctx.ui.theme.fg("dim", text);
51
+ }
52
+
44
53
  const lines = super.render(width);
45
54
  if (lines.length === 0) return lines;
46
55
 
@@ -77,8 +86,16 @@ class Editor extends CustomEditor {
77
86
 
78
87
  const modelBeforeText = this.slots.modelBefore;
79
88
  const modelText = formatModel(this.ctx.model?.provider, this.ctx.model?.id, this.pi.getThinkingLevel());
89
+ const coloredModelText = this.thinkingLevelIndicator === "model" ? this.withThinkingLevelColor(modelText) : theme.fg("dim", modelText);
90
+
91
+ return [modelBeforeText ? theme.fg("dim", modelBeforeText) : undefined, coloredModelText].filter(Boolean).join(theme.fg("dim", " · "));
92
+ }
93
+
94
+ private withThinkingLevelColor(text: string): string {
95
+ const theme = this.ctx.ui.theme;
96
+ const thinkingLevel = this.pi.getThinkingLevel();
80
97
 
81
- return theme.fg("dim", [modelBeforeText, modelText].filter(Boolean).join(" · "));
98
+ return theme.getThinkingBorderColor(thinkingLevel)(text);
82
99
  }
83
100
  }
84
101
 
@@ -89,13 +106,13 @@ export function registerEditor(pi: ExtensionAPI, events: EventCollector): void {
89
106
 
90
107
  pi.on("session_start", (_event, ctx) => {
91
108
  const config = loadConfig(ctx).editor;
92
- if (!ctx.hasUI || !config) return;
109
+ if (ctx.mode !== "tui" || !config) return;
93
110
 
94
111
  spinner = new Spinner(config.spinner);
95
112
 
96
113
  ctx.ui.setWorkingVisible(false);
97
114
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
98
- editor = new Editor(pi, ctx, tui, theme, keybindings, spinner);
115
+ editor = new Editor(pi, ctx, tui, theme, keybindings, spinner, config.thinkingLevelIndicator);
99
116
 
100
117
  events.on(PRESET_CHANGE, (data) => {
101
118
  const payload = parsePresetChange(data);
@@ -75,7 +75,7 @@ class FooterComponent implements Component {
75
75
  }
76
76
 
77
77
  private getStyledCostText(): string {
78
- const totalCost = this.ctx.sessionManager.getBranch().reduce((acc, entry) => acc + (getEntryUsage(entry)?.cost.total ?? 0), 0);
78
+ const totalCost = this.ctx.sessionManager.getEntries().reduce((acc, entry) => acc + (getEntryUsage(entry)?.cost.total ?? 0), 0);
79
79
 
80
80
  // Hide cost below half a cent, since it would render as $0.00.
81
81
  if (totalCost < 0.005) return "";
@@ -100,7 +100,7 @@ class FooterComponent implements Component {
100
100
  export function registerFooter(pi: ExtensionAPI): void {
101
101
  pi.on("session_start", (_event, ctx) => {
102
102
  const config = loadConfig(ctx).footer;
103
- if (!ctx.hasUI || !config) return;
103
+ if (ctx.mode !== "tui" || !config) return;
104
104
 
105
105
  ctx.ui.setFooter((_tui, theme, footerData) => new FooterComponent(ctx, theme, footerData, config.statusPosition));
106
106
  });