skydive-cli 0.4.0-beta.979 → 0.4.1-beta.1

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,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { C as setActiveWorkspace, S as listWorkspaces, a as WORDMARK, b as getActiveWorkspaceId, c as applyTheme, d as noColorRequested, f as theme, g as themeVersion, h as themeModeFromColorFgBg, i as MARK_CELLS, l as findTheme, m as themeMode, n as buildCrashReport, p as themeForMode, r as writeCrashReport, s as DEFAULT_THEME_ID, t as installCrashHandler, u as monoTheme, v as themesForMode } from "./install-Io3UMV51.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-CiaU2ygh.mjs";
4
- import { a as errorMessage, i as sendErrorMessage, n as createRestClient, o as isRecord, r as errorDetail, t as HttpError } from "./rest-CG7VpQ56.mjs";
5
- import { i as billingBlockedOutcomeFromSendResponse } from "./billing-blocked-CwfG1BTR.mjs";
6
- import { t as PortalClient } from "./client-DiaJZQG6.mjs";
7
- import { d as makeLineParser, f as parseDaemonMessage, l as daemonPaths, n as ensureDaemonRunning, s as LOCAL_PROTOCOL_VERSION, u as encodeLine } from "./daemon-BSSZpzto.mjs";
8
- import { t as SandboxStream } from "./client-D8s9vY4p.mjs";
9
- import { t as runRawPtyPassthrough } from "./raw-pty-DAmb8uqt.mjs";
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-QFAEvYKe.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";
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";
6
+ import { i as billingBlockedOutcomeFromSendResponse } from "./billing-blocked-2wju4gC_.mjs";
7
+ import { t as PortalClient } from "./client-Cf7cvftg.mjs";
8
+ import "./daemon-DaUaCMwC.mjs";
9
+ import { t as SandboxStream } from "./client-Cn2af31H.mjs";
10
+ import { t as PortalDaemonClient } from "./daemon-client-CJxbCLOb.mjs";
11
+ import { t as runRawPtyPassthrough } from "./raw-pty-B6mAroiI.mjs";
10
12
  import * as os$1 from "node:os";
11
13
  import { homedir, platform, release, tmpdir } from "node:os";
12
14
  import path, { basename, extname, isAbsolute, join, win32 } from "node:path";
@@ -14,12 +16,12 @@ import { z } from "zod";
14
16
  import open from "open";
15
17
  import { constants } from "node:fs";
16
18
  import { execFile, spawn } from "node:child_process";
17
- import { createHash, randomUUID } from "node:crypto";
19
+ import { createHash } from "node:crypto";
18
20
  import { connect, createConnection } from "node:net";
