@agentuity/coder 1.0.37

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 (92) hide show
  1. package/README.md +57 -0
  2. package/dist/chain-preview.d.ts +55 -0
  3. package/dist/chain-preview.d.ts.map +1 -0
  4. package/dist/chain-preview.js +472 -0
  5. package/dist/chain-preview.js.map +1 -0
  6. package/dist/client.d.ts +43 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +402 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/commands.d.ts +22 -0
  11. package/dist/commands.d.ts.map +1 -0
  12. package/dist/commands.js +99 -0
  13. package/dist/commands.js.map +1 -0
  14. package/dist/footer.d.ts +34 -0
  15. package/dist/footer.d.ts.map +1 -0
  16. package/dist/footer.js +249 -0
  17. package/dist/footer.js.map +1 -0
  18. package/dist/handlers.d.ts +24 -0
  19. package/dist/handlers.d.ts.map +1 -0
  20. package/dist/handlers.js +83 -0
  21. package/dist/handlers.js.map +1 -0
  22. package/dist/hub-overlay.d.ts +107 -0
  23. package/dist/hub-overlay.d.ts.map +1 -0
  24. package/dist/hub-overlay.js +1794 -0
  25. package/dist/hub-overlay.js.map +1 -0
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +1585 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/output-viewer.d.ts +49 -0
  31. package/dist/output-viewer.d.ts.map +1 -0
  32. package/dist/output-viewer.js +389 -0
  33. package/dist/output-viewer.js.map +1 -0
  34. package/dist/overlay.d.ts +40 -0
  35. package/dist/overlay.d.ts.map +1 -0
  36. package/dist/overlay.js +225 -0
  37. package/dist/overlay.js.map +1 -0
  38. package/dist/protocol.d.ts +118 -0
  39. package/dist/protocol.d.ts.map +1 -0
  40. package/dist/protocol.js +3 -0
  41. package/dist/protocol.js.map +1 -0
  42. package/dist/remote-session.d.ts +113 -0
  43. package/dist/remote-session.d.ts.map +1 -0
  44. package/dist/remote-session.js +645 -0
  45. package/dist/remote-session.js.map +1 -0
  46. package/dist/remote-tui.d.ts +40 -0
  47. package/dist/remote-tui.d.ts.map +1 -0
  48. package/dist/remote-tui.js +606 -0
  49. package/dist/remote-tui.js.map +1 -0
  50. package/dist/renderers.d.ts +34 -0
  51. package/dist/renderers.d.ts.map +1 -0
  52. package/dist/renderers.js +669 -0
  53. package/dist/renderers.js.map +1 -0
  54. package/dist/review.d.ts +15 -0
  55. package/dist/review.d.ts.map +1 -0
  56. package/dist/review.js +154 -0
  57. package/dist/review.js.map +1 -0
  58. package/dist/titlebar.d.ts +3 -0
  59. package/dist/titlebar.d.ts.map +1 -0
  60. package/dist/titlebar.js +59 -0
  61. package/dist/titlebar.js.map +1 -0
  62. package/dist/todo/index.d.ts +3 -0
  63. package/dist/todo/index.d.ts.map +1 -0
  64. package/dist/todo/index.js +3 -0
  65. package/dist/todo/index.js.map +1 -0
  66. package/dist/todo/store.d.ts +6 -0
  67. package/dist/todo/store.d.ts.map +1 -0
  68. package/dist/todo/store.js +43 -0
  69. package/dist/todo/store.js.map +1 -0
  70. package/dist/todo/types.d.ts +13 -0
  71. package/dist/todo/types.d.ts.map +1 -0
  72. package/dist/todo/types.js +2 -0
  73. package/dist/todo/types.js.map +1 -0
  74. package/package.json +44 -0
  75. package/src/chain-preview.ts +621 -0
  76. package/src/client.ts +515 -0
  77. package/src/commands.ts +132 -0
  78. package/src/footer.ts +305 -0
  79. package/src/handlers.ts +113 -0
  80. package/src/hub-overlay.ts +2324 -0
  81. package/src/index.ts +1907 -0
  82. package/src/output-viewer.ts +480 -0
  83. package/src/overlay.ts +294 -0
  84. package/src/protocol.ts +157 -0
  85. package/src/remote-session.ts +800 -0
  86. package/src/remote-tui.ts +707 -0
  87. package/src/renderers.ts +740 -0
  88. package/src/review.ts +201 -0
  89. package/src/titlebar.ts +63 -0
  90. package/src/todo/index.ts +2 -0
  91. package/src/todo/store.ts +49 -0
  92. package/src/todo/types.ts +14 -0
