pi-telegram-plus 0.0.1 → 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.
@@ -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) {
package/lib/text-split.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Split text for Telegram's 4096-byte message limit.
3
3
  * Handles UTF-8 boundaries safely and prefers word/newline breaks.
4
+ *
5
+ * `splitTelegramText` is the legacy byte-level splitter (kept for the
6
+ * single-chunk `[0]` path used by edit/buttons). `splitTelegramHtml` is the
7
+ * semantic splitter used for multi-message sends: it cuts at block boundaries
8
+ * so every chunk is independently valid Telegram HTML (no unbalanced
9
+ * <pre>/<blockquote> that would force a plain-text fallback).
4
10
  */
5
11
 
6
12
  const TELEGRAM_TEXT_LIMIT = 4096;
@@ -56,4 +62,213 @@ export function splitTelegramText(text: string): string[] {
56
62
  }
57
63
  if (rest) chunks.push(rest);
58
64
  return chunks;
59
- }
65
+ }
66
+
67
+ // ── Semantic HTML splitter ───────────────────────────────────────────────────
68
+ // Protects atomic blocks (<pre>, <blockquote>) so a cut never lands inside
69
+ // one and produces unbalanced tags. Oversized atomic blocks are split along
70
+ // their internal line boundaries into multiple complete blocks.
71
+
72
+ /** Split rendered Telegram HTML into <= maxBytes chunks, each valid HTML. */
73
+ export function splitTelegramHtml(html: string, maxBytes: number = SAFE_TEXT_CHUNK): string[] {
74
+ if (byteLength(html) <= maxBytes) return [html];
75
+ const blocks = splitHtmlBlocks(html);
76
+ const chunks: string[] = [];
77
+ let cur = "";
78
+ const flush = () => {
79
+ const t = cur.trim();
80
+ if (t) chunks.push(t);
81
+ cur = "";
82
+ };
83
+ for (const block of blocks) {
84
+ if (byteLength(block) > maxBytes) {
85
+ flush();
86
+ for (const piece of splitOversizedBlock(block, maxBytes)) chunks.push(piece);
87
+ continue;
88
+ }
89
+ if (byteLength(cur) + 2 + byteLength(block) > maxBytes) flush();
90
+ cur = cur ? `${cur}\n\n${block}` : block;
91
+ }
92
+ flush();
93
+ return chunks.length ? chunks : [html];
94
+ }
95
+
96
+ /** Tokenize HTML into top-level blocks (atomic tags stay intact). */
97
+ function splitHtmlBlocks(html: string): string[] {
98
+ const atoms: string[] = [];
99
+ const placeholder = (atom: string) => {
100
+ atoms.push(atom);
101
+ return `\x00ATOM${atoms.length - 1}\x00`;
102
+ };
103
+ // Wrap each atomic block in blank lines so \n\n splitting isolates it.
104
+ // <pre> never contains a literal </pre> (code content is HTML-escaped),
105
+ // and our blockquotes don't nest, so non-greedy match is safe.
106
+ const protectedHtml = html
107
+ .replace(/<pre>[\s\S]*?<\/pre>/g, (m) => `\n\n${placeholder(m)}\n\n`)
108
+ .replace(/<blockquote[^>]*>[\s\S]*?<\/blockquote>/g, (m) => `\n\n${placeholder(m)}\n\n`);
109
+ const blocks: string[] = [];
110
+ for (const part of protectedHtml.split(/\n\n+/)) {
111
+ const t = part.trim();
112
+ if (!t) continue;
113
+ blocks.push(t.replace(/\x00ATOM(\d+)\x00/g, (_m, idx) => atoms[Number(idx)] ?? ""));
114
+ }
115
+ return blocks;
116
+ }
117
+
118
+ /** Split a single oversized block along its internal structure. */
119
+ function splitOversizedBlock(block: string, maxBytes: number): string[] {
120
+ // <pre>...</pre> (code or box-table): split content by lines into multiple
121
+ // complete <pre> chunks.
122
+ const preMatch = /^(<pre>)(<code[^>]*>)?([\s\S]*)(<\/code>)?(<\/pre>)$/.exec(block);
123
+ if (preMatch) {
124
+ const [, preOpen, codeOpen, content, codeClose, preClose] = preMatch;
125
+ const open = (preOpen ?? "") + (codeOpen ?? "");
126
+ const close = (codeClose ?? "") + (preClose ?? "");
127
+ const lines = content.replace(/\n$/, "").split("\n");
128
+ // Box-drawing table: content starts with the top border ┌ and ends with the
129
+ // bottom border └. Splitting mid-table would drop the header row in
130
+ // continuation chunks (inconsistent), so repeat top border + header row +
131
+ // separator at the start of every chunk and the bottom border at the end —
132
+ // each chunk renders as a complete, consistent mini-table.
133
+ if (lines[0]?.startsWith("┌") && lines[lines.length - 1]?.startsWith("└")) {
134
+ return splitBoxPreTable(lines, maxBytes, open, close);
135
+ }
136
+ return packLines(lines, maxBytes, open, close);
137
+ }
138
+ // <blockquote...>...</blockquote>: split inner content, re-wrap each piece.
139
+ const bqMatch = /^(<blockquote[^>]*>)([\s\S]*)(<\/blockquote>)$/.exec(block);
140
+ if (bqMatch) {
141
+ const [, open, inner, close] = bqMatch;
142
+ const budget = maxBytes - byteLength(open) - byteLength(close);
143
+ return packLines(inner.replace(/\n$/, "").split("\n"), Math.max(50, budget), open, close);
144
+ }
145
+ // Card table (has a "──" header/row separator): repeat the header in each chunk.
146
+ const lines = block.split("\n");
147
+ const sepIdx = lines.indexOf("──");
148
+ if (sepIdx > 0) {
149
+ const header = lines.slice(0, sepIdx + 1).join("\n");
150
+ const rows = lines.slice(sepIdx + 1);
151
+ const headerOverhead = byteLength(header) + 1;
152
+ const pieces = packLines(rows, Math.max(50, maxBytes - headerOverhead), "", "");
153
+ return pieces.map((p) => `${header}\n${p}`);
154
+ }
155
+ // Transposed record (every line is `<b>label</b>: value`): keep each chunk's
156
+ // labels so a huge cell value is never separated from its column context.
157
+ if (lines.length > 0 && lines.every((l) => /^<b>.*<\/b>:/.test(l))) {
158
+ return splitTransposeRecord(lines, maxBytes);
159
+ }
160
+ // Plain paragraph / list: byte-split at newline/space boundaries.
161
+ return splitPlainBytes(block, maxBytes);
162
+ }
163
+
164
+ /** Split a transposed `label: value` record, repeating a label across a byte-
165
+ * split huge value so every chunk keeps its column context (same style). */
166
+ function splitTransposeRecord(lines: string[], maxBytes: number): string[] {
167
+ const chunks: string[] = [];
168
+ let cur = "";
169
+ for (const line of lines) {
170
+ if (byteLength(line) <= maxBytes) {
171
+ const candidate = cur ? `${cur}\n${line}` : line;
172
+ if (byteLength(candidate) > maxBytes && cur) { chunks.push(cur); cur = line; }
173
+ else cur = candidate;
174
+ continue;
175
+ }
176
+ // Huge value line: byte-split the value, repeating the `label:` prefix.
177
+ if (cur) { chunks.push(cur); cur = ""; }
178
+ const m = /^(<b>.*?<\/b>:)\s*/.exec(line);
179
+ const label = m ? m[1] : "";
180
+ const value = m ? line.slice(m[0].length) : line;
181
+ const valueBudget = Math.max(50, maxBytes - byteLength(label) - 1);
182
+ for (const piece of splitPlainBytes(value, valueBudget)) chunks.push(`${label} ${piece}`);
183
+ }
184
+ if (cur) chunks.push(cur);
185
+ return chunks.length ? chunks : [lines.join("\n")];
186
+ }
187
+
188
+ /** Pack string lines into chunks `open + lines + close`, each <= maxBytes. */
189
+ function packLines(lines: string[], maxBytes: number, open: string, close: string): string[] {
190
+ const overhead = byteLength(open) + byteLength(close);
191
+ const budget = Math.max(50, maxBytes - overhead);
192
+ const chunks: string[] = [];
193
+ let cur = "";
194
+ for (const line of lines) {
195
+ if (cur) {
196
+ const candidate = `${cur}\n${line}`;
197
+ if (byteLength(candidate) > budget) {
198
+ chunks.push(`${open}${cur}${close}`);
199
+ cur = "";
200
+ } else {
201
+ cur = candidate;
202
+ continue;
203
+ }
204
+ }
205
+ // cur is empty: start a new chunk with `line`. If the line alone exceeds
206
+ // budget, byte-split it as a last-resort guard so no chunk ever overflows
207
+ // maxBytes and triggers a per-chunk plain-text fallback (which would mix
208
+ // styles within the same table).
209
+ if (byteLength(line) > budget) {
210
+ for (const piece of splitPlainBytes(line, budget)) chunks.push(`${open}${piece}${close}`);
211
+ } else {
212
+ cur = line;
213
+ }
214
+ }
215
+ if (cur) chunks.push(`${open}${cur}${close}`);
216
+ return chunks.length ? chunks : [`${open}${lines.join("\n")}${close}`];
217
+ }
218
+
219
+ /**
220
+ * Split an oversized box-drawing table (inside <pre>) so every chunk is a
221
+ * complete mini-table: top border + header row + separator + N data rows +
222
+ * bottom border. This keeps the same style and header context in every chunk
223
+ * (a naive line-split would drop the header row in continuation chunks).
224
+ */
225
+ function splitBoxPreTable(lines: string[], maxBytes: number, open: string, close: string): string[] {
226
+ const topBorder = lines[0];
227
+ const headerRow = lines[1];
228
+ const separator = lines[2];
229
+ const bottomBorder = lines[lines.length - 1];
230
+ const dataRows = lines.slice(3, -1);
231
+ const header = [topBorder, headerRow, separator].join("\n");
232
+ const overhead = byteLength(open) + byteLength(close) + byteLength(header) + 1 + byteLength(bottomBorder) + 1;
233
+ const budget = Math.max(50, maxBytes - overhead);
234
+ const chunks: string[] = [];
235
+ let cur: string[] = [];
236
+ let curBytes = 0;
237
+ const flush = () => {
238
+ if (cur.length) {
239
+ chunks.push(`${open}${header}\n${cur.join("\n")}\n${bottomBorder}${close}`);
240
+ cur = []; curBytes = 0;
241
+ }
242
+ };
243
+ for (const row of dataRows) {
244
+ const rowBytes = byteLength(row) + 1;
245
+ if (curBytes + rowBytes > budget && cur.length) flush();
246
+ // Single row alone exceeds budget: byte-split it so no chunk overflows.
247
+ if (byteLength(row) > budget) {
248
+ flush();
249
+ for (const piece of splitPlainBytes(row, budget)) {
250
+ chunks.push(`${open}${header}\n${piece}\n${bottomBorder}${close}`);
251
+ }
252
+ continue;
253
+ }
254
+ cur.push(row); curBytes += rowBytes;
255
+ }
256
+ flush();
257
+ return chunks.length ? chunks : [`${open}${header}\n${dataRows.join("\n")}\n${bottomBorder}${close}`];
258
+ }
259
+
260
+ /** Byte-aware split at newline/space; fallback for plain text. */
261
+ function splitPlainBytes(text: string, maxBytes: number): string[] {
262
+ if (byteLength(text) <= maxBytes) return [text];
263
+ const chunks: string[] = [];
264
+ let rest = text;
265
+ while (byteLength(rest) > maxBytes) {
266
+ const { head } = takeUtf8Prefix(rest, maxBytes);
267
+ let cut = Math.max(head.lastIndexOf("\n"), head.lastIndexOf(" "));
268
+ if (cut <= 0) cut = head.length;
269
+ chunks.push(rest.slice(0, cut));
270
+ rest = rest.slice(cut).replace(/^\s+/, "");
271
+ }
272
+ if (rest) chunks.push(rest);
273
+ return chunks;
274
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "pi-telegram-plus",
4
- "version": "0.0.1",
4
+ "version": "0.0.3",
5
5
  "description": "Full Telegram control of pi coding agent — commands, menus, interactive UI, model management, and more",
6
6
  "keywords": [
7
7
  "pi-package",