@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 +14 -0
- package/README.md +63 -1
- package/package.json +1 -1
- package/src/index.ts +213 -44
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
|
|
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
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* and
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
|
176
|
-
|
|
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
|
-
|
|
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.
|
|
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 +
|
|
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
|
|
226
|
-
|
|
227
|
-
:
|
|
228
|
-
|
|
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
|
-
|
|
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
|
}
|