skydive-cli 0.5.0-beta.3 → 0.5.0-beta.30
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/CHANGELOG.md +3 -0
- package/README.md +15 -4
- package/dist/js/api-DG5W6iwx.mjs +131 -0
- package/dist/js/bin.mjs +251 -84
- package/dist/js/{boot-BVVbrf2k.mjs → boot-0-dpPM1_.mjs} +2051 -591
- package/dist/js/{client-Dd5sMXPv.mjs → client-DbqRBquD.mjs} +206 -133
- package/dist/js/client-hH2PJL8y.mjs +5 -0
- package/dist/js/{daemon-DDBIumf1.mjs → daemon-Ce8WLdGI.mjs} +3 -2
- package/dist/js/{daemon-Bq93vOIk.mjs → daemon-DuxaUuek.mjs} +93 -7
- package/dist/js/{daemon-client-Bewt98dE.mjs → daemon-client-C4heMWTq.mjs} +12 -8
- package/dist/js/daemon-client-LJdnM_nE.mjs +7 -0
- package/dist/js/forward-DL6DYqyc.mjs +187 -0
- package/dist/js/{print-ba_0hiV9.mjs → print-CQkXZ02a.mjs} +2 -2
- package/dist/js/{print-CbayCa87.mjs → print-DYiQC5IW.mjs} +211 -35
- package/dist/js/{print-share-C1LPsgTP.mjs → print-share-BH9gvCls.mjs} +3 -1
- package/dist/js/{profiler-DtuXkIkR.mjs → profiler-BoO7ldO0.mjs} +258 -4
- package/dist/js/{raw-pty-B6mAroiI.mjs → raw-pty-DY4KelZW.mjs} +1 -1
- package/dist/js/raw-pty-DmdUf4_w.mjs +5 -0
- package/dist/js/{rest-DADJh0bi.mjs → rest-DSpGS5XU.mjs} +1 -1
- package/dist/js/{rest-BY2nADw5.mjs → rest-xXfqT4SX.mjs} +27 -12
- package/package.json +1 -1
- package/dist/js/client-CKBQ8pft.mjs +0 -4
- package/dist/js/daemon-client-srAit8Fz.mjs +0 -6
- package/dist/js/raw-pty-3EkG-jjH.mjs +0 -5
- /package/dist/js/{client-Cn2af31H.mjs → client-c4c5MmgN.mjs} +0 -0
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { D as
|
|
3
|
-
import { B as
|
|
2
|
+
import { A as takenAliasNames, C as themesForMode, D as dedupeAliasName, E as aliasActivationHint, F as getActiveWorkspaceId, L as listWorkspaces, N as detectShell, O as installAgentAlias, T as machineOsFromPlatform, _ as theme, a as installCrashHandler, b as themeModeFromColorFgBg, c as MARK_CELLS, d as splashFitsWidth, f as DEFAULT_THEME_ID, g as noColorRequested, h as monoTheme, i as writeArtifact, k as slugifyAliasName, l as WORDMARK, m as findTheme, n as profilingEnabled, o as buildCrashReport, p as applyTheme, r as record, s as writeCrashReport, v as themeForMode, x as themeVersion, y as themeMode, z as setActiveWorkspace } from "./profiler-BoO7ldO0.mjs";
|
|
3
|
+
import { B as resolveWebUrl, C as getConfigPath, O as getReviewStateDir, P as recordDefaultAgent, U as saveTheme, b as DEFAULT_APP_URL, c as cardActionErrorMessage, d as reconcileMaskedInput, f as resolveConnectUrl, h as specKeyFor, i as resolveAgent, k as getSavedTheme, l as parseExternalOauthConnectParams, m as parseConnectCard, p as computeSettledLabel, s as MASK_CHAR, u as parseOauthConnectParams, y as DEFAULT_API_URL } from "./print-DYiQC5IW.mjs";
|
|
4
4
|
import { t as HttpError } from "./http-error-DzyrsLAZ.mjs";
|
|
5
|
-
import { a as isRecord, i as errorMessage, n as errorDetail, r as sendErrorMessage, t as createRestClient } from "./rest-
|
|
5
|
+
import { a as isRecord, i as errorMessage, n as errorDetail, r as sendErrorMessage, t as createRestClient } from "./rest-xXfqT4SX.mjs";
|
|
6
6
|
import { i as billingBlockedOutcomeFromSendResponse } from "./billing-blocked-2wju4gC_.mjs";
|
|
7
|
-
import { t as PortalClient } from "./client-
|
|
8
|
-
import "./daemon-
|
|
9
|
-
import
|
|
10
|
-
import { t as
|
|
11
|
-
import { t as
|
|
7
|
+
import { t as PortalClient } from "./client-DbqRBquD.mjs";
|
|
8
|
+
import "./daemon-DuxaUuek.mjs";
|
|
9
|
+
import "./api-DG5W6iwx.mjs";
|
|
10
|
+
import { t as SandboxStream } from "./client-c4c5MmgN.mjs";
|
|
11
|
+
import { t as PortalDaemonClient } from "./daemon-client-C4heMWTq.mjs";
|
|
12
|
+
import { t as runRawPtyPassthrough } from "./raw-pty-DY4KelZW.mjs";
|
|
12
13
|
import * as os$1 from "node:os";
|
|
13
14
|
import { homedir, platform, release, tmpdir } from "node:os";
|
|
14
15
|
import path, { basename, extname, isAbsolute, join, win32 } from "node:path";
|
|
@@ -136,6 +137,7 @@ const useStore = create((set, get) => ({
|
|
|
136
137
|
seedPrompt: null,
|
|
137
138
|
autoGrantMachine: false,
|
|
138
139
|
newConversationIntent: false,
|
|
140
|
+
forcedOnboarding: false,
|
|
139
141
|
earlyInput: "",
|
|
140
142
|
goTo: (screen) => set({
|
|
141
143
|
screen,
|
|
@@ -212,7 +214,17 @@ const useStore = create((set, get) => ({
|
|
|
212
214
|
/**
|
|
213
215
|
* Decide the screen the chat TUI boots into.
|
|
214
216
|
*
|
|
215
|
-
* With no selector we open the agent picker
|
|
217
|
+
* With no selector we open the agent picker — unless the config stores a
|
|
218
|
+
* `defaultAgent`, in which case a bare `skydive chat` opens a fresh
|
|
219
|
+
* conversation with that agent directly. The default is maintained
|
|
220
|
+
* automatically (every conversation the TUI opens records its agent, so a
|
|
221
|
+
* bare launch returns to whoever you talked to last — the dominant launch
|
|
222
|
+
* pattern), and `skydive config set defaultAgent` can seed or override it. A
|
|
223
|
+
* stored selector that no longer resolves degrades silently to the agent
|
|
224
|
+
* picker rather than an error screen: config goes stale (agent renamed,
|
|
225
|
+
* archived, another one claiming the same name), and unlike a mistyped
|
|
226
|
+
* `--agent` there is no command line to correct — and picking an agent
|
|
227
|
+
* there heals the default on its own.
|
|
216
228
|
*
|
|
217
229
|
* With `--agent` (`skydive chat --agent grace`, no `-p`) we resolve it
|
|
218
230
|
* against the org roster and open that agent's conversation list, skipping
|
|
@@ -228,20 +240,31 @@ const useStore = create((set, get) => ({
|
|
|
228
240
|
* `--agent`. When both are given, the conversation wins (it pins the agent
|
|
229
241
|
* anyway).
|
|
230
242
|
*
|
|
243
|
+
* None of these paths load the whole org agent roster. The only decision that
|
|
244
|
+
* needs every candidate is disambiguating a fuzzy name/slug selector; an id
|
|
245
|
+
* (or the `--resume` conversation's agentId) is exact and resolves with a
|
|
246
|
+
* single `getAgent`, and `--new` with no selector only needs "exactly one
|
|
247
|
+
* agent, or not", which a two-row page answers. A large org must never sit on
|
|
248
|
+
* the `connecting…` splash while the roster streams in.
|
|
249
|
+
*
|
|
231
250
|
* Kept out of the React component so it's unit-testable under Node (the
|
|
232
251
|
* component itself can't render without Bun + OpenTUI).
|
|
233
252
|
*/
|
|
234
|
-
|
|
253
|
+
/** A selector that is a bare uuid is an exact agent id: it can be resolved
|
|
254
|
+
* with a single `getAgent` fetch, so no boot path that carries one needs the
|
|
255
|
+
* org roster. A name/slug selector is the only case that can be ambiguous and
|
|
256
|
+
* therefore still needs the full candidate list to disambiguate. */
|
|
257
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
258
|
+
async function resolveInitialScreen({ rest, agentSelector, conversationId, newConversation, defaultAgentSelector, forceOnboarding }) {
|
|
235
259
|
if (conversationId) {
|
|
236
|
-
const
|
|
260
|
+
const conversation = await rest.getConversation({ conversationId }).catch((err) => {
|
|
237
261
|
if (err instanceof HttpError && err.status === 404) throw new Error(`No conversation with id ${conversationId}. It may have been deleted, or belong to another workspace.`);
|
|
238
262
|
throw err;
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (!agent) throw new Error(`Conversation ${conversationId} belongs to an agent you can't access.`);
|
|
263
|
+
});
|
|
264
|
+
const agent = await rest.getAgent({ agentId: conversation.agentId }).catch((err) => {
|
|
265
|
+
if (err instanceof HttpError && (err.status === 404 || err.status === 403)) throw new Error(`Conversation ${conversationId} belongs to an agent you can't access.`);
|
|
266
|
+
throw err;
|
|
267
|
+
});
|
|
245
268
|
return {
|
|
246
269
|
kind: "chat",
|
|
247
270
|
agent,
|
|
@@ -262,26 +285,68 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
|
|
|
262
285
|
}
|
|
263
286
|
};
|
|
264
287
|
}
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
288
|
+
if (forceOnboarding && !agentSelector) return { kind: "first-run" };
|
|
289
|
+
if (!agentSelector && !newConversation && !defaultAgentSelector) {
|
|
290
|
+
if ((await rest.listAgents({
|
|
291
|
+
scope: "org",
|
|
292
|
+
onPage: null
|
|
293
|
+
})).length === 0) return { kind: "first-run" };
|
|
294
|
+
return { kind: "agent-picker" };
|
|
295
|
+
}
|
|
296
|
+
if (!agentSelector && defaultAgentSelector) {
|
|
297
|
+
const agent = UUID_RE.test(defaultAgentSelector) ? await rest.getAgent({ agentId: defaultAgentSelector }).catch((err) => {
|
|
298
|
+
if (err instanceof HttpError && (err.status === 404 || err.status === 403)) return null;
|
|
299
|
+
throw err;
|
|
300
|
+
}) : resolveAgentOrNull(await rest.listAgents({
|
|
301
|
+
scope: "org",
|
|
302
|
+
onPage: null
|
|
303
|
+
}), defaultAgentSelector);
|
|
304
|
+
if (agent) return newChat(agent);
|
|
305
|
+
return { kind: "agent-picker" };
|
|
306
|
+
}
|
|
307
|
+
if (agentSelector && UUID_RE.test(agentSelector)) {
|
|
308
|
+
const agent = await rest.getAgent({ agentId: agentSelector }).catch((err) => {
|
|
309
|
+
if (err instanceof HttpError && (err.status === 404 || err.status === 403)) throw new Error(`No agent with id ${agentSelector}. It may not exist, or belong to another workspace.`);
|
|
310
|
+
throw err;
|
|
311
|
+
});
|
|
312
|
+
if (newConversation) return {
|
|
274
313
|
kind: "chat",
|
|
275
|
-
agent
|
|
314
|
+
agent,
|
|
276
315
|
conversation: {
|
|
277
316
|
kind: "new",
|
|
278
|
-
agentId:
|
|
317
|
+
agentId: agent.id,
|
|
279
318
|
nonce: crypto.randomUUID()
|
|
280
319
|
}
|
|
281
320
|
};
|
|
321
|
+
return {
|
|
322
|
+
kind: "conversation-picker",
|
|
323
|
+
agent,
|
|
324
|
+
initialQuery: ""
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (!agentSelector && newConversation) {
|
|
328
|
+
const firstTwo = await rest.listAgents({
|
|
329
|
+
scope: "org",
|
|
330
|
+
onPage: null,
|
|
331
|
+
limit: 2
|
|
332
|
+
});
|
|
333
|
+
const only = firstTwo.length === 1 ? firstTwo[0] : null;
|
|
334
|
+
if (!only) return { kind: "agent-picker" };
|
|
335
|
+
return newChat(only);
|
|
282
336
|
}
|
|
283
|
-
const agent = resolveAgent(
|
|
284
|
-
|
|
337
|
+
const agent = resolveAgent(await rest.listAgents({
|
|
338
|
+
scope: "org",
|
|
339
|
+
onPage: null
|
|
340
|
+
}), agentSelector);
|
|
341
|
+
if (newConversation) return newChat(agent);
|
|
342
|
+
return {
|
|
343
|
+
kind: "conversation-picker",
|
|
344
|
+
agent,
|
|
345
|
+
initialQuery: ""
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function newChat(agent) {
|
|
349
|
+
return {
|
|
285
350
|
kind: "chat",
|
|
286
351
|
agent,
|
|
287
352
|
conversation: {
|
|
@@ -290,10 +355,17 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
|
|
|
290
355
|
nonce: crypto.randomUUID()
|
|
291
356
|
}
|
|
292
357
|
};
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
358
|
+
}
|
|
359
|
+
/** `resolveAgent` throws for a selector that matches nothing or matches
|
|
360
|
+
* ambiguously — both phrased for a human who can retype `--agent`. A stored
|
|
361
|
+
* default has no command line behind it, so both shapes just mean "no usable
|
|
362
|
+
* default". */
|
|
363
|
+
function resolveAgentOrNull(agents, selector) {
|
|
364
|
+
try {
|
|
365
|
+
return resolveAgent(agents, selector);
|
|
366
|
+
} catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
297
369
|
}
|
|
298
370
|
|
|
299
371
|
//#endregion
|
|
@@ -324,6 +396,8 @@ function createPortalClient(opts) {
|
|
|
324
396
|
deviceToken: null
|
|
325
397
|
}),
|
|
326
398
|
resolveCwd: () => process.cwd(),
|
|
399
|
+
persistedMachineName: null,
|
|
400
|
+
onMachineName: () => {},
|
|
327
401
|
onState: (state) => opts.onState({
|
|
328
402
|
status: state.status,
|
|
329
403
|
machineName: state.machineName,
|
|
@@ -337,7 +411,7 @@ function createPortalClient(opts) {
|
|
|
337
411
|
start: () => Promise.resolve(),
|
|
338
412
|
enable: () => client.enable(),
|
|
339
413
|
disable: () => client.disable(),
|
|
340
|
-
grantAgent: (agentId) => client.grantAgent(agentId),
|
|
414
|
+
grantAgent: (agentId, conversationId) => client.grantAgent(agentId, conversationId),
|
|
341
415
|
decline: (_agentId, declined) => {
|
|
342
416
|
if (declined && client.grantedAgentIds().length === 0) client.disable();
|
|
343
417
|
},
|
|
@@ -1016,42 +1090,133 @@ function createAgentHost({ renderer, notifications }) {
|
|
|
1016
1090
|
* take the one host we can identify unambiguously (github.com, incl. `www.`),
|
|
1017
1091
|
* which covers the common product case; GHES stays the native badge's job.
|
|
1018
1092
|
*
|
|
1019
|
-
* Only the `/pull/<n>` form (not `/issues
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
*/
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1093
|
+
* Only the `/pull/<n>` form (not `/issues/`). A trailing path/query/fragment
|
|
1094
|
+
* (e.g. `/files`, `#discussion`) is allowed but not captured into the canonical
|
|
1095
|
+
* URL. Also matches inside markdown links, backticks, and trailing punctuation.
|
|
1096
|
+
*
|
|
1097
|
+
* The `https://` (and `www.`) prefix is optional: agents and people routinely
|
|
1098
|
+
* write a bare `github.com/o/r/pull/7`, and that should link a PR the same way
|
|
1099
|
+
* the full URL does. To keep a bare host from matching inside a longer hostname
|
|
1100
|
+
* (`mygithub.com/...`, `notgithub.com/...`), a protocol-less match must be
|
|
1101
|
+
* preceded by a start-of-string or a non-host character (anything but a word
|
|
1102
|
+
* char or `.`) — a leading boundary the `(?:^|[^\w.])` group asserts and
|
|
1103
|
+
* consumes. The consumed boundary char is outside the captured groups, so it
|
|
1104
|
+
* never leaks into owner/repo. `matchAll` won't overlap, but the boundary is a
|
|
1105
|
+
* single delimiter (space, `(`, `[`, etc.) between PRs in practice, so distinct
|
|
1106
|
+
* back-to-back URLs still each match.
|
|
1107
|
+
*/
|
|
1108
|
+
const PR_URL_RE = /(?:^|[^\w.])(?:https?:\/\/)?(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/gi;
|
|
1109
|
+
/**
|
|
1110
|
+
* Matches a bare, human-style PR reference `#<n>` — the way people (and LLMs)
|
|
1111
|
+
* name a PR in prose ("opened #20858", "landed #42"). Requires a non-word
|
|
1112
|
+
* boundary before the `#` so a fragment tail like `/pull/7#discussion_r5` or a
|
|
1113
|
+
* hex color `#20858` embedded in a word doesn't count, and captures the digits
|
|
1114
|
+
* up to a word boundary. We only harvest these from **assistant prose**, never
|
|
1115
|
+
* from tool output, because `#N` is exactly the relevance signal we're after:
|
|
1116
|
+
* the agent chose to talk about this PR.
|
|
1117
|
+
*/
|
|
1118
|
+
const PR_HASH_RE = /(?:^|[^\w#/])#(\d+)\b/g;
|
|
1119
|
+
/**
|
|
1120
|
+
* Every full `/pull/<n>` github.com URL in `urlText`, keyed by number to the
|
|
1121
|
+
* candidate refs for that number. A number can map to more than one repo, so we
|
|
1122
|
+
* keep all candidates; a bare `#N` label can't say which repo, but the pill's
|
|
1123
|
+
* own URL does. Deduped by repo-qualified identity (`owner/repo#number`) so the
|
|
1124
|
+
* same PR linked twice — even with a different trailing path/fragment —
|
|
1125
|
+
* collapses to one ref.
|
|
1126
|
+
*
|
|
1127
|
+
* `deferTrailingNumber` guards the streaming truncation case: when true, a URL
|
|
1128
|
+
* whose number runs to the very end of `urlText` is skipped because the next
|
|
1129
|
+
* delta may extend those digits (`.../pull/19` now, `.../pull/19739` later).
|
|
1130
|
+
* Only the still-growing PROSE buffer sets this; settled tool output is always
|
|
1131
|
+
* complete, so its URLs are harvested with the guard off.
|
|
1132
|
+
*/
|
|
1133
|
+
function urlsByNumber(urlText, deferTrailingNumber, byNumber) {
|
|
1134
|
+
for (const match of urlText.matchAll(PR_URL_RE)) {
|
|
1038
1135
|
const owner = match[1];
|
|
1039
1136
|
const repo = match[2];
|
|
1040
1137
|
const numberText = match[3];
|
|
1041
1138
|
if (!owner || !repo || !numberText) continue;
|
|
1042
|
-
if (
|
|
1043
|
-
if (match.index + match[0].length >=
|
|
1139
|
+
if (deferTrailingNumber) {
|
|
1140
|
+
if (match.index + match[0].length >= urlText.length) continue;
|
|
1044
1141
|
}
|
|
1045
1142
|
const number = Number.parseInt(numberText, 10);
|
|
1046
1143
|
if (!Number.isFinite(number) || number <= 0) continue;
|
|
1047
|
-
const
|
|
1048
|
-
if (byKey.has(key)) continue;
|
|
1049
|
-
byKey.set(key, {
|
|
1144
|
+
const ref = {
|
|
1050
1145
|
number,
|
|
1051
|
-
key
|
|
1146
|
+
key: `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`,
|
|
1052
1147
|
url: `https://github.com/${owner}/${repo}/pull/${number}`
|
|
1053
|
-
}
|
|
1148
|
+
};
|
|
1149
|
+
const existing = byNumber.get(number);
|
|
1150
|
+
if (existing) {
|
|
1151
|
+
if (!existing.some((r) => r.key === ref.key)) existing.push(ref);
|
|
1152
|
+
} else byNumber.set(number, [ref]);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
/** Every human-style `#N` named in `proseText` — the relevance signal. */
|
|
1156
|
+
function hashNumbers(proseText) {
|
|
1157
|
+
const numbers = /* @__PURE__ */ new Set();
|
|
1158
|
+
for (const match of proseText.matchAll(PR_HASH_RE)) {
|
|
1159
|
+
const numberText = match[1];
|
|
1160
|
+
if (!numberText) continue;
|
|
1161
|
+
const number = Number.parseInt(numberText, 10);
|
|
1162
|
+
if (Number.isFinite(number) && number > 0) numbers.add(number);
|
|
1163
|
+
}
|
|
1164
|
+
return numbers;
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* The relevance contract, in one place.
|
|
1168
|
+
*
|
|
1169
|
+
* A GitHub PR URL is cheap to emit and shows up in bulk any time a tool dumps
|
|
1170
|
+
* PRs the conversation isn't actually about — `gh pr list`, `gh pr status`, a
|
|
1171
|
+
* `gh search` — printing dozens of `/pull/N` URLs the agent never discusses.
|
|
1172
|
+
* Pinning a sidebar pill for every one of those is noise.
|
|
1173
|
+
*
|
|
1174
|
+
* So a PR is surfaced only when BOTH hold:
|
|
1175
|
+
* 1. a fully-qualified `github.com/<o>/<r>/pull/<n>` URL exists — this may
|
|
1176
|
+
* come from the assistant's prose OR from tool output (a `gh pr create`
|
|
1177
|
+
* that printed the URL the agent then referenced by number), and
|
|
1178
|
+
* 2. the **assistant's prose** names the number as a human-style `#<n>`.
|
|
1179
|
+
*
|
|
1180
|
+
* The `#N` in prose is the intent signal — "the agent is talking about this
|
|
1181
|
+
* PR", as opposed to "a tool happened to list it". The full URL (from wherever)
|
|
1182
|
+
* proves the PR is real and pins down its repo + canonical link. A URL with no
|
|
1183
|
+
* `#N` in prose (the whole of a `gh pr list` dump) is dropped; a `#N` with no
|
|
1184
|
+
* matching URL anywhere (a bare issue-style ref) is dropped.
|
|
1185
|
+
*
|
|
1186
|
+
* `proseText` is the assistant's prose alone (the only source of the `#N`
|
|
1187
|
+
* relevance signal); `urlOnlyText` is settled tool output that may carry full
|
|
1188
|
+
* PR URLs but no `#N` intent. URLs are harvested from both; `#N` from prose
|
|
1189
|
+
* only.
|
|
1190
|
+
*/
|
|
1191
|
+
function collectRelevant(proseText, urlOnlyText, options, byKey) {
|
|
1192
|
+
const byNumber = /* @__PURE__ */ new Map();
|
|
1193
|
+
urlsByNumber(proseText, options.requireCompleteNumber, byNumber);
|
|
1194
|
+
if (urlOnlyText) urlsByNumber(urlOnlyText, false, byNumber);
|
|
1195
|
+
if (byNumber.size === 0) return;
|
|
1196
|
+
const named = hashNumbers(proseText);
|
|
1197
|
+
if (named.size === 0) return;
|
|
1198
|
+
for (const [number, refs] of byNumber) {
|
|
1199
|
+
if (!named.has(number)) continue;
|
|
1200
|
+
for (const ref of refs) if (!byKey.has(ref.key)) byKey.set(ref.key, ref);
|
|
1054
1201
|
}
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Extracts every distinct GitHub PR that is both linked (a full `/pull/<n>`
|
|
1205
|
+
* URL) AND named (`#<n>`), de-duplicated by **repo-qualified identity**
|
|
1206
|
+
* (`owner/repo#number`) — NOT by number, since `#42` can exist in many repos.
|
|
1207
|
+
*
|
|
1208
|
+
* The two-argument form takes the URL pool and the prose separately, so a URL
|
|
1209
|
+
* from tool output can satisfy the link requirement while the `#N` stays a
|
|
1210
|
+
* prose-only signal. The one-argument form (prose only) is the common case: it
|
|
1211
|
+
* requires the URL to be in the same prose that names it.
|
|
1212
|
+
*
|
|
1213
|
+
* See {@link collectRelevant} for the contract.
|
|
1214
|
+
*/
|
|
1215
|
+
function extractPullRequests(proseText, options) {
|
|
1216
|
+
const requireCompleteNumber = options?.requireCompleteNumber ?? true;
|
|
1217
|
+
const urlOnly = options?.urlOnlyText ?? "";
|
|
1218
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1219
|
+
collectRelevant(proseText, urlOnly, { requireCompleteNumber }, byKey);
|
|
1055
1220
|
return [...byKey.values()];
|
|
1056
1221
|
}
|
|
1057
1222
|
/**
|
|
@@ -1090,28 +1255,34 @@ function stringifyToolOutput(value) {
|
|
|
1090
1255
|
}
|
|
1091
1256
|
/**
|
|
1092
1257
|
* A streaming PR scanner: fed run chunks as they stream, it reports each
|
|
1093
|
-
* distinct PR the moment
|
|
1094
|
-
* the
|
|
1258
|
+
* distinct PR the moment BOTH conditions hold — a full `/pull/<n>` URL has been
|
|
1259
|
+
* seen (in prose OR tool output) AND the assistant's prose names it `#<n>` —
|
|
1260
|
+
* exactly once, without ever rescanning the transcript.
|
|
1095
1261
|
*
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1262
|
+
* Two sources feed it, with different roles:
|
|
1263
|
+
* - **Assistant text** (`text-delta`): the ONLY source of the `#N` relevance
|
|
1264
|
+
* signal, and also a source of URLs. Accumulated per part id (a URL or `#N`
|
|
1265
|
+
* can split across two deltas); only the current part's buffer is scanned.
|
|
1266
|
+
* - **Tool output** (`tool-output-available`): a URL-only source. A
|
|
1267
|
+
* `gh pr create` prints the URL the agent then references by number, so the
|
|
1268
|
+
* URL for a #N mention often lives here, not in the prose. Tool output can
|
|
1269
|
+
* NEVER originate a pill on its own — a `gh pr list` dump (URLs, no `#N`)
|
|
1270
|
+
* stays silent — but it satisfies the URL half for a PR the prose names.
|
|
1103
1271
|
*
|
|
1104
1272
|
* `onPullRequest` fires once per never-before-seen PR (keyed by
|
|
1105
|
-
* `owner/repo#number`)
|
|
1106
|
-
*
|
|
1107
|
-
* same chunks the transcript reducer sees.
|
|
1273
|
+
* `owner/repo#number`). Construct one per run (or per conversation) and feed it
|
|
1274
|
+
* the same chunks the transcript reducer sees. See {@link collectRelevant}.
|
|
1108
1275
|
*/
|
|
1109
1276
|
function createPullRequestScanner(onPullRequest) {
|
|
1110
1277
|
let text = "";
|
|
1111
1278
|
let lastPartId;
|
|
1279
|
+
let toolUrlText = "";
|
|
1112
1280
|
const seen = /* @__PURE__ */ new Set();
|
|
1113
|
-
const report = (
|
|
1114
|
-
for (const pr of extractPullRequests(
|
|
1281
|
+
const report = (final) => {
|
|
1282
|
+
for (const pr of extractPullRequests(text, {
|
|
1283
|
+
requireCompleteNumber: !final,
|
|
1284
|
+
urlOnlyText: toolUrlText
|
|
1285
|
+
})) {
|
|
1115
1286
|
if (seen.has(pr.key)) continue;
|
|
1116
1287
|
seen.add(pr.key);
|
|
1117
1288
|
onPullRequest(pr);
|
|
@@ -1121,7 +1292,8 @@ function createPullRequestScanner(onPullRequest) {
|
|
|
1121
1292
|
if (chunk["type"] === "tool-output-available") {
|
|
1122
1293
|
const outputText = stringifyToolOutput(chunk["output"]);
|
|
1123
1294
|
if (!outputText.includes("/pull/")) return;
|
|
1124
|
-
|
|
1295
|
+
toolUrlText += `\n${outputText}`;
|
|
1296
|
+
report(true);
|
|
1125
1297
|
return;
|
|
1126
1298
|
}
|
|
1127
1299
|
if (chunk["type"] !== "text-delta") return;
|
|
@@ -1129,13 +1301,12 @@ function createPullRequestScanner(onPullRequest) {
|
|
|
1129
1301
|
if (!delta) return;
|
|
1130
1302
|
const partId = typeof chunk["id"] === "string" ? chunk["id"] : void 0;
|
|
1131
1303
|
if (partId && lastPartId && partId !== lastPartId) {
|
|
1132
|
-
|
|
1304
|
+
report(true);
|
|
1133
1305
|
text = "";
|
|
1134
1306
|
}
|
|
1135
1307
|
text += delta;
|
|
1136
1308
|
lastPartId = partId ?? lastPartId;
|
|
1137
|
-
|
|
1138
|
-
report(text, false);
|
|
1309
|
+
report(false);
|
|
1139
1310
|
} };
|
|
1140
1311
|
}
|
|
1141
1312
|
|
|
@@ -1479,6 +1650,7 @@ function screenLabelParts(screen, chatTitle) {
|
|
|
1479
1650
|
case "error": return { title: "" };
|
|
1480
1651
|
case "agent-picker": return { title: "pick an agent" };
|
|
1481
1652
|
case "agent-create": return { title: "create an agent" };
|
|
1653
|
+
case "first-run": return { title: "welcome" };
|
|
1482
1654
|
case "workspace-picker": return { title: "switch workspace" };
|
|
1483
1655
|
case "conversation-picker": return {
|
|
1484
1656
|
title: "pick a conversation",
|
|
@@ -1649,13 +1821,15 @@ function WordmarkRows({ mono }) {
|
|
|
1649
1821
|
});
|
|
1650
1822
|
}
|
|
1651
1823
|
/**
|
|
1652
|
-
* Height-aware brand header for the pickers. Always shown
|
|
1653
|
-
* list is loading / empty / erroring) so the splash is
|
|
1654
|
-
* Full mark + wordmark when the terminal is tall
|
|
1655
|
-
*
|
|
1824
|
+
* Height- and width-aware brand header for the pickers. Always shown
|
|
1825
|
+
* (including while the list is loading / empty / erroring) so the splash is
|
|
1826
|
+
* never gated on data. Full mark + wordmark only when the terminal is tall
|
|
1827
|
+
* enough AND wide enough that the art sits on six lines; otherwise a compact
|
|
1828
|
+
* one-liner. Wrapping the splash would blow the 7-row budget the list
|
|
1829
|
+
* subtracts, and the overflow collapses the top list rows onto each other.
|
|
1656
1830
|
*/
|
|
1657
|
-
function BrandHeader({ height }) {
|
|
1658
|
-
if (height
|
|
1831
|
+
function BrandHeader({ height, width }) {
|
|
1832
|
+
if (showFullSplash(height, width)) return /* @__PURE__ */ jsxs("box", {
|
|
1659
1833
|
style: { flexDirection: "column" },
|
|
1660
1834
|
children: [/* @__PURE__ */ jsx(SkydiveMark, { variant: "full" }), /* @__PURE__ */ jsx("box", { style: { height: 1 } })]
|
|
1661
1835
|
});
|
|
@@ -1664,9 +1838,12 @@ function BrandHeader({ height }) {
|
|
|
1664
1838
|
children: "✦ Skydive"
|
|
1665
1839
|
});
|
|
1666
1840
|
}
|
|
1841
|
+
function showFullSplash(height, width) {
|
|
1842
|
+
return height >= 24 && splashFitsWidth(width);
|
|
1843
|
+
}
|
|
1667
1844
|
/** Row budget the BrandHeader consumes; callers subtract this from visibleRows. */
|
|
1668
|
-
function brandHeaderRows(height) {
|
|
1669
|
-
return height
|
|
1845
|
+
function brandHeaderRows(height, width) {
|
|
1846
|
+
return showFullSplash(height, width) ? 7 : 1;
|
|
1670
1847
|
}
|
|
1671
1848
|
function SkydiveMark({ variant = "full" }) {
|
|
1672
1849
|
const mono = noColor();
|
|
@@ -1753,13 +1930,16 @@ function AgentPickerScreen() {
|
|
|
1753
1930
|
scope,
|
|
1754
1931
|
cache
|
|
1755
1932
|
]);
|
|
1756
|
-
const { height } = useTerminalDimensions();
|
|
1933
|
+
const { width, height } = useTerminalDimensions();
|
|
1757
1934
|
if (state.kind === "loading") return /* @__PURE__ */ jsxs("box", {
|
|
1758
1935
|
style: {
|
|
1759
1936
|
flexDirection: "column",
|
|
1760
1937
|
flexGrow: 1
|
|
1761
1938
|
},
|
|
1762
|
-
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
1939
|
+
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
1940
|
+
height,
|
|
1941
|
+
width
|
|
1942
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
1763
1943
|
fg: theme.muted,
|
|
1764
1944
|
children: "loading agents…"
|
|
1765
1945
|
})]
|
|
@@ -1769,7 +1949,10 @@ function AgentPickerScreen() {
|
|
|
1769
1949
|
flexDirection: "column",
|
|
1770
1950
|
flexGrow: 1
|
|
1771
1951
|
},
|
|
1772
|
-
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
1952
|
+
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
1953
|
+
height,
|
|
1954
|
+
width
|
|
1955
|
+
}), /* @__PURE__ */ jsxs("text", {
|
|
1773
1956
|
fg: theme.error,
|
|
1774
1957
|
children: ["error: ", state.message]
|
|
1775
1958
|
})]
|
|
@@ -1779,7 +1962,10 @@ function AgentPickerScreen() {
|
|
|
1779
1962
|
flexDirection: "column",
|
|
1780
1963
|
flexGrow: 1
|
|
1781
1964
|
},
|
|
1782
|
-
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
1965
|
+
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
1966
|
+
height,
|
|
1967
|
+
width
|
|
1968
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
1783
1969
|
fg: theme.muted,
|
|
1784
1970
|
children: scope === "mine" ? "no agents of yours yet. press tab to see all agents, ctrl+w to switch workspace, or ctrl+n to create one." : "no agents in this workspace. press ctrl+w to switch workspace, or ctrl+n to create one."
|
|
1785
1971
|
})]
|
|
@@ -1800,7 +1986,7 @@ function FilterableAgentList({ agents, scope, onPick }) {
|
|
|
1800
1986
|
const hits = fuzzyFilter(query, agents, agentKeys);
|
|
1801
1987
|
const filtered = hits.map((h) => h.item);
|
|
1802
1988
|
const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
|
|
1803
|
-
const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height));
|
|
1989
|
+
const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height, width));
|
|
1804
1990
|
const start = windowStart(clamped, filtered.length, visibleRows);
|
|
1805
1991
|
const windowed = hits.slice(start, start + visibleRows);
|
|
1806
1992
|
useKeyboard((key) => {
|
|
@@ -1822,7 +2008,8 @@ function FilterableAgentList({ agents, scope, onPick }) {
|
|
|
1822
2008
|
}
|
|
1823
2009
|
} : {
|
|
1824
2010
|
kind: "conversation-picker",
|
|
1825
|
-
agent
|
|
2011
|
+
agent,
|
|
2012
|
+
initialQuery: ""
|
|
1826
2013
|
});
|
|
1827
2014
|
}
|
|
1828
2015
|
});
|
|
@@ -1832,7 +2019,10 @@ function FilterableAgentList({ agents, scope, onPick }) {
|
|
|
1832
2019
|
flexGrow: 1
|
|
1833
2020
|
},
|
|
1834
2021
|
children: [
|
|
1835
|
-
/* @__PURE__ */ jsx(BrandHeader, {
|
|
2022
|
+
/* @__PURE__ */ jsx(BrandHeader, {
|
|
2023
|
+
height,
|
|
2024
|
+
width
|
|
2025
|
+
}),
|
|
1836
2026
|
/* @__PURE__ */ jsxs("box", {
|
|
1837
2027
|
style: {
|
|
1838
2028
|
flexDirection: "row",
|
|
@@ -1954,7 +2144,54 @@ function AgentCreateScreen() {
|
|
|
1954
2144
|
cancelled = true;
|
|
1955
2145
|
};
|
|
1956
2146
|
}, [rest]);
|
|
2147
|
+
const openChat = (agent) => {
|
|
2148
|
+
goTo({
|
|
2149
|
+
kind: "chat",
|
|
2150
|
+
agent,
|
|
2151
|
+
conversation: {
|
|
2152
|
+
kind: "new",
|
|
2153
|
+
agentId: agent.id,
|
|
2154
|
+
nonce: crypto.randomUUID()
|
|
2155
|
+
}
|
|
2156
|
+
});
|
|
2157
|
+
};
|
|
2158
|
+
const acceptAlias = (agent, alias) => {
|
|
2159
|
+
try {
|
|
2160
|
+
const shell = detectShell();
|
|
2161
|
+
if (!shell) {
|
|
2162
|
+
openChat(agent);
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
const result = installAgentAlias({
|
|
2166
|
+
alias,
|
|
2167
|
+
agentId: agent.id,
|
|
2168
|
+
shell
|
|
2169
|
+
});
|
|
2170
|
+
setState({
|
|
2171
|
+
kind: "aliased",
|
|
2172
|
+
agent,
|
|
2173
|
+
message: `Added \`${result.alias}\`. ${aliasActivationHint(result)}`
|
|
2174
|
+
});
|
|
2175
|
+
} catch {
|
|
2176
|
+
openChat(agent);
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
1957
2179
|
useKeyboard((key) => {
|
|
2180
|
+
if (state.kind === "offer-alias") {
|
|
2181
|
+
if (key.name === "y") {
|
|
2182
|
+
acceptAlias(state.agent, state.alias);
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
if (key.name === "n" || key.name === "escape" || key.name === "return") {
|
|
2186
|
+
openChat(state.agent);
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
if (state.kind === "aliased") {
|
|
2192
|
+
openChat(state.agent);
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
1958
2195
|
if (key.name === "escape" && state.kind !== "creating") goTo({ kind: "agent-picker" });
|
|
1959
2196
|
});
|
|
1960
2197
|
const createAgent = async () => {
|
|
@@ -1970,15 +2207,13 @@ function AgentCreateScreen() {
|
|
|
1970
2207
|
setState({ kind: "creating" });
|
|
1971
2208
|
try {
|
|
1972
2209
|
const agent = await rest.createAgent({ name: result.name });
|
|
1973
|
-
|
|
1974
|
-
|
|
2210
|
+
const shell = detectShell();
|
|
2211
|
+
if (shell) setState({
|
|
2212
|
+
kind: "offer-alias",
|
|
1975
2213
|
agent,
|
|
1976
|
-
|
|
1977
|
-
kind: "new",
|
|
1978
|
-
agentId: agent.id,
|
|
1979
|
-
nonce: crypto.randomUUID()
|
|
1980
|
-
}
|
|
2214
|
+
alias: dedupeAliasName(slugifyAliasName(agent.name), takenAliasNames(shell))
|
|
1981
2215
|
});
|
|
2216
|
+
else openChat(agent);
|
|
1982
2217
|
} catch (err) {
|
|
1983
2218
|
if (err instanceof HttpError && err.status === 401) {
|
|
1984
2219
|
fail("Session expired. Run `skydive auth login --web`, then retry.");
|
|
@@ -2018,6 +2253,11 @@ function AgentCreateScreen() {
|
|
|
2018
2253
|
});
|
|
2019
2254
|
}
|
|
2020
2255
|
};
|
|
2256
|
+
if (state.kind === "offer-alias") return /* @__PURE__ */ jsx(AliasOffer, {
|
|
2257
|
+
agentName: state.agent.name,
|
|
2258
|
+
alias: state.alias
|
|
2259
|
+
});
|
|
2260
|
+
if (state.kind === "aliased") return /* @__PURE__ */ jsx(AliasResult, { message: state.message });
|
|
2021
2261
|
return /* @__PURE__ */ jsxs("box", {
|
|
2022
2262
|
style: {
|
|
2023
2263
|
flexDirection: "column",
|
|
@@ -2070,87 +2310,341 @@ function AgentCreateScreen() {
|
|
|
2070
2310
|
]
|
|
2071
2311
|
});
|
|
2072
2312
|
}
|
|
2313
|
+
/**
|
|
2314
|
+
* The post-create prompt offering to alias the agent's name to a shell
|
|
2315
|
+
* command. Presentational so the frame gallery can render it directly.
|
|
2316
|
+
*/
|
|
2317
|
+
function AliasOffer({ agentName, alias }) {
|
|
2318
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
2319
|
+
style: {
|
|
2320
|
+
flexDirection: "column",
|
|
2321
|
+
flexGrow: 1,
|
|
2322
|
+
gap: 1
|
|
2323
|
+
},
|
|
2324
|
+
children: [
|
|
2325
|
+
/* @__PURE__ */ jsxs("text", {
|
|
2326
|
+
fg: theme.fg,
|
|
2327
|
+
children: [
|
|
2328
|
+
"Created ",
|
|
2329
|
+
agentName,
|
|
2330
|
+
"."
|
|
2331
|
+
]
|
|
2332
|
+
}),
|
|
2333
|
+
/* @__PURE__ */ jsxs("text", {
|
|
2334
|
+
fg: theme.muted,
|
|
2335
|
+
children: [
|
|
2336
|
+
"Add a shell alias so you can run `",
|
|
2337
|
+
alias,
|
|
2338
|
+
"` to open this agent?"
|
|
2339
|
+
]
|
|
2340
|
+
}),
|
|
2341
|
+
/* @__PURE__ */ jsx("text", {
|
|
2342
|
+
fg: theme.dim,
|
|
2343
|
+
children: "y add alias · n skip"
|
|
2344
|
+
})
|
|
2345
|
+
]
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
/** The confirmation shown after an alias is written. */
|
|
2349
|
+
function AliasResult({ message }) {
|
|
2350
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
2351
|
+
style: {
|
|
2352
|
+
flexDirection: "column",
|
|
2353
|
+
flexGrow: 1,
|
|
2354
|
+
gap: 1
|
|
2355
|
+
},
|
|
2356
|
+
children: [/* @__PURE__ */ jsx("text", {
|
|
2357
|
+
fg: theme.fg,
|
|
2358
|
+
children: message
|
|
2359
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
2360
|
+
fg: theme.dim,
|
|
2361
|
+
children: "press any key to continue"
|
|
2362
|
+
})]
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2073
2365
|
|
|
2074
2366
|
//#endregion
|
|
2075
|
-
//#region src/chat/tui/screens/
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2367
|
+
//#region src/chat/tui/screens/first-run.tsx
|
|
2368
|
+
/** Rows the mark lockup needs before it's worth painting (matches the
|
|
2369
|
+
* new-chat splash): the 6-row mark, a spacer, and a couple of content rows.
|
|
2370
|
+
* Below this the mark is dropped and only the message renders. */
|
|
2371
|
+
const MARK_MIN_ROWS$1 = 12;
|
|
2372
|
+
/** Columns the full mark + wordmark lockup spans; below this the pinwheel
|
|
2373
|
+
* mark renders alone instead of wrapping the wordmark. */
|
|
2374
|
+
const FULL_MARK_MIN_COLS$1 = 70;
|
|
2375
|
+
function FirstRunScreen() {
|
|
2376
|
+
const rest = useStore((s) => s.rest);
|
|
2377
|
+
const goTo = useStore((s) => s.goTo);
|
|
2378
|
+
const fail = useStore((s) => s.fail);
|
|
2379
|
+
const authKind = useStore((s) => s.authKind);
|
|
2380
|
+
const forced = useStore((s) => s.forcedOnboarding);
|
|
2381
|
+
const [state, setState] = useState({ kind: "creating" });
|
|
2382
|
+
const canShareMachine = authKind === "session";
|
|
2081
2383
|
useEffect(() => {
|
|
2384
|
+
if (!rest) return;
|
|
2082
2385
|
let cancelled = false;
|
|
2083
2386
|
(async () => {
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
}), getActiveWorkspaceId({
|
|
2088
|
-
appUrl,
|
|
2089
|
-
sessionToken
|
|
2090
|
-
})]);
|
|
2091
|
-
if (cancelled) return;
|
|
2092
|
-
if (workspaces.isErr()) {
|
|
2387
|
+
try {
|
|
2388
|
+
const agent = await rest.createAgent({});
|
|
2389
|
+
if (cancelled) return;
|
|
2093
2390
|
setState({
|
|
2094
|
-
kind: "
|
|
2095
|
-
|
|
2391
|
+
kind: "offer",
|
|
2392
|
+
agent
|
|
2096
2393
|
});
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2394
|
+
} catch (err) {
|
|
2395
|
+
if (cancelled) return;
|
|
2396
|
+
if (err instanceof HttpError && err.status === 401) {
|
|
2397
|
+
fail("Session expired. Run `skydive auth login --web`, then retry.");
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2100
2400
|
setState({
|
|
2101
2401
|
kind: "error",
|
|
2102
|
-
message:
|
|
2402
|
+
message: createErrorMessage(err)
|
|
2103
2403
|
});
|
|
2104
|
-
return;
|
|
2105
2404
|
}
|
|
2106
|
-
setHighlight(Math.max(0, workspaces.value.findIndex((workspace) => workspace.id === activeId.value)));
|
|
2107
|
-
setState({
|
|
2108
|
-
kind: "ready",
|
|
2109
|
-
workspaces: workspaces.value,
|
|
2110
|
-
activeId: activeId.value
|
|
2111
|
-
});
|
|
2112
2405
|
})();
|
|
2113
2406
|
return () => {
|
|
2114
2407
|
cancelled = true;
|
|
2115
2408
|
};
|
|
2116
|
-
}, [
|
|
2117
|
-
const
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2409
|
+
}, [rest, fail]);
|
|
2410
|
+
const openNewChat = (agent) => {
|
|
2411
|
+
goTo({
|
|
2412
|
+
kind: "chat",
|
|
2413
|
+
agent,
|
|
2414
|
+
conversation: {
|
|
2415
|
+
kind: "new",
|
|
2416
|
+
agentId: agent.id,
|
|
2417
|
+
nonce: crypto.randomUUID()
|
|
2418
|
+
}
|
|
2419
|
+
});
|
|
2420
|
+
};
|
|
2421
|
+
const openOnboardingChat = (agent, conversationId, title) => {
|
|
2422
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2423
|
+
goTo({
|
|
2424
|
+
kind: "chat",
|
|
2425
|
+
agent,
|
|
2426
|
+
conversation: {
|
|
2427
|
+
id: conversationId,
|
|
2428
|
+
title,
|
|
2429
|
+
createdAt: now,
|
|
2430
|
+
updatedAt: now,
|
|
2431
|
+
preview: null,
|
|
2432
|
+
channel: "web",
|
|
2433
|
+
channelLabel: null,
|
|
2434
|
+
agent: {
|
|
2435
|
+
id: agent.id,
|
|
2436
|
+
name: agent.name,
|
|
2437
|
+
slug: agent.slug ?? null,
|
|
2438
|
+
title: agent.title ?? null
|
|
2141
2439
|
}
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2440
|
+
}
|
|
2441
|
+
});
|
|
2442
|
+
};
|
|
2443
|
+
const acceptImport = async (agent) => {
|
|
2444
|
+
if (!rest) return;
|
|
2445
|
+
setState({ kind: "starting" });
|
|
2446
|
+
const { portalClient } = useStore.getState();
|
|
2447
|
+
try {
|
|
2448
|
+
const os = machineOsFromPlatform(process.platform);
|
|
2449
|
+
const { conversationId } = await rest.createOnboardingConversation({
|
|
2450
|
+
agentId: agent.id,
|
|
2451
|
+
projectDir: process.cwd(),
|
|
2452
|
+
os
|
|
2453
|
+
});
|
|
2454
|
+
if (portalClient) {
|
|
2455
|
+
portalClient.enable();
|
|
2456
|
+
portalClient.grantAgent(agent.id, conversationId).catch(() => {
|
|
2457
|
+
useStore.getState().showToast({
|
|
2458
|
+
message: `Couldn't share this machine automatically. ${agent.name} will ask in chat.`,
|
|
2459
|
+
variant: "warning"
|
|
2460
|
+
});
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
openOnboardingChat(agent, conversationId, `Onboarding ${agent.name}`);
|
|
2464
|
+
} catch (err) {
|
|
2465
|
+
if (err instanceof HttpError && err.status === 401) {
|
|
2466
|
+
fail("Session expired. Run `skydive auth login --web`, then retry.");
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
useStore.getState().showToast({
|
|
2470
|
+
message: "Couldn't start onboarding automatically. Opening a chat instead.",
|
|
2471
|
+
variant: "warning"
|
|
2472
|
+
});
|
|
2473
|
+
openNewChat(agent);
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
const declineImport = (agent) => {
|
|
2477
|
+
setState({ kind: "starting" });
|
|
2478
|
+
openNewChat(agent);
|
|
2479
|
+
};
|
|
2480
|
+
useKeyboard((key) => {
|
|
2481
|
+
if (state.kind !== "offer") return;
|
|
2482
|
+
if (!canShareMachine) {
|
|
2483
|
+
declineImport(state.agent);
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
if (key.name === "return" || key.name === "y") acceptImport(state.agent);
|
|
2487
|
+
else if (key.name === "n" || key.name === "escape") declineImport(state.agent);
|
|
2488
|
+
});
|
|
2489
|
+
return /* @__PURE__ */ jsx(Centered, { children: renderBody({
|
|
2490
|
+
state,
|
|
2491
|
+
forced,
|
|
2492
|
+
canShareMachine
|
|
2493
|
+
}) });
|
|
2494
|
+
}
|
|
2495
|
+
/** The Skydive mark centered above the screen's content, matching the
|
|
2496
|
+
* new-chat splash lockup and degrading by available space. */
|
|
2497
|
+
function Centered({ children }) {
|
|
2498
|
+
const { width, height } = useTerminalDimensions();
|
|
2499
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
2500
|
+
style: {
|
|
2501
|
+
flexGrow: 1,
|
|
2502
|
+
flexDirection: "column",
|
|
2503
|
+
justifyContent: "center",
|
|
2504
|
+
alignItems: "center"
|
|
2505
|
+
},
|
|
2506
|
+
children: [height >= MARK_MIN_ROWS$1 ? /* @__PURE__ */ jsx("box", {
|
|
2507
|
+
style: {
|
|
2508
|
+
flexDirection: "column",
|
|
2509
|
+
alignItems: "center",
|
|
2510
|
+
marginBottom: 1
|
|
2511
|
+
},
|
|
2512
|
+
children: /* @__PURE__ */ jsx(SkydiveMark, { variant: width >= FULL_MARK_MIN_COLS$1 ? "full" : "mark" })
|
|
2513
|
+
}) : null, /* @__PURE__ */ jsx("box", {
|
|
2514
|
+
style: {
|
|
2515
|
+
flexDirection: "column",
|
|
2516
|
+
alignItems: "center",
|
|
2517
|
+
gap: 1
|
|
2518
|
+
},
|
|
2519
|
+
children
|
|
2520
|
+
})]
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
function renderBody({ state, forced, canShareMachine }) {
|
|
2524
|
+
if (state.kind === "error") return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("text", {
|
|
2525
|
+
fg: theme.error,
|
|
2526
|
+
children: state.message
|
|
2527
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
2528
|
+
fg: theme.muted,
|
|
2529
|
+
children: "press ctrl+c to quit, then run `skydive` again to retry."
|
|
2530
|
+
})] });
|
|
2531
|
+
if (state.kind === "creating") return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("text", {
|
|
2532
|
+
fg: theme.fg,
|
|
2533
|
+
children: "Welcome to Skydive."
|
|
2534
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
2535
|
+
fg: theme.muted,
|
|
2536
|
+
children: forced ? "creating an agent…" : "setting up your first agent…"
|
|
2537
|
+
})] });
|
|
2538
|
+
if (state.kind === "starting") return /* @__PURE__ */ jsx("text", {
|
|
2539
|
+
fg: theme.muted,
|
|
2540
|
+
children: "opening your agent…"
|
|
2541
|
+
});
|
|
2542
|
+
const { agent } = state;
|
|
2543
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("text", {
|
|
2544
|
+
fg: theme.fg,
|
|
2545
|
+
children: forced ? `${agent.name} is ready.` : `Your first agent, ${agent.name}, is ready.`
|
|
2546
|
+
}), canShareMachine ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("text", {
|
|
2547
|
+
fg: theme.muted,
|
|
2548
|
+
children: [
|
|
2549
|
+
"Want ",
|
|
2550
|
+
agent.name,
|
|
2551
|
+
" to learn how you already work? It can look at the coding agents set up on this machine to get up to speed fast. It will connect to your computer to read your setup, show you a plan first, and never copy credentials."
|
|
2552
|
+
]
|
|
2553
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
2554
|
+
fg: theme.dim,
|
|
2555
|
+
children: "↵/y yes, learn from my setup · n skip"
|
|
2556
|
+
})] }) : /* @__PURE__ */ jsx("text", {
|
|
2557
|
+
fg: theme.dim,
|
|
2558
|
+
children: "press any key to open the chat"
|
|
2559
|
+
})] });
|
|
2560
|
+
}
|
|
2561
|
+
function createErrorMessage(err) {
|
|
2562
|
+
if (err instanceof HttpError && err.status === 403) return "You don't have permission to create an agent in this workspace.";
|
|
2563
|
+
if (err instanceof HttpError && err.status === 429) return "Too many requests. Wait a moment, then run `skydive` again.";
|
|
2564
|
+
if (err instanceof HttpError) return "Skydive could not create your agent. Try again in a moment.";
|
|
2565
|
+
return "Could not reach Skydive. Check your connection and try again.";
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
//#endregion
|
|
2569
|
+
//#region src/chat/tui/screens/workspace-picker.tsx
|
|
2570
|
+
function WorkspacePicker({ appUrl, sessionToken, onSelect, onCancel }) {
|
|
2571
|
+
const { height } = useTerminalDimensions();
|
|
2572
|
+
const [state, setState] = useState({ kind: "loading" });
|
|
2573
|
+
const [highlight, setHighlight] = useState(0);
|
|
2574
|
+
const [switching, setSwitching] = useState(false);
|
|
2575
|
+
useEffect(() => {
|
|
2576
|
+
let cancelled = false;
|
|
2577
|
+
(async () => {
|
|
2578
|
+
const [workspaces, activeId] = await Promise.all([listWorkspaces({
|
|
2579
|
+
appUrl,
|
|
2580
|
+
sessionToken
|
|
2581
|
+
}), getActiveWorkspaceId({
|
|
2582
|
+
appUrl,
|
|
2583
|
+
sessionToken
|
|
2584
|
+
})]);
|
|
2585
|
+
if (cancelled) return;
|
|
2586
|
+
if (workspaces.isErr()) {
|
|
2587
|
+
setState({
|
|
2588
|
+
kind: "error",
|
|
2589
|
+
message: workspaces.error.message
|
|
2590
|
+
});
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
if (activeId.isErr()) {
|
|
2594
|
+
setState({
|
|
2595
|
+
kind: "error",
|
|
2596
|
+
message: activeId.error.message
|
|
2597
|
+
});
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
setHighlight(Math.max(0, workspaces.value.findIndex((workspace) => workspace.id === activeId.value)));
|
|
2601
|
+
setState({
|
|
2602
|
+
kind: "ready",
|
|
2603
|
+
workspaces: workspaces.value,
|
|
2604
|
+
activeId: activeId.value
|
|
2605
|
+
});
|
|
2606
|
+
})();
|
|
2607
|
+
return () => {
|
|
2608
|
+
cancelled = true;
|
|
2609
|
+
};
|
|
2610
|
+
}, [appUrl, sessionToken]);
|
|
2611
|
+
const rows = state.kind === "ready" ? state.workspaces : [];
|
|
2612
|
+
const clamped = Math.min(highlight, Math.max(0, rows.length - 1));
|
|
2613
|
+
useKeyboard((key) => {
|
|
2614
|
+
if (switching) return;
|
|
2615
|
+
if (key.name === "escape" || key.name === "c" && key.ctrl) onCancel();
|
|
2616
|
+
else if (key.name === "up") setHighlight(Math.max(0, clamped - 1));
|
|
2617
|
+
else if (key.name === "down") setHighlight(Math.min(rows.length - 1, clamped + 1));
|
|
2618
|
+
else if (key.name === "return") {
|
|
2619
|
+
const workspace = rows[clamped];
|
|
2620
|
+
if (!workspace) return;
|
|
2621
|
+
setSwitching(true);
|
|
2622
|
+
(async () => {
|
|
2623
|
+
const result = await setActiveWorkspace({
|
|
2624
|
+
appUrl,
|
|
2625
|
+
sessionToken,
|
|
2626
|
+
organizationId: workspace.id
|
|
2627
|
+
});
|
|
2628
|
+
if (result.isErr()) {
|
|
2629
|
+
setState({
|
|
2630
|
+
kind: "error",
|
|
2631
|
+
message: result.error.message
|
|
2632
|
+
});
|
|
2633
|
+
setSwitching(false);
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
onSelect(workspace);
|
|
2637
|
+
})();
|
|
2638
|
+
}
|
|
2639
|
+
});
|
|
2640
|
+
if (state.kind === "loading") return /* @__PURE__ */ jsx("text", {
|
|
2641
|
+
fg: theme.muted,
|
|
2642
|
+
children: "loading workspaces…"
|
|
2643
|
+
});
|
|
2644
|
+
if (state.kind === "error") return /* @__PURE__ */ jsxs("box", {
|
|
2645
|
+
style: { flexDirection: "column" },
|
|
2646
|
+
children: [/* @__PURE__ */ jsxs("text", {
|
|
2647
|
+
fg: theme.error,
|
|
2154
2648
|
children: ["error: ", state.message]
|
|
2155
2649
|
}), /* @__PURE__ */ jsx("text", {
|
|
2156
2650
|
fg: theme.dim,
|
|
@@ -2419,16 +2913,81 @@ function useDebouncedValue(value, delayMs) {
|
|
|
2419
2913
|
}
|
|
2420
2914
|
|
|
2421
2915
|
//#endregion
|
|
2422
|
-
//#region src/chat/tui/screens/conversation-
|
|
2423
|
-
|
|
2424
|
-
|
|
2916
|
+
//#region src/chat/tui/screens/conversation-channel.ts
|
|
2917
|
+
function isNewConversation(c) {
|
|
2918
|
+
return "kind" in c && c.kind === "new";
|
|
2919
|
+
}
|
|
2920
|
+
/**
|
|
2921
|
+
* The channels a human can start a conversation on. Enumerated, not derived,
|
|
2922
|
+
* to keep `@createinc/anyone-db` out of the CLI build (see rest.ts). A newly
|
|
2923
|
+
* added human channel needs adding here too. The picker filters its default
|
|
2924
|
+
* view to these; `isAgentRunConversation` is their complement.
|
|
2925
|
+
*/
|
|
2425
2926
|
const humanChannels = [
|
|
2426
2927
|
"web",
|
|
2427
2928
|
"slack",
|
|
2428
2929
|
"email",
|
|
2429
2930
|
"imessage"
|
|
2430
2931
|
];
|
|
2431
|
-
|
|
2932
|
+
/**
|
|
2933
|
+
* Whether a conversation is an agent run — a thread the agent opened for
|
|
2934
|
+
* itself (cron firing, inbound webhook wake) rather than one a person
|
|
2935
|
+
* started. These have no human participant row, so per-user archive can
|
|
2936
|
+
* never apply to them (there is nothing to mark); delete is their only
|
|
2937
|
+
* cleanup affordance, and the UI should not offer archive on them.
|
|
2938
|
+
*
|
|
2939
|
+
* Best-effort: an a2a/subagent thread carries a `web` channel binding, so it
|
|
2940
|
+
* reads as `'web'` here and is NOT detected. Archive on one of those still
|
|
2941
|
+
* fails server-side with the existing error notice — this predicate only
|
|
2942
|
+
* removes the affordance where the client can know it's dead.
|
|
2943
|
+
*/
|
|
2944
|
+
function isAgentRunConversation(c) {
|
|
2945
|
+
if (isNewConversation(c)) return false;
|
|
2946
|
+
if (c.channel === null) return false;
|
|
2947
|
+
const { channel } = c;
|
|
2948
|
+
return !humanChannels.some((human) => human === channel);
|
|
2949
|
+
}
|
|
2950
|
+
/**
|
|
2951
|
+
* The channel a conversation belongs to when it isn't the web channel, else
|
|
2952
|
+
* null. A brand-new conversation is always web, and the list route reports the
|
|
2953
|
+
* web channel as either `'web'` or (legacy) null — both mean "post from here".
|
|
2954
|
+
* Anything else (slack, email, imessage, webhook, cron) is owned by that
|
|
2955
|
+
* channel: the message has to go through it, so the TUI can't send here.
|
|
2956
|
+
* Mirrors the web app's `channel !== null && channel !== 'web'` gate.
|
|
2957
|
+
*/
|
|
2958
|
+
function nonWebChannelLabel(c) {
|
|
2959
|
+
if (isNewConversation(c)) return null;
|
|
2960
|
+
if (c.channel === null || c.channel === "web") return null;
|
|
2961
|
+
return c.channelLabel ?? c.channel;
|
|
2962
|
+
}
|
|
2963
|
+
/**
|
|
2964
|
+
* The list-row shape for a conversation the TUI just forked, so the chat
|
|
2965
|
+
* screen can reopen on the copy without waiting for a list refetch. A fork
|
|
2966
|
+
* carries none of the source's channel binding — that's what makes it
|
|
2967
|
+
* sendable from here — so it is always a web conversation.
|
|
2968
|
+
*/
|
|
2969
|
+
function forkedConversationRef({ agent, forked }) {
|
|
2970
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2971
|
+
return {
|
|
2972
|
+
id: forked.id,
|
|
2973
|
+
title: forked.title,
|
|
2974
|
+
createdAt: now,
|
|
2975
|
+
updatedAt: now,
|
|
2976
|
+
preview: null,
|
|
2977
|
+
channel: "web",
|
|
2978
|
+
channelLabel: null,
|
|
2979
|
+
agent: {
|
|
2980
|
+
id: agent.id,
|
|
2981
|
+
name: agent.name
|
|
2982
|
+
}
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
//#endregion
|
|
2987
|
+
//#region src/chat/tui/screens/conversation-picker.tsx
|
|
2988
|
+
const SEARCH_DEBOUNCE_MS = 250;
|
|
2989
|
+
const SEARCH_QUERY_MAX_LENGTH = 200;
|
|
2990
|
+
function ConversationPickerScreen({ agent, initialQuery }) {
|
|
2432
2991
|
const rest = useStore((s) => s.rest);
|
|
2433
2992
|
const goTo = useStore((s) => s.goTo);
|
|
2434
2993
|
const [showAutomated, setShowAutomated] = useState(false);
|
|
@@ -2492,13 +3051,16 @@ function ConversationPickerScreen({ agent }) {
|
|
|
2492
3051
|
cacheKey,
|
|
2493
3052
|
cache
|
|
2494
3053
|
]);
|
|
2495
|
-
const { height } = useTerminalDimensions();
|
|
3054
|
+
const { width, height } = useTerminalDimensions();
|
|
2496
3055
|
if (state.kind === "loading") return /* @__PURE__ */ jsxs("box", {
|
|
2497
3056
|
style: {
|
|
2498
3057
|
flexDirection: "column",
|
|
2499
3058
|
flexGrow: 1
|
|
2500
3059
|
},
|
|
2501
|
-
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
3060
|
+
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
3061
|
+
height,
|
|
3062
|
+
width
|
|
3063
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
2502
3064
|
fg: theme.muted,
|
|
2503
3065
|
children: "loading conversations…"
|
|
2504
3066
|
})]
|
|
@@ -2508,13 +3070,17 @@ function ConversationPickerScreen({ agent }) {
|
|
|
2508
3070
|
flexDirection: "column",
|
|
2509
3071
|
flexGrow: 1
|
|
2510
3072
|
},
|
|
2511
|
-
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
3073
|
+
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
3074
|
+
height,
|
|
3075
|
+
width
|
|
3076
|
+
}), /* @__PURE__ */ jsxs("text", {
|
|
2512
3077
|
fg: theme.error,
|
|
2513
3078
|
children: ["error: ", state.message]
|
|
2514
3079
|
})]
|
|
2515
3080
|
});
|
|
2516
3081
|
return /* @__PURE__ */ jsx(ConversationList, {
|
|
2517
3082
|
agent,
|
|
3083
|
+
initialQuery,
|
|
2518
3084
|
conversations: state.conversations,
|
|
2519
3085
|
complete: state.complete,
|
|
2520
3086
|
showAutomated,
|
|
@@ -2524,10 +3090,10 @@ function ConversationPickerScreen({ agent }) {
|
|
|
2524
3090
|
onPick: goTo
|
|
2525
3091
|
});
|
|
2526
3092
|
}
|
|
2527
|
-
function ConversationList({ agent, conversations, complete, showAutomated, onToggleAutomated, showArchived, onToggleArchived, onPick }) {
|
|
3093
|
+
function ConversationList({ agent, initialQuery, conversations, complete, showAutomated, onToggleAutomated, showArchived, onToggleArchived, onPick }) {
|
|
2528
3094
|
const { width, height } = useTerminalDimensions();
|
|
2529
3095
|
const rest = useStore((s) => s.rest);
|
|
2530
|
-
const [query, setQuery] = useState(
|
|
3096
|
+
const [query, setQuery] = useState(initialQuery);
|
|
2531
3097
|
const [highlight, setHighlight] = useState(0);
|
|
2532
3098
|
const debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
|
|
2533
3099
|
const isSearching = debouncedQuery.length > 0;
|
|
@@ -2615,6 +3181,7 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
|
|
|
2615
3181
|
const clamped = Math.min(highlight, Math.max(0, rows.length - 1));
|
|
2616
3182
|
const highlightRow = rows[clamped];
|
|
2617
3183
|
const highlightArchived = highlightRow?.kind === "conv" && isArchived(highlightRow.hit.item);
|
|
3184
|
+
const highlightAgentRun = highlightRow?.kind === "conv" && isAgentRunConversation(highlightRow.hit.item);
|
|
2618
3185
|
const confirmConv = confirmId !== null ? list.find((c) => c.id === confirmId) : void 0;
|
|
2619
3186
|
const searchState = isSearching ? search?.state : void 0;
|
|
2620
3187
|
const countSegment = isSearching && (searchState?.kind === "loading" || search === null) ? "searching…" : isSearching && searchState?.kind === "error" ? `search failed: ${searchState.message}` : `${hits.length}/${list.length}${complete || isSearching ? "" : " (loading…)"}`;
|
|
@@ -2628,7 +3195,7 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
|
|
|
2628
3195
|
fg: theme.dim,
|
|
2629
3196
|
text: fitHintLine([
|
|
2630
3197
|
countSegment,
|
|
2631
|
-
`ctrl+a ${highlightArchived ? "unarchive" : "archive"}
|
|
3198
|
+
...highlightAgentRun ? [] : [`ctrl+a ${highlightArchived ? "unarchive" : "archive"}`],
|
|
2632
3199
|
"ctrl+d delete",
|
|
2633
3200
|
`tab ${showAutomated ? "hide" : "show"} agent runs`,
|
|
2634
3201
|
`shift+tab ${showArchived ? "hide" : "show"} archived`,
|
|
@@ -2637,7 +3204,7 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
|
|
|
2637
3204
|
"↵ open"
|
|
2638
3205
|
], innerWidth)
|
|
2639
3206
|
};
|
|
2640
|
-
const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height));
|
|
3207
|
+
const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height, width));
|
|
2641
3208
|
const start = windowStart(clamped, rows.length, visibleRows);
|
|
2642
3209
|
const windowed = rows.slice(start, start + visibleRows);
|
|
2643
3210
|
const open = (row) => {
|
|
@@ -2722,6 +3289,10 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
|
|
|
2722
3289
|
} else if (key.name === "a" && key.ctrl) {
|
|
2723
3290
|
const row = rows[clamped];
|
|
2724
3291
|
if (row?.kind === "conv") {
|
|
3292
|
+
if (isAgentRunConversation(row.hit.item)) {
|
|
3293
|
+
setError("agent runs can't be archived — ctrl+d deletes");
|
|
3294
|
+
return;
|
|
3295
|
+
}
|
|
2725
3296
|
setError(null);
|
|
2726
3297
|
toggleArchived(row.hit.item);
|
|
2727
3298
|
}
|
|
@@ -2738,7 +3309,10 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
|
|
|
2738
3309
|
flexGrow: 1
|
|
2739
3310
|
},
|
|
2740
3311
|
children: [
|
|
2741
|
-
/* @__PURE__ */ jsx(BrandHeader, {
|
|
3312
|
+
/* @__PURE__ */ jsx(BrandHeader, {
|
|
3313
|
+
height,
|
|
3314
|
+
width
|
|
3315
|
+
}),
|
|
2742
3316
|
/* @__PURE__ */ jsxs("box", {
|
|
2743
3317
|
style: {
|
|
2744
3318
|
flexDirection: "row",
|
|
@@ -2755,6 +3329,7 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
|
|
|
2755
3329
|
focused: confirmId === null,
|
|
2756
3330
|
width: Math.max(10, width - 12),
|
|
2757
3331
|
maxLength: SEARCH_QUERY_MAX_LENGTH,
|
|
3332
|
+
value: query,
|
|
2758
3333
|
onInput: (value) => {
|
|
2759
3334
|
setQuery(value.slice(0, SEARCH_QUERY_MAX_LENGTH));
|
|
2760
3335
|
setHighlight(0);
|
|
@@ -2779,15 +3354,20 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
|
|
|
2779
3354
|
children: [marker, truncate("+ new conversation", rowWidth)]
|
|
2780
3355
|
}, "new");
|
|
2781
3356
|
const archived = isArchived(row.hit.item);
|
|
3357
|
+
const unread = row.hit.item.unread === true;
|
|
2782
3358
|
const { title, detail, tag } = fitRowText(singleLine(row.hit.item.title ?? "(untitled)"), `${archived ? "archived · " : ""}${previewLine(row.hit.item)}`, rowWidth, coAgentsTag(row.hit.item, agent.id));
|
|
3359
|
+
const titleText = /* @__PURE__ */ jsx(FuzzyText, {
|
|
3360
|
+
text: title,
|
|
3361
|
+
indexes: row.hit.item.title ? row.hit.highlights[0] : null
|
|
3362
|
+
});
|
|
2783
3363
|
return /* @__PURE__ */ jsxs("text", {
|
|
2784
3364
|
fg: archived && !selected ? theme.dim : fg,
|
|
2785
3365
|
children: [
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
}),
|
|
3366
|
+
!selected && unread ? /* @__PURE__ */ jsx("span", {
|
|
3367
|
+
fg: theme.accent,
|
|
3368
|
+
children: "● "
|
|
3369
|
+
}) : marker,
|
|
3370
|
+
unread ? /* @__PURE__ */ jsx("b", { children: titleText }) : titleText,
|
|
2791
3371
|
tag ? /* @__PURE__ */ jsxs("span", {
|
|
2792
3372
|
fg: theme.muted,
|
|
2793
3373
|
children: [" ", tag]
|
|
@@ -4169,328 +4749,547 @@ function parseMatches(output) {
|
|
|
4169
4749
|
return null;
|
|
4170
4750
|
}
|
|
4171
4751
|
|
|
4752
|
+
//#endregion
|
|
4753
|
+
//#region src/chat/tui/chunks/shell-local.tsx
|
|
4754
|
+
/**
|
|
4755
|
+
* Renders a `! <cmd>` local-shell escape: the command the user ran on their own
|
|
4756
|
+
* machine, followed by its combined stdout/stderr. Visually distinct from agent
|
|
4757
|
+
* tool calls (which run in the sandbox) — a `!` sigil colored by outcome (dim
|
|
4758
|
+
* while running, success/error once done), so a glance tells you this executed
|
|
4759
|
+
* locally, not on the agent.
|
|
4760
|
+
*/
|
|
4761
|
+
function ShellLocalItem({ item }) {
|
|
4762
|
+
const running = item.exit === null;
|
|
4763
|
+
const failed = item.exit !== null && item.exit !== 0;
|
|
4764
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
4765
|
+
style: { flexDirection: "column" },
|
|
4766
|
+
children: [/* @__PURE__ */ jsxs("text", { children: [
|
|
4767
|
+
/* @__PURE__ */ jsx("span", {
|
|
4768
|
+
fg: running ? theme.dim : failed ? theme.error : theme.success,
|
|
4769
|
+
children: "! "
|
|
4770
|
+
}),
|
|
4771
|
+
/* @__PURE__ */ jsx("span", {
|
|
4772
|
+
fg: theme.tool,
|
|
4773
|
+
children: item.command
|
|
4774
|
+
}),
|
|
4775
|
+
running ? /* @__PURE__ */ jsx("span", {
|
|
4776
|
+
fg: theme.dim,
|
|
4777
|
+
children: " · running…"
|
|
4778
|
+
}) : null,
|
|
4779
|
+
failed ? /* @__PURE__ */ jsxs("span", {
|
|
4780
|
+
fg: theme.error,
|
|
4781
|
+
children: [" · exit ", item.exit]
|
|
4782
|
+
}) : null
|
|
4783
|
+
] }), item.output ? /* @__PURE__ */ jsx(Indented, { children: /* @__PURE__ */ jsx("text", {
|
|
4784
|
+
fg: theme.muted,
|
|
4785
|
+
children: item.output.replace(/\n+$/, "")
|
|
4786
|
+
}) }) : null]
|
|
4787
|
+
});
|
|
4788
|
+
}
|
|
4789
|
+
|
|
4172
4790
|
//#endregion
|
|
4173
4791
|
//#region src/chat/tui/chat/conversation-context.tsx
|
|
4174
4792
|
const ConversationContext = createContext({
|
|
4175
4793
|
rest: null,
|
|
4176
|
-
conversationId: null
|
|
4794
|
+
conversationId: null,
|
|
4795
|
+
collectSandboxStats: null,
|
|
4796
|
+
latestComputerId: null,
|
|
4797
|
+
scrolledUp: false
|
|
4177
4798
|
});
|
|
4178
4799
|
function useConversationContext() {
|
|
4179
4800
|
return useContext(ConversationContext);
|
|
4180
4801
|
}
|
|
4181
4802
|
|
|
4182
4803
|
//#endregion
|
|
4183
|
-
//#region src/chat/tui/
|
|
4184
|
-
/** Braille dot-cycle frames — the classic smooth terminal spinner. */
|
|
4185
|
-
const SPINNER_FRAMES = [
|
|
4186
|
-
"⠋",
|
|
4187
|
-
"⠙",
|
|
4188
|
-
"⠹",
|
|
4189
|
-
"⠸",
|
|
4190
|
-
"⠼",
|
|
4191
|
-
"⠴",
|
|
4192
|
-
"⠦",
|
|
4193
|
-
"⠧",
|
|
4194
|
-
"⠇",
|
|
4195
|
-
"⠏"
|
|
4196
|
-
];
|
|
4197
|
-
/** Milliseconds between frames. ~12.5fps reads as smooth without thrashing. */
|
|
4198
|
-
const SPINNER_INTERVAL_MS = 80;
|
|
4199
|
-
/** Frame for a given tick count. Wraps at the end of the cycle; negative-safe. */
|
|
4200
|
-
function spinnerFrame(tick) {
|
|
4201
|
-
const n = SPINNER_FRAMES.length;
|
|
4202
|
-
return SPINNER_FRAMES[(Math.trunc(tick) % n + n) % n] ?? SPINNER_FRAMES[0];
|
|
4203
|
-
}
|
|
4204
|
-
|
|
4205
|
-
//#endregion
|
|
4206
|
-
//#region src/chat/tui/spinner.tsx
|
|
4804
|
+
//#region src/chat/tui/chat/sandbox-stats.ts
|
|
4207
4805
|
/**
|
|
4208
|
-
*
|
|
4209
|
-
*
|
|
4210
|
-
*
|
|
4211
|
-
*
|
|
4806
|
+
* `/computer`: a live snapshot of the agent's sandbox as a set of bar graphs.
|
|
4807
|
+
*
|
|
4808
|
+
* The CLI runs on the USER's machine, so it can't read the sandbox's `/proc`
|
|
4809
|
+
* directly — it runs one small, portable shell command over the sandbox-exec
|
|
4810
|
+
* stream (`SandboxStream`, mode `exec`) and parses the output here. Everything
|
|
4811
|
+
* in this module is pure so the parse + layout are unit-testable without a
|
|
4812
|
+
* sandbox or a renderer.
|
|
4813
|
+
*
|
|
4814
|
+
* The command reads `/proc` and `df` only (no tools that might be missing from
|
|
4815
|
+
* a minimal image), emitting a tagged, tab-separated block. The leading
|
|
4816
|
+
* `SKYDIVE_STATS_V1` sentinel lets the parser find the block even when a shell
|
|
4817
|
+
* rc file prints a banner first, and versions the wire shape.
|
|
4212
4818
|
*/
|
|
4213
|
-
|
|
4214
|
-
const [tick, setTick] = useState(0);
|
|
4215
|
-
useEffect(() => {
|
|
4216
|
-
const id = setInterval(() => setTick((t) => t + 1), SPINNER_INTERVAL_MS);
|
|
4217
|
-
return () => clearInterval(id);
|
|
4218
|
-
}, []);
|
|
4219
|
-
return /* @__PURE__ */ jsx("span", {
|
|
4220
|
-
fg: color,
|
|
4221
|
-
children: spinnerFrame(tick)
|
|
4222
|
-
});
|
|
4223
|
-
}
|
|
4224
|
-
|
|
4225
|
-
//#endregion
|
|
4226
|
-
//#region src/chat/tui/chat/subagents.ts
|
|
4227
|
-
/** A run the platform hasn't settled yet — still queued or mid-run. */
|
|
4228
|
-
function isLiveStatus(status) {
|
|
4229
|
-
return status === "queued" || status === "running";
|
|
4230
|
-
}
|
|
4231
|
-
function toTaskStatus(raw) {
|
|
4232
|
-
switch (raw) {
|
|
4233
|
-
case "queued":
|
|
4234
|
-
case "running":
|
|
4235
|
-
case "completed":
|
|
4236
|
-
case "failed":
|
|
4237
|
-
case "canceled": return raw;
|
|
4238
|
-
default: return "queued";
|
|
4239
|
-
}
|
|
4240
|
-
}
|
|
4241
|
-
const FALLBACK_TITLE = "Subagent task";
|
|
4819
|
+
const SANDBOX_STATS_SENTINEL = "SKYDIVE_STATS_V1";
|
|
4242
4820
|
/**
|
|
4243
|
-
*
|
|
4244
|
-
*
|
|
4245
|
-
*
|
|
4246
|
-
* matches the fan-out width. Mirrors the web client's `subagentTaskTitles`.
|
|
4821
|
+
* How often the live `/computer` card refreshes its snapshot, in ms.
|
|
4822
|
+
* Deliberately slow: the card is a background health readout, not a `top`, so
|
|
4823
|
+
* it refreshes quietly every 30s rather than flickering every second.
|
|
4247
4824
|
*/
|
|
4248
|
-
|
|
4249
|
-
if (!isRecord(input)) return [];
|
|
4250
|
-
const tasks = input["tasks"];
|
|
4251
|
-
if (!Array.isArray(tasks)) return [];
|
|
4252
|
-
return tasks.map((task) => {
|
|
4253
|
-
if (!isRecord(task)) return FALLBACK_TITLE;
|
|
4254
|
-
const title = task["title"];
|
|
4255
|
-
return typeof title === "string" && title.trim() ? title.trim() : FALLBACK_TITLE;
|
|
4256
|
-
});
|
|
4257
|
-
}
|
|
4258
|
-
const QUEUED_TASK_ID_LINE = /^\s*-\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):\s/i;
|
|
4825
|
+
const COMPUTER_POLL_INTERVAL_MS = 3e4;
|
|
4259
4826
|
/**
|
|
4260
|
-
*
|
|
4261
|
-
*
|
|
4262
|
-
*
|
|
4263
|
-
*
|
|
4827
|
+
* Whether a `/computer` card should refresh right now. Pure so the gating is
|
|
4828
|
+
* unit-testable. A card refreshes only when it is the most recent `/computer`
|
|
4829
|
+
* in the transcript (`isLatest`), the session can reach the sandbox
|
|
4830
|
+
* (`canCollect`), and the transcript is at the bottom (`!scrolledUp`) — while
|
|
4831
|
+
* you're scrolled up reading something else it quietly stops (no wasted polls),
|
|
4832
|
+
* and resumes when you scroll back down. Older cards freeze regardless, so a
|
|
4833
|
+
* scrolled-up history of runs isn't a fleet of background pollers. The pause is
|
|
4834
|
+
* silent: nothing about it is rendered.
|
|
4264
4835
|
*/
|
|
4265
|
-
function
|
|
4266
|
-
|
|
4267
|
-
if (!text) return [];
|
|
4268
|
-
return text.split("\n").map((line) => line.match(QUEUED_TASK_ID_LINE)?.[1] ?? null).filter((id) => id !== null);
|
|
4836
|
+
function shouldPollComputer(opts) {
|
|
4837
|
+
return opts.isLatest && opts.canCollect && !opts.scrolledUp;
|
|
4269
4838
|
}
|
|
4270
4839
|
/**
|
|
4271
|
-
*
|
|
4272
|
-
*
|
|
4273
|
-
*
|
|
4274
|
-
*
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4840
|
+
* The command run in the sandbox to collect the snapshot. `sh`-portable, reads
|
|
4841
|
+
* only `/proc` and `df`. Each metric is one `key<TAB>value` line so the parser
|
|
4842
|
+
* never has to positionally align anything.
|
|
4843
|
+
*
|
|
4844
|
+
* The `port` lines enumerate LISTEN sockets the sandbox user's own processes
|
|
4845
|
+
* hold: one awk pass maps socket inodes from /proc/net/tcp{,6} to pids via
|
|
4846
|
+
* /proc/<pid>/fd, then a shell loop attaches each pid's cmdline. Platform
|
|
4847
|
+
* daemons run as root, so their /proc/<pid>/fd is unreadable here and they
|
|
4848
|
+
* never emit a line — the "user's servers only" filter falls out of
|
|
4849
|
+
* permissions rather than a curated list. Hex address:port is left for the
|
|
4850
|
+
* parser to decode (strtonum is gawk-only).
|
|
4851
|
+
*/
|
|
4852
|
+
const SANDBOX_STATS_COMMAND = [
|
|
4853
|
+
`printf '${SANDBOX_STATS_SENTINEL}\\n'`,
|
|
4854
|
+
`printf 'nproc\\t%s\\n' "$(nproc 2>/dev/null || echo 1)"`,
|
|
4855
|
+
`printf 'loadavg\\t%s\\n' "$(cut -d' ' -f1-3 /proc/loadavg)"`,
|
|
4856
|
+
`printf 'uptime\\t%s\\n' "$(cut -d' ' -f1 /proc/uptime)"`,
|
|
4857
|
+
`awk '/^MemTotal:/{t=$2}/^MemAvailable:/{a=$2}END{printf "mem_total_kb\\t%d\\nmem_avail_kb\\t%d\\n",t,a}' /proc/meminfo`,
|
|
4858
|
+
`awk '/^SwapTotal:/{t=$2}/^SwapFree:/{f=$2}END{printf "swap_total_kb\\t%d\\nswap_free_kb\\t%d\\n",t,f}' /proc/meminfo`,
|
|
4859
|
+
`df -Pk / | awk 'NR==2{printf "disk_total_kb\\t%d\\ndisk_used_kb\\t%d\\n",$2,$3}'`,
|
|
4860
|
+
`printf 'procs\\t%s\\n' "$(ls -1 /proc 2>/dev/null | grep -c '^[0-9]\\+$')"`,
|
|
4861
|
+
`ls -l /proc/[0-9]*/fd 2>/dev/null | awk 'BEGIN { while ((getline l < "/proc/net/tcp") > 0) { split(l,f," "); if (f[4]=="0A") want[f[10]]=f[2] } close("/proc/net/tcp"); while ((getline l < "/proc/net/tcp6") > 0) { split(l,f," "); if (f[4]=="0A") want[f[10]]=f[2] } close("/proc/net/tcp6") } $0 ~ "^/proc/[0-9]+/fd:$" { pid=substr($0,7,length($0)-10) } match($0, /socket:\\[[0-9]+\\]/) { ino=substr($0,RSTART+8,RLENGTH-9); if (ino in want && !(ino in done)) { done[ino]=1; print want[ino] "\\t" pid } }' | while read addr pid; do cl=$(head -c 200 "/proc/$pid/cmdline" 2>/dev/null | tr "\\0" " "); printf 'port\\t%s\\t%s\\t%s\\n' "$addr" "$pid" "$cl"; done`
|
|
4862
|
+
].join(" ; ");
|
|
4863
|
+
const KB = 1024;
|
|
4864
|
+
const HIDDEN_PORTS = new Set([
|
|
4865
|
+
38994,
|
|
4866
|
+
49983,
|
|
4867
|
+
38996
|
|
4868
|
+
]);
|
|
4869
|
+
const V6_LOOPBACK_HEX = "00000000000000000000000001000000";
|
|
4870
|
+
/**
|
|
4871
|
+
* Whether a /proc/net/tcp{,6} local-address hex field is a loopback bind.
|
|
4872
|
+
* v4 words are little-endian, so 127.0.0.1 is `0100007F` — the first octet
|
|
4873
|
+
* (127) is the LAST byte pair. Covers v4-mapped v6 (`::ffff:127.x.y.z`) via
|
|
4874
|
+
* the same tail check.
|
|
4875
|
+
*/
|
|
4876
|
+
function isLoopbackHex(addrHex) {
|
|
4877
|
+
const hex = addrHex.toUpperCase();
|
|
4878
|
+
if (hex.length === 8) return hex.slice(6) === "7F";
|
|
4879
|
+
if (hex.length === 32) {
|
|
4880
|
+
if (hex === V6_LOOPBACK_HEX) return true;
|
|
4881
|
+
return hex.startsWith("0000000000000000FFFF0000") && hex.slice(30) === "7F";
|
|
4882
|
+
}
|
|
4883
|
+
return false;
|
|
4884
|
+
}
|
|
4885
|
+
const RUNTIME_NAMES = new Set([
|
|
4886
|
+
"node",
|
|
4887
|
+
"bun",
|
|
4888
|
+
"deno",
|
|
4889
|
+
"python",
|
|
4890
|
+
"python3",
|
|
4891
|
+
"sh",
|
|
4892
|
+
"bash"
|
|
4893
|
+
]);
|
|
4894
|
+
const FLAGS_WITH_VALUE = new Set([
|
|
4895
|
+
"--require",
|
|
4896
|
+
"-r",
|
|
4897
|
+
"--import",
|
|
4898
|
+
"--loader",
|
|
4899
|
+
"--experimental-loader"
|
|
4900
|
+
]);
|
|
4901
|
+
/**
|
|
4902
|
+
* A short display name from a raw cmdline: basename of argv[0], plus — when
|
|
4903
|
+
* argv[0] is a bare runtime — the basename of the first non-flag argument with
|
|
4904
|
+
* its extension dropped (`node …/vite/bin/vite.js` → `node vite`).
|
|
4905
|
+
*/
|
|
4906
|
+
function processDisplayName(cmdline) {
|
|
4907
|
+
const argv = cmdline.trim().split(/\s+/).filter(Boolean);
|
|
4908
|
+
const first = argv[0];
|
|
4909
|
+
if (first === void 0) return null;
|
|
4910
|
+
const base = (p) => p.split("/").pop() ?? p;
|
|
4911
|
+
const name = base(first);
|
|
4912
|
+
if (!RUNTIME_NAMES.has(name)) return name;
|
|
4913
|
+
let script;
|
|
4914
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
4915
|
+
const arg = argv[i];
|
|
4916
|
+
if (arg === void 0) break;
|
|
4917
|
+
if (FLAGS_WITH_VALUE.has(arg)) {
|
|
4918
|
+
i += 1;
|
|
4919
|
+
continue;
|
|
4286
4920
|
}
|
|
4287
|
-
if (
|
|
4288
|
-
|
|
4289
|
-
|
|
4921
|
+
if (arg.startsWith("-")) continue;
|
|
4922
|
+
script = arg;
|
|
4923
|
+
break;
|
|
4924
|
+
}
|
|
4925
|
+
if (!script) return name;
|
|
4926
|
+
return `${name} ${base(script).replace(/\.(?:mjs|cjs|js|ts|tsx|py)$/, "")}`;
|
|
4927
|
+
}
|
|
4928
|
+
/**
|
|
4929
|
+
* Fold the collector's `port` lines (one per listen socket) into one entry
|
|
4930
|
+
* per port: v4/v6 binds of the same server dedupe, `loopbackOnly` only when
|
|
4931
|
+
* every bind is loopback, and the hidden platform ports drop out.
|
|
4932
|
+
*/
|
|
4933
|
+
function parsePortLines(lines) {
|
|
4934
|
+
const byPort = /* @__PURE__ */ new Map();
|
|
4935
|
+
for (const line of lines) {
|
|
4936
|
+
const port = Number.parseInt(line.portHex, 16);
|
|
4937
|
+
if (!Number.isFinite(port) || port <= 0 || HIDDEN_PORTS.has(port)) continue;
|
|
4938
|
+
const entry = byPort.get(port);
|
|
4939
|
+
if (entry) entry.loopback.push(isLoopbackHex(line.addrHex));
|
|
4940
|
+
else byPort.set(port, {
|
|
4941
|
+
loopback: [isLoopbackHex(line.addrHex)],
|
|
4942
|
+
cmdline: line.cmdline
|
|
4943
|
+
});
|
|
4944
|
+
}
|
|
4945
|
+
return [...byPort.entries()].map(([port, entry]) => ({
|
|
4946
|
+
port,
|
|
4947
|
+
loopbackOnly: entry.loopback.every(Boolean),
|
|
4948
|
+
process: entry.cmdline ? processDisplayName(entry.cmdline) : null
|
|
4949
|
+
})).sort((a, b) => a.port - b.port);
|
|
4950
|
+
}
|
|
4951
|
+
/**
|
|
4952
|
+
* Parse the collector's output into `SandboxStats`, or return null when the
|
|
4953
|
+
* block is absent or malformed (so the caller can show the raw output as an
|
|
4954
|
+
* error instead of a broken graph). Tolerant of extra lines before the
|
|
4955
|
+
* sentinel (shell banners) and of a missing swap section (swap totals default
|
|
4956
|
+
* to 0, which the renderer treats as "no swap").
|
|
4957
|
+
*/
|
|
4958
|
+
function parseSandboxStats(raw) {
|
|
4959
|
+
const start = raw.indexOf(SANDBOX_STATS_SENTINEL);
|
|
4960
|
+
if (start === -1) return null;
|
|
4961
|
+
const fields = /* @__PURE__ */ new Map();
|
|
4962
|
+
const portLines = [];
|
|
4963
|
+
for (const line of raw.slice(start).split("\n")) {
|
|
4964
|
+
const tab = line.indexOf(" ");
|
|
4965
|
+
if (tab === -1) continue;
|
|
4966
|
+
const key = line.slice(0, tab).trim();
|
|
4967
|
+
if (key === "port") {
|
|
4968
|
+
const parts = line.split(" ");
|
|
4969
|
+
const [addrHex = "", portHex = ""] = (parts[1] ?? "").split(":");
|
|
4970
|
+
if (addrHex && portHex) portLines.push({
|
|
4971
|
+
addrHex,
|
|
4972
|
+
portHex,
|
|
4973
|
+
cmdline: parts.slice(3).join(" ").trim()
|
|
4974
|
+
});
|
|
4975
|
+
continue;
|
|
4290
4976
|
}
|
|
4291
|
-
|
|
4977
|
+
fields.set(key, line.slice(tab + 1).trim());
|
|
4978
|
+
}
|
|
4979
|
+
const num = (key) => {
|
|
4980
|
+
const v = fields.get(key);
|
|
4981
|
+
if (v === void 0) return null;
|
|
4982
|
+
const n = Number(v);
|
|
4983
|
+
return Number.isFinite(n) ? n : null;
|
|
4984
|
+
};
|
|
4985
|
+
const load = (fields.get("loadavg") ?? "").split(/\s+/).map(Number).filter((n) => Number.isFinite(n));
|
|
4986
|
+
const nproc = num("nproc");
|
|
4987
|
+
const uptime = num("uptime");
|
|
4988
|
+
const memTotal = num("mem_total_kb");
|
|
4989
|
+
const memAvail = num("mem_avail_kb");
|
|
4990
|
+
const diskTotal = num("disk_total_kb");
|
|
4991
|
+
const diskUsed = num("disk_used_kb");
|
|
4992
|
+
const procs = num("procs");
|
|
4993
|
+
const [load1, load5, load15] = load;
|
|
4994
|
+
if (nproc === null || uptime === null || memTotal === null || memAvail === null || diskTotal === null || diskUsed === null || procs === null || load1 === void 0 || load5 === void 0 || load15 === void 0) return null;
|
|
4995
|
+
return {
|
|
4996
|
+
nproc: Math.max(1, Math.round(nproc)),
|
|
4997
|
+
loadavg: [
|
|
4998
|
+
load1,
|
|
4999
|
+
load5,
|
|
5000
|
+
load15
|
|
5001
|
+
],
|
|
5002
|
+
uptimeSeconds: uptime,
|
|
5003
|
+
memTotalBytes: memTotal * KB,
|
|
5004
|
+
memAvailBytes: memAvail * KB,
|
|
5005
|
+
swapTotalBytes: (num("swap_total_kb") ?? 0) * KB,
|
|
5006
|
+
swapFreeBytes: (num("swap_free_kb") ?? 0) * KB,
|
|
5007
|
+
diskTotalBytes: diskTotal * KB,
|
|
5008
|
+
diskUsedBytes: diskUsed * KB,
|
|
5009
|
+
procs: Math.round(procs),
|
|
5010
|
+
ports: parsePortLines(portLines)
|
|
4292
5011
|
};
|
|
4293
|
-
visit(value, 0);
|
|
4294
|
-
return parts.join("\n");
|
|
4295
5012
|
}
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
5013
|
+
function severityFor(fraction) {
|
|
5014
|
+
if (fraction >= .9) return "crit";
|
|
5015
|
+
if (fraction >= .75) return "warn";
|
|
5016
|
+
return "ok";
|
|
5017
|
+
}
|
|
5018
|
+
const GiB = 1024 * 1024 * 1024;
|
|
5019
|
+
function gb(bytes) {
|
|
5020
|
+
return (bytes / GiB).toFixed(1);
|
|
5021
|
+
}
|
|
5022
|
+
/** `120050` seconds -> `1d 9h 20m`. Always at least `0m`. */
|
|
5023
|
+
function formatUptime(seconds) {
|
|
5024
|
+
const total = Math.max(0, Math.floor(seconds));
|
|
5025
|
+
const d = Math.floor(total / 86400);
|
|
5026
|
+
const h = Math.floor(total % 86400 / 3600);
|
|
5027
|
+
const m = Math.floor(total % 3600 / 60);
|
|
5028
|
+
const parts = [];
|
|
5029
|
+
if (d > 0) parts.push(`${d}d`);
|
|
5030
|
+
if (h > 0 || d > 0) parts.push(`${h}h`);
|
|
5031
|
+
parts.push(`${m}m`);
|
|
5032
|
+
return parts.join(" ");
|
|
5033
|
+
}
|
|
5034
|
+
/**
|
|
5035
|
+
* Reduce a snapshot to the ordered list of gauges the `/computer` card draws:
|
|
5036
|
+
* CPU load (normalized to core count), memory, swap (only when the box has
|
|
5037
|
+
* any), and disk. Load can exceed 1.0 (oversubscribed CPU) — the bar clamps
|
|
5038
|
+
* but the caption shows the true number so that's still legible.
|
|
5039
|
+
*/
|
|
5040
|
+
function toGauges(stats) {
|
|
5041
|
+
const gauges = [];
|
|
5042
|
+
const load1 = stats.loadavg[0];
|
|
5043
|
+
const loadFraction = load1 / stats.nproc;
|
|
5044
|
+
gauges.push({
|
|
5045
|
+
label: "cpu load",
|
|
5046
|
+
fraction: loadFraction,
|
|
5047
|
+
detail: `${load1.toFixed(2)} / ${stats.nproc} cores`,
|
|
5048
|
+
severity: severityFor(loadFraction)
|
|
5049
|
+
});
|
|
5050
|
+
const memUsed = Math.max(0, stats.memTotalBytes - stats.memAvailBytes);
|
|
5051
|
+
const memFraction = stats.memTotalBytes > 0 ? memUsed / stats.memTotalBytes : 0;
|
|
5052
|
+
gauges.push({
|
|
5053
|
+
label: "memory",
|
|
5054
|
+
fraction: memFraction,
|
|
5055
|
+
detail: `${gb(memUsed)} / ${gb(stats.memTotalBytes)} GB`,
|
|
5056
|
+
severity: severityFor(memFraction)
|
|
5057
|
+
});
|
|
5058
|
+
if (stats.swapTotalBytes > 0) {
|
|
5059
|
+
const swapUsed = Math.max(0, stats.swapTotalBytes - stats.swapFreeBytes);
|
|
5060
|
+
const swapFraction = swapUsed / stats.swapTotalBytes;
|
|
5061
|
+
gauges.push({
|
|
5062
|
+
label: "swap",
|
|
5063
|
+
fraction: swapFraction,
|
|
5064
|
+
detail: `${gb(swapUsed)} / ${gb(stats.swapTotalBytes)} GB`,
|
|
5065
|
+
severity: severityFor(swapFraction)
|
|
5066
|
+
});
|
|
5067
|
+
}
|
|
5068
|
+
const diskFraction = stats.diskTotalBytes > 0 ? stats.diskUsedBytes / stats.diskTotalBytes : 0;
|
|
5069
|
+
gauges.push({
|
|
5070
|
+
label: "disk",
|
|
5071
|
+
fraction: diskFraction,
|
|
5072
|
+
detail: `${gb(stats.diskUsedBytes)} / ${gb(stats.diskTotalBytes)} GB`,
|
|
5073
|
+
severity: severityFor(diskFraction)
|
|
5074
|
+
});
|
|
5075
|
+
return gauges;
|
|
5076
|
+
}
|
|
5077
|
+
/**
|
|
5078
|
+
* A single-line horizontal bar of `width` cells filled to `fraction`
|
|
5079
|
+
* (clamped 0..1), using block glyphs. The last partial cell uses one of the
|
|
5080
|
+
* eighth-block glyphs so a 37%% bar reads differently from a 40%% one even in a
|
|
5081
|
+
* narrow width. Pure string, so the renderer just colors it.
|
|
5082
|
+
*/
|
|
5083
|
+
function renderBar(fraction, width) {
|
|
5084
|
+
const cells = Math.max(1, width);
|
|
5085
|
+
const exact = Math.min(1, Math.max(0, fraction)) * cells;
|
|
5086
|
+
const full = Math.floor(exact);
|
|
5087
|
+
const remainder = exact - full;
|
|
5088
|
+
const eighths = [
|
|
5089
|
+
"",
|
|
5090
|
+
"▏",
|
|
5091
|
+
"▎",
|
|
5092
|
+
"▍",
|
|
5093
|
+
"▌",
|
|
5094
|
+
"▋",
|
|
5095
|
+
"▊",
|
|
5096
|
+
"▉"
|
|
5097
|
+
];
|
|
5098
|
+
const partialIndex = Math.round(remainder * 8);
|
|
5099
|
+
const partial = full < cells ? eighths[partialIndex] : "";
|
|
5100
|
+
const filled = "█".repeat(Math.min(full, cells)) + partial;
|
|
5101
|
+
return (filled + "░".repeat(Math.max(0, cells - filled.length))).slice(0, cells);
|
|
5102
|
+
}
|
|
5103
|
+
/** How many port rows the card draws before folding the rest into `+N more`. */
|
|
5104
|
+
const MAX_PORT_ROWS = 8;
|
|
5105
|
+
const WELL_KNOWN_PORTS = new Map([[3e3, "agent harness"], [3001, "agent webserver"]]);
|
|
5106
|
+
/**
|
|
5107
|
+
* Reduce the snapshot's ports to drawable rows. The well-known note wins over
|
|
5108
|
+
* `local only` — a loopback-bound 3001 being unreachable is a distinct problem
|
|
5109
|
+
* the gauges' error path already surfaces, and one annotation per row keeps
|
|
5110
|
+
* the table scannable.
|
|
5111
|
+
*/
|
|
5112
|
+
function toPortRows(ports) {
|
|
5113
|
+
return ports.map((p) => ({
|
|
5114
|
+
port: p.port,
|
|
5115
|
+
process: p.process ?? "?",
|
|
5116
|
+
note: WELL_KNOWN_PORTS.get(p.port) ?? (p.loopbackOnly ? "local only" : null)
|
|
4311
5117
|
}));
|
|
4312
|
-
return ids.map((id, index) => {
|
|
4313
|
-
const row = fetched?.get(id) ?? null;
|
|
4314
|
-
return {
|
|
4315
|
-
id,
|
|
4316
|
-
title: row?.title ?? titles[index] ?? FALLBACK_TITLE,
|
|
4317
|
-
status: row ? toTaskStatus(row.status) : "queued",
|
|
4318
|
-
createdAt: row?.createdAt ?? null,
|
|
4319
|
-
completedAt: row?.completedAt ?? null
|
|
4320
|
-
};
|
|
4321
|
-
});
|
|
4322
5118
|
}
|
|
4323
5119
|
|
|
4324
5120
|
//#endregion
|
|
4325
|
-
//#region src/chat/tui/chunks/
|
|
4326
|
-
/** How often a live batch re-reads its tasks' status. */
|
|
4327
|
-
const POLL_INTERVAL_MS = 3e3;
|
|
4328
|
-
function rowGlyph(status) {
|
|
4329
|
-
switch (status) {
|
|
4330
|
-
case "completed": return {
|
|
4331
|
-
ch: "✔",
|
|
4332
|
-
fg: theme.success
|
|
4333
|
-
};
|
|
4334
|
-
case "failed":
|
|
4335
|
-
case "canceled": return {
|
|
4336
|
-
ch: "✗",
|
|
4337
|
-
fg: theme.error
|
|
4338
|
-
};
|
|
4339
|
-
case "running": return {
|
|
4340
|
-
ch: "▶",
|
|
4341
|
-
fg: theme.accent
|
|
4342
|
-
};
|
|
4343
|
-
case "queued": return {
|
|
4344
|
-
ch: "○",
|
|
4345
|
-
fg: theme.dim
|
|
4346
|
-
};
|
|
4347
|
-
}
|
|
4348
|
-
}
|
|
4349
|
-
function rowColor$1(status) {
|
|
4350
|
-
switch (status) {
|
|
4351
|
-
case "running": return theme.fg;
|
|
4352
|
-
case "completed": return theme.dim;
|
|
4353
|
-
default: return theme.muted;
|
|
4354
|
-
}
|
|
4355
|
-
}
|
|
4356
|
-
function headerLabel(rows) {
|
|
4357
|
-
const subject = rows.length === 1 ? "Subagent" : `${rows.length} subagents`;
|
|
4358
|
-
return rows.some((row) => isLiveStatus(row.status)) ? `${subject} running` : subject;
|
|
4359
|
-
}
|
|
5121
|
+
//#region src/chat/tui/chunks/computer.tsx
|
|
4360
5122
|
/**
|
|
4361
|
-
*
|
|
4362
|
-
*
|
|
4363
|
-
*
|
|
5123
|
+
* Renders the `/computer` card: a snapshot of the agent's sandbox as a stack of
|
|
5124
|
+
* colored bar graphs (cpu load, memory, swap, disk) plus a footer of scalar
|
|
5125
|
+
* facts (uptime, process count).
|
|
4364
5126
|
*
|
|
4365
|
-
*
|
|
4366
|
-
*
|
|
4367
|
-
*
|
|
4368
|
-
*
|
|
4369
|
-
*
|
|
4370
|
-
*
|
|
4371
|
-
*
|
|
4372
|
-
*
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
const
|
|
4377
|
-
const
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
const live = rows.length === 0 || rows.some((row) => isLiveStatus(row.status));
|
|
5127
|
+
* The card keeps itself current in place rather than making the user re-run the
|
|
5128
|
+
* command — but quietly: it refreshes on a slow interval with no "updating"
|
|
5129
|
+
* chrome, so the numbers just stay fresh without any flicker. Only the most
|
|
5130
|
+
* recent `/computer` card refreshes (older ones freeze at their last snapshot),
|
|
5131
|
+
* and the latest one silently stops while the transcript is scrolled up (no
|
|
5132
|
+
* wasted polls on a card you're not looking at) and resumes at the bottom — the
|
|
5133
|
+
* pause is never rendered. The refresh uses the same sandbox-exec relay
|
|
5134
|
+
* `/sandbox` uses, injected via context; the parse/layout math lives in
|
|
5135
|
+
* `sandbox-stats.ts` and this component only draws.
|
|
5136
|
+
*/
|
|
5137
|
+
function ComputerItem({ item }) {
|
|
5138
|
+
const { collectSandboxStats, latestComputerId, scrolledUp } = useConversationContext();
|
|
5139
|
+
const live = shouldPollComputer({
|
|
5140
|
+
isLatest: latestComputerId === item.id,
|
|
5141
|
+
canCollect: collectSandboxStats !== null,
|
|
5142
|
+
scrolledUp
|
|
5143
|
+
});
|
|
5144
|
+
const [stats, setStats] = useState(item.stats);
|
|
5145
|
+
const [error, setError] = useState(item.error);
|
|
5146
|
+
const hasStats = useRef(item.stats !== null);
|
|
4386
5147
|
useEffect(() => {
|
|
4387
|
-
if (!
|
|
5148
|
+
if (!live || !collectSandboxStats) return;
|
|
4388
5149
|
let cancelled = false;
|
|
4389
5150
|
let timer = null;
|
|
5151
|
+
let inFlight = null;
|
|
4390
5152
|
const tick = async () => {
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
if (
|
|
5153
|
+
const collection = collectSandboxStats();
|
|
5154
|
+
inFlight = collection;
|
|
5155
|
+
const result = await collection.result;
|
|
5156
|
+
inFlight = null;
|
|
5157
|
+
if (cancelled) return;
|
|
5158
|
+
if (result.ok) {
|
|
5159
|
+
hasStats.current = true;
|
|
5160
|
+
setStats(result.stats);
|
|
5161
|
+
setError(null);
|
|
5162
|
+
} else if (!("aborted" in result)) {
|
|
5163
|
+
if (!hasStats.current) setError(result.error);
|
|
5164
|
+
}
|
|
5165
|
+
if (!cancelled) timer = setTimeout(tick, COMPUTER_POLL_INTERVAL_MS);
|
|
4397
5166
|
};
|
|
4398
5167
|
tick();
|
|
4399
5168
|
return () => {
|
|
4400
5169
|
cancelled = true;
|
|
4401
5170
|
if (timer !== null) clearTimeout(timer);
|
|
5171
|
+
inFlight?.abort();
|
|
4402
5172
|
};
|
|
4403
|
-
}, [
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
5173
|
+
}, [live, collectSandboxStats]);
|
|
5174
|
+
if (stats === null && error === null) return /* @__PURE__ */ jsx(Shell, { children: /* @__PURE__ */ jsx("text", {
|
|
5175
|
+
fg: theme.dim,
|
|
5176
|
+
children: "reading sandbox stats…"
|
|
5177
|
+
}) });
|
|
5178
|
+
if (stats === null) return /* @__PURE__ */ jsx(Shell, { children: /* @__PURE__ */ jsxs("text", {
|
|
5179
|
+
fg: theme.error,
|
|
5180
|
+
children: ["couldn't read sandbox stats", error ? `: ${error}` : ""]
|
|
5181
|
+
}) });
|
|
5182
|
+
const gauges = toGauges(stats);
|
|
5183
|
+
const portRows = toPortRows(stats.ports);
|
|
5184
|
+
const labelWidth = Math.max(5, ...gauges.map((g) => g.label.length));
|
|
5185
|
+
const barWidth = 24;
|
|
5186
|
+
return /* @__PURE__ */ jsxs(Shell, { children: [
|
|
5187
|
+
gauges.map((g) => /* @__PURE__ */ jsx(GaugeRow, {
|
|
5188
|
+
gauge: g,
|
|
5189
|
+
labelWidth,
|
|
5190
|
+
barWidth
|
|
5191
|
+
}, g.label)),
|
|
5192
|
+
/* @__PURE__ */ jsx(PortsSection, {
|
|
5193
|
+
rows: portRows,
|
|
5194
|
+
labelWidth
|
|
5195
|
+
}),
|
|
5196
|
+
/* @__PURE__ */ jsxs("text", {
|
|
5197
|
+
fg: theme.dim,
|
|
5198
|
+
children: [
|
|
5199
|
+
`${"uptime".padEnd(labelWidth)} `,
|
|
5200
|
+
/* @__PURE__ */ jsx("span", {
|
|
5201
|
+
fg: theme.muted,
|
|
5202
|
+
children: formatUptime(stats.uptimeSeconds)
|
|
5203
|
+
}),
|
|
5204
|
+
/* @__PURE__ */ jsx("span", {
|
|
5205
|
+
fg: theme.dim,
|
|
5206
|
+
children: ` · ${stats.procs} processes`
|
|
5207
|
+
})
|
|
5208
|
+
]
|
|
5209
|
+
})
|
|
5210
|
+
] });
|
|
5211
|
+
}
|
|
5212
|
+
/** The card frame: a bold `computer` title over the graph rows. */
|
|
5213
|
+
function Shell({ children }) {
|
|
4416
5214
|
return /* @__PURE__ */ jsxs("box", {
|
|
4417
5215
|
style: { flexDirection: "column" },
|
|
4418
|
-
children: [/* @__PURE__ */ jsxs("text", {
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
children: "✓"
|
|
4422
|
-
}),
|
|
4423
|
-
/* @__PURE__ */ jsxs("span", {
|
|
4424
|
-
fg: theme.tool,
|
|
4425
|
-
children: [" ", /* @__PURE__ */ jsx("b", { children: headerLabel(rows) })]
|
|
4426
|
-
}),
|
|
4427
|
-
/* @__PURE__ */ jsxs("span", {
|
|
5216
|
+
children: [/* @__PURE__ */ jsxs("text", {
|
|
5217
|
+
fg: theme.muted,
|
|
5218
|
+
children: [/* @__PURE__ */ jsx("b", { children: "computer" }), /* @__PURE__ */ jsx("span", {
|
|
4428
5219
|
fg: theme.dim,
|
|
4429
|
-
children:
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
"/",
|
|
4433
|
-
rows.length
|
|
4434
|
-
]
|
|
4435
|
-
})
|
|
4436
|
-
] }), /* @__PURE__ */ jsx("box", {
|
|
4437
|
-
style: {
|
|
4438
|
-
flexDirection: "column",
|
|
4439
|
-
paddingLeft: 2
|
|
4440
|
-
},
|
|
4441
|
-
children: rows.map((row) => {
|
|
4442
|
-
const glyph = rowGlyph(row.status);
|
|
4443
|
-
return /* @__PURE__ */ jsxs("box", {
|
|
4444
|
-
style: { flexDirection: "row" },
|
|
4445
|
-
children: [/* @__PURE__ */ jsxs("text", {
|
|
4446
|
-
fg: glyph.fg,
|
|
4447
|
-
children: [glyph.ch, " "]
|
|
4448
|
-
}), /* @__PURE__ */ jsx("text", {
|
|
4449
|
-
fg: rowColor$1(row.status),
|
|
4450
|
-
children: row.title
|
|
4451
|
-
})]
|
|
4452
|
-
}, row.id);
|
|
4453
|
-
})
|
|
4454
|
-
})]
|
|
5220
|
+
children: " · agent sandbox"
|
|
5221
|
+
})]
|
|
5222
|
+
}), children]
|
|
4455
5223
|
});
|
|
4456
5224
|
}
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
5225
|
+
function severityColor(severity) {
|
|
5226
|
+
switch (severity) {
|
|
5227
|
+
case "ok": return theme.success;
|
|
5228
|
+
case "warn": return theme.warning;
|
|
5229
|
+
case "crit": return theme.error;
|
|
5230
|
+
default: {
|
|
5231
|
+
const exhaustive = severity;
|
|
5232
|
+
throw new Error(`unhandled severity: ${String(exhaustive)}`);
|
|
5233
|
+
}
|
|
5234
|
+
}
|
|
5235
|
+
}
|
|
5236
|
+
function GaugeRow({ gauge, labelWidth, barWidth }) {
|
|
5237
|
+
const color = severityColor(gauge.severity);
|
|
5238
|
+
const pct = `${Math.round(Math.min(1, Math.max(0, gauge.fraction)) * 100)}%`;
|
|
5239
|
+
return /* @__PURE__ */ jsxs("text", { children: [
|
|
5240
|
+
/* @__PURE__ */ jsx("span", {
|
|
5241
|
+
fg: theme.muted,
|
|
5242
|
+
children: `${gauge.label.padEnd(labelWidth)} `
|
|
5243
|
+
}),
|
|
5244
|
+
/* @__PURE__ */ jsx("span", {
|
|
5245
|
+
fg: color,
|
|
5246
|
+
children: renderBar(gauge.fraction, barWidth)
|
|
5247
|
+
}),
|
|
5248
|
+
/* @__PURE__ */ jsx("span", {
|
|
5249
|
+
fg: color,
|
|
5250
|
+
children: ` ${pct.padStart(4)}`
|
|
5251
|
+
}),
|
|
5252
|
+
/* @__PURE__ */ jsx("span", {
|
|
5253
|
+
fg: theme.dim,
|
|
5254
|
+
children: ` ${gauge.detail}`
|
|
5255
|
+
})
|
|
5256
|
+
] });
|
|
5257
|
+
}
|
|
4460
5258
|
/**
|
|
4461
|
-
*
|
|
4462
|
-
*
|
|
4463
|
-
*
|
|
4464
|
-
*
|
|
4465
|
-
* locally, not on the agent.
|
|
5259
|
+
* The listening-ports table: one row per port the sandbox user's processes
|
|
5260
|
+
* hold, first row carrying the `ports` gutter label to line up with the
|
|
5261
|
+
* gauges. Folds past MAX_PORT_ROWS into a `+N more` line so a busy box can't
|
|
5262
|
+
* flood the transcript.
|
|
4466
5263
|
*/
|
|
4467
|
-
function
|
|
4468
|
-
|
|
4469
|
-
const
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
fg: running ? theme.dim : failed ? theme.error : theme.success,
|
|
4475
|
-
children: "! "
|
|
4476
|
-
}),
|
|
4477
|
-
/* @__PURE__ */ jsx("span", {
|
|
4478
|
-
fg: theme.tool,
|
|
4479
|
-
children: item.command
|
|
4480
|
-
}),
|
|
4481
|
-
running ? /* @__PURE__ */ jsx("span", {
|
|
4482
|
-
fg: theme.dim,
|
|
4483
|
-
children: " · running…"
|
|
4484
|
-
}) : null,
|
|
4485
|
-
failed ? /* @__PURE__ */ jsxs("span", {
|
|
4486
|
-
fg: theme.error,
|
|
4487
|
-
children: [" · exit ", item.exit]
|
|
4488
|
-
}) : null
|
|
4489
|
-
] }), item.output ? /* @__PURE__ */ jsx(Indented, { children: /* @__PURE__ */ jsx("text", {
|
|
5264
|
+
function PortsSection({ rows, labelWidth }) {
|
|
5265
|
+
if (rows.length === 0) return null;
|
|
5266
|
+
const shown = rows.slice(0, MAX_PORT_ROWS);
|
|
5267
|
+
const folded = rows.length - shown.length;
|
|
5268
|
+
const gutter = " ".repeat(labelWidth);
|
|
5269
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [shown.map((row, i) => /* @__PURE__ */ jsxs("text", { children: [
|
|
5270
|
+
/* @__PURE__ */ jsx("span", {
|
|
4490
5271
|
fg: theme.muted,
|
|
4491
|
-
children:
|
|
4492
|
-
})
|
|
4493
|
-
|
|
5272
|
+
children: `${i === 0 ? "ports".padEnd(labelWidth) : gutter} `
|
|
5273
|
+
}),
|
|
5274
|
+
/* @__PURE__ */ jsx("span", {
|
|
5275
|
+
fg: theme.fg,
|
|
5276
|
+
children: String(row.port).padStart(5)
|
|
5277
|
+
}),
|
|
5278
|
+
/* @__PURE__ */ jsx("span", {
|
|
5279
|
+
fg: theme.muted,
|
|
5280
|
+
children: ` ${row.process}`
|
|
5281
|
+
}),
|
|
5282
|
+
row.note ? /* @__PURE__ */ jsx("span", {
|
|
5283
|
+
fg: theme.dim,
|
|
5284
|
+
children: ` · ${row.note}`
|
|
5285
|
+
}) : null
|
|
5286
|
+
] }, row.port)), folded > 0 ? /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
|
|
5287
|
+
fg: theme.muted,
|
|
5288
|
+
children: `${gutter} `
|
|
5289
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
5290
|
+
fg: theme.dim,
|
|
5291
|
+
children: `+${folded} more`
|
|
5292
|
+
})] }) : null] });
|
|
4494
5293
|
}
|
|
4495
5294
|
|
|
4496
5295
|
//#endregion
|
|
@@ -4529,20 +5328,23 @@ function RenderItem({ item }) {
|
|
|
4529
5328
|
children: [item.text ? /* @__PURE__ */ jsx("text", {
|
|
4530
5329
|
fg: theme.user,
|
|
4531
5330
|
children: item.text
|
|
4532
|
-
}) : null, item.attachments
|
|
5331
|
+
}) : null, item.attachments.length > 0 ? /* @__PURE__ */ jsxs("text", {
|
|
4533
5332
|
fg: theme.muted,
|
|
4534
5333
|
children: ["⎘ ", item.attachments.join(" · ")]
|
|
4535
5334
|
}) : null]
|
|
4536
5335
|
});
|
|
4537
|
-
case "pending-steer": return /* @__PURE__ */
|
|
5336
|
+
case "pending-steer": return /* @__PURE__ */ jsxs(Row, {
|
|
4538
5337
|
barColor: theme.dim,
|
|
4539
|
-
children: /* @__PURE__ */ jsxs("text", {
|
|
5338
|
+
children: [/* @__PURE__ */ jsxs("text", {
|
|
4540
5339
|
fg: theme.muted,
|
|
4541
5340
|
children: [item.text, /* @__PURE__ */ jsx("span", {
|
|
4542
5341
|
fg: theme.dim,
|
|
4543
5342
|
children: " · steering… (esc to cancel)"
|
|
4544
5343
|
})]
|
|
4545
|
-
})
|
|
5344
|
+
}), item.attachments.length > 0 ? /* @__PURE__ */ jsxs("text", {
|
|
5345
|
+
fg: theme.dim,
|
|
5346
|
+
children: ["⎘ ", item.attachments.join(" · ")]
|
|
5347
|
+
}) : null]
|
|
4546
5348
|
});
|
|
4547
5349
|
case "assistant-text":
|
|
4548
5350
|
if (item.done) return /* @__PURE__ */ jsx(Row, {
|
|
@@ -4588,6 +5390,10 @@ function RenderItem({ item }) {
|
|
|
4588
5390
|
})]
|
|
4589
5391
|
})
|
|
4590
5392
|
});
|
|
5393
|
+
case "computer": return /* @__PURE__ */ jsx(Row, {
|
|
5394
|
+
barColor: null,
|
|
5395
|
+
children: /* @__PURE__ */ jsx(ComputerItem, { item })
|
|
5396
|
+
});
|
|
4591
5397
|
case "recap": return /* @__PURE__ */ jsx(Row, {
|
|
4592
5398
|
barColor: null,
|
|
4593
5399
|
children: /* @__PURE__ */ jsxs("text", {
|
|
@@ -4678,7 +5484,7 @@ function ToolItem({ item }) {
|
|
|
4678
5484
|
case "read": return /* @__PURE__ */ jsx(ToolRead, { item });
|
|
4679
5485
|
case "grep":
|
|
4680
5486
|
case "glob": return /* @__PURE__ */ jsx(ToolGrep, { item });
|
|
4681
|
-
case "subagent": return
|
|
5487
|
+
case "subagent": return null;
|
|
4682
5488
|
default: return /* @__PURE__ */ jsx(ToolGeneric, { item });
|
|
4683
5489
|
}
|
|
4684
5490
|
}
|
|
@@ -5277,7 +6083,8 @@ function applyChunk(items, chunk, agentName = null) {
|
|
|
5277
6083
|
return items.map((m) => m.kind === "pending-steer" && ids.has(m.id) ? {
|
|
5278
6084
|
kind: "user",
|
|
5279
6085
|
id: m.id,
|
|
5280
|
-
text: m.text
|
|
6086
|
+
text: m.text,
|
|
6087
|
+
attachments: m.attachments
|
|
5281
6088
|
} : m);
|
|
5282
6089
|
}
|
|
5283
6090
|
case "data-anyone-render-spec": {
|
|
@@ -5287,6 +6094,22 @@ function applyChunk(items, chunk, agentName = null) {
|
|
|
5287
6094
|
if (!card) return [...items];
|
|
5288
6095
|
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : null;
|
|
5289
6096
|
const specKey = specKeyFor(data.spec);
|
|
6097
|
+
if (card.computeRequestId !== null) {
|
|
6098
|
+
const existingIndex = items.findIndex((m) => m.kind === "card" && m.card.computeRequestId === card.computeRequestId);
|
|
6099
|
+
const existing = items[existingIndex];
|
|
6100
|
+
if (existing?.kind === "card") {
|
|
6101
|
+
if (existing.specKey === specKey || card.settled === null) return [...items];
|
|
6102
|
+
const next = [...items];
|
|
6103
|
+
next[existingIndex] = {
|
|
6104
|
+
...existing,
|
|
6105
|
+
specKey,
|
|
6106
|
+
card,
|
|
6107
|
+
status: "done",
|
|
6108
|
+
detail: card.settled
|
|
6109
|
+
};
|
|
6110
|
+
return next;
|
|
6111
|
+
}
|
|
6112
|
+
}
|
|
5290
6113
|
if (items.some((m) => m.kind === "card" && m.toolCallId === toolCallId && m.specKey === specKey)) return [...items];
|
|
5291
6114
|
return [...items, {
|
|
5292
6115
|
kind: "card",
|
|
@@ -5294,8 +6117,8 @@ function applyChunk(items, chunk, agentName = null) {
|
|
|
5294
6117
|
toolCallId,
|
|
5295
6118
|
specKey,
|
|
5296
6119
|
card,
|
|
5297
|
-
status: "idle",
|
|
5298
|
-
detail:
|
|
6120
|
+
status: card.settled === null ? "idle" : "done",
|
|
6121
|
+
detail: card.settled,
|
|
5299
6122
|
agentName
|
|
5300
6123
|
}];
|
|
5301
6124
|
}
|
|
@@ -5518,7 +6341,7 @@ function uiMessagesToItems(messages) {
|
|
|
5518
6341
|
kind: "user",
|
|
5519
6342
|
id: m.id,
|
|
5520
6343
|
text,
|
|
5521
|
-
|
|
6344
|
+
attachments
|
|
5522
6345
|
});
|
|
5523
6346
|
continue;
|
|
5524
6347
|
}
|
|
@@ -5557,6 +6380,20 @@ function uiMessagesToItems(messages) {
|
|
|
5557
6380
|
if (card && data) {
|
|
5558
6381
|
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : null;
|
|
5559
6382
|
const specKey = specKeyFor(data.spec);
|
|
6383
|
+
if (card.computeRequestId !== null) {
|
|
6384
|
+
const existingIndex = out.findIndex((item) => item.kind === "card" && item.card.computeRequestId === card.computeRequestId);
|
|
6385
|
+
const existing = out[existingIndex];
|
|
6386
|
+
if (existing?.kind === "card") {
|
|
6387
|
+
if (existing.specKey !== specKey && card.settled !== null) out[existingIndex] = {
|
|
6388
|
+
...existing,
|
|
6389
|
+
specKey,
|
|
6390
|
+
card,
|
|
6391
|
+
status: "done",
|
|
6392
|
+
detail: card.settled
|
|
6393
|
+
};
|
|
6394
|
+
continue;
|
|
6395
|
+
}
|
|
6396
|
+
}
|
|
5560
6397
|
if (out.some((item) => item.kind === "card" && item.toolCallId === toolCallId && item.specKey === specKey)) continue;
|
|
5561
6398
|
const ordinal = out.filter((item) => item.kind === "card" && item.toolCallId === toolCallId).length;
|
|
5562
6399
|
out.push({
|
|
@@ -5565,8 +6402,8 @@ function uiMessagesToItems(messages) {
|
|
|
5565
6402
|
toolCallId,
|
|
5566
6403
|
specKey,
|
|
5567
6404
|
card,
|
|
5568
|
-
status: "idle",
|
|
5569
|
-
detail:
|
|
6405
|
+
status: card.settled === null ? "idle" : "done",
|
|
6406
|
+
detail: card.settled,
|
|
5570
6407
|
agentName
|
|
5571
6408
|
});
|
|
5572
6409
|
}
|
|
@@ -5675,7 +6512,7 @@ function windowTodos(todos, cap = MAX_VISIBLE_ROWS) {
|
|
|
5675
6512
|
const sorted = [...todos].sort((a, b) => a.position - b.position);
|
|
5676
6513
|
let leadingDone = 0;
|
|
5677
6514
|
while (leadingDone < sorted.length && sorted[leadingDone]?.status === "completed") leadingDone += 1;
|
|
5678
|
-
const head = leadingDone >= 2 || sorted.length > cap ? leadingDone : 0;
|
|
6515
|
+
const head = leadingDone === sorted.length && sorted.length > 0 || leadingDone >= 2 || sorted.length > cap ? leadingDone : 0;
|
|
5679
6516
|
const rest = sorted.slice(head);
|
|
5680
6517
|
const rowBudget = Math.max(1, head > 0 ? cap - 1 : cap);
|
|
5681
6518
|
if (rest.length <= rowBudget) return {
|
|
@@ -5706,7 +6543,7 @@ function todoCardHeight(todos) {
|
|
|
5706
6543
|
}
|
|
5707
6544
|
|
|
5708
6545
|
//#endregion
|
|
5709
|
-
//#region src/chat/tui/chat/
|
|
6546
|
+
//#region src/chat/tui/chat/task-card.tsx
|
|
5710
6547
|
function glyph(status) {
|
|
5711
6548
|
switch (status) {
|
|
5712
6549
|
case "completed": return {
|
|
@@ -5735,38 +6572,18 @@ function rowColor(status) {
|
|
|
5735
6572
|
default: return theme.muted;
|
|
5736
6573
|
}
|
|
5737
6574
|
}
|
|
5738
|
-
/**
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
* written a list (`platform todo write`) and until it clears one, so a
|
|
5742
|
-
* conversation the agent never wrote todos for grows no chrome for it.
|
|
5743
|
-
*
|
|
5744
|
-
* The card can't scroll like the web card, so it windows (see `windowTodos`):
|
|
5745
|
-
* a leading run of completed todos folds into a `✔ N done` line, the active
|
|
5746
|
-
* and upcoming rows fill the rest, and a `+N more` tail counts pending work
|
|
5747
|
-
* past the cap. The window follows the in-progress row down as todos complete.
|
|
5748
|
-
*
|
|
5749
|
-
* `isActive` dims the header while the run is idle: an in-progress row only
|
|
5750
|
-
* reads as "happening now" when a run is actually producing output.
|
|
5751
|
-
*/
|
|
5752
|
-
function TodoCardView({ todos, isActive }) {
|
|
5753
|
-
if (todos.length === 0) return null;
|
|
5754
|
-
const completed = todos.filter((t) => t.status === "completed").length;
|
|
6575
|
+
/** Shared plan/subagent checklist content. The caller owns outer spacing. */
|
|
6576
|
+
function TaskCardContent({ label, todos, isActive }) {
|
|
6577
|
+
const completed = todos.filter((todo) => todo.status === "completed").length;
|
|
5755
6578
|
const { doneSummary, rows, moreCount } = windowTodos(todos);
|
|
5756
6579
|
return /* @__PURE__ */ jsxs("box", {
|
|
5757
|
-
style: {
|
|
5758
|
-
flexDirection: "column",
|
|
5759
|
-
flexShrink: 0,
|
|
5760
|
-
marginTop: 1,
|
|
5761
|
-
paddingLeft: 1,
|
|
5762
|
-
paddingRight: 1
|
|
5763
|
-
},
|
|
6580
|
+
style: { flexDirection: "column" },
|
|
5764
6581
|
children: [
|
|
5765
6582
|
/* @__PURE__ */ jsxs("box", {
|
|
5766
6583
|
style: { flexDirection: "row" },
|
|
5767
6584
|
children: [/* @__PURE__ */ jsx("text", {
|
|
5768
6585
|
fg: isActive ? theme.accent : theme.muted,
|
|
5769
|
-
children: /* @__PURE__ */ jsx("b", { children:
|
|
6586
|
+
children: /* @__PURE__ */ jsx("b", { children: label })
|
|
5770
6587
|
}), /* @__PURE__ */ jsxs("text", {
|
|
5771
6588
|
fg: theme.dim,
|
|
5772
6589
|
children: [
|
|
@@ -5811,6 +6628,260 @@ function TodoCardView({ todos, isActive }) {
|
|
|
5811
6628
|
]
|
|
5812
6629
|
});
|
|
5813
6630
|
}
|
|
6631
|
+
function taskCardContentHeight(todos) {
|
|
6632
|
+
if (todos.length === 0) return 0;
|
|
6633
|
+
const window = windowTodos(todos);
|
|
6634
|
+
return 1 + (window.doneSummary > 0 ? 1 : 0) + window.rows.length + (window.moreCount > 0 ? 1 : 0);
|
|
6635
|
+
}
|
|
6636
|
+
|
|
6637
|
+
//#endregion
|
|
6638
|
+
//#region src/chat/tui/chat/todo-card.tsx
|
|
6639
|
+
/**
|
|
6640
|
+
* The agent's live todo list, pinned just above the composer — the TUI analog
|
|
6641
|
+
* of the web chat's todo card. Rendered only once the agent has actually
|
|
6642
|
+
* written a list (`platform todo write`) and until it clears one, so a
|
|
6643
|
+
* conversation the agent never wrote todos for grows no chrome for it.
|
|
6644
|
+
*
|
|
6645
|
+
* The card can't scroll like the web card, so it windows completed and pending
|
|
6646
|
+
* work. `isActive` dims the header while the run is idle.
|
|
6647
|
+
*/
|
|
6648
|
+
function TodoCardView({ todos, isActive }) {
|
|
6649
|
+
if (todos.length === 0) return null;
|
|
6650
|
+
return /* @__PURE__ */ jsx("box", {
|
|
6651
|
+
style: {
|
|
6652
|
+
flexDirection: "column",
|
|
6653
|
+
flexShrink: 0,
|
|
6654
|
+
marginTop: 1,
|
|
6655
|
+
paddingLeft: 1,
|
|
6656
|
+
paddingRight: 1
|
|
6657
|
+
},
|
|
6658
|
+
children: /* @__PURE__ */ jsx(TaskCardContent, {
|
|
6659
|
+
label: "Plan",
|
|
6660
|
+
todos,
|
|
6661
|
+
isActive
|
|
6662
|
+
})
|
|
6663
|
+
});
|
|
6664
|
+
}
|
|
6665
|
+
|
|
6666
|
+
//#endregion
|
|
6667
|
+
//#region src/chat/tui/chat/subagents.ts
|
|
6668
|
+
/** A run the platform hasn't settled yet — still queued or mid-run. */
|
|
6669
|
+
function isLiveStatus(status) {
|
|
6670
|
+
return status === "queued" || status === "running";
|
|
6671
|
+
}
|
|
6672
|
+
function toTaskStatus(raw) {
|
|
6673
|
+
switch (raw) {
|
|
6674
|
+
case "queued":
|
|
6675
|
+
case "running":
|
|
6676
|
+
case "completed":
|
|
6677
|
+
case "failed":
|
|
6678
|
+
case "canceled": return raw;
|
|
6679
|
+
default: return "queued";
|
|
6680
|
+
}
|
|
6681
|
+
}
|
|
6682
|
+
const FALLBACK_TITLE = "Subagent task";
|
|
6683
|
+
/**
|
|
6684
|
+
* Each delegated task's display title from the `subagent` call's input, in
|
|
6685
|
+
* call order — the short name the tool requires per task. A malformed entry
|
|
6686
|
+
* yields a fallback label rather than being dropped, so the row count always
|
|
6687
|
+
* matches the fan-out width. Mirrors the web client's `subagentTaskTitles`.
|
|
6688
|
+
*/
|
|
6689
|
+
function subagentTaskTitles(input) {
|
|
6690
|
+
if (!isRecord(input)) return [];
|
|
6691
|
+
const tasks = input["tasks"];
|
|
6692
|
+
if (!Array.isArray(tasks)) return [];
|
|
6693
|
+
return tasks.map((task) => {
|
|
6694
|
+
if (!isRecord(task)) return FALLBACK_TITLE;
|
|
6695
|
+
const title = task["title"];
|
|
6696
|
+
return typeof title === "string" && title.trim() ? title.trim() : FALLBACK_TITLE;
|
|
6697
|
+
});
|
|
6698
|
+
}
|
|
6699
|
+
const QUEUED_TASK_ID_LINE = /^\s*-\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):\s/i;
|
|
6700
|
+
/**
|
|
6701
|
+
* The ids of the runs a `subagent` call queued, read back out of its own
|
|
6702
|
+
* output. The call returns the moment the runs exist, so the ids are there
|
|
6703
|
+
* long before any child finishes — they are what ties this card to the task
|
|
6704
|
+
* rows the status poll returns, and their order is the tasks' call order.
|
|
6705
|
+
*/
|
|
6706
|
+
function queuedSubagentTaskIds(output) {
|
|
6707
|
+
const text = flattenOutputText(output);
|
|
6708
|
+
if (!text) return [];
|
|
6709
|
+
return text.split("\n").map((line) => line.match(QUEUED_TASK_ID_LINE)?.[1] ?? null).filter((id) => id !== null);
|
|
6710
|
+
}
|
|
6711
|
+
/**
|
|
6712
|
+
* Tool output reaches the TUI as `unknown`: the raw string live, possibly
|
|
6713
|
+
* wrapped in content parts or arrays when loaded from history. Walk it for
|
|
6714
|
+
* string leaves, newline-joined — the PR scanner's `stringifyToolOutput`
|
|
6715
|
+
* shape, bounded small because the id list sits at the top of the output.
|
|
6716
|
+
*/
|
|
6717
|
+
function flattenOutputText(value) {
|
|
6718
|
+
const MAX_LEN = 2e4;
|
|
6719
|
+
const parts = [];
|
|
6720
|
+
let total = 0;
|
|
6721
|
+
const visit = (v, depth) => {
|
|
6722
|
+
if (total >= MAX_LEN || depth > 4) return;
|
|
6723
|
+
if (typeof v === "string") {
|
|
6724
|
+
parts.push(v);
|
|
6725
|
+
total += v.length;
|
|
6726
|
+
return;
|
|
6727
|
+
}
|
|
6728
|
+
if (Array.isArray(v)) {
|
|
6729
|
+
for (const item of v) visit(item, depth + 1);
|
|
6730
|
+
return;
|
|
6731
|
+
}
|
|
6732
|
+
if (isRecord(v)) for (const item of Object.values(v)) visit(item, depth + 1);
|
|
6733
|
+
};
|
|
6734
|
+
visit(value, 0);
|
|
6735
|
+
return parts.join("\n");
|
|
6736
|
+
}
|
|
6737
|
+
function nextAnchoredSubagentIds({ current, nextId, liveById }) {
|
|
6738
|
+
if (current.includes(nextId)) return current;
|
|
6739
|
+
return current.some((id) => liveById[id] ?? true) ? [...current, nextId] : [nextId];
|
|
6740
|
+
}
|
|
6741
|
+
/**
|
|
6742
|
+
* The card's rows: one per queued run id (call order), each carrying the
|
|
6743
|
+
* freshest known state. A task the status fetch hasn't returned yet falls
|
|
6744
|
+
* back to the input's title and reads as queued; a fetched row's own title
|
|
6745
|
+
* wins over the input's (the server's copy is the one the child actually
|
|
6746
|
+
* ran under). Before the ids exist — the call is still streaming — rows come
|
|
6747
|
+
* from the input titles alone, so the card never renders empty mid-turn.
|
|
6748
|
+
*/
|
|
6749
|
+
function subagentRows({ titles, ids, fetched }) {
|
|
6750
|
+
if (ids.length === 0) return titles.map((title, index) => ({
|
|
6751
|
+
id: `queued:${index}`,
|
|
6752
|
+
title,
|
|
6753
|
+
status: "queued",
|
|
6754
|
+
createdAt: null,
|
|
6755
|
+
completedAt: null
|
|
6756
|
+
}));
|
|
6757
|
+
return ids.map((id, index) => {
|
|
6758
|
+
const row = fetched?.get(id) ?? null;
|
|
6759
|
+
return {
|
|
6760
|
+
id,
|
|
6761
|
+
title: row?.title ?? titles[index] ?? FALLBACK_TITLE,
|
|
6762
|
+
status: row ? toTaskStatus(row.status) : "queued",
|
|
6763
|
+
createdAt: row?.createdAt ?? null,
|
|
6764
|
+
completedAt: row?.completedAt ?? null
|
|
6765
|
+
};
|
|
6766
|
+
});
|
|
6767
|
+
}
|
|
6768
|
+
|
|
6769
|
+
//#endregion
|
|
6770
|
+
//#region src/chat/tui/chunks/tool-subagent.tsx
|
|
6771
|
+
/** How often a live batch re-reads its tasks' status. */
|
|
6772
|
+
const POLL_INTERVAL_MS = 3e3;
|
|
6773
|
+
function subagentTodos(rows) {
|
|
6774
|
+
return rows.map((row, position) => ({
|
|
6775
|
+
id: row.id,
|
|
6776
|
+
content: row.title,
|
|
6777
|
+
position,
|
|
6778
|
+
status: row.status === "completed" ? "completed" : row.status === "running" ? "in_progress" : row.status === "failed" || row.status === "canceled" ? "failed" : "pending"
|
|
6779
|
+
}));
|
|
6780
|
+
}
|
|
6781
|
+
/**
|
|
6782
|
+
* The transcript block for a `subagent` tool call — the TUI analog of the web
|
|
6783
|
+
* chat's fan-out card. One row per delegated run: status glyph, the short
|
|
6784
|
+
* title the delegating agent wrote, ticking over live as children settle.
|
|
6785
|
+
*
|
|
6786
|
+
* Rows are seeded from the call itself (titles from its input, task ids from
|
|
6787
|
+
* its own output), so the card renders the moment the call does. Status then
|
|
6788
|
+
* comes from the server's task rows, polled while any run is live — the
|
|
6789
|
+
* delegated runs outlive the call that spawned them, so the transcript stream
|
|
6790
|
+
* carries nothing more about them. The poll stops the moment the batch
|
|
6791
|
+
* settles; reopening an old conversation does one fetch and stops there. A
|
|
6792
|
+
* failed poll is skipped silently (the next tick retries; stale rows beat an
|
|
6793
|
+
* error row in the scrollback).
|
|
6794
|
+
*/
|
|
6795
|
+
function ToolSubagent({ item, isActive, onLiveChange, onHeightChange }) {
|
|
6796
|
+
const { rest, conversationId } = useConversationContext();
|
|
6797
|
+
const titles = subagentTaskTitles(item.input);
|
|
6798
|
+
const ids = item.output?.kind === "ok" ? queuedSubagentTaskIds(item.output.value) : [];
|
|
6799
|
+
const idsKey = ids.join(",");
|
|
6800
|
+
const [fetched, setFetched] = useState(null);
|
|
6801
|
+
const rows = subagentRows({
|
|
6802
|
+
titles,
|
|
6803
|
+
ids,
|
|
6804
|
+
fetched
|
|
6805
|
+
});
|
|
6806
|
+
const live = rows.length === 0 || rows.some((row) => isLiveStatus(row.status));
|
|
6807
|
+
const todos = subagentTodos(rows);
|
|
6808
|
+
useEffect(() => {
|
|
6809
|
+
onLiveChange?.(live);
|
|
6810
|
+
}, [live, onLiveChange]);
|
|
6811
|
+
useEffect(() => {
|
|
6812
|
+
onHeightChange?.(taskCardContentHeight(todos));
|
|
6813
|
+
}, [onHeightChange, todos]);
|
|
6814
|
+
useEffect(() => {
|
|
6815
|
+
if (!rest || !conversationId || idsKey.length === 0 || !live) return;
|
|
6816
|
+
let cancelled = false;
|
|
6817
|
+
let timer = null;
|
|
6818
|
+
const tick = async () => {
|
|
6819
|
+
try {
|
|
6820
|
+
const tasks = await rest.listSubagentTasks({ conversationId });
|
|
6821
|
+
if (cancelled) return;
|
|
6822
|
+
setFetched(tasks);
|
|
6823
|
+
} catch (_error) {}
|
|
6824
|
+
if (!cancelled) timer = setTimeout(tick, POLL_INTERVAL_MS);
|
|
6825
|
+
};
|
|
6826
|
+
tick();
|
|
6827
|
+
return () => {
|
|
6828
|
+
cancelled = true;
|
|
6829
|
+
if (timer !== null) clearTimeout(timer);
|
|
6830
|
+
};
|
|
6831
|
+
}, [
|
|
6832
|
+
rest,
|
|
6833
|
+
conversationId,
|
|
6834
|
+
idsKey,
|
|
6835
|
+
live
|
|
6836
|
+
]);
|
|
6837
|
+
if (rows.length === 0) return /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
|
|
6838
|
+
fg: theme.warning,
|
|
6839
|
+
children: "◐ "
|
|
6840
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
6841
|
+
fg: theme.tool,
|
|
6842
|
+
children: "Delegating to subagents…"
|
|
6843
|
+
})] });
|
|
6844
|
+
return /* @__PURE__ */ jsx(TaskCardContent, {
|
|
6845
|
+
label: rows.length === 1 ? "Subagent" : "Subagents",
|
|
6846
|
+
todos,
|
|
6847
|
+
isActive: isActive ?? live
|
|
6848
|
+
});
|
|
6849
|
+
}
|
|
6850
|
+
|
|
6851
|
+
//#endregion
|
|
6852
|
+
//#region src/chat/tui/chat/subagent-card.tsx
|
|
6853
|
+
/**
|
|
6854
|
+
* Delegated work pinned above the plan. Each batch owns its status polling and
|
|
6855
|
+
* stays as a dimmed completion summary after every run settles.
|
|
6856
|
+
*/
|
|
6857
|
+
function SubagentCardView({ items, isActive, onHeightChange, onBatchLiveChange }) {
|
|
6858
|
+
const [heightById, setHeightById] = useState({});
|
|
6859
|
+
const setBatchHeight = useCallback((id, height) => {
|
|
6860
|
+
setHeightById((current) => current[id] === height ? current : {
|
|
6861
|
+
...current,
|
|
6862
|
+
[id]: height
|
|
6863
|
+
});
|
|
6864
|
+
}, []);
|
|
6865
|
+
const height = useMemo(() => items.length === 0 ? 0 : 1 + items.reduce((rows, item) => rows + (heightById[item.id] ?? 0), 0) + (items.length - 1), [heightById, items]);
|
|
6866
|
+
useEffect(() => onHeightChange?.(height), [height, onHeightChange]);
|
|
6867
|
+
if (items.length === 0) return null;
|
|
6868
|
+
return /* @__PURE__ */ jsx("box", {
|
|
6869
|
+
style: {
|
|
6870
|
+
flexDirection: "column",
|
|
6871
|
+
flexShrink: 0,
|
|
6872
|
+
marginTop: 1,
|
|
6873
|
+
paddingLeft: 1,
|
|
6874
|
+
paddingRight: 1,
|
|
6875
|
+
gap: 1
|
|
6876
|
+
},
|
|
6877
|
+
children: items.map((item) => /* @__PURE__ */ jsx(ToolSubagent, {
|
|
6878
|
+
item,
|
|
6879
|
+
isActive,
|
|
6880
|
+
onLiveChange: (live) => onBatchLiveChange?.(item.id, live),
|
|
6881
|
+
onHeightChange: (batchHeight) => setBatchHeight(item.id, batchHeight)
|
|
6882
|
+
}, item.id))
|
|
6883
|
+
});
|
|
6884
|
+
}
|
|
5814
6885
|
|
|
5815
6886
|
//#endregion
|
|
5816
6887
|
//#region src/chat/tui/chat/pr-links.tsx
|
|
@@ -5969,6 +7040,65 @@ function formatShare(share) {
|
|
|
5969
7040
|
return share.grantedToAgent ? "shared (this agent can run commands here)" : "shared (this agent is not granted)";
|
|
5970
7041
|
}
|
|
5971
7042
|
|
|
7043
|
+
//#endregion
|
|
7044
|
+
//#region src/chat/sandbox/collect-stats.ts
|
|
7045
|
+
function collectSandboxStats(cfg) {
|
|
7046
|
+
const open = cfg.open ?? SandboxStream.open;
|
|
7047
|
+
const dec = new TextDecoder();
|
|
7048
|
+
let buffer = "";
|
|
7049
|
+
let settled = false;
|
|
7050
|
+
let stream = null;
|
|
7051
|
+
let resolveRef = null;
|
|
7052
|
+
return {
|
|
7053
|
+
result: new Promise((resolve) => {
|
|
7054
|
+
resolveRef = resolve;
|
|
7055
|
+
const settle = (r) => {
|
|
7056
|
+
if (settled) return;
|
|
7057
|
+
settled = true;
|
|
7058
|
+
resolve(r);
|
|
7059
|
+
};
|
|
7060
|
+
stream = open({
|
|
7061
|
+
mode: "exec",
|
|
7062
|
+
appUrl: cfg.appUrl,
|
|
7063
|
+
sessionToken: cfg.sessionToken,
|
|
7064
|
+
agentId: cfg.agentId,
|
|
7065
|
+
command: SANDBOX_STATS_COMMAND,
|
|
7066
|
+
onEvent: (e) => {
|
|
7067
|
+
if (e.type === "data") buffer += dec.decode(e.bytes);
|
|
7068
|
+
else if (e.type === "error") settle({
|
|
7069
|
+
ok: false,
|
|
7070
|
+
error: e.message
|
|
7071
|
+
});
|
|
7072
|
+
else if (e.type === "exit") {
|
|
7073
|
+
const stats = parseSandboxStats(buffer);
|
|
7074
|
+
if (stats) settle({
|
|
7075
|
+
ok: true,
|
|
7076
|
+
stats
|
|
7077
|
+
});
|
|
7078
|
+
else settle({
|
|
7079
|
+
ok: false,
|
|
7080
|
+
error: e.code === 0 ? "unexpected output from the sandbox" : `sandbox command exited ${e.code}`
|
|
7081
|
+
});
|
|
7082
|
+
} else if (e.type === "close") settle({
|
|
7083
|
+
ok: false,
|
|
7084
|
+
error: e.failure ?? "connection closed before the snapshot finished"
|
|
7085
|
+
});
|
|
7086
|
+
}
|
|
7087
|
+
});
|
|
7088
|
+
}),
|
|
7089
|
+
abort: () => {
|
|
7090
|
+
if (settled) return;
|
|
7091
|
+
settled = true;
|
|
7092
|
+
stream?.close();
|
|
7093
|
+
resolveRef?.({
|
|
7094
|
+
ok: false,
|
|
7095
|
+
aborted: true,
|
|
7096
|
+
error: "aborted"
|
|
7097
|
+
});
|
|
7098
|
+
}
|
|
7099
|
+
};
|
|
7100
|
+
}
|
|
7101
|
+
|
|
5972
7102
|
//#endregion
|
|
5973
7103
|
//#region src/chat/notification-preview.ts
|
|
5974
7104
|
const MAX_NOTIFICATION_PREVIEW_CHARS = 240;
|
|
@@ -6019,6 +7149,101 @@ function createResponsePreview() {
|
|
|
6019
7149
|
};
|
|
6020
7150
|
}
|
|
6021
7151
|
|
|
7152
|
+
//#endregion
|
|
7153
|
+
//#region src/chat/tui/new-chat-splash.tsx
|
|
7154
|
+
/** Rows the splash art needs before it's worth painting: the 6-row mark, a
|
|
7155
|
+
* spacer, the two identity/hint lines, and a row of breathing room. Below
|
|
7156
|
+
* this only the identity + hint render (small panes must keep every
|
|
7157
|
+
* transcript row). */
|
|
7158
|
+
const MARK_MIN_ROWS = 10;
|
|
7159
|
+
/** Columns the full mark + wordmark lockup spans (~68), with margin; below
|
|
7160
|
+
* this the pinwheel mark renders alone instead of wrapping the wordmark. */
|
|
7161
|
+
const FULL_MARK_MIN_COLS = 70;
|
|
7162
|
+
/**
|
|
7163
|
+
* Brand splash filling an empty conversation's transcript area: the Skydive
|
|
7164
|
+
* mark centered above the agent's identity and the "say hello" hint, with
|
|
7165
|
+
* the composer live below. The identity line matters here because nothing
|
|
7166
|
+
* else on this screen names the agent — a bare `skydive chat` boots straight
|
|
7167
|
+
* into this view via the configured default. Rendered only while the
|
|
7168
|
+
* transcript is empty, so the first submission replaces it with the regular
|
|
7169
|
+
* conversation view. Degrades by available space: full lockup → mark only →
|
|
7170
|
+
* identity + hint only.
|
|
7171
|
+
*/
|
|
7172
|
+
function NewChatSplash({ agentName, agentTitle, width, height }) {
|
|
7173
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
7174
|
+
style: {
|
|
7175
|
+
height,
|
|
7176
|
+
flexDirection: "column",
|
|
7177
|
+
justifyContent: "center",
|
|
7178
|
+
alignItems: "center"
|
|
7179
|
+
},
|
|
7180
|
+
children: [
|
|
7181
|
+
height >= MARK_MIN_ROWS ? /* @__PURE__ */ jsx("box", {
|
|
7182
|
+
style: {
|
|
7183
|
+
flexDirection: "column",
|
|
7184
|
+
alignItems: "center",
|
|
7185
|
+
marginBottom: 1
|
|
7186
|
+
},
|
|
7187
|
+
children: /* @__PURE__ */ jsx(SkydiveMark, { variant: width >= FULL_MARK_MIN_COLS ? "full" : "mark" })
|
|
7188
|
+
}) : null,
|
|
7189
|
+
/* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
|
|
7190
|
+
fg: theme.accent,
|
|
7191
|
+
children: agentName
|
|
7192
|
+
}), agentTitle ? /* @__PURE__ */ jsxs("span", {
|
|
7193
|
+
fg: theme.muted,
|
|
7194
|
+
children: [" · ", agentTitle]
|
|
7195
|
+
}) : null] }),
|
|
7196
|
+
/* @__PURE__ */ jsx("text", {
|
|
7197
|
+
fg: theme.dim,
|
|
7198
|
+
children: "say hello below"
|
|
7199
|
+
})
|
|
7200
|
+
]
|
|
7201
|
+
});
|
|
7202
|
+
}
|
|
7203
|
+
|
|
7204
|
+
//#endregion
|
|
7205
|
+
//#region src/chat/tui/spinner-frames.ts
|
|
7206
|
+
/** Braille dot-cycle frames — the classic smooth terminal spinner. */
|
|
7207
|
+
const SPINNER_FRAMES = [
|
|
7208
|
+
"⠋",
|
|
7209
|
+
"⠙",
|
|
7210
|
+
"⠹",
|
|
7211
|
+
"⠸",
|
|
7212
|
+
"⠼",
|
|
7213
|
+
"⠴",
|
|
7214
|
+
"⠦",
|
|
7215
|
+
"⠧",
|
|
7216
|
+
"⠇",
|
|
7217
|
+
"⠏"
|
|
7218
|
+
];
|
|
7219
|
+
/** Milliseconds between frames. ~12.5fps reads as smooth without thrashing. */
|
|
7220
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
7221
|
+
/** Frame for a given tick count. Wraps at the end of the cycle; negative-safe. */
|
|
7222
|
+
function spinnerFrame(tick) {
|
|
7223
|
+
const n = SPINNER_FRAMES.length;
|
|
7224
|
+
return SPINNER_FRAMES[(Math.trunc(tick) % n + n) % n] ?? SPINNER_FRAMES[0];
|
|
7225
|
+
}
|
|
7226
|
+
|
|
7227
|
+
//#endregion
|
|
7228
|
+
//#region src/chat/tui/spinner.tsx
|
|
7229
|
+
/**
|
|
7230
|
+
* An animated spinner glyph. Advances one frame every SPINNER_INTERVAL_MS via
|
|
7231
|
+
* a self-owned interval that's cleared on unmount, so the animation runs only
|
|
7232
|
+
* while the component is mounted. Returns a <span>, so render it inside a
|
|
7233
|
+
* <text> (matching how the rest of the TUI composes inline glyphs).
|
|
7234
|
+
*/
|
|
7235
|
+
function Spinner({ color }) {
|
|
7236
|
+
const [tick, setTick] = useState(0);
|
|
7237
|
+
useEffect(() => {
|
|
7238
|
+
const id = setInterval(() => setTick((t) => t + 1), SPINNER_INTERVAL_MS);
|
|
7239
|
+
return () => clearInterval(id);
|
|
7240
|
+
}, []);
|
|
7241
|
+
return /* @__PURE__ */ jsx("span", {
|
|
7242
|
+
fg: color,
|
|
7243
|
+
children: spinnerFrame(tick)
|
|
7244
|
+
});
|
|
7245
|
+
}
|
|
7246
|
+
|
|
6022
7247
|
//#endregion
|
|
6023
7248
|
//#region src/chat/tui/prompt-history.ts
|
|
6024
7249
|
/**
|
|
@@ -6752,6 +7977,13 @@ const slashCommands = [
|
|
|
6752
7977
|
description: "switch conversation (no id: pick from the list)",
|
|
6753
7978
|
action: { kind: "conversation" }
|
|
6754
7979
|
},
|
|
7980
|
+
{
|
|
7981
|
+
name: "search",
|
|
7982
|
+
aliases: ["find"],
|
|
7983
|
+
argsHint: "[query]",
|
|
7984
|
+
description: "search conversations by title, message, or participant",
|
|
7985
|
+
action: { kind: "search" }
|
|
7986
|
+
},
|
|
6755
7987
|
{
|
|
6756
7988
|
name: "model",
|
|
6757
7989
|
aliases: [],
|
|
@@ -6773,6 +8005,13 @@ const slashCommands = [
|
|
|
6773
8005
|
description: "show connection and session status",
|
|
6774
8006
|
action: { kind: "status" }
|
|
6775
8007
|
},
|
|
8008
|
+
{
|
|
8009
|
+
name: "computer",
|
|
8010
|
+
aliases: ["stats"],
|
|
8011
|
+
argsHint: null,
|
|
8012
|
+
description: "show the agent sandbox's cpu, memory, and disk",
|
|
8013
|
+
action: { kind: "computer" }
|
|
8014
|
+
},
|
|
6776
8015
|
{
|
|
6777
8016
|
name: "plan",
|
|
6778
8017
|
aliases: ["todos", "todo"],
|
|
@@ -6847,7 +8086,8 @@ const slashCommands = [
|
|
|
6847
8086
|
*
|
|
6848
8087
|
* An argument-less command only matches as the exact line — `/help me write
|
|
6849
8088
|
* a plan` stays an ordinary message rather than swallowing the user's text
|
|
6850
|
-
* into the help modal.
|
|
8089
|
+
* into the help modal. `sandbox`, `conversation`, `search` and `rename`
|
|
8090
|
+
* accept a rest (their argsHint is non-null).
|
|
6851
8091
|
*/
|
|
6852
8092
|
function matchSlashCommand(line) {
|
|
6853
8093
|
if (!line.startsWith("/")) return null;
|
|
@@ -9950,6 +11190,10 @@ function routeInput(raw) {
|
|
|
9950
11190
|
kind: "open-conversation",
|
|
9951
11191
|
ref: match.rest
|
|
9952
11192
|
} : { kind: "conversation-picker" };
|
|
11193
|
+
case "search": return {
|
|
11194
|
+
kind: "conversation-search",
|
|
11195
|
+
query: match.rest
|
|
11196
|
+
};
|
|
9953
11197
|
case "rename-conversation": return {
|
|
9954
11198
|
kind: "rename-conversation",
|
|
9955
11199
|
title: match.rest
|
|
@@ -9967,6 +11211,7 @@ function routeInput(raw) {
|
|
|
9967
11211
|
case "model-picker":
|
|
9968
11212
|
case "theme-picker":
|
|
9969
11213
|
case "status":
|
|
11214
|
+
case "computer":
|
|
9970
11215
|
case "share":
|
|
9971
11216
|
case "browser":
|
|
9972
11217
|
case "connect":
|
|
@@ -10034,47 +11279,6 @@ async function runPtySession(opts) {
|
|
|
10034
11279
|
}
|
|
10035
11280
|
}
|
|
10036
11281
|
|
|
10037
|
-
//#endregion
|
|
10038
|
-
//#region src/chat/tui/screens/conversation-channel.ts
|
|
10039
|
-
function isNewConversation(c) {
|
|
10040
|
-
return "kind" in c && c.kind === "new";
|
|
10041
|
-
}
|
|
10042
|
-
/**
|
|
10043
|
-
* The channel a conversation belongs to when it isn't the web channel, else
|
|
10044
|
-
* null. A brand-new conversation is always web, and the list route reports the
|
|
10045
|
-
* web channel as either `'web'` or (legacy) null — both mean "post from here".
|
|
10046
|
-
* Anything else (slack, email, imessage, webhook, cron) is owned by that
|
|
10047
|
-
* channel: the message has to go through it, so the TUI can't send here.
|
|
10048
|
-
* Mirrors the web app's `channel !== null && channel !== 'web'` gate.
|
|
10049
|
-
*/
|
|
10050
|
-
function nonWebChannelLabel(c) {
|
|
10051
|
-
if (isNewConversation(c)) return null;
|
|
10052
|
-
if (c.channel === null || c.channel === "web") return null;
|
|
10053
|
-
return c.channelLabel ?? c.channel;
|
|
10054
|
-
}
|
|
10055
|
-
/**
|
|
10056
|
-
* The list-row shape for a conversation the TUI just forked, so the chat
|
|
10057
|
-
* screen can reopen on the copy without waiting for a list refetch. A fork
|
|
10058
|
-
* carries none of the source's channel binding — that's what makes it
|
|
10059
|
-
* sendable from here — so it is always a web conversation.
|
|
10060
|
-
*/
|
|
10061
|
-
function forkedConversationRef({ agent, forked }) {
|
|
10062
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
10063
|
-
return {
|
|
10064
|
-
id: forked.id,
|
|
10065
|
-
title: forked.title,
|
|
10066
|
-
createdAt: now,
|
|
10067
|
-
updatedAt: now,
|
|
10068
|
-
preview: null,
|
|
10069
|
-
channel: "web",
|
|
10070
|
-
channelLabel: null,
|
|
10071
|
-
agent: {
|
|
10072
|
-
id: agent.id,
|
|
10073
|
-
name: agent.name
|
|
10074
|
-
}
|
|
10075
|
-
};
|
|
10076
|
-
}
|
|
10077
|
-
|
|
10078
11282
|
//#endregion
|
|
10079
11283
|
//#region src/chat/tui/screens/chat.tsx
|
|
10080
11284
|
/**
|
|
@@ -10159,11 +11363,13 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10159
11363
|
const portalClient = useStore((s) => s.portalClient);
|
|
10160
11364
|
const setPortalPrompt = useStore((s) => s.setPortalPrompt);
|
|
10161
11365
|
const declinedRef = useRef(/* @__PURE__ */ new Set());
|
|
11366
|
+
const grantInFlightRef = useRef(/* @__PURE__ */ new Set());
|
|
10162
11367
|
const grantPrompt = portal.prompt && portal.prompt.agentId === agent.id ? portal.prompt : null;
|
|
10163
11368
|
const isShared = portal.status !== "off";
|
|
10164
11369
|
const initialConversationId = isNewConversation(conversation) ? null : conversation.id;
|
|
10165
11370
|
const agentHost = useAgentHost();
|
|
10166
11371
|
const nonWebChannel = nonWebChannelLabel(conversation);
|
|
11372
|
+
const agentRunConversation = isAgentRunConversation(conversation);
|
|
10167
11373
|
const [conversationId, setConversationId] = useState(initialConversationId);
|
|
10168
11374
|
const { compacting, compactConversation, onCompactionEvent } = useCompaction({
|
|
10169
11375
|
rest,
|
|
@@ -10172,6 +11378,12 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10172
11378
|
const [items, setItems] = useState([]);
|
|
10173
11379
|
const [todos, setTodos] = useState(null);
|
|
10174
11380
|
const [planHidden, setPlanHidden] = useState(false);
|
|
11381
|
+
const [subagentRows, setSubagentRows] = useState(0);
|
|
11382
|
+
const [anchoredSubagentIds, setAnchoredSubagentIds] = useState([]);
|
|
11383
|
+
const anchoredSubagentLiveRef = useRef({});
|
|
11384
|
+
const setAnchoredBatchLive = useCallback((id, live) => {
|
|
11385
|
+
anchoredSubagentLiveRef.current[id] = live;
|
|
11386
|
+
}, []);
|
|
10175
11387
|
const [scrolledUp, setScrolledUp] = useState(false);
|
|
10176
11388
|
const hadTodosRef = useRef(false);
|
|
10177
11389
|
useEffect(() => {
|
|
@@ -10214,14 +11426,18 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10214
11426
|
const forkingRef = useRef(false);
|
|
10215
11427
|
const [credPrompt, setCredPrompt] = useState(null);
|
|
10216
11428
|
const credPromptOpen = credPrompt !== null;
|
|
11429
|
+
const [computePrompt, setComputePrompt] = useState(null);
|
|
11430
|
+
const computePromptOpen = computePrompt !== null;
|
|
10217
11431
|
const [menuDismissed, setMenuDismissed] = useState(false);
|
|
10218
11432
|
const [menuHighlight, setMenuHighlight] = useState(0);
|
|
10219
|
-
const commandQuery = grantPrompt || credPrompt ? null : detectCommandTrigger(input);
|
|
11433
|
+
const commandQuery = grantPrompt || credPrompt || computePrompt ? null : detectCommandTrigger(input);
|
|
10220
11434
|
const menuCommands = commandQuery === null ? [] : filterSlashCommands(commandQuery);
|
|
10221
11435
|
const menuOpen = commandQuery !== null && !menuDismissed;
|
|
10222
11436
|
const menuHighlightClamped = Math.min(menuHighlight, Math.max(0, menuCommands.length - 1));
|
|
10223
11437
|
const runRef = useRef(run);
|
|
10224
11438
|
runRef.current = run;
|
|
11439
|
+
const conversationIdRef = useRef(conversationId);
|
|
11440
|
+
conversationIdRef.current = conversationId;
|
|
10225
11441
|
const itemsRef = useRef(items);
|
|
10226
11442
|
itemsRef.current = items;
|
|
10227
11443
|
const attachmentsRef = useRef(attachments);
|
|
@@ -10232,7 +11448,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10232
11448
|
credPromptRef.current = credPrompt;
|
|
10233
11449
|
const seenRunsRef = useRef(/* @__PURE__ */ new Set());
|
|
10234
11450
|
const shellSessionRef = useRef(null);
|
|
10235
|
-
const hostState = grantPrompt || credPrompt ? "blocked" : run.kind === "idle" ? "idle" : "working";
|
|
11451
|
+
const hostState = grantPrompt || credPrompt || computePrompt ? "blocked" : run.kind === "idle" ? "idle" : "working";
|
|
10236
11452
|
useEffect(() => {
|
|
10237
11453
|
agentHost.transition({ state: hostState });
|
|
10238
11454
|
}, [agentHost, hostState]);
|
|
@@ -10271,13 +11487,16 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10271
11487
|
});
|
|
10272
11488
|
setScrolledUp(false);
|
|
10273
11489
|
}, []);
|
|
11490
|
+
const scrolledUpRef = useRef(false);
|
|
10274
11491
|
useEffect(() => {
|
|
10275
11492
|
const id = setInterval(() => {
|
|
10276
11493
|
const box = scrollRef.current;
|
|
10277
11494
|
if (!box) return;
|
|
10278
11495
|
const viewport = box.viewport.height;
|
|
10279
|
-
const
|
|
10280
|
-
|
|
11496
|
+
const next = !(box.scrollTop + viewport >= box.scrollHeight - 1);
|
|
11497
|
+
if (scrolledUpRef.current === next) return;
|
|
11498
|
+
scrolledUpRef.current = next;
|
|
11499
|
+
setScrolledUp(next);
|
|
10281
11500
|
}, 150);
|
|
10282
11501
|
return () => clearInterval(id);
|
|
10283
11502
|
}, []);
|
|
@@ -10285,11 +11504,12 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10285
11504
|
const reviewBelowBoxRef = useRef(null);
|
|
10286
11505
|
const paneDragRef = useRef(null);
|
|
10287
11506
|
const composerBoxHeight = composerRows + 2;
|
|
10288
|
-
const pendingVisible = !grantPrompt && !credPrompt && !nonWebChannel && attachments.length > 0;
|
|
11507
|
+
const pendingVisible = !grantPrompt && !credPrompt && !computePrompt && !nonWebChannel && attachments.length > 0;
|
|
10289
11508
|
const pendingRows = pendingVisible ? attachments.length + 2 : 0;
|
|
10290
11509
|
const credPromptHeight = 4 + (credPrompt && (credPrompt.error || credPrompt.submitting) ? 1 : 0);
|
|
10291
11510
|
const nonWebNoticeHeight = 5;
|
|
10292
|
-
const
|
|
11511
|
+
const computePromptHeight = 4;
|
|
11512
|
+
const bottomBoxHeight = credPrompt ? credPromptHeight : computePrompt ? computePromptHeight : nonWebChannel ? nonWebNoticeHeight : composerBoxHeight;
|
|
10293
11513
|
const bodyWidth = Math.max(1, width - 2);
|
|
10294
11514
|
const bodyHeight = Math.max(1, height - 3);
|
|
10295
11515
|
const reviewPlacement = filePanePlacement({
|
|
@@ -10306,9 +11526,13 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10306
11526
|
const menuRows = menuOpen ? completionMenuHeight(menuCommands.length) : 0;
|
|
10307
11527
|
const todoCardVisible = !credPrompt && !planHidden && !!todos && todos.length > 0;
|
|
10308
11528
|
const todoRows = todoCardVisible ? todoCardHeight(todos) : 0;
|
|
11529
|
+
const subagentItems = useMemo(() => {
|
|
11530
|
+
const ids = new Set(anchoredSubagentIds);
|
|
11531
|
+
return items.filter((item) => item.kind === "tool" && item.toolName === "subagent" && ids.has(item.id));
|
|
11532
|
+
}, [items, anchoredSubagentIds]);
|
|
10309
11533
|
const jumpHintVisible = scrolledUp && !credPrompt;
|
|
10310
11534
|
const jumpHintRows = jumpHintVisible ? 1 : 0;
|
|
10311
|
-
const scrollHeight = Math.max(3, chatHeight - 2 - bottomBoxHeight - pendingRows - inlineReviewHeight - menuRows - jumpHintRows - todoRows);
|
|
11535
|
+
const scrollHeight = Math.max(3, chatHeight - 2 - bottomBoxHeight - pendingRows - inlineReviewHeight - menuRows - jumpHintRows - subagentRows - todoRows);
|
|
10312
11536
|
useEffect(() => {
|
|
10313
11537
|
if (reviewOpen && reviewFocused) {
|
|
10314
11538
|
const composer = composerRef.current;
|
|
@@ -10342,7 +11566,8 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10342
11566
|
reviewOpen,
|
|
10343
11567
|
reviewFocused,
|
|
10344
11568
|
reviewPlacement,
|
|
10345
|
-
credPromptOpen
|
|
11569
|
+
credPromptOpen,
|
|
11570
|
+
computePromptOpen
|
|
10346
11571
|
]);
|
|
10347
11572
|
useEffect(() => {
|
|
10348
11573
|
if (!rest || !initialConversationId) return;
|
|
@@ -10358,10 +11583,13 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10358
11583
|
text: recapText
|
|
10359
11584
|
});
|
|
10360
11585
|
setItems(loaded);
|
|
11586
|
+
const toolUrlText = loaded.map((item) => item.kind === "tool" && item.output?.kind === "ok" ? stringifyToolOutput(item.output.value) : "").join("\n");
|
|
10361
11587
|
for (const item of loaded) {
|
|
10362
|
-
|
|
10363
|
-
|
|
10364
|
-
|
|
11588
|
+
if (item.kind !== "assistant-text") continue;
|
|
11589
|
+
for (const pr of extractPullRequests(item.text, {
|
|
11590
|
+
requireCompleteNumber: false,
|
|
11591
|
+
urlOnlyText: toolUrlText
|
|
11592
|
+
})) {
|
|
10365
11593
|
if (reportedPrsRef.current.has(pr.key)) continue;
|
|
10366
11594
|
reportedPrsRef.current.set(pr.key, pr);
|
|
10367
11595
|
setPrLinks((prev) => [...prev, pr]);
|
|
@@ -10388,6 +11616,15 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10388
11616
|
initialConversationId,
|
|
10389
11617
|
agentHost
|
|
10390
11618
|
]);
|
|
11619
|
+
const markRead = useCallback(() => {
|
|
11620
|
+
const id = conversationIdRef.current;
|
|
11621
|
+
if (!rest || !id) return;
|
|
11622
|
+
rest.markConversationRead({ conversationId: id }).catch(() => {});
|
|
11623
|
+
}, [rest]);
|
|
11624
|
+
useEffect(() => {
|
|
11625
|
+
if (!conversationId) return;
|
|
11626
|
+
markRead();
|
|
11627
|
+
}, [conversationId, markRead]);
|
|
10391
11628
|
const attachToRun = useCallback((runId, runAgentName, runStartedAtMs) => {
|
|
10392
11629
|
if (!rest) return;
|
|
10393
11630
|
seenRunsRef.current.add(runId);
|
|
@@ -10400,6 +11637,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10400
11637
|
agentHost.addPullRequest(pr);
|
|
10401
11638
|
});
|
|
10402
11639
|
const streamAgentName = runAgentName ?? agent.name;
|
|
11640
|
+
let sawErrorChunk = false;
|
|
10403
11641
|
setRun((prev) => ({
|
|
10404
11642
|
kind: "streaming",
|
|
10405
11643
|
runId,
|
|
@@ -10411,8 +11649,21 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10411
11649
|
signal: abort.signal,
|
|
10412
11650
|
onEvent: (event) => {
|
|
10413
11651
|
if (event.kind === "chunk") {
|
|
11652
|
+
if (event.chunk.type === "error") sawErrorChunk = true;
|
|
10414
11653
|
responsePreview.addChunk(event.chunk);
|
|
10415
11654
|
prScanner.addChunk(event.chunk);
|
|
11655
|
+
if ((event.chunk.type === "tool-input-start" || event.chunk.type === "tool-input-available") && event.chunk.toolName === "subagent" && typeof event.chunk.toolCallId === "string") {
|
|
11656
|
+
const id = event.chunk.toolCallId;
|
|
11657
|
+
setAnchoredSubagentIds((current) => {
|
|
11658
|
+
const next = nextAnchoredSubagentIds({
|
|
11659
|
+
current,
|
|
11660
|
+
nextId: id,
|
|
11661
|
+
liveById: anchoredSubagentLiveRef.current
|
|
11662
|
+
});
|
|
11663
|
+
if (next.length === 1 && next[0] === id) anchoredSubagentLiveRef.current = { [id]: true };
|
|
11664
|
+
return next;
|
|
11665
|
+
});
|
|
11666
|
+
}
|
|
10416
11667
|
if (event.chunk.type === "data-anyone-todos-updated") {
|
|
10417
11668
|
const next = todosFromPayload(event.chunk.data);
|
|
10418
11669
|
if (next) setTodos(next.length > 0 ? next : null);
|
|
@@ -10428,7 +11679,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10428
11679
|
id: crypto.randomUUID(),
|
|
10429
11680
|
text: billingOutcome.message
|
|
10430
11681
|
}]);
|
|
10431
|
-
else if (event.status !== "ok" && errorText) setItems((prev) => [...prev, {
|
|
11682
|
+
else if (event.status !== "ok" && errorText && !sawErrorChunk) setItems((prev) => [...prev, {
|
|
10432
11683
|
kind: "error",
|
|
10433
11684
|
id: crypto.randomUUID(),
|
|
10434
11685
|
text: errorText
|
|
@@ -10436,9 +11687,11 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10436
11687
|
setItems((prev) => prev.map((m) => m.kind === "pending-steer" ? {
|
|
10437
11688
|
kind: "user",
|
|
10438
11689
|
id: m.id,
|
|
10439
|
-
text: m.text
|
|
11690
|
+
text: m.text,
|
|
11691
|
+
attachments: m.attachments
|
|
10440
11692
|
} : m));
|
|
10441
11693
|
setRun({ kind: "idle" });
|
|
11694
|
+
markRead();
|
|
10442
11695
|
agentHost.transition({
|
|
10443
11696
|
state: "idle",
|
|
10444
11697
|
notification: {
|
|
@@ -10460,7 +11713,8 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10460
11713
|
}, [
|
|
10461
11714
|
rest,
|
|
10462
11715
|
agent.name,
|
|
10463
|
-
agentHost
|
|
11716
|
+
agentHost,
|
|
11717
|
+
markRead
|
|
10464
11718
|
]);
|
|
10465
11719
|
const multiAgent = useMemo(() => conversationAgentNames(items, agent.name), [items, agent.name]).size > 1;
|
|
10466
11720
|
const transcriptWindow = useMemo(() => windowItems(items), [items]);
|
|
@@ -10513,7 +11767,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10513
11767
|
kind: "user",
|
|
10514
11768
|
id: optimisticId,
|
|
10515
11769
|
text: trimmed,
|
|
10516
|
-
|
|
11770
|
+
attachments: stagedNames
|
|
10517
11771
|
}]);
|
|
10518
11772
|
const wasStreaming = runRef.current.kind === "streaming";
|
|
10519
11773
|
if (!wasStreaming) setRun({
|
|
@@ -10562,7 +11816,8 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10562
11816
|
if (directiveId) setItems((prev) => prev.map((m) => m.id === optimisticId ? {
|
|
10563
11817
|
kind: "pending-steer",
|
|
10564
11818
|
id: directiveId,
|
|
10565
|
-
text: trimmed
|
|
11819
|
+
text: trimmed,
|
|
11820
|
+
attachments: m.kind === "user" || m.kind === "pending-steer" ? m.attachments : []
|
|
10566
11821
|
} : m));
|
|
10567
11822
|
const current = runRef.current;
|
|
10568
11823
|
if (!(current.kind === "streaming" && current.runId === result.runId)) attachToRun(result.runId, result.agentName ?? null);
|
|
@@ -10609,12 +11864,14 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10609
11864
|
});
|
|
10610
11865
|
(async () => {
|
|
10611
11866
|
if (autoGrantMachine && portalClient && portalStatus === "connected" && !portalClient.isGranted(agent.id)) try {
|
|
10612
|
-
|
|
11867
|
+
grantInFlightRef.current.add(agent.id);
|
|
11868
|
+
await portalClient.grantAgent(agent.id, null);
|
|
10613
11869
|
useStore.getState().showToast({
|
|
10614
|
-
message: `Shared this machine with ${agent.name} for the import. The grant persists. Revoke anytime: skydive portal revoke --agent ${agent.name}`,
|
|
11870
|
+
message: `Shared this machine with ${agent.name} for the import. The grant persists. Revoke anytime: skydive portal revoke --agent "${agent.name}"`,
|
|
10615
11871
|
durationMs: 1e4
|
|
10616
11872
|
});
|
|
10617
11873
|
} catch (err) {
|
|
11874
|
+
grantInFlightRef.current.delete(agent.id);
|
|
10618
11875
|
useStore.getState().showToast({
|
|
10619
11876
|
message: `Couldn't grant machine access automatically (${errorMessage(err)}). The agent will ask in chat instead.`,
|
|
10620
11877
|
variant: "warning"
|
|
@@ -10669,6 +11926,14 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10669
11926
|
});
|
|
10670
11927
|
return;
|
|
10671
11928
|
}
|
|
11929
|
+
if (action.kind === "decide_compute") {
|
|
11930
|
+
setComputePrompt({
|
|
11931
|
+
cardId: item.id,
|
|
11932
|
+
requestId: action.requestId,
|
|
11933
|
+
summary: item.card.subtitle
|
|
11934
|
+
});
|
|
11935
|
+
return;
|
|
11936
|
+
}
|
|
10672
11937
|
updateCard(item.id, {
|
|
10673
11938
|
status: "busy",
|
|
10674
11939
|
detail: null
|
|
@@ -10678,9 +11943,12 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10678
11943
|
if (!portalClient) throw new Error("portal is unavailable in this session");
|
|
10679
11944
|
const justEnabled = portal.status === "off";
|
|
10680
11945
|
if (justEnabled) portalClient.enable();
|
|
11946
|
+
grantInFlightRef.current.add(action.agentId);
|
|
11947
|
+
if (useStore.getState().portal.prompt?.agentId === action.agentId) setPortalPrompt(null);
|
|
10681
11948
|
try {
|
|
10682
|
-
await portalClient.grantAgent(action.agentId);
|
|
11949
|
+
await portalClient.grantAgent(action.agentId, action.conversationId);
|
|
10683
11950
|
} catch (grantErr) {
|
|
11951
|
+
grantInFlightRef.current.delete(action.agentId);
|
|
10684
11952
|
if (justEnabled) portalClient.disable();
|
|
10685
11953
|
throw grantErr;
|
|
10686
11954
|
}
|
|
@@ -10741,8 +12009,45 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10741
12009
|
appUrl,
|
|
10742
12010
|
updateCard,
|
|
10743
12011
|
portalClient,
|
|
10744
|
-
portal.status
|
|
12012
|
+
portal.status,
|
|
12013
|
+
setPortalPrompt
|
|
10745
12014
|
]);
|
|
12015
|
+
const decideCompute = useCallback((prompt, decision) => {
|
|
12016
|
+
setComputePrompt(null);
|
|
12017
|
+
if (!rest) return;
|
|
12018
|
+
updateCard(prompt.cardId, {
|
|
12019
|
+
status: "busy",
|
|
12020
|
+
detail: null
|
|
12021
|
+
});
|
|
12022
|
+
const settleIfStillDeciding = (patch) => {
|
|
12023
|
+
setItems((prev) => prev.map((m) => m.kind === "card" && m.id === prompt.cardId && m.status === "busy" && m.card.settled === null ? {
|
|
12024
|
+
...m,
|
|
12025
|
+
...patch
|
|
12026
|
+
} : m));
|
|
12027
|
+
};
|
|
12028
|
+
(async () => {
|
|
12029
|
+
try {
|
|
12030
|
+
const { status } = await rest.decideComputeRequest({
|
|
12031
|
+
requestId: prompt.requestId,
|
|
12032
|
+
decision
|
|
12033
|
+
});
|
|
12034
|
+
settleIfStillDeciding({
|
|
12035
|
+
status: "done",
|
|
12036
|
+
detail: computeSettledLabel(status) ?? status
|
|
12037
|
+
});
|
|
12038
|
+
} catch (err) {
|
|
12039
|
+
settleIfStillDeciding({
|
|
12040
|
+
status: "error",
|
|
12041
|
+
detail: cardActionErrorMessage(err)
|
|
12042
|
+
});
|
|
12043
|
+
}
|
|
12044
|
+
})();
|
|
12045
|
+
}, [rest, updateCard]);
|
|
12046
|
+
useEffect(() => {
|
|
12047
|
+
if (!computePrompt) return;
|
|
12048
|
+
const target = items.find((m) => m.kind === "card" && m.id === computePrompt.cardId);
|
|
12049
|
+
if (!target || target.card.settled !== null || target.status === "done") setComputePrompt(null);
|
|
12050
|
+
}, [computePrompt, items]);
|
|
10746
12051
|
const promptForCards = useCallback(() => {
|
|
10747
12052
|
const targets = itemsRef.current.filter(isActionableCard);
|
|
10748
12053
|
const [only] = targets;
|
|
@@ -10905,6 +12210,16 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10905
12210
|
sessionToken,
|
|
10906
12211
|
agent.id
|
|
10907
12212
|
]);
|
|
12213
|
+
const runComputerStats = useCallback(() => {
|
|
12214
|
+
if (!appUrl || !sessionToken) return;
|
|
12215
|
+
setItems((prev) => [...prev, {
|
|
12216
|
+
kind: "computer",
|
|
12217
|
+
id: crypto.randomUUID(),
|
|
12218
|
+
state: "loading",
|
|
12219
|
+
stats: null,
|
|
12220
|
+
error: null
|
|
12221
|
+
}]);
|
|
12222
|
+
}, [appUrl, sessionToken]);
|
|
10908
12223
|
const runSandboxPty = useCallback(async () => {
|
|
10909
12224
|
if (!appUrl || !sessionToken) return;
|
|
10910
12225
|
const result = await runPtySession({
|
|
@@ -10999,7 +12314,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
10999
12314
|
stageAttachment(image);
|
|
11000
12315
|
}, [stageAttachment]);
|
|
11001
12316
|
usePaste((event) => {
|
|
11002
|
-
if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt || nonWebChannel) return;
|
|
12317
|
+
if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt || computePromptOpen || nonWebChannel) return;
|
|
11003
12318
|
const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
|
|
11004
12319
|
const route = routePaste({
|
|
11005
12320
|
kind: event.metadata?.kind,
|
|
@@ -11053,6 +12368,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11053
12368
|
themePickerOpen,
|
|
11054
12369
|
helpOpen,
|
|
11055
12370
|
credPromptOpen,
|
|
12371
|
+
computePromptOpen,
|
|
11056
12372
|
applyComposerText
|
|
11057
12373
|
]);
|
|
11058
12374
|
const cancelRun = useCallback(async () => {
|
|
@@ -11066,7 +12382,8 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11066
12382
|
setItems((prev) => prev.map((m) => m.kind === "pending-steer" ? {
|
|
11067
12383
|
kind: "user",
|
|
11068
12384
|
id: m.id,
|
|
11069
|
-
text: m.text
|
|
12385
|
+
text: m.text,
|
|
12386
|
+
attachments: m.attachments
|
|
11070
12387
|
} : m));
|
|
11071
12388
|
useStore.getState().showToast({ message: "run cancelled" });
|
|
11072
12389
|
}, [rest]);
|
|
@@ -11134,7 +12451,11 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11134
12451
|
goTo
|
|
11135
12452
|
]);
|
|
11136
12453
|
const archiveConversation = useCallback(() => {
|
|
11137
|
-
if (grantPrompt || credPrompt) return;
|
|
12454
|
+
if (grantPrompt || credPrompt || computePrompt) return;
|
|
12455
|
+
if (agentRunConversation) {
|
|
12456
|
+
useStore.getState().showToast({ message: "agent runs can't be archived — ctrl+d in the picker deletes" });
|
|
12457
|
+
return;
|
|
12458
|
+
}
|
|
11138
12459
|
if (runRef.current.kind !== "idle") {
|
|
11139
12460
|
useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
|
|
11140
12461
|
return;
|
|
@@ -11154,7 +12475,8 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11154
12475
|
});
|
|
11155
12476
|
goTo({
|
|
11156
12477
|
kind: "conversation-picker",
|
|
11157
|
-
agent
|
|
12478
|
+
agent,
|
|
12479
|
+
initialQuery: ""
|
|
11158
12480
|
});
|
|
11159
12481
|
}).catch((err) => useStore.getState().showToast({
|
|
11160
12482
|
variant: "error",
|
|
@@ -11163,6 +12485,8 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11163
12485
|
}, [
|
|
11164
12486
|
grantPrompt,
|
|
11165
12487
|
credPrompt,
|
|
12488
|
+
computePrompt,
|
|
12489
|
+
agentRunConversation,
|
|
11166
12490
|
conversationId,
|
|
11167
12491
|
rest,
|
|
11168
12492
|
goTo,
|
|
@@ -11244,7 +12568,9 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11244
12568
|
rest,
|
|
11245
12569
|
agentSelector: null,
|
|
11246
12570
|
conversationId: target,
|
|
11247
|
-
newConversation: false
|
|
12571
|
+
newConversation: false,
|
|
12572
|
+
defaultAgentSelector: null,
|
|
12573
|
+
forceOnboarding: false
|
|
11248
12574
|
}));
|
|
11249
12575
|
} catch (err) {
|
|
11250
12576
|
setItems((prev) => [...prev, {
|
|
@@ -11283,19 +12609,24 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11283
12609
|
if (!portalClient) return;
|
|
11284
12610
|
const agentName = agent.name;
|
|
11285
12611
|
setPortalPrompt(null);
|
|
11286
|
-
|
|
12612
|
+
grantInFlightRef.current.add(agent.id);
|
|
12613
|
+
portalClient.grantAgent(agent.id, conversationId).then(() => useStore.getState().showToast({
|
|
11287
12614
|
variant: "success",
|
|
11288
12615
|
message: `shared this machine with ${agentName}`
|
|
11289
|
-
})).catch((err) =>
|
|
11290
|
-
|
|
11291
|
-
|
|
11292
|
-
|
|
11293
|
-
|
|
12616
|
+
})).catch((err) => {
|
|
12617
|
+
grantInFlightRef.current.delete(agent.id);
|
|
12618
|
+
setItems((prev) => [...prev, {
|
|
12619
|
+
kind: "error",
|
|
12620
|
+
id: crypto.randomUUID(),
|
|
12621
|
+
text: `couldn't share machine: ${errorDetail(err)}`
|
|
12622
|
+
}]);
|
|
12623
|
+
});
|
|
11294
12624
|
}, [
|
|
11295
12625
|
portalClient,
|
|
11296
12626
|
agent.id,
|
|
11297
12627
|
agent.name,
|
|
11298
|
-
setPortalPrompt
|
|
12628
|
+
setPortalPrompt,
|
|
12629
|
+
conversationId
|
|
11299
12630
|
]);
|
|
11300
12631
|
const declineGrant = useCallback(() => {
|
|
11301
12632
|
declinedRef.current.add(agent.id);
|
|
@@ -11408,7 +12739,15 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11408
12739
|
case "conversation-picker":
|
|
11409
12740
|
leaveForScreen({
|
|
11410
12741
|
kind: "conversation-picker",
|
|
11411
|
-
agent
|
|
12742
|
+
agent,
|
|
12743
|
+
initialQuery: ""
|
|
12744
|
+
});
|
|
12745
|
+
break;
|
|
12746
|
+
case "conversation-search":
|
|
12747
|
+
leaveForScreen({
|
|
12748
|
+
kind: "conversation-picker",
|
|
12749
|
+
agent,
|
|
12750
|
+
initialQuery: routed.query
|
|
11412
12751
|
});
|
|
11413
12752
|
break;
|
|
11414
12753
|
case "open-conversation":
|
|
@@ -11438,6 +12777,9 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11438
12777
|
})
|
|
11439
12778
|
}]);
|
|
11440
12779
|
break;
|
|
12780
|
+
case "computer":
|
|
12781
|
+
runComputerStats();
|
|
12782
|
+
break;
|
|
11441
12783
|
case "plan": {
|
|
11442
12784
|
if (!(!!todos && todos.length > 0)) {
|
|
11443
12785
|
useStore.getState().showToast({ message: "no plan to show yet" });
|
|
@@ -11490,6 +12832,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11490
12832
|
history,
|
|
11491
12833
|
runLocalShell,
|
|
11492
12834
|
runSandboxExec,
|
|
12835
|
+
runComputerStats,
|
|
11493
12836
|
runSandboxPty,
|
|
11494
12837
|
archiveConversation,
|
|
11495
12838
|
renameConversation,
|
|
@@ -11535,6 +12878,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11535
12878
|
if (portal.grantedAgentIds.includes(agent.id)) return;
|
|
11536
12879
|
if (portal.declinedAgentIds.includes(agent.id)) return;
|
|
11537
12880
|
if (declinedRef.current.has(agent.id)) return;
|
|
12881
|
+
if (grantInFlightRef.current.has(agent.id)) return;
|
|
11538
12882
|
if (portal.prompt?.agentId === agent.id) return;
|
|
11539
12883
|
setPortalPrompt({
|
|
11540
12884
|
agentId: agent.id,
|
|
@@ -11558,6 +12902,14 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11558
12902
|
setPortalPrompt,
|
|
11559
12903
|
agentHost
|
|
11560
12904
|
]);
|
|
12905
|
+
useEffect(() => {
|
|
12906
|
+
for (const agentId of portal.grantedAgentIds) grantInFlightRef.current.delete(agentId);
|
|
12907
|
+
if (portal.prompt && portal.grantedAgentIds.includes(portal.prompt.agentId)) setPortalPrompt(null);
|
|
12908
|
+
}, [
|
|
12909
|
+
portal.grantedAgentIds,
|
|
12910
|
+
portal.prompt,
|
|
12911
|
+
setPortalPrompt
|
|
12912
|
+
]);
|
|
11561
12913
|
useKeyboard((key) => {
|
|
11562
12914
|
if (reviewOpen) {
|
|
11563
12915
|
if (key.name === "g" && key.ctrl) {
|
|
@@ -11581,6 +12933,20 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11581
12933
|
return;
|
|
11582
12934
|
}
|
|
11583
12935
|
}
|
|
12936
|
+
if (computePrompt) {
|
|
12937
|
+
if (key.name === "y") {
|
|
12938
|
+
decideCompute(computePrompt, "approve");
|
|
12939
|
+
return;
|
|
12940
|
+
}
|
|
12941
|
+
if (key.name === "n") {
|
|
12942
|
+
decideCompute(computePrompt, "deny");
|
|
12943
|
+
return;
|
|
12944
|
+
}
|
|
12945
|
+
if (key.name === "escape") {
|
|
12946
|
+
setComputePrompt(null);
|
|
12947
|
+
return;
|
|
12948
|
+
}
|
|
12949
|
+
}
|
|
11584
12950
|
if (credPrompt) {
|
|
11585
12951
|
if (key.name === "escape") {
|
|
11586
12952
|
if (!credPrompt.submitting) setCredPrompt(null);
|
|
@@ -11736,21 +13102,51 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11736
13102
|
useStore.getState().showToast({ message: `${agent.name} keeps working; reopen to reattach` });
|
|
11737
13103
|
goTo({
|
|
11738
13104
|
kind: "conversation-picker",
|
|
11739
|
-
agent
|
|
13105
|
+
agent,
|
|
13106
|
+
initialQuery: ""
|
|
11740
13107
|
});
|
|
11741
13108
|
return;
|
|
11742
13109
|
}
|
|
11743
13110
|
if (runRef.current.kind === "idle") goTo({
|
|
11744
13111
|
kind: "conversation-picker",
|
|
11745
|
-
agent
|
|
13112
|
+
agent,
|
|
13113
|
+
initialQuery: ""
|
|
11746
13114
|
});
|
|
11747
13115
|
}
|
|
11748
13116
|
});
|
|
11749
13117
|
const hasPendingSteer = items.some((m) => m.kind === "pending-steer");
|
|
13118
|
+
const latestComputerId = useMemo(() => {
|
|
13119
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
13120
|
+
const item = items[i];
|
|
13121
|
+
if (item && item.kind === "computer") return item.id;
|
|
13122
|
+
}
|
|
13123
|
+
return null;
|
|
13124
|
+
}, [items]);
|
|
13125
|
+
const boundCollectSandboxStats = useMemo(() => {
|
|
13126
|
+
if (!appUrl || !sessionToken) return null;
|
|
13127
|
+
return () => collectSandboxStats({
|
|
13128
|
+
appUrl,
|
|
13129
|
+
sessionToken,
|
|
13130
|
+
agentId: agent.id
|
|
13131
|
+
});
|
|
13132
|
+
}, [
|
|
13133
|
+
appUrl,
|
|
13134
|
+
sessionToken,
|
|
13135
|
+
agent.id
|
|
13136
|
+
]);
|
|
11750
13137
|
const conversationContext = useMemo(() => ({
|
|
11751
13138
|
rest,
|
|
11752
|
-
conversationId
|
|
11753
|
-
|
|
13139
|
+
conversationId,
|
|
13140
|
+
collectSandboxStats: boundCollectSandboxStats,
|
|
13141
|
+
latestComputerId,
|
|
13142
|
+
scrolledUp
|
|
13143
|
+
}), [
|
|
13144
|
+
rest,
|
|
13145
|
+
conversationId,
|
|
13146
|
+
boundCollectSandboxStats,
|
|
13147
|
+
latestComputerId,
|
|
13148
|
+
scrolledUp
|
|
13149
|
+
]);
|
|
11754
13150
|
const actionableCards = useMemo(() => items.filter(isActionableCard), [items]);
|
|
11755
13151
|
const statusText = useMemo(() => formatChatFooterStatus({
|
|
11756
13152
|
runKind: run.kind,
|
|
@@ -11899,13 +13295,11 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11899
13295
|
children: [!historyLoaded ? /* @__PURE__ */ jsx("text", {
|
|
11900
13296
|
fg: theme.muted,
|
|
11901
13297
|
children: "loading history…"
|
|
11902
|
-
}) : items.length === 0 ? /* @__PURE__ */
|
|
11903
|
-
|
|
11904
|
-
|
|
11905
|
-
|
|
11906
|
-
|
|
11907
|
-
" below."
|
|
11908
|
-
]
|
|
13298
|
+
}) : items.length === 0 ? /* @__PURE__ */ jsx(NewChatSplash, {
|
|
13299
|
+
agentName: agent.name,
|
|
13300
|
+
agentTitle: agent.title ?? null,
|
|
13301
|
+
width: reviewView && reviewPlacement === "side" ? Math.max(1, bodyWidth - reviewWidth - 1) : bodyWidth,
|
|
13302
|
+
height: Math.max(1, scrollHeight - 1)
|
|
11909
13303
|
}) : /* @__PURE__ */ jsxs(Fragment, { children: [transcriptWindow.hiddenCount > 0 ? /* @__PURE__ */ jsx("box", {
|
|
11910
13304
|
style: {
|
|
11911
13305
|
paddingLeft: 1,
|
|
@@ -11948,6 +13342,12 @@ function ChatScreen({ agent, conversation }) {
|
|
|
11948
13342
|
children: [" ", "end"]
|
|
11949
13343
|
})]
|
|
11950
13344
|
}) : null,
|
|
13345
|
+
/* @__PURE__ */ jsx(SubagentCardView, {
|
|
13346
|
+
items: subagentItems,
|
|
13347
|
+
isActive: run.kind !== "idle",
|
|
13348
|
+
onHeightChange: setSubagentRows,
|
|
13349
|
+
onBatchLiveChange: setAnchoredBatchLive
|
|
13350
|
+
}),
|
|
11951
13351
|
todoCardVisible && todos ? /* @__PURE__ */ jsx(TodoCardView, {
|
|
11952
13352
|
todos,
|
|
11953
13353
|
isActive: run.kind !== "idle"
|
|
@@ -12040,6 +13440,46 @@ function ChatScreen({ agent, conversation }) {
|
|
|
12040
13440
|
children: "n"
|
|
12041
13441
|
})
|
|
12042
13442
|
]
|
|
13443
|
+
}) : computePrompt ? /* @__PURE__ */ jsxs("box", {
|
|
13444
|
+
style: {
|
|
13445
|
+
border: true,
|
|
13446
|
+
borderColor: theme.warning,
|
|
13447
|
+
paddingLeft: 1,
|
|
13448
|
+
paddingRight: 1,
|
|
13449
|
+
height: computePromptHeight,
|
|
13450
|
+
flexShrink: 0,
|
|
13451
|
+
marginTop: 1,
|
|
13452
|
+
flexDirection: "column"
|
|
13453
|
+
},
|
|
13454
|
+
children: [/* @__PURE__ */ jsx("text", {
|
|
13455
|
+
fg: theme.warning,
|
|
13456
|
+
children: computePrompt.summary ?? "more compute requested"
|
|
13457
|
+
}), /* @__PURE__ */ jsxs("text", { children: [
|
|
13458
|
+
/* @__PURE__ */ jsx("span", {
|
|
13459
|
+
fg: theme.success,
|
|
13460
|
+
children: "y"
|
|
13461
|
+
}),
|
|
13462
|
+
/* @__PURE__ */ jsx("span", {
|
|
13463
|
+
fg: theme.muted,
|
|
13464
|
+
children: " upgrade / "
|
|
13465
|
+
}),
|
|
13466
|
+
/* @__PURE__ */ jsx("span", {
|
|
13467
|
+
fg: theme.error,
|
|
13468
|
+
children: "n"
|
|
13469
|
+
}),
|
|
13470
|
+
/* @__PURE__ */ jsx("span", {
|
|
13471
|
+
fg: theme.muted,
|
|
13472
|
+
children: " keep current / "
|
|
13473
|
+
}),
|
|
13474
|
+
/* @__PURE__ */ jsx("span", {
|
|
13475
|
+
fg: theme.dim,
|
|
13476
|
+
children: "esc"
|
|
13477
|
+
}),
|
|
13478
|
+
/* @__PURE__ */ jsx("span", {
|
|
13479
|
+
fg: theme.muted,
|
|
13480
|
+
children: " later"
|
|
13481
|
+
})
|
|
13482
|
+
] })]
|
|
12043
13483
|
}) : nonWebChannel ? /* @__PURE__ */ jsxs("box", {
|
|
12044
13484
|
style: {
|
|
12045
13485
|
border: true,
|
|
@@ -12336,13 +13776,16 @@ function ToastView() {
|
|
|
12336
13776
|
return () => clearTimeout(handle);
|
|
12337
13777
|
}, [toast, dismissToast]);
|
|
12338
13778
|
if (!toast) return null;
|
|
13779
|
+
const cap = Math.min(60, Math.max(20, width - 6));
|
|
13780
|
+
const chrome = 4;
|
|
13781
|
+
const contentWidth = Math.max(...toast.message.split("\n").map((line) => Bun.stringWidth(line)));
|
|
12339
13782
|
return /* @__PURE__ */ jsx("box", {
|
|
12340
13783
|
style: {
|
|
12341
13784
|
position: "absolute",
|
|
12342
13785
|
top: 1,
|
|
12343
13786
|
right: 1,
|
|
12344
13787
|
zIndex: 100,
|
|
12345
|
-
|
|
13788
|
+
width: Math.min(cap, contentWidth + chrome),
|
|
12346
13789
|
border: true,
|
|
12347
13790
|
borderColor: toastColor(toast.variant),
|
|
12348
13791
|
backgroundColor: theme.surface ?? "#1a1a1a",
|
|
@@ -12359,9 +13802,12 @@ function ToastView() {
|
|
|
12359
13802
|
|
|
12360
13803
|
//#endregion
|
|
12361
13804
|
//#region src/chat/tui/app.tsx
|
|
12362
|
-
function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath, notifications, agentSelector, conversationId, newConversation }) {
|
|
13805
|
+
function App({ appUrl, sessionToken, authKind, workspaceId, shareMachine, promptHistoryPath, notifications, agentSelector, conversationId, newConversation, defaultAgentSelector, forceOnboarding }) {
|
|
12363
13806
|
const renderer = useRenderer();
|
|
12364
13807
|
const screen = useStore((s) => s.screen);
|
|
13808
|
+
useEffect(() => {
|
|
13809
|
+
if (screen.kind === "chat") recordDefaultAgent(screen.agent.id);
|
|
13810
|
+
}, [screen]);
|
|
12365
13811
|
const chatTitle = useStore((s) => s.chatTitle);
|
|
12366
13812
|
useStore((s) => s.themeId);
|
|
12367
13813
|
const rest = useStore((s) => s.rest);
|
|
@@ -12429,7 +13875,8 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
|
|
|
12429
13875
|
useEffect(() => {
|
|
12430
13876
|
const rest = createRestClient({
|
|
12431
13877
|
appUrl,
|
|
12432
|
-
sessionToken
|
|
13878
|
+
sessionToken,
|
|
13879
|
+
workspaceId
|
|
12433
13880
|
});
|
|
12434
13881
|
setClients({
|
|
12435
13882
|
appUrl,
|
|
@@ -12444,7 +13891,9 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
|
|
|
12444
13891
|
rest,
|
|
12445
13892
|
agentSelector,
|
|
12446
13893
|
conversationId,
|
|
12447
|
-
newConversation: newConversation || useStore.getState().seedPrompt !== null
|
|
13894
|
+
newConversation: newConversation || useStore.getState().seedPrompt !== null,
|
|
13895
|
+
defaultAgentSelector,
|
|
13896
|
+
forceOnboarding
|
|
12448
13897
|
});
|
|
12449
13898
|
if (cancelled) return;
|
|
12450
13899
|
goTo(screen);
|
|
@@ -12463,10 +13912,13 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
|
|
|
12463
13912
|
}, [
|
|
12464
13913
|
appUrl,
|
|
12465
13914
|
sessionToken,
|
|
13915
|
+
workspaceId,
|
|
12466
13916
|
promptHistoryPath,
|
|
12467
13917
|
agentSelector,
|
|
12468
13918
|
conversationId,
|
|
12469
13919
|
newConversation,
|
|
13920
|
+
defaultAgentSelector,
|
|
13921
|
+
forceOnboarding,
|
|
12470
13922
|
goTo,
|
|
12471
13923
|
setClients,
|
|
12472
13924
|
fail
|
|
@@ -12507,6 +13959,7 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
|
|
|
12507
13959
|
screen.kind === "error" && /* @__PURE__ */ jsx(ErrorScreen, { message: screen.message }),
|
|
12508
13960
|
screen.kind === "agent-picker" && rest && /* @__PURE__ */ jsx(AgentPickerScreen, {}),
|
|
12509
13961
|
screen.kind === "agent-create" && rest && /* @__PURE__ */ jsx(AgentCreateScreen, {}),
|
|
13962
|
+
screen.kind === "first-run" && rest && /* @__PURE__ */ jsx(FirstRunScreen, {}),
|
|
12510
13963
|
screen.kind === "workspace-picker" && /* @__PURE__ */ jsx(WorkspacePicker, {
|
|
12511
13964
|
appUrl,
|
|
12512
13965
|
sessionToken,
|
|
@@ -12516,7 +13969,10 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
|
|
|
12516
13969
|
goTo({ kind: "agent-picker" });
|
|
12517
13970
|
}
|
|
12518
13971
|
}),
|
|
12519
|
-
screen.kind === "conversation-picker" && rest && /* @__PURE__ */ jsx(ConversationPickerScreen, {
|
|
13972
|
+
screen.kind === "conversation-picker" && rest && /* @__PURE__ */ jsx(ConversationPickerScreen, {
|
|
13973
|
+
agent: screen.agent,
|
|
13974
|
+
initialQuery: screen.initialQuery
|
|
13975
|
+
}),
|
|
12520
13976
|
screen.kind === "chat" && rest && /* @__PURE__ */ jsx(ChatScreen, {
|
|
12521
13977
|
agent: screen.agent,
|
|
12522
13978
|
conversation: screen.conversation
|
|
@@ -13045,7 +14501,7 @@ function onRenderCommit(id, phase, actualDuration, baseDuration, startTime, comm
|
|
|
13045
14501
|
* module — and the heavy OpenTUI dependency graph it pulls in — is
|
|
13046
14502
|
* dynamically imported and never loaded on the Node management path.
|
|
13047
14503
|
*/
|
|
13048
|
-
async function runChat({ appUrl, sessionToken, authKind = "session", shareMachine, promptHistoryPath, theme: themeOverride, notifications, agentSelector, conversationId, newConversation, seedPrompt }) {
|
|
14504
|
+
async function runChat({ appUrl, sessionToken, authKind = "session", workspaceId, shareMachine, promptHistoryPath, theme: themeOverride, notifications, agentSelector, conversationId, newConversation, defaultAgentSelector, forceOnboarding, seedPrompt }) {
|
|
13049
14505
|
await registerGrammars();
|
|
13050
14506
|
const watchdog = startMemoryWatchdog();
|
|
13051
14507
|
installCrashHandler({ memoryProvider: () => watchdog.snapshot() });
|
|
@@ -13060,7 +14516,8 @@ async function runChat({ appUrl, sessionToken, authKind = "session", shareMachin
|
|
|
13060
14516
|
authKind,
|
|
13061
14517
|
seedPrompt: seedPrompt ?? null,
|
|
13062
14518
|
autoGrantMachine: Boolean(seedPrompt && shareMachine),
|
|
13063
|
-
newConversationIntent: newConversation
|
|
14519
|
+
newConversationIntent: newConversation,
|
|
14520
|
+
forcedOnboarding: forceOnboarding
|
|
13064
14521
|
});
|
|
13065
14522
|
const store = useStore.getState();
|
|
13066
14523
|
if (noColorRequested()) {
|
|
@@ -13081,12 +14538,15 @@ async function runChat({ appUrl, sessionToken, authKind = "session", shareMachin
|
|
|
13081
14538
|
appUrl,
|
|
13082
14539
|
sessionToken,
|
|
13083
14540
|
authKind,
|
|
14541
|
+
workspaceId,
|
|
13084
14542
|
shareMachine,
|
|
13085
14543
|
promptHistoryPath,
|
|
13086
14544
|
notifications,
|
|
13087
14545
|
agentSelector: agentSelector ?? null,
|
|
13088
14546
|
conversationId: conversationId ?? null,
|
|
13089
|
-
newConversation
|
|
14547
|
+
newConversation,
|
|
14548
|
+
defaultAgentSelector: defaultAgentSelector ?? null,
|
|
14549
|
+
forceOnboarding
|
|
13090
14550
|
})
|
|
13091
14551
|
}));
|
|
13092
14552
|
}
|