pi-telegram-plus 0.0.2 โ†’ 0.0.3

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/lib/renderer.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { escapeHtml } from "./html.ts";
3
6
  import { markdownToTelegramHtml } from "./markdown.ts";
4
7
  import type { TelegramConfig, TelegramRenderLevel, TelegramTransport, TelegramTurn } from "./types.ts";
5
8
  import { RENDER_LEVELS } from "./types.ts";
9
+ import { log } from "./logger.ts";
10
+
11
+ const renderLog = log.child("renderer");
6
12
 
7
13
  type AnyMessage = {
8
14
  role?: string;
@@ -51,7 +57,9 @@ function contentToRenderParts(
51
57
  : `๐Ÿ”ง ${name}\n${stringifyShort(p.arguments ?? {}, 1200)}`);
52
58
  }
53
59
  }
54
- return { body: body.filter(Boolean).join("\n"), inlineEvents };
60
+ // Multiple text parts (common when a turn interleaves text around tool calls)
61
+ // are separated by a blank line so paragraphs don't run together on mobile.
62
+ return { body: body.filter(Boolean).join("\n\n"), inlineEvents };
55
63
  }
56
64
 
57
65
  function contentImages(content: unknown): Array<{ data: string; mimeType?: string }> {
@@ -65,6 +73,33 @@ function contentImages(content: unknown): Array<{ data: string; mimeType?: strin
65
73
  });
66
74
  }
67
75
 
