pi-spark 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026—PRESENT Zilong Liang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ ![Cover](./assets/cover.png)
2
+
3
+ # Pi Spark
4
+
5
+ A small, opinionated collection of [pi](https://pi.dev/) extensions.
6
+
7
+ ## Extensions
8
+
9
+ - **Editor**: replaces the default editor with a compact working indicator and current model info.
10
+ - **Footer**: shows the session info, cost, and context usage in one line, followed by extension statuses.
11
+ - **Fullscreen**: clears the screen and scrollback on session start and pins the editor and footer to the bottom for a full-screen session.
12
+ - **Presets**: switches named model presets with `/preset` and quick cycle shortcuts.
13
+ - **Recap**: generates a short idle-session recap and exposes a `/recap` command for manual generation, inspired by [Claude Code's session recap](https://code.claude.com/docs/en/interactive-mode#session-recap).
14
+
15
+ ![Screenshot](./assets/screenshot.png)
16
+
17
+ ## Install
18
+
19
+ Install from npm:
20
+
21
+ ```bash
22
+ pi install npm:pi-spark
23
+ ```
24
+
25
+ Install from git:
26
+
27
+ ```bash
28
+ pi install git:github.com/zlliang/pi-spark
29
+ ```
30
+
31
+ ## Configure
32
+
33
+ Spark reads config from `~/.pi/agent/spark.json` and from the current project’s `.pi/spark.json`. Project config overrides matching global fields.
34
+
35
+ Example:
36
+
37
+ ```json
38
+ {
39
+ "editor": {
40
+ "spinner": "dots"
41
+ },
42
+ "footer": false,
43
+ "presets": {
44
+ "gpt": {
45
+ "provider": "openai-codex",
46
+ "model": "gpt-5.5",
47
+ "thinkingLevel": "medium"
48
+ },
49
+ "claude-opus": {
50
+ "provider": "anthropic",
51
+ "model": "claude-opus-4-8",
52
+ "thinkingLevel": "high"
53
+ }
54
+ },
55
+ "recap": {
56
+ "idle": 180000,
57
+ "provider": "openai-codex",
58
+ "model": "gpt-5.4-mini",
59
+ "thinkingLevel": "off"
60
+ }
61
+ }
62
+ ```
63
+
64
+ Notes:
65
+
66
+ - Set an extension key to `false` to disable it.
67
+ - The `editor.spinner` value can be `lights` or `dots`.
68
+ - Presets can be selected with `/preset` or `/preset <key>`.
69
+ - Cycle presets with `ctrl+super+p` and `ctrl+shift+super+p` (`super` is `command` on macOS).
70
+ - The `recap.idle` value is in milliseconds and must be at least `5000`.
71
+
72
+ ## Recommended pi settings
73
+
74
+ The Fullscreen extension pins the editor and footer to the bottom of the terminal. For the cleanest experience, pair it with pi's `terminal.clearOnShrink` setting, which clears empty rows when content shrinks so the pinned UI does not leave stale lines behind.
75
+
76
+ Add this to `~/.pi/agent/settings.json` (global) or `.pi/settings.json` (project):
77
+
78
+ ```json
79
+ {
80
+ "terminal": {
81
+ "clearOnShrink": true
82
+ }
83
+ }
84
+ ```
85
+
86
+ This setting defaults to `false` because it can cause flicker in some terminals. With Fullscreen enabled the trade-off is usually worth it.
Binary file
Binary file
@@ -0,0 +1,7 @@
1
+ import * as z from "zod";
2
+
3
+ import { spinnerPresetSchema } from "./spinner";
4
+
5
+ export const editorConfigSchema = z.object({
6
+ spinner: spinnerPresetSchema.optional(),
7
+ });
@@ -0,0 +1,111 @@
1
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { Spinner } from "./spinner";
4
+ import { SplitLine } from "../shared/components/split-line";
5
+ import { loadConfig } from "../shared/config";
6
+ import { autoCollectEvents, PRESET_CHANGE, parsePresetChange } from "../shared/events";
7
+ import { formatModel } from "../shared/format";
8
+
9
+ import type { ExtensionAPI, ExtensionContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
10
+ import type { TUI, EditorTheme } from "@earendil-works/pi-tui";
11
+
12
+ class Editor extends CustomEditor {
13
+ private pi: ExtensionAPI;
14
+ private ctx: ExtensionContext;
15
+
16
+ private spinner: Spinner;
17
+ private slots: { modelBefore: string | undefined };
18
+
19
+ constructor(pi: ExtensionAPI, ctx: ExtensionContext, tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, spinner: Spinner = new Spinner()) {
20
+ super(tui, theme, keybindings);
21
+
22
+ this.pi = pi;
23
+ this.ctx = ctx;
24
+
25
+ this.spinner = spinner;
26
+ this.spinner.setTUI(tui);
27
+ this.slots = { modelBefore: undefined };
28
+ }
29
+
30
+ setSlot(slot: keyof typeof this.slots, value: string | undefined): void {
31
+ this.slots[slot] = value;
32
+ this.tui.requestRender();
33
+ }
34
+
35
+ render(width: number): string[] {
36
+ const lines = super.render(width);
37
+ if (lines.length === 0) return lines;
38
+
39
+ lines[0] = this.renderTopBorder(width);
40
+
41
+ return lines;
42
+ }
43
+
44
+ private renderTopBorder(width: number): string {
45
+ const theme = this.ctx.ui.theme;
46
+
47
+ const left = this.getLeft();
48
+ const right = this.getRight();
49
+
50
+ return new SplitLine(left, right, {
51
+ padding: 1,
52
+ innerPadding: 1,
53
+ spacingChar: this.borderColor("─"),
54
+ ellipsis: theme.fg("dim", "…"),
55
+ }).render(width)[0];
56
+ }
57
+
58
+ private getLeft(): string {
59
+ const theme = this.ctx.ui.theme;
60
+
61
+ return theme.fg("accent", this.spinner.getFrame());
62
+ }
63
+
64
+ private getRight(): string {
65
+ const theme = this.ctx.ui.theme;
66
+
67
+ const modelBeforeText = this.slots.modelBefore;
68
+ const modelText = formatModel(this.ctx.model?.provider, this.ctx.model?.id, this.pi.getThinkingLevel());
69
+
70
+ return theme.fg("dim", [modelBeforeText, modelText].filter(Boolean).join(" • "));
71
+ }
72
+ }
73
+
74
+ export default function (pi: ExtensionAPI) {
75
+ const events = autoCollectEvents(pi);
76
+ let spinner: Spinner | undefined = undefined;
77
+
78
+ pi.on("session_start", (_event, ctx) => {
79
+ if (!ctx.hasUI) return;
80
+
81
+ const config = loadConfig(ctx, "editor");
82
+ if (!config) return;
83
+
84
+ spinner = new Spinner(config.spinner);
85
+
86
+ ctx.ui.setWorkingVisible(false);
87
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
88
+ const editor = new Editor(pi, ctx, tui, theme, keybindings, spinner);
89
+
90
+ events.on(PRESET_CHANGE, (data) => {
91
+ const payload = parsePresetChange(data);
92
+ editor.setSlot("modelBefore", payload ? `preset:${payload}` : undefined);
93
+ });
94
+
95
+ return editor;
96
+ });
97
+ });
98
+
99
+ pi.on("agent_start", () => {
100
+ spinner?.start();
101
+ });
102
+
103
+ pi.on("agent_end", () => {
104
+ spinner?.stop();
105
+ });
106
+
107
+ pi.on("session_shutdown", () => {
108
+ spinner?.dispose();
109
+ spinner = undefined;
110
+ });
111
+ }
@@ -0,0 +1,92 @@
1
+ import * as z from "zod";
2
+
3
+ import type { TUI } from "@earendil-works/pi-tui";
4
+
5
+ export const spinnerPresetSchema = z.enum(["lights", "dots"]);
6
+
7
+ type SpinnerPreset = z.infer<typeof spinnerPresetSchema>;
8
+
9
+ interface SpinnerParams {
10
+ frames: string[];
11
+ interval: number | { min: number; max: number };
12
+ random: boolean;
13
+ }
14
+
15
+ const SPINNER_PRESETS: Record<SpinnerPreset, SpinnerParams> = {
16
+ lights: {
17
+ frames: ["○○○○", "●○○○", "○●○○", "○○●○", "○○○●", "●●○○", "●○●○", "●○○●", "○●●○", "○●○●", "○○●●", "●●●○", "●●○●", "●○●●", "○●●●", "●●●●"],
18
+ interval: { min: 120, max: 240 },
19
+ random: true,
20
+ },
21
+ dots: {
22
+ frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
23
+ interval: 80,
24
+ random: false,
25
+ },
26
+ };
27
+
28
+ const DEFAULT_SPINNER_PRESET = "lights";
29
+
30
+ export class Spinner {
31
+ private tui: TUI | undefined;
32
+
33
+ private frames: string[];
34
+ private interval: number | { min: number; max: number };
35
+ private random: boolean;
36
+
37
+ private working: boolean = false;
38
+ private frameIndex: number = -1;
39
+ private timer: ReturnType<typeof setTimeout> | undefined;
40
+
41
+ constructor(preset: SpinnerPreset = DEFAULT_SPINNER_PRESET) {
42
+ const params = SPINNER_PRESETS[preset];
43
+
44
+ this.frames = params.frames;
45
+ this.interval = params.interval;
46
+ this.random = params.random;
47
+ }
48
+
49
+ setTUI(tui: TUI): void {
50
+ this.tui = tui;
51
+ }
52
+
53
+ getFrame(): string {
54
+ if (!this.working) return "";
55
+
56
+ return this.frames[this.frameIndex] ?? "";
57
+ }
58
+
59
+ start(): void {
60
+ this.stop();
61
+
62
+ this.working = true;
63
+ this.tick();
64
+ }
65
+
66
+ stop(): void {
67
+ this.working = false;
68
+ this.frameIndex = -1;
69
+
70
+ if (this.timer) {
71
+ clearTimeout(this.timer);
72
+ this.timer = undefined;
73
+ }
74
+
75
+ this.tui?.requestRender();
76
+ }
77
+
78
+ dispose(): void {
79
+ this.stop();
80
+ this.tui = undefined;
81
+ }
82
+
83
+ private tick(): void {
84
+ if (!this.working) return;
85
+
86
+ this.frameIndex = this.random ? Math.floor(Math.random() * this.frames.length) : (this.frameIndex + 1) % this.frames.length;
87
+ this.tui?.requestRender();
88
+
89
+ const delay = typeof this.interval === "number" ? this.interval : this.interval.min + Math.floor(Math.random() * (this.interval.max - this.interval.min + 1));
90
+ this.timer = setTimeout(() => this.tick(), delay);
91
+ }
92
+ }
@@ -0,0 +1,3 @@
1
+ import * as z from "zod";
2
+
3
+ export const footerConfigSchema = z.object({});
@@ -0,0 +1,122 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ import { SplitLine } from "../shared/components/split-line";
5
+ import { loadConfig } from "../shared/config";
6
+ import { formatContextUsage, formatCost, sanitizeText } from "../shared/format";
7
+ import { getEntryUsage } from "../shared/usage";
8
+
9
+ import type { ExtensionContext, ExtensionAPI, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
10
+ import type { Component } from "@earendil-works/pi-tui";
11
+
12
+ class FooterComponent implements Component {
13
+ private ctx: ExtensionContext;
14
+ private theme: Theme;
15
+ private footerData: ReadonlyFooterDataProvider;
16
+
17
+ constructor(ctx: ExtensionContext, theme: Theme, footerData: ReadonlyFooterDataProvider) {
18
+ this.ctx = ctx;
19
+ this.theme = theme;
20
+ this.footerData = footerData;
21
+ }
22
+
23
+ invalidate(): void {
24
+ // No-op
25
+ }
26
+
27
+ render(width: number): string[] {
28
+ return [this.renderMainLine(width), this.renderStatusLine(width)].filter(Boolean);
29
+ }
30
+
31
+ private renderMainLine(width: number): string {
32
+ const left = this.getLeft();
33
+ const right = this.getRight();
34
+ return new SplitLine(left, right, {
35
+ primarySide: "right",
36
+ ellipsis: this.theme.fg("dim", "…"),
37
+ }).render(width)[0];
38
+ }
39
+
40
+ private getLeft(): string {
41
+ let text = formatCwd(this.ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
42
+
43
+ const branch = this.footerData.getGitBranch();
44
+ if (branch) text = `${text} [${branch}]`;
45
+
46
+ const sessionName = this.ctx.sessionManager.getSessionName();
47
+ if (sessionName) text = `${text} • ${sessionName}`;
48
+
49
+ return this.theme.fg("dim", text);
50
+ }
51
+
52
+ private getRight(): string {
53
+ const styledCostText = this.getStyledCostText();
54
+ const styledContextUsageText = this.getStyledContextUsageText();
55
+
56
+ return `${styledCostText}${this.theme.fg("dim", " • ")}${styledContextUsageText}`;
57
+ }
58
+
59
+ private getStyledCostText(): string {
60
+ const cost = this.ctx.sessionManager.getBranch().reduce((acc, entry) => {
61
+ const entryUsage = getEntryUsage(this.ctx, entry);
62
+ if (entryUsage) acc[entryUsage.type] += entryUsage.usage.cost.total;
63
+ return acc;
64
+ }, { subscription: 0, paid: 0 });
65
+
66
+ const isSubscription = this.ctx.model ? this.ctx.modelRegistry.isUsingOAuth(this.ctx.model) : false;
67
+ const subscriptionCostText = isSubscription || cost.subscription > 0 ? formatCost(cost.subscription, true) : undefined;
68
+ const paidCostText = !isSubscription || cost.paid > 0 ? formatCost(cost.paid, false) : undefined;
69
+ const costText = [subscriptionCostText, paidCostText].filter(Boolean).join(" + ");
70
+
71
+ const totalCost = cost.subscription + cost.paid;
72
+ if (totalCost > 20) return this.theme.fg("warning", costText);
73
+
74
+ return this.theme.fg("dim", costText);
75
+ }
76
+
77
+ private getStyledContextUsageText(): string {
78
+ const contextUsage = this.ctx.getContextUsage();
79
+ const contextUsageText = formatContextUsage(contextUsage);
80
+ const percent = contextUsage?.percent ?? null;
81
+
82
+ if (percent && percent > 90) return this.theme.fg("error", contextUsageText);
83
+ if (percent && percent > 70) return this.theme.fg("warning", contextUsageText);
84
+ return this.theme.fg("dim", contextUsageText);
85
+ }
86
+
87
+ /** Add extension statues on a single line, sorted by key alphabetically. */
88
+ private renderStatusLine(width: number): string {
89
+ const extensionStatuses = this.footerData.getExtensionStatuses();
90
+ if (extensionStatuses.size === 0) return "";
91
+
92
+ const statusLine = Array.from(extensionStatuses.entries())
93
+ .sort(([a], [b]) => a.localeCompare(b))
94
+ .map(([, text]) => sanitizeText(text))
95
+ .join(this.theme.fg("dim", " • "));
96
+
97
+ return truncateToWidth(statusLine, width, this.theme.fg("dim", "..."));
98
+ }
99
+ }
100
+
101
+ function formatCwd(cwd: string, home?: string): string {
102
+ if (!home) return cwd;
103
+
104
+ const resolvedCwd = resolve(cwd);
105
+ const resolvedHome = resolve(home);
106
+ const relativeToHome = relative(resolvedHome, resolvedCwd);
107
+ const isInsideHome = relativeToHome === "" || (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
108
+ if (!isInsideHome) return cwd;
109
+
110
+ return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
111
+ }
112
+
113
+ export default function (pi: ExtensionAPI) {
114
+ pi.on("session_start", (_event, ctx) => {
115
+ if (!ctx.hasUI) return;
116
+
117
+ const config = loadConfig(ctx, "footer");
118
+ if (!config) return;
119
+
120
+ ctx.ui.setFooter((_tui, theme, footerData) => new FooterComponent(ctx, theme, footerData));
121
+ });
122
+ }
@@ -0,0 +1,3 @@
1
+ import * as z from "zod";
2
+
3
+ export const fullscreenConfigSchema = z.object({});
@@ -0,0 +1,36 @@
1
+ import type { Component, TUI } from "@earendil-works/pi-tui";
2
+
3
+ /**
4
+ * Fills the gap above the editor so the editor and footer stay pinned to the
5
+ * bottom of the terminal when the session content is shorter than one screen.
6
+ */
7
+ export class BottomFiller implements Component {
8
+ private tui: TUI;
9
+ private measuring = false;
10
+
11
+ constructor(tui: TUI) {
12
+ this.tui = tui;
13
+ }
14
+
15
+ invalidate(): void {
16
+ // No-op
17
+ }
18
+
19
+ render(width: number): string[] {
20
+ // The TUI renders this component as part of its child tree, so measuring the
21
+ // siblings re-enters this render. Guard against the recursion and count our
22
+ // own contribution as zero lines while measuring.
23
+ if (this.measuring) return [];
24
+ this.measuring = true;
25
+
26
+ const rows = this.tui.terminal.rows;
27
+ let others = 0;
28
+ for (const child of this.tui.children) {
29
+ others += child.render(width).length;
30
+ if (others >= rows) break; // content already fills the screen
31
+ }
32
+
33
+ this.measuring = false;
34
+ return new Array(Math.max(0, rows - others)).fill("");
35
+ }
36
+ }
@@ -0,0 +1,47 @@
1
+ import { BottomFiller } from "./filler";
2
+ import { loadConfig } from "../shared/config";
3
+
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { TUI } from "@earendil-works/pi-tui";
6
+
7
+ const WIDGET_KEY = "fullscreen";
8
+
9
+ export default function (pi: ExtensionAPI) {
10
+ let tui: TUI | undefined;
11
+ let pendingClear = false;
12
+
13
+ /**
14
+ * Mount a persistent filler above the editor. The widget factory receives the TUI handle
15
+ * (otherwise unavailable via `ctx.ui`), so it both captures the TUI for clearing and pins the
16
+ * editor and footer to the bottom of the screen.
17
+ */
18
+ function mountFiller(ctx: ExtensionContext): void {
19
+ ctx.ui.setWidget(WIDGET_KEY, (capturedTui) => {
20
+ tui = capturedTui;
21
+
22
+ if (pendingClear) {
23
+ pendingClear = false;
24
+ queueMicrotask(() => capturedTui.requestRender(true));
25
+ }
26
+
27
+ return new BottomFiller(capturedTui);
28
+ });
29
+ }
30
+
31
+ pi.on("session_start", (_event, ctx) => {
32
+ if (!ctx.hasUI) return;
33
+
34
+ const config = loadConfig(ctx, "fullscreen");
35
+ if (!config) return;
36
+
37
+ if (tui) {
38
+ // Force a full repaint, which resets the TUI's diff state and clears the screen and
39
+ // scrollback (`\x1b[2J\x1b[H\x1b[3J`), just like the built-in `clearOnShrink` behavior.
40
+ tui.requestRender(true);
41
+ return;
42
+ }
43
+
44
+ pendingClear = true;
45
+ mountFiller(ctx);
46
+ });
47
+ }
@@ -0,0 +1,8 @@
1
+ import * as z from "zod";
2
+
3
+ import { modelSchema } from "../shared/config/model";
4
+
5
+ export const presetsConfigSchema = z.record(z.string().min(1), modelSchema);
6
+
7
+ export type PresetsConfig = z.infer<typeof presetsConfigSchema>;
8
+ export type PresetConfig = PresetsConfig[string];
@@ -0,0 +1,72 @@
1
+ import { Key } from "@earendil-works/pi-tui";
2
+
3
+ import { PresetManager } from "./manager";
4
+ import { showPresetSelector } from "./selector";
5
+ import { loadConfig } from "../shared/config";
6
+
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+
9
+ export default function (pi: ExtensionAPI) {
10
+ let presetManager: PresetManager | undefined = undefined;
11
+
12
+ pi.on("session_start", (_event, ctx) => {
13
+ const config = loadConfig(ctx, "presets");
14
+ if (!config || Object.keys(config).length === 0) return;
15
+
16
+ presetManager = new PresetManager(pi, config);
17
+ presetManager.sync(ctx);
18
+
19
+ pi.registerCommand("preset", {
20
+ description: "Switch model preset",
21
+ getArgumentCompletions: (prefix: string) => {
22
+ if (!presetManager) return null;
23
+
24
+ const items = presetManager.keys
25
+ .filter((key) => key.startsWith(prefix))
26
+ .map((key) => ({ value: key, label: key, description: presetManager!.describe(key) }));
27
+
28
+ return items.length > 0 ? items : null;
29
+ },
30
+ handler: async (args, ctx) => {
31
+ if (!presetManager) return;
32
+
33
+ const key = args.trim();
34
+ if (key) {
35
+ await presetManager.apply(key, ctx);
36
+ return;
37
+ }
38
+
39
+ const selected = await showPresetSelector(ctx, presetManager);
40
+ if (selected) {
41
+ await presetManager.apply(selected, ctx);
42
+ }
43
+ },
44
+ });
45
+ });
46
+
47
+ pi.on("model_select", (_event, ctx) => {
48
+ presetManager?.sync(ctx);
49
+ });
50
+
51
+ pi.on("thinking_level_select", (_event, ctx) => {
52
+ presetManager?.sync(ctx);
53
+ });
54
+
55
+ pi.registerShortcut(Key.ctrlSuper("p"), {
56
+ description: "Cycle model preset forward",
57
+ handler: async (ctx) => {
58
+ await presetManager?.cycle(ctx, "forward");
59
+ },
60
+ });
61
+
62
+ pi.registerShortcut(Key.ctrlShiftSuper("p"), {
63
+ description: "Cycle model preset backward",
64
+ handler: async (ctx) => {
65
+ await presetManager?.cycle(ctx, "backward");
66
+ },
67
+ });
68
+
69
+ pi.on("session_shutdown", () => {
70
+ presetManager = undefined;
71
+ });
72
+ }