@agentuity/coder-tui 2.0.8

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 (117) 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 +44 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +411 -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-state.d.ts +31 -0
  23. package/dist/hub-overlay-state.d.ts.map +1 -0
  24. package/dist/hub-overlay-state.js +78 -0
  25. package/dist/hub-overlay-state.js.map +1 -0
  26. package/dist/hub-overlay.d.ts +146 -0
  27. package/dist/hub-overlay.d.ts.map +1 -0
  28. package/dist/hub-overlay.js +2354 -0
  29. package/dist/hub-overlay.js.map +1 -0
  30. package/dist/inbound-rpc.d.ts +3 -0
  31. package/dist/inbound-rpc.d.ts.map +1 -0
  32. package/dist/inbound-rpc.js +29 -0
  33. package/dist/inbound-rpc.js.map +1 -0
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +1641 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/native-remote-ui-context.d.ts +5 -0
  39. package/dist/native-remote-ui-context.d.ts.map +1 -0
  40. package/dist/native-remote-ui-context.js +30 -0
  41. package/dist/native-remote-ui-context.js.map +1 -0
  42. package/dist/output-viewer.d.ts +49 -0
  43. package/dist/output-viewer.d.ts.map +1 -0
  44. package/dist/output-viewer.js +389 -0
  45. package/dist/output-viewer.js.map +1 -0
  46. package/dist/overlay.d.ts +40 -0
  47. package/dist/overlay.d.ts.map +1 -0
  48. package/dist/overlay.js +225 -0
  49. package/dist/overlay.js.map +1 -0
  50. package/dist/protocol.d.ts +605 -0
  51. package/dist/protocol.d.ts.map +1 -0
  52. package/dist/protocol.js +4 -0
  53. package/dist/protocol.js.map +1 -0
  54. package/dist/remote-lifecycle.d.ts +61 -0
  55. package/dist/remote-lifecycle.d.ts.map +1 -0
  56. package/dist/remote-lifecycle.js +190 -0
  57. package/dist/remote-lifecycle.js.map +1 -0
  58. package/dist/remote-session.d.ts +130 -0
  59. package/dist/remote-session.d.ts.map +1 -0
  60. package/dist/remote-session.js +896 -0
  61. package/dist/remote-session.js.map +1 -0
  62. package/dist/remote-tui.d.ts +42 -0
  63. package/dist/remote-tui.d.ts.map +1 -0
  64. package/dist/remote-tui.js +868 -0
  65. package/dist/remote-tui.js.map +1 -0
  66. package/dist/remote-ui-handler.d.ts +5 -0
  67. package/dist/remote-ui-handler.d.ts.map +1 -0
  68. package/dist/remote-ui-handler.js +53 -0
  69. package/dist/remote-ui-handler.js.map +1 -0
  70. package/dist/renderers.d.ts +34 -0
  71. package/dist/renderers.d.ts.map +1 -0
  72. package/dist/renderers.js +669 -0
  73. package/dist/renderers.js.map +1 -0
  74. package/dist/review.d.ts +15 -0
  75. package/dist/review.d.ts.map +1 -0
  76. package/dist/review.js +154 -0
  77. package/dist/review.js.map +1 -0
  78. package/dist/titlebar.d.ts +3 -0
  79. package/dist/titlebar.d.ts.map +1 -0
  80. package/dist/titlebar.js +59 -0
  81. package/dist/titlebar.js.map +1 -0
  82. package/dist/todo/index.d.ts +3 -0
  83. package/dist/todo/index.d.ts.map +1 -0
  84. package/dist/todo/index.js +3 -0
  85. package/dist/todo/index.js.map +1 -0
  86. package/dist/todo/store.d.ts +6 -0
  87. package/dist/todo/store.d.ts.map +1 -0
  88. package/dist/todo/store.js +43 -0
  89. package/dist/todo/store.js.map +1 -0
  90. package/dist/todo/types.d.ts +13 -0
  91. package/dist/todo/types.d.ts.map +1 -0
  92. package/dist/todo/types.js +2 -0
  93. package/dist/todo/types.js.map +1 -0
  94. package/package.json +42 -0
  95. package/src/chain-preview.ts +621 -0
  96. package/src/client.ts +527 -0
  97. package/src/commands.ts +132 -0
  98. package/src/footer.ts +305 -0
  99. package/src/handlers.ts +113 -0
  100. package/src/hub-overlay-state.ts +127 -0
  101. package/src/hub-overlay.ts +3037 -0
  102. package/src/inbound-rpc.ts +35 -0
  103. package/src/index.ts +1963 -0
  104. package/src/native-remote-ui-context.ts +41 -0
  105. package/src/output-viewer.ts +480 -0
  106. package/src/overlay.ts +294 -0
  107. package/src/protocol.ts +758 -0
  108. package/src/remote-lifecycle.ts +270 -0
  109. package/src/remote-session.ts +1100 -0
  110. package/src/remote-tui.ts +1023 -0
  111. package/src/remote-ui-handler.ts +86 -0
  112. package/src/renderers.ts +740 -0
  113. package/src/review.ts +201 -0
  114. package/src/titlebar.ts +63 -0
  115. package/src/todo/index.ts +2 -0
  116. package/src/todo/store.ts +49 -0
  117. package/src/todo/types.ts +14 -0
