pi-spark 0.21.0 → 0.22.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/README.md +16 -6
- package/assets/screenshot-themes.png +0 -0
- package/docs/openai-codex-banked-rate-limit-resets.md +1 -1
- package/docs/reverse-engineering-fireworks-credits.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/manager.ts +4 -3
- package/src/features/credits/providers/openai-codex-panel.ts +9 -6
- package/src/features/credits/providers/openai-codex.ts +13 -12
- package/src/features/presets/manager.ts +7 -3
- package/src/utils/auth.ts +11 -0
- package/assets/screencast-write.mp4 +0 -0
- 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
|
|
|
@@ -80,7 +89,7 @@ pi-spark names the session automatically after the first completed turn, so it's
|
|
|
80
89
|
|
|
81
90
|
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
91
|
|
|
83
|
-
https://
|
|
92
|
+
https://github.com/user-attachments/assets/b7bb77c1-c188-46a2-aba9-6487bdf58fa0
|
|
84
93
|
|
|
85
94
|
## Configuration
|
|
86
95
|
|
|
@@ -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
|
|
|
@@ -89,7 +89,7 @@ The amount is `units + nanos / 1e9` (the standard `google.type.Money` encoding),
|
|
|
89
89
|
The provider lives in [`src/features/credits/providers/fireworks.ts`](../src/features/credits/providers/fireworks.ts) and follows the same `CreditsProvider` contract as the HTTP-based providers; only the transport differs.
|
|
90
90
|
|
|
91
91
|
- **Transport.** A single, reused `@grpc/grpc-js` client dials `gateway.fireworks.ai:443` over TLS. The service is declared in [`fireworks.proto`](../src/features/credits/providers/fireworks.proto) — the minimal slice needed, loaded at runtime with `@grpc/proto-loader`. The `.proto` is resolved relative to the module via `import.meta.url`, so it ships and loads from the package directory.
|
|
92
|
-
- **Auth.** Each call sends `x-api-key: <key>` in gRPC metadata. The
|
|
92
|
+
- **Auth.** Each call sends `x-api-key: <key>` in gRPC metadata. The manager resolves provider authentication through Pi's `modelRegistry.getProviderAuth("fireworks")`. For Fireworks, Pi returns the API key sourced from `FIREWORKS_API_KEY`.
|
|
93
93
|
- **Account resolution.** `ListAccounts` is called once and the resolved `accounts/<id>` is cached per API key, so steady-state refreshes are a single `GetBalance` round trip.
|
|
94
94
|
- **Cancellation.** The shared `AbortSignal` cancels the in-flight gRPC call, and a defensive deadline bounds each request.
|
|
95
95
|
- **Result.** `Money` is converted to a dollar amount and returned as `{ type: "balance", remaining }`, which the status line renders like any other balance provider.
|
package/index.ts
CHANGED
|
@@ -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.0",
|
|
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,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getAuthToken } from "../../utils/auth";
|
|
1
2
|
import { findProvider } from "./providers";
|
|
2
3
|
import { renderCredits, renderError } from "./status";
|
|
3
4
|
|
|
@@ -47,14 +48,14 @@ export class CreditsManager {
|
|
|
47
48
|
|
|
48
49
|
private async fetch(ctx: ExtensionContext, provider: CreditsProvider, signal: AbortSignal): Promise<void> {
|
|
49
50
|
try {
|
|
50
|
-
const
|
|
51
|
+
const token = await getAuthToken(ctx.modelRegistry, provider.id);
|
|
51
52
|
if (signal.aborted) return;
|
|
52
|
-
if (!
|
|
53
|
+
if (!token) {
|
|
53
54
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
54
55
|
return;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
const credits = await provider.fetch(
|
|
58
|
+
const credits = await provider.fetch(token, signal);
|
|
58
59
|
|
|
59
60
|
// The active model may have changed while the request was in flight.
|
|
60
61
|
if (ctx.model?.provider !== provider.id) 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,13 +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
|
+
import { getAuthToken } from "../../../utils/auth";
|
|
6
7
|
import { toNumber } from "../../../utils/format";
|
|
7
8
|
import { http, withAuth } from "../../../utils/http";
|
|
8
9
|
|
|
9
10
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import type { KyInstance } from "ky";
|
|
11
|
-
import type {
|
|
12
|
+
import type { CodexResetsPanelData } from "./openai-codex-panel";
|
|
12
13
|
import type { Credits, CreditsLane, CreditsProvider, RefreshCredits } from "../types";
|
|
13
14
|
|
|
14
15
|
const PROVIDER = "openai-codex";
|
|
@@ -50,19 +51,19 @@ interface ConsumeResetResponse {
|
|
|
50
51
|
windows_reset: number;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
async function
|
|
54
|
-
const
|
|
55
|
-
if (!
|
|
54
|
+
async function runCodexResets(ctx: ExtensionContext, refresh: RefreshCredits): Promise<void> {
|
|
55
|
+
const token = await getAuthToken(ctx.modelRegistry, PROVIDER);
|
|
56
|
+
if (!token) return;
|
|
56
57
|
|
|
57
|
-
const client = createClient(
|
|
58
|
+
const client = createClient(token);
|
|
58
59
|
|
|
59
|
-
const credit = await
|
|
60
|
+
const credit = await showCodexResetsSelector(ctx, async (signal) => {
|
|
60
61
|
const [usage, details] = await Promise.all([fetchUsage(client, signal), fetchResetCredits(client, signal)]);
|
|
61
62
|
|
|
62
63
|
return {
|
|
63
64
|
...details,
|
|
64
65
|
usage: toCredits(usage, `· ${formatAvailableResets(details.available_count)}`),
|
|
65
|
-
} satisfies
|
|
66
|
+
} satisfies CodexResetsPanelData;
|
|
66
67
|
});
|
|
67
68
|
if (!credit || !(await confirmCodexReset(ctx, credit))) return;
|
|
68
69
|
|
|
@@ -70,7 +71,7 @@ async function runCodexReset(ctx: ExtensionContext, refresh: RefreshCredits): Pr
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
async function redeemReset(ctx: ExtensionContext, client: KyInstance, credit: BankedRateLimitReset, refresh: RefreshCredits): Promise<void> {
|
|
73
|
-
await
|
|
74
|
+
await showCodexResetsLoader(ctx, async () => {
|
|
74
75
|
const response = await consumeReset(client, uuidv7(), credit.id);
|
|
75
76
|
if (response.code === "nothing_to_reset") throw new Error("No Codex rate-limit window currently needs a reset");
|
|
76
77
|
if (response.code === "no_credit") throw new Error("No Codex resets are available");
|
|
@@ -143,9 +144,9 @@ export const openaiCodexProvider: CreditsProvider = {
|
|
|
143
144
|
register(pi, _ctx, refresh): void {
|
|
144
145
|
if (readStoredCredential(PROVIDER)?.type !== "oauth") return;
|
|
145
146
|
|
|
146
|
-
pi.registerCommand("codex-
|
|
147
|
-
description: "
|
|
148
|
-
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),
|
|
149
150
|
});
|
|
150
151
|
},
|
|
151
152
|
};
|
|
@@ -80,10 +80,14 @@ export class PresetManager {
|
|
|
80
80
|
const currentIndex = currentKey ? this.keys.indexOf(currentKey) : -1;
|
|
81
81
|
const step = direction === "forward" ? 1 : -1;
|
|
82
82
|
const nextIndex = currentIndex === -1 ? direction === "forward" ? 0 : this.keys.length - 1 : (currentIndex + step + this.keys.length) % this.keys.length;
|
|
83
|
-
const nextKey = this.keys[nextIndex];
|
|
84
|
-
if (!nextKey) return;
|
|
85
83
|
|
|
86
|
-
|
|
84
|
+
for (let offset = 0; offset < this.keys.length; offset++) {
|
|
85
|
+
const candidateIndex = (nextIndex + step * offset + this.keys.length) % this.keys.length;
|
|
86
|
+
const candidateKey = this.keys[candidateIndex];
|
|
87
|
+
if (!candidateKey) continue;
|
|
88
|
+
|
|
89
|
+
if (await this.apply(candidateKey, ctx)) return;
|
|
90
|
+
}
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
describe(key: string): string {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ProviderId } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export async function getAuthToken(modelRegistry: ModelRegistry, provider: ProviderId): Promise<string | undefined> {
|
|
5
|
+
const providerAuth = await modelRegistry.getProviderAuth(provider);
|
|
6
|
+
|
|
7
|
+
const authorization = Object.entries(providerAuth?.auth.headers ?? {}).find(([name]) => name.toLowerCase() === "authorization")?.[1];
|
|
8
|
+
const bearerToken = authorization?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
9
|
+
|
|
10
|
+
return providerAuth?.auth.apiKey ?? bearerToken;
|
|
11
|
+
}
|
|
Binary file
|
|
@@ -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
|
-
}
|