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.
Files changed (37) hide show
  1. package/README.md +1 -10
  2. package/bin/pix.mjs +11 -154
  3. package/dist/app/app.d.ts +1 -0
  4. package/dist/app/app.js +34 -9
  5. package/dist/app/cli/startup-info.d.ts +0 -1
  6. package/dist/app/cli/startup-info.js +0 -3
  7. package/dist/app/commands/command-session-actions.js +3 -0
  8. package/dist/app/popup/popup-menu-controller.js +7 -1
  9. package/dist/app/rendering/conversation-entry-renderer.js +29 -40
  10. package/dist/app/rendering/render-text.d.ts +6 -0
  11. package/dist/app/rendering/render-text.js +9 -0
  12. package/dist/app/rendering/tab-line-renderer.js +1 -5
  13. package/dist/app/rendering/tool-block-renderer.js +7 -1
  14. package/dist/app/screen/mouse-controller.js +14 -6
  15. package/dist/app/session/session-event-controller.js +5 -4
  16. package/dist/app/session/session-lifecycle-controller.js +0 -4
  17. package/dist/app/session/tabs-controller.d.ts +5 -1
  18. package/dist/app/session/tabs-controller.js +111 -23
  19. package/dist/app/types.d.ts +5 -0
  20. package/dist/app/workspace/workspace-actions-controller.d.ts +3 -0
  21. package/dist/app/workspace/workspace-actions-controller.js +71 -16
  22. package/dist/app/workspace/workspace-undo.js +41 -6
  23. package/dist/markdown-format.d.ts +4 -0
  24. package/dist/markdown-format.js +6 -1
  25. package/dist/theme.js +18 -18
  26. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +1 -0
  27. package/external/pi-tools-suite/src/telegram-mirror/README.md +81 -46
  28. package/external/pi-tools-suite/src/telegram-mirror/bot.ts +81 -10
  29. package/external/pi-tools-suite/src/telegram-mirror/events.ts +6 -38
  30. package/external/pi-tools-suite/src/telegram-mirror/index.ts +246 -40
  31. package/external/pi-tools-suite/src/telegram-mirror/ipc.ts +20 -0
  32. package/external/pi-tools-suite/src/telegram-mirror/multiplexer.ts +247 -17
  33. package/external/pi-tools-suite/src/telegram-mirror/renderer.ts +75 -78
  34. package/external/pi-tools-suite/src/todo/index.ts +7 -6
  35. package/external/pi-tools-suite/src/todo/tool/response-envelope.ts +1 -1
  36. package/external/pi-tools-suite/src/web-search/index.ts +139 -2
  37. 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: { push(event: RendererEvent): void; reset(): void };
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
- if (this.activeId === this.selfId) this.renderer.push(event);
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.toLowerCase();
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
- socket.onMessage = (msg) => {
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
- // silent drop otherwise
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
- await this.reply(`✅ Active: ${target.info.label}${target.isLeader ? " (leader)" : ""}`);
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 ? " [active]" : "";
471
+ const marker = entry.info.id === this.activeId ? " [following]" : "";
343
472
  const role = entry.isLeader ? " (leader)" : "";
344
- return `${idx + 1}. ${entry.info.label}${role}${marker}`;
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("Use /use N or /use &lt;id&gt; to switch.");
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>active</i> pi instance.",
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 — switch active by 1-based index from /list",
626
+ "/use N — follow by 1-based index from /list",
398
627
  "/use &lt;id&gt; — 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 &lt;msg&gt; — 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(text|tool|thinking|status)
6
- * → accumulate chunks in arrival order
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
- * Tools render name-only (no args, no result). Thinking renders a single
12
- * `💭 thinking…` marker per turn. Tool-name batching reduces spam when
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: "thinking" }
25
- | { kind: "tool_start"; toolCallId: string; toolName: string }
26
- | { kind: "tool_end"; toolCallId: string; toolName: string; isError: boolean }
27
- | { kind: "turn_end"; reason: "end" | "error" | "aborted" }
28
- | { kind: "info"; text: string };
29
-
30
- type ToolStatus = "running" | "done" | "error";
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 | ToolEntry;
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 thinkingShown = false;
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.reset();
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.appendText(`\n— ${event.reason === "aborted" ? "aborted" : event.reason === "error" ? "error" : "done"} —\n`);
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.thinkingShown = false;
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 appendTool(status: ToolStatus, name: string): void {
144
- const last = this.chunks[this.chunks.length - 1];
145
- if (last && last.kind === "tool" && last.status === status && last.name === name) {
146
- last.count += 1;
147
- } else {
148
- this.chunks.push({ kind: "tool", status, name, count: 1 });
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
- if (!this.active) {
173
- const sent = await this.bot.sendMessage(chunks[0]);
174
- if (sent?.message_id) {
175
- this.active = { messageId: sent.message_id, body: chunks[0] };
176
- this.sentMessageIds.push(sent.message_id);
177
- }
178
- for (const chunk of chunks.slice(1)) {
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
- } else {
186
- await this.bot.editMessageText(this.active.messageId, chunks[0]);
187
- this.active.body = chunks[0];
188
- for (const chunk of chunks.slice(1)) {
189
- const spilled = await this.bot.sendMessage(chunk);
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
- if (chunk.kind === "text") {
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
- 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 (!Array.isArray(message.content)) return false;
204
- return message.content.some((block) => typeof (block as { type?: unknown }).type === "string");
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 you stop, update any finished todo items to completed. Treat the final user-facing report step like any other todo: once it is done, mark it completed immediately.",
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");