@fonz/tgcc 0.6.13 → 0.6.15

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.
@@ -2,6 +2,7 @@ import type { StreamInnerEvent } from './cc-protocol.js';
2
2
  export interface TelegramSender {
3
3
  sendMessage(chatId: number | string, text: string, parseMode?: string): Promise<number>;
4
4
  editMessage(chatId: number | string, messageId: number, text: string, parseMode?: string): Promise<void>;
5
+ deleteMessage?(chatId: number | string, messageId: number): Promise<void>;
5
6
  sendPhoto?(chatId: number | string, imageBuffer: Buffer, caption?: string): Promise<number>;
6
7
  }
7
8
  export interface TurnUsage {
@@ -16,6 +17,13 @@ export interface StreamAccumulatorOptions {
16
17
  sender: TelegramSender;
17
18
  editIntervalMs?: number;
18
19
  splitThreshold?: number;
20
+ logger?: {
21
+ error?: (...args: unknown[]) => void;
22
+ warn?: (...args: unknown[]) => void;
23
+ debug?: (...args: unknown[]) => void;
24
+ };
25
+ /** Callback for critical errors that should be surfaced to the user */
26
+ onError?: (err: unknown, context: string) => void;
19
27
  }
20
28
  /** Escape characters that are special in Telegram HTML. */
21
29
  export declare function escapeHtml(text: string): string;
@@ -36,6 +44,8 @@ export declare class StreamAccumulator {
36
44
  private sender;
37
45
  private editIntervalMs;
38
46
  private splitThreshold;
47
+ private logger?;
48
+ private onError?;
39
49
  private tgMessageId;
40
50
  private buffer;
41
51
  private thinkingBuffer;
@@ -44,11 +54,13 @@ export declare class StreamAccumulator {
44
54
  private lastEditTime;
45
55
  private editTimer;
46
56
  private thinkingIndicatorShown;
47
- private toolIndicators;
48
57
  private messageIds;
49
58
  private finished;
50
59
  private sendQueue;
51
60
  private turnUsage;
61
+ private toolMessages;
62
+ private toolInputBuffers;
63
+ private currentToolBlockId;
52
64
  constructor(options: StreamAccumulatorOptions);
53
65
  get allMessageIds(): number[];
54
66
  /** Set usage stats for the current turn (called from bridge on result event) */
@@ -60,7 +72,19 @@ export declare class StreamAccumulator {
60
72
  private sendOrEdit;
61
73
  private _doSendOrEdit;
62
74
  private sendImage;
63
- private showToolIndicator;
75
+ /** Send an independent tool indicator message (not through the accumulator's sendOrEdit). */
76
+ private sendToolIndicator;
77
+ /** Update a tool indicator message with input preview once the JSON value is complete. */
78
+ private toolIndicatorLastSummary;
79
+ private updateToolIndicatorWithInput;
80
+ /** Resolve a tool indicator with success/failure status. Edits to a compact summary with input detail. */
81
+ resolveToolMessage(blockId: string, isError: boolean, errorMessage?: string, resultContent?: string, toolUseResult?: Record<string, unknown>): Promise<void>;
82
+ /** Edit a specific tool indicator message by block ID. */
83
+ editToolMessage(blockId: string, html: string): Promise<void>;
84
+ /** Delete a specific tool indicator message by block ID. */
85
+ deleteToolMessage(blockId: string): Promise<void>;
86
+ /** Delete all tool indicator messages. */
87
+ deleteAllToolMessages(): Promise<void>;
64
88
  private throttledEdit;
65
89
  /** Build the full message text including thinking blockquote prefix and usage footer.
66
90
  * Returns { text, hasHtmlSuffix } — caller must pass rawHtml=true when hasHtmlSuffix is set
@@ -73,10 +97,12 @@ export declare class StreamAccumulator {
73
97
  private forceSplitOrTruncate;
74
98
  finalize(): Promise<void>;
75
99
  private clearEditTimer;
76
- /** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message */
100
+ /** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message.
101
+ * toolMessages persists across resets — they are independent of the text accumulator. */
77
102
  softReset(): void;
78
103
  /** Full reset: also clears tgMessageId (next send creates a new message).
79
- * Chains on the existing sendQueue so any pending finalize() edits complete first. */
104
+ * Chains on the existing sendQueue so any pending finalize() edits complete first.
105
+ * toolMessages persists — they are independent fire-and-forget messages. */
80
106
  reset(): void;
81
107
  }
82
108
  /** Format usage stats as an HTML italic footer line */
package/dist/streaming.js CHANGED
@@ -34,6 +34,8 @@ export class StreamAccumulator {
34
34
  sender;
35
35
  editIntervalMs;
36
36
  splitThreshold;
37
+ logger;
38
+ onError;
37
39
  // State
38
40
  tgMessageId = null;
39
41
  buffer = '';
@@ -43,16 +45,21 @@ export class StreamAccumulator {
43
45
  lastEditTime = 0;
44
46
  editTimer = null;
45
47
  thinkingIndicatorShown = false;
46
- toolIndicators = [];
47
48
  messageIds = []; // all message IDs sent during this turn
48
49
  finished = false;
49
50
  sendQueue = Promise.resolve();
50
51
  turnUsage = null;
52
+ // Per-tool-use independent indicator messages (persists across resets)
53
+ toolMessages = new Map();
54
+ toolInputBuffers = new Map(); // tool block ID → accumulated input JSON
55
+ currentToolBlockId = null;
51
56
  constructor(options) {
52
57
  this.chatId = options.chatId;
53
58
  this.sender = options.sender;
54
59
  this.editIntervalMs = options.editIntervalMs ?? 1000;
55
60
  this.splitThreshold = options.splitThreshold ?? 4000;
61
+ this.logger = options.logger;
62
+ this.onError = options.onError;
56
63
  }
57
64
  get allMessageIds() { return [...this.messageIds]; }
58
65
  /** Set usage stats for the current turn (called from bridge on result event) */
@@ -91,20 +98,23 @@ export class StreamAccumulator {
91
98
  if (blockType === 'thinking') {
92
99
  this.currentBlockType = 'thinking';
93
100
  if (!this.thinkingIndicatorShown && !this.buffer) {
94
- await this.sendOrEdit('<i>💭 Thinking...</i>', true);
101
+ await this.sendOrEdit('<blockquote>💭 Thinking...</blockquote>', true);
95
102
  this.thinkingIndicatorShown = true;
96
103
  }
97
104
  }
98
105
  else if (blockType === 'text') {
99
106
  this.currentBlockType = 'text';
100
- // Clear tool indicators when real text starts
101
- this.toolIndicators = [];
107
+ // Text always gets its own message — don't touch tool indicator messages
108
+ if (!this.tgMessageId) {
109
+ // Will create a new message on next sendOrEdit
110
+ }
102
111
  }
103
112
  else if (blockType === 'tool_use') {
104
113
  this.currentBlockType = 'tool_use';
105
- const name = event.content_block.name;
106
- this.toolIndicators.push(name);
107
- await this.showToolIndicator(name);
114
+ const block = event.content_block;
115
+ this.currentToolBlockId = block.id;
116
+ // Send an independent indicator message for this tool_use block
117
+ await this.sendToolIndicator(block.id, block.name);
108
118
  }
109
119
  else if (blockType === 'image') {
110
120
  this.currentBlockType = 'image';
@@ -130,22 +140,37 @@ export class StreamAccumulator {
130
140
  this.thinkingBuffer += delta.thinking;
131
141
  }
132
142
  }
143
+ else if (this.currentBlockType === 'tool_use' && 'delta' in event) {
144
+ const delta = event.delta;
145
+ if (delta?.type === 'input_json_delta' && delta.partial_json && this.currentToolBlockId) {
146
+ const blockId = this.currentToolBlockId;
147
+ const prev = this.toolInputBuffers.get(blockId) ?? '';
148
+ this.toolInputBuffers.set(blockId, prev + delta.partial_json);
149
+ // Update indicator with input preview once we have enough
150
+ await this.updateToolIndicatorWithInput(blockId);
151
+ }
152
+ }
133
153
  else if (this.currentBlockType === 'image' && 'delta' in event) {
134
154
  const delta = event.delta;
135
155
  if (delta?.type === 'image_delta' && delta.data) {
136
156
  this.imageBase64Buffer += delta.data;
137
157
  }
138
158
  }
139
- // Ignore input_json_delta content
140
159
  }
141
160
  // ── TG message management ──
142
161
  /** Send or edit a message. If rawHtml is true, text is already HTML-safe. */
143
162
  async sendOrEdit(text, rawHtml = false) {
144
- this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(text, rawHtml));
163
+ this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(text, rawHtml)).catch(err => {
164
+ this.logger?.error?.({ err }, 'sendOrEdit failed');
165
+ this.onError?.(err, 'Failed to send/edit message');
166
+ });
145
167
  return this.sendQueue;
146
168
  }
147
169
  async _doSendOrEdit(text, rawHtml = false) {
148
- const safeText = (rawHtml ? text : makeHtmlSafe(text)) || '...';
170
+ let safeText = (rawHtml ? text : makeHtmlSafe(text)) || '...';
171
+ // Guard against HTML that has tags but no visible text content (e.g. "<b></b>")
172
+ if (!safeText.replace(/<[^>]*>/g, '').trim())
173
+ safeText = '...';
149
174
  // Update timing BEFORE API call to prevent races
150
175
  this.lastEditTime = Date.now();
151
176
  try {
@@ -158,17 +183,20 @@ export class StreamAccumulator {
158
183
  }
159
184
  }
160
185
  catch (err) {
161
- // Handle TG rate limit (429)
162
- if (err && typeof err === 'object' && 'error_code' in err && err.error_code === 429) {
186
+ const errorCode = err && typeof err === 'object' && 'error_code' in err
187
+ ? err.error_code : 0;
188
+ // Handle TG rate limit (429) — retry
189
+ if (errorCode === 429) {
163
190
  const retryAfter = err.parameters?.retry_after ?? 5;
164
191
  this.editIntervalMs = Math.min(this.editIntervalMs * 2, 5000);
165
192
  await sleep(retryAfter * 1000);
166
193
  return this._doSendOrEdit(text);
167
194
  }
168
- // Ignore "message is not modified" errors
195
+ // Ignore "message is not modified" errors (harmless)
169
196
  if (err instanceof Error && err.message.includes('message is not modified'))
170
197
  return;
171
- throw err;
198
+ // 400 (bad request), 403 (forbidden), and all other errors — log and skip, never throw
199
+ this.logger?.error?.({ err, errorCode }, 'Telegram API error in _doSendOrEdit — skipping');
172
200
  }
173
201
  }
174
202
  async sendImage() {
@@ -181,16 +209,149 @@ export class StreamAccumulator {
181
209
  }
182
210
  catch (err) {
183
211
  // Fall back to text indicator on failure
212
+ this.logger?.error?.({ err }, 'Failed to send image');
184
213
  this.buffer += '\n[Image could not be sent]';
185
214
  }
186
215
  this.imageBase64Buffer = '';
187
216
  }
188
- async showToolIndicator(toolName) {
189
- const bufferHtml = this.buffer ? makeHtmlSafe(this.buffer) : '';
190
- const indicator = bufferHtml
191
- ? `${bufferHtml}\n\n<i>Using ${escapeHtml(toolName)}...</i>`
192
- : `<i>Using ${escapeHtml(toolName)}...</i>`;
193
- await this.sendOrEdit(indicator, true);
217
+ /** Send an independent tool indicator message (not through the accumulator's sendOrEdit). */
218
+ async sendToolIndicator(blockId, toolName) {
219
+ const startTime = Date.now();
220
+ this.sendQueue = this.sendQueue.then(async () => {
221
+ try {
222
+ const html = `<blockquote expandable>⚡ ${escapeHtml(toolName)}…</blockquote>`;
223
+ const msgId = await this.sender.sendMessage(this.chatId, html, 'HTML');
224
+ this.toolMessages.set(blockId, { msgId, toolName, startTime });
225
+ }
226
+ catch (err) {
227
+ // Tool indicator is non-critical — log and continue
228
+ this.logger?.debug?.({ err, toolName }, 'Failed to send tool indicator');
229
+ }
230
+ }).catch(err => {
231
+ this.logger?.error?.({ err }, 'sendToolIndicator queue error');
232
+ });
233
+ return this.sendQueue;
234
+ }
235
+ /** Update a tool indicator message with input preview once the JSON value is complete. */
236
+ toolIndicatorLastSummary = new Map(); // blockId → last rendered summary
237
+ async updateToolIndicatorWithInput(blockId) {
238
+ const entry = this.toolMessages.get(blockId);
239
+ if (!entry)
240
+ return;
241
+ const inputJson = this.toolInputBuffers.get(blockId) ?? '';
242
+ // Only extract from complete JSON (try parse succeeds) or complete regex match
243
+ // (the value must have a closing quote to avoid truncated paths)
244
+ const summary = extractToolInputSummary(entry.toolName, inputJson, 120, true);
245
+ if (!summary)
246
+ return; // not enough input yet or value still streaming
247
+ // Skip if summary hasn't changed since last edit
248
+ if (this.toolIndicatorLastSummary.get(blockId) === summary)
249
+ return;
250
+ this.toolIndicatorLastSummary.set(blockId, summary);
251
+ const codeLine = `\n<code>${escapeHtml(summary)}</code>`;
252
+ const html = `<blockquote expandable>⚡ ${escapeHtml(entry.toolName)}…${codeLine}</blockquote>`;
253
+ this.sendQueue = this.sendQueue.then(async () => {
254
+ try {
255
+ await this.sender.editMessage(this.chatId, entry.msgId, html, 'HTML');
256
+ }
257
+ catch (err) {
258
+ // "message is not modified" or other edit failure — non-critical
259
+ this.logger?.debug?.({ err }, 'Failed to update tool indicator with input');
260
+ }
261
+ }).catch(err => {
262
+ this.logger?.error?.({ err }, 'updateToolIndicatorWithInput queue error');
263
+ });
264
+ return this.sendQueue;
265
+ }
266
+ /** Resolve a tool indicator with success/failure status. Edits to a compact summary with input detail. */
267
+ async resolveToolMessage(blockId, isError, errorMessage, resultContent, toolUseResult) {
268
+ const entry = this.toolMessages.get(blockId);
269
+ if (!entry)
270
+ return;
271
+ const { msgId, toolName, startTime } = entry;
272
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
273
+ const inputJson = this.toolInputBuffers.get(blockId) ?? '';
274
+ const summary = extractToolInputSummary(toolName, inputJson);
275
+ const resultStat = extractToolResultStat(toolName, resultContent, toolUseResult);
276
+ const codeLine = summary ? `\n<code>${escapeHtml(summary)}</code>` : '';
277
+ const statLine = resultStat ? `\n${escapeHtml(resultStat)}` : '';
278
+ const icon = isError ? '❌' : '✅';
279
+ const html = `<blockquote expandable>${icon} ${escapeHtml(toolName)} (${elapsed}s)${codeLine}${statLine}</blockquote>`;
280
+ // Clean up input buffer
281
+ this.toolInputBuffers.delete(blockId);
282
+ this.toolIndicatorLastSummary.delete(blockId);
283
+ this.sendQueue = this.sendQueue.then(async () => {
284
+ try {
285
+ await this.sender.editMessage(this.chatId, msgId, html, 'HTML');
286
+ }
287
+ catch (err) {
288
+ // Edit failure on resolve — non-critical
289
+ this.logger?.debug?.({ err, toolName }, 'Failed to resolve tool indicator');
290
+ }
291
+ }).catch(err => {
292
+ this.logger?.error?.({ err }, 'resolveToolMessage queue error');
293
+ });
294
+ return this.sendQueue;
295
+ }
296
+ /** Edit a specific tool indicator message by block ID. */
297
+ async editToolMessage(blockId, html) {
298
+ const entry = this.toolMessages.get(blockId);
299
+ if (!entry)
300
+ return;
301
+ this.sendQueue = this.sendQueue.then(async () => {
302
+ try {
303
+ await this.sender.editMessage(this.chatId, entry.msgId, html, 'HTML');
304
+ }
305
+ catch (err) {
306
+ // Edit failure — non-critical
307
+ this.logger?.debug?.({ err }, 'Failed to edit tool message');
308
+ }
309
+ }).catch(err => {
310
+ this.logger?.error?.({ err }, 'editToolMessage queue error');
311
+ });
312
+ return this.sendQueue;
313
+ }
314
+ /** Delete a specific tool indicator message by block ID. */
315
+ async deleteToolMessage(blockId) {
316
+ const entry = this.toolMessages.get(blockId);
317
+ if (!entry)
318
+ return;
319
+ this.toolMessages.delete(blockId);
320
+ this.sendQueue = this.sendQueue.then(async () => {
321
+ try {
322
+ if (this.sender.deleteMessage) {
323
+ await this.sender.deleteMessage(this.chatId, entry.msgId);
324
+ }
325
+ }
326
+ catch (err) {
327
+ // Delete failure — non-critical
328
+ this.logger?.debug?.({ err }, 'Failed to delete tool message');
329
+ }
330
+ }).catch(err => {
331
+ this.logger?.error?.({ err }, 'deleteToolMessage queue error');
332
+ });
333
+ return this.sendQueue;
334
+ }
335
+ /** Delete all tool indicator messages. */
336
+ async deleteAllToolMessages() {
337
+ const ids = [...this.toolMessages.values()];
338
+ this.toolMessages.clear();
339
+ if (!this.sender.deleteMessage)
340
+ return;
341
+ this.sendQueue = this.sendQueue.then(async () => {
342
+ for (const { msgId } of ids) {
343
+ try {
344
+ await this.sender.deleteMessage(this.chatId, msgId);
345
+ }
346
+ catch (err) {
347
+ // Delete failure — non-critical
348
+ this.logger?.debug?.({ err }, 'Failed to delete tool message in batch');
349
+ }
350
+ }
351
+ }).catch(err => {
352
+ this.logger?.error?.({ err }, 'deleteAllToolMessages queue error');
353
+ });
354
+ return this.sendQueue;
194
355
  }
195
356
  async throttledEdit() {
196
357
  const now = Date.now();
@@ -301,24 +462,24 @@ export class StreamAccumulator {
301
462
  this.editTimer = null;
302
463
  }
303
464
  }
304
- /** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message */
465
+ /** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message.
466
+ * toolMessages persists across resets — they are independent of the text accumulator. */
305
467
  softReset() {
306
468
  this.buffer = '';
307
469
  this.thinkingBuffer = '';
308
470
  this.imageBase64Buffer = '';
309
471
  this.currentBlockType = null;
472
+ this.currentToolBlockId = null;
310
473
  this.lastEditTime = 0;
311
474
  this.thinkingIndicatorShown = false;
312
- this.toolIndicators = [];
313
475
  this.finished = false;
314
476
  this.turnUsage = null;
315
- this.clearEditTimer(); // Ensure cleanup
477
+ this.clearEditTimer();
316
478
  }
317
479
  /** Full reset: also clears tgMessageId (next send creates a new message).
318
- * Chains on the existing sendQueue so any pending finalize() edits complete first. */
480
+ * Chains on the existing sendQueue so any pending finalize() edits complete first.
481
+ * toolMessages persists — they are independent fire-and-forget messages. */
319
482
  reset() {
320
- // Chain on the existing queue so pending sends (e.g. finalize) complete
321
- // before the new turn starts sending.
322
483
  const prevQueue = this.sendQueue;
323
484
  this.softReset();
324
485
  this.tgMessageId = null;
@@ -327,6 +488,183 @@ export class StreamAccumulator {
327
488
  }
328
489
  }
329
490
  // ── Helpers ──
491
+ /** Extract a human-readable summary from a tool's input JSON (may be partial/incomplete). */
492
+ /** Shorten absolute paths to relative-ish display: /home/fonz/Botverse/KYO/src/foo.ts → KYO/src/foo.ts */
493
+ function shortenPath(p) {
494
+ // Strip common prefixes
495
+ return p
496
+ .replace(/^\/home\/[^/]+\/Botverse\//, '')
497
+ .replace(/^\/home\/[^/]+\/Projects\//, '')
498
+ .replace(/^\/home\/[^/]+\//, '~/');
499
+ }
500
+ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireComplete = false) {
501
+ if (!inputJson)
502
+ return null;
503
+ // Determine which field(s) to look for based on tool name
504
+ const fieldsByTool = {
505
+ Bash: ['command'],
506
+ Read: ['file_path', 'path'],
507
+ Write: ['file_path', 'path'],
508
+ Edit: ['file_path', 'path'],
509
+ MultiEdit: ['file_path', 'path'],
510
+ Search: ['pattern', 'query'],
511
+ Grep: ['pattern', 'query'],
512
+ Glob: ['pattern'],
513
+ TodoWrite: [], // handled specially below
514
+ TaskOutput: [], // handled specially below
515
+ };
516
+ const skipTools = new Set(['TodoRead']);
517
+ if (skipTools.has(toolName))
518
+ return null;
519
+ const fields = fieldsByTool[toolName];
520
+ const isPathTool = ['Read', 'Write', 'Edit', 'MultiEdit'].includes(toolName);
521
+ // Try parsing complete JSON first
522
+ try {
523
+ const parsed = JSON.parse(inputJson);
524
+ // TaskOutput: show task ID compactly
525
+ if (toolName === 'TaskOutput' && parsed.task_id) {
526
+ return `collecting result · ${String(parsed.task_id).slice(0, 7)}`;
527
+ }
528
+ // TodoWrite: show in-progress item or summary
529
+ if (toolName === 'TodoWrite' && Array.isArray(parsed.todos)) {
530
+ const todos = parsed.todos;
531
+ const inProgress = todos.find(t => t.status === 'in_progress');
532
+ const item = inProgress ?? todos[todos.length - 1];
533
+ const total = todos.length;
534
+ const done = todos.filter(t => t.status === 'completed').length;
535
+ const label = item?.content?.trim() ?? '';
536
+ const prefix = `[${done}/${total}] `;
537
+ const combined = prefix + label;
538
+ return combined.length > maxLen ? combined.slice(0, maxLen) + '…' : combined;
539
+ }
540
+ if (fields) {
541
+ for (const f of fields) {
542
+ if (typeof parsed[f] === 'string' && parsed[f].trim()) {
543
+ let val = parsed[f].trim();
544
+ if (isPathTool)
545
+ val = shortenPath(val);
546
+ return val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
547
+ }
548
+ }
549
+ }
550
+ // Default: first string value
551
+ for (const val of Object.values(parsed)) {
552
+ if (typeof val === 'string' && val.trim()) {
553
+ const v = val.trim();
554
+ return v.length > maxLen ? v.slice(0, maxLen) + '…' : v;
555
+ }
556
+ }
557
+ return null;
558
+ }
559
+ catch {
560
+ if (requireComplete)
561
+ return null; // Only use fully-parsed JSON when requireComplete
562
+ // Partial JSON — regex extraction (only used for final resolve, not live preview)
563
+ const targetFields = fields ?? ['command', 'file_path', 'path', 'pattern', 'query'];
564
+ for (const key of targetFields) {
565
+ const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, 'i'); // require closing quote
566
+ const m = inputJson.match(re);
567
+ if (m?.[1]) {
568
+ let val = m[1].replace(/\\n/g, ' ').replace(/\\t/g, ' ').replace(/\\"/g, '"');
569
+ if (isPathTool)
570
+ val = shortenPath(val);
571
+ return val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
572
+ }
573
+ }
574
+ return null;
575
+ }
576
+ }
577
+ /** Extract a compact stat from a tool result for display in the indicator. */
578
+ function extractToolResultStat(toolName, content, toolUseResult) {
579
+ // For Edit/Write: use structured patch data if available
580
+ if (toolUseResult && (toolName === 'Edit' || toolName === 'MultiEdit')) {
581
+ const patches = toolUseResult.structuredPatch;
582
+ if (patches?.length) {
583
+ let added = 0, removed = 0;
584
+ for (const patch of patches) {
585
+ if (patch.lines) {
586
+ for (const line of patch.lines) {
587
+ if (line.startsWith('+') && !line.startsWith('+++'))
588
+ added++;
589
+ else if (line.startsWith('-') && !line.startsWith('---'))
590
+ removed++;
591
+ }
592
+ }
593
+ }
594
+ if (added || removed) {
595
+ const parts = [];
596
+ if (added)
597
+ parts.push(`+${added}`);
598
+ if (removed)
599
+ parts.push(`-${removed}`);
600
+ return parts.join(' / ');
601
+ }
602
+ }
603
+ }
604
+ if (toolUseResult && toolName === 'Write') {
605
+ const c = toolUseResult.content;
606
+ if (c) {
607
+ const lines = c.split('\n').length;
608
+ return `${lines} lines`;
609
+ }
610
+ }
611
+ if (!content)
612
+ return '';
613
+ const first = content.split('\n')[0].trim();
614
+ // Skip generic "The file X has been updated/created" messages
615
+ if (/^(The file |File created|Successfully)/.test(first)) {
616
+ // Try to extract something useful anyway
617
+ const lines = content.match(/(\d+)\s*lines?/i);
618
+ if (lines)
619
+ return `${lines[1]} lines`;
620
+ return '';
621
+ }
622
+ switch (toolName) {
623
+ case 'Write': {
624
+ const lines = content.match(/(\d+)\s*lines?/i);
625
+ const bytes = content.match(/(\d[\d,.]*)\s*(bytes?|KB|MB)/i);
626
+ if (lines)
627
+ return `${lines[1]} lines`;
628
+ if (bytes)
629
+ return `${bytes[1]} ${bytes[2]}`;
630
+ return '';
631
+ }
632
+ case 'Edit':
633
+ case 'MultiEdit': {
634
+ const replaced = content.match(/replaced\s+(\d+)\s*lines?/i);
635
+ const chars = content.match(/(\d+)\s*characters?/i);
636
+ if (replaced)
637
+ return `${replaced[1]} lines replaced`;
638
+ if (chars)
639
+ return `${chars[1]} chars changed`;
640
+ return '';
641
+ }
642
+ case 'Bash': {
643
+ const exit = content.match(/exit\s*code\s*[:=]?\s*(\d+)/i);
644
+ if (exit && exit[1] !== '0')
645
+ return `exit ${exit[1]}`;
646
+ const outputLines = content.split('\n').filter(l => l.trim()).length;
647
+ if (outputLines > 3)
648
+ return `${outputLines} lines output`;
649
+ return first.length > 60 ? first.slice(0, 60) + '…' : first;
650
+ }
651
+ case 'Read': {
652
+ const lines = content.split('\n').length;
653
+ return `${lines} lines`;
654
+ }
655
+ case 'Search':
656
+ case 'Grep':
657
+ case 'Glob': {
658
+ const matches = content.match(/(\d+)\s*(match|result|file)/i);
659
+ if (matches)
660
+ return `${matches[1]} ${matches[2]}s`;
661
+ const resultLines = content.split('\n').filter(l => l.trim()).length;
662
+ return `${resultLines} results`;
663
+ }
664
+ default:
665
+ return '';
666
+ }
667
+ }
330
668
  function findSplitPoint(text, threshold) {
331
669
  // Try to split at paragraph break
332
670
  const paragraphBreak = text.lastIndexOf('\n\n', threshold);
@@ -354,6 +692,8 @@ function formatTokens(n) {
354
692
  }
355
693
  /** Format usage stats as an HTML italic footer line */
356
694
  export function formatUsageFooter(usage) {
695
+ const totalCtx = usage.inputTokens + usage.cacheReadTokens + usage.cacheCreationTokens;
696
+ const ctxPct = Math.round(totalCtx / 200000 * 100);
357
697
  const parts = [
358
698
  `↩️ ${formatTokens(usage.inputTokens)} in`,
359
699
  `${formatTokens(usage.outputTokens)} out`,
@@ -361,6 +701,7 @@ export function formatUsageFooter(usage) {
361
701
  if (usage.costUsd != null) {
362
702
  parts.push(`$${usage.costUsd.toFixed(4)}`);
363
703
  }
704
+ parts.push(`${ctxPct}%`);
364
705
  return `<i>${parts.join(' · ')}</i>`;
365
706
  }
366
707
  // ── Sub-agent detection patterns ──
@@ -510,8 +851,10 @@ export class SubAgentTracker {
510
851
  try {
511
852
  await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
512
853
  }
513
- catch { /* ignore */ }
514
- });
854
+ catch (err) {
855
+ // Non-critical — edit failure on dispatched agent status
856
+ }
857
+ }).catch(() => { });
515
858
  }
516
859
  }
517
860
  async handleEvent(event) {
@@ -591,9 +934,9 @@ export class SubAgentTracker {
591
934
  await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
592
935
  }
593
936
  catch {
594
- // Ignore edit failures
937
+ // Edit failure on tool result — non-critical
595
938
  }
596
- });
939
+ }).catch(() => { });
597
940
  await this.sendQueue;
598
941
  }
599
942
  async onBlockStart(event) {
@@ -630,9 +973,9 @@ export class SubAgentTracker {
630
973
  this.consolidatedAgentMsgId = msgId;
631
974
  }
632
975
  catch {
633
- // Silently ignore
976
+ // Sub-agent indicator is non-critical
634
977
  }
635
- });
978
+ }).catch(() => { });
636
979
  await this.sendQueue;
