grok-telegram-bot 2.3.0 → 2.4.0

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.
Files changed (68) hide show
  1. package/.env.example +26 -0
  2. package/CHANGELOG.md +55 -0
  3. package/package.json +1 -1
  4. package/scripts/analyze-jsonl.ts +33 -0
  5. package/scripts/delayed-restart.ps1 +29 -0
  6. package/scripts/probe-exit-response-shape.py +77 -0
  7. package/scripts/probe-plan-exit.py +60 -0
  8. package/scripts/probe-plan-exit2.py +48 -0
  9. package/scripts/probe-plan-fields.py +41 -0
  10. package/scripts/probe-plan-fields2.py +58 -0
  11. package/scripts/probe-plan-response-path.py +48 -0
  12. package/scripts/sample-claude-tooluse.ts +21 -0
  13. package/scripts/sample-kiro-events.ts +31 -0
  14. package/scripts/smoke-exit-plan.ts +274 -0
  15. package/scripts/smoke-exit-shapes.ts +252 -0
  16. package/scripts/smoke-import.mjs +82 -0
  17. package/scripts/smoke-import.ts +73 -0
  18. package/src/app/accounts.ts +84 -0
  19. package/src/app/instance-lock.ts +6 -0
  20. package/src/app/types.ts +19 -2
  21. package/src/app/updater.ts +17 -6
  22. package/src/app/usage.ts +204 -7
  23. package/src/bot/account-rotator.ts +71 -2
  24. package/src/bot/bot.ts +36 -0
  25. package/src/bot/chat-controller.ts +35 -0
  26. package/src/bot/commands.ts +2 -0
  27. package/src/bot/complexity-gate.ts +69 -0
  28. package/src/bot/deps.ts +19 -0
  29. package/src/bot/handlers/accounts.ts +55 -5
  30. package/src/bot/handlers/import-session.ts +290 -0
  31. package/src/bot/handlers/menu.ts +17 -38
  32. package/src/bot/handlers/message.ts +1 -0
  33. package/src/bot/handlers/running.ts +35 -5
  34. package/src/bot/handlers/session-card.ts +12 -0
  35. package/src/bot/handlers/sessions.ts +14 -3
  36. package/src/bot/handlers/usage.ts +118 -16
  37. package/src/bot/menu/keyboard.ts +5 -4
  38. package/src/bot/menu/status-panel.ts +19 -6
  39. package/src/bot/prompt-content.ts +4 -0
  40. package/src/bot/reauth-controller.ts +2 -2
  41. package/src/bot/session-fork.ts +11 -0
  42. package/src/bot/session-runtime.ts +831 -64
  43. package/src/bot/suggestions.ts +429 -0
  44. package/src/config.ts +41 -0
  45. package/src/grok/client.ts +106 -20
  46. package/src/grok/plan-approval.ts +72 -0
  47. package/src/grok/session-log.ts +16 -0
  48. package/src/grok/types.ts +21 -2
  49. package/src/import/build-import.ts +132 -0
  50. package/src/import/history-readers.ts +681 -0
  51. package/src/import/list-running.ts +100 -0
  52. package/src/import/sources.ts +78 -0
  53. package/src/index.ts +179 -24
  54. package/src/render/diff.ts +11 -2
  55. package/src/render/file-summary.ts +31 -1
  56. package/src/render/markdown.ts +293 -35
  57. package/src/render/plan.ts +127 -0
  58. package/src/render/session-comment.ts +261 -0
  59. package/src/render/tool-call-detail.ts +400 -19
  60. package/src/render/tool-call-merge.ts +115 -0
  61. package/src/render/tool-call.ts +405 -142
  62. package/src/render/truncate.ts +85 -0
  63. package/src/service/windows.ts +14 -2
  64. package/src/sessions/history.ts +57 -0
  65. package/src/sessions/store.ts +3 -0
  66. package/src/sessions/types.ts +5 -0
  67. package/src/stream/streamer.ts +73 -9
  68. package/src/tasks/runner.ts +4 -3
