@pi-kaush/pi-welcome-screen 0.1.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 +9 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/package.json +58 -0
- package/src/index.ts +860 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Add a responsive custom Pi startup header.
|
|
6
|
+
- Summarize loaded context, skills, prompts, and extensions.
|
|
7
|
+
- Split extensions into local, package, and linked source-path groups.
|
|
8
|
+
- Show the full linked source path instead of ambiguous labels such as `src`.
|
|
9
|
+
- Restore Pi's native resource panel when startup data cannot be reproduced safely.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaushik Gopal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# @pi-kaush/pi-welcome-screen
|
|
2
|
+
|
|
3
|
+
A compact, centered startup screen for the [Pi coding agent](https://pi.dev). It keeps Pi's loaded context, skills, prompts, and extensions visible while replacing the stock header with a responsive branded layout.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Responsive stacked and wide terminal layouts
|
|
8
|
+
- Pi version displayed beside a compact banner
|
|
9
|
+
- Loaded resources grouped into readable sections
|
|
10
|
+
- Extensions split into Pi-local, installed-package, and linked source-path groups
|
|
11
|
+
- Full linked source paths shown instead of ambiguous labels such as `src`
|
|
12
|
+
- Unknown startup diagnostics preserved through a fail-safe fallback
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pi install npm:@pi-kaush/pi-welcome-screen@0.1.0
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Restart Pi or run `/reload`.
|
|
21
|
+
|
|
22
|
+
## Run from a local clone
|
|
23
|
+
|
|
24
|
+
From any project, point Pi at the extension's source file:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pi -e ~/path/to/pi-kaush/extensions/pi-welcome-screen/src/index.ts
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Use `--no-extensions` before `-e` to test it without your other configured extensions.
|
|
31
|
+
|
|
32
|
+
## Compatibility
|
|
33
|
+
|
|
34
|
+
The extension installs its header through Pi's public `ctx.ui.setHeader()` API. Pi 0.80.6 does not expose structured startup-resource data, so the extension also uses a narrowly guarded bridge to inspect and temporarily relocate Pi's native startup-resource panel.
|
|
35
|
+
|
|
36
|
+
Every expected component shape is checked before it is touched. If Pi changes the panel, exposes an unknown section, or produces incomplete resource data, the extension restores Pi's untouched native panel rather than hiding information. The initial release is tested against Pi 0.80.6.
|
|
37
|
+
|
|
38
|
+
Like any custom-header extension, it shares Pi's single header slot. If another extension also calls `setHeader()`, the last installed header wins; neither extension needs to replace the editor or intercept terminal input.
|
|
39
|
+
|
|
40
|
+
## Security and performance
|
|
41
|
+
|
|
42
|
+
The package contains readable TypeScript and has:
|
|
43
|
+
|
|
44
|
+
- no runtime dependencies or install scripts;
|
|
45
|
+
- no network, subprocess, clipboard, prompt, tool, model, or telemetry access;
|
|
46
|
+
- no background work after the startup resource snapshot completes.
|
|
47
|
+
|
|
48
|
+
At startup it reads only the names of entries in Pi's local extension directory and the text already rendered in Pi's startup-resource panel so extensions can be grouped by provenance. Resource capture uses at most three short 50 ms retries and is then disposed or replaced by Pi's native panel.
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
From the repository root:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
npm ci --ignore-scripts
|
|
56
|
+
npm run check
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Inspect the exact publish payload:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
npm pack --workspace @pi-kaush/pi-welcome-screen --dry-run
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-kaush/pi-welcome-screen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Replace Pi's startup header with centered branding and a compact resource summary.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Kaushik Gopal",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-package",
|
|
12
|
+
"pi-extension",
|
|
13
|
+
"welcome-screen",
|
|
14
|
+
"terminal-ui"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/kaushikgopal/pi-kaush/tree/main/extensions/pi-welcome-screen#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/kaushikgopal/pi-kaush/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/kaushikgopal/pi-kaush.git",
|
|
23
|
+
"directory": "extensions/pi-welcome-screen"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./src/index.ts"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": ">=0.80.6",
|
|
38
|
+
"@earendil-works/pi-tui": ">=0.80.6"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@earendil-works/pi-coding-agent": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"@earendil-works/pi-tui": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=20"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"package:check": "node ./scripts/check-package.mjs"
|
|
56
|
+
},
|
|
57
|
+
"sideEffects": false
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getAgentDir,
|
|
3
|
+
VERSION,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type Theme,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
type Component,
|
|
11
|
+
type Container,
|
|
12
|
+
type TUI,
|
|
13
|
+
truncateToWidth,
|
|
14
|
+
visibleWidth,
|
|
15
|
+
wrapTextWithAnsi,
|
|
16
|
+
} from "@earendil-works/pi-tui";
|
|
17
|
+
|
|
18
|
+
const MAX_STACKED_COLUMN_WIDTH = 80;
|
|
19
|
+
const MAX_RESOURCE_COLUMN_WIDTH = 80;
|
|
20
|
+
const BRAND_COLUMN_WIDTH = 40;
|
|
21
|
+
const WIDE_COLUMN_GAP = 4;
|
|
22
|
+
const MIN_WIDE_RESOURCE_COLUMN_WIDTH = 60;
|
|
23
|
+
const MIN_WIDE_LAYOUT_WIDTH =
|
|
24
|
+
BRAND_COLUMN_WIDTH + WIDE_COLUMN_GAP + MIN_WIDE_RESOURCE_COLUMN_WIDTH;
|
|
25
|
+
const MAX_WIDE_LAYOUT_WIDTH =
|
|
26
|
+
BRAND_COLUMN_WIDTH + WIDE_COLUMN_GAP + MAX_RESOURCE_COLUMN_WIDTH;
|
|
27
|
+
const MAX_LIST_ROWS_PER_COLUMN = 6;
|
|
28
|
+
const MIN_LIST_COLUMN_WIDTH = 22;
|
|
29
|
+
const LIST_COLUMN_GAP = 2;
|
|
30
|
+
const RESOURCE_POLL_INTERVAL_MS = 50;
|
|
31
|
+
const MAX_RESOURCE_RETRIES = 3;
|
|
32
|
+
const RESOURCE_PANEL_INDEX = 1;
|
|
33
|
+
const RESOURCE_BRIDGE_KEY = "__piKaushWelcomeScreenResourceBridge";
|
|
34
|
+
|
|
35
|
+
let cachedLocalExtensionNames: Set<string> | undefined;
|
|
36
|
+
|
|
37
|
+
const PI_BANNER = ["█████████", "███ ███", "██████ ███", "███ ███"];
|
|
38
|
+
|
|
39
|
+
type WelcomeSection = "Context" | "Skills" | "Prompts" | "Extensions";
|
|
40
|
+
const WELCOME_SECTIONS: readonly WelcomeSection[] = [
|
|
41
|
+
"Context",
|
|
42
|
+
"Skills",
|
|
43
|
+
"Prompts",
|
|
44
|
+
"Extensions",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export interface WelcomeResources {
|
|
48
|
+
context: string[];
|
|
49
|
+
skills: string[];
|
|
50
|
+
prompts: string[];
|
|
51
|
+
extensions: string[];
|
|
52
|
+
/** Extensions loaded from npm or git packages. */
|
|
53
|
+
packageExtensions?: string[];
|
|
54
|
+
/** Local extension entry points outside Pi's extension directories. */
|
|
55
|
+
sourceExtensions?: string[];
|
|
56
|
+
/** @deprecated Use packageExtensions. */
|
|
57
|
+
vendoredExtensions?: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface CollapsedTextComponent extends Component {
|
|
61
|
+
getCollapsedText?: () => string;
|
|
62
|
+
getExpandedText?: () => string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ResourcePanel extends Component {
|
|
66
|
+
children: Component[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ResourceBridge {
|
|
70
|
+
panel: ResourcePanel;
|
|
71
|
+
originalIndex: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ResourcePanelSnapshot {
|
|
75
|
+
resourceText: string;
|
|
76
|
+
expandedExtensionsText?: string;
|
|
77
|
+
requiresNativePanel: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type BridgeTui = TUI & {
|
|
81
|
+
[RESOURCE_BRIDGE_KEY]?: ResourceBridge;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function stripAnsi(text: string): string {
|
|
85
|
+
return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isResourcePanel(
|
|
89
|
+
component: Component | undefined,
|
|
90
|
+
): component is ResourcePanel {
|
|
91
|
+
if (!component || typeof component !== "object") return false;
|
|
92
|
+
return Array.isArray((component as Partial<Container>).children);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getSectionHeading(text: string): string | undefined {
|
|
96
|
+
return stripAnsi(text.split("\n", 1)[0] ?? "")
|
|
97
|
+
.trim()
|
|
98
|
+
.match(/^\[([^\]]+)\]$/)?.[1];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function inspectResourcePanel(panel: ResourcePanel): ResourcePanelSnapshot {
|
|
102
|
+
const sections: string[] = [];
|
|
103
|
+
let expandedExtensionsText: string | undefined;
|
|
104
|
+
|
|
105
|
+
for (const child of panel.children) {
|
|
106
|
+
const collapsible = child as CollapsedTextComponent;
|
|
107
|
+
if (typeof collapsible.getCollapsedText === "function") {
|
|
108
|
+
const text = collapsible.getCollapsedText();
|
|
109
|
+
const heading = getSectionHeading(text);
|
|
110
|
+
if (WELCOME_SECTIONS.some((section) => section === heading)) {
|
|
111
|
+
sections.push(text);
|
|
112
|
+
if (
|
|
113
|
+
(heading === "Extensions" ||
|
|
114
|
+
/(?:^|\n)\s*\[Extensions\]\s*(?:\n|$)/.test(stripAnsi(text))) &&
|
|
115
|
+
typeof collapsible.getExpandedText === "function"
|
|
116
|
+
) {
|
|
117
|
+
expandedExtensionsText = collapsible.getExpandedText();
|
|
118
|
+
}
|
|
119
|
+
} else if (heading !== "Themes") {
|
|
120
|
+
// Unknown native sections may contain actionable information. Keep
|
|
121
|
+
// Pi's panel intact rather than guessing how to reproduce them.
|
|
122
|
+
return { resourceText: "", requiresNativePanel: true };
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const hasVisibleContent = child
|
|
128
|
+
.render(1_000)
|
|
129
|
+
.some((line) => stripAnsi(line).trim().length > 0);
|
|
130
|
+
if (hasVisibleContent) {
|
|
131
|
+
return { resourceText: "", requiresNativePanel: true };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
resourceText: sections.join("\n"),
|
|
137
|
+
...(expandedExtensionsText ? { expandedExtensionsText } : {}),
|
|
138
|
+
requiresNativePanel: false,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function splitList(body: string[]): string[] {
|
|
143
|
+
return body
|
|
144
|
+
.join(" ")
|
|
145
|
+
.split(",")
|
|
146
|
+
.map((item) => item.trim())
|
|
147
|
+
.filter(Boolean);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function normalizeExtensionName(label: string): string {
|
|
151
|
+
let name = label.trim().replace(/^npm:/, "").replace(/\\/g, "/");
|
|
152
|
+
// Windows drive letters use a colon as part of the path, not as the
|
|
153
|
+
// separator between a package name and its extension entry point.
|
|
154
|
+
const packageSeparator = /^[A-Za-z]:\//.test(name) ? -1 : name.indexOf(":");
|
|
155
|
+
const isPackageLabel = packageSeparator !== -1;
|
|
156
|
+
if (isPackageLabel) name = name.slice(0, packageSeparator);
|
|
157
|
+
|
|
158
|
+
name = name.replace(/\/$/, "");
|
|
159
|
+
if (!isPackageLabel && !name.startsWith("@")) {
|
|
160
|
+
if (/\/(?:index)\.(?:[cm]?[jt]s)$/.test(name)) return name;
|
|
161
|
+
const segments = name.split("/").filter(Boolean);
|
|
162
|
+
const fileName = segments.pop() ?? name;
|
|
163
|
+
name = fileName;
|
|
164
|
+
}
|
|
165
|
+
return name.replace(/\.(?:[cm]?[jt]s)$/, "");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isWelcomeScreenExtension(name: string): boolean {
|
|
169
|
+
const normalized = name.replace(/\\/g, "/");
|
|
170
|
+
return (
|
|
171
|
+
name === "welcome-screen" ||
|
|
172
|
+
name === "pi-welcome-screen" ||
|
|
173
|
+
name === "@pi-kaush/pi-welcome-screen" ||
|
|
174
|
+
name === "src" ||
|
|
175
|
+
/\/(?:pi-)?welcome-screen(?:\/|$)/.test(normalized)
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function sortExtensionNames(names: string[]): string[] {
|
|
180
|
+
return [...names].sort((left, right) => {
|
|
181
|
+
const scopeOrder =
|
|
182
|
+
Number(left.startsWith("@")) - Number(right.startsWith("@"));
|
|
183
|
+
return scopeOrder || left.localeCompare(right);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function unique(items: string[]): string[] {
|
|
188
|
+
return [...new Set(items.filter(Boolean))];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface ExtensionGroups {
|
|
192
|
+
localExtensions: string[];
|
|
193
|
+
packageExtensions: string[];
|
|
194
|
+
sourceExtensions: string[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isPackageSource(label: string): boolean {
|
|
198
|
+
return label.startsWith("npm:") || label.startsWith("git:");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizePackageSource(label: string): string {
|
|
202
|
+
return label.replace(/^(?:npm|git):/, "");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isExplicitSourcePath(label: string): boolean {
|
|
206
|
+
const normalized = label.replace(/\\/g, "/");
|
|
207
|
+
return (
|
|
208
|
+
normalized.startsWith("/") ||
|
|
209
|
+
normalized.startsWith("~/") ||
|
|
210
|
+
normalized.startsWith("./") ||
|
|
211
|
+
normalized.startsWith("../") ||
|
|
212
|
+
/^[A-Za-z]:\//.test(normalized) ||
|
|
213
|
+
/\/(?:index)\.(?:[cm]?[jt]s)$/.test(normalized)
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseExpandedExtensionGroups(
|
|
218
|
+
text: string | undefined,
|
|
219
|
+
localExtensionNames: Set<string>,
|
|
220
|
+
): ExtensionGroups | undefined {
|
|
221
|
+
if (!text || getSectionHeading(text) !== "Extensions") return undefined;
|
|
222
|
+
|
|
223
|
+
const localExtensions: string[] = [];
|
|
224
|
+
const packageExtensions: string[] = [];
|
|
225
|
+
const sourceExtensions: string[] = [];
|
|
226
|
+
let foundItem = false;
|
|
227
|
+
|
|
228
|
+
for (const rawLine of text.split("\n").slice(1)) {
|
|
229
|
+
const line = stripAnsi(rawLine).replace(/\s+$/, "");
|
|
230
|
+
const packageSource = line.match(/^ {4}((?:npm|git):.+)$/)?.[1];
|
|
231
|
+
if (packageSource) {
|
|
232
|
+
packageExtensions.push(normalizePackageSource(packageSource));
|
|
233
|
+
foundItem = true;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const path = line.match(/^ {4}(\S.*)$/)?.[1];
|
|
238
|
+
if (!path || /^(?:project|user|path)$/.test(path)) continue;
|
|
239
|
+
|
|
240
|
+
const name = normalizeExtensionName(path);
|
|
241
|
+
if (
|
|
242
|
+
localExtensionNames.has(name) ||
|
|
243
|
+
/(?:^|\/)\.pi\/(?:agent\/)?extensions(?:\/|$)/.test(
|
|
244
|
+
path.replace(/\\/g, "/"),
|
|
245
|
+
)
|
|
246
|
+
) {
|
|
247
|
+
localExtensions.push(name);
|
|
248
|
+
} else {
|
|
249
|
+
sourceExtensions.push(path.replace(/\\/g, "/"));
|
|
250
|
+
}
|
|
251
|
+
foundItem = true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!foundItem) return undefined;
|
|
255
|
+
return {
|
|
256
|
+
localExtensions: sortExtensionNames(unique(localExtensions)),
|
|
257
|
+
packageExtensions: sortExtensionNames(unique(packageExtensions)),
|
|
258
|
+
sourceExtensions: sortExtensionNames(unique(sourceExtensions)),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function classifyCompactExtensionLabels(
|
|
263
|
+
labels: string[],
|
|
264
|
+
localExtensionNames: Set<string>,
|
|
265
|
+
): ExtensionGroups {
|
|
266
|
+
const localExtensions: string[] = [];
|
|
267
|
+
const packageExtensions: string[] = [];
|
|
268
|
+
const sourceExtensions: string[] = [];
|
|
269
|
+
|
|
270
|
+
for (const label of labels) {
|
|
271
|
+
const name = normalizeExtensionName(label);
|
|
272
|
+
const indexParent = label
|
|
273
|
+
.replace(/\\/g, "/")
|
|
274
|
+
.match(/(?:^|\/)([^/]+)\/index\.(?:[cm]?[jt]s)$/)?.[1];
|
|
275
|
+
if (
|
|
276
|
+
localExtensionNames.has(name) ||
|
|
277
|
+
localExtensionNames.has(indexParent ?? "")
|
|
278
|
+
)
|
|
279
|
+
localExtensions.push(name);
|
|
280
|
+
else if (isPackageSource(label) || name.startsWith("@"))
|
|
281
|
+
packageExtensions.push(
|
|
282
|
+
isPackageSource(label) ? normalizePackageSource(label) : name,
|
|
283
|
+
);
|
|
284
|
+
else if (isExplicitSourcePath(label)) sourceExtensions.push(name);
|
|
285
|
+
else packageExtensions.push(name);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
localExtensions: sortExtensionNames(unique(localExtensions)),
|
|
290
|
+
packageExtensions: sortExtensionNames(unique(packageExtensions)),
|
|
291
|
+
sourceExtensions: sortExtensionNames(unique(sourceExtensions)),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function getLocalExtensionNames(): Set<string> {
|
|
296
|
+
if (cachedLocalExtensionNames) return cachedLocalExtensionNames;
|
|
297
|
+
|
|
298
|
+
const extensionsDir = join(getAgentDir(), "extensions");
|
|
299
|
+
try {
|
|
300
|
+
cachedLocalExtensionNames = new Set(
|
|
301
|
+
readdirSync(extensionsDir, { withFileTypes: true }).flatMap((entry) => {
|
|
302
|
+
if (/\.[cm]?[jt]s$/.test(entry.name))
|
|
303
|
+
return normalizeExtensionName(entry.name);
|
|
304
|
+
if (
|
|
305
|
+
entry.isDirectory() &&
|
|
306
|
+
existsSync(join(extensionsDir, entry.name, "index.ts"))
|
|
307
|
+
) {
|
|
308
|
+
return entry.name;
|
|
309
|
+
}
|
|
310
|
+
return [];
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
} catch {
|
|
314
|
+
cachedLocalExtensionNames = new Set();
|
|
315
|
+
}
|
|
316
|
+
return cachedLocalExtensionNames;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function parseWelcomeResources(
|
|
320
|
+
text: string,
|
|
321
|
+
localExtensionNames = getLocalExtensionNames(),
|
|
322
|
+
expandedExtensionsText?: string,
|
|
323
|
+
): WelcomeResources {
|
|
324
|
+
const bodies = new Map<WelcomeSection, string[]>();
|
|
325
|
+
let currentSection: WelcomeSection | undefined;
|
|
326
|
+
|
|
327
|
+
for (const rawLine of text.split("\n")) {
|
|
328
|
+
const line = stripAnsi(rawLine).trim();
|
|
329
|
+
const header = line.match(/^\[([^\]]+)\]$/)?.[1];
|
|
330
|
+
if (header) {
|
|
331
|
+
currentSection = WELCOME_SECTIONS.find((section) => section === header);
|
|
332
|
+
if (currentSection && !bodies.has(currentSection))
|
|
333
|
+
bodies.set(currentSection, []);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (line && currentSection) bodies.get(currentSection)?.push(line);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const context = unique(splitList(bodies.get("Context") ?? []));
|
|
341
|
+
const skills = unique(splitList(bodies.get("Skills") ?? []));
|
|
342
|
+
const prompts = unique(splitList(bodies.get("Prompts") ?? []));
|
|
343
|
+
const extensionLabels = unique(splitList(bodies.get("Extensions") ?? []));
|
|
344
|
+
const groups =
|
|
345
|
+
parseExpandedExtensionGroups(expandedExtensionsText, localExtensionNames) ??
|
|
346
|
+
classifyCompactExtensionLabels(extensionLabels, localExtensionNames);
|
|
347
|
+
const extensions = [
|
|
348
|
+
...groups.localExtensions,
|
|
349
|
+
...groups.packageExtensions,
|
|
350
|
+
...groups.sourceExtensions,
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
context,
|
|
355
|
+
skills,
|
|
356
|
+
prompts,
|
|
357
|
+
extensions,
|
|
358
|
+
packageExtensions: groups.packageExtensions,
|
|
359
|
+
sourceExtensions: groups.sourceExtensions,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function takeResourcePanel(tui: TUI): ResourceBridge | undefined {
|
|
364
|
+
const host = tui as BridgeTui;
|
|
365
|
+
if (host[RESOURCE_BRIDGE_KEY]) return undefined;
|
|
366
|
+
|
|
367
|
+
// TODO: Replace this bridge when Pi exposes structured startup resources
|
|
368
|
+
// through its custom-header API.
|
|
369
|
+
// Pi 0.80 places the loaded-resources container immediately after the
|
|
370
|
+
// header. Guard the full shape so an upstream layout change falls back to
|
|
371
|
+
// Pi's untouched resource panel instead of moving an unrelated component.
|
|
372
|
+
if (tui.children.length < 8 || !isResourcePanel(tui.children[0]))
|
|
373
|
+
return undefined;
|
|
374
|
+
const panel = tui.children[RESOURCE_PANEL_INDEX];
|
|
375
|
+
if (!isResourcePanel(panel)) return undefined;
|
|
376
|
+
|
|
377
|
+
const bridge = { panel, originalIndex: RESOURCE_PANEL_INDEX };
|
|
378
|
+
tui.removeChild(panel);
|
|
379
|
+
host[RESOURCE_BRIDGE_KEY] = bridge;
|
|
380
|
+
return bridge;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function restoreResourcePanel(
|
|
384
|
+
tui: TUI,
|
|
385
|
+
bridge: ResourceBridge | undefined,
|
|
386
|
+
): void {
|
|
387
|
+
if (!bridge) return;
|
|
388
|
+
|
|
389
|
+
if (!tui.children.includes(bridge.panel)) {
|
|
390
|
+
const index = Math.min(bridge.originalIndex, tui.children.length);
|
|
391
|
+
tui.children.splice(index, 0, bridge.panel);
|
|
392
|
+
}
|
|
393
|
+
delete (tui as BridgeTui)[RESOURCE_BRIDGE_KEY];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function centerBlockLine(
|
|
397
|
+
line: string,
|
|
398
|
+
blockWidth: number,
|
|
399
|
+
width: number,
|
|
400
|
+
): string {
|
|
401
|
+
const clipped = truncateToWidth(line, width, "");
|
|
402
|
+
return (
|
|
403
|
+
" ".repeat(Math.max(0, Math.floor((width - blockWidth) / 2))) + clipped
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function wrapPrefixed(prefix: string, text: string, width: number): string[] {
|
|
408
|
+
const prefixWidth = visibleWidth(prefix);
|
|
409
|
+
if (width <= prefixWidth) return [truncateToWidth(prefix, width, "")];
|
|
410
|
+
|
|
411
|
+
const wrapped = wrapTextWithAnsi(text, width - prefixWidth);
|
|
412
|
+
const continuation = " ".repeat(prefixWidth);
|
|
413
|
+
return wrapped.map(
|
|
414
|
+
(line, index) => `${index === 0 ? prefix : continuation}${line}`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function appendSingleColumnRows(
|
|
419
|
+
lines: string[],
|
|
420
|
+
items: string[],
|
|
421
|
+
theme: Theme,
|
|
422
|
+
columnWidth: number,
|
|
423
|
+
): void {
|
|
424
|
+
for (const item of items) {
|
|
425
|
+
lines.push(
|
|
426
|
+
...wrapPrefixed(
|
|
427
|
+
theme.fg("dim", " • "),
|
|
428
|
+
theme.fg("dim", item),
|
|
429
|
+
columnWidth,
|
|
430
|
+
),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function getColumnWidths(listWidth: number, columnCount: number): number[] {
|
|
436
|
+
const totalCellWidth = listWidth - LIST_COLUMN_GAP * (columnCount - 1);
|
|
437
|
+
const baseCellWidth = Math.floor(totalCellWidth / columnCount);
|
|
438
|
+
const widerCellCount = totalCellWidth % columnCount;
|
|
439
|
+
return Array.from(
|
|
440
|
+
{ length: columnCount },
|
|
441
|
+
(_, index) => baseCellWidth + (index < widerCellCount ? 1 : 0),
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function canUseThreeColumns(items: string[], columnWidth: number): boolean {
|
|
446
|
+
const listWidth = Math.max(1, columnWidth - 2);
|
|
447
|
+
const fittingColumns = Math.floor(
|
|
448
|
+
(listWidth + LIST_COLUMN_GAP) / (MIN_LIST_COLUMN_WIDTH + LIST_COLUMN_GAP),
|
|
449
|
+
);
|
|
450
|
+
if (fittingColumns < 3) return false;
|
|
451
|
+
|
|
452
|
+
const rowsPerColumn = Math.ceil(items.length / 3);
|
|
453
|
+
const cellWidths = getColumnWidths(listWidth, 3);
|
|
454
|
+
return items.every((item, index) => {
|
|
455
|
+
const column = Math.floor(index / rowsPerColumn);
|
|
456
|
+
return visibleWidth(`• ${item}`) <= (cellWidths[column] ?? 0);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function getSharedMultiColumnCount(
|
|
461
|
+
resources: WelcomeResources,
|
|
462
|
+
columnWidth: number,
|
|
463
|
+
): 2 | 3 {
|
|
464
|
+
const packageExtensions = new Set(
|
|
465
|
+
resources.packageExtensions ??
|
|
466
|
+
resources.vendoredExtensions ??
|
|
467
|
+
resources.extensions.filter((name) => name.startsWith("@")),
|
|
468
|
+
);
|
|
469
|
+
const sourceExtensions = new Set(resources.sourceExtensions ?? []);
|
|
470
|
+
const localExtensions = resources.extensions.filter(
|
|
471
|
+
(name) => !packageExtensions.has(name) && !sourceExtensions.has(name),
|
|
472
|
+
);
|
|
473
|
+
const multiColumnLists = [resources.skills, localExtensions].filter(
|
|
474
|
+
(items) => items.length > MAX_LIST_ROWS_PER_COLUMN,
|
|
475
|
+
);
|
|
476
|
+
return multiColumnLists.every((items) =>
|
|
477
|
+
canUseThreeColumns(items, columnWidth),
|
|
478
|
+
)
|
|
479
|
+
? 3
|
|
480
|
+
: 2;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function appendColumnRows(
|
|
484
|
+
lines: string[],
|
|
485
|
+
items: string[],
|
|
486
|
+
theme: Theme,
|
|
487
|
+
columnWidth: number,
|
|
488
|
+
sharedColumnCount?: 2 | 3,
|
|
489
|
+
): void {
|
|
490
|
+
const listWidth = Math.max(1, columnWidth - 2);
|
|
491
|
+
const desiredColumns = Math.ceil(items.length / MAX_LIST_ROWS_PER_COLUMN);
|
|
492
|
+
const fittingColumns = Math.max(
|
|
493
|
+
1,
|
|
494
|
+
Math.floor(
|
|
495
|
+
(listWidth + LIST_COLUMN_GAP) / (MIN_LIST_COLUMN_WIDTH + LIST_COLUMN_GAP),
|
|
496
|
+
),
|
|
497
|
+
);
|
|
498
|
+
const requestedColumns =
|
|
499
|
+
sharedColumnCount && items.length > MAX_LIST_ROWS_PER_COLUMN
|
|
500
|
+
? sharedColumnCount
|
|
501
|
+
: desiredColumns;
|
|
502
|
+
const columnCount = Math.min(requestedColumns, fittingColumns);
|
|
503
|
+
|
|
504
|
+
if (columnCount === 1) {
|
|
505
|
+
appendSingleColumnRows(lines, items, theme, columnWidth);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const rowsPerColumn = Math.ceil(items.length / columnCount);
|
|
510
|
+
const cellWidths = getColumnWidths(listWidth, columnCount);
|
|
511
|
+
|
|
512
|
+
for (let row = 0; row < rowsPerColumn; row += 1) {
|
|
513
|
+
const cells = cellWidths.map((cellWidth, column) => {
|
|
514
|
+
const item = items[column * rowsPerColumn + row];
|
|
515
|
+
if (!item) return " ".repeat(cellWidth);
|
|
516
|
+
|
|
517
|
+
// truncateToWidth inserts ANSI resets around its ellipsis. Strip those
|
|
518
|
+
// because the complete row receives its muted color afterward; otherwise
|
|
519
|
+
// one truncated cell resets the color of every following column.
|
|
520
|
+
const cell = stripAnsi(truncateToWidth(`• ${item}`, cellWidth, "…"));
|
|
521
|
+
return cell + " ".repeat(Math.max(0, cellWidth - visibleWidth(cell)));
|
|
522
|
+
});
|
|
523
|
+
const rowText = ` ${cells.join(" ".repeat(LIST_COLUMN_GAP))}`.trimEnd();
|
|
524
|
+
lines.push(theme.fg("dim", rowText));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function appendSection(
|
|
529
|
+
lines: string[],
|
|
530
|
+
title: WelcomeSection,
|
|
531
|
+
body: string[],
|
|
532
|
+
theme: Theme,
|
|
533
|
+
columnWidth: number,
|
|
534
|
+
singleColumn = false,
|
|
535
|
+
sharedColumnCount?: 2 | 3,
|
|
536
|
+
): void {
|
|
537
|
+
if (lines.length > 0) lines.push("");
|
|
538
|
+
lines.push(theme.fg("mdHeading", `[${title}]`));
|
|
539
|
+
|
|
540
|
+
if (body.length === 0) {
|
|
541
|
+
lines.push(theme.fg("dim", " (none)"));
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (singleColumn) appendSingleColumnRows(lines, body, theme, columnWidth);
|
|
546
|
+
else appendColumnRows(lines, body, theme, columnWidth, sharedColumnCount);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function appendExtensionsSection(
|
|
550
|
+
lines: string[],
|
|
551
|
+
extensions: string[],
|
|
552
|
+
packageExtensionNames: string[] | undefined,
|
|
553
|
+
sourceExtensionNames: string[] | undefined,
|
|
554
|
+
theme: Theme,
|
|
555
|
+
columnWidth: number,
|
|
556
|
+
sharedColumnCount: 2 | 3,
|
|
557
|
+
): void {
|
|
558
|
+
if (lines.length > 0) lines.push("");
|
|
559
|
+
lines.push(theme.fg("mdHeading", "[Extensions]"));
|
|
560
|
+
|
|
561
|
+
if (extensions.length === 0) {
|
|
562
|
+
lines.push(theme.fg("dim", " (none)"));
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const packageExtensions = new Set(
|
|
567
|
+
// Keep direct callers that provide only `extensions` backward compatible.
|
|
568
|
+
packageExtensionNames ?? extensions.filter((name) => name.startsWith("@")),
|
|
569
|
+
);
|
|
570
|
+
const sourceExtensions = new Set(sourceExtensionNames ?? []);
|
|
571
|
+
const localExtensions = extensions.filter(
|
|
572
|
+
(name) => !packageExtensions.has(name) && !sourceExtensions.has(name),
|
|
573
|
+
);
|
|
574
|
+
const installedPackageExtensions = extensions.filter((name) =>
|
|
575
|
+
packageExtensions.has(name),
|
|
576
|
+
);
|
|
577
|
+
const linkedSourceExtensions = extensions.filter((name) =>
|
|
578
|
+
sourceExtensions.has(name),
|
|
579
|
+
);
|
|
580
|
+
const groups = [
|
|
581
|
+
{ title: "Local", items: localExtensions, multiColumn: true },
|
|
582
|
+
{
|
|
583
|
+
title: "Packages",
|
|
584
|
+
items: installedPackageExtensions,
|
|
585
|
+
multiColumn: false,
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
title: "Source paths",
|
|
589
|
+
items: linkedSourceExtensions,
|
|
590
|
+
multiColumn: false,
|
|
591
|
+
},
|
|
592
|
+
].filter(({ items }) => items.length > 0);
|
|
593
|
+
|
|
594
|
+
for (const [index, group] of groups.entries()) {
|
|
595
|
+
if (index > 0) lines.push("");
|
|
596
|
+
lines.push(theme.fg("muted", ` ${group.title}`));
|
|
597
|
+
if (group.multiColumn) {
|
|
598
|
+
appendColumnRows(
|
|
599
|
+
lines,
|
|
600
|
+
group.items,
|
|
601
|
+
theme,
|
|
602
|
+
columnWidth,
|
|
603
|
+
sharedColumnCount,
|
|
604
|
+
);
|
|
605
|
+
} else {
|
|
606
|
+
appendSingleColumnRows(lines, group.items, theme, columnWidth);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function renderBrandColumn(theme: Theme, columnWidth: number): string[] {
|
|
612
|
+
const lines: string[] = [];
|
|
613
|
+
const bannerWidth = Math.max(...PI_BANNER.map((line) => visibleWidth(line)));
|
|
614
|
+
for (const bannerLine of PI_BANNER) {
|
|
615
|
+
lines.push(
|
|
616
|
+
centerBlockLine(
|
|
617
|
+
theme.bold(theme.fg("accent", bannerLine)),
|
|
618
|
+
bannerWidth,
|
|
619
|
+
columnWidth,
|
|
620
|
+
),
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
lines.push("");
|
|
624
|
+
const versionSummary = theme.fg("dim", `v${VERSION}`);
|
|
625
|
+
lines.push(
|
|
626
|
+
centerBlockLine(versionSummary, visibleWidth(versionSummary), columnWidth),
|
|
627
|
+
);
|
|
628
|
+
return lines;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function renderResourceColumn(
|
|
632
|
+
resources: WelcomeResources,
|
|
633
|
+
theme: Theme,
|
|
634
|
+
columnWidth: number,
|
|
635
|
+
): string[] {
|
|
636
|
+
const lines: string[] = [];
|
|
637
|
+
const sharedColumnCount = getSharedMultiColumnCount(resources, columnWidth);
|
|
638
|
+
appendSection(lines, "Context", resources.context, theme, columnWidth, true);
|
|
639
|
+
appendSection(
|
|
640
|
+
lines,
|
|
641
|
+
"Skills",
|
|
642
|
+
resources.skills,
|
|
643
|
+
theme,
|
|
644
|
+
columnWidth,
|
|
645
|
+
false,
|
|
646
|
+
sharedColumnCount,
|
|
647
|
+
);
|
|
648
|
+
appendSection(lines, "Prompts", resources.prompts, theme, columnWidth);
|
|
649
|
+
appendExtensionsSection(
|
|
650
|
+
lines,
|
|
651
|
+
resources.extensions,
|
|
652
|
+
resources.packageExtensions ?? resources.vendoredExtensions,
|
|
653
|
+
resources.sourceExtensions,
|
|
654
|
+
theme,
|
|
655
|
+
columnWidth,
|
|
656
|
+
sharedColumnCount,
|
|
657
|
+
);
|
|
658
|
+
return lines;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function renderStackedWelcome(
|
|
662
|
+
resources: WelcomeResources | undefined,
|
|
663
|
+
theme: Theme,
|
|
664
|
+
columnWidth: number,
|
|
665
|
+
): string[] {
|
|
666
|
+
const lines = ["", ...renderBrandColumn(theme, columnWidth)];
|
|
667
|
+
if (resources)
|
|
668
|
+
lines.push("", ...renderResourceColumn(resources, theme, columnWidth));
|
|
669
|
+
lines.push("");
|
|
670
|
+
return lines;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function padToWidth(text: string, width: number): string {
|
|
674
|
+
const clipped = truncateToWidth(text, width, "");
|
|
675
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function renderWideWelcome(
|
|
679
|
+
resources: WelcomeResources,
|
|
680
|
+
theme: Theme,
|
|
681
|
+
layoutWidth: number,
|
|
682
|
+
): string[] {
|
|
683
|
+
const resourceColumnWidth =
|
|
684
|
+
layoutWidth - BRAND_COLUMN_WIDTH - WIDE_COLUMN_GAP;
|
|
685
|
+
const brandLines = renderBrandColumn(theme, BRAND_COLUMN_WIDTH);
|
|
686
|
+
const resourceLines = renderResourceColumn(
|
|
687
|
+
resources,
|
|
688
|
+
theme,
|
|
689
|
+
resourceColumnWidth,
|
|
690
|
+
);
|
|
691
|
+
const rowCount = Math.max(brandLines.length, resourceLines.length);
|
|
692
|
+
const brandTopPadding = Math.floor((rowCount - brandLines.length) / 2);
|
|
693
|
+
|
|
694
|
+
return Array.from({ length: rowCount }, (_, row) => {
|
|
695
|
+
const brandLine = brandLines[row - brandTopPadding] ?? "";
|
|
696
|
+
const resourceLine = resourceLines[row] ?? "";
|
|
697
|
+
const combined = [
|
|
698
|
+
padToWidth(brandLine, BRAND_COLUMN_WIDTH),
|
|
699
|
+
" ".repeat(WIDE_COLUMN_GAP),
|
|
700
|
+
truncateToWidth(resourceLine, resourceColumnWidth, ""),
|
|
701
|
+
].join("");
|
|
702
|
+
return combined.trimEnd();
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export function renderCenteredWelcome(
|
|
707
|
+
resources: WelcomeResources | undefined,
|
|
708
|
+
theme: Theme,
|
|
709
|
+
width: number,
|
|
710
|
+
): string[] {
|
|
711
|
+
if (width <= 0) return [];
|
|
712
|
+
const useWideLayout =
|
|
713
|
+
resources !== undefined && width >= MIN_WIDE_LAYOUT_WIDTH;
|
|
714
|
+
const layoutWidth = useWideLayout
|
|
715
|
+
? Math.min(MAX_WIDE_LAYOUT_WIDTH, width)
|
|
716
|
+
: Math.min(MAX_STACKED_COLUMN_WIDTH, width);
|
|
717
|
+
const leftPadding = " ".repeat(Math.floor((width - layoutWidth) / 2));
|
|
718
|
+
const lines =
|
|
719
|
+
useWideLayout && resources
|
|
720
|
+
? renderWideWelcome(resources, theme, layoutWidth)
|
|
721
|
+
: renderStackedWelcome(resources, theme, layoutWidth);
|
|
722
|
+
|
|
723
|
+
return lines.map((line) =>
|
|
724
|
+
line ? leftPadding + truncateToWidth(line, layoutWidth, "") : "",
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
class WelcomeHeader implements Component {
|
|
729
|
+
private resourceReadyTimer: ReturnType<typeof setTimeout> | undefined;
|
|
730
|
+
private resources: WelcomeResources | undefined;
|
|
731
|
+
private cachedWidth: number | undefined;
|
|
732
|
+
private cachedLines: string[] | undefined;
|
|
733
|
+
private disposed = false;
|
|
734
|
+
|
|
735
|
+
constructor(
|
|
736
|
+
private readonly tui: TUI,
|
|
737
|
+
private readonly theme: Theme,
|
|
738
|
+
private readonly bridge: ResourceBridge | undefined,
|
|
739
|
+
forceInitialRender: boolean,
|
|
740
|
+
) {
|
|
741
|
+
// session_start runs just before Pi populates its loaded-resource panel.
|
|
742
|
+
this.resourceReadyTimer = setTimeout(
|
|
743
|
+
() => this.captureResourcesWhenReady(forceInitialRender, 0),
|
|
744
|
+
0,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private captureResourcesWhenReady(
|
|
749
|
+
forceInitialRender: boolean,
|
|
750
|
+
attempt: number,
|
|
751
|
+
): void {
|
|
752
|
+
if (this.disposed) return;
|
|
753
|
+
if (!this.bridge) {
|
|
754
|
+
this.resourceReadyTimer = undefined;
|
|
755
|
+
if (attempt === 0) this.tui.requestRender(forceInitialRender);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
let snapshot: ResourcePanelSnapshot;
|
|
760
|
+
try {
|
|
761
|
+
snapshot = inspectResourcePanel(this.bridge.panel);
|
|
762
|
+
} catch {
|
|
763
|
+
this.showNativePanel(forceInitialRender);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (snapshot.requiresNativePanel) {
|
|
767
|
+
this.showNativePanel(forceInitialRender);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const { resourceText, expandedExtensionsText } = snapshot;
|
|
772
|
+
const candidateResources = resourceText
|
|
773
|
+
? parseWelcomeResources(
|
|
774
|
+
resourceText,
|
|
775
|
+
getLocalExtensionNames(),
|
|
776
|
+
expandedExtensionsText,
|
|
777
|
+
)
|
|
778
|
+
: undefined;
|
|
779
|
+
// Pi 0.80 builds this panel synchronously. If a future Pi populates it
|
|
780
|
+
// asynchronously after Extensions appears, this snapshot can be incomplete.
|
|
781
|
+
const resourcePanelIsComplete = Boolean(
|
|
782
|
+
candidateResources?.extensions.some(isWelcomeScreenExtension),
|
|
783
|
+
);
|
|
784
|
+
if (resourcePanelIsComplete) {
|
|
785
|
+
this.resources = candidateResources;
|
|
786
|
+
this.clearRenderCache();
|
|
787
|
+
this.resourceReadyTimer = undefined;
|
|
788
|
+
this.tui.requestRender(forceInitialRender);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (attempt === 0) {
|
|
793
|
+
// Remove Pi's pre-TUI model-scope line while leaving resource sections
|
|
794
|
+
// hidden until the complete panel can be revealed atomically.
|
|
795
|
+
this.tui.requestRender(forceInitialRender);
|
|
796
|
+
}
|
|
797
|
+
if (attempt < MAX_RESOURCE_RETRIES) {
|
|
798
|
+
this.resourceReadyTimer = setTimeout(
|
|
799
|
+
() => this.captureResourcesWhenReady(false, attempt + 1),
|
|
800
|
+
RESOURCE_POLL_INTERVAL_MS,
|
|
801
|
+
);
|
|
802
|
+
} else {
|
|
803
|
+
this.showNativePanel(false);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
private showNativePanel(forceRender: boolean): void {
|
|
808
|
+
this.resourceReadyTimer = undefined;
|
|
809
|
+
restoreResourcePanel(this.tui, this.bridge);
|
|
810
|
+
this.clearRenderCache();
|
|
811
|
+
this.tui.requestRender(forceRender);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private clearRenderCache(): void {
|
|
815
|
+
this.cachedWidth = undefined;
|
|
816
|
+
this.cachedLines = undefined;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
render(width: number): string[] {
|
|
820
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
821
|
+
return this.cachedLines;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const resources = this.resources;
|
|
825
|
+
const lines = renderCenteredWelcome(resources, this.theme, width);
|
|
826
|
+
if (resources) {
|
|
827
|
+
this.cachedWidth = width;
|
|
828
|
+
this.cachedLines = lines;
|
|
829
|
+
}
|
|
830
|
+
return lines;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
invalidate(): void {
|
|
834
|
+
this.clearRenderCache();
|
|
835
|
+
this.bridge?.panel.invalidate();
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
dispose(): void {
|
|
839
|
+
if (this.disposed) return;
|
|
840
|
+
this.disposed = true;
|
|
841
|
+
if (this.resourceReadyTimer) clearTimeout(this.resourceReadyTimer);
|
|
842
|
+
restoreResourcePanel(this.tui, this.bridge);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export default function (pi: ExtensionAPI) {
|
|
847
|
+
pi.on("session_start", (event, ctx) => {
|
|
848
|
+
if (ctx.mode !== "tui") return;
|
|
849
|
+
|
|
850
|
+
ctx.ui.setHeader(
|
|
851
|
+
(tui, theme) =>
|
|
852
|
+
new WelcomeHeader(
|
|
853
|
+
tui,
|
|
854
|
+
theme,
|
|
855
|
+
takeResourcePanel(tui),
|
|
856
|
+
event.reason === "startup",
|
|
857
|
+
),
|
|
858
|
+
);
|
|
859
|
+
});
|
|
860
|
+
}
|