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.
@@ -0,0 +1,668 @@
1
+ /**
2
+ * Bridge for pi-goal's `custom()` dialogs to Telegram inline buttons.
3
+ *
4
+ * Layer B of the Telegram interaction plan: pi-goal's `ctx.ui.custom(factory)`
5
+ * instantiates a opaque TUI component. We instantiate the factory with a minimal
6
+ * tui shim, render it to text, detect the dialog shape by its rendered output,
7
+ * and drive Telegram inline buttons accordingly.
8
+ *
9
+ * Safety invariant (non-negotiable): any detection failure or transport error
10
+ * returns a cancelled structured result ({ questions: [], answers: [],
11
+ * cancelled: true }), never hangs, never throws, never returns `undefined`.
12
+ * pi-goal's `runGoalQuestionnaire` accesses `result.cancelled` / `result.answers`
13
+ * without an undefined-guard, so returning `undefined` would cause a TypeError.
14
+ */
15
+
16
+ import { escapeHtml } from "./html.ts";
17
+
18
+ /** Strip ANSI SGR escape codes from a string (one-line regex, no pi-core deps). */
19
+ export function stripAnsi(text: string): string {
20
+ // eslint-disable-next-line no-control-regex
21
+ return text.replace(/\x1B\[[0-9;]*m/g, "");
22
+ }
23
+
24
+ export type ButtonRow = { text: string; value: string }[];
25
+
26
+ export interface BridgeCustomDialogDeps {
27
+ /** The opaque factory from `ctx.ui.custom(factory)`. */
28
+ factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (result: unknown) => void) => unknown | Promise<unknown>;
29
+ /** The real TUI theme (for factory instantiation — theme.fg/bg must work). */
30
+ theme: unknown;
31
+ /** Render width in columns (default 80). */
32
+ width?: number;
33
+ /** Send an inline-button message to Telegram. Button values are raw (un-encoded). */
34
+ sendButtons: (text: string, rows: ButtonRow[]) => Promise<{ message_id: number }>;
35
+ /** Wait for user input. Returns the raw callback value, typed text, or undefined (cancel/timeout). */
36
+ waitInput: (acceptsText?: boolean, sensitive?: boolean) => Promise<string | boolean | undefined>;
37
+ /** Send a notification to the Telegram chat. */
38
+ notify: (message: string, level?: "info" | "warning" | "error") => void;
39
+ /** Remove the inline keyboard from the current prompt message (terminal cleanup). Optional; defaults to no-op. */
40
+ removeKeyboard?: () => Promise<void>;
41
+ }
42
+
43
+ // ---- pi-goal literal strings (from showProposalDialog) ----
44
+ const CONFIRM_ANSWER = "Confirm — create this goal now";
45
+ const CONTINUE_ANSWER = "Continue chatting — keep refining";
46
+
47
+ const MAX_BUTTON_TEXT = 60;
48
+
49
+ type DialogShape = "confirmation" | "single-question" | "multi-question" | "unknown";
50
+
51
+ /** Minimal tui shim satisfying what pi-goal's Editor needs: requestRender + terminal.rows. */
52
+ function buildTuiShim(): { requestRender(): void; terminal: { rows: number } } {
53
+ return { requestRender: () => {}, terminal: { rows: 80 } };
54
+ }
55
+
56
+ function truncateLabel(text: string, max = MAX_BUTTON_TEXT): string {
57
+ return text.length <= max ? text : text.slice(0, max - 1) + "…";
58
+ }
59
+
60
+ // ---- Shape detection (operates on ANSI-stripped render text) ----
61
+
62
+ function detectShape(text: string): DialogShape {
63
+ // Multi-question questionnaires show a tab bar with "✓ Submit".
64
+ if (text.includes("✓ Submit")) return "multi-question";
65
+ // Confirmation dialog: known header + known trailing options.
66
+ if (/Confirm (Sisyphus )?Goal Draft/.test(text) && text.includes(CONFIRM_ANSWER)) return "confirmation";
67
+ // Single-question: has numbered option lines or a free-text prompt.
68
+ if (/^\s*[>]?\s*\d+\.\s+/m.test(text) || text.includes("Press Enter to write your answer")) return "single-question";
69
+ return "unknown";
70
+ }
71
+
72
+ function extractConfirmationHeader(text: string): string {
73
+ const m = text.match(/Confirm (Sisyphus )?Goal Draft/);
74
+ return m ? m[0] : "Confirm Goal Draft";
75
+ }
76
+
77
+ /** Extract question/context text: content lines between the top dash line and the first option row. */
78
+ function extractContentLines(lines: string[]): string[] {
79
+ const content: string[] = [];
80
+ for (const line of lines) {
81
+ const trimmed = line.trim();
82
+ if (/^─+$/.test(trimmed)) {
83
+ if (content.length > 0) break; // bottom border
84
+ continue; // skip top border
85
+ }
86
+ if (trimmed === "") {
87
+ if (content.length > 0) break; // blank line after content
88
+ continue;
89
+ }
90
+ if (/navigate.*cancel/.test(trimmed)) continue; // hint line
91
+ if (/Press Enter to write/.test(trimmed)) continue;
92
+ if (/^\s*[>]?\s*\d+\.\s+/.test(trimmed)) break; // option line
93
+ if (/Write your own answer/.test(trimmed)) break;
94
+ content.push(trimmed);
95
+ }
96
+ return content;
97
+ }
98
+
99
+ /** Extract option labels from rendered numbered lines, stripping selection marker and recommended star. */
100
+ function extractOptions(lines: string[]): string[] {
101
+ const options: string[] = [];
102
+ for (const line of lines) {
103
+ const m = line.match(/^\s*[>]?\s*\d+\.\s+(.+)$/);
104
+ if (m) {
105
+ const label = m[1].replace(/\s*★$/, "").trim();
106
+ if (label !== "Write your own answer...") options.push(label);
107
+ }
108
+ }
109
+ return options;
110
+ }
111
+
112
+ function hasCustomOption(lines: string[]): boolean {
113
+ return lines.some((l) => l.includes("Write your own answer..."));
114
+ }
115
+
116
+ // ---- Multi-question questionnaire support ----
117
+ // pi-goal's goal_questionnaire renders ONE tab at a time (the current question
118
+ // + its options) behind an opaque `custom(factory)` component. A single render
119
+ // only exposes the first question; the other questions' options are hidden until
120
+ // the user switches tabs. To bridge the whole questionnaire to Telegram we drive
121
+ // the opaque component ourselves: send a raw Tab byte ("\t", what matchesKey(…,
122
+ // Key.tab) accepts) to cycle tabs and render each, extracting every question's
123
+ // text/options. We then run a tab-style Telegram flow mirroring the TUI
124
+ // (per-question option buttons + Prev/Next tab navigation + Submit) and return a
125
+ // constructed GoalQuestionnaireResult — bypassing the component's `done`, the
126
+ // same way the confirmation and single-question paths do.
127
+
128
+ interface ParsedQuestion {
129
+ id: string;
130
+ question: string;
131
+ context: string;
132
+ options: string[];
133
+ allowCustom: boolean;
134
+ recommended: number; // 0-based option index, -1 if none
135
+ }
136
+
137
+ /** Raw terminal byte that matchesKey(…, Key.tab) accepts (pi-tui keys.js). */
138
+ const TAB_KEY = "\t";
139
+
140
+ /** Extract the ordered question ids from the multi-question tab bar line. */
141
+ function parseTabBarIds(text: string): string[] {
142
+ const tabLine = text.split("\n").find((l) => l.includes("Submit") && l.includes("←"));
143
+ if (!tabLine) return [];
144
+ const ids: string[] = [];
145
+ for (const m of tabLine.matchAll(/[□■]\s+(\S+)/g)) {
146
+ const id = m[1];
147
+ if (id && id !== "Submit") ids.push(id);
148
+ }
149
+ return ids;
150
+ }
151
+
152
+ /**
153
+ * Extract the current tab's question text + context from a rendered tab.
154
+ * Skips the top border, the tab bar (line with ←/Submit/→), and the hint line;
155
+ * stops at the first option row or the bottom border.
156
+ */
157
+ function extractTabContent(lines: string[]): { question: string; context: string } {
158
+ const content: string[] = [];
159
+ let sawTabBar = false;
160
+ for (const line of lines) {
161
+ const t = line.trim();
162
+ if (/^─+$/.test(t)) { if (content.length > 0) break; continue; }
163
+ if (t.includes("Submit") && t.includes("←")) { sawTabBar = true; continue; }
164
+ if (t === "") { if (content.length > 0) break; continue; }
165
+ if (/navigate.*cancel/.test(t)) continue;
166
+ if (/Press Enter to write/.test(t)) continue;
167
+ if (/^\s*[>]?\s*\d+\.\s+/.test(t)) break; // option row
168
+ if (/Write your own answer/.test(t)) break;
169
+ if (!sawTabBar) continue; // pre-tab-bar noise (shouldn't happen)
170
+ content.push(t);
171
+ }
172
+ return { question: content[0] ?? "", context: content.slice(1).join("\n") };
173
+ }
174
+
175
+ /** Parse one rendered tab into a ParsedQuestion (options, recommended, custom flag). */
176
+ function parseTabQuestion(lines: string[], id: string): ParsedQuestion {
177
+ const stripped = lines.map(stripAnsi);
178
+ const text = stripped.join("\n");
179
+ const textLines = text.split("\n");
180
+ const { question, context } = extractTabContent(textLines);
181
+ const options: string[] = [];
182
+ let recommended = -1;
183
+ for (const line of textLines) {
184
+ const m = line.match(/^\s*[>]?\s*(\d+)\.\s+(.+)$/);
185
+ if (m) {
186
+ const idx = parseInt(m[1], 10) - 1;
187
+ let label = m[2];
188
+ const isRec = /\s*★\s*$/.test(label);
189
+ label = label.replace(/\s*★\s*$/, "").trim();
190
+ if (label === "Write your own answer...") continue;
191
+ options.push(label);
192
+ if (isRec) recommended = idx;
193
+ }
194
+ }
195
+ const allowCustom = options.length === 0 ? true : hasCustomOption(textLines);
196
+ return { id, question: question || id, context, options, allowCustom, recommended };
197
+ }
198
+
199
+ /**
200
+ * Per-question selection state for the Telegram multi-select flows.
201
+ *
202
+ * pi-goal's questionnaire model is single-answer-per-question (answer: string),
203
+ * but on Telegram we let the user toggle multiple options for one question and
204
+ * join them into a single string before returning (the pi-goal contract is
205
+ * unchanged — see the goal contract). `selected` holds toggled option indices;
206
+ * `wasCustom` + `custom` hold a free-text answer that replaces the selection.
207
+ */
208
+ interface QState {
209
+ selected: Set<number>;
210
+ custom: string | undefined;
211
+ wasCustom: boolean;
212
+ }
213
+
214
+ function newState(): QState {
215
+ return { selected: new Set<number>(), custom: undefined, wasCustom: false };
216
+ }
217
+
218
+ /** A question is answered if it has at least one toggled option or a custom text. */
219
+ function qAnswered(s: QState): boolean {
220
+ if (s.wasCustom) return s.custom !== undefined && s.custom.trim().length > 0;
221
+ return s.selected.size > 0;
222
+ }
223
+
224
+ /** Build the single-string answer pi-goal expects: joined options, or custom text. */
225
+ function qAnswer(s: QState, options: string[]): string {
226
+ if (s.wasCustom && s.custom !== undefined) return s.custom.trim();
227
+ return [...s.selected].sort((a, b) => a - b).map((i) => options[i]).join(" / ");
228
+ }
229
+
230
+ /** Toggle an option index in a question state, clearing any prior custom answer. */
231
+ function toggleOption(s: QState, idx: number): void {
232
+ if (s.wasCustom) { s.wasCustom = false; s.custom = undefined; }
233
+ if (s.selected.has(idx)) s.selected.delete(idx);
234
+ else s.selected.add(idx);
235
+ }
236
+
237
+ /** Single-select: set exactly one option as the answer, replacing any prior selection/custom. */
238
+ function selectOption(s: QState, idx: number): void {
239
+ s.wasCustom = false;
240
+ s.custom = undefined;
241
+ s.selected.clear();
242
+ s.selected.add(idx);
243
+ }
244
+
245
+ /** Set a custom (free-text) answer, replacing any prior option selection. */
246
+ function setCustom(s: QState, text: string): void {
247
+ s.wasCustom = true;
248
+ s.custom = text;
249
+ s.selected.clear();
250
+ }
251
+
252
+ /** Join selected option labels for the Telegram message preview. */
253
+ function selectedPreview(s: QState, options: string[]): string {
254
+ if (s.wasCustom && s.custom) return `(custom) ${s.custom}`;
255
+ if (s.selected.size === 0) return "";
256
+ return [...s.selected].sort((a, b) => a - b).map((i) => options[i]).join(" / ");
257
+ }
258
+
259
+ const CANCELLED_RESULT = <T,>() => ({ questions: [], answers: [], cancelled: true }) as T;
260
+
261
+ /**
262
+ * Run a tab-style Telegram flow for a multi-question questionnaire, mirroring
263
+ * the TUI. Per-question options are multi-select toggles (no forced auto-advance
264
+ * on pick); the user navigates tabs freely with ◀ Tab / Tab ▶. The Submit button
265
+ * only appears once every question is answered; before that the message shows a
266
+ * "Still to answer: …" placeholder. Returns a constructed GoalQuestionnaireResult.
267
+ */
268
+ async function runMultiQuestionFlow(
269
+ questions: ParsedQuestion[],
270
+ deps: BridgeCustomDialogDeps,
271
+ ): Promise<{ questions: ParsedQuestion[]; answers: { id: string; question: string; answer: string; wasCustom: boolean }[]; cancelled: boolean }> {
272
+ const n = questions.length;
273
+ const states: QState[] = questions.map(() => newState());
274
+ let current = 0;
275
+ let page = 0;
276
+ const PAGE_SIZE = 8;
277
+ const removeKeyboard = deps.removeKeyboard ?? (async () => {});
278
+
279
+ const answeredAll = () => states.every((s) => qAnswered(s));
280
+ const unansweredIds = () => questions.filter((_, i) => !qAnswered(states[i])).map((q) => q.id);
281
+
282
+ // Advance to the next unanswered question after the current one is answered.
283
+ // Searches forward first, then wraps to any earlier unanswered tab. If every
284
+ // question is answered, stays on the current tab so ✓ Submit becomes visible.
285
+ const advanceAfterAnswer = () => {
286
+ let next = -1;
287
+ for (let i = current + 1; i < n; i++) if (!qAnswered(states[i])) { next = i; break; }
288
+ if (next === -1) {
289
+ for (let i = 0; i < current; i++) if (!qAnswered(states[i])) { next = i; break; }
290
+ }
291
+ if (next !== -1) { current = next; page = 0; }
292
+ };
293
+
294
+ const buildText = () => {
295
+ const tabsLine = questions
296
+ .map((q, i) => {
297
+ const mark = qAnswered(states[i]) ? "■" : "□";
298
+ const cur = i === current ? "▸" : " ";
299
+ return `${cur}${mark} ${q.id}`;
300
+ })
301
+ .join(" ");
302
+ const q = questions[current];
303
+ if (!q) return `<b>${escapeHtml(tabsLine)}</b>`;
304
+ const ctx = q.context ? `\n${escapeHtml(q.context)}` : "";
305
+ const preview = selectedPreview(states[current], q.options);
306
+ const selLine = preview ? `\nSelected: ${escapeHtml(preview)}` : "";
307
+ const tail = answeredAll() ? "" : `\n⚠️ Still to answer: ${unansweredIds().join(", ")}`;
308
+ return `<b>${escapeHtml(tabsLine)}</b>\n\n<b>${escapeHtml(q.question)}</b>${ctx}${selLine}${tail}\n<i>Question ${current + 1}/${n}</i>`;
309
+ };
310
+
311
+ const buildRows = (): ButtonRow[] => {
312
+ const q = questions[current];
313
+ if (!q) return [[{ text: "Cancel", value: "cancel" }]];
314
+ const start = page * PAGE_SIZE;
315
+ const pageOpts = q.options.slice(start, start + PAGE_SIZE);
316
+ const rows: ButtonRow[] = pageOpts.map((label, i) => {
317
+ const absIdx = start + i;
318
+ const sel = states[current].selected.has(absIdx);
319
+ const mark = sel ? "☑" : "☐";
320
+ const rec = absIdx === q.recommended ? " ★" : "";
321
+ return [{ text: truncateLabel(`${mark} ${absIdx + 1}. ${label}${rec}`), value: `o:${absIdx}` }];
322
+ });
323
+ const nav: { text: string; value: string }[] = [];
324
+ const pageCount = Math.max(1, Math.ceil(q.options.length / PAGE_SIZE));
325
+ if (page > 0) nav.push({ text: "◀", value: `op:${page - 1}` });
326
+ if (page < pageCount - 1) nav.push({ text: "▶", value: `op:${page + 1}` });
327
+ if (current > 0) nav.push({ text: "◀ Tab", value: `t:${current - 1}` });
328
+ if (current < n - 1) nav.push({ text: "Tab ▶", value: `t:${current + 1}` });
329
+ if (q.allowCustom || q.options.length === 0) nav.push({ text: "✏️ Type", value: "custom" });
330
+ // Submit only appears once every question is answered (terminal-state gate).
331
+ if (answeredAll()) nav.push({ text: "✓ Submit", value: "submit" });
332
+ nav.push({ text: "Cancel", value: "cancel" });
333
+ rows.push(nav);
334
+ return rows;
335
+ };
336
+
337
+ const cancelled = { questions, answers: [], cancelled: true };
338
+
339
+ // eslint-disable-next-line no-constant-condition
340
+ while (true) {
341
+ try {
342
+ await deps.sendButtons(buildText(), buildRows());
343
+ } catch {
344
+ deps.notify("⚠️ Failed to send dialog buttons; the agent will continue.", "warning");
345
+ return cancelled;
346
+ }
347
+
348
+ let value: string | boolean | undefined;
349
+ try {
350
+ value = await deps.waitInput(false, false);
351
+ } catch {
352
+ deps.notify("⚠️ Dialog input failed; the agent will continue.", "warning");
353
+ return cancelled;
354
+ }
355
+
356
+ if (typeof value !== "string") { void removeKeyboard(); return cancelled; } // undefined (timeout/stop) or unexpected
357
+ if (value === "cancel") { void removeKeyboard(); return cancelled; }
358
+
359
+ if (value === "submit") {
360
+ if (answeredAll()) {
361
+ void removeKeyboard();
362
+ const orderedAnswers = questions.map((q, i) => ({
363
+ id: q.id,
364
+ question: q.question,
365
+ answer: qAnswer(states[i], q.options),
366
+ wasCustom: states[i].wasCustom,
367
+ }));
368
+ return { questions, answers: orderedAnswers, cancelled: false };
369
+ }
370
+ // No Submit button is rendered when not all answered; reaching here means a
371
+ // stale/late callback. Re-show the current state.
372
+ deps.notify(`Still to answer: ${unansweredIds().join(", ")}`, "warning");
373
+ continue;
374
+ }
375
+
376
+ if (value === "custom") {
377
+ // Free-text entry for the current question.
378
+ try {
379
+ await deps.sendButtons(`${buildText()}\n\nPlease type your answer:`, [[
380
+ { text: "Cancel", value: "cancel" },
381
+ ]]);
382
+ } catch {
383
+ deps.notify("⚠️ Failed to send dialog buttons; the agent will continue.", "warning");
384
+ void removeKeyboard();
385
+ return cancelled;
386
+ }
387
+ let typed: string | boolean | undefined;
388
+ try {
389
+ typed = await deps.waitInput(true, false);
390
+ } catch {
391
+ void removeKeyboard();
392
+ return cancelled;
393
+ }
394
+ if (typed === "cancel" || typed === undefined) { void removeKeyboard(); return cancelled; }
395
+ if (typeof typed === "string" && typed.trim()) {
396
+ setCustom(states[current], typed.trim());
397
+ // Auto-advance to the next unanswered question, mirroring the option-pick path.
398
+ advanceAfterAnswer();
399
+ }
400
+ // Stay on the (now advanced) tab; user navigates manually with ◀ Tab / Tab ▶.
401
+ continue;
402
+ }
403
+
404
+ if (value.startsWith("o:")) {
405
+ const idx = parseInt(value.slice(2), 10);
406
+ const q = questions[current];
407
+ if (q && idx >= 0 && idx < q.options.length) {
408
+ // Single-select (pi-goal questionnaire contract: one answer per question):
409
+ // tapping an option sets it as THE answer (replacing any prior pick), then
410
+ // auto-advances to the next unanswered question so the user flows Q1 → Q2 → …
411
+ // without manually pressing Tab ▶.
412
+ selectOption(states[current], idx);
413
+ advanceAfterAnswer();
414
+ }
415
+ continue;
416
+ }
417
+ if (value.startsWith("op:")) {
418
+ page = Math.max(0, parseInt(value.slice(3), 10) || 0);
419
+ continue;
420
+ }
421
+ if (value.startsWith("t:")) {
422
+ const next = parseInt(value.slice(2), 10);
423
+ if (next >= 0 && next < n) { current = next; page = 0; }
424
+ continue;
425
+ }
426
+
427
+ // Unknown value → cancel.
428
+ void removeKeyboard();
429
+ return cancelled;
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Bridge an opaque `custom(factory)` dialog to Telegram inline buttons.
435
+ *
436
+ * Returns `T | undefined`: known shapes produce a `GoalQuestionnaireResult`-shaped
437
+ * object cast to `T`; all failure paths (unknown shape, factory throw, render
438
+ * throw, transport error) produce `{ questions: [], answers: [], cancelled: true }`.
439
+ * The `| undefined` in the return type is kept for `ExtensionUIContext.custom`
440
+ * interface compliance but is never resolved at runtime.
441
+ */
442
+ export async function bridgeCustomDialog<T>(deps: BridgeCustomDialogDeps): Promise<T | undefined> {
443
+ const width = deps.width ?? 80;
444
+ const tuiShim = buildTuiShim();
445
+
446
+ // If the factory auto-submits (calls done before returning), capture that result.
447
+ let factoryResult: T | undefined;
448
+ let factoryDone = false;
449
+ const done = (result: unknown) => { factoryResult = result as T; factoryDone = true; };
450
+
451
+ let component: { render(width: number): string[]; handleInput?(data: string): void };
452
+ try {
453
+ component = (await deps.factory(tuiShim, deps.theme, undefined, done)) as {
454
+ render(width: number): string[];
455
+ handleInput?(data: string): void;
456
+ };
457
+ } catch {
458
+ deps.notify("⚠️ Terminal-only dialog was auto-dismissed; the agent will continue.", "warning");
459
+ return { questions: [], answers: [], cancelled: true } as T;
460
+ }
461
+
462
+ // Coerce to a cancelled result if a factory auto-called done(undefined); keeps the
463
+ // "never resolves undefined" invariant universal. Not reachable for pi-goal today.
464
+ if (factoryDone) return (factoryResult ?? ({ questions: [], answers: [], cancelled: true } as T));
465
+
466
+ let text: string;
467
+ try {
468
+ text = stripAnsi(component.render(width).join("\n"));
469
+ } catch {
470
+ deps.notify("⚠️ Terminal-only dialog was auto-dismissed; the agent will continue.", "warning");
471
+ return { questions: [], answers: [], cancelled: true } as T;
472
+ }
473
+
474
+ const lines = text.split("\n");
475
+ const shape = detectShape(text);
476
+
477
+ // ---- Confirmation dialog (pi-goal showProposalDialog) ----
478
+ if (shape === "confirmation") {
479
+ const header = extractConfirmationHeader(text);
480
+ const removeKeyboard = deps.removeKeyboard ?? (async () => {});
481
+ try {
482
+ await deps.sendButtons(`<b>${escapeHtml(header)}</b>`, [[
483
+ { text: "✅ Confirm", value: "confirm" },
484
+ { text: "💬 Continue chatting", value: "continue" },
485
+ ]]);
486
+ const value = await deps.waitInput(false, false);
487
+
488
+ if (value === "confirm") {
489
+ void removeKeyboard();
490
+ return { questions: [], answers: [{ id: "confirm", question: header, answer: CONFIRM_ANSWER, wasCustom: false }], cancelled: false } as T;
491
+ }
492
+ if (value === "continue") {
493
+ void removeKeyboard();
494
+ return { questions: [], answers: [{ id: "confirm", question: header, answer: CONTINUE_ANSWER, wasCustom: false }], cancelled: false } as T;
495
+ }
496
+ // /stop or timeout → cancel
497
+ void removeKeyboard();
498
+ return { questions: [], answers: [], cancelled: true } as T;
499
+ } catch {
500
+ deps.notify("⚠️ Failed to send dialog buttons; the agent will continue.", "warning");
501
+ return { questions: [], answers: [], cancelled: true } as T;
502
+ }
503
+ }
504
+
505
+ // ---- Multi-question questionnaire (goal_questionnaire) ----
506
+ // Drive the opaque component: cycle tabs with a raw Tab byte and render each
507
+ // tab to extract that question's text/options, then run a tab-style Telegram
508
+ // flow (runMultiQuestionFlow) mirroring the TUI. The component is bypassed
509
+ // for the answer/submit — we return a constructed result, like the other paths.
510
+ if (shape === "multi-question") {
511
+ if (typeof component.handleInput !== "function") {
512
+ deps.notify("⚠️ Terminal-only dialog was auto-dismissed; the agent will continue.", "warning");
513
+ return { questions: [], answers: [], cancelled: true } as T;
514
+ }
515
+ try {
516
+ const initialLines = component.render(width);
517
+ const initialText = stripAnsi(initialLines.join("\n"));
518
+ const ids = parseTabBarIds(initialText);
519
+ if (ids.length < 2) {
520
+ // Not actually multi-question — safe degrade.
521
+ deps.notify("⚠️ Terminal-only dialog was auto-dismissed; the agent will continue.", "warning");
522
+ return { questions: [], answers: [], cancelled: true } as T;
523
+ }
524
+ const questions: ParsedQuestion[] = [parseTabQuestion(initialLines, ids[0])];
525
+ for (let i = 1; i < ids.length; i++) {
526
+ component.handleInput(TAB_KEY);
527
+ questions.push(parseTabQuestion(component.render(width), ids[i]));
528
+ }
529
+ const result = await runMultiQuestionFlow(questions, deps);
530
+ return result as T;
531
+ } catch {
532
+ deps.notify("⚠️ Terminal-only dialog was auto-dismissed; the agent will continue.", "warning");
533
+ return { questions: [], answers: [], cancelled: true } as T;
534
+ }
535
+ }
536
+
537
+ // ---- Single-question (goal_question) ----
538
+ // Multi-select toggle: tap an option to toggle it (multiple options may be
539
+ // selected). A ✓ Submit button only appears once at least one option is
540
+ // selected (or a custom text is provided); before that the message shows a
541
+ // "Select one or more options, then Submit." placeholder. Free-text entry via
542
+ // ✏️ Type answer finalizes immediately with wasCustom=true (same as before).
543
+ // The returned answer is the joined string of selected options.
544
+ if (shape === "single-question") {
545
+ const contentLines = extractContentLines(lines);
546
+ const questionText = contentLines[0] ?? "Question";
547
+ const contextText = contentLines.slice(1).join("\n");
548
+ const options = extractOptions(lines);
549
+ const allowCustom = hasCustomOption(lines);
550
+ const removeKeyboard = deps.removeKeyboard ?? (async () => {});
551
+ // Terminal cleanup helper: strip the inline keyboard so the user can't tap
552
+ // stale buttons after the flow resolves. Continuation callbacks (p:/s:) edit
553
+ // the same message in place and must NOT reach these returns.
554
+ const cancel = (): T => { void removeKeyboard(); return CANCELLED_RESULT<T>(); };
555
+
556
+ const displayText = contextText
557
+ ? `<b>${escapeHtml(questionText)}</b>\n${escapeHtml(contextText)}`
558
+ : `<b>${escapeHtml(questionText)}</b>`;
559
+
560
+ const PAGE_SIZE = 10;
561
+ const state = newState();
562
+ let page = 0;
563
+ const pageCount = Math.max(1, Math.ceil(options.length / PAGE_SIZE));
564
+
565
+ while (true) {
566
+ const start = page * PAGE_SIZE;
567
+ const pageOptions = options.slice(start, start + PAGE_SIZE);
568
+ const rows: ButtonRow[] = pageOptions.map((label, i) => {
569
+ const absIdx = start + i;
570
+ const sel = state.selected.has(absIdx);
571
+ const mark = sel ? "☑" : "☐";
572
+ return [{ text: truncateLabel(`${mark} ${label}`), value: `s:${absIdx}` }];
573
+ });
574
+ const nav: { text: string; value: string }[] = [];
575
+ if (page > 0) nav.push({ text: "◀ Prev", value: `p:${page - 1}` });
576
+ if (page < pageCount - 1) nav.push({ text: "Next ▶", value: `p:${page + 1}` });
577
+ if (allowCustom || options.length === 0) nav.push({ text: "✏️ Type answer", value: "custom" });
578
+ if (qAnswered(state)) nav.push({ text: "✓ Submit", value: "submit" });
579
+ nav.push({ text: "Cancel", value: "cancel" });
580
+ rows.push(nav);
581
+
582
+ const preview = selectedPreview(state, options);
583
+ const selLine = preview ? `\nSelected: ${escapeHtml(preview)}` : "";
584
+ const placeholder = options.length > 0 && !qAnswered(state)
585
+ ? "\nSelect one or more options, then Submit."
586
+ : "";
587
+ const suffix = pageCount > 1 ? ` (${page + 1}/${pageCount})` : "";
588
+ try {
589
+ await deps.sendButtons(`${displayText}${selLine}${placeholder}${suffix}`, rows);
590
+ } catch {
591
+ deps.notify("⚠️ Failed to send dialog buttons; the agent will continue.", "warning");
592
+ return cancel();
593
+ }
594
+
595
+ let value: string | boolean | undefined;
596
+ try {
597
+ value = await deps.waitInput(false, false);
598
+ } catch {
599
+ deps.notify("⚠️ Dialog input failed; the agent will continue.", "warning");
600
+ return cancel();
601
+ }
602
+
603
+ if (value === undefined) return cancel();
604
+
605
+ if (typeof value === "string") {
606
+ if (value === "cancel") return cancel();
607
+ if (value.startsWith("p:")) {
608
+ const next = parseInt(value.slice(2), 10);
609
+ if (next >= 0 && next < pageCount) page = next;
610
+ continue;
611
+ }
612
+ if (value.startsWith("s:")) {
613
+ const idx = parseInt(value.slice(2), 10);
614
+ if (idx >= 0 && idx < options.length) {
615
+ toggleOption(state, idx);
616
+ }
617
+ continue;
618
+ }
619
+ if (value === "submit") {
620
+ if (qAnswered(state)) {
621
+ void removeKeyboard();
622
+ return {
623
+ questions: [{ id: "q1", question: questionText, options, allowCustom }],
624
+ answers: [{ id: "q1", question: questionText, answer: qAnswer(state, options), wasCustom: state.wasCustom }],
625
+ cancelled: false,
626
+ } as T;
627
+ }
628
+ // No Submit button is rendered when nothing is selected; reaching here
629
+ // means a stale callback. Re-show the current state.
630
+ continue;
631
+ }
632
+ if (value === "custom") {
633
+ // Free-text entry phase (finalizes immediately, same as before).
634
+ try {
635
+ await deps.sendButtons(`${displayText}\n\nPlease type your answer:`, [[
636
+ { text: "Cancel", value: "cancel" },
637
+ ]]);
638
+ } catch {
639
+ deps.notify("⚠️ Failed to send dialog buttons; the agent will continue.", "warning");
640
+ return cancel();
641
+ }
642
+ let textValue: string | boolean | undefined;
643
+ try {
644
+ textValue = await deps.waitInput(true, false);
645
+ } catch {
646
+ deps.notify("⚠️ Dialog input failed; the agent will continue.", "warning");
647
+ return cancel();
648
+ }
649
+ if (typeof textValue === "string" && textValue.trim()) {
650
+ void removeKeyboard();
651
+ return {
652
+ questions: [{ id: "q1", question: questionText, options, allowCustom }],
653
+ answers: [{ id: "q1", question: questionText, answer: textValue.trim(), wasCustom: true }],
654
+ cancelled: false,
655
+ } as T;
656
+ }
657
+ return cancel();
658
+ }
659
+ }
660
+ // Unknown value → cancel
661
+ return cancel();
662
+ }
663
+ }
664
+
665
+ // ---- Unknown component → safe fallback ----
666
+ deps.notify("⚠️ Terminal-only dialog was auto-dismissed; the agent will continue.", "warning");
667
+ return { questions: [], answers: [], cancelled: true } as T;
668
+ }
package/lib/heartbeat.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { formatTelegramStatusLine } from "./status.ts";
2
2
  import type { TelegramConfig, TelegramTurn } from "./types.ts";
3
+ import { log } from "./logger.ts";
4
+
5
+ const heartbeatLog = log.child("heartbeat");
3
6
 
4
7
  const TYPING_REFRESH_MS = 4000;
5
8
  const HEARTBEAT_MS = 2000;
@@ -27,8 +30,9 @@ export function createHeartbeat(deps: HeartbeatDeps) {
27
30
  if (generation === typingGeneration && deps.getActiveTurn() === turn) {
28
31
  await deps.sendChatAction(turn.chatId, "typing");
29
32
  }
30
- } catch {
33
+ } catch (err) {
31
34
  // Non-critical: the next pulse can retry while the turn is still processing.
35
+ heartbeatLog.debug("typing pulse sendChatAction failed", { err });
32
36
  } finally {
33
37
  typingInFlight = false;
34
38
  }