@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,2324 @@
1
+ import { type Theme, getMarkdownTheme } from '@mariozechner/pi-coding-agent';
2
+ import { matchesKey, Markdown as MdComponent } from '@mariozechner/pi-tui';
3
+ import { truncateToWidth } from './renderers.ts';
4
+
5
+ interface Component {
6
+ render(width: number): string[];
7
+ handleInput?(data: string): void;
8
+ invalidate(): void;
9
+ }
10
+
11
+ interface Focusable {
12
+ focused: boolean;
13
+ }
14
+
15
+ interface TUIRef {
16
+ requestRender(): void;
17
+ }
18
+
19
+ interface HubSessionSummary {
20
+ sessionId: string;
21
+ label?: string;
22
+ status: string;
23
+ mode: string;
24
+ observerCount: number;
25
+ subAgentCount: number;
26
+ taskCount: number;
27
+ participantCount: number;
28
+ createdAt: string;
29
+ }
30
+
31
+ interface HubParticipant {
32
+ id: string;
33
+ role: string;
34
+ transport?: string;
35
+ connectedAt?: string;
36
+ idle?: boolean;
37
+ }
38
+
39
+ interface HubTask {
40
+ taskId: string;
41
+ agent: string;
42
+ status: string;
43
+ prompt?: string;
44
+ duration?: number;
45
+ startedAt?: string;
46
+ completedAt?: string;
47
+ }
48
+
49
+ interface HubTodoSummary {
50
+ open?: number;
51
+ in_progress?: number;
52
+ done?: number;
53
+ closed?: number;
54
+ cancelled?: number;
55
+ }
56
+
57
+ interface HubTodo {
58
+ id: string;
59
+ title: string;
60
+ status: string;
61
+ type?: string;
62
+ priority?: string;
63
+ parentTaskId?: string | null;
64
+ assignee?: string | null;
65
+ origin?: string | null;
66
+ attachmentCount?: number;
67
+ }
68
+
69
+ type AgentActivity = Record<
70
+ string,
71
+ {
72
+ status?: string;
73
+ currentTool?: string;
74
+ toolCallCount?: number;
75
+ lastActivity?: string;
76
+ }
77
+ >;
78
+
79
+ interface HubSessionDetail {
80
+ sessionId: string;
81
+ label?: string;
82
+ status: string;
83
+ createdAt: string;
84
+ mode: string;
85
+ context?: {
86
+ branch?: string;
87
+ workingDirectory?: string;
88
+ };
89
+ participants?: HubParticipant[];
90
+ tasks?: HubTask[];
91
+ todos?: HubTodo[];
92
+ todoSummary?: HubTodoSummary;
93
+ todosUnavailable?: string;
94
+ agentActivity?: AgentActivity;
95
+ stream?: {
96
+ output?: string;
97
+ thinking?: string;
98
+ tasks?: Record<string, { output?: string; thinking?: string }>;
99
+ };
100
+ }
101
+
102
+ interface HubTodoListResponse {
103
+ ok?: boolean;
104
+ count?: number;
105
+ summary?: HubTodoSummary;
106
+ todos?: HubTodo[];
107
+ unavailable?: boolean;
108
+ message?: string;
109
+ }
110
+
111
+ interface HubListResponse {
112
+ sessions?: {
113
+ websocket?: HubSessionSummary[];
114
+ };
115
+ }
116
+
117
+ interface FeedEntry {
118
+ at: number;
119
+ sessionId?: string;
120
+ text: string;
121
+ }
122
+
123
+ interface SessionDigest {
124
+ status: string;
125
+ taskCount: number;
126
+ observerCount: number;
127
+ subAgentCount: number;
128
+ }
129
+
130
+ interface SessionStreamState {
131
+ controller: AbortController;
132
+ mode: 'summary' | 'full';
133
+ }
134
+
135
+ interface StreamBuffer {
136
+ output: string;
137
+ thinking: string;
138
+ }
139
+
140
+ interface HubOverlayOptions {
141
+ baseUrl: string;
142
+ currentSessionId?: string;
143
+ initialSessionId?: string;
144
+ startInDetail?: boolean;
145
+ done: (result: undefined) => void;
146
+ }
147
+
148
+ type ScreenMode = 'list' | 'detail' | 'feed' | 'task';
149
+
150
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
151
+ const POLL_MS = 4_000;
152
+ const REQUEST_TIMEOUT_MS = 5_000;
153
+ const MAX_FEED_ITEMS = 80;
154
+ const STREAM_SESSION_LIMIT = 8;
155
+
156
+ function visibleWidth(text: string): number {
157
+ return text.replace(ANSI_RE, '').replace(/\t/g, ' ').length;
158
+ }
159
+
160
+ function padRight(text: string, width: number): string {
161
+ if (width <= 0) return '';
162
+ const normalized = text.replace(/\t/g, ' ');
163
+ const truncated = truncateToWidth(normalized, width);
164
+ const remaining = width - visibleWidth(truncated);
165
+ return remaining > 0 ? truncated + ' '.repeat(remaining) : truncated;
166
+ }
167
+
168
+ function hLine(width: number): string {
169
+ return width > 0 ? '─'.repeat(width) : '';
170
+ }
171
+
172
+ function buildTopBorder(width: number, title: string): string {
173
+ if (width <= 0) return '';
174
+ if (width === 1) return '╭';
175
+ if (width === 2) return '╭╮';
176
+
177
+ const inner = width - 2;
178
+ const titleText = ` ${title} `;
179
+ if (titleText.length >= inner) return `╭${hLine(inner)}╮`;
180
+
181
+ const left = Math.floor((inner - titleText.length) / 2);
182
+ const right = inner - titleText.length - left;
183
+ return `╭${hLine(left)}${titleText}${hLine(right)}╮`;
184
+ }
185
+
186
+ function buildBottomBorder(width: number): string {
187
+ if (width <= 0) return '';
188
+ if (width === 1) return '╰';
189
+ if (width === 2) return '╰╯';
190
+ return `╰${hLine(width - 2)}╯`;
191
+ }
192
+
193
+ function formatClock(ms: number): string {
194
+ const d = new Date(ms);
195
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
196
+ }
197
+
198
+ function formatRelative(isoDate: string): string {
199
+ const ts = Date.parse(isoDate);
200
+ if (Number.isNaN(ts)) return '-';
201
+ const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000));
202
+ if (seconds < 60) return `${seconds}s ago`;
203
+ const minutes = Math.floor(seconds / 60);
204
+ if (minutes < 60) return `${minutes}m ago`;
205
+ const hours = Math.floor(minutes / 60);
206
+ if (hours < 24) return `${hours}h ago`;
207
+ const days = Math.floor(hours / 24);
208
+ return `${days}d ago`;
209
+ }
210
+
211
+ function shortId(id: string): string {
212
+ if (id.length <= 12) return id;
213
+ return id.slice(0, 12);
214
+ }
215
+
216
+ function getVisibleRange(total: number, selected: number, windowSize: number): [number, number] {
217
+ if (total <= windowSize) return [0, total];
218
+ const half = Math.floor(windowSize / 2);
219
+ let start = Math.max(0, selected - half);
220
+ let end = start + windowSize;
221
+ if (end > total) {
222
+ end = total;
223
+ start = end - windowSize;
224
+ }
225
+ return [start, end];
226
+ }
227
+
228
+ function wrapText(text: string, width: number): string[] {
229
+ if (width <= 0) return [''];
230
+ if (!text) return [''];
231
+
232
+ const lines: string[] = [];
233
+ const paragraphs = text.split(/\r?\n/);
234
+ for (const paragraph of paragraphs) {
235
+ const words = paragraph.split(/\s+/).filter(Boolean);
236
+ if (words.length === 0) {
237
+ lines.push('');
238
+ continue;
239
+ }
240
+
241
+ let current = words[0]!;
242
+ for (let i = 1; i < words.length; i++) {
243
+ const word = words[i]!;
244
+ const candidate = `${current} ${word}`;
245
+ if (candidate.length <= width) {
246
+ current = candidate;
247
+ } else {
248
+ lines.push(current);
249
+ current = word.length > width ? word.slice(0, width) : word;
250
+ }
251
+ }
252
+ lines.push(current);
253
+ }
254
+
255
+ return lines;
256
+ }
257
+
258
+ function toSingleLine(text: string): string {
259
+ return text
260
+ .replace(/[\r\n\t]+/g, ' ')
261
+ .replace(/[\x00-\x1f\x7f]/g, '')
262
+ .replace(/\s+/g, ' ')
263
+ .trim();
264
+ }
265
+
266
+ interface MessageSegments {
267
+ output: string;
268
+ thinking: string;
269
+ }
270
+
271
+ function summarizeArgs(value: unknown, maxWidth = 100): string {
272
+ if (!value || typeof value !== 'object') return '';
273
+ const args = value as Record<string, unknown>;
274
+ if (typeof args.command === 'string')
275
+ return truncateToWidth(toSingleLine(args.command), maxWidth);
276
+ if (typeof args.path === 'string') return truncateToWidth(toSingleLine(args.path), maxWidth);
277
+ if (typeof args.filePath === 'string')
278
+ return truncateToWidth(toSingleLine(args.filePath), maxWidth);
279
+ if (typeof args.pattern === 'string')
280
+ return truncateToWidth(toSingleLine(args.pattern), maxWidth);
281
+ try {
282
+ const raw = JSON.stringify(value);
283
+ return truncateToWidth(toSingleLine(raw), 100);
284
+ } catch {
285
+ return '';
286
+ }
287
+ }
288
+
289
+ function summarizeToolCall(name: string, argsRaw: unknown): string | null {
290
+ const args =
291
+ argsRaw && typeof argsRaw === 'object' ? (argsRaw as Record<string, unknown>) : undefined;
292
+
293
+ if (name === 'task') {
294
+ const agent = typeof args?.subagent_type === 'string' ? args.subagent_type : '?';
295
+ const description =
296
+ typeof args?.description === 'string'
297
+ ? truncateToWidth(toSingleLine(args.description), 80)
298
+ : '';
299
+ return description ? `${agent} - ${description}` : `${agent} - delegated task`;
300
+ }
301
+
302
+ if (name === 'parallel_tasks') {
303
+ const tasks = Array.isArray(args?.tasks) ? args.tasks : [];
304
+ const agents = tasks
305
+ .map((task) =>
306
+ task && typeof task === 'object'
307
+ ? (task as Record<string, unknown>).subagent_type
308
+ : undefined
309
+ )
310
+ .filter((agent): agent is string => typeof agent === 'string');
311
+ if (agents.length > 0) return agents.join(' + ');
312
+ return 'parallel delegated tasks';
313
+ }
314
+
315
+ return null;
316
+ }
317
+
318
+ function extractToolResultText(contentRaw: unknown): string {
319
+ if (typeof contentRaw === 'string') return contentRaw;
320
+ if (!Array.isArray(contentRaw)) return '';
321
+ const parts: string[] = [];
322
+ for (const item of contentRaw) {
323
+ if (!item || typeof item !== 'object') continue;
324
+ const block = item as Record<string, unknown>;
325
+ if (typeof block.text === 'string') {
326
+ parts.push(block.text);
327
+ }
328
+ }
329
+ return parts.join('\n');
330
+ }
331
+
332
+ function formatDuration(ms: number): string {
333
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
334
+ return `${ms}ms`;
335
+ }
336
+
337
+ function extractMessageSegments(data: Record<string, unknown> | undefined): MessageSegments {
338
+ const fallback = typeof data?.text === 'string' ? data.text : '';
339
+ const messageRaw = data?.message;
340
+ if (!messageRaw || typeof messageRaw !== 'object') {
341
+ return { output: fallback, thinking: '' };
342
+ }
343
+
344
+ const message = messageRaw as Record<string, unknown>;
345
+ const role = typeof message.role === 'string' ? message.role : '';
346
+ if (role && role !== 'assistant') {
347
+ return { output: '', thinking: '' };
348
+ }
349
+
350
+ const content = message.content;
351
+ const outputParts: string[] = [];
352
+ const thinkingParts: string[] = [];
353
+
354
+ if (typeof content === 'string') {
355
+ outputParts.push(content);
356
+ } else if (Array.isArray(content)) {
357
+ for (const blockRaw of content) {
358
+ if (!blockRaw || typeof blockRaw !== 'object') continue;
359
+ const block = blockRaw as Record<string, unknown>;
360
+ const type = typeof block.type === 'string' ? block.type : '';
361
+
362
+ if (type === 'text' && typeof block.text === 'string') {
363
+ outputParts.push(block.text);
364
+ continue;
365
+ }
366
+ if (type === 'thinking' && typeof block.thinking === 'string') {
367
+ thinkingParts.push(block.thinking);
368
+ continue;
369
+ }
370
+ }
371
+ }
372
+
373
+ if (outputParts.length === 0 && fallback) {
374
+ outputParts.push(fallback);
375
+ }
376
+
377
+ return {
378
+ output: outputParts.join('\n\n').trim(),
379
+ thinking: thinkingParts.join('\n\n').trim(),
380
+ };
381
+ }
382
+
383
+ export class HubOverlay implements Component, Focusable {
384
+ public focused = true;
385
+
386
+ private readonly tui: TUIRef;
387
+ private readonly theme: Theme;
388
+ private readonly done: (result: undefined) => void;
389
+ private readonly baseUrl: string;
390
+ private readonly currentSessionId?: string;
391
+
392
+ private screen: ScreenMode;
393
+ private selectedIndex = 0;
394
+ private detailSessionId: string | null;
395
+ private detailScrollOffset = 0;
396
+ private detailMaxScroll = 0;
397
+ private feedScrollOffset = 0;
398
+ private feedMaxScroll = 0;
399
+ private feedScope: 'global' | 'session' = 'global';
400
+ private feedViewMode: 'stream' | 'events' = 'stream';
401
+ private showFeedThinking = true;
402
+ private feedFollowing = true;
403
+ private taskScrollOffset = 0;
404
+ private taskMaxScroll = 0;
405
+ private selectedTaskIndex = 0;
406
+ private showTaskThinking = true;
407
+ private taskFollowing = true;
408
+
409
+ private sessions: HubSessionSummary[] = [];
410
+ private detail: HubSessionDetail | null = null;
411
+ private cachedTodos: { todos: any[]; summary: any; sessionId: string } | null = null;
412
+ private feed: FeedEntry[] = [];
413
+ private sessionFeed = new Map<string, FeedEntry[]>();
414
+ private sessionBuffers = new Map<string, StreamBuffer>();
415
+ private taskBuffers = new Map<string, StreamBuffer>();
416
+ private hydratedSessions = new Set<string>();
417
+ private previousDigests = new Map<string, SessionDigest>();
418
+
419
+ private loadingList = true;
420
+ private loadingDetail = false;
421
+ private listError = '';
422
+ private detailError = '';
423
+ private lastUpdatedAt = 0;
424
+ private listInFlight = false;
425
+ private detailInFlight = false;
426
+ private sseControllers = new Map<string, SessionStreamState>();
427
+
428
+ private disposed = false;
429
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
430
+ private mdRenderer: MdComponent | null = null;
431
+
432
+ constructor(tui: TUIRef, theme: Theme, options: HubOverlayOptions) {
433
+ this.tui = tui;
434
+ this.theme = theme;
435
+ this.done = options.done;
436
+ this.baseUrl = options.baseUrl;
437
+ this.currentSessionId = options.currentSessionId;
438
+ this.detailSessionId = options.initialSessionId ?? null;
439
+ this.screen = options.startInDetail && options.initialSessionId ? 'detail' : 'list';
440
+ try {
441
+ const mdTheme = getMarkdownTheme?.();
442
+ if (mdTheme) {
443
+ this.mdRenderer = new MdComponent('', 0, 0, mdTheme);
444
+ }
445
+ } catch {
446
+ this.mdRenderer = null;
447
+ }
448
+
449
+ void this.refreshList(true);
450
+ if (this.detailSessionId) {
451
+ void this.refreshDetail(this.detailSessionId, true);
452
+ }
453
+
454
+ this.pollTimer = setInterval(() => {
455
+ if (this.disposed) return;
456
+ void this.refreshList();
457
+ if (this.detailSessionId) {
458
+ void this.refreshDetail(this.detailSessionId);
459
+ }
460
+ }, POLL_MS);
461
+ }
462
+
463
+ handleInput(data: string): void {
464
+ if (this.disposed) return;
465
+
466
+ const inSessionContext = this.isSessionContext();
467
+
468
+ if (data === '1') {
469
+ this.screen = inSessionContext ? 'detail' : 'list';
470
+ void this.syncSseStreams(this.sessions);
471
+ this.requestRender();
472
+ return;
473
+ }
474
+
475
+ if (data === '2') {
476
+ // Session/task views use task drill-down, not direct feed jumps from task screen.
477
+ if (this.screen === 'task') return;
478
+
479
+ if (inSessionContext && this.detailSessionId) {
480
+ this.feedScope = 'session';
481
+ this.feedViewMode = 'stream';
482
+ } else {
483
+ this.feedScope = 'global';
484
+ this.feedViewMode = 'events';
485
+ }
486
+ this.feedScrollOffset = 0;
487
+ this.feedFollowing = true;
488
+ this.screen = 'feed';
489
+ void this.syncSseStreams(this.sessions);
490
+ this.requestRender();
491
+ return;
492
+ }
493
+
494
+ if (data === '3') {
495
+ const atSessionLevel =
496
+ !!this.detailSessionId &&
497
+ (this.screen === 'detail' || (this.screen === 'feed' && this.feedScope === 'session'));
498
+ if (!atSessionLevel) return;
499
+
500
+ this.feedScope = 'session';
501
+ this.feedViewMode = 'events';
502
+ this.feedScrollOffset = 0;
503
+ this.feedFollowing = true;
504
+ this.screen = 'feed';
505
+ void this.syncSseStreams(this.sessions);
506
+ this.requestRender();
507
+ return;
508
+ }
509
+
510
+ if (matchesKey(data, 'escape')) {
511
+ if (this.screen === 'task') {
512
+ this.screen = 'detail';
513
+ void this.syncSseStreams(this.sessions);
514
+ this.requestRender();
515
+ return;
516
+ }
517
+ if (this.screen === 'feed') {
518
+ this.screen = this.feedScope === 'session' ? 'detail' : 'list';
519
+ void this.syncSseStreams(this.sessions);
520
+ this.requestRender();
521
+ return;
522
+ }
523
+ if (this.screen === 'detail') {
524
+ this.screen = 'list';
525
+ void this.syncSseStreams(this.sessions);
526
+ this.requestRender();
527
+ return;
528
+ }
529
+ this.close();
530
+ return;
531
+ }
532
+
533
+ if (matchesKey(data, 'r') || data.toLowerCase() === 'r') {
534
+ void this.refreshList();
535
+ if (
536
+ (this.screen === 'detail' || this.screen === 'task' || this.screen === 'feed') &&
537
+ this.detailSessionId
538
+ ) {
539
+ void this.refreshDetail(this.detailSessionId);
540
+ }
541
+ return;
542
+ }
543
+
544
+ if (this.screen === 'list') {
545
+ this.handleListInput(data);
546
+ return;
547
+ }
548
+
549
+ if (this.screen === 'feed') {
550
+ this.handleFeedInput(data);
551
+ return;
552
+ }
553
+
554
+ if (this.screen === 'task') {
555
+ this.handleTaskInput(data);
556
+ return;
557
+ }
558
+
559
+ this.handleDetailInput(data);
560
+ }
561
+
562
+ render(width: number): string[] {
563
+ const safeWidth = Math.max(6, width);
564
+ const termHeight = process.stdout.rows || 40;
565
+ const maxLines = Math.max(12, Math.floor(termHeight * 0.95) - 2);
566
+
567
+ const lines =
568
+ this.screen === 'detail'
569
+ ? this.renderDetailScreen(safeWidth, maxLines)
570
+ : this.screen === 'feed'
571
+ ? this.renderFeedScreen(safeWidth, maxLines)
572
+ : this.screen === 'task'
573
+ ? this.renderTaskScreen(safeWidth, maxLines)
574
+ : this.renderListScreen(safeWidth, maxLines);
575
+ return lines.map((line) => truncateToWidth(line, safeWidth));
576
+ }
577
+
578
+ invalidate(): void {
579
+ this.requestRender();
580
+ }
581
+
582
+ dispose(): void {
583
+ if (this.disposed) return;
584
+ this.disposed = true;
585
+ if (this.pollTimer) {
586
+ clearInterval(this.pollTimer);
587
+ this.pollTimer = null;
588
+ }
589
+ for (const stream of this.sseControllers.values()) {
590
+ stream.controller.abort();
591
+ }
592
+ this.sseControllers.clear();
593
+ }
594
+
595
+ private requestRender(): void {
596
+ try {
597
+ this.tui.requestRender();
598
+ } catch {
599
+ // Best effort render invalidation.
600
+ }
601
+ }
602
+
603
+ private close(): void {
604
+ this.dispose();
605
+ this.done(undefined);
606
+ }
607
+
608
+ private handleListInput(data: string): void {
609
+ const count = this.sessions.length;
610
+
611
+ if (matchesKey(data, 'up') || data.toLowerCase() === 'k') {
612
+ if (count > 0) {
613
+ this.selectedIndex = (this.selectedIndex - 1 + count) % count;
614
+ this.requestRender();
615
+ }
616
+ return;
617
+ }
618
+
619
+ if (matchesKey(data, 'down') || data.toLowerCase() === 'j') {
620
+ if (count > 0) {
621
+ this.selectedIndex = (this.selectedIndex + 1) % count;
622
+ this.requestRender();
623
+ }
624
+ return;
625
+ }
626
+
627
+ if (matchesKey(data, 'enter')) {
628
+ const selected = this.sessions[this.selectedIndex];
629
+ if (!selected) return;
630
+ this.detailSessionId = selected.sessionId;
631
+ this.detailScrollOffset = 0;
632
+ this.selectedTaskIndex = 0;
633
+ this.showTaskThinking = true;
634
+ this.taskFollowing = true;
635
+ this.screen = 'detail';
636
+ void this.syncSseStreams(this.sessions);
637
+ void this.refreshDetail(selected.sessionId, true);
638
+ this.requestRender();
639
+ }
640
+ }
641
+
642
+ private isSessionContext(): boolean {
643
+ return (
644
+ this.screen === 'detail' ||
645
+ this.screen === 'task' ||
646
+ (this.screen === 'feed' && this.feedScope === 'session')
647
+ );
648
+ }
649
+
650
+ private handleDetailInput(data: string): void {
651
+ const tasks = this.getDetailTasks();
652
+
653
+ if (matchesKey(data, 'up')) {
654
+ if (tasks.length > 0) {
655
+ this.selectedTaskIndex = (this.selectedTaskIndex - 1 + tasks.length) % tasks.length;
656
+ this.requestRender();
657
+ }
658
+ return;
659
+ }
660
+
661
+ if (matchesKey(data, 'down')) {
662
+ if (tasks.length > 0) {
663
+ this.selectedTaskIndex = (this.selectedTaskIndex + 1) % tasks.length;
664
+ this.requestRender();
665
+ }
666
+ return;
667
+ }
668
+
669
+ if (matchesKey(data, 'enter')) {
670
+ if (tasks.length > 0) {
671
+ this.selectedTaskIndex = Math.min(this.selectedTaskIndex, tasks.length - 1);
672
+ this.taskScrollOffset = 0;
673
+ this.showTaskThinking = true;
674
+ this.taskFollowing = true;
675
+ this.screen = 'task';
676
+ this.requestRender();
677
+ }
678
+ return;
679
+ }
680
+
681
+ if (data.toLowerCase() === 'k') {
682
+ if (this.detailScrollOffset > 0) {
683
+ this.detailScrollOffset -= 1;
684
+ this.requestRender();
685
+ }
686
+ return;
687
+ }
688
+
689
+ if (data.toLowerCase() === 'j') {
690
+ if (this.detailScrollOffset < this.detailMaxScroll) {
691
+ this.detailScrollOffset += 1;
692
+ this.requestRender();
693
+ }
694
+ return;
695
+ }
696
+
697
+ if (matchesKey(data, 'pageUp') || matchesKey(data, 'shift+up')) {
698
+ const jump = Math.max(1, Math.floor((process.stdout.rows || 40) / 3));
699
+ this.detailScrollOffset = Math.max(0, this.detailScrollOffset - jump);
700
+ this.requestRender();
701
+ return;
702
+ }
703
+
704
+ if (matchesKey(data, 'pageDown') || matchesKey(data, 'shift+down')) {
705
+ const jump = Math.max(1, Math.floor((process.stdout.rows || 40) / 3));
706
+ this.detailScrollOffset = Math.min(this.detailMaxScroll, this.detailScrollOffset + jump);
707
+ this.requestRender();
708
+ }
709
+ }
710
+
711
+ private handleFeedInput(data: string): void {
712
+ const maxScroll = this.feedMaxScroll;
713
+ const jump = Math.max(1, Math.floor((process.stdout.rows || 40) / 3));
714
+
715
+ if (matchesKey(data, 't') || data.toLowerCase() === 't') {
716
+ if (this.feedScope === 'session' && this.feedViewMode === 'stream') {
717
+ this.showFeedThinking = !this.showFeedThinking;
718
+ this.requestRender();
719
+ }
720
+ return;
721
+ }
722
+
723
+ if (
724
+ this.feedScope === 'session' &&
725
+ this.feedViewMode === 'stream' &&
726
+ (matchesKey(data, 'enter') || matchesKey(data, 'v') || data.toLowerCase() === 'v')
727
+ ) {
728
+ const tasks = this.getDetailTasks();
729
+ if (tasks.length > 0) {
730
+ this.selectedTaskIndex = Math.min(this.selectedTaskIndex, tasks.length - 1);
731
+ this.taskScrollOffset = 0;
732
+ this.showTaskThinking = true;
733
+ this.taskFollowing = true;
734
+ this.screen = 'task';
735
+ this.requestRender();
736
+ }
737
+ return;
738
+ }
739
+
740
+ if (matchesKey(data, 'f') || data.toLowerCase() === 'f') {
741
+ this.feedFollowing = !this.feedFollowing;
742
+ if (this.feedFollowing) {
743
+ this.feedScrollOffset = maxScroll;
744
+ }
745
+ this.requestRender();
746
+ return;
747
+ }
748
+
749
+ if (matchesKey(data, 'up') || data.toLowerCase() === 'k') {
750
+ if (this.feedScrollOffset > 0) {
751
+ this.feedFollowing = false;
752
+ this.feedScrollOffset -= 1;
753
+ this.requestRender();
754
+ }
755
+ return;
756
+ }
757
+
758
+ if (matchesKey(data, 'down') || data.toLowerCase() === 'j') {
759
+ if (this.feedScrollOffset < this.feedMaxScroll) {
760
+ this.feedFollowing = false;
761
+ this.feedScrollOffset += 1;
762
+ this.requestRender();
763
+ }
764
+ return;
765
+ }
766
+
767
+ if (matchesKey(data, 'pageUp') || matchesKey(data, 'shift+up')) {
768
+ this.feedFollowing = false;
769
+ this.feedScrollOffset = Math.max(0, this.feedScrollOffset - jump);
770
+ this.requestRender();
771
+ return;
772
+ }
773
+
774
+ if (matchesKey(data, 'pageDown') || matchesKey(data, 'shift+down')) {
775
+ this.feedFollowing = false;
776
+ this.feedScrollOffset = Math.min(this.feedMaxScroll, this.feedScrollOffset + jump);
777
+ this.requestRender();
778
+ return;
779
+ }
780
+
781
+ if (data === 'g') {
782
+ this.feedFollowing = false;
783
+ this.feedScrollOffset = 0;
784
+ this.requestRender();
785
+ return;
786
+ }
787
+
788
+ if (data === 'G') {
789
+ this.feedFollowing = false;
790
+ this.feedScrollOffset = this.feedMaxScroll;
791
+ this.requestRender();
792
+ return;
793
+ }
794
+
795
+ if (data === '{') {
796
+ this.feedFollowing = false;
797
+ const segment = Math.max(1, Math.floor((this.feedMaxScroll || 1) * 0.25));
798
+ this.feedScrollOffset = Math.max(0, this.feedScrollOffset - segment);
799
+ this.requestRender();
800
+ return;
801
+ }
802
+
803
+ if (data === '}') {
804
+ this.feedFollowing = false;
805
+ const segment = Math.max(1, Math.floor((this.feedMaxScroll || 1) * 0.25));
806
+ this.feedScrollOffset = Math.min(this.feedMaxScroll, this.feedScrollOffset + segment);
807
+ this.requestRender();
808
+ return;
809
+ }
810
+ }
811
+
812
+ private handleTaskInput(data: string): void {
813
+ const tasks = this.getDetailTasks();
814
+ const maxScroll = this.taskMaxScroll;
815
+ const jump = Math.max(1, Math.floor((process.stdout.rows || 40) / 3));
816
+
817
+ if (matchesKey(data, 't') || data.toLowerCase() === 't') {
818
+ this.showTaskThinking = !this.showTaskThinking;
819
+ this.requestRender();
820
+ return;
821
+ }
822
+
823
+ if (matchesKey(data, 'f') || data.toLowerCase() === 'f') {
824
+ this.taskFollowing = !this.taskFollowing;
825
+ if (this.taskFollowing) {
826
+ this.taskScrollOffset = maxScroll;
827
+ }
828
+ this.requestRender();
829
+ return;
830
+ }
831
+
832
+ if (data === '[') {
833
+ if (tasks.length > 0) {
834
+ this.selectedTaskIndex = (this.selectedTaskIndex - 1 + tasks.length) % tasks.length;
835
+ this.taskScrollOffset = 0;
836
+ this.taskFollowing = true;
837
+ this.requestRender();
838
+ }
839
+ return;
840
+ }
841
+
842
+ if (data === ']') {
843
+ if (tasks.length > 0) {
844
+ this.selectedTaskIndex = (this.selectedTaskIndex + 1) % tasks.length;
845
+ this.taskScrollOffset = 0;
846
+ this.taskFollowing = true;
847
+ this.requestRender();
848
+ }
849
+ return;
850
+ }
851
+
852
+ if (matchesKey(data, 'up') || data.toLowerCase() === 'k') {
853
+ if (this.taskScrollOffset > 0) {
854
+ this.taskFollowing = false;
855
+ this.taskScrollOffset -= 1;
856
+ this.requestRender();
857
+ }
858
+ return;
859
+ }
860
+
861
+ if (matchesKey(data, 'down') || data.toLowerCase() === 'j') {
862
+ if (this.taskScrollOffset < this.taskMaxScroll) {
863
+ this.taskFollowing = false;
864
+ this.taskScrollOffset += 1;
865
+ this.requestRender();
866
+ }
867
+ return;
868
+ }
869
+
870
+ if (matchesKey(data, 'pageUp') || matchesKey(data, 'shift+up')) {
871
+ this.taskFollowing = false;
872
+ this.taskScrollOffset = Math.max(0, this.taskScrollOffset - jump);
873
+ this.requestRender();
874
+ return;
875
+ }
876
+
877
+ if (matchesKey(data, 'pageDown') || matchesKey(data, 'shift+down')) {
878
+ this.taskFollowing = false;
879
+ this.taskScrollOffset = Math.min(this.taskMaxScroll, this.taskScrollOffset + jump);
880
+ this.requestRender();
881
+ return;
882
+ }
883
+
884
+ if (data === 'g') {
885
+ this.taskFollowing = false;
886
+ this.taskScrollOffset = 0;
887
+ this.requestRender();
888
+ return;
889
+ }
890
+
891
+ if (data === 'G') {
892
+ this.taskFollowing = false;
893
+ this.taskScrollOffset = this.taskMaxScroll;
894
+ this.requestRender();
895
+ return;
896
+ }
897
+
898
+ if (data === '{') {
899
+ this.taskFollowing = false;
900
+ const segment = Math.max(1, Math.floor((this.taskMaxScroll || 1) * 0.25));
901
+ this.taskScrollOffset = Math.max(0, this.taskScrollOffset - segment);
902
+ this.requestRender();
903
+ return;
904
+ }
905
+
906
+ if (data === '}') {
907
+ this.taskFollowing = false;
908
+ const segment = Math.max(1, Math.floor((this.taskMaxScroll || 1) * 0.25));
909
+ this.taskScrollOffset = Math.min(this.taskMaxScroll, this.taskScrollOffset + segment);
910
+ this.requestRender();
911
+ return;
912
+ }
913
+ }
914
+
915
+ private getDetailTasks(): HubTask[] {
916
+ return this.detail?.tasks ?? [];
917
+ }
918
+
919
+ private getSessionBuffer(sessionId: string): StreamBuffer {
920
+ let buffer = this.sessionBuffers.get(sessionId);
921
+ if (!buffer) {
922
+ buffer = { output: '', thinking: '' };
923
+ this.sessionBuffers.set(sessionId, buffer);
924
+ }
925
+ return buffer;
926
+ }
927
+
928
+ private getTaskBuffer(sessionId: string, taskId: string): StreamBuffer {
929
+ const key = this.getTaskFeedKey(sessionId, taskId);
930
+ let buffer = this.taskBuffers.get(key);
931
+ if (!buffer) {
932
+ buffer = { output: '', thinking: '' };
933
+ this.taskBuffers.set(key, buffer);
934
+ }
935
+ return buffer;
936
+ }
937
+
938
+ private appendBufferText(
939
+ sessionId: string,
940
+ kind: 'output' | 'thinking',
941
+ chunk: string,
942
+ taskId?: string
943
+ ): void {
944
+ if (!chunk) return;
945
+
946
+ // Session stream is lead/top-level only; task-scoped output lives in task buffers.
947
+ if (!taskId) {
948
+ const sessionBuffer = this.getSessionBuffer(sessionId);
949
+ if (kind === 'output') sessionBuffer.output += chunk;
950
+ else sessionBuffer.thinking += chunk;
951
+ }
952
+
953
+ if (taskId) {
954
+ const taskBuffer = this.getTaskBuffer(sessionId, taskId);
955
+ if (kind === 'output') taskBuffer.output += chunk;
956
+ else taskBuffer.thinking += chunk;
957
+ }
958
+ }
959
+
960
+ private renderMarkdownLines(text: string, width: number): string[] {
961
+ if (!text) return [];
962
+ const safeWidth = Math.max(20, width);
963
+ if (this.mdRenderer) {
964
+ try {
965
+ this.mdRenderer.setText(text);
966
+ return this.mdRenderer.render(safeWidth);
967
+ } catch {
968
+ return text.split(/\r?\n/);
969
+ }
970
+ }
971
+ return text.split(/\r?\n/);
972
+ }
973
+
974
+ private renderStreamLines(
975
+ output: string,
976
+ thinking: string,
977
+ showThinking: boolean,
978
+ width: number
979
+ ): string[] {
980
+ const lines: string[] = [];
981
+
982
+ if (showThinking && thinking.trim()) {
983
+ for (const line of thinking.split(/\r?\n/)) {
984
+ lines.push(this.theme.fg('dim', line));
985
+ }
986
+ lines.push(this.theme.fg('muted', '--- end thinking ---'));
987
+ lines.push('');
988
+ }
989
+
990
+ if (output.trim()) {
991
+ lines.push(...this.renderMarkdownLines(output, width));
992
+ }
993
+
994
+ return lines;
995
+ }
996
+
997
+ private buildTopTabs(
998
+ active: 'detail' | 'feed' | 'events' | 'list',
999
+ sessionTabs: boolean
1000
+ ): string {
1001
+ const divider = this.theme.fg('dim', ' | ');
1002
+ const tab = (key: string, label: string, isActive: boolean): string => {
1003
+ const text = `[${key}] ${label}`;
1004
+ return isActive
1005
+ ? this.theme.bold(this.theme.fg('accent', text))
1006
+ : this.theme.fg('dim', text);
1007
+ };
1008
+
1009
+ if (sessionTabs) {
1010
+ return ` ${tab('1', 'Detail', active === 'detail')}${divider}${tab('2', 'Feed', active === 'feed')}${divider}${tab('3', 'Events', active === 'events')}`;
1011
+ }
1012
+
1013
+ return ` ${tab('1', 'List', active === 'list')}${divider}${tab('2', 'Feed', active === 'feed')}`;
1014
+ }
1015
+
1016
+ private async fetchJson<T>(path: string, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
1017
+ const controller = new AbortController();
1018
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1019
+ try {
1020
+ // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
1021
+ const apiKey = process.env.AGENTUITY_CODER_API_KEY;
1022
+ const headers: Record<string, string> = { accept: 'application/json' };
1023
+ if (apiKey) headers['x-agentuity-auth-api-key'] = apiKey;
1024
+ const response = await fetch(`${this.baseUrl}${path}`, {
1025
+ headers,
1026
+ signal: controller.signal,
1027
+ });
1028
+ if (!response.ok) {
1029
+ throw new Error(`Hub returned ${response.status}`);
1030
+ }
1031
+ return (await response.json()) as T;
1032
+ } finally {
1033
+ clearTimeout(timeout);
1034
+ }
1035
+ }
1036
+
1037
+ private async refreshList(initial = false): Promise<void> {
1038
+ if (this.disposed || this.listInFlight) return;
1039
+ this.listInFlight = true;
1040
+ if (initial) {
1041
+ this.loadingList = true;
1042
+ this.listError = '';
1043
+ this.requestRender();
1044
+ }
1045
+
1046
+ try {
1047
+ const data = await this.fetchJson<HubListResponse>('/api/hub/sessions');
1048
+ const sessions = (data.sessions?.websocket ?? []).slice().sort((a, b) => {
1049
+ return Date.parse(b.createdAt) - Date.parse(a.createdAt);
1050
+ });
1051
+
1052
+ this.updateFeedFromList(sessions);
1053
+ this.sessions = sessions;
1054
+ if (this.selectedIndex >= this.sessions.length) {
1055
+ this.selectedIndex = Math.max(0, this.sessions.length - 1);
1056
+ }
1057
+ this.loadingList = false;
1058
+ this.listError = '';
1059
+ this.lastUpdatedAt = Date.now();
1060
+ void this.syncSseStreams(this.sessions);
1061
+ this.requestRender();
1062
+ } catch (err) {
1063
+ this.loadingList = false;
1064
+ this.listError = err instanceof Error ? err.message : String(err);
1065
+ this.requestRender();
1066
+ } finally {
1067
+ this.listInFlight = false;
1068
+ }
1069
+ }
1070
+
1071
+ private async refreshDetail(sessionId: string, initial = false): Promise<void> {
1072
+ if (this.disposed || this.detailInFlight) return;
1073
+ this.detailInFlight = true;
1074
+ if (initial) {
1075
+ this.loadingDetail = true;
1076
+ this.detailError = '';
1077
+ this.requestRender();
1078
+ }
1079
+
1080
+ try {
1081
+ const [detail, todosResponse] = await Promise.all([
1082
+ this.fetchJson<HubSessionDetail>(`/api/hub/session/${encodeURIComponent(sessionId)}`),
1083
+ this.fetchJson<HubTodoListResponse>(
1084
+ `/api/hub/session/${encodeURIComponent(sessionId)}/todos?includeTerminal=true&includeSync=true&limit=30`,
1085
+ 15_000
1086
+ ).catch((err: any) => {
1087
+ const isAbort = err?.name === 'AbortError' || err?.message?.includes('aborted');
1088
+ return {
1089
+ _fetchError: true,
1090
+ message: isAbort ? 'Todos loading\u2026' : err?.message || 'Failed to load todos',
1091
+ } as any;
1092
+ }),
1093
+ ]);
1094
+ if (todosResponse?._fetchError) {
1095
+ // Use cached todos if available, show loading indicator
1096
+ if (this.cachedTodos && this.cachedTodos.sessionId === sessionId) {
1097
+ detail.todos = this.cachedTodos.todos;
1098
+ detail.todoSummary = this.cachedTodos.summary;
1099
+ detail.todosUnavailable = todosResponse.message;
1100
+ } else {
1101
+ detail.todosUnavailable = todosResponse.message;
1102
+ }
1103
+ } else if (todosResponse) {
1104
+ detail.todos = Array.isArray(todosResponse.todos) ? todosResponse.todos : [];
1105
+ detail.todoSummary =
1106
+ todosResponse.summary && typeof todosResponse.summary === 'object'
1107
+ ? todosResponse.summary
1108
+ : undefined;
1109
+ detail.todosUnavailable = todosResponse.unavailable
1110
+ ? typeof todosResponse.message === 'string'
1111
+ ? todosResponse.message
1112
+ : 'Task service unavailable'
1113
+ : undefined;
1114
+ // Cache successful todo data
1115
+ if (detail.todos && detail.todos.length > 0) {
1116
+ this.cachedTodos = { todos: detail.todos, summary: detail.todoSummary, sessionId };
1117
+ }
1118
+ }
1119
+ this.detail = detail;
1120
+ this.detailSessionId = sessionId;
1121
+ this.applyStreamProjection(sessionId, detail.stream);
1122
+ const taskCount = detail.tasks?.length ?? 0;
1123
+ if (taskCount === 0) {
1124
+ this.selectedTaskIndex = 0;
1125
+ } else if (this.selectedTaskIndex >= taskCount) {
1126
+ this.selectedTaskIndex = taskCount - 1;
1127
+ }
1128
+ this.loadingDetail = false;
1129
+ this.detailError = '';
1130
+ this.lastUpdatedAt = Date.now();
1131
+ this.requestRender();
1132
+ } catch (err) {
1133
+ this.loadingDetail = false;
1134
+ this.detailError = err instanceof Error ? err.message : String(err);
1135
+ this.requestRender();
1136
+ } finally {
1137
+ this.detailInFlight = false;
1138
+ }
1139
+ }
1140
+
1141
+ private updateFeedFromList(sessions: HubSessionSummary[]): void {
1142
+ if (this.previousDigests.size === 0) {
1143
+ if (sessions.length > 0) {
1144
+ this.pushFeed(
1145
+ `Loaded ${sessions.length} active session${sessions.length === 1 ? '' : 's'}`
1146
+ );
1147
+ }
1148
+ for (const session of sessions) {
1149
+ this.previousDigests.set(session.sessionId, {
1150
+ status: session.status,
1151
+ taskCount: session.taskCount,
1152
+ observerCount: session.observerCount,
1153
+ subAgentCount: session.subAgentCount,
1154
+ });
1155
+ }
1156
+ return;
1157
+ }
1158
+
1159
+ const nextDigests = new Map<string, SessionDigest>();
1160
+ for (const session of sessions) {
1161
+ const prev = this.previousDigests.get(session.sessionId);
1162
+ const label = session.label || shortId(session.sessionId);
1163
+
1164
+ if (!prev) {
1165
+ this.pushFeed(`${label}: session discovered (${session.mode})`, session.sessionId);
1166
+ } else {
1167
+ if (prev.status !== session.status) {
1168
+ this.pushFeed(`${label}: ${prev.status} -> ${session.status}`, session.sessionId);
1169
+ }
1170
+ if (session.taskCount > prev.taskCount) {
1171
+ const delta = session.taskCount - prev.taskCount;
1172
+ this.pushFeed(
1173
+ `${label}: +${delta} task${delta === 1 ? '' : 's'}`,
1174
+ session.sessionId
1175
+ );
1176
+ }
1177
+ if (session.observerCount !== prev.observerCount) {
1178
+ this.pushFeed(
1179
+ `${label}: observers ${prev.observerCount} -> ${session.observerCount}`,
1180
+ session.sessionId
1181
+ );
1182
+ }
1183
+ if (session.subAgentCount !== prev.subAgentCount) {
1184
+ this.pushFeed(
1185
+ `${label}: agents ${prev.subAgentCount} -> ${session.subAgentCount}`,
1186
+ session.sessionId
1187
+ );
1188
+ }
1189
+ }
1190
+
1191
+ nextDigests.set(session.sessionId, {
1192
+ status: session.status,
1193
+ taskCount: session.taskCount,
1194
+ observerCount: session.observerCount,
1195
+ subAgentCount: session.subAgentCount,
1196
+ });
1197
+ }
1198
+
1199
+ for (const oldSessionId of this.previousDigests.keys()) {
1200
+ if (!nextDigests.has(oldSessionId)) {
1201
+ this.pushFeed(`${shortId(oldSessionId)}: session removed`, oldSessionId);
1202
+ }
1203
+ }
1204
+
1205
+ this.previousDigests = nextDigests;
1206
+ }
1207
+
1208
+ private async syncSseStreams(sessions: HubSessionSummary[]): Promise<void> {
1209
+ if (this.disposed) return;
1210
+ const desiredModes = new Map<string, 'summary' | 'full'>();
1211
+ for (const session of sessions.slice(0, STREAM_SESSION_LIMIT)) {
1212
+ desiredModes.set(session.sessionId, 'summary');
1213
+ }
1214
+ if (this.detailSessionId && this.isSessionContext()) {
1215
+ desiredModes.set(this.detailSessionId, 'full');
1216
+ }
1217
+
1218
+ for (const [sessionId, stream] of this.sseControllers) {
1219
+ const desiredMode = desiredModes.get(sessionId);
1220
+ if (!desiredMode) {
1221
+ stream.controller.abort();
1222
+ this.sseControllers.delete(sessionId);
1223
+ continue;
1224
+ }
1225
+ if (stream.mode !== desiredMode) {
1226
+ stream.controller.abort();
1227
+ this.sseControllers.delete(sessionId);
1228
+ }
1229
+ }
1230
+
1231
+ for (const [sessionId, mode] of desiredModes) {
1232
+ if (!this.sseControllers.has(sessionId)) {
1233
+ void this.startSseStream(sessionId, mode);
1234
+ }
1235
+ }
1236
+ }
1237
+
1238
+ private async startSseStream(sessionId: string, mode: 'summary' | 'full'): Promise<void> {
1239
+ if (this.disposed) return;
1240
+ const existing = this.sseControllers.get(sessionId);
1241
+ if (existing && existing.mode === mode) return;
1242
+ if (existing) {
1243
+ existing.controller.abort();
1244
+ this.sseControllers.delete(sessionId);
1245
+ }
1246
+
1247
+ const controller = new AbortController();
1248
+ this.sseControllers.set(sessionId, { controller, mode });
1249
+ const subscribe = mode === 'full' ? '*' : 'session_*,task_*,agent_*';
1250
+
1251
+ try {
1252
+ // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
1253
+ const apiKey = process.env.AGENTUITY_CODER_API_KEY;
1254
+ const sseHeaders: Record<string, string> = { accept: 'text/event-stream' };
1255
+ if (apiKey) sseHeaders['x-agentuity-auth-api-key'] = apiKey;
1256
+ const response = await fetch(
1257
+ `${this.baseUrl}/api/hub/session/${encodeURIComponent(sessionId)}/events?subscribe=${encodeURIComponent(subscribe)}`,
1258
+ {
1259
+ headers: sseHeaders,
1260
+ signal: controller.signal,
1261
+ }
1262
+ );
1263
+
1264
+ if (!response.ok || !response.body) {
1265
+ throw new Error(`Hub returned ${response.status} for stream`);
1266
+ }
1267
+
1268
+ const reader = response.body.getReader();
1269
+ const decoder = new TextDecoder();
1270
+ let buffer = '';
1271
+
1272
+ while (true) {
1273
+ const { done, value } = await reader.read();
1274
+ if (done) break;
1275
+ buffer += decoder.decode(value, { stream: true });
1276
+ buffer = this.consumeSseBuffer(sessionId, mode, buffer);
1277
+ }
1278
+ } catch (err) {
1279
+ if (controller.signal.aborted || this.disposed) return;
1280
+ const label = this.getSessionLabel(sessionId);
1281
+ const msg = err instanceof Error ? err.message : String(err);
1282
+ this.pushFeed(`${label}: stream error (${msg})`, sessionId);
1283
+ this.requestRender();
1284
+ } finally {
1285
+ const current = this.sseControllers.get(sessionId);
1286
+ if (current && current.controller === controller) {
1287
+ this.sseControllers.delete(sessionId);
1288
+ }
1289
+ }
1290
+ }
1291
+
1292
+ private consumeSseBuffer(
1293
+ sessionId: string,
1294
+ mode: 'summary' | 'full',
1295
+ rawBuffer: string
1296
+ ): string {
1297
+ const normalized = rawBuffer.replace(/\r\n/g, '\n');
1298
+ let cursor = 0;
1299
+
1300
+ while (true) {
1301
+ const boundary = normalized.indexOf('\n\n', cursor);
1302
+ if (boundary === -1) break;
1303
+
1304
+ const block = normalized.slice(cursor, boundary);
1305
+ cursor = boundary + 2;
1306
+ if (!block.trim()) continue;
1307
+
1308
+ let eventName = 'message';
1309
+ const dataLines: string[] = [];
1310
+ for (const line of block.split('\n')) {
1311
+ if (line.startsWith('event:')) {
1312
+ eventName = line.slice(6).trim() || eventName;
1313
+ } else if (line.startsWith('data:')) {
1314
+ dataLines.push(line.slice(5).trimStart());
1315
+ }
1316
+ }
1317
+ const dataText = dataLines.join('\n');
1318
+ this.handleSseEvent(sessionId, mode, eventName, dataText);
1319
+ }
1320
+
1321
+ return normalized.slice(cursor);
1322
+ }
1323
+
1324
+ private handleSseEvent(
1325
+ sessionId: string,
1326
+ mode: 'summary' | 'full',
1327
+ sseEvent: string,
1328
+ dataText: string
1329
+ ): void {
1330
+ let payload: unknown = undefined;
1331
+ if (dataText) {
1332
+ try {
1333
+ payload = JSON.parse(dataText);
1334
+ } catch {
1335
+ payload = dataText;
1336
+ }
1337
+ }
1338
+
1339
+ let eventName = sseEvent;
1340
+ let eventData: unknown = payload;
1341
+ if (payload && typeof payload === 'object') {
1342
+ const record = payload as Record<string, unknown>;
1343
+ if (typeof record.event === 'string') {
1344
+ eventName = record.event;
1345
+ eventData = record.data;
1346
+ }
1347
+ }
1348
+
1349
+ if (eventName === 'hydration') {
1350
+ this.applyHydration(sessionId, eventData);
1351
+ return;
1352
+ }
1353
+ if (eventName === 'snapshot') {
1354
+ return;
1355
+ }
1356
+
1357
+ const data =
1358
+ eventData && typeof eventData === 'object'
1359
+ ? (eventData as Record<string, unknown>)
1360
+ : undefined;
1361
+ const taskId = typeof data?.taskId === 'string' ? data.taskId : undefined;
1362
+
1363
+ const text = this.formatEventFeedLine(sessionId, eventName, eventData);
1364
+ if (text) {
1365
+ this.pushFeed(text, sessionId);
1366
+ }
1367
+ if (mode === 'full') {
1368
+ this.captureStreamContent(sessionId, eventName, eventData, taskId);
1369
+ const streamLine = this.formatStreamLine(eventName, eventData);
1370
+ if (streamLine) {
1371
+ this.pushSessionFeed(sessionId, streamLine, taskId);
1372
+ }
1373
+ }
1374
+ this.requestRender();
1375
+
1376
+ if (
1377
+ this.detailSessionId === sessionId &&
1378
+ (eventName === 'session_join' ||
1379
+ eventName === 'session_leave' ||
1380
+ eventName === 'task_start' ||
1381
+ eventName === 'task_complete' ||
1382
+ eventName === 'task_error' ||
1383
+ eventName === 'session_shutdown')
1384
+ ) {
1385
+ void this.refreshDetail(sessionId);
1386
+ }
1387
+
1388
+ if (eventName === 'session_shutdown') {
1389
+ const stream = this.sseControllers.get(sessionId);
1390
+ if (stream) {
1391
+ stream.controller.abort();
1392
+ this.sseControllers.delete(sessionId);
1393
+ }
1394
+ }
1395
+ }
1396
+
1397
+ private applyHydration(sessionId: string, eventData: unknown): void {
1398
+ if (this.hydratedSessions.has(sessionId)) return;
1399
+ if (!eventData || typeof eventData !== 'object') return;
1400
+ const payload = eventData as Record<string, unknown>;
1401
+ const loadedFromProjection = this.applyStreamProjection(sessionId, payload.stream);
1402
+
1403
+ if (!loadedFromProjection) {
1404
+ const entries = Array.isArray(payload.entries) ? payload.entries : [];
1405
+ for (const rawEntry of entries) {
1406
+ if (!rawEntry || typeof rawEntry !== 'object') continue;
1407
+ const entry = rawEntry as Record<string, unknown>;
1408
+ const type = typeof entry.type === 'string' ? entry.type : '';
1409
+ const content = typeof entry.content === 'string' ? entry.content : '';
1410
+ const taskId = typeof entry.taskId === 'string' ? entry.taskId : undefined;
1411
+
1412
+ if (!content) continue;
1413
+ if (type === 'message' || type === 'task_result') {
1414
+ this.appendBufferText(sessionId, 'output', content + '\n\n', taskId);
1415
+ } else if (type === 'thinking') {
1416
+ this.appendBufferText(sessionId, 'thinking', content + '\n\n', taskId);
1417
+ }
1418
+ }
1419
+ }
1420
+
1421
+ this.hydratedSessions.add(sessionId);
1422
+ }
1423
+
1424
+ private applyStreamProjection(sessionId: string, streamRaw: unknown): boolean {
1425
+ if (!streamRaw || typeof streamRaw !== 'object') return false;
1426
+ const stream = streamRaw as Record<string, unknown>;
1427
+ const sessionBuffer = this.getSessionBuffer(sessionId);
1428
+ sessionBuffer.output = typeof stream.output === 'string' ? stream.output : '';
1429
+ sessionBuffer.thinking = typeof stream.thinking === 'string' ? stream.thinking : '';
1430
+ const taskStreams =
1431
+ stream.tasks && typeof stream.tasks === 'object'
1432
+ ? (stream.tasks as Record<string, unknown>)
1433
+ : {};
1434
+ for (const [taskId, rawBlock] of Object.entries(taskStreams)) {
1435
+ if (!rawBlock || typeof rawBlock !== 'object') continue;
1436
+ const block = rawBlock as Record<string, unknown>;
1437
+ const taskBuffer = this.getTaskBuffer(sessionId, taskId);
1438
+ taskBuffer.output = typeof block.output === 'string' ? block.output : '';
1439
+ taskBuffer.thinking = typeof block.thinking === 'string' ? block.thinking : '';
1440
+ }
1441
+ return true;
1442
+ }
1443
+
1444
+ private captureStreamContent(
1445
+ sessionId: string,
1446
+ eventName: string,
1447
+ eventData: unknown,
1448
+ taskId?: string
1449
+ ): void {
1450
+ const data =
1451
+ eventData && typeof eventData === 'object'
1452
+ ? (eventData as Record<string, unknown>)
1453
+ : undefined;
1454
+
1455
+ if (eventName === 'message_update') {
1456
+ const assistantMessageEvent = data?.assistantMessageEvent;
1457
+ if (assistantMessageEvent && typeof assistantMessageEvent === 'object') {
1458
+ const ame = assistantMessageEvent as Record<string, unknown>;
1459
+ const type = typeof ame.type === 'string' ? ame.type : '';
1460
+ const delta = typeof ame.delta === 'string' ? ame.delta : '';
1461
+ if (type === 'text_delta' && delta) {
1462
+ this.appendBufferText(sessionId, 'output', delta, taskId);
1463
+ } else if (type === 'thinking_delta' && delta) {
1464
+ this.appendBufferText(sessionId, 'thinking', delta, taskId);
1465
+ }
1466
+ }
1467
+ return;
1468
+ }
1469
+
1470
+ if (eventName === 'message_end') {
1471
+ const segments = extractMessageSegments(data);
1472
+ if (segments.thinking) {
1473
+ this.appendBufferText(sessionId, 'thinking', segments.thinking + '\n\n', taskId);
1474
+ }
1475
+ if (segments.output) {
1476
+ this.appendBufferText(sessionId, 'output', segments.output + '\n\n', taskId);
1477
+ }
1478
+ return;
1479
+ }
1480
+
1481
+ if (eventName === 'thinking_end') {
1482
+ const text = typeof data?.text === 'string' ? data.text : '';
1483
+ if (text) this.appendBufferText(sessionId, 'thinking', text + '\n\n', taskId);
1484
+ return;
1485
+ }
1486
+
1487
+ if (eventName === 'agent_progress') {
1488
+ const status = typeof data?.status === 'string' ? data.status : '';
1489
+ const delta = typeof data?.delta === 'string' ? data.delta : '';
1490
+ if (status === 'text_delta' && delta) {
1491
+ this.appendBufferText(sessionId, 'output', delta, taskId);
1492
+ } else if (status === 'thinking_delta' && delta) {
1493
+ this.appendBufferText(sessionId, 'thinking', delta, taskId);
1494
+ }
1495
+ return;
1496
+ }
1497
+
1498
+ if (eventName === 'tool_call' || eventName === 'tool_result') {
1499
+ const line = this.formatStreamLine(eventName, eventData);
1500
+ if (line) this.appendBufferText(sessionId, 'output', `${line}\n\n`, taskId);
1501
+ return;
1502
+ }
1503
+
1504
+ if (eventName === 'task_complete') {
1505
+ const result = typeof data?.result === 'string' ? data.result : '';
1506
+ if (result) this.appendBufferText(sessionId, 'output', result + '\n\n', taskId);
1507
+ return;
1508
+ }
1509
+
1510
+ if (eventName === 'task_error') {
1511
+ const error = typeof data?.error === 'string' ? data.error : '';
1512
+ if (error) this.appendBufferText(sessionId, 'output', `[task error] ${error}\n`, taskId);
1513
+ }
1514
+ }
1515
+
1516
+ private formatEventFeedLine(
1517
+ sessionId: string,
1518
+ eventName: string,
1519
+ eventData: unknown
1520
+ ): string | null {
1521
+ const label = this.getSessionLabel(sessionId);
1522
+ const data =
1523
+ eventData && typeof eventData === 'object'
1524
+ ? (eventData as Record<string, unknown>)
1525
+ : undefined;
1526
+
1527
+ if (eventName === 'task_start') {
1528
+ const taskId = typeof data?.taskId === 'string' ? shortId(data.taskId) : 'task';
1529
+ const agent = typeof data?.agent === 'string' ? data.agent : 'agent';
1530
+ return `${label}: ${taskId} started (${agent})`;
1531
+ }
1532
+ if (eventName === 'task_complete') {
1533
+ const taskId = typeof data?.taskId === 'string' ? shortId(data.taskId) : 'task';
1534
+ const duration = typeof data?.duration === 'number' ? ` ${data.duration}ms` : '';
1535
+ return `${label}: ${taskId} completed${duration}`;
1536
+ }
1537
+ if (eventName === 'task_error') {
1538
+ const taskId = typeof data?.taskId === 'string' ? shortId(data.taskId) : 'task';
1539
+ return `${label}: ${taskId} failed`;
1540
+ }
1541
+ if (eventName === 'session_join' || eventName === 'session_leave') {
1542
+ const participant = data?.participant as Record<string, unknown> | undefined;
1543
+ const role = typeof participant?.role === 'string' ? participant.role : 'participant';
1544
+ return `${label}: ${role} ${eventName === 'session_join' ? 'joined' : 'left'}`;
1545
+ }
1546
+ if (eventName === 'agent_start' || eventName === 'agent_end') {
1547
+ const agent =
1548
+ typeof data?.agentName === 'string'
1549
+ ? data.agentName
1550
+ : typeof data?.agent === 'string'
1551
+ ? data.agent
1552
+ : 'agent';
1553
+ return `${label}: ${agent} ${eventName === 'agent_start' ? 'started' : 'ended'}`;
1554
+ }
1555
+ if (
1556
+ eventName === 'session_complete' ||
1557
+ eventName === 'session_error' ||
1558
+ eventName === 'session_shutdown'
1559
+ ) {
1560
+ return `${label}: ${eventName}`;
1561
+ }
1562
+
1563
+ return null;
1564
+ }
1565
+
1566
+ private getSessionLabel(sessionId: string): string {
1567
+ const session = this.sessions.find((item) => item.sessionId === sessionId);
1568
+ return session?.label || shortId(sessionId);
1569
+ }
1570
+
1571
+ private getFeedEntries(scope: 'global' | 'session'): FeedEntry[] {
1572
+ if (scope === 'session' && this.detailSessionId) {
1573
+ return this.sessionFeed.get(this.detailSessionId) ?? [];
1574
+ }
1575
+ return this.feed;
1576
+ }
1577
+
1578
+ private pushFeed(text: string, sessionId?: string): void {
1579
+ this.feed.unshift({ at: Date.now(), sessionId, text });
1580
+ if (this.feed.length > MAX_FEED_ITEMS) {
1581
+ this.feed.length = MAX_FEED_ITEMS;
1582
+ }
1583
+ }
1584
+
1585
+ private pushSessionFeed(sessionId: string, text: string, taskId?: string): void {
1586
+ const entries = this.sessionFeed.get(sessionId) ?? [];
1587
+ entries.unshift({ at: Date.now(), sessionId, text });
1588
+ if (entries.length > MAX_FEED_ITEMS) entries.length = MAX_FEED_ITEMS;
1589
+ this.sessionFeed.set(sessionId, entries);
1590
+
1591
+ if (taskId) {
1592
+ // Keep task-scoped stream buffers warm for immediate task-view drill-in.
1593
+ this.getTaskBuffer(sessionId, taskId);
1594
+ }
1595
+ }
1596
+
1597
+ private formatStreamLine(eventName: string, eventData: unknown): string | null {
1598
+ const data =
1599
+ eventData && typeof eventData === 'object'
1600
+ ? (eventData as Record<string, unknown>)
1601
+ : undefined;
1602
+ const normalize = (value: string): string => value.replace(/\s+/g, ' ').trim();
1603
+
1604
+ if (eventName === 'message_update') {
1605
+ const assistantMessageEvent = data?.assistantMessageEvent;
1606
+ if (assistantMessageEvent && typeof assistantMessageEvent === 'object') {
1607
+ const ame = assistantMessageEvent as Record<string, unknown>;
1608
+ const type = typeof ame.type === 'string' ? ame.type : '';
1609
+ const delta = typeof ame.delta === 'string' ? ame.delta : '';
1610
+ // Keep event view high-signal: show text streaming, suppress thinking token spam.
1611
+ if (type === 'text_delta' && delta) {
1612
+ const cleaned = truncateToWidth(normalize(delta), 120);
1613
+ if (cleaned) {
1614
+ return `${type || 'delta'} ${cleaned}`;
1615
+ }
1616
+ }
1617
+ }
1618
+ return null;
1619
+ }
1620
+
1621
+ if (eventName === 'message_end') {
1622
+ const segments = extractMessageSegments(data);
1623
+ const output = segments.output || segments.thinking;
1624
+ const cleaned = output ? truncateToWidth(normalize(output), 120) : '';
1625
+ return cleaned ? `message ${cleaned}` : 'message_end';
1626
+ }
1627
+
1628
+ if (eventName === 'thinking_end' && typeof data?.text === 'string') {
1629
+ const cleaned = truncateToWidth(normalize(data.text), 120);
1630
+ return cleaned ? `thinking ${cleaned}` : 'thinking_end';
1631
+ }
1632
+
1633
+ if (eventName === 'tool_call') {
1634
+ const name =
1635
+ typeof data?.name === 'string'
1636
+ ? data.name
1637
+ : typeof data?.toolName === 'string'
1638
+ ? data.toolName
1639
+ : 'tool';
1640
+ const input = data?.args ?? data?.input;
1641
+ const summarized = summarizeToolCall(name, input);
1642
+ if (summarized) return summarized;
1643
+ const argsPreview = summarizeArgs(input, 90);
1644
+ return argsPreview ? `tool_call ${name} ${argsPreview}` : `tool_call ${name}`;
1645
+ }
1646
+
1647
+ if (eventName === 'tool_result') {
1648
+ const name =
1649
+ typeof data?.name === 'string'
1650
+ ? data.name
1651
+ : typeof data?.toolName === 'string'
1652
+ ? data.toolName
1653
+ : 'tool';
1654
+ const input = (data?.args ?? data?.input) as Record<string, unknown> | undefined;
1655
+ if (name === 'task') {
1656
+ const agent = typeof input?.subagent_type === 'string' ? input.subagent_type : 'agent';
1657
+ const description =
1658
+ typeof input?.description === 'string'
1659
+ ? truncateToWidth(toSingleLine(input.description), 80)
1660
+ : '';
1661
+ const header = description ? `${agent} - ${description}` : `${agent} - delegated task`;
1662
+
1663
+ const rawResult = extractToolResultText(data?.content);
1664
+ const stats = rawResult.match(
1665
+ /_(\w+): (\d+)ms \| (\d+) in (\d+) out tokens \| \$([0-9.]+)_/
1666
+ );
1667
+ if (stats) {
1668
+ const durationMs = Number(stats[2] ?? 0);
1669
+ const tokIn = stats[3] ?? '0';
1670
+ const tokOut = stats[4] ?? '0';
1671
+ const cost = stats[5] ?? '0';
1672
+ return `${header}\ndone ${formatDuration(durationMs)} ↑${tokIn} ↓${tokOut} $${cost}`;
1673
+ }
1674
+
1675
+ const details =
1676
+ data?.details && typeof data.details === 'object'
1677
+ ? (data.details as Record<string, unknown>)
1678
+ : undefined;
1679
+ const duration =
1680
+ typeof details?.duration === 'number' ? ` ${formatDuration(details.duration)}` : '';
1681
+ const failed = data?.isError === true || details?.error === true;
1682
+ return `${header}\n${failed ? 'failed' : `done${duration}`}`;
1683
+ }
1684
+ return `tool_result ${name}`;
1685
+ }
1686
+
1687
+ if (eventName === 'agent_progress') {
1688
+ const agent = typeof data?.agentName === 'string' ? data.agentName : 'agent';
1689
+ const status = typeof data?.status === 'string' ? data.status : 'progress';
1690
+ const toolName = typeof data?.currentTool === 'string' ? data.currentTool : '';
1691
+ const toolArgsRaw = typeof data?.currentToolArgs === 'string' ? data.currentToolArgs : '';
1692
+ const toolArgs = toolArgsRaw ? truncateToWidth(normalize(toolArgsRaw), 80) : '';
1693
+
1694
+ // Deltas are already represented in rendered stream mode; skip them in event mode
1695
+ // to avoid noisy, low-signal token lines.
1696
+ if (status.endsWith('_delta')) {
1697
+ return null;
1698
+ }
1699
+
1700
+ if (status === 'tool_start') {
1701
+ const parts = ['tool_call', agent];
1702
+ if (toolName) parts.push(toolName);
1703
+ if (toolArgs) parts.push(toolArgs);
1704
+ return parts.join(' ');
1705
+ }
1706
+
1707
+ if (status === 'tool_end') {
1708
+ return toolName ? `tool_result ${agent} ${toolName}` : `tool_result ${agent}`;
1709
+ }
1710
+
1711
+ if (status === 'completed' || status === 'failed') {
1712
+ return `agent ${agent} ${status}`;
1713
+ }
1714
+
1715
+ if (status === 'running' && toolName) {
1716
+ return toolArgs
1717
+ ? `agent ${agent} running ${toolName} ${toolArgs}`
1718
+ : `agent ${agent} running ${toolName}`;
1719
+ }
1720
+
1721
+ return null;
1722
+ }
1723
+
1724
+ const parts: string[] = [];
1725
+ if (typeof data?.taskId === 'string') parts.push(shortId(data.taskId));
1726
+ if (typeof data?.agent === 'string') parts.push(data.agent);
1727
+ else if (typeof data?.agentName === 'string') parts.push(data.agentName);
1728
+ else if (typeof data?.name === 'string') parts.push(data.name);
1729
+ if (typeof data?.status === 'string') parts.push(data.status);
1730
+ if (typeof data?.message === 'string') parts.push(data.message);
1731
+ if (typeof data?.error === 'string') parts.push(`error: ${data.error}`);
1732
+ if (typeof data?.text === 'string') parts.push(data.text);
1733
+ if (typeof data?.delta === 'string') parts.push(data.delta);
1734
+
1735
+ let detail = parts.find((part) => part.trim().length > 0) ?? '';
1736
+
1737
+ if (detail) {
1738
+ detail = truncateToWidth(normalize(detail), 120);
1739
+ return `${eventName} ${detail}`;
1740
+ }
1741
+
1742
+ if (eventData !== undefined) {
1743
+ try {
1744
+ const serialized =
1745
+ typeof eventData === 'string' ? eventData : JSON.stringify(eventData);
1746
+ if (serialized) {
1747
+ const compact = truncateToWidth(serialized.replace(/\s+/g, ' ').trim(), 140);
1748
+ return `${eventName} ${compact}`;
1749
+ }
1750
+ } catch {
1751
+ // Best-effort formatting only.
1752
+ }
1753
+ }
1754
+ return null;
1755
+ }
1756
+
1757
+ private getTaskFeedKey(sessionId: string, taskId: string): string {
1758
+ return `${sessionId}:${taskId}`;
1759
+ }
1760
+
1761
+ private renderListScreen(width: number, maxLines: number): string[] {
1762
+ const inner = Math.max(0, width - 2);
1763
+ const lines: string[] = [];
1764
+ const headerRows = 2;
1765
+ const footerRows = 2;
1766
+ const contentBudget = Math.max(5, maxLines - headerRows - footerRows);
1767
+ const body: string[] = [];
1768
+
1769
+ lines.push(buildTopBorder(width, 'Coder Hub Sessions'));
1770
+ lines.push(this.contentLine('', inner));
1771
+
1772
+ if (this.loadingList) {
1773
+ body.push(this.contentLine(this.theme.fg('dim', ' Loading sessions...'), inner));
1774
+ } else if (this.listError) {
1775
+ body.push(this.contentLine(this.theme.fg('error', ` ${this.listError}`), inner));
1776
+ } else {
1777
+ const updated = this.lastUpdatedAt
1778
+ ? `${formatClock(this.lastUpdatedAt)} updated`
1779
+ : 'not updated';
1780
+ body.push(
1781
+ this.contentLine(
1782
+ this.theme.fg('muted', ` Active: ${this.sessions.length} sessions ${updated}`),
1783
+ inner
1784
+ )
1785
+ );
1786
+ }
1787
+ body.push(
1788
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
1789
+ );
1790
+ body.push(this.contentLine(this.theme.bold(' Teams / Observers'), inner));
1791
+
1792
+ if (this.sessions.length === 0 && !this.loadingList && !this.listError) {
1793
+ body.push(this.contentLine(this.theme.fg('muted', ' No active Hub sessions'), inner));
1794
+ } else if (!this.loadingList && !this.listError) {
1795
+ const listBudget = Math.max(1, contentBudget - body.length);
1796
+ const [start, end] = getVisibleRange(this.sessions.length, this.selectedIndex, listBudget);
1797
+ if (start > 0) {
1798
+ body.push(this.contentLine(this.theme.fg('dim', ` ↑ ${start} more above`), inner));
1799
+ }
1800
+ for (let i = start; i < end; i++) {
1801
+ const session = this.sessions[i]!;
1802
+ const selected = i === this.selectedIndex;
1803
+ const marker = selected ? this.theme.fg('accent', '›') : ' ';
1804
+ const label = session.label || shortId(session.sessionId);
1805
+ const name = selected ? this.theme.bold(label) : label;
1806
+ const statusColor =
1807
+ session.status === 'running'
1808
+ ? 'success'
1809
+ : session.status === 'error' || session.status === 'failed'
1810
+ ? 'error'
1811
+ : 'warning';
1812
+ const status = this.theme.fg(
1813
+ statusColor as 'success' | 'error' | 'warning',
1814
+ session.status
1815
+ );
1816
+ const self =
1817
+ this.currentSessionId === session.sessionId
1818
+ ? this.theme.fg('accent', ' (this)')
1819
+ : '';
1820
+ const metrics = this.theme.fg(
1821
+ 'muted',
1822
+ `obs:${session.observerCount} agents:${session.subAgentCount} tasks:${session.taskCount} ${formatRelative(session.createdAt)}`
1823
+ );
1824
+ body.push(
1825
+ this.contentLine(
1826
+ ` ${marker} ${name}${self} ${status} ${session.mode} ${metrics}`,
1827
+ inner
1828
+ )
1829
+ );
1830
+ }
1831
+ if (end < this.sessions.length) {
1832
+ body.push(
1833
+ this.contentLine(
1834
+ this.theme.fg('dim', ` ↓ ${this.sessions.length - end} more below`),
1835
+ inner
1836
+ )
1837
+ );
1838
+ }
1839
+ }
1840
+
1841
+ const windowedBody = body.slice(0, contentBudget);
1842
+ lines.push(...windowedBody);
1843
+ while (lines.length < maxLines - footerRows) {
1844
+ lines.push(this.contentLine('', inner));
1845
+ }
1846
+
1847
+ lines.push(
1848
+ this.contentLine(
1849
+ this.theme.fg('dim', ' [↑↓] Select [Enter] Open [r] Refresh [Esc] Close'),
1850
+ inner
1851
+ )
1852
+ );
1853
+ lines.push(buildBottomBorder(width));
1854
+ return lines.slice(0, maxLines);
1855
+ }
1856
+
1857
+ private renderDetailScreen(width: number, maxLines: number): string[] {
1858
+ const inner = Math.max(0, width - 2);
1859
+ const lines: string[] = [];
1860
+ const title = this.detail?.label || this.detailSessionId || 'Hub Session';
1861
+ const headerRows = 3;
1862
+ const footerRows = 2;
1863
+ const contentBudget = Math.max(5, maxLines - headerRows - footerRows);
1864
+
1865
+ lines.push(buildTopBorder(width, `Session ${shortId(title)}`));
1866
+ lines.push(this.contentLine('', inner));
1867
+ lines.push(this.contentLine(this.buildTopTabs('detail', true), inner));
1868
+
1869
+ const body: string[] = [];
1870
+ if (this.loadingDetail) {
1871
+ body.push(this.contentLine(this.theme.fg('dim', ' Loading session detail...'), inner));
1872
+ } else if (this.detailError) {
1873
+ body.push(this.contentLine(this.theme.fg('error', ` ${this.detailError}`), inner));
1874
+ } else if (!this.detail) {
1875
+ body.push(this.contentLine(this.theme.fg('muted', ' No detail available'), inner));
1876
+ } else {
1877
+ const session = this.detail;
1878
+ const participants = session.participants ?? [];
1879
+ const tasks = session.tasks ?? [];
1880
+ const activityEntries = Object.entries(session.agentActivity ?? {});
1881
+
1882
+ body.push(this.contentLine(this.theme.bold(' Overview'), inner));
1883
+ body.push(this.contentLine(this.theme.fg('muted', ` ID: ${session.sessionId}`), inner));
1884
+ body.push(
1885
+ this.contentLine(
1886
+ this.theme.fg('muted', ` Status: ${session.status} Mode: ${session.mode}`),
1887
+ inner
1888
+ )
1889
+ );
1890
+ body.push(
1891
+ this.contentLine(
1892
+ this.theme.fg('muted', ` Created: ${formatRelative(session.createdAt)}`),
1893
+ inner
1894
+ )
1895
+ );
1896
+ body.push(
1897
+ this.contentLine(
1898
+ this.theme.fg(
1899
+ 'muted',
1900
+ ` Participants: ${participants.length} Tasks: ${tasks.length} Active agents: ${activityEntries.length}`
1901
+ ),
1902
+ inner
1903
+ )
1904
+ );
1905
+ if (session.context?.branch) {
1906
+ body.push(
1907
+ this.contentLine(
1908
+ this.theme.fg('muted', ` Branch: ${session.context.branch}`),
1909
+ inner
1910
+ )
1911
+ );
1912
+ }
1913
+ if (session.context?.workingDirectory) {
1914
+ body.push(
1915
+ this.contentLine(
1916
+ this.theme.fg('muted', ` CWD: ${session.context.workingDirectory}`),
1917
+ inner
1918
+ )
1919
+ );
1920
+ }
1921
+ body.push(
1922
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
1923
+ );
1924
+ body.push(this.contentLine(this.theme.bold(' Tasks'), inner));
1925
+ body.push(
1926
+ this.contentLine(
1927
+ this.theme.fg('dim', ' Use ↑ and ↓ to move, Enter to open task view'),
1928
+ inner
1929
+ )
1930
+ );
1931
+ if (tasks.length === 0) {
1932
+ body.push(this.contentLine(this.theme.fg('dim', ' (no tasks yet)'), inner));
1933
+ } else {
1934
+ if (this.selectedTaskIndex >= tasks.length) {
1935
+ this.selectedTaskIndex = tasks.length - 1;
1936
+ }
1937
+ for (let i = 0; i < Math.min(tasks.length, 50); i++) {
1938
+ const task = tasks[i]!;
1939
+ const selected = i === this.selectedTaskIndex;
1940
+ const statusColor =
1941
+ task.status === 'completed'
1942
+ ? 'success'
1943
+ : task.status === 'failed'
1944
+ ? 'error'
1945
+ : 'warning';
1946
+ const status = this.theme.fg(
1947
+ statusColor as 'success' | 'error' | 'warning',
1948
+ task.status
1949
+ );
1950
+ const prompt = task.prompt
1951
+ ? truncateToWidth(toSingleLine(task.prompt), Math.max(16, inner - 34))
1952
+ : '';
1953
+ const duration = typeof task.duration === 'number' ? ` ${task.duration}ms` : '';
1954
+ const marker = selected ? this.theme.fg('accent', '›') : ' ';
1955
+ body.push(
1956
+ this.contentLine(
1957
+ `${marker} ${shortId(task.taskId).padEnd(12)} ${task.agent.padEnd(9)} ${status}${duration} ${prompt}`,
1958
+ inner
1959
+ )
1960
+ );
1961
+ }
1962
+ }
1963
+
1964
+ body.push(
1965
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
1966
+ );
1967
+ body.push(this.contentLine(this.theme.bold(' Session Todos'), inner));
1968
+ if (session.todosUnavailable) {
1969
+ body.push(
1970
+ this.contentLine(this.theme.fg('warning', ` ${session.todosUnavailable}`), inner)
1971
+ );
1972
+ } else {
1973
+ const todos = session.todos ?? [];
1974
+ const summary = session.todoSummary ?? {};
1975
+ body.push(
1976
+ this.contentLine(
1977
+ this.theme.fg(
1978
+ 'dim',
1979
+ ` open:${summary.open ?? 0} in_progress:${summary.in_progress ?? 0} done:${summary.done ?? 0} closed:${summary.closed ?? 0} cancelled:${summary.cancelled ?? 0}`
1980
+ ),
1981
+ inner
1982
+ )
1983
+ );
1984
+ if (todos.length === 0) {
1985
+ body.push(
1986
+ this.contentLine(
1987
+ this.theme.fg(
1988
+ 'dim',
1989
+ ` (no todos linked to session ${shortId(session.sessionId)})`
1990
+ ),
1991
+ inner
1992
+ )
1993
+ );
1994
+ } else {
1995
+ for (const todo of todos.slice(0, 20)) {
1996
+ const statusColor =
1997
+ todo.status === 'done'
1998
+ ? 'success'
1999
+ : todo.status === 'cancelled' || todo.status === 'closed'
2000
+ ? 'error'
2001
+ : todo.status === 'in_progress'
2002
+ ? 'accent'
2003
+ : 'warning';
2004
+ const status = this.theme.fg(
2005
+ statusColor as 'success' | 'error' | 'warning' | 'accent',
2006
+ todo.status
2007
+ );
2008
+ const details = [
2009
+ typeof todo.priority === 'string' ? `prio:${todo.priority}` : undefined,
2010
+ typeof todo.type === 'string' ? `type:${todo.type}` : undefined,
2011
+ typeof todo.assignee === 'string' && todo.assignee.length > 0
2012
+ ? `owner:${todo.assignee}`
2013
+ : undefined,
2014
+ typeof todo.attachmentCount === 'number' && todo.attachmentCount > 0
2015
+ ? `att:${todo.attachmentCount}`
2016
+ : undefined,
2017
+ ].filter((part): part is string => !!part);
2018
+ const title = truncateToWidth(
2019
+ toSingleLine(todo.title || ''),
2020
+ Math.max(16, inner - 48)
2021
+ );
2022
+ const meta =
2023
+ details.length > 0 ? this.theme.fg('dim', ` ${details.join(' ')}`) : '';
2024
+ body.push(
2025
+ this.contentLine(
2026
+ ` ${shortId(todo.id).padEnd(12)} ${status} ${title}${meta}`,
2027
+ inner
2028
+ )
2029
+ );
2030
+ }
2031
+ if (todos.length > 20) {
2032
+ body.push(
2033
+ this.contentLine(
2034
+ this.theme.fg('dim', ` ... ${todos.length - 20} more todos`),
2035
+ inner
2036
+ )
2037
+ );
2038
+ }
2039
+ }
2040
+ }
2041
+
2042
+ body.push(
2043
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2044
+ );
2045
+ body.push(this.contentLine(this.theme.bold(' Participants'), inner));
2046
+ if (participants.length === 0) {
2047
+ body.push(this.contentLine(this.theme.fg('dim', ' (none)'), inner));
2048
+ } else {
2049
+ for (const participant of participants) {
2050
+ const when = participant.connectedAt ? formatRelative(participant.connectedAt) : '-';
2051
+ const idle = participant.idle ? this.theme.fg('warning', ' idle') : '';
2052
+ body.push(
2053
+ this.contentLine(
2054
+ ` ${participant.id.padEnd(12)} ${participant.role.padEnd(9)} ${(participant.transport || 'ws').padEnd(3)} ${when}${idle}`,
2055
+ inner
2056
+ )
2057
+ );
2058
+ }
2059
+ }
2060
+
2061
+ body.push(
2062
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2063
+ );
2064
+ body.push(this.contentLine(this.theme.bold(' Agent Activity'), inner));
2065
+ if (activityEntries.length === 0) {
2066
+ body.push(this.contentLine(this.theme.fg('dim', ' (none)'), inner));
2067
+ } else {
2068
+ for (const [agent, info] of activityEntries.slice(0, 15)) {
2069
+ const tool = info.currentTool ? ` ${info.currentTool}` : '';
2070
+ const calls =
2071
+ typeof info.toolCallCount === 'number'
2072
+ ? this.theme.fg('dim', ` (${info.toolCallCount} calls)`)
2073
+ : '';
2074
+ const status = info.status || 'idle';
2075
+ body.push(this.contentLine(` ${agent.padEnd(12)} ${status}${tool}${calls}`, inner));
2076
+ }
2077
+ }
2078
+ }
2079
+
2080
+ this.detailMaxScroll = Math.max(0, body.length - contentBudget);
2081
+ if (this.detailScrollOffset > this.detailMaxScroll) {
2082
+ this.detailScrollOffset = this.detailMaxScroll;
2083
+ }
2084
+ const windowedBody = body.slice(
2085
+ this.detailScrollOffset,
2086
+ this.detailScrollOffset + contentBudget
2087
+ );
2088
+ lines.push(...windowedBody);
2089
+ while (lines.length < maxLines - footerRows) {
2090
+ lines.push(this.contentLine('', inner));
2091
+ }
2092
+
2093
+ const scrollInfo =
2094
+ this.detailMaxScroll > 0
2095
+ ? this.theme.fg('dim', ` scroll ${this.detailScrollOffset}/${this.detailMaxScroll}`)
2096
+ : this.theme.fg('dim', ' scroll 0/0');
2097
+ lines.push(
2098
+ this.contentLine(
2099
+ `${scrollInfo} ${this.theme.fg('dim', '[↑↓] Task [j/k] Scroll [Enter] Open [r] Refresh [Esc] Back')}`,
2100
+ inner
2101
+ )
2102
+ );
2103
+ lines.push(buildBottomBorder(width));
2104
+ return lines.slice(0, maxLines);
2105
+ }
2106
+
2107
+ private renderFeedScreen(width: number, maxLines: number): string[] {
2108
+ const inner = Math.max(0, width - 2);
2109
+ const lines: string[] = [];
2110
+ const headerRows = 5;
2111
+ const footerRows = 2;
2112
+ const contentBudget = Math.max(5, maxLines - headerRows - footerRows);
2113
+ const scoped = this.feedScope === 'session' && !!this.detailSessionId;
2114
+ const title = scoped
2115
+ ? `${this.feedViewMode === 'events' ? 'Session Events' : 'Session Feed'} ${shortId(this.getSessionLabel(this.detailSessionId!))}`
2116
+ : 'Global Feed';
2117
+ const entryBudget = Math.max(1, contentBudget - 2);
2118
+ const sessionBuffer =
2119
+ scoped && this.detailSessionId ? this.sessionBuffers.get(this.detailSessionId) : undefined;
2120
+
2121
+ lines.push(buildTopBorder(width, title));
2122
+ lines.push(this.contentLine('', inner));
2123
+ lines.push(
2124
+ this.contentLine(
2125
+ this.buildTopTabs(
2126
+ scoped ? (this.feedViewMode === 'events' ? 'events' : 'feed') : 'feed',
2127
+ scoped
2128
+ ),
2129
+ inner
2130
+ )
2131
+ );
2132
+ lines.push(
2133
+ this.contentLine(
2134
+ this.theme.fg(
2135
+ 'muted',
2136
+ scoped
2137
+ ? this.feedViewMode === 'stream'
2138
+ ? ' Streaming rendered session output (sub-agent style) — [v] task stream'
2139
+ : ' Streaming full session events'
2140
+ : ' Streaming event summaries across all sessions'
2141
+ ),
2142
+ inner
2143
+ )
2144
+ );
2145
+ lines.push(
2146
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2147
+ );
2148
+
2149
+ let contentLines: string[] = [];
2150
+ if (scoped && this.feedViewMode === 'stream') {
2151
+ contentLines = this.renderStreamLines(
2152
+ sessionBuffer?.output ?? '',
2153
+ sessionBuffer?.thinking ?? '',
2154
+ this.showFeedThinking,
2155
+ Math.max(12, inner - 4)
2156
+ );
2157
+ if (contentLines.length === 0) {
2158
+ contentLines = [this.theme.fg('dim', '(no streamed output yet)')];
2159
+ }
2160
+ } else {
2161
+ const entries = this.getFeedEntries(scoped ? 'session' : 'global');
2162
+ contentLines = [...entries]
2163
+ .reverse()
2164
+ .map((entry) => `${this.theme.fg('dim', formatClock(entry.at))} ${entry.text}`);
2165
+ if (contentLines.length === 0) {
2166
+ contentLines = [this.theme.fg('dim', '(no feed items yet)')];
2167
+ }
2168
+ }
2169
+
2170
+ this.feedMaxScroll = Math.max(0, contentLines.length - entryBudget);
2171
+ if (this.feedFollowing) {
2172
+ this.feedScrollOffset = this.feedMaxScroll;
2173
+ }
2174
+ if (this.feedScrollOffset > this.feedMaxScroll) {
2175
+ this.feedScrollOffset = this.feedMaxScroll;
2176
+ }
2177
+
2178
+ const windowed = contentLines.slice(
2179
+ this.feedScrollOffset,
2180
+ this.feedScrollOffset + entryBudget
2181
+ );
2182
+ for (const line of windowed) {
2183
+ lines.push(this.contentLine(` ${line}`, inner));
2184
+ }
2185
+ while (lines.length < maxLines - footerRows) {
2186
+ lines.push(this.contentLine('', inner));
2187
+ }
2188
+
2189
+ const scrollInfo =
2190
+ this.feedMaxScroll > 0
2191
+ ? this.theme.fg('dim', ` scroll ${this.feedScrollOffset}/${this.feedMaxScroll}`)
2192
+ : this.theme.fg('dim', ' scroll 0/0');
2193
+ const thinkingHint =
2194
+ scoped && this.feedViewMode === 'stream' && sessionBuffer?.thinking
2195
+ ? ' [t] Thinking'
2196
+ : '';
2197
+ const taskHint = scoped && this.feedViewMode === 'stream' ? ' [v] Task view' : '';
2198
+ const followHint = ` [f] ${this.feedFollowing ? 'Unfollow' : 'Follow'}`;
2199
+ lines.push(
2200
+ this.contentLine(
2201
+ `${scrollInfo} ${this.theme.fg('dim', `[↑↓] Scroll${thinkingHint}${taskHint}${followHint} [r] Refresh [Esc] Back`)}`,
2202
+ inner
2203
+ )
2204
+ );
2205
+ lines.push(buildBottomBorder(width));
2206
+ return lines.slice(0, maxLines);
2207
+ }
2208
+
2209
+ private renderTaskScreen(width: number, maxLines: number): string[] {
2210
+ const inner = Math.max(0, width - 2);
2211
+ const lines: string[] = [];
2212
+ const headerRows = 2;
2213
+ const footerRows = 2;
2214
+ const contentBudget = Math.max(5, maxLines - headerRows - footerRows);
2215
+ const tasks = this.getDetailTasks();
2216
+ const selected = tasks[this.selectedTaskIndex];
2217
+ const title = selected ? `Task ${shortId(selected.taskId)} ${selected.agent}` : 'Task Detail';
2218
+
2219
+ lines.push(buildTopBorder(width, title));
2220
+ lines.push(this.contentLine('', inner));
2221
+
2222
+ const body: string[] = [];
2223
+ if (!selected) {
2224
+ body.push(this.contentLine(this.theme.fg('dim', ' No task selected'), inner));
2225
+ } else {
2226
+ body.push(this.contentLine(this.theme.bold(' Task Overview'), inner));
2227
+ body.push(
2228
+ this.contentLine(this.theme.fg('muted', ` Task ID: ${selected.taskId}`), inner)
2229
+ );
2230
+ body.push(this.contentLine(this.theme.fg('muted', ` Agent: ${selected.agent}`), inner));
2231
+ body.push(this.contentLine(this.theme.fg('muted', ` Status: ${selected.status}`), inner));
2232
+ if (typeof selected.duration === 'number') {
2233
+ body.push(
2234
+ this.contentLine(this.theme.fg('muted', ` Duration: ${selected.duration}ms`), inner)
2235
+ );
2236
+ }
2237
+ if (selected.startedAt) {
2238
+ body.push(
2239
+ this.contentLine(this.theme.fg('muted', ` Started: ${selected.startedAt}`), inner)
2240
+ );
2241
+ }
2242
+ if (selected.completedAt) {
2243
+ body.push(
2244
+ this.contentLine(
2245
+ this.theme.fg('muted', ` Completed: ${selected.completedAt}`),
2246
+ inner
2247
+ )
2248
+ );
2249
+ }
2250
+ body.push(
2251
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2252
+ );
2253
+ body.push(this.contentLine(this.theme.bold(' Prompt'), inner));
2254
+
2255
+ const wrappedPrompt = wrapText(
2256
+ selected.prompt || '(no prompt recorded)',
2257
+ Math.max(10, inner - 4)
2258
+ );
2259
+ for (const wrapped of wrappedPrompt) {
2260
+ body.push(this.contentLine(` ${wrapped}`, inner));
2261
+ }
2262
+ body.push(
2263
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2264
+ );
2265
+ body.push(this.contentLine(this.theme.bold(' Task Output'), inner));
2266
+
2267
+ const taskBuffer = this.detailSessionId
2268
+ ? this.taskBuffers.get(this.getTaskFeedKey(this.detailSessionId, selected.taskId))
2269
+ : undefined;
2270
+
2271
+ const rendered = this.renderStreamLines(
2272
+ taskBuffer?.output ?? '',
2273
+ taskBuffer?.thinking ?? '',
2274
+ this.showTaskThinking,
2275
+ Math.max(12, inner - 4)
2276
+ );
2277
+ if (rendered.length === 0) {
2278
+ body.push(this.contentLine(this.theme.fg('dim', ' (no task output yet)'), inner));
2279
+ } else {
2280
+ for (const line of rendered) {
2281
+ body.push(this.contentLine(` ${line}`, inner));
2282
+ }
2283
+ }
2284
+ }
2285
+
2286
+ this.taskMaxScroll = Math.max(0, body.length - contentBudget);
2287
+ if (this.taskFollowing) {
2288
+ this.taskScrollOffset = this.taskMaxScroll;
2289
+ }
2290
+ if (this.taskScrollOffset > this.taskMaxScroll) {
2291
+ this.taskScrollOffset = this.taskMaxScroll;
2292
+ }
2293
+
2294
+ const windowedBody = body.slice(this.taskScrollOffset, this.taskScrollOffset + contentBudget);
2295
+ lines.push(...windowedBody);
2296
+ while (lines.length < maxLines - footerRows) {
2297
+ lines.push(this.contentLine('', inner));
2298
+ }
2299
+
2300
+ const scrollInfo =
2301
+ this.taskMaxScroll > 0
2302
+ ? this.theme.fg('dim', ` scroll ${this.taskScrollOffset}/${this.taskMaxScroll}`)
2303
+ : this.theme.fg('dim', ' scroll 0/0');
2304
+ const selectedTaskThinking =
2305
+ this.detailSessionId && selected
2306
+ ? this.taskBuffers.get(this.getTaskFeedKey(this.detailSessionId, selected.taskId))
2307
+ ?.thinking
2308
+ : '';
2309
+ const thinkingHint = selectedTaskThinking ? ' [t] Thinking' : '';
2310
+ const followHint = ` [f] ${this.taskFollowing ? 'Unfollow' : 'Follow'}`;
2311
+ lines.push(
2312
+ this.contentLine(
2313
+ `${scrollInfo} ${this.theme.fg('dim', `[↑↓] Scroll [[ and ]] Task${thinkingHint}${followHint} [Esc] Back`)}`,
2314
+ inner
2315
+ )
2316
+ );
2317
+ lines.push(buildBottomBorder(width));
2318
+ return lines.slice(0, maxLines);
2319
+ }
2320
+
2321
+ private contentLine(text: string, innerWidth: number): string {
2322
+ return `│${padRight(text, innerWidth)}│`;
2323
+ }
2324
+ }