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 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
 
@@ -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
 
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.1",
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.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,
@@ -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 !== "tui" || !config) return;
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 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,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, showCodexResetLoader, showCodexResetSelector } from "./openai-codex-panel";
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 { CodexResetPanelData } from "./openai-codex-panel";
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 runCodexReset(ctx: ExtensionContext, refresh: RefreshCredits): Promise<void> {
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 showCodexResetSelector(ctx, async (signal) => {
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 CodexResetPanelData;
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 showCodexResetLoader(ctx, async () => {
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-reset", {
148
- description: "Select and redeem a banked OpenAI Codex rate-limit reset",
149
- 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),
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, formatCost, formatCwd, sanitizeText } from "../../utils/format";
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
- // Hide cost below half a cent, since it would render as $0.00.
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, {
@@ -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);
@@ -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 resolveModelSelection(ctx, config, feature, fallbackModel);
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 resolveModelSelection(ctx: ExtensionContext, config: OptionalModelConfig, feature: string, fallbackModel: Model<Api>): Promise<ModelSelection> {
73
- if (!config.provider && !config.model) {
74
- return {
75
- model: fallbackModel,
76
- warning: undefined,
77
- };
78
- }
79
-
80
- if (!config.provider || !config.model) {
81
- return {
82
- model: fallbackModel,
83
- warning: `Both ${feature}.provider and ${feature}.model are required; using the current model.`,
84
- };
85
- }
86
-
87
- const model = ctx.modelRegistry.find(config.provider, config.model);
88
- if (!model) {
89
- return {
90
- model: fallbackModel,
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
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
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,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
- }