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 +21 -0
- package/README.md +64 -0
- package/extensions/thinking-timer.ts +254 -0
- package/package.json +33 -0
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
|
+
}
|