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.
- package/README.md +102 -229
- package/index.ts +30 -11
- package/lib/attachments.ts +7 -3
- package/lib/commands/info.ts +109 -3
- package/lib/commands/register.ts +15 -3
- package/lib/commands/session.ts +4 -1
- package/lib/commands/telegram-commands.ts +140 -55
- package/lib/config.ts +19 -4
- package/lib/controller.ts +53 -14
- package/lib/custom-dialogs.ts +668 -0
- package/lib/heartbeat.ts +5 -1
- package/lib/logger.ts +304 -0
- package/lib/markdown.ts +392 -23
- package/lib/menu-commands.ts +10 -6
- package/lib/polling.ts +13 -8
- package/lib/renderer.ts +146 -9
- package/lib/telegram-api.ts +129 -54
- package/lib/telegram-ui.ts +89 -10
- package/lib/text-split.ts +216 -1
- package/package.json +1 -1
package/lib/telegram-ui.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
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",
|