pi-quick-context 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/LICENSE +21 -0
- package/README.md +62 -0
- package/package.json +45 -0
- package/src/index.ts +559 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 cris7ian
|
|
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,62 @@
|
|
|
1
|
+
# pi-quick-context
|
|
2
|
+
|
|
3
|
+
Print the session context inline in [pi](https://pi.dev), the same way `/hotkeys` does — without depending on the startup header or `quietStartup`.
|
|
4
|
+
|
|
5
|
+
That way you can set `quietStartup` to `true` for a clean ui but check the context on-demand without inspecting dofiles or spending tokens.
|
|
6
|
+
|
|
7
|
+
## What it shows
|
|
8
|
+
|
|
9
|
+
- **Session** — model, thinking level, cwd, session id
|
|
10
|
+
- **Keys** — config-aware keybinding hints (respects your `keybindings.json`)
|
|
11
|
+
- **Context Files** — loaded `AGENTS.md` / `CLAUDE.md` files
|
|
12
|
+
- **Skills** — all loaded skills
|
|
13
|
+
- **Prompts** — prompt templates from the agent dir, project, settings, and installed packages
|
|
14
|
+
- **Extensions** — enabled configured extensions in the startup header's label format (`pkg`, `pkg:file`, `pkg:dir`)
|
|
15
|
+
- **Tools** — the currently active tool set
|
|
16
|
+
|
|
17
|
+
Nothing the extension prints is sent to the model: content is rendered as a TUI-only custom entry.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
| Command | What it prints |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `/context` | Full context: session, keys, context files, skills, prompts, extensions, tools |
|
|
24
|
+
| `/context skills` | All skill names (expand with `ctrl+o` to see descriptions) |
|
|
25
|
+
| `/context prompts` | All prompt template names |
|
|
26
|
+
| `/context extensions` | All enabled extension candidates |
|
|
27
|
+
| `Ctrl+Shift+H` | Same as `/context` |
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Local checkout (development)
|
|
33
|
+
pi install ~/Developer/pi-quick-context
|
|
34
|
+
|
|
35
|
+
# From GitHub
|
|
36
|
+
pi install git:github.com/<user>/pi-quick-context
|
|
37
|
+
|
|
38
|
+
# From npm
|
|
39
|
+
pi install npm:pi-quick-context
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then run `/reload` in an interactive pi session (or restart pi). Uninstall with `pi remove`.
|
|
43
|
+
|
|
44
|
+
## How it gets the data
|
|
45
|
+
|
|
46
|
+
- Skills, prompts, and tools come from pi's live runtime, so filtering, trust, reloads, and active-resource changes are already applied.
|
|
47
|
+
- Context files use live system-prompt options when available. The hotkey falls back to the current effective system prompt.
|
|
48
|
+
- Extensions use pi's settings-aware package resolver. It applies scopes, trust, filters, globs, conventions, and npm/git/local package resolution without loading extension code twice.
|
|
49
|
+
|
|
50
|
+
Pi does not currently expose the final loaded-extension list to extensions. The extension section therefore shows enabled candidates and cannot include temporary CLI or inline extensions, or exclude a candidate that failed during loading.
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pi -e src/index.ts # try it without installing
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The entry point is `src/index.ts`. It has no runtime dependencies beyond the core `@earendil-works/pi-coding-agent` peer package.
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-quick-context",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Print session context inline in pi: loaded context files, skills, prompt templates, extensions, tools, and keybinding hints.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"context"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/cris7ian/pi-quick-context.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/cris7ian/pi-quick-context",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/cris7ian/pi-quick-context/issues"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.19.0"
|
|
28
|
+
},
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./src/index.ts"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --test 'tests/*.test.ts'",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.19.0",
|
|
43
|
+
"typescript": "^5.7.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-quick-context
|
|
3
|
+
*
|
|
4
|
+
* Prints the session context inline in the chat, like /hotkeys:
|
|
5
|
+
* loaded context files, skills, prompt templates, extension candidates, active tools,
|
|
6
|
+
* session identity, and keybinding hints. Works independently of quietStartup.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* /context Full context (session, keys, files, skills, prompts, extensions, tools)
|
|
10
|
+
* /context skills All skill names
|
|
11
|
+
* /context prompts All prompt template names
|
|
12
|
+
* /context extensions All enabled extension candidates
|
|
13
|
+
* Ctrl+Shift+H Same as /context
|
|
14
|
+
*
|
|
15
|
+
* Skills, prompts, and tools come from pi's live runtime. Context files use
|
|
16
|
+
* live system-prompt options when available, falling back to the effective
|
|
17
|
+
* system prompt string. Extension candidates come from pi's settings-aware
|
|
18
|
+
* package resolver.
|
|
19
|
+
*
|
|
20
|
+
* Install:
|
|
21
|
+
* pi install ~/Developer/pi-quick-context # local checkout
|
|
22
|
+
* pi install git:github.com/<user>/pi-quick-context # from GitHub
|
|
23
|
+
* pi install npm:pi-quick-context # from npm
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
BuildSystemPromptOptions,
|
|
28
|
+
ExtensionAPI,
|
|
29
|
+
ExtensionCommandContext,
|
|
30
|
+
ExtensionContext,
|
|
31
|
+
Theme,
|
|
32
|
+
} from "@earendil-works/pi-coding-agent";
|
|
33
|
+
import {
|
|
34
|
+
DefaultPackageManager,
|
|
35
|
+
getAgentDir,
|
|
36
|
+
keyHint,
|
|
37
|
+
keyText,
|
|
38
|
+
rawKeyHint,
|
|
39
|
+
SettingsManager,
|
|
40
|
+
} from "@earendil-works/pi-coding-agent";
|
|
41
|
+
import { basename, dirname, relative } from "path";
|
|
42
|
+
|
|
43
|
+
/** Cap for skills in the full /context view; /context skills is uncapped. */
|
|
44
|
+
const SKILL_CAP = 40;
|
|
45
|
+
|
|
46
|
+
export function unescapeXml(value: string): string {
|
|
47
|
+
return value
|
|
48
|
+
.replace(/"/g, '"')
|
|
49
|
+
.replace(/'/g, "'")
|
|
50
|
+
.replace(/</g, "<")
|
|
51
|
+
.replace(/>/g, ">")
|
|
52
|
+
.replace(/&/g, "&");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Recover loaded context files from the effective system prompt when
|
|
57
|
+
* structured options are unavailable on the hotkey path.
|
|
58
|
+
*
|
|
59
|
+
* NOTE: This is a heuristic fallback that tracks the current pi prompt tags.
|
|
60
|
+
* A pi format change breaks it silently; structured options always win.
|
|
61
|
+
*/
|
|
62
|
+
export function parseFromPrompt(prompt: string): {
|
|
63
|
+
contextFiles: string[];
|
|
64
|
+
} {
|
|
65
|
+
const contextFiles: string[] = [];
|
|
66
|
+
for (const match of prompt.matchAll(/<project_instructions path="([^"]*)">/g)) {
|
|
67
|
+
contextFiles.push(unescapeXml(match[1]));
|
|
68
|
+
}
|
|
69
|
+
return { contextFiles };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Render helpers (ANSI-safe, width-aware)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
// --- Display width -----------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** Approximate terminal column width for a code point (0, 1, or 2 columns). */
|
|
79
|
+
export function isWideCodePoint(cp: number): boolean {
|
|
80
|
+
return (
|
|
81
|
+
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
82
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) || // CJK radicals .. Yijing hexagrams
|
|
83
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
|
|
84
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
|
|
85
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
|
|
86
|
+
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
|
|
87
|
+
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
|
|
88
|
+
(cp >= 0x1f300 && cp <= 0x1faff) || // Emoji (most)
|
|
89
|
+
cp >= 0x20000 // CJK extensions B+
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function charDisplayWidth(ch: string): number {
|
|
94
|
+
// Combining marks, zero-width spaces, variation selectors, BOM: 0 columns.
|
|
95
|
+
if (/[\u0300-\u036f\u200b-\u200f\ufe00-\ufe0f\ufeff]/.test(ch)) {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
return isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Visible column width of a line, ignoring ANSI escape sequences. */
|
|
102
|
+
export function displayWidth(text: string): number {
|
|
103
|
+
let width = 0;
|
|
104
|
+
let inEscape = false;
|
|
105
|
+
for (const ch of text) {
|
|
106
|
+
if (inEscape) {
|
|
107
|
+
if (/[a-zA-Z]/.test(ch)) {
|
|
108
|
+
inEscape = false;
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (ch === "\x1b") {
|
|
113
|
+
inEscape = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
width += charDisplayWidth(ch);
|
|
117
|
+
}
|
|
118
|
+
return width;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Wrapping / fitting -------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/** Wrap a list of short words into lines no wider than maxWidth columns. */
|
|
124
|
+
export function wrapWords(words: string[], maxWidth: number, prefix = ""): string[] {
|
|
125
|
+
const lines: string[] = [];
|
|
126
|
+
let current = prefix;
|
|
127
|
+
let currentWidth = displayWidth(prefix);
|
|
128
|
+
for (const word of words) {
|
|
129
|
+
const sep = current === prefix || current === "" ? "" : " ";
|
|
130
|
+
const wordWidth = displayWidth(word);
|
|
131
|
+
if (currentWidth + sep.length + wordWidth > maxWidth && current !== prefix) {
|
|
132
|
+
lines.push(current);
|
|
133
|
+
current = `${prefix}${word}`;
|
|
134
|
+
currentWidth = displayWidth(current);
|
|
135
|
+
} else {
|
|
136
|
+
current += sep + word;
|
|
137
|
+
currentWidth += sep.length + wordWidth;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (current !== "") {
|
|
141
|
+
lines.push(current);
|
|
142
|
+
}
|
|
143
|
+
return lines;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Truncate a line to the given visual width, ignoring ANSI escape sequences.
|
|
148
|
+
* Never cuts inside an escape sequence or a multibyte character. Re-closes
|
|
149
|
+
* styling with a reset when truncation drops escape sequences, so colors do
|
|
150
|
+
* not bleed past the ellipsis.
|
|
151
|
+
*/
|
|
152
|
+
export function fit(line: string, width: number): string {
|
|
153
|
+
if (width <= 0) {
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
const chars = Array.from(line); // Split into code points; surrogate pairs stay intact.
|
|
157
|
+
let visual = 0;
|
|
158
|
+
let cut = chars.length;
|
|
159
|
+
let sawEscape = false;
|
|
160
|
+
for (let i = 0; i < chars.length; i++) {
|
|
161
|
+
if (chars[i] === "\x1b") {
|
|
162
|
+
// Skip the full escape sequence: \x1b[ <params> <letter> (e.g. \x1b[38;2;128;128;128m).
|
|
163
|
+
sawEscape = true;
|
|
164
|
+
let j = i + 1;
|
|
165
|
+
while (j < chars.length && !/[a-zA-Z]/.test(chars[j])) {
|
|
166
|
+
j++;
|
|
167
|
+
}
|
|
168
|
+
i = j;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
visual += charDisplayWidth(chars[i]);
|
|
172
|
+
if (visual > width - 1) {
|
|
173
|
+
// Keep the last column for the ellipsis.
|
|
174
|
+
cut = i;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (cut >= chars.length) {
|
|
179
|
+
return line;
|
|
180
|
+
}
|
|
181
|
+
// Cut positions are always visible characters, so the kept prefix contains
|
|
182
|
+
// only complete escape sequences; a reset safely closes any style whose
|
|
183
|
+
// terminator was cut away.
|
|
184
|
+
return `${chars.slice(0, cut).join("")}…${sawEscape ? "\x1b[0m" : ""}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Resource enumeration
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** npm package name from a settings spec: "npm:pkg", "npm:pkg@1.0.0", "npm:@scope/pkg@1" */
|
|
192
|
+
export function npmName(spec: string): string {
|
|
193
|
+
const name = spec.replace(/^npm:/, "");
|
|
194
|
+
if (name.startsWith("@")) {
|
|
195
|
+
const parts = name.split("@");
|
|
196
|
+
return parts.slice(0, 2).join("@");
|
|
197
|
+
}
|
|
198
|
+
return name.split("@")[0];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Git clone path from a settings spec: "git:github.com/u/r@v1", "https://…", "ssh://…" */
|
|
202
|
+
export function gitPath(spec: string): string {
|
|
203
|
+
const cleaned = spec
|
|
204
|
+
.replace(/^git:/, "")
|
|
205
|
+
.replace(/^ssh:\/\//, "")
|
|
206
|
+
.replace(/^https?:\/\//, "")
|
|
207
|
+
.replace(/^git@/, "")
|
|
208
|
+
.replace(":", "/")
|
|
209
|
+
.split("@")[0]
|
|
210
|
+
.replace(/\.git$/, "");
|
|
211
|
+
return cleaned
|
|
212
|
+
.split("/")
|
|
213
|
+
.filter(Boolean)
|
|
214
|
+
.join("/");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** List prompt template command names (without the leading slash). */
|
|
218
|
+
function listPrompts(pi: ExtensionAPI): string[] {
|
|
219
|
+
return [...new Set(pi.getCommands().filter((command) => command.source === "prompt").map((command) => command.name))].sort();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Extension display label, matching the startup header:
|
|
224
|
+
* - a package's root ./index.ts renders as just the package name
|
|
225
|
+
* - dir/index.ts renders as "package:dir"
|
|
226
|
+
* - any other file renders as "package:filename"
|
|
227
|
+
* - a plain file renders as its basename
|
|
228
|
+
*/
|
|
229
|
+
export function extensionLabel(pkg: string, rel: string): string {
|
|
230
|
+
const norm = rel.replace(/^(\.\/)+/, "").replace(/^extensions\//, "");
|
|
231
|
+
const base = basename(norm);
|
|
232
|
+
if (base === "index.ts" || base === "index.js") {
|
|
233
|
+
const dir = dirname(norm);
|
|
234
|
+
return dir === "." ? pkg : `${pkg}:${dir}`;
|
|
235
|
+
}
|
|
236
|
+
return `${pkg}:${norm}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function resolvedExtensionLabel(
|
|
240
|
+
path: string,
|
|
241
|
+
metadata: { source: string; origin: "package" | "top-level"; baseDir?: string },
|
|
242
|
+
): string {
|
|
243
|
+
const rel = relative(metadata.baseDir ?? dirname(path), path).replace(/\\/g, "/");
|
|
244
|
+
if (metadata.source.startsWith("npm:")) {
|
|
245
|
+
return extensionLabel(npmName(metadata.source), rel);
|
|
246
|
+
}
|
|
247
|
+
if (/^(git:|https?:\/\/|ssh:\/\/|git@)/.test(metadata.source)) {
|
|
248
|
+
const parts = gitPath(metadata.source).split("/");
|
|
249
|
+
return extensionLabel(parts.slice(1).join("/") || parts[0] || metadata.source, rel);
|
|
250
|
+
}
|
|
251
|
+
if (metadata.origin === "package" && metadata.baseDir) {
|
|
252
|
+
return extensionLabel(basename(metadata.baseDir), rel);
|
|
253
|
+
}
|
|
254
|
+
const norm = rel.replace(/^extensions\//, "");
|
|
255
|
+
const base = basename(norm);
|
|
256
|
+
if (base !== "index.ts" && base !== "index.js") {
|
|
257
|
+
return norm;
|
|
258
|
+
}
|
|
259
|
+
const dir = dirname(norm);
|
|
260
|
+
return dir === "." ? basename(metadata.baseDir ?? dirname(path)) : dir;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Resolve enabled extension candidates without executing their factories. */
|
|
264
|
+
export async function listResolvedExtensions(cwd: string, agentDir: string, projectTrusted: boolean): Promise<string[]> {
|
|
265
|
+
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
|
|
266
|
+
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
|
267
|
+
const resolved = await packageManager.resolve(async () => "skip");
|
|
268
|
+
const records = resolved.extensions
|
|
269
|
+
.filter((resource) => resource.enabled)
|
|
270
|
+
.map((resource) => ({
|
|
271
|
+
resource,
|
|
272
|
+
label: resolvedExtensionLabel(resource.path, resource.metadata),
|
|
273
|
+
}));
|
|
274
|
+
const labelCounts = new Map<string, number>();
|
|
275
|
+
const scopedLabelCounts = new Map<string, number>();
|
|
276
|
+
for (const record of records) {
|
|
277
|
+
labelCounts.set(record.label, (labelCounts.get(record.label) ?? 0) + 1);
|
|
278
|
+
const scopedLabel = `${record.resource.metadata.scope}:${record.label}`;
|
|
279
|
+
scopedLabelCounts.set(scopedLabel, (scopedLabelCounts.get(scopedLabel) ?? 0) + 1);
|
|
280
|
+
}
|
|
281
|
+
return records.map((record, index) => {
|
|
282
|
+
if (labelCounts.get(record.label) === 1) {
|
|
283
|
+
return record.label;
|
|
284
|
+
}
|
|
285
|
+
const scopedLabel = `${record.resource.metadata.scope}:${record.label}`;
|
|
286
|
+
if (scopedLabelCounts.get(scopedLabel) === 1) {
|
|
287
|
+
return scopedLabel;
|
|
288
|
+
}
|
|
289
|
+
const segments = record.resource.path.replace(/\\/g, "/").split("/").filter(Boolean);
|
|
290
|
+
for (let count = 2; count <= segments.length; count++) {
|
|
291
|
+
const suffix = segments.slice(-count).join("/");
|
|
292
|
+
const unique = records.every((other, otherIndex) => {
|
|
293
|
+
if (otherIndex === index || other.label !== record.label || other.resource.metadata.scope !== record.resource.metadata.scope) {
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
return !other.resource.path.replace(/\\/g, "/").endsWith(suffix);
|
|
297
|
+
});
|
|
298
|
+
if (unique) {
|
|
299
|
+
return `${record.resource.metadata.scope}:${suffix}`;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return `${record.resource.metadata.scope}:${record.resource.path.replace(/\\/g, "/")}`;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** List enabled extension candidates using pi's resolver. */
|
|
307
|
+
async function listExtensions(ctx: ExtensionContext): Promise<string[]> {
|
|
308
|
+
try {
|
|
309
|
+
return await listResolvedExtensions(ctx.cwd, getAgentDir(), ctx.isProjectTrusted());
|
|
310
|
+
} catch {
|
|
311
|
+
ctx.ui.notify("Could not resolve extension candidates.", "warning");
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Entry data
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
/** A generic section whose items are printed wrapped, one line per word-block. */
|
|
321
|
+
interface ItemSection {
|
|
322
|
+
kind: "items";
|
|
323
|
+
title: string;
|
|
324
|
+
items: string[];
|
|
325
|
+
/** Prefix each item with "/" when printed. */
|
|
326
|
+
slash?: boolean;
|
|
327
|
+
/** Per-item descriptions shown when the entry is expanded (ctrl+o). */
|
|
328
|
+
descriptions?: Record<string, string>;
|
|
329
|
+
/** Set when items were capped; rendered as a dim trailing line instead of a fake item. */
|
|
330
|
+
truncatedTo?: string;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
interface ContextEntryData {
|
|
334
|
+
meta?: {
|
|
335
|
+
modelLabel: string;
|
|
336
|
+
thinking: string;
|
|
337
|
+
cwd: string;
|
|
338
|
+
sessionId: string;
|
|
339
|
+
sessionName: string;
|
|
340
|
+
};
|
|
341
|
+
sections: ItemSection[];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** First available list, preserving an authoritative empty result. */
|
|
345
|
+
export function firstAvailable<T>(...values: (T[] | undefined)[]): T[] {
|
|
346
|
+
for (const value of values) {
|
|
347
|
+
if (value !== undefined) {
|
|
348
|
+
return value;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function toHomePath(path: string): string {
|
|
355
|
+
const home = process.env.HOME;
|
|
356
|
+
if (home && path.startsWith(home + "/")) {
|
|
357
|
+
return `~${path.slice(home.length)}`;
|
|
358
|
+
}
|
|
359
|
+
return path;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Gather skills and files through fallbacks, plus the authoritative live tools. */
|
|
363
|
+
function gatherResources(pi: ExtensionAPI, ctx: ExtensionContext, options?: BuildSystemPromptOptions) {
|
|
364
|
+
const liveSkills = pi
|
|
365
|
+
.getCommands()
|
|
366
|
+
.filter((command) => command.source === "skill")
|
|
367
|
+
.map((command) => ({
|
|
368
|
+
name: command.name.startsWith("skill:") ? command.name.slice("skill:".length) : command.name,
|
|
369
|
+
description: command.description ?? "",
|
|
370
|
+
}));
|
|
371
|
+
const skills = firstAvailable(
|
|
372
|
+
options?.skills?.map((skill) => ({ name: skill.name, description: skill.description })),
|
|
373
|
+
liveSkills,
|
|
374
|
+
);
|
|
375
|
+
let parsedContextFiles: string[] = [];
|
|
376
|
+
if (options?.contextFiles === undefined) {
|
|
377
|
+
try {
|
|
378
|
+
parsedContextFiles = parseFromPrompt(ctx.getSystemPrompt()).contextFiles;
|
|
379
|
+
} catch {
|
|
380
|
+
// getSystemPrompt may be unavailable in some modes.
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const contextFiles = firstAvailable(
|
|
384
|
+
options?.contextFiles?.map((file) => file.path),
|
|
385
|
+
parsedContextFiles,
|
|
386
|
+
);
|
|
387
|
+
const tools = pi.getActiveTools();
|
|
388
|
+
return { skills, contextFiles, tools };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function buildEntryData(
|
|
392
|
+
pi: ExtensionAPI,
|
|
393
|
+
ctx: ExtensionContext,
|
|
394
|
+
args: string,
|
|
395
|
+
options?: BuildSystemPromptOptions,
|
|
396
|
+
): Promise<ContextEntryData> {
|
|
397
|
+
const resources = gatherResources(pi, ctx, options);
|
|
398
|
+
const sections: ItemSection[] = [];
|
|
399
|
+
const addSection = (
|
|
400
|
+
title: string,
|
|
401
|
+
items: string[],
|
|
402
|
+
slash = false,
|
|
403
|
+
descriptions?: Record<string, string>,
|
|
404
|
+
truncatedTo?: string,
|
|
405
|
+
) => {
|
|
406
|
+
sections.push({ kind: "items", title, items, slash, descriptions, truncatedTo });
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
switch (args) {
|
|
410
|
+
case "skills":
|
|
411
|
+
addSection(
|
|
412
|
+
`Skills (${resources.skills.length})`,
|
|
413
|
+
resources.skills.map((skill) => skill.name),
|
|
414
|
+
false,
|
|
415
|
+
Object.fromEntries(resources.skills.map((skill) => [skill.name, skill.description])),
|
|
416
|
+
);
|
|
417
|
+
break; // /context skills is uncapped
|
|
418
|
+
case "prompts":
|
|
419
|
+
addSection("Prompts", listPrompts(pi), true);
|
|
420
|
+
break;
|
|
421
|
+
case "extensions":
|
|
422
|
+
addSection("Extensions", await listExtensions(ctx));
|
|
423
|
+
break;
|
|
424
|
+
case "":
|
|
425
|
+
default: {
|
|
426
|
+
const model = ctx.model;
|
|
427
|
+
const meta: ContextEntryData["meta"] = {
|
|
428
|
+
modelLabel: model ? `${model.name || model.id} (${model.provider})` : "not set",
|
|
429
|
+
thinking: ctx.thinkingLevel ?? "default",
|
|
430
|
+
cwd: toHomePath(ctx.cwd),
|
|
431
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
432
|
+
sessionName: ctx.sessionManager.getSessionName() ?? "",
|
|
433
|
+
};
|
|
434
|
+
const allSkills = resources.skills.map((skill) => skill.name);
|
|
435
|
+
addSection(
|
|
436
|
+
`Skills (${resources.skills.length})`,
|
|
437
|
+
allSkills.slice(0, SKILL_CAP),
|
|
438
|
+
false,
|
|
439
|
+
Object.fromEntries(resources.skills.map((skill) => [skill.name, skill.description])),
|
|
440
|
+
allSkills.length > SKILL_CAP
|
|
441
|
+
? `+${allSkills.length - SKILL_CAP} more — run /context skills for all`
|
|
442
|
+
: undefined,
|
|
443
|
+
);
|
|
444
|
+
addSection("Context Files", resources.contextFiles.map((path) => toHomePath(path)));
|
|
445
|
+
addSection("Prompts", listPrompts(pi), true);
|
|
446
|
+
addSection("Extensions", await listExtensions(ctx));
|
|
447
|
+
addSection("Tools", resources.tools);
|
|
448
|
+
return { meta, sections };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return { sections };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function printContext(pi: ExtensionAPI, ctx: ExtensionContext, args: string, options?: BuildSystemPromptOptions): Promise<void> {
|
|
455
|
+
if (ctx.mode !== "tui") {
|
|
456
|
+
ctx.ui.notify("The context print is only available in interactive mode.", "warning");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
pi.appendEntry("context-header", await buildEntryData(pi, ctx, args, options));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export default function (pi: ExtensionAPI) {
|
|
463
|
+
// Inline rendering (like /hotkeys): entries are TUI-only, never sent to the model.
|
|
464
|
+
pi.registerEntryRenderer("context-header", (entry, { expanded }, theme: Theme) => {
|
|
465
|
+
// Entry data may come from an older session file written by a previous
|
|
466
|
+
// version; tolerate missing/malformed fields instead of crashing.
|
|
467
|
+
const data = (entry.data ?? {}) as Partial<ContextEntryData>;
|
|
468
|
+
const sections = Array.isArray(data.sections) ? data.sections : [];
|
|
469
|
+
return {
|
|
470
|
+
render(width: number): string[] {
|
|
471
|
+
const lines: string[] = [];
|
|
472
|
+
const section = (title: string, count?: number) =>
|
|
473
|
+
lines.push(theme.fg("mdHeading", `[${title}${count !== undefined ? ` (${count})` : ""}]`));
|
|
474
|
+
|
|
475
|
+
// Session block.
|
|
476
|
+
if (data.meta) {
|
|
477
|
+
section("Session");
|
|
478
|
+
const kv = (label: string, value: string) =>
|
|
479
|
+
lines.push(` ${theme.fg("dim", `${label}:`)} ${value}`);
|
|
480
|
+
kv("Model", data.meta.modelLabel);
|
|
481
|
+
kv("Thinking", data.meta.thinking);
|
|
482
|
+
kv("Cwd", data.meta.cwd);
|
|
483
|
+
kv("Session", data.meta.sessionName ? `${data.meta.sessionName} (${data.meta.sessionId})` : data.meta.sessionId);
|
|
484
|
+
lines.push("");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Keys block (config-aware keybinding hints).
|
|
488
|
+
section("Keys");
|
|
489
|
+
lines.push(
|
|
490
|
+
` ${[
|
|
491
|
+
keyHint("app.interrupt", "interrupt"),
|
|
492
|
+
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"),
|
|
493
|
+
rawKeyHint("/", "commands"),
|
|
494
|
+
rawKeyHint("!", "bash"),
|
|
495
|
+
keyHint("app.tools.expand", "more"),
|
|
496
|
+
].join(theme.fg("muted", " · "))}`,
|
|
497
|
+
);
|
|
498
|
+
lines.push(
|
|
499
|
+
` ${[
|
|
500
|
+
keyHint("app.model.cycleForward", "model"),
|
|
501
|
+
keyHint("app.thinking.toggle", "thinking"),
|
|
502
|
+
keyHint("app.editor.external", "editor"),
|
|
503
|
+
].join(theme.fg("muted", " · "))}`,
|
|
504
|
+
);
|
|
505
|
+
lines.push(` ${theme.fg("dim", `Run ${theme.fg("accent", "/hotkeys")} for all shortcuts.`)}`);
|
|
506
|
+
lines.push("");
|
|
507
|
+
|
|
508
|
+
// Generic sections.
|
|
509
|
+
for (const itemSection of sections) {
|
|
510
|
+
if (!itemSection || !Array.isArray(itemSection.items)) {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
section(itemSection.title);
|
|
514
|
+
if (itemSection.items.length === 0) {
|
|
515
|
+
lines.push(` ${theme.fg("dim", "none")}`);
|
|
516
|
+
lines.push("");
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (expanded && itemSection.descriptions) {
|
|
520
|
+
for (const item of itemSection.items) {
|
|
521
|
+
const desc = itemSection.descriptions[item] ?? "";
|
|
522
|
+
const suffix = desc ? ` — ${desc}` : "";
|
|
523
|
+
lines.push(fit(` ${item}${suffix}`, width));
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
const words = itemSection.items.map((item) => (itemSection.slash ? `/${item}` : item));
|
|
527
|
+
lines.push(...wrapWords(words, Math.max(0, width - 2), " "));
|
|
528
|
+
}
|
|
529
|
+
if (itemSection.truncatedTo) {
|
|
530
|
+
lines.push(` ${theme.fg("dim", itemSection.truncatedTo)}`);
|
|
531
|
+
}
|
|
532
|
+
lines.push("");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return lines.map((line) => fit(line, width));
|
|
536
|
+
},
|
|
537
|
+
invalidate() {},
|
|
538
|
+
};
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
pi.registerCommand("context", {
|
|
542
|
+
description: "Print session context inline: files, skills, prompts, extensions, tools. Usage: /context [skills|prompts|extensions]",
|
|
543
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
544
|
+
const arg = (args ?? "").trim().toLowerCase();
|
|
545
|
+
if (arg !== "" && arg !== "skills" && arg !== "prompts" && arg !== "extensions") {
|
|
546
|
+
ctx.ui.notify("Usage: /context [skills|prompts|extensions]", "warning");
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
await printContext(pi, ctx, arg, ctx.getSystemPromptOptions());
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
pi.registerShortcut("ctrl+shift+h", {
|
|
554
|
+
description: "Print session context (same as /context)",
|
|
555
|
+
handler: async (ctx: ExtensionContext) => {
|
|
556
|
+
await printContext(pi, ctx, "");
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
}
|