@@ -0,0 +1,3037 @@
1
+ import { type Theme, getMarkdownTheme } from '@mariozechner/pi-coding-agent';
2
+ import { matchesKey, Markdown as MdComponent } from '@mariozechner/pi-tui';
3
+ import {
4
+ buildProjectionFromEntries,
5
+ normalizeStreamProjection,
6
+ shouldReplaceStreamProjection,
7
+ type ConversationEntryLike,
8
+ type StreamBuffer,
9
+ type StreamProjection,
10
+ type StreamProjectionSource,
11
+ } from './hub-overlay-state.ts';
12
+ import { truncateToWidth } from './renderers.ts';
13
+ import type {
14
+ ConversationEntry as HubConversationEntry,
15
+ ReplayHistoryResponse as HubReplayResponse,
16
+ SessionEventHistoryItem as HubEventHistoryItem,
17
+ SessionEventHistoryResponse as HubEventHistoryResponse,
18
+ SessionListItem as HubSessionSummary,
19
+ SessionListResponse as HubListResponse,
20
+ SessionSnapshot as BaseHubSessionDetail,
21
+ SessionTodoItem as HubTodo,
22
+ SessionTodoListResponse as HubTodoListResponse,
23
+ SessionTodoSummary as HubTodoSummary,
24
+ SseHydrationMessage,
25
+ } from './protocol.ts';
26
+
27
+ interface Component {
28
+ render(width: number): string[];
29
+ handleInput?(data: string): void;
30
+ invalidate(): void;
31
+ }
32
+
33
+ interface Focusable {
34
+ focused: boolean;
35
+ }
36
+
37
+ interface TUIRef {
38
+ requestRender(): void;
39
+ }
40
+
41
+ type HubTask = BaseHubSessionDetail['tasks'][number];
42
+
43
+ type HubSessionDetail = BaseHubSessionDetail & {
44
+ todos?: HubTodo[];
45
+ todoSummary?: HubTodoSummary;
46
+ todosUnavailable?: string;
47
+ };
48
+
49
+ interface FeedEntry {
50
+ at: number;
51
+ sessionId?: string;
52
+ text: string;
53
+ }
54
+
55
+ interface SessionDigest {
56
+ status: string;
57
+ taskCount: number;
58
+ observerCount: number;
59
+ subAgentCount: number;
60
+ }
61
+
62
+ interface SessionStreamState {
63
+ controller: AbortController;
64
+ mode: 'summary' | 'full';
65
+ }
66
+
67
+ interface HubOverlayOptions {
68
+ baseUrl: string;
69
+ currentSessionId?: string;
70
+ initialSessionId?: string;
71
+ startInDetail?: boolean;
72
+ done: (result: undefined) => void;
73
+ }
74
+
75
+ type ScreenMode = 'list' | 'detail' | 'feed' | 'task';
76
+
77
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
78
+ const POLL_MS = 4_000;
79
+ const REQUEST_TIMEOUT_MS = 5_000;
80
+ const HISTORY_FETCH_FAILURE_COOLDOWN_MS = 15_000;
81
+ const MAX_FEED_ITEMS = 80;
82
+ const STREAM_SESSION_LIMIT = 8;
83
+
84
+ class HubRequestError extends Error {
85
+ public readonly _tag = 'HubRequestError';
86
+ public readonly path: string;
87
+ public readonly status: number;
88
+ public readonly statusText?: string;
89
+ public readonly body?: string;
90
+ public readonly plainArgs: {
91
+ path: string;
92
+ status: number;
93
+ statusText?: string;
94
+ body?: string;
95
+ };
96
+
97
+ constructor(args: {
98
+ message: string;
99
+ path: string;
100
+ status: number;
101
+ statusText?: string;
102
+ body?: string;
103
+ cause?: unknown;
104
+ }) {
105
+ super(args.message, args.cause === undefined ? undefined : { cause: args.cause });
106
+ this.name = 'HubRequestError';
107
+ this.path = args.path;
108
+ this.status = args.status;
109
+ this.statusText = args.statusText;
110
+ this.body = args.body;
111
+ this.plainArgs = {
112
+ path: args.path,
113
+ status: args.status,
114
+ statusText: args.statusText,
115
+ body: args.body,
116
+ };
117
+ }
118
+ }
119
+
120
+ function visibleWidth(text: string): number {
121
+ return text.replace(ANSI_RE, '').replace(/\t/g, ' ').length;
122
+ }
123
+
124
+ function padRight(text: string, width: number): string {
125
+ if (width <= 0) return '';
126
+ const normalized = text.replace(/\t/g, ' ');
127
+ const truncated = truncateToWidth(normalized, width);
128
+ const remaining = width - visibleWidth(truncated);
129
+ return remaining > 0 ? truncated + ' '.repeat(remaining) : truncated;
130
+ }
131
+
132
+ function hLine(width: number): string {
133
+ return width > 0 ? '─'.repeat(width) : '';
134
+ }
135
+
136
+ function buildTopBorder(width: number, title: string): string {
137
+ if (width <= 0) return '';
138
+ if (width === 1) return '╭';
139
+ if (width === 2) return '╭╮';
140
+
141
+ const inner = width - 2;
142
+ const titleText = ` ${title} `;
143
+ if (titleText.length >= inner) return `╭${hLine(inner)}╮`;
144
+
145
+ const left = Math.floor((inner - titleText.length) / 2);
146
+ const right = inner - titleText.length - left;
147
+ return `╭${hLine(left)}${titleText}${hLine(right)}╮`;
148
+ }
149
+
150
+ function buildBottomBorder(width: number): string {
151
+ if (width <= 0) return '';
152
+ if (width === 1) return '╰';
153
+ if (width === 2) return '╰╯';
154
+ return `╰${hLine(width - 2)}╯`;
155
+ }
156
+
157
+ function formatClock(ms: number): string {
158
+ const d = new Date(ms);
159
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
160
+ }
161
+
162
+ function formatRelative(value: string | number): string {
163
+ const ts = typeof value === 'number' ? value : Date.parse(value);
164
+ if (Number.isNaN(ts)) return '-';
165
+ const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000));
166
+ if (seconds < 60) return `${seconds}s ago`;
167
+ const minutes = Math.floor(seconds / 60);
168
+ if (minutes < 60) return `${minutes}m ago`;
169
+ const hours = Math.floor(minutes / 60);
170
+ if (hours < 24) return `${hours}h ago`;
171
+ const days = Math.floor(hours / 24);
172
+ return `${days}d ago`;
173
+ }
174
+
175
+ function formatElapsedCompact(ms: number): string {
176
+ if (ms < 1_000) return `${ms}ms`;
177
+ if (ms < 60_000) return `${Math.round(ms / 1_000)}s`;
178
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
179
+ return `${Math.round(ms / 3_600_000)}h`;
180
+ }
181
+
182
+ function shortId(id: string): string {
183
+ if (id.length <= 12) return id;
184
+ return id.slice(0, 12);
185
+ }
186
+
187
+ function getVisibleRange(total: number, selected: number, windowSize: number): [number, number] {
188
+ if (total <= windowSize) return [0, total];
189
+ const half = Math.floor(windowSize / 2);
190
+ let start = Math.max(0, selected - half);
191
+ let end = start + windowSize;
192
+ if (end > total) {
193
+ end = total;
194
+ start = end - windowSize;
195
+ }
196
+ return [start, end];
197
+ }
198
+
199
+ function wrapText(text: string, width: number): string[] {
200
+ if (width <= 0) return [''];
201
+ if (!text) return [''];
202
+
203
+ const lines: string[] = [];
204
+ const paragraphs = text.split(/\r?\n/);
205
+ for (const paragraph of paragraphs) {
206
+ const words = paragraph.split(/\s+/).filter(Boolean);
207
+ if (words.length === 0) {
208
+ lines.push('');
209
+ continue;
210
+ }
211
+
212
+ let current = words[0]!;
213
+ for (let i = 1; i < words.length; i++) {
214
+ const word = words[i]!;
215
+ const candidate = `${current} ${word}`;
216
+ if (candidate.length <= width) {
217
+ current = candidate;
218
+ } else {
219
+ lines.push(current);
220
+ current = word.length > width ? word.slice(0, width) : word;
221
+ }
222
+ }
223
+ lines.push(current);
224
+ }
225
+
226
+ return lines;
227
+ }
228
+
229
+ function toSingleLine(text: string): string {
230
+ return text
231
+ .replace(/[\r\n\t]+/g, ' ')
232
+ .replace(/[\x00-\x1f\x7f]/g, '')
233
+ .replace(/\s+/g, ' ')
234
+ .trim();
235
+ }
236
+
237
+ interface MessageSegments {
238
+ output: string;
239
+ thinking: string;
240
+ }
241
+
242
+ function summarizeArgs(value: unknown, maxWidth = 100): string {
243
+ if (!value || typeof value !== 'object') return '';
244
+ const args = value as Record<string, unknown>;
245
+ if (typeof args.command === 'string')
246
+ return truncateToWidth(toSingleLine(args.command), maxWidth);
247
+ if (typeof args.path === 'string') return truncateToWidth(toSingleLine(args.path), maxWidth);
248
+ if (typeof args.filePath === 'string')
249
+ return truncateToWidth(toSingleLine(args.filePath), maxWidth);
250
+ if (typeof args.pattern === 'string')
251
+ return truncateToWidth(toSingleLine(args.pattern), maxWidth);
252
+ try {
253
+ const raw = JSON.stringify(value);
254
+ return truncateToWidth(toSingleLine(raw), 100);
255
+ } catch {
256
+ return '';
257
+ }
258
+ }
259
+
260
+ function summarizeToolCall(name: string, argsRaw: unknown): string | null {
261
+ const args =
262
+ argsRaw && typeof argsRaw === 'object' ? (argsRaw as Record<string, unknown>) : undefined;
263
+
264
+ if (name === 'task') {
265
+ const agent = typeof args?.subagent_type === 'string' ? args.subagent_type : '?';
266
+ const description =
267
+ typeof args?.description === 'string'
268
+ ? truncateToWidth(toSingleLine(args.description), 80)
269
+ : '';
270
+ return description ? `${agent} - ${description}` : `${agent} - delegated task`;
271
+ }
272
+
273
+ if (name === 'parallel_tasks') {
274
+ const tasks = Array.isArray(args?.tasks) ? args.tasks : [];
275
+ const agents = tasks
276
+ .map((task) =>
277
+ task && typeof task === 'object'
278
+ ? (task as Record<string, unknown>).subagent_type
279
+ : undefined
280
+ )
281
+ .filter((agent): agent is string => typeof agent === 'string');
282
+ if (agents.length > 0) return agents.join(' + ');
283
+ return 'parallel delegated tasks';
284
+ }
285
+
286
+ return null;
287
+ }
288
+
289
+ function extractToolResultText(contentRaw: unknown): string {
290
+ if (typeof contentRaw === 'string') return contentRaw;
291
+ if (!Array.isArray(contentRaw)) return '';
292
+ const parts: string[] = [];
293
+ for (const item of contentRaw) {
294
+ if (!item || typeof item !== 'object') continue;
295
+ const block = item as Record<string, unknown>;
296
+ if (typeof block.text === 'string') {
297
+ parts.push(block.text);
298
+ }
299
+ }
300
+ return parts.join('\n');
301
+ }
302
+
303
+ function formatDuration(ms: number): string {
304
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
305
+ return `${ms}ms`;
306
+ }
307
+
308
+ function extractMessageSegments(data: Record<string, unknown> | undefined): MessageSegments {
309
+ const fallback = typeof data?.text === 'string' ? data.text : '';
310
+ const messageRaw = data?.message;
311
+ if (!messageRaw || typeof messageRaw !== 'object') {
312
+ return { output: fallback, thinking: '' };
313
+ }
314
+
315
+ const message = messageRaw as Record<string, unknown>;
316
+ const role = typeof message.role === 'string' ? message.role : '';
317
+ if (role && role !== 'assistant') {
318
+ return { output: '', thinking: '' };
319
+ }
320
+
321
+ const content = message.content;
322
+ const outputParts: string[] = [];
323
+ const thinkingParts: string[] = [];
324
+
325
+ if (typeof content === 'string') {
326
+ outputParts.push(content);
327
+ } else if (Array.isArray(content)) {
328
+ for (const blockRaw of content) {
329
+ if (!blockRaw || typeof blockRaw !== 'object') continue;
330
+ const block = blockRaw as Record<string, unknown>;
331
+ const type = typeof block.type === 'string' ? block.type : '';
332
+
333
+ if (type === 'text' && typeof block.text === 'string') {
334
+ outputParts.push(block.text);
335
+ continue;
336
+ }
337
+ if (type === 'thinking' && typeof block.thinking === 'string') {
338
+ thinkingParts.push(block.thinking);
339
+ continue;
340
+ }
341
+ }
342
+ }
343
+
344
+ if (outputParts.length === 0 && fallback) {
345
+ outputParts.push(fallback);
346
+ }
347
+
348
+ return {
349
+ output: outputParts.join('\n\n').trim(),
350
+ thinking: thinkingParts.join('\n\n').trim(),
351
+ };
352
+ }
353
+
354
+ export class HubOverlay implements Component, Focusable {
355
+ public focused = true;
356
+
357
+ private readonly tui: TUIRef;
358
+ private readonly theme: Theme;
359
+ private readonly done: (result: undefined) => void;
360
+ private readonly baseUrl: string;
361
+ private readonly currentSessionId?: string;
362
+
363
+ private screen: ScreenMode;
364
+ private selectedIndex = 0;
365
+ private detailSessionId: string | null;
366
+ private detailScrollOffset = 0;
367
+ private detailMaxScroll = 0;
368
+ private feedScrollOffset = 0;
369
+ private feedMaxScroll = 0;
370
+ private feedScope: 'global' | 'session' = 'global';
371
+ private feedViewMode: 'stream' | 'events' = 'stream';
372
+ private showFeedThinking = true;
373
+ private feedFollowing = true;
374
+ private taskScrollOffset = 0;
375
+ private taskMaxScroll = 0;
376
+ private selectedTaskIndex = 0;
377
+ private showTaskThinking = true;
378
+ private taskFollowing = true;
379
+
380
+ private sessions: HubSessionSummary[] = [];
381
+ private detail: HubSessionDetail | null = null;
382
+ private cachedTodos: { todos: HubTodo[]; summary?: HubTodoSummary; sessionId: string } | null =
383
+ null;
384
+ private feed: FeedEntry[] = [];
385
+ private sessionFeed = new Map<string, FeedEntry[]>();
386
+ private sessionHistoryFeed = new Map<string, FeedEntry[]>();
387
+ private sessionBuffers = new Map<string, StreamBuffer>();
388
+ private taskBuffers = new Map<string, StreamBuffer>();
389
+ private streamSources = new Map<string, StreamProjectionSource>();
390
+ private replayLoadedSessions = new Set<string>();
391
+ private replayInFlight = new Set<string>();
392
+ private replayRequestControllers = new Map<string, AbortController>();
393
+ private replayRequestGenerations = new Map<string, number>();
394
+ private replayFailureUntil = new Map<string, number>();
395
+ private eventHistoryLoadedSessions = new Set<string>();
396
+ private eventHistoryInFlight = new Set<string>();
397
+ private eventHistoryRequestControllers = new Map<string, AbortController>();
398
+ private eventHistoryRequestGenerations = new Map<string, number>();
399
+ private eventHistoryFailureUntil = new Map<string, number>();
400
+ private previousDigests = new Map<string, SessionDigest>();
401
+
402
+ private loadingList = true;
403
+ private loadingDetail = false;
404
+ private listError = '';
405
+ private detailError = '';
406
+ private detailTodoRequestId = 0;
407
+ private resumeBusySessionId: string | null = null;
408
+ private lastUpdatedAt = 0;
409
+ private listInFlight = false;
410
+ private detailInFlight = false;
411
+ private sseControllers = new Map<string, SessionStreamState>();
412
+
413
+ private disposed = false;
414
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
415
+ private mdRenderer: MdComponent | null = null;
416
+
417
+ constructor(tui: TUIRef, theme: Theme, options: HubOverlayOptions) {
418
+ this.tui = tui;
419
+ this.theme = theme;
420
+ this.done = options.done;
421
+ this.baseUrl = options.baseUrl;
422
+ this.currentSessionId = options.currentSessionId;
423
+ this.detailSessionId = options.initialSessionId ?? null;
424
+ this.screen = options.startInDetail && options.initialSessionId ? 'detail' : 'list';
425
+ try {
426
+ const mdTheme = getMarkdownTheme?.();
427
+ if (mdTheme) {
428
+ this.mdRenderer = new MdComponent('', 0, 0, mdTheme);
429
+ }
430
+ } catch {
431
+ this.mdRenderer = null;
432
+ }
433
+
434
+ void this.refreshList(true);
435
+ if (this.detailSessionId) {
436
+ void this.refreshDetail(this.detailSessionId, true);
437
+ }
438
+
439
+ this.pollTimer = setInterval(() => {
440
+ if (this.disposed) return;
441
+ void this.refreshList();
442
+ if (this.detailSessionId) {
443
+ void this.refreshDetail(this.detailSessionId);
444
+ }
445
+ }, POLL_MS);
446
+ }
447
+
448
+ handleInput(data: string): void {
449
+ if (this.disposed) return;
450
+
451
+ const inSessionContext = this.isSessionContext();
452
+
453
+ if (data === '1') {
454
+ this.screen = inSessionContext ? 'detail' : 'list';
455
+ void this.syncSseStreams(this.sessions);
456
+ this.requestRender();
457
+ return;
458
+ }
459
+
460
+ if (data === '2') {
461
+ // Session/task views use task drill-down, not direct feed jumps from task screen.
462
+ if (this.screen === 'task') return;
463
+
464
+ if (inSessionContext && this.detailSessionId) {
465
+ this.feedScope = 'session';
466
+ this.feedViewMode = 'stream';
467
+ } else {
468
+ this.feedScope = 'global';
469
+ this.feedViewMode = 'events';
470
+ }
471
+ this.feedScrollOffset = 0;
472
+ this.feedFollowing = true;
473
+ this.screen = 'feed';
474
+ void this.syncSseStreams(this.sessions);
475
+ this.requestRender();
476
+ return;
477
+ }
478
+
479
+ if (data === '3') {
480
+ const atSessionLevel =
481
+ !!this.detailSessionId &&
482
+ (this.screen === 'detail' || (this.screen === 'feed' && this.feedScope === 'session'));
483
+ if (!atSessionLevel) return;
484
+
485
+ this.feedScope = 'session';
486
+ this.feedViewMode = 'events';
487
+ this.feedScrollOffset = 0;
488
+ this.feedFollowing = true;
489
+ this.screen = 'feed';
490
+ void this.syncSseStreams(this.sessions);
491
+ this.requestRender();
492
+ return;
493
+ }
494
+
495
+ if (matchesKey(data, 'escape')) {
496
+ if (this.screen === 'task') {
497
+ this.screen = 'detail';
498
+ void this.syncSseStreams(this.sessions);
499
+ this.requestRender();
500
+ return;
501
+ }
502
+ if (this.screen === 'feed') {
503
+ this.screen = this.feedScope === 'session' ? 'detail' : 'list';
504
+ void this.syncSseStreams(this.sessions);
505
+ this.requestRender();
506
+ return;
507
+ }
508
+ if (this.screen === 'detail') {
509
+ this.screen = 'list';
510
+ void this.syncSseStreams(this.sessions);
511
+ this.requestRender();
512
+ return;
513
+ }
514
+ this.close();
515
+ return;
516
+ }
517
+
518
+ if (matchesKey(data, 'r') || data.toLowerCase() === 'r') {
519
+ void this.refreshList();
520
+ if (
521
+ (this.screen === 'detail' || this.screen === 'task' || this.screen === 'feed') &&
522
+ this.detailSessionId
523
+ ) {
524
+ void this.refreshDetail(this.detailSessionId);
525
+ }
526
+ return;
527
+ }
528
+
529
+ if (this.screen === 'list') {
530
+ this.handleListInput(data);
531
+ return;
532
+ }
533
+
534
+ if (this.screen === 'feed') {
535
+ this.handleFeedInput(data);
536
+ return;
537
+ }
538
+
539
+ if (this.screen === 'task') {
540
+ this.handleTaskInput(data);
541
+ return;
542
+ }
543
+
544
+ this.handleDetailInput(data);
545
+ }
546
+
547
+ render(width: number): string[] {
548
+ const safeWidth = Math.max(6, width);
549
+ const termHeight = process.stdout.rows || 40;
550
+ const maxLines = Math.max(12, Math.floor(termHeight * 0.95) - 2);
551
+
552
+ const lines =
553
+ this.screen === 'detail'
554
+ ? this.renderDetailScreen(safeWidth, maxLines)
555
+ : this.screen === 'feed'
556
+ ? this.renderFeedScreen(safeWidth, maxLines)
557
+ : this.screen === 'task'
558
+ ? this.renderTaskScreen(safeWidth, maxLines)
559
+ : this.renderListScreen(safeWidth, maxLines);
560
+ return lines.map((line) => truncateToWidth(line, safeWidth));
561
+ }
562
+
563
+ invalidate(): void {
564
+ this.requestRender();
565
+ }
566
+
567
+ dispose(): void {
568
+ if (this.disposed) return;
569
+ this.disposed = true;
570
+ if (this.pollTimer) {
571
+ clearInterval(this.pollTimer);
572
+ this.pollTimer = null;
573
+ }
574
+ for (const stream of this.sseControllers.values()) {
575
+ stream.controller.abort();
576
+ }
577
+ this.sseControllers.clear();
578
+ for (const controller of this.replayRequestControllers.values()) {
579
+ controller.abort();
580
+ }
581
+ this.replayRequestControllers.clear();
582
+ for (const controller of this.eventHistoryRequestControllers.values()) {
583
+ controller.abort();
584
+ }
585
+ this.eventHistoryRequestControllers.clear();
586
+ }
587
+
588
+ private requestRender(): void {
589
+ try {
590
+ this.tui.requestRender();
591
+ } catch {
592
+ // Best effort render invalidation.
593
+ }
594
+ }
595
+
596
+ private nextRequestGeneration(generations: Map<string, number>, sessionId: string): number {
597
+ const next = (generations.get(sessionId) ?? 0) + 1;
598
+ generations.set(sessionId, next);
599
+ return next;
600
+ }
601
+
602
+ private invalidateSessionRequest(
603
+ generations: Map<string, number>,
604
+ controllers: Map<string, AbortController>,
605
+ inFlight: Set<string>,
606
+ sessionId: string
607
+ ): void {
608
+ this.nextRequestGeneration(generations, sessionId);
609
+ inFlight.delete(sessionId);
610
+ const controller = controllers.get(sessionId);
611
+ if (controller) controller.abort();
612
+ controllers.delete(sessionId);
613
+ }
614
+
615
+ private isCurrentSessionRequest(
616
+ generations: Map<string, number>,
617
+ controllers: Map<string, AbortController>,
618
+ sessionId: string,
619
+ generation: number,
620
+ controller: AbortController
621
+ ): boolean {
622
+ return (
623
+ !controller.signal.aborted &&
624
+ generations.get(sessionId) === generation &&
625
+ controllers.get(sessionId) === controller
626
+ );
627
+ }
628
+
629
+ private isFailureCoolingDown(failureUntil: Map<string, number>, sessionId: string): boolean {
630
+ const until = failureUntil.get(sessionId);
631
+ if (!until) return false;
632
+ if (until <= Date.now()) {
633
+ failureUntil.delete(sessionId);
634
+ return false;
635
+ }
636
+ return true;
637
+ }
638
+
639
+ private markFailureCooldown(failureUntil: Map<string, number>, sessionId: string): void {
640
+ failureUntil.set(sessionId, Date.now() + HISTORY_FETCH_FAILURE_COOLDOWN_MS);
641
+ }
642
+
643
+ private clearReplayFailureCooldown(sessionId: string): void {
644
+ this.replayFailureUntil.delete(sessionId);
645
+ }
646
+
647
+ private clearEventHistoryFailureCooldown(sessionId: string): void {
648
+ this.eventHistoryFailureUntil.delete(sessionId);
649
+ }
650
+
651
+ private resetHistoricalSessionState(sessionId: string): void {
652
+ this.invalidateSessionRequest(
653
+ this.replayRequestGenerations,
654
+ this.replayRequestControllers,
655
+ this.replayInFlight,
656
+ sessionId
657
+ );
658
+ this.invalidateSessionRequest(
659
+ this.eventHistoryRequestGenerations,
660
+ this.eventHistoryRequestControllers,
661
+ this.eventHistoryInFlight,
662
+ sessionId
663
+ );
664
+ this.replayLoadedSessions.delete(sessionId);
665
+ this.eventHistoryLoadedSessions.delete(sessionId);
666
+ this.sessionHistoryFeed.delete(sessionId);
667
+ this.clearReplayFailureCooldown(sessionId);
668
+ this.clearEventHistoryFailureCooldown(sessionId);
669
+ }
670
+
671
+ private close(): void {
672
+ this.dispose();
673
+ this.done(undefined);
674
+ }
675
+
676
+ private handleListInput(data: string): void {
677
+ const count = this.sessions.length;
678
+
679
+ if (matchesKey(data, 'up') || data.toLowerCase() === 'k') {
680
+ if (count > 0) {
681
+ this.selectedIndex = (this.selectedIndex - 1 + count) % count;
682
+ this.requestRender();
683
+ }
684
+ return;
685
+ }
686
+
687
+ if (matchesKey(data, 'down') || data.toLowerCase() === 'j') {
688
+ if (count > 0) {
689
+ this.selectedIndex = (this.selectedIndex + 1) % count;
690
+ this.requestRender();
691
+ }
692
+ return;
693
+ }
694
+
695
+ if (matchesKey(data, 'enter')) {
696
+ const selected = this.sessions[this.selectedIndex];
697
+ if (!selected) return;
698
+ this.detailSessionId = selected.sessionId;
699
+ this.detailScrollOffset = 0;
700
+ this.selectedTaskIndex = 0;
701
+ this.showTaskThinking = true;
702
+ this.taskFollowing = true;
703
+ this.screen = 'detail';
704
+ void this.syncSseStreams(this.sessions);
705
+ void this.refreshDetail(selected.sessionId, true);
706
+ this.requestRender();
707
+ }
708
+ }
709
+
710
+ private isSessionContext(): boolean {
711
+ return (
712
+ this.screen === 'detail' ||
713
+ this.screen === 'task' ||
714
+ (this.screen === 'feed' && this.feedScope === 'session')
715
+ );
716
+ }
717
+
718
+ private handleDetailInput(data: string): void {
719
+ const tasks = this.getDetailTasks();
720
+
721
+ if ((matchesKey(data, 'w') || data.toLowerCase() === 'w') && this.detailSessionId) {
722
+ if (this.canResumeSession(this.detail)) {
723
+ void this.resumeSession(this.detailSessionId);
724
+ }
725
+ return;
726
+ }
727
+
728
+ if (matchesKey(data, 'up')) {
729
+ if (tasks.length > 0) {
730
+ this.selectedTaskIndex = (this.selectedTaskIndex - 1 + tasks.length) % tasks.length;
731
+ this.requestRender();
732
+ }
733
+ return;
734
+ }
735
+
736
+ if (matchesKey(data, 'down')) {
737
+ if (tasks.length > 0) {
738
+ this.selectedTaskIndex = (this.selectedTaskIndex + 1) % tasks.length;
739
+ this.requestRender();
740
+ }
741
+ return;
742
+ }
743
+
744
+ if (matchesKey(data, 'enter')) {
745
+ if (tasks.length > 0) {
746
+ this.selectedTaskIndex = Math.min(this.selectedTaskIndex, tasks.length - 1);
747
+ this.taskScrollOffset = 0;
748
+ this.showTaskThinking = true;
749
+ this.taskFollowing = true;
750
+ this.screen = 'task';
751
+ this.requestRender();
752
+ }
753
+ return;
754
+ }
755
+
756
+ if (data.toLowerCase() === 'k') {
757
+ if (this.detailScrollOffset > 0) {
758
+ this.detailScrollOffset -= 1;
759
+ this.requestRender();
760
+ }
761
+ return;
762
+ }
763
+
764
+ if (data.toLowerCase() === 'j') {
765
+ if (this.detailScrollOffset < this.detailMaxScroll) {
766
+ this.detailScrollOffset += 1;
767
+ this.requestRender();
768
+ }
769
+ return;
770
+ }
771
+
772
+ if (matchesKey(data, 'pageUp') || matchesKey(data, 'shift+up')) {
773
+ const jump = Math.max(1, Math.floor((process.stdout.rows || 40) / 3));
774
+ this.detailScrollOffset = Math.max(0, this.detailScrollOffset - jump);
775
+ this.requestRender();
776
+ return;
777
+ }
778
+
779
+ if (matchesKey(data, 'pageDown') || matchesKey(data, 'shift+down')) {
780
+ const jump = Math.max(1, Math.floor((process.stdout.rows || 40) / 3));
781
+ this.detailScrollOffset = Math.min(this.detailMaxScroll, this.detailScrollOffset + jump);
782
+ this.requestRender();
783
+ }
784
+ }
785
+
786
+ private handleFeedInput(data: string): void {
787
+ const maxScroll = this.feedMaxScroll;
788
+ const jump = Math.max(1, Math.floor((process.stdout.rows || 40) / 3));
789
+
790
+ if (matchesKey(data, 't') || data.toLowerCase() === 't') {
791
+ if (this.feedScope === 'session' && this.feedViewMode === 'stream') {
792
+ this.showFeedThinking = !this.showFeedThinking;
793
+ this.requestRender();
794
+ }
795
+ return;
796
+ }
797
+
798
+ if (
799
+ this.feedScope === 'session' &&
800
+ this.feedViewMode === 'stream' &&
801
+ (matchesKey(data, 'enter') || matchesKey(data, 'v') || data.toLowerCase() === 'v')
802
+ ) {
803
+ const tasks = this.getDetailTasks();
804
+ if (tasks.length > 0) {
805
+ this.selectedTaskIndex = Math.min(this.selectedTaskIndex, tasks.length - 1);
806
+ this.taskScrollOffset = 0;
807
+ this.showTaskThinking = true;
808
+ this.taskFollowing = true;
809
+ this.screen = 'task';
810
+ this.requestRender();
811
+ }
812
+ return;
813
+ }
814
+
815
+ if (matchesKey(data, 'f') || data.toLowerCase() === 'f') {
816
+ this.feedFollowing = !this.feedFollowing;
817
+ if (this.feedFollowing) {
818
+ this.feedScrollOffset = maxScroll;
819
+ }
820
+ this.requestRender();
821
+ return;
822
+ }
823
+
824
+ if (matchesKey(data, 'up') || data.toLowerCase() === 'k') {
825
+ if (this.feedScrollOffset > 0) {
826
+ this.feedFollowing = false;
827
+ this.feedScrollOffset -= 1;
828
+ this.requestRender();
829
+ }
830
+ return;
831
+ }
832
+
833
+ if (matchesKey(data, 'down') || data.toLowerCase() === 'j') {
834
+ if (this.feedScrollOffset < this.feedMaxScroll) {
835
+ this.feedFollowing = false;
836
+ this.feedScrollOffset += 1;
837
+ this.requestRender();
838
+ }
839
+ return;
840
+ }
841
+
842
+ if (matchesKey(data, 'pageUp') || matchesKey(data, 'shift+up')) {
843
+ this.feedFollowing = false;
844
+ this.feedScrollOffset = Math.max(0, this.feedScrollOffset - jump);
845
+ this.requestRender();
846
+ return;
847
+ }
848
+
849
+ if (matchesKey(data, 'pageDown') || matchesKey(data, 'shift+down')) {
850
+ this.feedFollowing = false;
851
+ this.feedScrollOffset = Math.min(this.feedMaxScroll, this.feedScrollOffset + jump);
852
+ this.requestRender();
853
+ return;
854
+ }
855
+
856
+ if (data === 'g') {
857
+ this.feedFollowing = false;
858
+ this.feedScrollOffset = 0;
859
+ this.requestRender();
860
+ return;
861
+ }
862
+
863
+ if (data === 'G') {
864
+ this.feedFollowing = false;
865
+ this.feedScrollOffset = this.feedMaxScroll;
866
+ this.requestRender();
867
+ return;
868
+ }
869
+
870
+ if (data === '{') {
871
+ this.feedFollowing = false;
872
+ const segment = Math.max(1, Math.floor((this.feedMaxScroll || 1) * 0.25));
873
+ this.feedScrollOffset = Math.max(0, this.feedScrollOffset - segment);
874
+ this.requestRender();
875
+ return;
876
+ }
877
+
878
+ if (data === '}') {
879
+ this.feedFollowing = false;
880
+ const segment = Math.max(1, Math.floor((this.feedMaxScroll || 1) * 0.25));
881
+ this.feedScrollOffset = Math.min(this.feedMaxScroll, this.feedScrollOffset + segment);
882
+ this.requestRender();
883
+ return;
884
+ }
885
+ }
886
+
887
+ private handleTaskInput(data: string): void {
888
+ const tasks = this.getDetailTasks();
889
+ const maxScroll = this.taskMaxScroll;
890
+ const jump = Math.max(1, Math.floor((process.stdout.rows || 40) / 3));
891
+
892
+ if (matchesKey(data, 't') || data.toLowerCase() === 't') {
893
+ this.showTaskThinking = !this.showTaskThinking;
894
+ this.requestRender();
895
+ return;
896
+ }
897
+
898
+ if (matchesKey(data, 'f') || data.toLowerCase() === 'f') {
899
+ this.taskFollowing = !this.taskFollowing;
900
+ if (this.taskFollowing) {
901
+ this.taskScrollOffset = maxScroll;
902
+ }
903
+ this.requestRender();
904
+ return;
905
+ }
906
+
907
+ if (data === '[') {
908
+ if (tasks.length > 0) {
909
+ this.selectedTaskIndex = (this.selectedTaskIndex - 1 + tasks.length) % tasks.length;
910
+ this.taskScrollOffset = 0;
911
+ this.taskFollowing = true;
912
+ this.requestRender();
913
+ }
914
+ return;
915
+ }
916
+
917
+ if (data === ']') {
918
+ if (tasks.length > 0) {
919
+ this.selectedTaskIndex = (this.selectedTaskIndex + 1) % tasks.length;
920
+ this.taskScrollOffset = 0;
921
+ this.taskFollowing = true;
922
+ this.requestRender();
923
+ }
924
+ return;
925
+ }
926
+
927
+ if (matchesKey(data, 'up') || data.toLowerCase() === 'k') {
928
+ if (this.taskScrollOffset > 0) {
929
+ this.taskFollowing = false;
930
+ this.taskScrollOffset -= 1;
931
+ this.requestRender();
932
+ }
933
+ return;
934
+ }
935
+
936
+ if (matchesKey(data, 'down') || data.toLowerCase() === 'j') {
937
+ if (this.taskScrollOffset < this.taskMaxScroll) {
938
+ this.taskFollowing = false;
939
+ this.taskScrollOffset += 1;
940
+ this.requestRender();
941
+ }
942
+ return;
943
+ }
944
+
945
+ if (matchesKey(data, 'pageUp') || matchesKey(data, 'shift+up')) {
946
+ this.taskFollowing = false;
947
+ this.taskScrollOffset = Math.max(0, this.taskScrollOffset - jump);
948
+ this.requestRender();
949
+ return;
950
+ }
951
+
952
+ if (matchesKey(data, 'pageDown') || matchesKey(data, 'shift+down')) {
953
+ this.taskFollowing = false;
954
+ this.taskScrollOffset = Math.min(this.taskMaxScroll, this.taskScrollOffset + jump);
955
+ this.requestRender();
956
+ return;
957
+ }
958
+
959
+ if (data === 'g') {
960
+ this.taskFollowing = false;
961
+ this.taskScrollOffset = 0;
962
+ this.requestRender();
963
+ return;
964
+ }
965
+
966
+ if (data === 'G') {
967
+ this.taskFollowing = false;
968
+ this.taskScrollOffset = this.taskMaxScroll;
969
+ this.requestRender();
970
+ return;
971
+ }
972
+
973
+ if (data === '{') {
974
+ this.taskFollowing = false;
975
+ const segment = Math.max(1, Math.floor((this.taskMaxScroll || 1) * 0.25));
976
+ this.taskScrollOffset = Math.max(0, this.taskScrollOffset - segment);
977
+ this.requestRender();
978
+ return;
979
+ }
980
+
981
+ if (data === '}') {
982
+ this.taskFollowing = false;
983
+ const segment = Math.max(1, Math.floor((this.taskMaxScroll || 1) * 0.25));
984
+ this.taskScrollOffset = Math.min(this.taskMaxScroll, this.taskScrollOffset + segment);
985
+ this.requestRender();
986
+ return;
987
+ }
988
+ }
989
+
990
+ private getDetailTasks(): HubTask[] {
991
+ return this.detail?.tasks ?? [];
992
+ }
993
+
994
+ private getSessionBuffer(sessionId: string): StreamBuffer {
995
+ let buffer = this.sessionBuffers.get(sessionId);
996
+ if (!buffer) {
997
+ buffer = { output: '', thinking: '' };
998
+ this.sessionBuffers.set(sessionId, buffer);
999
+ }
1000
+ return buffer;
1001
+ }
1002
+
1003
+ private getTaskBuffer(sessionId: string, taskId: string): StreamBuffer {
1004
+ const key = this.getTaskFeedKey(sessionId, taskId);
1005
+ let buffer = this.taskBuffers.get(key);
1006
+ if (!buffer) {
1007
+ buffer = { output: '', thinking: '' };
1008
+ this.taskBuffers.set(key, buffer);
1009
+ }
1010
+ return buffer;
1011
+ }
1012
+
1013
+ private getStreamSource(sessionId: string): StreamProjectionSource {
1014
+ return this.streamSources.get(sessionId) ?? 'none';
1015
+ }
1016
+
1017
+ private setStreamSource(sessionId: string, source: StreamProjectionSource): void {
1018
+ this.streamSources.set(sessionId, source);
1019
+ }
1020
+
1021
+ private clearTaskBuffers(sessionId: string): void {
1022
+ for (const key of this.taskBuffers.keys()) {
1023
+ if (key.startsWith(`${sessionId}:`)) {
1024
+ this.taskBuffers.delete(key);
1025
+ }
1026
+ }
1027
+ }
1028
+
1029
+ private replaceStreamProjection(
1030
+ sessionId: string,
1031
+ projectionRaw: StreamProjection | null | undefined,
1032
+ source: Exclude<StreamProjectionSource, 'live'>
1033
+ ): boolean {
1034
+ if (!projectionRaw) return false;
1035
+ const currentSource = this.getStreamSource(sessionId);
1036
+ if (!shouldReplaceStreamProjection(currentSource, source)) return false;
1037
+
1038
+ const projection = normalizeStreamProjection(projectionRaw);
1039
+ const sessionBuffer = this.getSessionBuffer(sessionId);
1040
+ sessionBuffer.output = projection.output;
1041
+ sessionBuffer.thinking = projection.thinking;
1042
+
1043
+ this.clearTaskBuffers(sessionId);
1044
+ for (const [taskId, block] of Object.entries(projection.tasks)) {
1045
+ const taskBuffer = this.getTaskBuffer(sessionId, taskId);
1046
+ taskBuffer.output = block.output;
1047
+ taskBuffer.thinking = block.thinking;
1048
+ }
1049
+
1050
+ this.setStreamSource(sessionId, source);
1051
+ return true;
1052
+ }
1053
+
1054
+ private applyReplayEntries(sessionId: string, entries: ConversationEntryLike[]): void {
1055
+ const projection = buildProjectionFromEntries(entries);
1056
+ this.clearReplayFailureCooldown(sessionId);
1057
+ if (this.replaceStreamProjection(sessionId, projection, 'replay')) {
1058
+ this.replayLoadedSessions.add(sessionId);
1059
+ }
1060
+ }
1061
+
1062
+ private appendBufferText(
1063
+ sessionId: string,
1064
+ kind: 'output' | 'thinking',
1065
+ chunk: string,
1066
+ taskId?: string
1067
+ ): void {
1068
+ if (!chunk) return;
1069
+ this.clearReplayFailureCooldown(sessionId);
1070
+ if (this.replayInFlight.has(sessionId)) {
1071
+ this.invalidateSessionRequest(
1072
+ this.replayRequestGenerations,
1073
+ this.replayRequestControllers,
1074
+ this.replayInFlight,
1075
+ sessionId
1076
+ );
1077
+ }
1078
+ this.setStreamSource(sessionId, 'live');
1079
+
1080
+ // Session stream is lead/top-level only; task-scoped output lives in task buffers.
1081
+ if (!taskId) {
1082
+ const sessionBuffer = this.getSessionBuffer(sessionId);
1083
+ if (kind === 'output') sessionBuffer.output += chunk;
1084
+ else sessionBuffer.thinking += chunk;
1085
+ }
1086
+
1087
+ if (taskId) {
1088
+ const taskBuffer = this.getTaskBuffer(sessionId, taskId);
1089
+ if (kind === 'output') taskBuffer.output += chunk;
1090
+ else taskBuffer.thinking += chunk;
1091
+ }
1092
+ }
1093
+
1094
+ private renderMarkdownLines(text: string, width: number): string[] {
1095
+ if (!text) return [];
1096
+ const safeWidth = Math.max(20, width);
1097
+ if (this.mdRenderer) {
1098
+ try {
1099
+ this.mdRenderer.setText(text);
1100
+ return this.mdRenderer.render(safeWidth);
1101
+ } catch {
1102
+ return text.split(/\r?\n/);
1103
+ }
1104
+ }
1105
+ return text.split(/\r?\n/);
1106
+ }
1107
+
1108
+ private renderStreamLines(
1109
+ output: string,
1110
+ thinking: string,
1111
+ showThinking: boolean,
1112
+ width: number
1113
+ ): string[] {
1114
+ const lines: string[] = [];
1115
+
1116
+ if (showThinking && thinking.trim()) {
1117
+ for (const line of thinking.split(/\r?\n/)) {
1118
+ lines.push(this.theme.fg('dim', line));
1119
+ }
1120
+ lines.push(this.theme.fg('muted', '--- end thinking ---'));
1121
+ lines.push('');
1122
+ }
1123
+
1124
+ if (output.trim()) {
1125
+ lines.push(...this.renderMarkdownLines(output, width));
1126
+ }
1127
+
1128
+ return lines;
1129
+ }
1130
+
1131
+ private buildTopTabs(
1132
+ active: 'detail' | 'feed' | 'events' | 'list',
1133
+ sessionTabs: boolean
1134
+ ): string {
1135
+ const divider = this.theme.fg('dim', ' | ');
1136
+ const tab = (key: string, label: string, isActive: boolean): string => {
1137
+ const text = `[${key}] ${label}`;
1138
+ return isActive
1139
+ ? this.theme.bold(this.theme.fg('accent', text))
1140
+ : this.theme.fg('dim', text);
1141
+ };
1142
+
1143
+ if (sessionTabs) {
1144
+ return ` ${tab('1', 'Detail', active === 'detail')}${divider}${tab('2', 'Feed', active === 'feed')}${divider}${tab('3', 'Events', active === 'events')}`;
1145
+ }
1146
+
1147
+ return ` ${tab('1', 'List', active === 'list')}${divider}${tab('2', 'Feed', active === 'feed')}`;
1148
+ }
1149
+
1150
+ private async fetchJson<T>(
1151
+ path: string,
1152
+ timeoutMs = REQUEST_TIMEOUT_MS,
1153
+ init?: RequestInit
1154
+ ): Promise<T> {
1155
+ const controller = new AbortController();
1156
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1157
+ try {
1158
+ // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
1159
+ const apiKey = process.env.AGENTUITY_CODER_API_KEY;
1160
+ const headers: Record<string, string> = {
1161
+ accept: 'application/json',
1162
+ ...(init?.headers && typeof init.headers === 'object'
1163
+ ? (init.headers as Record<string, string>)
1164
+ : {}),
1165
+ };
1166
+ if (apiKey) headers['x-agentuity-auth-api-key'] = apiKey;
1167
+ const signal = init?.signal
1168
+ ? AbortSignal.any([controller.signal, init.signal])
1169
+ : controller.signal;
1170
+ const response = await fetch(`${this.baseUrl}${path}`, {
1171
+ ...init,
1172
+ headers,
1173
+ signal,
1174
+ });
1175
+ if (!response.ok) {
1176
+ let message = `Hub returned ${response.status}`;
1177
+ let rawBody = '';
1178
+ try {
1179
+ const text = await response.text();
1180
+ if (text) {
1181
+ rawBody = text;
1182
+ try {
1183
+ const parsed = JSON.parse(text) as { error?: unknown; message?: unknown };
1184
+ if (typeof parsed.error === 'string' && parsed.error.trim()) {
1185
+ message = parsed.error;
1186
+ } else if (typeof parsed.message === 'string' && parsed.message.trim()) {
1187
+ message = parsed.message;
1188
+ } else {
1189
+ message = text;
1190
+ }
1191
+ } catch {
1192
+ message = text;
1193
+ }
1194
+ }
1195
+ } catch {
1196
+ // Fall back to the status-only message.
1197
+ }
1198
+ throw new HubRequestError({
1199
+ message,
1200
+ path,
1201
+ status: response.status,
1202
+ statusText: response.statusText || undefined,
1203
+ body: rawBody || undefined,
1204
+ });
1205
+ }
1206
+ return (await response.json()) as T;
1207
+ } finally {
1208
+ clearTimeout(timeout);
1209
+ }
1210
+ }
1211
+
1212
+ private getSessionSummary(sessionId: string): HubSessionSummary | undefined {
1213
+ return this.sessions.find((item) => item.sessionId === sessionId);
1214
+ }
1215
+
1216
+ private isRuntimeAvailable(sessionId: string): boolean {
1217
+ const detailRuntime =
1218
+ this.detail?.sessionId === sessionId ? this.detail.runtimeAvailable : undefined;
1219
+ if (typeof detailRuntime === 'boolean') return detailRuntime;
1220
+
1221
+ const session = this.getSessionSummary(sessionId);
1222
+ if (typeof session?.runtimeAvailable === 'boolean') return session.runtimeAvailable;
1223
+ if (session?.historyOnly) return false;
1224
+ if (session?.bucket === 'history') return false;
1225
+ return true;
1226
+ }
1227
+
1228
+ private isHistoryOnly(sessionId: string): boolean {
1229
+ if (this.detail?.sessionId === sessionId && typeof this.detail.historyOnly === 'boolean') {
1230
+ return this.detail.historyOnly;
1231
+ }
1232
+ return this.getSessionSummary(sessionId)?.historyOnly === true;
1233
+ }
1234
+
1235
+ private canResumeSession(detail: HubSessionDetail | null): boolean {
1236
+ if (!detail) return false;
1237
+ return (
1238
+ detail.mode === 'sandbox' &&
1239
+ detail.bucket === 'paused' &&
1240
+ detail.historyOnly !== true &&
1241
+ detail.runtimeAvailable === false
1242
+ );
1243
+ }
1244
+
1245
+ private isArchivedStatus(status: string): boolean {
1246
+ return status === 'archived' || status === 'shutdown' || status === 'stopped';
1247
+ }
1248
+
1249
+ private getStatusTone(status: string): 'success' | 'warning' | 'error' | 'muted' {
1250
+ if (status === 'active' || status === 'running' || status === 'connected') {
1251
+ return 'success';
1252
+ }
1253
+ if (status === 'error' || status === 'failed' || status === 'stopped') {
1254
+ return 'error';
1255
+ }
1256
+ if (status === 'archived' || status === 'shutdown') {
1257
+ return 'muted';
1258
+ }
1259
+ return 'warning';
1260
+ }
1261
+
1262
+ private getConnectionState(input: {
1263
+ sessionId: string;
1264
+ mode: string;
1265
+ status: string;
1266
+ bucket?: 'running' | 'paused' | 'provisioning' | 'history';
1267
+ runtimeAvailable?: boolean;
1268
+ controlAvailable?: boolean;
1269
+ historyOnly?: boolean;
1270
+ }): {
1271
+ label: string;
1272
+ tone: 'success' | 'warning' | 'error' | 'accent' | 'muted';
1273
+ controlAvailable: boolean;
1274
+ } {
1275
+ const isLocalSession = input.mode === 'tui';
1276
+ const isArchived = this.isArchivedStatus(input.status);
1277
+ const bucket = input.bucket ?? (input.historyOnly ? 'history' : 'running');
1278
+ const runtimeAvailable = input.runtimeAvailable !== false;
1279
+ const controlAvailable = input.controlAvailable ?? runtimeAvailable;
1280
+ const historyOnly = input.historyOnly === true || bucket === 'history';
1281
+ const canWake = !isLocalSession && !isArchived && bucket === 'paused' && historyOnly !== true;
1282
+
1283
+ if (historyOnly) return { label: 'History only', tone: 'warning', controlAvailable };
1284
+ if (isLocalSession && bucket === 'provisioning')
1285
+ return { label: 'Starting', tone: 'warning', controlAvailable };
1286
+ if (isLocalSession && !isArchived)
1287
+ return { label: 'View only', tone: 'muted', controlAvailable };
1288
+ if (bucket === 'provisioning')
1289
+ return { label: 'Starting', tone: 'warning', controlAvailable };
1290
+ if (canWake) {
1291
+ return {
1292
+ label: this.resumeBusySessionId === input.sessionId ? 'Waking' : 'Paused',
1293
+ tone: this.resumeBusySessionId === input.sessionId ? 'accent' : 'warning',
1294
+ controlAvailable,
1295
+ };
1296
+ }
1297
+ if (!isArchived && runtimeAvailable && !controlAvailable) {
1298
+ return { label: 'Read only', tone: 'muted', controlAvailable };
1299
+ }
1300
+ if (!isArchived && runtimeAvailable)
1301
+ return { label: 'Live', tone: 'success', controlAvailable };
1302
+ if (isArchived) return { label: 'Archived', tone: 'muted', controlAvailable };
1303
+ return { label: 'Disconnected', tone: 'warning', controlAvailable };
1304
+ }
1305
+
1306
+ private formatTagSummary(tags: string[] | undefined, maxCount = 4): string | null {
1307
+ if (!tags || tags.length === 0) return null;
1308
+ const visible = tags.slice(0, maxCount);
1309
+ const remainder = tags.length - visible.length;
1310
+ return remainder > 0 ? `${visible.join(', ')} +${remainder}` : visible.join(', ');
1311
+ }
1312
+
1313
+ private getSessionEventEntries(sessionId: string): FeedEntry[] {
1314
+ if (!this.isRuntimeAvailable(sessionId) || this.isHistoryOnly(sessionId)) {
1315
+ return this.sessionHistoryFeed.get(sessionId) ?? [];
1316
+ }
1317
+ return this.sessionFeed.get(sessionId) ?? [];
1318
+ }
1319
+
1320
+ private async refreshTodos(sessionId: string): Promise<void> {
1321
+ const requestId = ++this.detailTodoRequestId;
1322
+ try {
1323
+ const todosResponse = await this.fetchJson<HubTodoListResponse>(
1324
+ `/api/hub/session/${encodeURIComponent(sessionId)}/todos?includeTerminal=true&includeSync=true&limit=30`,
1325
+ 15_000
1326
+ ).catch((err: any) => {
1327
+ const isAbort = err?.name === 'AbortError' || err?.message?.includes('aborted');
1328
+ return {
1329
+ _fetchError: true,
1330
+ message: isAbort ? 'Todos loading...' : err?.message || 'Failed to load todos',
1331
+ } as HubTodoListResponse & { _fetchError: true };
1332
+ });
1333
+
1334
+ if (
1335
+ this.disposed ||
1336
+ this.detail?.sessionId !== sessionId ||
1337
+ requestId !== this.detailTodoRequestId
1338
+ ) {
1339
+ return;
1340
+ }
1341
+
1342
+ if ((todosResponse as HubTodoListResponse & { _fetchError?: boolean })._fetchError) {
1343
+ if (this.cachedTodos && this.cachedTodos.sessionId === sessionId) {
1344
+ this.detail.todos = this.cachedTodos.todos;
1345
+ this.detail.todoSummary = this.cachedTodos.summary;
1346
+ }
1347
+ this.detail.todosUnavailable = todosResponse.message;
1348
+ this.requestRender();
1349
+ return;
1350
+ }
1351
+
1352
+ this.detail.todos = Array.isArray(todosResponse.todos) ? todosResponse.todos : [];
1353
+ this.detail.todoSummary =
1354
+ todosResponse.summary && typeof todosResponse.summary === 'object'
1355
+ ? todosResponse.summary
1356
+ : undefined;
1357
+ this.detail.todosUnavailable = todosResponse.unavailable
1358
+ ? typeof todosResponse.message === 'string'
1359
+ ? todosResponse.message
1360
+ : 'Task service unavailable'
1361
+ : undefined;
1362
+
1363
+ if (this.detail.todos.length > 0) {
1364
+ this.cachedTodos = {
1365
+ todos: this.detail.todos,
1366
+ summary: this.detail.todoSummary,
1367
+ sessionId,
1368
+ };
1369
+ }
1370
+
1371
+ this.requestRender();
1372
+ } catch {
1373
+ // Best-effort todos loading.
1374
+ }
1375
+ }
1376
+
1377
+ private async refreshList(initial = false): Promise<void> {
1378
+ if (this.disposed || this.listInFlight) return;
1379
+ this.listInFlight = true;
1380
+ if (initial) {
1381
+ this.loadingList = true;
1382
+ this.listError = '';
1383
+ this.requestRender();
1384
+ }
1385
+
1386
+ try {
1387
+ const data = await this.fetchJson<HubListResponse>('/api/hub/sessions');
1388
+ const sessions = (data.sessions?.websocket ?? []).slice().sort((a, b) => {
1389
+ return Date.parse(b.createdAt) - Date.parse(a.createdAt);
1390
+ });
1391
+
1392
+ this.updateFeedFromList(sessions);
1393
+ this.sessions = sessions;
1394
+ if (this.selectedIndex >= this.sessions.length) {
1395
+ this.selectedIndex = Math.max(0, this.sessions.length - 1);
1396
+ }
1397
+ this.loadingList = false;
1398
+ this.listError = '';
1399
+ this.lastUpdatedAt = Date.now();
1400
+ void this.syncSseStreams(this.sessions);
1401
+ this.requestRender();
1402
+ } catch (err) {
1403
+ this.loadingList = false;
1404
+ this.listError = err instanceof Error ? err.message : String(err);
1405
+ this.requestRender();
1406
+ } finally {
1407
+ this.listInFlight = false;
1408
+ }
1409
+ }
1410
+
1411
+ private async refreshDetail(sessionId: string, initial = false): Promise<void> {
1412
+ if (this.disposed || this.detailInFlight) return;
1413
+ this.detailInFlight = true;
1414
+ if (initial) {
1415
+ this.loadingDetail = true;
1416
+ this.detailError = '';
1417
+ this.requestRender();
1418
+ }
1419
+
1420
+ try {
1421
+ const detail = await this.fetchJson<HubSessionDetail>(
1422
+ `/api/hub/session/${encodeURIComponent(sessionId)}`
1423
+ );
1424
+
1425
+ if (this.cachedTodos && this.cachedTodos.sessionId === sessionId) {
1426
+ detail.todos = this.cachedTodos.todos;
1427
+ detail.todoSummary = this.cachedTodos.summary;
1428
+ detail.todosUnavailable = 'Loading todos...';
1429
+ } else {
1430
+ detail.todosUnavailable = 'Loading todos...';
1431
+ }
1432
+
1433
+ this.detail = detail;
1434
+ this.detailSessionId = sessionId;
1435
+ this.replaceStreamProjection(sessionId, detail.stream, 'snapshot');
1436
+ if (detail.runtimeAvailable !== false && detail.historyOnly !== true) {
1437
+ this.resetHistoricalSessionState(sessionId);
1438
+ }
1439
+ const taskCount = detail.tasks?.length ?? 0;
1440
+ if (taskCount === 0) {
1441
+ this.selectedTaskIndex = 0;
1442
+ } else if (this.selectedTaskIndex >= taskCount) {
1443
+ this.selectedTaskIndex = taskCount - 1;
1444
+ }
1445
+ this.loadingDetail = false;
1446
+ this.detailError = '';
1447
+ this.lastUpdatedAt = Date.now();
1448
+ this.requestRender();
1449
+ void this.refreshTodos(sessionId);
1450
+ if (detail.runtimeAvailable === false || detail.historyOnly === true) {
1451
+ void this.refreshReplay(sessionId);
1452
+ void this.refreshEventHistory(sessionId);
1453
+ }
1454
+ } catch (err) {
1455
+ this.loadingDetail = false;
1456
+ this.detailError = err instanceof Error ? err.message : String(err);
1457
+ this.requestRender();
1458
+ } finally {
1459
+ this.detailInFlight = false;
1460
+ }
1461
+ }
1462
+
1463
+ private async refreshReplay(sessionId: string): Promise<void> {
1464
+ if (this.isFailureCoolingDown(this.replayFailureUntil, sessionId)) return;
1465
+ if (this.replayLoadedSessions.has(sessionId) || this.replayInFlight.has(sessionId)) return;
1466
+ const controller = new AbortController();
1467
+ const generation = this.nextRequestGeneration(this.replayRequestGenerations, sessionId);
1468
+ this.replayInFlight.add(sessionId);
1469
+ this.replayRequestControllers.set(sessionId, controller);
1470
+ try {
1471
+ const data = await this.fetchJson<HubReplayResponse>(
1472
+ `/api/hub/session/${encodeURIComponent(sessionId)}/replay`,
1473
+ 15_000,
1474
+ { signal: controller.signal }
1475
+ );
1476
+ if (
1477
+ !this.isCurrentSessionRequest(
1478
+ this.replayRequestGenerations,
1479
+ this.replayRequestControllers,
1480
+ sessionId,
1481
+ generation,
1482
+ controller
1483
+ )
1484
+ ) {
1485
+ return;
1486
+ }
1487
+ this.applyReplayEntries(sessionId, Array.isArray(data.entries) ? data.entries : []);
1488
+ this.replayLoadedSessions.add(sessionId);
1489
+ this.requestRender();
1490
+ } catch (err) {
1491
+ if (
1492
+ controller.signal.aborted ||
1493
+ !this.isCurrentSessionRequest(
1494
+ this.replayRequestGenerations,
1495
+ this.replayRequestControllers,
1496
+ sessionId,
1497
+ generation,
1498
+ controller
1499
+ )
1500
+ ) {
1501
+ return;
1502
+ }
1503
+ this.markFailureCooldown(this.replayFailureUntil, sessionId);
1504
+ this.pushFeed(
1505
+ `${this.getSessionLabel(sessionId)}: replay unavailable (${err instanceof Error ? err.message : String(err)})`,
1506
+ sessionId
1507
+ );
1508
+ this.requestRender();
1509
+ } finally {
1510
+ if (
1511
+ this.isCurrentSessionRequest(
1512
+ this.replayRequestGenerations,
1513
+ this.replayRequestControllers,
1514
+ sessionId,
1515
+ generation,
1516
+ controller
1517
+ )
1518
+ ) {
1519
+ this.replayInFlight.delete(sessionId);
1520
+ this.replayRequestControllers.delete(sessionId);
1521
+ }
1522
+ }
1523
+ }
1524
+
1525
+ private formatHistoryEventLine(sessionId: string, event: HubEventHistoryItem): string | null {
1526
+ const payload =
1527
+ event.payload && typeof event.payload === 'object'
1528
+ ? (event.payload as Record<string, unknown>)
1529
+ : undefined;
1530
+ return (
1531
+ this.formatEventFeedLine(sessionId, event.event, payload) ??
1532
+ this.formatStreamLine(event.event, payload) ??
1533
+ `${event.event}${event.agent ? ` ${event.agent}` : ''}`
1534
+ );
1535
+ }
1536
+
1537
+ private async refreshEventHistory(sessionId: string): Promise<void> {
1538
+ if (this.isFailureCoolingDown(this.eventHistoryFailureUntil, sessionId)) {
1539
+ return;
1540
+ }
1541
+ if (
1542
+ this.eventHistoryLoadedSessions.has(sessionId) ||
1543
+ this.eventHistoryInFlight.has(sessionId)
1544
+ ) {
1545
+ return;
1546
+ }
1547
+ const controller = new AbortController();
1548
+ const generation = this.nextRequestGeneration(this.eventHistoryRequestGenerations, sessionId);
1549
+ this.eventHistoryInFlight.add(sessionId);
1550
+ this.eventHistoryRequestControllers.set(sessionId, controller);
1551
+ try {
1552
+ const data = await this.fetchJson<HubEventHistoryResponse>(
1553
+ `/api/hub/session/${encodeURIComponent(sessionId)}/events/history?limit=100`,
1554
+ 15_000,
1555
+ { signal: controller.signal }
1556
+ );
1557
+ if (
1558
+ !this.isCurrentSessionRequest(
1559
+ this.eventHistoryRequestGenerations,
1560
+ this.eventHistoryRequestControllers,
1561
+ sessionId,
1562
+ generation,
1563
+ controller
1564
+ )
1565
+ ) {
1566
+ return;
1567
+ }
1568
+ const entries = (data.events ?? [])
1569
+ .map((event): FeedEntry | null => {
1570
+ const text = this.formatHistoryEventLine(sessionId, event);
1571
+ if (!text) return null;
1572
+ return {
1573
+ at: Number.isNaN(Date.parse(event.occurredAt))
1574
+ ? Date.now()
1575
+ : Date.parse(event.occurredAt),
1576
+ sessionId,
1577
+ text,
1578
+ };
1579
+ })
1580
+ .filter((entry): entry is FeedEntry => entry !== null)
1581
+ .sort((a, b) => b.at - a.at);
1582
+ this.sessionHistoryFeed.set(sessionId, entries);
1583
+ this.clearEventHistoryFailureCooldown(sessionId);
1584
+ this.eventHistoryLoadedSessions.add(sessionId);
1585
+ this.requestRender();
1586
+ } catch (err) {
1587
+ if (
1588
+ controller.signal.aborted ||
1589
+ !this.isCurrentSessionRequest(
1590
+ this.eventHistoryRequestGenerations,
1591
+ this.eventHistoryRequestControllers,
1592
+ sessionId,
1593
+ generation,
1594
+ controller
1595
+ )
1596
+ ) {
1597
+ return;
1598
+ }
1599
+ this.markFailureCooldown(this.eventHistoryFailureUntil, sessionId);
1600
+ this.pushFeed(
1601
+ `${this.getSessionLabel(sessionId)}: event history unavailable (${err instanceof Error ? err.message : String(err)})`,
1602
+ sessionId
1603
+ );
1604
+ this.requestRender();
1605
+ } finally {
1606
+ if (
1607
+ this.isCurrentSessionRequest(
1608
+ this.eventHistoryRequestGenerations,
1609
+ this.eventHistoryRequestControllers,
1610
+ sessionId,
1611
+ generation,
1612
+ controller
1613
+ )
1614
+ ) {
1615
+ this.eventHistoryInFlight.delete(sessionId);
1616
+ this.eventHistoryRequestControllers.delete(sessionId);
1617
+ }
1618
+ }
1619
+ }
1620
+
1621
+ private async resumeSession(sessionId: string): Promise<void> {
1622
+ if (this.resumeBusySessionId) return;
1623
+ this.resumeBusySessionId = sessionId;
1624
+ this.pushFeed(`${this.getSessionLabel(sessionId)}: resume requested`, sessionId);
1625
+ this.requestRender();
1626
+
1627
+ try {
1628
+ const response = await this.fetchJson<{ resumed?: boolean; error?: string }>(
1629
+ `/api/hub/session/${encodeURIComponent(sessionId)}/resume`,
1630
+ 15_000,
1631
+ { method: 'POST' }
1632
+ );
1633
+ if (response.resumed !== true) {
1634
+ throw new Error(response.error || 'Hub declined to resume the session');
1635
+ }
1636
+ this.resetHistoricalSessionState(sessionId);
1637
+ await this.refreshList();
1638
+ await this.refreshDetail(sessionId);
1639
+ } catch (err) {
1640
+ const message = err instanceof Error ? err.message : String(err);
1641
+ this.detailError = message;
1642
+ this.pushFeed(`${this.getSessionLabel(sessionId)}: resume failed (${message})`, sessionId);
1643
+ this.requestRender();
1644
+ } finally {
1645
+ this.resumeBusySessionId = null;
1646
+ this.requestRender();
1647
+ }
1648
+ }
1649
+
1650
+ private updateFeedFromList(sessions: HubSessionSummary[]): void {
1651
+ if (this.previousDigests.size === 0) {
1652
+ if (sessions.length > 0) {
1653
+ this.pushFeed(
1654
+ `Loaded ${sessions.length} active session${sessions.length === 1 ? '' : 's'}`
1655
+ );
1656
+ }
1657
+ for (const session of sessions) {
1658
+ this.previousDigests.set(session.sessionId, {
1659
+ status: session.status,
1660
+ taskCount: session.taskCount,
1661
+ observerCount: session.observerCount,
1662
+ subAgentCount: session.subAgentCount,
1663
+ });
1664
+ }
1665
+ return;
1666
+ }
1667
+
1668
+ const nextDigests = new Map<string, SessionDigest>();
1669
+ for (const session of sessions) {
1670
+ const prev = this.previousDigests.get(session.sessionId);
1671
+ const label = session.label || shortId(session.sessionId);
1672
+
1673
+ if (!prev) {
1674
+ this.pushFeed(`${label}: session discovered (${session.mode})`, session.sessionId);
1675
+ } else {
1676
+ if (prev.status !== session.status) {
1677
+ this.pushFeed(`${label}: ${prev.status} -> ${session.status}`, session.sessionId);
1678
+ }
1679
+ if (session.taskCount > prev.taskCount) {
1680
+ const delta = session.taskCount - prev.taskCount;
1681
+ this.pushFeed(
1682
+ `${label}: +${delta} task${delta === 1 ? '' : 's'}`,
1683
+ session.sessionId
1684
+ );
1685
+ }
1686
+ if (session.observerCount !== prev.observerCount) {
1687
+ this.pushFeed(
1688
+ `${label}: observers ${prev.observerCount} -> ${session.observerCount}`,
1689
+ session.sessionId
1690
+ );
1691
+ }
1692
+ if (session.subAgentCount !== prev.subAgentCount) {
1693
+ this.pushFeed(
1694
+ `${label}: agents ${prev.subAgentCount} -> ${session.subAgentCount}`,
1695
+ session.sessionId
1696
+ );
1697
+ }
1698
+ }
1699
+
1700
+ nextDigests.set(session.sessionId, {
1701
+ status: session.status,
1702
+ taskCount: session.taskCount,
1703
+ observerCount: session.observerCount,
1704
+ subAgentCount: session.subAgentCount,
1705
+ });
1706
+ }
1707
+
1708
+ for (const oldSessionId of this.previousDigests.keys()) {
1709
+ if (!nextDigests.has(oldSessionId)) {
1710
+ this.pushFeed(`${shortId(oldSessionId)}: session removed`, oldSessionId);
1711
+ }
1712
+ }
1713
+
1714
+ this.previousDigests = nextDigests;
1715
+ }
1716
+
1717
+ private async syncSseStreams(sessions: HubSessionSummary[]): Promise<void> {
1718
+ if (this.disposed) return;
1719
+ const desiredModes = new Map<string, 'summary' | 'full'>();
1720
+ for (const session of sessions.slice(0, STREAM_SESSION_LIMIT)) {
1721
+ if (session.runtimeAvailable === false || session.historyOnly === true) continue;
1722
+ desiredModes.set(session.sessionId, 'summary');
1723
+ }
1724
+ if (
1725
+ this.detailSessionId &&
1726
+ this.isSessionContext() &&
1727
+ this.isRuntimeAvailable(this.detailSessionId) &&
1728
+ !this.isHistoryOnly(this.detailSessionId)
1729
+ ) {
1730
+ desiredModes.set(this.detailSessionId, 'full');
1731
+ }
1732
+
1733
+ for (const [sessionId, stream] of this.sseControllers) {
1734
+ const desiredMode = desiredModes.get(sessionId);
1735
+ if (!desiredMode) {
1736
+ stream.controller.abort();
1737
+ this.sseControllers.delete(sessionId);
1738
+ continue;
1739
+ }
1740
+ if (stream.mode !== desiredMode) {
1741
+ stream.controller.abort();
1742
+ this.sseControllers.delete(sessionId);
1743
+ }
1744
+ }
1745
+
1746
+ for (const [sessionId, mode] of desiredModes) {
1747
+ if (!this.sseControllers.has(sessionId)) {
1748
+ void this.startSseStream(sessionId, mode);
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ private async startSseStream(sessionId: string, mode: 'summary' | 'full'): Promise<void> {
1754
+ if (this.disposed) return;
1755
+ const existing = this.sseControllers.get(sessionId);
1756
+ if (existing && existing.mode === mode) return;
1757
+ if (existing) {
1758
+ existing.controller.abort();
1759
+ this.sseControllers.delete(sessionId);
1760
+ }
1761
+
1762
+ const controller = new AbortController();
1763
+ this.sseControllers.set(sessionId, { controller, mode });
1764
+ const subscribe = mode === 'full' ? '*' : 'session_*,task_*,agent_*';
1765
+
1766
+ try {
1767
+ // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
1768
+ const apiKey = process.env.AGENTUITY_CODER_API_KEY;
1769
+ const sseHeaders: Record<string, string> = { accept: 'text/event-stream' };
1770
+ if (apiKey) sseHeaders['x-agentuity-auth-api-key'] = apiKey;
1771
+ const response = await fetch(
1772
+ `${this.baseUrl}/api/hub/session/${encodeURIComponent(sessionId)}/events?subscribe=${encodeURIComponent(subscribe)}`,
1773
+ {
1774
+ headers: sseHeaders,
1775
+ signal: controller.signal,
1776
+ }
1777
+ );
1778
+
1779
+ if (!response.ok || !response.body) {
1780
+ throw new Error(`Hub returned ${response.status} for stream`);
1781
+ }
1782
+
1783
+ const reader = response.body.getReader();
1784
+ const decoder = new TextDecoder();
1785
+ let buffer = '';
1786
+
1787
+ while (true) {
1788
+ const { done, value } = await reader.read();
1789
+ if (done) break;
1790
+ buffer += decoder.decode(value, { stream: true });
1791
+ buffer = this.consumeSseBuffer(sessionId, mode, buffer);
1792
+ }
1793
+ } catch (err) {
1794
+ if (controller.signal.aborted || this.disposed) return;
1795
+ const label = this.getSessionLabel(sessionId);
1796
+ const msg = err instanceof Error ? err.message : String(err);
1797
+ this.pushFeed(`${label}: stream error (${msg})`, sessionId);
1798
+ this.requestRender();
1799
+ } finally {
1800
+ const current = this.sseControllers.get(sessionId);
1801
+ if (current && current.controller === controller) {
1802
+ this.sseControllers.delete(sessionId);
1803
+ }
1804
+ }
1805
+ }
1806
+
1807
+ private consumeSseBuffer(
1808
+ sessionId: string,
1809
+ mode: 'summary' | 'full',
1810
+ rawBuffer: string
1811
+ ): string {
1812
+ const normalized = rawBuffer.replace(/\r\n/g, '\n');
1813
+ let cursor = 0;
1814
+
1815
+ while (true) {
1816
+ const boundary = normalized.indexOf('\n\n', cursor);
1817
+ if (boundary === -1) break;
1818
+
1819
+ const block = normalized.slice(cursor, boundary);
1820
+ cursor = boundary + 2;
1821
+ if (!block.trim()) continue;
1822
+
1823
+ let eventName = 'message';
1824
+ const dataLines: string[] = [];
1825
+ for (const line of block.split('\n')) {
1826
+ if (line.startsWith('event:')) {
1827
+ eventName = line.slice(6).trim() || eventName;
1828
+ } else if (line.startsWith('data:')) {
1829
+ dataLines.push(line.slice(5).trimStart());
1830
+ }
1831
+ }
1832
+ const dataText = dataLines.join('\n');
1833
+ this.handleSseEvent(sessionId, mode, eventName, dataText);
1834
+ }
1835
+
1836
+ return normalized.slice(cursor);
1837
+ }
1838
+
1839
+ private handleSseEvent(
1840
+ sessionId: string,
1841
+ mode: 'summary' | 'full',
1842
+ sseEvent: string,
1843
+ dataText: string
1844
+ ): void {
1845
+ let payload: unknown = undefined;
1846
+ if (dataText) {
1847
+ try {
1848
+ payload = JSON.parse(dataText);
1849
+ } catch {
1850
+ payload = dataText;
1851
+ }
1852
+ }
1853
+
1854
+ let eventName = sseEvent;
1855
+ let eventData: unknown = payload;
1856
+ if (payload && typeof payload === 'object') {
1857
+ const record = payload as Record<string, unknown>;
1858
+ if (typeof record.event === 'string') {
1859
+ eventName = record.event;
1860
+ eventData = record.data;
1861
+ }
1862
+ }
1863
+
1864
+ if (eventName === 'hydration') {
1865
+ this.applyHydration(sessionId, eventData);
1866
+ return;
1867
+ }
1868
+ if (eventName === 'snapshot') {
1869
+ return;
1870
+ }
1871
+
1872
+ const data =
1873
+ eventData && typeof eventData === 'object'
1874
+ ? (eventData as Record<string, unknown>)
1875
+ : undefined;
1876
+ const taskId = typeof data?.taskId === 'string' ? data.taskId : undefined;
1877
+
1878
+ const text = this.formatEventFeedLine(sessionId, eventName, eventData);
1879
+ if (text) {
1880
+ this.pushFeed(text, sessionId);
1881
+ }
1882
+ if (mode === 'full') {
1883
+ this.captureStreamContent(sessionId, eventName, eventData, taskId);
1884
+ const streamLine = this.formatStreamLine(eventName, eventData);
1885
+ if (streamLine) {
1886
+ this.pushSessionFeed(sessionId, streamLine, taskId);
1887
+ }
1888
+ }
1889
+ this.requestRender();
1890
+
1891
+ if (
1892
+ this.detailSessionId === sessionId &&
1893
+ (eventName === 'session_join' ||
1894
+ eventName === 'session_leave' ||
1895
+ eventName === 'task_start' ||
1896
+ eventName === 'task_complete' ||
1897
+ eventName === 'task_error' ||
1898
+ eventName === 'session_shutdown')
1899
+ ) {
1900
+ void this.refreshDetail(sessionId);
1901
+ }
1902
+
1903
+ if (eventName === 'session_shutdown') {
1904
+ const stream = this.sseControllers.get(sessionId);
1905
+ if (stream) {
1906
+ stream.controller.abort();
1907
+ this.sseControllers.delete(sessionId);
1908
+ }
1909
+ }
1910
+ }
1911
+
1912
+ private applyHydration(sessionId: string, eventData: unknown): void {
1913
+ if (!eventData || typeof eventData !== 'object') return;
1914
+ const payload = eventData as SseHydrationMessage;
1915
+ const loadedFromProjection = this.replaceStreamProjection(
1916
+ sessionId,
1917
+ payload.stream as StreamProjection | undefined,
1918
+ 'hydration'
1919
+ );
1920
+
1921
+ if (!loadedFromProjection) {
1922
+ const entries = Array.isArray(payload.entries)
1923
+ ? payload.entries.filter(
1924
+ (entry): entry is HubConversationEntry => !!entry && typeof entry === 'object'
1925
+ )
1926
+ : [];
1927
+ this.replaceStreamProjection(sessionId, buildProjectionFromEntries(entries), 'hydration');
1928
+ }
1929
+ }
1930
+
1931
+ private captureStreamContent(
1932
+ sessionId: string,
1933
+ eventName: string,
1934
+ eventData: unknown,
1935
+ taskId?: string
1936
+ ): void {
1937
+ const data =
1938
+ eventData && typeof eventData === 'object'
1939
+ ? (eventData as Record<string, unknown>)
1940
+ : undefined;
1941
+
1942
+ if (eventName === 'message_update') {
1943
+ const assistantMessageEvent = data?.assistantMessageEvent;
1944
+ if (assistantMessageEvent && typeof assistantMessageEvent === 'object') {
1945
+ const ame = assistantMessageEvent as Record<string, unknown>;
1946
+ const type = typeof ame.type === 'string' ? ame.type : '';
1947
+ const delta = typeof ame.delta === 'string' ? ame.delta : '';
1948
+ if (type === 'text_delta' && delta) {
1949
+ this.appendBufferText(sessionId, 'output', delta, taskId);
1950
+ } else if (type === 'thinking_delta' && delta) {
1951
+ this.appendBufferText(sessionId, 'thinking', delta, taskId);
1952
+ }
1953
+ }
1954
+ return;
1955
+ }
1956
+
1957
+ if (eventName === 'message_end') {
1958
+ const segments = extractMessageSegments(data);
1959
+ if (segments.thinking) {
1960
+ this.appendBufferText(sessionId, 'thinking', segments.thinking + '\n\n', taskId);
1961
+ }
1962
+ if (segments.output) {
1963
+ this.appendBufferText(sessionId, 'output', segments.output + '\n\n', taskId);
1964
+ }
1965
+ return;
1966
+ }
1967
+
1968
+ if (eventName === 'thinking_end') {
1969
+ const text = typeof data?.text === 'string' ? data.text : '';
1970
+ if (text) this.appendBufferText(sessionId, 'thinking', text + '\n\n', taskId);
1971
+ return;
1972
+ }
1973
+
1974
+ if (eventName === 'agent_progress') {
1975
+ const status = typeof data?.status === 'string' ? data.status : '';
1976
+ const delta = typeof data?.delta === 'string' ? data.delta : '';
1977
+ if (status === 'text_delta' && delta) {
1978
+ this.appendBufferText(sessionId, 'output', delta, taskId);
1979
+ } else if (status === 'thinking_delta' && delta) {
1980
+ this.appendBufferText(sessionId, 'thinking', delta, taskId);
1981
+ }
1982
+ return;
1983
+ }
1984
+
1985
+ if (eventName === 'tool_call' || eventName === 'tool_result') {
1986
+ const line = this.formatStreamLine(eventName, eventData);
1987
+ if (line) this.appendBufferText(sessionId, 'output', `${line}\n\n`, taskId);
1988
+ return;
1989
+ }
1990
+
1991
+ if (eventName === 'task_complete') {
1992
+ const result = typeof data?.result === 'string' ? data.result : '';
1993
+ if (result) this.appendBufferText(sessionId, 'output', result + '\n\n', taskId);
1994
+ return;
1995
+ }
1996
+
1997
+ if (eventName === 'task_error') {
1998
+ const error = typeof data?.error === 'string' ? data.error : '';
1999
+ if (error) this.appendBufferText(sessionId, 'output', `[task error] ${error}\n`, taskId);
2000
+ }
2001
+ }
2002
+
2003
+ private formatEventFeedLine(
2004
+ sessionId: string,
2005
+ eventName: string,
2006
+ eventData: unknown
2007
+ ): string | null {
2008
+ const label = this.getSessionLabel(sessionId);
2009
+ const data =
2010
+ eventData && typeof eventData === 'object'
2011
+ ? (eventData as Record<string, unknown>)
2012
+ : undefined;
2013
+
2014
+ if (eventName === 'task_start') {
2015
+ const taskId = typeof data?.taskId === 'string' ? shortId(data.taskId) : 'task';
2016
+ const agent = typeof data?.agent === 'string' ? data.agent : 'agent';
2017
+ return `${label}: ${taskId} started (${agent})`;
2018
+ }
2019
+ if (eventName === 'task_complete') {
2020
+ const taskId = typeof data?.taskId === 'string' ? shortId(data.taskId) : 'task';
2021
+ const duration = typeof data?.duration === 'number' ? ` ${data.duration}ms` : '';
2022
+ return `${label}: ${taskId} completed${duration}`;
2023
+ }
2024
+ if (eventName === 'task_error') {
2025
+ const taskId = typeof data?.taskId === 'string' ? shortId(data.taskId) : 'task';
2026
+ return `${label}: ${taskId} failed`;
2027
+ }
2028
+ if (eventName === 'session_join' || eventName === 'session_leave') {
2029
+ const participant = data?.participant as Record<string, unknown> | undefined;
2030
+ const role = typeof participant?.role === 'string' ? participant.role : 'participant';
2031
+ return `${label}: ${role} ${eventName === 'session_join' ? 'joined' : 'left'}`;
2032
+ }
2033
+ if (eventName === 'agent_start' || eventName === 'agent_end') {
2034
+ const agent =
2035
+ typeof data?.agentName === 'string'
2036
+ ? data.agentName
2037
+ : typeof data?.agent === 'string'
2038
+ ? data.agent
2039
+ : 'agent';
2040
+ return `${label}: ${agent} ${eventName === 'agent_start' ? 'started' : 'ended'}`;
2041
+ }
2042
+ if (
2043
+ eventName === 'session_complete' ||
2044
+ eventName === 'session_error' ||
2045
+ eventName === 'session_shutdown'
2046
+ ) {
2047
+ return `${label}: ${eventName}`;
2048
+ }
2049
+
2050
+ return null;
2051
+ }
2052
+
2053
+ private getSessionLabel(sessionId: string): string {
2054
+ const session = this.sessions.find((item) => item.sessionId === sessionId);
2055
+ return session?.label || shortId(sessionId);
2056
+ }
2057
+
2058
+ private getFeedEntries(scope: 'global' | 'session'): FeedEntry[] {
2059
+ if (scope === 'session' && this.detailSessionId) {
2060
+ return this.getSessionEventEntries(this.detailSessionId);
2061
+ }
2062
+ return this.feed;
2063
+ }
2064
+
2065
+ private pushFeed(text: string, sessionId?: string): void {
2066
+ this.feed.unshift({ at: Date.now(), sessionId, text });
2067
+ if (this.feed.length > MAX_FEED_ITEMS) {
2068
+ this.feed.length = MAX_FEED_ITEMS;
2069
+ }
2070
+ }
2071
+
2072
+ private pushSessionFeed(sessionId: string, text: string, taskId?: string): void {
2073
+ const entries = this.sessionFeed.get(sessionId) ?? [];
2074
+ entries.unshift({ at: Date.now(), sessionId, text });
2075
+ if (entries.length > MAX_FEED_ITEMS) entries.length = MAX_FEED_ITEMS;
2076
+ this.sessionFeed.set(sessionId, entries);
2077
+
2078
+ if (taskId) {
2079
+ // Keep task-scoped stream buffers warm for immediate task-view drill-in.
2080
+ this.getTaskBuffer(sessionId, taskId);
2081
+ }
2082
+ }
2083
+
2084
+ private formatStreamLine(eventName: string, eventData: unknown): string | null {
2085
+ const data =
2086
+ eventData && typeof eventData === 'object'
2087
+ ? (eventData as Record<string, unknown>)
2088
+ : undefined;
2089
+ const normalize = (value: string): string => value.replace(/\s+/g, ' ').trim();
2090
+
2091
+ if (eventName === 'message_update') {
2092
+ const assistantMessageEvent = data?.assistantMessageEvent;
2093
+ if (assistantMessageEvent && typeof assistantMessageEvent === 'object') {
2094
+ const ame = assistantMessageEvent as Record<string, unknown>;
2095
+ const type = typeof ame.type === 'string' ? ame.type : '';
2096
+ const delta = typeof ame.delta === 'string' ? ame.delta : '';
2097
+ // Keep event view high-signal: show text streaming, suppress thinking token spam.
2098
+ if (type === 'text_delta' && delta) {
2099
+ const cleaned = truncateToWidth(normalize(delta), 120);
2100
+ if (cleaned) {
2101
+ return `${type || 'delta'} ${cleaned}`;
2102
+ }
2103
+ }
2104
+ }
2105
+ return null;
2106
+ }
2107
+
2108
+ if (eventName === 'message_end') {
2109
+ const segments = extractMessageSegments(data);
2110
+ const output = segments.output || segments.thinking;
2111
+ const cleaned = output ? truncateToWidth(normalize(output), 120) : '';
2112
+ return cleaned ? `message ${cleaned}` : 'message_end';
2113
+ }
2114
+
2115
+ if (eventName === 'thinking_end' && typeof data?.text === 'string') {
2116
+ const cleaned = truncateToWidth(normalize(data.text), 120);
2117
+ return cleaned ? `thinking ${cleaned}` : 'thinking_end';
2118
+ }
2119
+
2120
+ if (eventName === 'tool_call') {
2121
+ const name =
2122
+ typeof data?.name === 'string'
2123
+ ? data.name
2124
+ : typeof data?.toolName === 'string'
2125
+ ? data.toolName
2126
+ : 'tool';
2127
+ const input = data?.args ?? data?.input;
2128
+ const summarized = summarizeToolCall(name, input);
2129
+ if (summarized) return summarized;
2130
+ const argsPreview = summarizeArgs(input, 90);
2131
+ return argsPreview ? `tool_call ${name} ${argsPreview}` : `tool_call ${name}`;
2132
+ }
2133
+
2134
+ if (eventName === 'tool_result') {
2135
+ const name =
2136
+ typeof data?.name === 'string'
2137
+ ? data.name
2138
+ : typeof data?.toolName === 'string'
2139
+ ? data.toolName
2140
+ : 'tool';
2141
+ const input = (data?.args ?? data?.input) as Record<string, unknown> | undefined;
2142
+ if (name === 'task') {
2143
+ const agent = typeof input?.subagent_type === 'string' ? input.subagent_type : 'agent';
2144
+ const description =
2145
+ typeof input?.description === 'string'
2146
+ ? truncateToWidth(toSingleLine(input.description), 80)
2147
+ : '';
2148
+ const header = description ? `${agent} - ${description}` : `${agent} - delegated task`;
2149
+
2150
+ const rawResult = extractToolResultText(data?.content);
2151
+ const stats = rawResult.match(
2152
+ /_(\w+): (\d+)ms \| (\d+) in (\d+) out tokens \| \$([0-9.]+)_/
2153
+ );
2154
+ if (stats) {
2155
+ const durationMs = Number(stats[2] ?? 0);
2156
+ const tokIn = stats[3] ?? '0';
2157
+ const tokOut = stats[4] ?? '0';
2158
+ const cost = stats[5] ?? '0';
2159
+ return `${header}\ndone ${formatDuration(durationMs)} ↑${tokIn} ↓${tokOut} $${cost}`;
2160
+ }
2161
+
2162
+ const details =
2163
+ data?.details && typeof data.details === 'object'
2164
+ ? (data.details as Record<string, unknown>)
2165
+ : undefined;
2166
+ const duration =
2167
+ typeof details?.duration === 'number' ? ` ${formatDuration(details.duration)}` : '';
2168
+ const failed = data?.isError === true || details?.error === true;
2169
+ return `${header}\n${failed ? 'failed' : `done${duration}`}`;
2170
+ }
2171
+ return `tool_result ${name}`;
2172
+ }
2173
+
2174
+ if (eventName === 'agent_progress') {
2175
+ const agent = typeof data?.agentName === 'string' ? data.agentName : 'agent';
2176
+ const status = typeof data?.status === 'string' ? data.status : 'progress';
2177
+ const toolName = typeof data?.currentTool === 'string' ? data.currentTool : '';
2178
+ const toolArgsRaw = typeof data?.currentToolArgs === 'string' ? data.currentToolArgs : '';
2179
+ const toolArgs = toolArgsRaw ? truncateToWidth(normalize(toolArgsRaw), 80) : '';
2180
+
2181
+ // Deltas are already represented in rendered stream mode; skip them in event mode
2182
+ // to avoid noisy, low-signal token lines.
2183
+ if (status.endsWith('_delta')) {
2184
+ return null;
2185
+ }
2186
+
2187
+ if (status === 'tool_start') {
2188
+ const parts = ['tool_call', agent];
2189
+ if (toolName) parts.push(toolName);
2190
+ if (toolArgs) parts.push(toolArgs);
2191
+ return parts.join(' ');
2192
+ }
2193
+
2194
+ if (status === 'tool_end') {
2195
+ return toolName ? `tool_result ${agent} ${toolName}` : `tool_result ${agent}`;
2196
+ }
2197
+
2198
+ if (status === 'completed' || status === 'failed') {
2199
+ return `agent ${agent} ${status}`;
2200
+ }
2201
+
2202
+ if (status === 'running' && toolName) {
2203
+ return toolArgs
2204
+ ? `agent ${agent} running ${toolName} ${toolArgs}`
2205
+ : `agent ${agent} running ${toolName}`;
2206
+ }
2207
+
2208
+ return null;
2209
+ }
2210
+
2211
+ const parts: string[] = [];
2212
+ if (typeof data?.taskId === 'string') parts.push(shortId(data.taskId));
2213
+ if (typeof data?.agent === 'string') parts.push(data.agent);
2214
+ else if (typeof data?.agentName === 'string') parts.push(data.agentName);
2215
+ else if (typeof data?.name === 'string') parts.push(data.name);
2216
+ if (typeof data?.status === 'string') parts.push(data.status);
2217
+ if (typeof data?.message === 'string') parts.push(data.message);
2218
+ if (typeof data?.error === 'string') parts.push(`error: ${data.error}`);
2219
+ if (typeof data?.text === 'string') parts.push(data.text);
2220
+ if (typeof data?.delta === 'string') parts.push(data.delta);
2221
+
2222
+ let detail = parts.find((part) => part.trim().length > 0) ?? '';
2223
+
2224
+ if (detail) {
2225
+ detail = truncateToWidth(normalize(detail), 120);
2226
+ return `${eventName} ${detail}`;
2227
+ }
2228
+
2229
+ if (eventData !== undefined) {
2230
+ try {
2231
+ const serialized =
2232
+ typeof eventData === 'string' ? eventData : JSON.stringify(eventData);
2233
+ if (serialized) {
2234
+ const compact = truncateToWidth(serialized.replace(/\s+/g, ' ').trim(), 140);
2235
+ return `${eventName} ${compact}`;
2236
+ }
2237
+ } catch {
2238
+ // Best-effort formatting only.
2239
+ }
2240
+ }
2241
+ return null;
2242
+ }
2243
+
2244
+ private getTaskFeedKey(sessionId: string, taskId: string): string {
2245
+ return `${sessionId}:${taskId}`;
2246
+ }
2247
+
2248
+ private renderListScreen(width: number, maxLines: number): string[] {
2249
+ const inner = Math.max(0, width - 2);
2250
+ const lines: string[] = [];
2251
+ const headerRows = 2;
2252
+ const footerRows = 2;
2253
+ const contentBudget = Math.max(5, maxLines - headerRows - footerRows);
2254
+ const body: string[] = [];
2255
+
2256
+ lines.push(buildTopBorder(width, 'Coder Hub Sessions'));
2257
+ lines.push(this.contentLine('', inner));
2258
+
2259
+ if (this.loadingList) {
2260
+ body.push(this.contentLine(this.theme.fg('dim', ' Loading sessions...'), inner));
2261
+ } else if (this.listError) {
2262
+ body.push(this.contentLine(this.theme.fg('error', ` ${this.listError}`), inner));
2263
+ } else {
2264
+ const updated = this.lastUpdatedAt
2265
+ ? `${formatClock(this.lastUpdatedAt)} updated`
2266
+ : 'not updated';
2267
+ const liveCount = this.sessions.filter((session) => {
2268
+ const connection = this.getConnectionState({
2269
+ sessionId: session.sessionId,
2270
+ mode: session.mode,
2271
+ status: session.status,
2272
+ bucket: session.bucket,
2273
+ runtimeAvailable: session.runtimeAvailable,
2274
+ controlAvailable: session.controlAvailable,
2275
+ historyOnly: session.historyOnly,
2276
+ });
2277
+ return connection.label === 'Live';
2278
+ }).length;
2279
+ const pausedCount = this.sessions.filter(
2280
+ (session) => session.bucket === 'paused' && session.historyOnly !== true
2281
+ ).length;
2282
+ const historyCount = this.sessions.filter(
2283
+ (session) => session.historyOnly === true || session.bucket === 'history'
2284
+ ).length;
2285
+ body.push(
2286
+ this.contentLine(
2287
+ this.theme.fg(
2288
+ 'muted',
2289
+ ` Active: ${this.sessions.length} sessions Live:${liveCount} Paused:${pausedCount} History:${historyCount} ${updated}`
2290
+ ),
2291
+ inner
2292
+ )
2293
+ );
2294
+ }
2295
+ body.push(
2296
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2297
+ );
2298
+ body.push(this.contentLine(this.theme.bold(' Teams / Observers'), inner));
2299
+
2300
+ if (this.sessions.length === 0 && !this.loadingList && !this.listError) {
2301
+ body.push(this.contentLine(this.theme.fg('muted', ' No active Hub sessions'), inner));
2302
+ } else if (!this.loadingList && !this.listError) {
2303
+ const listBudget = Math.max(1, contentBudget - body.length);
2304
+ const [start, end] = getVisibleRange(this.sessions.length, this.selectedIndex, listBudget);
2305
+ if (start > 0) {
2306
+ body.push(this.contentLine(this.theme.fg('dim', ` ↑ ${start} more above`), inner));
2307
+ }
2308
+ for (let i = start; i < end; i++) {
2309
+ const session = this.sessions[i]!;
2310
+ const selected = i === this.selectedIndex;
2311
+ const marker = selected ? this.theme.fg('accent', '›') : ' ';
2312
+ const label = session.label || shortId(session.sessionId);
2313
+ const name = selected ? this.theme.bold(label) : label;
2314
+ const status = this.theme.fg(this.getStatusTone(session.status), session.status);
2315
+ const connection = this.getConnectionState({
2316
+ sessionId: session.sessionId,
2317
+ mode: session.mode,
2318
+ status: session.status,
2319
+ bucket: session.bucket,
2320
+ runtimeAvailable: session.runtimeAvailable,
2321
+ controlAvailable: session.controlAvailable,
2322
+ historyOnly: session.historyOnly,
2323
+ });
2324
+ const connectionLabel = this.theme.fg(connection.tone, connection.label);
2325
+ const self =
2326
+ this.currentSessionId === session.sessionId
2327
+ ? this.theme.fg('accent', ' (this)')
2328
+ : '';
2329
+ const tagSummary = this.formatTagSummary(session.tags, 2);
2330
+ const metricsParts = [
2331
+ `obs:${session.observerCount}`,
2332
+ `agents:${session.subAgentCount}`,
2333
+ `tasks:${session.taskCount}`,
2334
+ formatRelative(session.createdAt),
2335
+ session.defaultAgent ? `agent:${session.defaultAgent}` : undefined,
2336
+ tagSummary ? `tags:${tagSummary}` : undefined,
2337
+ ].filter((part): part is string => !!part);
2338
+ const metrics = this.theme.fg('muted', metricsParts.join(' '));
2339
+ body.push(
2340
+ this.contentLine(
2341
+ ` ${marker} ${name}${self} ${status} ${session.mode} ${connectionLabel} ${metrics}`,
2342
+ inner
2343
+ )
2344
+ );
2345
+ }
2346
+ if (end < this.sessions.length) {
2347
+ body.push(
2348
+ this.contentLine(
2349
+ this.theme.fg('dim', ` ↓ ${this.sessions.length - end} more below`),
2350
+ inner
2351
+ )
2352
+ );
2353
+ }
2354
+ }
2355
+
2356
+ const windowedBody = body.slice(0, contentBudget);
2357
+ lines.push(...windowedBody);
2358
+ while (lines.length < maxLines - footerRows) {
2359
+ lines.push(this.contentLine('', inner));
2360
+ }
2361
+
2362
+ lines.push(
2363
+ this.contentLine(
2364
+ this.theme.fg('dim', ' [↑↓] Select [Enter] Open [r] Refresh [Esc] Close'),
2365
+ inner
2366
+ )
2367
+ );
2368
+ lines.push(buildBottomBorder(width));
2369
+ return lines.slice(0, maxLines);
2370
+ }
2371
+
2372
+ private renderDetailScreen(width: number, maxLines: number): string[] {
2373
+ const inner = Math.max(0, width - 2);
2374
+ const lines: string[] = [];
2375
+ const title =
2376
+ this.detail?.label ||
2377
+ (this.detailSessionId ? shortId(this.detailSessionId) : 'Hub Session');
2378
+ const headerRows = 3;
2379
+ const footerRows = 2;
2380
+ const contentBudget = Math.max(5, maxLines - headerRows - footerRows);
2381
+
2382
+ lines.push(buildTopBorder(width, `Session ${title}`));
2383
+ lines.push(this.contentLine('', inner));
2384
+ lines.push(this.contentLine(this.buildTopTabs('detail', true), inner));
2385
+
2386
+ const body: string[] = [];
2387
+ if (this.loadingDetail) {
2388
+ body.push(this.contentLine(this.theme.fg('dim', ' Loading session detail...'), inner));
2389
+ } else if (this.detailError) {
2390
+ body.push(this.contentLine(this.theme.fg('error', ` ${this.detailError}`), inner));
2391
+ } else if (!this.detail) {
2392
+ body.push(this.contentLine(this.theme.fg('muted', ' No detail available'), inner));
2393
+ } else {
2394
+ const session = this.detail;
2395
+ const participants = session.participants ?? [];
2396
+ const tasks = session.tasks ?? [];
2397
+ const activityEntries = Object.entries(session.agentActivity ?? {});
2398
+ const connection = this.getConnectionState({
2399
+ sessionId: session.sessionId,
2400
+ mode: session.mode,
2401
+ status: session.status,
2402
+ bucket: session.bucket,
2403
+ runtimeAvailable: session.runtimeAvailable,
2404
+ controlAvailable: session.controlAvailable,
2405
+ historyOnly: session.historyOnly,
2406
+ });
2407
+ const inactiveTasks = session.diagnostics?.inactiveRunningTasks ?? [];
2408
+ const inactiveTaskById = new Map(
2409
+ inactiveTasks.map((item) => [item.taskId, item] as const)
2410
+ );
2411
+ const pushWrappedLine = (
2412
+ text: string,
2413
+ tone: 'muted' | 'warning' | 'error' | 'dim' = 'muted'
2414
+ ): void => {
2415
+ for (const wrapped of wrapText(text, Math.max(12, inner - 4))) {
2416
+ body.push(this.contentLine(this.theme.fg(tone, ` ${wrapped}`), inner));
2417
+ }
2418
+ };
2419
+
2420
+ body.push(this.contentLine(this.theme.bold(' Overview'), inner));
2421
+ body.push(this.contentLine(this.theme.fg('muted', ` ID: ${session.sessionId}`), inner));
2422
+ body.push(
2423
+ this.contentLine(
2424
+ ` Status: ${this.theme.fg(this.getStatusTone(session.status), session.status)} Mode: ${session.mode} Bucket: ${session.bucket ?? '-'}`,
2425
+ inner
2426
+ )
2427
+ );
2428
+ body.push(
2429
+ this.contentLine(
2430
+ ` Connection: ${this.theme.fg(connection.tone, connection.label)} Runtime: ${
2431
+ session.runtimeAvailable === false ? 'offline' : 'ready'
2432
+ } Control: ${connection.controlAvailable ? 'enabled' : 'read-only'}`,
2433
+ inner
2434
+ )
2435
+ );
2436
+ body.push(
2437
+ this.contentLine(
2438
+ this.theme.fg('muted', ` Created: ${formatRelative(session.createdAt)}`),
2439
+ inner
2440
+ )
2441
+ );
2442
+ body.push(
2443
+ this.contentLine(
2444
+ this.theme.fg(
2445
+ 'muted',
2446
+ ` Participants: ${participants.length} Tasks: ${tasks.length} Active agents: ${activityEntries.length}`
2447
+ ),
2448
+ inner
2449
+ )
2450
+ );
2451
+ if (session.defaultAgent) {
2452
+ body.push(
2453
+ this.contentLine(
2454
+ this.theme.fg('muted', ` Default agent: ${session.defaultAgent}`),
2455
+ inner
2456
+ )
2457
+ );
2458
+ }
2459
+ const tagSummary = this.formatTagSummary(session.tags);
2460
+ if (tagSummary) {
2461
+ pushWrappedLine(`Tags: ${tagSummary}`);
2462
+ }
2463
+ if (session.streamId || session.streamUrl) {
2464
+ const streamState = session.streamId
2465
+ ? shortId(session.streamId)
2466
+ : session.streamUrl
2467
+ ? 'attached'
2468
+ : 'none';
2469
+ body.push(this.contentLine(this.theme.fg('muted', ` Stream: ${streamState}`), inner));
2470
+ }
2471
+ if (session.context?.branch) {
2472
+ body.push(
2473
+ this.contentLine(
2474
+ this.theme.fg('muted', ` Branch: ${session.context.branch}`),
2475
+ inner
2476
+ )
2477
+ );
2478
+ }
2479
+ if (session.context?.workingDirectory) {
2480
+ body.push(
2481
+ this.contentLine(
2482
+ this.theme.fg('muted', ` CWD: ${session.context.workingDirectory}`),
2483
+ inner
2484
+ )
2485
+ );
2486
+ }
2487
+ if (session.task) {
2488
+ body.push(
2489
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2490
+ );
2491
+ body.push(this.contentLine(this.theme.bold(' Root Task'), inner));
2492
+ for (const wrapped of wrapText(session.task, Math.max(12, inner - 4))) {
2493
+ body.push(this.contentLine(` ${wrapped}`, inner));
2494
+ }
2495
+ }
2496
+ if (session.error) {
2497
+ body.push(
2498
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2499
+ );
2500
+ body.push(this.contentLine(this.theme.bold(' Error'), inner));
2501
+ pushWrappedLine(session.error, 'error');
2502
+ }
2503
+ if (this.canResumeSession(session)) {
2504
+ const resumeMessage =
2505
+ this.resumeBusySessionId === session.sessionId
2506
+ ? 'Waking sandbox session...'
2507
+ : 'Press [w] to wake this paused sandbox session.';
2508
+ pushWrappedLine(
2509
+ resumeMessage,
2510
+ this.resumeBusySessionId === session.sessionId ? 'muted' : 'warning'
2511
+ );
2512
+ }
2513
+ body.push(
2514
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2515
+ );
2516
+ body.push(this.contentLine(this.theme.bold(' Tasks'), inner));
2517
+ body.push(
2518
+ this.contentLine(
2519
+ this.theme.fg('dim', ' Use ↑ and ↓ to move, Enter to open task view'),
2520
+ inner
2521
+ )
2522
+ );
2523
+ if (tasks.length === 0) {
2524
+ body.push(this.contentLine(this.theme.fg('dim', ' (no tasks yet)'), inner));
2525
+ } else {
2526
+ if (this.selectedTaskIndex >= tasks.length) {
2527
+ this.selectedTaskIndex = tasks.length - 1;
2528
+ }
2529
+ for (let i = 0; i < Math.min(tasks.length, 50); i++) {
2530
+ const task = tasks[i]!;
2531
+ const selected = i === this.selectedTaskIndex;
2532
+ const statusColor =
2533
+ task.status === 'completed'
2534
+ ? 'success'
2535
+ : task.status === 'failed'
2536
+ ? 'error'
2537
+ : task.status === 'running'
2538
+ ? 'accent'
2539
+ : 'warning';
2540
+ const status = this.theme.fg(statusColor, task.status);
2541
+ const inactive = inactiveTaskById.get(task.taskId);
2542
+ const prompt = task.prompt
2543
+ ? truncateToWidth(toSingleLine(task.prompt), Math.max(16, inner - 34))
2544
+ : '';
2545
+ const duration =
2546
+ typeof task.duration === 'number'
2547
+ ? ` ${formatElapsedCompact(task.duration)}`
2548
+ : '';
2549
+ const idle = inactive
2550
+ ? ` ${this.theme.fg(
2551
+ 'warning',
2552
+ `idle ${formatElapsedCompact(inactive.inactivityMs)}`
2553
+ )}`
2554
+ : '';
2555
+ const marker = selected ? this.theme.fg('accent', '›') : ' ';
2556
+ body.push(
2557
+ this.contentLine(
2558
+ `${marker} ${shortId(task.taskId).padEnd(12)} ${task.agent.padEnd(9)} ${status}${duration}${idle} ${prompt}`,
2559
+ inner
2560
+ )
2561
+ );
2562
+ }
2563
+ }
2564
+
2565
+ body.push(
2566
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2567
+ );
2568
+ body.push(this.contentLine(this.theme.bold(' Session Todos'), inner));
2569
+ if (session.todosUnavailable) {
2570
+ body.push(
2571
+ this.contentLine(this.theme.fg('warning', ` ${session.todosUnavailable}`), inner)
2572
+ );
2573
+ } else {
2574
+ const todos = session.todos ?? [];
2575
+ const summary: HubTodoSummary = session.todoSummary ?? {
2576
+ open: 0,
2577
+ in_progress: 0,
2578
+ done: 0,
2579
+ closed: 0,
2580
+ cancelled: 0,
2581
+ };
2582
+ body.push(
2583
+ this.contentLine(
2584
+ this.theme.fg(
2585
+ 'dim',
2586
+ ` open:${summary.open ?? 0} in_progress:${summary.in_progress ?? 0} done:${(summary.done ?? 0) + (summary.closed ?? 0)} cancelled:${summary.cancelled ?? 0}`
2587
+ ),
2588
+ inner
2589
+ )
2590
+ );
2591
+ if (todos.length === 0) {
2592
+ body.push(
2593
+ this.contentLine(
2594
+ this.theme.fg(
2595
+ 'dim',
2596
+ ` (no todos linked to session ${shortId(session.sessionId)})`
2597
+ ),
2598
+ inner
2599
+ )
2600
+ );
2601
+ } else {
2602
+ for (const todo of todos.slice(0, 20)) {
2603
+ const statusColor =
2604
+ todo.status === 'done' || todo.status === 'closed'
2605
+ ? 'success'
2606
+ : todo.status === 'cancelled'
2607
+ ? 'error'
2608
+ : todo.status === 'in_progress'
2609
+ ? 'accent'
2610
+ : 'warning';
2611
+ const status = this.theme.fg(
2612
+ statusColor as 'success' | 'error' | 'warning' | 'accent',
2613
+ todo.status
2614
+ );
2615
+ const details = [
2616
+ typeof todo.priority === 'string' ? `prio:${todo.priority}` : undefined,
2617
+ typeof todo.type === 'string' ? `type:${todo.type}` : undefined,
2618
+ typeof todo.assignee?.name === 'string' && todo.assignee.name.length > 0
2619
+ ? `owner:${todo.assignee.name}`
2620
+ : typeof todo.assignee?.id === 'string' && todo.assignee.id.length > 0
2621
+ ? `owner:${todo.assignee.id}`
2622
+ : undefined,
2623
+ typeof todo.attachmentCount === 'number' && todo.attachmentCount > 0
2624
+ ? `att:${todo.attachmentCount}`
2625
+ : undefined,
2626
+ ].filter((part): part is string => !!part);
2627
+ const title = truncateToWidth(
2628
+ toSingleLine(todo.title || ''),
2629
+ Math.max(16, inner - 48)
2630
+ );
2631
+ const meta =
2632
+ details.length > 0 ? this.theme.fg('dim', ` ${details.join(' ')}`) : '';
2633
+ body.push(
2634
+ this.contentLine(
2635
+ ` ${shortId(todo.id).padEnd(12)} ${status} ${title}${meta}`,
2636
+ inner
2637
+ )
2638
+ );
2639
+ }
2640
+ if (todos.length > 20) {
2641
+ body.push(
2642
+ this.contentLine(
2643
+ this.theme.fg('dim', ` ... ${todos.length - 20} more todos`),
2644
+ inner
2645
+ )
2646
+ );
2647
+ }
2648
+ }
2649
+ }
2650
+
2651
+ body.push(
2652
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2653
+ );
2654
+ body.push(this.contentLine(this.theme.bold(' Participants'), inner));
2655
+ if (participants.length === 0) {
2656
+ body.push(this.contentLine(this.theme.fg('dim', ' (none)'), inner));
2657
+ } else {
2658
+ for (const participant of participants) {
2659
+ const when = participant.connectedAt ? formatRelative(participant.connectedAt) : '-';
2660
+ const idle = participant.idle ? this.theme.fg('warning', ' idle') : '';
2661
+ body.push(
2662
+ this.contentLine(
2663
+ ` ${participant.id.padEnd(12)} ${participant.role.padEnd(9)} ${(participant.transport || 'ws').padEnd(3)} ${when}${idle}`,
2664
+ inner
2665
+ )
2666
+ );
2667
+ }
2668
+ }
2669
+
2670
+ if (inactiveTasks.length > 0) {
2671
+ body.push(
2672
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2673
+ );
2674
+ body.push(this.contentLine(this.theme.bold(' Diagnostics'), inner));
2675
+ body.push(
2676
+ this.contentLine(
2677
+ this.theme.fg(
2678
+ 'warning',
2679
+ ` ${inactiveTasks.length} running task${inactiveTasks.length === 1 ? '' : 's'} without recent activity`
2680
+ ),
2681
+ inner
2682
+ )
2683
+ );
2684
+ for (const item of inactiveTasks.slice(0, 8)) {
2685
+ const lastSeen = item.lastActivityAt
2686
+ ? ` last ${formatRelative(item.lastActivityAt)}`
2687
+ : '';
2688
+ body.push(
2689
+ this.contentLine(
2690
+ this.theme.fg(
2691
+ 'warning',
2692
+ ` ${shortId(item.taskId).padEnd(12)} ${item.agent.padEnd(10)} idle ${formatElapsedCompact(item.inactivityMs)} started ${formatRelative(item.startedAt)}${lastSeen}`
2693
+ ),
2694
+ inner
2695
+ )
2696
+ );
2697
+ }
2698
+ }
2699
+
2700
+ body.push(
2701
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2702
+ );
2703
+ body.push(this.contentLine(this.theme.bold(' Agent Activity'), inner));
2704
+ if (activityEntries.length === 0) {
2705
+ body.push(this.contentLine(this.theme.fg('dim', ' (none)'), inner));
2706
+ } else {
2707
+ for (const [agent, info] of activityEntries.slice(0, 15)) {
2708
+ const status = info.status || 'idle';
2709
+ const statusTone =
2710
+ status === 'completed'
2711
+ ? 'success'
2712
+ : status === 'failed'
2713
+ ? 'error'
2714
+ : status === 'tool_start' || status === 'running'
2715
+ ? 'accent'
2716
+ : 'warning';
2717
+ const tool = info.currentTool ? ` tool:${info.currentTool}` : '';
2718
+ const calls =
2719
+ typeof info.toolCallCount === 'number' ? ` calls:${info.toolCallCount}` : '';
2720
+ const lastSeen =
2721
+ info.lastActivity !== undefined && info.lastActivity !== null
2722
+ ? ` last:${formatRelative(info.lastActivity)}`
2723
+ : '';
2724
+ const totalElapsed =
2725
+ typeof info.totalElapsed === 'number'
2726
+ ? ` total:${formatElapsedCompact(info.totalElapsed)}`
2727
+ : '';
2728
+ body.push(
2729
+ this.contentLine(
2730
+ ` ${agent.padEnd(12)} ${this.theme.fg(statusTone, status)}${this.theme.fg(
2731
+ 'dim',
2732
+ `${tool}${calls}${lastSeen}${totalElapsed}`
2733
+ )}`,
2734
+ inner
2735
+ )
2736
+ );
2737
+ if (info.currentToolArgs) {
2738
+ for (const wrapped of wrapText(
2739
+ toSingleLine(info.currentToolArgs),
2740
+ Math.max(12, inner - 6)
2741
+ )) {
2742
+ body.push(this.contentLine(this.theme.fg('dim', ` ${wrapped}`), inner));
2743
+ }
2744
+ }
2745
+ }
2746
+ }
2747
+ }
2748
+
2749
+ this.detailMaxScroll = Math.max(0, body.length - contentBudget);
2750
+ if (this.detailScrollOffset > this.detailMaxScroll) {
2751
+ this.detailScrollOffset = this.detailMaxScroll;
2752
+ }
2753
+ const windowedBody = body.slice(
2754
+ this.detailScrollOffset,
2755
+ this.detailScrollOffset + contentBudget
2756
+ );
2757
+ lines.push(...windowedBody);
2758
+ while (lines.length < maxLines - footerRows) {
2759
+ lines.push(this.contentLine('', inner));
2760
+ }
2761
+
2762
+ const scrollInfo =
2763
+ this.detailMaxScroll > 0
2764
+ ? this.theme.fg('dim', ` scroll ${this.detailScrollOffset}/${this.detailMaxScroll}`)
2765
+ : this.theme.fg('dim', ' scroll 0/0');
2766
+ const wakeHint = this.canResumeSession(this.detail) ? ' [w] Wake' : '';
2767
+ lines.push(
2768
+ this.contentLine(
2769
+ `${scrollInfo} ${this.theme.fg('dim', `[↑↓] Task [j/k] Scroll [Enter] Open${wakeHint} [r] Refresh [Esc] Back`)}`,
2770
+ inner
2771
+ )
2772
+ );
2773
+ lines.push(buildBottomBorder(width));
2774
+ return lines.slice(0, maxLines);
2775
+ }
2776
+
2777
+ private renderFeedScreen(width: number, maxLines: number): string[] {
2778
+ const inner = Math.max(0, width - 2);
2779
+ const lines: string[] = [];
2780
+ const headerRows = 5;
2781
+ const footerRows = 2;
2782
+ const contentBudget = Math.max(5, maxLines - headerRows - footerRows);
2783
+ const scoped = this.feedScope === 'session' && !!this.detailSessionId;
2784
+ const sessionId = scoped ? this.detailSessionId! : null;
2785
+ const historySession = sessionId
2786
+ ? !this.isRuntimeAvailable(sessionId) || this.isHistoryOnly(sessionId)
2787
+ : false;
2788
+ const title = scoped
2789
+ ? `${this.feedViewMode === 'events' ? 'Session Events' : 'Session Feed'} ${this.getSessionLabel(sessionId!)}`
2790
+ : 'Global Feed';
2791
+ const entryBudget = Math.max(1, contentBudget - 2);
2792
+ const sessionBuffer = sessionId ? this.sessionBuffers.get(sessionId) : undefined;
2793
+
2794
+ lines.push(buildTopBorder(width, title));
2795
+ lines.push(this.contentLine('', inner));
2796
+ lines.push(
2797
+ this.contentLine(
2798
+ this.buildTopTabs(
2799
+ scoped ? (this.feedViewMode === 'events' ? 'events' : 'feed') : 'feed',
2800
+ scoped
2801
+ ),
2802
+ inner
2803
+ )
2804
+ );
2805
+ lines.push(
2806
+ this.contentLine(
2807
+ this.theme.fg(
2808
+ 'muted',
2809
+ scoped
2810
+ ? this.feedViewMode === 'stream'
2811
+ ? historySession
2812
+ ? ' Rehydrated session output from stored replay — [v] task stream'
2813
+ : ' Live rendered session output (sub-agent style) — [v] task stream'
2814
+ : historySession
2815
+ ? ' Stored session events from history'
2816
+ : ' Live full session events'
2817
+ : ' Streaming event summaries across all sessions'
2818
+ ),
2819
+ inner
2820
+ )
2821
+ );
2822
+ lines.push(
2823
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2824
+ );
2825
+
2826
+ let contentLines: string[] = [];
2827
+ if (scoped && this.feedViewMode === 'stream') {
2828
+ contentLines = this.renderStreamLines(
2829
+ sessionBuffer?.output ?? '',
2830
+ sessionBuffer?.thinking ?? '',
2831
+ this.showFeedThinking,
2832
+ Math.max(12, inner - 4)
2833
+ );
2834
+ if (contentLines.length === 0) {
2835
+ if (sessionId && historySession && this.replayInFlight.has(sessionId)) {
2836
+ contentLines = [this.theme.fg('dim', '(loading replay...)')];
2837
+ } else if (sessionId && historySession && this.replayLoadedSessions.has(sessionId)) {
2838
+ contentLines = [this.theme.fg('dim', '(no replay output available)')];
2839
+ } else {
2840
+ contentLines = [this.theme.fg('dim', '(no streamed output yet)')];
2841
+ }
2842
+ }
2843
+ } else {
2844
+ const entries = this.getFeedEntries(scoped ? 'session' : 'global');
2845
+ contentLines = [...entries]
2846
+ .reverse()
2847
+ .map((entry) => `${this.theme.fg('dim', formatClock(entry.at))} ${entry.text}`);
2848
+ if (contentLines.length === 0) {
2849
+ if (sessionId && historySession && this.eventHistoryInFlight.has(sessionId)) {
2850
+ contentLines = [this.theme.fg('dim', '(loading event history...)')];
2851
+ } else if (
2852
+ sessionId &&
2853
+ historySession &&
2854
+ this.eventHistoryLoadedSessions.has(sessionId)
2855
+ ) {
2856
+ contentLines = [this.theme.fg('dim', '(no stored events available)')];
2857
+ } else {
2858
+ contentLines = [
2859
+ this.theme.fg(
2860
+ 'dim',
2861
+ scoped ? '(no session feed items yet)' : '(no feed items yet)'
2862
+ ),
2863
+ ];
2864
+ }
2865
+ }
2866
+ }
2867
+
2868
+ this.feedMaxScroll = Math.max(0, contentLines.length - entryBudget);
2869
+ if (this.feedFollowing) {
2870
+ this.feedScrollOffset = this.feedMaxScroll;
2871
+ }
2872
+ if (this.feedScrollOffset > this.feedMaxScroll) {
2873
+ this.feedScrollOffset = this.feedMaxScroll;
2874
+ }
2875
+
2876
+ const windowed = contentLines.slice(
2877
+ this.feedScrollOffset,
2878
+ this.feedScrollOffset + entryBudget
2879
+ );
2880
+ for (const line of windowed) {
2881
+ lines.push(this.contentLine(` ${line}`, inner));
2882
+ }
2883
+ while (lines.length < maxLines - footerRows) {
2884
+ lines.push(this.contentLine('', inner));
2885
+ }
2886
+
2887
+ const scrollInfo =
2888
+ this.feedMaxScroll > 0
2889
+ ? this.theme.fg('dim', ` scroll ${this.feedScrollOffset}/${this.feedMaxScroll}`)
2890
+ : this.theme.fg('dim', ' scroll 0/0');
2891
+ const thinkingHint =
2892
+ scoped && this.feedViewMode === 'stream' && sessionBuffer?.thinking
2893
+ ? ' [t] Thinking'
2894
+ : '';
2895
+ const taskHint = scoped && this.feedViewMode === 'stream' ? ' [v] Task view' : '';
2896
+ const followHint = ` [f] ${this.feedFollowing ? 'Unfollow' : 'Follow'}`;
2897
+ lines.push(
2898
+ this.contentLine(
2899
+ `${scrollInfo} ${this.theme.fg('dim', `[↑↓] Scroll${thinkingHint}${taskHint}${followHint} [r] Refresh [Esc] Back`)}`,
2900
+ inner
2901
+ )
2902
+ );
2903
+ lines.push(buildBottomBorder(width));
2904
+ return lines.slice(0, maxLines);
2905
+ }
2906
+
2907
+ private renderTaskScreen(width: number, maxLines: number): string[] {
2908
+ const inner = Math.max(0, width - 2);
2909
+ const lines: string[] = [];
2910
+ const headerRows = 2;
2911
+ const footerRows = 2;
2912
+ const contentBudget = Math.max(5, maxLines - headerRows - footerRows);
2913
+ const tasks = this.getDetailTasks();
2914
+ const selected = tasks[this.selectedTaskIndex];
2915
+ const title = selected ? `Task ${shortId(selected.taskId)} ${selected.agent}` : 'Task Detail';
2916
+
2917
+ lines.push(buildTopBorder(width, title));
2918
+ lines.push(this.contentLine('', inner));
2919
+
2920
+ const body: string[] = [];
2921
+ if (!selected) {
2922
+ body.push(this.contentLine(this.theme.fg('dim', ' No task selected'), inner));
2923
+ } else {
2924
+ const historySession = this.detailSessionId
2925
+ ? !this.isRuntimeAvailable(this.detailSessionId) ||
2926
+ this.isHistoryOnly(this.detailSessionId)
2927
+ : false;
2928
+ body.push(this.contentLine(this.theme.bold(' Task Overview'), inner));
2929
+ body.push(
2930
+ this.contentLine(this.theme.fg('muted', ` Task ID: ${selected.taskId}`), inner)
2931
+ );
2932
+ body.push(this.contentLine(this.theme.fg('muted', ` Agent: ${selected.agent}`), inner));
2933
+ body.push(this.contentLine(this.theme.fg('muted', ` Status: ${selected.status}`), inner));
2934
+ if (typeof selected.duration === 'number') {
2935
+ body.push(
2936
+ this.contentLine(
2937
+ this.theme.fg('muted', ` Duration: ${formatElapsedCompact(selected.duration)}`),
2938
+ inner
2939
+ )
2940
+ );
2941
+ }
2942
+ if (selected.startedAt) {
2943
+ body.push(
2944
+ this.contentLine(this.theme.fg('muted', ` Started: ${selected.startedAt}`), inner)
2945
+ );
2946
+ }
2947
+ if (selected.completedAt) {
2948
+ body.push(
2949
+ this.contentLine(
2950
+ this.theme.fg('muted', ` Completed: ${selected.completedAt}`),
2951
+ inner
2952
+ )
2953
+ );
2954
+ }
2955
+ body.push(
2956
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2957
+ );
2958
+ body.push(this.contentLine(this.theme.bold(' Prompt'), inner));
2959
+
2960
+ const wrappedPrompt = wrapText(
2961
+ selected.prompt || '(no prompt recorded)',
2962
+ Math.max(10, inner - 4)
2963
+ );
2964
+ for (const wrapped of wrappedPrompt) {
2965
+ body.push(this.contentLine(` ${wrapped}`, inner));
2966
+ }
2967
+ body.push(
2968
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2969
+ );
2970
+ body.push(this.contentLine(this.theme.bold(' Task Output'), inner));
2971
+
2972
+ const taskBuffer = this.detailSessionId
2973
+ ? this.taskBuffers.get(this.getTaskFeedKey(this.detailSessionId, selected.taskId))
2974
+ : undefined;
2975
+
2976
+ const rendered = this.renderStreamLines(
2977
+ taskBuffer?.output ?? '',
2978
+ taskBuffer?.thinking ?? '',
2979
+ this.showTaskThinking,
2980
+ Math.max(12, inner - 4)
2981
+ );
2982
+ if (rendered.length === 0) {
2983
+ if (
2984
+ historySession &&
2985
+ this.detailSessionId &&
2986
+ this.replayInFlight.has(this.detailSessionId)
2987
+ ) {
2988
+ body.push(this.contentLine(this.theme.fg('dim', ' (loading replay...)'), inner));
2989
+ } else {
2990
+ body.push(this.contentLine(this.theme.fg('dim', ' (no task output yet)'), inner));
2991
+ }
2992
+ } else {
2993
+ for (const line of rendered) {
2994
+ body.push(this.contentLine(` ${line}`, inner));
2995
+ }
2996
+ }
2997
+ }
2998
+
2999
+ this.taskMaxScroll = Math.max(0, body.length - contentBudget);
3000
+ if (this.taskFollowing) {
3001
+ this.taskScrollOffset = this.taskMaxScroll;
3002
+ }
3003
+ if (this.taskScrollOffset > this.taskMaxScroll) {
3004
+ this.taskScrollOffset = this.taskMaxScroll;
3005
+ }
3006
+
3007
+ const windowedBody = body.slice(this.taskScrollOffset, this.taskScrollOffset + contentBudget);
3008
+ lines.push(...windowedBody);
3009
+ while (lines.length < maxLines - footerRows) {
3010
+ lines.push(this.contentLine('', inner));
3011
+ }
3012
+
3013
+ const scrollInfo =
3014
+ this.taskMaxScroll > 0
3015
+ ? this.theme.fg('dim', ` scroll ${this.taskScrollOffset}/${this.taskMaxScroll}`)
3016
+ : this.theme.fg('dim', ' scroll 0/0');
3017
+ const selectedTaskThinking =
3018
+ this.detailSessionId && selected
3019
+ ? this.taskBuffers.get(this.getTaskFeedKey(this.detailSessionId, selected.taskId))
3020
+ ?.thinking
3021
+ : '';
3022
+ const thinkingHint = selectedTaskThinking ? ' [t] Thinking' : '';
3023
+ const followHint = ` [f] ${this.taskFollowing ? 'Unfollow' : 'Follow'}`;
3024
+ lines.push(
3025
+ this.contentLine(
3026
+ `${scrollInfo} ${this.theme.fg('dim', `[↑↓] Scroll [[ and ]] Task${thinkingHint}${followHint} [Esc] Back`)}`,
3027
+ inner
3028
+ )
3029
+ );
3030
+ lines.push(buildBottomBorder(width));
3031
+ return lines.slice(0, maxLines);
3032
+ }
3033
+
3034
+ private contentLine(text: string, innerWidth: number): string {
3035
+ return `│${padRight(text, innerWidth)}│`;
3036
+ }
3037
+ }