skydive-cli 0.5.0-beta.3 → 0.5.0-beta.31

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-BVbYs_Yg.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-C_TnQSPg.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-CgfbKXst.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-BTyg329O.mjs";
9
+ import "./api-DG5W6iwx.mjs";
10
+ import { t as SandboxStream } from "./client-c4c5MmgN.mjs";
11
+ import { t as PortalDaemonClient } from "./daemon-client--A_yMKq6.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,297 @@ 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
4207
- /**
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).
4212
- */
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";
4242
- /**
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`.
4247
- */
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;
4259
- /**
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.
4264
- */
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);
4269
- }
4270
- /**
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;
4286
- }
4287
- if (Array.isArray(v)) {
4288
- for (const item of v) visit(item, depth + 1);
4289
- return;
4290
- }
4291
- if (isRecord(v)) for (const item of Object.values(v)) visit(item, depth + 1);
4292
- };
4293
- visit(value, 0);
4294
- return parts.join("\n");
4295
- }
4804
+ //#region src/chat/tui/chat/sandbox-stats.ts
4296
4805
  /**
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
4311
- }));
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
- });
4806
+ * `/computer`: a live snapshot of the agent's sandbox as a set of bar graphs.
4807
+ *
4808
+ * The numbers come from the api, NOT from the CLI reaching into the sandbox.
4809
+ * The worker's fleet metrics sweep already samples every live box's CPU /
4810
+ * memory / disk from the provider and stores the latest per-sandbox snapshot;
4811
+ * the CLI reads that back over one plain REST call (`collectSandboxStats`). So
4812
+ * there is no exec into the box and no cold-start wake — when the agent has no
4813
+ * running sandbox the api answers "no snapshot" immediately instead of the card
4814
+ * hanging on a stream that never replies.
4815
+ *
4816
+ * Everything in this module is pure so the layout math is unit-testable without
4817
+ * an api or a renderer.
4818
+ */
4819
+ /**
4820
+ * How often the live `/computer` card refreshes its snapshot, in ms.
4821
+ * Deliberately slow: the card is a background health readout, not a `top`, so
4822
+ * it refreshes quietly every 30s rather than flickering every second. Also
4823
+ * comfortably slower than the worker's ~60s sweep cadence, so we never poll for
4824
+ * a sample that can't have changed.
4825
+ */
4826
+ const COMPUTER_POLL_INTERVAL_MS = 3e4;
4827
+ /**
4828
+ * A snapshot older than this is stale enough that we no longer present it as
4829
+ * "current". The api sample carries the provider's measurement time; the sweep
4830
+ * that writes it runs ~every 60s and the Redis key TTLs at 5 min, so a sample
4831
+ * this old means the box likely stopped reporting (paused / gone). The card
4832
+ * treats it as "no current snapshot" rather than drawing minutes-old bars as if
4833
+ * they were live.
4834
+ */
4835
+ const COMPUTER_STATS_STALE_MS = 5 * 6e4;
4836
+ /**
4837
+ * Whether a `/computer` card should refresh right now. Pure so the gating is
4838
+ * unit-testable. A card refreshes only when it is the most recent `/computer`
4839
+ * in the transcript (`isLatest`), the session can reach the api
4840
+ * (`canCollect`), and the transcript is at the bottom (`!scrolledUp`) — while
4841
+ * you're scrolled up reading something else it quietly stops (no wasted polls),
4842
+ * and resumes when you scroll back down. Older cards freeze regardless, so a
4843
+ * scrolled-up history of runs isn't a fleet of background pollers. The pause is
4844
+ * silent: nothing about it is rendered.
4845
+ */
4846
+ function shouldPollComputer(opts) {
4847
+ return opts.isLatest && opts.canCollect && !opts.scrolledUp;
4848
+ }
4849
+ function severityFor(fraction) {
4850
+ if (fraction >= .9) return "crit";
4851
+ if (fraction >= .75) return "warn";
4852
+ return "ok";
4853
+ }
4854
+ const GiB = 1024 * 1024 * 1024;
4855
+ function gb(bytes) {
4856
+ return (bytes / GiB).toFixed(1);
4857
+ }
4858
+ /**
4859
+ * Reduce a snapshot to the ordered list of gauges the `/computer` card draws:
4860
+ * CPU utilization, memory, and disk — the three signals the api's live-stats
4861
+ * read carries. CPU is a provider-measured utilization percent (already
4862
+ * normalized across cores), so it maps straight to a 0..1 bar; the caption
4863
+ * shows the vCPU count for context.
4864
+ */
4865
+ function toGauges(stats) {
4866
+ const gauges = [];
4867
+ const cpuFraction = stats.cpuUsedPct / 100;
4868
+ gauges.push({
4869
+ label: "cpu",
4870
+ fraction: cpuFraction,
4871
+ detail: `${stats.cpuUsedPct.toFixed(0)}% · ${stats.cpuCount} vCPU`,
4872
+ severity: severityFor(cpuFraction)
4873
+ });
4874
+ const memFraction = stats.memTotalBytes > 0 ? stats.memUsedBytes / stats.memTotalBytes : 0;
4875
+ gauges.push({
4876
+ label: "memory",
4877
+ fraction: memFraction,
4878
+ detail: `${gb(stats.memUsedBytes)} / ${gb(stats.memTotalBytes)} GB`,
4879
+ severity: severityFor(memFraction)
4880
+ });
4881
+ const diskFraction = stats.diskTotalBytes > 0 ? stats.diskUsedBytes / stats.diskTotalBytes : 0;
4882
+ gauges.push({
4883
+ label: "disk",
4884
+ fraction: diskFraction,
4885
+ detail: `${gb(stats.diskUsedBytes)} / ${gb(stats.diskTotalBytes)} GB`,
4886
+ severity: severityFor(diskFraction)
4887
+ });
4888
+ return gauges;
4889
+ }
4890
+ /**
4891
+ * A single-line horizontal bar of `width` cells filled to `fraction`
4892
+ * (clamped 0..1), using block glyphs. The last partial cell uses one of the
4893
+ * eighth-block glyphs so a 37%% bar reads differently from a 40%% one even in a
4894
+ * narrow width. Pure string, so the renderer just colors it.
4895
+ */
4896
+ function renderBar(fraction, width) {
4897
+ const cells = Math.max(1, width);
4898
+ const exact = Math.min(1, Math.max(0, fraction)) * cells;
4899
+ const full = Math.floor(exact);
4900
+ const remainder = exact - full;
4901
+ const eighths = [
4902
+ "",
4903
+ "▏",
4904
+ "▎",
4905
+ "▍",
4906
+ "▌",
4907
+ "▋",
4908
+ "▊",
4909
+ "▉"
4910
+ ];
4911
+ const partialIndex = Math.round(remainder * 8);
4912
+ const partial = full < cells ? eighths[partialIndex] : "";
4913
+ const filled = "█".repeat(Math.min(full, cells)) + partial;
4914
+ return (filled + "░".repeat(Math.max(0, cells - filled.length))).slice(0, cells);
4322
4915
  }
4323
4916
 
4324
4917
  //#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
- }
4918
+ //#region src/chat/tui/chunks/computer.tsx
4360
4919
  /**
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.
4920
+ * Renders the `/computer` card: a snapshot of the agent's sandbox as a stack of
4921
+ * colored bar graphs (cpu, memory, disk).
4364
4922
  *
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));
4923
+ * The numbers come from the api, not from reaching into the box: the card polls
4924
+ * `collectSandboxStats` (injected via context), which reads the live resource
4925
+ * sample the worker's fleet sweep already stores. So there's no exec and no
4926
+ * cold-start wake when the agent has no running sandbox the read returns
4927
+ * "no snapshot" at once and the card shows an idle line instead of hanging.
4928
+ *
4929
+ * The card keeps itself current in place rather than making the user re-run the
4930
+ * command but quietly: it refreshes on a slow interval with no "updating"
4931
+ * chrome, so the numbers just stay fresh without any flicker. Only the most
4932
+ * recent `/computer` card refreshes (older ones freeze at their last snapshot),
4933
+ * and the latest one silently stops while the transcript is scrolled up (no
4934
+ * wasted polls on a card you're not looking at) and resumes at the bottom — the
4935
+ * pause is never rendered. The parse/layout math lives in `sandbox-stats.ts` and
4936
+ * this component only draws.
4937
+ */
4938
+ function ComputerItem({ item }) {
4939
+ const { collectSandboxStats, latestComputerId, scrolledUp } = useConversationContext();
4940
+ const live = shouldPollComputer({
4941
+ isLatest: latestComputerId === item.id,
4942
+ canCollect: collectSandboxStats !== null,
4943
+ scrolledUp
4944
+ });
4945
+ const [stats, setStats] = useState(item.stats);
4946
+ const [error, setError] = useState(item.error);
4947
+ const hasResolved = useRef(item.stats !== null || item.state !== "loading");
4948
+ const [idle, setIdle] = useState(item.state !== "loading" && item.stats === null && item.error === null);
4386
4949
  useEffect(() => {
4387
- if (!rest || !conversationId || idsKey.length === 0 || !live) return;
4950
+ if (!live || !collectSandboxStats) return;
4388
4951
  let cancelled = false;
4389
4952
  let timer = null;
4953
+ let inFlight = null;
4390
4954
  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);
4955
+ const collection = collectSandboxStats();
4956
+ inFlight = collection;
4957
+ const result = await collection.result;
4958
+ inFlight = null;
4959
+ if (cancelled) return;
4960
+ if (result.ok) {
4961
+ hasResolved.current = true;
4962
+ setStats(result.stats);
4963
+ setError(null);
4964
+ setIdle(result.stats === null);
4965
+ } else if (!("aborted" in result)) {
4966
+ if (!hasResolved.current) setError(result.error);
4967
+ }
4968
+ if (!cancelled) timer = setTimeout(tick, COMPUTER_POLL_INTERVAL_MS);
4397
4969
  };
4398
4970
  tick();
4399
4971
  return () => {
4400
4972
  cancelled = true;
4401
4973
  if (timer !== null) clearTimeout(timer);
4974
+ inFlight?.abort();
4402
4975
  };
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
- })] });
4976
+ }, [live, collectSandboxStats]);
4977
+ if (stats === null && error === null && !idle) return /* @__PURE__ */ jsx(Shell, { children: /* @__PURE__ */ jsx("text", {
4978
+ fg: theme.dim,
4979
+ children: "reading sandbox stats…"
4980
+ }) });
4981
+ if (stats === null && error !== null) return /* @__PURE__ */ jsx(Shell, { children: /* @__PURE__ */ jsxs("text", {
4982
+ fg: theme.error,
4983
+ children: ["couldn't read sandbox stats: ", error]
4984
+ }) });
4985
+ if (stats === null) return /* @__PURE__ */ jsx(Shell, { children: /* @__PURE__ */ jsx("text", {
4986
+ fg: theme.dim,
4987
+ children: "no sandbox running"
4988
+ }) });
4989
+ const gauges = toGauges(stats);
4990
+ const labelWidth = Math.max(...gauges.map((g) => g.label.length));
4991
+ const barWidth = 24;
4992
+ return /* @__PURE__ */ jsx(Shell, { children: gauges.map((g) => /* @__PURE__ */ jsx(GaugeRow, {
4993
+ gauge: g,
4994
+ labelWidth,
4995
+ barWidth
4996
+ }, g.label)) });
4997
+ }
4998
+ /** The card frame: a bold `computer` title over the graph rows. */
4999
+ function Shell({ children }) {
4416
5000
  return /* @__PURE__ */ jsxs("box", {
4417
5001
  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", {
5002
+ children: [/* @__PURE__ */ jsxs("text", {
5003
+ fg: theme.muted,
5004
+ children: [/* @__PURE__ */ jsx("b", { children: "computer" }), /* @__PURE__ */ jsx("span", {
4428
5005
  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
- })]
5006
+ children: " · agent sandbox"
5007
+ })]
5008
+ }), children]
4455
5009
  });
