pi-spark 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.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://raw.githubusercontent.com/zlliang/pi-spark/refs/heads/main/assets/screencast-write.mp4
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
+ ```
Binary file
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.0",
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;
@@ -5,7 +5,6 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import type { CreditsProvider } from "./types";
6
6
 
7
7
  const STATUS_KEY = "credits";
8
- const REQUEST_TIMEOUT_MS = 30_000;
9
8
 
10
9
  export class CreditsManager {
11
10
  private inflight: AbortController | undefined = undefined;
@@ -55,8 +54,7 @@ export class CreditsManager {
55
54
  return;
56
55
  }
57
56
 
58
- const signals = AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]);
59
- const credits = await provider.fetch(apiKey, signals);
57
+ const credits = await provider.fetch(apiKey, signal);
60
58
 
61
59
  // The active model may have changed while the request was in flight.
62
60
  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 };
@@ -4,8 +4,10 @@ import prettyMilliseconds from "pretty-ms";
4
4
 
5
5
  import { confirmCodexReset, formatAvailableResets, showCodexResetLoader, showCodexResetSelector } from "./openai-codex-panel";
6
6
  import { toNumber } from "../../../utils/format";
7
+ import { http, withAuth } from "../../../utils/http";
7
8
 
8
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type { KyInstance } from "ky";
9
11
  import type { CodexResetPanelData } from "./openai-codex-panel";
10
12
  import type { Credits, CreditsLane, CreditsProvider, RefreshCredits } from "../types";
11
13
 
@@ -14,7 +16,6 @@ const BASE_URL = "https://chatgpt.com/backend-api";
14
16
  const USAGE_PATH = "/wham/usage";
15
17
  const RESET_CREDITS_PATH = "/wham/rate-limit-reset-credits";
16
18
  const CONSUME_RESET_PATH = "/wham/rate-limit-reset-credits/consume";
17
- const REQUEST_TIMEOUT_MS = 30_000;
18
19
 
19
20
  interface CodexUsageResponse {
20
21
  rate_limit?: {
@@ -53,11 +54,10 @@ async function runCodexReset(ctx: ExtensionContext, refresh: RefreshCredits): Pr
53
54
  const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
54
55
  if (!apiKey) return;
55
56
 
56
- const headers = buildHeaders(apiKey);
57
+ const client = createClient(apiKey);
57
58
 
58
59
  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)]);
60
+ const [usage, details] = await Promise.all([fetchUsage(client, signal), fetchResetCredits(client, signal)]);
61
61
 
62
62
  return {
63
63
  ...details,
@@ -66,63 +66,42 @@ async function runCodexReset(ctx: ExtensionContext, refresh: RefreshCredits): Pr
66
66
  });
67
67
  if (!credit || !(await confirmCodexReset(ctx, credit))) return;
68
68
 
69
- await redeemReset(ctx, headers, credit, refresh);
69
+ await redeemReset(ctx, client, credit, refresh);
70
70
  }
71
71
 
72
- async function redeemReset(ctx: ExtensionContext, headers: Record<string, string>, credit: BankedRateLimitReset, refresh: RefreshCredits): Promise<void> {
72
+ async function redeemReset(ctx: ExtensionContext, client: KyInstance, credit: BankedRateLimitReset, refresh: RefreshCredits): Promise<void> {
73
73
  await showCodexResetLoader(ctx, async () => {
74
- const response = await consumeReset(headers, uuidv7(), credit.id);
74
+ const response = await consumeReset(client, uuidv7(), credit.id);
75
75
  if (response.code === "nothing_to_reset") throw new Error("No Codex rate-limit window currently needs a reset");
76
76
  if (response.code === "no_credit") throw new Error("No Codex resets are available");
77
77
  }).finally(() => refresh(ctx));
78
78
  }
79
79
 
80
- function buildHeaders(apiKey: string): Record<string, string> {
81
- const headers: Record<string, string> = {
82
- Accept: "application/json",
83
- Authorization: `Bearer ${apiKey}`,
84
- };
85
-
80
+ function createClient(apiKey: string): KyInstance {
81
+ const client = withAuth(http, apiKey);
86
82
  const credential = readStoredCredential(PROVIDER);
87
83
  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
84
 
104
- return (await response.json()) as T;
85
+ return typeof accountId === "string" && accountId.length > 0
86
+ ? client.extend({ headers: { "ChatGPT-Account-ID": accountId } })
87
+ : client;
105
88
  }
106
89
 
107
- async function fetchUsage(headers: Record<string, string>, signal: AbortSignal): Promise<CodexUsageResponse> {
108
- return requestJson<CodexUsageResponse>(USAGE_PATH, headers, signal);
90
+ async function fetchUsage(client: KyInstance, signal: AbortSignal): Promise<CodexUsageResponse> {
91
+ return client.get(`${BASE_URL}${USAGE_PATH}`, { signal }).json<CodexUsageResponse>();
109
92
  }
110
93
 
111
- async function fetchResetCredits(headers: Record<string, string>, signal: AbortSignal): Promise<RateLimitResetCreditsResponse> {
112
- return requestJson<RateLimitResetCreditsResponse>(RESET_CREDITS_PATH, headers, signal);
94
+ async function fetchResetCredits(client: KyInstance, signal: AbortSignal): Promise<RateLimitResetCreditsResponse> {
95
+ return client.get(`${BASE_URL}${RESET_CREDITS_PATH}`, { signal }).json<RateLimitResetCreditsResponse>();
113
96
  }
114
97
 
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 }),
98
+ async function consumeReset(client: KyInstance, redeemRequestId: string, creditId: string): Promise<ConsumeResetResponse> {
99
+ return client.post(`${BASE_URL}${CONSUME_RESET_PATH}`, {
100
+ json: {
101
+ redeem_request_id: redeemRequestId,
102
+ credit_id: creditId
124
103
  },
125
- );
104
+ }).json<ConsumeResetResponse>();
126
105
  }
127
106
 
128
107
  function toCredits(usage: CodexUsageResponse, suffix?: string): Credits {
@@ -153,8 +132,8 @@ export const openaiCodexProvider: CreditsProvider = {
153
132
  label: "Codex",
154
133
 
155
134
  async fetch(apiKey, signal): Promise<Credits> {
156
- const headers = buildHeaders(apiKey);
157
- const usage = await fetchUsage(headers, signal);
135
+ const client = createClient(apiKey);
136
+ const usage = await fetchUsage(client, signal);
158
137
  const availableCount = usage.rate_limit_reset_credits?.available_count ?? 0;
159
138
  const suffix = availableCount > 0 ? `(${formatAvailableResets(availableCount)})` : undefined;
160
139
 
@@ -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
  });
@@ -1,32 +1,26 @@
1
1
  import type { Component, TUI } from "@earendil-works/pi-tui";
2
2
 
3
- /**
4
- * Fills the gap above the editor so the editor and footer stay pinned to the
5
- * bottom of the terminal when the session content is shorter than one screen.
6
- */
3
+ const FILLER_MARKER = "\0pi-spark:fullscreen\0";
4
+
5
+ /** Pins the editor and footer to the bottom when the rest of the UI is shorter than the terminal. */
7
6
  export class BottomFiller implements Component {
8
7
  private tui: TUI;
9
- private measuring = false;
10
- private statusContainer?: Component;
11
- private originalStatusRender?: Component["render"];
12
- private patchedStatusRender?: Component["render"];
8
+ private active = false;
9
+ private restore?: () => void;
13
10
 
14
11
  constructor(tui: TUI) {
15
12
  this.tui = tui;
16
13
  }
17
14
 
18
15
  /**
19
- * Suppress Pi's two-line idle status placeholder while leaving active status
20
- * indicators and `clearOnShrink` unchanged.
16
+ * Install the layout after Pi has added this component to its widget container.
21
17
  *
22
- * Pi added the placeholder to prevent status indicators from shrinking the TUI in
23
- * [#6026](https://github.com/earendil-works/pi/pull/6026). Fullscreen already stabilizes short
24
- * layouts with this filler, while in long layouts the placeholder surfaces as blank space above
25
- * the editor. This render wrapper specifically counteracts that placeholder without hiding real
26
- * statuses.
18
+ * Pi has no flexible spacer or layout-measurement API. The filler therefore emits a temporary
19
+ * marker during Pi's normal render pass. A root render wrapper replaces that marker afterward,
20
+ * when the total height is known. Every component, including the transcript, renders only once.
27
21
  */
28
- suppressIdleStatus(): void {
29
- if (this.patchedStatusRender) return;
22
+ mount(): void {
23
+ if (this.active) return;
30
24
 
31
25
  const widgetIndex = this.tui.children.findIndex((child) => "children" in child && Array.isArray(child.children) && child.children.includes(this));
32
26
  if (widgetIndex <= 0) return;
@@ -34,47 +28,55 @@ export class BottomFiller implements Component {
34
28
  const statusContainer = this.tui.children[widgetIndex - 1];
35
29
  if (!statusContainer) return;
36
30
 
37
- const originalRender = statusContainer.render;
38
- const patchedRender = (width: number): string[] => {
39
- const lines = originalRender.call(statusContainer, width);
40
- return lines.length === 2 && lines.every((line) => line === " ".repeat(width)) ? [] : lines;
31
+ const originalStatusRender = statusContainer.render;
32
+ const patchedStatusRender = (width: number): string[] => {
33
+ const lines = originalStatusRender.call(statusContainer, width);
34
+ if (!this.active) return lines;
35
+
36
+ // Fullscreen already keeps the layout stable, so Pi's two idle placeholder rows only add an
37
+ // unwanted gap above the editor. Real status indicators are left untouched.
38
+ const blankLine = " ".repeat(width);
39
+ return lines.length === 2 && lines.every((line) => line === blankLine) ? [] : lines;
40
+ };
41
+
42
+ const originalTuiRender = this.tui.render;
43
+ const patchedTuiRender = (width: number): string[] => {
44
+ const lines = originalTuiRender.call(this.tui, width);
45
+ if (!this.active) return lines;
46
+
47
+ const markerIndex = lines.indexOf(FILLER_MARKER);
48
+ if (markerIndex === -1) return lines;
49
+
50
+ lines.splice(markerIndex, 1);
51
+ const fillerHeight = Math.max(0, this.tui.terminal.rows - lines.length);
52
+ if (fillerHeight > 0) lines.splice(markerIndex, 0, ...new Array<string>(fillerHeight).fill(""));
53
+
54
+ // Pi reads this after render() and may reset it to the configured value on startup/reload.
55
+ this.tui.setClearOnShrink(true);
56
+ return lines;
41
57
  };
42
58
 
43
- statusContainer.render = patchedRender;
44
- this.statusContainer = statusContainer;
45
- this.originalStatusRender = originalRender;
46
- this.patchedStatusRender = patchedRender;
59
+ this.active = true;
60
+ statusContainer.render = patchedStatusRender;
61
+ this.tui.render = patchedTuiRender;
62
+
63
+ this.restore = () => {
64
+ if (this.tui.render === patchedTuiRender) this.tui.render = originalTuiRender;
65
+ if (statusContainer.render === patchedStatusRender) statusContainer.render = originalStatusRender;
66
+ };
47
67
  }
48
68
 
49
69
  dispose(): void {
50
- const statusContainer = this.statusContainer;
51
- const originalStatusRender = this.originalStatusRender;
52
- if (statusContainer && originalStatusRender && statusContainer.render === this.patchedStatusRender) {
53
- statusContainer.render = originalStatusRender;
54
- }
70
+ this.active = false;
71
+ this.restore?.();
72
+ this.restore = undefined;
55
73
  }
56
74
 
57
75
  invalidate(): void {
58
- // No-op
76
+ // No cached render state
59
77
  }
60
78
 
61
- render(width: number): string[] {
62
- // Guard against re-entrancy: measuring the siblings below renders this component again.
63
- if (this.measuring) return [];
64
- this.measuring = true;
65
-
66
- // Re-assert `clearOnShrink` every render pass; the TUI reads it after `render()` returns,
67
- // so this wins deterministically over Pi's reset to the settings value on startup/reload.
68
- this.tui.setClearOnShrink(true);
69
-
70
- const rows = this.tui.terminal.rows;
71
- let others = 0;
72
- for (const child of this.tui.children) {
73
- others += child.render(width).length;
74
- if (others >= rows) break;
75
- }
76
-
77
- this.measuring = false;
78
- return new Array(Math.max(0, rows - others)).fill("");
79
+ render(_width: number): string[] {
80
+ return this.active ? [FILLER_MARKER] : [];
79
81
  }
80
82
  }
@@ -39,7 +39,7 @@ export function registerFullscreen(pi: ExtensionAPI): void {
39
39
 
40
40
  pi.on("session_start", (_event, ctx) => {
41
41
  const config = loadConfig(ctx).fullscreen;
42
- if (!ctx.hasUI || !config) return;
42
+ if (ctx.mode !== "tui" || !config) return;
43
43
 
44
44
  enabled = true;
45
45
  pendingClear = true;
@@ -56,7 +56,7 @@ export function registerFullscreen(pi: ExtensionAPI): void {
56
56
 
57
57
  // Defer setup until `setWidget` has mounted the filler in the render tree.
58
58
  queueMicrotask(() => {
59
- filler.suppressIdleStatus();
59
+ filler.mount();
60
60
  if (shouldClear) capturedTui.requestRender(true);
61
61
  });
62
62
 
@@ -10,7 +10,7 @@ export function registerRecap(pi: ExtensionAPI): void {
10
10
 
11
11
  pi.on("session_start", (event, ctx) => {
12
12
  const config = loadConfig(ctx).recap;
13
- if (!ctx.hasUI || !config) return;
13
+ if (ctx.mode !== "tui" || !config) return;
14
14
 
15
15
  recapManager = new RecapManager(pi, config);
16
16
 
@@ -8,13 +8,18 @@ export function registerTitle(pi: ExtensionAPI): void {
8
8
 
9
9
  pi.on("session_start", (_event, ctx) => {
10
10
  const config = loadConfig(ctx).title;
11
- if (!ctx.hasUI || !config) return;
11
+ if (!config) return;
12
12
 
13
13
  titleManager = new TitleManager(pi, config);
14
14
  });
15
15
 
16
- // Generate the title after the first exchange, once the session has context to summarize.
17
- // The manager runs at most once and skips sessions that already have a name.
16
+ // Generate the title after the first completed turn, without waiting for the full agent run.
17
+ // The manager skips sessions that already have a name and prevents concurrent attempts.
18
+ pi.on("turn_end", (_event, ctx) => {
19
+ void titleManager?.run(ctx);
20
+ });
21
+
22
+ // Fall back to settlement if the run ends without a completed turn or an early attempt fails.
18
23
  pi.on("agent_settled", (_event, ctx) => {
19
24
  void titleManager?.run(ctx);
20
25
  });
@@ -32,9 +32,8 @@ export class TitleManager {
32
32
 
33
33
  /** Generate and set the session title once, silently, from the current context. */
34
34
  async run(ctx: ExtensionContext): Promise<void> {
35
- if (this.pi.getSessionName()) return;
35
+ if (this.pi.getSessionName() || this.inflight) return;
36
36
 
37
- this.cancelInflight();
38
37
  const controller = new AbortController();
39
38
  this.inflight = controller;
40
39
 
@@ -44,7 +43,7 @@ export class TitleManager {
44
43
 
45
44
  const { model, thinkingLevel } = modelSettings;
46
45
  const result = await this.generate(ctx, model, thinkingLevel, controller.signal);
47
- if (controller.signal.aborted || this.inflight !== controller || !result.content) return;
46
+ if (controller.signal.aborted || this.inflight !== controller || !result.content || this.pi.getSessionName()) return;
48
47
 
49
48
  this.pi.setSessionName(result.content);
50
49
  this.pi.appendEntry("title", {
@@ -0,0 +1,3 @@
1
+ import * as z from "zod";
2
+
3
+ export const writeConfigSchema = z.object({});
@@ -0,0 +1,177 @@
1
+ import { createWriteToolDefinition, getLanguageFromPath, highlightCode, keyHint } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+
4
+ import { loadConfig } from "../../config";
5
+ import { renderPath } from "./path";
6
+
7
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
8
+
9
+ const PREVIEW_LINES = 10;
10
+ const HIGHLIGHT_LOOKBACK_LINES = 50;
11
+
12
+ function normalizeContent(content: string): string {
13
+ return content.replace(/\r/g, "").replace(/\t/g, " ");
14
+ }
15
+
16
+ class HighlightCache {
17
+ private rawPath: string | null = null;
18
+ private rawContent = "";
19
+ private argsComplete = false;
20
+ private language = "";
21
+ private normalizedLines: string[] = [];
22
+ private highlightedLines: string[] = [];
23
+
24
+ update(rawPath: string | null, rawContent: string | null, argsComplete: boolean): string[] | undefined {
25
+ if (rawContent === null) {
26
+ this.clear();
27
+ return undefined;
28
+ }
29
+
30
+ const language = rawPath ? getLanguageFromPath(rawPath) : undefined;
31
+ if (!language) {
32
+ this.clear();
33
+ return undefined;
34
+ }
35
+ if (this.matches(rawPath, rawContent, argsComplete, language)) return this.highlightedLines;
36
+
37
+ if (argsComplete || !this.canExtend(rawPath, rawContent, language)) {
38
+ this.rebuild(rawPath, rawContent, argsComplete, language);
39
+ } else {
40
+ this.extend(rawContent);
41
+ }
42
+
43
+ return this.highlightedLines;
44
+ }
45
+
46
+ clear(): void {
47
+ this.rawPath = null;
48
+ this.rawContent = "";
49
+ this.argsComplete = false;
50
+ this.language = "";
51
+ this.normalizedLines = [];
52
+ this.highlightedLines = [];
53
+ }
54
+
55
+ private rebuild(rawPath: string | null, rawContent: string, argsComplete: boolean, language: string): void {
56
+ const normalizedContent = normalizeContent(rawContent);
57
+ this.rawPath = rawPath;
58
+ this.rawContent = rawContent;
59
+ this.argsComplete = argsComplete;
60
+ this.language = language;
61
+ this.normalizedLines = normalizedContent.split("\n");
62
+ this.highlightedLines = highlightCode(normalizedContent, language);
63
+ }
64
+
65
+ private matches(rawPath: string | null, rawContent: string, argsComplete: boolean, language: string): boolean {
66
+ return this.rawPath === rawPath && this.rawContent === rawContent && this.argsComplete === argsComplete && this.language === language;
67
+ }
68
+
69
+ private canExtend(rawPath: string | null, rawContent: string, language: string): boolean {
70
+ return this.rawPath === rawPath && this.language === language && rawContent.startsWith(this.rawContent);
71
+ }
72
+
73
+ private extend(rawContent: string): void {
74
+ const segments = normalizeContent(rawContent.slice(this.rawContent.length)).split("\n");
75
+ const lastIndex = this.normalizedLines.length - 1;
76
+ const start = Math.max(0, lastIndex - HIGHLIGHT_LOOKBACK_LINES);
77
+
78
+ this.rawContent = rawContent;
79
+ this.argsComplete = false;
80
+ this.normalizedLines[lastIndex] += segments[0] ?? "";
81
+ this.normalizedLines.push(...segments.slice(1));
82
+
83
+ const highlightedLines = highlightCode(this.normalizedLines.slice(start).join("\n"), this.language);
84
+ this.highlightedLines.splice(start, this.highlightedLines.length - start, ...highlightedLines);
85
+ }
86
+ }
87
+
88
+ class WriteCallComponent extends Text {
89
+ private highlightCache = new HighlightCache();
90
+
91
+ constructor() {
92
+ super("", 0, 0);
93
+ }
94
+
95
+ updateHighlight(rawPath: string | null, rawContent: string | null, argsComplete: boolean): string[] | undefined {
96
+ return this.highlightCache.update(rawPath, rawContent, argsComplete);
97
+ }
98
+
99
+ override invalidate(): void {
100
+ this.highlightCache.clear();
101
+ super.invalidate();
102
+ }
103
+ }
104
+
105
+ function parseArg(value: unknown): string | null {
106
+ if (typeof value === "string") return value;
107
+ return value == null ? "" : null;
108
+ }
109
+
110
+ function trimTrailingEmptyLines(lines: string[]): string[] {
111
+ let end = lines.length;
112
+ while (end > 0 && lines[end - 1] === "") end--;
113
+ return lines.slice(0, end);
114
+ }
115
+
116
+ function renderOmission(hiddenLines: number, totalLines: number, theme: Theme): string {
117
+ const summary = theme.fg("muted", `... (${hiddenLines} earlier lines, ${totalLines} total,`);
118
+ return `${summary} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
119
+ }
120
+
121
+ function formatWriteCall(path: string | null, content: string | null, highlightedLines: string[] | undefined, expanded: boolean, cwd: string, theme: Theme): string {
122
+ const header = `${theme.fg("toolTitle", theme.bold("write"))} ${renderPath(path, cwd, theme)}`;
123
+ if (content === null) return `${header}\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
124
+ if (!content) return header;
125
+
126
+ const language = path ? getLanguageFromPath(path) : undefined;
127
+ const normalizedContent = normalizeContent(content);
128
+ const contentLines = language ? (highlightedLines ?? highlightCode(normalizedContent, language)) : normalizedContent.split("\n");
129
+ const lines = trimTrailingEmptyLines(contentLines);
130
+
131
+ const visibleLines = expanded ? lines : lines.slice(-PREVIEW_LINES);
132
+ const hiddenLines = lines.length - visibleLines.length;
133
+ const displayLines = language ? visibleLines : visibleLines.map((line) => theme.fg("toolOutput", line));
134
+
135
+ const body = hiddenLines > 0 ? [renderOmission(hiddenLines, lines.length, theme), ...displayLines] : displayLines;
136
+
137
+ return [header, "", ...body].join("\n");
138
+ }
139
+
140
+ export function registerWrite(pi: ExtensionAPI): void {
141
+ let enabled = true;
142
+ const toolCache = new Map<string, ReturnType<typeof createWriteToolDefinition>>();
143
+
144
+ function getWriteTool(cwd: string): ReturnType<typeof createWriteToolDefinition> {
145
+ let tool = toolCache.get(cwd);
146
+ if (!tool) {
147
+ tool = createWriteToolDefinition(cwd);
148
+ toolCache.set(cwd, tool);
149
+ }
150
+
151
+ return tool;
152
+ };
153
+
154
+ pi.on("session_start", (_event, ctx) => {
155
+ enabled = loadConfig(ctx).write !== false;
156
+ });
157
+
158
+ pi.registerTool({
159
+ ...getWriteTool(process.cwd()),
160
+ execute(toolCallId, params, signal, onUpdate, ctx) {
161
+ return getWriteTool(ctx.cwd).execute(toolCallId, params, signal, onUpdate, ctx);
162
+ },
163
+ renderCall(args, theme, context) {
164
+ if (!enabled) return getWriteTool(context.cwd).renderCall!(args, theme, context);
165
+
166
+ const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;
167
+ const path = parseArg(renderArgs?.file_path ?? renderArgs?.path);
168
+ const content = parseArg(renderArgs?.content);
169
+
170
+ const component = context.lastComponent instanceof WriteCallComponent ? context.lastComponent : new WriteCallComponent();
171
+ const highlightedLines = component.updateHighlight(path, content, context.argsComplete);
172
+ component.setText(formatWriteCall(path, content, highlightedLines, context.expanded, context.cwd, theme));
173
+
174
+ return component;
175
+ },
176
+ });
177
+ }
@@ -0,0 +1,33 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { getCapabilities, hyperlink } from "@earendil-works/pi-tui";
5
+
6
+ import type { Theme } from "@earendil-works/pi-coding-agent";
7
+
8
+ export function renderPath(path: string | null, cwd: string, theme: Theme): string {
9
+ if (path === null) return theme.fg("error", "[invalid arg]");
10
+ if (!path) return theme.fg("toolOutput", "...");
11
+
12
+ const home = homedir();
13
+ const displayPath = path.startsWith(home) ? `~${path.slice(home.length)}` : path;
14
+ const styledPath = theme.fg("accent", displayPath);
15
+ if (!getCapabilities().hyperlinks) return styledPath;
16
+
17
+ return hyperlink(styledPath, pathToFileURL(resolvePath(path, cwd)).href);
18
+ }
19
+
20
+ function resolvePath(path: string, cwd: string): string {
21
+ const normalizedPath = normalizePath(path);
22
+ const normalizedCwd = normalizePath(cwd);
23
+ return isAbsolute(normalizedPath) ? resolve(normalizedPath) : resolve(normalizedCwd, normalizedPath);
24
+ }
25
+
26
+ function normalizePath(path: string): string {
27
+ const home = homedir();
28
+ if (path === "~") return home;
29
+ if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) {
30
+ return join(home, path.slice(2));
31
+ }
32
+ return path.startsWith("file://") ? fileURLToPath(path) : path;
33
+ }
@@ -1,5 +1,7 @@
1
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
2
 
3
+ import { http } from "./http";
4
+
3
5
  import type { ProviderId } from "@earendil-works/pi-ai";
4
6
  import type { ContextUsage } from "@earendil-works/pi-coding-agent";
5
7
 
@@ -76,10 +78,7 @@ export async function convertToUSD(amount: number | undefined, currency: string
76
78
  if (!currency || currency === "USD") return amount;
77
79
 
78
80
  const url = `${FRANKFURTER_API}/${encodeURIComponent(currency)}/USD`;
79
- const response = await fetch(url, { headers: { Accept: "application/json" }, signal });
80
- if (!response.ok) throw new Error("currency conversion failed");
81
-
82
- const payload = (await response.json()) as FrankfurterRateResponse;
81
+ const payload = await http.get(url, { signal }).json<FrankfurterRateResponse>();
83
82
  const rate = toNumber(payload.rate);
84
83
  if (rate === undefined) throw new Error("currency conversion failed");
85
84
 
@@ -0,0 +1,16 @@
1
+ import ky from "ky";
2
+
3
+ import type { KyInstance } from "ky";
4
+
5
+ const REQUEST_RETRY_LIMIT = 2;
6
+ const REQUEST_TIMEOUT_MS = 30_000;
7
+
8
+ export const http = ky.create({
9
+ headers: { Accept: "application/json" },
10
+ retry: REQUEST_RETRY_LIMIT,
11
+ timeout: REQUEST_TIMEOUT_MS,
12
+ });
13
+
14
+ export function withAuth(client: KyInstance, apiKey: string): KyInstance {
15
+ return client.extend({ headers: { Authorization: `Bearer ${apiKey}` } });
16
+ }
@@ -33,6 +33,7 @@ export function isUsage(value: unknown): value is Usage {
33
33
  * - `message` carries them on `message`.
34
34
  * - `custom` on `data`.
35
35
  * - `custom_message` on `details`.
36
+ * - `compaction` and `branch_summary` directly on the entry.
36
37
  *
37
38
  * Other entry types carry no usage. Returns `undefined` when the resolved field has no `usage`.
38
39
  */
@@ -41,6 +42,7 @@ export function getEntryUsage(entry: SessionEntry): Usage | undefined {
41
42
  if (entry.type === "message") source = entry.message;
42
43
  else if (entry.type === "custom") source = entry.data;
43
44
  else if (entry.type === "custom_message") source = entry.details;
45
+ else if (entry.type === "compaction" || entry.type === "branch_summary") source = entry;
44
46
  else return;
45
47
 
46
48
  if (typeof source !== "object" || source === null) return;
@@ -0,0 +1,91 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
+ "name": "github-dark-default",
4
+ "vars": {
5
+ "accent": "#2f81f7",
6
+ "border": "#1f6feb",
7
+ "borderMuted": "#21262d",
8
+ "success": "#3fb950",
9
+ "error": "#f85149",
10
+ "warning": "#d29922",
11
+ "text": "#e6edf3",
12
+ "muted": "#b1bac4",
13
+ "dim": "#8b949e",
14
+ "selectedBg": "#343941",
15
+ "userMsgBg": "#21262d",
16
+ "toolPendingBg": "#21262d",
17
+ "toolSuccessBg": "#1c241d",
18
+ "toolErrorBg": "#2a1f1e",
19
+ "customMsgBg": "#13233a",
20
+ "blue": "#79c0ff",
21
+ "green": "#7ee787",
22
+ "orange": "#ffa657",
23
+ "purple": "#d2a8ff",
24
+ "pink": "#db61a2"
25
+ },
26
+ "colors": {
27
+ "accent": "accent",
28
+ "border": "border",
29
+ "borderAccent": "accent",
30
+ "borderMuted": "borderMuted",
31
+ "success": "success",
32
+ "error": "error",
33
+ "warning": "warning",
34
+ "muted": "muted",
35
+ "dim": "dim",
36
+ "text": "text",
37
+ "thinkingText": "muted",
38
+
39
+ "selectedBg": "selectedBg",
40
+ "userMessageBg": "userMsgBg",
41
+ "userMessageText": "text",
42
+ "customMessageBg": "customMsgBg",
43
+ "customMessageText": "text",
44
+ "customMessageLabel": "accent",
45
+ "toolPendingBg": "toolPendingBg",
46
+ "toolSuccessBg": "toolSuccessBg",
47
+ "toolErrorBg": "toolErrorBg",
48
+ "toolTitle": "text",
49
+ "toolOutput": "muted",
50
+
51
+ "mdHeading": "blue",
52
+ "mdLink": "accent",
53
+ "mdLinkUrl": "dim",
54
+ "mdCode": "blue",
55
+ "mdCodeBlock": "text",
56
+ "mdCodeBlockBorder": "dim",
57
+ "mdQuote": "green",
58
+ "mdQuoteBorder": "border",
59
+ "mdHr": "borderMuted",
60
+ "mdListBullet": "orange",
61
+
62
+ "toolDiffAdded": "success",
63
+ "toolDiffRemoved": "error",
64
+ "toolDiffContext": "muted",
65
+
66
+ "syntaxComment": "dim",
67
+ "syntaxKeyword": "#ff7b72",
68
+ "syntaxFunction": "purple",
69
+ "syntaxVariable": "orange",
70
+ "syntaxString": "#a5d6ff",
71
+ "syntaxNumber": "blue",
72
+ "syntaxType": "green",
73
+ "syntaxOperator": "text",
74
+ "syntaxPunctuation": "text",
75
+
76
+ "thinkingOff": "dim",
77
+ "thinkingMinimal": "muted",
78
+ "thinkingLow": "#5f87af",
79
+ "thinkingMedium": "#81a2be",
80
+ "thinkingHigh": "#b294bb",
81
+ "thinkingXhigh": "#d183e8",
82
+ "thinkingMax": "#ff5fff",
83
+
84
+ "bashMode": "success"
85
+ },
86
+ "export": {
87
+ "pageBg": "#010409",
88
+ "cardBg": "#0d1117",
89
+ "infoBg": "#272215"
90
+ }
91
+ }
@@ -0,0 +1,91 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
+ "name": "github-light-default",
4
+ "vars": {
5
+ "accent": "#0969da",
6
+ "border": "#218bff",
7
+ "borderMuted": "#d8dee4",
8
+ "success": "#1a7f37",
9
+ "error": "#cf222e",
10
+ "warning": "#9a6700",
11
+ "text": "#1f2328",
12
+ "muted": "#57606a",
13
+ "dim": "#6e7781",
14
+ "selectedBg": "#eff1f3",
15
+ "userMsgBg": "#eaeef2",
16
+ "toolPendingBg": "#eaeef2",
17
+ "toolSuccessBg": "#e6f1e8",
18
+ "toolErrorBg": "#f9e9e7",
19
+ "customMsgBg": "#ddf4ff",
20
+ "blue": "#0550ae",
21
+ "green": "#116329",
22
+ "orange": "#953800",
23
+ "purple": "#8250df",
24
+ "pink": "#bf3989"
25
+ },
26
+ "colors": {
27
+ "accent": "accent",
28
+ "border": "border",
29
+ "borderAccent": "accent",
30
+ "borderMuted": "borderMuted",
31
+ "success": "success",
32
+ "error": "error",
33
+ "warning": "warning",
34
+ "muted": "muted",
35
+ "dim": "dim",
36
+ "text": "text",
37
+ "thinkingText": "muted",
38
+
39
+ "selectedBg": "selectedBg",
40
+ "userMessageBg": "userMsgBg",
41
+ "userMessageText": "text",
42
+ "customMessageBg": "customMsgBg",
43
+ "customMessageText": "text",
44
+ "customMessageLabel": "accent",
45
+ "toolPendingBg": "toolPendingBg",
46
+ "toolSuccessBg": "toolSuccessBg",
47
+ "toolErrorBg": "toolErrorBg",
48
+ "toolTitle": "text",
49
+ "toolOutput": "muted",
50
+
51
+ "mdHeading": "blue",
52
+ "mdLink": "accent",
53
+ "mdLinkUrl": "dim",
54
+ "mdCode": "blue",
55
+ "mdCodeBlock": "text",
56
+ "mdCodeBlockBorder": "dim",
57
+ "mdQuote": "green",
58
+ "mdQuoteBorder": "border",
59
+ "mdHr": "borderMuted",
60
+ "mdListBullet": "orange",
61
+
62
+ "toolDiffAdded": "success",
63
+ "toolDiffRemoved": "error",
64
+ "toolDiffContext": "muted",
65
+
66
+ "syntaxComment": "dim",
67
+ "syntaxKeyword": "error",
68
+ "syntaxFunction": "purple",
69
+ "syntaxVariable": "orange",
70
+ "syntaxString": "#0a3069",
71
+ "syntaxNumber": "blue",
72
+ "syntaxType": "green",
73
+ "syntaxOperator": "text",
74
+ "syntaxPunctuation": "text",
75
+
76
+ "thinkingOff": "dim",
77
+ "thinkingMinimal": "muted",
78
+ "thinkingLow": "#547da7",
79
+ "thinkingMedium": "#5a8080",
80
+ "thinkingHigh": "#875f87",
81
+ "thinkingXhigh": "#8b008b",
82
+ "thinkingMax": "#af005f",
83
+
84
+ "bashMode": "success"
85
+ },
86
+ "export": {
87
+ "pageBg": "#f6f8fa",
88
+ "cardBg": "#ffffff",
89
+ "infoBg": "#fff8c5"
90
+ }
91
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
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
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.