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 CHANGED
@@ -22,16 +22,25 @@ pi install git:github.com/zlliang/pi-spark
22
22
 
23
23
  ## Features
24
24
 
25
- ### Compact TUI: editor, footer, and fullscreen
25
+ ### Compact TUI: editor and footer
26
26
 
27
- pi-spark ships with custom editor, footer, and fullscreen rendering, replacing the default ones. The compact TUI gives you a calm, immersive experience without distraction.
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
  ![Compact TUI](./assets/screenshot-tui.png)
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-reset` 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.
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
  ![Codex resets](./assets/screenshot-codex-resets.png)
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://raw.githubusercontent.com/zlliang/pi-spark/refs/heads/main/assets/screencast-write.mp4
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
+ ![Themes](./assets/screenshot-themes.png)
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-reset` 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.
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 key comes from Pi's `modelRegistry.getApiKeyForProvider("fireworks")` (env `FIREWORKS_API_KEY`), handled by the manager.
92
+ - **Auth.** Each call sends `x-api-key: <key>` in gRPC metadata. The manager resolves provider authentication through Pi's `modelRegistry.getProviderAuth("fireworks")`. For Fireworks, Pi returns the API key sourced from `FIREWORKS_API_KEY`.
93
93
  - **Account resolution.** `ListAccounts` is called once and the resolved `accounts/<id>` is cached per API key, so steady-state refreshes are a single `GetBalance` round trip.
94
94
  - **Cancellation.** The shared `AbortSignal` cancels the in-flight gRPC call, and a defensive deadline bounds each request.
95
95
  - **Result.** `Money` is converted to a dollar amount and returned as `{ type: "balance", remaining }`, which the status line renders like any other balance provider.
package/index.ts CHANGED
@@ -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.21.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.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",
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.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",
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"
@@ -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, fileName: string = "spark.json"): SparkConfig {
15
- const key = `${ctx.cwd}\u0000${fileName}`;
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, fileName);
22
- const raw = defu(readRawConfig(projectPath) ?? {}, readRawConfig(globalPath) ?? {});
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(key, config as SparkConfig);
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 readRawConfig(path: string): Record<string, unknown> | undefined {
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 {
@@ -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 apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider.id);
51
+ const token = await getAuthToken(ctx.modelRegistry, provider.id);
51
52
  if (signal.aborted) return;
52
- if (!apiKey) {
53
+ if (!token) {
53
54
  ctx.ui.setStatus(STATUS_KEY, undefined);
54
55
  return;
55
56
  }
56
57
 
57
- const credits = await provider.fetch(apiKey, signal);
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 CodexResetPanelData extends RateLimitResetCreditsResponse {
15
+ export interface CodexResetsPanelData extends RateLimitResetCreditsResponse {
15
16
  usage: Credits;
16
17
  }
17
18
 
18
- export async function showCodexResetSelector(ctx: ExtensionContext, load: (signal: AbortSignal) => Promise<CodexResetPanelData>): Promise<BankedRateLimitReset | undefined> {
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 showCodexResetLoader(ctx: ExtensionContext, run: () => Promise<void>): Promise<void> {
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) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }).format(new Date(value));
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, showCodexResetLoader, showCodexResetSelector } from "./openai-codex-panel";
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 { CodexResetPanelData } from "./openai-codex-panel";
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 runCodexReset(ctx: ExtensionContext, refresh: RefreshCredits): Promise<void> {
54
- const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
55
- if (!apiKey) return;
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(apiKey);
58
+ const client = createClient(token);
58
59
 
59
- const credit = await showCodexResetSelector(ctx, async (signal) => {
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 CodexResetPanelData;
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 showCodexResetLoader(ctx, async () => {
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-reset", {
147
- description: "Select and redeem a banked OpenAI Codex rate-limit reset",
148
- handler: async (_args, ctx) => await runCodexReset(ctx, refresh),
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
- await this.apply(nextKey, ctx);
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,3 +0,0 @@
1
- import * as z from "zod";
2
-
3
- export const fullscreenConfigSchema = z.object({});
@@ -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
- }