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 +9 -0
- package/README.md +28 -4
- package/assets/screencast-write.mp4 +0 -0
- package/index.ts +2 -0
- package/package.json +14 -9
- package/src/config/schema.ts +2 -0
- package/src/features/credits/index.ts +1 -1
- package/src/features/credits/manager.ts +1 -3
- package/src/features/credits/providers/deepseek.ts +2 -9
- package/src/features/credits/providers/kimi-code.ts +2 -9
- package/src/features/credits/providers/moonshot.ts +2 -9
- package/src/features/credits/providers/openai-codex.ts +24 -45
- package/src/features/credits/providers/openrouter.ts +2 -9
- package/src/features/credits/providers/vercel-ai-gateway.ts +2 -9
- package/src/features/editor/config.ts +3 -0
- package/src/features/editor/index.ts +21 -4
- package/src/features/footer/index.ts +2 -2
- package/src/features/fullscreen/filler.ts +51 -49
- package/src/features/fullscreen/index.ts +2 -2
- package/src/features/recap/index.ts +1 -1
- package/src/features/title/index.ts +8 -3
- package/src/features/title/manager.ts +2 -3
- package/src/features/write/config.ts +3 -0
- package/src/features/write/index.ts +177 -0
- package/src/features/write/path.ts +33 -0
- package/src/utils/format.ts +3 -4
- package/src/utils/http.ts +16 -0
- package/src/utils/usage.ts +2 -0
- package/themes/github-dark-default.json +91 -0
- package/themes/github-light-default.json +91 -0
- package/LICENSE +0 -21
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
|
|
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
|
-
-
|
|
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
|
|
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.
|
|
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.
|
|
42
|
-
"@earendil-works/pi-ai": ">=0.
|
|
43
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
44
|
-
"@earendil-works/pi-tui": ">=0.
|
|
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.
|
|
49
|
-
"@earendil-works/pi-ai": ">=0.
|
|
50
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
51
|
-
"@earendil-works/pi-tui": ">=0.
|
|
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"
|
package/src/config/schema.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
57
|
+
const client = createClient(apiKey);
|
|
57
58
|
|
|
58
59
|
const credit = await showCodexResetSelector(ctx, async (signal) => {
|
|
59
|
-
const
|
|
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,
|
|
69
|
+
await redeemReset(ctx, client, credit, refresh);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
async function redeemReset(ctx: ExtensionContext,
|
|
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(
|
|
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
|
|
81
|
-
const
|
|
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
|
|
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(
|
|
108
|
-
return
|
|
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(
|
|
112
|
-
return
|
|
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(
|
|
116
|
-
return
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
157
|
-
const usage = await fetchUsage(
|
|
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
|
|
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
|
|
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.
|
|
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 (
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
5
|
-
|
|
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
|
|
10
|
-
private
|
|
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
|
-
*
|
|
20
|
-
* indicators and `clearOnShrink` unchanged.
|
|
16
|
+
* Install the layout after Pi has added this component to its widget container.
|
|
21
17
|
*
|
|
22
|
-
* Pi
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
29
|
-
if (this.
|
|
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
|
|
38
|
-
const
|
|
39
|
-
const lines =
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
this.
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
76
|
+
// No cached render state
|
|
59
77
|
}
|
|
60
78
|
|
|
61
|
-
render(
|
|
62
|
-
|
|
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 (
|
|
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.
|
|
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 (
|
|
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 (!
|
|
11
|
+
if (!config) return;
|
|
12
12
|
|
|
13
13
|
titleManager = new TitleManager(pi, config);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
// Generate the title after the first
|
|
17
|
-
// The manager
|
|
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,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
|
+
}
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/utils/usage.ts
CHANGED
|
@@ -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.
|