pi-spark 0.21.1 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/assets/screenshot-themes.png +0 -0
- package/docs/openai-codex-banked-rate-limit-resets.md +1 -1
- package/index.ts +0 -2
- package/package.json +9 -9
- package/src/config/index.ts +8 -7
- package/src/config/schema.ts +0 -2
- package/src/features/credits/index.ts +1 -1
- package/src/features/credits/providers/openai-codex-panel.ts +9 -6
- package/src/features/credits/providers/openai-codex.ts +9 -9
- package/src/features/footer/index.ts +3 -5
- package/src/features/recap/manager.ts +1 -0
- package/src/features/title/manager.ts +1 -0
- package/src/utils/format.ts +0 -4
- package/src/utils/model.ts +21 -34
- package/src/features/fullscreen/config.ts +0 -3
- package/src/features/fullscreen/filler.ts +0 -82
- package/src/features/fullscreen/index.ts +0 -87
package/README.md
CHANGED
|
@@ -22,16 +22,25 @@ pi install git:github.com/zlliang/pi-spark
|
|
|
22
22
|
|
|
23
23
|
## Features
|
|
24
24
|
|
|
25
|
-
### Compact TUI: editor
|
|
25
|
+
### Compact TUI: editor and footer
|
|
26
26
|
|
|
27
|
-
pi-spark ships with custom editor
|
|
27
|
+
pi-spark ships with a custom editor and footer, replacing the default ones. The compact TUI gives you a calm experience without distraction.
|
|
28
28
|
|
|
29
29
|
- The editor shows a working indicator inspired by [Amp](https://ampcode.com/) and the current model on the top border. If you use [presets](#presets), the active preset appears there too.
|
|
30
30
|
- The footer shows session information, extension statuses, cost, and context usage on one line.
|
|
31
|
-
- The fullscreen rendering clears the terminal screen and scrollback on session start and exit, and pins the editor and footer to the bottom.
|
|
32
31
|
|
|
33
32
|

|
|
34
33
|
|
|
34
|
+
> Earlier pi-spark releases included a custom fullscreen renderer. Pi [0.84.0](https://github.com/earendil-works/pi/releases/tag/v0.84.0) now provides fullscreen mode natively, with a sticky editor and footer and an independently scrollable transcript, so pi-spark has retired its workaround. Enable Pi's fullscreen mode in `/settings`, with `pi --tui-mode fullscreen`, or in Pi's `settings.json`:
|
|
35
|
+
>
|
|
36
|
+
> ```json
|
|
37
|
+
> {
|
|
38
|
+
> "tuiMode": "fullscreen"
|
|
39
|
+
> }
|
|
40
|
+
> ```
|
|
41
|
+
>
|
|
42
|
+
> When upgrading, remove the retired `fullscreen` field from `spark.json`.
|
|
43
|
+
|
|
35
44
|
### Credits
|
|
36
45
|
|
|
37
46
|
pi-spark shows the active provider's credit balance or rate-limit usage in the status line, so you can keep an eye on what's left without leaving the terminal.
|
|
@@ -43,7 +52,7 @@ pi-spark shows the active provider's credit balance or rate-limit usage in the s
|
|
|
43
52
|
|
|
44
53
|
#### OpenAI Codex banked resets
|
|
45
54
|
|
|
46
|
-
Banked rate-limit resets are saved benefits that can reset eligible Codex usage windows when redeemed. When available, their count appears after the usage windows. Run `/codex-
|
|
55
|
+
Banked rate-limit resets are saved benefits that can reset eligible Codex usage windows when redeemed. When available, their count appears after the usage windows. Run `/codex-resets` to inspect each reset and its expiration date, then select one to redeem. See [OpenAI Codex Banked Rate-Limit Resets](./docs/openai-codex-banked-rate-limit-resets.md) for the underlying behavior and internal APIs.
|
|
47
56
|
|
|
48
57
|

|
|
49
58
|
|
|
@@ -129,7 +138,6 @@ All fields are optional. Each top-level feature runs with the defaults below unl
|
|
|
129
138
|
| `credits` | `CreditsConfig` | Shows the active provider's credit balance or rate-limit usage in the status line. |
|
|
130
139
|
| `editor` | `EditorConfig` | Shows a working indicator and the current model on the editor's top border. |
|
|
131
140
|
| `footer` | `FooterConfig` | Shows session info, extension statuses, cost, and context usage. |
|
|
132
|
-
| `fullscreen` | `{}` | Clears the screen and scrollback on start and exit, and pins the editor and footer to the bottom. |
|
|
133
141
|
| `presets` | `{ [name]: Preset }` | Defines named model presets, keyed by name. |
|
|
134
142
|
| `recap` | `RecapConfig` | Generates a session recap when idle or on demand. |
|
|
135
143
|
| `title` | `TitleConfig` | Names the session automatically after the first completed turn. |
|
|
@@ -230,6 +238,8 @@ pi-spark ships with two custom themes inspired by [GitHub's VS Code themes](http
|
|
|
230
238
|
- [`github-light-default`](themes/github-light-default.json)
|
|
231
239
|
- [`github-dark-default`](themes/github-dark-default.json)
|
|
232
240
|
|
|
241
|
+

|
|
242
|
+
|
|
233
243
|
Select either theme in `/settings`, or configure automatic switching between them:
|
|
234
244
|
|
|
235
245
|
```json
|
|
Binary file
|
|
@@ -212,7 +212,7 @@ When `/wham/usage` reports one or more available resets, the credits status appe
|
|
|
212
212
|
Codex 5h 42% 7d 7% (4 resets available)
|
|
213
213
|
```
|
|
214
214
|
|
|
215
|
-
The suffix is omitted when no reset is available. `/codex-
|
|
215
|
+
The suffix is omitted when no reset is available. `/codex-resets` opens an interactive panel that loads current usage and reset details, lists available resets by title, grant time, and expiration time, and sorts the earliest expiration first. Selecting a reset opens a separate confirmation prompt before any POST request is made.
|
|
216
216
|
|
|
217
217
|
Each confirmed redemption uses a new UUIDv7 idempotency key and makes one consume request without automatic retries. Successful and idempotently completed redemptions refresh the credits status.
|
|
218
218
|
|
package/index.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { autoCollectEvents } from "./src/events";
|
|
|
2
2
|
import { registerCredits } from "./src/features/credits";
|
|
3
3
|
import { registerEditor } from "./src/features/editor";
|
|
4
4
|
import { registerFooter } from "./src/features/footer";
|
|
5
|
-
import { registerFullscreen } from "./src/features/fullscreen";
|
|
6
5
|
import { registerPresets } from "./src/features/presets";
|
|
7
6
|
import { registerRecap } from "./src/features/recap";
|
|
8
7
|
import { registerTitle } from "./src/features/title";
|
|
@@ -23,7 +22,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
23
22
|
registerCredits(pi);
|
|
24
23
|
registerEditor(pi, events);
|
|
25
24
|
registerFooter(pi);
|
|
26
|
-
registerFullscreen(pi);
|
|
27
25
|
registerPresets(pi);
|
|
28
26
|
registerRecap(pi);
|
|
29
27
|
registerTitle(pi);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-spark",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Pi package that polishes your daily experience.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-coding-agent",
|
|
@@ -43,17 +43,17 @@
|
|
|
43
43
|
"zod": "^4.4.3"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
|
-
"@earendil-works/pi-agent-core": ">=0.
|
|
47
|
-
"@earendil-works/pi-ai": ">=0.
|
|
48
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
49
|
-
"@earendil-works/pi-tui": ">=0.
|
|
46
|
+
"@earendil-works/pi-agent-core": ">=0.84.0",
|
|
47
|
+
"@earendil-works/pi-ai": ">=0.84.0",
|
|
48
|
+
"@earendil-works/pi-coding-agent": ">=0.84.0",
|
|
49
|
+
"@earendil-works/pi-tui": ">=0.84.0",
|
|
50
50
|
"typebox": "*"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@earendil-works/pi-agent-core": ">=0.
|
|
54
|
-
"@earendil-works/pi-ai": ">=0.
|
|
55
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
56
|
-
"@earendil-works/pi-tui": ">=0.
|
|
53
|
+
"@earendil-works/pi-agent-core": ">=0.84.0",
|
|
54
|
+
"@earendil-works/pi-ai": ">=0.84.0",
|
|
55
|
+
"@earendil-works/pi-coding-agent": ">=0.84.0",
|
|
56
|
+
"@earendil-works/pi-tui": ">=0.84.0",
|
|
57
57
|
"@types/node": "*",
|
|
58
58
|
"typebox": "*",
|
|
59
59
|
"typescript": "^7.0.2"
|
package/src/config/index.ts
CHANGED
|
@@ -8,18 +8,19 @@ import { featureSchemas } from "./schema";
|
|
|
8
8
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import type { SparkConfig } from "./schema";
|
|
10
10
|
|
|
11
|
+
const CONFIG_FILE = "spark.json";
|
|
12
|
+
|
|
11
13
|
const cache = new Map<string, SparkConfig>();
|
|
12
14
|
|
|
13
15
|
/** Load and validate spark.json once per session lifecycle; later calls return the cached result. */
|
|
14
|
-
export function loadConfig(ctx: ExtensionContext
|
|
15
|
-
const
|
|
16
|
-
const cached = cache.get(key);
|
|
16
|
+
export function loadConfig(ctx: ExtensionContext): SparkConfig {
|
|
17
|
+
const cached = cache.get(ctx.cwd);
|
|
17
18
|
if (cached) return cached;
|
|
18
19
|
|
|
19
20
|
// Deep-merge the global file under the project file, so project settings win at scalar
|
|
20
21
|
// leaves while deep objects (e.g., `recap.model`) combine across both.
|
|
21
|
-
const [globalPath, projectPath] = getConfigPaths(ctx.cwd,
|
|
22
|
-
const raw = defu(
|
|
22
|
+
const [globalPath, projectPath] = getConfigPaths(ctx.cwd, CONFIG_FILE);
|
|
23
|
+
const raw = defu(readJson(projectPath) ?? {}, readJson(globalPath) ?? {});
|
|
23
24
|
|
|
24
25
|
// Validate each feature independently so a single invalid field disables only that feature
|
|
25
26
|
// (falling back to its enabled defaults) instead of taking down the whole config.
|
|
@@ -53,7 +54,7 @@ export function loadConfig(ctx: ExtensionContext, fileName: string = "spark.json
|
|
|
53
54
|
ctx.ui.notify(`Invalid pi-spark config: ${errors.join("; ")}`, "error");
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
cache.set(
|
|
57
|
+
cache.set(ctx.cwd, config as SparkConfig);
|
|
57
58
|
return config as SparkConfig;
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -61,7 +62,7 @@ function getConfigPaths(cwd: string, fileName: string): [globalPath: string, pro
|
|
|
61
62
|
return [join(getAgentDir(), fileName), join(cwd, CONFIG_DIR_NAME, fileName)];
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
function
|
|
65
|
+
function readJson(path: string): Record<string, unknown> | undefined {
|
|
65
66
|
try {
|
|
66
67
|
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
67
68
|
} catch {
|
package/src/config/schema.ts
CHANGED
|
@@ -3,7 +3,6 @@ import * as z from "zod";
|
|
|
3
3
|
import { creditsConfigSchema } from "../features/credits/config";
|
|
4
4
|
import { editorConfigSchema } from "../features/editor/config";
|
|
5
5
|
import { footerConfigSchema } from "../features/footer/config";
|
|
6
|
-
import { fullscreenConfigSchema } from "../features/fullscreen/config";
|
|
7
6
|
import { presetsConfigSchema } from "../features/presets/config";
|
|
8
7
|
import { recapConfigSchema } from "../features/recap/config";
|
|
9
8
|
import { titleConfigSchema } from "../features/title/config";
|
|
@@ -18,7 +17,6 @@ export const featureSchemas = {
|
|
|
18
17
|
credits: creditsConfigSchema,
|
|
19
18
|
editor: editorConfigSchema,
|
|
20
19
|
footer: footerConfigSchema,
|
|
21
|
-
fullscreen: fullscreenConfigSchema,
|
|
22
20
|
presets: presetsConfigSchema,
|
|
23
21
|
recap: recapConfigSchema,
|
|
24
22
|
title: titleConfigSchema,
|
|
@@ -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.mode
|
|
21
|
+
if (!["tui", "rpc"].includes(ctx.mode) || !config) return;
|
|
22
22
|
|
|
23
23
|
const providers = getEnabledProviders(config);
|
|
24
24
|
if (providers.length === 0) return;
|
|
@@ -10,12 +10,13 @@ import type { BankedRateLimitReset, RateLimitResetCreditsResponse } from "./open
|
|
|
10
10
|
import type { Credits } from "../types";
|
|
11
11
|
|
|
12
12
|
const DATE_COLUMN_WIDTH = 14;
|
|
13
|
+
const DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" });
|
|
13
14
|
|
|
14
|
-
export interface
|
|
15
|
+
export interface CodexResetsPanelData extends RateLimitResetCreditsResponse {
|
|
15
16
|
usage: Credits;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
export async function
|
|
19
|
+
export async function showCodexResetsSelector(ctx: ExtensionContext, load: (signal: AbortSignal) => Promise<CodexResetsPanelData>): Promise<BankedRateLimitReset | undefined> {
|
|
19
20
|
const selected = await ctx.ui.custom<BankedRateLimitReset | null>((tui, theme, _keybindings, done) => {
|
|
20
21
|
const controller = new AbortController();
|
|
21
22
|
|
|
@@ -175,7 +176,7 @@ export async function confirmCodexReset(ctx: ExtensionContext, credit: BankedRat
|
|
|
175
176
|
return confirmed ?? false;
|
|
176
177
|
}
|
|
177
178
|
|
|
178
|
-
export async function
|
|
179
|
+
export async function showCodexResetsLoader(ctx: ExtensionContext, run: () => Promise<void>): Promise<void> {
|
|
179
180
|
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
180
181
|
const container = new Container();
|
|
181
182
|
const loader = new Loader(tui, (text) => theme.fg("accent", text), (text) => theme.fg("muted", text), "Redeeming...");
|
|
@@ -214,10 +215,12 @@ function formatCreditTitle(credit: BankedRateLimitReset): string {
|
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
function formatCreditDescription(credit: BankedRateLimitReset, padEnd: boolean = true): string {
|
|
217
|
-
const formatDate = (value: string) =>
|
|
218
|
+
const formatDate = (value: string) => DATE_FORMATTER.format(new Date(value));
|
|
219
|
+
const pad = (text: string) => (padEnd ? text.padEnd(DATE_COLUMN_WIDTH) : text);
|
|
220
|
+
|
|
221
|
+
const granted = pad(`Granted ${formatDate(credit.granted_at)}`);
|
|
222
|
+
const expires = pad(credit.expires_at ? `Expires ${formatDate(credit.expires_at)}` : "No expiration");
|
|
218
223
|
|
|
219
|
-
const granted = padEnd ? `Granted ${formatDate(credit.granted_at)}`.padEnd(DATE_COLUMN_WIDTH) : `Granted ${formatDate(credit.granted_at)}`;
|
|
220
|
-
const expires = padEnd ? (credit.expires_at ? `Expires ${formatDate(credit.expires_at)}` : "No expiration").padEnd(DATE_COLUMN_WIDTH) : (credit.expires_at ? `Expires ${formatDate(credit.expires_at)}` : "No expiration");
|
|
221
224
|
return `${granted} · ${expires}`;
|
|
222
225
|
}
|
|
223
226
|
|
|
@@ -2,14 +2,14 @@ import { uuidv7 } from "@earendil-works/pi-agent-core";
|
|
|
2
2
|
import { readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import prettyMilliseconds from "pretty-ms";
|
|
4
4
|
|
|
5
|
-
import { confirmCodexReset, formatAvailableResets,
|
|
5
|
+
import { confirmCodexReset, formatAvailableResets, showCodexResetsLoader, showCodexResetsSelector } from "./openai-codex-panel";
|
|
6
6
|
import { getAuthToken } from "../../../utils/auth";
|
|
7
7
|
import { toNumber } from "../../../utils/format";
|
|
8
8
|
import { http, withAuth } from "../../../utils/http";
|
|
9
9
|
|
|
10
10
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import type { KyInstance } from "ky";
|
|
12
|
-
import type {
|
|
12
|
+
import type { CodexResetsPanelData } from "./openai-codex-panel";
|
|
13
13
|
import type { Credits, CreditsLane, CreditsProvider, RefreshCredits } from "../types";
|
|
14
14
|
|
|
15
15
|
const PROVIDER = "openai-codex";
|
|
@@ -51,19 +51,19 @@ interface ConsumeResetResponse {
|
|
|
51
51
|
windows_reset: number;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
async function
|
|
54
|
+
async function runCodexResets(ctx: ExtensionContext, refresh: RefreshCredits): Promise<void> {
|
|
55
55
|
const token = await getAuthToken(ctx.modelRegistry, PROVIDER);
|
|
56
56
|
if (!token) return;
|
|
57
57
|
|
|
58
58
|
const client = createClient(token);
|
|
59
59
|
|
|
60
|
-
const credit = await
|
|
60
|
+
const credit = await showCodexResetsSelector(ctx, async (signal) => {
|
|
61
61
|
const [usage, details] = await Promise.all([fetchUsage(client, signal), fetchResetCredits(client, signal)]);
|
|
62
62
|
|
|
63
63
|
return {
|
|
64
64
|
...details,
|
|
65
65
|
usage: toCredits(usage, `· ${formatAvailableResets(details.available_count)}`),
|
|
66
|
-
} satisfies
|
|
66
|
+
} satisfies CodexResetsPanelData;
|
|
67
67
|
});
|
|
68
68
|
if (!credit || !(await confirmCodexReset(ctx, credit))) return;
|
|
69
69
|
|
|
@@ -71,7 +71,7 @@ async function runCodexReset(ctx: ExtensionContext, refresh: RefreshCredits): Pr
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
async function redeemReset(ctx: ExtensionContext, client: KyInstance, credit: BankedRateLimitReset, refresh: RefreshCredits): Promise<void> {
|
|
74
|
-
await
|
|
74
|
+
await showCodexResetsLoader(ctx, async () => {
|
|
75
75
|
const response = await consumeReset(client, uuidv7(), credit.id);
|
|
76
76
|
if (response.code === "nothing_to_reset") throw new Error("No Codex rate-limit window currently needs a reset");
|
|
77
77
|
if (response.code === "no_credit") throw new Error("No Codex resets are available");
|
|
@@ -144,9 +144,9 @@ export const openaiCodexProvider: CreditsProvider = {
|
|
|
144
144
|
register(pi, _ctx, refresh): void {
|
|
145
145
|
if (readStoredCredential(PROVIDER)?.type !== "oauth") return;
|
|
146
146
|
|
|
147
|
-
pi.registerCommand("codex-
|
|
148
|
-
description: "
|
|
149
|
-
handler: async (_args, ctx) => await
|
|
147
|
+
pi.registerCommand("codex-resets", {
|
|
148
|
+
description: "Browse and redeem banked OpenAI Codex rate-limit resets",
|
|
149
|
+
handler: async (_args, ctx) => await runCodexResets(ctx, refresh),
|
|
150
150
|
});
|
|
151
151
|
},
|
|
152
152
|
};
|
|
@@ -5,7 +5,7 @@ import { hyperlink, Text } from "@earendil-works/pi-tui";
|
|
|
5
5
|
|
|
6
6
|
import { SplitLine } from "../../components/split-line";
|
|
7
7
|
import { loadConfig } from "../../config";
|
|
8
|
-
import { formatContextUsage,
|
|
8
|
+
import { formatContextUsage, formatCwd, sanitizeText } from "../../utils/format";
|
|
9
9
|
import { getEntryUsage } from "../../utils/usage";
|
|
10
10
|
|
|
11
11
|
import type { ExtensionContext, ExtensionAPI, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
@@ -76,11 +76,9 @@ class FooterComponent implements Component {
|
|
|
76
76
|
|
|
77
77
|
private getStyledCostText(): string {
|
|
78
78
|
const totalCost = this.ctx.sessionManager.getEntries().reduce((acc, entry) => acc + (getEntryUsage(entry)?.cost.total ?? 0), 0);
|
|
79
|
+
if (totalCost < 0.0005) return ""; // Hide cost below half a millicent, since it would render as $0.000.
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
if (totalCost < 0.005) return "";
|
|
82
|
-
|
|
83
|
-
const costText = formatCost(totalCost);
|
|
81
|
+
const costText = `$${totalCost.toFixed(3)}`;
|
|
84
82
|
|
|
85
83
|
if (totalCost > 20) return this.theme.fg("warning", costText);
|
|
86
84
|
return this.theme.fg("dim", costText);
|
|
@@ -87,6 +87,7 @@ export class RecapManager {
|
|
|
87
87
|
const options: SimpleStreamOptions = { maxTokens: MAX_TOKENS, signal };
|
|
88
88
|
if (auth.apiKey) options.apiKey = auth.apiKey;
|
|
89
89
|
if (auth.headers) options.headers = auth.headers;
|
|
90
|
+
if (auth.env) options.env = auth.env;
|
|
90
91
|
if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
|
|
91
92
|
|
|
92
93
|
const response = await completeBackground(model, {
|
|
@@ -70,6 +70,7 @@ export class TitleManager {
|
|
|
70
70
|
const options: SimpleStreamOptions = { maxTokens: MAX_TOKENS, signal };
|
|
71
71
|
if (auth.apiKey) options.apiKey = auth.apiKey;
|
|
72
72
|
if (auth.headers) options.headers = auth.headers;
|
|
73
|
+
if (auth.env) options.env = auth.env;
|
|
73
74
|
if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
|
|
74
75
|
|
|
75
76
|
const response = await completeBackground(model, {
|
package/src/utils/format.ts
CHANGED
|
@@ -26,10 +26,6 @@ export function formatContextUsage(contextUsage: ContextUsage | undefined): stri
|
|
|
26
26
|
return `${tokens === null ? "?" : formatTokens(tokens)}/${contextWindow === null ? "?" : formatTokens(contextWindow)} (${percentText})`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function formatCost(cost: number): string {
|
|
30
|
-
return `$${cost.toFixed(2)}`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
29
|
export function formatCwd(cwd: string, home: string): string {
|
|
34
30
|
const resolvedCwd = resolve(cwd);
|
|
35
31
|
const resolvedHome = resolve(home);
|
package/src/utils/model.ts
CHANGED
|
@@ -59,7 +59,7 @@ export async function resolveModelSettings(ctx: ExtensionContext, config: Option
|
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
const { model, warning: modelWarning } = await
|
|
62
|
+
const { model, warning: modelWarning } = await resolveModel(ctx, config, feature, fallbackModel);
|
|
63
63
|
const { thinkingLevel, warning: thinkingLevelWarning } = resolveThinkingLevel(model, config.thinkingLevel ?? DEFAULT_THINKING_LEVEL);
|
|
64
64
|
|
|
65
65
|
return {
|
|
@@ -69,41 +69,28 @@ export async function resolveModelSettings(ctx: ExtensionContext, config: Option
|
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
async function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
warning: `Model ${formatModel(config.provider, config.model)} not found; using the current model.`,
|
|
92
|
-
};
|
|
72
|
+
async function resolveModel(ctx: ExtensionContext, config: OptionalModelConfig, feature: string, fallbackModel: Model<Api>): Promise<ModelSelection> {
|
|
73
|
+
let model = fallbackModel;
|
|
74
|
+
let warning: string | undefined = undefined;
|
|
75
|
+
|
|
76
|
+
if (config.provider && config.model) {
|
|
77
|
+
const reference = formatModel(config.provider, config.model);
|
|
78
|
+
const configuredModel = ctx.modelRegistry.find(config.provider, config.model);
|
|
79
|
+
if (!configuredModel) {
|
|
80
|
+
warning = `Model ${reference} not found; using the current model.`;
|
|
81
|
+
} else {
|
|
82
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(configuredModel);
|
|
83
|
+
if (auth.ok) {
|
|
84
|
+
model = configuredModel;
|
|
85
|
+
} else {
|
|
86
|
+
warning = `Model ${reference} unavailable: ${auth.error}; using the current model.`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} else if (config.provider || config.model) {
|
|
90
|
+
warning = `Both ${feature}.provider and ${feature}.model are required; using the current model.`;
|
|
93
91
|
}
|
|
94
92
|
|
|
95
|
-
|
|
96
|
-
if (!auth.ok) {
|
|
97
|
-
return {
|
|
98
|
-
model: fallbackModel,
|
|
99
|
-
warning: `Model ${formatModel(config.provider, config.model)} unavailable: ${auth.error}; using the current model.`,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return {
|
|
104
|
-
model,
|
|
105
|
-
warning: undefined,
|
|
106
|
-
};
|
|
93
|
+
return { model, warning };
|
|
107
94
|
}
|
|
108
95
|
|
|
109
96
|
function resolveThinkingLevel(model: Model<Api>, requested: ModelThinkingLevel): ThinkingLevelSelection {
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
2
|
-
|
|
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. */
|
|
6
|
-
export class BottomFiller implements Component {
|
|
7
|
-
private tui: TUI;
|
|
8
|
-
private active = false;
|
|
9
|
-
private restore?: () => void;
|
|
10
|
-
|
|
11
|
-
constructor(tui: TUI) {
|
|
12
|
-
this.tui = tui;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Install the layout after Pi has added this component to its widget container.
|
|
17
|
-
*
|
|
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.
|
|
21
|
-
*/
|
|
22
|
-
mount(): void {
|
|
23
|
-
if (this.active) return;
|
|
24
|
-
|
|
25
|
-
const widgetIndex = this.tui.children.findIndex((child) => "children" in child && Array.isArray(child.children) && child.children.includes(this));
|
|
26
|
-
if (widgetIndex <= 0) return;
|
|
27
|
-
|
|
28
|
-
const statusContainer = this.tui.children[widgetIndex - 1];
|
|
29
|
-
if (!statusContainer) return;
|
|
30
|
-
|
|
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;
|
|
57
|
-
};
|
|
58
|
-
|
|
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
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
dispose(): void {
|
|
70
|
-
this.active = false;
|
|
71
|
-
this.restore?.();
|
|
72
|
-
this.restore = undefined;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
invalidate(): void {
|
|
76
|
-
// No cached render state
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
render(_width: number): string[] {
|
|
80
|
-
return this.active ? [FILLER_MARKER] : [];
|
|
81
|
-
}
|
|
82
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
|
|
4
|
-
import { BottomFiller } from "./filler";
|
|
5
|
-
import { loadConfig } from "../../config";
|
|
6
|
-
import { sanitizeText } from "../../utils/format";
|
|
7
|
-
|
|
8
|
-
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import type { TUI } from "@earendil-works/pi-tui";
|
|
10
|
-
import type { UserMessage } from "@earendil-works/pi-ai";
|
|
11
|
-
|
|
12
|
-
const WIDGET_KEY = "fullscreen";
|
|
13
|
-
|
|
14
|
-
function getSessionDisplayText(ctx: ExtensionContext, theme: Theme): string | undefined {
|
|
15
|
-
const sessionName = ctx.sessionManager.getSessionName();
|
|
16
|
-
if (sessionName) return theme.fg("warning", sanitizeText(sessionName));
|
|
17
|
-
|
|
18
|
-
for (const entry of ctx.sessionManager.getEntries()) {
|
|
19
|
-
if (entry.type !== "message" || entry.message.role !== "user") continue;
|
|
20
|
-
|
|
21
|
-
const text = extractText(entry.message);
|
|
22
|
-
if (text) return sanitizeText(text);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return undefined;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function extractText(message: UserMessage): string {
|
|
29
|
-
const content = message.content;
|
|
30
|
-
if (typeof content === "string") return content;
|
|
31
|
-
|
|
32
|
-
return content.filter((block) => block.type === "text").map((block) => block.text).join(" ");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function registerFullscreen(pi: ExtensionAPI): void {
|
|
36
|
-
let tui: TUI | undefined;
|
|
37
|
-
let enabled = false;
|
|
38
|
-
let pendingClear = false;
|
|
39
|
-
|
|
40
|
-
pi.on("session_start", (_event, ctx) => {
|
|
41
|
-
const config = loadConfig(ctx).fullscreen;
|
|
42
|
-
if (ctx.mode !== "tui" || !config) return;
|
|
43
|
-
|
|
44
|
-
enabled = true;
|
|
45
|
-
pendingClear = true;
|
|
46
|
-
|
|
47
|
-
// The TUI handle isn't exposed via `ctx.ui`; the widget factory is the only place that
|
|
48
|
-
// receives it. Mounting a persistent filler above the editor both captures the TUI and
|
|
49
|
-
// pins the editor and footer to the bottom of the screen.
|
|
50
|
-
ctx.ui.setWidget(WIDGET_KEY, (capturedTui) => {
|
|
51
|
-
tui = capturedTui;
|
|
52
|
-
|
|
53
|
-
const filler = new BottomFiller(capturedTui);
|
|
54
|
-
const shouldClear = pendingClear;
|
|
55
|
-
pendingClear = false;
|
|
56
|
-
|
|
57
|
-
// Defer setup until `setWidget` has mounted the filler in the render tree.
|
|
58
|
-
queueMicrotask(() => {
|
|
59
|
-
filler.mount();
|
|
60
|
-
if (shouldClear) capturedTui.requestRender(true);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
return filler;
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
pi.on("session_shutdown", (event, ctx) => {
|
|
68
|
-
if (!enabled) return;
|
|
69
|
-
|
|
70
|
-
if (event.reason === "quit" && tui) {
|
|
71
|
-
// The TUI is already stopped here, so write the clear sequence directly instead of
|
|
72
|
-
// repainting: clear screen, home, clear scrollback.
|
|
73
|
-
tui.terminal.write("\x1b[2J\x1b[H\x1b[3J");
|
|
74
|
-
|
|
75
|
-
const theme = ctx.ui.theme;
|
|
76
|
-
const exitMessage = `${theme.bold(theme.fg("accent", "pi"))} ${theme.fg("dim", `v${VERSION} exited`)}`;
|
|
77
|
-
const sessionText = getSessionDisplayText(ctx, theme);
|
|
78
|
-
const line = truncateToWidth(`${exitMessage}${sessionText ? `${theme.fg("dim", ":")} ${sessionText}` : ""}`, tui.terminal.columns, "…");
|
|
79
|
-
tui.terminal.write(`${line}\r\n`);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
83
|
-
tui = undefined;
|
|
84
|
-
enabled = false;
|
|
85
|
-
pendingClear = false;
|
|
86
|
-
});
|
|
87
|
-
}
|