76
+ /**
77
+ * Extract renderable text + image parts from a tool result. Tools return
78
+ * `{ content: (TextContent | ImageContent)[], details }`; we surface the
79
+ * actual output text (rendered as markdown) and any images, instead of the
80
+ * truncated JSON blob the previous `full` mode emitted.
81
+ *
82
+ * @internal Exported for tests; not part of the public module API.
83
+ */
84
+ export function extractToolResultParts(result: unknown): {
85
+ body: string;
86
+ images: Array<{ data: string; mimeType?: string }>;
87
+ } {
88
+ const content = (result as Record<string, any> | null | undefined)?.content;
89
+ const images = contentImages(content);
90
+ const body: string[] = [];
91
+ if (Array.isArray(content)) {
92
+ for (const part of content) {
93
+ if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") {
94
+ body.push(part.text);
95
+ }
96
+ }
97
+ } else if (typeof result === "string") {
98
+ body.push(result);
99
+ }
100
+ return { body: body.filter(Boolean).join("\n\n"), images };
101
+ }
102
+
68
103
  function stringifyShort(value: unknown, max = 900): string {
69
104
  let text: string;
70
105
  if (typeof value === "string") text = value;
@@ -75,6 +110,63 @@ function stringifyShort(value: unknown, max = 900): string {
75
110
  return text.length <= max ? text : text.slice(0, max - 1) + "โ€ฆ";
76
111
  }
77
112
 
113
+ // โ”€โ”€ Oversized code block โ†’ file attachment (C1) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
114
+ // A single fenced code block that won't fit in one Telegram message is sent
115
+ // as a downloadable document instead of being split across many <pre> chunks
116
+ // (which are painful to read on mobile). The fence is replaced in-body by a
117
+ // short notice so the assistant's prose still reads coherently.
118
+
119
+ const OVERSIZED_CODE_BYTES = 4000;
120
+
121
+ const LANG_EXT: Record<string, string> = {
122
+ ts: ".ts", tsx: ".tsx", js: ".js", jsx: ".jsx", mjs: ".mjs", cjs: ".cjs",
123
+ py: ".py", python: ".py", rb: ".rb", ruby: ".rb", go: ".go", rs: ".rs", rust: ".rs", java: ".java", kt: ".kt", kotlin: ".kt",
124
+ c: ".c", h: ".h", cpp: ".cpp", cc: ".cc", hpp: ".hpp", cs: ".cs", php: ".php",
125
+ swift: ".swift", sh: ".sh", bash: ".sh", zsh: ".sh", fish: ".sh", ps1: ".ps1",
126
+ sql: ".sql", json: ".json", yml: ".yml", yaml: ".yaml", toml: ".toml",
127
+ xml: ".xml", html: ".html", htm: ".html", css: ".css", scss: ".scss",
128
+ md: ".md", markdown: ".md", txt: ".txt", text: ".txt", dockerfile: "Dockerfile",
129
+ makefile: "Makefile", graphql: ".graphql", lua: ".lua", r: ".r", dart: ".dart",
130
+ scala: ".scala", clj: ".clj", ex: ".ex", exs: ".exs", erl: ".erl", vim: ".vim",
131
+ };
132
+
133
+ function langToExt(lang: string): string {
134
+ const key = (lang || "").trim().toLowerCase();
135
+ return LANG_EXT[key] ?? ".txt";
136
+ }
137
+
138
+ /**
139
+ * Pull fenced code blocks > OVERSIZED_CODE_BYTES out of the markdown body,
140
+ * replacing each with a one-line notice. Returns the trimmed body and the
141
+ * extracted blocks (lang + raw content) for file attachment.
142
+ *
143
+ * @internal Exported for tests; not part of the public module API.
144
+ */
145
+ export function extractOversizedCodeBlocks(body: string): {
146
+ body: string;
147
+ blocks: Array<{ lang: string; content: string; fileName: string }>;
148
+ } {
149
+ const blocks: Array<{ lang: string; content: string; fileName: string }> = [];
150
+ const stripped = body.replace(/```([^\n]*)\n([\s\S]*?)\n```/g, (m, langRaw, content) => {
151
+ const lang = String(langRaw || "").trim();
152
+ const code = String(content);
153
+ if (Buffer.byteLength(code, "utf8") <= OVERSIZED_CODE_BYTES) return m;
154
+ const idx = blocks.length + 1;
155
+ const fileName = `code-${idx}${langToExt(lang)}`;
156
+ blocks.push({ lang, content: code, fileName });
157
+ const lines = code.split("\n").length;
158
+ return `\n\n๐Ÿ“Ž \`${lang || "code"} block (${lines} lines) โ€” attached: ${fileName}\`\n\n`;
159
+ });
160
+ return { body: stripped, blocks };
161
+ }
162
+
163
+ async function writeTempCodeFile(fileName: string, content: string): Promise<string> {
164
+ const dir = await mkdtemp(join(tmpdir(), "pi-tg-code-"));
165
+ const filePath = join(dir, fileName);
166
+ await writeFile(filePath, content, "utf8");
167
+ return filePath;
168
+ }
169
+
78
170
  function renderLevel(config: TelegramConfig, key: "tool" | "thinking"): TelegramRenderLevel {
79
171
  const value = config[key];
80
172
  return (RENDER_LEVELS as readonly string[]).includes(value ?? "") ? value! : "brief";
@@ -176,15 +268,16 @@ export function registerTelegramRenderer(
176
268
  return;
177
269
  }
178
270
  if (options.final && Buffer.byteLength(html, "utf8") > EDIT_LIMIT) {
179
- await deps.transport.editText(turn.chatId, turn.replaceMessageId, "๐Ÿค– <b>Assistant</b>\n\nFinal answer follows in separate message(s).").catch(() => undefined);
271
+ await deps.transport.editText(turn.chatId, turn.replaceMessageId, "๐Ÿค– <b>Assistant</b>\n\nFinal answer follows in separate message(s).").catch(renderLog.swallow("warn", "editText final-answer pointer failed", { chatId: turn.chatId, messageId: turn.replaceMessageId }));
180
272
  turn.replaceMessageId = undefined;
181
273
  await deps.transport.sendText(turn.chatId, html);
182
274
  return;
183
275
  }
184
276
  try {
185
277
  await deps.transport.editText(turn.chatId, turn.replaceMessageId, html);
186
- } catch {
278
+ } catch (err) {
187
279
  // Edit failed (message deleted / too many edits). Fall back to a new message.
280
+ renderLog.debug("editText failed; falling back to sendText", { chatId: turn.chatId, messageId: turn.replaceMessageId, err });
188
281
  turn.replaceMessageId = undefined;
189
282
  await deps.transport.sendText(turn.chatId, html);
190
283
  }
@@ -208,7 +301,7 @@ export function registerTelegramRenderer(
208
301
  const turn = deps.getActiveTurn();
209
302
  if (!turn) return;
210
303
  if (turn.replaceMessageId !== undefined) await deps.transport.editText(turn.chatId, turn.replaceMessageId, "๐Ÿค– <b>Workingโ€ฆ</b>");
211
- } catch { /* suppressed */ }
304
+ } catch (err) { renderLog.warn("render status-clear handler failed", { err }); }
212
305
  });
