skydive-cli 0.5.0-beta.2 → 0.5.0-beta.21

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-y43HVmq7.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 setActiveWorkspace, C as themesForMode, D as getActiveWorkspaceId, 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 listWorkspaces, 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 } from "./profiler-Bl25cyUp.mjs";
3
+ import { D as getReviewStateDir, H as saveTheme, N as recordDefaultAgent, O as getSavedTheme, 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, z as resolveWebUrl } from "./print-OvYyj6Uk.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-Dx6b-Nq_.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-Dj9tGT12.mjs";
9
+ import "./api-DG5W6iwx.mjs";
10
+ import { t as SandboxStream } from "./client-c4c5MmgN.mjs";
11
+ import { t as PortalDaemonClient } from "./daemon-client-BNK77jX_.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,66 @@ 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
+ };
282
325
  }
283
- const agent = resolveAgent(agents, agentSelector);
284
- if (newConversation) return {
326
+ if (!agentSelector && newConversation) {
327
+ const firstTwo = await rest.listAgents({
328
+ scope: "org",
329
+ onPage: null,
330
+ limit: 2
331
+ });
332
+ const only = firstTwo.length === 1 ? firstTwo[0] : null;
333
+ if (!only) return { kind: "agent-picker" };
334
+ return newChat(only);
335
+ }
336
+ const agent = resolveAgent(await rest.listAgents({
337
+ scope: "org",
338
+ onPage: null
339
+ }), agentSelector);
340
+ if (newConversation) return newChat(agent);
341
+ return {
342
+ kind: "conversation-picker",
343
+ agent
344
+ };
345
+ }
346
+ function newChat(agent) {
347
+ return {
285
348
  kind: "chat",
286
349
  agent,
287
350
  conversation: {
@@ -290,10 +353,17 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
290
353
  nonce: crypto.randomUUID()
291
354
  }
292
355
  };
293
- return {
294
- kind: "conversation-picker",
295
- agent
296
- };
356
+ }
357
+ /** `resolveAgent` throws for a selector that matches nothing or matches
358
+ * ambiguously — both phrased for a human who can retype `--agent`. A stored
359
+ * default has no command line behind it, so both shapes just mean "no usable
360
+ * default". */
361
+ function resolveAgentOrNull(agents, selector) {
362
+ try {
363
+ return resolveAgent(agents, selector);
364
+ } catch {
365
+ return null;
366
+ }
297
367
  }
298
368
 
299
369
  //#endregion
@@ -324,6 +394,8 @@ function createPortalClient(opts) {
324
394
  deviceToken: null
325
395
  }),
326
396
  resolveCwd: () => process.cwd(),
