@signalridge/pi-welcome 1.2.0 → 1.3.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 CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0
4
+ ### Minor Changes
5
+
6
+ - 8488246: Put the inventory back, and take the wordmark off.
7
+
8
+ With `quietStartup` on, Pi's `[Context]`/`[Skills]`/`[Prompts]`/`[Themes]`/
9
+ `[Extensions]` sections are gone and nothing brings them back — `/reload` re-runs
10
+ the same suppressed listing. The card had replaced all of that with three
11
+ counts, which is strictly less than what the session used to show. It now names
12
+ them instead, eight per row with a `+N` tail: context files, skills, prompts,
13
+ extensions, themes, and the tool count.
14
+
15
+ Skills and prompts now come from `getCommands()` rather than `loadSkills()`. An
16
+ extension may contribute skill paths through `resources_discover`, which is
17
+ where most of them come from in practice, and the standalone loader cannot see
18
+ those — measured on a real session it returned zero skills while Pi was showing
19
+ dozens. Extensions and themes are read from the same settings files and
20
+ directories Pi reads.
21
+
22
+ The ridgeline mark, the `SIGNALRIDGE / PI` wordmark, and the tagline are gone.
23
+ The card exists to say what this session is, and a brand line says nothing a
24
+ returning user does not already know; the key hints lead instead, since with
25
+ `quietStartup` on they appear nowhere else. The Pi version returns to a row of
26
+ its own.
27
+
3
28
  ## 1.0.0
4
29
  ### Major Changes
5
30
 
package/README.md CHANGED
@@ -1,31 +1,35 @@
1
1
  # pi-welcome
2
2
 
3
3
  TUI-only Signalridge startup card: one rounded panel with the current
4
- repository, model, context budget, key hints, and a count of what Pi loaded. It
4
+ repository, model, context budget, key hints, and the inventory of what Pi loaded. It
5
5
  uses the active Pi theme, persists a bounded custom entry so a resumed session
6
6
  redraws it, and collapses to a width-safe compact summary on narrow terminals.
7
7
 
8
8
  ```
9
- ╭──────────────────────────────────────────────────────────────────────╮
10
- │ │
11
- │ ╭─╮ SIGNALRIDGE / PI v0.84.1 │
12
- │ ╰─╯ A focused coding workspace │
13
- │ │
14
- │ Ctrl+C interrupt · / commands · ! bash · Ctrl+L clear │
15
- │ │
16
- │ Directory: ~/ghq/github.com/signalridge/pi-extensions │
17
- │ Branch: main [+392 -202] │
18
- │ Session: (new) │
19
- │ Model: openai-codex / gpt-5.6-luna · thinking max │
20
- │ Budget: 400K · compacts at 270K │
21
- │ │
22
- │ Context: AGENTS.md · CLAUDE.md │
23
- │ Loaded: 61 skills · 12 prompts · 9 commands from extensions │
24
- │ Tools: 18 active of 42 │
25
- │ │
26
- ╰──────────────────────────────────────────────────────────────────────╯
9
+ ╭─────────────────────────────────────────────────────────────────────────╮
10
+ │ │
11
+ │ Ctrl+C interrupt · / commands · ! bash · Ctrl+L clear │
12
+ │ │
13
+ │ Directory: ~/ghq/github.com/signalridge/pi-extensions │
14
+ │ Branch: main [+392 -202] │
15
+ │ Session: (new) │
16
+ │ Model: openai-codex / gpt-5.6-luna · thinking max │
17
+ │ Budget: 400K · compacts at 270K │
18
+ │ Version: 0.84.1 │
19
+ │ │
20
+ │ Context: AGENTS.md │
21
+ │ Skills: commit, dev-preferences, toolbelt, write-tui +12 │
22
+ │ Prompts: /plan, /review, /ship +4 │
23
+ │ Extensions: btw, code-actions, goal, gpt-fast, statusline +18 │
24
+ │ Themes: catppuccin-mocha, signalridge-ridgeline +2 │
25
+ │ Tools: 18 active of 42 │
26
+ │ │
27
+ ╰─────────────────────────────────────────────────────────────────────────╯
27
28
  ```