213
306
 
214
307
  pi.on("tool_execution_start", async (event) => {
@@ -221,7 +314,7 @@ export function registerTelegramRenderer(
221
314
  : `๐Ÿ”ง ${event.toolName} started
222
315
  ${stringifyShort(event.args, 1200)}`;
223
316
  await sendInlineEvent(inline);
224
- } catch { /* suppressed */ }
317
+ } catch (err) { renderLog.warn("render tool_execution_start handler failed", { err }); }
225
318
  });
226
319
 
227
320
  pi.on("tool_execution_update", async (event) => {
@@ -236,7 +329,7 @@ ${stringifyShort(event.args, 1200)}`;
236
329
  if (!partial || partial === "{}") return;
237
330
  await sendInlineEvent(`๐Ÿ”„ ${event.toolName} update
238
331
  ${partial}`);
239
- } catch { /* suppressed */ }
332
+ } catch (err) { renderLog.warn("render tool_execution_update handler failed", { err }); }
240
333
  });
241
334
 
242
335
  pi.on("tool_execution_end", async (event) => {
@@ -247,17 +340,33 @@ ${partial}`);
247
340
  toolArgs.delete(event.toolCallId);
248
341
  if (level === "hidden") return;
249
342
  const status = event.isError ? "โŒ Tool failed" : "โœ… Tool finished";
250
- const result = stringifyShort(event.result, event.isError ? 1800 : 900);
251
343
  if (level === "brief") {
252
344
  if (!event.isError) return;
253
345
  await sendInlineEvent(formatToolFailureBrief(event.toolName, event.result, args));
346
+ return;
347
+ }
348
+ // full mode: render the tool's actual output (text + images) as a
349
+ // persistent new message, so users who opt into `full` see complete
350
+ // content instead of a truncated JSON blob. Tool results are excluded
351
+ // from the "never fold" rule, so long ones use an expandable blockquote.
352
+ const parts = extractToolResultParts(event.result);
353
+ const header = `${event.isError ? "โŒ" : "โœ…"} <b>${escapeHtml(event.toolName)}</b>`;
354
+ if (parts.body.trim()) {
355
+ const rendered = markdownToTelegramHtml(parts.body);
356
+ const expandable = rendered.length > 600 || rendered.split("\n").length > 8;
357
+ const tag = expandable ? "<blockquote expandable>" : "<blockquote>";
358
+ await send(`${tag}${header}\n${rendered}</blockquote>`);
254
359
  } else {
255
- await sendInlineEvent(result && result !== "{}"
256
- ? `${status}: ${event.toolName}
257
- ${result}`
258
- : `${status}: ${event.toolName}`);
360
+ await sendInlineEvent(`${status}: ${event.toolName}`);
361
+ }
362
+ for (const image of parts.images) {
363
+ const chatIds = currentChats();
364
+ for (const chatId of chatIds) {
365
+ await deps.transport.sendChatAction(chatId, "upload_photo");
366
+ await deps.transport.sendPhoto(chatId, image.data, "image").catch(renderLog.swallow("warn", "sendPhoto failed", { chatId }));
367
+ }
259
368
  }
260
- } catch { /* suppressed */ }
369
+ } catch (err) { renderLog.warn("tool-result image upload failed", { err }); }
261
370
  });
262
371
 
