@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 +39 -0
- package/README.md +75 -1
- package/package.json +1 -1
- package/src/index.ts +261 -55
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
|
|
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
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
|
|
@@ -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
|
|
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
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* and
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
|
176
|
-
|
|
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
|
-
|
|
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.
|
|
198
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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.
|
|
226
|
-
|
|
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
|
-
|
|
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
|
}
|