@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.
- package/README.md +57 -0
- package/dist/chain-preview.d.ts +55 -0
- package/dist/chain-preview.d.ts.map +1 -0
- package/dist/chain-preview.js +472 -0
- package/dist/chain-preview.js.map +1 -0
- package/dist/client.d.ts +44 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +411 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +22 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +99 -0
- package/dist/commands.js.map +1 -0
- package/dist/footer.d.ts +34 -0
- package/dist/footer.d.ts.map +1 -0
- package/dist/footer.js +249 -0
- package/dist/footer.js.map +1 -0
- package/dist/handlers.d.ts +24 -0
- package/dist/handlers.d.ts.map +1 -0
- package/dist/handlers.js +83 -0
- package/dist/handlers.js.map +1 -0
- package/dist/hub-overlay-state.d.ts +31 -0
- package/dist/hub-overlay-state.d.ts.map +1 -0
- package/dist/hub-overlay-state.js +78 -0
- package/dist/hub-overlay-state.js.map +1 -0
- package/dist/hub-overlay.d.ts +146 -0
- package/dist/hub-overlay.d.ts.map +1 -0
- package/dist/hub-overlay.js +2354 -0
- package/dist/hub-overlay.js.map +1 -0
- package/dist/inbound-rpc.d.ts +3 -0
- package/dist/inbound-rpc.d.ts.map +1 -0
- package/dist/inbound-rpc.js +29 -0
- package/dist/inbound-rpc.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1641 -0
- package/dist/index.js.map +1 -0
- package/dist/native-remote-ui-context.d.ts +5 -0
- package/dist/native-remote-ui-context.d.ts.map +1 -0
- package/dist/native-remote-ui-context.js +30 -0
- package/dist/native-remote-ui-context.js.map +1 -0
- package/dist/output-viewer.d.ts +49 -0
- package/dist/output-viewer.d.ts.map +1 -0
- package/dist/output-viewer.js +389 -0
- package/dist/output-viewer.js.map +1 -0
- package/dist/overlay.d.ts +40 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +225 -0
- package/dist/overlay.js.map +1 -0
- package/dist/protocol.d.ts +605 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +4 -0
- package/dist/protocol.js.map +1 -0
- package/dist/remote-lifecycle.d.ts +61 -0
- package/dist/remote-lifecycle.d.ts.map +1 -0
- package/dist/remote-lifecycle.js +190 -0
- package/dist/remote-lifecycle.js.map +1 -0
- package/dist/remote-session.d.ts +130 -0
- package/dist/remote-session.d.ts.map +1 -0
- package/dist/remote-session.js +896 -0
- package/dist/remote-session.js.map +1 -0
- package/dist/remote-tui.d.ts +42 -0
- package/dist/remote-tui.d.ts.map +1 -0
- package/dist/remote-tui.js +868 -0
- package/dist/remote-tui.js.map +1 -0
- package/dist/remote-ui-handler.d.ts +5 -0
- package/dist/remote-ui-handler.d.ts.map +1 -0
- package/dist/remote-ui-handler.js +53 -0
- package/dist/remote-ui-handler.js.map +1 -0
- package/dist/renderers.d.ts +34 -0
- package/dist/renderers.d.ts.map +1 -0
- package/dist/renderers.js +669 -0
- package/dist/renderers.js.map +1 -0
- package/dist/review.d.ts +15 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +154 -0
- package/dist/review.js.map +1 -0
- package/dist/titlebar.d.ts +3 -0
- package/dist/titlebar.d.ts.map +1 -0
- package/dist/titlebar.js +59 -0
- package/dist/titlebar.js.map +1 -0
- package/dist/todo/index.d.ts +3 -0
- package/dist/todo/index.d.ts.map +1 -0
- package/dist/todo/index.js +3 -0
- package/dist/todo/index.js.map +1 -0
- package/dist/todo/store.d.ts +6 -0
- package/dist/todo/store.d.ts.map +1 -0
- package/dist/todo/store.js +43 -0
- package/dist/todo/store.js.map +1 -0
- package/dist/todo/types.d.ts +13 -0
- package/dist/todo/types.d.ts.map +1 -0
- package/dist/todo/types.js +2 -0
- package/dist/todo/types.js.map +1 -0
- package/package.json +42 -0
- package/src/chain-preview.ts +621 -0
- package/src/client.ts +527 -0
- package/src/commands.ts +132 -0
- package/src/footer.ts +305 -0
- package/src/handlers.ts +113 -0
- package/src/hub-overlay-state.ts +127 -0
- package/src/hub-overlay.ts +3037 -0
- package/src/inbound-rpc.ts +35 -0
- package/src/index.ts +1963 -0
- package/src/native-remote-ui-context.ts +41 -0
- package/src/output-viewer.ts +480 -0
- package/src/overlay.ts +294 -0
- package/src/protocol.ts +758 -0
- package/src/remote-lifecycle.ts +270 -0
- package/src/remote-session.ts +1100 -0
- package/src/remote-tui.ts +1023 -0
- package/src/remote-ui-handler.ts +86 -0
- package/src/renderers.ts +740 -0
- package/src/review.ts +201 -0
- package/src/titlebar.ts +63 -0
- package/src/todo/index.ts +2 -0
- package/src/todo/store.ts +49 -0
- 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
|
+
}
|