@signalridge/pi-welcome 0.1.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,44 @@
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
+
28
+ ## 1.0.0
29
+ ### Major Changes
30
+
31
+ - Adopt a unified 1.0.0 across every published package.
32
+
33
+ The version numbers no longer track their upstream forks individually; from this release each package
34
+ is versioned on its own merit against the Signalridge line, and 1.0.0 is the shared starting point.
35
+ Packages whose behavior changed in this release document that change in their own changeset entries;
36
+ the remainder are re-released unchanged so the whole set shares one baseline.
37
+
38
+ ### Patch Changes
39
+
40
+ - Neutralize terminal control sequences and bidirectional overrides in the last three surfaces that rendered untrusted text raw: the recap the model writes from tool output, the workspace facts on the startup card, and the directory name written into the terminal title as an OSC escape sequence. The recap also sanitizes transcript text before it truncates it, so a cut can never hand the model half of an escape sequence.
41
+
3
42
  ## Unreleased
4
43
 
5
44
  - Moved the Pi startup card into the independently publishable Signalridge package.
package/README.md CHANGED
@@ -1,6 +1,80 @@
1
1
  # pi-welcome
2
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.
3
+ TUI-only Signalridge startup card: one rounded panel with the current
4
+ repository, model, context budget, key hints, and the inventory of what Pi loaded. It
5
+ uses the active Pi theme, persists a bounded custom entry so a resumed session
6
+ redraws it, and collapses to a width-safe compact summary on narrow terminals.
7
+
8
+ ```
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
+ ╰─────────────────────────────────────────────────────────────────────────╯
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
+
33
+ ## Turn on `quietStartup`
34
+
35
+ The card is designed to be the whole opening screen. Pi's own startup is a
36
+ separate header, key-hint block, and one `[Section]` per resource kind with a
37
+ blank line between each; leaving both on shows two unrelated designs and about
38
+ forty-five lines of them. Set:
39
+
40
+ ```json
41
+ { "quietStartup": true }
42
+ ```
43
+
44
+ in `~/.pi/agent/settings.json` (or `/settings` → Quiet startup). Pi then prints
45
+ nothing at startup and this card stands alone. Diagnostics — resource
46
+ collisions, extension load errors — are still shown when quiet, so nothing
47
+ actionable is hidden.
48
+
49
+ Leaving `quietStartup: false` is supported and changes nothing about the card;
50
+ Pi simply prints its own block above it again.
51
+
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.
64
+
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.
70
+
71
+ ## Untrusted text
72
+
73
+ A directory name, a git branch name, and a session name are all
74
+ attacker-controlled in a cloned repository, and the card is a persisted entry
75
+ replayed on every resume. Control characters and bidirectional overrides are
76
+ neutralized at render time, so an entry stored before that landed is cleaned up
77
+ on the way out too.
4
78
 
5
79
  ## Install
6
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-welcome",
3
- "version": "0.1.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
@@ -5,9 +5,13 @@
5
5
  * active Pi theme and semantic labels instead of a second palette or a bundled
6
6
  * font, so it stays legible in both dark and light terminals.
7
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.
8
+ * It is meant to be the ONLY thing a session opens with. Pi's own startup is a
9
+ * header, a key-hint block, and one `[Section]` per resource kind with a blank
10
+ * line between each; stacking this card underneath produced two unrelated
11
+ * designs and about forty-five lines of them. So the card carries the pieces
12
+ * worth keeping — the key hints and a count of what loaded — and `quietStartup`
13
+ * is turned on in settings to silence the rest. With `quietStartup: false` Pi
14
+ * still prints its own block and the two appear together again.
11
15
  *
12
16
  * The card is a custom entry, not a widget: it lands in the transcript, scrolls
13
17
  * away as the conversation grows, and survives a reload. Facts are captured at
@@ -16,14 +20,16 @@
16
20
  */
17
21
 
18
22
  import { execFileSync } from "node:child_process";
19
- import { readFileSync } from "node:fs";
23
+ import { readdirSync, readFileSync } from "node:fs";
20
24
  import { homedir } from "node:os";
21
- import { join } from "node:path";
25
+ import { basename, join } from "node:path";
22
26
 
23
27
  import {
24
28
  type ExtensionAPI,
25
29
  type ExtensionContext,
26
30
  getAgentDir,
31
+ keyText,
32
+ loadProjectContextFiles,
27
33
  type Theme,
28
34
  VERSION,
29
35
  } from "@earendil-works/pi-coding-agent";
@@ -31,22 +37,35 @@ import { type Component, truncateToWidth, visibleWidth, wrapTextWithAnsi } from
31
37
 
32
38
  const ENTRY_TYPE = "welcome-card";
33
39
 
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
40
  /** Columns of padding inside the card. */
40
41
  const PAD = 2;
41
42
 
42
43
  /** Below this width, use a compact no-border layout rather than overflow. */
43
44
  const MIN_WIDTH = 20;
44
45
 
45
- /** Sentinel row rendered as a blank line, separating the card's two groups. */
46
+ /** Sentinel row rendered as a blank line, separating the card's groups. */
46
47
  const GAP: [string, string] = ["", ""];
47
48
 
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;
59
+
48
60
  interface WelcomeData {
49
61
  rows: [string, string][];
62
+ /**
63
+ * Key hints, most useful first. Kept as separate items rather than one joined
64
+ * string so a narrow card can drop the trailing ones instead of wrapping a
65
+ * separator onto a line of its own. Optional because an entry written before
66
+ * this field existed replays without any.
67
+ */
68
+ hints?: string[];
50
69
  }
51
70
 
52
71
  // ─── Fact Collection ───────────────────────────────────────────────────────────
@@ -111,6 +130,154 @@ function formatTokens(n: number): string {
111
130
  return String(n);
112
131
  }
113
132
 
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
+ );
146
+ }
147
+
148
+ /**
149
+ * `ctrl+c` as `Ctrl+C`: key names read as proper nouns, the words after them
150
+ * stay lowercase. Pi capitalizes the same way internally but does not export
151
+ * the helper, so this repeats the one rule rather than reaching past the
152
+ * package's public surface.
153
+ */
154
+ function capitalizeKey(key: string): string {
155
+ return key.replace(/[^+/]+/gu, (part) => part.charAt(0).toUpperCase() + part.slice(1));
156
+ }
157
+
158
+ /**
159
+ * The key hints Pi's own header would have shown, as plain text.
160
+ *
161
+ * Read from the live keybindings rather than hard-coded, so a rebound key is
162
+ * still described correctly. Deliberately not built with pi's `keyHint()`: that
163
+ * colors through pi's own theme singleton, while this card renders with the
164
+ * theme handed to its entry renderer, and the two would disagree after a theme
165
+ * change. The whole line is dimmed by the renderer instead of highlighting
166
+ * individual keys.
167
+ */
168
+ export function collectHints(): string[] {
169
+ try {
170
+ // Ordered most useful first: a narrow card keeps the head of this list.
171
+ return [
172
+ [capitalizeKey(keyText("app.interrupt")), "interrupt"],
173
+ ["/", "commands"],
174
+ ["!", "bash"],
175
+ [capitalizeKey(keyText("app.clear")), "clear"],
176
+ [capitalizeKey(keyText("app.tools.expand")), "expand tools"],
177
+ ]
178
+ .filter(([key]) => key && key.length > 0)
179
+ .map(([key, description]) => `${key} ${description}`);
180
+ } catch {
181
+ // Keybindings are a TUI singleton; if it is not up yet, a card without a
182
+ // hint line is better than no card.
183
+ return [];
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Extension names, as Pi's own `[Extensions]` section lists them.
189
+ *
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".
247
+ */
248
+ export function collectResources(pi: ExtensionAPI, cwd: string): [string, string][] {
249
+ const rows: [string, string][] = [];
250
+ const push = (label: string, value: string | undefined) => {
251
+ if (value) rows.push([label, value]);
252
+ };
253
+
254
+ try {
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)))));
259
+ } catch {}
260
+
261
+ try {
262
+ const commands = pi.getCommands?.() ?? [];
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", "/")));
267
+ } catch {}
268
+
269
+ push("Extensions", nameList(collectExtensionNames(cwd)));
270
+ push("Themes", nameList(collectThemeNames()));
271
+
272
+ try {
273
+ const all = pi.getAllTools?.() ?? [];
274
+ const active = pi.getActiveTools?.() ?? [];
275
+ if (all.length) push("Tools", `${active.length} active of ${all.length}`);
276
+ } catch {}
277
+
278
+ return rows;
279
+ }
280
+
114
281
  function collect(pi: ExtensionAPI, ctx: ExtensionContext): WelcomeData {
115
282
  const rows: [string, string][] = [];
116
283
  const push = (label: string, value: string | undefined) => {
@@ -127,9 +294,9 @@ function collect(pi: ExtensionAPI, ctx: ExtensionContext): WelcomeData {
127
294
  push("Directory", tildify(ctx.cwd) + trusted);
128
295
  push("Branch", gitSummary(ctx.cwd));
129
296
 
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.
297
+ // Keep the fresh-session label short; raw session ids add noise without
298
+ // helping orientation — they are a timestamp glued to a uuid, identical in
299
+ // shape every time.
133
300
  push("Session", pi.getSessionName?.() || "(new)");
134
301
 
135
302
  const model = ctx.model;
@@ -152,36 +319,81 @@ function collect(pi: ExtensionAPI, ctx: ExtensionContext): WelcomeData {
152
319
  }
153
320
  }
154
321
 
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
322
  push("Version", VERSION);
164
- return { rows };
323
+
324
+ // GAP is the sentinel for a blank row: session facts above, what pi loaded
325
+ // below, so the card reads as two groups rather than one wall.
326
+ const resources = collectResources(pi, ctx.cwd);
327
+ if (resources.length > 0) rows.push(GAP, ...resources);
328
+
329
+ const hints = collectHints();
330
+ return hints.length > 0 ? { rows, hints } : { rows };
165
331
  }
166
332
 
333
+ // ─── Card Component ────────────────────────────────────────────────────────────
334
+
167
335
  /**
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.
336
+ * Card facts come from a directory name, a git branch name and a session name.
337
+ * All three are attacker-controlled in a cloned repository, and the card is a
338
+ * persisted entry, so a poisoned fact is replayed on every resume. Neutralize
339
+ * C0/C1 controls and bidi overrides here, at the terminal boundary, so a stored
340
+ * entry written before this landed is cleaned up on the way out too.
341
+ *
342
+ * Duplicated rather than shared, per the package boundary rule; `pi-stamp`'s
343
+ * metadata sanitizer is the reference shape.
174
344
  */
175
- export function collectResources(_cwd: string): Promise<[string, string][]> {
176
- return Promise.resolve([]);
345
+ export function sanitizeCardText(value: string): string {
346
+ let safe = "";
347
+ for (const character of value) {
348
+ safe += isUnsafeTerminalCodePoint(character.codePointAt(0) ?? 0) ? " " : character;
349
+ }
350
+ return safe.replace(/\s+/gu, " ").trim();
351
+ }
352
+
353
+ function isUnsafeTerminalCodePoint(codePoint: number): boolean {
354
+ return (
355
+ codePoint <= 0x1f ||
356
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
357
+ codePoint === 0x061c ||
358
+ codePoint === 0x200e ||
359
+ codePoint === 0x200f ||
360
+ (codePoint >= 0x202a && codePoint <= 0x202e) ||
361
+ (codePoint >= 0x2066 && codePoint <= 0x2069)
362
+ );
363
+ }
364
+
365
+ /**
366
+ * Join as many hints as fit on one line, dropping from the end.
367
+ *
368
+ * Wrapping would put a leading `·` at the start of the continuation, which
369
+ * reads as a bullet for the line below it rather than a separator. Losing the
370
+ * last hint costs less than that, and the list is ordered so the ones worth
371
+ * keeping come first.
372
+ */
373
+ export function fitHints(hints: readonly string[], width: number): string | undefined {
374
+ for (let count = hints.length; count > 0; count--) {
375
+ const line = hints.slice(0, count).join(" · ");
376
+ if (visibleWidth(line) <= width) return line;
377
+ }
378
+ return undefined;
177
379
  }
178
- // ─── Card Component ────────────────────────────────────────────────────────────
179
380
 
180
381
  export class WelcomeCard implements Component {
382
+ private readonly rows: [string, string][];
383
+ private readonly hints: string[];
384
+
181
385
  constructor(
182
- private readonly data: WelcomeData,
386
+ data: WelcomeData,
183
387
  private readonly theme: Theme,
184
- ) {}
388
+ ) {
389
+ this.rows = data.rows.map(([label, value]) => [sanitizeCardText(label), sanitizeCardText(value)]);
390
+ // The entry is replayed from a session file on disk, which an older build
391
+ // (or a hand edit) may have written with a different shape for this field.
392
+ this.hints = (Array.isArray(data.hints) ? data.hints : [])
393
+ .filter((hint): hint is string => typeof hint === "string")
394
+ .map(sanitizeCardText)
395
+ .filter((hint) => hint.length > 0);
396
+ }
185
397
 
186
398
  /** Pad or truncate a (possibly styled) string to exactly `width` columns. */
187
399
  private fit(text: string, width: number): string {
@@ -194,9 +406,8 @@ export class WelcomeCard implements Component {
194
406
  private renderCompact(width: number): string[] {
195
407
  const t = this.theme;
196
408
  const lines = [
197
- t.bold(t.fg("accent", "sr / pi")),
198
- t.fg("muted", "ready"),
199
- ...this.data.rows.map(([label, value]) =>
409
+ t.fg("muted", this.hints.length > 0 ? (this.hints[0] ?? "ready") : "ready"),
410
+ ...this.rows.map(([label, value]) =>
200
411
  label === GAP[0] && value === GAP[1] ? "" : `${t.fg("dim", `${label}:`)} ${t.fg("text", value)}`,
201
412
  ),
202
413
  ];
@@ -214,18 +425,20 @@ export class WelcomeCard implements Component {
214
425
  const blank = () => line("");
215
426
  const pad = " ".repeat(PAD);
216
427
  const body: string[] = [blank()];
217
- const logoWidth = Math.max(...LOGO.map(visibleWidth));
218
428
 
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
- );
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.
433
+ const hintLine = fitHints(this.hints, Math.max(1, inner - PAD * 2));
434
+ if (hintLine) {
435
+ // The whole line is dimmed rather than highlighting each key: this is
436
+ // orientation, not a control the user is meant to look at.
437
+ body.push(line(pad + t.fg("dim", hintLine)), blank());
438
+ }
224
439
 
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) {
440
+ const labelWidth = this.rows.length ? Math.max(...this.rows.map(([label]) => visibleWidth(label))) + 2 : 0;
441
+ for (const [label, value] of this.rows) {
229
442
  if (label === GAP[0] && value === GAP[1]) {
230
443
  body.push(blank());
231
444
  continue;
@@ -271,14 +484,7 @@ export default function (pi: ExtensionAPI) {
271
484
  } catch {}
272
485
 
273
486
  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);
487
+ pi.appendEntry<WelcomeData>(ENTRY_TYPE, collect(pi, ctx));
282
488
  } catch {
283
489
  // A missing card is not worth failing a session start over.
284
490
  }