@signalridge/pi-welcome 1.2.0 → 1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.1
4
+ ### Patch Changes
5
+
6
+ - 2f22dea: Give the card its head back, with a mark that says which tool this is.
7
+
8
+ 1.3.0 removed the whole title block. Only the wordmark was meant to go: the
9
+ tagline went with it because it sat on the same two lines, which left the card
10
+ opening straight into a key-hint row with nothing naming the session's tool or
11
+ version.
12
+
13
+ The two rows are back, now `pi` and its version over the tagline, with a pi
14
+ letterform for a mark — one beam over two legs, drawn from Block Elements so
15
+ every glyph is exactly one column in every terminal font. It says the same thing
16
+ as the title beside it rather than being a texture that could belong to any
17
+ tool, which the previous rounded outline and the solid bar that briefly replaced
18
+ it both were. The separate `Version` row is gone, since the version now rides
19
+ the title it belongs to.
20
+
21
+ ## 1.3.0
22
+ ### Minor Changes
23
+
24
+ - 8488246: Put the inventory back, and take the wordmark off.
25
+
26
+ With `quietStartup` on, Pi's `[Context]`/`[Skills]`/`[Prompts]`/`[Themes]`/
27
+ `[Extensions]` sections are gone and nothing brings them back — `/reload` re-runs
28
+ the same suppressed listing. The card had replaced all of that with three
29
+ counts, which is strictly less than what the session used to show. It now names
30
+ them instead, eight per row with a `+N` tail: context files, skills, prompts,
31
+ extensions, themes, and the tool count.
32
+
33
+ Skills and prompts now come from `getCommands()` rather than `loadSkills()`. An
34
+ extension may contribute skill paths through `resources_discover`, which is
35
+ where most of them come from in practice, and the standalone loader cannot see
36
+ those — measured on a real session it returned zero skills while Pi was showing
37
+ dozens. Extensions and themes are read from the same settings files and
38
+ directories Pi reads.
39
+
40
+ The ridgeline mark, the `SIGNALRIDGE / PI` wordmark, and the tagline are gone.
41
+ The card exists to say what this session is, and a brand line says nothing a
42
+ returning user does not already know; the key hints lead instead, since with
43
+ `quietStartup` on they appear nowhere else. The Pi version returns to a row of
44
+ its own.
45
+
3
46
  ## 1.0.0
4
47
  ### Major Changes
5
48
 
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.1",
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,9 +37,19 @@ 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";
40
+ /**
41
+ * A pi letterform: one beam over two legs, drawn in the accent color.
42
+ *
43
+ * The mark says the same thing as the title line beside it — one for the eye,
44
+ * one for the reader — rather than being a texture that could belong to any
45
+ * tool. The legs are solid blocks rather than thin verticals so the two rows
46
+ * carry equal weight; hairlines wash out at small font sizes.
47
+ *
48
+ * Block Elements only. Each is exactly one column in every terminal font, while
49
+ * an emoji or a private-use glyph falls back to something double-width on some
50
+ * machines and shifts the text beside it out of alignment.
51
+ */
52
+ const MARK = ["▄▄▄▄▄▄", "▐█ █▌"] as const;
44
53
  const SUBTITLE = "A focused coding workspace";
45
54
 
46
55
  /** Columns of padding inside the card. */
@@ -52,8 +61,16 @@ const MIN_WIDTH = 20;
52
61
  /** Sentinel row rendered as a blank line, separating the card's groups. */
53
62
  const GAP: [string, string] = ["", ""];
54
63
 
55
- /** Most context-file names to spell out before collapsing the rest into a count. */
56
- const MAX_CONTEXT_NAMES = 3;
64
+ /**
65
+ * Names to spell out in a resource row before collapsing the rest into `+N`.
66
+ *
67
+ * Pi's own listing prints every name and lets it wrap; with `quietStartup` on
68
+ * that listing is gone and this card is the only place the inventory appears,
69
+ * so it errs toward showing names rather than a bare count. Eight is what fits
70
+ * on one line at a normal width without turning the card into the wall of text
71
+ * the merge was meant to remove.
72
+ */
73
+ const MAX_NAMES = 8;
57
74
 