19
21
  import { access, appendFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
20
22
  import { MarkdownRenderable, RenderableEvents, StyledText, SyntaxStyle, addDefaultParsers, createCliRenderer, decodePasteBytes, detectLinks, fg, link } from "@opentui/core";
21
23
  import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
22
- import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
24
+ import { Profiler, createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
23
25
  import { create } from "zustand";
24
26
  import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
25
27
  import fuzzysort from "fuzzysort";
@@ -48,6 +50,58 @@ function toastColor(variant) {
48
50
  }
49
51
  }
50
52
 
53
+ //#endregion
54
+ //#region src/chat/tui/early-input.ts
55
+ /**
56
+ * Buffering for keystrokes typed BEFORE the chat composer exists.
57
+ *
58
+ * When the CLI boots straight into a conversation (`skydive chat --new`, or a
59
+ * seeded `skydive import`), it must resolve the agent roster over the network
60
+ * before the chat screen — and its composer — can mount. During that window
61
+ * the splash screen is up and there is no textarea to receive input, so a user
62
+ * who starts typing immediately would lose every character.
63
+ *
64
+ * These helpers accumulate that early input as plain text; the chat screen
65
+ * drains it into the composer on mount so nothing typed during the load is
66
+ * dropped. This is deliberately a text buffer, not a queue of key events: the
67
+ * composer is a plain single-field editor here, so replaying the resulting
68
+ * string (via `composer.setText`) reproduces exactly what the user meant to
69
+ * type, and stays trivially unit-testable without an OpenTUI renderer.
70
+ */
71
+ /**
72
+ * Whether a key is an ordinary printable character we should buffer as text.
73
+ *
74
+ * Excludes anything with a modifier (ctrl/meta/super/hyper — those are
75
+ * shortcuts, not text) and any named non-character key (enter, arrows, tab,
76
+ * escape, function keys, …). A printable stroke arrives with its character in
77
+ * `sequence` and a `name` that is that same single character (or empty for
78
+ * some symbols), never a multi-letter key name like `return`/`backspace`.
79
+ */
80
+ function isTextKey(key) {
81
+ if (key.ctrl || key.meta || key.super || key.hyper) return false;
82
+ const seq = key.sequence;
83
+ if (seq.length !== 1) return false;
84
+ const code = seq.codePointAt(0);
85
+ if (code === void 0) return false;
86
+ if (code < 32 || code === 127) return false;
87
+ return true;
88
+ }
89
+ /** Whether a key is Backspace/Delete-left (erase one buffered character). */
90
+ function isBackspaceKey(key) {
91
+ return !key.ctrl && !key.meta && key.name === "backspace";
92
+ }
93
+ /**
94
+ * Fold one key into the early-input buffer. Printable characters append,
95
+ * backspace erases the last character, everything else (enter, arrows,
96
+ * shortcuts) is ignored — we only reconstruct the draft text, not caret moves
97
+ * or submission, which the real composer owns once it takes over.
98
+ */
99
+ function reduceEarlyInput(buffer, key) {
100
+ if (isTextKey(key)) return buffer + key.sequence;
101
+ if (isBackspaceKey(key)) return buffer.slice(0, -1);
102
+ return buffer;
103
+ }
104
+
51
105
  //#endregion
52
106
  //#region src/chat/state/store.ts
53
107
  const initialPortalUi = {
@@ -81,6 +135,7 @@ const useStore = create((set, get) => ({
81
135
  seedPrompt: null,
82
136
  autoGrantMachine: false,
83
137
  newConversationIntent: false,
138
+ earlyInput: "",
84
139
  goTo: (screen) => set({
85
140
  screen,
86
141
  chatTitle: initialChatTitle(screen)
@@ -138,7 +193,16 @@ const useStore = create((set, get) => ({
138
193
  prompt
139
194
  } })),
140
195
  showToast: (input) => set({ toast: createToast(input) }),
141
- dismissToast: (id) => set((state) => state.toast?.id === id ? { toast: null } : {})
196
+ dismissToast: (id) => set((state) => state.toast?.id === id ? { toast: null } : {}),
197
+ appendEarlyInput: (key) => set((state) => {
198
+ const next = reduceEarlyInput(state.earlyInput, key);
199
+ return next === state.earlyInput ? {} : { earlyInput: next };
200
+ }),
201
+ takeEarlyInput: () => {
202
+ const { earlyInput } = get();
203
+ if (earlyInput !== "") set({ earlyInput: "" });
204
+ return earlyInput;
205
+ }
142
206
  }));
143
207
 
144
208
  //#endregion
@@ -168,7 +232,7 @@ const useStore = create((set, get) => ({
168
232
  async function resolveInitialScreen({ rest, agentSelector, conversationId, newConversation }) {
169
233
  if (conversationId) {
170
234
  const [conversation, agents] = await Promise.all([rest.getConversation({ conversationId }).catch((err) => {
171
- 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.`);
235
+ 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.`);
172
236
  throw err;
173
237
  }), rest.listAgents({
174
238
  scope: "org",
@@ -231,152 +295,7 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
231
295
  }
232
296
 
233
297
  //#endregion
234
- //#region src/chat/portal/daemon-client.ts
235
- var PortalDaemonClient = class {
236
- sessionId = randomUUID();
237
- socket = null;
238
- disposed = false;
239
- reconnectTimer = null;
240
- lastBind = null;
241
- wantEnabled = false;
242
- pendingGrants = /* @__PURE__ */ new Set();
243
- grantedAgentIds = /* @__PURE__ */ new Set();
244
- constructor(opts) {
245
- this.opts = opts;
246
- }
247
- /** Spawn the daemon if needed and attach. Idempotent. */
248
- async start() {
249
- if (this.disposed) return;
250
- await ensureDaemonRunning(this.opts.appUrl);
251
- this.connect();
252
- }
253
- connect() {
254
- if (this.disposed) return;
255
- const { socketPath } = daemonPaths(this.opts.appUrl);
256
- const socket = connect(socketPath);
257
- socket.setEncoding("utf8");
258
- this.socket = socket;
259
- const parse = makeLineParser();
260
- socket.on("connect", () => {
261
- this.send({
262
- t: "hello",
263
- v: LOCAL_PROTOCOL_VERSION,
264
- sessionId: this.sessionId,
265
- token: this.opts.sessionToken
266
- });
267
- if (this.wantEnabled) this.send({ t: "enable" });
268
- if (this.lastBind) this.send({
269
- t: "bind",
270
- sessionId: this.sessionId,
271
- conversationId: this.lastBind.conversationId,
272
- cwd: this.lastBind.cwd
273
- });
274
- for (const agentId of this.pendingGrants) this.send({
275
- t: "grant",
276
- agentId
277
- });
278
- });
279
- socket.on("data", (chunk) => {
280
- for (const line of parse(chunk)) {
281
- const msg = parseDaemonMessage(line);
282
- if (msg) this.onMessage(msg);
283
- }
284
- });
285
- const onClose = () => {
286
- if (this.socket === socket) this.socket = null;
287
- this.scheduleReconnect();
288
- };
289
- socket.on("close", onClose);
290
- socket.on("error", onClose);
291
- }
292
- scheduleReconnect() {
293
- if (this.disposed || this.reconnectTimer) return;
294
- this.reconnectTimer = setTimeout(() => {
295
- this.reconnectTimer = null;
296
- this.start();
297
- }, 500);
298
- this.reconnectTimer.unref();
299
- }
300
- onMessage(msg) {
301
- if (msg.t === "state") {
302
- this.grantedAgentIds = new Set(msg.grantedAgentIds);
303
- this.opts.onState({
304
- status: msg.status,
305
- machineName: msg.machineName,
306
- friendlyName: msg.friendlyName,
307
- error: msg.error,
308
- grantedAgentIds: msg.grantedAgentIds
309
- });
310
- }
311
- }
312
- /** Whether this agent is currently authorized on the machine (last daemon state). */
313
- isGranted(agentId) {
314
- return this.grantedAgentIds.has(agentId);
315
- }
316
- /** Whether the user has turned machine sharing on from this CLI. */
317
- isEnabled() {
318
- return this.wantEnabled;
319
- }
320
- send(msg) {
321
- if (this.socket?.writable) this.socket.write(encodeLine(msg));
322
- }
323
- /** Turn machine sharing on (the daemon connects the portal). */
324
- enable() {
325
- this.wantEnabled = true;
326
- this.send({ t: "enable" });
327
- }
328
- /** Turn machine sharing off. */
329
- disable() {
330
- this.wantEnabled = false;
331
- this.send({ t: "disable" });
332
- }
333
- /** Register / update this conversation's working directory for exec routing. */
334
- bind(conversationId, cwd) {
335
- this.lastBind = {
336
- conversationId,
337
- cwd
338
- };
339
- this.send({
340
- t: "bind",
341
- sessionId: this.sessionId,
342
- conversationId,
343
- cwd
344
- });
345
- }
346
- /**
347
- * Authorize one agent to run commands on this machine. Resolves once the
348
- * request is sent to the daemon (the daemon performs the grant and pushes the
349
- * updated state); kept async so it's a drop-in for the old in-process client's
350
- * awaited `grantAgent`.
351
- */
352
- grantAgent(agentId) {
353
- this.pendingGrants.add(agentId);
354
- this.send({
355
- t: "grant",
356
- agentId
357
- });
358
- return Promise.resolve();
359
- }
360
- /** Detach this CLI. The daemon keeps running for other clients. */
361
- dispose() {
362
- this.disposed = true;
363
- if (this.reconnectTimer) {
364
- clearTimeout(this.reconnectTimer);
365
- this.reconnectTimer = null;
366
- }
367
- this.send({
368
- t: "bye",
369
- sessionId: this.sessionId
370
- });
371
- try {
372
- this.socket?.destroy();
373
- } catch (_error) {}
374
- this.socket = null;
375
- }
376
- };
377
-
378
- //#endregion
379
- //#region src/chat/portal/create-portal-client.ts
298
+ //#region ../portal-daemon/src/create-portal-client.ts
380
299
  /**
381
300
  * Escape hatch: setting `SKYDIVE_NO_DAEMON` (to any non-empty value)
382
301
  * makes the CLI bypass the portal daemon and open the portal connection
@@ -681,9 +600,9 @@ function createCmuxWorkspaceTitleAdapter({ socket, workspaceId }) {
681
600
  //#endregion
682
601
  //#region src/chat/agent-host/adapters/cmux/sidebar-pr.ts
683
602
  /** Neutral gray — deliberately not green/purple; we don't render PR state. */
684
- const PILL_COLOR = "#8b949e";
685
- const PILL_ICON = "git-pull-request";
686
- const PILL_PRIORITY = 90;
603
+ const PILL_COLOR$1 = "#8b949e";
604
+ const PILL_ICON$1 = "git-pull-request";
605
+ const PILL_PRIORITY$1 = 90;
687
606
  /**
688
607
  * The cmux sidebar status key for a PR, derived from its repo-qualified
689
608
  * identity (`owner/repo#number`) so PRs from different repos never collide on a
@@ -712,7 +631,7 @@ function createCmuxSidebarPrAdapter({ socket, workspaceId }) {
712
631
  const runSet = async (pr) => {
713
632
  if (applied.has(pr.key)) return;
714
633
  applied.add(pr.key);
715
- const line = `set_status ${pillKey(pr.key)} ${quote(`PR #${pr.number}`)} --icon=${PILL_ICON} --color=${PILL_COLOR} --priority=${PILL_PRIORITY} --url=${pr.url} --tab=${workspaceId}`;
634
+ const line = `set_status ${pillKey(pr.key)} ${quote(`PR #${pr.number}`)} --icon=${PILL_ICON$1} --color=${PILL_COLOR$1} --priority=${PILL_PRIORITY$1} --url=${pr.url} --tab=${workspaceId}`;
716
635
  if (!await socket.command(line)) applied.delete(pr.key);
717
636
  };
718
637
  const runClear = async (prKey) => {
@@ -733,6 +652,54 @@ function createCmuxSidebarPrAdapter({ socket, workspaceId }) {
733
652
  };
734
653
  }
735
654
 
655
+ //#endregion
656
+ //#region src/chat/agent-host/adapters/cmux/run-status.ts
657
+ /**
658
+ * Our sidebar status key. One stable key means every run reuses the same pill
659
+ * rather than leaking a new one per run, and teardown clears exactly what we
660
+ * set. cmux keys are space-split tokens, so it must be a single bare token.
661
+ */
662
+ const PILL_KEY = "skydive-run";
663
+ /** The label shown on the pill while the agent is working. */
664
+ const PILL_LABEL = "Running";
665
+ /**
666
+ * `sync` is a cmux-rendered named glyph (spinning-arrows), the closest
667
+ * "in progress" affordance to the disabled loading spinner.
668
+ */
669
+ const PILL_ICON = "sync";
670
+ /** A distinct blue so "Running" reads apart from the gray PR pills. */
671
+ const PILL_COLOR = "#3b82f6";
672
+ /**
673
+ * Above the PR pills (priority 90) so the live "Running" signal sorts to the
674
+ * top of the workspace's status line while a run is in flight.
675
+ */
676
+ const PILL_PRIORITY = 100;
677
+ /** cmux renders a spinner while its `working` (working) state is on. */
678
+ function isWorking(state) {
679
+ return state === "working";
680
+ }
681
+ function createCmuxRunStatusAdapter({ socket, workspaceId }) {
682
+ let applied;
683
+ let chain = Promise.resolve();
684
+ const enqueue = (step) => {
685
+ chain = chain.then(step).catch((_error) => {});
686
+ };
687
+ const runSet = async (on) => {
688
+ if (applied === on) return;
689
+ const line = on ? `set_status ${PILL_KEY} "${PILL_LABEL}" --icon=${PILL_ICON} --color=${PILL_COLOR} --priority=${PILL_PRIORITY} --tab=${workspaceId}` : `clear_status ${PILL_KEY} --tab=${workspaceId}`;
690
+ if (await socket.command(line)) applied = on;
691
+ };
692
+ return {
693
+ transition({ state }) {
694
+ const on = isWorking(state);
695
+ enqueue(() => runSet(on));
696
+ },
697
+ dispose() {
698
+ enqueue(() => runSet(false));
699
+ }
700
+ };
701
+ }
702
+
736
703
  //#endregion
737
704
  //#region src/chat/agent-host/adapters/cmux/socket.ts
738
705
  const cmuxRpcResponseSchema = z.object({
@@ -785,6 +752,9 @@ function createCmuxSocket({ socketPath, timeoutMs = RPC_TIMEOUT_MS }) {
785
752
  `${++seq}`;
786
753
  return roundTrip(`${line}\n`, (reply) => reply.startsWith("OK") || reply === "OK", false);
787
754
  },
755
+ commandRaw(line) {
756
+ return roundTrip(`${line}\n`, (reply) => reply, null);
757
+ },
788
758
  request(method, params) {
789
759
  return new Promise((resolve) => {
790
760
  let settled = false;
@@ -1011,6 +981,9 @@ function createAgentHost({ renderer, notifications }) {
1011
981
  }), createCmuxSidebarPrAdapter({
1012
982
  socket,
1013
983
  workspaceId: cmux.workspaceId
984
+ }), createCmuxRunStatusAdapter({
985
+ socket,
986
+ workspaceId: cmux.workspaceId
1014
987
  }));
1015
988
  }
1016
989
  const herdr = createHerdrReporter();
@@ -1418,7 +1391,7 @@ function StatusBar({ rightHint }) {
1418
1391
  const portal = useStore((s) => s.portal);
1419
1392
  const left = headerLeftLabel(appUrl, workspaceName);
1420
1393
  const middle = screenLabel(screen, chatTitle);
1421
- const share = portalIndicator(portal);
1394
+ const share = portalIndicator(portal, screenAgentId(screen));
1422
1395
  return /* @__PURE__ */ jsxs("box", {
1423
1396
  style: {
1424
1397
  flexDirection: "row",
@@ -1450,23 +1423,40 @@ function StatusBar({ rightHint }) {
1450
1423
  ]
1451
1424
  });
1452
1425
  }
1453
- function portalIndicator(portal) {
1426
+ /**
1427
+ * The share indicator, which answers exactly one question: can the agent on
1428
+ * screen run commands on this machine right now? Not "is the portal
1429
+ * connected" — the connection is a per-machine fact, access is per agent, and
1430
+ * a badge that tracks the former tells a user who just declined that the agent
1431
+ * is in.
1432
+ *
1433
+ * So it is scoped to the current agent and shown only once that agent holds a
1434
+ * grant. Everything else — no agent on screen, portal off, connected but this
1435
+ * agent ungranted, still connecting — renders as the off state (nothing), which
1436
+ * is the truthful reading: this agent cannot reach the machine.
1437
+ *
1438
+ * The one exception is an error, and only for an agent that IS granted: its
1439
+ * access is supposed to be live and isn't, which the user needs to see.
1440
+ */
1441
+ function portalIndicator(portal, agentId) {
1442
+ if (!agentId || !portal.grantedAgentIds.includes(agentId)) return null;
1454
1443
  switch (portal.status) {
1455
- case "off": return null;
1456
- case "connecting": return {
1457
- text: "◌ sharing…",
1458
- color: theme.warning
1459
- };
1444
+ case "off":
1445
+ case "connecting": return null;
1460
1446
  case "connected": return {
1461
- text: "● machine shared",
1447
+ text: "● machine connected",
1462
1448
  color: theme.success
1463
1449
  };
1464
1450
  case "error": return {
1465
- text: "● share error",
1451
+ text: "● machine error",
1466
1452
  color: theme.error
1467
1453
  };
1468
1454
  }
1469
1455
  }
1456
+ /** The agent whose access the indicator speaks for, if a screen names one. */
1457
+ function screenAgentId(screen) {
1458
+ return screen.kind === "chat" || screen.kind === "conversation-picker" ? screen.agent.id : null;
1459
+ }
1470
1460
  /**
1471
1461
  * The per-screen label, split into its parts: the screen's own `title` and,
1472
1462
  * when the screen belongs to an agent, that `agentName`. Kept structured so the
@@ -1842,6 +1832,9 @@ function FilterableAgentList({ agents, scope, onPick }) {
1842
1832
  fg: theme.muted,
1843
1833
  children: "search "
1844
1834
  }), /* @__PURE__ */ jsx("input", {
1835
+ textColor: theme.fg,
1836
+ focusedTextColor: theme.fg,
1837
+ placeholderColor: theme.dim,
1845
1838
  placeholder: "type to filter…",
1846
1839
  focused: true,
1847
1840
  width: Math.max(10, width - 12),
@@ -2040,6 +2033,9 @@ function AgentCreateScreen() {
2040
2033
  flexShrink: 0
2041
2034
  },
2042
2035
  children: /* @__PURE__ */ jsx("input", {
2036
+ textColor: theme.fg,
2037
+ focusedTextColor: theme.fg,
2038
+ placeholderColor: theme.dim,
2043
2039
  placeholder: "Agent name",
2044
2040
  onInput: (value) => {
2045
2041
  setName(value);
@@ -2742,6 +2738,9 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
2742
2738
  fg: theme.muted,
2743
2739
  children: "search "
2744
2740
  }), /* @__PURE__ */ jsx("input", {
2741
+ textColor: theme.fg,
2742
+ focusedTextColor: theme.fg,
2743
+ placeholderColor: theme.dim,
2745
2744
  placeholder: "type to filter…",
2746
2745
  focused: confirmId === null,
2747
2746
  width: Math.max(10, width - 12),
@@ -4160,6 +4159,292 @@ function parseMatches(output) {
4160
4159
  return null;
4161
4160
  }
4162
4161
 
4162
+ //#endregion
4163
+ //#region src/chat/tui/chat/conversation-context.tsx
4164
+ const ConversationContext = createContext({
4165
+ rest: null,
4166
+ conversationId: null
4167
+ });
4168
+ function useConversationContext() {
4169
+ return useContext(ConversationContext);
4170
+ }
4171
+
4172
+ //#endregion
4173
+ //#region src/chat/tui/spinner-frames.ts
4174
+ /** Braille dot-cycle frames — the classic smooth terminal spinner. */
4175
+ const SPINNER_FRAMES = [
4176
+ "⠋",
4177
+ "⠙",
4178
+ "⠹",
4179
+ "⠸",
4180
+ "⠼",
4181
+ "⠴",
4182
+ "⠦",
4183
+ "⠧",
4184
+ "⠇",
4185
+ "⠏"
4186
+ ];
4187
+ /** Milliseconds between frames. ~12.5fps reads as smooth without thrashing. */
4188
+ const SPINNER_INTERVAL_MS = 80;
4189
+ /** Frame for a given tick count. Wraps at the end of the cycle; negative-safe. */
4190
+ function spinnerFrame(tick) {
4191
+ const n = SPINNER_FRAMES.length;
4192
+ return SPINNER_FRAMES[(Math.trunc(tick) % n + n) % n] ?? SPINNER_FRAMES[0];
4193
+ }
4194
+
4195
+ //#endregion
4196
+ //#region src/chat/tui/spinner.tsx
4197
+ /**
4198
+ * An animated spinner glyph. Advances one frame every SPINNER_INTERVAL_MS via
4199
+ * a self-owned interval that's cleared on unmount, so the animation runs only
4200
+ * while the component is mounted. Returns a <span>, so render it inside a
4201
+ * <text> (matching how the rest of the TUI composes inline glyphs).
4202
+ */
4203
+ function Spinner({ color }) {
4204
+ const [tick, setTick] = useState(0);
4205
+ useEffect(() => {
4206
+ const id = setInterval(() => setTick((t) => t + 1), SPINNER_INTERVAL_MS);
4207
+ return () => clearInterval(id);
4208
+ }, []);
4209
+ return /* @__PURE__ */ jsx("span", {
4210
+ fg: color,
4211
+ children: spinnerFrame(tick)
4212
+ });
4213
+ }
4214
+
4215
+ //#endregion
4216
+ //#region src/chat/tui/chat/subagents.ts
4217
+ /** A run the platform hasn't settled yet — still queued or mid-run. */
4218
+ function isLiveStatus(status) {
4219
+ return status === "queued" || status === "running";
4220
+ }
4221
+ function toTaskStatus(raw) {
4222
+ switch (raw) {
4223
+ case "queued":
4224
+ case "running":
4225
+ case "completed":
4226
+ case "failed":
4227
+ case "canceled": return raw;
4228
+ default: return "queued";
4229
+ }
4230
+ }
4231
+ const FALLBACK_TITLE = "Subagent task";
4232
+ /**
4233
+ * Each delegated task's display title from the `subagent` call's input, in
4234
+ * call order — the short name the tool requires per task. A malformed entry
4235
+ * yields a fallback label rather than being dropped, so the row count always
4236
+ * matches the fan-out width. Mirrors the web client's `subagentTaskTitles`.
4237
+ */
4238
+ function subagentTaskTitles(input) {
4239
+ if (!isRecord(input)) return [];
4240
+ const tasks = input["tasks"];
4241
+ if (!Array.isArray(tasks)) return [];
4242
+ return tasks.map((task) => {
4243
+ if (!isRecord(task)) return FALLBACK_TITLE;
4244
+ const title = task["title"];
4245
+ return typeof title === "string" && title.trim() ? title.trim() : FALLBACK_TITLE;
4246
+ });
4247
+ }
4248
+ 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;
4249
+ /**
4250
+ * The ids of the runs a `subagent` call queued, read back out of its own
4251
+ * output. The call returns the moment the runs exist, so the ids are there
4252
+ * long before any child finishes — they are what ties this card to the task
4253
+ * rows the status poll returns, and their order is the tasks' call order.
4254
+ */
4255
+ function queuedSubagentTaskIds(output) {
4256
+ const text = flattenOutputText(output);
4257
+ if (!text) return [];
4258
+ return text.split("\n").map((line) => line.match(QUEUED_TASK_ID_LINE)?.[1] ?? null).filter((id) => id !== null);
4259
+ }
4260
+ /**
4261
+ * Tool output reaches the TUI as `unknown`: the raw string live, possibly
4262
+ * wrapped in content parts or arrays when loaded from history. Walk it for
4263
+ * string leaves, newline-joined — the PR scanner's `stringifyToolOutput`
4264
+ * shape, bounded small because the id list sits at the top of the output.
4265
+ */
4266
+ function flattenOutputText(value) {
4267
+ const MAX_LEN = 2e4;
4268
+ const parts = [];
4269
+ let total = 0;
4270
+ const visit = (v, depth) => {
4271
+ if (total >= MAX_LEN || depth > 4) return;
4272
+ if (typeof v === "string") {
4273
+ parts.push(v);
4274
+ total += v.length;
4275
+ return;
4276
+ }
4277
+ if (Array.isArray(v)) {
4278
+ for (const item of v) visit(item, depth + 1);
4279
+ return;
4280
+ }
4281
+ if (isRecord(v)) for (const item of Object.values(v)) visit(item, depth + 1);
4282
+ };
4283
+ visit(value, 0);
4284
+ return parts.join("\n");
4285
+ }
4286
+ /**
4287
+ * The card's rows: one per queued run id (call order), each carrying the
4288
+ * freshest known state. A task the status fetch hasn't returned yet falls
4289
+ * back to the input's title and reads as queued; a fetched row's own title
4290
+ * wins over the input's (the server's copy is the one the child actually
4291
+ * ran under). Before the ids exist — the call is still streaming — rows come
4292
+ * from the input titles alone, so the card never renders empty mid-turn.
4293
+ */
4294
+ function subagentRows({ titles, ids, fetched }) {
4295
+ if (ids.length === 0) return titles.map((title, index) => ({
4296
+ id: `queued:${index}`,
4297
+ title,
4298
+ status: "queued",
4299
+ createdAt: null,
4300
+ completedAt: null
4301
+ }));
4302
+ return ids.map((id, index) => {
4303
+ const row = fetched?.get(id) ?? null;
4304
+ return {
4305
+ id,
4306
+ title: row?.title ?? titles[index] ?? FALLBACK_TITLE,
4307
+ status: row ? toTaskStatus(row.status) : "queued",
4308
+ createdAt: row?.createdAt ?? null,
4309
+ completedAt: row?.completedAt ?? null
4310
+ };
4311
+ });
4312
+ }
4313
+
4314
+ //#endregion
4315
+ //#region src/chat/tui/chunks/tool-subagent.tsx
4316
+ /** How often a live batch re-reads its tasks' status. */
4317
+ const POLL_INTERVAL_MS = 3e3;
4318
+ function rowGlyph(status) {
4319
+ switch (status) {
4320
+ case "completed": return {
4321
+ ch: "✔",
4322
+ fg: theme.success
4323
+ };
4324
+ case "failed":
4325
+ case "canceled": return {
4326
+ ch: "✗",
4327
+ fg: theme.error
4328
+ };
4329
+ case "running": return {
4330
+ ch: "▶",
4331
+ fg: theme.accent
4332
+ };
4333
+ case "queued": return {
4334
+ ch: "○",
4335
+ fg: theme.dim
4336
+ };
4337
+ }
4338
+ }
4339
+ function rowColor$1(status) {
4340
+ switch (status) {
4341
+ case "running": return theme.fg;
4342
+ case "completed": return theme.dim;
4343
+ default: return theme.muted;
4344
+ }
4345
+ }
4346
+ function headerLabel(rows) {
4347
+ const subject = rows.length === 1 ? "Subagent" : `${rows.length} subagents`;
4348
+ return rows.some((row) => isLiveStatus(row.status)) ? `${subject} running` : subject;
4349
+ }
4350
+ /**
4351
+ * The transcript block for a `subagent` tool call — the TUI analog of the web
4352
+ * chat's fan-out card. One row per delegated run: status glyph, the short
4353
+ * title the delegating agent wrote, ticking over live as children settle.
4354
+ *
4355
+ * Rows are seeded from the call itself (titles from its input, task ids from
4356
+ * its own output), so the card renders the moment the call does. Status then
4357
+ * comes from the server's task rows, polled while any run is live — the
4358
+ * delegated runs outlive the call that spawned them, so the transcript stream
4359
+ * carries nothing more about them. The poll stops the moment the batch
4360
+ * settles; reopening an old conversation does one fetch and stops there. A
4361
+ * failed poll is skipped silently (the next tick retries; stale rows beat an
4362
+ * error row in the scrollback).
4363
+ */
4364
+ function ToolSubagent({ item }) {
4365
+ const { rest, conversationId } = useConversationContext();
4366
+ const titles = subagentTaskTitles(item.input);
4367
+ const ids = item.output?.kind === "ok" ? queuedSubagentTaskIds(item.output.value) : [];
4368
+ const idsKey = ids.join(",");
4369
+ const [fetched, setFetched] = useState(null);
4370
+ const rows = subagentRows({
4371
+ titles,
4372
+ ids,
4373
+ fetched
4374
+ });
4375
+ const live = rows.length === 0 || rows.some((row) => isLiveStatus(row.status));
4376
+ useEffect(() => {
4377
+ if (!rest || !conversationId || idsKey.length === 0 || !live) return;
4378
+ let cancelled = false;
4379
+ let timer = null;
4380
+ const tick = async () => {
4381
+ try {
4382
+ const tasks = await rest.listSubagentTasks({ conversationId });
4383
+ if (cancelled) return;
4384
+ setFetched(tasks);
4385
+ } catch (_error) {}
4386
+ if (!cancelled) timer = setTimeout(tick, POLL_INTERVAL_MS);
4387
+ };
4388
+ tick();
4389
+ return () => {
4390
+ cancelled = true;
4391
+ if (timer !== null) clearTimeout(timer);
4392
+ };
4393
+ }, [
4394
+ rest,
4395
+ conversationId,
4396
+ idsKey,
4397
+ live
4398
+ ]);
4399
+ if (rows.length === 0) return /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
4400
+ fg: theme.warning,
4401
+ children: "◐ "
4402
+ }), /* @__PURE__ */ jsx("span", {
4403
+ fg: theme.tool,
4404
+ children: "Delegating to subagents…"
4405
+ })] });
4406
+ return /* @__PURE__ */ jsxs("box", {
4407
+ style: { flexDirection: "column" },
4408
+ children: [/* @__PURE__ */ jsxs("text", { children: [
4409
+ live ? /* @__PURE__ */ jsx(Spinner, { color: theme.accent }) : /* @__PURE__ */ jsx("span", {
4410
+ fg: theme.success,
4411
+ children: "✓"
4412
+ }),
4413
+ /* @__PURE__ */ jsxs("span", {
4414
+ fg: theme.tool,
4415
+ children: [" ", /* @__PURE__ */ jsx("b", { children: headerLabel(rows) })]
4416
+ }),
4417
+ /* @__PURE__ */ jsxs("span", {
4418
+ fg: theme.dim,
4419
+ children: [
4420
+ " ",
4421
+ rows.filter((row) => row.status === "completed").length,
4422
+ "/",
4423
+ rows.length
4424
+ ]
4425
+ })
4426
+ ] }), /* @__PURE__ */ jsx("box", {
4427
+ style: {
4428
+ flexDirection: "column",
4429
+ paddingLeft: 2
4430
+ },
4431
+ children: rows.map((row) => {
4432
+ const glyph = rowGlyph(row.status);
4433
+ return /* @__PURE__ */ jsxs("box", {
4434
+ style: { flexDirection: "row" },
4435
+ children: [/* @__PURE__ */ jsxs("text", {
4436
+ fg: glyph.fg,
4437
+ children: [glyph.ch, " "]
4438
+ }), /* @__PURE__ */ jsx("text", {
4439
+ fg: rowColor$1(row.status),
4440
+ children: row.title
4441
+ })]
4442
+ }, row.id);
4443
+ })
4444
+ })]
4445
+ });
4446
+ }
4447
+
4163
4448
  //#endregion
4164
4449
  //#region src/chat/tui/chunks/shell-local.tsx
4165
4450
  /**
@@ -4383,6 +4668,7 @@ function ToolItem({ item }) {
4383
4668
  case "read": return /* @__PURE__ */ jsx(ToolRead, { item });
4384
4669
  case "grep":
4385
4670
  case "glob": return /* @__PURE__ */ jsx(ToolGrep, { item });
4671
+ case "subagent": return /* @__PURE__ */ jsx(ToolSubagent, { item });
4386
4672
  default: return /* @__PURE__ */ jsx(ToolGeneric, { item });
4387
4673
  }
4388
4674
  }
@@ -4552,6 +4838,270 @@ function clampItem(item) {
4552
4838
  }
4553
4839
  }
4554
4840
 
4841
+ //#endregion
4842
+ //#region src/chat/tui/chat/fix-json.ts
4843
+ /**
4844
+ * Implemented as a scanner with additional fixing
4845
+ * that performs a single linear time scan pass over the partial JSON.
4846
+ *
4847
+ * The states should ideally match relevant states from the JSON spec:
4848
+ * https://www.json.org/json-en.html
4849
+ *
4850
+ * Please note that invalid JSON is not considered/covered, because it
4851
+ * is assumed that the resulting JSON will be processed by a standard
4852
+ * JSON parser that will detect any invalid JSON.
4853
+ */
4854
+ function fixJson(input) {
4855
+ const stack = ["ROOT"];
4856
+ let lastValidIndex = -1;
4857
+ let literalStart = null;
4858
+ function processValueStart(char, i, swapState) {
4859
+ switch (char) {
4860
+ case "\"":
4861
+ lastValidIndex = i;
4862
+ stack.pop();
4863
+ stack.push(swapState);
4864
+ stack.push("INSIDE_STRING");
4865
+ break;
4866
+ case "f":
4867
+ case "t":
4868
+ case "n":
4869
+ lastValidIndex = i;
4870
+ literalStart = i;
4871
+ stack.pop();
4872
+ stack.push(swapState);
4873
+ stack.push("INSIDE_LITERAL");
4874
+ break;
4875
+ case "-":
4876
+ stack.pop();
4877
+ stack.push(swapState);
4878
+ stack.push("INSIDE_NUMBER");
4879
+ break;
4880
+ case "0":
4881
+ case "1":
4882
+ case "2":
4883
+ case "3":
4884
+ case "4":
4885
+ case "5":
4886
+ case "6":
4887
+ case "7":
4888
+ case "8":
4889
+ case "9":
4890
+ lastValidIndex = i;
4891
+ stack.pop();
4892
+ stack.push(swapState);
4893
+ stack.push("INSIDE_NUMBER");
4894
+ break;
4895
+ case "{":
4896
+ lastValidIndex = i;
4897
+ stack.pop();
4898
+ stack.push(swapState);
4899
+ stack.push("INSIDE_OBJECT_START");
4900
+ break;
4901
+ case "[":
4902
+ lastValidIndex = i;
4903
+ stack.pop();
4904
+ stack.push(swapState);
4905
+ stack.push("INSIDE_ARRAY_START");
4906
+ break;
4907
+ }
4908
+ }
4909
+ function processAfterObjectValue(char, i) {
4910
+ switch (char) {
4911
+ case ",":
4912
+ stack.pop();
4913
+ stack.push("INSIDE_OBJECT_AFTER_COMMA");
4914
+ break;
4915
+ case "}":
4916
+ lastValidIndex = i;
4917
+ stack.pop();
4918
+ break;
4919
+ }
4920
+ }
4921
+ function processAfterArrayValue(char, i) {
4922
+ switch (char) {
4923
+ case ",":
4924
+ stack.pop();
4925
+ stack.push("INSIDE_ARRAY_AFTER_COMMA");
4926
+ break;
4927
+ case "]":
4928
+ lastValidIndex = i;
4929
+ stack.pop();
4930
+ break;
4931
+ }
4932
+ }
4933
+ for (let i = 0; i < input.length; i++) {
4934
+ const char = input[i];
4935
+ if (!char) continue;
4936
+ switch (stack[stack.length - 1]) {
4937
+ case "ROOT":
4938
+ processValueStart(char, i, "FINISH");
4939
+ break;
4940
+ case "INSIDE_OBJECT_START":
4941
+ switch (char) {
4942
+ case "\"":
4943
+ stack.pop();
4944
+ stack.push("INSIDE_OBJECT_KEY");
4945
+ break;
4946
+ case "}":
4947
+ lastValidIndex = i;
4948
+ stack.pop();
4949
+ break;
4950
+ }
4951
+ break;
4952
+ case "INSIDE_OBJECT_AFTER_COMMA":
4953
+ switch (char) {
4954
+ case "\"":
4955
+ stack.pop();
4956
+ stack.push("INSIDE_OBJECT_KEY");
4957
+ break;
4958
+ }
4959
+ break;
4960
+ case "INSIDE_OBJECT_KEY":
4961
+ switch (char) {
4962
+ case "\"":
4963
+ stack.pop();
4964
+ stack.push("INSIDE_OBJECT_AFTER_KEY");
4965
+ break;
4966
+ }
4967
+ break;
4968
+ case "INSIDE_OBJECT_AFTER_KEY":
4969
+ switch (char) {
4970
+ case ":":
4971
+ stack.pop();
4972
+ stack.push("INSIDE_OBJECT_BEFORE_VALUE");
4973
+ break;
4974
+ }
4975
+ break;
4976
+ case "INSIDE_OBJECT_BEFORE_VALUE":
4977
+ processValueStart(char, i, "INSIDE_OBJECT_AFTER_VALUE");
4978
+ break;
4979
+ case "INSIDE_OBJECT_AFTER_VALUE":
4980
+ processAfterObjectValue(char, i);
4981
+ break;
4982
+ case "INSIDE_STRING":
4983
+ switch (char) {
4984
+ case "\"":
4985
+ stack.pop();
4986
+ lastValidIndex = i;
4987
+ break;
4988
+ case "\\":
4989
+ stack.push("INSIDE_STRING_ESCAPE");
4990
+ break;
4991
+ default: lastValidIndex = i;
4992
+ }
4993
+ break;
4994
+ case "INSIDE_ARRAY_START":
4995
+ switch (char) {
4996
+ case "]":
4997
+ lastValidIndex = i;
4998
+ stack.pop();
4999
+ break;
5000
+ default:
5001
+ lastValidIndex = i;
5002
+ processValueStart(char, i, "INSIDE_ARRAY_AFTER_VALUE");
5003
+ break;
5004
+ }
5005
+ break;
5006
+ case "INSIDE_ARRAY_AFTER_VALUE":
5007
+ switch (char) {
5008
+ case ",":
5009
+ stack.pop();
5010
+ stack.push("INSIDE_ARRAY_AFTER_COMMA");
5011
+ break;
5012
+ case "]":
5013
+ lastValidIndex = i;
5014
+ stack.pop();
5015
+ break;
5016
+ default:
5017
+ lastValidIndex = i;
5018
+ break;
5019
+ }
5020
+ break;
5021
+ case "INSIDE_ARRAY_AFTER_COMMA":
5022
+ processValueStart(char, i, "INSIDE_ARRAY_AFTER_VALUE");
5023
+ break;
5024
+ case "INSIDE_STRING_ESCAPE":
5025
+ stack.pop();
5026
+ lastValidIndex = i;
5027
+ break;
5028
+ case "INSIDE_NUMBER":
5029
+ switch (char) {
5030
+ case "0":
5031
+ case "1":
5032
+ case "2":
5033
+ case "3":
5034
+ case "4":
5035
+ case "5":
5036
+ case "6":
5037
+ case "7":
5038
+ case "8":
5039
+ case "9":
5040
+ lastValidIndex = i;
5041
+ break;
5042
+ case "e":
5043
+ case "E":
5044
+ case "-":
5045
+ case ".": break;
5046
+ case ",":
5047
+ stack.pop();
5048
+ if (stack[stack.length - 1] === "INSIDE_ARRAY_AFTER_VALUE") processAfterArrayValue(char, i);
5049
+ if (stack[stack.length - 1] === "INSIDE_OBJECT_AFTER_VALUE") processAfterObjectValue(char, i);
5050
+ break;
5051
+ case "}":
5052
+ stack.pop();
5053
+ if (stack[stack.length - 1] === "INSIDE_OBJECT_AFTER_VALUE") processAfterObjectValue(char, i);
5054
+ break;
5055
+ case "]":
5056
+ stack.pop();
5057
+ if (stack[stack.length - 1] === "INSIDE_ARRAY_AFTER_VALUE") processAfterArrayValue(char, i);
5058
+ break;
5059
+ default:
5060
+ stack.pop();
5061
+ break;
5062
+ }
5063
+ break;
5064
+ case "INSIDE_LITERAL": {
5065
+ if (!literalStart) continue;
5066
+ const partialLiteral = input.substring(literalStart, i + 1);
5067
+ if (!"false".startsWith(partialLiteral) && !"true".startsWith(partialLiteral) && !"null".startsWith(partialLiteral)) {
5068
+ stack.pop();
5069
+ if (stack[stack.length - 1] === "INSIDE_OBJECT_AFTER_VALUE") processAfterObjectValue(char, i);
5070
+ else if (stack[stack.length - 1] === "INSIDE_ARRAY_AFTER_VALUE") processAfterArrayValue(char, i);
5071
+ } else lastValidIndex = i;
5072
+ break;
5073
+ }
5074
+ }
5075
+ }
5076
+ let result = input.slice(0, lastValidIndex + 1);
5077
+ for (let i = stack.length - 1; i >= 0; i--) switch (stack[i]) {
5078
+ case "INSIDE_STRING":
5079
+ result += "\"";
5080
+ break;
5081
+ case "INSIDE_OBJECT_KEY":
5082
+ case "INSIDE_OBJECT_AFTER_KEY":
5083
+ case "INSIDE_OBJECT_AFTER_COMMA":
5084
+ case "INSIDE_OBJECT_START":
5085
+ case "INSIDE_OBJECT_BEFORE_VALUE":
5086
+ case "INSIDE_OBJECT_AFTER_VALUE":
5087
+ result += "}";
5088
+ break;
5089
+ case "INSIDE_ARRAY_START":
5090
+ case "INSIDE_ARRAY_AFTER_COMMA":
5091
+ case "INSIDE_ARRAY_AFTER_VALUE":
5092
+ result += "]";
5093
+ break;
5094
+ case "INSIDE_LITERAL": {
5095
+ if (!literalStart) continue;
5096
+ const partialLiteral = input.substring(literalStart, input.length);
5097
+ if ("true".startsWith(partialLiteral)) result += "true".slice(partialLiteral.length);
5098
+ else if ("false".startsWith(partialLiteral)) result += "false".slice(partialLiteral.length);
5099
+ else if ("null".startsWith(partialLiteral)) result += "null".slice(partialLiteral.length);
5100
+ }
5101
+ }
5102
+ return result;
5103
+ }
5104
+
4555
5105
  //#endregion
4556
5106
  //#region src/chat/tui/chat/reducer.ts
4557
5107
  function str(chunk, key) {
@@ -4559,6 +5109,24 @@ function str(chunk, key) {
4559
5109
  return typeof value === "string" ? value : null;
4560
5110
  }
4561
5111
  /**
5112
+ * Best-effort parse of a tool call's partial arguments while they stream.
5113
+ * `fixJson` (the vercel/ai scanner) closes off the truncated JSON so a
5114
+ * half-arrived `{"command":"npm ins` parses to `{ command: 'npm ins' }`,
5115
+ * letting the renderers show the structured input filling in live rather
5116
+ * than a raw JSON fragment. Returns null until enough has arrived to parse
5117
+ * (or when the text isn't an object/array), so callers keep their raw
5118
+ * `inputText` fallback for the first few characters.
5119
+ */
5120
+ function parsePartialInput(text) {
5121
+ const trimmed = text.trimStart();
5122
+ if (trimmed[0] !== "{" && trimmed[0] !== "[") return null;
5123
+ try {
5124
+ return JSON.parse(fixJson(text));
5125
+ } catch (_error) {
5126
+ return null;
5127
+ }
5128
+ }
5129
+ /**
4562
5130
  * `agentName` is the display name of the agent producing this run's stream.
4563
5131
  * It's stamped onto each assistant-origin item so a multi-agent conversation
4564
5132
  * can attribute live turns; pass null in a solo thread to leave items
@@ -4626,10 +5194,17 @@ function applyChunk(items, chunk, agentName = null) {
4626
5194
  const toolCallId = str(chunk, "toolCallId");
4627
5195
  const delta = str(chunk, "inputTextDelta") ?? str(chunk, "delta") ?? "";
4628
5196
  if (!toolCallId) return [...items];
4629
- return items.map((m) => m.kind === "tool" && m.id === toolCallId ? {
4630
- ...m,
4631
- inputText: m.inputText + delta
4632
- } : m);
5197
+ return items.map((m) => {
5198
+ if (m.kind !== "tool" || m.id !== toolCallId) return m;
5199
+ const inputText = m.inputText + delta;
5200
+ const parsed = parsePartialInput(inputText);
5201
+ const input = parsed == null ? null : clampPayload(parsed);
5202
+ return {
5203
+ ...m,
5204
+ inputText,
5205
+ input
5206
+ };
5207
+ });
4633
5208
  }
4634
5209
  case "tool-input-available": {
4635
5210
  const toolCallId = str(chunk, "toolCallId");
@@ -5245,13 +5820,14 @@ const MAX_PR_CHIPS = 3;
5245
5820
  *
5246
5821
  * Caps at {@link MAX_PR_CHIPS} shown; any beyond that collapse into a trailing
5247
5822
  * `+N` so the footer row can't be flooded by a run that links many PRs. Shows
5248
- * the most recent (last-seen) PRs, since those are what's in play.
5823
+ * the most recent (last-seen) PRs, since those are what's in play, and renders
5824
+ * them newest-first (reverse chronological) so the freshest PR leads the chip.
5249
5825
  */
5250
5826
  function prLinksStyledText(prs) {
5251
5827
  const dot = fg(theme.dim ?? "#565f89");
5252
5828
  const label = fg(theme.muted ?? "#7a7a7a");
5253
5829
  const accent = fg(theme.accent ?? "#7aa2f7");
5254
- const shown = prs.slice(-MAX_PR_CHIPS);
5830
+ const shown = prs.slice(-MAX_PR_CHIPS).reverse();
5255
5831
  const overflow = prs.length - shown.length;
5256
5832
  const chunks = [dot("· "), label("PR ")];
5257
5833
  shown.forEach((pr, i) => {
@@ -5313,7 +5889,7 @@ function useCompaction({ rest, conversationId }) {
5313
5889
  }, [armIndicator, clearTimer]);
5314
5890
  const compactConversation = useCallback(() => {
5315
5891
  if (!rest || !conversationId) {
5316
- useStore.getState().showToast({ message: "nothing to compact yet send a message first" });
5892
+ useStore.getState().showToast({ message: "nothing to compact yet; send a message first" });
5317
5893
  return;
5318
5894
  }
5319
5895
  armIndicator();
@@ -5370,7 +5946,7 @@ function formatStatusInfo({ appUrl, workspaceName, agentName, conversationId, mo
5370
5946
  ["app", appUrl ?? "unknown"],
5371
5947
  ["workspace", workspaceName ?? "unknown"],
5372
5948
  ["agent", agentName],
5373
- ["conversation", conversationId ?? "new created when you send the first message"],
5949
+ ["conversation", conversationId ?? "new (created when you send the first message)"],
5374
5950
  ["model", model ?? "default"],
5375
5951
  ["local access", formatShare(share)]
5376
5952
  ];
@@ -5378,9 +5954,9 @@ function formatStatusInfo({ appUrl, workspaceName, agentName, conversationId, mo
5378
5954
  return rows.map(([label, value]) => `${label.padEnd(width)} ${value}`).join("\n");
5379
5955
  }
5380
5956
  function formatShare(share) {
5381
- if (share.status === "off") return "off ctrl+t to share this machine";
5957
+ if (share.status === "off") return "off (ctrl+t to share this machine)";
5382
5958
  if (share.status !== "connected") return share.status;
5383
- return share.grantedToAgent ? "shared this agent can run commands here" : "shared this agent is not granted";
5959
+ return share.grantedToAgent ? "shared (this agent can run commands here)" : "shared (this agent is not granted)";
5384
5960
  }
5385
5961
 
5386
5962
  //#endregion
@@ -5433,49 +6009,6 @@ function createResponsePreview() {
5433
6009
  };
5434
6010
  }
5435
6011
 
5436
- //#endregion
5437
- //#region src/chat/tui/spinner-frames.ts
5438
- /** Braille dot-cycle frames — the classic smooth terminal spinner. */
5439
- const SPINNER_FRAMES = [
5440
- "⠋",
5441
- "⠙",
5442
- "⠹",
5443
- "⠸",
5444
- "⠼",
5445
- "⠴",
5446
- "⠦",
5447
- "⠧",
5448
- "⠇",
5449
- "⠏"
5450
- ];
5451
- /** Milliseconds between frames. ~12.5fps reads as smooth without thrashing. */
5452
- const SPINNER_INTERVAL_MS = 80;
5453
- /** Frame for a given tick count. Wraps at the end of the cycle; negative-safe. */
5454
- function spinnerFrame(tick) {
5455
- const n = SPINNER_FRAMES.length;
5456
- return SPINNER_FRAMES[(Math.trunc(tick) % n + n) % n] ?? SPINNER_FRAMES[0];
5457
- }
5458
-
5459
- //#endregion
5460
- //#region src/chat/tui/spinner.tsx
5461
- /**
5462
- * An animated spinner glyph. Advances one frame every SPINNER_INTERVAL_MS via
5463
- * a self-owned interval that's cleared on unmount, so the animation runs only
5464
- * while the component is mounted. Returns a <span>, so render it inside a
5465
- * <text> (matching how the rest of the TUI composes inline glyphs).
5466
- */
5467
- function Spinner({ color }) {
5468
- const [tick, setTick] = useState(0);
5469
- useEffect(() => {
5470
- const id = setInterval(() => setTick((t) => t + 1), SPINNER_INTERVAL_MS);
5471
- return () => clearInterval(id);
5472
- }, []);
5473
- return /* @__PURE__ */ jsx("span", {
5474
- fg: color,
5475
- children: spinnerFrame(tick)
5476
- });
5477
- }
5478
-
5479
6012
  //#endregion
5480
6013
  //#region src/chat/tui/prompt-history.ts
5481
6014
  /**
@@ -6055,6 +6588,9 @@ function HelpModal({ onClose }) {
6055
6588
  fg: theme.muted,
6056
6589
  children: "search "
6057
6590
  }), /* @__PURE__ */ jsx("input", {
6591
+ textColor: theme.fg,
6592
+ focusedTextColor: theme.fg,
6593
+ placeholderColor: theme.dim,
6058
6594
  placeholder: "type to filter…",
6059
6595
  focused: true,
6060
6596
  width: Math.max(10, width - 12),
@@ -6094,7 +6630,7 @@ function HelpModal({ onClose }) {
6094
6630
  children: [
6095
6631
  "… ",
6096
6632
  hidden,
6097
- " more search to narrow down"
6633
+ " more (search to narrow down)"
6098
6634
  ]
6099
6635
  }) : null]
6100
6636
  }),
@@ -6139,7 +6675,7 @@ function CompletionMenu({ items, highlight }) {
6139
6675
  },
6140
6676
  children: windowed.length === 0 ? /* @__PURE__ */ jsx("text", {
6141
6677
  fg: theme.muted,
6142
- children: "no matching command ↵ sends as a message"
6678
+ children: "no matching command (↵ sends as a message)"
6143
6679
  }) : windowed.map((item, i) => {
6144
6680
  const selected = start + i === highlight;
6145
6681
  return /* @__PURE__ */ jsxs("text", {
@@ -6606,7 +7142,7 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
6606
7142
  }),
6607
7143
  modelLocked ? /* @__PURE__ */ jsx("text", {
6608
7144
  fg: theme.warning,
6609
- children: "this agent is locked to a Google-compliant model changes will be rejected. disconnect Google to switch freely."
7145
+ children: "this agent is locked to a Google-compliant model; changes will be rejected. disconnect Google to switch freely."
6610
7146
  }) : null,
6611
7147
  /* @__PURE__ */ jsxs("box", {
6612
7148
  style: {
@@ -6618,6 +7154,9 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
6618
7154
  fg: theme.muted,
6619
7155
  children: "search "
6620
7156
  }), /* @__PURE__ */ jsx("input", {
7157
+ textColor: theme.fg,
7158
+ focusedTextColor: theme.fg,
7159
+ placeholderColor: theme.dim,
6621
7160
  placeholder: "type to filter…",
6622
7161
  focused: true,
6623
7162
  width: Math.max(10, width - 12),
@@ -6744,7 +7283,7 @@ function ThemePreview() {
6744
7283
  /* @__PURE__ */ jsx("text", { children: " " }),
6745
7284
  /* @__PURE__ */ jsx("markdown", {
6746
7285
  content: [
6747
- "Wrapped it in exponential backoff three attempts, then it",
7286
+ "Wrapped it in exponential backoff: three attempts, then it",
6748
7287
  "throws the last error:",
6749
7288
  "",
6750
7289
  "```typescript",
@@ -6880,7 +7419,7 @@ function ThemePicker({ onClose }) {
6880
7419
  },
6881
7420
  children: /* @__PURE__ */ jsx("text", {
6882
7421
  fg: theme.muted,
6883
- children: "theme is pinned (NO_COLOR, --theme, or SKYDIVE_THEME) unset it to pick interactively."
7422
+ children: "theme is pinned (NO_COLOR, --theme, or SKYDIVE_THEME). unset it to pick interactively."
6884
7423
  })
6885
7424
  }),
6886
7425
  /* @__PURE__ */ jsx("text", {
@@ -6924,7 +7463,7 @@ function ThemePicker({ onClose }) {
6924
7463
  }),
6925
7464
  /* @__PURE__ */ jsxs("text", {
6926
7465
  fg: theme.muted,
6927
- children: [themeMode, " themes following the terminal background"]
7466
+ children: [themeMode, " themes (following the terminal background)"]
6928
7467
  }),
6929
7468
  placement === "right" ? /* @__PURE__ */ jsxs("box", {
6930
7469
  style: {
@@ -7562,6 +8101,9 @@ function ReviewFileSearch({ query, onQueryChange, placeholder = "filter files…
7562
8101
  fg: theme.accent,
7563
8102
  children: "/ "
7564
8103
  }), /* @__PURE__ */ jsx("input", {
8104
+ textColor: theme.fg,
8105
+ focusedTextColor: theme.fg,
8106
+ placeholderColor: theme.dim,
7565
8107
  placeholder,
7566
8108
  focused: true,
7567
8109
  width: "100%",
@@ -8281,6 +8823,9 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
8281
8823
  height: 3
8282
8824
  },
8283
8825
  children: /* @__PURE__ */ jsx("input", {
8826
+ textColor: theme.fg,
8827
+ focusedTextColor: theme.fg,
8828
+ placeholderColor: theme.dim,
8284
8829
  placeholder: "Leave a comment…",
8285
8830
  focused: true,
8286
8831
  width: Math.max(8, width - 10),
@@ -8636,7 +9181,7 @@ function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHei
8636
9181
  if (ok) {
8637
9182
  persist([]);
8638
9183
  setStatus("copied to clipboard");
8639
- } else setStatus("clipboard unavailable comments kept");
9184
+ } else setStatus("clipboard unavailable; comments kept");
8640
9185
  });
8641
9186
  return;
8642
9187
  }
@@ -8808,6 +9353,9 @@ function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHei
8808
9353
  height: 3
8809
9354
  },
8810
9355
  children: /* @__PURE__ */ jsx("input", {
9356
+ textColor: theme.fg,
9357
+ focusedTextColor: theme.fg,
9358
+ placeholderColor: theme.dim,
8811
9359
  placeholder: "Leave a comment…",
8812
9360
  focused: true,
8813
9361
  width: Math.max(8, sourceWidth - 8),
@@ -9562,9 +10110,10 @@ function ChatScreen({ agent, conversation }) {
9562
10110
  hadTodosRef.current = hasTodos;
9563
10111
  }, [todos]);
9564
10112
  const [historyLoaded, setHistoryLoaded] = useState(initialConversationId === null);
9565
- const [input, setInput] = useState("");
10113
+ const [input, setInput] = useState(() => useStore.getState().takeEarlyInput());
9566
10114
  const [run, setRun] = useState({ kind: "idle" });
9567
10115
  const [model, setModel] = useState(agent.model ?? null);
10116
+ const [modelLocked, setModelLocked] = useState(agent.modelLocked ?? false);
9568
10117
  const [cardPickerOpen, setCardPickerOpen] = useState(false);
9569
10118
  const [modelPickerOpen, setModelPickerOpen] = useState(false);
9570
10119
  const [themePickerOpen, setThemePickerOpen] = useState(false);
@@ -9863,6 +10412,12 @@ function ChatScreen({ agent, conversation }) {
9863
10412
  onCompactionEvent(event.status);
9864
10413
  return;
9865
10414
  }
10415
+ if (event.kind === "agent") {
10416
+ if (event.id !== agent.id) return;
10417
+ setModel(event.model);
10418
+ setModelLocked(event.modelLocked);
10419
+ return;
10420
+ }
9866
10421
  if (event.kind === "conversation" && event.title) setChatTitle(event.title);
9867
10422
  }
9868
10423
  }).catch(() => {});
@@ -9872,6 +10427,7 @@ function ChatScreen({ agent, conversation }) {
9872
10427
  }, [
9873
10428
  rest,
9874
10429
  conversationId,
10430
+ agent.id,
9875
10431
  setChatTitle,
9876
10432
  attachToRun,
9877
10433
  onCompactionEvent
@@ -9985,12 +10541,12 @@ function ChatScreen({ agent, conversation }) {
9985
10541
  if (autoGrantMachine && portalClient && portalStatus === "connected" && !portalClient.isGranted(agent.id)) try {
9986
10542
  await portalClient.grantAgent(agent.id);
9987
10543
  useStore.getState().showToast({
9988
- message: `Shared this machine with ${agent.name} for the import. The grant persists revoke anytime: skydive portal revoke --agent ${agent.name}`,
10544
+ message: `Shared this machine with ${agent.name} for the import. The grant persists. Revoke anytime: skydive portal revoke --agent ${agent.name}`,
9989
10545
  durationMs: 1e4
9990
10546
  });
9991
10547
  } catch (err) {
9992
10548
  useStore.getState().showToast({
9993
- message: `Couldn't grant machine access automatically (${errorMessage(err)}) the agent will ask in chat instead.`,
10549
+ message: `Couldn't grant machine access automatically (${errorMessage(err)}). The agent will ask in chat instead.`,
9994
10550
  variant: "warning"
9995
10551
  });
9996
10552
  }
@@ -10330,7 +10886,7 @@ function ChatScreen({ agent, conversation }) {
10330
10886
  setItems((prev) => [...prev, {
10331
10887
  kind: "error",
10332
10888
  id: crypto.randomUUID(),
10333
- text: `attachment limit reached (${maxAttachments}) ${fileName} skipped`
10889
+ text: `attachment limit reached (${maxAttachments}); ${fileName} skipped`
10334
10890
  }]);
10335
10891
  return;
10336
10892
  }
@@ -10456,7 +11012,7 @@ function ChatScreen({ agent, conversation }) {
10456
11012
  const openInBrowser = useCallback(() => {
10457
11013
  if (!appUrl) return;
10458
11014
  if (!conversationId) {
10459
- useStore.getState().showToast({ message: "no web page yet send a message to start the conversation" });
11015
+ useStore.getState().showToast({ message: "no web page yet; send a message to start the conversation" });
10460
11016
  return;
10461
11017
  }
10462
11018
  const url = `${resolveWebUrl(appUrl)}/c/${encodeURIComponent(conversationId)}`;
@@ -10466,7 +11022,7 @@ function ChatScreen({ agent, conversation }) {
10466
11022
  })).catch((err) => setItems((prev) => [...prev, {
10467
11023
  kind: "error",
10468
11024
  id: crypto.randomUUID(),
10469
- text: `couldn't open a browser visit ${url} (${errorMessage(err)})`
11025
+ text: `couldn't open a browser; visit ${url} (${errorMessage(err)})`
10470
11026
  }]));
10471
11027
  }, [appUrl, conversationId]);
10472
11028
  const forkAndContinue = useCallback(() => {
@@ -10512,7 +11068,7 @@ function ChatScreen({ agent, conversation }) {
10512
11068
  return;
10513
11069
  }
10514
11070
  if (!conversationId) {
10515
- useStore.getState().showToast({ message: "nothing to archive yet send a message to start this one" });
11071
+ useStore.getState().showToast({ message: "nothing to archive yet; send a message to start this one" });
10516
11072
  return;
10517
11073
  }
10518
11074
  if (!rest) return;
@@ -10522,7 +11078,7 @@ function ChatScreen({ agent, conversation }) {
10522
11078
  }).then(() => {
10523
11079
  useStore.getState().showToast({
10524
11080
  variant: "success",
10525
- message: "archived shift+tab in the picker shows it, ctrl+a restores"
11081
+ message: "archived (shift+tab in the picker shows it, ctrl+a restores)"
10526
11082
  });
10527
11083
  goTo({
10528
11084
  kind: "conversation-picker",
@@ -10530,7 +11086,7 @@ function ChatScreen({ agent, conversation }) {
10530
11086
  });
10531
11087
  }).catch((err) => useStore.getState().showToast({
10532
11088
  variant: "error",
10533
- message: err instanceof HttpError && err.status === 404 ? "can't archive this one it's already gone from your list" : `couldn't archive: ${errorMessage(err)}`
11089
+ message: err instanceof HttpError && err.status === 404 ? "can't archive this one; it's already gone from your list" : `couldn't archive: ${errorMessage(err)}`
10534
11090
  }));
10535
11091
  }, [
10536
11092
  grantPrompt,
@@ -10554,7 +11110,7 @@ function ChatScreen({ agent, conversation }) {
10554
11110
  return;
10555
11111
  }
10556
11112
  if (!conversationId) {
10557
- useStore.getState().showToast({ message: "nothing to rename yet send a message to start this one" });
11113
+ useStore.getState().showToast({ message: "nothing to rename yet; send a message to start this one" });
10558
11114
  return;
10559
11115
  }
10560
11116
  if (!rest) return;
@@ -10583,7 +11139,7 @@ function ChatScreen({ agent, conversation }) {
10583
11139
  const leaveForScreen = useCallback((next) => {
10584
11140
  const current = runRef.current;
10585
11141
  if (current.kind === "streaming") current.abort.abort();
10586
- if (current.kind !== "idle") useStore.getState().showToast({ message: `${agent.name} keeps working reopen to reattach` });
11142
+ if (current.kind !== "idle") useStore.getState().showToast({ message: `${agent.name} keeps working; reopen to reattach` });
10587
11143
  goTo(next);
10588
11144
  }, [agent.name, goTo]);
10589
11145
  /**
@@ -10602,7 +11158,7 @@ function ChatScreen({ agent, conversation }) {
10602
11158
  setItems((prev) => [...prev, {
10603
11159
  kind: "error",
10604
11160
  id: crypto.randomUUID(),
10605
- text: `not a conversation id: ${ref} pass an id, its /c/<id> link, or run /conversation on its own to pick from the list`
11161
+ text: `not a conversation id: ${ref}. pass an id, its /c/<id> link, or run /conversation on its own to pick from the list`
10606
11162
  }]);
10607
11163
  return;
10608
11164
  }
@@ -10632,7 +11188,7 @@ function ChatScreen({ agent, conversation }) {
10632
11188
  ]);
10633
11189
  const toggleShare = useCallback(() => {
10634
11190
  if (!portalClient) {
10635
- useStore.getState().showToast({ message: useStore.getState().authKind === "api-key" ? "local access needs a browser session run `skydive auth login --web`" : "local access is unavailable in this session" });
11191
+ useStore.getState().showToast({ message: useStore.getState().authKind === "api-key" ? "local access needs a browser session; run `skydive auth login --web`" : "local access is unavailable in this session" });
10636
11192
  return;
10637
11193
  }
10638
11194
  if (portal.status === "off") {
@@ -10675,7 +11231,7 @@ function ChatScreen({ agent, conversation }) {
10675
11231
  }, [agent.id, setPortalPrompt]);
10676
11232
  const openPortalAccess = useCallback(() => {
10677
11233
  if (!portalClient) {
10678
- useStore.getState().showToast({ message: useStore.getState().authKind === "api-key" ? "local access needs a browser session run `skydive auth login --web`" : "local access is unavailable in this session" });
11234
+ useStore.getState().showToast({ message: useStore.getState().authKind === "api-key" ? "local access needs a browser session; run `skydive auth login --web`" : "local access is unavailable in this session" });
10679
11235
  return;
10680
11236
  }
10681
11237
  switch (portal.status) {
@@ -10764,7 +11320,7 @@ function ChatScreen({ agent, conversation }) {
10764
11320
  break;
10765
11321
  }
10766
11322
  if (!conversationId) {
10767
- useStore.getState().showToast({ message: "nothing to fork yet send a message first" });
11323
+ useStore.getState().showToast({ message: "nothing to fork yet; send a message first" });
10768
11324
  break;
10769
11325
  }
10770
11326
  forkAndContinue();
@@ -11095,7 +11651,7 @@ function ChatScreen({ agent, conversation }) {
11095
11651
  if (runRef.current.kind === "streaming") {
11096
11652
  if (cancelLatestSteer()) return;
11097
11653
  runRef.current.abort.abort();
11098
- useStore.getState().showToast({ message: `${agent.name} keeps working reopen to reattach` });
11654
+ useStore.getState().showToast({ message: `${agent.name} keeps working; reopen to reattach` });
11099
11655
  goTo({
11100
11656
  kind: "conversation-picker",
11101
11657
  agent
@@ -11109,6 +11665,10 @@ function ChatScreen({ agent, conversation }) {
11109
11665
  }
11110
11666
  });
11111
11667
  const hasPendingSteer = items.some((m) => m.kind === "pending-steer");
11668
+ const conversationContext = useMemo(() => ({
11669
+ rest,
11670
+ conversationId
11671
+ }), [rest, conversationId]);
11112
11672
  const actionableCards = useMemo(() => items.filter(isActionableCard), [items]);
11113
11673
  const statusText = useMemo(() => formatChatFooterStatus({
11114
11674
  runKind: run.kind,
@@ -11137,7 +11697,7 @@ function ChatScreen({ agent, conversation }) {
11137
11697
  if (modelPickerOpen) return /* @__PURE__ */ jsx(ModelPicker, {
11138
11698
  agentId: agent.id,
11139
11699
  currentModel: model,
11140
- modelLocked: agent.modelLocked ?? false,
11700
+ modelLocked,
11141
11701
  onSelected: (modelId) => {
11142
11702
  setModel(modelId);
11143
11703
  setModelPickerOpen(false);
@@ -11191,7 +11751,7 @@ function ChatScreen({ agent, conversation }) {
11191
11751
  setReviewFocused(false);
11192
11752
  }
11193
11753
  }) : null;
11194
- const chatView = /* @__PURE__ */ jsxs("box", {
11754
+ const chatViewBody = /* @__PURE__ */ jsxs("box", {
11195
11755
  onMouseDown: (event) => {
11196
11756
  const paneBox = reviewBelowBoxRef.current;
11197
11757
  if (reviewOpen && reviewPlacement === "below" && paneBox && event.y === paneBox.screenY + paneBox.height - 1) {
@@ -11439,6 +11999,9 @@ function ChatScreen({ agent, conversation }) {
11439
11999
  },
11440
12000
  children: /* @__PURE__ */ jsx("textarea", {
11441
12001
  ref: composerRef,
12002
+ textColor: theme.fg,
12003
+ focusedTextColor: theme.fg,
12004
+ placeholderColor: theme.dim,
11442
12005
  placeholder: forking ? "forking into a new chat…" : `message ${agent.name}…`,
11443
12006
  keyBindings: composerKeyBindings,
11444
12007
  onContentChange: handleComposerChange,
@@ -11478,6 +12041,10 @@ function ChatScreen({ agent, conversation }) {
11478
12041
  })
11479
12042
  ]
11480
12043
  });
12044
+ const chatView = /* @__PURE__ */ jsx(ConversationContext.Provider, {
12045
+ value: conversationContext,
12046
+ children: chatViewBody
12047
+ });
11481
12048
  if (!reviewView || reviewPlacement === "below") return chatView;
11482
12049
  const fullscreen = reviewPlacement === "fullscreen";
11483
12050
  return /* @__PURE__ */ jsxs("box", {
@@ -11571,12 +12138,18 @@ function CredentialPromptBox({ prompt, height, onInput, onSubmit }) {
11571
12138
  ]
11572
12139
  }),
11573
12140
  field.secret ? /* @__PURE__ */ jsx("input", {
12141
+ textColor: theme.fg,
12142
+ focusedTextColor: theme.fg,
12143
+ placeholderColor: theme.dim,
11574
12144
  value: MASK_CHAR.repeat(prompt.current.length),
11575
12145
  placeholder: field.placeholder ?? "",
11576
12146
  onInput,
11577
12147
  onSubmit,
11578
12148
  focused: !prompt.submitting
11579
12149
  }, field.key) : /* @__PURE__ */ jsx("input", {
12150
+ textColor: theme.fg,
12151
+ focusedTextColor: theme.fg,
12152
+ placeholderColor: theme.dim,
11580
12153
  placeholder: field.placeholder ?? "",
11581
12154
  onInput,
11582
12155
  onSubmit,
@@ -11816,7 +12389,12 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
11816
12389
  setClients,
11817
12390
  fail
11818
12391
  ]);
12392
+ const appendEarlyInput = useStore((s) => s.appendEarlyInput);
11819
12393
  useKeyboard((key) => {
12394
+ if (screen.kind === "splash" && !(key.name === "c" && key.ctrl)) {
12395
+ appendEarlyInput(key);
12396
+ return;
12397
+ }
11820
12398
  if (key.name === "c" && key.ctrl && screen.kind !== "chat") {
11821
12399
  useStore.getState().portalClient?.dispose();
11822
12400
  agentHost.dispose();
@@ -12284,6 +12862,97 @@ async function settleBootThemeMode({ source, applyThemeMode, graceMs = THEME_QUE
12284
12862
  if (answered) applyThemeMode(answered);
12285
12863
  }
12286
12864
 
12865
+ //#endregion
12866
+ //#region src/profiling/tui.ts
12867
+ /**
12868
+ * TUI-side profiling instrumentation. Everything here rides on the shared
12869
+ * profile directory created in bin.ts (see profiler.ts) and is inert unless
12870
+ * SKYDIVE_PROFILE is set.
12871
+ */
12872
+ /**
12873
+ * Renderer options to merge into createCliRenderer when profiling:
12874
+ * gatherStats makes OpenTUI retain per-frame timings (frame time, cells
12875
+ * updated, native render + stdout write time) that getStats() exposes.
12876
+ */
12877
+ function profilingRendererOptions() {
12878
+ return profilingEnabled() ? { gatherStats: true } : {};
12879
+ }
12880
+ /**
12881
+ * Samples renderer stats periodically and dumps the full series at exit.
12882
+ * The periodic samples give the timeline shape (fps over the session); the
12883
+ * final dump carries the complete frame-time distribution.
12884
+ */
12885
+ function startRendererStatsCapture(renderer) {
12886
+ if (!profilingEnabled()) return;
12887
+ const sample = () => {
12888
+ const s = renderer.getStats();
12889
+ record("render", {
12890
+ fps: s.fps,
12891
+ frameCount: s.frameCount,
12892
+ averageFrameTime: s.averageFrameTime,
12893
+ maxFrameTime: s.maxFrameTime,
12894
+ cellsUpdated: s.cellsUpdated,
12895
+ nativeRenderTime: s.nativeRenderTime ?? null,
12896
+ nativeStdoutWriteTime: s.nativeStdoutWriteTime ?? null
12897
+ });
12898
+ };
12899
+ const interval = setInterval(sample, 1e3);
12900
+ interval.unref?.();
12901
+ process.on("exit", () => {
12902
+ clearInterval(interval);
12903
+ const s = renderer.getStats();
12904
+ writeArtifact("render.json", {
12905
+ fps: s.fps,
12906
+ frameCount: s.frameCount,
12907
+ frameTimes: s.frameTimes,
12908
+ averageFrameTime: s.averageFrameTime,
12909
+ minFrameTime: s.minFrameTime,
12910
+ maxFrameTime: s.maxFrameTime,
12911
+ cellsUpdated: s.cellsUpdated,
12912
+ averageCellsUpdated: s.averageCellsUpdated
12913
+ });
12914
+ });
12915
+ }
12916
+ /**
12917
+ * Records zustand store transitions. Subscribes to the whole store and
12918
+ * logs a compact diff-flavored event per change: the screen kind always,
12919
+ * plus which top-level keys changed. Gives an agent the "what was the UI
12920
+ * doing at t=X" axis without serializing message payloads.
12921
+ */
12922
+ function startStateCapture(store) {
12923
+ if (!profilingEnabled()) return;
12924
+ store.subscribe((state, prev) => {
12925
+ const prevEntries = new Map(Object.entries(prev));
12926
+ const changed = [];
12927
+ let screenKind = null;
12928
+ for (const [key, value] of Object.entries(state)) {
12929
+ if (typeof value === "function") continue;
12930
+ if (value !== prevEntries.get(key)) changed.push(key);
12931
+ if (key === "screen" && typeof value === "object" && value !== null && "kind" in value && typeof value.kind === "string") screenKind = value.kind;
12932
+ }
12933
+ if (changed.length === 0) return;
12934
+ record("state", {
12935
+ screen: screenKind,
12936
+ changed
12937
+ });
12938
+ });
12939
+ }
12940
+ /**
12941
+ * React Profiler onRender callback for the <Profiler> wrapping <App>.
12942
+ * One event per commit: which subtree, mount/update, and actual vs base
12943
+ * duration (actualDuration >> baseDuration means memoization is losing).
12944
+ */
12945
+ function onRenderCommit(id, phase, actualDuration, baseDuration, startTime, commitTime) {
12946
+ record("commits", {
12947
+ id,
12948
+ phase,
12949
+ actualDuration,
12950
+ baseDuration,
12951
+ startTime,
12952
+ commitTime
12953
+ });
12954
+ }
12955
+
12287
12956
  //#endregion
12288
12957
  //#region src/chat/boot.tsx
12289
12958
  /**
@@ -12296,7 +12965,12 @@ async function runChat({ appUrl, sessionToken, authKind = "session", shareMachin
12296
12965
  await registerGrammars();
12297
12966
  const watchdog = startMemoryWatchdog();
12298
12967
  installCrashHandler({ memoryProvider: () => watchdog.snapshot() });
12299
- const renderer = await createCliRenderer({ exitOnCtrlC: false });
12968
+ const renderer = await createCliRenderer({
12969
+ exitOnCtrlC: false,
12970
+ ...profilingRendererOptions()
12971
+ });
12972
+ startRendererStatsCapture(renderer);
12973
+ startStateCapture(useStore);
12300
12974
  extend({ markdown: LinkifiedMarkdownRenderable });
12301
12975
  useStore.setState({
12302
12976
  authKind,
@@ -12316,16 +12990,20 @@ async function runChat({ appUrl, sessionToken, authKind = "session", shareMachin
12316
12990
  source: renderer,
12317
12991
  applyThemeMode: (mode) => useStore.getState().setThemeMode(mode)
12318
12992
  });
12319
- createRoot(renderer).render(/* @__PURE__ */ jsx(App, {
12320
- appUrl,
12321
- sessionToken,
12322
- authKind,
12323
- shareMachine,
12324
- promptHistoryPath,
12325
- notifications,
12326
- agentSelector: agentSelector ?? null,
12327
- conversationId: conversationId ?? null,
12328
- newConversation
12993
+ createRoot(renderer).render(/* @__PURE__ */ jsx(Profiler, {
12994
+ id: "app",
12995
+ onRender: onRenderCommit,
12996
+ children: /* @__PURE__ */ jsx(App, {
12997
+ appUrl,
12998
+ sessionToken,
12999
+ authKind,
13000
+ shareMachine,
13001
+ promptHistoryPath,
13002
+ notifications,
13003
+ agentSelector: agentSelector ?? null,
13004
+ conversationId: conversationId ?? null,
13005
+ newConversation
13006
+ })
12329
13007
  }));
12330
13008
  }
12331
13009
  /** Open the same workspace picker used by the initial agent screen. */