pi-spark 0.2.1 → 0.3.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 +6 -21
- package/extensions/footer/index.ts +7 -15
- package/extensions/fullscreen/index.ts +77 -19
- package/extensions/shared/format/index.ts +28 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@ A small, opinionated collection of [pi](https://pi.dev/) extensions.
|
|
|
6
6
|
|
|
7
7
|
## Extensions
|
|
8
8
|
|
|
9
|
-
- **Editor
|
|
10
|
-
- **Footer
|
|
11
|
-
- **Fullscreen
|
|
12
|
-
- **Presets
|
|
13
|
-
- **Recap
|
|
9
|
+
- **Editor:** replaces the default editor with a compact working indicator (inspired by [Amp](https://ampcode.com/)) and current model info.
|
|
10
|
+
- **Footer:** shows session information, extension statuses, cost, and context usage on one line.
|
|
11
|
+
- **Fullscreen:** clears the screen and scrollback on session start, pins the editor and footer to the bottom for a full-screen session, and clears again on exit.
|
|
12
|
+
- **Presets:** switches named model presets with `/preset`, `--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
14
|
|
|
15
15
|

|
|
16
16
|
|
|
@@ -65,23 +65,8 @@ Notes:
|
|
|
65
65
|
|
|
66
66
|
- Set an extension key to `false` to disable it.
|
|
67
67
|
- The `editor.spinner` value can be `dots`, `lights`, or `tildes`.
|
|
68
|
+
- Fullscreen enables Pi's `clearOnShrink` behavior programmatically so the pinned editor and footer stay aligned after taller UI components close.
|
|
68
69
|
- Presets can be selected with `/preset` or `/preset <key>`.
|
|
69
70
|
- Start pi with a preset using `pi --preset <key>`.
|
|
70
71
|
- Cycle presets with `ctrl+super+p` and `ctrl+shift+super+p` (`super` is `command` on macOS).
|
|
71
72
|
- The `recap.idle` value is in milliseconds and must be at least `5000`.
|
|
72
|
-
|
|
73
|
-
## Recommended pi settings
|
|
74
|
-
|
|
75
|
-
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.
|
|
76
|
-
|
|
77
|
-
Add this to `~/.pi/agent/settings.json` (global) or `.pi/settings.json` (project):
|
|
78
|
-
|
|
79
|
-
```json
|
|
80
|
-
{
|
|
81
|
-
"terminal": {
|
|
82
|
-
"clearOnShrink": true
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
This setting defaults to `false` because it can cause flicker in some terminals. With Fullscreen enabled the trade-off is usually worth it.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
2
4
|
|
|
3
5
|
import { SplitLine } from "../shared/components/split-line";
|
|
4
6
|
import { loadConfig } from "../shared/config";
|
|
5
|
-
import { formatContextUsage, formatCost, sanitizeText } from "../shared/format";
|
|
7
|
+
import { formatContextUsage, formatCost, formatCwd, linkText, sanitizeText } from "../shared/format";
|
|
6
8
|
import { getEntryUsage } from "../shared/usage";
|
|
7
9
|
|
|
8
10
|
import type { ExtensionContext, ExtensionAPI, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
@@ -31,7 +33,9 @@ class FooterComponent implements Component {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
private getLeft(): string {
|
|
34
|
-
|
|
36
|
+
const cwd = this.ctx.sessionManager.getCwd();
|
|
37
|
+
const url = pathToFileURL(resolve(cwd));
|
|
38
|
+
let text = linkText(formatCwd(cwd, homedir()), url.href);
|
|
35
39
|
|
|
36
40
|
const branch = this.footerData.getGitBranch();
|
|
37
41
|
if (branch) text = `${text} [${branch}]`;
|
|
@@ -90,18 +94,6 @@ class FooterComponent implements Component {
|
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
function formatCwd(cwd: string, home?: string): string {
|
|
94
|
-
if (!home) return cwd;
|
|
95
|
-
|
|
96
|
-
const resolvedCwd = resolve(cwd);
|
|
97
|
-
const resolvedHome = resolve(home);
|
|
98
|
-
const relativeToHome = relative(resolvedHome, resolvedCwd);
|
|
99
|
-
const isInsideHome = relativeToHome === "" || (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
|
|
100
|
-
if (!isInsideHome) return cwd;
|
|
101
|
-
|
|
102
|
-
return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
97
|
export default function (pi: ExtensionAPI) {
|
|
106
98
|
pi.on("session_start", (_event, ctx) => {
|
|
107
99
|
if (!ctx.hasUI) return;
|
|
@@ -1,24 +1,71 @@
|
|
|
1
|
+
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
1
3
|
import { BottomFiller } from "./filler";
|
|
2
4
|
import { loadConfig } from "../shared/config";
|
|
5
|
+
import { sanitizeText } from "../shared/format";
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
9
|
|
|
4
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
10
|
import type { TUI } from "@earendil-works/pi-tui";
|
|
11
|
+
import type { UserMessage } from "@earendil-works/pi-ai";
|
|
6
12
|
|
|
7
13
|
const WIDGET_KEY = "fullscreen";
|
|
8
14
|
|
|
15
|
+
function getSessionDisplayText(ctx: ExtensionContext, theme: Theme): string | undefined {
|
|
16
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
17
|
+
if (sessionName) return theme.fg("warning", sanitizeText(sessionName));
|
|
18
|
+
|
|
19
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
20
|
+
if (entry.type !== "message" || entry.message.role !== "user") continue;
|
|
21
|
+
|
|
22
|
+
const text = extractText(entry.message);
|
|
23
|
+
if (text) return sanitizeText(text);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function extractText(message: UserMessage): string {
|
|
30
|
+
const content = message.content;
|
|
31
|
+
if (typeof content === "string") return content;
|
|
32
|
+
|
|
33
|
+
return content.filter((block) => block.type === "text").map((block) => block.text).join(" ");
|
|
34
|
+
}
|
|
35
|
+
|
|
9
36
|
export default function (pi: ExtensionAPI) {
|
|
10
37
|
let tui: TUI | undefined;
|
|
38
|
+
let enabled = false;
|
|
11
39
|
let pendingClear = false;
|
|
40
|
+
let previousClearOnShrink: boolean | undefined;
|
|
12
41
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
42
|
+
pi.on("session_start", (_event, ctx) => {
|
|
43
|
+
if (!ctx.hasUI) return;
|
|
44
|
+
|
|
45
|
+
const config = loadConfig(ctx, "fullscreen");
|
|
46
|
+
if (!config) return;
|
|
47
|
+
|
|
48
|
+
enabled = true;
|
|
49
|
+
pendingClear = true;
|
|
50
|
+
|
|
51
|
+
// The TUI handle isn't exposed via `ctx.ui`; the widget factory is the only place that
|
|
52
|
+
// receives it. Mounting a persistent filler above the editor both captures the TUI and
|
|
53
|
+
// pins the editor and footer to the bottom of the screen.
|
|
19
54
|
ctx.ui.setWidget(WIDGET_KEY, (capturedTui) => {
|
|
20
55
|
tui = capturedTui;
|
|
21
56
|
|
|
57
|
+
// Enable `clearOnShrink`, but defer it to a macrotask. On `/reload`, pi resets
|
|
58
|
+
// `clearOnShrink` to the settings value right after `session_start`, inside the reload's
|
|
59
|
+
// microtask continuation. A `setTimeout` callback runs only after that microtask queue
|
|
60
|
+
// drains, so it lands last and sticks. `queueMicrotask` (or a synchronous call) would run
|
|
61
|
+
// before the reset and get clobbered.
|
|
62
|
+
previousClearOnShrink ??= capturedTui.getClearOnShrink();
|
|
63
|
+
setTimeout(() => capturedTui.setClearOnShrink(true));
|
|
64
|
+
|
|
65
|
+
// Clear the screen once on entry. `session_start` has no TUI to repaint with, and a
|
|
66
|
+
// synchronous repaint here is too early (the filler isn't in the render tree until
|
|
67
|
+
// `setWidget` returns), so defer to a microtask. `pendingClear` limits this to the
|
|
68
|
+
// `session_start`-triggered mount rather than every widget rebuild.
|
|
22
69
|
if (pendingClear) {
|
|
23
70
|
pendingClear = false;
|
|
24
71
|
queueMicrotask(() => capturedTui.requestRender(true));
|
|
@@ -26,22 +73,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
26
73
|
|
|
27
74
|
return new BottomFiller(capturedTui);
|
|
28
75
|
});
|
|
29
|
-
}
|
|
76
|
+
});
|
|
30
77
|
|
|
31
|
-
pi.on("
|
|
32
|
-
if (!
|
|
78
|
+
pi.on("session_shutdown", (event, ctx) => {
|
|
79
|
+
if (!enabled) return;
|
|
33
80
|
|
|
34
|
-
|
|
35
|
-
if (
|
|
81
|
+
// Restore `clearOnShrink` to its pre-fullscreen value.
|
|
82
|
+
if (previousClearOnShrink !== undefined) {
|
|
83
|
+
tui?.setClearOnShrink(previousClearOnShrink);
|
|
84
|
+
previousClearOnShrink = undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (event.reason === "quit" && tui) {
|
|
88
|
+
// On normal interactive quit, shutdown handlers run after pi stops the TUI, so
|
|
89
|
+
// `requestRender(true)` can no longer repaint. Write the clear sequence directly: clear
|
|
90
|
+
// screen, move home, then clear scrollback.
|
|
91
|
+
tui.terminal.write("\x1b[2J\x1b[H\x1b[3J");
|
|
36
92
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
93
|
+
// Leave one concise line after the cleared session.
|
|
94
|
+
const theme = ctx.ui.theme;
|
|
95
|
+
const exitMessage = `${theme.bold(theme.fg("accent", "pi"))} ${theme.fg("dim", `v${VERSION} exited`)}`;
|
|
96
|
+
const sessionText = getSessionDisplayText(ctx, theme);
|
|
97
|
+
const line = truncateToWidth(`${exitMessage}${sessionText ? `${theme.fg("dim", ":")} ${sessionText}` : ""}`, tui.terminal.columns, "…");
|
|
98
|
+
tui.terminal.write(`${line}\r\n`);
|
|
42
99
|
}
|
|
43
100
|
|
|
44
|
-
|
|
45
|
-
|
|
101
|
+
tui = undefined;
|
|
102
|
+
enabled = false;
|
|
103
|
+
pendingClear = false;
|
|
46
104
|
});
|
|
47
105
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
|
|
1
3
|
import type { Provider } from "@earendil-works/pi-ai";
|
|
2
4
|
import type { ContextUsage } from "@earendil-works/pi-coding-agent";
|
|
3
5
|
|
|
@@ -26,6 +28,32 @@ export function formatCost(cost: number, isSubscription: boolean): string {
|
|
|
26
28
|
return `$${cost.toFixed(2)}${isSubscription ? " (sub)" : ""}`;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
export function formatCwd(cwd: string, home: string): string {
|
|
32
|
+
const resolvedCwd = resolve(cwd);
|
|
33
|
+
const resolvedHome = resolve(home);
|
|
34
|
+
const relativeToHome = relative(resolvedHome, resolvedCwd);
|
|
35
|
+
const isInsideHome = relativeToHome === "" || (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
|
|
36
|
+
const displayCwd = isInsideHome ? (relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`) : resolvedCwd;
|
|
37
|
+
|
|
38
|
+
return shortenPath(displayCwd);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Shorten every path component except the first and last to one character, like fish `prompt_pwd`. */
|
|
42
|
+
function shortenPath(path: string): string {
|
|
43
|
+
const parts = path.split(sep);
|
|
44
|
+
const lastIndex = parts.length - 1;
|
|
45
|
+
|
|
46
|
+
return parts.map((part, index) => {
|
|
47
|
+
if (!part || index === 0 || index === lastIndex) return part;
|
|
48
|
+
return Array.from(part)[0] ?? part;
|
|
49
|
+
}).join(sep);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Wrap text in an OSC 8 hyperlink while preserving the visible text. */
|
|
53
|
+
export function linkText(text: string, url: string): string {
|
|
54
|
+
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
55
|
+
}
|
|
56
|
+
|
|
29
57
|
/** Replace newlines, tabs, carriage returns with space, then collapse multiple spaces */
|
|
30
58
|
export function sanitizeText(text: string): string {
|
|
31
59
|
return text
|