397
+ persistedMachineName: null,
398
+ onMachineName: () => {},
327
399
  onState: (state) => opts.onState({
328
400
  status: state.status,
329
401
  machineName: state.machineName,
@@ -337,7 +409,7 @@ function createPortalClient(opts) {
337
409
  start: () => Promise.resolve(),
338
410
  enable: () => client.enable(),
339
411
  disable: () => client.disable(),
340
- grantAgent: (agentId) => client.grantAgent(agentId),
412
+ grantAgent: (agentId, conversationId) => client.grantAgent(agentId, conversationId),
341
413
  decline: (_agentId, declined) => {
342
414
  if (declined && client.grantedAgentIds().length === 0) client.disable();
343
415
  },
@@ -1016,42 +1088,133 @@ function createAgentHost({ renderer, notifications }) {
1016
1088
  * take the one host we can identify unambiguously (github.com, incl. `www.`),
1017
1089
  * which covers the common product case; GHES stays the native badge's job.
1018
1090
  *
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)) {
1091
+ * Only the `/pull/<n>` form (not `/issues/`). A trailing path/query/fragment
1092
+ * (e.g. `/files`, `#discussion`) is allowed but not captured into the canonical
1093
+ * URL. Also matches inside markdown links, backticks, and trailing punctuation.
1094
+ *
1095
+ * The `https://` (and `www.`) prefix is optional: agents and people routinely
1096
+ * write a bare `github.com/o/r/pull/7`, and that should link a PR the same way
1097
+ * the full URL does. To keep a bare host from matching inside a longer hostname
1098
+ * (`mygithub.com/...`, `notgithub.com/...`), a protocol-less match must be
1099
+ * preceded by a start-of-string or a non-host character (anything but a word
1100
+ * char or `.`) a leading boundary the `(?:^|[^\w.])` group asserts and
1101
+ * consumes. The consumed boundary char is outside the captured groups, so it
1102
+ * never leaks into owner/repo. `matchAll` won't overlap, but the boundary is a
1103
+ * single delimiter (space, `(`, `[`, etc.) between PRs in practice, so distinct
1104
+ * back-to-back URLs still each match.
1105
+ */
1106
+ const PR_URL_RE = /(?:^|[^\w.])(?:https?:\/\/)?(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/gi;
1107
+ /**
1108
+ * Matches a bare, human-style PR reference `#<n>` — the way people (and LLMs)
1109
+ * name a PR in prose ("opened #20858", "landed #42"). Requires a non-word
1110
+ * boundary before the `#` so a fragment tail like `/pull/7#discussion_r5` or a
1111
+ * hex color `#20858` embedded in a word doesn't count, and captures the digits
1112
+ * up to a word boundary. We only harvest these from **assistant prose**, never
1113
+ * from tool output, because `#N` is exactly the relevance signal we're after:
1114
+ * the agent chose to talk about this PR.
1115
+ */
1116
+ const PR_HASH_RE = /(?:^|[^\w#/])#(\d+)\b/g;
1117
+ /**
1118
+ * Every full `/pull/<n>` github.com URL in `urlText`, keyed by number to the
1119
+ * candidate refs for that number. A number can map to more than one repo, so we
1120
+ * keep all candidates; a bare `#N` label can't say which repo, but the pill's
1121
+ * own URL does. Deduped by repo-qualified identity (`owner/repo#number`) so the
1122
+ * same PR linked twice — even with a different trailing path/fragment —
1123
+ * collapses to one ref.
1124
+ *
1125
+ * `deferTrailingNumber` guards the streaming truncation case: when true, a URL
1126
+ * whose number runs to the very end of `urlText` is skipped because the next
1127
+ * delta may extend those digits (`.../pull/19` now, `.../pull/19739` later).
1128
+ * Only the still-growing PROSE buffer sets this; settled tool output is always
1129
+ * complete, so its URLs are harvested with the guard off.
1130
+ */
1131
+ function urlsByNumber(urlText, deferTrailingNumber, byNumber) {
1132
+ for (const match of urlText.matchAll(PR_URL_RE)) {
1038
1133
  const owner = match[1];
1039
1134
  const repo = match[2];
1040
1135
  const numberText = match[3];
1041
1136
  if (!owner || !repo || !numberText) continue;
1042
- if (requireCompleteNumber) {
1043
- if (match.index + match[0].length >= text.length) continue;
1137
+ if (deferTrailingNumber) {
1138
+ if (match.index + match[0].length >= urlText.length) continue;
1044
1139
  }
1045
1140
  const number = Number.parseInt(numberText, 10);
1046
1141
  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, {
1142
+ const ref = {
1050
1143
  number,
1051
- key,
1144
+ key: `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`,
1052
1145
  url: `https://github.com/${owner}/${repo}/pull/${number}`
1053
- });
1146
+ };
1147
+ const existing = byNumber.get(number);
1148
+ if (existing) {
1149
+ if (!existing.some((r) => r.key === ref.key)) existing.push(ref);
1150
+ } else byNumber.set(number, [ref]);
1054
1151
  }
1152
+ }
1153
+ /** Every human-style `#N` named in `proseText` — the relevance signal. */
1154
+ function hashNumbers(proseText) {
1155
+ const numbers = /* @__PURE__ */ new Set();
1156
+ for (const match of proseText.matchAll(PR_HASH_RE)) {
1157
+ const numberText = match[1];
1158
+ if (!numberText) continue;
1159
+ const number = Number.parseInt(numberText, 10);
1160
+ if (Number.isFinite(number) && number > 0) numbers.add(number);
1161
+ }
1162
+ return numbers;
1163
+ }
1164
+ /**
1165
+ * The relevance contract, in one place.
1166
+ *
1167
+ * A GitHub PR URL is cheap to emit and shows up in bulk any time a tool dumps
1168
+ * PRs the conversation isn't actually about — `gh pr list`, `gh pr status`, a
1169
+ * `gh search` — printing dozens of `/pull/N` URLs the agent never discusses.
1170
+ * Pinning a sidebar pill for every one of those is noise.
1171
+ *
1172
+ * So a PR is surfaced only when BOTH hold:
1173
+ * 1. a fully-qualified `github.com/<o>/<r>/pull/<n>` URL exists — this may
1174
+ * come from the assistant's prose OR from tool output (a `gh pr create`
1175
+ * that printed the URL the agent then referenced by number), and
1176
+ * 2. the **assistant's prose** names the number as a human-style `#<n>`.
1177
+ *
1178
+ * The `#N` in prose is the intent signal — "the agent is talking about this
1179
+ * PR", as opposed to "a tool happened to list it". The full URL (from wherever)
1180
+ * proves the PR is real and pins down its repo + canonical link. A URL with no
1181
+ * `#N` in prose (the whole of a `gh pr list` dump) is dropped; a `#N` with no
1182
+ * matching URL anywhere (a bare issue-style ref) is dropped.
1183
+ *
1184
+ * `proseText` is the assistant's prose alone (the only source of the `#N`
1185
+ * relevance signal); `urlOnlyText` is settled tool output that may carry full
1186
+ * PR URLs but no `#N` intent. URLs are harvested from both; `#N` from prose
1187
+ * only.
1188
+ */
1189
+ function collectRelevant(proseText, urlOnlyText, options, byKey) {
1190
+ const byNumber = /* @__PURE__ */ new Map();
1191
+ urlsByNumber(proseText, options.requireCompleteNumber, byNumber);
1192
+ if (urlOnlyText) urlsByNumber(urlOnlyText, false, byNumber);
1193
+ if (byNumber.size === 0) return;
1194
+ const named = hashNumbers(proseText);
1195
+ if (named.size === 0) return;
1196
+ for (const [number, refs] of byNumber) {
1197
+ if (!named.has(number)) continue;
1198
+ for (const ref of refs) if (!byKey.has(ref.key)) byKey.set(ref.key, ref);
1199
+ }
1200
+ }
1201
+ /**
1202
+ * Extracts every distinct GitHub PR that is both linked (a full `/pull/<n>`
1203
+ * URL) AND named (`#<n>`), de-duplicated by **repo-qualified identity**
1204
+ * (`owner/repo#number`) — NOT by number, since `#42` can exist in many repos.
1205
+ *
1206
+ * The two-argument form takes the URL pool and the prose separately, so a URL
1207
+ * from tool output can satisfy the link requirement while the `#N` stays a
1208
+ * prose-only signal. The one-argument form (prose only) is the common case: it
1209
+ * requires the URL to be in the same prose that names it.
1210
+ *
1211
+ * See {@link collectRelevant} for the contract.
1212
+ */
1213
+ function extractPullRequests(proseText, options) {
1214
+ const requireCompleteNumber = options?.requireCompleteNumber ?? true;
1215
+ const urlOnly = options?.urlOnlyText ?? "";
1216
+ const byKey = /* @__PURE__ */ new Map();
1217
+ collectRelevant(proseText, urlOnly, { requireCompleteNumber }, byKey);
1055
1218
  return [...byKey.values()];
1056
1219
  }
1057
1220
  /**
@@ -1090,28 +1253,34 @@ function stringifyToolOutput(value) {
1090
1253
  }
1091
1254
  /**
1092
1255
  * 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:
1256
+ * distinct PR the moment BOTH conditions hold a full `/pull/<n>` URL has been
1257
+ * seen (in prose OR tool output) AND the assistant's prose names it `#<n>` —
1258
+ * exactly once, without ever rescanning the transcript.
1095
1259
  *
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.
1260
+ * Two sources feed it, with different roles:
1261
+ * - **Assistant text** (`text-delta`): the ONLY source of the `#N` relevance
1262
+ * signal, and also a source of URLs. Accumulated per part id (a URL or `#N`
1263
+ * can split across two deltas); only the current part's buffer is scanned.
1264
+ * - **Tool output** (`tool-output-available`): a URL-only source. A
1265
+ * `gh pr create` prints the URL the agent then references by number, so the
1266
+ * URL for a #N mention often lives here, not in the prose. Tool output can
1267
+ * NEVER originate a pill on its own — a `gh pr list` dump (URLs, no `#N`)
1268
+ * stays silent — but it satisfies the URL half for a PR the prose names.
1103
1269
  *
1104
1270
  * `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.
1271
+ * `owner/repo#number`). Construct one per run (or per conversation) and feed it
1272
+ * the same chunks the transcript reducer sees. See {@link collectRelevant}.
1108
1273
  */
1109
1274
  function createPullRequestScanner(onPullRequest) {
1110
1275
  let text = "";
1111
1276
  let lastPartId;
1277
+ let toolUrlText = "";
1112
1278
  const seen = /* @__PURE__ */ new Set();
1113
- const report = (buffer, final) => {
1114
- for (const pr of extractPullRequests(buffer, { requireCompleteNumber: !final })) {
1279
+ const report = (final) => {
1280
+ for (const pr of extractPullRequests(text, {
1281
+ requireCompleteNumber: !final,
1282
+ urlOnlyText: toolUrlText
1283
+ })) {
1115
1284
  if (seen.has(pr.key)) continue;
1116
1285
  seen.add(pr.key);
1117
1286
  onPullRequest(pr);
@@ -1121,7 +1290,8 @@ function createPullRequestScanner(onPullRequest) {
1121
1290
  if (chunk["type"] === "tool-output-available") {
1122
1291
  const outputText = stringifyToolOutput(chunk["output"]);
1123
1292
  if (!outputText.includes("/pull/")) return;
1124
- report(outputText, true);
1293
+ toolUrlText += `\n${outputText}`;
1294
+ report(true);
1125
1295
  return;
1126
1296
  }
1127
1297
  if (chunk["type"] !== "text-delta") return;
@@ -1129,13 +1299,12 @@ function createPullRequestScanner(onPullRequest) {
1129
1299
  if (!delta) return;
1130
1300
  const partId = typeof chunk["id"] === "string" ? chunk["id"] : void 0;
1131
1301
  if (partId && lastPartId && partId !== lastPartId) {
1132
- if (text.includes("/pull/")) report(text, true);
1302
+ report(true);
1133
1303
  text = "";
1134
1304
  }
1135
1305
  text += delta;
1136
1306
  lastPartId = partId ?? lastPartId;
1137
- if (!text.includes("/pull/")) return;
1138
- report(text, false);
1307
+ report(false);
1139
1308
  } };
1140
1309
  }
1141
1310
 
@@ -1479,6 +1648,7 @@ function screenLabelParts(screen, chatTitle) {
1479
1648
  case "error": return { title: "" };
1480
1649
  case "agent-picker": return { title: "pick an agent" };
1481
1650
  case "agent-create": return { title: "create an agent" };
1651
+ case "first-run": return { title: "welcome" };
1482
1652
  case "workspace-picker": return { title: "switch workspace" };
1483
1653
  case "conversation-picker": return {
1484
1654
  title: "pick a conversation",
@@ -1649,13 +1819,15 @@ function WordmarkRows({ mono }) {
1649
1819
  });
1650
1820
  }
1651
1821
  /**
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.
1822
+ * Height- and width-aware brand header for the pickers. Always shown
1823
+ * (including while the list is loading / empty / erroring) so the splash is
1824
+ * never gated on data. Full mark + wordmark only when the terminal is tall
1825
+ * enough AND wide enough that the art sits on six lines; otherwise a compact
1826
+ * one-liner. Wrapping the splash would blow the 7-row budget the list
1827
+ * subtracts, and the overflow collapses the top list rows onto each other.
1656
1828
  */
1657
- function BrandHeader({ height }) {
1658
- if (height >= 24) return /* @__PURE__ */ jsxs("box", {
1829
+ function BrandHeader({ height, width }) {
1830
+ if (showFullSplash(height, width)) return /* @__PURE__ */ jsxs("box", {
1659
1831
  style: { flexDirection: "column" },
1660
1832
  children: [/* @__PURE__ */ jsx(SkydiveMark, { variant: "full" }), /* @__PURE__ */ jsx("box", { style: { height: 1 } })]
1661
1833
  });
@@ -1664,9 +1836,12 @@ function BrandHeader({ height }) {
1664
1836
  children: "✦ Skydive"
1665
1837
  });
1666
1838
  }
1839
+ function showFullSplash(height, width) {
1840
+ return height >= 24 && splashFitsWidth(width);
1841
+ }
1667
1842
  /** Row budget the BrandHeader consumes; callers subtract this from visibleRows. */
1668
- function brandHeaderRows(height) {
1669
- return height >= 24 ? 7 : 1;
1843
+ function brandHeaderRows(height, width) {
1844
+ return showFullSplash(height, width) ? 7 : 1;
1670
1845
  }
1671
1846
  function SkydiveMark({ variant = "full" }) {
1672
1847
  const mono = noColor();
@@ -1753,13 +1928,16 @@ function AgentPickerScreen() {
1753
1928
  scope,
1754
1929
  cache
1755
1930
  ]);
1756
- const { height } = useTerminalDimensions();
1931
+ const { width, height } = useTerminalDimensions();
1757
1932
  if (state.kind === "loading") return /* @__PURE__ */ jsxs("box", {
1758
1933
  style: {
1759
1934
  flexDirection: "column",
1760
1935
  flexGrow: 1
1761
1936
  },
1762
- children: [/* @__PURE__ */ jsx(BrandHeader, { height }), /* @__PURE__ */ jsx("text", {
1937
+ children: [/* @__PURE__ */ jsx(BrandHeader, {
1938
+ height,
1939
+ width
1940
+ }), /* @__PURE__ */ jsx("text", {
1763
1941
  fg: theme.muted,
1764
1942
  children: "loading agents…"
1765
1943
  })]
@@ -1769,7 +1947,10 @@ function AgentPickerScreen() {
1769
1947
  flexDirection: "column",
1770
1948
  flexGrow: 1
1771
1949
  },
1772
- children: [/* @__PURE__ */ jsx(BrandHeader, { height }), /* @__PURE__ */ jsxs("text", {
1950
+ children: [/* @__PURE__ */ jsx(BrandHeader, {
1951
+ height,
1952
+ width
1953
+ }), /* @__PURE__ */ jsxs("text", {
1773
1954
  fg: theme.error,
1774
1955
  children: ["error: ", state.message]
1775
1956
  })]
@@ -1779,7 +1960,10 @@ function AgentPickerScreen() {
1779
1960
  flexDirection: "column",
1780
1961
  flexGrow: 1
1781
1962
  },
1782
- children: [/* @__PURE__ */ jsx(BrandHeader, { height }), /* @__PURE__ */ jsx("text", {
1963
+ children: [/* @__PURE__ */ jsx(BrandHeader, {
1964
+ height,
1965
+ width
1966
+ }), /* @__PURE__ */ jsx("text", {
1783
1967
  fg: theme.muted,
1784
1968
  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
1969
  })]
@@ -1800,7 +1984,7 @@ function FilterableAgentList({ agents, scope, onPick }) {
1800
1984
  const hits = fuzzyFilter(query, agents, agentKeys);
1801
1985
  const filtered = hits.map((h) => h.item);
1802
1986
  const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
1803
- const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height));
1987
+ const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height, width));
1804
1988
  const start = windowStart(clamped, filtered.length, visibleRows);
1805
1989
  const windowed = hits.slice(start, start + visibleRows);
1806
1990
  useKeyboard((key) => {
@@ -1832,7 +2016,10 @@ function FilterableAgentList({ agents, scope, onPick }) {
1832
2016
  flexGrow: 1
1833
2017
  },
1834
2018
  children: [
1835
- /* @__PURE__ */ jsx(BrandHeader, { height }),
2019
+ /* @__PURE__ */ jsx(BrandHeader, {
2020
+ height,
2021
+ width
2022
+ }),
1836
2023
  /* @__PURE__ */ jsxs("box", {
1837
2024
  style: {
1838
2025
  flexDirection: "row",
@@ -2071,6 +2258,208 @@ function AgentCreateScreen() {
2071
2258
  });
2072
2259
  }
2073
2260
 
2261
+ //#endregion
2262
+ //#region src/chat/tui/screens/first-run.tsx
2263
+ /** Rows the mark lockup needs before it's worth painting (matches the
2264
+ * new-chat splash): the 6-row mark, a spacer, and a couple of content rows.
2265
+ * Below this the mark is dropped and only the message renders. */
2266
+ const MARK_MIN_ROWS$1 = 12;
2267
+ /** Columns the full mark + wordmark lockup spans; below this the pinwheel
2268
+ * mark renders alone instead of wrapping the wordmark. */
2269
+ const FULL_MARK_MIN_COLS$1 = 70;
2270
+ function FirstRunScreen() {
2271
+ const rest = useStore((s) => s.rest);
2272
+ const goTo = useStore((s) => s.goTo);
2273
+ const fail = useStore((s) => s.fail);
2274
+ const authKind = useStore((s) => s.authKind);
2275
+ const forced = useStore((s) => s.forcedOnboarding);
2276
+ const [state, setState] = useState({ kind: "creating" });
2277
+ const canShareMachine = authKind === "session";
2278
+ useEffect(() => {
2279
+ if (!rest) return;
2280
+ let cancelled = false;
2281
+ (async () => {
2282
+ try {
2283
+ const agent = await rest.createAgent({});
2284
+ if (cancelled) return;
2285
+ setState({
2286
+ kind: "offer",
2287
+ agent
2288
+ });
2289
+ } catch (err) {
2290
+ if (cancelled) return;
2291
+ if (err instanceof HttpError && err.status === 401) {
2292
+ fail("Session expired. Run `skydive auth login --web`, then retry.");
2293
+ return;
2294
+ }
2295
+ setState({
2296
+ kind: "error",
2297
+ message: createErrorMessage(err)
2298
+ });
2299
+ }
2300
+ })();
2301
+ return () => {
2302
+ cancelled = true;
2303
+ };
2304
+ }, [rest, fail]);
2305
+ const openNewChat = (agent) => {
2306
+ goTo({
2307
+ kind: "chat",
2308
+ agent,
2309
+ conversation: {
2310
+ kind: "new",
2311
+ agentId: agent.id,
2312
+ nonce: crypto.randomUUID()
2313
+ }
2314
+ });
2315
+ };
2316
+ const openOnboardingChat = (agent, conversationId, title) => {
2317
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2318
+ goTo({
2319
+ kind: "chat",
2320
+ agent,
2321
+ conversation: {
2322
+ id: conversationId,
2323
+ title,
2324
+ createdAt: now,
2325
+ updatedAt: now,
2326
+ preview: null,
2327
+ channel: "web",
2328
+ channelLabel: null,
2329
+ agent: {
2330
+ id: agent.id,
2331
+ name: agent.name,
2332
+ slug: agent.slug ?? null,
2333
+ title: agent.title ?? null
2334
+ }
2335
+ }
2336
+ });
2337
+ };
2338
+ const acceptImport = async (agent) => {
2339
+ if (!rest) return;
2340
+ setState({ kind: "starting" });
2341
+ const { portalClient } = useStore.getState();
2342
+ try {
2343
+ const os = machineOsFromPlatform(process.platform);
2344
+ const { conversationId } = await rest.createOnboardingConversation({
2345
+ agentId: agent.id,
2346
+ projectDir: process.cwd(),
2347
+ os
2348
+ });
2349
+ if (portalClient) {
2350
+ portalClient.enable();
2351
+ portalClient.grantAgent(agent.id, conversationId).catch(() => {
2352
+ useStore.getState().showToast({
2353
+ message: `Couldn't share this machine automatically. ${agent.name} will ask in chat.`,
2354
+ variant: "warning"
2355
+ });
2356
+ });
2357
+ }
2358
+ openOnboardingChat(agent, conversationId, `Onboarding ${agent.name}`);
2359
+ } catch (err) {
2360
+ if (err instanceof HttpError && err.status === 401) {
2361
+ fail("Session expired. Run `skydive auth login --web`, then retry.");
2362
+ return;
2363
+ }
2364
+ useStore.getState().showToast({
2365
+ message: "Couldn't start onboarding automatically. Opening a chat instead.",
2366
+ variant: "warning"
2367
+ });
2368
+ openNewChat(agent);
2369
+ }
2370
+ };
2371
+ const declineImport = (agent) => {
2372
+ setState({ kind: "starting" });
2373
+ openNewChat(agent);
2374
+ };
2375
+ useKeyboard((key) => {
2376
+ if (state.kind !== "offer") return;
2377
+ if (!canShareMachine) {
2378
+ declineImport(state.agent);
2379
+ return;
2380
+ }
2381
+ if (key.name === "return" || key.name === "y") acceptImport(state.agent);
2382
+ else if (key.name === "n" || key.name === "escape") declineImport(state.agent);
2383
+ });
2384
+ return /* @__PURE__ */ jsx(Centered, { children: renderBody({
2385
+ state,
2386
+ forced,
2387
+ canShareMachine
2388
+ }) });
2389
+ }
2390
+ /** The Skydive mark centered above the screen's content, matching the
2391
+ * new-chat splash lockup and degrading by available space. */
2392
+ function Centered({ children }) {
2393
+ const { width, height } = useTerminalDimensions();
2394
+ return /* @__PURE__ */ jsxs("box", {
2395
+ style: {
2396
+ flexGrow: 1,
2397
+ flexDirection: "column",
2398
+ justifyContent: "center",
2399
+ alignItems: "center"
2400
+ },
2401
+ children: [height >= MARK_MIN_ROWS$1 ? /* @__PURE__ */ jsx("box", {
2402
+ style: {
2403
+ flexDirection: "column",
2404
+ alignItems: "center",
2405
+ marginBottom: 1
2406
+ },
2407
+ children: /* @__PURE__ */ jsx(SkydiveMark, { variant: width >= FULL_MARK_MIN_COLS$1 ? "full" : "mark" })
2408
+ }) : null, /* @__PURE__ */ jsx("box", {
2409
+ style: {
2410
+ flexDirection: "column",
2411
+ alignItems: "center",
2412
+ gap: 1
2413
+ },
2414
+ children
2415
+ })]
2416
+ });
2417
+ }
2418
+ function renderBody({ state, forced, canShareMachine }) {
2419
+ if (state.kind === "error") return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("text", {
2420
+ fg: theme.error,
2421
+ children: state.message
2422
+ }), /* @__PURE__ */ jsx("text", {
2423
+ fg: theme.muted,
2424
+ children: "press ctrl+c to quit, then run `skydive` again to retry."
2425
+ })] });
2426
+ if (state.kind === "creating") return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("text", {
2427
+ fg: theme.fg,
2428
+ children: "Welcome to Skydive."
2429
+ }), /* @__PURE__ */ jsx("text", {
2430
+ fg: theme.muted,
2431
+ children: forced ? "creating an agent…" : "setting up your first agent…"
2432
+ })] });
2433
+ if (state.kind === "starting") return /* @__PURE__ */ jsx("text", {
2434
+ fg: theme.muted,
2435
+ children: "opening your agent…"
2436
+ });
2437
+ const { agent } = state;
2438
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("text", {
2439
+ fg: theme.fg,
2440
+ children: forced ? `${agent.name} is ready.` : `Your first agent, ${agent.name}, is ready.`
2441
+ }), canShareMachine ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("text", {
2442
+ fg: theme.muted,
2443
+ children: [
2444
+ "Want ",
2445
+ agent.name,
2446
+ " 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."
2447
+ ]
2448
+ }), /* @__PURE__ */ jsx("text", {
2449
+ fg: theme.dim,
2450
+ children: "↵/y yes, learn from my setup · n skip"
2451
+ })] }) : /* @__PURE__ */ jsx("text", {
2452
+ fg: theme.dim,
2453
+ children: "press any key to open the chat"
2454
+ })] });
2455
+ }
2456
+ function createErrorMessage(err) {
2457
+ if (err instanceof HttpError && err.status === 403) return "You don't have permission to create an agent in this workspace.";
2458
+ if (err instanceof HttpError && err.status === 429) return "Too many requests. Wait a moment, then run `skydive` again.";
2459
+ if (err instanceof HttpError) return "Skydive could not create your agent. Try again in a moment.";
2460
+ return "Could not reach Skydive. Check your connection and try again.";
2461
+ }
2462
+
2074
2463
  //#endregion
2075
2464
  //#region src/chat/tui/screens/workspace-picker.tsx
2076
2465
  function WorkspacePicker({ appUrl, sessionToken, onSelect, onCancel }) {
@@ -2419,15 +2808,80 @@ function useDebouncedValue(value, delayMs) {
2419
2808
  }
2420
2809
 
2421
2810
  //#endregion
2422
- //#region src/chat/tui/screens/conversation-picker.tsx
2423
- const SEARCH_DEBOUNCE_MS = 250;
2424
- const SEARCH_QUERY_MAX_LENGTH = 200;
2811
+ //#region src/chat/tui/screens/conversation-channel.ts
2812
+ function isNewConversation(c) {
2813
+ return "kind" in c && c.kind === "new";
2814
+ }
2815
+ /**
2816
+ * The channels a human can start a conversation on. Enumerated, not derived,
2817
+ * to keep `@createinc/anyone-db` out of the CLI build (see rest.ts). A newly
2818
+ * added human channel needs adding here too. The picker filters its default
2819
+ * view to these; `isAgentRunConversation` is their complement.
2820
+ */
2425
2821
  const humanChannels = [
2426
2822
  "web",
2427
2823
  "slack",
2428
2824
  "email",
2429
2825
  "imessage"
2430
2826
  ];
2827
+ /**
2828
+ * Whether a conversation is an agent run — a thread the agent opened for
2829
+ * itself (cron firing, inbound webhook wake) rather than one a person
2830
+ * started. These have no human participant row, so per-user archive can
2831
+ * never apply to them (there is nothing to mark); delete is their only
2832
+ * cleanup affordance, and the UI should not offer archive on them.
2833
+ *
2834
+ * Best-effort: an a2a/subagent thread carries a `web` channel binding, so it
2835
+ * reads as `'web'` here and is NOT detected. Archive on one of those still
2836
+ * fails server-side with the existing error notice — this predicate only
2837
+ * removes the affordance where the client can know it's dead.
2838
+ */
2839
+ function isAgentRunConversation(c) {
2840
+ if (isNewConversation(c)) return false;
2841
+ if (c.channel === null) return false;
2842
+ const { channel } = c;
2843
+ return !humanChannels.some((human) => human === channel);
2844
+ }
2845
+ /**
2846
+ * The channel a conversation belongs to when it isn't the web channel, else
2847
+ * null. A brand-new conversation is always web, and the list route reports the
2848
+ * web channel as either `'web'` or (legacy) null — both mean "post from here".
2849
+ * Anything else (slack, email, imessage, webhook, cron) is owned by that
2850
+ * channel: the message has to go through it, so the TUI can't send here.
2851
+ * Mirrors the web app's `channel !== null && channel !== 'web'` gate.
2852
+ */
2853
+ function nonWebChannelLabel(c) {
2854
+ if (isNewConversation(c)) return null;
2855
+ if (c.channel === null || c.channel === "web") return null;
2856
+ return c.channelLabel ?? c.channel;
2857
+ }
2858
+ /**
2859
+ * The list-row shape for a conversation the TUI just forked, so the chat
2860
+ * screen can reopen on the copy without waiting for a list refetch. A fork
2861
+ * carries none of the source's channel binding — that's what makes it
2862
+ * sendable from here — so it is always a web conversation.
2863
+ */
2864
+ function forkedConversationRef({ agent, forked }) {
2865
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2866
+ return {
2867
+ id: forked.id,
2868
+ title: forked.title,
2869
+ createdAt: now,
2870
+ updatedAt: now,
2871
+ preview: null,
2872
+ channel: "web",
2873
+ channelLabel: null,
2874
+ agent: {
2875
+ id: agent.id,
2876
+ name: agent.name
2877
+ }
2878
+ };
2879
+ }
2880
+
2881
+ //#endregion
2882
+ //#region src/chat/tui/screens/conversation-picker.tsx
2883
+ const SEARCH_DEBOUNCE_MS = 250;
2884
+ const SEARCH_QUERY_MAX_LENGTH = 200;
2431
2885
  function ConversationPickerScreen({ agent }) {
2432
2886
  const rest = useStore((s) => s.rest);
2433
2887
  const goTo = useStore((s) => s.goTo);
@@ -2492,13 +2946,16 @@ function ConversationPickerScreen({ agent }) {
2492
2946
  cacheKey,
2493
2947
  cache
2494
2948
  ]);
2495
- const { height } = useTerminalDimensions();
2949
+ const { width, height } = useTerminalDimensions();
2496
2950
  if (state.kind === "loading") return /* @__PURE__ */ jsxs("box", {
2497
2951
  style: {
2498
2952
  flexDirection: "column",
2499
2953
  flexGrow: 1
2500
2954
  },
2501
- children: [/* @__PURE__ */ jsx(BrandHeader, { height }), /* @__PURE__ */ jsx("text", {
2955
+ children: [/* @__PURE__ */ jsx(BrandHeader, {
2956
+ height,
2957
+ width
2958
+ }), /* @__PURE__ */ jsx("text", {
2502
2959
  fg: theme.muted,
2503
2960
  children: "loading conversations…"
2504
2961
  })]
@@ -2508,7 +2965,10 @@ function ConversationPickerScreen({ agent }) {
2508
2965
  flexDirection: "column",
2509
2966
  flexGrow: 1
2510
2967
  },
2511
- children: [/* @__PURE__ */ jsx(BrandHeader, { height }), /* @__PURE__ */ jsxs("text", {
2968
+ children: [/* @__PURE__ */ jsx(BrandHeader, {
2969
+ height,
2970
+ width
2971
+ }), /* @__PURE__ */ jsxs("text", {
2512
2972
  fg: theme.error,
2513
2973
  children: ["error: ", state.message]
2514
2974
  })]
@@ -2615,6 +3075,7 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
2615
3075
  const clamped = Math.min(highlight, Math.max(0, rows.length - 1));
2616
3076
  const highlightRow = rows[clamped];
2617
3077
  const highlightArchived = highlightRow?.kind === "conv" && isArchived(highlightRow.hit.item);
3078
+ const highlightAgentRun = highlightRow?.kind === "conv" && isAgentRunConversation(highlightRow.hit.item);
2618
3079
  const confirmConv = confirmId !== null ? list.find((c) => c.id === confirmId) : void 0;
2619
3080
  const searchState = isSearching ? search?.state : void 0;
2620
3081
  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 +3089,7 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
2628
3089
  fg: theme.dim,
2629
3090
  text: fitHintLine([
2630
3091
  countSegment,
2631
- `ctrl+a ${highlightArchived ? "unarchive" : "archive"}`,
3092
+ ...highlightAgentRun ? [] : [`ctrl+a ${highlightArchived ? "unarchive" : "archive"}`],
2632
3093
  "ctrl+d delete",
2633
3094
  `tab ${showAutomated ? "hide" : "show"} agent runs`,
2634
3095
  `shift+tab ${showArchived ? "hide" : "show"} archived`,
@@ -2637,7 +3098,7 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
2637
3098
  "↵ open"
2638
3099
  ], innerWidth)
2639
3100
  };
2640
- const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height));
3101
+ const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height, width));
2641
3102
  const start = windowStart(clamped, rows.length, visibleRows);
2642
3103
  const windowed = rows.slice(start, start + visibleRows);
2643
3104
  const open = (row) => {
@@ -2722,6 +3183,10 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
2722
3183
  } else if (key.name === "a" && key.ctrl) {
2723
3184
  const row = rows[clamped];
2724
3185
  if (row?.kind === "conv") {
3186
+ if (isAgentRunConversation(row.hit.item)) {
3187
+ setError("agent runs can't be archived — ctrl+d deletes");
3188
+ return;
3189
+ }
2725
3190
  setError(null);
2726
3191
  toggleArchived(row.hit.item);
2727
3192
  }
@@ -2738,7 +3203,10 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
2738
3203
  flexGrow: 1
2739
3204
  },
2740
3205
  children: [
2741
- /* @__PURE__ */ jsx(BrandHeader, { height }),
3206
+ /* @__PURE__ */ jsx(BrandHeader, {
3207
+ height,
3208
+ width
3209
+ }),
2742
3210
  /* @__PURE__ */ jsxs("box", {
2743
3211
  style: {
2744
3212
  flexDirection: "row",
@@ -2779,15 +3247,20 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
2779
3247
  children: [marker, truncate("+ new conversation", rowWidth)]
2780
3248
  }, "new");
2781
3249
  const archived = isArchived(row.hit.item);
3250
+ const unread = row.hit.item.unread === true;
2782
3251
  const { title, detail, tag } = fitRowText(singleLine(row.hit.item.title ?? "(untitled)"), `${archived ? "archived · " : ""}${previewLine(row.hit.item)}`, rowWidth, coAgentsTag(row.hit.item, agent.id));
3252
+ const titleText = /* @__PURE__ */ jsx(FuzzyText, {
3253
+ text: title,
3254
+ indexes: row.hit.item.title ? row.hit.highlights[0] : null
3255
+ });
2783
3256
  return /* @__PURE__ */ jsxs("text", {
2784
3257
  fg: archived && !selected ? theme.dim : fg,
2785
3258
  children: [
2786
- marker,
2787
- /* @__PURE__ */ jsx(FuzzyText, {
2788
- text: title,
2789
- indexes: row.hit.item.title ? row.hit.highlights[0] : null
2790
- }),
3259
+ !selected && unread ? /* @__PURE__ */ jsx("span", {
3260
+ fg: theme.accent,
3261
+ children: "● "
3262
+ }) : marker,
3263
+ unread ? /* @__PURE__ */ jsx("b", { children: titleText }) : titleText,
2791
3264
  tag ? /* @__PURE__ */ jsxs("span", {
2792
3265
  fg: theme.muted,
2793
3266
  children: [" ", tag]
@@ -4173,7 +4646,10 @@ function parseMatches(output) {
4173
4646
  //#region src/chat/tui/chat/conversation-context.tsx
4174
4647
  const ConversationContext = createContext({
4175
4648
  rest: null,
4176
- conversationId: null
4649
+ conversationId: null,
4650
+ collectSandboxStats: null,
4651
+ latestComputerId: null,
4652
+ scrolledUp: false
4177
4653
  });
4178
4654
  function useConversationContext() {
4179
4655
  return useContext(ConversationContext);
@@ -4493,6 +4969,329 @@ function ShellLocalItem({ item }) {
4493
4969
  });
4494
4970
  }
4495
4971
 
4972
+ //#endregion
4973
+ //#region src/chat/tui/chat/sandbox-stats.ts
4974
+ /**
4975
+ * `/computer`: a live snapshot of the agent's sandbox as a set of bar graphs.
4976
+ *
4977
+ * The CLI runs on the USER's machine, so it can't read the sandbox's `/proc`
4978
+ * directly — it runs one small, portable shell command over the sandbox-exec
4979
+ * stream (`SandboxStream`, mode `exec`) and parses the output here. Everything
4980
+ * in this module is pure so the parse + layout are unit-testable without a
4981
+ * sandbox or a renderer.
4982
+ *
4983
+ * The command reads `/proc` and `df` only (no tools that might be missing from
4984
+ * a minimal image), emitting a tagged, tab-separated block. The leading
4985
+ * `SKYDIVE_STATS_V1` sentinel lets the parser find the block even when a shell
4986
+ * rc file prints a banner first, and versions the wire shape.
4987
+ */
4988
+ const SANDBOX_STATS_SENTINEL = "SKYDIVE_STATS_V1";
4989
+ /**
4990
+ * How often the live `/computer` card refreshes its snapshot, in ms.
4991
+ * Deliberately slow: the card is a background health readout, not a `top`, so
4992
+ * it refreshes quietly every 30s rather than flickering every second.
4993
+ */
4994
+ const COMPUTER_POLL_INTERVAL_MS = 3e4;
4995
+ /**
4996
+ * Whether a `/computer` card should refresh right now. Pure so the gating is
4997
+ * unit-testable. A card refreshes only when it is the most recent `/computer`
4998
+ * in the transcript (`isLatest`), the session can reach the sandbox
4999
+ * (`canCollect`), and the transcript is at the bottom (`!scrolledUp`) — while
5000
+ * you're scrolled up reading something else it quietly stops (no wasted polls),
5001
+ * and resumes when you scroll back down. Older cards freeze regardless, so a
5002
+ * scrolled-up history of runs isn't a fleet of background pollers. The pause is
5003
+ * silent: nothing about it is rendered.
5004
+ */
5005
+ function shouldPollComputer(opts) {
5006
+ return opts.isLatest && opts.canCollect && !opts.scrolledUp;
5007
+ }
5008
+ /**
5009
+ * The command run in the sandbox to collect the snapshot. `sh`-portable, reads
5010
+ * only `/proc` and `df`. Each metric is one `key<TAB>value` line so the parser
5011
+ * never has to positionally align anything.
5012
+ */
5013
+ const SANDBOX_STATS_COMMAND = [
5014
+ `printf '${SANDBOX_STATS_SENTINEL}\\n'`,
5015
+ `printf 'nproc\\t%s\\n' "$(nproc 2>/dev/null || echo 1)"`,
5016
+ `printf 'loadavg\\t%s\\n' "$(cut -d' ' -f1-3 /proc/loadavg)"`,
5017
+ `printf 'uptime\\t%s\\n' "$(cut -d' ' -f1 /proc/uptime)"`,
5018
+ `awk '/^MemTotal:/{t=$2}/^MemAvailable:/{a=$2}END{printf "mem_total_kb\\t%d\\nmem_avail_kb\\t%d\\n",t,a}' /proc/meminfo`,
5019
+ `awk '/^SwapTotal:/{t=$2}/^SwapFree:/{f=$2}END{printf "swap_total_kb\\t%d\\nswap_free_kb\\t%d\\n",t,f}' /proc/meminfo`,
5020
+ `df -Pk / | awk 'NR==2{printf "disk_total_kb\\t%d\\ndisk_used_kb\\t%d\\n",$2,$3}'`,
5021
+ `printf 'procs\\t%s\\n' "$(ls -1 /proc 2>/dev/null | grep -c '^[0-9]\\+$')"`
5022
+ ].join(" ; ");
5023
+ const KB = 1024;
5024
+ /**
5025
+ * Parse the collector's output into `SandboxStats`, or return null when the
5026
+ * block is absent or malformed (so the caller can show the raw output as an
5027
+ * error instead of a broken graph). Tolerant of extra lines before the
5028
+ * sentinel (shell banners) and of a missing swap section (swap totals default
5029
+ * to 0, which the renderer treats as "no swap").
5030
+ */
5031
+ function parseSandboxStats(raw) {
5032
+ const start = raw.indexOf(SANDBOX_STATS_SENTINEL);
5033
+ if (start === -1) return null;
5034
+ const fields = /* @__PURE__ */ new Map();
5035
+ for (const line of raw.slice(start).split("\n")) {
5036
+ const tab = line.indexOf(" ");
5037
+ if (tab === -1) continue;
5038
+ fields.set(line.slice(0, tab).trim(), line.slice(tab + 1).trim());
5039
+ }
5040
+ const num = (key) => {
5041
+ const v = fields.get(key);
5042
+ if (v === void 0) return null;
5043
+ const n = Number(v);
5044
+ return Number.isFinite(n) ? n : null;
5045
+ };
5046
+ const load = (fields.get("loadavg") ?? "").split(/\s+/).map(Number).filter((n) => Number.isFinite(n));
5047
+ const nproc = num("nproc");
5048
+ const uptime = num("uptime");
5049
+ const memTotal = num("mem_total_kb");
5050
+ const memAvail = num("mem_avail_kb");
5051
+ const diskTotal = num("disk_total_kb");
5052
+ const diskUsed = num("disk_used_kb");
5053
+ const procs = num("procs");
5054
+ const [load1, load5, load15] = load;
5055
+ if (nproc === null || uptime === null || memTotal === null || memAvail === null || diskTotal === null || diskUsed === null || procs === null || load1 === void 0 || load5 === void 0 || load15 === void 0) return null;
5056
+ return {
5057
+ nproc: Math.max(1, Math.round(nproc)),
5058
+ loadavg: [
5059
+ load1,
5060
+ load5,
5061
+ load15
5062
+ ],
5063
+ uptimeSeconds: uptime,
5064
+ memTotalBytes: memTotal * KB,
5065
+ memAvailBytes: memAvail * KB,
5066
+ swapTotalBytes: (num("swap_total_kb") ?? 0) * KB,
5067
+ swapFreeBytes: (num("swap_free_kb") ?? 0) * KB,
5068
+ diskTotalBytes: diskTotal * KB,
5069
+ diskUsedBytes: diskUsed * KB,
5070
+ procs: Math.round(procs)
5071
+ };
5072
+ }
5073
+ function severityFor(fraction) {
5074
+ if (fraction >= .9) return "crit";
5075
+ if (fraction >= .75) return "warn";
5076
+ return "ok";
5077
+ }
5078
+ const GiB = 1024 * 1024 * 1024;
5079
+ function gb(bytes) {
5080
+ return (bytes / GiB).toFixed(1);
5081
+ }
5082
+ /** `120050` seconds -> `1d 9h 20m`. Always at least `0m`. */
5083
+ function formatUptime(seconds) {
5084
+ const total = Math.max(0, Math.floor(seconds));
5085
+ const d = Math.floor(total / 86400);
5086
+ const h = Math.floor(total % 86400 / 3600);
5087
+ const m = Math.floor(total % 3600 / 60);
5088
+ const parts = [];
5089
+ if (d > 0) parts.push(`${d}d`);
5090
+ if (h > 0 || d > 0) parts.push(`${h}h`);
5091
+ parts.push(`${m}m`);
5092
+ return parts.join(" ");
5093
+ }
5094
+ /**
5095
+ * Reduce a snapshot to the ordered list of gauges the `/computer` card draws:
5096
+ * CPU load (normalized to core count), memory, swap (only when the box has
5097
+ * any), and disk. Load can exceed 1.0 (oversubscribed CPU) — the bar clamps
5098
+ * but the caption shows the true number so that's still legible.
5099
+ */
5100
+ function toGauges(stats) {
5101
+ const gauges = [];
5102
+ const load1 = stats.loadavg[0];
5103
+ const loadFraction = load1 / stats.nproc;
5104
+ gauges.push({
5105
+ label: "cpu load",
5106
+ fraction: loadFraction,
5107
+ detail: `${load1.toFixed(2)} / ${stats.nproc} cores`,
5108
+ severity: severityFor(loadFraction)
5109
+ });
5110
+ const memUsed = Math.max(0, stats.memTotalBytes - stats.memAvailBytes);
5111
+ const memFraction = stats.memTotalBytes > 0 ? memUsed / stats.memTotalBytes : 0;
5112
+ gauges.push({
5113
+ label: "memory",
5114
+ fraction: memFraction,
5115
+ detail: `${gb(memUsed)} / ${gb(stats.memTotalBytes)} GB`,
5116
+ severity: severityFor(memFraction)
5117
+ });
5118
+ if (stats.swapTotalBytes > 0) {
5119
+ const swapUsed = Math.max(0, stats.swapTotalBytes - stats.swapFreeBytes);
5120
+ const swapFraction = swapUsed / stats.swapTotalBytes;
5121
+ gauges.push({
5122
+ label: "swap",
5123
+ fraction: swapFraction,
5124
+ detail: `${gb(swapUsed)} / ${gb(stats.swapTotalBytes)} GB`,
5125
+ severity: severityFor(swapFraction)
5126
+ });
5127
+ }
5128
+ const diskFraction = stats.diskTotalBytes > 0 ? stats.diskUsedBytes / stats.diskTotalBytes : 0;
5129
+ gauges.push({
5130
+ label: "disk",
5131
+ fraction: diskFraction,
5132
+ detail: `${gb(stats.diskUsedBytes)} / ${gb(stats.diskTotalBytes)} GB`,
5133
+ severity: severityFor(diskFraction)
5134
+ });
5135
+ return gauges;
5136
+ }
5137
+ /**
5138
+ * A single-line horizontal bar of `width` cells filled to `fraction`
5139
+ * (clamped 0..1), using block glyphs. The last partial cell uses one of the
5140
+ * eighth-block glyphs so a 37%% bar reads differently from a 40%% one even in a
5141
+ * narrow width. Pure string, so the renderer just colors it.
5142
+ */
5143
+ function renderBar(fraction, width) {
5144
+ const cells = Math.max(1, width);
5145
+ const exact = Math.min(1, Math.max(0, fraction)) * cells;
5146
+ const full = Math.floor(exact);
5147
+ const remainder = exact - full;
5148
+ const eighths = [
5149
+ "",
5150
+ "▏",
5151
+ "▎",
5152
+ "▍",
5153
+ "▌",
5154
+ "▋",
5155
+ "▊",
5156
+ "▉"
5157
+ ];
5158
+ const partialIndex = Math.round(remainder * 8);
5159
+ const partial = full < cells ? eighths[partialIndex] : "";
5160
+ const filled = "█".repeat(Math.min(full, cells)) + partial;
5161
+ return (filled + "░".repeat(Math.max(0, cells - filled.length))).slice(0, cells);
5162
+ }
5163
+
5164
+ //#endregion
5165
+ //#region src/chat/tui/chunks/computer.tsx
5166
+ /**
5167
+ * Renders the `/computer` card: a snapshot of the agent's sandbox as a stack of
5168
+ * colored bar graphs (cpu load, memory, swap, disk) plus a footer of scalar
5169
+ * facts (uptime, process count).
5170
+ *
5171
+ * The card keeps itself current in place rather than making the user re-run the
5172
+ * command — but quietly: it refreshes on a slow interval with no "updating"
5173
+ * chrome, so the numbers just stay fresh without any flicker. Only the most
5174
+ * recent `/computer` card refreshes (older ones freeze at their last snapshot),
5175
+ * and the latest one silently stops while the transcript is scrolled up (no
5176
+ * wasted polls on a card you're not looking at) and resumes at the bottom — the
5177
+ * pause is never rendered. The refresh uses the same sandbox-exec relay
5178
+ * `/sandbox` uses, injected via context; the parse/layout math lives in
5179
+ * `sandbox-stats.ts` and this component only draws.
5180
+ */
5181
+ function ComputerItem({ item }) {
5182
+ const { collectSandboxStats, latestComputerId, scrolledUp } = useConversationContext();
5183
+ const live = shouldPollComputer({
5184
+ isLatest: latestComputerId === item.id,
5185
+ canCollect: collectSandboxStats !== null,
5186
+ scrolledUp
5187
+ });
5188
+ const [stats, setStats] = useState(item.stats);
5189
+ const [error, setError] = useState(item.error);
5190
+ const hasStats = useRef(item.stats !== null);
5191
+ useEffect(() => {
5192
+ if (!live || !collectSandboxStats) return;
5193
+ let cancelled = false;
5194
+ let timer = null;
5195
+ let inFlight = null;
5196
+ const tick = async () => {
5197
+ const collection = collectSandboxStats();
5198
+ inFlight = collection;
5199
+ const result = await collection.result;
5200
+ inFlight = null;
5201
+ if (cancelled) return;
5202
+ if (result.ok) {
5203
+ hasStats.current = true;
5204
+ setStats(result.stats);
5205
+ setError(null);
5206
+ } else if (!("aborted" in result)) {
5207
+ if (!hasStats.current) setError(result.error);
5208
+ }
5209
+ if (!cancelled) timer = setTimeout(tick, COMPUTER_POLL_INTERVAL_MS);
5210
+ };
5211
+ tick();
5212
+ return () => {
5213
+ cancelled = true;
5214
+ if (timer !== null) clearTimeout(timer);
5215
+ inFlight?.abort();
5216
+ };
5217
+ }, [live, collectSandboxStats]);
5218
+ if (stats === null && error === null) return /* @__PURE__ */ jsx(Shell, { children: /* @__PURE__ */ jsx("text", {
5219
+ fg: theme.dim,
5220
+ children: "reading sandbox stats…"
5221
+ }) });
5222
+ if (stats === null) return /* @__PURE__ */ jsx(Shell, { children: /* @__PURE__ */ jsxs("text", {
5223
+ fg: theme.error,
5224
+ children: ["couldn't read sandbox stats", error ? `: ${error}` : ""]
5225
+ }) });
5226
+ const gauges = toGauges(stats);
5227
+ const labelWidth = Math.max(...gauges.map((g) => g.label.length));
5228
+ const barWidth = 24;
5229
+ return /* @__PURE__ */ jsxs(Shell, { children: [gauges.map((g) => /* @__PURE__ */ jsx(GaugeRow, {
5230
+ gauge: g,
5231
+ labelWidth,
5232
+ barWidth
5233
+ }, g.label)), /* @__PURE__ */ jsxs("text", {
5234
+ fg: theme.dim,
5235
+ children: [
5236
+ `${"uptime".padEnd(labelWidth)} `,
5237
+ /* @__PURE__ */ jsx("span", {
5238
+ fg: theme.muted,
5239
+ children: formatUptime(stats.uptimeSeconds)
5240
+ }),
5241
+ /* @__PURE__ */ jsx("span", {
5242
+ fg: theme.dim,
5243
+ children: ` · ${stats.procs} processes`
5244
+ })
5245
+ ]
5246
+ })] });
5247
+ }
5248
+ /** The card frame: a bold `computer` title over the graph rows. */
5249
+ function Shell({ children }) {
5250
+ return /* @__PURE__ */ jsxs("box", {
5251
+ style: { flexDirection: "column" },
5252
+ children: [/* @__PURE__ */ jsxs("text", {
5253
+ fg: theme.muted,
5254
+ children: [/* @__PURE__ */ jsx("b", { children: "computer" }), /* @__PURE__ */ jsx("span", {
5255
+ fg: theme.dim,
5256
+ children: " · agent sandbox"
5257
+ })]
5258
+ }), children]
5259
+ });
5260
+ }
5261
+ function severityColor(severity) {
5262
+ switch (severity) {
5263
+ case "ok": return theme.success;
5264
+ case "warn": return theme.warning;
5265
+ case "crit": return theme.error;
5266
+ default: {
5267
+ const exhaustive = severity;
5268
+ throw new Error(`unhandled severity: ${String(exhaustive)}`);
5269
+ }
5270
+ }
5271
+ }
5272
+ function GaugeRow({ gauge, labelWidth, barWidth }) {
5273
+ const color = severityColor(gauge.severity);
5274
+ const pct = `${Math.round(Math.min(1, Math.max(0, gauge.fraction)) * 100)}%`;
5275
+ return /* @__PURE__ */ jsxs("text", { children: [
5276
+ /* @__PURE__ */ jsx("span", {
5277
+ fg: theme.muted,
5278
+ children: `${gauge.label.padEnd(labelWidth)} `
5279
+ }),
5280
+ /* @__PURE__ */ jsx("span", {
5281
+ fg: color,
5282
+ children: renderBar(gauge.fraction, barWidth)
5283
+ }),
5284
+ /* @__PURE__ */ jsx("span", {
5285
+ fg: color,
5286
+ children: ` ${pct.padStart(4)}`
5287
+ }),
5288
+ /* @__PURE__ */ jsx("span", {
5289
+ fg: theme.dim,
5290
+ children: ` ${gauge.detail}`
5291
+ })
5292
+ ] });
5293
+ }
5294
+
4496
5295
  //#endregion
4497
5296
  //#region src/chat/tui/chunks/index.tsx
4498
5297
  /**
@@ -4529,20 +5328,23 @@ function RenderItem({ item }) {
4529
5328
  children: [item.text ? /* @__PURE__ */ jsx("text", {
4530
5329
  fg: theme.user,
4531
5330
  children: item.text
4532
- }) : null, item.attachments && item.attachments.length > 0 ? /* @__PURE__ */ jsxs("text", {
5331
+ }) : null, item.attachments.length > 0 ? /* @__PURE__ */ jsxs("text", {
4533
5332
  fg: theme.muted,
4534
5333
  children: ["⎘ ", item.attachments.join(" · ")]
4535
5334
  }) : null]
4536
5335
  });
4537
- case "pending-steer": return /* @__PURE__ */ jsx(Row, {
5336
+ case "pending-steer": return /* @__PURE__ */ jsxs(Row, {
4538
5337
  barColor: theme.dim,
4539
- children: /* @__PURE__ */ jsxs("text", {
5338
+ children: [/* @__PURE__ */ jsxs("text", {
4540
5339
  fg: theme.muted,
4541
5340
  children: [item.text, /* @__PURE__ */ jsx("span", {
4542
5341
  fg: theme.dim,
4543
5342
  children: " · steering… (esc to cancel)"
4544
5343
  })]
4545
- })
5344
+ }), item.attachments.length > 0 ? /* @__PURE__ */ jsxs("text", {
5345
+ fg: theme.dim,
5346
+ children: ["⎘ ", item.attachments.join(" · ")]
5347
+ }) : null]
4546
5348
  });
4547
5349
  case "assistant-text":
4548
5350
  if (item.done) return /* @__PURE__ */ jsx(Row, {
@@ -4588,6 +5390,10 @@ function RenderItem({ item }) {
4588
5390
  })]
4589
5391
  })
4590
5392
  });
5393
+ case "computer": return /* @__PURE__ */ jsx(Row, {
5394
+ barColor: null,
5395
+ children: /* @__PURE__ */ jsx(ComputerItem, { item })
5396
+ });
4591
5397
  case "recap": return /* @__PURE__ */ jsx(Row, {
4592
5398
  barColor: null,
4593
5399
  children: /* @__PURE__ */ jsxs("text", {
@@ -5277,7 +6083,8 @@ function applyChunk(items, chunk, agentName = null) {
5277
6083
  return items.map((m) => m.kind === "pending-steer" && ids.has(m.id) ? {
5278
6084
  kind: "user",
5279
6085
  id: m.id,
5280
- text: m.text
6086
+ text: m.text,
6087
+ attachments: m.attachments
5281
6088
  } : m);
5282
6089
  }
5283
6090
  case "data-anyone-render-spec": {
@@ -5518,7 +6325,7 @@ function uiMessagesToItems(messages) {
5518
6325
  kind: "user",
5519
6326
  id: m.id,
5520
6327
  text,
5521
- ...attachments.length > 0 ? { attachments } : {}
6328
+ attachments
5522
6329
  });
5523
6330
  continue;
5524
6331
  }
@@ -5969,6 +6776,65 @@ function formatShare(share) {
5969
6776
  return share.grantedToAgent ? "shared (this agent can run commands here)" : "shared (this agent is not granted)";
5970
6777
  }
5971
6778
 
6779
+ //#endregion
6780
+ //#region src/chat/sandbox/collect-stats.ts
6781
+ function collectSandboxStats(cfg) {
6782
+ const open = cfg.open ?? SandboxStream.open;
6783
+ const dec = new TextDecoder();
6784
+ let buffer = "";
6785
+ let settled = false;
6786
+ let stream = null;
6787
+ let resolveRef = null;
6788
+ return {
6789
+ result: new Promise((resolve) => {
6790
+ resolveRef = resolve;
6791
+ const settle = (r) => {
6792
+ if (settled) return;
6793
+ settled = true;
6794
+ resolve(r);
6795
+ };
6796
+ stream = open({
6797
+ mode: "exec",
6798
+ appUrl: cfg.appUrl,
6799
+ sessionToken: cfg.sessionToken,
6800
+ agentId: cfg.agentId,
6801
+ command: SANDBOX_STATS_COMMAND,
6802
+ onEvent: (e) => {
6803
+ if (e.type === "data") buffer += dec.decode(e.bytes);
6804
+ else if (e.type === "error") settle({
6805
+ ok: false,
6806
+ error: e.message
6807
+ });
6808
+ else if (e.type === "exit") {
6809
+ const stats = parseSandboxStats(buffer);
6810
+ if (stats) settle({
6811
+ ok: true,
6812
+ stats
6813
+ });
6814
+ else settle({
6815
+ ok: false,
6816
+ error: e.code === 0 ? "unexpected output from the sandbox" : `sandbox command exited ${e.code}`
6817
+ });
6818
+ } else if (e.type === "close") settle({
6819
+ ok: false,
6820
+ error: e.failure ?? "connection closed before the snapshot finished"
6821
+ });
6822
+ }
6823
+ });
6824
+ }),
6825
+ abort: () => {
6826
+ if (settled) return;
6827
+ settled = true;
6828
+ stream?.close();
6829
+ resolveRef?.({
6830
+ ok: false,
6831
+ aborted: true,
6832
+ error: "aborted"
6833
+ });
6834
+ }
6835
+ };
6836
+ }
6837
+
5972
6838
  //#endregion
5973
6839
  //#region src/chat/notification-preview.ts
5974
6840
  const MAX_NOTIFICATION_PREVIEW_CHARS = 240;
@@ -6019,6 +6885,58 @@ function createResponsePreview() {
6019
6885
  };
6020
6886
  }
6021
6887
 
6888
+ //#endregion
6889
+ //#region src/chat/tui/new-chat-splash.tsx
6890
+ /** Rows the splash art needs before it's worth painting: the 6-row mark, a
6891
+ * spacer, the two identity/hint lines, and a row of breathing room. Below
6892
+ * this only the identity + hint render (small panes must keep every
6893
+ * transcript row). */
6894
+ const MARK_MIN_ROWS = 10;
6895
+ /** Columns the full mark + wordmark lockup spans (~68), with margin; below
6896
+ * this the pinwheel mark renders alone instead of wrapping the wordmark. */
6897
+ const FULL_MARK_MIN_COLS = 70;
6898
+ /**
6899
+ * Brand splash filling an empty conversation's transcript area: the Skydive
6900
+ * mark centered above the agent's identity and the "say hello" hint, with
6901
+ * the composer live below. The identity line matters here because nothing
6902
+ * else on this screen names the agent — a bare `skydive chat` boots straight
6903
+ * into this view via the configured default. Rendered only while the
6904
+ * transcript is empty, so the first submission replaces it with the regular
6905
+ * conversation view. Degrades by available space: full lockup → mark only →
6906
+ * identity + hint only.
6907
+ */
6908
+ function NewChatSplash({ agentName, agentTitle, width, height }) {
6909
+ return /* @__PURE__ */ jsxs("box", {
6910
+ style: {
6911
+ height,
6912
+ flexDirection: "column",
6913
+ justifyContent: "center",
6914
+ alignItems: "center"
6915
+ },
6916
+ children: [
6917
+ height >= MARK_MIN_ROWS ? /* @__PURE__ */ jsx("box", {
6918
+ style: {
6919
+ flexDirection: "column",
6920
+ alignItems: "center",
6921
+ marginBottom: 1
6922
+ },
6923
+ children: /* @__PURE__ */ jsx(SkydiveMark, { variant: width >= FULL_MARK_MIN_COLS ? "full" : "mark" })
6924
+ }) : null,
6925
+ /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
6926
+ fg: theme.accent,
6927
+ children: agentName
6928
+ }), agentTitle ? /* @__PURE__ */ jsxs("span", {
6929
+ fg: theme.muted,
6930
+ children: [" · ", agentTitle]
6931
+ }) : null] }),
6932
+ /* @__PURE__ */ jsx("text", {
6933
+ fg: theme.dim,
6934
+ children: "say hello below"
6935
+ })
6936
+ ]
6937
+ });
6938
+ }
6939
+
6022
6940
  //#endregion
6023
6941
  //#region src/chat/tui/prompt-history.ts
6024
6942
  /**
@@ -6773,6 +7691,13 @@ const slashCommands = [
6773
7691
  description: "show connection and session status",
6774
7692
  action: { kind: "status" }
6775
7693
  },
7694
+ {
7695
+ name: "computer",
7696
+ aliases: ["stats"],
7697
+ argsHint: null,
7698
+ description: "show the agent sandbox's cpu, memory, and disk",
7699
+ action: { kind: "computer" }
7700
+ },
6776
7701
  {
6777
7702
  name: "plan",
6778
7703
  aliases: ["todos", "todo"],
@@ -9967,6 +10892,7 @@ function routeInput(raw) {
9967
10892
  case "model-picker":
9968
10893
  case "theme-picker":
9969
10894
  case "status":
10895
+ case "computer":
9970
10896
  case "share":
9971
10897
  case "browser":
9972
10898
  case "connect":
@@ -10034,47 +10960,6 @@ async function runPtySession(opts) {
10034
10960
  }
10035
10961
  }
10036
10962
 
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
10963
  //#endregion
10079
10964
  //#region src/chat/tui/screens/chat.tsx
10080
10965
  /**
@@ -10159,11 +11044,13 @@ function ChatScreen({ agent, conversation }) {
10159
11044
  const portalClient = useStore((s) => s.portalClient);
10160
11045
  const setPortalPrompt = useStore((s) => s.setPortalPrompt);
10161
11046
  const declinedRef = useRef(/* @__PURE__ */ new Set());
11047
+ const grantInFlightRef = useRef(/* @__PURE__ */ new Set());
10162
11048
  const grantPrompt = portal.prompt && portal.prompt.agentId === agent.id ? portal.prompt : null;
10163
11049
  const isShared = portal.status !== "off";
10164
11050
  const initialConversationId = isNewConversation(conversation) ? null : conversation.id;
10165
11051
  const agentHost = useAgentHost();
10166
11052
  const nonWebChannel = nonWebChannelLabel(conversation);
11053
+ const agentRunConversation = isAgentRunConversation(conversation);
10167
11054
  const [conversationId, setConversationId] = useState(initialConversationId);
10168
11055
  const { compacting, compactConversation, onCompactionEvent } = useCompaction({
10169
11056
  rest,
@@ -10222,6 +11109,8 @@ function ChatScreen({ agent, conversation }) {
10222
11109
  const menuHighlightClamped = Math.min(menuHighlight, Math.max(0, menuCommands.length - 1));
10223
11110
  const runRef = useRef(run);
10224
11111
  runRef.current = run;
11112
+ const conversationIdRef = useRef(conversationId);
11113
+ conversationIdRef.current = conversationId;
10225
11114
  const itemsRef = useRef(items);
10226
11115
  itemsRef.current = items;
10227
11116
  const attachmentsRef = useRef(attachments);
@@ -10271,13 +11160,16 @@ function ChatScreen({ agent, conversation }) {
10271
11160
  });
10272
11161
  setScrolledUp(false);
10273
11162
  }, []);
11163
+ const scrolledUpRef = useRef(false);
10274
11164
  useEffect(() => {
10275
11165
  const id = setInterval(() => {
10276
11166
  const box = scrollRef.current;
10277
11167
  if (!box) return;
10278
11168
  const viewport = box.viewport.height;
10279
- const atBottom = box.scrollTop + viewport >= box.scrollHeight - 1;
10280
- setScrolledUp((prev) => prev === !atBottom ? prev : !atBottom);
11169
+ const next = !(box.scrollTop + viewport >= box.scrollHeight - 1);
11170
+ if (scrolledUpRef.current === next) return;
11171
+ scrolledUpRef.current = next;
11172
+ setScrolledUp(next);
10281
11173
  }, 150);
10282
11174
  return () => clearInterval(id);
10283
11175
  }, []);
@@ -10358,10 +11250,13 @@ function ChatScreen({ agent, conversation }) {
10358
11250
  text: recapText
10359
11251
  });
10360
11252
  setItems(loaded);
11253
+ const toolUrlText = loaded.map((item) => item.kind === "tool" && item.output?.kind === "ok" ? stringifyToolOutput(item.output.value) : "").join("\n");
10361
11254
  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 })) {
11255
+ if (item.kind !== "assistant-text") continue;
11256
+ for (const pr of extractPullRequests(item.text, {
11257
+ requireCompleteNumber: false,
11258
+ urlOnlyText: toolUrlText
11259
+ })) {
10365
11260
  if (reportedPrsRef.current.has(pr.key)) continue;
10366
11261
  reportedPrsRef.current.set(pr.key, pr);
10367
11262
  setPrLinks((prev) => [...prev, pr]);
@@ -10388,6 +11283,15 @@ function ChatScreen({ agent, conversation }) {
10388
11283
  initialConversationId,
10389
11284
  agentHost
10390
11285
  ]);
11286
+ const markRead = useCallback(() => {
11287
+ const id = conversationIdRef.current;
11288
+ if (!rest || !id) return;
11289
+ rest.markConversationRead({ conversationId: id }).catch(() => {});
11290
+ }, [rest]);
11291
+ useEffect(() => {
11292
+ if (!conversationId) return;
11293
+ markRead();
11294
+ }, [conversationId, markRead]);
10391
11295
  const attachToRun = useCallback((runId, runAgentName, runStartedAtMs) => {
10392
11296
  if (!rest) return;
10393
11297
  seenRunsRef.current.add(runId);
@@ -10400,6 +11304,7 @@ function ChatScreen({ agent, conversation }) {
10400
11304
  agentHost.addPullRequest(pr);
10401
11305
  });
10402
11306
  const streamAgentName = runAgentName ?? agent.name;
11307
+ let sawErrorChunk = false;
10403
11308
  setRun((prev) => ({
10404
11309
  kind: "streaming",
10405
11310
  runId,
@@ -10411,6 +11316,7 @@ function ChatScreen({ agent, conversation }) {
10411
11316
  signal: abort.signal,
10412
11317
  onEvent: (event) => {
10413
11318
  if (event.kind === "chunk") {
11319
+ if (event.chunk.type === "error") sawErrorChunk = true;
10414
11320
  responsePreview.addChunk(event.chunk);
10415
11321
  prScanner.addChunk(event.chunk);
10416
11322
  if (event.chunk.type === "data-anyone-todos-updated") {
@@ -10428,7 +11334,7 @@ function ChatScreen({ agent, conversation }) {
10428
11334
  id: crypto.randomUUID(),
10429
11335
  text: billingOutcome.message
10430
11336
  }]);
10431
- else if (event.status !== "ok" && errorText) setItems((prev) => [...prev, {
11337
+ else if (event.status !== "ok" && errorText && !sawErrorChunk) setItems((prev) => [...prev, {
10432
11338
  kind: "error",
10433
11339
  id: crypto.randomUUID(),
10434
11340
  text: errorText
@@ -10436,9 +11342,11 @@ function ChatScreen({ agent, conversation }) {
10436
11342
  setItems((prev) => prev.map((m) => m.kind === "pending-steer" ? {
10437
11343
  kind: "user",
10438
11344
  id: m.id,
10439
- text: m.text
11345
+ text: m.text,
11346
+ attachments: m.attachments
10440
11347
  } : m));
10441
11348
  setRun({ kind: "idle" });
11349
+ markRead();
10442
11350
  agentHost.transition({
10443
11351
  state: "idle",
10444
11352
  notification: {
@@ -10460,7 +11368,8 @@ function ChatScreen({ agent, conversation }) {
10460
11368
  }, [
10461
11369
  rest,
10462
11370
  agent.name,
10463
- agentHost
11371
+ agentHost,
11372
+ markRead
10464
11373
  ]);
10465
11374
  const multiAgent = useMemo(() => conversationAgentNames(items, agent.name), [items, agent.name]).size > 1;
10466
11375
  const transcriptWindow = useMemo(() => windowItems(items), [items]);
@@ -10513,7 +11422,7 @@ function ChatScreen({ agent, conversation }) {
10513
11422
  kind: "user",
10514
11423
  id: optimisticId,
10515
11424
  text: trimmed,
10516
- ...stagedNames.length > 0 ? { attachments: stagedNames } : {}
11425
+ attachments: stagedNames
10517
11426
  }]);
10518
11427
  const wasStreaming = runRef.current.kind === "streaming";
10519
11428
  if (!wasStreaming) setRun({
@@ -10562,7 +11471,8 @@ function ChatScreen({ agent, conversation }) {
10562
11471
  if (directiveId) setItems((prev) => prev.map((m) => m.id === optimisticId ? {
10563
11472
  kind: "pending-steer",
10564
11473
  id: directiveId,
10565
- text: trimmed
11474
+ text: trimmed,
11475
+ attachments: m.kind === "user" || m.kind === "pending-steer" ? m.attachments : []
10566
11476
  } : m));
10567
11477
  const current = runRef.current;
10568
11478
  if (!(current.kind === "streaming" && current.runId === result.runId)) attachToRun(result.runId, result.agentName ?? null);
@@ -10609,12 +11519,14 @@ function ChatScreen({ agent, conversation }) {
10609
11519
  });
10610
11520
  (async () => {
10611
11521
  if (autoGrantMachine && portalClient && portalStatus === "connected" && !portalClient.isGranted(agent.id)) try {
10612
- await portalClient.grantAgent(agent.id);
11522
+ grantInFlightRef.current.add(agent.id);
11523
+ await portalClient.grantAgent(agent.id, null);
10613
11524
  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}`,
11525
+ message: `Shared this machine with ${agent.name} for the import. The grant persists. Revoke anytime: skydive portal revoke --agent "${agent.name}"`,
10615
11526
  durationMs: 1e4
10616
11527
  });
10617
11528
  } catch (err) {
11529
+ grantInFlightRef.current.delete(agent.id);
10618
11530
  useStore.getState().showToast({
10619
11531
  message: `Couldn't grant machine access automatically (${errorMessage(err)}). The agent will ask in chat instead.`,
10620
11532
  variant: "warning"
@@ -10678,9 +11590,12 @@ function ChatScreen({ agent, conversation }) {
10678
11590
  if (!portalClient) throw new Error("portal is unavailable in this session");
10679
11591
  const justEnabled = portal.status === "off";
10680
11592
  if (justEnabled) portalClient.enable();
11593
+ grantInFlightRef.current.add(action.agentId);
11594
+ if (useStore.getState().portal.prompt?.agentId === action.agentId) setPortalPrompt(null);
10681
11595
  try {
10682
- await portalClient.grantAgent(action.agentId);
11596
+ await portalClient.grantAgent(action.agentId, action.conversationId);
10683
11597
  } catch (grantErr) {
11598
+ grantInFlightRef.current.delete(action.agentId);
10684
11599
  if (justEnabled) portalClient.disable();
10685
11600
  throw grantErr;
10686
11601
  }
@@ -10741,7 +11656,8 @@ function ChatScreen({ agent, conversation }) {
10741
11656
  appUrl,
10742
11657
  updateCard,
10743
11658
  portalClient,
10744
- portal.status
11659
+ portal.status,
11660
+ setPortalPrompt
10745
11661
  ]);
10746
11662
  const promptForCards = useCallback(() => {
10747
11663
  const targets = itemsRef.current.filter(isActionableCard);
@@ -10905,6 +11821,16 @@ function ChatScreen({ agent, conversation }) {
10905
11821
  sessionToken,
10906
11822
  agent.id
10907
11823
  ]);
11824
+ const runComputerStats = useCallback(() => {
11825
+ if (!appUrl || !sessionToken) return;
11826
+ setItems((prev) => [...prev, {
11827
+ kind: "computer",
11828
+ id: crypto.randomUUID(),
11829
+ state: "loading",
11830
+ stats: null,
11831
+ error: null
11832
+ }]);
11833
+ }, [appUrl, sessionToken]);
10908
11834
  const runSandboxPty = useCallback(async () => {
10909
11835
  if (!appUrl || !sessionToken) return;
10910
11836
  const result = await runPtySession({
@@ -11066,7 +11992,8 @@ function ChatScreen({ agent, conversation }) {
11066
11992
  setItems((prev) => prev.map((m) => m.kind === "pending-steer" ? {
11067
11993
  kind: "user",
11068
11994
  id: m.id,
11069
- text: m.text
11995
+ text: m.text,
11996
+ attachments: m.attachments
11070
11997
  } : m));
11071
11998
  useStore.getState().showToast({ message: "run cancelled" });
11072
11999
  }, [rest]);
@@ -11135,6 +12062,10 @@ function ChatScreen({ agent, conversation }) {
11135
12062
  ]);
11136
12063
  const archiveConversation = useCallback(() => {
11137
12064
  if (grantPrompt || credPrompt) return;
12065
+ if (agentRunConversation) {
12066
+ useStore.getState().showToast({ message: "agent runs can't be archived — ctrl+d in the picker deletes" });
12067
+ return;
12068
+ }
11138
12069
  if (runRef.current.kind !== "idle") {
11139
12070
  useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
11140
12071
  return;
@@ -11163,6 +12094,7 @@ function ChatScreen({ agent, conversation }) {
11163
12094
  }, [
11164
12095
  grantPrompt,
11165
12096
  credPrompt,
12097
+ agentRunConversation,
11166
12098
  conversationId,
11167
12099
  rest,
11168
12100
  goTo,
@@ -11244,7 +12176,9 @@ function ChatScreen({ agent, conversation }) {
11244
12176
  rest,
11245
12177
  agentSelector: null,
11246
12178
  conversationId: target,
11247
- newConversation: false
12179
+ newConversation: false,
12180
+ defaultAgentSelector: null,
12181
+ forceOnboarding: false
11248
12182
  }));
11249
12183
  } catch (err) {
11250
12184
  setItems((prev) => [...prev, {
@@ -11283,19 +12217,24 @@ function ChatScreen({ agent, conversation }) {
11283
12217
  if (!portalClient) return;
11284
12218
  const agentName = agent.name;
11285
12219
  setPortalPrompt(null);
11286
- portalClient.grantAgent(agent.id).then(() => useStore.getState().showToast({
12220
+ grantInFlightRef.current.add(agent.id);
12221
+ portalClient.grantAgent(agent.id, conversationId).then(() => useStore.getState().showToast({
11287
12222
  variant: "success",
11288
12223
  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
- }]));
12224
+ })).catch((err) => {
12225
+ grantInFlightRef.current.delete(agent.id);
12226
+ setItems((prev) => [...prev, {
12227
+ kind: "error",
12228
+ id: crypto.randomUUID(),
12229
+ text: `couldn't share machine: ${errorDetail(err)}`
12230
+ }]);
12231
+ });
11294
12232
  }, [
11295
12233
  portalClient,
11296
12234
  agent.id,
11297
12235
  agent.name,
11298
- setPortalPrompt
12236
+ setPortalPrompt,
12237
+ conversationId
11299
12238
  ]);
11300
12239
  const declineGrant = useCallback(() => {
11301
12240
  declinedRef.current.add(agent.id);
@@ -11438,6 +12377,9 @@ function ChatScreen({ agent, conversation }) {
11438
12377
  })
11439
12378
  }]);
11440
12379
  break;
12380
+ case "computer":
12381
+ runComputerStats();
12382
+ break;
11441
12383
  case "plan": {
11442
12384
  if (!(!!todos && todos.length > 0)) {
11443
12385
  useStore.getState().showToast({ message: "no plan to show yet" });
@@ -11490,6 +12432,7 @@ function ChatScreen({ agent, conversation }) {
11490
12432
  history,
11491
12433
  runLocalShell,
11492
12434
  runSandboxExec,
12435
+ runComputerStats,
11493
12436
  runSandboxPty,
11494
12437
  archiveConversation,
11495
12438
  renameConversation,
@@ -11535,6 +12478,7 @@ function ChatScreen({ agent, conversation }) {
11535
12478
  if (portal.grantedAgentIds.includes(agent.id)) return;
11536
12479
  if (portal.declinedAgentIds.includes(agent.id)) return;
11537
12480
  if (declinedRef.current.has(agent.id)) return;
12481
+ if (grantInFlightRef.current.has(agent.id)) return;
11538
12482
  if (portal.prompt?.agentId === agent.id) return;
11539
12483
  setPortalPrompt({
11540
12484
  agentId: agent.id,
@@ -11558,6 +12502,14 @@ function ChatScreen({ agent, conversation }) {
11558
12502
  setPortalPrompt,
11559
12503
  agentHost
11560
12504
  ]);
12505
+ useEffect(() => {
12506
+ for (const agentId of portal.grantedAgentIds) grantInFlightRef.current.delete(agentId);
12507
+ if (portal.prompt && portal.grantedAgentIds.includes(portal.prompt.agentId)) setPortalPrompt(null);
12508
+ }, [
12509
+ portal.grantedAgentIds,
12510
+ portal.prompt,
12511
+ setPortalPrompt
12512
+ ]);
11561
12513
  useKeyboard((key) => {
11562
12514
  if (reviewOpen) {
11563
12515
  if (key.name === "g" && key.ctrl) {
@@ -11747,10 +12699,38 @@ function ChatScreen({ agent, conversation }) {
11747
12699
  }
11748
12700
  });
11749
12701
  const hasPendingSteer = items.some((m) => m.kind === "pending-steer");
12702
+ const latestComputerId = useMemo(() => {
12703
+ for (let i = items.length - 1; i >= 0; i--) {
12704
+ const item = items[i];
12705
+ if (item && item.kind === "computer") return item.id;
12706
+ }
12707
+ return null;
12708
+ }, [items]);
12709
+ const boundCollectSandboxStats = useMemo(() => {
12710
+ if (!appUrl || !sessionToken) return null;
12711
+ return () => collectSandboxStats({
12712
+ appUrl,
12713
+ sessionToken,
12714
+ agentId: agent.id
12715
+ });
12716
+ }, [
12717
+ appUrl,
12718
+ sessionToken,
12719
+ agent.id
12720
+ ]);
11750
12721
  const conversationContext = useMemo(() => ({
11751
12722
  rest,
11752
- conversationId
11753
- }), [rest, conversationId]);
12723
+ conversationId,
12724
+ collectSandboxStats: boundCollectSandboxStats,
12725
+ latestComputerId,
12726
+ scrolledUp
12727
+ }), [
12728
+ rest,
12729
+ conversationId,
12730
+ boundCollectSandboxStats,
12731
+ latestComputerId,
12732
+ scrolledUp
12733
+ ]);
11754
12734
  const actionableCards = useMemo(() => items.filter(isActionableCard), [items]);
11755
12735
  const statusText = useMemo(() => formatChatFooterStatus({
11756
12736
  runKind: run.kind,
@@ -11899,13 +12879,11 @@ function ChatScreen({ agent, conversation }) {
11899
12879
  children: [!historyLoaded ? /* @__PURE__ */ jsx("text", {
11900
12880
  fg: theme.muted,
11901
12881
  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
- ]
12882
+ }) : items.length === 0 ? /* @__PURE__ */ jsx(NewChatSplash, {
12883
+ agentName: agent.name,
12884
+ agentTitle: agent.title ?? null,
12885
+ width: reviewView && reviewPlacement === "side" ? Math.max(1, bodyWidth - reviewWidth - 1) : bodyWidth,
12886
+ height: Math.max(1, scrollHeight - 1)
11909
12887
  }) : /* @__PURE__ */ jsxs(Fragment, { children: [transcriptWindow.hiddenCount > 0 ? /* @__PURE__ */ jsx("box", {
11910
12888
  style: {
11911
12889
  paddingLeft: 1,
@@ -12359,9 +13337,12 @@ function ToastView() {
12359
13337
 
12360
13338
  //#endregion
12361
13339
  //#region src/chat/tui/app.tsx
12362
- function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath, notifications, agentSelector, conversationId, newConversation }) {
13340
+ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath, notifications, agentSelector, conversationId, newConversation, defaultAgentSelector, forceOnboarding }) {
12363
13341
  const renderer = useRenderer();
12364
13342
  const screen = useStore((s) => s.screen);
13343
+ useEffect(() => {
13344
+ if (screen.kind === "chat") recordDefaultAgent(screen.agent.id);
13345
+ }, [screen]);
12365
13346
  const chatTitle = useStore((s) => s.chatTitle);
12366
13347
  useStore((s) => s.themeId);
12367
13348
  const rest = useStore((s) => s.rest);
@@ -12444,7 +13425,9 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
12444
13425
  rest,
12445
13426
  agentSelector,
12446
13427
  conversationId,
12447
- newConversation: newConversation || useStore.getState().seedPrompt !== null
13428
+ newConversation: newConversation || useStore.getState().seedPrompt !== null,
13429
+ defaultAgentSelector,
13430
+ forceOnboarding
12448
13431
  });
12449
13432
  if (cancelled) return;
12450
13433
  goTo(screen);
@@ -12467,6 +13450,8 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
12467
13450
  agentSelector,
12468
13451
  conversationId,
12469
13452
  newConversation,
13453
+ defaultAgentSelector,
13454
+ forceOnboarding,
12470
13455
  goTo,
12471
13456
  setClients,
12472
13457
  fail
@@ -12507,6 +13492,7 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
12507
13492
  screen.kind === "error" && /* @__PURE__ */ jsx(ErrorScreen, { message: screen.message }),
12508
13493
  screen.kind === "agent-picker" && rest && /* @__PURE__ */ jsx(AgentPickerScreen, {}),
12509
13494
  screen.kind === "agent-create" && rest && /* @__PURE__ */ jsx(AgentCreateScreen, {}),
13495
+ screen.kind === "first-run" && rest && /* @__PURE__ */ jsx(FirstRunScreen, {}),
12510
13496
  screen.kind === "workspace-picker" && /* @__PURE__ */ jsx(WorkspacePicker, {
12511
13497
  appUrl,
12512
13498
  sessionToken,
@@ -13045,7 +14031,7 @@ function onRenderCommit(id, phase, actualDuration, baseDuration, startTime, comm
13045
14031
  * module — and the heavy OpenTUI dependency graph it pulls in — is
13046
14032
  * dynamically imported and never loaded on the Node management path.
13047
14033
  */
13048
- async function runChat({ appUrl, sessionToken, authKind = "session", shareMachine, promptHistoryPath, theme: themeOverride, notifications, agentSelector, conversationId, newConversation, seedPrompt }) {
14034
+ async function runChat({ appUrl, sessionToken, authKind = "session", shareMachine, promptHistoryPath, theme: themeOverride, notifications, agentSelector, conversationId, newConversation, defaultAgentSelector, forceOnboarding, seedPrompt }) {
13049
14035
  await registerGrammars();
13050
14036
  const watchdog = startMemoryWatchdog();
13051
14037
  installCrashHandler({ memoryProvider: () => watchdog.snapshot() });
@@ -13060,7 +14046,8 @@ async function runChat({ appUrl, sessionToken, authKind = "session", shareMachin
13060
14046
  authKind,
13061
14047
  seedPrompt: seedPrompt ?? null,
13062
14048
  autoGrantMachine: Boolean(seedPrompt && shareMachine),
13063
- newConversationIntent: newConversation
14049
+ newConversationIntent: newConversation,
14050
+ forcedOnboarding: forceOnboarding
13064
14051
  });
13065
14052
  const store = useStore.getState();
13066
14053
  if (noColorRequested()) {
@@ -13086,7 +14073,9 @@ async function runChat({ appUrl, sessionToken, authKind = "session", shareMachin
13086
14073
  notifications,
13087
14074
  agentSelector: agentSelector ?? null,
13088
14075
  conversationId: conversationId ?? null,
13089
- newConversation
14076
+ newConversation,
14077
+ defaultAgentSelector: defaultAgentSelector ?? null,
14078
+ forceOnboarding
13090
14079
  })
13091
14080
  }));
13092
14081
  }