28
29
 
30
+ No logo and no wordmark: the card exists to say what this session is, and a
31
+ brand line says nothing a returning user does not already know.
32
+
29
33
  ## Turn on `quietStartup`
30
34
 
31
35
  The card is designed to be the whole opening screen. Pi's own startup is a
@@ -45,16 +49,24 @@ actionable is hidden.
45
49
  Leaving `quietStartup: false` is supported and changes nothing about the card;
46
50
  Pi simply prints its own block above it again.
47
51
 
48
- ## What the counts come from
52
+ ## Where the inventory comes from
53
+
54
+ With `quietStartup` on, Pi's `[Context]`/`[Skills]`/`[Prompts]`/`[Themes]`/
55
+ `[Extensions]` sections are gone and nothing brings them back — `/reload` re-runs
56
+ the same suppressed listing — so this card is the only place they appear. It
57
+ names them rather than counting them, capped at eight per row with a `+N` tail.
58
+
59
+ Skills and prompts come from `getCommands()`, **not** from `loadSkills()`. An
60
+ extension may contribute skill paths through `resources_discover`, which is
61
+ where most of them come from in practice, and the standalone loader cannot see
62
+ those: on a session showing dozens of skills it returns zero. `getCommands()`
63
+ reports what Pi actually registered.
49
64
 
50
- `Context`, `Loaded`, and `Tools` are counted through Pi's public API only:
51
- `loadProjectContextFiles` and `loadSkills` are pure filesystem reads, and
52
- `getCommands()` / `getAllTools()` report what is actually registered. Pi renders
53
- its full `[Skills]` / `[Prompts]` / `[Extensions]` listing from a resource
54
- loader it does not expose read-only, and the only way to obtain a second one is
55
- to construct it — whose `reload()` re-executes every extension factory and would
56
- duplicate tools, listeners, timers, and child processes. So the card reports
57
- counts it can derive honestly and leaves out what it cannot.
65
+ Extensions and themes are read from the same settings files and directories Pi
66
+ reads. Context files come from `loadProjectContextFiles`, and tool counts from
67
+ `getAllTools()` / `getActiveTools()`. Nothing here constructs a second resource
68
+ loader: its `reload()` re-executes every extension factory and would duplicate
69
+ tools, listeners, timers, and child processes.
58
70
 
59
71
  ## Untrusted text
60
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-welcome",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Startup resource and repository summary card for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import { execFileSync } from "node:child_process";
23
- import { readFileSync } from "node:fs";
23
+ import { readdirSync, readFileSync } from "node:fs";
24
24
  import { homedir } from "node:os";
25
25
  import { basename, join } from "node:path";
26
26
 
@@ -30,7 +30,6 @@ import {
30
30
  getAgentDir,
31
31
  keyText,
32
32
  loadProjectContextFiles,
33
- loadSkills,
34
33
  type Theme,
35
34
  VERSION,
36
35
  } from "@earendil-works/pi-coding-agent";
@@ -38,11 +37,6 @@ import { type Component, truncateToWidth, visibleWidth, wrapTextWithAnsi } from
38
37
 
39
38
  const ENTRY_TYPE = "welcome-card";
40
39
 
41
- /** A small original ridgeline mark; no private-use or font-specific glyphs. */
42
- const LOGO = ["╭─╮", "╰─╯"] as const;
43
- const BRAND = "SIGNALRIDGE / PI";
44
- const SUBTITLE = "A focused coding workspace";
45
-
46
40
  /** Columns of padding inside the card. */
47
41
  const PAD = 2;
48
42
 
@@ -52,8 +46,16 @@ const MIN_WIDTH = 20;
52
46
  /** Sentinel row rendered as a blank line, separating the card's groups. */
53
47
  const GAP: [string, string] = ["", ""];
54
48
 
