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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/package.json +45 -0
  4. 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(/&quot;/g, '"')
49
+ .replace(/&apos;/g, "'")
50
+ .replace(/&lt;/g, "<")
51
+ .replace(/&gt;/g, ">")
52
+ .replace(/&amp;/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
+ }