pi-aside 0.0.0-stage → 0.2.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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/docs/design.md +795 -0
- package/docs/implementation.md +156 -0
- package/docs/ux.md +312 -0
- package/package.json +58 -4
- package/src/busy.ts +54 -0
- package/src/fork.ts +520 -0
- package/src/index.ts +644 -0
- package/src/observe.ts +278 -0
- package/src/panel.ts +252 -0
- package/src/payload.ts +263 -0
- package/src/qmark.ts +48 -0
- package/src/reply.ts +69 -0
- package/src/tap.ts +49 -0
- package/src/thread.ts +331 -0
- package/src/transport.ts +103 -0
- package/src/usage.ts +84 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
// pi-aside: ask a side question about a running Pi session without touching it, at the cache-read price.
|
|
2
|
+
// Engine: docs/design.md. Interaction: docs/ux.md.
|
|
3
|
+
|
|
4
|
+
import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, keyText } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { getKeybindings, Markdown } from "@earendil-works/pi-tui";
|
|
6
|
+
import { BackgroundWork } from "./busy.ts";
|
|
7
|
+
import type { ForkEnv } from "./fork.ts";
|
|
8
|
+
import { Observer, type Recipe } from "./observe.ts";
|
|
9
|
+
import {
|
|
10
|
+
type MarkdownRenderer,
|
|
11
|
+
MONEY_THRESHOLD,
|
|
12
|
+
PANEL_LINES,
|
|
13
|
+
type PanelState,
|
|
14
|
+
plainText,
|
|
15
|
+
Reader,
|
|
16
|
+
type ReaderTurn,
|
|
17
|
+
renderBelow,
|
|
18
|
+
renderPanel,
|
|
19
|
+
SPIN_MS,
|
|
20
|
+
} from "./panel.ts";
|
|
21
|
+
import { endsWithQuestionMark, routeEnter } from "./qmark.ts";
|
|
22
|
+
import { parseReply } from "./reply.ts";
|
|
23
|
+
import { AsideThread, type TurnView } from "./thread.ts";
|
|
24
|
+
import { installFinalPayloadTap } from "./tap.ts";
|
|
25
|
+
import { currentSettings } from "./transport.ts";
|
|
26
|
+
import { UsageQueue } from "./usage.ts";
|
|
27
|
+
|
|
28
|
+
// biome-ignore lint: Pi's event and UI types are used structurally where they are not exported.
|
|
29
|
+
type Any = any;
|
|
30
|
+
|
|
31
|
+
const PANEL_WIDGET = "pi-aside";
|
|
32
|
+
const BELOW_WIDGET = "pi-aside-below";
|
|
33
|
+
const STATUS_KEY = "pi-aside";
|
|
34
|
+
const SIDE_NOTE_TYPE = "aside";
|
|
35
|
+
const REBUILT_NOTICE_AFTER = 3;
|
|
36
|
+
|
|
37
|
+
/** Pi's Markdown component with Pi's Markdown theme; the last few renders are kept (the spinner redraws often). */
|
|
38
|
+
function markdownRenderer(theme: Any): MarkdownRenderer {
|
|
39
|
+
const cache = new Map<string, string[]>();
|
|
40
|
+
return (text, width, style) => {
|
|
41
|
+
const key = `${style ?? ""}|${width}|${text}`;
|
|
42
|
+
const hit = cache.get(key);
|
|
43
|
+
if (hit) return hit;
|
|
44
|
+
let lines: string[];
|
|
45
|
+
try {
|
|
46
|
+
const textStyle = style === "thinking" ? { color: (s: string) => theme.fg("dim", s), italic: true } : undefined;
|
|
47
|
+
lines = new Markdown(text, 0, 0, getMarkdownTheme(), textStyle).render(Math.max(10, width)).map((line) => line.replace(/\s+$/u, ""));
|
|
48
|
+
while (lines.length > 0 && lines.at(-1) === "") lines.pop();
|
|
49
|
+
while (lines.length > 0 && lines[0] === "") lines.shift();
|
|
50
|
+
} catch {
|
|
51
|
+
lines = plainText(text, width);
|
|
52
|
+
}
|
|
53
|
+
if (cache.size >= 8) cache.delete(cache.keys().next().value!);
|
|
54
|
+
cache.set(key, lines);
|
|
55
|
+
return lines;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default function piAside(pi: ExtensionAPI): void {
|
|
60
|
+
const observer = new Observer();
|
|
61
|
+
let ctxRef: ExtensionContext | undefined;
|
|
62
|
+
let thread: AsideThread | undefined;
|
|
63
|
+
let panel: PanelState = { turns: [], collapsed: false };
|
|
64
|
+
let tuiRef: Any;
|
|
65
|
+
let panelShown = false;
|
|
66
|
+
let belowShown = false;
|
|
67
|
+
let handoff: { text: string; note: string } | undefined;
|
|
68
|
+
let acceptedHandoff: { text: string; note: string } | undefined;
|
|
69
|
+
let rebuiltInARow = 0;
|
|
70
|
+
let rebuiltNoticeShown = false;
|
|
71
|
+
let unsubscribeInput: (() => void) | undefined;
|
|
72
|
+
let ticker: ReturnType<typeof setInterval> | undefined;
|
|
73
|
+
|
|
74
|
+
const usage = new UsageQueue({
|
|
75
|
+
state: () => (ctxRef ? { idle: ctxRef.isIdle(), sessionId: ctxRef.sessionManager.getSessionId() } : undefined),
|
|
76
|
+
append: (entry) => {
|
|
77
|
+
(ctxRef!.sessionManager as Any).appendUsage("aside", entry.provider, entry.model, entry.usage, entry.note);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const background = new BackgroundWork(pi.events as Any, () => refreshBelow());
|
|
82
|
+
|
|
83
|
+
const env: ForkEnv = {
|
|
84
|
+
get registry() {
|
|
85
|
+
return ctxRef!.modelRegistry;
|
|
86
|
+
},
|
|
87
|
+
settings: () => currentSettings(() => pi.getSettings()),
|
|
88
|
+
get sessionManager() {
|
|
89
|
+
return ctxRef!.sessionManager;
|
|
90
|
+
},
|
|
91
|
+
selectedModel: () => ctxRef?.model,
|
|
92
|
+
thinkingLevel: () => pi.getThinkingLevel(),
|
|
93
|
+
systemPrompt: () => ctxRef?.getSystemPrompt() ?? "",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------- observation (design §4.1)
|
|
97
|
+
|
|
98
|
+
function recipeFor(ctx: ExtensionContext): Recipe | undefined {
|
|
99
|
+
const model = ctx.model as Any;
|
|
100
|
+
if (!model) return undefined;
|
|
101
|
+
const settings = env.settings();
|
|
102
|
+
const level = pi.getThinkingLevel();
|
|
103
|
+
return {
|
|
104
|
+
provider: model.provider,
|
|
105
|
+
modelId: model.id,
|
|
106
|
+
api: model.api,
|
|
107
|
+
reasoning: level === "off" ? undefined : level,
|
|
108
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
109
|
+
transport: settings.getTransport(),
|
|
110
|
+
thinkingBudgets: settings.getThinkingBudgets(),
|
|
111
|
+
blockImages: settings.getBlockImages(),
|
|
112
|
+
virtual: model.api === "pi-virtual",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
pi.on("context_with_system", (event, ctx) => {
|
|
117
|
+
ctxRef = ctx;
|
|
118
|
+
const recipe = recipeFor(ctx);
|
|
119
|
+
if (recipe) observer.open(event.messages.slice(), ctx.sessionManager.getLeafId(), recipe);
|
|
120
|
+
return undefined;
|
|
121
|
+
});
|
|
122
|
+
pi.on("before_provider_headers", (event) => {
|
|
123
|
+
observer.headers(event.headers as Any);
|
|
124
|
+
});
|
|
125
|
+
pi.on("before_provider_request", (event) => {
|
|
126
|
+
observer.request(event.payload);
|
|
127
|
+
return undefined;
|
|
128
|
+
});
|
|
129
|
+
pi.on("after_provider_response", (event) => {
|
|
130
|
+
observer.response(event.status);
|
|
131
|
+
});
|
|
132
|
+
pi.on("cache_warming_decision", () => {
|
|
133
|
+
observer.warming();
|
|
134
|
+
return undefined;
|
|
135
|
+
});
|
|
136
|
+
pi.on("provider_stream_event", (event) => {
|
|
137
|
+
const l = observer.latest;
|
|
138
|
+
if (l && l.recipe.virtual && l.state === "promoted" && !l.ready) {
|
|
139
|
+
l.recipe.provider = event.provider;
|
|
140
|
+
l.recipe.modelId = event.model;
|
|
141
|
+
l.recipe.api = event.api;
|
|
142
|
+
l.recipe.virtual = false;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
const usageOf = (u: Any) =>
|
|
146
|
+
u ? { input: u.input ?? 0, output: u.output ?? 0, cacheRead: u.cacheRead ?? 0, cacheWrite: u.cacheWrite ?? 0 } : undefined;
|
|
147
|
+
pi.on("message_update", (event) => {
|
|
148
|
+
const message = event.message as Any;
|
|
149
|
+
if (message.role === "assistant") observer.assistantUpdate(usageOf(message.usage));
|
|
150
|
+
});
|
|
151
|
+
pi.on("message_end", (event) => {
|
|
152
|
+
const message = event.message as Any;
|
|
153
|
+
if (message.role === "assistant") observer.assistantEnd(usageOf(message.usage), message.stopReason);
|
|
154
|
+
return undefined;
|
|
155
|
+
});
|
|
156
|
+
pi.on("agent_start", () => refreshBelow());
|
|
157
|
+
pi.on("agent_end", () => {
|
|
158
|
+
observer.end();
|
|
159
|
+
usage.drain();
|
|
160
|
+
refreshBelow();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------- sessions
|
|
164
|
+
|
|
165
|
+
function resetSession(ctx: ExtensionContext | undefined): void {
|
|
166
|
+
thread?.abort();
|
|
167
|
+
thread = undefined;
|
|
168
|
+
panel = { turns: [], collapsed: false, expanded: panel.expanded };
|
|
169
|
+
handoff = undefined;
|
|
170
|
+
acceptedHandoff = undefined;
|
|
171
|
+
rebuiltInARow = 0;
|
|
172
|
+
observer.reset();
|
|
173
|
+
background.reset();
|
|
174
|
+
if (ctx) ctxRef = ctx;
|
|
175
|
+
showPanel(false);
|
|
176
|
+
refreshBelow();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function beforeLeaving(): void {
|
|
180
|
+
const dropped = usage.flushOrDrop();
|
|
181
|
+
thread?.abort();
|
|
182
|
+
if (dropped > 0) {
|
|
183
|
+
try {
|
|
184
|
+
ctxRef?.ui.setStatus(STATUS_KEY, `aside: ${dropped} usage ${dropped === 1 ? "entry" : "entries"} not recorded (session was busy)`);
|
|
185
|
+
} catch {
|
|
186
|
+
// the UI may already be gone
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
pi.on("session_start", (_event, ctx) => {
|
|
192
|
+
resetSession(ctx);
|
|
193
|
+
installFinalPayloadTap(ctx.modelRegistry, (payload) => observer.finalPayload(payload));
|
|
194
|
+
installTui(ctx);
|
|
195
|
+
});
|
|
196
|
+
pi.on("session_before_switch", () => {
|
|
197
|
+
beforeLeaving();
|
|
198
|
+
return undefined;
|
|
199
|
+
});
|
|
200
|
+
pi.on("session_before_fork", () => {
|
|
201
|
+
beforeLeaving();
|
|
202
|
+
return undefined;
|
|
203
|
+
});
|
|
204
|
+
pi.on("session_shutdown", () => {
|
|
205
|
+
beforeLeaving();
|
|
206
|
+
unsubscribeInput?.();
|
|
207
|
+
unsubscribeInput = undefined;
|
|
208
|
+
background.dispose();
|
|
209
|
+
});
|
|
210
|
+
pi.on("session_compact", (_event, ctx) => {
|
|
211
|
+
ctxRef = ctx;
|
|
212
|
+
observer.reset();
|
|
213
|
+
});
|
|
214
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
215
|
+
ctxRef = ctx;
|
|
216
|
+
observer.reset();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------- busy and routing (ux §3.1)
|
|
220
|
+
|
|
221
|
+
function running(): boolean {
|
|
222
|
+
return ctxRef ? !ctxRef.isIdle() : false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function waiting(): boolean {
|
|
226
|
+
return !running() && background.pending > 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function busy(): boolean {
|
|
230
|
+
return running() || background.pending > 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
pi.on("input", (event, ctx) => {
|
|
234
|
+
ctxRef = ctx;
|
|
235
|
+
if (event.source !== "interactive") return { action: "continue" };
|
|
236
|
+
if (event.images && event.images.length > 0) return collapseAndContinue(event.text);
|
|
237
|
+
const key = event.streamingBehavior === "followUp" ? "followUp" : "submit";
|
|
238
|
+
if (routeEnter({ text: event.text, busy: busy(), key }) === "side") {
|
|
239
|
+
void startAside(event.text, { routeCheck: true });
|
|
240
|
+
return { action: "handled" };
|
|
241
|
+
}
|
|
242
|
+
return collapseAndContinue(event.text);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/** The user is sending something to the agent: collapse the panel and attach an accepted handoff's side note. */
|
|
246
|
+
function collapseAndContinue(text: string): { action: "continue" } {
|
|
247
|
+
// An answer still streaming stays visible: the user is waiting for it.
|
|
248
|
+
if (panel.turns.length > 0 && !panel.collapsed && !thread?.busy) {
|
|
249
|
+
panel.collapsed = true;
|
|
250
|
+
renderNow();
|
|
251
|
+
}
|
|
252
|
+
if (acceptedHandoff && text.trim().length > 0) {
|
|
253
|
+
const note = acceptedHandoff.note;
|
|
254
|
+
acceptedHandoff = undefined;
|
|
255
|
+
try {
|
|
256
|
+
pi.sendMessage(
|
|
257
|
+
{ customType: SIDE_NOTE_TYPE, content: note, display: true },
|
|
258
|
+
{ deliverAs: running() ? "steer" : "nextTurn" },
|
|
259
|
+
);
|
|
260
|
+
} catch {
|
|
261
|
+
// the handoff text still reaches the agent
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
handoff = undefined;
|
|
265
|
+
refreshBelow();
|
|
266
|
+
return { action: "continue" };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Send the user's original text with Pi's meaning: a steer while running, a prompt otherwise. */
|
|
270
|
+
function sendToAgent(text: string): void {
|
|
271
|
+
try {
|
|
272
|
+
if (running()) pi.sendUserMessage(text, { deliverAs: "steer" });
|
|
273
|
+
else pi.sendUserMessage(text);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
ctxRef?.ui.notify(`aside: could not send to the agent: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------- asking
|
|
280
|
+
|
|
281
|
+
function threadHost() {
|
|
282
|
+
return {
|
|
283
|
+
env,
|
|
284
|
+
observer,
|
|
285
|
+
usage,
|
|
286
|
+
cwd: () => ctxRef?.cwd ?? process.cwd(),
|
|
287
|
+
sessionId: () => ctxRef?.sessionManager.getSessionId() ?? "",
|
|
288
|
+
onUpdate: () => renderNow(),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function startAside(question: string, options: { routeCheck: boolean }): Promise<TurnView | undefined> {
|
|
293
|
+
const ctx = ctxRef;
|
|
294
|
+
if (!ctx) return undefined;
|
|
295
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
296
|
+
const continuing = thread && !panel.collapsed && !panel.sentToAgent && thread.valid(observer, sessionId);
|
|
297
|
+
if (!continuing) {
|
|
298
|
+
thread?.abort();
|
|
299
|
+
thread = new AsideThread(threadHost());
|
|
300
|
+
panel = { turns: thread.turns, collapsed: false, expanded: panel.expanded };
|
|
301
|
+
} else if (thread!.busy) {
|
|
302
|
+
thread!.abort();
|
|
303
|
+
}
|
|
304
|
+
const current = thread!;
|
|
305
|
+
handoff = undefined;
|
|
306
|
+
acceptedHandoff = undefined;
|
|
307
|
+
panel.collapsed = false;
|
|
308
|
+
panel.hidden = false;
|
|
309
|
+
panel.sentToAgent = undefined;
|
|
310
|
+
showPanel(true);
|
|
311
|
+
refreshBelow();
|
|
312
|
+
const wasWaiting = waiting();
|
|
313
|
+
let routedToAgent = false;
|
|
314
|
+
const turn = await current.ask(question, {
|
|
315
|
+
routeCheck: options.routeCheck,
|
|
316
|
+
onRoute: (route) => {
|
|
317
|
+
if (route !== "agent" || !options.routeCheck) return;
|
|
318
|
+
routedToAgent = true;
|
|
319
|
+
current.abort();
|
|
320
|
+
panel.sentToAgent = { text: question, waiting: wasWaiting };
|
|
321
|
+
sendToAgent(question);
|
|
322
|
+
renderNow();
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
if (current !== thread) return turn;
|
|
326
|
+
const parsed = parseReply(turn.raw, true);
|
|
327
|
+
if (options.routeCheck && !routedToAgent && !turn.aborted && turn.error && !parseReply(turn.raw, false).route) {
|
|
328
|
+
// The aside failed before its route line: the text keeps Pi's meaning.
|
|
329
|
+
panel.sentToAgent = { text: question, waiting: wasWaiting };
|
|
330
|
+
sendToAgent(question);
|
|
331
|
+
} else if (!routedToAgent && parsed.handoff && !turn.error && !turn.aborted) {
|
|
332
|
+
handoff = { text: parsed.handoff, note: sideNote(turn, parsed.body) };
|
|
333
|
+
}
|
|
334
|
+
noteTier(turn);
|
|
335
|
+
reportStatus(turn);
|
|
336
|
+
if (ctx.mode === "rpc" && !routedToAgent) {
|
|
337
|
+
const text = turn.error ? `aside failed: ${turn.error}` : turn.aborted ? "aside stopped" : parsed.body;
|
|
338
|
+
ctx.ui.notify(text, turn.error ? "error" : "info");
|
|
339
|
+
}
|
|
340
|
+
renderNow();
|
|
341
|
+
refreshBelow();
|
|
342
|
+
return turn;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function sideNote(turn: TurnView, answer: string): string {
|
|
346
|
+
return `Side note the user brought back from an aside (a side conversation you did not see):\n\nQ: ${turn.question}\nA: ${answer}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function noteTier(turn: TurnView): void {
|
|
350
|
+
if (turn.tier === "rebuilt" && !turn.error && !turn.aborted) {
|
|
351
|
+
rebuiltInARow++;
|
|
352
|
+
if (rebuiltInARow >= REBUILT_NOTICE_AFTER && !rebuiltNoticeShown) {
|
|
353
|
+
rebuiltNoticeShown = true;
|
|
354
|
+
ctxRef?.ui.notify(`aside: the last ${rebuiltInARow} asides rebuilt the context and did not read the main cache (${turn.reason ?? "unknown reason"})`, "warning");
|
|
355
|
+
}
|
|
356
|
+
} else if (turn.tier === "exact") {
|
|
357
|
+
rebuiltInARow = 0;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Detail line: tier, cache read against the cacheable prefix, cost. In the status bar only in RPC mode. */
|
|
362
|
+
function detail(turn: TurnView): string {
|
|
363
|
+
const fmt = (n: number) => n.toLocaleString("en-US");
|
|
364
|
+
const parts: string[] = [];
|
|
365
|
+
if (turn.tier === "exact") {
|
|
366
|
+
parts.push("exact");
|
|
367
|
+
parts.push(
|
|
368
|
+
turn.predicted !== undefined
|
|
369
|
+
? `read ${fmt(turn.read)} of ${fmt(turn.predicted)} cacheable${turn.written ? `, wrote ${fmt(turn.written)}` : ""}`
|
|
370
|
+
: `read ${fmt(turn.read)}, wrote ${fmt(turn.written)}`,
|
|
371
|
+
);
|
|
372
|
+
} else if (turn.tier === "rebuilt") {
|
|
373
|
+
parts.push(`rebuilt: not expected to hit${turn.reason ? ` (${turn.reason})` : ""}`);
|
|
374
|
+
parts.push(`read ${fmt(turn.read)}, wrote ${fmt(turn.written)}`);
|
|
375
|
+
}
|
|
376
|
+
if (turn.effort) parts.push(`thinking ${turn.effort}`);
|
|
377
|
+
parts.push(`$${turn.cost.toFixed(turn.cost < MONEY_THRESHOLD ? 3 : 2)}`);
|
|
378
|
+
return parts.join(" · ");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function reportStatus(turn: TurnView): void {
|
|
382
|
+
if (ctxRef?.mode === "rpc") ctxRef.ui.setStatus(STATUS_KEY, `aside: ${detail(turn)}`);
|
|
383
|
+
pi.events.emit("aside:done", {
|
|
384
|
+
question: turn.question,
|
|
385
|
+
tier: turn.tier,
|
|
386
|
+
reason: turn.reason,
|
|
387
|
+
read: turn.read,
|
|
388
|
+
written: turn.written,
|
|
389
|
+
predicted: turn.predicted,
|
|
390
|
+
effort: turn.effort,
|
|
391
|
+
firstTextMs: turn.firstTextAt === undefined ? undefined : turn.firstTextAt - turn.startedAt,
|
|
392
|
+
totalMs: Date.now() - turn.startedAt,
|
|
393
|
+
cost: turn.cost,
|
|
394
|
+
error: turn.error,
|
|
395
|
+
aborted: turn.aborted === true,
|
|
396
|
+
answer: parseReply(turn.raw, true).body,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---------------------------------------------------------------- /aside
|
|
401
|
+
|
|
402
|
+
pi.registerCommand("aside", {
|
|
403
|
+
description: "Ask a side question about the session without touching it; with no question, reread the latest aside",
|
|
404
|
+
handler: async (args, ctx) => {
|
|
405
|
+
ctxRef = ctx;
|
|
406
|
+
if (ctx.mode !== "tui" && ctx.mode !== "rpc") {
|
|
407
|
+
ctx.ui.notify("pi-aside needs the interactive or RPC mode", "error");
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const question = (args ?? "").trim();
|
|
411
|
+
if (question) {
|
|
412
|
+
void startAside(question, { routeCheck: false });
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (!thread || thread.turns.length === 0) {
|
|
416
|
+
ctx.ui.notify("No aside yet. Type /aside <question>, or end a message with ? while the agent works.", "info");
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (ctx.mode === "rpc") {
|
|
420
|
+
const turn = thread.turns.at(-1)!;
|
|
421
|
+
ctx.ui.notify(parseReply(turn.raw, true).body, "info");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
await openReader(ctx);
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
function readerTurns(): ReaderTurn[] {
|
|
429
|
+
return (thread?.turns ?? []).map((turn) => {
|
|
430
|
+
const parsed = parseReply(turn.raw, turn.done);
|
|
431
|
+
let answer = parsed.body;
|
|
432
|
+
if (turn.error) answer += `${answer ? "\n\n" : ""}aside failed: ${turn.error}`;
|
|
433
|
+
if (turn.aborted) answer += `${answer ? "\n\n" : ""}(stopped)`;
|
|
434
|
+
if (parsed.handoff) answer += `\n\nhandoff: ${parsed.handoff}`;
|
|
435
|
+
const hash = thread?.fork?.pHash ? ` · p ${thread.fork.pHash.slice(0, 12)}` : "";
|
|
436
|
+
return { question: turn.question, answer, detail: `${detail(turn)}${hash}` };
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function openReader(ctx: ExtensionContext): Promise<void> {
|
|
441
|
+
await ctx.ui.custom<void>(
|
|
442
|
+
(tui: Any, theme: Any, _keybindings: Any, done: (result: void) => void) =>
|
|
443
|
+
new Reader(
|
|
444
|
+
readerTurns,
|
|
445
|
+
theme,
|
|
446
|
+
() => done(undefined),
|
|
447
|
+
() => tui.requestRender(),
|
|
448
|
+
() => tui.terminal?.rows ?? process.stdout.rows ?? 40,
|
|
449
|
+
markdownRenderer(theme),
|
|
450
|
+
),
|
|
451
|
+
{ overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } as Any },
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------- TUI (ux §3.2, §3.3)
|
|
456
|
+
|
|
457
|
+
function renderNow(): void {
|
|
458
|
+
if (ctxRef?.mode !== "tui") return;
|
|
459
|
+
showPanel(panel.turns.length > 0 && !panel.hidden);
|
|
460
|
+
animate();
|
|
461
|
+
try {
|
|
462
|
+
tuiRef?.requestRender();
|
|
463
|
+
} catch {
|
|
464
|
+
// not mounted
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Whether the panel shows an answer still running (its spinner turns). */
|
|
469
|
+
function spinning(): boolean {
|
|
470
|
+
const turn = panel.turns.at(-1);
|
|
471
|
+
return !!turn && !turn.done && !panel.hidden && !panel.sentToAgent;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Redraw at the spinner's pace while an answer runs. */
|
|
475
|
+
function animate(): void {
|
|
476
|
+
if (ticker || !spinning()) return;
|
|
477
|
+
ticker = setInterval(() => {
|
|
478
|
+
if (!spinning() || ctxRef?.mode !== "tui") {
|
|
479
|
+
clearInterval(ticker);
|
|
480
|
+
ticker = undefined;
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
tuiRef?.requestRender();
|
|
484
|
+
} catch {
|
|
485
|
+
// not mounted
|
|
486
|
+
}
|
|
487
|
+
}, SPIN_MS);
|
|
488
|
+
ticker.unref?.();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** ctrl+o (`app.tools.expand`): a collapsed panel opens again, expanded; otherwise expanded and compact toggle. */
|
|
492
|
+
function toggleMore(): void {
|
|
493
|
+
if (panel.collapsed && !panel.hidden && panel.turns.length > 0) {
|
|
494
|
+
panel.collapsed = false;
|
|
495
|
+
panel.expanded = true;
|
|
496
|
+
} else {
|
|
497
|
+
panel.expanded = !panel.expanded;
|
|
498
|
+
}
|
|
499
|
+
renderNow();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Enter on an empty editor puts the panel away (docs/ux.md §3.2): the frame collapses to one dim line, and the
|
|
504
|
+
* next such Enter clears that line. The thread stays readable with `/aside`.
|
|
505
|
+
*/
|
|
506
|
+
function putAway(): boolean {
|
|
507
|
+
if (panel.turns.length === 0 || panel.hidden) return false;
|
|
508
|
+
if (panel.collapsed) panel.hidden = true;
|
|
509
|
+
else panel.collapsed = true;
|
|
510
|
+
handoff = undefined;
|
|
511
|
+
renderNow();
|
|
512
|
+
refreshBelow();
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function showPanel(show: boolean): void {
|
|
517
|
+
const ctx = ctxRef;
|
|
518
|
+
if (!ctx || ctx.mode !== "tui") return;
|
|
519
|
+
if (show === panelShown) return;
|
|
520
|
+
panelShown = show;
|
|
521
|
+
try {
|
|
522
|
+
if (!show) {
|
|
523
|
+
ctx.ui.setWidget(PANEL_WIDGET, undefined);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
ctx.ui.setWidget(
|
|
527
|
+
PANEL_WIDGET,
|
|
528
|
+
(tui: Any, theme: Any) => {
|
|
529
|
+
tuiRef = tui;
|
|
530
|
+
const md = markdownRenderer(theme);
|
|
531
|
+
return {
|
|
532
|
+
render: (width: number) => {
|
|
533
|
+
const rows = tui?.terminal?.rows ?? process.stdout.rows ?? 40;
|
|
534
|
+
panel.maxLines = Math.max(PANEL_LINES, rows - 14);
|
|
535
|
+
try {
|
|
536
|
+
panel.moreKey = keyText("app.tools.expand") || undefined;
|
|
537
|
+
} catch {
|
|
538
|
+
panel.moreKey = undefined;
|
|
539
|
+
}
|
|
540
|
+
return renderPanel(panel, width, theme, md, Date.now());
|
|
541
|
+
},
|
|
542
|
+
invalidate() {},
|
|
543
|
+
};
|
|
544
|
+
},
|
|
545
|
+
{ placement: "aboveEditor" },
|
|
546
|
+
);
|
|
547
|
+
} catch {
|
|
548
|
+
panelShown = false;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function belowState(): { hint: boolean; ghost: string | undefined } {
|
|
553
|
+
const ctx = ctxRef;
|
|
554
|
+
if (!ctx || ctx.mode !== "tui") return { hint: false, ghost: undefined };
|
|
555
|
+
let text = "";
|
|
556
|
+
try {
|
|
557
|
+
text = ctx.ui.getEditorText();
|
|
558
|
+
} catch {
|
|
559
|
+
text = "";
|
|
560
|
+
}
|
|
561
|
+
const hint = routeEnter({ text, busy: busy(), key: "submit" }) === "side" && endsWithQuestionMark(text);
|
|
562
|
+
const ghost = handoff && text.trim() === "" ? handoff.text : undefined;
|
|
563
|
+
return { hint, ghost };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
let belowCache = { hint: false, ghost: undefined as string | undefined };
|
|
567
|
+
|
|
568
|
+
function refreshBelow(): void {
|
|
569
|
+
const ctx = ctxRef;
|
|
570
|
+
if (!ctx || ctx.mode !== "tui") return;
|
|
571
|
+
const state = belowState();
|
|
572
|
+
belowCache = state;
|
|
573
|
+
panel.editorEmpty = editorText().trim() === "";
|
|
574
|
+
const show = state.hint || state.ghost !== undefined;
|
|
575
|
+
try {
|
|
576
|
+
if (show !== belowShown) {
|
|
577
|
+
belowShown = show;
|
|
578
|
+
if (!show) ctx.ui.setWidget(BELOW_WIDGET, undefined);
|
|
579
|
+
else
|
|
580
|
+
ctx.ui.setWidget(
|
|
581
|
+
BELOW_WIDGET,
|
|
582
|
+
(tui: Any, theme: Any) => {
|
|
583
|
+
tuiRef = tui;
|
|
584
|
+
return { render: (width: number) => renderBelow(belowCache, width, theme), invalidate() {} };
|
|
585
|
+
},
|
|
586
|
+
{ placement: "belowEditor" },
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
tuiRef?.requestRender();
|
|
590
|
+
} catch {
|
|
591
|
+
belowShown = false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function editorText(): string {
|
|
596
|
+
try {
|
|
597
|
+
return ctxRef?.ui.getEditorText() ?? "";
|
|
598
|
+
} catch {
|
|
599
|
+
return "";
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Whether Pi's input editor has the focus (not a selector, a dialog or another extension's overlay). */
|
|
604
|
+
function editorFocused(): boolean {
|
|
605
|
+
const focus = tuiRef?.getFocusedComponent;
|
|
606
|
+
if (typeof focus !== "function") return true;
|
|
607
|
+
const focused = focus.call(tuiRef);
|
|
608
|
+
return !!focused && typeof focused === "object" && focused.actionHandlers instanceof Map;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function installTui(ctx: ExtensionContext): void {
|
|
612
|
+
unsubscribeInput?.();
|
|
613
|
+
unsubscribeInput = undefined;
|
|
614
|
+
panelShown = false;
|
|
615
|
+
belowShown = false;
|
|
616
|
+
if (ctx.mode !== "tui") return;
|
|
617
|
+
unsubscribeInput = ctx.ui.onTerminalInput((data) => {
|
|
618
|
+
const kb = getKeybindings();
|
|
619
|
+
if (kb.matches(data, "tui.input.submit") && editorText().trim() === "" && editorFocused() && putAway()) {
|
|
620
|
+
return { consume: true };
|
|
621
|
+
}
|
|
622
|
+
// Pi's "more" (ctrl+o) expands the aside with everything else; Pi still toggles its tool output.
|
|
623
|
+
if (kb.matches(data, "app.tools.expand") && editorFocused()) toggleMore();
|
|
624
|
+
if (handoff && kb.matches(data, "tui.input.tab")) {
|
|
625
|
+
let text = "";
|
|
626
|
+
try {
|
|
627
|
+
text = ctxRef?.ui.getEditorText() ?? "";
|
|
628
|
+
} catch {
|
|
629
|
+
text = "";
|
|
630
|
+
}
|
|
631
|
+
if (text.trim() === "") {
|
|
632
|
+
ctxRef?.ui.setEditorText(handoff.text);
|
|
633
|
+
acceptedHandoff = handoff;
|
|
634
|
+
handoff = undefined;
|
|
635
|
+
setTimeout(refreshBelow, 0);
|
|
636
|
+
return { consume: true };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// The editor applies the key after this listener: look at its text afterwards.
|
|
640
|
+
setTimeout(refreshBelow, 0);
|
|
641
|
+
return undefined;
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|