55
- /** Most context-file names to spell out before collapsing the rest into a count. */
56
- const MAX_CONTEXT_NAMES = 3;
49
+ /**
50
+ * Names to spell out in a resource row before collapsing the rest into `+N`.
51
+ *
52
+ * Pi's own listing prints every name and lets it wrap; with `quietStartup` on
53
+ * that listing is gone and this card is the only place the inventory appears,
54
+ * so it errs toward showing names rather than a bare count. Eight is what fits
55
+ * on one line at a normal width without turning the card into the wall of text
56
+ * the merge was meant to remove.
57
+ */
58
+ const MAX_NAMES = 8;
57
59
 
58
60
  interface WelcomeData {
59
61
  rows: [string, string][];
@@ -128,8 +130,19 @@ function formatTokens(n: number): string {
128
130
  return String(n);
129
131
  }
130
132
 
131
- function plural(count: number, noun: string): string {
132
- return `${count} ${noun}${count === 1 ? "" : "s"}`;
133
+ /** `a, b, c +58` — the first few names, then how many were left out. */
134
+ function nameList(names: readonly string[], max = MAX_NAMES): string | undefined {
135
+ if (names.length === 0) return undefined;
136
+ const shown = names.slice(0, max).join(", ");
137
+ const rest = names.length - max;
138
+ return rest > 0 ? `${shown} +${rest}` : shown;
139
+ }
140
+
141
+ /** Sorted, de-duplicated, and free of empties — every row is built from this. */
142
+ function tidy(names: Iterable<string>): string[] {
143
+ return [...new Set([...names].map((name) => name.trim()).filter((name) => name.length > 0))].sort((a, b) =>
144
+ a.localeCompare(b),
145
+ );
133
146
  }
134
147
 
135
148
  /**
@@ -172,59 +185,94 @@ export function collectHints(): string[] {
172
185
  }
173
186
 
174
187
  /**
175
- * What pi loaded for this session, as counts rather than a listing.
188
+ * Extension names, as Pi's own `[Extensions]` section lists them.
176
189
  *
177
- * Pi renders the full `[Skills]`/`[Prompts]`/`[Extensions]` listing itself and
178
- * exposes no read-only API for it — the loader that holds it can only be
179
- * obtained by constructing a second one, whose `reload()` executes every
180
- * extension factory again and would duplicate tools, listeners, timers, and
181
- * child processes. So this counts through the public surfaces only:
182
- * `loadSkills` and `loadProjectContextFiles` are pure filesystem reads that
183
- * execute nothing, and `getCommands()` reports what is actually registered.
184
- * Anything not derivable that way is left off rather than guessed at.
190
+ * Read from the same two settings files Pi reads, plus the extension
191
+ * directories it scans. Pi labels a package-sourced extension by its package
192
+ * name and a file-sourced one by its path, so the scope and the `pi-` prefix
193
+ * come off to leave the part that identifies it.
194
+ */
195
+ function collectExtensionNames(cwd: string): string[] {
196
+ const names: string[] = [];
197
+ for (const settingsFile of [join(getAgentDir(), "settings.json"), join(cwd, ".pi", "settings.json")]) {
198
+ try {
199
+ const packages = JSON.parse(readFileSync(settingsFile, "utf8"))?.packages;
200
+ if (!Array.isArray(packages)) continue;
201
+ for (const entry of packages) {
202
+ const source = typeof entry === "string" ? entry : entry?.source;
203
+ if (typeof source !== "string") continue;
204
+ const spec = source.replace(/^npm:/u, "");
205
+ // `@scope/pi-name@1.2.3` and `./local/path` both reduce to a bare name.
206
+ const bare = (spec.startsWith("@") ? (spec.split("/")[1] ?? spec) : basename(spec)).replace(/@.*$/u, "");
207
+ names.push(bare.replace(/^pi-/u, ""));
208
+ }
209
+ } catch {}
210
+ }
211
+ for (const directory of [join(getAgentDir(), "extensions"), join(cwd, ".pi", "extensions")]) {
212
+ try {
213
+ for (const entry of readdirSync(directory)) {
214
+ names.push(entry.replace(/\.[cm]?[jt]s$/u, "").replace(/^pi-/u, ""));
215
+ }
216
+ } catch {}
217
+ }
218
+ return tidy(names);
219
+ }
220
+
221
+ /** Custom theme names, as Pi's own `[Themes]` section lists them. */
222
+ function collectThemeNames(): string[] {
223
+ try {
224
+ return tidy(
225
+ readdirSync(join(getAgentDir(), "themes"))
226
+ .filter((entry) => entry.endsWith(".json"))
227
+ .map((entry) => entry.replace(/\.json$/u, "")),
228
+ );
229
+ } catch {
230
+ return [];
231
+ }
232
+ }
233
+
234
+ /**
235
+ * What pi loaded for this session, by name — the inventory its own
236
+ * `[Context]`/`[Skills]`/`[Prompts]`/`[Themes]`/`[Extensions]` sections print.
237
+ *
238
+ * With `quietStartup` on those sections are gone and nothing brings them back
239
+ * (`/reload` re-runs the same suppressed listing), so this card is the only
240
+ * place the inventory appears and it names things rather than counting them.
241
+ *
242
+ * Skills and prompts come from `getCommands()`, not from `loadSkills()`: an
243
+ * extension may contribute skill paths through `resources_discover`, which is
244
+ * where most of them come from here, and the standalone loader cannot see those
245
+ * — it reports zero on a session showing dozens. `getCommands()` reports what
246
+ * Pi actually registered, which is the honest answer to "what loaded".
185
247
  */
186
248
  export function collectResources(pi: ExtensionAPI, cwd: string): [string, string][] {
187
249
  const rows: [string, string][] = [];
250
+ const push = (label: string, value: string | undefined) => {
251
+ if (value) rows.push([label, value]);
252
+ };
188
253
 
189
- const contextNames = (() => {
190
- try {
191
- const files = loadProjectContextFiles({ cwd, agentDir: getAgentDir() });
192
- // Several scopes can contribute the same filename (a project AGENTS.md
193
- // and a global one); the card is naming kinds of context, not paths.
194
- const names = [...new Set(files.map((file) => basename(file.path)))];
195
- if (names.length === 0) return undefined;
196
- const shown = names.slice(0, MAX_CONTEXT_NAMES).join(" · ");
197
- const rest = names.length - MAX_CONTEXT_NAMES;
198
- return rest > 0 ? `${shown} · +${rest}` : shown;
199
- } catch {
200
- return undefined;
201
- }
202
- })();
203
- if (contextNames) rows.push(["Context", contextNames]);
204
-
205
- const loaded: string[] = [];
206
254
  try {
207
- const { skills } = loadSkills({
208
- cwd,
209
- agentDir: getAgentDir(),
210
- skillPaths: [],
211
- includeDefaults: true,
212
- });
213
- if (skills.length > 0) loaded.push(plural(skills.length, "skill"));
255
+ const files = loadProjectContextFiles({ cwd, agentDir: getAgentDir() });
256
+ // Several scopes can contribute the same filename (a project AGENTS.md and
257
+ // a global one); the card names kinds of context, not paths.
258
+ push("Context", nameList(tidy(files.map((file) => basename(file.path)))));
214
259
  } catch {}
260
+
215
261
  try {
216
262
  const commands = pi.getCommands?.() ?? [];
217
- const prompts = commands.filter((command) => command.source === "prompt").length;
218
- const extensionCommands = commands.filter((command) => command.source === "extension").length;
219
- if (prompts > 0) loaded.push(plural(prompts, "prompt"));
220
- if (extensionCommands > 0) loaded.push(`${plural(extensionCommands, "command")} from extensions`);
263
+ const named = (source: string, prefix = "") =>
264
+ tidy(commands.filter((command) => command.source === source).map((command) => `${prefix}${command.name}`));
265
+ push("Skills", nameList(named("skill")));
266
+ push("Prompts", nameList(named("prompt", "/")));
221
267
  } catch {}
222
- if (loaded.length > 0) rows.push(["Loaded", loaded.join(" · ")]);
268
+
269
+ push("Extensions", nameList(collectExtensionNames(cwd)));
270
+ push("Themes", nameList(collectThemeNames()));
223
271
 
224
272
  try {
225
273
  const all = pi.getAllTools?.() ?? [];
226
274
  const active = pi.getActiveTools?.() ?? [];
227
- if (all.length) rows.push(["Tools", `${active.length} active of ${all.length}`]);
275
+ if (all.length) push("Tools", `${active.length} active of ${all.length}`);
228
276
  } catch {}
229
277
 
230
278
  return rows;
@@ -271,6 +319,8 @@ function collect(pi: ExtensionAPI, ctx: ExtensionContext): WelcomeData {
271
319
  }
272
320
  }
273
321
 
322
+ push("Version", VERSION);
323
+
274
324
  // GAP is the sentinel for a blank row: session facts above, what pi loaded
275
325
  // below, so the card reads as two groups rather than one wall.
276
326
  const resources = collectResources(pi, ctx.cwd);
@@ -356,7 +406,6 @@ export class WelcomeCard implements Component {
356
406
  private renderCompact(width: number): string[] {
357
407
  const t = this.theme;
358
408
  const lines = [
359
- t.bold(t.fg("accent", "sr / pi")),
360
409
  t.fg("muted", this.hints.length > 0 ? (this.hints[0] ?? "ready") : "ready"),
361
410
  ...this.rows.map(([label, value]) =>
362
411
  label === GAP[0] && value === GAP[1] ? "" : `${t.fg("dim", `${label}:`)} ${t.fg("text", value)}`,
@@ -376,30 +425,18 @@ export class WelcomeCard implements Component {
376
425
  const blank = () => line("");
377
426
  const pad = " ".repeat(PAD);
378
427
  const body: string[] = [blank()];
379
- const logoWidth = Math.max(...LOGO.map(visibleWidth));
380
-
381
- // The version rides the brand line rather than taking a row of its own: it
382
- // is the one fact nobody scans for, and the row it used to occupy sat at
383
- // the bottom where the eye lands last.
384
- const version = t.fg("dim", `v${VERSION}`);
385
- const brandRoom = Math.max(0, inner - PAD - logoWidth - PAD - PAD - visibleWidth(version));
386
- const brand = t.bold(t.fg("text", BRAND));
387
- const brandLine = brand + " ".repeat(Math.max(1, brandRoom - visibleWidth(BRAND) + 1)) + version;
388
-
389
- body.push(
390
- line(pad + t.fg("accent", LOGO[0].padEnd(logoWidth)) + pad + brandLine),
391
- line(pad + t.fg("accent", LOGO[1].padEnd(logoWidth)) + pad + t.fg("muted", SUBTITLE)),
392
- );
393
428
 
429
+ // No mark and no wordmark. The card exists to say what this session is, and
430
+ // a brand line says nothing a returning user does not already know. The
431
+ // hints lead instead: with `quietStartup` on this is the only place they
432
+ // appear, and they are what a new session actually needs.
394
433
  const hintLine = fitHints(this.hints, Math.max(1, inner - PAD * 2));
395
434
  if (hintLine) {
396
435
  // The whole line is dimmed rather than highlighting each key: this is
397
436
  // orientation, not a control the user is meant to look at.
398
- body.push(blank(), line(pad + t.fg("dim", hintLine)));
437
+ body.push(line(pad + t.fg("dim", hintLine)), blank());
399
438
  }
400
439
 
401
- body.push(blank());
402
-
403
440
  const labelWidth = this.rows.length ? Math.max(...this.rows.map(([label]) => visibleWidth(label))) + 2 : 0;
404
441
  for (const [label, value] of this.rows) {
405
442
  if (label === GAP[0] && value === GAP[1]) {