4456
5010
  }
4457
-
4458
- //#endregion
4459
- //#region src/chat/tui/chunks/shell-local.tsx
4460
- /**
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.
4466
- */
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", {
5011
+ function severityColor(severity) {
5012
+ switch (severity) {
5013
+ case "ok": return theme.success;
5014
+ case "warn": return theme.warning;
5015
+ case "crit": return theme.error;
5016
+ default: {
5017
+ const exhaustive = severity;
5018
+ throw new Error(`unhandled severity: ${String(exhaustive)}`);
5019
+ }
5020
+ }
5021
+ }
5022
+ function GaugeRow({ gauge, labelWidth, barWidth }) {
5023
+ const color = severityColor(gauge.severity);
5024
+ const pct = `${Math.round(Math.min(1, Math.max(0, gauge.fraction)) * 100)}%`;
5025
+ return /* @__PURE__ */ jsxs("text", { children: [
5026
+ /* @__PURE__ */ jsx("span", {
4490
5027
  fg: theme.muted,
4491
- children: item.output.replace(/\n+$/, "")
4492
- }) }) : null]
4493
- });
5028
+ children: `${gauge.label.padEnd(labelWidth)} `
5029
+ }),
5030
+ /* @__PURE__ */ jsx("span", {
5031
+ fg: color,
5032
+ children: renderBar(gauge.fraction, barWidth)
5033
+ }),
5034
+ /* @__PURE__ */ jsx("span", {
5035
+ fg: color,
5036
+ children: ` ${pct.padStart(4)}`
5037
+ }),
5038
+ /* @__PURE__ */ jsx("span", {
5039
+ fg: theme.dim,
5040
+ children: ` ${gauge.detail}`
5041
+ })
5042
+ ] });
4494
5043
  }
4495
5044
 
4496
5045
  //#endregion
@@ -4529,20 +5078,23 @@ function RenderItem({ item }) {
4529
5078
  children: [item.text ? /* @__PURE__ */ jsx("text", {
4530
5079
  fg: theme.user,
4531
5080
  children: item.text
4532
- }) : null, item.attachments && item.attachments.length > 0 ? /* @__PURE__ */ jsxs("text", {
5081
+ }) : null, item.attachments.length > 0 ? /* @__PURE__ */ jsxs("text", {
4533
5082
  fg: theme.muted,
4534
5083
  children: ["⎘ ", item.attachments.join(" · ")]
4535
5084
  }) : null]
4536
5085
  });
4537
- case "pending-steer": return /* @__PURE__ */ jsx(Row, {
5086
+ case "pending-steer": return /* @__PURE__ */ jsxs(Row, {
4538
5087
  barColor: theme.dim,
4539
- children: /* @__PURE__ */ jsxs("text", {
5088
+ children: [/* @__PURE__ */ jsxs("text", {
4540
5089
  fg: theme.muted,
4541
5090
  children: [item.text, /* @__PURE__ */ jsx("span", {
4542
5091
  fg: theme.dim,
4543
5092
  children: " · steering… (esc to cancel)"
4544
5093
  })]
4545
- })
5094
+ }), item.attachments.length > 0 ? /* @__PURE__ */ jsxs("text", {
5095
+ fg: theme.dim,
5096
+ children: ["⎘ ", item.attachments.join(" · ")]
5097
+ }) : null]
4546
5098
  });
4547
5099
  case "assistant-text":
4548
5100
  if (item.done) return /* @__PURE__ */ jsx(Row, {
@@ -4588,6 +5140,10 @@ function RenderItem({ item }) {
4588
5140
  })]
4589
5141
  })
4590
5142
  });
