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 CHANGED
@@ -6,11 +6,11 @@ A small, opinionated collection of [pi](https://pi.dev/) extensions.
6
6
 
7
7
  ## Extensions
8
8
 
9
- - **Editor**: replaces the default editor with a compact working indicator (inspired by [Amp](https://ampcode.com/)) 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`, `--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).
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
  ![Screenshot](./assets/screenshot.png)
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 { isAbsolute, relative, resolve, sep } from "node:path";
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
- let text = formatCwd(this.ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
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
- * 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 {
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("session_start", (_event, ctx) => {
32
- if (!ctx.hasUI) return;
78
+ pi.on("session_shutdown", (event, ctx) => {
79
+ if (!enabled) return;
33
80
 
34
- const config = loadConfig(ctx, "fullscreen");
35
- if (!config) return;
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
- 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;
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
- pendingClear = true;
45
- mountFiller(ctx);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "A small, opinionated collection of pi extensions",
5
5
  "keywords": [
6
6
  "pi-coding-agent",