@rol1n/pi-kit 0.0.0-stage → 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 (136) hide show
  1. package/README.md +98 -2
  2. package/extensions/ask-u-questions/README.md +32 -0
  3. package/extensions/ask-u-questions/answers.ts +53 -0
  4. package/extensions/ask-u-questions/dialog-ui.ts +57 -0
  5. package/extensions/ask-u-questions/index.ts +88 -0
  6. package/extensions/ask-u-questions/multi-choice.ts +260 -0
  7. package/extensions/ask-u-questions/results.ts +83 -0
  8. package/extensions/ask-u-questions/schema.ts +17 -0
  9. package/extensions/ask-u-questions/single-choice.ts +182 -0
  10. package/extensions/ask-u-questions/tool-renderers.ts +69 -0
  11. package/extensions/ask-u-questions/types.ts +45 -0
  12. package/extensions/ask-u-questions/ui-lock.ts +24 -0
  13. package/extensions/continue-signal/README.md +28 -0
  14. package/extensions/continue-signal/index.ts +51 -0
  15. package/extensions/continue-signal/renderer.ts +21 -0
  16. package/extensions/custom-providers/README.md +28 -0
  17. package/extensions/custom-providers/asr/handlers/dashscope.ts +154 -0
  18. package/extensions/custom-providers/asr/index.ts +37 -0
  19. package/extensions/custom-providers/asr/models.ts +18 -0
  20. package/extensions/custom-providers/asr/types.ts +20 -0
  21. package/extensions/custom-providers/chat/index.ts +33 -0
  22. package/extensions/custom-providers/chat/magpie.ts +258 -0
  23. package/extensions/custom-providers/chat/models.ts +111 -0
  24. package/extensions/custom-providers/classifier/index.ts +6 -0
  25. package/extensions/custom-providers/classifier/models.ts +1 -0
  26. package/extensions/custom-providers/image/handlers/dashscope.ts +240 -0
  27. package/extensions/custom-providers/image/handlers/openrouter.ts +185 -0
  28. package/extensions/custom-providers/image/index.ts +96 -0
  29. package/extensions/custom-providers/image/models.ts +71 -0
  30. package/extensions/custom-providers/image/types.ts +22 -0
  31. package/extensions/custom-providers/index.ts +50 -0
  32. package/extensions/custom-providers/types.ts +17 -0
  33. package/extensions/image-generate/README.md +29 -0
  34. package/extensions/image-generate/files.ts +49 -0
  35. package/extensions/image-generate/index.ts +200 -0
  36. package/extensions/image-generate/model-selector.ts +150 -0
  37. package/extensions/image-generate/models.ts +19 -0
  38. package/extensions/memory-log/README.md +54 -0
  39. package/extensions/memory-log/agent/consolidator/prompt.ts +47 -0
  40. package/extensions/memory-log/agent/consolidator/tools.ts +160 -0
  41. package/extensions/memory-log/agent/cost.ts +32 -0
  42. package/extensions/memory-log/agent/index.ts +42 -0
  43. package/extensions/memory-log/agent/observer/prompt.ts +73 -0
  44. package/extensions/memory-log/agent/observer/tool.ts +73 -0
  45. package/extensions/memory-log/commands/compact.ts +35 -0
  46. package/extensions/memory-log/commands/consolidate.ts +47 -0
  47. package/extensions/memory-log/commands/status.ts +50 -0
  48. package/extensions/memory-log/config.ts +253 -0
  49. package/extensions/memory-log/debug-log.ts +76 -0
  50. package/extensions/memory-log/hooks/compaction-hook.ts +161 -0
  51. package/extensions/memory-log/hooks/compaction-trigger.ts +160 -0
  52. package/extensions/memory-log/hooks/consolidator-trigger.ts +143 -0
  53. package/extensions/memory-log/hooks/observer-trigger.ts +176 -0
  54. package/extensions/memory-log/ids.ts +78 -0
  55. package/extensions/memory-log/index.ts +149 -0
  56. package/extensions/memory-log/ledger/fold.ts +77 -0
  57. package/extensions/memory-log/ledger/index.ts +7 -0
  58. package/extensions/memory-log/ledger/pool.ts +38 -0
  59. package/extensions/memory-log/ledger/progress.ts +181 -0
  60. package/extensions/memory-log/ledger/projection.ts +115 -0
  61. package/extensions/memory-log/ledger/render.ts +38 -0
  62. package/extensions/memory-log/ledger/serialize.ts +161 -0
  63. package/extensions/memory-log/ledger/types.ts +201 -0
  64. package/extensions/memory-log/memory/index-render.ts +45 -0
  65. package/extensions/memory-log/memory/paths.ts +120 -0
  66. package/extensions/memory-log/memory/session.ts +64 -0
  67. package/extensions/memory-log/runtime.ts +127 -0
  68. package/extensions/memory-log/spawn/launch.ts +133 -0
  69. package/extensions/memory-log/spawn/runs.ts +83 -0
  70. package/extensions/memory-log/tokens.ts +66 -0
  71. package/extensions/memory-log/ui/status-controller.ts +225 -0
  72. package/extensions/memory-log/ui/timeline.ts +103 -0
  73. package/extensions/model-vision/README.md +28 -0
  74. package/extensions/multi-harness/README.md +63 -0
  75. package/extensions/multi-harness/adapters.mjs +72 -0
  76. package/extensions/multi-harness/harness-done.mjs +14 -0
  77. package/extensions/multi-harness/index.ts +375 -0
  78. package/extensions/multi-harness/ipc.mjs +37 -0
  79. package/extensions/multi-harness/opencode-plugin.mjs +99 -0
  80. package/extensions/multi-harness/process-runner.mjs +115 -0
  81. package/extensions/multi-harness/turn-hook.mjs +29 -0
  82. package/extensions/multi-harness/worker-bridge.ts +52 -0
  83. package/extensions/oai-subquota/README.md +30 -0
  84. package/extensions/oai-subquota/api.ts +27 -0
  85. package/extensions/oai-subquota/auth.ts +34 -0
  86. package/extensions/oai-subquota/cache.ts +30 -0
  87. package/extensions/oai-subquota/index.ts +276 -0
  88. package/extensions/oai-subquota/snapshot.ts +167 -0
  89. package/extensions/oai-subquota/types.ts +40 -0
  90. package/extensions/oai-subquota/ui.ts +183 -0
  91. package/extensions/prompt-snipaste/README.md +33 -0
  92. package/extensions/prompt-snipaste/index.ts +143 -0
  93. package/extensions/prompt-snipaste/menu.ts +159 -0
  94. package/extensions/prompt-snipaste/renderers.ts +88 -0
  95. package/extensions/prompt-snipaste/snippets.ts +203 -0
  96. package/extensions/prompt-snipaste/types.ts +19 -0
  97. package/extensions/screenshot/README.md +25 -0
  98. package/extensions/srp-theme/README.md +24 -0
  99. package/extensions/srp-theme/footer.ts +194 -0
  100. package/extensions/srp-theme/index.ts +172 -0
  101. package/extensions/srp-theme/tps-meter.ts +442 -0
  102. package/extensions/subagent/README.md +35 -0
  103. package/extensions/subagent/bridge.ts +171 -0
  104. package/extensions/subagent/index.ts +590 -0
  105. package/extensions/subagent/mux.ts +621 -0
  106. package/extensions/subagent/session.ts +166 -0
  107. package/extensions/subagent/skills/subagent/SKILL.md +70 -0
  108. package/extensions/subagent/ui.ts +100 -0
  109. package/extensions/tui-asr/README.md +28 -0
  110. package/extensions/tui-asr/audio.ts +40 -0
  111. package/extensions/tui-asr/clipboard.ts +30 -0
  112. package/extensions/tui-asr/dictation.ts +205 -0
  113. package/extensions/tui-asr/index.ts +181 -0
  114. package/extensions/tui-asr/input.ts +58 -0
  115. package/extensions/tui-asr/model-selector.ts +163 -0
  116. package/extensions/tui-asr/providers.ts +25 -0
  117. package/extensions/tui-asr/status.ts +79 -0
  118. package/extensions/web-reach/README.md +37 -0
  119. package/extensions/web-reach/html.ts +83 -0
  120. package/extensions/web-reach/index.ts +82 -0
  121. package/extensions/web-reach/page.ts +132 -0
  122. package/extensions/web-reach/pdf.ts +42 -0
  123. package/extensions/web-reach/reader.ts +42 -0
  124. package/extensions/web-reach/rsc.ts +161 -0
  125. package/extensions/web-reach/search.ts +85 -0
  126. package/extensions/web-reach/transport.ts +35 -0
  127. package/extensions/windows-wheel-fix/README.md +21 -0
  128. package/extensions/windows-wheel-fix/index.ts +44 -0
  129. package/package.json +93 -4
  130. package/prompts/ask-questions.md +8 -0
  131. package/prompts/delegate-exploration.md +7 -0
  132. package/prompts/diagnose-report.md +7 -0
  133. package/prompts/frontend-tech-preset.md +18 -0
  134. package/prompts/orchestrator-mode.md +7 -0
  135. package/prompts/session-kickoff.md +7 -0
  136. package/prompts/verify-not-assume.md +7 -0