263
372
  pi.on("message_end", async (event) => {
@@ -269,13 +378,31 @@ ${result}`
269
378
  const toolLevel = renderLevel(config, "tool");
270
379
  const rendered = contentToRenderParts(message.content, thinkingLevel, toolLevel);
271
380
  await sendInlineEvents(rendered.inlineEvents);
272
- const body = rendered.body || message.errorMessage || "";
381
+ const rawBody = rendered.body || message.errorMessage || "";
382
+ // Pull oversized code blocks out of the body before rendering: they'd
383
+ // otherwise be split across many <pre> messages (painful on mobile). They
384
+ // are sent as downloadable files after the body.
385
+ const { body, blocks: codeFiles } = extractOversizedCodeBlocks(rawBody);
273
386
  const images = contentImages(message.content);
274
387
 
275
388
  const hasBody = body.trim().length > 0;
276
389
  if (hasBody) await sendToTurn(markdownToTelegramHtml(body), { final: true });
277
390
 
278
391
  const turn = deps.getActiveTurn();
392
+ if (codeFiles.length > 0) {
393
+ const chatIds = turn ? [turn.chatId] : currentChats();
394
+ for (const chatId of chatIds) {
395
+ for (const block of codeFiles) {
396
+ const filePath = await writeTempCodeFile(block.fileName, block.content).catch(renderLog.swallow("warn", "writeTempCodeFile failed", { fileName: block.fileName }));
397
+ if (!filePath) continue;
398
+ await deps.transport.sendChatAction(chatId, "upload_document");
399
+ const lines = block.content.split("\n").length;
400
+ const caption = `๐Ÿ“Ž ${block.lang || "code"} block (${lines} lines)`;
401
+ await deps.transport.sendDocument(chatId, filePath, caption).catch(renderLog.swallow("warn", "sendDocument code block failed", { chatId, fileName: block.fileName }));
402
+ await rm(filePath, { force: true }).catch(renderLog.swallow("debug", "rm temp code file failed", { filePath }));
403
+ }
404
+ }
405
+ }
279
406
  for (const image of images) {
280
407
  const chatIds = turn ? [turn.chatId] : currentChats();
281
408
  for (const chatId of chatIds) {
@@ -288,7 +415,7 @@ ${result}`
288
415
  await deps.transport.editText(turn.chatId, turn.replaceMessageId, `โœ… <b>Output sent.</b>\n${noun}`);
289
416
  turn.replaceMessageId = undefined;
290
417
  }
291
- } catch { /* suppressed */ }
418
+ } catch (err) { renderLog.warn("render assistant_message handler failed", { err }); }
292
419
  });
293
420
 
294
421
  }
@@ -1,7 +1,10 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { basename, extname } from "node:path";
3
3
  import type { TelegramButton, TelegramConfig, TelegramSentMessage, TelegramTransport, TelegramUpdate } from "./types.ts";
4
- import { stripHtml, splitTelegramText } from "./text-split.ts";
4
+ import { stripHtml, splitTelegramHtml } from "./text-split.ts";
5
+ import { log } from "./logger.ts";
6
+
7
+ const apiLog = log.child("telegram-api");
5
8
 
6
9
  type TelegramFileInfo = {
7
10
  file_id: string;
@@ -141,20 +144,60 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
141
144
  return token;
142
145
  };
143
146
 
