@signalridge/pi-welcome 0.1.0 → 1.2.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0
4
+ ### Major Changes
5
+
6
+ - Adopt a unified 1.0.0 across every published package.
7
+
8
+ The version numbers no longer track their upstream forks individually; from this release each package
9
+ is versioned on its own merit against the Signalridge line, and 1.0.0 is the shared starting point.
10
+ Packages whose behavior changed in this release document that change in their own changeset entries;
11
+ the remainder are re-released unchanged so the whole set shares one baseline.
12
+
13
+ ### Patch Changes
14
+
15
+ - 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.
16
+
3
17
  ## Unreleased
4
18
 
5
19
  - Moved the Pi startup card into the independently publishable Signalridge package.
package/README.md CHANGED
@@ -1,6 +1,68 @@
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 a count 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
+ │ ╭─╮ 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
+ ╰──────────────────────────────────────────────────────────────────────╯
27
+ ```
28
+
29
+ ## Turn on `quietStartup`
30
+
31
+ The card is designed to be the whole opening screen. Pi's own startup is a
32
+ separate header, key-hint block, and one `[Section]` per resource kind with a
33
+ blank line between each; leaving both on shows two unrelated designs and about
34
+ forty-five lines of them. Set:
35
+
36
+ ```json
37
+ { "quietStartup": true }
38
+ ```
39
+
40
+ in `~/.pi/agent/settings.json` (or `/settings` → Quiet startup). Pi then prints
41
+ nothing at startup and this card stands alone. Diagnostics — resource
42
+ collisions, extension load errors — are still shown when quiet, so nothing
43
+ actionable is hidden.
44
+
45
+ Leaving `quietStartup: false` is supported and changes nothing about the card;
46
+ Pi simply prints its own block above it again.
47
+
48
+ ## What the counts come from
49
+
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.
58
+
59
+ ## Untrusted text
60
+
61
+ A directory name, a git branch name, and a session name are all
62
+ attacker-controlled in a cloned repository, and the card is a persisted entry
63
+ replayed on every resume. Control characters and bidirectional overrides are
64
+ neutralized at render time, so an entry stored before that landed is cleaned up
65
+ on the way out too.
4
66
 
5
67
  ## Install
6
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-welcome",
3
- "version": "0.1.0",
3
+ "version": "1.2.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
@@ -18,12 +22,15 @@
18
22
  import { execFileSync } from "node:child_process";
19
23
  import { 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,
33
+ loadSkills,
27
34
  type Theme,
28
35
  VERSION,
29
36
  } from "@earendil-works/pi-coding-agent";
@@ -42,11 +49,21 @@ const PAD = 2;
42
49
  /** Below this width, use a compact no-border layout rather than overflow. */
43
50
  const MIN_WIDTH = 20;
44
51
 
45
- /** Sentinel row rendered as a blank line, separating the card's two groups. */
52
+ /** Sentinel row rendered as a blank line, separating the card's groups. */
46
53
  const GAP: [string, string] = ["", ""];
47
54
 
55
+ /** Most context-file names to spell out before collapsing the rest into a count. */
56
+ const MAX_CONTEXT_NAMES = 3;
57
+
48
58
  interface WelcomeData {
49
59
  rows: [string, string][];
60
+ /**
61
+ * Key hints, most useful first. Kept as separate items rather than one joined
62
+ * string so a narrow card can drop the trailing ones instead of wrapping a
63
+ * separator onto a line of its own. Optional because an entry written before
64
+ * this field existed replays without any.
65
+ */
66
+ hints?: string[];
50
67
  }
51
68
 
52
69
  // ─── Fact Collection ───────────────────────────────────────────────────────────
@@ -111,6 +128,108 @@ function formatTokens(n: number): string {
111
128
  return String(n);
112
129
  }
113
130
 
131
+ function plural(count: number, noun: string): string {
132
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
133
+ }
134
+
135
+ /**
136
+ * `ctrl+c` as `Ctrl+C`: key names read as proper nouns, the words after them
137
+ * stay lowercase. Pi capitalizes the same way internally but does not export
138
+ * the helper, so this repeats the one rule rather than reaching past the
139
+ * package's public surface.
140
+ */
141
+ function capitalizeKey(key: string): string {
142
+ return key.replace(/[^+/]+/gu, (part) => part.charAt(0).toUpperCase() + part.slice(1));
143
+ }
144
+
145
+ /**
146
+ * The key hints Pi's own header would have shown, as plain text.
147
+ *
148
+ * Read from the live keybindings rather than hard-coded, so a rebound key is
149
+ * still described correctly. Deliberately not built with pi's `keyHint()`: that
150
+ * colors through pi's own theme singleton, while this card renders with the
151
+ * theme handed to its entry renderer, and the two would disagree after a theme
152
+ * change. The whole line is dimmed by the renderer instead of highlighting
153
+ * individual keys.
154
+ */
155
+ export function collectHints(): string[] {
156
+ try {
157
+ // Ordered most useful first: a narrow card keeps the head of this list.
158
+ return [
159
+ [capitalizeKey(keyText("app.interrupt")), "interrupt"],
160
+ ["/", "commands"],
161
+ ["!", "bash"],
162
+ [capitalizeKey(keyText("app.clear")), "clear"],
163
+ [capitalizeKey(keyText("app.tools.expand")), "expand tools"],
164
+ ]
165
+ .filter(([key]) => key && key.length > 0)
166
+ .map(([key, description]) => `${key} ${description}`);
167
+ } catch {
168
+ // Keybindings are a TUI singleton; if it is not up yet, a card without a
169
+ // hint line is better than no card.
170
+ return [];
171
+ }
172
+ }
173
+
174
+ /**
175
+ * What pi loaded for this session, as counts rather than a listing.
176
+ *
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.
185
+ */
186
+ export function collectResources(pi: ExtensionAPI, cwd: string): [string, string][] {
187
+ const rows: [string, string][] = [];
188
+
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
+ 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"));
214
+ } catch {}
215
+ try {
216
+ 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`);
221
+ } catch {}
222
+ if (loaded.length > 0) rows.push(["Loaded", loaded.join(" · ")]);
223
+
224
+ try {
225
+ const all = pi.getAllTools?.() ?? [];
226
+ const active = pi.getActiveTools?.() ?? [];
227
+ if (all.length) rows.push(["Tools", `${active.length} active of ${all.length}`]);
228
+ } catch {}
229
+
230
+ return rows;
231
+ }
232
+
114
233
  function collect(pi: ExtensionAPI, ctx: ExtensionContext): WelcomeData {
115
234
  const rows: [string, string][] = [];
116
235
  const push = (label: string, value: string | undefined) => {
@@ -127,9 +246,9 @@ function collect(pi: ExtensionAPI, ctx: ExtensionContext): WelcomeData {
127
246
  push("Directory", tildify(ctx.cwd) + trusted);
128
247
  push("Branch", gitSummary(ctx.cwd));
129
248
 
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.
249
+ // Keep the fresh-session label short; raw session ids add noise without
250
+ // helping orientation — they are a timestamp glued to a uuid, identical in
251
+ // shape every time.
133
252
  push("Session", pi.getSessionName?.() || "(new)");
134
253
 
135
254
  const model = ctx.model;
@@ -152,36 +271,79 @@ function collect(pi: ExtensionAPI, ctx: ExtensionContext): WelcomeData {
152
271
  }
153
272
  }
154
273
 
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 {}
274
+ // GAP is the sentinel for a blank row: session facts above, what pi loaded
275
+ // below, so the card reads as two groups rather than one wall.
276
+ const resources = collectResources(pi, ctx.cwd);
277
+ if (resources.length > 0) rows.push(GAP, ...resources);
162
278
 
163
- push("Version", VERSION);
164
- return { rows };
279
+ const hints = collectHints();
280
+ return hints.length > 0 ? { rows, hints } : { rows };
165
281
  }
166
282
 
283
+ // ─── Card Component ────────────────────────────────────────────────────────────
284
+
167
285
  /**
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.
286
+ * Card facts come from a directory name, a git branch name and a session name.
287
+ * All three are attacker-controlled in a cloned repository, and the card is a
288
+ * persisted entry, so a poisoned fact is replayed on every resume. Neutralize
289
+ * C0/C1 controls and bidi overrides here, at the terminal boundary, so a stored
290
+ * entry written before this landed is cleaned up on the way out too.
291
+ *
292
+ * Duplicated rather than shared, per the package boundary rule; `pi-stamp`'s
293
+ * metadata sanitizer is the reference shape.
174
294
  */
175
- export function collectResources(_cwd: string): Promise<[string, string][]> {
176
- return Promise.resolve([]);
295
+ export function sanitizeCardText(value: string): string {
296
+ let safe = "";
297
+ for (const character of value) {
298
+ safe += isUnsafeTerminalCodePoint(character.codePointAt(0) ?? 0) ? " " : character;
299
+ }
300
+ return safe.replace(/\s+/gu, " ").trim();
301
+ }
302
+
303
+ function isUnsafeTerminalCodePoint(codePoint: number): boolean {
304
+ return (
305
+ codePoint <= 0x1f ||
306
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
307
+ codePoint === 0x061c ||
308
+ codePoint === 0x200e ||
309
+ codePoint === 0x200f ||
310
+ (codePoint >= 0x202a && codePoint <= 0x202e) ||
311
+ (codePoint >= 0x2066 && codePoint <= 0x2069)
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Join as many hints as fit on one line, dropping from the end.
317
+ *
318
+ * Wrapping would put a leading `·` at the start of the continuation, which
319
+ * reads as a bullet for the line below it rather than a separator. Losing the
320
+ * last hint costs less than that, and the list is ordered so the ones worth
321
+ * keeping come first.
322
+ */
323
+ export function fitHints(hints: readonly string[], width: number): string | undefined {
324
+ for (let count = hints.length; count > 0; count--) {
325
+ const line = hints.slice(0, count).join(" · ");
326
+ if (visibleWidth(line) <= width) return line;
327
+ }
328
+ return undefined;
177
329
  }
178
- // ─── Card Component ────────────────────────────────────────────────────────────
179
330
 
180
331
  export class WelcomeCard implements Component {
332
+ private readonly rows: [string, string][];
333
+ private readonly hints: string[];
334
+
181
335
  constructor(
182
- private readonly data: WelcomeData,
336
+ data: WelcomeData,
183
337
  private readonly theme: Theme,
184
- ) {}
338
+ ) {
339
+ this.rows = data.rows.map(([label, value]) => [sanitizeCardText(label), sanitizeCardText(value)]);
340
+ // The entry is replayed from a session file on disk, which an older build
341
+ // (or a hand edit) may have written with a different shape for this field.
342
+ this.hints = (Array.isArray(data.hints) ? data.hints : [])
343
+ .filter((hint): hint is string => typeof hint === "string")
344
+ .map(sanitizeCardText)
345
+ .filter((hint) => hint.length > 0);
346
+ }
185
347
 
186
348
  /** Pad or truncate a (possibly styled) string to exactly `width` columns. */
187
349
  private fit(text: string, width: number): string {
@@ -195,8 +357,8 @@ export class WelcomeCard implements Component {
195
357
  const t = this.theme;
196
358
  const lines = [
197
359
  t.bold(t.fg("accent", "sr / pi")),
198
- t.fg("muted", "ready"),
199
- ...this.data.rows.map(([label, value]) =>
360
+ t.fg("muted", this.hints.length > 0 ? (this.hints[0] ?? "ready") : "ready"),
361
+ ...this.rows.map(([label, value]) =>
200
362
  label === GAP[0] && value === GAP[1] ? "" : `${t.fg("dim", `${label}:`)} ${t.fg("text", value)}`,
201
363
  ),
202
364
  ];
@@ -216,16 +378,30 @@ export class WelcomeCard implements Component {
216
378
  const body: string[] = [blank()];
217
379
  const logoWidth = Math.max(...LOGO.map(visibleWidth));
218
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
+
219
389
  body.push(
220
- line(pad + t.fg("accent", LOGO[0].padEnd(logoWidth)) + pad + t.bold(t.fg("text", BRAND))),
390
+ line(pad + t.fg("accent", LOGO[0].padEnd(logoWidth)) + pad + brandLine),
221
391
  line(pad + t.fg("accent", LOGO[1].padEnd(logoWidth)) + pad + t.fg("muted", SUBTITLE)),
222
- blank(),
223
392
  );
224
393
 
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) {
394
+ const hintLine = fitHints(this.hints, Math.max(1, inner - PAD * 2));
395
+ if (hintLine) {
396
+ // The whole line is dimmed rather than highlighting each key: this is
397
+ // orientation, not a control the user is meant to look at.
398
+ body.push(blank(), line(pad + t.fg("dim", hintLine)));
399
+ }
400
+
401
+ body.push(blank());
402
+
403
+ const labelWidth = this.rows.length ? Math.max(...this.rows.map(([label]) => visibleWidth(label))) + 2 : 0;
404
+ for (const [label, value] of this.rows) {
229
405
  if (label === GAP[0] && value === GAP[1]) {
230
406
  body.push(blank());
231
407
  continue;
@@ -271,14 +447,7 @@ export default function (pi: ExtensionAPI) {
271
447
  } catch {}
272
448
 
273
449
  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);
450
+ pi.appendEntry<WelcomeData>(ENTRY_TYPE, collect(pi, ctx));
282
451
  } catch {
283
452
  // A missing card is not worth failing a session start over.
284
453
  }