58
75
  interface WelcomeData {
59
76
  rows: [string, string][];
@@ -128,8 +145,19 @@ function formatTokens(n: number): string {
128
145
  return String(n);
129
146
  }
130
147
 
131
- function plural(count: number, noun: string): string {
132
- return `${count} ${noun}${count === 1 ? "" : "s"}`;
148
+ /** `a, b, c +58` — the first few names, then how many were left out. */
149
+ function nameList(names: readonly string[], max = MAX_NAMES): string | undefined {
150
+ if (names.length === 0) return undefined;
151
+ const shown = names.slice(0, max).join(", ");
152
+ const rest = names.length - max;
153
+ return rest > 0 ? `${shown} +${rest}` : shown;
154
+ }
155
+
156
+ /** Sorted, de-duplicated, and free of empties — every row is built from this. */
157
+ function tidy(names: Iterable<string>): string[] {
158
+ return [...new Set([...names].map((name) => name.trim()).filter((name) => name.length > 0))].sort((a, b) =>
159
+ a.localeCompare(b),
160
+ );
133
161
  }
134
162
 
135
163
  /**
@@ -172,59 +200,94 @@ export function collectHints(): string[] {
172
200
  }
173
201
 
174
202
  /**
175
- * What pi loaded for this session, as counts rather than a listing.
203
+ * Extension names, as Pi's own `[Extensions]` section lists them.
176
204
  *
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.
205
+ * Read from the same two settings files Pi reads, plus the extension
206
+ * directories it scans. Pi labels a package-sourced extension by its package
207
+ * name and a file-sourced one by its path, so the scope and the `pi-` prefix
208
+ * come off to leave the part that identifies it.
209
+ */
210
+ function collectExtensionNames(cwd: string): string[] {
211
+ const names: string[] = [];
212
+ for (const settingsFile of [join(getAgentDir(), "settings.json"), join(cwd, ".pi", "settings.json")]) {
213
+ try {
214
+ const packages = JSON.parse(readFileSync(settingsFile, "utf8"))?.packages;
215
+ if (!Array.isArray(packages)) continue;
216
+ for (const entry of packages) {
217
+ const source = typeof entry === "string" ? entry : entry?.source;
218
+ if (typeof source !== "string") continue;
219
+ const spec = source.replace(/^npm:/u, "");
220
+ // `@scope/pi-name@1.2.3` and `./local/path` both reduce to a bare name.
221
+ const bare = (spec.startsWith("@") ? (spec.split("/")[1] ?? spec) : basename(spec)).replace(/@.*$/u, "");
222
+ names.push(bare.replace(/^pi-/u, ""));
223
+ }
224
+ } catch {}
225
+ }
226
+ for (const directory of [join(getAgentDir(), "extensions"), join(cwd, ".pi", "extensions")]) {
227
+ try {
228
+ for (const entry of readdirSync(directory)) {
229
+ names.push(entry.replace(/\.[cm]?[jt]s$/u, "").replace(/^pi-/u, ""));
230
+ }
231
+ } catch {}
232
+ }
233
+ return tidy(names);
234
+ }
235
+
236
+ /** Custom theme names, as Pi's own `[Themes]` section lists them. */
237
+ function collectThemeNames(): string[] {
238
+ try {
239
+ return tidy(
240
+ readdirSync(join(getAgentDir(), "themes"))
241
+ .filter((entry) => entry.endsWith(".json"))
242
+ .map((entry) => entry.replace(/\.json$/u, "")),
243
+ );
244
+ } catch {
245
+ return [];
246
+ }
247
+ }
248
+
249
+ /**
250
+ * What pi loaded for this session, by name — the inventory its own
251
+ * `[Context]`/`[Skills]`/`[Prompts]`/`[Themes]`/`[Extensions]` sections print.
252
+ *
253
+ * With `quietStartup` on those sections are gone and nothing brings them back
254
+ * (`/reload` re-runs the same suppressed listing), so this card is the only
255
+ * place the inventory appears and it names things rather than counting them.
256
+ *
257
+ * Skills and prompts come from `getCommands()`, not from `loadSkills()`: an
258
+ * extension may contribute skill paths through `resources_discover`, which is
259
+ * where most of them come from here, and the standalone loader cannot see those
260
+ * — it reports zero on a session showing dozens. `getCommands()` reports what
261
+ * Pi actually registered, which is the honest answer to "what loaded".
185
262
  */
186
263
  export function collectResources(pi: ExtensionAPI, cwd: string): [string, string][] {
187
264
  const rows: [string, string][] = [];
265
+ const push = (label: string, value: string | undefined) => {
266
+ if (value) rows.push([label, value]);
267
+ };
188
268
 
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
269
  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"));
270
+ const files = loadProjectContextFiles({ cwd, agentDir: getAgentDir() });
271
+ // Several scopes can contribute the same filename (a project AGENTS.md and
272
+ // a global one); the card names kinds of context, not paths.
273
+ push("Context", nameList(tidy(files.map((file) => basename(file.path)))));
214
274
  } catch {}
275
+
215
276
  try {
216
277
  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`);
278
+ const named = (source: string, prefix = "") =>
279
+ tidy(commands.filter((command) => command.source === source).map((command) => `${prefix}${command.name}`));
280
+ push("Skills", nameList(named("skill")));
281
+ push("Prompts", nameList(named("prompt", "/")));
221
282
  } catch {}
222
- if (loaded.length > 0) rows.push(["Loaded", loaded.join(" · ")]);
283
+
284
+ push("Extensions", nameList(collectExtensionNames(cwd)));
285
+ push("Themes", nameList(collectThemeNames()));
223
286
 
224
287
  try {
225
288
  const all = pi.getAllTools?.() ?? [];
226
289
  const active = pi.getActiveTools?.() ?? [];
227
- if (all.length) rows.push(["Tools", `${active.length} active of ${all.length}`]);
290
+ if (all.length) push("Tools", `${active.length} active of ${all.length}`);
228
291
  } catch {}
229
292
 
230
293
  return rows;
@@ -356,7 +419,6 @@ export class WelcomeCard implements Component {
356
419
  private renderCompact(width: number): string[] {
357
420
  const t = this.theme;
358
421
  const lines = [
359
- t.bold(t.fg("accent", "sr / pi")),
360
422
  t.fg("muted", this.hints.length > 0 ? (this.hints[0] ?? "ready") : "ready"),
361
423
  ...this.rows.map(([label, value]) =>
362
424
  label === GAP[0] && value === GAP[1] ? "" : `${t.fg("dim", `${label}:`)} ${t.fg("text", value)}`,
@@ -376,30 +438,25 @@ export class WelcomeCard implements Component {
376
438
  const blank = () => line("");
377
439
  const pad = " ".repeat(PAD);
378
440
  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
441
 
442
+ // The mark names pi, not a vendor: what a returning user needs from the top
443
+ // of the card is which tool and which version, and a company name is
444
+ // neither. The version rides the title line because it belongs to the same
445
+ // fact.
446
+ const markWidth = Math.max(...MARK.map(visibleWidth));
389
447
  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)),
448
+ line(pad + t.fg("accent", MARK[0].padEnd(markWidth)) + pad + t.bold(t.fg("text", `pi v${VERSION}`))),
449
+ line(pad + t.fg("accent", MARK[1].padEnd(markWidth)) + pad + t.fg("muted", SUBTITLE)),
450
+ blank(),
392
451
  );
393
452
 
394
453
  const hintLine = fitHints(this.hints, Math.max(1, inner - PAD * 2));
395
454
  if (hintLine) {
396
455
  // The whole line is dimmed rather than highlighting each key: this is
397
456
  // orientation, not a control the user is meant to look at.
398
- body.push(blank(), line(pad + t.fg("dim", hintLine)));
457
+ body.push(line(pad + t.fg("dim", hintLine)), blank());
399
458
  }
400
459
 
401
- body.push(blank());
402
-
403
460
  const labelWidth = this.rows.length ? Math.max(...this.rows.map(([label]) => visibleWidth(label))) + 2 : 0;
404
461
  for (const [label, value] of this.rows) {
405
462
  if (label === GAP[0] && value === GAP[1]) {