@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,203 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, isAbsolute, join, resolve } from "node:path";
4
+ import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import type { Snippet } from "./types.ts";
6
+
7
+ export function parseSnippet(filename: string, raw: string): Snippet | null {
8
+ const cleanRaw = raw.replace(/^\uFEFF/, "");
9
+ const match = cleanRaw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
10
+
11
+ let name = filename.replace(/\.md$/, "");
12
+ let description = "";
13
+ let placement: "prepend" | "append" = "append";
14
+ let order = 100;
15
+ let body = "";
16
+
17
+ if (match) {
18
+ const meta: Record<string, string> = {};
19
+ for (const line of match[1].split(/\r?\n/)) {
20
+ const kv = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/);
21
+ if (kv) meta[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, "");
22
+ }
23
+ if (meta.name) name = meta.name;
24
+ if (meta.description) description = meta.description;
25
+ if (meta.placement === "prepend") placement = "prepend";
26
+ const parsedOrder = Number.parseInt(meta.order ?? "100", 10);
27
+ if (!Number.isNaN(parsedOrder)) order = parsedOrder;
28
+ body = match[2].trim();
29
+ } else {
30
+ body = cleanRaw.trim();
31
+ if (!body) return null;
32
+ const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
33
+ description = firstLine.replace(/^#+\s*/, "").slice(0, 80);
34
+ }
35
+
36
+ if (!body) return null;
37
+
38
+ return {
39
+ id: filename,
40
+ name,
41
+ description,
42
+ placement,
43
+ order,
44
+ body,
45
+ };
46
+ }
47
+
48
+ function resolveSnippetPath(rawPath: string, cwd: string): string {
49
+ const trimmed = rawPath.trim();
50
+ if (trimmed === "~" || trimmed.startsWith("~/") || (process.platform === "win32" && trimmed.startsWith("~\\"))) {
51
+ return resolve(homedir(), trimmed.replace(/^~[/\\]?/, ""));
52
+ }
53
+ if (isAbsolute(trimmed)) {
54
+ return resolve(trimmed);
55
+ }
56
+ const fromCwd = resolve(cwd, trimmed);
57
+ if (existsSync(fromCwd)) return fromCwd;
58
+ const fromAgent = resolve(getAgentDir(), trimmed);
59
+ if (existsSync(fromAgent)) return fromAgent;
60
+ return fromCwd;
61
+ }
62
+
63
+ function getSettingsPrompts(cwd: string, pi?: ExtensionAPI): string[] {
64
+ const results: string[] = [];
65
+
66
+ if (pi) {
67
+ try {
68
+ const settings = pi.getSettings();
69
+ if (Array.isArray(settings?.prompts)) {
70
+ results.push(...settings.prompts);
71
+ return results;
72
+ }
73
+ } catch {
74
+ // pi.getSettings() may throw before runner binds
75
+ }
76
+ }
77
+
78
+ const settingsFiles = [
79
+ join(getAgentDir(), "settings.json"),
80
+ join(cwd, CONFIG_DIR_NAME, "settings.json"),
81
+ ];
82
+
83
+ for (const sf of settingsFiles) {
84
+ if (!existsSync(sf)) continue;
85
+ try {
86
+ const content = readFileSync(sf, "utf-8");
87
+ const parsed = JSON.parse(content.replace(/^\uFEFF/, ""));
88
+ if (Array.isArray(parsed?.prompts)) {
89
+ for (const p of parsed.prompts) {
90
+ if (typeof p === "string" && !results.includes(p)) {
91
+ results.push(p);
92
+ }
93
+ }
94
+ }
95
+ } catch {
96
+ // ignore JSON parse errors
97
+ }
98
+ }
99
+
100
+ return results;
101
+ }
102
+
103
+ export function loadSnippets(cwd: string, pi?: ExtensionAPI): Snippet[] {
104
+ const seen = new Map<string, Snippet>();
105
+ const excludedNames = new Set<string>();
106
+
107
+ const loadedPromptPaths = pi?.getCommands()
108
+ .filter((command) => command.source === "prompt")
109
+ .map((command) => command.sourceInfo.path);
110
+ const defaultDirs = loadedPromptPaths ? [] : [
111
+ join(getAgentDir(), "prompts"),
112
+ join(cwd, CONFIG_DIR_NAME, "prompts"),
113
+ ];
114
+ const settingsPrompts = loadedPromptPaths ?? getSettingsPrompts(cwd, pi);
115
+
116
+ const processDirectory = (dir: string) => {
117
+ if (!existsSync(dir)) return;
118
+ let files: string[];
119
+ try {
120
+ files = readdirSync(dir);
121
+ } catch (error) {
122
+ if (!(error instanceof Error) || !("code" in error)) throw error;
123
+ console.error(`Failed to read prompt directory ${dir}: ${error.message}`);
124
+ return;
125
+ }
126
+ for (const file of files) {
127
+ if (!file.endsWith(".md")) continue;
128
+ if (excludedNames.has(file)) continue;
129
+ const fullPath = join(dir, file);
130
+ try {
131
+ const snippet = parseSnippet(file, readFileSync(fullPath, "utf-8"));
132
+ if (snippet) seen.set(file, snippet);
133
+ } catch (error) {
134
+ if (!(error instanceof Error) || !("code" in error)) throw error;
135
+ console.error(`Failed to read prompt ${fullPath}: ${error.message}`);
136
+ }
137
+ }
138
+ };
139
+
140
+ const processFile = (filePath: string) => {
141
+ if (!existsSync(filePath) || !filePath.endsWith(".md")) return;
142
+ const filename = basename(filePath);
143
+ if (excludedNames.has(filename)) return;
144
+ try {
145
+ const snippet = parseSnippet(filename, readFileSync(filePath, "utf-8"));
146
+ if (snippet) seen.set(filename, snippet);
147
+ } catch (error) {
148
+ if (!(error instanceof Error) || !("code" in error)) throw error;
149
+ console.error(`Failed to read prompt file ${filePath}: ${error.message}`);
150
+ }
151
+ };
152
+
153
+ // Collect exclusions first
154
+ for (const raw of settingsPrompts) {
155
+ if (typeof raw !== "string") continue;
156
+ const trimmed = raw.trim();
157
+ if (trimmed.startsWith("-") || trimmed.startsWith("!")) {
158
+ const clean = trimmed.slice(1).trim();
159
+ excludedNames.add(basename(clean));
160
+ }
161
+ }
162
+
163
+ // 1. Load default prompt directories
164
+ for (const dir of defaultDirs) {
165
+ processDirectory(dir);
166
+ }
167
+
168
+ // 2. Load Pi's resolved prompts, or explicit settings paths without a runtime.
169
+ for (const raw of settingsPrompts) {
170
+ if (typeof raw !== "string") continue;
171
+ let trimmed = raw.trim();
172
+ if (trimmed.startsWith("-") || trimmed.startsWith("!")) continue;
173
+ if (trimmed.startsWith("+")) trimmed = trimmed.slice(1).trim();
174
+ if (!trimmed) continue;
175
+
176
+ const resolved = resolveSnippetPath(trimmed, cwd);
177
+ if (!existsSync(resolved)) continue;
178
+
179
+ try {
180
+ const stat = statSync(resolved);
181
+ if (stat.isDirectory()) {
182
+ processDirectory(resolved);
183
+ } else if (stat.isFile() && resolved.endsWith(".md")) {
184
+ processFile(resolved);
185
+ }
186
+ } catch (error) {
187
+ if (!(error instanceof Error) || !("code" in error)) throw error;
188
+ console.error(`Failed to inspect prompt path ${resolved}: ${error.message}`);
189
+ }
190
+ }
191
+
192
+ // Remove any excluded items
193
+ for (const ex of excludedNames) {
194
+ seen.delete(ex);
195
+ }
196
+
197
+ return Array.from(seen.values()).sort((a, b) => {
198
+ if (a.placement !== b.placement) return a.placement === "prepend" ? -1 : 1;
199
+ if (a.order !== b.order) return a.order - b.order;
200
+ return a.name.localeCompare(b.name);
201
+ });
202
+ }
203
+
@@ -0,0 +1,19 @@
1
+ export interface Snippet {
2
+ id: string;
3
+ name: string;
4
+ description: string;
5
+ placement: "prepend" | "append";
6
+ order: number;
7
+ body: string;
8
+ }
9
+
10
+ export interface PromptCardData {
11
+ snippets: {
12
+ id: string;
13
+ name: string;
14
+ placement: "prepend" | "append";
15
+ order: number;
16
+ body: string;
17
+ }[];
18
+ }
19
+
@@ -0,0 +1,25 @@
1
+ # screenshot
2
+
3
+ Insert the path of the newest Windows system screenshot into Pi's editor. It references an existing image rather than taking a new screenshot.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pi install npm:@rol1n/screenshot
9
+ ```
10
+
11
+ Available independently; it is not enabled in the aggregate package by default.
12
+
13
+ ## Use
14
+
15
+ Take a screenshot with the system screenshot tool, then run `/screenshot` in Pi. The extension finds the newest supported image by modification time and pastes its path into the editor.
16
+
17
+ ## Platform support
18
+
19
+ Native Windows checks standard and localized `Pictures/Screenshots` folders, including supported OneDrive locations, under `USERPROFILE`. WSL searches Windows profiles under the standard `/mnt/c/Users` mount. PNG, JPEG, GIF, BMP, and WebP files are supported.
20
+
21
+ On other platforms the command reports that Windows screenshots are unavailable. There is no screenshot-folder setting; a nonstandard Windows drive mount or screenshot location requires a source change. No model credentials are needed.
22
+
23
+ ## Source layout
24
+
25
+ `index.ts` registers the editor command. `discovery.ts` contains platform detection, screenshot-folder discovery, and newest-image selection.
@@ -0,0 +1,24 @@
1
+ # srp-theme
2
+
3
+ A Pi terminal presentation extension with a custom footer, prompt preview, agent-loop timing, and a tokens-per-second meter. It is an extension rather than a JSON theme package.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pi install npm:@rol1n/srp-theme
9
+ ```
10
+
11
+ Also included in `@rol1n/pi-kit`.
12
+
13
+ ## Commands
14
+
15
+ ```text
16
+ /srp-theme on
17
+ /srp-theme off
18
+ ```
19
+
20
+ These commands enable or disable the extension's footer/prompt presentation and TPS meter for the running session. The extension observes agent and turn events to update timing and throughput information.
21
+
22
+ ## Requirements and customization
23
+
24
+ Use Pi's interactive terminal mode for the custom widgets. The extension uses the active Pi theme's colors and does not replace your selected JSON theme. Appearance and layout constants live in `extensions/srp-theme/footer.ts` and `extensions/srp-theme/tps-meter.ts`; lifecycle hooks and the command are wired in `extensions/srp-theme/index.ts`. There is currently no package-specific settings section. No credentials or system binaries are required by the presentation layer itself.
@@ -0,0 +1,194 @@
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ // ============================ Prompt Preview Widget ============================
5
+
6
+ const POWERLINE_SEP_ANSI = "\x1b[38;5;244m";
7
+ const ANSI_RESET = "\x1b[0m";
8
+
9
+ /** Compact a multiline prompt into a single line, matching powerline's last-prompt behavior. */
10
+ export function compactPrompt(prompt: string | null | undefined): string {
11
+ // Custom-message runs (extension `sendMessage`, auto-continue, retries) have no user prompt.
12
+ if (typeof prompt !== "string") return "";
13
+ return prompt.replace(/\s+/g, " ").trim();
14
+ }
15
+
16
+ /**
17
+ * Render last prompt line using powerline's sep color (ANSI 244), displaying '↳ prompt'.
18
+ */
19
+ export function renderLastPromptLine(
20
+ lastUserPrompt: string | null | undefined,
21
+ width: number,
22
+ ): string[] {
23
+ const compact = compactPrompt(lastUserPrompt);
24
+ const prefix = ` ${POWERLINE_SEP_ANSI}↳${ANSI_RESET} `;
25
+ const availableWidth = width - visibleWidth(prefix);
26
+
27
+ if (!compact || availableWidth < 10) return [];
28
+
29
+ return [
30
+ truncateToWidth(
31
+ `${prefix}${POWERLINE_SEP_ANSI}${truncateToWidth(compact, availableWidth, "…")}${ANSI_RESET}`,
32
+ width,
33
+ "…",
34
+ ),
35
+ ];
36
+ }
37
+
38
+
39
+
40
+ // ============================ Incremental Time / Finished Widget ============================
41
+
42
+ // const WEEK_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
43
+ // type ClockVariant = "full" | "month_day" | "time_only";
44
+ type FinishedVariant = "full" | "compact" | "duration_only";
45
+
46
+ function formatDuration(ms: number): string {
47
+ if (ms < 0) return "0.0s";
48
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
49
+ if (ms < 3600_000) {
50
+ const totalSecs = Math.round(ms / 1000);
51
+ const mins = Math.floor(totalSecs / 60);
52
+ const secs = totalSecs % 60;
53
+ return `${mins}m${secs < 10 ? "0" : ""}${secs}s`;
54
+ }
55
+ const totalMins = Math.round(ms / 60000);
56
+ const hours = Math.floor(totalMins / 60);
57
+ const mins = totalMins % 60;
58
+ return `${hours}h${mins < 10 ? "0" : ""}${mins}m`;
59
+ }
60
+
61
+ /*
62
+ // 时间胶囊格式化已注释停用
63
+ function formatFooterClockVariant(date: Date, variant: ClockVariant): string {
64
+ const pad = (value: number) => value.toString().padStart(2, "0");
65
+ const hours = pad(date.getHours());
66
+ const minutes = pad(date.getMinutes());
67
+
68
+ if (variant === "time_only") return `[${hours}:${minutes}]`;
69
+
70
+ const month = pad(date.getMonth() + 1);
71
+ const dayOfMonth = pad(date.getDate());
72
+ if (variant === "month_day") return `[${month}-${dayOfMonth} ${hours}:${minutes}]`;
73
+
74
+ return `[${date.getFullYear()}-${month}-${dayOfMonth} ${WEEK_DAYS[date.getDay()]} ${hours}:${minutes}]`;
75
+ }
76
+ */
77
+
78
+ function formatLoopEndVariant(
79
+ finished: Date,
80
+ now: Date,
81
+ durationMs: number | null,
82
+ variant: FinishedVariant,
83
+ ): string {
84
+ const pad = (value: number) => value.toString().padStart(2, "0");
85
+ const hasDuration = typeof durationMs === "number" && durationMs >= 0;
86
+
87
+ if (variant === "duration_only") {
88
+ return hasDuration ? `X ${formatDuration(durationMs)}` : "X";
89
+ }
90
+
91
+ const isCrossYear = finished.getFullYear() !== now.getFullYear();
92
+ const isCrossDay =
93
+ finished.getMonth() !== now.getMonth() || finished.getDate() !== now.getDate();
94
+ const dateOrTime = isCrossYear
95
+ ? `${finished.getFullYear()}`
96
+ : isCrossDay
97
+ ? `${pad(finished.getMonth() + 1)}-${pad(finished.getDate())}`
98
+ : `${pad(finished.getHours())}:${pad(finished.getMinutes())}`;
99
+
100
+ const details = hasDuration ? `${formatDuration(durationMs)} ${dateOrTime}` : dateOrTime;
101
+ return `X ${details}`;
102
+ }
103
+
104
+ export function getLastLoopInfoFromSession(ctx: ExtensionContext): {
105
+ endTime: Date;
106
+ durationMs: number | null;
107
+ } | null {
108
+ try {
109
+ const entries = ctx.sessionManager?.getEntries?.() ?? [];
110
+ let assistantIndex = -1;
111
+ let endTime: Date | null = null;
112
+
113
+ for (let i = entries.length - 1; i >= 0; i--) {
114
+ const entry = entries[i] as any;
115
+ if (entry?.type !== "message" || entry?.message?.role !== "assistant" || !entry?.timestamp) continue;
116
+ const timestamp = new Date(entry.timestamp);
117
+ if (!Number.isNaN(timestamp.getTime())) {
118
+ assistantIndex = i;
119
+ endTime = timestamp;
120
+ break;
121
+ }
122
+ }
123
+
124
+ if (!endTime || assistantIndex < 0) return null;
125
+
126
+ let durationMs: number | null = null;
127
+ for (let i = assistantIndex - 1; i >= 0; i--) {
128
+ const entry = entries[i] as any;
129
+ if (entry?.type !== "message" || entry?.message?.role !== "user" || !entry?.timestamp) continue;
130
+ const startTime = new Date(entry.timestamp);
131
+ if (!Number.isNaN(startTime.getTime())) {
132
+ const duration = endTime.getTime() - startTime.getTime();
133
+ durationMs = duration >= 0 ? duration : null;
134
+ break;
135
+ }
136
+ }
137
+
138
+ return { endTime, durationMs };
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ export function renderTimeAndFinishedLine(
145
+ lastLoopEndTime: Date | null,
146
+ lastLoopDurationMs: number | null,
147
+ width: number,
148
+ theme: Theme,
149
+ ): string[] {
150
+ if (!lastLoopEndTime) return [];
151
+
152
+ const now = new Date();
153
+ const candidates: string[] = [];
154
+
155
+ const fullFinished = formatLoopEndVariant(lastLoopEndTime, now, lastLoopDurationMs, "full");
156
+ const compactFinished = formatLoopEndVariant(lastLoopEndTime, now, lastLoopDurationMs, "compact");
157
+ const durationFinished = formatLoopEndVariant(lastLoopEndTime, now, lastLoopDurationMs, "duration_only");
158
+
159
+ candidates.push(fullFinished);
160
+ if (compactFinished !== fullFinished) {
161
+ candidates.push(compactFinished);
162
+ }
163
+ if (durationFinished && durationFinished !== compactFinished && durationFinished !== fullFinished) {
164
+ candidates.push(durationFinished);
165
+ }
166
+
167
+ /*
168
+ // 时间胶囊(已禁用/注释,仅保留 agent-loop 完成时间和用时):
169
+ if (lastLoopEndTime) {
170
+ candidates.push(
171
+ `${fullFinished} ${formatFooterClockVariant(now, "full")}`,
172
+ `${fullFinished} ${formatFooterClockVariant(now, "month_day")}`,
173
+ `${fullFinished} ${formatFooterClockVariant(now, "time_only")}`,
174
+ );
175
+ if (compactFinished !== fullFinished) {
176
+ candidates.push(`${compactFinished} ${formatFooterClockVariant(now, "time_only")}`);
177
+ }
178
+ if (durationFinished && durationFinished !== compactFinished && durationFinished !== fullFinished) {
179
+ candidates.push(`${durationFinished} ${formatFooterClockVariant(now, "time_only")}`);
180
+ }
181
+ } else {
182
+ candidates.push(
183
+ formatFooterClockVariant(now, "full"),
184
+ formatFooterClockVariant(now, "month_day"),
185
+ formatFooterClockVariant(now, "time_only"),
186
+ );
187
+ }
188
+ */
189
+
190
+ const content = candidates.find((candidate) => visibleWidth(candidate) <= width) ?? candidates.at(-1)!;
191
+ const line = truncateToWidth(content, width, "…");
192
+ return [theme.fg("dim", line)];
193
+ }
194
+
@@ -0,0 +1,172 @@
1
+ /**
2
+ * srp-theme — Prompt preview and TPS meter extension.
3
+ *
4
+ * Scheme A (Pure Incremental Mode):
5
+ * Leaves Pi's native Header and Footer untouched so official features
6
+ * (CH cache hit rate, W cache write, auto-compaction indicator, routed models, etc.)
7
+ * work out-of-the-box without maintenance drift.
8
+ *
9
+ * Adds two lightweight incremental UI enhancements:
10
+ * 1. belowEditor widget: Displays the last user prompt in powerline style (↳ <prompt>).
11
+ * 2. Status line item: Real-time streaming TPS meter and sparkline summary.
12
+ */
13
+
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
16
+ import { getLastLoopInfoFromSession, renderLastPromptLine, renderTimeAndFinishedLine } from "./footer.ts";
17
+ import { safeFallbackTheme, TpsMeter } from "./tps-meter.ts";
18
+
19
+ export { compactPrompt, renderLastPromptLine } from "./footer.ts";
20
+ export { TpsMeter } from "./tps-meter.ts";
21
+
22
+ export default function (pi: ExtensionAPI) {
23
+ let lastUserPrompt = "";
24
+ let footerEnabled = true;
25
+ let lastLoopEndTime: Date | null = null;
26
+ let lastLoopDurationMs: number | null = null;
27
+ let currentLoopStartMs = 0;
28
+ const tpsMeter = new TpsMeter();
29
+
30
+ const installFooter = (ctx: ExtensionContext): void => {
31
+ // Keep Pi's native footer active; this widget is purely incremental.
32
+ ctx.ui.setFooter(undefined);
33
+ ctx.ui.setWidget(
34
+ "srp-footer",
35
+ (_tui, theme) => {
36
+ return {
37
+ invalidate() {},
38
+ dispose() {},
39
+ render(width: number): string[] {
40
+ return [
41
+ ...renderLastPromptLine(lastUserPrompt, width),
42
+ ...renderTimeAndFinishedLine(lastLoopEndTime, lastLoopDurationMs, width, theme),
43
+ ];
44
+ },
45
+ };
46
+ },
47
+ { placement: "belowEditor" },
48
+ );
49
+ };
50
+
51
+ const removeFooter = (ctx: ExtensionContext): void => {
52
+ ctx.ui.setWidget("srp-footer", undefined);
53
+ ctx.ui.setFooter(undefined);
54
+ };
55
+
56
+ pi.on("session_start", (_event, ctx) => {
57
+ lastUserPrompt = "";
58
+ currentLoopStartMs = 0;
59
+ const info = getLastLoopInfoFromSession(ctx);
60
+ lastLoopEndTime = info?.endTime ?? null;
61
+ lastLoopDurationMs = info?.durationMs ?? null;
62
+ footerEnabled = true;
63
+ tpsMeter.enabled = true;
64
+ tpsMeter.reset(ctx);
65
+
66
+ if (ctx.mode === "tui") {
67
+ // Restore native header & footer
68
+ ctx.ui.setHeader(undefined);
69
+ ctx.ui.setFooter(undefined);
70
+ removeFooter(ctx);
71
+ }
72
+ });
73
+
74
+ // Schedule resources_discover after session_start to position widget below powerline widgets
75
+ pi.on("resources_discover", (_event, ctx) => {
76
+ if (footerEnabled && ctx.mode === "tui") {
77
+ installFooter(ctx);
78
+ }
79
+ });
80
+
81
+ // event.prompt is expanded user input. It is absent for custom-message runs
82
+ // (messageType === "custom"), e.g. extension-triggered auto-continue; keep the previous prompt then.
83
+ pi.on("before_agent_start", (event) => {
84
+ if (event.messageType === "user" && typeof event.prompt === "string") {
85
+ lastUserPrompt = event.prompt;
86
+ }
87
+ currentLoopStartMs = Date.now();
88
+ });
89
+
90
+ // TPS monitoring and lifecycle hooks
91
+ pi.on("message_start", (event, ctx) => {
92
+ if (event.message.role !== "assistant") return;
93
+ tpsMeter.onMessageStart(ctx);
94
+ });
95
+
96
+ pi.on("message_update", (event) => {
97
+ if (event.message.role !== "assistant") return;
98
+ tpsMeter.onMessageUpdate(event.assistantMessageEvent as { type: string; delta?: unknown });
99
+ });
100
+
101
+ pi.on("message_end", (event, ctx) => {
102
+ if (event.message.role !== "assistant") return;
103
+ const realOut = (event.message as { usage?: { output?: number } })?.usage?.output;
104
+ tpsMeter.onMessageEnd(realOut, ctx);
105
+ });
106
+
107
+ pi.on("tool_execution_start", (_event, ctx) => {
108
+ tpsMeter.onToolStart(ctx);
109
+ });
110
+
111
+ pi.on("tool_call", (_event, ctx) => {
112
+ tpsMeter.onToolStart(ctx);
113
+ });
114
+
115
+ pi.on("turn_end", (_event, ctx) => {
116
+ tpsMeter.onTurnEnd(ctx);
117
+ });
118
+
119
+ pi.on("agent_end", (_event, ctx) => {
120
+ lastLoopEndTime = new Date();
121
+ if (currentLoopStartMs > 0) {
122
+ lastLoopDurationMs = Math.max(0, Date.now() - currentLoopStartMs);
123
+ }
124
+ tpsMeter.onAgentEnd(ctx);
125
+ });
126
+
127
+ pi.on("agent_settled", (_event, ctx) => {
128
+ lastLoopEndTime = new Date();
129
+ if (currentLoopStartMs > 0) {
130
+ lastLoopDurationMs = Math.max(0, Date.now() - currentLoopStartMs);
131
+ }
132
+ tpsMeter.onAgentSettled(ctx);
133
+ });
134
+
135
+ pi.registerCommand("srp-theme", {
136
+ description: "Toggle SRP prompt widget and TPS meter on/off",
137
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
138
+ const candidates: AutocompleteItem[] = [
139
+ { value: "on", label: "on", description: "Enable prompt preview and TPS meter" },
140
+ { value: "off", label: "off", description: "Disable prompt preview and TPS meter" },
141
+ ];
142
+ const filtered = candidates.filter((item) => item.value.startsWith(prefix.trimStart()));
143
+ return filtered.length > 0 ? filtered : null;
144
+ },
145
+ handler: async (args, ctx) => {
146
+ const state = args.trim().toLowerCase();
147
+ if (state !== "on" && state !== "off") {
148
+ ctx.ui.notify("Usage: /srp-theme on|off", "info");
149
+ return;
150
+ }
151
+
152
+ const enabled = state === "on";
153
+ footerEnabled = enabled;
154
+ tpsMeter.enabled = enabled;
155
+
156
+ if (ctx.mode === "tui") {
157
+ ctx.ui.setFooter(undefined);
158
+ if (enabled) installFooter(ctx);
159
+ else removeFooter(ctx);
160
+ }
161
+
162
+ if (enabled) {
163
+ const theme = ctx.ui?.theme ?? safeFallbackTheme;
164
+ ctx.ui.setStatus("tps", tpsMeter.renderFinal(theme));
165
+ } else {
166
+ tpsMeter.stopTick();
167
+ ctx.ui.setStatus("tps", undefined);
168
+ }
169
+ ctx.ui.notify(`srp-theme: ${enabled ? "Enabled" : "Disabled"} prompt widget and TPS`, "info");
170
+ },
171
+ });
172
+ }