@maxiaochao/pi-toolkit 0.1.7 → 0.1.9
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/CHANGELOG.md +9 -0
- package/README.md +58 -1
- package/extensions/btw.ts +958 -0
- package/extensions/cache-export/index.ts +4 -1
- package/extensions/cache-export/tests/run-tests.mjs +51 -0
- package/package.json +10 -3
- package/scripts/install-agents.mjs +35 -7
- package/skills/html-artifact/SKILL.md +1 -3
- package/themes/nightowl.json +82 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2026-09-02 - v0.1.9
|
|
2
|
+
|
|
3
|
+
- add the `/btw` side-chat extension
|
|
4
|
+
- bundle the `nightowl` theme
|
|
5
|
+
- select `nightowl` during npm postinstall only when no theme is configured
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
- fix cache export branch attribution and update HTML artifact guidance
|
|
9
|
+
|
|
1
10
|
## 2026-08-21 - v0.1.7
|
|
2
11
|
|
|
3
12
|
- add the html-artifact skill for interactive HTML explanations and codebase visualizations
|
package/README.md
CHANGED
|
@@ -1,7 +1,64 @@
|
|
|
1
1
|
# pi-toolkit
|
|
2
2
|
|
|
3
|
-
Personal
|
|
3
|
+
Personal Pi toolkit with the `/todos`, `/btw`, and `/cache_export` extensions plus the bundled Night Owl theme.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Install the root package to enable the toolkit extensions globally:
|
|
4
8
|
|
|
5
9
|
```bash
|
|
6
10
|
pi install npm:@maxiaochao/pi-toolkit
|
|
7
11
|
```
|
|
12
|
+
|
|
13
|
+
The package also provides the `/btw` side-chat extension and the `nightowl` theme. On npm installation, the postinstall hook selects `nightowl` only when no theme has been configured yet; an existing Pi theme setting is preserved.
|
|
14
|
+
|
|
15
|
+
The package postinstall hook synchronizes the bundled global instructions to:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
~/.pi/agent/AGENTS.md
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
It also selects `nightowl` as the default theme only when `~/.pi/agent/settings.json` does not already contain a theme. To switch manually, use `/settings` or:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi --use-theme nightowl
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
An installed package can expose a theme through `pi.themes`, but Pi has no package-manifest field for a default theme, so the guarded postinstall step is used for the npm install case.
|
|
28
|
+
|
|
29
|
+
When `global/AGENTS.md` changes, publish a new `@maxiaochao/pi-toolkit` version and update installed copies:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi update npm:@maxiaochao/pi-toolkit
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
To synchronize the file directly from a checkout instead:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
bash scripts/install.sh
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The `/btw` side chat is a separate agent session. It can inspect the main conversation and repository, and its thread stays out of the main context until you close it and choose to inject a summary. Use `/btw` to open it or `/btw <question>` to ask directly.
|
|
42
|
+
|
|
43
|
+
The bundled `nightowl` theme is available to Pi through the package manifest and is selected automatically on npm installation only when no theme is already configured.
|
|
44
|
+
|
|
45
|
+
## Tiny Subagent
|
|
46
|
+
|
|
47
|
+
The tiny subagent is a separate npm package. Install it independently when you want the `tiny_subagents` tool:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pi install npm:@maxiaochao/pi-tiny-subagent
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It is not included in the root package's `pi.extensions` list. The two packages have independent versions, release tags, and update commands:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pi update npm:@maxiaochao/pi-toolkit
|
|
57
|
+
pi update npm:@maxiaochao/pi-tiny-subagent
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
See [packages/tiny-subagent/README.md](packages/tiny-subagent/README.md) for personas, model selection, child isolation, live transcript rendering, and diagnostics.
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
Repository development and release rules are documented in [AGENTS.md](AGENTS.md).
|
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSessionContext,
|
|
3
|
+
createAgentSession,
|
|
4
|
+
createExtensionRuntime,
|
|
5
|
+
getMarkdownTheme,
|
|
6
|
+
SessionManager,
|
|
7
|
+
type AgentSession,
|
|
8
|
+
type AgentSessionEvent,
|
|
9
|
+
type ExtensionAPI,
|
|
10
|
+
type ExtensionCommandContext,
|
|
11
|
+
type ExtensionContext,
|
|
12
|
+
type ResourceLoader,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { type AssistantMessage, type Message, type ThinkingLevel as AiThinkingLevel } from "@earendil-works/pi-ai";
|
|
15
|
+
import {
|
|
16
|
+
Container,
|
|
17
|
+
Input,
|
|
18
|
+
Markdown,
|
|
19
|
+
truncateToWidth,
|
|
20
|
+
visibleWidth,
|
|
21
|
+
type Focusable,
|
|
22
|
+
type KeybindingsManager,
|
|
23
|
+
type OverlayHandle,
|
|
24
|
+
type TUI,
|
|
25
|
+
} from "@earendil-works/pi-tui";
|
|
26
|
+
|
|
27
|
+
const BTW_ENTRY_TYPE = "btw-thread-entry";
|
|
28
|
+
const BTW_RESET_TYPE = "btw-thread-reset";
|
|
29
|
+
|
|
30
|
+
const BTW_SYSTEM_PROMPT = [
|
|
31
|
+
"You are BTW, a side-channel assistant embedded in the user's coding agent.",
|
|
32
|
+
"You have access to the main conversation context — use it to give informed answers.",
|
|
33
|
+
"Help with focused questions, planning, and quick explorations.",
|
|
34
|
+
"Be direct and practical.",
|
|
35
|
+
].join(" ");
|
|
36
|
+
|
|
37
|
+
const BTW_SUMMARY_PROMPT =
|
|
38
|
+
"Summarize this side conversation for handoff into the main conversation. Keep key decisions, findings, risks, and next actions. Output only the summary.";
|
|
39
|
+
|
|
40
|
+
type SessionThinkingLevel = "off" | AiThinkingLevel;
|
|
41
|
+
|
|
42
|
+
type BtwDetails = {
|
|
43
|
+
question: string;
|
|
44
|
+
answer: string;
|
|
45
|
+
timestamp: number;
|
|
46
|
+
provider: string;
|
|
47
|
+
model: string;
|
|
48
|
+
thinkingLevel: SessionThinkingLevel;
|
|
49
|
+
usage?: AssistantMessage["usage"];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type BtwResetDetails = {
|
|
53
|
+
timestamp: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type OverlayRuntime = {
|
|
57
|
+
handle?: OverlayHandle;
|
|
58
|
+
refresh?: () => void;
|
|
59
|
+
close?: () => void;
|
|
60
|
+
finish?: () => void;
|
|
61
|
+
setDraft?: (value: string) => void;
|
|
62
|
+
closed?: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type SideSessionRuntime = {
|
|
66
|
+
session: AgentSession;
|
|
67
|
+
modelKey: string;
|
|
68
|
+
unsubscribe: () => void;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type ToolCallInfo = {
|
|
72
|
+
toolCallId: string;
|
|
73
|
+
toolName: string;
|
|
74
|
+
args: string;
|
|
75
|
+
status: "running" | "done" | "error";
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function stripDynamicSystemPromptFooter(systemPrompt: string): string {
|
|
79
|
+
return systemPrompt
|
|
80
|
+
.replace(/\nCurrent date and time:[^\n]*(?:\nCurrent working directory:[^\n]*)?$/u, "")
|
|
81
|
+
.replace(/\nCurrent working directory:[^\n]*$/u, "")
|
|
82
|
+
.trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createBtwResourceLoader(ctx: ExtensionContext, appendSystemPrompt: string[] = [BTW_SYSTEM_PROMPT]): ResourceLoader {
|
|
86
|
+
const extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
|
|
87
|
+
const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt());
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
getExtensions: () => extensionsResult,
|
|
91
|
+
getSkills: () => ({ skills: [], diagnostics: [] }),
|
|
92
|
+
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
93
|
+
getThemes: () => ({ themes: [], diagnostics: [] }),
|
|
94
|
+
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
95
|
+
getSystemPrompt: () => systemPrompt,
|
|
96
|
+
getAppendSystemPrompt: () => appendSystemPrompt,
|
|
97
|
+
extendResources: () => {},
|
|
98
|
+
reload: async () => {},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function extractText(parts: AssistantMessage["content"]): string {
|
|
103
|
+
return parts
|
|
104
|
+
.filter((part) => part.type === "text")
|
|
105
|
+
.map((part) => part.text)
|
|
106
|
+
.join("\n")
|
|
107
|
+
.trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractEventAssistantText(message: unknown): string {
|
|
111
|
+
if (!message || typeof message !== "object") {
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const maybeMessage = message as { role?: unknown; content?: unknown };
|
|
116
|
+
if (maybeMessage.role !== "assistant" || !Array.isArray(maybeMessage.content)) {
|
|
117
|
+
return "";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return maybeMessage.content
|
|
121
|
+
.filter((part): part is { type: "text"; text: string } => {
|
|
122
|
+
return !!part && typeof part === "object" && (part as { type?: unknown }).type === "text";
|
|
123
|
+
})
|
|
124
|
+
.map((part) => part.text)
|
|
125
|
+
.join("\n")
|
|
126
|
+
.trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getLastAssistantMessage(session: AgentSession): AssistantMessage | null {
|
|
130
|
+
for (let i = session.state.messages.length - 1; i >= 0; i--) {
|
|
131
|
+
const message = session.state.messages[i];
|
|
132
|
+
if (message.role === "assistant") {
|
|
133
|
+
return message as AssistantMessage;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildSeedMessages(ctx: ExtensionContext, thread: BtwDetails[]): Message[] {
|
|
141
|
+
const seed: Message[] = [];
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const contextMessages = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages;
|
|
145
|
+
seed.push(...(contextMessages.filter((message) => "role" in message) as Message[]));
|
|
146
|
+
} catch {
|
|
147
|
+
// Ignore context seed failures and continue with an empty side thread.
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const item of thread) {
|
|
151
|
+
seed.push(
|
|
152
|
+
{
|
|
153
|
+
role: "user",
|
|
154
|
+
content: [{ type: "text", text: item.question }],
|
|
155
|
+
timestamp: item.timestamp,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
role: "assistant",
|
|
159
|
+
content: [{ type: "text", text: item.answer }],
|
|
160
|
+
provider: item.provider,
|
|
161
|
+
model: item.model,
|
|
162
|
+
api: ctx.model?.api ?? "openai-responses",
|
|
163
|
+
usage:
|
|
164
|
+
item.usage ??
|
|
165
|
+
{
|
|
166
|
+
input: 0,
|
|
167
|
+
output: 0,
|
|
168
|
+
cacheRead: 0,
|
|
169
|
+
cacheWrite: 0,
|
|
170
|
+
totalTokens: 0,
|
|
171
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
172
|
+
},
|
|
173
|
+
stopReason: "stop",
|
|
174
|
+
timestamp: item.timestamp,
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return seed;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function formatThread(thread: BtwDetails[]): string {
|
|
183
|
+
return thread
|
|
184
|
+
.map((item) => `User: ${item.question.trim()}\nAssistant: ${item.answer.trim()}`)
|
|
185
|
+
.join("\n\n---\n\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
|
|
189
|
+
if (ctx.hasUI) {
|
|
190
|
+
ctx.ui.notify(message, level);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class BtwOverlay extends Container implements Focusable {
|
|
196
|
+
private readonly input: Input;
|
|
197
|
+
private readonly tui: TUI;
|
|
198
|
+
private readonly theme: ExtensionContext["ui"]["theme"];
|
|
199
|
+
private readonly keybindings: KeybindingsManager;
|
|
200
|
+
private readonly getTranscript: (width: number, theme: ExtensionContext["ui"]["theme"]) => string[];
|
|
201
|
+
private readonly getStatus: () => string;
|
|
202
|
+
private readonly onSubmitCallback: (value: string) => void;
|
|
203
|
+
private readonly onDismissCallback: () => void;
|
|
204
|
+
private _focused = false;
|
|
205
|
+
|
|
206
|
+
get focused(): boolean {
|
|
207
|
+
return this._focused;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
set focused(value: boolean) {
|
|
211
|
+
this._focused = value;
|
|
212
|
+
this.input.focused = value;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
constructor(
|
|
216
|
+
tui: TUI,
|
|
217
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
218
|
+
keybindings: KeybindingsManager,
|
|
219
|
+
getTranscript: (width: number, theme: ExtensionContext["ui"]["theme"]) => string[],
|
|
220
|
+
getStatus: () => string,
|
|
221
|
+
onSubmit: (value: string) => void,
|
|
222
|
+
onDismiss: () => void,
|
|
223
|
+
) {
|
|
224
|
+
super();
|
|
225
|
+
this.tui = tui;
|
|
226
|
+
this.theme = theme;
|
|
227
|
+
this.keybindings = keybindings;
|
|
228
|
+
this.getTranscript = getTranscript;
|
|
229
|
+
this.getStatus = getStatus;
|
|
230
|
+
this.onSubmitCallback = onSubmit;
|
|
231
|
+
this.onDismissCallback = onDismiss;
|
|
232
|
+
|
|
233
|
+
this.input = new Input();
|
|
234
|
+
this.input.onSubmit = (value) => {
|
|
235
|
+
this.onSubmitCallback(value);
|
|
236
|
+
};
|
|
237
|
+
this.input.onEscape = () => {
|
|
238
|
+
this.onDismissCallback();
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
handleInput(data: string): void {
|
|
243
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
244
|
+
this.onDismissCallback();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
this.input.handleInput(data);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
setDraft(value: string): void {
|
|
252
|
+
this.input.setValue(value);
|
|
253
|
+
this.tui.requestRender();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
getDraft(): string {
|
|
257
|
+
return this.input.getValue();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private frameLine(content: string, innerWidth: number): string {
|
|
261
|
+
const truncated = truncateToWidth(content, innerWidth, "");
|
|
262
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
263
|
+
return `${this.theme.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("borderMuted", "│")}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
267
|
+
const left = edge === "top" ? "┌" : "└";
|
|
268
|
+
const right = edge === "top" ? "┐" : "┘";
|
|
269
|
+
return this.theme.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
override render(width: number): string[] {
|
|
273
|
+
const dialogWidth = Math.max(56, Math.min(width, Math.floor(width * 0.9)));
|
|
274
|
+
const innerWidth = Math.max(40, dialogWidth - 2);
|
|
275
|
+
const terminalRows = process.stdout.rows ?? 30;
|
|
276
|
+
const dialogHeight = Math.max(16, Math.min(30, Math.floor(terminalRows * 0.75)));
|
|
277
|
+
const chromeHeight = 7;
|
|
278
|
+
const transcriptHeight = Math.max(6, dialogHeight - chromeHeight);
|
|
279
|
+
|
|
280
|
+
// Markdown renders to innerWidth already — no manual wrapping needed
|
|
281
|
+
const transcript = this.getTranscript(innerWidth, this.theme);
|
|
282
|
+
const visibleTranscript = transcript.slice(-transcriptHeight);
|
|
283
|
+
const transcriptPadding = Math.max(0, transcriptHeight - visibleTranscript.length);
|
|
284
|
+
|
|
285
|
+
const status = this.getStatus();
|
|
286
|
+
|
|
287
|
+
const previousFocused = this.input.focused;
|
|
288
|
+
this.input.focused = false;
|
|
289
|
+
const inputLine = this.input.render(innerWidth)[0] ?? "";
|
|
290
|
+
this.input.focused = previousFocused;
|
|
291
|
+
|
|
292
|
+
const lines = [
|
|
293
|
+
this.borderLine(innerWidth, "top"),
|
|
294
|
+
this.frameLine(this.theme.fg("accent", this.theme.bold(" BTW side chat ")), innerWidth),
|
|
295
|
+
this.frameLine(this.theme.fg("dim", "Separate side conversation. Esc closes."), innerWidth),
|
|
296
|
+
this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`),
|
|
297
|
+
];
|
|
298
|
+
|
|
299
|
+
for (const line of visibleTranscript) {
|
|
300
|
+
lines.push(this.frameLine(line, innerWidth));
|
|
301
|
+
}
|
|
302
|
+
for (let i = 0; i < transcriptPadding; i++) {
|
|
303
|
+
lines.push(this.frameLine("", innerWidth));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
lines.push(this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`));
|
|
307
|
+
lines.push(this.frameLine(this.theme.fg("warning", status), innerWidth));
|
|
308
|
+
lines.push(
|
|
309
|
+
`${this.theme.fg("borderMuted", "│")}${inputLine}${this.theme.fg("borderMuted", "│")}`,
|
|
310
|
+
);
|
|
311
|
+
lines.push(this.frameLine(this.theme.fg("dim", "Enter submit · Esc close"), innerWidth));
|
|
312
|
+
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
313
|
+
|
|
314
|
+
return lines;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export default function (pi: ExtensionAPI) {
|
|
319
|
+
let thread: BtwDetails[] = [];
|
|
320
|
+
let pendingQuestion: string | null = null;
|
|
321
|
+
let pendingAnswer = "";
|
|
322
|
+
let pendingError: string | null = null;
|
|
323
|
+
let pendingToolCalls: ToolCallInfo[] = [];
|
|
324
|
+
let sideBusy = false;
|
|
325
|
+
let overlayStatus = "Ready";
|
|
326
|
+
let overlayDraft = "";
|
|
327
|
+
let overlayRuntime: OverlayRuntime | null = null;
|
|
328
|
+
let activeSideSession: SideSessionRuntime | null = null;
|
|
329
|
+
let overlayRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
330
|
+
|
|
331
|
+
const mdTheme = getMarkdownTheme();
|
|
332
|
+
|
|
333
|
+
function getModelKey(ctx: ExtensionContext): string {
|
|
334
|
+
const model = ctx.model;
|
|
335
|
+
return model ? `${model.provider}/${model.id}` : "none";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renderMarkdownLines(text: string, width: number): string[] {
|
|
339
|
+
if (!text) return [];
|
|
340
|
+
try {
|
|
341
|
+
const md = new Markdown(text, 0, 0, mdTheme);
|
|
342
|
+
return md.render(width);
|
|
343
|
+
} catch {
|
|
344
|
+
// Fall back to plain text wrapping if Markdown rendering fails
|
|
345
|
+
return text.split("\n").flatMap((line) => {
|
|
346
|
+
if (!line) return [""];
|
|
347
|
+
const wrapped: string[] = [];
|
|
348
|
+
for (let i = 0; i < line.length; i += width) {
|
|
349
|
+
wrapped.push(line.slice(i, i + width));
|
|
350
|
+
}
|
|
351
|
+
return wrapped.length > 0 ? wrapped : [""];
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function formatToolArgs(toolName: string, args: unknown): string {
|
|
357
|
+
if (!args || typeof args !== "object") return "";
|
|
358
|
+
const a = args as Record<string, unknown>;
|
|
359
|
+
switch (toolName) {
|
|
360
|
+
case "bash":
|
|
361
|
+
return typeof a.command === "string" ? truncateToWidth(a.command.split("\n")[0], 50, "…") : "";
|
|
362
|
+
case "read":
|
|
363
|
+
case "write":
|
|
364
|
+
case "edit":
|
|
365
|
+
return typeof a.path === "string" ? a.path : "";
|
|
366
|
+
default: {
|
|
367
|
+
const first = Object.values(a)[0];
|
|
368
|
+
return typeof first === "string" ? truncateToWidth(first.split("\n")[0], 40, "…") : "";
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function renderToolCallLines(toolCalls: ToolCallInfo[], theme: ExtensionContext["ui"]["theme"], width: number): string[] {
|
|
374
|
+
const lines: string[] = [];
|
|
375
|
+
for (const tc of toolCalls) {
|
|
376
|
+
const icon = tc.status === "running" ? "⚙" : tc.status === "error" ? "✗" : "✓";
|
|
377
|
+
const color = tc.status === "error" ? "error" : tc.status === "done" ? "success" : "dim";
|
|
378
|
+
const label = theme.fg(color, `${icon} `) + theme.fg("toolTitle", tc.toolName);
|
|
379
|
+
const argsText = tc.args ? theme.fg("dim", ` ${tc.args}`) : "";
|
|
380
|
+
lines.push(truncateToWidth(` ${label}${argsText}`, width, ""));
|
|
381
|
+
}
|
|
382
|
+
return lines;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function getTranscriptLines(width: number, theme: ExtensionContext["ui"]["theme"]): string[] {
|
|
386
|
+
try {
|
|
387
|
+
return getTranscriptLinesInner(width, theme);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
return [theme.fg("error", `Render error: ${error instanceof Error ? error.message : String(error)}`)];
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function getTranscriptLinesInner(width: number, theme: ExtensionContext["ui"]["theme"]): string[] {
|
|
394
|
+
if (thread.length === 0 && !pendingQuestion && !pendingAnswer && !pendingError) {
|
|
395
|
+
return [theme.fg("dim", "No BTW messages yet. Type a question below.")];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const lines: string[] = [];
|
|
399
|
+
for (const item of thread.slice(-6)) {
|
|
400
|
+
// User message
|
|
401
|
+
const userText = item.question.trim().split("\n")[0];
|
|
402
|
+
lines.push(theme.fg("accent", theme.bold("You: ")) + truncateToWidth(userText, width - 5, "…"));
|
|
403
|
+
lines.push("");
|
|
404
|
+
|
|
405
|
+
// Assistant message rendered as markdown
|
|
406
|
+
const mdLines = renderMarkdownLines(item.answer, width);
|
|
407
|
+
lines.push(...mdLines);
|
|
408
|
+
lines.push("");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (pendingQuestion) {
|
|
412
|
+
const userText = pendingQuestion.trim().split("\n")[0];
|
|
413
|
+
lines.push(theme.fg("accent", theme.bold("You: ")) + truncateToWidth(userText, width - 5, "…"));
|
|
414
|
+
|
|
415
|
+
// Show tool calls inline
|
|
416
|
+
if (pendingToolCalls.length > 0) {
|
|
417
|
+
lines.push(...renderToolCallLines(pendingToolCalls, theme, width));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (pendingError) {
|
|
421
|
+
lines.push(theme.fg("error", `❌ ${pendingError}`));
|
|
422
|
+
} else if (pendingAnswer) {
|
|
423
|
+
lines.push("");
|
|
424
|
+
const mdLines = renderMarkdownLines(pendingAnswer, width);
|
|
425
|
+
lines.push(...mdLines);
|
|
426
|
+
} else if (pendingToolCalls.length === 0) {
|
|
427
|
+
lines.push(theme.fg("dim", "…"));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Trim trailing empty line
|
|
432
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
433
|
+
lines.pop();
|
|
434
|
+
}
|
|
435
|
+
return lines;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function syncOverlay(): void {
|
|
439
|
+
overlayRuntime?.refresh?.();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function scheduleOverlayRefresh(): void {
|
|
443
|
+
if (overlayRefreshTimer) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
overlayRefreshTimer = setTimeout(() => {
|
|
448
|
+
overlayRefreshTimer = null;
|
|
449
|
+
syncOverlay();
|
|
450
|
+
}, 16);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function setOverlayStatus(status: string, throttled = false): void {
|
|
454
|
+
overlayStatus = status;
|
|
455
|
+
if (throttled) {
|
|
456
|
+
scheduleOverlayRefresh();
|
|
457
|
+
} else {
|
|
458
|
+
syncOverlay();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function dismissOverlay(): void {
|
|
463
|
+
overlayRuntime?.close?.();
|
|
464
|
+
overlayRuntime = null;
|
|
465
|
+
if (overlayRefreshTimer) {
|
|
466
|
+
clearTimeout(overlayRefreshTimer);
|
|
467
|
+
overlayRefreshTimer = null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function setOverlayDraft(value: string): void {
|
|
472
|
+
overlayDraft = value;
|
|
473
|
+
overlayRuntime?.setDraft?.(value);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function disposeSideSession(): Promise<void> {
|
|
477
|
+
const current = activeSideSession;
|
|
478
|
+
activeSideSession = null;
|
|
479
|
+
if (!current) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
current.unsubscribe();
|
|
485
|
+
} catch {
|
|
486
|
+
// Ignore unsubscribe errors during cleanup.
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
await current.session.abort();
|
|
491
|
+
} catch {
|
|
492
|
+
// Ignore abort errors during cleanup.
|
|
493
|
+
}
|
|
494
|
+
current.session.dispose();
|
|
495
|
+
|
|
496
|
+
if (overlayRefreshTimer) {
|
|
497
|
+
clearTimeout(overlayRefreshTimer);
|
|
498
|
+
overlayRefreshTimer = null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function resetThread(ctx: ExtensionContext | ExtensionCommandContext, persist = true): Promise<void> {
|
|
503
|
+
thread = [];
|
|
504
|
+
pendingQuestion = null;
|
|
505
|
+
pendingAnswer = "";
|
|
506
|
+
pendingError = null;
|
|
507
|
+
pendingToolCalls = [];
|
|
508
|
+
sideBusy = false;
|
|
509
|
+
setOverlayDraft("");
|
|
510
|
+
setOverlayStatus("Ready");
|
|
511
|
+
await disposeSideSession();
|
|
512
|
+
if (persist) {
|
|
513
|
+
const details: BtwResetDetails = { timestamp: Date.now() };
|
|
514
|
+
pi.appendEntry(BTW_RESET_TYPE, details);
|
|
515
|
+
}
|
|
516
|
+
syncOverlay();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function restoreThread(ctx: ExtensionContext): Promise<void> {
|
|
520
|
+
await disposeSideSession();
|
|
521
|
+
thread = [];
|
|
522
|
+
pendingQuestion = null;
|
|
523
|
+
pendingAnswer = "";
|
|
524
|
+
pendingError = null;
|
|
525
|
+
pendingToolCalls = [];
|
|
526
|
+
sideBusy = false;
|
|
527
|
+
overlayStatus = "Ready";
|
|
528
|
+
overlayDraft = "";
|
|
529
|
+
const branch = ctx.sessionManager.getBranch();
|
|
530
|
+
let lastResetIndex = -1;
|
|
531
|
+
for (let i = 0; i < branch.length; i++) {
|
|
532
|
+
const entry = branch[i];
|
|
533
|
+
if (entry.type === "custom" && entry.customType === BTW_RESET_TYPE) {
|
|
534
|
+
lastResetIndex = i;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
for (const entry of branch.slice(lastResetIndex + 1)) {
|
|
539
|
+
if (entry.type !== "custom" || entry.customType !== BTW_ENTRY_TYPE) {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const details = entry.data as BtwDetails | undefined;
|
|
543
|
+
if (!details?.question || !details.answer) {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
thread.push(details);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
syncOverlay();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function createSideSession(ctx: ExtensionCommandContext): Promise<SideSessionRuntime | null> {
|
|
553
|
+
if (!ctx.model) {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const { session } = await createAgentSession({
|
|
558
|
+
sessionManager: SessionManager.inMemory(),
|
|
559
|
+
model: ctx.model,
|
|
560
|
+
modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
|
|
561
|
+
thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel,
|
|
562
|
+
tools: ["read", "bash", "edit", "write"],
|
|
563
|
+
resourceLoader: createBtwResourceLoader(ctx),
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const seedMessages = buildSeedMessages(ctx, thread);
|
|
567
|
+
if (seedMessages.length > 0) {
|
|
568
|
+
session.agent.state.messages = seedMessages as typeof session.agent.state.messages;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
572
|
+
if (!sideBusy || !pendingQuestion) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
switch (event.type) {
|
|
577
|
+
case "message_start":
|
|
578
|
+
case "message_update":
|
|
579
|
+
case "message_end": {
|
|
580
|
+
const streamed = extractEventAssistantText(event.message);
|
|
581
|
+
if (streamed) {
|
|
582
|
+
pendingAnswer = streamed;
|
|
583
|
+
pendingError = null;
|
|
584
|
+
}
|
|
585
|
+
setOverlayStatus(event.type === "message_end" ? "Finalizing side response..." : "Streaming side response...", true);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
case "tool_execution_start": {
|
|
589
|
+
const toolName = (event as { toolName?: string }).toolName ?? "unknown";
|
|
590
|
+
try {
|
|
591
|
+
pendingToolCalls.push({
|
|
592
|
+
toolCallId: (event as { toolCallId?: string }).toolCallId ?? "",
|
|
593
|
+
toolName,
|
|
594
|
+
args: formatToolArgs(toolName, (event as { args?: unknown }).args),
|
|
595
|
+
status: "running",
|
|
596
|
+
});
|
|
597
|
+
} catch {
|
|
598
|
+
// Ignore tool tracking failures
|
|
599
|
+
}
|
|
600
|
+
setOverlayStatus(`Running tool: ${toolName}...`, true);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
case "tool_execution_end": {
|
|
604
|
+
const endToolName = (event as { toolName?: string }).toolName ?? "unknown";
|
|
605
|
+
const tc = pendingToolCalls.find(
|
|
606
|
+
(t) => t.toolName === endToolName && t.status === "running",
|
|
607
|
+
);
|
|
608
|
+
if (tc) {
|
|
609
|
+
tc.status = (event as { isError?: boolean }).isError ? "error" : "done";
|
|
610
|
+
}
|
|
611
|
+
setOverlayStatus("Streaming side response...", true);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
case "turn_end": {
|
|
615
|
+
setOverlayStatus("Finalizing side response...", true);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
default:
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
session,
|
|
625
|
+
modelKey: getModelKey(ctx),
|
|
626
|
+
unsubscribe,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function ensureSideSession(ctx: ExtensionCommandContext): Promise<SideSessionRuntime | null> {
|
|
631
|
+
if (!ctx.model) {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const expectedModelKey = getModelKey(ctx);
|
|
636
|
+
if (activeSideSession && activeSideSession.modelKey === expectedModelKey) {
|
|
637
|
+
return activeSideSession;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
await disposeSideSession();
|
|
641
|
+
activeSideSession = await createSideSession(ctx);
|
|
642
|
+
return activeSideSession;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
|
|
646
|
+
if (!ctx.hasUI) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (overlayRuntime?.handle) {
|
|
651
|
+
overlayRuntime.handle.setHidden(false);
|
|
652
|
+
overlayRuntime.handle.focus();
|
|
653
|
+
overlayRuntime.refresh?.();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const runtime: OverlayRuntime = {};
|
|
658
|
+
const closeRuntime = () => {
|
|
659
|
+
if (runtime.closed) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
runtime.closed = true;
|
|
663
|
+
runtime.handle?.hide();
|
|
664
|
+
if (overlayRuntime === runtime) {
|
|
665
|
+
overlayRuntime = null;
|
|
666
|
+
}
|
|
667
|
+
runtime.finish?.();
|
|
668
|
+
};
|
|
669
|
+
runtime.close = closeRuntime;
|
|
670
|
+
overlayRuntime = runtime;
|
|
671
|
+
|
|
672
|
+
void ctx.ui
|
|
673
|
+
.custom<void>(
|
|
674
|
+
async (tui, theme, keybindings, done) => {
|
|
675
|
+
runtime.finish = () => done();
|
|
676
|
+
|
|
677
|
+
const overlay = new BtwOverlay(
|
|
678
|
+
tui,
|
|
679
|
+
theme,
|
|
680
|
+
keybindings,
|
|
681
|
+
(width, t) => getTranscriptLines(width, t),
|
|
682
|
+
() => overlayStatus,
|
|
683
|
+
(value) => {
|
|
684
|
+
void submitFromOverlay(ctx, value);
|
|
685
|
+
},
|
|
686
|
+
() => {
|
|
687
|
+
void closeOverlayFlow(ctx);
|
|
688
|
+
},
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
overlay.focused = true;
|
|
692
|
+
overlay.setDraft(overlayDraft);
|
|
693
|
+
runtime.setDraft = (value) => overlay.setDraft(value);
|
|
694
|
+
runtime.refresh = () => {
|
|
695
|
+
overlay.focused = runtime.handle?.isFocused() ?? false;
|
|
696
|
+
tui.requestRender();
|
|
697
|
+
};
|
|
698
|
+
runtime.close = () => {
|
|
699
|
+
overlayDraft = overlay.getDraft();
|
|
700
|
+
closeRuntime();
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
if (runtime.closed) {
|
|
704
|
+
done();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return overlay;
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
overlay: true,
|
|
711
|
+
overlayOptions: {
|
|
712
|
+
width: "80%",
|
|
713
|
+
minWidth: 72,
|
|
714
|
+
maxHeight: "78%",
|
|
715
|
+
anchor: "top-center",
|
|
716
|
+
margin: { top: 1, left: 2, right: 2 },
|
|
717
|
+
},
|
|
718
|
+
onHandle: (handle) => {
|
|
719
|
+
runtime.handle = handle;
|
|
720
|
+
handle.focus();
|
|
721
|
+
if (runtime.closed) {
|
|
722
|
+
closeRuntime();
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
)
|
|
727
|
+
.catch((error) => {
|
|
728
|
+
if (overlayRuntime === runtime) {
|
|
729
|
+
overlayRuntime = null;
|
|
730
|
+
}
|
|
731
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function summarizeThread(ctx: ExtensionContext, items: BtwDetails[]): Promise<string> {
|
|
736
|
+
const model = ctx.model;
|
|
737
|
+
if (!model) {
|
|
738
|
+
throw new Error("No active model selected.");
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
742
|
+
if (auth.ok === false) {
|
|
743
|
+
throw new Error(auth.error);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const { session } = await createAgentSession({
|
|
747
|
+
sessionManager: SessionManager.inMemory(),
|
|
748
|
+
model,
|
|
749
|
+
modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
|
|
750
|
+
thinkingLevel: "off",
|
|
751
|
+
tools: [],
|
|
752
|
+
resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARY_PROMPT]),
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
try {
|
|
756
|
+
await session.prompt(formatThread(items), { source: "extension" });
|
|
757
|
+
const response = getLastAssistantMessage(session);
|
|
758
|
+
if (!response) {
|
|
759
|
+
throw new Error("Summary finished without a response.");
|
|
760
|
+
}
|
|
761
|
+
if (response.stopReason === "aborted") {
|
|
762
|
+
throw new Error("Summary request was aborted.");
|
|
763
|
+
}
|
|
764
|
+
if (response.stopReason === "error") {
|
|
765
|
+
throw new Error(response.errorMessage || "Summary request failed.");
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return extractText(response.content) || "(No summary generated)";
|
|
769
|
+
} finally {
|
|
770
|
+
try {
|
|
771
|
+
await session.abort();
|
|
772
|
+
} catch {
|
|
773
|
+
// Ignore abort errors during temporary session teardown.
|
|
774
|
+
}
|
|
775
|
+
session.dispose();
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function injectSummaryIntoMain(ctx: ExtensionContext | ExtensionCommandContext): Promise<void> {
|
|
780
|
+
if (thread.length === 0) {
|
|
781
|
+
notify(ctx, "No BTW thread to summarize.", "warning");
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
setOverlayStatus("Summarizing BTW thread for injection...");
|
|
786
|
+
try {
|
|
787
|
+
const summary = await summarizeThread(ctx, thread);
|
|
788
|
+
const message = `Summary of my BTW side conversation:\n\n${summary}`;
|
|
789
|
+
if (ctx.isIdle()) {
|
|
790
|
+
pi.sendUserMessage(message);
|
|
791
|
+
} else {
|
|
792
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
await resetThread(ctx);
|
|
796
|
+
notify(ctx, "Injected BTW summary into main chat.", "info");
|
|
797
|
+
} catch (error) {
|
|
798
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function closeOverlayFlow(ctx: ExtensionContext | ExtensionCommandContext): Promise<void> {
|
|
803
|
+
dismissOverlay();
|
|
804
|
+
if (!ctx.hasUI) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (thread.length === 0) {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const choice = await ctx.ui.select("Close BTW:", ["Keep side thread", "Inject summary into main chat"]);
|
|
813
|
+
if (choice === "Inject summary into main chat") {
|
|
814
|
+
await injectSummaryIntoMain(ctx);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function runBtwPrompt(ctx: ExtensionCommandContext, question: string): Promise<void> {
|
|
819
|
+
const model = ctx.model;
|
|
820
|
+
if (!model) {
|
|
821
|
+
setOverlayStatus("No active model selected.");
|
|
822
|
+
notify(ctx, "No active model selected.", "error");
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
827
|
+
if (auth.ok === false) {
|
|
828
|
+
const message = auth.error;
|
|
829
|
+
setOverlayStatus(message);
|
|
830
|
+
notify(ctx, message, "error");
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (sideBusy) {
|
|
835
|
+
notify(ctx, "BTW is still processing the previous message.", "warning");
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const side = await ensureSideSession(ctx);
|
|
840
|
+
if (!side) {
|
|
841
|
+
notify(ctx, "Unable to create BTW side session.", "error");
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
sideBusy = true;
|
|
846
|
+
pendingQuestion = question;
|
|
847
|
+
pendingAnswer = "";
|
|
848
|
+
pendingError = null;
|
|
849
|
+
pendingToolCalls = [];
|
|
850
|
+
setOverlayStatus("Streaming side response...");
|
|
851
|
+
syncOverlay();
|
|
852
|
+
|
|
853
|
+
try {
|
|
854
|
+
await side.session.prompt(question, { source: "extension" });
|
|
855
|
+
const response = getLastAssistantMessage(side.session);
|
|
856
|
+
if (!response) {
|
|
857
|
+
throw new Error("BTW request finished without a response.");
|
|
858
|
+
}
|
|
859
|
+
if (response.stopReason === "aborted") {
|
|
860
|
+
throw new Error("BTW request aborted.");
|
|
861
|
+
}
|
|
862
|
+
if (response.stopReason === "error") {
|
|
863
|
+
throw new Error(response.errorMessage || "BTW request failed.");
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const answer = extractText(response.content) || "(No text response)";
|
|
867
|
+
pendingAnswer = answer;
|
|
868
|
+
const details: BtwDetails = {
|
|
869
|
+
question,
|
|
870
|
+
answer,
|
|
871
|
+
timestamp: Date.now(),
|
|
872
|
+
provider: model.provider,
|
|
873
|
+
model: model.id,
|
|
874
|
+
thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel,
|
|
875
|
+
usage: response.usage,
|
|
876
|
+
};
|
|
877
|
+
thread.push(details);
|
|
878
|
+
pi.appendEntry(BTW_ENTRY_TYPE, details);
|
|
879
|
+
|
|
880
|
+
pendingQuestion = null;
|
|
881
|
+
pendingAnswer = "";
|
|
882
|
+
pendingToolCalls = [];
|
|
883
|
+
setOverlayStatus("Ready for the next side question.");
|
|
884
|
+
} catch (error) {
|
|
885
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
886
|
+
pendingError = message;
|
|
887
|
+
setOverlayStatus("BTW request failed.");
|
|
888
|
+
notify(ctx, message, "error");
|
|
889
|
+
} finally {
|
|
890
|
+
sideBusy = false;
|
|
891
|
+
syncOverlay();
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function submitFromOverlay(ctx: ExtensionContext | ExtensionCommandContext, rawValue: string): Promise<void> {
|
|
896
|
+
const question = rawValue.trim();
|
|
897
|
+
if (!question) {
|
|
898
|
+
setOverlayStatus("Enter a question first.");
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
setOverlayDraft("");
|
|
903
|
+
if (!("waitForIdle" in ctx)) {
|
|
904
|
+
setOverlayStatus("BTW submit requires command context. Re-open with /btw.");
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
await runBtwPrompt(ctx, question);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
pi.registerCommand("btw", {
|
|
912
|
+
description: "Open a simple BTW side-chat popover. `/btw <text>` asks immediately, `/btw` opens the side thread.",
|
|
913
|
+
handler: async (args, ctx) => {
|
|
914
|
+
const question = args.trim();
|
|
915
|
+
|
|
916
|
+
if (!question) {
|
|
917
|
+
if (thread.length > 0 && ctx.hasUI) {
|
|
918
|
+
const choice = await ctx.ui.select("BTW side chat:", [
|
|
919
|
+
"Continue previous conversation",
|
|
920
|
+
"Start fresh",
|
|
921
|
+
]);
|
|
922
|
+
if (choice === "Continue previous conversation") {
|
|
923
|
+
// Dispose session so it's recreated with fresh main context on next submit
|
|
924
|
+
await disposeSideSession();
|
|
925
|
+
setOverlayStatus("Continuing BTW thread.");
|
|
926
|
+
await ensureOverlay(ctx);
|
|
927
|
+
} else if (choice === "Start fresh") {
|
|
928
|
+
await resetThread(ctx, true);
|
|
929
|
+
setOverlayStatus("Ready");
|
|
930
|
+
await ensureOverlay(ctx);
|
|
931
|
+
}
|
|
932
|
+
// null = user cancelled (Esc), do nothing
|
|
933
|
+
} else {
|
|
934
|
+
await resetThread(ctx, true);
|
|
935
|
+
setOverlayStatus("Ready");
|
|
936
|
+
await ensureOverlay(ctx);
|
|
937
|
+
}
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
await ensureOverlay(ctx);
|
|
942
|
+
await runBtwPrompt(ctx, question);
|
|
943
|
+
},
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
947
|
+
await restoreThread(ctx);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
951
|
+
await restoreThread(ctx);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
pi.on("session_shutdown", async () => {
|
|
955
|
+
await disposeSideSession();
|
|
956
|
+
dismissOverlay();
|
|
957
|
+
});
|
|
958
|
+
}
|
|
@@ -63,7 +63,10 @@ export default function (pi) {
|
|
|
63
63
|
pi.registerCommand("cache_export", {
|
|
64
64
|
description: "Export a tau-style interactive cache dashboard (hit-rate charts, per-request table, events)",
|
|
65
65
|
handler: async (args, ctx) => {
|
|
66
|
-
const
|
|
66
|
+
const sessionManager = ctx.sessionManager;
|
|
67
|
+
const entries = typeof sessionManager.getBranch === "function"
|
|
68
|
+
? sessionManager.getBranch()
|
|
69
|
+
: sessionManager.getEntries();
|
|
67
70
|
if (!entries || entries.length === 0) {
|
|
68
71
|
ctx.ui.notify("No session entries to analyze.", "error");
|
|
69
72
|
return;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// Deterministic test cases for analyzeStreaks / analyzeMisses / parseEntries.
|
|
2
2
|
// Run: node --experimental-strip-types tests/run-tests.mjs
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import registerCacheExport from "../index.ts";
|
|
3
7
|
import { parseEntries, analyzeStreaks, analyzeMisses, lineChartForTest } from "../render.ts";
|
|
4
8
|
|
|
5
9
|
let pass = 0, fail = 0;
|
|
@@ -314,6 +318,53 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
|
|
|
314
318
|
s.length === 2 && s.every((x) => x.hits.length === 3), JSON.stringify(s.map((x) => [x.from, x.to])));
|
|
315
319
|
}
|
|
316
320
|
|
|
321
|
+
// Q. subagent results remain visible as main-session tool input -----------
|
|
322
|
+
{
|
|
323
|
+
const entries = [
|
|
324
|
+
req({ cached: 0, fresh: 10000, min: 1, content: [
|
|
325
|
+
{ type: "toolCall", tool: "subagent", name: "subagent" },
|
|
326
|
+
{ type: "toolCall", tool: "bash", name: "bash" },
|
|
327
|
+
] }),
|
|
328
|
+
toolResult("delegated result", "subagent"),
|
|
329
|
+
toolResult("ordinary result", "bash"),
|
|
330
|
+
req({ cached: 9000, fresh: 10000, min: 2 }),
|
|
331
|
+
];
|
|
332
|
+
const d = parseEntries(entries);
|
|
333
|
+
check("Q subagent invocation remains in main-session tool counts",
|
|
334
|
+
d.toolCalls.some(([name]) => name === "subagent") && d.toolCalls.some(([name]) => name === "bash"),
|
|
335
|
+
JSON.stringify(d.toolCalls));
|
|
336
|
+
check("Q subagent result remains available for main fresh-input attribution",
|
|
337
|
+
d.requests[1].gapTools.length === 2 && d.requests[1].gapTools.some(({ tool }) => tool === "subagent"),
|
|
338
|
+
JSON.stringify(d.requests[1].gapTools));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// R. command exports only the current main-session branch ----------------
|
|
342
|
+
{
|
|
343
|
+
const root = mkdtempSync(join(tmpdir(), "cache-export-test-"));
|
|
344
|
+
const reportFile = join(root, "report.html");
|
|
345
|
+
const entry = (value, id, parentId) => ({ ...value, id, parentId });
|
|
346
|
+
const first = entry(req({ cached: 9000, fresh: 1000, min: 1, model: "main-first" }), "p1", null);
|
|
347
|
+
const abandoned = entry(req({ cached: 0, fresh: 9000, min: 2, model: "abandoned-model" }), "p2", "p1");
|
|
348
|
+
const active = entry(req({ cached: 8000, fresh: 2000, min: 3, model: "active-model" }), "p3", "p1");
|
|
349
|
+
|
|
350
|
+
let command;
|
|
351
|
+
registerCacheExport({ registerCommand: (name, definition) => { if (name === "cache_export") command = definition; } });
|
|
352
|
+
await command.handler(reportFile, {
|
|
353
|
+
sessionManager: {
|
|
354
|
+
getBranch: () => [first, active],
|
|
355
|
+
getEntries: () => [first, abandoned, active],
|
|
356
|
+
getSessionFile: () => join(root, "main.jsonl"),
|
|
357
|
+
getSessionId: () => "main",
|
|
358
|
+
},
|
|
359
|
+
ui: { notify: () => {} },
|
|
360
|
+
});
|
|
361
|
+
const html = readFileSync(reportFile, "utf8");
|
|
362
|
+
check("R current branch excludes abandoned requests",
|
|
363
|
+
html.includes("2 requests") && html.includes("active-model") && !html.includes("abandoned-model"));
|
|
364
|
+
check("R main-only report has no subagent dashboard", !html.includes("Subagent cache"));
|
|
365
|
+
rmSync(root, { recursive: true, force: true });
|
|
366
|
+
}
|
|
367
|
+
|
|
317
368
|
// K. degenerate inputs ---------------------------------------------------
|
|
318
369
|
{
|
|
319
370
|
check("K empty session -> no crash, no streaks", analyzeStreaks(parseEntries([]).requests).length === 0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Personal pi coding agent toolkit: /todos extension + /cache_export interactive cache dashboard.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
],
|
|
10
10
|
"files": [
|
|
11
11
|
"extensions",
|
|
12
|
+
"themes",
|
|
12
13
|
"README.md",
|
|
13
14
|
"CHANGELOG.md",
|
|
14
15
|
"global/AGENTS.md",
|
|
@@ -22,10 +23,14 @@
|
|
|
22
23
|
"pi": {
|
|
23
24
|
"extensions": [
|
|
24
25
|
"./extensions/todo.ts",
|
|
25
|
-
"./extensions/cache-export/index.ts"
|
|
26
|
+
"./extensions/cache-export/index.ts",
|
|
27
|
+
"./extensions/btw.ts"
|
|
26
28
|
],
|
|
27
29
|
"skills": [
|
|
28
30
|
"./skills"
|
|
31
|
+
],
|
|
32
|
+
"themes": [
|
|
33
|
+
"./themes"
|
|
29
34
|
]
|
|
30
35
|
},
|
|
31
36
|
"peerDependencies": {
|
|
@@ -36,7 +41,9 @@
|
|
|
36
41
|
},
|
|
37
42
|
"scripts": {
|
|
38
43
|
"test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs",
|
|
39
|
-
"load-test": "pi -ne -e ./extensions/todo.ts && pi -ne -e ./extensions/cache-export/index.ts",
|
|
44
|
+
"load-test": "pi -ne -e ./extensions/todo.ts && pi -ne -e ./extensions/cache-export/index.ts && pi -ne -e ./extensions/btw.ts",
|
|
45
|
+
"eval:agents": "node evaluations/agents-md/run.mjs",
|
|
46
|
+
"eval:agents:test": "node evaluations/agents-md/tests/run-tests.mjs",
|
|
40
47
|
"postinstall": "node scripts/install-agents.mjs"
|
|
41
48
|
},
|
|
42
49
|
"publishConfig": {
|
|
@@ -1,15 +1,43 @@
|
|
|
1
|
-
// postinstall hook: sync the bundled global AGENTS.md
|
|
2
|
-
//
|
|
1
|
+
// postinstall hook: sync the bundled global AGENTS.md and seed the default theme
|
|
2
|
+
// without overriding a theme the user has already selected.
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
|
|
8
|
+
const agentDir = join(homedir(), ".pi", "agent");
|
|
8
9
|
const bundled = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "global", "AGENTS.md"), "utf8");
|
|
9
|
-
const
|
|
10
|
+
const agentsTarget = join(agentDir, "AGENTS.md");
|
|
11
|
+
const settingsTarget = join(agentDir, "settings.json");
|
|
10
12
|
|
|
11
|
-
if (!existsSync(
|
|
12
|
-
mkdirSync(dirname(
|
|
13
|
-
writeFileSync(
|
|
14
|
-
console.log("[pi-toolkit] global AGENTS.md synced ->",
|
|
13
|
+
if (!existsSync(agentsTarget) || readFileSync(agentsTarget, "utf8") !== bundled) {
|
|
14
|
+
mkdirSync(dirname(agentsTarget), { recursive: true });
|
|
15
|
+
writeFileSync(agentsTarget, bundled, "utf8");
|
|
16
|
+
console.log("[pi-toolkit] global AGENTS.md synced ->", agentsTarget);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeSettings(settings) {
|
|
20
|
+
mkdirSync(dirname(settingsTarget), { recursive: true });
|
|
21
|
+
writeFileSync(settingsTarget, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!existsSync(settingsTarget)) {
|
|
25
|
+
writeSettings({ theme: "nightowl" });
|
|
26
|
+
console.log("[pi-toolkit] default theme set -> nightowl");
|
|
27
|
+
} else {
|
|
28
|
+
try {
|
|
29
|
+
const settings = JSON.parse(readFileSync(settingsTarget, "utf8"));
|
|
30
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
31
|
+
throw new Error("settings.json must contain a JSON object");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const record = settings;
|
|
35
|
+
if (typeof record.theme !== "string" || record.theme.length === 0) {
|
|
36
|
+
record.theme = "nightowl";
|
|
37
|
+
writeSettings(record);
|
|
38
|
+
console.log("[pi-toolkit] default theme set -> nightowl");
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.warn("[pi-toolkit] could not set default theme:", error instanceof Error ? error.message : String(error));
|
|
42
|
+
}
|
|
15
43
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: html-artifact
|
|
3
|
-
description: Create self-contained, responsive HTML artifacts for complex explanations that benefit from visual structure or interaction, including workflows, code/module relationships, state machines, event/data flows, architecture maps, timelines, comparisons, dashboards, and interactive demonstrations.
|
|
3
|
+
description: Create self-contained, responsive HTML artifacts for complex explanations that benefit from visual structure or interaction, including workflows, code/module relationships, state machines, event/data flows, architecture maps, timelines, comparisons, dashboards, and interactive demonstrations.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# HTML Artifact
|
|
7
7
|
|
|
8
|
-
Use this skill when the user needs to understand or explore a complex process, codebase relationship, system design, state transition, comparison, or other content where static Markdown would be hard to scan or interact with.
|
|
9
|
-
|
|
10
8
|
## Decide the format
|
|
11
9
|
|
|
12
10
|
Use Markdown when the response is short, linear, text-first, or primarily a focused code review. Use an HTML artifact when visual layout, filtering, expanding details, tabs, highlighting, diagrams, timelines, or interactive state changes materially improve understanding.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
3
|
+
"name": "nightowl",
|
|
4
|
+
"vars": {
|
|
5
|
+
"cyan": "#7fdbca",
|
|
6
|
+
"blue": "#82AAFF",
|
|
7
|
+
"green": "#c5e478",
|
|
8
|
+
"red": "#EF5350",
|
|
9
|
+
"yellow": "#ffcb8b",
|
|
10
|
+
"magenta": "#c792ea",
|
|
11
|
+
"gray": "#5f7e97",
|
|
12
|
+
"dimGray": "#4b6479",
|
|
13
|
+
"darkGray": "#122d42",
|
|
14
|
+
"accent": "#80CBC4",
|
|
15
|
+
"foreground": "#d6deeb",
|
|
16
|
+
"background": "#011627",
|
|
17
|
+
"userMsgBg": "#0b2942",
|
|
18
|
+
"toolPendingBg": "#01111d",
|
|
19
|
+
"toolSuccessBg": "#0b253a",
|
|
20
|
+
"toolErrorBg": "#1a1015"
|
|
21
|
+
},
|
|
22
|
+
"colors": {
|
|
23
|
+
"accent": "accent",
|
|
24
|
+
"border": "blue",
|
|
25
|
+
"borderAccent": "cyan",
|
|
26
|
+
"borderMuted": "darkGray",
|
|
27
|
+
"success": "green",
|
|
28
|
+
"error": "red",
|
|
29
|
+
"warning": "yellow",
|
|
30
|
+
"muted": "gray",
|
|
31
|
+
"dim": "dimGray",
|
|
32
|
+
"text": "",
|
|
33
|
+
"thinkingText": "gray",
|
|
34
|
+
|
|
35
|
+
"userMessageBg": "userMsgBg",
|
|
36
|
+
"userMessageText": "",
|
|
37
|
+
"toolPendingBg": "toolPendingBg",
|
|
38
|
+
"toolSuccessBg": "toolSuccessBg",
|
|
39
|
+
"toolErrorBg": "toolErrorBg",
|
|
40
|
+
"toolTitle": "",
|
|
41
|
+
"toolOutput": "gray",
|
|
42
|
+
|
|
43
|
+
"selectedBg": "darkGray",
|
|
44
|
+
"customMessageBg": "userMsgBg",
|
|
45
|
+
"customMessageText": "",
|
|
46
|
+
"customMessageLabel": "accent",
|
|
47
|
+
|
|
48
|
+
"mdHeading": "blue",
|
|
49
|
+
"mdLink": "cyan",
|
|
50
|
+
"mdLinkUrl": "dimGray",
|
|
51
|
+
"mdCode": "accent",
|
|
52
|
+
"mdCodeBlock": "green",
|
|
53
|
+
"mdCodeBlockBorder": "gray",
|
|
54
|
+
"mdQuote": "gray",
|
|
55
|
+
"mdQuoteBorder": "gray",
|
|
56
|
+
"mdHr": "gray",
|
|
57
|
+
"mdListBullet": "accent",
|
|
58
|
+
|
|
59
|
+
"toolDiffAdded": "green",
|
|
60
|
+
"toolDiffRemoved": "red",
|
|
61
|
+
"toolDiffContext": "gray",
|
|
62
|
+
|
|
63
|
+
"syntaxComment": "#637777",
|
|
64
|
+
"syntaxKeyword": "magenta",
|
|
65
|
+
"syntaxFunction": "blue",
|
|
66
|
+
"syntaxVariable": "cyan",
|
|
67
|
+
"syntaxString": "#c789d6",
|
|
68
|
+
"syntaxNumber": "#F78C6C",
|
|
69
|
+
"syntaxType": "yellow",
|
|
70
|
+
"syntaxOperator": "cyan",
|
|
71
|
+
"syntaxPunctuation": "foreground",
|
|
72
|
+
|
|
73
|
+
"thinkingOff": "darkGray",
|
|
74
|
+
"thinkingMinimal": "#234d70",
|
|
75
|
+
"thinkingLow": "#1d3b53",
|
|
76
|
+
"thinkingMedium": "#5f7e97",
|
|
77
|
+
"thinkingHigh": "#7e57c2",
|
|
78
|
+
"thinkingXhigh": "#c792ea",
|
|
79
|
+
|
|
80
|
+
"bashMode": "green"
|
|
81
|
+
}
|
|
82
|
+
}
|