@@ -0,0 +1,740 @@
1
+ /**
2
+ * TUI tool renderers for Hub tools.
3
+ *
4
+ * Each renderer provides a compact renderCall (one-line summary of the invocation)
5
+ * and a renderResult (collapsed / expanded views of the result).
6
+ * Renderers are looked up by tool name and spread into the registerTool() call.
7
+ */
8
+
9
+ import type {
10
+ Theme,
11
+ ToolRenderResultOptions,
12
+ AgentToolResult,
13
+ } from '@mariozechner/pi-coding-agent';
14
+ import { Box, Text, Container, type Component } from '@mariozechner/pi-tui';
15
+
16
+ // ──────────────────────────────────────────────
17
+ // Line-safety helper — must be declared before SimpleText so
18
+ // render() can reference it without temporal-dead-zone issues.
19
+ // ──────────────────────────────────────────────
20
+
21
+ /** Pre-render safety net — truncate lines before they reach render(). Reduced from 200 to 160. */
22
+ const SAFE_LINE_WIDTH = 160;
23
+ function safeLine(line: string): string {
24
+ return line.length > SAFE_LINE_WIDTH ? line.slice(0, SAFE_LINE_WIDTH - 3) + '...' : line;
25
+ }
26
+
27
+ /**
28
+ * Truncate a string (possibly containing ANSI escape codes) to fit within
29
+ * a given visible width. Handles escape sequences properly so colours are
30
+ * never broken — a RESET is appended when truncation occurs.
31
+ */
32
+ export function truncateToWidth(line: string, maxWidth: number): string {
33
+ if (maxWidth <= 0) return '';
34
+ const normalized = line.replace(/\t/g, ' ');
35
+ // Fast path: no ANSI codes and short enough
36
+ if (normalized.length <= maxWidth && !normalized.includes('\x1b')) return normalized;
37
+
38
+ // Strip ANSI to measure visible length
39
+ // eslint-disable-next-line no-control-regex
40
+ const visible = normalized.replace(/\x1b\[[0-9;]*m/g, '');
41
+ if (visible.length <= maxWidth) return normalized;
42
+
43
+ // Need to truncate — walk through respecting ANSI escape sequences
44
+ let vis = 0;
45
+ let i = 0;
46
+ const target = Math.max(0, maxWidth - 3); // room for "..."
47
+ while (i < normalized.length && vis < target) {
48
+ if (normalized[i] === '\x1b') {
49
+ // Skip entire ANSI escape sequence
50
+ const end = normalized.indexOf('m', i);
51
+ if (end !== -1) {
52
+ i = end + 1;
53
+ } else {
54
+ i++;
55
+ }
56
+ } else {
57
+ vis++;
58
+ i++;
59
+ }
60
+ }
61
+ return normalized.slice(0, i) + '\x1b[0m...';
62
+ }
63
+
64
+ // ──────────────────────────────────────────────
65
+ // Lightweight text component used by simpler renderers.
66
+ // Complex renderers (task, parallel_tasks) use Box/Text/Container from pi-tui.
67
+ // ──────────────────────────────────────────────
68
+
69
+ export class SimpleText {
70
+ private text: string;
71
+
72
+ constructor(text: string) {
73
+ this.text = text;
74
+ }
75
+
76
+ render(width: number): string[] {
77
+ return this.text.split('\n').map((line) => truncateToWidth(line, width));
78
+ }
79
+
80
+ invalidate(): void {
81
+ // no-op — we don't cache
82
+ }
83
+ }
84
+
85
+ // ──────────────────────────────────────────────
86
+ // Types matching Pi's ToolDefinition.renderCall / renderResult
87
+ // ──────────────────────────────────────────────
88
+
89
+ type RenderCallFn = (args: Record<string, unknown>, theme: Theme) => Component;
90
+ type RenderResultFn = (
91
+ result: AgentToolResult<unknown>,
92
+ options: ToolRenderResultOptions,
93
+ theme: Theme
94
+ ) => Component;
95
+
96
+ export interface ToolRenderers {
97
+ renderCall?: RenderCallFn;
98
+ renderResult?: RenderResultFn;
99
+ }
100
+
101
+ // ──────────────────────────────────────────────
102
+ // Helpers
103
+ // ──────────────────────────────────────────────
104
+
105
+ /** Extract plain-text from a tool result's content array. */
106
+ function resultText(result: AgentToolResult<unknown>): string {
107
+ return result.content
108
+ .filter((c) => 'text' in c && typeof c.text === 'string')
109
+ .map((c) => ('text' in c ? (c as { text: string }).text : ''))
110
+ .join('\n');
111
+ }
112
+
113
+ /** Attempt to parse result text as JSON, returning undefined on failure. */
114
+ function tryParseJson(text: string): unknown | undefined {
115
+ try {
116
+ return JSON.parse(text);
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ }
121
+
122
+ /** Truncate a string to a max length, appending '\u2026' when truncated. */
123
+ function truncate(str: string, max: number): string {
124
+ if (str.length <= max) return str;
125
+ return str.slice(0, max - 1) + '\u2026';
126
+ }
127
+
128
+ // ──────────────────────────────────────────────
129
+ // Individual tool renderers
130
+ // ──────────────────────────────────────────────
131
+
132
+ function memorySearchRenderers(): ToolRenderers {
133
+ return {
134
+ renderCall(args, theme) {
135
+ const query = String(args['query'] ?? '');
136
+ const limit = args['limit'] as number | undefined;
137
+ let text = theme.fg('toolTitle', theme.bold('memory search '));
138
+ text += theme.fg('accent', truncate(query, 60));
139
+ if (limit) text += theme.fg('muted', ` (limit ${limit})`);
140
+ return new SimpleText(text);
141
+ },
142
+ renderResult(result, { expanded, isPartial }, theme) {
143
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Searching\u2026'));
144
+ const raw = resultText(result);
145
+ const parsed = tryParseJson(raw);
146
+ const items = Array.isArray(parsed) ? parsed : [];
147
+ let text = theme.fg('success', `${items.length} result${items.length !== 1 ? 's' : ''}`);
148
+ if (expanded && items.length > 0) {
149
+ const lines = items.slice(0, 10).map((item: Record<string, unknown>) => {
150
+ const key = truncate(String(item['key'] ?? item['id'] ?? '?'), 120);
151
+ const score =
152
+ typeof item['score'] === 'number'
153
+ ? ` (${(item['score'] as number).toFixed(2)})`
154
+ : '';
155
+ return ` ${theme.fg('accent', key)}${theme.fg('muted', score)}`;
156
+ });
157
+ text += '\n' + lines.join('\n');
158
+ if (items.length > 10)
159
+ text += theme.fg('muted', `\n \u2026and ${items.length - 10} more`);
160
+ }
161
+ return new SimpleText(text);
162
+ },
163
+ };
164
+ }
165
+
166
+ function memoryStoreRenderers(): ToolRenderers {
167
+ return {
168
+ renderCall(args, theme) {
169
+ const key = String(args['key'] ?? '');
170
+ let text = theme.fg('toolTitle', theme.bold('memory store '));
171
+ text += theme.fg('accent', truncate(key, 60));
172
+ return new SimpleText(text);
173
+ },
174
+ renderResult(_result, { isPartial }, theme) {
175
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Storing\u2026'));
176
+ return new SimpleText(theme.fg('success', 'Stored'));
177
+ },
178
+ };
179
+ }
180
+
181
+ function memoryGetRenderers(): ToolRenderers {
182
+ return {
183
+ renderCall(args, theme) {
184
+ const key = String(args['key'] ?? '');
185
+ let text = theme.fg('toolTitle', theme.bold('memory get '));
186
+ text += theme.fg('accent', truncate(key, 60));
187
+ return new SimpleText(text);
188
+ },
189
+ renderResult(result, { expanded, isPartial }, theme) {
190
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Loading\u2026'));
191
+ const raw = resultText(result);
192
+ const parsed = tryParseJson(raw);
193
+ if (!parsed) {
194
+ return new SimpleText(theme.fg('muted', raw ? 'Retrieved' : 'Not found'));
195
+ }
196
+ let text = theme.fg('success', 'Retrieved');
197
+ if (expanded) {
198
+ const preview =
199
+ typeof parsed === 'object'
200
+ ? JSON.stringify(parsed, null, 2)
201
+ .split('\n')
202
+ .slice(0, 10)
203
+ .map(safeLine)
204
+ .join('\n')
205
+ : String(parsed);
206
+ text += '\n' + theme.fg('toolOutput', truncate(preview, 500));
207
+ }
208
+ return new SimpleText(text);
209
+ },
210
+ };
211
+ }
212
+
213
+ function memoryUpdateRenderers(): ToolRenderers {
214
+ return {
215
+ renderCall(args, theme) {
216
+ const key = String(args['key'] ?? '');
217
+ let text = theme.fg('toolTitle', theme.bold('memory update '));
218
+ text += theme.fg('accent', truncate(key, 60));
219
+ return new SimpleText(text);
220
+ },
221
+ renderResult(_result, { isPartial }, theme) {
222
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Updating\u2026'));
223
+ return new SimpleText(theme.fg('success', 'Updated'));
224
+ },
225
+ };
226
+ }
227
+
228
+ function memoryDeleteRenderers(): ToolRenderers {
229
+ return {
230
+ renderCall(args, theme) {
231
+ const key = String(args['key'] ?? '');
232
+ let text = theme.fg('toolTitle', theme.bold('memory delete '));
233
+ text += theme.fg('accent', truncate(key, 60));
234
+ return new SimpleText(text);
235
+ },
236
+ renderResult(_result, { isPartial }, theme) {
237
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Deleting\u2026'));
238
+ return new SimpleText(theme.fg('muted', 'Deleted'));
239
+ },
240
+ };
241
+ }
242
+
243
+ function memoryListRenderers(): ToolRenderers {
244
+ return {
245
+ renderCall(args, theme) {
246
+ const namespace = String(args['namespace'] ?? '');
247
+ const prefix = args['prefix'] as string | undefined;
248
+ let text = theme.fg('toolTitle', theme.bold('memory list'));
249
+ if (namespace) text += theme.fg('accent', ` ${truncate(namespace, 30)}`);
250
+ if (prefix) text += theme.fg('accent', ` ${truncate(prefix, 40)}`);
251
+ return new SimpleText(text);
252
+ },
253
+ renderResult(result, { expanded, isPartial }, theme) {
254
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Listing\u2026'));
255
+ const raw = resultText(result);
256
+ const parsed = tryParseJson(raw);
257
+ const keys = Array.isArray(parsed)
258
+ ? parsed
259
+ : parsed &&
260
+ typeof parsed === 'object' &&
261
+ Array.isArray((parsed as Record<string, unknown>)['keys'])
262
+ ? ((parsed as Record<string, unknown>)['keys'] as unknown[])
263
+ : [];
264
+ let text = theme.fg('success', `${keys.length} key${keys.length !== 1 ? 's' : ''}`);
265
+ if (expanded && keys.length > 0) {
266
+ const lines = keys
267
+ .slice(0, 15)
268
+ .map((k: unknown) => ` ${theme.fg('accent', truncate(String(k), 120))}`);
269
+ text += '\n' + lines.join('\n');
270
+ if (keys.length > 15)
271
+ text += theme.fg('muted', `\n \u2026and ${keys.length - 15} more`);
272
+ }
273
+ return new SimpleText(text);
274
+ },
275
+ };
276
+ }
277
+
278
+ function context7SearchRenderers(): ToolRenderers {
279
+ return {
280
+ renderCall(args, theme) {
281
+ const library = String(args['libraryId'] ?? args['library'] ?? '');
282
+ const query = String(args['query'] ?? '');
283
+ let text = theme.fg('toolTitle', theme.bold('context7 '));
284
+ if (library) text += theme.fg('accent', truncate(library, 30) + ' \u2014 ');
285
+ text += theme.fg('text', truncate(query, 50));
286
+ return new SimpleText(text);
287
+ },
288
+ renderResult(result, { expanded, isPartial }, theme) {
289
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Searching docs\u2026'));
290
+ const raw = resultText(result);
291
+ const parsed = tryParseJson(raw);
292
+ const snippets = Array.isArray(parsed)
293
+ ? parsed
294
+ : parsed &&
295
+ typeof parsed === 'object' &&
296
+ Array.isArray((parsed as Record<string, unknown>)['snippets'])
297
+ ? ((parsed as Record<string, unknown>)['snippets'] as unknown[])
298
+ : [];
299
+ const count = snippets.length || (raw.length > 0 ? '?' : '0');
300
+ let text = theme.fg('success', `${count} snippet${count !== 1 ? 's' : ''}`);
301
+ if (expanded && snippets.length > 0) {
302
+ const lines = snippets.slice(0, 5).map((s: unknown) => {
303
+ const snip = s as Record<string, unknown>;
304
+ const title = String(snip['title'] ?? snip['name'] ?? '');
305
+ return ` ${theme.fg('accent', truncate(title, 80))}`;
306
+ });
307
+ text += '\n' + lines.join('\n');
308
+ if (snippets.length > 5)
309
+ text += theme.fg('muted', `\n \u2026and ${snippets.length - 5} more`);
310
+ }
311
+ return new SimpleText(text);
312
+ },
313
+ };
314
+ }
315
+
316
+ function grepAppSearchRenderers(): ToolRenderers {
317
+ return {
318
+ renderCall(args, theme) {
319
+ const query = String(args['query'] ?? '');
320
+ const lang = args['language'] as string[] | string | undefined;
321
+ let text = theme.fg('toolTitle', theme.bold('grep.app '));
322
+ text += theme.fg('accent', truncate(query, 50));
323
+ if (lang) {
324
+ const langStr = Array.isArray(lang) ? lang.join(', ') : String(lang);
325
+ text += theme.fg('muted', ` [${langStr}]`);
326
+ }
327
+ return new SimpleText(text);
328
+ },
329
+ renderResult(result, { expanded, isPartial }, theme) {
330
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Searching GitHub\u2026'));
331
+ const raw = resultText(result);
332
+ const parsed = tryParseJson(raw);
333
+ const matches = Array.isArray(parsed)
334
+ ? parsed
335
+ : parsed &&
336
+ typeof parsed === 'object' &&
337
+ Array.isArray((parsed as Record<string, unknown>)['results'])
338
+ ? ((parsed as Record<string, unknown>)['results'] as unknown[])
339
+ : [];
340
+ const count = matches.length || (raw.length > 0 ? '?' : '0');
341
+ let text = theme.fg('success', `${count} match${count !== 1 ? 'es' : ''}`);
342
+ if (expanded && matches.length > 0) {
343
+ const lines = matches.slice(0, 8).map((m: unknown) => {
344
+ const match = m as Record<string, unknown>;
345
+ const path = String(match['path'] ?? match['file'] ?? match['repo'] ?? '');
346
+ return ` ${theme.fg('accent', truncate(path, 80))}`;
347
+ });
348
+ text += '\n' + lines.join('\n');
349
+ if (matches.length > 8)
350
+ text += theme.fg('muted', `\n \u2026and ${matches.length - 8} more`);
351
+ }
352
+ return new SimpleText(text);
353
+ },
354
+ };
355
+ }
356
+
357
+ function sessionDashboardRenderers(): ToolRenderers {
358
+ return {
359
+ renderCall(_args, theme) {
360
+ return new SimpleText(theme.fg('toolTitle', theme.bold('session dashboard')));
361
+ },
362
+ renderResult(result, { isPartial }, theme) {
363
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Loading dashboard\u2026'));
364
+ const raw = resultText(result);
365
+ const summary = truncate(raw.replace(/\n/g, ' '), 80);
366
+ return new SimpleText(theme.fg('toolOutput', summary || 'OK'));
367
+ },
368
+ };
369
+ }
370
+
371
+ function sessionTodoCreateRenderers(): ToolRenderers {
372
+ return {
373
+ renderCall(args, theme) {
374
+ const title = String(args['title'] ?? '');
375
+ let text = theme.fg('toolTitle', theme.bold('session todo create '));
376
+ text += theme.fg('accent', truncate(title, 60));
377
+ return new SimpleText(text);
378
+ },
379
+ renderResult(result, { expanded, isPartial }, theme) {
380
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Creating todo\u2026'));
381
+ const raw = resultText(result);
382
+ const parsed = tryParseJson(raw) as Record<string, unknown> | undefined;
383
+ const task =
384
+ parsed && typeof parsed === 'object'
385
+ ? (parsed['task'] as Record<string, unknown> | undefined)
386
+ : undefined;
387
+ if (!task)
388
+ return new SimpleText(theme.fg('toolOutput', truncate(raw.replace(/\n/g, ' '), 100)));
389
+ const id = String(task['id'] ?? '');
390
+ const status = String(task['status'] ?? 'open');
391
+ const priority = String(task['priority'] ?? 'none');
392
+ const title = String(task['title'] ?? '').trim();
393
+ const display = title.length > 0 ? truncate(title, 72) : truncate(id, 28);
394
+ let text = theme.fg('success', `${status} ${display}`);
395
+ text += theme.fg('dim', ` (${truncate(id, 20)} prio:${priority})`);
396
+ if (expanded && title.length > 0) {
397
+ text += '\n' + theme.fg('accent', truncate(title, 120));
398
+ }
399
+ return new SimpleText(text);
400
+ },
401
+ };
402
+ }
403
+
404
+ function sessionTodoUpdateRenderers(): ToolRenderers {
405
+ return {
406
+ renderCall(args, theme) {
407
+ const id = String(args['id'] ?? '');
408
+ const nextStatus =
409
+ typeof args['status'] === 'string' ? ` -> ${String(args['status'])}` : '';
410
+ return new SimpleText(
411
+ theme.fg('toolTitle', theme.bold('session todo update ')) +
412
+ theme.fg('accent', truncate(id, 24)) +
413
+ theme.fg('dim', nextStatus)
414
+ );
415
+ },
416
+ renderResult(result, { isPartial }, theme) {
417
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Updating todo\u2026'));
418
+ const raw = resultText(result);
419
+ const parsed = tryParseJson(raw) as Record<string, unknown> | undefined;
420
+ const task =
421
+ parsed && typeof parsed === 'object'
422
+ ? (parsed['task'] as Record<string, unknown> | undefined)
423
+ : undefined;
424
+ if (!task)
425
+ return new SimpleText(theme.fg('toolOutput', truncate(raw.replace(/\n/g, ' '), 100)));
426
+ const id = String(task['id'] ?? '');
427
+ const status = String(task['status'] ?? 'open');
428
+ const title = String(task['title'] ?? '').trim();
429
+ const display = title.length > 0 ? truncate(title, 72) : truncate(id, 28);
430
+ return new SimpleText(
431
+ theme.fg('success', `${status} ${display}`) + theme.fg('dim', ` (${truncate(id, 20)})`)
432
+ );
433
+ },
434
+ };
435
+ }
436
+
437
+ function sessionTodoListRenderers(): ToolRenderers {
438
+ return {
439
+ renderCall(args, theme) {
440
+ const status = args['status'] ? ` status:${String(args['status'])}` : '';
441
+ const assignee = args['assignee'] ? ` owner:${String(args['assignee'])}` : '';
442
+ const scope = args['scope'] ? ` scope:${String(args['scope'])}` : '';
443
+ return new SimpleText(
444
+ theme.fg('toolTitle', theme.bold('session todos')) +
445
+ theme.fg('dim', `${scope}${status}${assignee}`)
446
+ );
447
+ },
448
+ renderResult(result, { expanded, isPartial }, theme) {
449
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Loading todos\u2026'));
450
+ const raw = resultText(result);
451
+ const parsed = tryParseJson(raw) as Record<string, unknown> | undefined;
452
+ if (!parsed || typeof parsed !== 'object') {
453
+ return new SimpleText(theme.fg('toolOutput', truncate(raw.replace(/\n/g, ' '), 120)));
454
+ }
455
+
456
+ const count = typeof parsed['count'] === 'number' ? parsed['count'] : 0;
457
+ const summary =
458
+ parsed['summary'] && typeof parsed['summary'] === 'object'
459
+ ? (parsed['summary'] as Record<string, unknown>)
460
+ : {};
461
+ const todos = Array.isArray(parsed['todos'])
462
+ ? (parsed['todos'] as Array<Record<string, unknown>>)
463
+ : [];
464
+
465
+ let text = theme.fg('success', `${count} todo${count === 1 ? '' : 's'}`);
466
+ text += theme.fg(
467
+ 'dim',
468
+ ` o:${Number(summary['open'] ?? 0)} ip:${Number(summary['in_progress'] ?? 0)} d:${Number(summary['done'] ?? 0)} c:${Number(summary['closed'] ?? 0)} x:${Number(summary['cancelled'] ?? 0)}`
469
+ );
470
+
471
+ if (expanded && todos.length > 0) {
472
+ const lines = todos.slice(0, 20).map((todo) => {
473
+ const status = String(todo['status'] ?? 'open');
474
+ const marker =
475
+ status === 'done'
476
+ ? theme.fg('success', '✓')
477
+ : status === 'in_progress'
478
+ ? theme.fg('accent', '●')
479
+ : status === 'cancelled' || status === 'closed'
480
+ ? theme.fg('error', 'x')
481
+ : theme.fg('warning', '○');
482
+ const id = truncate(String(todo['id'] ?? ''), 18);
483
+ const title = truncate(String(todo['title'] ?? ''), 72);
484
+ const owner =
485
+ typeof todo['assignee'] === 'string' && (todo['assignee'] as string).length > 0
486
+ ? ` @${todo['assignee']}`
487
+ : '';
488
+ return ` ${marker} ${theme.fg('dim', id)} ${title}${theme.fg('muted', owner)}`;
489
+ });
490
+ text += '\n' + lines.join('\n');
491
+ if (todos.length > 20)
492
+ text += theme.fg('muted', `\n \u2026and ${todos.length - 20} more`);
493
+ }
494
+ return new SimpleText(text);
495
+ },
496
+ };
497
+ }
498
+
499
+ function sessionTodoCommentRenderers(): ToolRenderers {
500
+ return {
501
+ renderCall(args, theme) {
502
+ const id = String(args['id'] ?? '');
503
+ return new SimpleText(
504
+ theme.fg('toolTitle', theme.bold('session todo comment ')) +
505
+ theme.fg('accent', truncate(id, 32))
506
+ );
507
+ },
508
+ renderResult(result, { isPartial }, theme) {
509
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Adding comment\u2026'));
510
+ const raw = resultText(result);
511
+ const parsed = tryParseJson(raw) as Record<string, unknown> | undefined;
512
+ if (parsed && parsed['commented'] === true) {
513
+ const taskId =
514
+ typeof parsed['taskId'] === 'string' ? truncate(parsed['taskId'], 20) : '';
515
+ return new SimpleText(
516
+ theme.fg('success', `Comment saved${taskId ? ` (${taskId})` : ''}`)
517
+ );
518
+ }
519
+ return new SimpleText(theme.fg('toolOutput', truncate(raw.replace(/\n/g, ' '), 100)));
520
+ },
521
+ };
522
+ }
523
+
524
+ function sessionTodoAttachRenderers(): ToolRenderers {
525
+ return {
526
+ renderCall(args, theme) {
527
+ const id = String(args['id'] ?? '');
528
+ const name = String(args['name'] ?? '');
529
+ let text = theme.fg('toolTitle', theme.bold('session todo attach '));
530
+ text += theme.fg('accent', truncate(id, 24));
531
+ if (name) text += theme.fg('dim', ` ${truncate(name, 40)}`);
532
+ return new SimpleText(text);
533
+ },
534
+ renderResult(result, { isPartial }, theme) {
535
+ if (isPartial) return new SimpleText(theme.fg('warning', 'Attaching\u2026'));
536
+ const raw = resultText(result);
537
+ const parsed = tryParseJson(raw) as Record<string, unknown> | undefined;
538
+ if (!parsed || typeof parsed !== 'object') {
539
+ return new SimpleText(theme.fg('toolOutput', truncate(raw.replace(/\n/g, ' '), 100)));
540
+ }
541
+ const count =
542
+ typeof parsed['attachmentCount'] === 'number' ? parsed['attachmentCount'] : undefined;
543
+ const task =
544
+ parsed['task'] && typeof parsed['task'] === 'object'
545
+ ? (parsed['task'] as Record<string, unknown>)
546
+ : undefined;
547
+ const title = typeof task?.['title'] === 'string' ? truncate(task['title'], 56) : '';
548
+ const id = typeof task?.['id'] === 'string' ? truncate(task['id'], 20) : '';
549
+ const text =
550
+ count !== undefined ? `Attachment saved (${count} total)` : 'Attachment saved';
551
+ const suffix = title ? ` ${title}${id ? ` (${id})` : ''}` : id ? ` (${id})` : '';
552
+ return new SimpleText(theme.fg('success', `${text}${suffix}`));
553
+ },
554
+ };
555
+ }
556
+
557
+ // ──────────────────────────────────────────────
558
+ // Registry
559
+ function taskRenderers(): ToolRenderers {
560
+ return {
561
+ renderCall(args, theme) {
562
+ const agent = String(args['subagent_type'] ?? '?');
563
+ const desc = String(args['description'] ?? '');
564
+ let text = theme.fg('accent', safeLine(agent));
565
+ if (desc) text += theme.fg('dim', ` — ${truncate(desc, 60)}`);
566
+ return new Text(text, 0, 0);
567
+ },
568
+ renderResult(result, { expanded, isPartial }, theme) {
569
+ if (isPartial) return new Text(theme.fg('warning', 'running...'), 0, 0);
570
+ const raw = resultText(result);
571
+ const lineCount = raw.split('\n').length;
572
+
573
+ // Detect agent failure — result starts with "Agent X failed:"
574
+ const isError = raw.startsWith('Agent ') && raw.includes('failed:');
575
+ if (isError) {
576
+ const bgFn = (t: string) => theme.bg('toolErrorBg', t);
577
+ const box = new Box(1, 0, bgFn);
578
+ let errorContent = theme.fg('error', 'failed');
579
+ if (expanded) {
580
+ errorContent +=
581
+ '\n' + theme.fg('error', raw.split('\n').slice(0, 10).map(safeLine).join('\n'));
582
+ } else {
583
+ // Show first line of error in collapsed view
584
+ const firstLine = raw.split('\n')[0] || '';
585
+ errorContent += theme.fg('dim', ' ' + firstLine.slice(0, 80));
586
+ errorContent += theme.fg('muted', ' ctrl+h ctrl+shift+v|alt+shift+v');
587
+ }
588
+ box.addChild(new Text(errorContent, 0, 0));
589
+ return box;
590
+ }
591
+
592
+ // Try to extract token stats from the appended footer
593
+ // Pattern: _agent: Xms | Y in Z out tokens | $cost_
594
+ const statsMatch = raw.match(
595
+ /_(\w+): (\d+)ms \| (\d+) in (\d+) out tokens \| \$([0-9.]+)_/
596
+ );
597
+
598
+ let text = theme.fg('success', 'done');
599
+ if (statsMatch) {
600
+ const [, , durationMs, tokIn, tokOut, cost] = statsMatch;
601
+ const duration =
602
+ Number(durationMs) >= 1000
603
+ ? `${(Number(durationMs) / 1000).toFixed(1)}s`
604
+ : `${durationMs}ms`;
605
+ text += theme.fg('dim', ` ${duration} \u2191${tokIn} \u2193${tokOut} $${cost}`);
606
+ } else {
607
+ text += theme.fg('dim', ` (${lineCount} lines)`);
608
+ }
609
+
610
+ if (!expanded) {
611
+ text += theme.fg('muted', ' ctrl+h ctrl+shift+v|alt+shift+v');
612
+ }
613
+ if (expanded) {
614
+ const preview = raw.split('\n').slice(0, 20).map(safeLine).join('\n');
615
+ text += '\n' + theme.fg('dim', preview);
616
+ if (lineCount > 20) text += theme.fg('muted', '\n...more ctrl+shift+v|alt+shift+v');
617
+ }
618
+ return new Text(text, 0, 0);
619
+ },
620
+ };
621
+ }
622
+
623
+ function parallelTasksRenderers(): ToolRenderers {
624
+ return {
625
+ renderCall(args, theme) {
626
+ const tasks = (args['tasks'] as Array<Record<string, unknown>>) ?? [];
627
+ const agents = tasks.map((t) => String(t['subagent_type'] ?? '?'));
628
+ const text = theme.fg('accent', agents.join(' + '));
629
+ return new Text(safeLine(text), 0, 0);
630
+ },
631
+ renderResult(result, { expanded, isPartial }, theme) {
632
+ if (isPartial) return new Text(theme.fg('warning', 'running...'), 0, 0);
633
+ const raw = resultText(result);
634
+
635
+ // Parse agent names and statuses from ### headers in the raw output
636
+ // Format: "### agent_name (Xms)" for success, "### agent_name (FAILED)" for failure
637
+ const agentEntries: Array<{ name: string; failed: boolean }> = [];
638
+ const headerPattern = /^### (\S+) \((?:FAILED|(\d+)ms)\)/gm;
639
+ let match: RegExpExecArray | null = headerPattern.exec(raw);
640
+ while (match !== null) {
641
+ agentEntries.push({ name: match[1] ?? '?', failed: match[2] === undefined });
642
+ match = headerPattern.exec(raw);
643
+ }
644
+
645
+ // Build chain visualization with status icons
646
+ // ✓ = success (green), ✗ = failed (red)
647
+ const chain = agentEntries
648
+ .map(({ name, failed }) => {
649
+ const icon = failed
650
+ ? theme.fg('error', '\u2717') // ✗
651
+ : theme.fg('success', '\u2713'); // ✓
652
+ const nameColor = failed ? 'error' : 'dim';
653
+ return `${icon} ${theme.fg(nameColor as 'dim' | 'error', name)}`;
654
+ })
655
+ .join(' ');
656
+
657
+ const lineCount = raw.split('\n').length;
658
+ const hasFailures = agentEntries.some((e) => e.failed);
659
+
660
+ // Build summary header
661
+ let summaryText = chain;
662
+ summaryText +=
663
+ '\n' +
664
+ theme.fg(
665
+ hasFailures ? 'error' : 'success',
666
+ hasFailures ? 'done (with failures)' : 'done'
667
+ );
668
+ summaryText += theme.fg('dim', ` (${lineCount} lines)`);
669
+
670
+ if (!expanded) {
671
+ summaryText += theme.fg('muted', ' ctrl+h ctrl+shift+v|alt+shift+v');
672
+ return new Text(summaryText, 0, 0);
673
+ }
674
+
675
+ // Expanded view: use Container to group summary + agent sections
676
+ const container = new Container();
677
+ container.addChild(new Text(summaryText, 0, 0));
678
+
679
+ // Split by ### headers to show each agent section separately
680
+ const sections = raw.split(/(?=^### )/m);
681
+ for (const section of sections) {
682
+ const trimmed = section.trim();
683
+ if (!trimmed) continue;
684
+ const isFailed = trimmed.includes('(FAILED)');
685
+ const lines = trimmed.split('\n');
686
+ const preview = lines.slice(0, 15).map(safeLine).join('\n');
687
+ let sectionContent = preview;
688
+ if (lines.length > 15) {
689
+ sectionContent +=
690
+ '\n' +
691
+ theme.fg(
692
+ 'muted',
693
+ ` ...${lines.length - 15} more lines ctrl+shift+v|alt+shift+v`
694
+ );
695
+ }
696
+
697
+ if (isFailed) {
698
+ // Failed sections get error background via Box
699
+ const box = new Box(1, 0, (t: string) => theme.bg('toolErrorBg', t));
700
+ box.addChild(new Text(theme.fg('error', sectionContent), 0, 0));
701
+ container.addChild(box);
702
+ } else {
703
+ container.addChild(new Text(theme.fg('dim', sectionContent), 0, 0));
704
+ }
705
+ }
706
+
707
+ return container;
708
+ },
709
+ };
710
+ }
711
+
712
+ // ──────────────────────────────────────────────
713
+
714
+ const RENDERERS: Record<string, () => ToolRenderers> = {
715
+ memory_service_search: memorySearchRenderers,
716
+ memory_service_store: memoryStoreRenderers,
717
+ memory_service_get: memoryGetRenderers,
718
+ memory_service_update: memoryUpdateRenderers,
719
+ memory_service_delete: memoryDeleteRenderers,
720
+ memory_service_list: memoryListRenderers,
721
+ context7_search: context7SearchRenderers,
722
+ grep_app_search: grepAppSearchRenderers,
723
+ session_dashboard: sessionDashboardRenderers,
724
+ session_todo_create: sessionTodoCreateRenderers,
725
+ session_todo_update: sessionTodoUpdateRenderers,
726
+ session_todo_list: sessionTodoListRenderers,
727
+ session_todo_comment: sessionTodoCommentRenderers,
728
+ session_todo_attach: sessionTodoAttachRenderers,
729
+ task: taskRenderers,
730
+ parallel_tasks: parallelTasksRenderers,
731
+ };
732
+
733
+ /**
734
+ * Look up renderCall / renderResult functions for a Hub tool.
735
+ * Returns undefined for tools without custom rendering.
736
+ */
737
+ export function getToolRenderers(toolName: string): ToolRenderers | undefined {
738
+ const factory = RENDERERS[toolName];
739
+ return factory?.();
740
+ }