637
980
  }
638
981
  }
@@ -654,8 +997,10 @@ export class SubAgentTracker {
654
997
  try {
655
998
  await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
656
999
  }
657
- catch { /* ignore */ }
658
- });
1000
+ catch {
1001
+ // Consolidated message edit failure — non-critical
1002
+ }
1003
+ }).catch(() => { });
659
1004
  }
660
1005
  async onInputDelta(event) {
661
1006
  const toolUseId = this.blockToAgent.get(event.index);
@@ -797,8 +1142,10 @@ export class SubAgentTracker {
797
1142
  try {
798
1143
  await this.sender.setReaction?.(this.chatId, msgId, emoji);
799
1144
  }
800
- catch { /* ignore — reaction might not be supported */ }
801
- });
1145
+ catch {
1146
+ // Reaction failure — non-critical, might not be supported
1147
+ }
1148
+ }).catch(() => { });
802
1149
  }
803
1150
  // Check if ALL dispatched agents are now completed
804
1151
  if (this.onAllReported && !this.hasDispatchedAgents && this.agents.size > 0) {
@@ -890,8 +1237,10 @@ export class SubAgentTracker {
890
1237
  try {
891
1238
  await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
892
1239
  }
893
- catch { /* ignore */ }
894
- });
1240
+ catch {
1241
+ // Progress message edit failure — non-critical
1242
+ }
1243
+ }).catch(() => { });
895
1244
  }
896
1245
  /** Find a tracked sub-agent by tool_use_id. */
897
1246
  getAgentByToolUseId(toolUseId) {