pi-thinking-timer 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xryul
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,64 @@
1
+ # pi-thinking-timer
2
+
3
+ A small **pi** extension that shows a live timer next to the collapsed **Thinking** label.
4
+
5
+ Instead of only:
6
+
7
+ ```
8
+ Thinking...
9
+ ```
10
+
11
+ you’ll see something like:
12
+
13
+ ```
14
+ Thinking... 6.5s
15
+ ```
16
+
17
+ It updates live while the model is thinking and leaves the final duration when thinking ends.
18
+
19
+ > Note: This extension patches pi’s internal `AssistantMessageComponent` render/update behavior.
20
+ > If pi changes its internal UI structure, the extension may stop working (it should fail safely and simply show the default `Thinking...` text).
21
+
22
+ ## Install
23
+
24
+ ### Option A: Install from npm (recommended)
25
+
26
+ ```bash
27
+ pi install npm:pi-thinking-timer
28
+ ```
29
+
30
+ Then restart `pi` (or run `/reload`) and ensure the extension is enabled (use `pi config` if you manage resources explicitly).
31
+
32
+ ### Option B: Try without installing (temporary)
33
+
34
+ ```bash
35
+ pi -e npm:pi-thinking-timer
36
+ ```
37
+
38
+ ### Option C: Install from GitHub
39
+
40
+ ```bash
41
+ pi install git:github.com/xRyul/pi-thinking-timer
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ 1. Use a model/thinking level that produces thinking blocks.
47
+ 2. Collapse/expand thinking blocks with **Ctrl+T**.
48
+ 3. When collapsed, the label will show the elapsed time.
49
+
50
+ There are no settings.
51
+
52
+ ## Development
53
+
54
+ Clone and run pi with the local extension file:
55
+
56
+ ```bash
57
+ git clone https://github.com/xRyul/pi-thinking-timer
58
+ cd pi-thinking-timer
59
+ pi -e ./extensions/thinking-timer.ts
60
+ ```
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Thinking Timer Extension
3
+ *
4
+ * Goal: show a live ticking timer *inline* on the collapsed "Thinking..." line,
5
+ * so you see:
6
+ *
7
+ * Thinking... 6.5s
8
+ *
9
+ * instead of having a second "Working..."/"Thinking ..." indicator line.
10
+ *
11
+ * Implementation notes:
12
+ * - We track thinking_start/thinking_end stream events to measure durations.
13
+ * - We monkey-patch AssistantMessageComponent.updateContent() to replace the
14
+ * hardcoded "Thinking..." label with "Thinking... <time>".
15
+ * - This relies on internal rendering behavior (but uses exported components),
16
+ * so it may break if pi changes how it renders collapsed thinking blocks.
17
+ */
18
+
19
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
20
+ import { AssistantMessageComponent } from "@mariozechner/pi-coding-agent";
21
+ import { Text } from "@mariozechner/pi-tui";
22
+
23
+ type Store = {
24
+ /** Active thinking blocks: key -> start time (ms since epoch) */
25
+ starts: Map<string, number>;
26
+ /** Finalized thinking blocks: key -> duration ms */
27
+ durations: Map<string, number>;
28
+ /** Rendered label components for collapsed thinking blocks */
29
+ labels: Map<string, Text>;
30
+ /** Latest theme reference (ctx.ui.theme) */
31
+ theme?: ExtensionContext["ui"]["theme"];
32
+ };
33
+
34
+ const STORE_KEY = Symbol.for("pi.extensions.thinkingTimer.store");
35
+ const PATCH_KEY = Symbol.for("pi.extensions.thinkingTimer.patch");
36
+
37
+ function getStore(): Store | undefined {
38
+ return (globalThis as any)[STORE_KEY] as Store | undefined;
39
+ }
40
+
41
+ function formatElapsed(ms: number): string {
42
+ const totalSeconds = ms / 1000;
43
+ if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
44
+ const minutes = Math.floor(totalSeconds / 60);
45
+ const seconds = totalSeconds - minutes * 60;
46
+ return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`;
47
+ }
48
+
49
+ function makeThinkingLabel(theme: Store["theme"] | undefined, ms: number | null): string {
50
+ if (!theme) {
51
+ return ms === null ? "Thinking..." : `Thinking... ${formatElapsed(ms)}`;
52
+ }
53
+ if (ms === null) {
54
+ return theme.italic(theme.fg("thinkingText", "Thinking..."));
55
+ }
56
+ const base = theme.fg("thinkingText", "Thinking...");
57
+ const time = theme.fg("dim", ` ${formatElapsed(ms)}`);
58
+ return theme.italic(base + time);
59
+ }
60
+
61
+ function keyFor(timestamp: number, contentIndex: number): string {
62
+ return `${timestamp}:${contentIndex}`;
63
+ }
64
+
65
+ function ensureAssistantMessagePatchInstalled(): void {
66
+ const proto: any = AssistantMessageComponent.prototype as any;
67
+ if (proto[PATCH_KEY]) return;
68
+ proto[PATCH_KEY] = true;
69
+
70
+ const originalUpdateContent = proto.updateContent;
71
+
72
+ proto.updateContent = function patchedUpdateContent(this: any, message: any) {
73
+ originalUpdateContent.call(this, message);
74
+
75
+ try {
76
+ const store = getStore();
77
+ if (!store) return;
78
+ if (!message || !message.content || !Array.isArray(message.content)) return;
79
+ if (!this.hideThinkingBlock) return;
80
+ if (!this.contentContainer || !Array.isArray(this.contentContainer.children)) return;
81
+
82
+ // Find thinking content indices that would produce a collapsed label.
83
+ const thinkingIndices: number[] = [];
84
+ for (let i = 0; i < message.content.length; i++) {
85
+ const c = message.content[i];
86
+ if (c?.type === "thinking" && typeof c.thinking === "string" && c.thinking.trim()) {
87
+ thinkingIndices.push(i);
88
+ }
89
+ }
90
+ if (thinkingIndices.length === 0) return;
91
+
92
+ // Find the Text components that currently contain the hardcoded "Thinking..." label.
93
+ const labelComponents: Text[] = [];
94
+ for (const child of this.contentContainer.children as any[]) {
95
+ // Be defensive: avoid relying on instanceof across module boundaries.
96
+ if (!child || typeof child !== "object") continue;
97
+ if (typeof child.setText !== "function") continue;
98
+ if (typeof child.text !== "string") continue;
99
+ if (!child.text.includes("Thinking...")) continue;
100
+ labelComponents.push(child as Text);
101
+ }
102
+ if (labelComponents.length === 0) return;
103
+
104
+ const count = Math.min(thinkingIndices.length, labelComponents.length);
105
+ for (let j = 0; j < count; j++) {
106
+ const contentIndex = thinkingIndices[j]!;
107
+ const label = labelComponents[j]!;
108
+ const k = keyFor(message.timestamp, contentIndex);
109
+ store.labels.set(k, label);
110
+
111
+ // Apply either live or finalized duration if we have it.
112
+ let ms: number | null = null;
113
+ const start = store.starts.get(k);
114
+ const dur = store.durations.get(k);
115
+ if (dur !== undefined) {
116
+ ms = dur;
117
+ } else if (start !== undefined) {
118
+ ms = Date.now() - start;
119
+ }
120
+
121
+ // Only override label when we have timing info (or when live),
122
+ // otherwise leave the original rendering alone.
123
+ if (ms !== null) {
124
+ label.setText(makeThinkingLabel(store.theme, ms));
125
+ }
126
+ }
127
+ } catch {
128
+ // Never break rendering
129
+ }
130
+ };
131
+ }
132
+
133
+ export default function (pi: ExtensionAPI) {
134
+ // Shared store used by the patch (global so /reload replaces it cleanly)
135
+ const store: Store = {
136
+ starts: new Map(),
137
+ durations: new Map(),
138
+ labels: new Map(),
139
+ theme: undefined,
140
+ };
141
+ (globalThis as any)[STORE_KEY] = store;
142
+ ensureAssistantMessagePatchInstalled();
143
+
144
+ let ticker: ReturnType<typeof setInterval> | null = null;
145
+
146
+ function stopTicker() {
147
+ if (ticker) {
148
+ clearInterval(ticker);
149
+ ticker = null;
150
+ }
151
+ }
152
+
153
+ function tick() {
154
+ const s = getStore();
155
+ if (!s) return;
156
+ if (s.starts.size === 0) {
157
+ stopTicker();
158
+ return;
159
+ }
160
+ for (const [k, start] of s.starts.entries()) {
161
+ const label = s.labels.get(k);
162
+ if (!label) continue;
163
+ label.setText(makeThinkingLabel(s.theme, Date.now() - start));
164
+ }
165
+ }
166
+
167
+ function startTicker() {
168
+ if (ticker) return;
169
+ ticker = setInterval(tick, 100);
170
+ }
171
+
172
+ function finalizeThinkingBlock(k: string, endTimeMs = Date.now()) {
173
+ const s = getStore();
174
+ if (!s) return;
175
+ const start = s.starts.get(k);
176
+ if (start === undefined) return;
177
+ const dur = Math.max(0, endTimeMs - start);
178
+ s.starts.delete(k);
179
+ s.durations.set(k, dur);
180
+
181
+ const label = s.labels.get(k);
182
+ if (label) {
183
+ label.setText(makeThinkingLabel(s.theme, dur));
184
+ }
185
+ }
186
+
187
+ function resetAll(ctx: ExtensionContext) {
188
+ stopTicker();
189
+ store.starts.clear();
190
+ store.durations.clear();
191
+ store.labels.clear();
192
+ store.theme = ctx.ui.theme;
193
+ // Ensure we don't leave a custom working message around from earlier versions.
194
+ ctx.ui.setWorkingMessage();
195
+ }
196
+
197
+ pi.on("session_start", async (_event, ctx) => {
198
+ store.theme = ctx.ui.theme;
199
+ ctx.ui.setWorkingMessage();
200
+ });
201
+
202
+ pi.on("message_update", async (event, ctx) => {
203
+ store.theme = ctx.ui.theme;
204
+
205
+ const se = event.assistantMessageEvent as any;
206
+ if (!se || typeof se.type !== "string") return;
207
+
208
+ if (se.type === "thinking_start" || se.type === "thinking_delta") {
209
+ const msg = se.partial;
210
+ const k = keyFor(msg.timestamp, se.contentIndex);
211
+ if (!store.starts.has(k)) {
212
+ store.starts.set(k, Date.now());
213
+ }
214
+ startTicker();
215
+ // Try immediate paint if label already exists
216
+ tick();
217
+ return;
218
+ }
219
+
220
+ if (se.type === "thinking_end") {
221
+ const msg = se.partial;
222
+ const k = keyFor(msg.timestamp, se.contentIndex);
223
+ finalizeThinkingBlock(k);
224
+ if (store.starts.size === 0) stopTicker();
225
+ return;
226
+ }
227
+ });
228
+
229
+ // Safety: if a message ends while a thinking_start was seen but thinking_end was not,
230
+ // finalize any active thinking blocks for that message.
231
+ pi.on("message_end", async (event, ctx) => {
232
+ store.theme = ctx.ui.theme;
233
+ const msg: any = event.message;
234
+ if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) return;
235
+
236
+ for (let i = 0; i < msg.content.length; i++) {
237
+ const c = msg.content[i];
238
+ if (c?.type !== "thinking") continue;
239
+ const k = keyFor(msg.timestamp, i);
240
+ if (store.starts.has(k)) {
241
+ finalizeThinkingBlock(k, Date.now());
242
+ }
243
+ }
244
+ if (store.starts.size === 0) stopTicker();
245
+ });
246
+
247
+ pi.on("session_switch", async (_event, ctx) => {
248
+ resetAll(ctx);
249
+ });
250
+
251
+ pi.on("session_shutdown", async (_event, ctx) => {
252
+ resetAll(ctx);
253
+ });
254
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "pi-thinking-timer",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that shows a live timer next to collapsed Thinking blocks.",
5
+ "license": "MIT",
6
+ "author": "xryul",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/xRyul/pi-thinking-timer.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/xRyul/pi-thinking-timer/issues"
13
+ },
14
+ "homepage": "https://github.com/xRyul/pi-thinking-timer#readme",
15
+ "keywords": [
16
+ "pi-package",
17
+ "pi",
18
+ "pi-extension"
19
+ ],
20
+ "pi": {
21
+ "extensions": ["./extensions"]
22
+ },
23
+ "peerDependencies": {
24
+ "@mariozechner/pi-coding-agent": "*",
25
+ "@mariozechner/pi-tui": "*"
26
+ },
27
+ "files": [
28
+ "extensions",
29
+ "README.md",
30
+ "LICENSE",
31
+ "package.json"
32
+ ]
33
+ }