@signalridge/pi-welcome 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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Moved the Pi startup card into the independently publishable Signalridge package.
6
+ - Keep Pi's native resource listing enabled; the card no longer attempts to rebuild the private resource loader.
7
+ - Refined the card with original Signalridge Ridgeline branding and a width-safe compact layout.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 signalridge
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,17 @@
1
+ # pi-welcome
2
+
3
+ TUI-only Signalridge startup card showing Pi's current repository, model, budget, and tools. It uses the active Pi theme, keeps the native resource listing intact, and persists a bounded custom entry. The rounded card collapses to a width-safe compact summary on narrow terminals.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@signalridge/pi-welcome
9
+ ```
10
+
11
+ ## Use from this checkout
12
+
13
+ From the repository root:
14
+
15
+ ```bash
16
+ pi -e ./packages/pi-welcome
17
+ ```
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@signalridge/pi-welcome",
3
+ "version": "0.1.0",
4
+ "description": "Startup resource and repository summary card for Pi.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "piExtension": {
9
+ "lifecycle": "stable"
10
+ },
11
+ "peerDependencies": {
12
+ "@earendil-works/pi-coding-agent": "*",
13
+ "@earendil-works/pi-tui": "*"
14
+ },
15
+ "pi": {
16
+ "extensions": [
17
+ "./src/index.ts"
18
+ ]
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/signalridge/pi-extensions",
23
+ "directory": "packages/pi-welcome"
24
+ },
25
+ "homepage": "https://github.com/signalridge/pi-extensions/tree/main/packages/pi-welcome",
26
+ "bugs": {
27
+ "url": "https://github.com/signalridge/pi-extensions/issues"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "files": [
33
+ "README.md",
34
+ "LICENSE",
35
+ "CHANGELOG.md",
36
+ "src"
37
+ ],
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit",
40
+ "lint": "biome check .",
41
+ "test": "bun test test",
42
+ "check": "bun run lint && bun run typecheck && bun run test",
43
+ "format": "biome check --write ."
44
+ }
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Signalridge startup card for Pi.
3
+ *
4
+ * The card is a compact, rounded summary of the current workspace. It uses the
5
+ * active Pi theme and semantic labels instead of a second palette or a bundled
6
+ * font, so it stays legible in both dark and light terminals.
7
+ *
8
+ * It adds to Pi's native startup rather than replacing it. `quietStartup` must
9
+ * stay false: Pi owns the key hints and loaded-resource listing, while this
10
+ * card carries only workspace facts that are useful at a glance.
11
+ *
12
+ * The card is a custom entry, not a widget: it lands in the transcript, scrolls
13
+ * away as the conversation grows, and survives a reload. Facts are captured at
14
+ * session_start and stored in the entry, so a resumed session redraws the card
15
+ * as it was when that session began.
16
+ */
17
+
18
+ import { execFileSync } from "node:child_process";
19
+ import { readFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ import {
24
+ type ExtensionAPI,
25
+ type ExtensionContext,
26
+ getAgentDir,
27
+ type Theme,
28
+ VERSION,
29
+ } from "@earendil-works/pi-coding-agent";
30
+ import { type Component, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
31
+
32
+ const ENTRY_TYPE = "welcome-card";
33
+
34
+ /** A small original ridgeline mark; no private-use or font-specific glyphs. */
35
+ const LOGO = ["╭─╮", "╰─╯"] as const;
36
+ const BRAND = "SIGNALRIDGE / PI";
37
+ const SUBTITLE = "A focused coding workspace";
38
+
39
+ /** Columns of padding inside the card. */
40
+ const PAD = 2;
41
+
42
+ /** Below this width, use a compact no-border layout rather than overflow. */
43
+ const MIN_WIDTH = 20;
44
+
45
+ /** Sentinel row rendered as a blank line, separating the card's two groups. */
46
+ const GAP: [string, string] = ["", ""];
47
+
48
+ interface WelcomeData {
49
+ rows: [string, string][];
50
+ }
51
+
52
+ // ─── Fact Collection ───────────────────────────────────────────────────────────
53
+
54
+ function tildify(path: string): string {
55
+ const home = homedir();
56
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
57
+ }
58
+
59
+ /** `main [+392 -202]`, or undefined outside a repo. Best-effort and never throws. */
60
+ function gitSummary(cwd: string): string | undefined {
61
+ const git = (args: string[]): string | undefined => {
62
+ try {
63
+ return execFileSync("git", args, {
64
+ cwd,
65
+ encoding: "utf8",
66
+ timeout: 1000,
67
+ stdio: ["ignore", "pipe", "ignore"],
68
+ }).trim();
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ };
73
+
74
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
75
+ if (!branch) return undefined;
76
+
77
+ // --numstat over HEAD covers staged + unstaged; untracked files are excluded
78
+ // on purpose, matching what `git diff` itself reports.
79
+ const numstat = git(["diff", "HEAD", "--numstat"]);
80
+ if (!numstat) return branch;
81
+
82
+ let added = 0;
83
+ let removed = 0;
84
+ for (const line of numstat.split("\n")) {
85
+ const [a, r] = line.split("\t");
86
+ // Binary files report "-" for both counts.
87
+ added += Number.parseInt(a ?? "", 10) || 0;
88
+ removed += Number.parseInt(r ?? "", 10) || 0;
89
+ }
90
+ return added || removed ? `${branch} [+${added} -${removed}]` : branch;
91
+ }
92
+
93
+ /**
94
+ * `compaction.reserveTokens` from settings.json. ExtensionContext does not carry
95
+ * settings, so this reads the same file pi does — the trigger point is worth the
96
+ * one stat() because it, not the raw window, is what a long session runs into.
97
+ */
98
+ function readReserveTokens(): number | undefined {
99
+ try {
100
+ const raw = readFileSync(join(getAgentDir(), "settings.json"), "utf8");
101
+ const value = JSON.parse(raw)?.compaction?.reserveTokens;
102
+ return typeof value === "number" ? value : undefined;
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ function formatTokens(n: number): string {
109
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
110
+ if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
111
+ return String(n);
112
+ }
113
+
114
+ function collect(pi: ExtensionAPI, ctx: ExtensionContext): WelcomeData {
115
+ const rows: [string, string][] = [];
116
+ const push = (label: string, value: string | undefined) => {
117
+ if (value) rows.push([label, value]);
118
+ };
119
+
120
+ const trusted = (() => {
121
+ try {
122
+ return ctx.isProjectTrusted() ? "" : " (untrusted)";
123
+ } catch {
124
+ return "";
125
+ }
126
+ })();
127
+ push("Directory", tildify(ctx.cwd) + trusted);
128
+ push("Branch", gitSummary(ctx.cwd));
129
+
130
+ // Only a NAME is worth a row. The raw session id is a timestamp glued to a
131
+ // uuid; it is unreadable, identical in shape every time, and pushes the
132
+ // Keep the fresh-session label short; raw session ids add noise without helping orientation.
133
+ push("Session", pi.getSessionName?.() || "(new)");
134
+
135
+ const model = ctx.model;
136
+ if (model) {
137
+ const id = model.id;
138
+ const provider = model.provider;
139
+ const thinking = ctx.thinkingLevel ? ` · thinking ${ctx.thinkingLevel}` : "";
140
+ push("Model", `${provider ? `${provider} / ` : ""}${id}${thinking}`);
141
+
142
+ const window = model.contextWindow;
143
+ if (typeof window === "number" && window > 0) {
144
+ // Pi compacts at contextWindow - reserveTokens; surfacing the trigger up
145
+ // front is the one number that actually governs a long session.
146
+ const reserve = readReserveTokens();
147
+ const trigger =
148
+ typeof reserve === "number" && reserve > 0 && reserve < window
149
+ ? ` · compacts at ${formatTokens(window - reserve)}`
150
+ : "";
151
+ push("Budget", `${formatTokens(window)}${trigger}`);
152
+ }
153
+ }
154
+
155
+ try {
156
+ const all = pi.getAllTools?.() ?? [];
157
+ const active = pi.getActiveTools?.() ?? [];
158
+ if (all.length) {
159
+ push("Tools", `${active.length} active of ${all.length}`);
160
+ }
161
+ } catch {}
162
+
163
+ push("Version", VERSION);
164
+ return { rows };
165
+ }
166
+
167
+ /**
168
+ * Pi 0.84.1 does not expose the already-active resource loader through the
169
+ * public ExtensionContext/ExtensionAPI types. Do not construct a second
170
+ * DefaultResourceLoader here: reload() executes arbitrary extension factories
171
+ * and can duplicate tools, listeners, timers, and external processes. Pi owns
172
+ * the startup resource listing, so this extension intentionally omits it until
173
+ * a public non-executing API exists.
174
+ */
175
+ export function collectResources(_cwd: string): Promise<[string, string][]> {
176
+ return Promise.resolve([]);
177
+ }
178
+ // ─── Card Component ────────────────────────────────────────────────────────────
179
+
180
+ export class WelcomeCard implements Component {
181
+ constructor(
182
+ private readonly data: WelcomeData,
183
+ private readonly theme: Theme,
184
+ ) {}
185
+
186
+ /** Pad or truncate a (possibly styled) string to exactly `width` columns. */
187
+ private fit(text: string, width: number): string {
188
+ const safeWidth = Math.max(0, width);
189
+ const renderedWidth = visibleWidth(text);
190
+ if (renderedWidth > safeWidth) return truncateToWidth(text, safeWidth, "");
191
+ return text + " ".repeat(safeWidth - renderedWidth);
192
+ }
193
+
194
+ private renderCompact(width: number): string[] {
195
+ const t = this.theme;
196
+ const lines = [
197
+ t.bold(t.fg("accent", "sr / pi")),
198
+ t.fg("muted", "ready"),
199
+ ...this.data.rows.map(([label, value]) =>
200
+ label === GAP[0] && value === GAP[1] ? "" : `${t.fg("dim", `${label}:`)} ${t.fg("text", value)}`,
201
+ ),
202
+ ];
203
+ return lines.map((line) => truncateToWidth(line, Math.max(1, width), ""));
204
+ }
205
+
206
+ render(width: number): string[] {
207
+ const safeWidth = Math.max(1, width);
208
+ if (safeWidth < MIN_WIDTH) return this.renderCompact(safeWidth);
209
+
210
+ const t = this.theme;
211
+ const border = (text: string) => t.fg("border", text);
212
+ const inner = safeWidth - 2;
213
+ const line = (content: string) => border("│") + this.fit(content, inner) + border("│");
214
+ const blank = () => line("");
215
+ const pad = " ".repeat(PAD);
216
+ const body: string[] = [blank()];
217
+ const logoWidth = Math.max(...LOGO.map(visibleWidth));
218
+
219
+ body.push(
220
+ line(pad + t.fg("accent", LOGO[0].padEnd(logoWidth)) + pad + t.bold(t.fg("text", BRAND))),
221
+ line(pad + t.fg("accent", LOGO[1].padEnd(logoWidth)) + pad + t.fg("muted", SUBTITLE)),
222
+ blank(),
223
+ );
224
+
225
+ const labelWidth = this.data.rows.length
226
+ ? Math.max(...this.data.rows.map(([label]) => visibleWidth(label))) + 2
227
+ : 0;
228
+ for (const [label, value] of this.data.rows) {
229
+ if (label === GAP[0] && value === GAP[1]) {
230
+ body.push(blank());
231
+ continue;
232
+ }
233
+
234
+ const gutter = pad + `${label}:`.padEnd(labelWidth);
235
+ const room = Math.max(1, inner - visibleWidth(gutter) - PAD);
236
+ const [head, ...tail] = wrapTextWithAnsi(value, room);
237
+ body.push(line(t.fg("dim", gutter) + t.fg("text", head ?? "")));
238
+ for (const continuation of tail) {
239
+ body.push(line(" ".repeat(visibleWidth(gutter)) + t.fg("text", continuation)));
240
+ }
241
+ }
242
+
243
+ body.push(blank());
244
+ const rule = (left: string, right: string) => border(left + "─".repeat(inner) + right);
245
+ return [rule("╭", "╮"), ...body, rule("╰", "╯")];
246
+ }
247
+
248
+ invalidate(): void {}
249
+ }
250
+
251
+ // ─── Extension Entry ───────────────────────────────────────────────────────────
252
+
253
+ export default function (pi: ExtensionAPI) {
254
+ pi.registerEntryRenderer<WelcomeData>(ENTRY_TYPE, (entry, _options, theme) => {
255
+ const data = entry.data;
256
+ if (!data?.rows?.length) return undefined;
257
+ return new WelcomeCard(data, theme);
258
+ });
259
+
260
+ pi.on("session_start", async (_event, ctx) => {
261
+ if (ctx.mode !== "tui") return;
262
+
263
+ // A resumed session already carries its card; appending on every
264
+ // session_start would stack a fresh one on top at each reload.
265
+ try {
266
+ for (const entry of ctx.sessionManager.getEntries()) {
267
+ if (entry.type === "custom" && entry.customType === ENTRY_TYPE) {
268
+ return;
269
+ }
270
+ }
271
+ } catch {}
272
+
273
+ try {
274
+ const data = collect(pi, ctx);
275
+ const resources = await collectResources(ctx.cwd);
276
+ if (resources.length) {
277
+ // GAP is the sentinel for a blank row: session facts above, what pi
278
+ // loaded below, so the card reads as two groups rather than one wall.
279
+ data.rows.push(GAP, ...resources);
280
+ }
281
+ pi.appendEntry<WelcomeData>(ENTRY_TYPE, data);
282
+ } catch {
283
+ // A missing card is not worth failing a session start over.
284
+ }
285
+ });
286
+ }