@vellumai/cli 0.1.11 → 0.1.12
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/bun.lock +7 -0
- package/package.json +9 -2
- package/src/commands/client.ts +110 -0
- package/src/commands/hatch.ts +74 -5
- package/src/components/DefaultMainScreen.tsx +1756 -49
- package/src/components/TextInput.tsx +115 -0
- package/src/index.ts +3 -0
- package/src/lib/constants.ts +1 -1
- package/src/lib/doctor-client.ts +127 -0
- package/src/lib/interfaces-seed.ts +0 -28
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
3
|
import { basename } from "path";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
|
|
5
|
+
import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
|
|
4
6
|
|
|
7
|
+
import { removeAssistantEntry } from "../lib/assistant-config";
|
|
5
8
|
import { SPECIES_CONFIG, type Species } from "../lib/constants";
|
|
9
|
+
import { callDoctorDaemon, type ChatLogEntry } from "../lib/doctor-client";
|
|
6
10
|
import { checkHealth } from "../lib/health-check";
|
|
7
|
-
import { withStatusEmoji } from "../lib/status-emoji";
|
|
11
|
+
import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
|
|
12
|
+
import TextInput from "./TextInput";
|
|
8
13
|
|
|
9
14
|
export const ANSI = {
|
|
10
15
|
reset: "\x1b[0m",
|
|
@@ -20,14 +25,420 @@ export const ANSI = {
|
|
|
20
25
|
|
|
21
26
|
export const SLASH_COMMANDS = ["/clear", "/doctor", "/exit", "/help", "/q", "/quit", "/retire"];
|
|
22
27
|
|
|
28
|
+
const POLL_INTERVAL_MS = 3000;
|
|
29
|
+
const SEND_TIMEOUT_MS = 5000;
|
|
30
|
+
const RESPONSE_POLL_INTERVAL_MS = 1000;
|
|
31
|
+
const RESPONSE_TIMEOUT_MS = 180000;
|
|
32
|
+
|
|
33
|
+
interface ListMessagesResponse {
|
|
34
|
+
messages: RuntimeMessage[];
|
|
35
|
+
nextCursor?: string;
|
|
36
|
+
interfaces?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface SendMessageResponse {
|
|
40
|
+
accepted: boolean;
|
|
41
|
+
messageId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface AllowlistOption {
|
|
45
|
+
label: string;
|
|
46
|
+
pattern: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ScopeOption {
|
|
50
|
+
label: string;
|
|
51
|
+
scope: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface PendingConfirmation {
|
|
55
|
+
toolName: string;
|
|
56
|
+
toolUseId: string;
|
|
57
|
+
input: Record<string, unknown>;
|
|
58
|
+
riskLevel: string;
|
|
59
|
+
executionTarget?: "sandbox" | "host";
|
|
60
|
+
allowlistOptions?: AllowlistOption[];
|
|
61
|
+
scopeOptions?: ScopeOption[];
|
|
62
|
+
principalKind?: string;
|
|
63
|
+
principalId?: string;
|
|
64
|
+
principalVersion?: string;
|
|
65
|
+
persistentDecisionsAllowed?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface CreateRunResponse {
|
|
69
|
+
id: string;
|
|
70
|
+
status: string;
|
|
71
|
+
messageId: string | null;
|
|
72
|
+
createdAt: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface GetRunResponse {
|
|
76
|
+
id: string;
|
|
77
|
+
status: string;
|
|
78
|
+
messageId: string | null;
|
|
79
|
+
pendingConfirmation: PendingConfirmation | null;
|
|
80
|
+
pendingSecret: PendingSecret | null;
|
|
81
|
+
error: string | null;
|
|
82
|
+
createdAt: string;
|
|
83
|
+
updatedAt: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface SubmitDecisionResponse {
|
|
87
|
+
accepted: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface AddTrustRuleResponse {
|
|
91
|
+
accepted: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type TrustDecision = "always_allow" | "always_allow_high_risk" | "always_deny";
|
|
95
|
+
|
|
96
|
+
interface HealthResponse {
|
|
97
|
+
status: string;
|
|
98
|
+
message?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function runtimeRequest<T>(
|
|
102
|
+
baseUrl: string,
|
|
103
|
+
assistantId: string,
|
|
104
|
+
path: string,
|
|
105
|
+
init?: RequestInit,
|
|
106
|
+
bearerToken?: string,
|
|
107
|
+
): Promise<T> {
|
|
108
|
+
const url = `${baseUrl}/v1/assistants/${assistantId}${path}`;
|
|
109
|
+
const response = await fetch(url, {
|
|
110
|
+
...init,
|
|
111
|
+
headers: {
|
|
112
|
+
"Content-Type": "application/json",
|
|
113
|
+
...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
|
|
114
|
+
...(init?.headers as Record<string, string> | undefined),
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const body = await response.text().catch(() => "");
|
|
120
|
+
throw new Error(`HTTP ${response.status}: ${body || response.statusText}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (response.status === 204) {
|
|
124
|
+
return undefined as T;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return response.json() as Promise<T>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function checkHealthRuntime(baseUrl: string): Promise<HealthResponse> {
|
|
131
|
+
const url = `${baseUrl}/healthz`;
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
134
|
+
|
|
135
|
+
const response = await fetch(url, {
|
|
136
|
+
signal: controller.signal,
|
|
137
|
+
headers: { "Content-Type": "application/json" },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
clearTimeout(timeoutId);
|
|
141
|
+
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return response.json() as Promise<HealthResponse>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function pollMessages(
|
|
150
|
+
baseUrl: string,
|
|
151
|
+
assistantId: string,
|
|
152
|
+
bearerToken?: string,
|
|
153
|
+
): Promise<ListMessagesResponse> {
|
|
154
|
+
const params = new URLSearchParams({ conversationKey: assistantId });
|
|
155
|
+
return runtimeRequest<ListMessagesResponse>(
|
|
156
|
+
baseUrl,
|
|
157
|
+
assistantId,
|
|
158
|
+
`/messages?${params.toString()}`,
|
|
159
|
+
undefined,
|
|
160
|
+
bearerToken,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function sendMessage(
|
|
165
|
+
baseUrl: string,
|
|
166
|
+
assistantId: string,
|
|
167
|
+
content: string,
|
|
168
|
+
signal?: AbortSignal,
|
|
169
|
+
bearerToken?: string,
|
|
170
|
+
): Promise<SendMessageResponse> {
|
|
171
|
+
return runtimeRequest<SendMessageResponse>(
|
|
172
|
+
baseUrl,
|
|
173
|
+
assistantId,
|
|
174
|
+
"/messages",
|
|
175
|
+
{
|
|
176
|
+
method: "POST",
|
|
177
|
+
body: JSON.stringify({ conversationKey: assistantId, content }),
|
|
178
|
+
signal,
|
|
179
|
+
},
|
|
180
|
+
bearerToken,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function createRun(
|
|
185
|
+
baseUrl: string,
|
|
186
|
+
assistantId: string,
|
|
187
|
+
content: string,
|
|
188
|
+
signal?: AbortSignal,
|
|
189
|
+
bearerToken?: string,
|
|
190
|
+
): Promise<CreateRunResponse> {
|
|
191
|
+
return runtimeRequest<CreateRunResponse>(
|
|
192
|
+
baseUrl,
|
|
193
|
+
assistantId,
|
|
194
|
+
"/runs",
|
|
195
|
+
{
|
|
196
|
+
method: "POST",
|
|
197
|
+
body: JSON.stringify({ conversationKey: assistantId, content }),
|
|
198
|
+
signal,
|
|
199
|
+
},
|
|
200
|
+
bearerToken,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function getRun(
|
|
205
|
+
baseUrl: string,
|
|
206
|
+
assistantId: string,
|
|
207
|
+
runId: string,
|
|
208
|
+
bearerToken?: string,
|
|
209
|
+
): Promise<GetRunResponse> {
|
|
210
|
+
return runtimeRequest<GetRunResponse>(
|
|
211
|
+
baseUrl,
|
|
212
|
+
assistantId,
|
|
213
|
+
`/runs/${runId}`,
|
|
214
|
+
undefined,
|
|
215
|
+
bearerToken,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function submitDecision(
|
|
220
|
+
baseUrl: string,
|
|
221
|
+
assistantId: string,
|
|
222
|
+
runId: string,
|
|
223
|
+
decision: "allow" | "deny",
|
|
224
|
+
bearerToken?: string,
|
|
225
|
+
): Promise<SubmitDecisionResponse> {
|
|
226
|
+
return runtimeRequest<SubmitDecisionResponse>(
|
|
227
|
+
baseUrl,
|
|
228
|
+
assistantId,
|
|
229
|
+
`/runs/${runId}/decision`,
|
|
230
|
+
{
|
|
231
|
+
method: "POST",
|
|
232
|
+
body: JSON.stringify({ decision }),
|
|
233
|
+
},
|
|
234
|
+
bearerToken,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function addTrustRule(
|
|
239
|
+
baseUrl: string,
|
|
240
|
+
assistantId: string,
|
|
241
|
+
runId: string,
|
|
242
|
+
pattern: string,
|
|
243
|
+
scope: string,
|
|
244
|
+
decision: "allow" | "deny",
|
|
245
|
+
bearerToken?: string,
|
|
246
|
+
): Promise<AddTrustRuleResponse> {
|
|
247
|
+
return runtimeRequest<AddTrustRuleResponse>(
|
|
248
|
+
baseUrl,
|
|
249
|
+
assistantId,
|
|
250
|
+
`/runs/${runId}/trust-rule`,
|
|
251
|
+
{
|
|
252
|
+
method: "POST",
|
|
253
|
+
body: JSON.stringify({ pattern, scope, decision }),
|
|
254
|
+
},
|
|
255
|
+
bearerToken,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function formatConfirmationPreview(toolName: string, input: Record<string, unknown>): string {
|
|
260
|
+
switch (toolName) {
|
|
261
|
+
case "bash":
|
|
262
|
+
return String(input.command ?? "");
|
|
263
|
+
case "file_read":
|
|
264
|
+
return `read ${input.path ?? ""}`;
|
|
265
|
+
case "file_write":
|
|
266
|
+
return `write ${input.path ?? ""}`;
|
|
267
|
+
case "file_edit":
|
|
268
|
+
return `edit ${input.path ?? ""}`;
|
|
269
|
+
case "web_fetch":
|
|
270
|
+
return String(input.url ?? "").slice(0, 80);
|
|
271
|
+
case "browser_navigate":
|
|
272
|
+
return `navigate ${String(input.url ?? "").slice(0, 80)}`;
|
|
273
|
+
case "browser_close":
|
|
274
|
+
return input.close_all_pages ? "close all browser pages" : "close browser page";
|
|
275
|
+
case "browser_click":
|
|
276
|
+
return `click ${input.element_id ?? input.selector ?? ""}`;
|
|
277
|
+
case "browser_type":
|
|
278
|
+
return `type into ${input.element_id ?? input.selector ?? ""}`;
|
|
279
|
+
case "browser_press_key":
|
|
280
|
+
return `press "${input.key ?? ""}"`;
|
|
281
|
+
default:
|
|
282
|
+
return `${toolName}: ${JSON.stringify(input).slice(0, 80)}`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function handleConfirmationPrompt(
|
|
287
|
+
baseUrl: string,
|
|
288
|
+
assistantId: string,
|
|
289
|
+
runId: string,
|
|
290
|
+
confirmation: PendingConfirmation,
|
|
291
|
+
chatApp: ChatAppHandle,
|
|
292
|
+
bearerToken?: string,
|
|
293
|
+
): Promise<void> {
|
|
294
|
+
const preview = formatConfirmationPreview(confirmation.toolName, confirmation.input);
|
|
295
|
+
const allowlistOptions = confirmation.allowlistOptions ?? [];
|
|
296
|
+
|
|
297
|
+
chatApp.addStatus(`\u250C ${confirmation.toolName}: ${preview}`);
|
|
298
|
+
chatApp.addStatus(`\u2502 Risk: ${confirmation.riskLevel}`);
|
|
299
|
+
if (confirmation.executionTarget) {
|
|
300
|
+
chatApp.addStatus(`\u2502 Target: ${confirmation.executionTarget}`);
|
|
301
|
+
}
|
|
302
|
+
chatApp.addStatus("\u2514");
|
|
303
|
+
|
|
304
|
+
const options = ["Allow once", "Deny once"];
|
|
305
|
+
if (allowlistOptions.length > 0 && confirmation.persistentDecisionsAllowed !== false) {
|
|
306
|
+
options.push("Allowlist...", "Denylist...");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const index = await chatApp.showSelection("Tool Approval", options);
|
|
310
|
+
|
|
311
|
+
if (index === 0) {
|
|
312
|
+
await submitDecision(baseUrl, assistantId, runId, "allow", bearerToken);
|
|
313
|
+
chatApp.addStatus("\u2714 Allowed", "green");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (index === 2) {
|
|
317
|
+
await handlePatternSelection(
|
|
318
|
+
baseUrl,
|
|
319
|
+
assistantId,
|
|
320
|
+
runId,
|
|
321
|
+
confirmation,
|
|
322
|
+
chatApp,
|
|
323
|
+
"always_allow",
|
|
324
|
+
bearerToken,
|
|
325
|
+
);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (index === 3) {
|
|
329
|
+
await handlePatternSelection(
|
|
330
|
+
baseUrl,
|
|
331
|
+
assistantId,
|
|
332
|
+
runId,
|
|
333
|
+
confirmation,
|
|
334
|
+
chatApp,
|
|
335
|
+
"always_deny",
|
|
336
|
+
bearerToken,
|
|
337
|
+
);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
await submitDecision(baseUrl, assistantId, runId, "deny", bearerToken);
|
|
342
|
+
chatApp.addStatus("\u2718 Denied", "yellow");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function handlePatternSelection(
|
|
346
|
+
baseUrl: string,
|
|
347
|
+
assistantId: string,
|
|
348
|
+
runId: string,
|
|
349
|
+
confirmation: PendingConfirmation,
|
|
350
|
+
chatApp: ChatAppHandle,
|
|
351
|
+
trustDecision: TrustDecision,
|
|
352
|
+
bearerToken?: string,
|
|
353
|
+
): Promise<void> {
|
|
354
|
+
const allowlistOptions = confirmation.allowlistOptions ?? [];
|
|
355
|
+
const label = trustDecision === "always_deny" ? "Denylist" : "Allowlist";
|
|
356
|
+
const options = allowlistOptions.map((o) => o.label);
|
|
357
|
+
|
|
358
|
+
const index = await chatApp.showSelection(`${label}: choose command pattern`, options);
|
|
359
|
+
|
|
360
|
+
if (index >= 0 && index < allowlistOptions.length) {
|
|
361
|
+
const selectedPattern = allowlistOptions[index].pattern;
|
|
362
|
+
await handleScopeSelection(
|
|
363
|
+
baseUrl,
|
|
364
|
+
assistantId,
|
|
365
|
+
runId,
|
|
366
|
+
confirmation,
|
|
367
|
+
chatApp,
|
|
368
|
+
selectedPattern,
|
|
369
|
+
trustDecision,
|
|
370
|
+
bearerToken,
|
|
371
|
+
);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
await submitDecision(baseUrl, assistantId, runId, "deny", bearerToken);
|
|
376
|
+
chatApp.addStatus("\u2718 Denied", "yellow");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function handleScopeSelection(
|
|
380
|
+
baseUrl: string,
|
|
381
|
+
assistantId: string,
|
|
382
|
+
runId: string,
|
|
383
|
+
confirmation: PendingConfirmation,
|
|
384
|
+
chatApp: ChatAppHandle,
|
|
385
|
+
selectedPattern: string,
|
|
386
|
+
trustDecision: TrustDecision,
|
|
387
|
+
bearerToken?: string,
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
const scopeOptions = confirmation.scopeOptions ?? [];
|
|
390
|
+
const label = trustDecision === "always_deny" ? "Denylist" : "Allowlist";
|
|
391
|
+
const options = scopeOptions.map((o) => o.label);
|
|
392
|
+
|
|
393
|
+
const index = await chatApp.showSelection(`${label}: choose scope`, options);
|
|
394
|
+
|
|
395
|
+
if (index >= 0 && index < scopeOptions.length) {
|
|
396
|
+
const ruleDecision = trustDecision === "always_deny" ? "deny" : "allow";
|
|
397
|
+
await addTrustRule(
|
|
398
|
+
baseUrl,
|
|
399
|
+
assistantId,
|
|
400
|
+
runId,
|
|
401
|
+
selectedPattern,
|
|
402
|
+
scopeOptions[index].scope,
|
|
403
|
+
ruleDecision,
|
|
404
|
+
bearerToken,
|
|
405
|
+
);
|
|
406
|
+
await submitDecision(
|
|
407
|
+
baseUrl,
|
|
408
|
+
assistantId,
|
|
409
|
+
runId,
|
|
410
|
+
ruleDecision === "deny" ? "deny" : "allow",
|
|
411
|
+
bearerToken,
|
|
412
|
+
);
|
|
413
|
+
const ruleLabel = trustDecision === "always_deny" ? "Denylisted" : "Allowlisted";
|
|
414
|
+
const ruleColor = trustDecision === "always_deny" ? "yellow" : "green";
|
|
415
|
+
chatApp.addStatus(
|
|
416
|
+
`${trustDecision === "always_deny" ? "\u2718" : "\u2714"} ${ruleLabel}`,
|
|
417
|
+
ruleColor,
|
|
418
|
+
);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
await submitDecision(baseUrl, assistantId, runId, "deny", bearerToken);
|
|
423
|
+
chatApp.addStatus("\u2718 Denied", "yellow");
|
|
424
|
+
}
|
|
425
|
+
|
|
23
426
|
export const TYPING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
24
427
|
|
|
428
|
+
export interface ToolCallInfo {
|
|
429
|
+
name: string;
|
|
430
|
+
input: Record<string, unknown>;
|
|
431
|
+
result?: string;
|
|
432
|
+
isError?: boolean;
|
|
433
|
+
}
|
|
434
|
+
|
|
25
435
|
export interface RuntimeMessage {
|
|
26
436
|
id: string;
|
|
27
437
|
role: "user" | "assistant";
|
|
28
438
|
content: string;
|
|
29
439
|
timestamp: string;
|
|
30
|
-
toolCalls?:
|
|
440
|
+
toolCalls?: ToolCallInfo[];
|
|
441
|
+
label?: string;
|
|
31
442
|
}
|
|
32
443
|
|
|
33
444
|
export function formatTimestamp(ts: string): string {
|
|
@@ -39,33 +450,145 @@ export function formatTimestamp(ts: string): string {
|
|
|
39
450
|
}
|
|
40
451
|
}
|
|
41
452
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
453
|
+
function formatToolCallPreview(tc: ToolCallInfo): string {
|
|
454
|
+
switch (tc.name) {
|
|
455
|
+
case "bash":
|
|
456
|
+
return String(tc.input.command ?? "").slice(0, 80);
|
|
457
|
+
case "file_read":
|
|
458
|
+
return `read ${tc.input.path ?? ""}`;
|
|
459
|
+
case "file_write":
|
|
460
|
+
return `write ${tc.input.path ?? ""}`;
|
|
461
|
+
case "file_edit":
|
|
462
|
+
return `edit ${tc.input.path ?? ""}`;
|
|
463
|
+
case "web_search":
|
|
464
|
+
return String(tc.input.query ?? "").slice(0, 80);
|
|
465
|
+
case "web_fetch":
|
|
466
|
+
return String(tc.input.url ?? "").slice(0, 80);
|
|
467
|
+
case "browser_navigate":
|
|
468
|
+
return `navigate ${String(tc.input.url ?? "").slice(0, 80)}`;
|
|
469
|
+
case "browser_click":
|
|
470
|
+
return `click ${String(tc.input.element_id ?? tc.input.selector ?? "").slice(0, 60)}`;
|
|
471
|
+
case "browser_type":
|
|
472
|
+
return `type into ${String(tc.input.element_id ?? tc.input.selector ?? "").slice(0, 60)}`;
|
|
473
|
+
default:
|
|
474
|
+
return JSON.stringify(tc.input).slice(0, 80);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function truncateValue(value: unknown, maxLen: number): string {
|
|
479
|
+
if (typeof value === "string") {
|
|
480
|
+
if (value.length > maxLen) {
|
|
481
|
+
return value.slice(0, maxLen - 3) + "...";
|
|
56
482
|
}
|
|
483
|
+
return value;
|
|
484
|
+
}
|
|
485
|
+
const serialized = JSON.stringify(value);
|
|
486
|
+
if (serialized.length > maxLen) {
|
|
487
|
+
return serialized.slice(0, maxLen - 3) + "...";
|
|
57
488
|
}
|
|
489
|
+
return serialized;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
interface ToolCallDisplayProps {
|
|
493
|
+
tc: ToolCallInfo;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function ToolCallDisplay({ tc }: ToolCallDisplayProps): ReactElement {
|
|
497
|
+
const preview = formatToolCallPreview(tc);
|
|
498
|
+
const statusIcon = tc.isError ? "\u2718" : "\u2714";
|
|
499
|
+
const statusColor = tc.isError ? "red" : "green";
|
|
500
|
+
|
|
501
|
+
return (
|
|
502
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
503
|
+
<Text dimColor>
|
|
504
|
+
{"\u250C"} {tc.name}: {preview}
|
|
505
|
+
</Text>
|
|
506
|
+
{typeof tc.input === "object" && tc.input
|
|
507
|
+
? Object.entries(tc.input).map(([key, value]) => (
|
|
508
|
+
<Text key={key} dimColor>
|
|
509
|
+
{"\u2502"} {key}: {truncateValue(value, 70)}
|
|
510
|
+
</Text>
|
|
511
|
+
))
|
|
512
|
+
: null}
|
|
513
|
+
{tc.result !== undefined ? (
|
|
514
|
+
<Text dimColor>
|
|
515
|
+
{"\u2502"} <Text color={statusColor}>{statusIcon}</Text> {truncateValue(tc.result, 70)}
|
|
516
|
+
</Text>
|
|
517
|
+
) : null}
|
|
518
|
+
<Text dimColor>{"\u2514"}</Text>
|
|
519
|
+
</Box>
|
|
520
|
+
);
|
|
58
521
|
}
|
|
59
522
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
523
|
+
interface MessageDisplayProps {
|
|
524
|
+
msg: RuntimeMessage;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function MessageDisplay({ msg }: MessageDisplayProps): ReactElement {
|
|
528
|
+
const time = formatTimestamp(msg.timestamp);
|
|
529
|
+
const defaultLabel = msg.role === "user" ? "You:" : "Assistant:";
|
|
530
|
+
const label = msg.label ?? defaultLabel;
|
|
531
|
+
const labelColor = msg.role === "user" ? "green" : "cyan";
|
|
532
|
+
|
|
533
|
+
return (
|
|
534
|
+
<Box flexDirection="column">
|
|
535
|
+
<Text>
|
|
536
|
+
{time ? <Text dimColor>{time} </Text> : null}
|
|
537
|
+
<Text color={labelColor} bold>
|
|
538
|
+
{label}{" "}
|
|
539
|
+
</Text>
|
|
540
|
+
<Text>{msg.content}</Text>
|
|
541
|
+
</Text>
|
|
542
|
+
{msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length > 0
|
|
543
|
+
? msg.toolCalls.map((tc, i) => <ToolCallDisplay key={i} tc={tc} />)
|
|
544
|
+
: null}
|
|
545
|
+
</Box>
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function HelpDisplay(): ReactElement {
|
|
550
|
+
return (
|
|
551
|
+
<Box flexDirection="column">
|
|
552
|
+
<Text bold>Commands:</Text>
|
|
553
|
+
<Text>
|
|
554
|
+
{" /doctor [question] "}
|
|
555
|
+
<Text dimColor>Run diagnostics on the remote instance via SSH</Text>
|
|
556
|
+
</Text>
|
|
557
|
+
<Text>
|
|
558
|
+
{" /retire "}
|
|
559
|
+
<Text dimColor>Retire the remote instance and exit</Text>
|
|
560
|
+
</Text>
|
|
561
|
+
<Text>
|
|
562
|
+
{" /quit, /exit, /q "}
|
|
563
|
+
<Text dimColor>Disconnect and exit</Text>
|
|
564
|
+
</Text>
|
|
565
|
+
<Text>
|
|
566
|
+
{" /clear "}
|
|
567
|
+
<Text dimColor>Clear the screen</Text>
|
|
568
|
+
</Text>
|
|
569
|
+
<Text>
|
|
570
|
+
{" /help, ? "}
|
|
571
|
+
<Text dimColor>Show this help</Text>
|
|
572
|
+
</Text>
|
|
573
|
+
</Box>
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function SpinnerDisplay({ text }: { text: string }): ReactElement {
|
|
578
|
+
const [frameIndex, setFrameIndex] = useState(0);
|
|
579
|
+
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
const timer = setInterval(() => {
|
|
582
|
+
setFrameIndex((prev) => (prev + 1) % TYPING_FRAMES.length);
|
|
583
|
+
}, 80);
|
|
584
|
+
return () => clearInterval(timer);
|
|
585
|
+
}, []);
|
|
586
|
+
|
|
587
|
+
return (
|
|
588
|
+
<Text dimColor>
|
|
589
|
+
{TYPING_FRAMES[frameIndex]} {text}
|
|
590
|
+
</Text>
|
|
64
591
|
);
|
|
65
|
-
console.log(` /retire ${ANSI.dim}Retire the remote instance and exit${ANSI.reset}`);
|
|
66
|
-
console.log(` /quit, /exit, /q ${ANSI.dim}Disconnect and exit${ANSI.reset}`);
|
|
67
|
-
console.log(` /clear ${ANSI.dim}Clear the screen${ANSI.reset}`);
|
|
68
|
-
console.log(` /help, ? ${ANSI.dim}Show this help${ANSI.reset}`);
|
|
69
592
|
}
|
|
70
593
|
|
|
71
594
|
export function renderErrorMainScreen(error: unknown): number {
|
|
@@ -84,12 +607,19 @@ interface DefaultMainScreenProps {
|
|
|
84
607
|
runtimeUrl: string;
|
|
85
608
|
assistantId: string;
|
|
86
609
|
species: Species;
|
|
610
|
+
healthStatus?: string;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
interface StyledLine {
|
|
614
|
+
text: string;
|
|
615
|
+
style: "heading" | "dim" | "normal";
|
|
87
616
|
}
|
|
88
617
|
|
|
89
618
|
function DefaultMainScreen({
|
|
90
619
|
runtimeUrl,
|
|
91
620
|
assistantId,
|
|
92
621
|
species,
|
|
622
|
+
healthStatus,
|
|
93
623
|
}: DefaultMainScreenProps): ReactElement {
|
|
94
624
|
const cwd = process.cwd();
|
|
95
625
|
const dirName = basename(cwd);
|
|
@@ -97,6 +627,11 @@ function DefaultMainScreen({
|
|
|
97
627
|
const art = config.art;
|
|
98
628
|
const accentColor = species === "openclaw" ? "red" : "magenta";
|
|
99
629
|
|
|
630
|
+
const { stdout } = useStdout();
|
|
631
|
+
const terminalColumns = stdout.columns || 80;
|
|
632
|
+
const totalWidth = Math.min(72, terminalColumns);
|
|
633
|
+
const rightPanelWidth = Math.max(1, totalWidth - LEFT_PANEL_WIDTH);
|
|
634
|
+
|
|
100
635
|
const tips = ["Send a message to start chatting", "Use /help to see available commands"];
|
|
101
636
|
|
|
102
637
|
const leftLines = [
|
|
@@ -109,24 +644,24 @@ function DefaultMainScreen({
|
|
|
109
644
|
` ~/${dirName}`,
|
|
110
645
|
];
|
|
111
646
|
|
|
112
|
-
const rightLines = [
|
|
113
|
-
" ",
|
|
114
|
-
"Tips for getting started",
|
|
115
|
-
...tips,
|
|
116
|
-
" ",
|
|
117
|
-
"Assistant",
|
|
118
|
-
assistantId,
|
|
119
|
-
"Species",
|
|
120
|
-
`${config.hatchedEmoji} ${species}`,
|
|
121
|
-
"Status",
|
|
122
|
-
withStatusEmoji("checking..."),
|
|
647
|
+
const rightLines: StyledLine[] = [
|
|
648
|
+
{ text: " ", style: "normal" },
|
|
649
|
+
{ text: "Tips for getting started", style: "heading" },
|
|
650
|
+
...tips.map((t) => ({ text: t, style: "normal" as const })),
|
|
651
|
+
{ text: " ", style: "normal" },
|
|
652
|
+
{ text: "Assistant", style: "heading" },
|
|
653
|
+
{ text: assistantId, style: "dim" },
|
|
654
|
+
{ text: "Species", style: "heading" },
|
|
655
|
+
{ text: `${config.hatchedEmoji} ${species}`, style: "dim" },
|
|
656
|
+
{ text: "Status", style: "heading" },
|
|
657
|
+
{ text: withStatusEmoji(healthStatus ?? "checking..."), style: "dim" },
|
|
123
658
|
];
|
|
124
659
|
|
|
125
660
|
const maxLines = Math.max(leftLines.length, rightLines.length);
|
|
126
661
|
|
|
127
662
|
return (
|
|
128
|
-
<Box flexDirection="column" width={
|
|
129
|
-
<Text dimColor>{"── Vellum " + "─".repeat(
|
|
663
|
+
<Box flexDirection="column" width={totalWidth}>
|
|
664
|
+
<Text dimColor>{"── Vellum " + "─".repeat(Math.max(0, totalWidth - 10))}</Text>
|
|
130
665
|
<Box flexDirection="row">
|
|
131
666
|
<Box flexDirection="column" width={LEFT_PANEL_WIDTH}>
|
|
132
667
|
{Array.from({ length: maxLines }, (_, i) => {
|
|
@@ -155,30 +690,29 @@ function DefaultMainScreen({
|
|
|
155
690
|
return <Text key={i}>{line}</Text>;
|
|
156
691
|
})}
|
|
157
692
|
</Box>
|
|
158
|
-
<Box flexDirection="column">
|
|
693
|
+
<Box flexDirection="column" width={rightPanelWidth}>
|
|
159
694
|
{Array.from({ length: maxLines }, (_, i) => {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (isHeading) {
|
|
695
|
+
const item = rightLines[i];
|
|
696
|
+
if (!item) return <Text key={i}> </Text>;
|
|
697
|
+
if (item.style === "heading") {
|
|
164
698
|
return (
|
|
165
699
|
<Text key={i} color={accentColor}>
|
|
166
|
-
{
|
|
700
|
+
{item.text}
|
|
167
701
|
</Text>
|
|
168
702
|
);
|
|
169
703
|
}
|
|
170
|
-
if (
|
|
704
|
+
if (item.style === "dim") {
|
|
171
705
|
return (
|
|
172
706
|
<Text key={i} dimColor>
|
|
173
|
-
{
|
|
707
|
+
{item.text}
|
|
174
708
|
</Text>
|
|
175
709
|
);
|
|
176
710
|
}
|
|
177
|
-
return <Text key={i}>{
|
|
711
|
+
return <Text key={i}>{item.text}</Text>;
|
|
178
712
|
})}
|
|
179
713
|
</Box>
|
|
180
714
|
</Box>
|
|
181
|
-
<Text dimColor>{"─".repeat(
|
|
715
|
+
<Text dimColor>{"─".repeat(totalWidth)}</Text>
|
|
182
716
|
<Text> </Text>
|
|
183
717
|
<Text dimColor> ? for shortcuts</Text>
|
|
184
718
|
<Text> </Text>
|
|
@@ -188,6 +722,71 @@ function DefaultMainScreen({
|
|
|
188
722
|
|
|
189
723
|
const LEFT_PANEL_WIDTH = 36;
|
|
190
724
|
|
|
725
|
+
export interface SelectionRequest {
|
|
726
|
+
title: string;
|
|
727
|
+
options: string[];
|
|
728
|
+
resolve: (index: number) => void;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
interface StatusLine {
|
|
732
|
+
type: "status";
|
|
733
|
+
text: string;
|
|
734
|
+
color?: string;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
interface SpinnerLine {
|
|
738
|
+
type: "spinner";
|
|
739
|
+
text: string;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
interface HelpLine {
|
|
743
|
+
type: "help";
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
interface ErrorLine {
|
|
747
|
+
type: "error";
|
|
748
|
+
text: string;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
type FeedItem = RuntimeMessage | StatusLine | SpinnerLine | HelpLine | ErrorLine;
|
|
752
|
+
|
|
753
|
+
function isRuntimeMessage(item: FeedItem): item is RuntimeMessage {
|
|
754
|
+
return "role" in item;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function estimateItemHeight(item: FeedItem, terminalColumns: number): number {
|
|
758
|
+
if (isRuntimeMessage(item)) {
|
|
759
|
+
const cols = Math.max(1, terminalColumns);
|
|
760
|
+
let lines = 0;
|
|
761
|
+
for (const line of item.content.split("\n")) {
|
|
762
|
+
lines += Math.max(1, Math.ceil(line.length / cols));
|
|
763
|
+
}
|
|
764
|
+
if (item.role === "assistant" && item.toolCalls) {
|
|
765
|
+
for (const tc of item.toolCalls) {
|
|
766
|
+
const paramCount =
|
|
767
|
+
typeof tc.input === "object" && tc.input ? Object.keys(tc.input).length : 0;
|
|
768
|
+
lines += 2 + paramCount + (tc.result !== undefined ? 1 : 0);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return lines + 1;
|
|
772
|
+
}
|
|
773
|
+
if (item.type === "help") {
|
|
774
|
+
return 6;
|
|
775
|
+
}
|
|
776
|
+
return 1;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function calculateHeaderHeight(species: Species): number {
|
|
780
|
+
const config = SPECIES_CONFIG[species];
|
|
781
|
+
const artLength = config.art.length;
|
|
782
|
+
const leftLineCount = 3 + artLength + 3;
|
|
783
|
+
const rightLineCount = 11;
|
|
784
|
+
const maxLines = Math.max(leftLineCount, rightLineCount);
|
|
785
|
+
return maxLines + 5;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const SCROLL_STEP = 5;
|
|
789
|
+
|
|
191
790
|
export function render(runtimeUrl: string, assistantId: string, species: Species): number {
|
|
192
791
|
const config = SPECIES_CONFIG[species];
|
|
193
792
|
const art = config.art;
|
|
@@ -215,3 +814,1111 @@ export function render(runtimeUrl: string, assistantId: string, species: Species
|
|
|
215
814
|
|
|
216
815
|
return 1 + maxLines + 4;
|
|
217
816
|
}
|
|
817
|
+
|
|
818
|
+
interface SelectionWindowProps {
|
|
819
|
+
title: string;
|
|
820
|
+
options: string[];
|
|
821
|
+
onSelect: (index: number) => void;
|
|
822
|
+
onCancel: () => void;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function SelectionWindow({
|
|
826
|
+
title,
|
|
827
|
+
options,
|
|
828
|
+
onSelect,
|
|
829
|
+
onCancel,
|
|
830
|
+
}: SelectionWindowProps): ReactElement {
|
|
831
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
832
|
+
|
|
833
|
+
useInput(
|
|
834
|
+
(
|
|
835
|
+
input: string,
|
|
836
|
+
key: {
|
|
837
|
+
upArrow: boolean;
|
|
838
|
+
downArrow: boolean;
|
|
839
|
+
return: boolean;
|
|
840
|
+
escape: boolean;
|
|
841
|
+
ctrl: boolean;
|
|
842
|
+
},
|
|
843
|
+
) => {
|
|
844
|
+
if (key.upArrow) {
|
|
845
|
+
setSelectedIndex((prev: number) => (prev - 1 + options.length) % options.length);
|
|
846
|
+
} else if (key.downArrow) {
|
|
847
|
+
setSelectedIndex((prev: number) => (prev + 1) % options.length);
|
|
848
|
+
} else if (key.return) {
|
|
849
|
+
onSelect(selectedIndex);
|
|
850
|
+
} else if (key.escape || (key.ctrl && input === "c")) {
|
|
851
|
+
onCancel();
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
const windowWidth = 60;
|
|
857
|
+
const borderH = "\u2500".repeat(Math.max(0, windowWidth - title.length - 5));
|
|
858
|
+
|
|
859
|
+
return (
|
|
860
|
+
<Box flexDirection="column" width={windowWidth}>
|
|
861
|
+
<Text>{"\u250C\u2500 " + title + " " + borderH + "\u2510"}</Text>
|
|
862
|
+
{options.map((option, i) => {
|
|
863
|
+
const marker = i === selectedIndex ? "\u276F" : " ";
|
|
864
|
+
const padding = " ".repeat(Math.max(0, windowWidth - option.length - 6));
|
|
865
|
+
return (
|
|
866
|
+
<Text key={i}>
|
|
867
|
+
{"\u2502 "}
|
|
868
|
+
<Text color={i === selectedIndex ? "cyan" : undefined}>{marker}</Text>{" "}
|
|
869
|
+
<Text bold={i === selectedIndex}>{option}</Text>
|
|
870
|
+
{padding}
|
|
871
|
+
{"\u2502"}
|
|
872
|
+
</Text>
|
|
873
|
+
);
|
|
874
|
+
})}
|
|
875
|
+
<Text>{"\u2514" + "\u2500".repeat(windowWidth - 2) + "\u2518"}</Text>
|
|
876
|
+
<Text dimColor>{" \u2191/\u2193 navigate Enter select Esc cancel"}</Text>
|
|
877
|
+
</Box>
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
interface SecretInputWindowProps {
|
|
882
|
+
label: string;
|
|
883
|
+
placeholder?: string;
|
|
884
|
+
onSubmit: (value: string) => void;
|
|
885
|
+
onCancel: () => void;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function SecretInputWindow({
|
|
889
|
+
label,
|
|
890
|
+
placeholder,
|
|
891
|
+
onSubmit,
|
|
892
|
+
onCancel,
|
|
893
|
+
}: SecretInputWindowProps): ReactElement {
|
|
894
|
+
const [value, setValue] = useState("");
|
|
895
|
+
|
|
896
|
+
useInput(
|
|
897
|
+
(
|
|
898
|
+
input: string,
|
|
899
|
+
key: {
|
|
900
|
+
return: boolean;
|
|
901
|
+
escape: boolean;
|
|
902
|
+
ctrl: boolean;
|
|
903
|
+
backspace: boolean;
|
|
904
|
+
delete: boolean;
|
|
905
|
+
},
|
|
906
|
+
) => {
|
|
907
|
+
if (key.return) {
|
|
908
|
+
onSubmit(value);
|
|
909
|
+
} else if (key.escape || (key.ctrl && input === "c")) {
|
|
910
|
+
onCancel();
|
|
911
|
+
} else if (key.backspace || key.delete) {
|
|
912
|
+
setValue((prev) => prev.slice(0, -1));
|
|
913
|
+
} else if (input && !key.ctrl) {
|
|
914
|
+
setValue((prev) => prev + input);
|
|
915
|
+
}
|
|
916
|
+
},
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
const windowWidth = 60;
|
|
920
|
+
const borderH = "\u2500".repeat(Math.max(0, windowWidth - label.length - 5));
|
|
921
|
+
const masked = "\u2022".repeat(value.length);
|
|
922
|
+
const displayText = value.length > 0 ? masked : (placeholder ?? "Enter secret...");
|
|
923
|
+
const displayColor = value.length > 0 ? undefined : "gray";
|
|
924
|
+
const contentPad = " ".repeat(Math.max(0, windowWidth - displayText.length - 4));
|
|
925
|
+
|
|
926
|
+
return (
|
|
927
|
+
<Box flexDirection="column" width={windowWidth}>
|
|
928
|
+
<Text>{"\u250C\u2500 " + label + " " + borderH + "\u2510"}</Text>
|
|
929
|
+
<Text>
|
|
930
|
+
{"\u2502 "}
|
|
931
|
+
<Text color={displayColor}>{displayText}</Text>
|
|
932
|
+
{contentPad}
|
|
933
|
+
{"\u2502"}
|
|
934
|
+
</Text>
|
|
935
|
+
<Text>{"\u2514" + "\u2500".repeat(windowWidth - 2) + "\u2518"}</Text>
|
|
936
|
+
<Text dimColor>{" Enter submit Esc cancel"}</Text>
|
|
937
|
+
</Box>
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
export interface SecretInputRequest {
|
|
942
|
+
label: string;
|
|
943
|
+
placeholder?: string;
|
|
944
|
+
resolve: (value: string) => void;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
export interface PendingSecret {
|
|
948
|
+
requestId: string;
|
|
949
|
+
service: string;
|
|
950
|
+
field: string;
|
|
951
|
+
label: string;
|
|
952
|
+
description?: string;
|
|
953
|
+
placeholder?: string;
|
|
954
|
+
purpose?: string;
|
|
955
|
+
allowOneTimeSend?: boolean;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
export interface ChatAppHandle {
|
|
959
|
+
addMessage: (msg: RuntimeMessage) => void;
|
|
960
|
+
addStatus: (text: string, color?: string) => void;
|
|
961
|
+
showSpinner: (text: string) => void;
|
|
962
|
+
hideSpinner: () => void;
|
|
963
|
+
showHelp: () => void;
|
|
964
|
+
showError: (text: string) => void;
|
|
965
|
+
showSelection: (title: string, options: string[]) => Promise<number>;
|
|
966
|
+
showSecretInput: (label: string, placeholder?: string) => Promise<string>;
|
|
967
|
+
handleSecretPrompt: (
|
|
968
|
+
secret: PendingSecret,
|
|
969
|
+
onSubmit: (value: string, delivery?: "store" | "transient_send") => Promise<void>,
|
|
970
|
+
) => Promise<void>;
|
|
971
|
+
clearFeed: () => void;
|
|
972
|
+
setBusy: (busy: boolean) => void;
|
|
973
|
+
updateHealthStatus: (status: string) => void;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
interface ChatAppProps {
|
|
977
|
+
runtimeUrl: string;
|
|
978
|
+
assistantId: string;
|
|
979
|
+
species: Species;
|
|
980
|
+
bearerToken?: string;
|
|
981
|
+
project?: string;
|
|
982
|
+
zone?: string;
|
|
983
|
+
onExit: () => void;
|
|
984
|
+
handleRef: (handle: ChatAppHandle) => void;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function ChatApp({
|
|
988
|
+
runtimeUrl,
|
|
989
|
+
assistantId,
|
|
990
|
+
species,
|
|
991
|
+
bearerToken,
|
|
992
|
+
project,
|
|
993
|
+
zone,
|
|
994
|
+
onExit,
|
|
995
|
+
handleRef,
|
|
996
|
+
}: ChatAppProps): ReactElement {
|
|
997
|
+
const [inputValue, setInputValue] = useState("");
|
|
998
|
+
const [feed, setFeed] = useState<FeedItem[]>([]);
|
|
999
|
+
const [spinnerText, setSpinnerText] = useState<string | null>(null);
|
|
1000
|
+
const [selection, setSelection] = useState<SelectionRequest | null>(null);
|
|
1001
|
+
const [secretInput, setSecretInput] = useState<SecretInputRequest | null>(null);
|
|
1002
|
+
const [inputFocused, setInputFocused] = useState(true);
|
|
1003
|
+
const [scrollIndex, setScrollIndex] = useState<number | null>(null);
|
|
1004
|
+
const [healthStatus, setHealthStatus] = useState<string | undefined>(undefined);
|
|
1005
|
+
const prevFeedLengthRef = useRef(0);
|
|
1006
|
+
const busyRef = useRef(false);
|
|
1007
|
+
const connectedRef = useRef(false);
|
|
1008
|
+
const connectingRef = useRef(false);
|
|
1009
|
+
const seenMessageIdsRef = useRef(new Set<string>());
|
|
1010
|
+
const chatLogRef = useRef<ChatLogEntry[]>([]);
|
|
1011
|
+
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
1012
|
+
const doctorSessionIdRef = useRef(randomUUID());
|
|
1013
|
+
const handleRef_ = useRef<ChatAppHandle | null>(null);
|
|
1014
|
+
|
|
1015
|
+
const { stdout } = useStdout();
|
|
1016
|
+
const terminalRows = stdout.rows || 24;
|
|
1017
|
+
const terminalColumns = stdout.columns || 80;
|
|
1018
|
+
const headerHeight = calculateHeaderHeight(species);
|
|
1019
|
+
const bottomHeight = selection
|
|
1020
|
+
? selection.options.length + 3
|
|
1021
|
+
: secretInput
|
|
1022
|
+
? 5
|
|
1023
|
+
: spinnerText
|
|
1024
|
+
? 2
|
|
1025
|
+
: 1;
|
|
1026
|
+
const availableRows = Math.max(3, terminalRows - headerHeight - bottomHeight);
|
|
1027
|
+
|
|
1028
|
+
const addMessage = useCallback((msg: RuntimeMessage) => {
|
|
1029
|
+
setFeed((prev) => [...prev, msg]);
|
|
1030
|
+
if (msg.role === "assistant" && !busyRef.current) {
|
|
1031
|
+
setSpinnerText(null);
|
|
1032
|
+
setInputFocused(true);
|
|
1033
|
+
}
|
|
1034
|
+
}, []);
|
|
1035
|
+
|
|
1036
|
+
useEffect(() => {
|
|
1037
|
+
if (feed.length > prevFeedLengthRef.current && scrollIndex === null) {
|
|
1038
|
+
prevFeedLengthRef.current = feed.length;
|
|
1039
|
+
} else if (feed.length > prevFeedLengthRef.current) {
|
|
1040
|
+
prevFeedLengthRef.current = feed.length;
|
|
1041
|
+
} else if (feed.length === 0) {
|
|
1042
|
+
prevFeedLengthRef.current = 0;
|
|
1043
|
+
setScrollIndex(null);
|
|
1044
|
+
}
|
|
1045
|
+
}, [feed.length, scrollIndex]);
|
|
1046
|
+
|
|
1047
|
+
const visibleWindow = useMemo(() => {
|
|
1048
|
+
if (feed.length === 0) {
|
|
1049
|
+
return {
|
|
1050
|
+
items: [] as FeedItem[],
|
|
1051
|
+
startIndex: 0,
|
|
1052
|
+
endIndex: 0,
|
|
1053
|
+
hiddenAbove: 0,
|
|
1054
|
+
hiddenBelow: 0,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (scrollIndex === null) {
|
|
1059
|
+
let totalHeight = 0;
|
|
1060
|
+
let start = feed.length;
|
|
1061
|
+
for (let i = feed.length - 1; i >= 0; i--) {
|
|
1062
|
+
const h = estimateItemHeight(feed[i], terminalColumns);
|
|
1063
|
+
if (totalHeight + h > availableRows) {
|
|
1064
|
+
break;
|
|
1065
|
+
}
|
|
1066
|
+
totalHeight += h;
|
|
1067
|
+
start = i;
|
|
1068
|
+
}
|
|
1069
|
+
if (start === feed.length && feed.length > 0) {
|
|
1070
|
+
start = feed.length - 1;
|
|
1071
|
+
}
|
|
1072
|
+
return {
|
|
1073
|
+
items: feed.slice(start, feed.length),
|
|
1074
|
+
startIndex: start,
|
|
1075
|
+
endIndex: feed.length,
|
|
1076
|
+
hiddenAbove: start,
|
|
1077
|
+
hiddenBelow: 0,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const start = Math.max(0, Math.min(scrollIndex, feed.length - 1));
|
|
1082
|
+
let totalHeight = 0;
|
|
1083
|
+
let end = start;
|
|
1084
|
+
for (let i = start; i < feed.length; i++) {
|
|
1085
|
+
const h = estimateItemHeight(feed[i], terminalColumns);
|
|
1086
|
+
if (totalHeight + h > availableRows) {
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
totalHeight += h;
|
|
1090
|
+
end = i + 1;
|
|
1091
|
+
}
|
|
1092
|
+
return {
|
|
1093
|
+
items: feed.slice(start, end),
|
|
1094
|
+
startIndex: start,
|
|
1095
|
+
endIndex: end,
|
|
1096
|
+
hiddenAbove: start,
|
|
1097
|
+
hiddenBelow: feed.length - end,
|
|
1098
|
+
};
|
|
1099
|
+
}, [feed, scrollIndex, availableRows, terminalColumns]);
|
|
1100
|
+
|
|
1101
|
+
const addStatus = useCallback((text: string, color?: string) => {
|
|
1102
|
+
const item: StatusLine = { type: "status", text, color };
|
|
1103
|
+
setFeed((prev) => [...prev, item]);
|
|
1104
|
+
}, []);
|
|
1105
|
+
|
|
1106
|
+
const showSpinner = useCallback((text: string) => {
|
|
1107
|
+
setSpinnerText(text);
|
|
1108
|
+
setInputFocused(false);
|
|
1109
|
+
}, []);
|
|
1110
|
+
|
|
1111
|
+
const hideSpinner = useCallback(() => {
|
|
1112
|
+
setSpinnerText(null);
|
|
1113
|
+
setInputFocused(true);
|
|
1114
|
+
}, []);
|
|
1115
|
+
|
|
1116
|
+
const showHelpFn = useCallback(() => {
|
|
1117
|
+
const item: HelpLine = { type: "help" };
|
|
1118
|
+
setFeed((prev) => [...prev, item]);
|
|
1119
|
+
}, []);
|
|
1120
|
+
|
|
1121
|
+
const showError = useCallback((text: string) => {
|
|
1122
|
+
const item: ErrorLine = { type: "error", text };
|
|
1123
|
+
setFeed((prev) => [...prev, item]);
|
|
1124
|
+
}, []);
|
|
1125
|
+
|
|
1126
|
+
const showSelection = useCallback((title: string, options: string[]): Promise<number> => {
|
|
1127
|
+
setInputFocused(false);
|
|
1128
|
+
return new Promise<number>((resolve) => {
|
|
1129
|
+
setSelection({ title, options, resolve });
|
|
1130
|
+
});
|
|
1131
|
+
}, []);
|
|
1132
|
+
|
|
1133
|
+
const showSecretInput = useCallback((label: string, placeholder?: string): Promise<string> => {
|
|
1134
|
+
setInputFocused(false);
|
|
1135
|
+
return new Promise<string>((resolve) => {
|
|
1136
|
+
setSecretInput({ label, placeholder, resolve });
|
|
1137
|
+
});
|
|
1138
|
+
}, []);
|
|
1139
|
+
|
|
1140
|
+
const handleSecretPromptFn = useCallback(
|
|
1141
|
+
async (
|
|
1142
|
+
secret: PendingSecret,
|
|
1143
|
+
onSubmit: (value: string, delivery?: "store" | "transient_send") => Promise<void>,
|
|
1144
|
+
): Promise<void> => {
|
|
1145
|
+
addStatus(`\u250C Secret needed: ${secret.label}`);
|
|
1146
|
+
addStatus(`\u2502 Service: ${secret.service} / ${secret.field}`);
|
|
1147
|
+
if (secret.description) {
|
|
1148
|
+
addStatus(`\u2502 ${secret.description}`);
|
|
1149
|
+
}
|
|
1150
|
+
if (secret.purpose) {
|
|
1151
|
+
addStatus(`\u2502 Purpose: ${secret.purpose}`);
|
|
1152
|
+
}
|
|
1153
|
+
addStatus("\u2514");
|
|
1154
|
+
|
|
1155
|
+
let delivery: "store" | "transient_send" | undefined;
|
|
1156
|
+
if (secret.allowOneTimeSend) {
|
|
1157
|
+
const deliveryIndex = await showSelection("Secret delivery", [
|
|
1158
|
+
"Store securely",
|
|
1159
|
+
"Send once (transient)",
|
|
1160
|
+
]);
|
|
1161
|
+
if (deliveryIndex === 1) {
|
|
1162
|
+
delivery = "transient_send";
|
|
1163
|
+
} else {
|
|
1164
|
+
delivery = "store";
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const value = await showSecretInput(secret.label, secret.placeholder);
|
|
1169
|
+
|
|
1170
|
+
if (!value) {
|
|
1171
|
+
try {
|
|
1172
|
+
await onSubmit("", delivery);
|
|
1173
|
+
} catch {
|
|
1174
|
+
// Best-effort
|
|
1175
|
+
}
|
|
1176
|
+
addStatus("\u2718 Cancelled", "yellow");
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
try {
|
|
1181
|
+
await onSubmit(value, delivery);
|
|
1182
|
+
addStatus("\u2714 Secret submitted", "green");
|
|
1183
|
+
} catch (err) {
|
|
1184
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1185
|
+
showError(`Failed to submit secret: ${msg}`);
|
|
1186
|
+
}
|
|
1187
|
+
},
|
|
1188
|
+
[addStatus, showSelection, showSecretInput, showError],
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
const setBusy = useCallback((busy: boolean) => {
|
|
1192
|
+
busyRef.current = busy;
|
|
1193
|
+
if (!busy) {
|
|
1194
|
+
setSpinnerText(null);
|
|
1195
|
+
setInputFocused(true);
|
|
1196
|
+
}
|
|
1197
|
+
}, []);
|
|
1198
|
+
|
|
1199
|
+
const clearFeed = useCallback(() => {
|
|
1200
|
+
setFeed([]);
|
|
1201
|
+
setSpinnerText(null);
|
|
1202
|
+
setSelection(null);
|
|
1203
|
+
setSecretInput(null);
|
|
1204
|
+
setInputFocused(true);
|
|
1205
|
+
setScrollIndex(null);
|
|
1206
|
+
busyRef.current = false;
|
|
1207
|
+
}, []);
|
|
1208
|
+
|
|
1209
|
+
const updateHealthStatus = useCallback((status: string) => {
|
|
1210
|
+
setHealthStatus(status);
|
|
1211
|
+
}, []);
|
|
1212
|
+
|
|
1213
|
+
const cleanup = useCallback(() => {
|
|
1214
|
+
if (pollTimerRef.current) {
|
|
1215
|
+
clearInterval(pollTimerRef.current);
|
|
1216
|
+
pollTimerRef.current = null;
|
|
1217
|
+
}
|
|
1218
|
+
}, []);
|
|
1219
|
+
|
|
1220
|
+
useEffect(() => {
|
|
1221
|
+
return cleanup;
|
|
1222
|
+
}, [cleanup]);
|
|
1223
|
+
|
|
1224
|
+
const ensureConnected = useCallback(async (): Promise<boolean> => {
|
|
1225
|
+
if (connectedRef.current) {
|
|
1226
|
+
return true;
|
|
1227
|
+
}
|
|
1228
|
+
if (connectingRef.current || !handleRef_.current) {
|
|
1229
|
+
return false;
|
|
1230
|
+
}
|
|
1231
|
+
connectingRef.current = true;
|
|
1232
|
+
const h = handleRef_.current;
|
|
1233
|
+
|
|
1234
|
+
h.showSpinner("Connecting...");
|
|
1235
|
+
|
|
1236
|
+
try {
|
|
1237
|
+
const health = await checkHealthRuntime(runtimeUrl);
|
|
1238
|
+
h.hideSpinner();
|
|
1239
|
+
h.updateHealthStatus(health.status);
|
|
1240
|
+
if (health.status === "healthy" || health.status === "ok") {
|
|
1241
|
+
h.addStatus(`${statusEmoji(health.status)} Connected to assistant`, "green");
|
|
1242
|
+
} else {
|
|
1243
|
+
const statusMsg = health.message ? ` - ${health.message}` : "";
|
|
1244
|
+
h.addStatus(
|
|
1245
|
+
`${statusEmoji(health.status)} Assistant status: ${health.status}${statusMsg}`,
|
|
1246
|
+
"yellow",
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
h.showSpinner("Loading conversation history...");
|
|
1251
|
+
|
|
1252
|
+
try {
|
|
1253
|
+
const historyResponse = await pollMessages(runtimeUrl, assistantId, bearerToken);
|
|
1254
|
+
h.hideSpinner();
|
|
1255
|
+
if (historyResponse.messages.length > 0) {
|
|
1256
|
+
for (const msg of historyResponse.messages) {
|
|
1257
|
+
h.addMessage(msg);
|
|
1258
|
+
seenMessageIdsRef.current.add(msg.id);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
} catch {
|
|
1262
|
+
h.hideSpinner();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
pollTimerRef.current = setInterval(async () => {
|
|
1266
|
+
try {
|
|
1267
|
+
const response = await pollMessages(runtimeUrl, assistantId, bearerToken);
|
|
1268
|
+
for (const msg of response.messages) {
|
|
1269
|
+
if (!seenMessageIdsRef.current.has(msg.id)) {
|
|
1270
|
+
seenMessageIdsRef.current.add(msg.id);
|
|
1271
|
+
if (msg.role === "assistant") {
|
|
1272
|
+
handleRef_.current?.addMessage(msg);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
} catch {
|
|
1277
|
+
// Poll failure; continue silently
|
|
1278
|
+
}
|
|
1279
|
+
}, POLL_INTERVAL_MS);
|
|
1280
|
+
|
|
1281
|
+
connectedRef.current = true;
|
|
1282
|
+
connectingRef.current = false;
|
|
1283
|
+
return true;
|
|
1284
|
+
} catch {
|
|
1285
|
+
h.hideSpinner();
|
|
1286
|
+
connectingRef.current = false;
|
|
1287
|
+
h.updateHealthStatus("unreachable");
|
|
1288
|
+
h.addStatus(`${statusEmoji("unreachable")} Failed to connect: Timeout`, "red");
|
|
1289
|
+
return false;
|
|
1290
|
+
}
|
|
1291
|
+
}, [runtimeUrl, assistantId, bearerToken]);
|
|
1292
|
+
|
|
1293
|
+
const handleInput = useCallback(
|
|
1294
|
+
async (input: string): Promise<void> => {
|
|
1295
|
+
const h = handleRef_.current;
|
|
1296
|
+
if (!h) {
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const trimmed = input.trim();
|
|
1301
|
+
if (!trimmed) {
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (trimmed === "/quit" || trimmed === "/exit" || trimmed === "/q") {
|
|
1306
|
+
cleanup();
|
|
1307
|
+
process.exit(0);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (trimmed === "/clear") {
|
|
1311
|
+
h.clearFeed();
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (trimmed === "/help" || trimmed === "?") {
|
|
1316
|
+
h.showHelp();
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (trimmed === "/retire") {
|
|
1321
|
+
if (!project || !zone) {
|
|
1322
|
+
h.showError("No instance info available. Connect to a hatched instance first.");
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
const confirmIndex = await h.showSelection(`Retire ${assistantId}?`, [
|
|
1327
|
+
"Yes, retire",
|
|
1328
|
+
"Cancel",
|
|
1329
|
+
]);
|
|
1330
|
+
if (confirmIndex !== 0) {
|
|
1331
|
+
h.addStatus("Cancelled.");
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
h.showSpinner(`Retiring instance ${assistantId}...`);
|
|
1336
|
+
|
|
1337
|
+
try {
|
|
1338
|
+
const labelChild = spawn(
|
|
1339
|
+
"gcloud",
|
|
1340
|
+
[
|
|
1341
|
+
"compute",
|
|
1342
|
+
"instances",
|
|
1343
|
+
"add-labels",
|
|
1344
|
+
assistantId,
|
|
1345
|
+
`--project=${project}`,
|
|
1346
|
+
`--zone=${zone}`,
|
|
1347
|
+
"--labels=retired-by=vel",
|
|
1348
|
+
],
|
|
1349
|
+
{ stdio: "pipe" },
|
|
1350
|
+
);
|
|
1351
|
+
await new Promise<void>((resolve) => {
|
|
1352
|
+
labelChild.on("close", () => resolve());
|
|
1353
|
+
labelChild.on("error", () => resolve());
|
|
1354
|
+
});
|
|
1355
|
+
} catch {
|
|
1356
|
+
// Best-effort labeling before deletion
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const child = spawn(
|
|
1360
|
+
"gcloud",
|
|
1361
|
+
[
|
|
1362
|
+
"compute",
|
|
1363
|
+
"instances",
|
|
1364
|
+
"delete",
|
|
1365
|
+
assistantId,
|
|
1366
|
+
`--project=${project}`,
|
|
1367
|
+
`--zone=${zone}`,
|
|
1368
|
+
"--quiet",
|
|
1369
|
+
],
|
|
1370
|
+
{ stdio: "pipe" },
|
|
1371
|
+
);
|
|
1372
|
+
|
|
1373
|
+
child.on("close", (code) => {
|
|
1374
|
+
handleRef_.current?.hideSpinner();
|
|
1375
|
+
if (code === 0) {
|
|
1376
|
+
removeAssistantEntry(assistantId);
|
|
1377
|
+
handleRef_.current?.addStatus(`Removed ${assistantId} from lockfile.json`);
|
|
1378
|
+
} else {
|
|
1379
|
+
handleRef_.current?.showError(`Failed to delete instance (exit code ${code})`);
|
|
1380
|
+
}
|
|
1381
|
+
cleanup();
|
|
1382
|
+
process.exit(code === 0 ? 0 : 1);
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
child.on("error", (err) => {
|
|
1386
|
+
handleRef_.current?.hideSpinner();
|
|
1387
|
+
handleRef_.current?.showError(`Failed to retire instance: ${err.message}`);
|
|
1388
|
+
});
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (trimmed === "/doctor" || trimmed.startsWith("/doctor ")) {
|
|
1393
|
+
if (!project || !zone) {
|
|
1394
|
+
h.showError("No instance info available. Connect to a hatched instance first.");
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
const userPrompt = trimmed.slice("/doctor".length).trim() || undefined;
|
|
1398
|
+
const recentChatContext = chatLogRef.current.slice(-20);
|
|
1399
|
+
|
|
1400
|
+
chatLogRef.current.push({ role: "user", content: trimmed });
|
|
1401
|
+
|
|
1402
|
+
if (userPrompt) {
|
|
1403
|
+
const doctorUserMsg: RuntimeMessage = {
|
|
1404
|
+
id: "local-user-" + Date.now(),
|
|
1405
|
+
role: "user",
|
|
1406
|
+
content: userPrompt,
|
|
1407
|
+
timestamp: new Date().toISOString(),
|
|
1408
|
+
label: "You (to Doctor):",
|
|
1409
|
+
};
|
|
1410
|
+
h.addMessage(doctorUserMsg);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
h.showSpinner(`Analyzing ${assistantId}...`);
|
|
1414
|
+
|
|
1415
|
+
try {
|
|
1416
|
+
const result = await callDoctorDaemon(
|
|
1417
|
+
assistantId,
|
|
1418
|
+
project,
|
|
1419
|
+
zone,
|
|
1420
|
+
userPrompt,
|
|
1421
|
+
(event) => {
|
|
1422
|
+
switch (event.phase) {
|
|
1423
|
+
case "invoking_prompt":
|
|
1424
|
+
handleRef_.current?.showSpinner(`Analyzing ${assistantId}...`);
|
|
1425
|
+
break;
|
|
1426
|
+
case "calling_tool":
|
|
1427
|
+
handleRef_.current?.showSpinner(
|
|
1428
|
+
`Running ${event.toolName ?? "tool"} on ${assistantId}...`,
|
|
1429
|
+
);
|
|
1430
|
+
break;
|
|
1431
|
+
case "processing_tool_result":
|
|
1432
|
+
handleRef_.current?.showSpinner(`Reviewing diagnostics for ${assistantId}...`);
|
|
1433
|
+
break;
|
|
1434
|
+
}
|
|
1435
|
+
},
|
|
1436
|
+
doctorSessionIdRef.current,
|
|
1437
|
+
recentChatContext,
|
|
1438
|
+
);
|
|
1439
|
+
h.hideSpinner();
|
|
1440
|
+
if (result.recommendation) {
|
|
1441
|
+
h.addStatus(`Recommendation:\n${result.recommendation}`);
|
|
1442
|
+
chatLogRef.current.push({ role: "assistant", content: result.recommendation });
|
|
1443
|
+
} else if (result.error) {
|
|
1444
|
+
h.showError(result.error);
|
|
1445
|
+
chatLogRef.current.push({ role: "error", content: result.error });
|
|
1446
|
+
}
|
|
1447
|
+
} catch (err) {
|
|
1448
|
+
h.hideSpinner();
|
|
1449
|
+
const errorMsg = `Doctor daemon unreachable: ${err instanceof Error ? err.message : err}`;
|
|
1450
|
+
h.showError(errorMsg);
|
|
1451
|
+
chatLogRef.current.push({ role: "error", content: errorMsg });
|
|
1452
|
+
}
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
if (!trimmed.startsWith("/")) {
|
|
1457
|
+
const userMsg: RuntimeMessage = {
|
|
1458
|
+
id: "local-user-" + Date.now(),
|
|
1459
|
+
role: "user",
|
|
1460
|
+
content: trimmed,
|
|
1461
|
+
timestamp: new Date().toISOString(),
|
|
1462
|
+
};
|
|
1463
|
+
h.addMessage(userMsg);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const isConnected = await ensureConnected();
|
|
1467
|
+
if (!isConnected) {
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
chatLogRef.current.push({ role: "user", content: trimmed });
|
|
1472
|
+
seenMessageIdsRef.current.add("pending-user-" + Date.now());
|
|
1473
|
+
|
|
1474
|
+
h.showSpinner("Sending...");
|
|
1475
|
+
h.setBusy(true);
|
|
1476
|
+
|
|
1477
|
+
try {
|
|
1478
|
+
const controller = new AbortController();
|
|
1479
|
+
const timeoutId = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
|
|
1480
|
+
|
|
1481
|
+
let runId: string | undefined;
|
|
1482
|
+
try {
|
|
1483
|
+
const runResult = await createRun(
|
|
1484
|
+
runtimeUrl,
|
|
1485
|
+
assistantId,
|
|
1486
|
+
trimmed,
|
|
1487
|
+
controller.signal,
|
|
1488
|
+
bearerToken,
|
|
1489
|
+
);
|
|
1490
|
+
clearTimeout(timeoutId);
|
|
1491
|
+
runId = runResult.id;
|
|
1492
|
+
} catch (createErr) {
|
|
1493
|
+
clearTimeout(timeoutId);
|
|
1494
|
+
const is409 = createErr instanceof Error && createErr.message.includes("HTTP 409");
|
|
1495
|
+
if (is409) {
|
|
1496
|
+
h.setBusy(false);
|
|
1497
|
+
h.hideSpinner();
|
|
1498
|
+
h.showError("Assistant is still working. Please wait and try again.");
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
const sendResult = await sendMessage(
|
|
1502
|
+
runtimeUrl,
|
|
1503
|
+
assistantId,
|
|
1504
|
+
trimmed,
|
|
1505
|
+
undefined,
|
|
1506
|
+
bearerToken,
|
|
1507
|
+
);
|
|
1508
|
+
if (!sendResult.accepted) {
|
|
1509
|
+
h.setBusy(false);
|
|
1510
|
+
h.hideSpinner();
|
|
1511
|
+
h.showError("Message was not accepted by the assistant");
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
h.showSpinner("Working...");
|
|
1517
|
+
|
|
1518
|
+
const startTime = Date.now();
|
|
1519
|
+
while (Date.now() - startTime < RESPONSE_TIMEOUT_MS) {
|
|
1520
|
+
await new Promise((resolve) => setTimeout(resolve, RESPONSE_POLL_INTERVAL_MS));
|
|
1521
|
+
|
|
1522
|
+
if (runId) {
|
|
1523
|
+
try {
|
|
1524
|
+
const runStatus = await getRun(runtimeUrl, assistantId, runId, bearerToken);
|
|
1525
|
+
|
|
1526
|
+
if (runStatus.status === "needs_confirmation" && runStatus.pendingConfirmation) {
|
|
1527
|
+
h.hideSpinner();
|
|
1528
|
+
await handleConfirmationPrompt(
|
|
1529
|
+
runtimeUrl,
|
|
1530
|
+
assistantId,
|
|
1531
|
+
runId,
|
|
1532
|
+
runStatus.pendingConfirmation,
|
|
1533
|
+
h,
|
|
1534
|
+
bearerToken,
|
|
1535
|
+
);
|
|
1536
|
+
h.showSpinner("Working...");
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
if (runStatus.status === "needs_secret" && runStatus.pendingSecret) {
|
|
1541
|
+
h.hideSpinner();
|
|
1542
|
+
await h.handleSecretPrompt(runStatus.pendingSecret, async (value, delivery) => {
|
|
1543
|
+
await runtimeRequest(
|
|
1544
|
+
runtimeUrl,
|
|
1545
|
+
assistantId,
|
|
1546
|
+
`/runs/${runId}/secret`,
|
|
1547
|
+
{
|
|
1548
|
+
method: "POST",
|
|
1549
|
+
body: JSON.stringify({ value, delivery }),
|
|
1550
|
+
},
|
|
1551
|
+
bearerToken,
|
|
1552
|
+
);
|
|
1553
|
+
});
|
|
1554
|
+
h.showSpinner("Working...");
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
if (runStatus.status === "completed") {
|
|
1559
|
+
try {
|
|
1560
|
+
const pollResult = await pollMessages(runtimeUrl, assistantId, bearerToken);
|
|
1561
|
+
for (const msg of pollResult.messages) {
|
|
1562
|
+
if (!seenMessageIdsRef.current.has(msg.id)) {
|
|
1563
|
+
seenMessageIdsRef.current.add(msg.id);
|
|
1564
|
+
if (msg.role === "assistant") {
|
|
1565
|
+
h.addMessage(msg);
|
|
1566
|
+
chatLogRef.current.push({ role: "assistant", content: msg.content });
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
} catch {
|
|
1571
|
+
// Final poll failure; continue to cleanup
|
|
1572
|
+
}
|
|
1573
|
+
h.setBusy(false);
|
|
1574
|
+
h.hideSpinner();
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
if (runStatus.status === "failed") {
|
|
1579
|
+
h.setBusy(false);
|
|
1580
|
+
h.hideSpinner();
|
|
1581
|
+
const errorMsg = runStatus.error ?? "Run failed";
|
|
1582
|
+
h.showError(errorMsg);
|
|
1583
|
+
chatLogRef.current.push({ role: "error", content: errorMsg });
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
} catch {
|
|
1587
|
+
// Run status poll failure; fall through to message poll
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
try {
|
|
1592
|
+
const pollResult = await pollMessages(runtimeUrl, assistantId, bearerToken);
|
|
1593
|
+
for (const msg of pollResult.messages) {
|
|
1594
|
+
if (!seenMessageIdsRef.current.has(msg.id)) {
|
|
1595
|
+
seenMessageIdsRef.current.add(msg.id);
|
|
1596
|
+
if (msg.role === "assistant") {
|
|
1597
|
+
h.addMessage(msg);
|
|
1598
|
+
chatLogRef.current.push({ role: "assistant", content: msg.content });
|
|
1599
|
+
if (!runId) {
|
|
1600
|
+
h.setBusy(false);
|
|
1601
|
+
h.hideSpinner();
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
} catch {
|
|
1608
|
+
// Poll failure; retry
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
h.setBusy(false);
|
|
1613
|
+
h.hideSpinner();
|
|
1614
|
+
h.showError("Response timed out. The assistant may still be processing.");
|
|
1615
|
+
try {
|
|
1616
|
+
const doctorResult = await callDoctorDaemon(
|
|
1617
|
+
assistantId,
|
|
1618
|
+
project,
|
|
1619
|
+
zone,
|
|
1620
|
+
undefined,
|
|
1621
|
+
undefined,
|
|
1622
|
+
doctorSessionIdRef.current,
|
|
1623
|
+
);
|
|
1624
|
+
if (doctorResult.diagnostics) {
|
|
1625
|
+
h.addStatus(
|
|
1626
|
+
`--- SSH Diagnostics ---\n${doctorResult.diagnostics}\n--- End Diagnostics ---`,
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
} catch {
|
|
1630
|
+
// Doctor daemon unreachable; skip diagnostics
|
|
1631
|
+
}
|
|
1632
|
+
} catch (error) {
|
|
1633
|
+
h.setBusy(false);
|
|
1634
|
+
h.hideSpinner();
|
|
1635
|
+
const isTimeout = error instanceof Error && error.name === "AbortError";
|
|
1636
|
+
if (isTimeout) {
|
|
1637
|
+
const errorMsg = "Send timed out";
|
|
1638
|
+
h.showError(errorMsg);
|
|
1639
|
+
chatLogRef.current.push({ role: "error", content: errorMsg });
|
|
1640
|
+
} else {
|
|
1641
|
+
const is409 = error instanceof Error && error.message.includes("HTTP 409");
|
|
1642
|
+
if (is409) {
|
|
1643
|
+
h.showError("Assistant is still working. Please wait and try again.");
|
|
1644
|
+
} else {
|
|
1645
|
+
const errorMsg = `Failed to send: ${error instanceof Error ? error.message : error}`;
|
|
1646
|
+
h.showError(errorMsg);
|
|
1647
|
+
chatLogRef.current.push({ role: "error", content: errorMsg });
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
},
|
|
1652
|
+
[runtimeUrl, assistantId, bearerToken, project, zone, cleanup, ensureConnected],
|
|
1653
|
+
);
|
|
1654
|
+
|
|
1655
|
+
const handleSubmit = useCallback(
|
|
1656
|
+
(value: string) => {
|
|
1657
|
+
setInputValue("");
|
|
1658
|
+
handleInput(value);
|
|
1659
|
+
},
|
|
1660
|
+
[handleInput],
|
|
1661
|
+
);
|
|
1662
|
+
|
|
1663
|
+
useEffect(() => {
|
|
1664
|
+
const handle: ChatAppHandle = {
|
|
1665
|
+
addMessage,
|
|
1666
|
+
addStatus,
|
|
1667
|
+
showSpinner,
|
|
1668
|
+
hideSpinner,
|
|
1669
|
+
showHelp: showHelpFn,
|
|
1670
|
+
showError,
|
|
1671
|
+
showSelection,
|
|
1672
|
+
showSecretInput,
|
|
1673
|
+
handleSecretPrompt: handleSecretPromptFn,
|
|
1674
|
+
clearFeed,
|
|
1675
|
+
setBusy,
|
|
1676
|
+
updateHealthStatus,
|
|
1677
|
+
};
|
|
1678
|
+
handleRef_.current = handle;
|
|
1679
|
+
handleRef(handle);
|
|
1680
|
+
}, [
|
|
1681
|
+
handleRef,
|
|
1682
|
+
addMessage,
|
|
1683
|
+
addStatus,
|
|
1684
|
+
showSpinner,
|
|
1685
|
+
hideSpinner,
|
|
1686
|
+
showHelpFn,
|
|
1687
|
+
showError,
|
|
1688
|
+
showSelection,
|
|
1689
|
+
showSecretInput,
|
|
1690
|
+
handleSecretPromptFn,
|
|
1691
|
+
clearFeed,
|
|
1692
|
+
setBusy,
|
|
1693
|
+
updateHealthStatus,
|
|
1694
|
+
]);
|
|
1695
|
+
|
|
1696
|
+
useEffect(() => {
|
|
1697
|
+
ensureConnected();
|
|
1698
|
+
}, [ensureConnected]);
|
|
1699
|
+
|
|
1700
|
+
useInput(
|
|
1701
|
+
(input, key) => {
|
|
1702
|
+
if (key.ctrl && input === "c") {
|
|
1703
|
+
onExit();
|
|
1704
|
+
}
|
|
1705
|
+
},
|
|
1706
|
+
{ isActive: inputFocused },
|
|
1707
|
+
);
|
|
1708
|
+
|
|
1709
|
+
useInput(
|
|
1710
|
+
(_input, key) => {
|
|
1711
|
+
if (key.shift && key.upArrow) {
|
|
1712
|
+
setScrollIndex((prev) => {
|
|
1713
|
+
if (prev === null) {
|
|
1714
|
+
return Math.max(0, visibleWindow.startIndex - SCROLL_STEP);
|
|
1715
|
+
}
|
|
1716
|
+
return Math.max(0, prev - SCROLL_STEP);
|
|
1717
|
+
});
|
|
1718
|
+
} else if (key.shift && key.downArrow) {
|
|
1719
|
+
setScrollIndex((prev) => {
|
|
1720
|
+
if (prev === null) {
|
|
1721
|
+
return null;
|
|
1722
|
+
}
|
|
1723
|
+
const nextIndex = prev + SCROLL_STEP;
|
|
1724
|
+
let totalHeight = 0;
|
|
1725
|
+
for (let i = nextIndex; i < feed.length; i++) {
|
|
1726
|
+
totalHeight += estimateItemHeight(feed[i], terminalColumns);
|
|
1727
|
+
if (totalHeight > availableRows) {
|
|
1728
|
+
return nextIndex;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
return null;
|
|
1732
|
+
});
|
|
1733
|
+
} else if (key.meta && key.upArrow) {
|
|
1734
|
+
setScrollIndex(0);
|
|
1735
|
+
} else if (key.meta && key.downArrow) {
|
|
1736
|
+
setScrollIndex(null);
|
|
1737
|
+
}
|
|
1738
|
+
},
|
|
1739
|
+
{ isActive: !selection && !secretInput },
|
|
1740
|
+
);
|
|
1741
|
+
|
|
1742
|
+
const handleSecretSubmit = useCallback(
|
|
1743
|
+
(value: string) => {
|
|
1744
|
+
if (secretInput) {
|
|
1745
|
+
const { resolve } = secretInput;
|
|
1746
|
+
setSecretInput(null);
|
|
1747
|
+
setInputFocused(true);
|
|
1748
|
+
resolve(value);
|
|
1749
|
+
}
|
|
1750
|
+
},
|
|
1751
|
+
[secretInput],
|
|
1752
|
+
);
|
|
1753
|
+
|
|
1754
|
+
const handleSecretCancel = useCallback(() => {
|
|
1755
|
+
if (secretInput) {
|
|
1756
|
+
const { resolve } = secretInput;
|
|
1757
|
+
setSecretInput(null);
|
|
1758
|
+
setInputFocused(true);
|
|
1759
|
+
resolve("");
|
|
1760
|
+
}
|
|
1761
|
+
}, [secretInput]);
|
|
1762
|
+
|
|
1763
|
+
const handleSelectionSelect = useCallback(
|
|
1764
|
+
(index: number) => {
|
|
1765
|
+
if (selection) {
|
|
1766
|
+
const { resolve } = selection;
|
|
1767
|
+
setSelection(null);
|
|
1768
|
+
setInputFocused(true);
|
|
1769
|
+
resolve(index);
|
|
1770
|
+
}
|
|
1771
|
+
},
|
|
1772
|
+
[selection],
|
|
1773
|
+
);
|
|
1774
|
+
|
|
1775
|
+
const handleSelectionCancel = useCallback(() => {
|
|
1776
|
+
if (selection) {
|
|
1777
|
+
const { resolve } = selection;
|
|
1778
|
+
setSelection(null);
|
|
1779
|
+
setInputFocused(true);
|
|
1780
|
+
resolve(-1);
|
|
1781
|
+
}
|
|
1782
|
+
}, [selection]);
|
|
1783
|
+
|
|
1784
|
+
return (
|
|
1785
|
+
<Box flexDirection="column" height={terminalRows}>
|
|
1786
|
+
<DefaultMainScreen
|
|
1787
|
+
runtimeUrl={runtimeUrl}
|
|
1788
|
+
assistantId={assistantId}
|
|
1789
|
+
species={species}
|
|
1790
|
+
healthStatus={healthStatus}
|
|
1791
|
+
/>
|
|
1792
|
+
|
|
1793
|
+
{visibleWindow.hiddenAbove > 0 ? (
|
|
1794
|
+
<Text dimColor>
|
|
1795
|
+
{"\u2191"} {visibleWindow.hiddenAbove} more above (Shift+\u2191/Cmd+\u2191)
|
|
1796
|
+
</Text>
|
|
1797
|
+
) : null}
|
|
1798
|
+
|
|
1799
|
+
{visibleWindow.items.map((item, i) => {
|
|
1800
|
+
const feedIndex = visibleWindow.startIndex + i;
|
|
1801
|
+
if (isRuntimeMessage(item)) {
|
|
1802
|
+
return (
|
|
1803
|
+
<Box key={feedIndex} flexDirection="column" marginBottom={1}>
|
|
1804
|
+
<MessageDisplay msg={item} />
|
|
1805
|
+
</Box>
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
if (item.type === "status") {
|
|
1809
|
+
return (
|
|
1810
|
+
<Text key={feedIndex} color={item.color as "green" | "yellow" | "red" | undefined}>
|
|
1811
|
+
{item.text}
|
|
1812
|
+
</Text>
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
if (item.type === "help") {
|
|
1816
|
+
return <HelpDisplay key={feedIndex} />;
|
|
1817
|
+
}
|
|
1818
|
+
if (item.type === "error") {
|
|
1819
|
+
return (
|
|
1820
|
+
<Text key={feedIndex} color="red">
|
|
1821
|
+
{item.text}
|
|
1822
|
+
</Text>
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
return null;
|
|
1826
|
+
})}
|
|
1827
|
+
|
|
1828
|
+
{visibleWindow.hiddenBelow > 0 ? (
|
|
1829
|
+
<Text dimColor>
|
|
1830
|
+
{"\u2193"} {visibleWindow.hiddenBelow} more below (Shift+\u2193/Cmd+\u2193)
|
|
1831
|
+
</Text>
|
|
1832
|
+
) : null}
|
|
1833
|
+
|
|
1834
|
+
{spinnerText ? <SpinnerDisplay text={spinnerText} /> : null}
|
|
1835
|
+
|
|
1836
|
+
{selection ? (
|
|
1837
|
+
<SelectionWindow
|
|
1838
|
+
title={selection.title}
|
|
1839
|
+
options={selection.options}
|
|
1840
|
+
onSelect={handleSelectionSelect}
|
|
1841
|
+
onCancel={handleSelectionCancel}
|
|
1842
|
+
/>
|
|
1843
|
+
) : null}
|
|
1844
|
+
|
|
1845
|
+
{secretInput ? (
|
|
1846
|
+
<SecretInputWindow
|
|
1847
|
+
label={secretInput.label}
|
|
1848
|
+
placeholder={secretInput.placeholder}
|
|
1849
|
+
onSubmit={handleSecretSubmit}
|
|
1850
|
+
onCancel={handleSecretCancel}
|
|
1851
|
+
/>
|
|
1852
|
+
) : null}
|
|
1853
|
+
|
|
1854
|
+
{!selection && !secretInput ? (
|
|
1855
|
+
<Box>
|
|
1856
|
+
<Text color="green" bold>
|
|
1857
|
+
you{">"}
|
|
1858
|
+
{" "}
|
|
1859
|
+
</Text>
|
|
1860
|
+
<TextInput
|
|
1861
|
+
value={inputValue}
|
|
1862
|
+
onChange={setInputValue}
|
|
1863
|
+
onSubmit={handleSubmit}
|
|
1864
|
+
focus={inputFocused}
|
|
1865
|
+
/>
|
|
1866
|
+
</Box>
|
|
1867
|
+
) : null}
|
|
1868
|
+
</Box>
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
export interface ChatAppInstance {
|
|
1873
|
+
handle: ChatAppHandle;
|
|
1874
|
+
unmount: () => void;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
export function renderChatApp(
|
|
1878
|
+
runtimeUrl: string,
|
|
1879
|
+
assistantId: string,
|
|
1880
|
+
species: Species,
|
|
1881
|
+
onExit: () => void,
|
|
1882
|
+
options?: { bearerToken?: string; project?: string; zone?: string },
|
|
1883
|
+
): ChatAppInstance {
|
|
1884
|
+
let chatHandle: ChatAppHandle | null = null;
|
|
1885
|
+
|
|
1886
|
+
const instance = inkRender(
|
|
1887
|
+
<ChatApp
|
|
1888
|
+
runtimeUrl={runtimeUrl}
|
|
1889
|
+
assistantId={assistantId}
|
|
1890
|
+
species={species}
|
|
1891
|
+
bearerToken={options?.bearerToken}
|
|
1892
|
+
project={options?.project}
|
|
1893
|
+
zone={options?.zone}
|
|
1894
|
+
onExit={onExit}
|
|
1895
|
+
handleRef={(h) => {
|
|
1896
|
+
chatHandle = h;
|
|
1897
|
+
}}
|
|
1898
|
+
/>,
|
|
1899
|
+
{ exitOnCtrlC: false },
|
|
1900
|
+
);
|
|
1901
|
+
|
|
1902
|
+
const handle: ChatAppHandle = {
|
|
1903
|
+
addMessage: (msg) => chatHandle?.addMessage(msg),
|
|
1904
|
+
addStatus: (text, color) => chatHandle?.addStatus(text, color),
|
|
1905
|
+
showSpinner: (text) => chatHandle?.showSpinner(text),
|
|
1906
|
+
hideSpinner: () => chatHandle?.hideSpinner(),
|
|
1907
|
+
showHelp: () => chatHandle?.showHelp(),
|
|
1908
|
+
showError: (text) => chatHandle?.showError(text),
|
|
1909
|
+
showSelection: (title, options) =>
|
|
1910
|
+
chatHandle?.showSelection(title, options) ?? Promise.resolve(-1),
|
|
1911
|
+
showSecretInput: (label, placeholder) =>
|
|
1912
|
+
chatHandle?.showSecretInput(label, placeholder) ?? Promise.resolve(""),
|
|
1913
|
+
handleSecretPrompt: (secret, onSubmitCb) =>
|
|
1914
|
+
chatHandle?.handleSecretPrompt(secret, onSubmitCb) ?? Promise.resolve(),
|
|
1915
|
+
clearFeed: () => chatHandle?.clearFeed(),
|
|
1916
|
+
setBusy: (busy) => chatHandle?.setBusy(busy),
|
|
1917
|
+
updateHealthStatus: (status) => chatHandle?.updateHealthStatus(status),
|
|
1918
|
+
};
|
|
1919
|
+
|
|
1920
|
+
return {
|
|
1921
|
+
handle,
|
|
1922
|
+
unmount: () => instance.unmount(),
|
|
1923
|
+
};
|
|
1924
|
+
}
|