147
+ /** True when an error represents a network-level failure (fetch rejected,
148
+ * DNS, connection refused/timeout, โ€ฆ). When the bot is unreachable the
149
+ * plain-text fallback retry will fail identically, so callers should skip
150
+ * it and let existing `.catch(swallow(...))` / status-line reporting
151
+ * surface the outage instead of pointlessly retrying (and logging). */
152
+ const isNetworkError = (err: unknown): boolean => {
153
+ const msg = err instanceof Error ? err.message : String(err);
154
+ // telegramApi wraps raw fetch failures as "Telegram API request failed: <cause>".
155
+ if (msg.startsWith("Telegram API request failed")) return true;
156
+ // sendDocument/sendPhoto wrap as "Telegram sendX failed: <cause>"; direct
157
+ // Node fetch errors also surface with these signatures.
158
+ return /\bfetch failed\b|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|UND_ERR|socket hang up|network(?:\s|_)?error/i.test(msg);
159
+ };
160
+
161
+ /** Log why an HTML-format send failed before falling back to plain text.
162
+ * Without this the renderer's failures were completely silent โ€” a single
163
+ * unsupported tag nuked the whole message's formatting and nobody noticed.
164
+ *
165
+ * Network-level failures are intentionally NOT logged here: the polling
166
+ * loop already reports them via the status line / ui.notify, and logging
167
+ * every failed send when the bot is simply offline would only noise up the
168
+ * log file. */
169
+ const warnHtmlFallback = (label: string, err: unknown, preview: string) => {
170
+ if (isNetworkError(err)) return;
171
+ const reason = err instanceof Error ? err.message : String(err);
172
+ const snippet = preview.replace(/\s+/g, " ").slice(0, 120);
173
+ apiLog.warn(`HTML ${label} rejected; falling back to plain text`, { reason, snippet });
174
+ };
175
+
144
176
  return {
145
177
  async sendText(chatId, text) {
146
178
  const sent: TelegramSentMessage[] = [];
147
- for (const chunk of splitTelegramText(text)) {
179
+ // Use the semantic HTML splitter so multi-message sends cut at block
180
+ // boundaries (every chunk is independently valid Telegram HTML) instead
181
+ // of the legacy byte splitter that could split mid-<pre>/<blockquote>
182
+ // and force a plain-text fallback.
183
+ for (const chunk of splitTelegramHtml(text)) {
148
184
  const body = {
149
185
  chat_id: chatId,
150
186
  text: chunk,
151
187
  parse_mode: "HTML",
152
188
  };
153
189
  const msg = await callApi<TelegramSentMessage>("sendMessage", body)
154
- .catch(() => callApi<TelegramSentMessage>("sendMessage", {
155
- chat_id: chatId,
156
- text: stripHtml(chunk),
157
- }));
190
+ .catch((err) => {
191
+ // Network failure: the plain-text retry will fail identically.
192
+ // Propagate so the caller's `.catch` (or renderer suppression)
193
+ // handles it; the polling status line already reports outages.
194
+ if (isNetworkError(err)) throw err;
195
+ warnHtmlFallback("sendMessage", err, chunk);
196
+ return callApi<TelegramSentMessage>("sendMessage", {
197
+ chat_id: chatId,
198
+ text: stripHtml(chunk),
199
+ });
200
+ });
158
201
  sent.push(msg);
159
202
  }
160
203
  return sent;
@@ -164,48 +207,60 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
164
207
  // Button messages cannot be split without duplicating keyboards, so keep
165
208
  // title text short. The UI layer already truncates button labels.
166
209
  const reply_markup = buildInlineKeyboard(rows);
167
- const first = splitTelegramText(text)[0];
210
+ const first = splitTelegramHtml(text)[0];
168
211
  return await callApi<TelegramSentMessage>("sendMessage", {
169
212
  chat_id: chatId,
170
213
  text: first,
171
214
  parse_mode: "HTML",
172
215
  reply_markup,
173
- }).catch(() => callApi<TelegramSentMessage>("sendMessage", {
174
- chat_id: chatId,
175
- text: stripHtml(first),
176
- reply_markup,
177
- }));
216
+ }).catch((err) => {
217
+ if (isNetworkError(err)) throw err;
218
+ warnHtmlFallback("sendButtons", err, first);
219
+ return callApi<TelegramSentMessage>("sendMessage", {
220
+ chat_id: chatId,
221
+ text: stripHtml(first),
222
+ reply_markup,
223
+ });
224
+ });
178
225
  },
179
226
 
180
227
  async editText(chatId, messageId, text) {
181
- const first = splitTelegramText(text)[0];
228
+ const first = splitTelegramHtml(text)[0];
182
229
  await callApi("editMessageText", {
183
230
  chat_id: chatId,
184
231
  message_id: messageId,
185
232
  text: first,
186
233
  parse_mode: "HTML",
187
- }).catch(() => callApi("editMessageText", {
188
- chat_id: chatId,
189
- message_id: messageId,
190
- text: stripHtml(first),
191
- }).catch(() => undefined));
234
+ }).catch((err) => {
235
+ if (isNetworkError(err)) return; // swallow; nothing useful to retry
236
+ warnHtmlFallback("editMessageText", err, first);
237
+ return callApi("editMessageText", {
238
+ chat_id: chatId,
239
+ message_id: messageId,
240
+ text: stripHtml(first),
241
+ }).catch(apiLog.swallow("debug", "editMessageText plain-text fallback failed", { chatId, messageId }));
242
+ });
192
243
  },
193
244
 
194
245
  async editButtons(chatId, messageId, text, rows) {
195
246
  const reply_markup = buildInlineKeyboard(rows);
196
- const first = splitTelegramText(text)[0];
247
+ const first = splitTelegramHtml(text)[0];
197
248
  await callApi("editMessageText", {
198
249
  chat_id: chatId,
199
250
  message_id: messageId,
200
251
  text: first,
201
252
  parse_mode: "HTML",
202
253
  reply_markup,
203
- }).catch(() => callApi("editMessageText", {
204
- chat_id: chatId,
205
- message_id: messageId,
206
- text: stripHtml(first),
207
- reply_markup,
208
- }).catch(() => undefined));
254
+ }).catch((err) => {
255
+ if (isNetworkError(err)) return; // swallow; nothing useful to retry
256
+ warnHtmlFallback("editButtons", err, first);
257
+ return callApi("editMessageText", {
258
+ chat_id: chatId,
259
+ message_id: messageId,
260
+ text: stripHtml(first),
261
+ reply_markup,
262
+ }).catch(apiLog.swallow("debug", "editButtons plain-text fallback failed", { chatId, messageId }));
263
+ });
209
264
  },