@@ -0,0 +1,40 @@
1
+ export interface LimitWindow {
2
+ usedPercent: number;
3
+ leftPercent: number;
4
+ windowSeconds?: number;
5
+ resetAt?: number;
6
+ resetAfterSeconds?: number;
7
+ }
8
+
9
+ export interface RateLimit {
10
+ id: string;
11
+ name: string;
12
+ primary?: LimitWindow;
13
+ secondary?: LimitWindow;
14
+ }
15
+
16
+ export interface CreditsSnapshot {
17
+ hasCredits?: boolean;
18
+ unlimited?: boolean;
19
+ balance?: number | string;
20
+ }
21
+
22
+ export interface ResetsSnapshot {
23
+ availableCount?: number;
24
+ applicableCount?: number;
25
+ }
26
+
27
+ export interface AccountSnapshot {
28
+ email?: string;
29
+ plan?: string;
30
+ accountId?: string;
31
+ }
32
+
33
+ export interface CodexUsageSnapshot {
34
+ source: "api" | "headers" | "cache";
35
+ fetchedAt: string;
36
+ account: AccountSnapshot;
37
+ defaultLimit?: RateLimit;
38
+ credits?: CreditsSnapshot;
39
+ resets?: ResetsSnapshot;
40
+ }
@@ -0,0 +1,183 @@
1
+ import type { ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { CodexUsageSnapshot, LimitWindow } from "./types.ts";
4
+
5
+ const USAGE_SETTINGS_URL = "https://chatgpt.com/codex/settings/usage";
6
+ const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
7
+
8
+ function pct(value: number | undefined): string {
9
+ if (value === undefined || !Number.isFinite(value)) return "?%";
10
+ return Number.isInteger(value) ? `${value}%` : `${value.toFixed(1)}%`;
11
+ }
12
+
13
+ function padLabel(label: string, width = 28): string {
14
+ return `${label}:`.padEnd(width, " ");
15
+ }
16
+
17
+ function formatTime(d: Date): string {
18
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
19
+ }
20
+
21
+ function sameDay(a: Date, b: Date): boolean {
22
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
23
+ }
24
+
25
+ function formatReset(resetAt: number | undefined, now = new Date()): string {
26
+ if (!resetAt) return "";
27
+ const reset = new Date(resetAt * 1000);
28
+ if (!Number.isFinite(reset.getTime())) return "";
29
+ const time = formatTime(reset);
30
+ if (sameDay(reset, now)) return `resets ${time}`;
31
+ const date = `${reset.getDate()} ${MONTH_NAMES[reset.getMonth()]}`;
32
+ return reset.getFullYear() === now.getFullYear() ? `resets ${time} on ${date}` : `resets ${time} on ${date} ${reset.getFullYear()}`;
33
+ }
34
+
35
+ export function formatDuration(seconds: number | undefined): string {
36
+ if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) return "";
37
+ const rounded = Math.round(seconds);
38
+ const days = Math.floor(rounded / 86_400);
39
+ const hours = Math.floor((rounded % 86_400) / 3600);
40
+ const mins = Math.floor((rounded % 3600) / 60);
41
+ if (days > 0) return `${days}d${hours ? `${hours}h` : ""}`;
42
+ if (hours > 0) return `${hours}h${mins ? `${mins}m` : ""}`;
43
+ return `${mins || 1}m`;
44
+ }
45
+
46
+ function resetSeconds(window: LimitWindow | undefined, now: Date): number | undefined {
47
+ if (!window) return undefined;
48
+ if (window.resetAfterSeconds !== undefined) return window.resetAfterSeconds;
49
+ return window.resetAt !== undefined ? Math.max(0, Math.round(window.resetAt - now.getTime() / 1000)) : undefined;
50
+ }
51
+
52
+ export function bar(leftPercent: number | undefined, width = 20): string {
53
+ const left = Math.max(0, Math.min(100, leftPercent ?? 0));
54
+ const filled = Math.max(0, Math.min(width, Math.round((left / 100) * width)));
55
+ return `[${"█".repeat(filled)}${"░".repeat(width - filled)}]`;
56
+ }
57
+
58
+ function formatWindowLine(label: string, window: LimitWindow | undefined, now: Date): string | undefined {
59
+ if (!window) return undefined;
60
+ const reset = formatReset(window.resetAt, now) || (formatDuration(window.resetAfterSeconds) ? `resets in ${formatDuration(window.resetAfterSeconds)}` : "");
61
+ const suffix = reset ? ` (${reset})` : "";
62
+ return `${padLabel(label)}${bar(window.leftPercent)} ${pct(window.leftPercent)} left${suffix}`;
63
+ }
64
+
65
+ function formatCreditsLine(snapshot: CodexUsageSnapshot): string | undefined {
66
+ const c = snapshot.credits;
67
+ if (!c) return undefined;
68
+ if (c.unlimited) return `${padLabel("Credits")}unlimited`;
69
+ if (c.balance !== undefined) {
70
+ const val = Number(c.balance);
71
+ if (!Number.isFinite(val) || val <= 0) return undefined;
72
+ return `${padLabel("Credits")}${Math.floor(val).toLocaleString()} credits`;
73
+ }
74
+ return c.hasCredits ? `${padLabel("Credits")}available` : undefined;
75
+ }
76
+
77
+ function formatResetsLine(snapshot: CodexUsageSnapshot): string | undefined {
78
+ const count = snapshot.resets?.availableCount;
79
+ return typeof count === "number" && count > 0 ? `${padLabel("Resets")}${count} available` : undefined;
80
+ }
81
+
82
+ export function formatStatusline(snapshot: CodexUsageSnapshot, now = new Date()): string {
83
+ const parts: string[] = [];
84
+ const primary = snapshot.defaultLimit?.primary;
85
+ const secondary = snapshot.defaultLimit?.secondary;
86
+
87
+ if (primary) {
88
+ parts.push(`5h:${pct(primary.leftPercent)}`);
89
+ }
90
+
91
+ if (secondary) {
92
+ parts.push(`7d:${pct(secondary.leftPercent)}`);
93
+ const r7d = formatDuration(resetSeconds(secondary, now));
94
+ if (r7d) parts.push(`↺ ${r7d}`);
95
+ }
96
+
97
+ return parts.length > 0 ? parts.join(" ") : "unavailable";
98
+ }
99
+
100
+ function makeBox(lines: string[], reqWidth?: number): string {
101
+ const contentWidth = Math.max(...lines.map((l) => visibleWidth(l)), 1);
102
+ const width = Math.max(contentWidth, reqWidth ?? 0);
103
+ const top = `╭${"─".repeat(width + 2)}╮`;
104
+ const bottom = `╰${"─".repeat(width + 2)}╯`;
105
+ const body = lines.map((l) => `│ ${l}${" ".repeat(Math.max(0, width - visibleWidth(l)))} │`);
106
+ return [top, ...body, bottom].join("\n");
107
+ }
108
+
109
+ export function formatBoxedStatus(snapshot: CodexUsageSnapshot, now = new Date()): string {
110
+ const lines: string[] = [
111
+ ">_ Codex usage",
112
+ "",
113
+ `Visit ${USAGE_SETTINGS_URL} for up-to-date`,
114
+ "information on rate limits and credits",
115
+ "",
116
+ ];
117
+
118
+ const acct: string[] = [];
119
+ if (snapshot.account.email) acct.push(snapshot.account.email);
120
+ if (snapshot.account.plan) acct.push(`(${snapshot.account.plan[0]?.toUpperCase()}${snapshot.account.plan.slice(1)})`);
121
+ if (acct.length > 0) lines.push(`${padLabel("Account")}${acct.join(" ")}`);
122
+ if (snapshot.fetchedAt) lines.push(`${padLabel("Updated")}${new Date(snapshot.fetchedAt).toLocaleString()}`);
123
+ lines.push("");
124
+
125
+ const p = formatWindowLine("5h limit", snapshot.defaultLimit?.primary, now);
126
+ const s = formatWindowLine("Weekly limit", snapshot.defaultLimit?.secondary, now);
127
+ if (p) lines.push(p);
128
+ if (s) lines.push(s);
129
+
130
+ const credits = formatCreditsLine(snapshot);
131
+ if (credits) lines.push(credits);
132
+
133
+ const resets = formatResetsLine(snapshot);
134
+ if (resets) lines.push(resets);
135
+
136
+ return makeBox(lines);
137
+ }
138
+
139
+ function colorForLeft(left: number): "success" | "warning" | "error" {
140
+ return left >= 60 ? "success" : left >= 25 ? "warning" : "error";
141
+ }
142
+
143
+ function colorizeStatusLine(line: string, theme: Theme): string {
144
+ let out = line;
145
+ const match = line.match(/(\[[█░]+\])\s+(\d+(?:\.\d+)?)% left/);
146
+ if (match?.[1] && match[2]) {
147
+ const barText = match[1];
148
+ const pctText = match[2];
149
+ const color = colorForLeft(Number(pctText) || 0);
150
+ out = out.replace(barText, theme.fg(color, barText));
151
+ out = out.replace(`${pctText}% left`, theme.fg(color, `${pctText}% left`));
152
+ }
153
+ out = out.replace(">_ Codex usage", theme.fg("accent", theme.bold(">_ Codex usage")));
154
+ out = out.replace(/(Account|Updated|5h limit|Weekly limit|Credits|Resets):/g, (lbl) => theme.fg("muted", lbl));
155
+ out = out.replace(/(Visit https:\/\/chatgpt\.com\/codex\/settings\/usage for up-to-date|information on rate limits and credits)/g, (txt) => theme.fg("dim", txt));
156
+
157
+ if (out.startsWith("╭") || out.startsWith("╰")) return theme.fg("dim", out);
158
+ if (out.startsWith("│") && out.endsWith("│")) {
159
+ return `${theme.fg("dim", out[0] ?? "")}${out.slice(1, -1)}${theme.fg("dim", out.at(-1) ?? "")}`;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ export function renderStatusMessage(message: { content: string }, _opt: unknown, theme: Theme): Text {
165
+ return new Text(message.content.split("\n").map((l) => colorizeStatusLine(l, theme)).join("\n"), 0, 0);
166
+ }
167
+
168
+ export function dimText(ctx: ExtensionContext | ExtensionCommandContext, text: string): string {
169
+ try {
170
+ return ctx.ui?.theme?.fg ? ctx.ui.theme.fg("dim", text) : text;
171
+ } catch {
172
+ return text;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Format quota text for Pi's shared extension-status row.
178
+ * It must stay compact because Pi combines all extension statuses before
179
+ * applying the footer width limit.
180
+ */
181
+ export function formatRightAlignedStatusline(snapshot: CodexUsageSnapshot, now = new Date()): string {
182
+ return formatStatusline(snapshot, now);
183
+ }
@@ -0,0 +1,33 @@
1
+ # prompt-snipaste
2
+
3
+ Choose prompt snippets to prepend or append to the next user message, with a terminal preview and a session transcript card.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pi install npm:@rol1n/prompt-snipaste
9
+ ```
10
+
11
+ Also included in `@rol1n/pi-kit`.
12
+
13
+ ## Use
14
+
15
+ Open `/snipaste` or press Alt+S. Use the arrow keys to navigate, Space to toggle snippets, Tab to preview, Enter to confirm, and Escape to cancel. Selected snippets apply once to the next user input; they are then cleared.
16
+
17
+ ## Prompt configuration
18
+
19
+ The picker uses Pi's resolved prompt commands, including prompts installed through packages. Package resource filters and disabled prompts are respected; a top-level `prompts: []` does not hide enabled package prompts. Personal and project prompt directories or explicit prompt paths can still be configured through Pi normally.
20
+
21
+ Markdown frontmatter controls each snippet:
22
+
23
+ ```markdown
24
+ ---
25
+ name: Review checklist
26
+ description: Add the review criteria to the next message
27
+ placement: append
28
+ order: 100
29
+ ---
30
+ Check correctness, error handling, and missing validation.
31
+ ```
32
+
33
+ `placement` accepts `prepend` or `append`; append is the default. Lower `order` values come first within a placement. Without frontmatter, the file name becomes the snippet name and the body is used directly. The extension's fallback loader, when used without a Pi runtime, reads conventional prompt directories and explicit prompt paths.
@@ -0,0 +1,143 @@
1
+ /** Select prompt snippets for the next user message with /snipaste or Alt+S. */
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Key } from "@earendil-works/pi-tui";
4
+ import type { PromptCardData, Snippet } from "./types.ts";
5
+ import { loadSnippets } from "./snippets.ts";
6
+ import { showSnippetMenu } from "./menu.ts";
7
+ import { buildPromptCardComponent, updateSnippetWidget } from "./renderers.ts";
8
+
9
+ export default function promptSnipaste(pi: ExtensionAPI) {
10
+ let enabled = new Set<string>();
11
+ let snippets: Snippet[] = [];
12
+
13
+ function updateWidget(ctx: ExtensionContext) {
14
+ updateSnippetWidget(ctx, () => ({ snippets, enabled }));
15
+ }
16
+
17
+ async function openMenu(ctx: ExtensionContext) {
18
+ if (ctx.mode !== "tui") {
19
+ ctx.ui.notify("Prompt snippets menu is only available in interactive terminals", "warning");
20
+ return;
21
+ }
22
+
23
+ snippets = loadSnippets(ctx.cwd, pi);
24
+ enabled = new Set([...enabled].filter((id) => snippets.some((s) => s.id === id)));
25
+
26
+ if (snippets.length === 0) {
27
+ ctx.ui.notify("No Markdown files found in prompt directories", "warning");
28
+ updateWidget(ctx);
29
+ return;
30
+ }
31
+
32
+ const working = new Set(enabled);
33
+
34
+ const confirmed = await showSnippetMenu(ctx, snippets, working);
35
+
36
+ if (confirmed) {
37
+ enabled = working;
38
+ }
39
+ updateWidget(ctx);
40
+ }
41
+
42
+ let pendingTurnSnippets: Snippet[] | null = null;
43
+
44
+ pi.registerEntryRenderer<PromptCardData>("prompt-snipaste-card", (entry, _options, theme) => {
45
+ const data = entry.data;
46
+ if (!data?.snippets || data.snippets.length === 0) return undefined;
47
+ return buildPromptCardComponent(data.snippets, theme);
48
+ });
49
+
50
+ pi.on("session_start", (_event, ctx) => {
51
+ enabled = new Set();
52
+ pendingTurnSnippets = null;
53
+ snippets = loadSnippets(ctx.cwd, pi);
54
+ updateWidget(ctx);
55
+ });
56
+
57
+ pi.on("input", async (_event, ctx) => {
58
+ if (enabled.size === 0) return undefined;
59
+
60
+ snippets = loadSnippets(ctx.cwd, pi);
61
+ const active = snippets.filter((s) => enabled.has(s.id));
62
+ enabled = new Set();
63
+ updateWidget(ctx);
64
+
65
+ if (active.length === 0) return undefined;
66
+
67
+ pendingTurnSnippets = active;
68
+
69
+ pi.appendEntry<PromptCardData>("prompt-snipaste-card", {
70
+ snippets: active.map((s) => ({
71
+ id: s.id,
72
+ name: s.name,
73
+ placement: s.placement,
74
+ order: s.order,
75
+ body: s.body,
76
+ })),
77
+ });
78
+
79
+ return undefined;
80
+ });
81
+
82
+ pi.on("context", async (event, _ctx) => {
83
+ if (!pendingTurnSnippets || pendingTurnSnippets.length === 0) return undefined;
84
+
85
+ const active = pendingTurnSnippets;
86
+ pendingTurnSnippets = null;
87
+
88
+ const prepends = active.filter((s) => s.placement === "prepend");
89
+ const appends = active.filter((s) => s.placement === "append");
90
+
91
+ const messages = [...event.messages];
92
+ for (let i = messages.length - 1; i >= 0; i--) {
93
+ const msg = messages[i];
94
+ if (msg.role === "user") {
95
+ if (typeof msg.content === "string") {
96
+ const parts: string[] = [];
97
+
98
+ if (prepends.length > 0) {
99
+ parts.push(prepends.map((s) => s.body.trim()).join("\n\n"));
100
+ }
101
+ if (msg.content.trim()) {
102
+ parts.push(msg.content.trim());
103
+ }
104
+ if (appends.length > 0) {
105
+ parts.push(appends.map((s) => s.body.trim()).join("\n\n"));
106
+ }
107
+
108
+ messages[i] = {
109
+ ...msg,
110
+ content: parts.join("\n\n"),
111
+ };
112
+ } else if (Array.isArray(msg.content)) {
113
+ const prependText = prepends.map((s) => s.body.trim()).join("\n\n");
114
+ const appendText = appends.map((s) => s.body.trim()).join("\n\n");
115
+ const newContent = [...msg.content];
116
+ if (prependText) {
117
+ newContent.unshift({ type: "text", text: prependText });
118
+ }
119
+ if (appendText) {
120
+ newContent.push({ type: "text", text: appendText });
121
+ }
122
+ messages[i] = {
123
+ ...msg,
124
+ content: newContent as any,
125
+ };
126
+ }
127
+ return { messages };
128
+ }
129
+ }
130
+
131
+ return undefined;
132
+ });
133
+
134
+ pi.registerCommand("snipaste", {
135
+ description: "Select prepended or appended prompt snippets to inject into the next message",
136
+ handler: (_args, ctx) => openMenu(ctx),
137
+ });
138
+
139
+ pi.registerShortcut(Key.alt("s"), {
140
+ description: "Open prompt snippets menu",
141
+ handler: (ctx) => openMenu(ctx),
142
+ });
143
+ }
@@ -0,0 +1,159 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import type { Snippet } from "./types.ts";
4
+
5
+ /** Edit a working selection; the caller commits it only after confirmation. */
6
+ export function showSnippetMenu(ctx: ExtensionContext, snippets: Snippet[], working: Set<string>): Promise<boolean> {
7
+ return ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
8
+ const prepends = snippets.filter((s) => s.placement === "prepend");
9
+ const appends = snippets.filter((s) => s.placement === "append");
10
+ const items = [...prepends, ...appends];
11
+
12
+ let mode: "list" | "preview" = "list";
13
+ let cursor = 0;
14
+ let listScroll = 0;
15
+ let previewScroll = 0;
16
+
17
+ const itemRow = (snippet: Snippet, idx: number, width: number): string => {
18
+ const pointer = idx === cursor ? theme.fg("accent", "> ") : " ";
19
+ const checkbox = working.has(snippet.id) ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
20
+ const desc = snippet.description ? theme.fg("dim", ` — ${snippet.description}`) : "";
21
+ return truncateToWidth(`${pointer}${checkbox} ${theme.bold(snippet.name)}${desc}`, width);
22
+ };
23
+
24
+ const buildListRows = (width: number): { text: string; itemIndex: number | null }[] => {
25
+ const rows: { text: string; itemIndex: number | null }[] = [];
26
+ rows.push({ text: theme.fg("dim", "↑ Prepend Prompts"), itemIndex: null });
27
+ prepends.forEach((s, i) => rows.push({ text: itemRow(s, i, width), itemIndex: i }));
28
+ rows.push({ text: "", itemIndex: null });
29
+ rows.push({ text: theme.fg("dim", "↓ Append Prompts"), itemIndex: null });
30
+ appends.forEach((s, i) => rows.push({ text: itemRow(s, prepends.length + i, width), itemIndex: prepends.length + i }));
31
+ return rows;
32
+ };
33
+
34
+ const buildPreviewRows = (snippet: Snippet, width: number): string[] => {
35
+ const rows: string[] = [];
36
+ rows.push(truncateToWidth(theme.bold(snippet.name), width));
37
+ rows.push(truncateToWidth(theme.fg("dim", `${snippet.placement === "prepend" ? "Prepend" : "Append"} · Order ${snippet.order} · ${snippet.id}`), width));
38
+ rows.push(theme.fg("dim", "─".repeat(Math.min(width, 40))));
39
+ for (const line of snippet.body.split("\n")) {
40
+ for (const wrapped of wrapTextWithAnsi(line, width)) {
41
+ rows.push(truncateToWidth(wrapped, width));
42
+ }
43
+ }
44
+ return rows;
45
+ };
46
+
47
+ const viewport = (
48
+ lines: string[],
49
+ scroll: number,
50
+ maxView: number,
51
+ focusRow?: number,
52
+ ): { out: string[]; scroll: number } => {
53
+ const clipped = lines.length > maxView;
54
+ const view = clipped ? Math.max(1, maxView - 2) : maxView;
55
+
56
+ let s = Math.min(Math.max(0, scroll), Math.max(0, lines.length - view));
57
+ if (focusRow !== undefined) {
58
+ if (focusRow < s) s = focusRow;
59
+ else if (focusRow >= s + view) s = focusRow - view + 1;
60
+ }
61
+
62
+ const visible = lines.slice(s, s + view);
63
+ if (!clipped) return { out: visible, scroll: s };
64
+
65
+ const above = s;
66
+ const below = lines.length - (s + view);
67
+ return {
68
+ out: [
69
+ above > 0 ? theme.fg("dim", ` ↑ ${above} more`) : "",
70
+ ...visible,
71
+ below > 0 ? theme.fg("dim", ` ↓ ${below} more`) : "",
72
+ ],
73
+ scroll: s,
74
+ };
75
+ };
76
+
77
+ return {
78
+ render(width: number): string[] {
79
+ const maxView = Math.max(5, tui.terminal.rows - 10);
80
+
81
+ let content: string[];
82
+ let title: string;
83
+ let hints: string;
84
+ if (mode === "list") {
85
+ const rows = buildListRows(width);
86
+ const cursorRow = rows.findIndex((r) => r.itemIndex === cursor);
87
+ const v = viewport(rows.map((r) => r.text), listScroll, maxView, cursorRow);
88
+ content = v.out;
89
+ listScroll = v.scroll;
90
+ title = "Prompt Snippets";
91
+ hints = "↑↓ Navigate • Space Select • Tab Preview • Enter Confirm • Esc Cancel";
92
+ } else {
93
+ const snippet = items[cursor];
94
+ const rows = buildPreviewRows(snippet, width);
95
+ const v = viewport(rows, previewScroll, maxView);
96
+ content = v.out;
97
+ previewScroll = v.scroll;
98
+ title = `Preview: ${snippet.name}`;
99
+ hints = "↑↓ Scroll • Tab/Esc Back to list";
100
+ }
101
+
102
+ const innerW = width - 4;
103
+ const titleVisW = visibleWidth(title);
104
+ const hintsVisW = visibleWidth(hints);
105
+ const titlePad = Math.max(0, innerW - titleVisW);
106
+ const hintsPad = Math.max(0, innerW - hintsVisW);
107
+
108
+ const rawLines = [
109
+ theme.fg("border", `╭─ ${theme.bold(title)} ${"─".repeat(titlePad)}╮`),
110
+ ...content.map((l) => {
111
+ const pad = Math.max(0, innerW - visibleWidth(l));
112
+ return theme.fg("border", "│ ") + l + " ".repeat(pad) + theme.fg("border", " │");
113
+ }),
114
+ theme.fg("border", `├${"─".repeat(innerW + 2)}┤`),
115
+ theme.fg("border", "│ ") + theme.fg("dim", hints) + " ".repeat(hintsPad) + theme.fg("border", " │"),
116
+ theme.fg("border", `╰${"─".repeat(innerW + 2)}╯`),
117
+ ];
118
+
119
+ return rawLines.map((line) => truncateToWidth(line, width));
120
+ },
121
+ invalidate() {},
122
+ handleInput(data: string) {
123
+ if (mode === "list") {
124
+ if (matchesKey(data, Key.up)) {
125
+ cursor = (cursor - 1 + items.length) % items.length;
126
+ tui.requestRender();
127
+ } else if (matchesKey(data, Key.down)) {
128
+ cursor = (cursor + 1) % items.length;
129
+ tui.requestRender();
130
+ } else if (matchesKey(data, Key.space)) {
131
+ const id = items[cursor].id;
132
+ if (working.has(id)) working.delete(id);
133
+ else working.add(id);
134
+ tui.requestRender();
135
+ } else if (matchesKey(data, Key.tab)) {
136
+ mode = "preview";
137
+ previewScroll = 0;
138
+ tui.requestRender();
139
+ } else if (matchesKey(data, Key.enter)) {
140
+ done(true);
141
+ } else if (matchesKey(data, Key.escape)) {
142
+ done(false);
143
+ }
144
+ } else {
145
+ if (matchesKey(data, Key.up)) {
146
+ previewScroll--;
147
+ tui.requestRender();
148
+ } else if (matchesKey(data, Key.down)) {
149
+ previewScroll++;
150
+ tui.requestRender();
151
+ } else if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
152
+ mode = "list";
153
+ tui.requestRender();
154
+ }
155
+ }
156
+ },
157
+ };
158
+ });
159
+ }
@@ -0,0 +1,88 @@
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import type { PromptCardData, Snippet } from "./types.ts";
4
+
5
+ const WIDGET_ID = "prompt-snipaste";
6
+
7
+ export function buildPromptCardComponent(
8
+ snippets: PromptCardData["snippets"],
9
+ theme: Theme,
10
+ ) {
11
+ const borderCol = (s: string) => theme.fg("borderAccent", s);
12
+ const dim = (s: string) => theme.fg("dim", s);
13
+ const bold = (s: string) => theme.bold(s);
14
+
15
+ const titleText = ` Prompt Snippets (${snippets.length}) `;
16
+
17
+ return {
18
+ render(width: number): string[] {
19
+ const effectiveWidth = Math.max(20, Math.min(width, 100));
20
+ const innerW = effectiveWidth - 4;
21
+
22
+ const titleVisW = visibleWidth(titleText);
23
+ const topDashLen = Math.max(1, innerW - titleVisW);
24
+ const topLine = borderCol("╭─") + bold(titleText) + borderCol("─".repeat(topDashLen) + "╮");
25
+ const botLine = borderCol("╰" + "─".repeat(innerW + 2) + "╯");
26
+
27
+ const lines: string[] = [];
28
+ lines.push(topLine);
29
+
30
+ snippets.forEach((item, idx) => {
31
+ if (idx > 0) {
32
+ lines.push(borderCol("├" + "─".repeat(innerW + 2) + "┤"));
33
+ }
34
+ const header = `● ${item.name}`;
35
+ const headerVisW = visibleWidth(header);
36
+ const headerPad = Math.max(0, innerW - headerVisW);
37
+ lines.push(borderCol("│ ") + bold(header) + " ".repeat(headerPad) + borderCol(" │"));
38
+
39
+ const bodyLines = item.body.split(/\r?\n/);
40
+ for (const line of bodyLines) {
41
+ if (!line.trim()) continue;
42
+ for (const wrapped of wrapTextWithAnsi(line, innerW)) {
43
+ const pad = Math.max(0, innerW - visibleWidth(wrapped));
44
+ lines.push(borderCol("│ ") + dim(wrapped) + " ".repeat(pad) + borderCol(" │"));
45
+ }
46
+ }
47
+ });
48
+
49
+ lines.push(botLine);
50
+ return lines.map((l) => truncateToWidth(l, width));
51
+ },
52
+ invalidate() {},
53
+ };
54
+ }
55
+
56
+ export function updateSnippetWidget(
57
+ ctx: ExtensionContext,
58
+ getSelection: () => { snippets: Snippet[]; enabled: Set<string> },
59
+ ) {
60
+ if (getSelection().enabled.size === 0) {
61
+ ctx.ui.setWidget(WIDGET_ID, undefined);
62
+ return;
63
+ }
64
+
65
+ ctx.ui.setWidget(WIDGET_ID, (_tui, theme) => ({
66
+ render(width: number): string[] {
67
+ const { snippets, enabled } = getSelection();
68
+ const active = snippets.filter((s) => enabled.has(s.id));
69
+ if (active.length === 0) return [];
70
+
71
+ const prepends = active.filter((s) => s.placement === "prepend");
72
+ const appends = active.filter((s) => s.placement === "append");
73
+
74
+ const parts: string[] = [];
75
+ if (prepends.length > 0) {
76
+ parts.push(theme.fg("accent", `↑ ${prepends.map((s) => s.name).join(", ")}`));
77
+ }
78
+ if (appends.length > 0) {
79
+ parts.push(theme.fg("warning", `↓ ${appends.map((s) => s.name).join(", ")}`));
80
+ }
81
+
82
+ const text = `[Prompts: ${parts.join(" · ")}]`;
83
+ return [truncateToWidth(text, width)];
84
+ },
85
+ invalidate() {},
86
+ }));
87
+ }
88
+