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.
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { D as setActiveWorkspace, E as listWorkspaces, S as themesForMode, _ as themeForMode, a as installCrashHandler, b as themeVersion, c as MARK_CELLS, d as DEFAULT_THEME_ID, f as applyTheme, g as theme, h as noColorRequested, i as writeArtifact, l as WORDMARK, m as monoTheme, n as profilingEnabled, o as buildCrashReport, p as findTheme, r as record, s as writeCrashReport, v as themeMode, w as getActiveWorkspaceId, y as themeModeFromColorFgBg } from "./profiler-DtuXkIkR.mjs";
3
- import { B as saveTheme, D as getSavedTheme, E as getReviewStateDir, L as resolveWebUrl, S as getConfigPath, c as cardActionErrorMessage, d as reconcileMaskedInput, f as resolveConnectUrl, i as resolveAgent, l as parseExternalOauthConnectParams, m as specKeyFor, p as parseConnectCard, s as MASK_CHAR, u as parseOauthConnectParams, v as DEFAULT_API_URL, y as DEFAULT_APP_URL } from "./print-CbayCa87.mjs";
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-BY2nADw5.mjs";
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-Dd5sMXPv.mjs";
8
- import "./daemon-Bq93vOIk.mjs";
9
- import { t as SandboxStream } from "./client-Cn2af31H.mjs";
10
- import { t as PortalDaemonClient } from "./daemon-client-Bewt98dE.mjs";
11
- import { t as runRawPtyPassthrough } from "./raw-pty-B6mAroiI.mjs";
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, exactly as before.
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
- async function resolveInitialScreen({ rest, agentSelector, conversationId, newConversation }) {
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 [conversation, agents] = await Promise.all([rest.getConversation({ conversationId }).catch((err) => {
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
- }), rest.listAgents({
240
- scope: "org",
241
- onPage: null
242
- })]);
243
- const agent = agents.find((a) => a.id === conversation.agentId);
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 (!agentSelector && !newConversation) return { kind: "agent-picker" };
266
- const agents = await rest.listAgents({
267
- scope: "org",
268
- onPage: null
269
- });
270
- if (!agentSelector && newConversation) {
271
- const only = agents.length === 1 ? agents[0] : null;
272
- if (!only) return { kind: "agent-picker" };
273
- return {
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: only,
314
+ agent,
276
315
  conversation: {
277
316
  kind: "new",
278
- agentId: only.id,
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(agents, agentSelector);
284
- if (newConversation) return {
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
- return {
294
- kind: "conversation-picker",
295
- agent
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/`, not a bare `#123` — ambiguous with
1020
- * issues without local repo context we don't have). A trailing path/query/
1021
- * fragment (e.g. `/files`, `#discussion`) is allowed but not captured into the
1022
- * canonical URL. Also matches inside markdown links, backticks, and trailing
1023
- * punctuation.
1024
- */
1025
- const PR_URL_RE = /https?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/gi;
1026
- /**
1027
- * Extracts every distinct GitHub PR referenced in a block of text, in first-seen
1028
- * order, de-duplicated by **repo-qualified identity** (`owner/repo#number`)
1029
- * NOT by number, since `#42` can exist in many repos. Each ref carries a
1030
- * canonicalized URL (scheme + host + `/owner/repo/pull/number`, no trailing
1031
- * path/query/fragment) so the same PR linked twice with different fragments
1032
- * collapses to one pill.
1033
- */
1034
- function extractPullRequests(text, options) {
1035
- const requireCompleteNumber = options?.requireCompleteNumber ?? true;
1036
- const byKey = /* @__PURE__ */ new Map();
1037
- for (const match of text.matchAll(PR_URL_RE)) {
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 (requireCompleteNumber) {
1043
- if (match.index + match[0].length >= text.length) continue;
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 key = `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`;
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 its URL is seen, exactly once, without ever rescanning
1094
- * the transcript. Two sources feed it:
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
- * - **Assistant text** (`text-delta`): accumulated per part id (a URL can split
1097
- * across two deltas) and only the current part's buffer is scanned, so cost
1098
- * is bounded by one text block, not the whole scrollback.
1099
- * - **Tool output** (`tool-output-available`): the settled output of a tool
1100
- * call (e.g. `gh pr create` stdout) arrives whole, not delta-streamed, so we
1101
- * flatten and scan it in one pass. This catches PRs a tool printed that the
1102
- * agent never restated in prose the common case for an opened PR.
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`) for the life of the scanner, regardless of which source
1106
- * it came from. Construct one per run (or per conversation) and feed it the
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 = (buffer, final) => {
1114
- for (const pr of extractPullRequests(buffer, { requireCompleteNumber: !final })) {
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
- report(outputText, true);
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
- if (text.includes("/pull/")) report(text, true);
1304
+ report(true);
1133
1305
  text = "";
1134
1306
  }
1135
1307
  text += delta;
1136
1308
  lastPartId = partId ?? lastPartId;
1137
- if (!text.includes("/pull/")) return;
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 (including while the
1653
- * list is loading / empty / erroring) so the splash is never gated on data.
1654
- * Full mark + wordmark when the terminal is tall enough; compact one-liner on
1655
- * short terminals so the list still has room.
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 >= 24) return /* @__PURE__ */ jsxs("box", {
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 >= 24 ? 7 : 1;
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, { height }), /* @__PURE__ */ jsx("text", {
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, { height }), /* @__PURE__ */ jsxs("text", {
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, { height }), /* @__PURE__ */ jsx("text", {
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, { height }),
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
- goTo({
1974
- kind: "chat",
2210
+ const shell = detectShell();
2211
+ if (shell) setState({
2212
+ kind: "offer-alias",
1975
2213
  agent,
1976
- conversation: {
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/workspace-picker.tsx
2076
- function WorkspacePicker({ appUrl, sessionToken, onSelect, onCancel }) {
2077
- const { height } = useTerminalDimensions();
2078
- const [state, setState] = useState({ kind: "loading" });
2079
- const [highlight, setHighlight] = useState(0);
2080
- const [switching, setSwitching] = useState(false);
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
- const [workspaces, activeId] = await Promise.all([listWorkspaces({
2085
- appUrl,
2086
- sessionToken
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: "error",
2095
- message: workspaces.error.message
2391
+ kind: "offer",
2392
+ agent
2096
2393
  });
2097
- return;
2098
- }
2099
- if (activeId.isErr()) {
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: activeId.error.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
- }, [appUrl, sessionToken]);
2117
- const rows = state.kind === "ready" ? state.workspaces : [];
2118
- const clamped = Math.min(highlight, Math.max(0, rows.length - 1));
2119
- useKeyboard((key) => {
2120
- if (switching) return;
2121
- if (key.name === "escape" || key.name === "c" && key.ctrl) onCancel();
2122
- else if (key.name === "up") setHighlight(Math.max(0, clamped - 1));
2123
- else if (key.name === "down") setHighlight(Math.min(rows.length - 1, clamped + 1));
2124
- else if (key.name === "return") {
2125
- const workspace = rows[clamped];
2126
- if (!workspace) return;
2127
- setSwitching(true);
2128
- (async () => {
2129
- const result = await setActiveWorkspace({
2130
- appUrl,
2131
- sessionToken,
2132
- organizationId: workspace.id
2133
- });
2134
- if (result.isErr()) {
2135
- setState({
2136
- kind: "error",
2137
- message: result.error.message
2138
- });
2139
- setSwitching(false);
2140
- return;
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
- onSelect(workspace);
2143
- })();
2144
- }
2145
- });
2146
- if (state.kind === "loading") return /* @__PURE__ */ jsx("text", {
2147
- fg: theme.muted,
2148
- children: "loading workspaces…"
2149
- });
2150
- if (state.kind === "error") return /* @__PURE__ */ jsxs("box", {
2151
- style: { flexDirection: "column" },
2152
- children: [/* @__PURE__ */ jsxs("text", {
2153
- fg: theme.error,
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-picker.tsx
2423
- const SEARCH_DEBOUNCE_MS = 250;
2424
- const SEARCH_QUERY_MAX_LENGTH = 200;
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
- function ConversationPickerScreen({ agent }) {
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, { height }), /* @__PURE__ */ jsx("text", {
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, { height }), /* @__PURE__ */ jsxs("text", {
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, { height }),
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
- marker,
2787
- /* @__PURE__ */ jsx(FuzzyText, {
2788
- text: title,
2789
- indexes: row.hit.item.title ? row.hit.highlights[0] : null
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/spinner-frames.ts
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
- * An animated spinner glyph. Advances one frame every SPINNER_INTERVAL_MS via
4209
- * a self-owned interval that's cleared on unmount, so the animation runs only
4210
- * while the component is mounted. Returns a <span>, so render it inside a
4211
- * <text> (matching how the rest of the TUI composes inline glyphs).
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
- function Spinner({ color }) {
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
- * Each delegated task's display title from the `subagent` call's input, in
4244
- * call order the short name the tool requires per task. A malformed entry
4245
- * yields a fallback label rather than being dropped, so the row count always
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
- function subagentTaskTitles(input) {
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
- * The ids of the runs a `subagent` call queued, read back out of its own
4261
- * output. The call returns the moment the runs exist, so the ids are there
4262
- * long before any child finishes they are what ties this card to the task
4263
- * rows the status poll returns, and their order is the tasks' call order.
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 queuedSubagentTaskIds(output) {
4266
- const text = flattenOutputText(output);
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
- * Tool output reaches the TUI as `unknown`: the raw string live, possibly
4272
- * wrapped in content parts or arrays when loaded from history. Walk it for
4273
- * string leaves, newline-joined the PR scanner's `stringifyToolOutput`
4274
- * shape, bounded small because the id list sits at the top of the output.
4275
- */
4276
- function flattenOutputText(value) {
4277
- const MAX_LEN = 2e4;
4278
- const parts = [];
4279
- let total = 0;
4280
- const visit = (v, depth) => {
4281
- if (total >= MAX_LEN || depth > 4) return;
4282
- if (typeof v === "string") {
4283
- parts.push(v);
4284
- total += v.length;
4285
- return;
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 (Array.isArray(v)) {
4288
- for (const item of v) visit(item, depth + 1);
4289
- return;
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
- if (isRecord(v)) for (const item of Object.values(v)) visit(item, depth + 1);
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
- * The card's rows: one per queued run id (call order), each carrying the
4298
- * freshest known state. A task the status fetch hasn't returned yet falls
4299
- * back to the input's title and reads as queued; a fetched row's own title
4300
- * wins over the input's (the server's copy is the one the child actually
4301
- * ran under). Before the ids exist — the call is still streaming — rows come
4302
- * from the input titles alone, so the card never renders empty mid-turn.
4303
- */
4304
- function subagentRows({ titles, ids, fetched }) {
4305
- if (ids.length === 0) return titles.map((title, index) => ({
4306
- id: `queued:${index}`,
4307
- title,
4308
- status: "queued",
4309
- createdAt: null,
4310
- completedAt: null
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/tool-subagent.tsx
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
- * The transcript block for a `subagent` tool call the TUI analog of the web
4362
- * chat's fan-out card. One row per delegated run: status glyph, the short
4363
- * title the delegating agent wrote, ticking over live as children settle.
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
- * Rows are seeded from the call itself (titles from its input, task ids from
4366
- * its own output), so the card renders the moment the call does. Status then
4367
- * comes from the server's task rows, polled while any run is live — the
4368
- * delegated runs outlive the call that spawned them, so the transcript stream
4369
- * carries nothing more about them. The poll stops the moment the batch
4370
- * settles; reopening an old conversation does one fetch and stops there. A
4371
- * failed poll is skipped silently (the next tick retries; stale rows beat an
4372
- * error row in the scrollback).
4373
- */
4374
- function ToolSubagent({ item }) {
4375
- const { rest, conversationId } = useConversationContext();
4376
- const titles = subagentTaskTitles(item.input);
4377
- const ids = item.output?.kind === "ok" ? queuedSubagentTaskIds(item.output.value) : [];
4378
- const idsKey = ids.join(",");
4379
- const [fetched, setFetched] = useState(null);
4380
- const rows = subagentRows({
4381
- titles,
4382
- ids,
4383
- fetched
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 (!rest || !conversationId || idsKey.length === 0 || !live) return;
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
- try {
4392
- const tasks = await rest.listSubagentTasks({ conversationId });
4393
- if (cancelled) return;
4394
- setFetched(tasks);
4395
- } catch (_error) {}
4396
- if (!cancelled) timer = setTimeout(tick, POLL_INTERVAL_MS);
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
- rest,
4405
- conversationId,
4406
- idsKey,
4407
- live
4408
- ]);
4409
- if (rows.length === 0) return /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
4410
- fg: theme.warning,
4411
- children: "◐ "
4412
- }), /* @__PURE__ */ jsx("span", {
4413
- fg: theme.tool,
4414
- children: "Delegating to subagents…"
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", { children: [
4419
- live ? /* @__PURE__ */ jsx(Spinner, { color: theme.accent }) : /* @__PURE__ */ jsx("span", {
4420
- fg: theme.success,
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
- rows.filter((row) => row.status === "completed").length,
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
- //#endregion
4459
- //#region src/chat/tui/chunks/shell-local.tsx
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
- * Renders a `! <cmd>` local-shell escape: the command the user ran on their own
4462
- * machine, followed by its combined stdout/stderr. Visually distinct from agent
4463
- * tool calls (which run in the sandbox) a `!` sigil colored by outcome (dim
4464
- * while running, success/error once done), so a glance tells you this executed
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 ShellLocalItem({ item }) {
4468
- const running = item.exit === null;
4469
- const failed = item.exit !== null && item.exit !== 0;
4470
- return /* @__PURE__ */ jsxs("box", {
4471
- style: { flexDirection: "column" },
4472
- children: [/* @__PURE__ */ jsxs("text", { children: [
4473
- /* @__PURE__ */ jsx("span", {
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: item.output.replace(/\n+$/, "")
4492
- }) }) : null]
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 && item.attachments.length > 0 ? /* @__PURE__ */ jsxs("text", {
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__ */ jsx(Row, {
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 /* @__PURE__ */ jsx(ToolSubagent, { item });
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: null,
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
- ...attachments.length > 0 ? { attachments } : {}
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: null,
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/todo-card.tsx
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
- * The agent's live todo list, pinned just above the composer — the TUI analog
5740
- * of the web chat's todo card. Rendered only once the agent has actually
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: "Plan" })
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. Only `sandbox` and `conversation` accept a rest.
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 atBottom = box.scrollTop + viewport >= box.scrollHeight - 1;
10280
- setScrolledUp((prev) => prev === !atBottom ? prev : !atBottom);
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 bottomBoxHeight = credPrompt ? credPromptHeight : nonWebChannel ? nonWebNoticeHeight : composerBoxHeight;
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
- const text = item.kind === "assistant-text" ? item.text : item.kind === "tool" && item.output?.kind === "ok" ? stringifyToolOutput(item.output.value) : null;
10363
- if (text === null) continue;
10364
- for (const pr of extractPullRequests(text, { requireCompleteNumber: false })) {
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
- ...stagedNames.length > 0 ? { attachments: stagedNames } : {}
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
- await portalClient.grantAgent(agent.id);
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
- portalClient.grantAgent(agent.id).then(() => useStore.getState().showToast({
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) => setItems((prev) => [...prev, {
11290
- kind: "error",
11291
- id: crypto.randomUUID(),
11292
- text: `couldn't share machine: ${errorDetail(err)}`
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
- }), [rest, conversationId]);
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__ */ jsxs("text", {
11903
- fg: theme.muted,
11904
- children: [
11905
- "say hello to ",
11906
- agent.name,
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
- maxWidth: Math.min(60, Math.max(20, width - 6)),
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, { agent: screen.agent }),
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
  }