210
265
 
211
266
  async answerCallbackQuery(callbackQueryId, text) {
@@ -220,14 +275,14 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
220
275
  chat_id: chatId,
221
276
  message_id: messageId,
222
277
  reply_markup: { inline_keyboard: [] },
223
- }).catch(() => undefined);
278
+ }).catch(apiLog.swallow("debug", "removeInlineKeyboard failed", { chatId, messageId }));
224
279
  },
225
280
 
226
281
  async deleteMessage(chatId, messageId) {
227
282
  await callApi("deleteMessage", {
228
283
  chat_id: chatId,
229
284
  message_id: messageId,
230
- }).catch(() => undefined);
285
+ }).catch(apiLog.swallow("warn", "deleteMessage failed", { chatId, messageId }));
231
286
  },
232
287
 
233
288
  async sendDocument(chatId, path, caption, signal) {
@@ -313,7 +368,7 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
313
368
  await telegramApi(requireToken(), "sendChatAction", {
314
369
  chat_id: chatId,
315
370
  action,
316
- }).catch(() => undefined);
371
+ }).catch(apiLog.swallow("debug", "sendChatAction failed", { chatId, action }));
317
372
  },
318
373
  };
319
374
  }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
2
  import { encodeUiCallback } from "./callback-protocol.ts";
3
+ import { bridgeCustomDialog } from "./custom-dialogs.ts";
3
4
  import { escapeHtml } from "./html.ts";
4
5
  import type { CapturedAgentSession, PendingInputResolver, TelegramTransport } from "./types.ts";
5
6
 