@@ -2,38 +2,156 @@
2
2
  * Convert standard Markdown (as produced by the agent) into Telegram
3
3
  * MarkdownV2, with correct escaping and graceful handling of code blocks,
4
4
  * headings, lists, quotes, links and inline styles.
5
+ *
6
+ * Design goals:
7
+ * - Nothing meaningful is dropped: unbalanced fences, partial markup, and
8
+ * orphan backticks are escaped as literal text rather than deleted.
9
+ * - Code fences use a dynamic fence length so bodies that contain ``` never
10
+ * break the outer block (common when displaying source of fence helpers).
11
+ * - Closing fences must match open length (CommonMark-style ≥ open ticks) and
12
+ * are recognized on the first body line (empty code blocks).
13
+ * - Inline styles nest safely; unclosed markers fall through as escaped text.
14
+ * - Quote / thinking lines use a safer inline subset to avoid mid-stream
15
+ * breakage from half-open ** or nested fences.
5
16
  */
6
17
  import { escapeCode, escapeMdV2, escapeUrl } from "./escape.js";
7
18
 
8
- const FENCE = /```([^\n`]*)\n([\s\S]*?)```/g;
19
+ /**
20
+ * Match a fenced code block: opening run of 3+ backticks, optional lang,
21
+ * optional trailing newline. `^` is only applied at the start of the remaining
22
+ * slice (we never search with multiline ^).
23
+ */
24
+ const FENCE_OPEN = /^(```+)([^\n`]*)\n?/;
9
25
 
10
26
  /** Main entry: returns a MarkdownV2-safe string. */
11
27
  export function toTelegramMarkdown(src: string): string {
28
+ if (!src) return "";
29
+ // Normalize newlines; strip zero-width / bidi junk that Telegram chokes on.
30
+ const normalized = src
31
+ .replace(/\r\n/g, "\n")
32
+ .replace(/\r/g, "\n")
33
+ .replace(/[\u200B-\u200D\uFEFF\u202A-\u202E]/g, "");
34
+
12
35
  let out = "";
13
- let last = 0;
14
- let m: RegExpExecArray | null;
15
- FENCE.lastIndex = 0;
16
-
17
- while ((m = FENCE.exec(src)) !== null) {
18
- out += renderTextBlock(src.slice(last, m.index));
19
- const lang = (m[1] ?? "").trim();
20
- const code = (m[2] ?? "").replace(/\n$/, "");
21
- out += "```" + lang + "\n" + escapeCode(code) + "\n```\n";
22
- last = FENCE.lastIndex;
36
+ let i = 0;
37
+ const n = normalized.length;
38
+
39
+ while (i < n) {
40
+ const slice = normalized.slice(i);
41
+ const open = FENCE_OPEN.exec(slice);
42
+ if (open && open.index === 0) {
43
+ const ticks = open[1]!;
44
+ const minTicks = ticks.length;
45
+ const langRaw = (open[2] ?? "").trim();
46
+ const bodyStart = i + open[0].length;
47
+ const close = findClosingFence(normalized, bodyStart, minTicks);
48
+ if (close) {
49
+ const code = normalized.slice(bodyStart, close.bodyEnd);
50
+ out += fenceOut(code, sanitizeFenceLang(langRaw));
51
+ i = close.afterClose;
52
+ continue;
53
+ }
54
+ // Unclosed fence → treat rest as code (streaming-safe).
55
+ const code = normalized.slice(bodyStart);
56
+ out += fenceOut(code, sanitizeFenceLang(langRaw));
57
+ break;
58
+ }
59
+
60
+ // Find next fence start (line-start ``` only).
61
+ const nextFence = findNextFenceStart(normalized, i);
62
+ const end = nextFence === -1 ? n : nextFence;
63
+ out += renderTextBlock(normalized.slice(i, end));
64
+ i = end;
65
+ if (nextFence === -1) break;
23
66
  }
24
- out += renderTextBlock(src.slice(last));
25
67
 
26
- return out.replace(/\n{3,}/g, "\n\n").trim();
68
+ return out.replace(/\n{4,}/g, "\n\n\n").trim();
69
+ }
70
+
71
+ /** Emit a Telegram-safe fenced block; fence length adapts to body content. */
72
+ function fenceOut(code: string, lang: string): string {
73
+ // Drop a single trailing newline so we don't pad every closed fence with a
74
+ // blank line inside the code block; keep internal newlines intact.
75
+ const body = code.endsWith("\n") ? code.slice(0, -1) : code;
76
+ let tickLen = 3;
77
+ const runs = body.match(/`+/g);
78
+ if (runs) {
79
+ const max = Math.max(...runs.map((r) => r.length));
80
+ if (max >= tickLen) tickLen = max + 1;
81
+ }
82
+ const marker = "`".repeat(tickLen);
83
+ return marker + lang + "\n" + escapeCode(body) + "\n" + marker + "\n";
84
+ }
85
+
86
+ /**
87
+ * Find a closing fence starting at `from` (body start).
88
+ * Closing fence: a line that is only ≥ minTicks backticks (optional trailing
89
+ * spaces / lang-like junk), CommonMark-style.
90
+ *
91
+ * Returns bodyEnd (exclusive, before the closing fence line's leading newline
92
+ * if any) and afterClose (index past the closing fence line + its newline).
93
+ */
94
+ function findClosingFence(
95
+ src: string,
96
+ from: number,
97
+ minTicks: number,
98
+ ): { bodyEnd: number; afterClose: number } | undefined {
99
+ let lineStart = from;
100
+ while (lineStart <= src.length) {
101
+ // Measure leading backticks on this line.
102
+ let j = lineStart;
103
+ while (j < src.length && src[j] === "`") j++;
104
+ const tickCount = j - lineStart;
105
+ if (tickCount >= minTicks) {
106
+ const nl = src.indexOf("\n", j);
107
+ const lineEnd = nl === -1 ? src.length : nl;
108
+ const rest = src.slice(j, lineEnd);
109
+ // Closing fence: rest empty/whitespace, or only a simple info string.
110
+ if (/^\s*$/.test(rest) || /^[A-Za-z0-9_+\-#./]*\s*$/.test(rest)) {
111
+ // bodyEnd: exclude the newline that precedes this line when the close
112
+ // is not the first character of the body (so code doesn't keep a
113
+ // trailing blank). When close is on the first body line (empty block),
114
+ // bodyEnd == from.
115
+ let bodyEnd = lineStart;
116
+ if (lineStart > from && src[lineStart - 1] === "\n") {
117
+ bodyEnd = lineStart - 1;
118
+ }
119
+ const afterClose = nl === -1 ? src.length : nl + 1;
120
+ return { bodyEnd, afterClose };
121
+ }
122
+ }
123
+ // Advance to next line.
124
+ const nl = src.indexOf("\n", lineStart);
125
+ if (nl === -1) return undefined;
126
+ lineStart = nl + 1;
127
+ if (lineStart > src.length) return undefined;
128
+ }
129
+ return undefined;
130
+ }
131
+
132
+ function findNextFenceStart(src: string, from: number): number {
133
+ if (from === 0 && src.startsWith("```")) return 0;
134
+ let idx = from;
135
+ while (idx < src.length) {
136
+ const nl = src.indexOf("\n", idx);
137
+ if (nl === -1) return -1;
138
+ if (src.startsWith("```", nl + 1)) return nl + 1;
139
+ idx = nl + 1;
140
+ }
141
+ return -1;
142
+ }
143
+
144
+ function sanitizeFenceLang(raw: string): string {
145
+ const t = raw.trim();
146
+ if (!t) return "";
147
+ // Telegram fence info is free-form but we keep it ASCII-safe.
148
+ return /^[A-Za-z0-9_+\-#./]+$/.test(t) ? t : "";
27
149
  }
28
150
 
29
151
  function renderTextBlock(text: string): string {
30
152
  if (!text) return "";
31
153
  return text
32
154
  .split("\n")
33
- // Drop stray orphan backtick lines (` or ``) left by an unbalanced/partial
34
- // code fence — they otherwise render as a broken-looking lone "`". A real
35
- // fence is ``` (3+) and is handled by the FENCE pass, so it's never seen here.
36
- .filter((line) => !/^\s*`{1,2}\s*$/.test(line))
37
155
  .map((line) => renderLine(line))
38
156
  .join("\n");
39
157
  }
@@ -46,9 +164,14 @@ function renderLine(line: string): string {
46
164
  // Horizontal rule
47
165
  if (/^\s*([-*_])\1{2,}\s*$/.test(line)) return "\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014";
48
166
 
49
- // Blockquote (keep '>' literal so Telegram renders the quote)
50
- const quote = /^>\s?(.*)$/.exec(line);
51
- if (quote) return ">" + renderInline(quote[1] ?? "");
167
+ // Blockquote process body with lighter inline rules to avoid * / ` breakage
168
+ // inside long thinking dumps.
169
+ const quote = /^(>+)\s?(.*)$/.exec(line);
170
+ if (quote) {
171
+ const depth = (quote[1] ?? ">").length;
172
+ const prefix = ">".repeat(depth);
173
+ return prefix + renderQuoteInline(quote[2] ?? "");
174
+ }
52
175
 
53
176
  // Unordered list
54
177
  const ul = /^(\s*)[-*+]\s+(.*)$/.exec(line);
@@ -61,6 +184,42 @@ function renderLine(line: string): string {
61
184
  return renderInline(line);
62
185
  }
63
186
 
187
+ /**
188
+ * Quote bodies (thinking): prefer plain escaping + simple `code` only.
189
+ * Nested **bold** and triple-backtick fences often break mid-stream thoughts.
190
+ */
191
+ function renderQuoteInline(text: string): string {
192
+ let out = "";
193
+ let i = 0;
194
+ const n = text.length;
195
+ while (i < n) {
196
+ const c = text[i]!;
197
+ if (c === "`") {
198
+ let ticks = 1;
199
+ while (i + ticks < n && text[i + ticks] === "`") ticks++;
200
+ if (ticks >= 3) {
201
+ // Triple+ ticks inside a quote → escape as literals (never open a fence).
202
+ out += escapeMdV2("`".repeat(ticks));
203
+ i += ticks;
204
+ continue;
205
+ }
206
+ // Prefer matching same-length close; skip empty ``.
207
+ const end = findCloseTicks(text, i + ticks, ticks);
208
+ if (end !== -1 && end > i + ticks) {
209
+ out += "`" + escapeCode(text.slice(i + ticks, end)) + "`";
210
+ i = end + ticks;
211
+ continue;
212
+ }
213
+ out += escapeMdV2("`".repeat(ticks));
214
+ i += ticks;
215
+ continue;
216
+ }
217
+ out += escapeMdV2(c);
218
+ i += 1;
219
+ }
220
+ return out;
221
+ }
222
+
64
223
  /** Render inline markdown spans into MarkdownV2. */
65
224
  function renderInline(text: string): string {
66
225
  let out = "";
@@ -71,21 +230,32 @@ function renderInline(text: string): string {
71
230
  const c = text[i]!;
72
231
  const next = text[i + 1];
73
232
 
74
- // Inline code
233
+ // Inline code — count backticks; never treat ``` as inline.
75
234
  if (c === "`") {
76
- const end = text.indexOf("`", i + 1);
77
- if (end !== -1) {
78
- out += "`" + escapeCode(text.slice(i + 1, end)) + "`";
79
- i = end + 1;
235
+ let ticks = 1;
236
+ while (i + ticks < n && text[i + ticks] === "`") ticks++;
237
+ if (ticks >= 3) {
238
+ out += escapeMdV2("`".repeat(ticks));
239
+ i += ticks;
80
240
  continue;
81
241
  }
242
+ const close = findCloseTicks(text, i + ticks, ticks);
243
+ if (close !== -1 && close > i + ticks) {
244
+ const body = text.slice(i + ticks, close);
245
+ out += "`" + escapeCode(body) + "`";
246
+ i = close + ticks;
247
+ continue;
248
+ }
249
+ out += escapeMdV2("`".repeat(ticks));
250
+ i += ticks;
251
+ continue;
82
252
  }
83
253
 
84
254
  // Bold ** ** or __ __
85
255
  if ((c === "*" && next === "*") || (c === "_" && next === "_")) {
86
256
  const marker = c + c;
87
- const end = text.indexOf(marker, i + 2);
88
- if (end !== -1 && end > i + 2) {
257
+ const end = findBalanced(text, i + 2, marker);
258
+ if (end !== -1) {
89
259
  out += "*" + renderInline(text.slice(i + 2, end)) + "*";
90
260
  i = end + 2;
91
261
  continue;
@@ -94,8 +264,8 @@ function renderInline(text: string): string {
94
264
 
95
265
  // Strikethrough ~~ ~~
96
266
  if (c === "~" && next === "~") {
97
- const end = text.indexOf("~~", i + 2);
98
- if (end !== -1 && end > i + 2) {
267
+ const end = findBalanced(text, i + 2, "~~");
268
+ if (end !== -1) {
99
269
  out += "~" + renderInline(text.slice(i + 2, end)) + "~";
100
270
  i = end + 2;
101
271
  continue;
@@ -103,21 +273,34 @@ function renderInline(text: string): string {
103
273
  }
104
274
 
105
275
  // Italic * * (single)
106
- if (c === "*") {
107
- const end = text.indexOf("*", i + 1);
108
- if (end !== -1 && end > i + 1) {
276
+ if (c === "*" && next !== "*") {
277
+ const end = findSingleStar(text, i + 1);
278
+ if (end !== -1) {
109
279
  out += "_" + renderInline(text.slice(i + 1, end)) + "_";
110
280
  i = end + 1;
111
281
  continue;
112
282
  }
113
283
  }
114
284
 
285
+ // Italic _ _ (single) — skip snake_case
286
+ if (c === "_" && next !== "_") {
287
+ const prev = i > 0 ? text[i - 1]! : " ";
288
+ if (!/\w/.test(prev)) {
289
+ const end = findSingleUnderscore(text, i + 1);
290
+ if (end !== -1) {
291
+ out += "_" + renderInline(text.slice(i + 1, end)) + "_";
292
+ i = end + 1;
293
+ continue;
294
+ }
295
+ }
296
+ }
297
+
115
298
  // Link [text](url)
116
299
  if (c === "[") {
117
- const link = /^\[([^\]]*)\]\(([^)\s]+)\)/.exec(text.slice(i));
300
+ const link = parseLink(text, i);
118
301
  if (link) {
119
- out += "[" + renderInline(link[1] ?? "") + "](" + escapeUrl(link[2] ?? "") + ")";
120
- i += link[0].length;
302
+ out += "[" + renderInline(link.text) + "](" + escapeUrl(link.url) + ")";
303
+ i = link.end;
121
304
  continue;
122
305
  }
123
306
  }
@@ -128,3 +311,78 @@ function renderInline(text: string): string {
128
311
 
129
312
  return out;
130
313
  }
314
+
315
+ function findCloseTicks(text: string, from: number, ticks: number): number {
316
+ const needle = "`".repeat(ticks);
317
+ let idx = from;
318
+ while (idx < text.length) {
319
+ const at = text.indexOf(needle, idx);
320
+ if (at === -1) return -1;
321
+ // Don't stop on a longer run (e.g. looking for ` but hit ```).
322
+ const after = at + ticks;
323
+ if (after < text.length && text[after] === "`") {
324
+ // Skip the whole run.
325
+ let j = after;
326
+ while (j < text.length && text[j] === "`") j++;
327
+ idx = j;
328
+ continue;
329
+ }
330
+ return at;
331
+ }
332
+ return -1;
333
+ }
334
+
335
+ function findBalanced(text: string, from: number, marker: string): number {
336
+ // Don't span blank lines — keeps half-open ** from swallowing the rest of
337
+ // the message when the agent streams incomplete emphasis.
338
+ const end = text.indexOf(marker, from);
339
+ if (end === -1 || end <= from) return -1;
340
+ const mid = text.slice(from, end);
341
+ if (mid.includes("\n\n")) return -1;
342
+ return end;
343
+ }
344
+
345
+ function findSingleStar(text: string, from: number): number {
346
+ for (let i = from; i < text.length; i++) {
347
+ if (text[i] === "*" && text[i + 1] !== "*") {
348
+ if (text.slice(from, i).includes("\n")) return -1;
349
+ if (i > from) return i;
350
+ }
351
+ }
352
+ return -1;
353
+ }
354
+
355
+ function findSingleUnderscore(text: string, from: number): number {
356
+ for (let i = from; i < text.length; i++) {
357
+ if (text[i] === "_" && text[i + 1] !== "_") {
358
+ if (text.slice(from, i).includes("\n")) return -1;
359
+ const next = text[i + 1] ?? " ";
360
+ if (/\w/.test(next)) continue;
361
+ if (i > from) return i;
362
+ }
363
+ }
364
+ return -1;
365
+ }
366
+
367
+ function parseLink(text: string, start: number): { text: string; url: string; end: number } | undefined {
368
+ if (text[start] !== "[") return undefined;
369
+ let depth = 0;
370
+ let i = start;
371
+ for (; i < text.length; i++) {
372
+ if (text[i] === "[") depth++;
373
+ else if (text[i] === "]") {
374
+ depth--;
375
+ if (depth === 0) {
376
+ i++;
377
+ break;
378
+ }
379
+ }
380
+ }
381
+ if (depth !== 0 || text[i] !== "(") return undefined;
382
+ const linkText = text.slice(start + 1, i - 1);
383
+ const close = text.indexOf(")", i + 1);
384
+ if (close === -1) return undefined;
385
+ const url = text.slice(i + 1, close).trim();
386
+ if (!url || /\s/.test(url)) return undefined;
387
+ return { text: linkText, url, end: close + 1 };
388
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ACP plan updates → compact, professional status lines for Telegram.
3
+ *
4
+ * Plan steps stay visible on the live stream (above the progress bar) so the
5
+ * user always sees what's done / in progress / pending.
6
+ */
7
+ import type { SessionUpdate } from "../grok/types.js";
8
+
9
+ export type PlanStatus = "pending" | "in_progress" | "completed" | "cancelled" | string;
10
+
11
+ export interface PlanEntry {
12
+ content: string;
13
+ status: PlanStatus;
14
+ priority?: string;
15
+ }
16
+
17
+ const ICON: Record<string, string> = {
18
+ completed: "\u2705", // ✅
19
+ done: "\u2705",
20
+ finished: "\u2705",
21
+ in_progress: "\u25B6\uFE0F", // ▶️
22
+ inprogress: "\u25B6\uFE0F",
23
+ active: "\u25B6\uFE0F",
24
+ running: "\u25B6\uFE0F",
25
+ pending: "\u25CB", // ○
26
+ todo: "\u25CB",
27
+ cancelled: "\u2716", // ✖
28
+ canceled: "\u2716",
29
+ skipped: "\u2716",
30
+ };
31
+
32
+ /**
33
+ * Parse a session/update plan payload into entries.
34
+ * Supports common ACP shapes: entries[], plan[], or steps[].
35
+ */
36
+ export function parsePlanUpdate(u: SessionUpdate): PlanEntry[] | undefined {
37
+ const raw =
38
+ (u as { entries?: unknown }).entries ??
39
+ (u as { plan?: unknown }).plan ??
40
+ (u as { steps?: unknown }).steps ??
41
+ (u as { items?: unknown }).items;
42
+ if (!Array.isArray(raw) || raw.length === 0) return undefined;
43
+
44
+ const out: PlanEntry[] = [];
45
+ for (const item of raw) {
46
+ if (!item || typeof item !== "object") continue;
47
+ const rec = item as Record<string, unknown>;
48
+ const content = String(
49
+ rec.content ?? rec.text ?? rec.title ?? rec.description ?? rec.step ?? "",
50
+ )
51
+ .replace(/\s+/g, " ")
52
+ .trim();
53
+ if (!content) continue;
54
+ const status = String(rec.status ?? rec.state ?? "pending")
55
+ .toLowerCase()
56
+ .trim()
57
+ .replace(/[\s-]+/g, "_");
58
+ const priority =
59
+ typeof rec.priority === "string"
60
+ ? rec.priority
61
+ : typeof rec.priority === "number"
62
+ ? String(rec.priority)
63
+ : undefined;
64
+ out.push({ content: content.slice(0, 200), status, priority });
65
+ }
66
+ return out.length ? out : undefined;
67
+ }
68
+
69
+ /**
70
+ * Compact plan board for the live stream and status panel (always above the
71
+ * progress bar). Plain text + emoji — safe for both MarkdownV2 and plain API.
72
+ */
73
+ export function renderPlanMarkdown(entries: PlanEntry[]): string {
74
+ if (!entries.length) return "";
75
+ const done = entries.filter((e) => isDone(e.status)).length;
76
+ const active = entries.filter((e) => isActive(e.status)).length;
77
+ const total = entries.length;
78
+ // Header: clipboard · done/total · optional "N active"
79
+ const head =
80
+ active > 0
81
+ ? `\u{1F4CB} Plan \u00B7 ${done}/${total} \u00B7 ${active} active`
82
+ : `\u{1F4CB} Plan \u00B7 ${done}/${total}`;
83
+ const lines: string[] = [head];
84
+
85
+ for (const e of entries) {
86
+ const icon = ICON[e.status] ?? "\u25CB";
87
+ // Minimalist: icon + step text (status is encoded in the icon)
88
+ const suffix = statusSuffix(e.status);
89
+ lines.push(`${icon}${suffix} ${e.content}`);
90
+ }
91
+ return lines.join("\n");
92
+ }
93
+
94
+ /** One-line summary for status panel / cards. */
95
+ export function renderPlanOneLine(entries: PlanEntry[]): string {
96
+ if (!entries.length) return "";
97
+ const current = entries.find((e) => isActive(e.status));
98
+ const done = entries.filter((e) => isDone(e.status)).length;
99
+ const total = entries.length;
100
+ if (current) {
101
+ return `\u{1F4CB} ${done}/${total} \u00B7 \u25B6\uFE0F ${truncate(current.content, 80)}`;
102
+ }
103
+ if (done === total) return `\u{1F4CB} ${done}/${total} complete`;
104
+ const next = entries.find((e) => isPending(e.status));
105
+ if (next) return `\u{1F4CB} ${done}/${total} \u00B7 next: ${truncate(next.content, 70)}`;
106
+ return `\u{1F4CB} ${done}/${total}`;
107
+ }
108
+
109
+ function isDone(s: string): boolean {
110
+ return s === "completed" || s === "done" || s === "finished";
111
+ }
112
+ function isActive(s: string): boolean {
113
+ return s === "in_progress" || s === "inprogress" || s === "active" || s === "running";
114
+ }
115
+ function isPending(s: string): boolean {
116
+ return s === "pending" || s === "todo" || s === "";
117
+ }
118
+
119
+ /** Optional short tag after the icon for cancelled/skipped only. */
120
+ function statusSuffix(s: string): string {
121
+ if (s === "cancelled" || s === "canceled" || s === "skipped") return " skip";
122
+ return "";
123
+ }
124
+
125
+ function truncate(s: string, n: number): string {
126
+ return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
127
+ }