pi-ui-extend 0.1.21 → 0.1.24
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/README.md +1 -10
- package/bin/pix.mjs +11 -154
- package/dist/app/app.d.ts +1 -0
- package/dist/app/app.js +34 -9
- package/dist/app/cli/startup-info.d.ts +0 -1
- package/dist/app/cli/startup-info.js +0 -3
- package/dist/app/commands/command-session-actions.js +3 -0
- package/dist/app/popup/popup-menu-controller.js +7 -1
- package/dist/app/rendering/conversation-entry-renderer.js +29 -40
- package/dist/app/rendering/render-text.d.ts +6 -0
- package/dist/app/rendering/render-text.js +9 -0
- package/dist/app/rendering/tab-line-renderer.js +1 -5
- package/dist/app/rendering/tool-block-renderer.js +7 -1
- package/dist/app/screen/mouse-controller.js +14 -6
- package/dist/app/session/session-event-controller.js +5 -4
- package/dist/app/session/session-lifecycle-controller.js +0 -4
- package/dist/app/session/tabs-controller.d.ts +5 -1
- package/dist/app/session/tabs-controller.js +111 -23
- package/dist/app/types.d.ts +5 -0
- package/dist/app/workspace/workspace-actions-controller.d.ts +3 -0
- package/dist/app/workspace/workspace-actions-controller.js +71 -16
- package/dist/app/workspace/workspace-undo.js +41 -6
- package/dist/markdown-format.d.ts +4 -0
- package/dist/markdown-format.js +6 -1
- package/dist/theme.js +18 -18
- package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +1 -0
- package/external/pi-tools-suite/src/telegram-mirror/README.md +81 -46
- package/external/pi-tools-suite/src/telegram-mirror/bot.ts +81 -10
- package/external/pi-tools-suite/src/telegram-mirror/events.ts +6 -38
- package/external/pi-tools-suite/src/telegram-mirror/index.ts +246 -40
- package/external/pi-tools-suite/src/telegram-mirror/ipc.ts +20 -0
- package/external/pi-tools-suite/src/telegram-mirror/multiplexer.ts +247 -17
- package/external/pi-tools-suite/src/telegram-mirror/renderer.ts +75 -78
- package/external/pi-tools-suite/src/todo/index.ts +7 -6
- package/external/pi-tools-suite/src/todo/tool/response-envelope.ts +1 -1
- package/external/pi-tools-suite/src/web-search/index.ts +139 -2
- package/package.json +7 -7
|
@@ -27,14 +27,15 @@
|
|
|
27
27
|
* acquire leadership themselves (handled in index.ts, not here).
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import type { TelegramBot } from "./bot.js";
|
|
31
|
-
import type { RendererEvent } from "./renderer.js";
|
|
30
|
+
import type { TelegramBot, TelegramReplyMarkup, TelegramUpdate } from "./bot.js";
|
|
31
|
+
import type { RendererEvent, RendererInstance } from "./renderer.js";
|
|
32
32
|
import type { IpcServer, IpcSocket, InstanceInfo } from "./ipc.js";
|
|
33
33
|
import { generateReqId } from "./ipc.js";
|
|
34
34
|
|
|
35
35
|
/** Locally-executable operations on the leader's own pi session. */
|
|
36
36
|
export interface LocalDispatch {
|
|
37
37
|
sendUserMessage(text: string): void;
|
|
38
|
+
currentDialog(): string | undefined;
|
|
38
39
|
abort(): void;
|
|
39
40
|
compact(): void;
|
|
40
41
|
status(): { idle: boolean; hasPending: boolean } | undefined;
|
|
@@ -44,7 +45,12 @@ export interface MultiplexerDeps {
|
|
|
44
45
|
selfId: string;
|
|
45
46
|
selfInfo: InstanceInfo;
|
|
46
47
|
bot: TelegramBot;
|
|
47
|
-
renderer: {
|
|
48
|
+
renderer: {
|
|
49
|
+
push(event: RendererEvent): void;
|
|
50
|
+
reset(): void;
|
|
51
|
+
showTranscript?(instance: RendererInstance | undefined, transcript: string): Promise<void>;
|
|
52
|
+
sentIds?: readonly number[];
|
|
53
|
+
};
|
|
48
54
|
server: IpcServer;
|
|
49
55
|
dispatch: LocalDispatch;
|
|
50
56
|
/** Cluster-wide teardown: broadcast stand_down to followers then stop polling. */
|
|
@@ -62,6 +68,11 @@ interface InstanceEntry {
|
|
|
62
68
|
isLeader: boolean;
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
interface InstanceStatus {
|
|
72
|
+
idle: boolean;
|
|
73
|
+
hasPending: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
const COMMAND_TIMEOUT_MS = 10_000;
|
|
66
77
|
|
|
67
78
|
export class Multiplexer {
|
|
@@ -76,6 +87,7 @@ export class Multiplexer {
|
|
|
76
87
|
|
|
77
88
|
private readonly followers = new Map<string, FollowerEntry>();
|
|
78
89
|
private activeId: string | null = null;
|
|
90
|
+
private readonly lastStatus = new Map<string, InstanceStatus>();
|
|
79
91
|
|
|
80
92
|
constructor(deps: MultiplexerDeps) {
|
|
81
93
|
this.selfId = deps.selfId;
|
|
@@ -111,7 +123,12 @@ export class Multiplexer {
|
|
|
111
123
|
|
|
112
124
|
/** Local pi events flow in here. */
|
|
113
125
|
pushLocalEvent(event: RendererEvent): void {
|
|
114
|
-
|
|
126
|
+
this.handleInstanceEvent(this.selfId, event);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async showActiveDialog(): Promise<void> {
|
|
130
|
+
if (!this.activeId) return;
|
|
131
|
+
await this.showDialogFor(this.activeId);
|
|
115
132
|
}
|
|
116
133
|
|
|
117
134
|
// ─── Telegram → pix dispatch ──────────────────────────────────────────
|
|
@@ -121,13 +138,17 @@ export class Multiplexer {
|
|
|
121
138
|
if (!trimmed) return;
|
|
122
139
|
|
|
123
140
|
if (trimmed.startsWith("/")) {
|
|
141
|
+
const messageId = updateMessageIdFromText();
|
|
124
142
|
const [head, ...rest] = trimmed.split(/\s+/);
|
|
125
143
|
const arg = rest.join(" ").trim();
|
|
126
|
-
const command = head
|
|
144
|
+
const command = normalizeBotCommand(head);
|
|
127
145
|
switch (command) {
|
|
128
146
|
case "/start":
|
|
129
147
|
case "/help":
|
|
130
|
-
await this.reply(HELP_TEXT);
|
|
148
|
+
await this.reply(HELP_TEXT, mainMenuMarkup());
|
|
149
|
+
return;
|
|
150
|
+
case "/menu":
|
|
151
|
+
await this.reply("Choose a project/session to follow:", this.instancesMarkup());
|
|
131
152
|
return;
|
|
132
153
|
case "/list":
|
|
133
154
|
await this.replyList();
|
|
@@ -145,6 +166,11 @@ export class Multiplexer {
|
|
|
145
166
|
case "/status":
|
|
146
167
|
await this.handleStatus();
|
|
147
168
|
return;
|
|
169
|
+
case "/clear":
|
|
170
|
+
case "/clear_history":
|
|
171
|
+
case "/clean":
|
|
172
|
+
await this.clearTelegramHistory(messageId ? [messageId] : []);
|
|
173
|
+
return;
|
|
148
174
|
case "/say":
|
|
149
175
|
if (!arg) {
|
|
150
176
|
await this.reply("Usage: /say <message>");
|
|
@@ -174,14 +200,38 @@ export class Multiplexer {
|
|
|
174
200
|
await this.routeCommandToActive("sendUserMessage", { text: trimmed }, "message");
|
|
175
201
|
}
|
|
176
202
|
|
|
203
|
+
async handleTelegramUpdate(update: TelegramUpdate): Promise<void> {
|
|
204
|
+
const callback = update.callback_query;
|
|
205
|
+
if (callback) {
|
|
206
|
+
await this.handleCallback(callback.id, callback.data ?? "");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const text = update.message?.text;
|
|
210
|
+
if (typeof text === "string") await this.handleTgTextWithMessage(text, update.message?.message_id);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async handleTgTextWithMessage(text: string, messageId: number | undefined): Promise<void> {
|
|
214
|
+
const previous = currentTelegramMessageId;
|
|
215
|
+
currentTelegramMessageId = messageId;
|
|
216
|
+
try {
|
|
217
|
+
await this.handleTgText(text);
|
|
218
|
+
} finally {
|
|
219
|
+
currentTelegramMessageId = previous;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
177
223
|
// ─── Followers ───────────────────────────────────────────────────────
|
|
178
224
|
|
|
179
225
|
private attachFollower(socket: IpcSocket): void {
|
|
180
|
-
|
|
226
|
+
socket.onMessage = (msg) => {
|
|
181
227
|
if (msg.type === "register") {
|
|
182
228
|
this.registerFollower(socket, msg.info);
|
|
183
229
|
return;
|
|
184
230
|
}
|
|
231
|
+
if (msg.type === "instance_update") {
|
|
232
|
+
this.updateFollowerInfo(socket, msg.info);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
185
235
|
if (msg.type === "event") {
|
|
186
236
|
this.handleFollowerEvent(msg.from, msg.event as RendererEvent);
|
|
187
237
|
return;
|
|
@@ -213,6 +263,24 @@ export class Multiplexer {
|
|
|
213
263
|
this.followers.set(info.id, { socket, info });
|
|
214
264
|
this.log(`follower registered: ${info.label}`);
|
|
215
265
|
socket.send({ type: "registered", leader: this.selfInfo, activeId: this.activeId });
|
|
266
|
+
void this.reply(`➕ ${formatInstanceTitle(info)} connected.`, this.instancesMarkup());
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private updateFollowerInfo(socket: IpcSocket, info: InstanceInfo): void {
|
|
270
|
+
const existing = this.followers.get(info.id);
|
|
271
|
+
if (existing) {
|
|
272
|
+
existing.info = info;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.followers.set(info.id, { socket, info });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
updateSelfInfo(info: InstanceInfo): void {
|
|
279
|
+
this.selfInfo.cwd = info.cwd;
|
|
280
|
+
this.selfInfo.label = info.label;
|
|
281
|
+
this.selfInfo.sessionId = info.sessionId;
|
|
282
|
+
this.selfInfo.sessionFile = info.sessionFile;
|
|
283
|
+
this.selfInfo.sessionName = info.sessionName;
|
|
216
284
|
}
|
|
217
285
|
|
|
218
286
|
private findFollowerBySocket(socket: IpcSocket): FollowerEntry | undefined {
|
|
@@ -224,8 +292,34 @@ export class Multiplexer {
|
|
|
224
292
|
|
|
225
293
|
private handleFollowerEvent(fromId: string, event: RendererEvent): void {
|
|
226
294
|
if (!this.followers.has(fromId)) return; // unknown/unregistered
|
|
295
|
+
this.handleInstanceEvent(fromId, event);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private handleInstanceEvent(fromId: string, event: RendererEvent): void {
|
|
299
|
+
const entry = this.getInstance(fromId);
|
|
300
|
+
if (!entry) return;
|
|
301
|
+
if (event.kind === "turn_start") {
|
|
302
|
+
this.recordStatus(fromId, { idle: false, hasPending: false }, entry.info);
|
|
303
|
+
if (this.activeId === fromId) this.renderer.push(withInstance(event, entry.info));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (event.kind === "turn_end") {
|
|
307
|
+
this.recordStatus(fromId, { idle: true, hasPending: false }, entry.info);
|
|
308
|
+
if (this.activeId === fromId) {
|
|
309
|
+
this.renderer.push(event);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
227
313
|
if (this.activeId === fromId) this.renderer.push(event);
|
|
228
|
-
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private recordStatus(instanceId: string, next: InstanceStatus, info: InstanceInfo): void {
|
|
317
|
+
const prev = this.lastStatus.get(instanceId);
|
|
318
|
+
this.lastStatus.set(instanceId, next);
|
|
319
|
+
if (prev && prev.idle === next.idle) return;
|
|
320
|
+
const icon = next.idle ? "🟢" : "🟡";
|
|
321
|
+
const text = `${icon} ${formatInstanceTitle(info)} is ${formatStatus(next)}.`;
|
|
322
|
+
void this.reply(text, this.instancesMarkup(), { silent: instanceId !== this.activeId });
|
|
229
323
|
}
|
|
230
324
|
|
|
231
325
|
// ─── Routing helpers ─────────────────────────────────────────────────
|
|
@@ -329,7 +423,42 @@ export class Multiplexer {
|
|
|
329
423
|
return;
|
|
330
424
|
}
|
|
331
425
|
this.activeId = target.info.id;
|
|
332
|
-
|
|
426
|
+
this.renderer.reset();
|
|
427
|
+
await this.reply(`✅ Following: ${formatInstanceTitle(target.info)}${target.isLeader ? " (leader)" : ""}`, this.instancesMarkup());
|
|
428
|
+
await this.showDialogFor(target.info.id);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private async showDialogFor(instanceId: string): Promise<void> {
|
|
432
|
+
const entry = this.getInstance(instanceId);
|
|
433
|
+
if (!entry) return;
|
|
434
|
+
let text: string | undefined;
|
|
435
|
+
if (instanceId === this.selfId) {
|
|
436
|
+
text = this.dispatch.currentDialog();
|
|
437
|
+
} else {
|
|
438
|
+
const follower = this.followers.get(instanceId);
|
|
439
|
+
if (!follower) return;
|
|
440
|
+
try {
|
|
441
|
+
const reqId = generateReqId();
|
|
442
|
+
const reply = await follower.socket.request(
|
|
443
|
+
{ type: "query", reqId, to: instanceId, query: "dialog" },
|
|
444
|
+
COMMAND_TIMEOUT_MS,
|
|
445
|
+
);
|
|
446
|
+
if (reply.type === "query_reply" && reply.ok && isRecord(reply.result)) {
|
|
447
|
+
text = typeof reply.result.text === "string" ? reply.result.text : undefined;
|
|
448
|
+
}
|
|
449
|
+
} catch (error) {
|
|
450
|
+
this.log(`dialog query failed for ${entry.info.label}: ${errorMessage(error)}`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (!text?.trim()) return;
|
|
455
|
+
if (this.renderer.showTranscript) {
|
|
456
|
+
await this.renderer.showTranscript(instanceToRenderer(entry.info), text);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this.renderer.reset();
|
|
460
|
+
this.renderer.push({ kind: "turn_start", instance: instanceToRenderer(entry.info) });
|
|
461
|
+
this.renderer.push({ kind: "assistant_text", delta: text });
|
|
333
462
|
}
|
|
334
463
|
|
|
335
464
|
private async replyList(): Promise<void> {
|
|
@@ -339,18 +468,69 @@ export class Multiplexer {
|
|
|
339
468
|
return;
|
|
340
469
|
}
|
|
341
470
|
const lines = instances.map((entry, idx) => {
|
|
342
|
-
const marker = entry.info.id === this.activeId ? " [
|
|
471
|
+
const marker = entry.info.id === this.activeId ? " [following]" : "";
|
|
343
472
|
const role = entry.isLeader ? " (leader)" : "";
|
|
344
|
-
|
|
473
|
+
const status = this.lastStatus.get(entry.info.id);
|
|
474
|
+
const statusText = status ? ` — ${formatStatus(status)}` : "";
|
|
475
|
+
return `${idx + 1}. ${formatInstanceTitle(entry.info)}${role}${marker}${statusText}`;
|
|
345
476
|
});
|
|
346
477
|
lines.push("");
|
|
347
|
-
lines.push("
|
|
348
|
-
await this.reply(lines.join("\n"));
|
|
478
|
+
lines.push("Tap a button below, or use /use N.");
|
|
479
|
+
await this.reply(lines.join("\n"), this.instancesMarkup());
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private async handleCallback(callbackId: string, data: string): Promise<void> {
|
|
483
|
+
try {
|
|
484
|
+
await this.bot.answerCallbackQuery(callbackId);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
this.log(`answerCallbackQuery failed: ${errorMessage(error)}`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (data === "tg:list") {
|
|
490
|
+
await this.replyList();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (data === "tg:status") {
|
|
494
|
+
await this.handleStatus();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (data === "tg:help") {
|
|
498
|
+
await this.reply(HELP_TEXT, mainMenuMarkup());
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (data.startsWith("tg:use:")) {
|
|
502
|
+
await this.handleUse(data.slice("tg:use:".length));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private instancesMarkup(): TelegramReplyMarkup {
|
|
508
|
+
const rows = this.listInstances().map((entry, idx) => {
|
|
509
|
+
const prefix = entry.info.id === this.activeId ? "✅ " : "";
|
|
510
|
+
return [{ text: `${prefix}${idx + 1}. ${buttonLabel(entry.info)}`, callback_data: `tg:use:${idx + 1}` }];
|
|
511
|
+
});
|
|
512
|
+
rows.push([
|
|
513
|
+
{ text: "🔄 List", callback_data: "tg:list" },
|
|
514
|
+
{ text: "📊 Status", callback_data: "tg:status" },
|
|
515
|
+
]);
|
|
516
|
+
return { inline_keyboard: rows };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private async clearTelegramHistory(extraMessageIds: readonly number[] = []): Promise<void> {
|
|
520
|
+
const result = await this.bot.deleteKnownMessages([...(this.renderer.sentIds ?? []), ...extraMessageIds]);
|
|
521
|
+
this.renderer.reset();
|
|
522
|
+
this.log(`telegram history clear requested: attempted=${result.attempted} deleted=${result.deleted}`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private getInstance(id: string): InstanceEntry | undefined {
|
|
526
|
+
if (id === this.selfId) return { info: this.selfInfo, isLeader: true };
|
|
527
|
+
const follower = this.followers.get(id);
|
|
528
|
+
return follower ? { info: follower.info, isLeader: false } : undefined;
|
|
349
529
|
}
|
|
350
530
|
|
|
351
|
-
private async reply(text: string): Promise<void> {
|
|
531
|
+
private async reply(text: string, replyMarkup?: TelegramReplyMarkup, options: { silent?: boolean } = {}): Promise<void> {
|
|
352
532
|
try {
|
|
353
|
-
await this.bot.sendMessage(text);
|
|
533
|
+
await this.bot.sendMessage(text, { replyMarkup, silent: options.silent });
|
|
354
534
|
} catch (error) {
|
|
355
535
|
this.log(`reply failed: ${errorMessage(error)}`);
|
|
356
536
|
}
|
|
@@ -366,6 +546,54 @@ function formatStatus(status: { idle: boolean; hasPending: boolean }): string {
|
|
|
366
546
|
return status.hasPending ? "streaming (queued messages waiting)" : "streaming";
|
|
367
547
|
}
|
|
368
548
|
|
|
549
|
+
function formatInstanceTitle(info: InstanceInfo): string {
|
|
550
|
+
return info.sessionName ? `${info.label} · ${info.sessionName}` : info.label;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function buttonLabel(info: InstanceInfo): string {
|
|
554
|
+
return info.sessionName ? `${info.label} · ${info.sessionName}` : info.label;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function withInstance(event: Extract<RendererEvent, { kind: "turn_start" }>, info: InstanceInfo): RendererEvent {
|
|
558
|
+
return {
|
|
559
|
+
...event,
|
|
560
|
+
instance: instanceToRenderer(info),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function instanceToRenderer(info: InstanceInfo): RendererInstance {
|
|
565
|
+
return {
|
|
566
|
+
label: info.label,
|
|
567
|
+
cwd: info.cwd,
|
|
568
|
+
...(info.sessionId ? { sessionId: info.sessionId } : {}),
|
|
569
|
+
...(info.sessionName ? { sessionName: info.sessionName } : {}),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function normalizeBotCommand(head: string): string {
|
|
574
|
+
const withoutMention = head.split("@", 1)[0] ?? head;
|
|
575
|
+
return withoutMention.toLowerCase();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
579
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let currentTelegramMessageId: number | undefined;
|
|
583
|
+
|
|
584
|
+
function updateMessageIdFromText(): number | undefined {
|
|
585
|
+
return currentTelegramMessageId;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function mainMenuMarkup(): TelegramReplyMarkup {
|
|
589
|
+
return {
|
|
590
|
+
inline_keyboard: [
|
|
591
|
+
[{ text: "🧭 Choose project/session", callback_data: "tg:list" }],
|
|
592
|
+
[{ text: "📊 Active status", callback_data: "tg:status" }],
|
|
593
|
+
],
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
369
597
|
function errorMessage(error: unknown): string {
|
|
370
598
|
return error instanceof Error ? error.message : String(error);
|
|
371
599
|
}
|
|
@@ -390,11 +618,12 @@ function resolveUseTarget(arg: string, instances: { info: InstanceInfo; isLeader
|
|
|
390
618
|
const HELP_TEXT = [
|
|
391
619
|
"<b>pix Telegram mirror</b>",
|
|
392
620
|
"",
|
|
393
|
-
"Free text → forwarded to the <i>
|
|
621
|
+
"Free text → forwarded to the <i>followed</i> pi session.",
|
|
394
622
|
"",
|
|
395
623
|
"<b>Multi-instance commands</b>",
|
|
624
|
+
"/menu — show Telegram buttons",
|
|
396
625
|
"/list — show all known pi instances",
|
|
397
|
-
"/use N —
|
|
626
|
+
"/use N — follow by 1-based index from /list",
|
|
398
627
|
"/use <id> — switch by id/label substring",
|
|
399
628
|
"/disconnect — stop the bot cluster-wide (resume with /reload in pi)",
|
|
400
629
|
"",
|
|
@@ -402,6 +631,7 @@ const HELP_TEXT = [
|
|
|
402
631
|
"/abort /stop — cancel current turn on active",
|
|
403
632
|
"/compact — trigger compaction on active",
|
|
404
633
|
"/status — show idle/streaming state of active",
|
|
634
|
+
"/clear — best-effort delete known bot messages from this chat",
|
|
405
635
|
"/say <msg> — explicit send (escape /-prefixed text)",
|
|
406
636
|
"/new — not supported; run /new inside pi",
|
|
407
637
|
"/help — this message",
|
|
@@ -2,38 +2,31 @@
|
|
|
2
2
|
* Streaming-aware message renderer.
|
|
3
3
|
*
|
|
4
4
|
* Pipeline:
|
|
5
|
-
* pix events → renderer.push(
|
|
6
|
-
* → accumulate
|
|
7
|
-
* → coalesce consecutive same-kind same-name tool events into ×N
|
|
5
|
+
* pix events → renderer.push(assistant_text|status)
|
|
6
|
+
* → accumulate assistant-visible text only
|
|
8
7
|
* → throttled (~1.2 s) editMessageText flushes, paginated on overflow
|
|
9
8
|
* → on turn_end, flush remainder
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* the agent fires many small tools in a row (e.g. 10× read).
|
|
10
|
+
* Tool calls/results and thinking deltas are intentionally ignored: Telegram
|
|
11
|
+
* is a second screen for user-visible assistant messages, not a debug log.
|
|
14
12
|
*/
|
|
15
13
|
|
|
16
14
|
import { chunkForTelegram, markdownToTelegram, TELEGRAM_MESSAGE_MAX } from "./format.js";
|
|
17
15
|
import type { TelegramBot } from "./bot.js";
|
|
18
16
|
|
|
19
17
|
const THROTTLE_MS = 1200;
|
|
18
|
+
const SESSION_SEPARATOR = "\n\n━━━━━━━━━━━━\n\n";
|
|
20
19
|
|
|
21
20
|
export type RendererEvent =
|
|
22
|
-
| { kind: "turn_start" }
|
|
21
|
+
| { kind: "turn_start"; instance?: RendererInstance }
|
|
23
22
|
| { kind: "assistant_text"; delta: string }
|
|
24
|
-
| { kind: "
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
interface ToolEntry {
|
|
33
|
-
kind: "tool";
|
|
34
|
-
status: ToolStatus;
|
|
35
|
-
name: string;
|
|
36
|
-
count: number;
|
|
23
|
+
| { kind: "turn_end"; reason: "end" | "error" | "aborted" };
|
|
24
|
+
|
|
25
|
+
export interface RendererInstance {
|
|
26
|
+
label: string;
|
|
27
|
+
cwd?: string;
|
|
28
|
+
sessionName?: string;
|
|
29
|
+
sessionId?: string;
|
|
37
30
|
}
|
|
38
31
|
|
|
39
32
|
interface TextEntry {
|
|
@@ -41,19 +34,27 @@ interface TextEntry {
|
|
|
41
34
|
content: string;
|
|
42
35
|
}
|
|
43
36
|
|
|
44
|
-
type Chunk = TextEntry
|
|
37
|
+
type Chunk = TextEntry;
|
|
45
38
|
|
|
46
39
|
interface ActiveMessage {
|
|
47
40
|
messageId: number;
|
|
48
41
|
body: string;
|
|
49
42
|
}
|
|
50
43
|
|
|
44
|
+
interface SentPage {
|
|
45
|
+
messageId: number;
|
|
46
|
+
body: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
51
49
|
export class TurnRenderer {
|
|
52
50
|
private active: ActiveMessage | undefined;
|
|
53
51
|
private chunks: Chunk[] = [];
|
|
54
|
-
private
|
|
52
|
+
private header: string | undefined;
|
|
55
53
|
private scheduledFlush: ReturnType<typeof setTimeout> | undefined;
|
|
56
54
|
private readonly sentMessageIds: number[] = [];
|
|
55
|
+
private pages: SentPage[] = [];
|
|
56
|
+
private turnHasText = false;
|
|
57
|
+
private turnOpen = false;
|
|
57
58
|
|
|
58
59
|
constructor(
|
|
59
60
|
private readonly bot: TelegramBot,
|
|
@@ -67,33 +68,21 @@ export class TurnRenderer {
|
|
|
67
68
|
push(event: RendererEvent): void {
|
|
68
69
|
switch (event.kind) {
|
|
69
70
|
case "turn_start":
|
|
70
|
-
this.
|
|
71
|
+
this.startTurn(event.instance);
|
|
72
|
+
this.header = renderHeader(event.instance);
|
|
71
73
|
return;
|
|
72
74
|
case "assistant_text":
|
|
73
75
|
if (!event.delta) return;
|
|
76
|
+
if (!this.turnOpen) this.startTurn(undefined);
|
|
77
|
+
this.turnHasText = true;
|
|
74
78
|
this.appendText(event.delta);
|
|
75
79
|
this.scheduleFlush();
|
|
76
80
|
return;
|
|
77
|
-
case "thinking":
|
|
78
|
-
if (this.thinkingShown) return;
|
|
79
|
-
this.thinkingShown = true;
|
|
80
|
-
this.appendText("\n💭 thinking…\n");
|
|
81
|
-
this.scheduleFlush();
|
|
82
|
-
return;
|
|
83
|
-
case "tool_start":
|
|
84
|
-
this.appendTool("running", event.toolName);
|
|
85
|
-
this.scheduleFlush();
|
|
86
|
-
return;
|
|
87
|
-
case "tool_end":
|
|
88
|
-
this.appendTool(event.isError ? "error" : "done", event.toolName);
|
|
89
|
-
this.scheduleFlush();
|
|
90
|
-
return;
|
|
91
|
-
case "info":
|
|
92
|
-
this.appendText(`\nℹ️ ${event.text}\n`);
|
|
93
|
-
this.scheduleFlush();
|
|
94
|
-
return;
|
|
95
81
|
case "turn_end":
|
|
96
|
-
this.
|
|
82
|
+
if (this.turnHasText) {
|
|
83
|
+
this.appendText(`\n\n— ${event.reason === "aborted" ? "aborted" : event.reason === "error" ? "error" : "done"} —\n`);
|
|
84
|
+
}
|
|
85
|
+
this.turnOpen = false;
|
|
97
86
|
void this.flushNow();
|
|
98
87
|
return;
|
|
99
88
|
}
|
|
@@ -115,8 +104,21 @@ export class TurnRenderer {
|
|
|
115
104
|
this.scheduledFlush = undefined;
|
|
116
105
|
}
|
|
117
106
|
this.chunks = [];
|
|
118
|
-
this.
|
|
107
|
+
this.header = undefined;
|
|
119
108
|
this.active = undefined;
|
|
109
|
+
this.pages = [];
|
|
110
|
+
this.turnHasText = false;
|
|
111
|
+
this.turnOpen = false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Replace the current Telegram-rendered buffer with a session transcript. */
|
|
115
|
+
async showTranscript(instance: RendererInstance | undefined, transcript: string): Promise<void> {
|
|
116
|
+
this.reset();
|
|
117
|
+
this.header = renderHeader(instance);
|
|
118
|
+
const trimmed = transcript.trim();
|
|
119
|
+
if (!trimmed) return;
|
|
120
|
+
this.chunks = [{ kind: "text", content: trimmed }];
|
|
121
|
+
await this.flushNow();
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
/** Update the most recent message with [aborted] trailer. */
|
|
@@ -140,13 +142,12 @@ export class TurnRenderer {
|
|
|
140
142
|
}
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
private
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
145
|
+
private startTurn(instance: RendererInstance | undefined): void {
|
|
146
|
+
if (this.turnOpen) return;
|
|
147
|
+
if (!this.header) this.header = renderHeader(instance);
|
|
148
|
+
if (this.chunks.length > 0) this.appendText(SESSION_SEPARATOR);
|
|
149
|
+
this.turnHasText = false;
|
|
150
|
+
this.turnOpen = true;
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
private scheduleFlush(): void {
|
|
@@ -169,46 +170,42 @@ export class TurnRenderer {
|
|
|
169
170
|
if (chunks.length === 0) return;
|
|
170
171
|
|
|
171
172
|
try {
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const spilled = await this.bot.sendMessage(chunk);
|
|
180
|
-
if (spilled?.message_id) {
|
|
181
|
-
this.active = { messageId: spilled.message_id, body: chunk };
|
|
182
|
-
this.sentMessageIds.push(spilled.message_id);
|
|
173
|
+
for (let idx = 0; idx < chunks.length; idx += 1) {
|
|
174
|
+
const chunk = chunks[idx];
|
|
175
|
+
const page = this.pages[idx];
|
|
176
|
+
if (page) {
|
|
177
|
+
if (page.body !== chunk) {
|
|
178
|
+
await this.bot.editMessageText(page.messageId, chunk);
|
|
179
|
+
page.body = chunk;
|
|
183
180
|
}
|
|
181
|
+
continue;
|
|
184
182
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (spilled?.message_id) {
|
|
191
|
-
this.active = { messageId: spilled.message_id, body: chunk };
|
|
192
|
-
this.sentMessageIds.push(spilled.message_id);
|
|
193
|
-
}
|
|
183
|
+
const sent = await this.bot.sendMessage(chunk);
|
|
184
|
+
if (sent?.message_id) {
|
|
185
|
+
const next = { messageId: sent.message_id, body: chunk };
|
|
186
|
+
this.pages.push(next);
|
|
187
|
+
this.sentMessageIds.push(sent.message_id);
|
|
194
188
|
}
|
|
195
189
|
}
|
|
190
|
+
this.active = this.pages[this.pages.length - 1];
|
|
196
191
|
} catch (error) {
|
|
197
192
|
this.logger(`telegram send failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
198
193
|
}
|
|
199
194
|
}
|
|
200
195
|
|
|
201
196
|
private renderChunks(): string {
|
|
202
|
-
let out = "";
|
|
197
|
+
let out = this.header ? `${this.header}\n\n` : "";
|
|
203
198
|
for (const chunk of this.chunks) {
|
|
204
|
-
|
|
205
|
-
out += chunk.content;
|
|
206
|
-
} else {
|
|
207
|
-
const icon = chunk.status === "running" ? "🔧" : chunk.status === "done" ? "✅" : "❌";
|
|
208
|
-
const counter = chunk.count > 1 ? ` ×${chunk.count}` : "";
|
|
209
|
-
out += `\n${icon} ${chunk.name}${counter}\n`;
|
|
210
|
-
}
|
|
199
|
+
out += chunk.content;
|
|
211
200
|
}
|
|
212
201
|
return out;
|
|
213
202
|
}
|
|
214
203
|
}
|
|
204
|
+
|
|
205
|
+
function renderHeader(instance: RendererInstance | undefined): string | undefined {
|
|
206
|
+
if (!instance) return undefined;
|
|
207
|
+
const bits = [instance.label];
|
|
208
|
+
if (instance.sessionName) bits.push(instance.sessionName);
|
|
209
|
+
else if (instance.sessionId) bits.push(instance.sessionId.slice(0, 8));
|
|
210
|
+
return `🤖 ${bits.join(" · ")}`;
|
|
211
|
+
}
|
|
@@ -197,12 +197,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
197
197
|
setter.call(pi, level);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
200
|
+
function isCompletedAssistantReply(message: AgentMessageLike | undefined): boolean {
|
|
201
|
+
if (message?.role !== "assistant") return false;
|
|
202
|
+
if (message.stopReason === "aborted" || message.stopReason === "error" || message.stopReason === "length") return false;
|
|
203
|
+
if (typeof message.content === "string") return message.content.trim().length > 0;
|
|
204
|
+
if (!Array.isArray(message.content)) return false;
|
|
205
|
+
return message.content.some((block) => typeof (block as { type?: unknown }).type === "string");
|
|
206
|
+
}
|
|
206
207
|
|
|
207
208
|
function hasCompletedAssistantReply(messages: readonly unknown[] | undefined): boolean {
|
|
208
209
|
if (!Array.isArray(messages)) return false;
|
|
@@ -167,7 +167,7 @@ function appendWorkflowReminder(text: string, op: Op, state: TaskState): string
|
|
|
167
167
|
}
|
|
168
168
|
if (hasInProgress) {
|
|
169
169
|
lines.push(
|
|
170
|
-
"Reminder: before
|
|
170
|
+
"Reminder: before your final response, update any finished todo items to completed. Treat the final user-facing report step like any other todo: mark it completed immediately before sending the report.",
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
173
|
return lines.join("\n\n");
|