@@ -84,14 +85,29 @@ export function createTelegramUiRuntime(deps: {
84
85
 
85
86
  return {
86
87
  create(chatId) {
87
- const base = deps.getSession()?.extensionRunner.getUIContext?.();
88
+ // base is the real TUI UI context (create() is called BEFORE runWithTelegramUi
89
+ // swaps the UI context, so this is the genuine TUI context). We forward persistent
90
+ // /stateful methods to base so the TUI stays accurate even when the trigger came
91
+ // from Telegram. Interactive modals stay Telegram-only; editor ops are no-ops.
92
+ const base = deps.getSession()?.extensionRunner.getUIContext?.() as ExtensionUIContext | undefined;
93
+
94
+ const notifyFn = (message: string, level: "info" | "warning" | "error" = "info") => {
95
+ const fid = activeFlowByChat.get(chatId);
96
+ void sendOrReplaceText(chatId, `<b>${escapeHtml(String(level))}</b>\n${escapeHtml(message)}`, fid);
97
+ };
98
+
88
99
  return {
89
- ...(base as ExtensionUIContext),
90
100
  chatId,
91
- notify: (message, level = "info") => {
92
- const flowId = activeFlowByChat.get(chatId);
93
- void sendOrReplaceText(chatId, `<b>${escapeHtml(String(level))}</b>\n${escapeHtml(message)}`, flowId);
94
- },
101
+
102
+ // ---- Interactive modals: Telegram-only (do NOT forward to base) ----
103
+ // Plan Layer A: "่ฐ่งฆๅ‘็š„ turn ๅฐฑ็ป™่ฐ". A Telegram-triggered turn must NOT
104
+ // also pop the modal in the local TUI, because ExtensionUIContext.custom and
105
+ // the other modals expose no external cancel handle โ€” once the Telegram side
106
+ // resolves, any TUI-side component we mounted could never be dismissed, so
107
+ // the local TUI would stay "stuck at the selection". Local TUI turns never
108
+ // enter runWithTelegramUi, so they keep using the real TUI UIContext and are
109
+ // completely unaffected by this Telegram-only path.
110
+ notify: notifyFn,
95
111
  confirm: async (title, message) => {
96
112
  const flowId = beginFlow();
97
113
  activeFlowByChat.set(chatId, flowId);
@@ -100,6 +116,7 @@ export function createTelegramUiRuntime(deps: {
100
116
  ]], flowId);
101
117
  const value = await waitInput(chatId, flowId, false, false, sent.message_id);
102
118
  activeFlowByChat.delete(chatId);
119
+ void deps.transport.removeInlineKeyboard(chatId, sent.message_id);
103
120
  return value === true || value === "yes";
104
121
  },
105
122
  input: async (title, placeholder) => {
@@ -108,6 +125,7 @@ export function createTelegramUiRuntime(deps: {
108
125
  const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title)}</b>${placeholder ? `\n${escapeHtml(placeholder)}` : ""}`, [[{ text: "Cancel", value: cb(flowId, "cancel") }]], flowId);
109
126
  const value = await waitInput(chatId, flowId, false, true, sent.message_id);
110
127
  activeFlowByChat.delete(chatId);
128
+ void deps.transport.removeInlineKeyboard(chatId, sent.message_id);
111
129
  return typeof value === "string" ? value : undefined;
112
130
  },
113
131
  inputSecret: async (title: string, placeholder?: string) => {
@@ -116,6 +134,7 @@ export function createTelegramUiRuntime(deps: {
116
134
  const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title)}</b>${placeholder ? `\n${escapeHtml(placeholder)}` : ""}`, [[{ text: "Cancel", value: cb(flowId, "cancel") }]], flowId);
117
135
  const value = await waitInput(chatId, flowId, true, true, sent.message_id);
118
136
  activeFlowByChat.delete(chatId);
137
+ void deps.transport.removeInlineKeyboard(chatId, sent.message_id);
119
138
  return typeof value === "string" ? value : undefined;
120
139
  },
121
140
  editor: async (title, prefill) => {
@@ -124,6 +143,7 @@ export function createTelegramUiRuntime(deps: {
124
143
  const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title)}</b>${prefill ? `\n${escapeHtml(prefill)}` : ""}`, [[{ text: "Cancel", value: cb(flowId, "cancel") }]], flowId);
125
144
  const value = await waitInput(chatId, flowId, false, true, sent.message_id);
126
145
  activeFlowByChat.delete(chatId);
146
+ void deps.transport.removeInlineKeyboard(chatId, sent.message_id);
127
147
  return typeof value === "string" ? value : undefined;
128
148
  },
129
149
  select: async (title, options) => {
@@ -140,15 +160,74 @@ export function createTelegramUiRuntime(deps: {
140
160
  const suffix = pageCount > 1 ? ` (${page + 1}/${pageCount})` : "";
141
161
  const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title + suffix)}</b>`, rows, flowId);
142
162
  const value = await waitInput(chatId, flowId, false, false, sent.message_id);
143
- if (typeof value !== "string") { activeFlowByChat.delete(chatId); return undefined; }
144
- if (value === "cancel") { activeFlowByChat.delete(chatId); return undefined; }
163
+ if (typeof value !== "string") { activeFlowByChat.delete(chatId); void deps.transport.removeInlineKeyboard(chatId, sent.message_id); return undefined; }
164
+ if (value === "cancel") { activeFlowByChat.delete(chatId); void deps.transport.removeInlineKeyboard(chatId, sent.message_id); return undefined; }
145
165
  if (value.startsWith("p:")) { const next = parseInt(value.slice(2), 10); if (next >= 0 && next < pageCount) page = next; continue; }
146
- if (value.startsWith("s:")) { const idx = parseInt(value.slice(2), 10); activeFlowByChat.delete(chatId); return idx >= 0 && idx < options.length ? options[idx] : undefined; }
147
- if (options.includes(value)) { activeFlowByChat.delete(chatId); return value; }
166
+ if (value.startsWith("s:")) { const idx = parseInt(value.slice(2), 10); activeFlowByChat.delete(chatId); void deps.transport.removeInlineKeyboard(chatId, sent.message_id); return idx >= 0 && idx < options.length ? options[idx] : undefined; }
167
+ if (options.includes(value)) { activeFlowByChat.delete(chatId); void deps.transport.removeInlineKeyboard(chatId, sent.message_id); return value; }
148
168
  activeFlowByChat.delete(chatId);
169
+ void deps.transport.removeInlineKeyboard(chatId, sent.message_id);
149
170
  return undefined;
150
171
  }
151
172
  },
173
+ // custom: bridge pi-goal dialogs to Telegram buttons (Layer B). Does NOT
174
+ // forward to base โ€” see the modals comment above for why racing the TUI is
175
+ // structurally un-dismissible and would leave the local TUI stuck.
176
+ custom: async <T>(factory: (tui: any, theme: any, keybindings: any, done: (result: T) => void) => any | Promise<any>, _options?: any): Promise<T> => {
177
+ const flowId = beginFlow();
178
+ activeFlowByChat.set(chatId, flowId);
179
+ let promptMessageId: number | undefined;
180
+ const result = await bridgeCustomDialog<T>({
181
+ factory,
182
+ theme: base?.theme,
183
+ width: 80,
184
+ sendButtons: async (text, rows) => {
185
+ const encodedRows = rows.map((row) => row.map((btn) => ({ text: btn.text, value: cb(flowId, btn.value) })));
186
+ const sent = await sendOrReplaceButtons(chatId, text, encodedRows, flowId);
187
+ promptMessageId = sent.message_id;
188
+ return sent;
189
+ },
190
+ waitInput: (acceptsText = false, sensitive = false) =>
191
+ waitInput(chatId, flowId, sensitive, acceptsText, promptMessageId),
192
+ notify: notifyFn,
193
+ removeKeyboard: async () => { if (promptMessageId) await deps.transport.removeInlineKeyboard(chatId, promptMessageId); },
194
+ });
195
+ activeFlowByChat.delete(chatId);
196
+ // pi-goal accesses result.cancelled/answers without an undefined-guard, so
197
+ // never resolve undefined: fall back to a structured cancelled result.
198
+ return (result ?? ({ questions: [], answers: [], cancelled: true } as unknown as T)) as T;
199
+ },
200
+
201
+ // ---- Persistent/stateful UI: forward to TUI base (keeps TUI accurate) ----
202
+ setStatus: (key: string, text: string | undefined) => { base?.setStatus?.(key, text); },
203
+ setWorkingMessage: (message?: string) => { base?.setWorkingMessage?.(message); },
204
+ setWorkingVisible: (visible: boolean) => { base?.setWorkingVisible?.(visible); },
205
+ setWorkingIndicator: (options?: { frames?: string[]; intervalMs?: number }) => { base?.setWorkingIndicator?.(options); },
206
+ setHiddenThinkingLabel: (label?: string) => { base?.setHiddenThinkingLabel?.(label); },
207
+ setWidget: ((key: string, content: unknown, options?: unknown) => { base?.setWidget?.(key as string, content as any, options as any); }) as ExtensionUIContext["setWidget"],
208
+ setFooter: (factory: unknown) => { base?.setFooter?.(factory as any); },
209
+ setHeader: (factory: unknown) => { base?.setHeader?.(factory as any); },
210
+ setTitle: (title: string) => { base?.setTitle?.(title); },
211
+ setToolsExpanded: (expanded: boolean) => { base?.setToolsExpanded?.(expanded); },
212
+ getToolsExpanded: () => base?.getToolsExpanded?.() ?? false,
213
+ setTheme: (theme: string | unknown) => base?.setTheme?.(theme as any) ?? { success: false, error: "UI not available" },
214
+ getTheme: (name: string) => base?.getTheme?.(name),
215
+ getAllThemes: () => base?.getAllThemes?.() ?? [],
216
+
217
+ // ---- Editor/terminal: no-ops (remote turns must not touch local editor) ----
218
+ onTerminalInput: () => () => {},
219
+ pasteToEditor: () => {},
220
+ setEditorText: () => {},
221
+ getEditorText: () => "",
222
+ setEditorComponent: () => {},
223
+ getEditorComponent: () => undefined,
224
+ addAutocompleteProvider: () => {},
225
+
226
+ // ---- Theme getter: delegate to base (factory needs theme.fg/bg) ----
227
+ // base is always defined when create() runs (create() is called before the
228
+ // runWithTelegramUi UI swap, per plan ยง2.1). The non-null assertion makes
229
+ // this precondition explicit instead of hiding it behind a cast.
230
+ get theme() { return base!.theme; },
152
231
  };
153
232
  },
154
233
  resolveInput(chatId, raw, replyToMessageId, fromCallback = false) {