5143
+ case "computer": return /* @__PURE__ */ jsx(Row, {
5144
+ barColor: null,
5145
+ children: /* @__PURE__ */ jsx(ComputerItem, { item })
5146
+ });
4591
5147
  case "recap": return /* @__PURE__ */ jsx(Row, {
4592
5148
  barColor: null,
4593
5149
  children: /* @__PURE__ */ jsxs("text", {
@@ -4678,7 +5234,7 @@ function ToolItem({ item }) {
4678
5234
  case "read": return /* @__PURE__ */ jsx(ToolRead, { item });
4679
5235
  case "grep":
4680
5236
  case "glob": return /* @__PURE__ */ jsx(ToolGrep, { item });
4681
- case "subagent": return /* @__PURE__ */ jsx(ToolSubagent, { item });
5237
+ case "subagent": return null;
4682
5238
  default: return /* @__PURE__ */ jsx(ToolGeneric, { item });
4683
5239
  }
4684
5240
  }
@@ -5277,7 +5833,8 @@ function applyChunk(items, chunk, agentName = null) {
5277
5833
  return items.map((m) => m.kind === "pending-steer" && ids.has(m.id) ? {
5278
5834
  kind: "user",
5279
5835
  id: m.id,
5280
- text: m.text
5836
+ text: m.text,
5837
+ attachments: m.attachments
5281
5838
  } : m);
5282
5839
  }
5283
5840
  case "data-anyone-render-spec": {
@@ -5287,6 +5844,22 @@ function applyChunk(items, chunk, agentName = null) {
5287
5844
  if (!card) return [...items];
5288
5845
  const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : null;
5289
5846
  const specKey = specKeyFor(data.spec);
5847
+ if (card.computeRequestId !== null) {
5848
+ const existingIndex = items.findIndex((m) => m.kind === "card" && m.card.computeRequestId === card.computeRequestId);
5849
+ const existing = items[existingIndex];
5850
+ if (existing?.kind === "card") {
5851
+ if (existing.specKey === specKey || card.settled === null) return [...items];
5852
+ const next = [...items];
5853
+ next[existingIndex] = {
5854
+ ...existing,
5855
+ specKey,
5856
+ card,
5857
+ status: "done",
5858
+ detail: card.settled
5859
+ };
5860
+ return next;
5861
+ }
5862
+ }
5290
5863
  if (items.some((m) => m.kind === "card" && m.toolCallId === toolCallId && m.specKey === specKey)) return [...items];
5291
5864
  return [...items, {
5292
5865
  kind: "card",
@@ -5294,8 +5867,8 @@ function applyChunk(items, chunk, agentName = null) {
5294
5867
  toolCallId,
5295
5868
  specKey,
5296
5869
  card,
5297
- status: "idle",
5298
- detail: null,
5870
+ status: card.settled === null ? "idle" : "done",
5871
+ detail: card.settled,
5299
5872
  agentName
5300
5873
  }];
5301
5874
  }
@@ -5518,7 +6091,7 @@ function uiMessagesToItems(messages) {
5518
6091
  kind: "user",
5519
6092
  id: m.id,
5520
6093
  text,
5521
- ...attachments.length > 0 ? { attachments } : {}
6094
+ attachments
5522
6095
  });
5523
6096
  continue;
5524
6097
  }
@@ -5557,6 +6130,20 @@ function uiMessagesToItems(messages) {
5557
6130
  if (card && data) {
5558
6131
  const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : null;
5559
6132
  const specKey = specKeyFor(data.spec);
6133
+ if (card.computeRequestId !== null) {
6134
+ const existingIndex = out.findIndex((item) => item.kind === "card" && item.card.computeRequestId === card.computeRequestId);
6135
+ const existing = out[existingIndex];
6136
+ if (existing?.kind === "card") {
6137
+ if (existing.specKey !== specKey && card.settled !== null) out[existingIndex] = {
6138
+ ...existing,
6139
+ specKey,
6140
+ card,
6141
+ status: "done",
6142
+ detail: card.settled
6143
+ };
6144
+ continue;
6145
+ }
6146
+ }
5560
6147
  if (out.some((item) => item.kind === "card" && item.toolCallId === toolCallId && item.specKey === specKey)) continue;
5561
6148
  const ordinal = out.filter((item) => item.kind === "card" && item.toolCallId === toolCallId).length;
5562
6149
  out.push({
@@ -5565,8 +6152,8 @@ function uiMessagesToItems(messages) {
5565
6152
  toolCallId,
5566
6153
  specKey,
5567
6154
  card,
5568
- status: "idle",
5569
- detail: null,
6155
+ status: card.settled === null ? "idle" : "done",
6156
+ detail: card.settled,
5570
6157
  agentName
5571
6158
  });
5572
6159
  }
@@ -5675,7 +6262,7 @@ function windowTodos(todos, cap = MAX_VISIBLE_ROWS) {
5675
6262
  const sorted = [...todos].sort((a, b) => a.position - b.position);
5676
6263
  let leadingDone = 0;
5677
6264
  while (leadingDone < sorted.length && sorted[leadingDone]?.status === "completed") leadingDone += 1;
5678
- const head = leadingDone >= 2 || sorted.length > cap ? leadingDone : 0;
6265
+ const head = leadingDone === sorted.length && sorted.length > 0 || leadingDone >= 2 || sorted.length > cap ? leadingDone : 0;
5679
6266
  const rest = sorted.slice(head);
5680
6267
  const rowBudget = Math.max(1, head > 0 ? cap - 1 : cap);
5681
6268
  if (rest.length <= rowBudget) return {
@@ -5706,7 +6293,7 @@ function todoCardHeight(todos) {
5706
6293
  }
5707
6294
 
5708
6295
  //#endregion
5709
- //#region src/chat/tui/chat/todo-card.tsx
6296
+ //#region src/chat/tui/chat/task-card.tsx
5710
6297
  function glyph(status) {
5711
6298
  switch (status) {
5712
6299
  case "completed": return {
@@ -5735,38 +6322,18 @@ function rowColor(status) {
5735
6322
  default: return theme.muted;
5736
6323
  }
5737
6324
  }
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;
6325
+ /** Shared plan/subagent checklist content. The caller owns outer spacing. */
6326
+ function TaskCardContent({ label, todos, isActive }) {
6327
+ const completed = todos.filter((todo) => todo.status === "completed").length;
5755
6328
  const { doneSummary, rows, moreCount } = windowTodos(todos);
5756
6329
  return /* @__PURE__ */ jsxs("box", {
5757
- style: {
5758
- flexDirection: "column",
5759
- flexShrink: 0,
5760
- marginTop: 1,
5761
- paddingLeft: 1,
5762
- paddingRight: 1
5763
- },
6330
+ style: { flexDirection: "column" },
5764
6331
  children: [
5765
6332
  /* @__PURE__ */ jsxs("box", {
5766
6333
  style: { flexDirection: "row" },
5767
6334
  children: [/* @__PURE__ */ jsx("text", {
5768
6335
  fg: isActive ? theme.accent : theme.muted,
5769
- children: /* @__PURE__ */ jsx("b", { children: "Plan" })
6336
+ children: /* @__PURE__ */ jsx("b", { children: label })
5770
6337
  }), /* @__PURE__ */ jsxs("text", {
5771
6338
  fg: theme.dim,
5772
6339
  children: [
@@ -5811,6 +6378,260 @@ function TodoCardView({ todos, isActive }) {
5811
6378
  ]
5812
6379
  });
5813
6380
  }
6381
+ function taskCardContentHeight(todos) {
6382
+ if (todos.length === 0) return 0;
6383
+ const window = windowTodos(todos);
6384
+ return 1 + (window.doneSummary > 0 ? 1 : 0) + window.rows.length + (window.moreCount > 0 ? 1 : 0);
6385
+ }
6386
+
6387
+ //#endregion
6388
+ //#region src/chat/tui/chat/todo-card.tsx
6389
+ /**
6390
+ * The agent's live todo list, pinned just above the composer — the TUI analog
6391
+ * of the web chat's todo card. Rendered only once the agent has actually
6392
+ * written a list (`platform todo write`) and until it clears one, so a
6393
+ * conversation the agent never wrote todos for grows no chrome for it.
6394
+ *
6395
+ * The card can't scroll like the web card, so it windows completed and pending
6396
+ * work. `isActive` dims the header while the run is idle.
6397
+ */
6398
+ function TodoCardView({ todos, isActive }) {
6399
+ if (todos.length === 0) return null;
6400
+ return /* @__PURE__ */ jsx("box", {
6401
+ style: {
6402
+ flexDirection: "column",
6403
+ flexShrink: 0,
6404
+ marginTop: 1,
6405
+ paddingLeft: 1,
6406
+ paddingRight: 1
6407
+ },
6408
+ children: /* @__PURE__ */ jsx(TaskCardContent, {
6409
+ label: "Plan",
6410
+ todos,
6411
+ isActive
6412
+ })
6413
+ });
6414
+ }
6415
+
6416
+ //#endregion
6417
+ //#region src/chat/tui/chat/subagents.ts
6418
+ /** A run the platform hasn't settled yet — still queued or mid-run. */
6419
+ function isLiveStatus(status) {
6420
+ return status === "queued" || status === "running";
6421
+ }
6422
+ function toTaskStatus(raw) {
6423
+ switch (raw) {
6424
+ case "queued":
6425
+ case "running":
6426
+ case "completed":
6427
+ case "failed":
6428
+ case "canceled": return raw;
6429
+ default: return "queued";
6430
+ }
6431
+ }
6432
+ const FALLBACK_TITLE = "Subagent task";
6433
+ /**
6434
+ * Each delegated task's display title from the `subagent` call's input, in
6435
+ * call order — the short name the tool requires per task. A malformed entry
6436
+ * yields a fallback label rather than being dropped, so the row count always
6437
+ * matches the fan-out width. Mirrors the web client's `subagentTaskTitles`.
6438
+ */
6439
+ function subagentTaskTitles(input) {
6440
+ if (!isRecord(input)) return [];
6441
+ const tasks = input["tasks"];
6442
+ if (!Array.isArray(tasks)) return [];
6443
+ return tasks.map((task) => {
6444
+ if (!isRecord(task)) return FALLBACK_TITLE;
6445
+ const title = task["title"];
6446
+ return typeof title === "string" && title.trim() ? title.trim() : FALLBACK_TITLE;
6447
+ });
6448
+ }
6449
+ 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;
6450
+ /**
6451
+ * The ids of the runs a `subagent` call queued, read back out of its own
6452
+ * output. The call returns the moment the runs exist, so the ids are there
6453
+ * long before any child finishes — they are what ties this card to the task
6454
+ * rows the status poll returns, and their order is the tasks' call order.
6455
+ */
6456
+ function queuedSubagentTaskIds(output) {
6457
+ const text = flattenOutputText(output);
6458
+ if (!text) return [];
6459
+ return text.split("\n").map((line) => line.match(QUEUED_TASK_ID_LINE)?.[1] ?? null).filter((id) => id !== null);
6460
+ }
6461
+ /**
6462
+ * Tool output reaches the TUI as `unknown`: the raw string live, possibly
6463
+ * wrapped in content parts or arrays when loaded from history. Walk it for
6464
+ * string leaves, newline-joined — the PR scanner's `stringifyToolOutput`
6465
+ * shape, bounded small because the id list sits at the top of the output.
6466
+ */
6467
+ function flattenOutputText(value) {
6468
+ const MAX_LEN = 2e4;
6469
+ const parts = [];
6470
+ let total = 0;
6471
+ const visit = (v, depth) => {
6472
+ if (total >= MAX_LEN || depth > 4) return;
6473
+ if (typeof v === "string") {
6474
+ parts.push(v);
6475
+ total += v.length;
6476
+ return;
6477
+ }
6478
+ if (Array.isArray(v)) {
6479
+ for (const item of v) visit(item, depth + 1);
6480
+ return;
6481
+ }
6482
+ if (isRecord(v)) for (const item of Object.values(v)) visit(item, depth + 1);
6483
+ };
6484
+ visit(value, 0);
6485
+ return parts.join("\n");
6486
+ }
6487
+ function nextAnchoredSubagentIds({ current, nextId, liveById }) {
6488
+ if (current.includes(nextId)) return current;
6489
+ return current.some((id) => liveById[id] ?? true) ? [...current, nextId] : [nextId];
6490
+ }
6491
+ /**
6492
+ * The card's rows: one per queued run id (call order), each carrying the
6493
+ * freshest known state. A task the status fetch hasn't returned yet falls
6494
+ * back to the input's title and reads as queued; a fetched row's own title
6495
+ * wins over the input's (the server's copy is the one the child actually
6496
+ * ran under). Before the ids exist — the call is still streaming — rows come
6497
+ * from the input titles alone, so the card never renders empty mid-turn.
6498
+ */
6499
+ function subagentRows({ titles, ids, fetched }) {
6500
+ if (ids.length === 0) return titles.map((title, index) => ({
6501
+ id: `queued:${index}`,
6502
+ title,
6503
+ status: "queued",
6504
+ createdAt: null,
6505
+ completedAt: null
6506
+ }));
6507
+ return ids.map((id, index) => {
6508
+ const row = fetched?.get(id) ?? null;
6509
+ return {
6510
+ id,
6511
+ title: row?.title ?? titles[index] ?? FALLBACK_TITLE,
6512
+ status: row ? toTaskStatus(row.status) : "queued",
6513
+ createdAt: row?.createdAt ?? null,
6514
+ completedAt: row?.completedAt ?? null
6515
+ };
6516
+ });
6517
+ }
6518
+
6519
+ //#endregion
6520
+ //#region src/chat/tui/chunks/tool-subagent.tsx
6521
+ /** How often a live batch re-reads its tasks' status. */
6522
+ const POLL_INTERVAL_MS = 3e3;
6523
+ function subagentTodos(rows) {
6524
+ return rows.map((row, position) => ({
6525
+ id: row.id,
6526
+ content: row.title,
6527
+ position,
6528
+ status: row.status === "completed" ? "completed" : row.status === "running" ? "in_progress" : row.status === "failed" || row.status === "canceled" ? "failed" : "pending"
6529
+ }));
6530
+ }
6531
+ /**
6532
+ * The transcript block for a `subagent` tool call — the TUI analog of the web
6533
+ * chat's fan-out card. One row per delegated run: status glyph, the short
6534
+ * title the delegating agent wrote, ticking over live as children settle.
6535
+ *
6536
+ * Rows are seeded from the call itself (titles from its input, task ids from
6537
+ * its own output), so the card renders the moment the call does. Status then
6538
+ * comes from the server's task rows, polled while any run is live — the
6539
+ * delegated runs outlive the call that spawned them, so the transcript stream
6540
+ * carries nothing more about them. The poll stops the moment the batch
6541
+ * settles; reopening an old conversation does one fetch and stops there. A
6542
+ * failed poll is skipped silently (the next tick retries; stale rows beat an
6543
+ * error row in the scrollback).
6544
+ */
6545
+ function ToolSubagent({ item, isActive, onLiveChange, onHeightChange }) {
6546
+ const { rest, conversationId } = useConversationContext();
6547
+ const titles = subagentTaskTitles(item.input);
6548
+ const ids = item.output?.kind === "ok" ? queuedSubagentTaskIds(item.output.value) : [];
6549
+ const idsKey = ids.join(",");
6550
+ const [fetched, setFetched] = useState(null);
6551
+ const rows = subagentRows({
6552
+ titles,
6553
+ ids,
6554
+ fetched
6555
+ });
6556
+ const live = rows.length === 0 || rows.some((row) => isLiveStatus(row.status));
6557
+ const todos = subagentTodos(rows);
6558
+ useEffect(() => {
6559
+ onLiveChange?.(live);
6560
+ }, [live, onLiveChange]);
6561
+ useEffect(() => {
6562
+ onHeightChange?.(taskCardContentHeight(todos));
6563
+ }, [onHeightChange, todos]);
6564
+ useEffect(() => {
6565
+ if (!rest || !conversationId || idsKey.length === 0 || !live) return;
6566
+ let cancelled = false;
6567
+ let timer = null;
6568
+ const tick = async () => {
6569
+ try {
6570
+ const tasks = await rest.listSubagentTasks({ conversationId });
6571
+ if (cancelled) return;
6572
+ setFetched(tasks);
6573
+ } catch (_error) {}
6574
+ if (!cancelled) timer = setTimeout(tick, POLL_INTERVAL_MS);
6575
+ };
6576
+ tick();
6577
+ return () => {
6578
+ cancelled = true;
6579
+ if (timer !== null) clearTimeout(timer);
6580
+ };
6581
+ }, [
6582
+ rest,
6583
+ conversationId,
6584
+ idsKey,
6585
+ live
6586
+ ]);
6587
+ if (rows.length === 0) return /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
6588
+ fg: theme.warning,
6589
+ children: "◐ "
6590
+ }), /* @__PURE__ */ jsx("span", {
6591
+ fg: theme.tool,
6592
+ children: "Delegating to subagents…"
6593
+ })] });
6594
+ return /* @__PURE__ */ jsx(TaskCardContent, {
6595
+ label: rows.length === 1 ? "Subagent" : "Subagents",
6596
+ todos,
6597
+ isActive: isActive ?? live
6598
+ });
6599
+ }
6600
+
6601
+ //#endregion
6602
+ //#region src/chat/tui/chat/subagent-card.tsx
6603
+ /**
6604
+ * Delegated work pinned above the plan. Each batch owns its status polling and
6605
+ * stays as a dimmed completion summary after every run settles.
6606
+ */
6607
+ function SubagentCardView({ items, isActive, onHeightChange, onBatchLiveChange }) {
6608
+ const [heightById, setHeightById] = useState({});
6609
+ const setBatchHeight = useCallback((id, height) => {
6610
+ setHeightById((current) => current[id] === height ? current : {
6611
+ ...current,
6612
+ [id]: height
6613
+ });
6614
+ }, []);
6615
+ const height = useMemo(() => items.length === 0 ? 0 : 1 + items.reduce((rows, item) => rows + (heightById[item.id] ?? 0), 0) + (items.length - 1), [heightById, items]);
6616
+ useEffect(() => onHeightChange?.(height), [height, onHeightChange]);
6617
+ if (items.length === 0) return null;
6618
+ return /* @__PURE__ */ jsx("box", {
6619
+ style: {
6620
+ flexDirection: "column",
6621
+ flexShrink: 0,
6622
+ marginTop: 1,
6623
+ paddingLeft: 1,
6624
+ paddingRight: 1,
6625
+ gap: 1
6626
+ },
6627
+ children: items.map((item) => /* @__PURE__ */ jsx(ToolSubagent, {
6628
+ item,
6629
+ isActive,
6630
+ onLiveChange: (live) => onBatchLiveChange?.(item.id, live),
6631
+ onHeightChange: (batchHeight) => setBatchHeight(item.id, batchHeight)
6632
+ }, item.id))
6633
+ });
6634
+ }
5814
6635
 
5815
6636
  //#endregion
5816
6637
  //#region src/chat/tui/chat/pr-links.tsx
@@ -5969,6 +6790,55 @@ function formatShare(share) {
5969
6790
  return share.grantedToAgent ? "shared (this agent can run commands here)" : "shared (this agent is not granted)";
5970
6791
  }
5971
6792
 
6793
+ //#endregion
6794
+ //#region src/chat/sandbox/collect-stats.ts
6795
+ /**
6796
+ * Drop a snapshot the api hasn't refreshed recently: the sweep that writes it
6797
+ * runs ~every 60s, so a sample older than {@link COMPUTER_STATS_STALE_MS} means
6798
+ * the box stopped reporting (paused / gone) and its numbers are no longer live.
6799
+ * Presenting it as current would be a lie, so treat it as "no snapshot".
6800
+ */
6801
+ function freshOrNull(stats, now) {
6802
+ if (!stats) return null;
6803
+ return now - stats.sampledAt <= COMPUTER_STATS_STALE_MS ? stats : null;
6804
+ }
6805
+ function collectSandboxStats(cfg) {
6806
+ let aborted = false;
6807
+ let resolveRef = null;
6808
+ let settled = false;
6809
+ const settle = (r) => {
6810
+ if (settled) return;
6811
+ settled = true;
6812
+ resolveRef?.(r);
6813
+ };
6814
+ return {
6815
+ result: new Promise((resolve) => {
6816
+ resolveRef = resolve;
6817
+ cfg.read().then((stats) => {
6818
+ if (aborted) return;
6819
+ settle({
6820
+ ok: true,
6821
+ stats: freshOrNull(stats, cfg.now())
6822
+ });
6823
+ }).catch((err) => {
6824
+ if (aborted) return;
6825
+ settle({
6826
+ ok: false,
6827
+ error: err instanceof Error ? err.message : String(err)
6828
+ });
6829
+ });
6830
+ }),
6831
+ abort: () => {
6832
+ aborted = true;
6833
+ settle({
6834
+ ok: false,
6835
+ aborted: true,
6836
+ error: "aborted"
6837
+ });
6838
+ }
6839
+ };
6840
+ }
6841
+
5972
6842
  //#endregion
5973
6843
  //#region src/chat/notification-preview.ts
5974
6844
  const MAX_NOTIFICATION_PREVIEW_CHARS = 240;
@@ -6019,6 +6889,101 @@ function createResponsePreview() {
6019
6889
  };
6020
6890
  }
6021
6891
 
6892
+ //#endregion
6893
+ //#region src/chat/tui/new-chat-splash.tsx
6894
+ /** Rows the splash art needs before it's worth painting: the 6-row mark, a
6895
+ * spacer, the two identity/hint lines, and a row of breathing room. Below
6896
+ * this only the identity + hint render (small panes must keep every
6897
+ * transcript row). */
6898
+ const MARK_MIN_ROWS = 10;
6899
+ /** Columns the full mark + wordmark lockup spans (~68), with margin; below
6900
+ * this the pinwheel mark renders alone instead of wrapping the wordmark. */
6901
+ const FULL_MARK_MIN_COLS = 70;
6902
+ /**
6903
+ * Brand splash filling an empty conversation's transcript area: the Skydive
6904
+ * mark centered above the agent's identity and the "say hello" hint, with
6905
+ * the composer live below. The identity line matters here because nothing
6906
+ * else on this screen names the agent — a bare `skydive chat` boots straight
6907
+ * into this view via the configured default. Rendered only while the
6908
+ * transcript is empty, so the first submission replaces it with the regular
6909
+ * conversation view. Degrades by available space: full lockup → mark only →
6910
+ * identity + hint only.
6911
+ */
6912
+ function NewChatSplash({ agentName, agentTitle, width, height }) {
6913
+ return /* @__PURE__ */ jsxs("box", {
6914
+ style: {
6915
+ height,
6916
+ flexDirection: "column",
6917
+ justifyContent: "center",
6918
+ alignItems: "center"
6919
+ },
6920
+ children: [
6921
+ height >= MARK_MIN_ROWS ? /* @__PURE__ */ jsx("box", {
6922
+ style: {
6923
+ flexDirection: "column",
6924
+ alignItems: "center",
6925
+ marginBottom: 1
6926
+ },
6927
+ children: /* @__PURE__ */ jsx(SkydiveMark, { variant: width >= FULL_MARK_MIN_COLS ? "full" : "mark" })
6928
+ }) : null,
6929
+ /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
6930
+ fg: theme.accent,
6931
+ children: agentName
6932
+ }), agentTitle ? /* @__PURE__ */ jsxs("span", {
6933
+ fg: theme.muted,
6934
+ children: [" · ", agentTitle]
6935
+ }) : null] }),
6936
+ /* @__PURE__ */ jsx("text", {
6937
+ fg: theme.dim,
6938
+ children: "say hello below"
6939
+ })
6940
+ ]
6941
+ });
6942
+ }
6943
+
6944
+ //#endregion
6945
+ //#region src/chat/tui/spinner-frames.ts
6946
+ /** Braille dot-cycle frames — the classic smooth terminal spinner. */
6947
+ const SPINNER_FRAMES = [
6948
+ "⠋",
6949
+ "⠙",
6950
+ "⠹",
6951
+ "⠸",
6952
+ "⠼",
6953
+ "⠴",
6954
+ "⠦",
6955
+ "⠧",
6956
+ "⠇",
6957
+ "⠏"
6958
+ ];
6959
+ /** Milliseconds between frames. ~12.5fps reads as smooth without thrashing. */
6960
+ const SPINNER_INTERVAL_MS = 80;
6961
+ /** Frame for a given tick count. Wraps at the end of the cycle; negative-safe. */
6962
+ function spinnerFrame(tick) {
6963
+ const n = SPINNER_FRAMES.length;
6964
+ return SPINNER_FRAMES[(Math.trunc(tick) % n + n) % n] ?? SPINNER_FRAMES[0];
6965
+ }
6966
+
6967
+ //#endregion
6968
+ //#region src/chat/tui/spinner.tsx
6969
+ /**
6970
+ * An animated spinner glyph. Advances one frame every SPINNER_INTERVAL_MS via
6971
+ * a self-owned interval that's cleared on unmount, so the animation runs only
6972
+ * while the component is mounted. Returns a <span>, so render it inside a
6973
+ * <text> (matching how the rest of the TUI composes inline glyphs).
6974
+ */
6975
+ function Spinner({ color }) {
6976
+ const [tick, setTick] = useState(0);
6977
+ useEffect(() => {
6978
+ const id = setInterval(() => setTick((t) => t + 1), SPINNER_INTERVAL_MS);
6979
+ return () => clearInterval(id);
6980
+ }, []);
6981
+ return /* @__PURE__ */ jsx("span", {
6982
+ fg: color,
6983
+ children: spinnerFrame(tick)
6984
+ });
6985
+ }
6986
+
6022
6987
  //#endregion
6023
6988
  //#region src/chat/tui/prompt-history.ts
6024
6989
  /**
@@ -6752,6 +7717,13 @@ const slashCommands = [
6752
7717
  description: "switch conversation (no id: pick from the list)",
6753
7718
  action: { kind: "conversation" }
6754
7719
  },
7720
+ {
7721
+ name: "search",
7722
+ aliases: ["find"],
7723
+ argsHint: "[query]",
7724
+ description: "search conversations by title, message, or participant",
7725
+ action: { kind: "search" }
7726
+ },
6755
7727
  {
6756
7728
  name: "model",
6757
7729
  aliases: [],
@@ -6773,6 +7745,13 @@ const slashCommands = [
6773
7745
  description: "show connection and session status",
6774
7746
  action: { kind: "status" }
6775
7747
  },
7748
+ {
7749
+ name: "computer",
7750
+ aliases: ["stats"],
7751
+ argsHint: null,
7752
+ description: "show the agent sandbox's cpu, memory, and disk",
7753
+ action: { kind: "computer" }
7754
+ },
6776
7755
  {
6777
7756
  name: "plan",
6778
7757
  aliases: ["todos", "todo"],
@@ -6847,7 +7826,8 @@ const slashCommands = [
6847
7826
  *
6848
7827
  * An argument-less command only matches as the exact line — `/help me write
6849
7828
  * 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.
7829
+ * into the help modal. `sandbox`, `conversation`, `search` and `rename`
7830
+ * accept a rest (their argsHint is non-null).
6851
7831
  */
6852
7832
  function matchSlashCommand(line) {
6853
7833
  if (!line.startsWith("/")) return null;
@@ -9950,6 +10930,10 @@ function routeInput(raw) {
9950
10930
  kind: "open-conversation",
9951
10931
  ref: match.rest
9952
10932
  } : { kind: "conversation-picker" };
10933
+ case "search": return {
10934
+ kind: "conversation-search",
10935
+ query: match.rest
10936
+ };
9953
10937
  case "rename-conversation": return {
9954
10938
  kind: "rename-conversation",
9955
10939
  title: match.rest
@@ -9967,6 +10951,7 @@ function routeInput(raw) {
9967
10951
  case "model-picker":
9968
10952
  case "theme-picker":
9969
10953
  case "status":
10954
+ case "computer":
9970
10955
  case "share":
9971
10956
  case "browser":
9972
10957
  case "connect":
@@ -10034,47 +11019,6 @@ async function runPtySession(opts) {
10034
11019
  }
10035
11020
  }
10036
11021
 
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
11022
  //#endregion
10079
11023
  //#region src/chat/tui/screens/chat.tsx
10080
11024
  /**
@@ -10159,11 +11103,13 @@ function ChatScreen({ agent, conversation }) {
10159
11103
  const portalClient = useStore((s) => s.portalClient);
10160
11104
  const setPortalPrompt = useStore((s) => s.setPortalPrompt);
10161
11105
  const declinedRef = useRef(/* @__PURE__ */ new Set());
11106
+ const grantInFlightRef = useRef(/* @__PURE__ */ new Set());
10162
11107
  const grantPrompt = portal.prompt && portal.prompt.agentId === agent.id ? portal.prompt : null;
10163
11108
  const isShared = portal.status !== "off";
10164
11109
  const initialConversationId = isNewConversation(conversation) ? null : conversation.id;
10165
11110
  const agentHost = useAgentHost();
10166
11111
  const nonWebChannel = nonWebChannelLabel(conversation);
11112
+ const agentRunConversation = isAgentRunConversation(conversation);
10167
11113
  const [conversationId, setConversationId] = useState(initialConversationId);
10168
11114
  const { compacting, compactConversation, onCompactionEvent } = useCompaction({
10169
11115
  rest,
@@ -10172,6 +11118,12 @@ function ChatScreen({ agent, conversation }) {
10172
11118
  const [items, setItems] = useState([]);
10173
11119
  const [todos, setTodos] = useState(null);
10174
11120
  const [planHidden, setPlanHidden] = useState(false);
11121
+ const [subagentRows, setSubagentRows] = useState(0);
11122
+ const [anchoredSubagentIds, setAnchoredSubagentIds] = useState([]);
11123
+ const anchoredSubagentLiveRef = useRef({});
11124
+ const setAnchoredBatchLive = useCallback((id, live) => {
11125
+ anchoredSubagentLiveRef.current[id] = live;
11126
+ }, []);
10175
11127
  const [scrolledUp, setScrolledUp] = useState(false);
10176
11128
  const hadTodosRef = useRef(false);
10177
11129
  useEffect(() => {
@@ -10214,14 +11166,18 @@ function ChatScreen({ agent, conversation }) {
10214
11166
  const forkingRef = useRef(false);
10215
11167
  const [credPrompt, setCredPrompt] = useState(null);
10216
11168
  const credPromptOpen = credPrompt !== null;
11169
+ const [computePrompt, setComputePrompt] = useState(null);
11170
+ const computePromptOpen = computePrompt !== null;
10217
11171
  const [menuDismissed, setMenuDismissed] = useState(false);
10218
11172
  const [menuHighlight, setMenuHighlight] = useState(0);
10219
- const commandQuery = grantPrompt || credPrompt ? null : detectCommandTrigger(input);
11173
+ const commandQuery = grantPrompt || credPrompt || computePrompt ? null : detectCommandTrigger(input);
10220
11174
  const menuCommands = commandQuery === null ? [] : filterSlashCommands(commandQuery);
10221
11175
  const menuOpen = commandQuery !== null && !menuDismissed;
10222
11176
  const menuHighlightClamped = Math.min(menuHighlight, Math.max(0, menuCommands.length - 1));
10223
11177
  const runRef = useRef(run);
10224
11178
  runRef.current = run;
11179
+ const conversationIdRef = useRef(conversationId);
11180
+ conversationIdRef.current = conversationId;
10225
11181
  const itemsRef = useRef(items);
10226
11182
  itemsRef.current = items;
10227
11183
  const attachmentsRef = useRef(attachments);
@@ -10232,7 +11188,7 @@ function ChatScreen({ agent, conversation }) {
10232
11188
  credPromptRef.current = credPrompt;
10233
11189
  const seenRunsRef = useRef(/* @__PURE__ */ new Set());
10234
11190
  const shellSessionRef = useRef(null);
10235
- const hostState = grantPrompt || credPrompt ? "blocked" : run.kind === "idle" ? "idle" : "working";
11191
+ const hostState = grantPrompt || credPrompt || computePrompt ? "blocked" : run.kind === "idle" ? "idle" : "working";
10236
11192
  useEffect(() => {
10237
11193
  agentHost.transition({ state: hostState });
10238
11194
  }, [agentHost, hostState]);
@@ -10271,13 +11227,16 @@ function ChatScreen({ agent, conversation }) {
10271
11227
  });
10272
11228
  setScrolledUp(false);
10273
11229
  }, []);
11230
+ const scrolledUpRef = useRef(false);
10274
11231
  useEffect(() => {
10275
11232
  const id = setInterval(() => {
10276
11233
  const box = scrollRef.current;
10277
11234
  if (!box) return;
10278
11235
  const viewport = box.viewport.height;
10279
- const atBottom = box.scrollTop + viewport >= box.scrollHeight - 1;
10280
- setScrolledUp((prev) => prev === !atBottom ? prev : !atBottom);
11236
+ const next = !(box.scrollTop + viewport >= box.scrollHeight - 1);
11237
+ if (scrolledUpRef.current === next) return;
11238
+ scrolledUpRef.current = next;
11239
+ setScrolledUp(next);
10281
11240
  }, 150);
10282
11241
  return () => clearInterval(id);
10283
11242
  }, []);
@@ -10285,11 +11244,12 @@ function ChatScreen({ agent, conversation }) {
10285
11244
  const reviewBelowBoxRef = useRef(null);
10286
11245
  const paneDragRef = useRef(null);
10287
11246
  const composerBoxHeight = composerRows + 2;
10288
- const pendingVisible = !grantPrompt && !credPrompt && !nonWebChannel && attachments.length > 0;
11247
+ const pendingVisible = !grantPrompt && !credPrompt && !computePrompt && !nonWebChannel && attachments.length > 0;
10289
11248
  const pendingRows = pendingVisible ? attachments.length + 2 : 0;
10290
11249
  const credPromptHeight = 4 + (credPrompt && (credPrompt.error || credPrompt.submitting) ? 1 : 0);
10291
11250
  const nonWebNoticeHeight = 5;
10292
- const bottomBoxHeight = credPrompt ? credPromptHeight : nonWebChannel ? nonWebNoticeHeight : composerBoxHeight;
11251
+ const computePromptHeight = 4;
11252
+ const bottomBoxHeight = credPrompt ? credPromptHeight : computePrompt ? computePromptHeight : nonWebChannel ? nonWebNoticeHeight : composerBoxHeight;
10293
11253
  const bodyWidth = Math.max(1, width - 2);
10294
11254
  const bodyHeight = Math.max(1, height - 3);
10295
11255
  const reviewPlacement = filePanePlacement({
@@ -10306,9 +11266,13 @@ function ChatScreen({ agent, conversation }) {
10306
11266
  const menuRows = menuOpen ? completionMenuHeight(menuCommands.length) : 0;
10307
11267
  const todoCardVisible = !credPrompt && !planHidden && !!todos && todos.length > 0;
10308
11268
  const todoRows = todoCardVisible ? todoCardHeight(todos) : 0;
11269
+ const subagentItems = useMemo(() => {
11270
+ const ids = new Set(anchoredSubagentIds);
11271
+ return items.filter((item) => item.kind === "tool" && item.toolName === "subagent" && ids.has(item.id));
11272
+ }, [items, anchoredSubagentIds]);
10309
11273
  const jumpHintVisible = scrolledUp && !credPrompt;
10310
11274
  const jumpHintRows = jumpHintVisible ? 1 : 0;
10311
- const scrollHeight = Math.max(3, chatHeight - 2 - bottomBoxHeight - pendingRows - inlineReviewHeight - menuRows - jumpHintRows - todoRows);
11275
+ const scrollHeight = Math.max(3, chatHeight - 2 - bottomBoxHeight - pendingRows - inlineReviewHeight - menuRows - jumpHintRows - subagentRows - todoRows);
10312
11276
  useEffect(() => {
10313
11277
  if (reviewOpen && reviewFocused) {
10314
11278
  const composer = composerRef.current;
@@ -10342,7 +11306,8 @@ function ChatScreen({ agent, conversation }) {
10342
11306
  reviewOpen,
10343
11307
  reviewFocused,
10344
11308
  reviewPlacement,
10345
- credPromptOpen
11309
+ credPromptOpen,
11310
+ computePromptOpen
10346
11311
  ]);
10347
11312
  useEffect(() => {
10348
11313
  if (!rest || !initialConversationId) return;
@@ -10358,10 +11323,13 @@ function ChatScreen({ agent, conversation }) {
10358
11323
  text: recapText
10359
11324
  });
10360
11325
  setItems(loaded);
11326
+ const toolUrlText = loaded.map((item) => item.kind === "tool" && item.output?.kind === "ok" ? stringifyToolOutput(item.output.value) : "").join("\n");
10361
11327
  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 })) {
11328
+ if (item.kind !== "assistant-text") continue;
11329
+ for (const pr of extractPullRequests(item.text, {
11330
+ requireCompleteNumber: false,
11331
+ urlOnlyText: toolUrlText
11332
+ })) {
10365
11333
  if (reportedPrsRef.current.has(pr.key)) continue;
10366
11334
  reportedPrsRef.current.set(pr.key, pr);
10367
11335
  setPrLinks((prev) => [...prev, pr]);
@@ -10388,6 +11356,15 @@ function ChatScreen({ agent, conversation }) {
10388
11356
  initialConversationId,
10389
11357
  agentHost
10390
11358
  ]);
11359
+ const markRead = useCallback(() => {
11360
+ const id = conversationIdRef.current;
11361
+ if (!rest || !id) return;
11362
+ rest.markConversationRead({ conversationId: id }).catch(() => {});
11363
+ }, [rest]);
11364
+ useEffect(() => {
11365
+ if (!conversationId) return;
11366
+ markRead();
11367
+ }, [conversationId, markRead]);
10391
11368
  const attachToRun = useCallback((runId, runAgentName, runStartedAtMs) => {
10392
11369
  if (!rest) return;
10393
11370
  seenRunsRef.current.add(runId);
@@ -10400,6 +11377,7 @@ function ChatScreen({ agent, conversation }) {
10400
11377
  agentHost.addPullRequest(pr);
10401
11378
  });
10402
11379
  const streamAgentName = runAgentName ?? agent.name;
11380
+ let sawErrorChunk = false;
10403
11381
  setRun((prev) => ({
10404
11382
  kind: "streaming",
10405
11383
  runId,
@@ -10411,8 +11389,21 @@ function ChatScreen({ agent, conversation }) {
10411
11389
  signal: abort.signal,
10412
11390
  onEvent: (event) => {
10413
11391
  if (event.kind === "chunk") {
11392
+ if (event.chunk.type === "error") sawErrorChunk = true;
10414
11393
  responsePreview.addChunk(event.chunk);
10415
11394
  prScanner.addChunk(event.chunk);
11395
+ if ((event.chunk.type === "tool-input-start" || event.chunk.type === "tool-input-available") && event.chunk.toolName === "subagent" && typeof event.chunk.toolCallId === "string") {
11396
+ const id = event.chunk.toolCallId;
11397
+ setAnchoredSubagentIds((current) => {
11398
+ const next = nextAnchoredSubagentIds({
11399
+ current,
11400
+ nextId: id,
11401
+ liveById: anchoredSubagentLiveRef.current
11402
+ });
11403
+ if (next.length === 1 && next[0] === id) anchoredSubagentLiveRef.current = { [id]: true };
11404
+ return next;
11405
+ });
11406
+ }
10416
11407
  if (event.chunk.type === "data-anyone-todos-updated") {
10417
11408
  const next = todosFromPayload(event.chunk.data);
10418
11409
  if (next) setTodos(next.length > 0 ? next : null);
@@ -10428,7 +11419,7 @@ function ChatScreen({ agent, conversation }) {
10428
11419
  id: crypto.randomUUID(),
10429
11420
  text: billingOutcome.message
10430
11421
  }]);
10431
- else if (event.status !== "ok" && errorText) setItems((prev) => [...prev, {
11422
+ else if (event.status !== "ok" && errorText && !sawErrorChunk) setItems((prev) => [...prev, {
10432
11423
  kind: "error",
10433
11424
  id: crypto.randomUUID(),
10434
11425
  text: errorText
@@ -10436,9 +11427,11 @@ function ChatScreen({ agent, conversation }) {
10436
11427
  setItems((prev) => prev.map((m) => m.kind === "pending-steer" ? {
10437
11428
  kind: "user",
10438
11429
  id: m.id,
10439
- text: m.text
11430
+ text: m.text,
11431
+ attachments: m.attachments
10440
11432
  } : m));
10441
11433
  setRun({ kind: "idle" });
11434
+ markRead();
10442
11435
  agentHost.transition({
10443
11436
  state: "idle",
10444
11437
  notification: {
@@ -10460,7 +11453,8 @@ function ChatScreen({ agent, conversation }) {
10460
11453
  }, [
10461
11454
  rest,
10462
11455
  agent.name,
10463
- agentHost
11456
+ agentHost,
11457
+ markRead
10464
11458
  ]);
10465
11459
  const multiAgent = useMemo(() => conversationAgentNames(items, agent.name), [items, agent.name]).size > 1;
10466
11460
  const transcriptWindow = useMemo(() => windowItems(items), [items]);
@@ -10513,7 +11507,7 @@ function ChatScreen({ agent, conversation }) {
10513
11507
  kind: "user",
10514
11508
  id: optimisticId,
10515
11509
  text: trimmed,
10516
- ...stagedNames.length > 0 ? { attachments: stagedNames } : {}
11510
+ attachments: stagedNames
10517
11511
  }]);
10518
11512
  const wasStreaming = runRef.current.kind === "streaming";
10519
11513
  if (!wasStreaming) setRun({
@@ -10562,7 +11556,8 @@ function ChatScreen({ agent, conversation }) {
10562
11556
  if (directiveId) setItems((prev) => prev.map((m) => m.id === optimisticId ? {
10563
11557
  kind: "pending-steer",
10564
11558
  id: directiveId,
10565
- text: trimmed
11559
+ text: trimmed,
11560
+ attachments: m.kind === "user" || m.kind === "pending-steer" ? m.attachments : []
10566
11561
  } : m));
10567
11562
  const current = runRef.current;
10568
11563
  if (!(current.kind === "streaming" && current.runId === result.runId)) attachToRun(result.runId, result.agentName ?? null);
@@ -10609,12 +11604,14 @@ function ChatScreen({ agent, conversation }) {
10609
11604
  });
10610
11605
  (async () => {
10611
11606
  if (autoGrantMachine && portalClient && portalStatus === "connected" && !portalClient.isGranted(agent.id)) try {
10612
- await portalClient.grantAgent(agent.id);
11607
+ grantInFlightRef.current.add(agent.id);
11608
+ await portalClient.grantAgent(agent.id, null);
10613
11609
  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}`,
11610
+ message: `Shared this machine with ${agent.name} for the import. The grant persists. Revoke anytime: skydive portal revoke --agent "${agent.name}"`,
10615
11611
  durationMs: 1e4
10616
11612
  });
10617
11613
  } catch (err) {
11614
+ grantInFlightRef.current.delete(agent.id);
10618
11615
  useStore.getState().showToast({
10619
11616
  message: `Couldn't grant machine access automatically (${errorMessage(err)}). The agent will ask in chat instead.`,
10620
11617
  variant: "warning"
@@ -10669,6 +11666,14 @@ function ChatScreen({ agent, conversation }) {
10669
11666
  });
10670
11667
  return;
10671
11668
  }
11669
+ if (action.kind === "decide_compute") {
11670
+ setComputePrompt({
11671
+ cardId: item.id,
11672
+ requestId: action.requestId,
11673
+ summary: item.card.subtitle
11674
+ });
11675
+ return;
11676
+ }
10672
11677
  updateCard(item.id, {
10673
11678
  status: "busy",
10674
11679
  detail: null
@@ -10678,9 +11683,12 @@ function ChatScreen({ agent, conversation }) {
10678
11683
  if (!portalClient) throw new Error("portal is unavailable in this session");
10679
11684
  const justEnabled = portal.status === "off";
10680
11685
  if (justEnabled) portalClient.enable();
11686
+ grantInFlightRef.current.add(action.agentId);
11687
+ if (useStore.getState().portal.prompt?.agentId === action.agentId) setPortalPrompt(null);
10681
11688
  try {
10682
- await portalClient.grantAgent(action.agentId);
11689
+ await portalClient.grantAgent(action.agentId, action.conversationId);
10683
11690
  } catch (grantErr) {
11691
+ grantInFlightRef.current.delete(action.agentId);
10684
11692
  if (justEnabled) portalClient.disable();
10685
11693
  throw grantErr;
10686
11694
  }
@@ -10741,8 +11749,45 @@ function ChatScreen({ agent, conversation }) {
10741
11749
  appUrl,
10742
11750
  updateCard,
10743
11751
  portalClient,
10744
- portal.status
11752
+ portal.status,
11753
+ setPortalPrompt
10745
11754
  ]);
11755
+ const decideCompute = useCallback((prompt, decision) => {
11756
+ setComputePrompt(null);
11757
+ if (!rest) return;
11758
+ updateCard(prompt.cardId, {
11759
+ status: "busy",
11760
+ detail: null
11761
+ });
11762
+ const settleIfStillDeciding = (patch) => {
11763
+ setItems((prev) => prev.map((m) => m.kind === "card" && m.id === prompt.cardId && m.status === "busy" && m.card.settled === null ? {
11764
+ ...m,
11765
+ ...patch
11766
+ } : m));
11767
+ };
11768
+ (async () => {
11769
+ try {
11770
+ const { status } = await rest.decideComputeRequest({
11771
+ requestId: prompt.requestId,
11772
+ decision
11773
+ });
11774
+ settleIfStillDeciding({
11775
+ status: "done",
11776
+ detail: computeSettledLabel(status) ?? status
11777
+ });
11778
+ } catch (err) {
11779
+ settleIfStillDeciding({
11780
+ status: "error",
11781
+ detail: cardActionErrorMessage(err)
11782
+ });
11783
+ }
11784
+ })();
11785
+ }, [rest, updateCard]);
11786
+ useEffect(() => {
11787
+ if (!computePrompt) return;
11788
+ const target = items.find((m) => m.kind === "card" && m.id === computePrompt.cardId);
11789
+ if (!target || target.card.settled !== null || target.status === "done") setComputePrompt(null);
11790
+ }, [computePrompt, items]);
10746
11791
  const promptForCards = useCallback(() => {
10747
11792
  const targets = itemsRef.current.filter(isActionableCard);
10748
11793
  const [only] = targets;
@@ -10905,6 +11950,16 @@ function ChatScreen({ agent, conversation }) {
10905
11950
  sessionToken,
10906
11951
  agent.id
10907
11952
  ]);
11953
+ const runComputerStats = useCallback(() => {
11954
+ if (!appUrl || !sessionToken) return;
11955
+ setItems((prev) => [...prev, {
11956
+ kind: "computer",
11957
+ id: crypto.randomUUID(),
11958
+ state: "loading",
11959
+ stats: null,
11960
+ error: null
11961
+ }]);
11962
+ }, [appUrl, sessionToken]);
10908
11963
  const runSandboxPty = useCallback(async () => {
10909
11964
  if (!appUrl || !sessionToken) return;
10910
11965
  const result = await runPtySession({
@@ -10999,7 +12054,7 @@ function ChatScreen({ agent, conversation }) {
10999
12054
  stageAttachment(image);
11000
12055
  }, [stageAttachment]);
11001
12056
  usePaste((event) => {
11002
- if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt || nonWebChannel) return;
12057
+ if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt || computePromptOpen || nonWebChannel) return;
11003
12058
  const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
11004
12059
  const route = routePaste({
11005
12060
  kind: event.metadata?.kind,
@@ -11053,6 +12108,7 @@ function ChatScreen({ agent, conversation }) {
11053
12108
  themePickerOpen,
11054
12109
  helpOpen,
11055
12110
  credPromptOpen,
12111
+ computePromptOpen,
11056
12112
  applyComposerText
11057
12113
  ]);
11058
12114
  const cancelRun = useCallback(async () => {
@@ -11066,7 +12122,8 @@ function ChatScreen({ agent, conversation }) {
11066
12122
  setItems((prev) => prev.map((m) => m.kind === "pending-steer" ? {
11067
12123
  kind: "user",
11068
12124
  id: m.id,
11069
- text: m.text
12125
+ text: m.text,
12126
+ attachments: m.attachments
11070
12127
  } : m));
11071
12128
  useStore.getState().showToast({ message: "run cancelled" });
11072
12129
  }, [rest]);
@@ -11134,7 +12191,11 @@ function ChatScreen({ agent, conversation }) {
11134
12191
  goTo
11135
12192
  ]);
11136
12193
  const archiveConversation = useCallback(() => {
11137
- if (grantPrompt || credPrompt) return;
12194
+ if (grantPrompt || credPrompt || computePrompt) return;
12195
+ if (agentRunConversation) {
12196
+ useStore.getState().showToast({ message: "agent runs can't be archived — ctrl+d in the picker deletes" });
12197
+ return;
12198
+ }
11138
12199
  if (runRef.current.kind !== "idle") {
11139
12200
  useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
11140
12201
  return;
@@ -11154,7 +12215,8 @@ function ChatScreen({ agent, conversation }) {
11154
12215
  });
11155
12216
  goTo({
11156
12217
  kind: "conversation-picker",
11157
- agent
12218
+ agent,
12219
+ initialQuery: ""
11158
12220
  });
11159
12221
  }).catch((err) => useStore.getState().showToast({
11160
12222
  variant: "error",
@@ -11163,6 +12225,8 @@ function ChatScreen({ agent, conversation }) {
11163
12225
  }, [
11164
12226
  grantPrompt,
11165
12227
  credPrompt,
12228
+ computePrompt,
12229
+ agentRunConversation,
11166
12230
  conversationId,
11167
12231
  rest,
11168
12232
  goTo,
@@ -11244,7 +12308,9 @@ function ChatScreen({ agent, conversation }) {
11244
12308
  rest,
11245
12309
  agentSelector: null,
11246
12310
  conversationId: target,
11247
- newConversation: false
12311
+ newConversation: false,
12312
+ defaultAgentSelector: null,
12313
+ forceOnboarding: false
11248
12314
  }));
11249
12315
  } catch (err) {
11250
12316
  setItems((prev) => [...prev, {
@@ -11283,19 +12349,24 @@ function ChatScreen({ agent, conversation }) {
11283
12349
  if (!portalClient) return;
11284
12350
  const agentName = agent.name;
11285
12351
  setPortalPrompt(null);
11286
- portalClient.grantAgent(agent.id).then(() => useStore.getState().showToast({
12352
+ grantInFlightRef.current.add(agent.id);
12353
+ portalClient.grantAgent(agent.id, conversationId).then(() => useStore.getState().showToast({
11287
12354
  variant: "success",
11288
12355
  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
- }]));
12356
+ })).catch((err) => {
12357
+ grantInFlightRef.current.delete(agent.id);
12358
+ setItems((prev) => [...prev, {
12359
+ kind: "error",
12360
+ id: crypto.randomUUID(),
12361
+ text: `couldn't share machine: ${errorDetail(err)}`
12362
+ }]);
12363
+ });
11294
12364
  }, [
11295
12365
  portalClient,
11296
12366
  agent.id,
11297
12367
  agent.name,
11298
- setPortalPrompt
12368
+ setPortalPrompt,
12369
+ conversationId
11299
12370
  ]);
11300
12371
  const declineGrant = useCallback(() => {
11301
12372
  declinedRef.current.add(agent.id);
@@ -11408,7 +12479,15 @@ function ChatScreen({ agent, conversation }) {
11408
12479
  case "conversation-picker":
11409
12480
  leaveForScreen({
11410
12481
  kind: "conversation-picker",
11411
- agent
12482
+ agent,
12483
+ initialQuery: ""
12484
+ });
12485
+ break;
12486
+ case "conversation-search":
12487
+ leaveForScreen({
12488
+ kind: "conversation-picker",
12489
+ agent,
12490
+ initialQuery: routed.query
11412
12491
  });
11413
12492
  break;
11414
12493
  case "open-conversation":
@@ -11438,6 +12517,9 @@ function ChatScreen({ agent, conversation }) {
11438
12517
  })
11439
12518
  }]);
11440
12519
  break;
12520
+ case "computer":
12521
+ runComputerStats();
12522
+ break;
11441
12523
  case "plan": {
11442
12524
  if (!(!!todos && todos.length > 0)) {
11443
12525
  useStore.getState().showToast({ message: "no plan to show yet" });
@@ -11490,6 +12572,7 @@ function ChatScreen({ agent, conversation }) {
11490
12572
  history,
11491
12573
  runLocalShell,
11492
12574
  runSandboxExec,
12575
+ runComputerStats,
11493
12576
  runSandboxPty,
11494
12577
  archiveConversation,
11495
12578
  renameConversation,
@@ -11535,6 +12618,7 @@ function ChatScreen({ agent, conversation }) {
11535
12618
  if (portal.grantedAgentIds.includes(agent.id)) return;
11536
12619
  if (portal.declinedAgentIds.includes(agent.id)) return;
11537
12620
  if (declinedRef.current.has(agent.id)) return;
12621
+ if (grantInFlightRef.current.has(agent.id)) return;
11538
12622
  if (portal.prompt?.agentId === agent.id) return;
11539
12623
  setPortalPrompt({
11540
12624
  agentId: agent.id,
@@ -11558,6 +12642,14 @@ function ChatScreen({ agent, conversation }) {
11558
12642
  setPortalPrompt,
11559
12643
  agentHost
11560
12644
  ]);
12645
+ useEffect(() => {
12646
+ for (const agentId of portal.grantedAgentIds) grantInFlightRef.current.delete(agentId);
12647
+ if (portal.prompt && portal.grantedAgentIds.includes(portal.prompt.agentId)) setPortalPrompt(null);
12648
+ }, [
12649
+ portal.grantedAgentIds,
12650
+ portal.prompt,
12651
+ setPortalPrompt
12652
+ ]);
11561
12653
  useKeyboard((key) => {
11562
12654
  if (reviewOpen) {
11563
12655
  if (key.name === "g" && key.ctrl) {
@@ -11581,6 +12673,20 @@ function ChatScreen({ agent, conversation }) {
11581
12673
  return;
11582
12674
  }
11583
12675
  }
12676
+ if (computePrompt) {
12677
+ if (key.name === "y") {
12678
+ decideCompute(computePrompt, "approve");
12679
+ return;
12680
+ }
12681
+ if (key.name === "n") {
12682
+ decideCompute(computePrompt, "deny");
12683
+ return;
12684
+ }
12685
+ if (key.name === "escape") {
12686
+ setComputePrompt(null);
12687
+ return;
12688
+ }
12689
+ }
11584
12690
  if (credPrompt) {
11585
12691
  if (key.name === "escape") {
11586
12692
  if (!credPrompt.submitting) setCredPrompt(null);
@@ -11736,21 +12842,46 @@ function ChatScreen({ agent, conversation }) {
11736
12842
  useStore.getState().showToast({ message: `${agent.name} keeps working; reopen to reattach` });
11737
12843
  goTo({
11738
12844
  kind: "conversation-picker",
11739
- agent
12845
+ agent,
12846
+ initialQuery: ""
11740
12847
  });
11741
12848
  return;
11742
12849
  }
11743
12850
  if (runRef.current.kind === "idle") goTo({
11744
12851
  kind: "conversation-picker",
11745
- agent
12852
+ agent,
12853
+ initialQuery: ""
11746
12854
  });
11747
12855
  }
11748
12856
  });
11749
12857
  const hasPendingSteer = items.some((m) => m.kind === "pending-steer");
12858
+ const latestComputerId = useMemo(() => {
12859
+ for (let i = items.length - 1; i >= 0; i--) {
12860
+ const item = items[i];
12861
+ if (item && item.kind === "computer") return item.id;
12862
+ }
12863
+ return null;
12864
+ }, [items]);
12865
+ const boundCollectSandboxStats = useMemo(() => {
12866
+ if (!rest) return null;
12867
+ return () => collectSandboxStats({
12868
+ read: () => rest.computeLiveStats(),
12869
+ now: () => Date.now()
12870
+ });
12871
+ }, [rest]);
11750
12872
  const conversationContext = useMemo(() => ({
11751
12873
  rest,
11752
- conversationId
11753
- }), [rest, conversationId]);
12874
+ conversationId,
12875
+ collectSandboxStats: boundCollectSandboxStats,
12876
+ latestComputerId,
12877
+ scrolledUp
12878
+ }), [
12879
+ rest,
12880
+ conversationId,
12881
+ boundCollectSandboxStats,
12882
+ latestComputerId,
12883
+ scrolledUp
12884
+ ]);
11754
12885
  const actionableCards = useMemo(() => items.filter(isActionableCard), [items]);
11755
12886
  const statusText = useMemo(() => formatChatFooterStatus({
11756
12887
  runKind: run.kind,
@@ -11899,13 +13030,11 @@ function ChatScreen({ agent, conversation }) {
11899
13030
  children: [!historyLoaded ? /* @__PURE__ */ jsx("text", {
11900
13031
  fg: theme.muted,
11901
13032
  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
- ]
13033
+ }) : items.length === 0 ? /* @__PURE__ */ jsx(NewChatSplash, {
13034
+ agentName: agent.name,
13035
+ agentTitle: agent.title ?? null,
13036
+ width: reviewView && reviewPlacement === "side" ? Math.max(1, bodyWidth - reviewWidth - 1) : bodyWidth,
13037
+ height: Math.max(1, scrollHeight - 1)
11909
13038
  }) : /* @__PURE__ */ jsxs(Fragment, { children: [transcriptWindow.hiddenCount > 0 ? /* @__PURE__ */ jsx("box", {
11910
13039
  style: {
11911
13040
  paddingLeft: 1,
@@ -11948,6 +13077,12 @@ function ChatScreen({ agent, conversation }) {
11948
13077
  children: [" ", "end"]
11949
13078
  })]
11950
13079
  }) : null,
13080
+ /* @__PURE__ */ jsx(SubagentCardView, {
13081
+ items: subagentItems,
13082
+ isActive: run.kind !== "idle",
13083
+ onHeightChange: setSubagentRows,
13084
+ onBatchLiveChange: setAnchoredBatchLive
13085
+ }),
11951
13086
  todoCardVisible && todos ? /* @__PURE__ */ jsx(TodoCardView, {
11952
13087
  todos,
11953
13088
  isActive: run.kind !== "idle"
@@ -12040,6 +13175,46 @@ function ChatScreen({ agent, conversation }) {
12040
13175
  children: "n"
12041
13176
  })
12042
13177
  ]
13178
+ }) : computePrompt ? /* @__PURE__ */ jsxs("box", {
13179
+ style: {
13180
+ border: true,
13181
+ borderColor: theme.warning,
13182
+ paddingLeft: 1,
13183
+ paddingRight: 1,
13184
+ height: computePromptHeight,
13185
+ flexShrink: 0,
13186
+ marginTop: 1,
13187
+ flexDirection: "column"
13188
+ },
13189
+ children: [/* @__PURE__ */ jsx("text", {
13190
+ fg: theme.warning,
13191
+ children: computePrompt.summary ?? "more compute requested"
13192
+ }), /* @__PURE__ */ jsxs("text", { children: [
13193
+ /* @__PURE__ */ jsx("span", {
13194
+ fg: theme.success,
13195
+ children: "y"
13196
+ }),
13197
+ /* @__PURE__ */ jsx("span", {
13198
+ fg: theme.muted,
13199
+ children: " upgrade / "
13200
+ }),
13201
+ /* @__PURE__ */ jsx("span", {
13202
+ fg: theme.error,
13203
+ children: "n"
13204
+ }),
13205
+ /* @__PURE__ */ jsx("span", {
13206
+ fg: theme.muted,
13207
+ children: " keep current / "
13208
+ }),
13209
+ /* @__PURE__ */ jsx("span", {
13210
+ fg: theme.dim,
13211
+ children: "esc"
13212
+ }),
13213
+ /* @__PURE__ */ jsx("span", {
13214
+ fg: theme.muted,
13215
+ children: " later"
13216
+ })
13217
+ ] })]
12043
13218
  }) : nonWebChannel ? /* @__PURE__ */ jsxs("box", {
12044
13219
  style: {
12045
13220
  border: true,
@@ -12336,13 +13511,16 @@ function ToastView() {
12336
13511
  return () => clearTimeout(handle);
12337
13512
  }, [toast, dismissToast]);
12338
13513
  if (!toast) return null;
13514
+ const cap = Math.min(60, Math.max(20, width - 6));
13515
+ const chrome = 4;
13516
+ const contentWidth = Math.max(...toast.message.split("\n").map((line) => Bun.stringWidth(line)));
12339
13517
  return /* @__PURE__ */ jsx("box", {
12340
13518
  style: {
12341
13519
  position: "absolute",
12342
13520
  top: 1,
12343
13521
  right: 1,
12344
13522
  zIndex: 100,
12345
- maxWidth: Math.min(60, Math.max(20, width - 6)),
13523
+ width: Math.min(cap, contentWidth + chrome),
12346
13524
  border: true,
12347
13525
  borderColor: toastColor(toast.variant),
12348
13526
  backgroundColor: theme.surface ?? "#1a1a1a",
@@ -12359,9 +13537,12 @@ function ToastView() {
12359
13537
 
12360
13538
  //#endregion
12361
13539
  //#region src/chat/tui/app.tsx
12362
- function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath, notifications, agentSelector, conversationId, newConversation }) {
13540
+ function App({ appUrl, sessionToken, authKind, workspaceId, shareMachine, promptHistoryPath, notifications, agentSelector, conversationId, newConversation, defaultAgentSelector, forceOnboarding }) {
12363
13541
  const renderer = useRenderer();
12364
13542
  const screen = useStore((s) => s.screen);
13543
+ useEffect(() => {
13544
+ if (screen.kind === "chat") recordDefaultAgent(screen.agent.id);
13545
+ }, [screen]);
12365
13546
  const chatTitle = useStore((s) => s.chatTitle);
12366
13547
  useStore((s) => s.themeId);
12367
13548
  const rest = useStore((s) => s.rest);
@@ -12429,7 +13610,8 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
12429
13610
  useEffect(() => {
12430
13611
  const rest = createRestClient({
12431
13612
  appUrl,
12432
- sessionToken
13613
+ sessionToken,
13614
+ workspaceId
12433
13615
  });
12434
13616
  setClients({
12435
13617
  appUrl,
@@ -12444,7 +13626,9 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
12444
13626
  rest,
12445
13627
  agentSelector,
12446
13628
  conversationId,
12447
- newConversation: newConversation || useStore.getState().seedPrompt !== null
13629
+ newConversation: newConversation || useStore.getState().seedPrompt !== null,
13630
+ defaultAgentSelector,
13631
+ forceOnboarding
12448
13632
  });
12449
13633
  if (cancelled) return;
12450
13634
  goTo(screen);
@@ -12463,10 +13647,13 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
12463
13647
  }, [
12464
13648
  appUrl,
12465
13649
  sessionToken,
13650
+ workspaceId,
12466
13651
  promptHistoryPath,
12467
13652
  agentSelector,
12468
13653
  conversationId,
12469
13654
  newConversation,
13655
+ defaultAgentSelector,
13656
+ forceOnboarding,
12470
13657
  goTo,
12471
13658
  setClients,
12472
13659
  fail
@@ -12507,6 +13694,7 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
12507
13694
  screen.kind === "error" && /* @__PURE__ */ jsx(ErrorScreen, { message: screen.message }),
12508
13695
  screen.kind === "agent-picker" && rest && /* @__PURE__ */ jsx(AgentPickerScreen, {}),
12509
13696
  screen.kind === "agent-create" && rest && /* @__PURE__ */ jsx(AgentCreateScreen, {}),
13697
+ screen.kind === "first-run" && rest && /* @__PURE__ */ jsx(FirstRunScreen, {}),
12510
13698
  screen.kind === "workspace-picker" && /* @__PURE__ */ jsx(WorkspacePicker, {
12511
13699
  appUrl,
12512
13700
  sessionToken,
@@ -12516,7 +13704,10 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
12516
13704
  goTo({ kind: "agent-picker" });
12517
13705
  }
12518
13706
  }),
12519
- screen.kind === "conversation-picker" && rest && /* @__PURE__ */ jsx(ConversationPickerScreen, { agent: screen.agent }),
13707
+ screen.kind === "conversation-picker" && rest && /* @__PURE__ */ jsx(ConversationPickerScreen, {
13708
+ agent: screen.agent,
13709
+ initialQuery: screen.initialQuery
13710
+ }),
12520
13711
  screen.kind === "chat" && rest && /* @__PURE__ */ jsx(ChatScreen, {
12521
13712
  agent: screen.agent,
12522
13713
  conversation: screen.conversation
@@ -13045,7 +14236,7 @@ function onRenderCommit(id, phase, actualDuration, baseDuration, startTime, comm
13045
14236
  * module — and the heavy OpenTUI dependency graph it pulls in — is
13046
14237
  * dynamically imported and never loaded on the Node management path.
13047
14238
  */
13048
- async function runChat({ appUrl, sessionToken, authKind = "session", shareMachine, promptHistoryPath, theme: themeOverride, notifications, agentSelector, conversationId, newConversation, seedPrompt }) {
14239
+ async function runChat({ appUrl, sessionToken, authKind = "session", workspaceId, shareMachine, promptHistoryPath, theme: themeOverride, notifications, agentSelector, conversationId, newConversation, defaultAgentSelector, forceOnboarding, seedPrompt }) {
13049
14240
  await registerGrammars();
13050
14241
  const watchdog = startMemoryWatchdog();
13051
14242
  installCrashHandler({ memoryProvider: () => watchdog.snapshot() });
@@ -13060,7 +14251,8 @@ async function runChat({ appUrl, sessionToken, authKind = "session", shareMachin
13060
14251
  authKind,
13061
14252
  seedPrompt: seedPrompt ?? null,
13062
14253
  autoGrantMachine: Boolean(seedPrompt && shareMachine),
13063
- newConversationIntent: newConversation
14254
+ newConversationIntent: newConversation,
14255
+ forcedOnboarding: forceOnboarding
13064
14256
  });
13065
14257
  const store = useStore.getState();
13066
14258
  if (noColorRequested()) {
@@ -13081,12 +14273,15 @@ async function runChat({ appUrl, sessionToken, authKind = "session", shareMachin
13081
14273
  appUrl,
13082
14274
  sessionToken,
13083
14275
  authKind,
14276
+ workspaceId,
13084
14277
  shareMachine,
13085
14278
  promptHistoryPath,
13086
14279
  notifications,
13087
14280
  agentSelector: agentSelector ?? null,
13088
14281
  conversationId: conversationId ?? null,
13089
- newConversation
14282
+ newConversation,
14283
+ defaultAgentSelector: defaultAgentSelector ?? null,
14284
+ forceOnboarding
13090
14285
  })
13091
14286
  }));
13092
14287
  }