skydive-cli 0.4.0 → 0.4.1-beta.2

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-Ba-UTi-l.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-X2N5wJ5l.mjs";
4
- import { a as errorMessage, i as sendErrorMessage, n as createRestClient, o as isRecord, r as errorDetail, t as HttpError } from "./rest-imDZZGQA.mjs";
5
- import { i as billingBlockedOutcomeFromSendResponse } from "./billing-blocked-CwfG1BTR.mjs";
6
- import { t as PortalClient } from "./client-CKzK-12y.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-CaFz7Wtw.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-BA-c3-gP.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
  /**
@@ -5728,6 +6261,10 @@ const keybindGroups = [
5728
6261
  keys: "/plan [show|hide]",
5729
6262
  action: "show / hide the pinned plan card"
5730
6263
  },
6264
+ {
6265
+ keys: "/rename <title>",
6266
+ action: "rename this conversation (for everyone)"
6267
+ },
5731
6268
  {
5732
6269
  keys: "/",
5733
6270
  action: "open the command menu (start of message)"
@@ -6051,6 +6588,9 @@ function HelpModal({ onClose }) {
6051
6588
  fg: theme.muted,
6052
6589
  children: "search "
6053
6590
  }), /* @__PURE__ */ jsx("input", {
6591
+ textColor: theme.fg,
6592
+ focusedTextColor: theme.fg,
6593
+ placeholderColor: theme.dim,
6054
6594
  placeholder: "type to filter…",
6055
6595
  focused: true,
6056
6596
  width: Math.max(10, width - 12),
@@ -6090,7 +6630,7 @@ function HelpModal({ onClose }) {
6090
6630
  children: [
6091
6631
  "… ",
6092
6632
  hidden,
6093
- " more search to narrow down"
6633
+ " more (search to narrow down)"
6094
6634
  ]
6095
6635
  }) : null]
6096
6636
  }),
@@ -6135,7 +6675,7 @@ function CompletionMenu({ items, highlight }) {
6135
6675
  },
6136
6676
  children: windowed.length === 0 ? /* @__PURE__ */ jsx("text", {
6137
6677
  fg: theme.muted,
6138
- children: "no matching command ↵ sends as a message"
6678
+ children: "no matching command (↵ sends as a message)"
6139
6679
  }) : windowed.map((item, i) => {
6140
6680
  const selected = start + i === highlight;
6141
6681
  return /* @__PURE__ */ jsxs("text", {
@@ -6188,6 +6728,13 @@ const slashCommands = [
6188
6728
  description: "archive this conversation and hide it from your list",
6189
6729
  action: { kind: "archive-conversation" }
6190
6730
  },
6731
+ {
6732
+ name: "rename",
6733
+ aliases: ["title"],
6734
+ argsHint: "<new title>",
6735
+ description: "rename this conversation (for everyone)",
6736
+ action: { kind: "rename-conversation" }
6737
+ },
6191
6738
  {
6192
6739
  name: "conversation",
6193
6740
  aliases: ["conversations", "conv"],
@@ -6595,7 +7142,7 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
6595
7142
  }),
6596
7143
  modelLocked ? /* @__PURE__ */ jsx("text", {
6597
7144
  fg: theme.warning,
6598
- 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."
6599
7146
  }) : null,
6600
7147
  /* @__PURE__ */ jsxs("box", {
6601
7148
  style: {
@@ -6607,6 +7154,9 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
6607
7154
  fg: theme.muted,
6608
7155
  children: "search "
6609
7156
  }), /* @__PURE__ */ jsx("input", {
7157
+ textColor: theme.fg,
7158
+ focusedTextColor: theme.fg,
7159
+ placeholderColor: theme.dim,
6610
7160
  placeholder: "type to filter…",
6611
7161
  focused: true,
6612
7162
  width: Math.max(10, width - 12),
@@ -6733,7 +7283,7 @@ function ThemePreview() {
6733
7283
  /* @__PURE__ */ jsx("text", { children: " " }),
6734
7284
  /* @__PURE__ */ jsx("markdown", {
6735
7285
  content: [
6736
- "Wrapped it in exponential backoff three attempts, then it",
7286
+ "Wrapped it in exponential backoff: three attempts, then it",
6737
7287
  "throws the last error:",
6738
7288
  "",
6739
7289
  "```typescript",
@@ -6869,7 +7419,7 @@ function ThemePicker({ onClose }) {
6869
7419
  },
6870
7420
  children: /* @__PURE__ */ jsx("text", {
6871
7421
  fg: theme.muted,
6872
- 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."
6873
7423
  })
6874
7424
  }),
6875
7425
  /* @__PURE__ */ jsx("text", {
@@ -6913,7 +7463,7 @@ function ThemePicker({ onClose }) {
6913
7463
  }),
6914
7464
  /* @__PURE__ */ jsxs("text", {
6915
7465
  fg: theme.muted,
6916
- children: [themeMode, " themes following the terminal background"]
7466
+ children: [themeMode, " themes (following the terminal background)"]
6917
7467
  }),
6918
7468
  placement === "right" ? /* @__PURE__ */ jsxs("box", {
6919
7469
  style: {
@@ -7551,6 +8101,9 @@ function ReviewFileSearch({ query, onQueryChange, placeholder = "filter files…
7551
8101
  fg: theme.accent,
7552
8102
  children: "/ "
7553
8103
  }), /* @__PURE__ */ jsx("input", {
8104
+ textColor: theme.fg,
8105
+ focusedTextColor: theme.fg,
8106
+ placeholderColor: theme.dim,
7554
8107
  placeholder,
7555
8108
  focused: true,
7556
8109
  width: "100%",
@@ -8270,6 +8823,9 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
8270
8823
  height: 3
8271
8824
  },
8272
8825
  children: /* @__PURE__ */ jsx("input", {
8826
+ textColor: theme.fg,
8827
+ focusedTextColor: theme.fg,
8828
+ placeholderColor: theme.dim,
8273
8829
  placeholder: "Leave a comment…",
8274
8830
  focused: true,
8275
8831
  width: Math.max(8, width - 10),
@@ -8625,7 +9181,7 @@ function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHei
8625
9181
  if (ok) {
8626
9182
  persist([]);
8627
9183
  setStatus("copied to clipboard");
8628
- } else setStatus("clipboard unavailable comments kept");
9184
+ } else setStatus("clipboard unavailable; comments kept");
8629
9185
  });
8630
9186
  return;
8631
9187
  }
@@ -8797,6 +9353,9 @@ function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHei
8797
9353
  height: 3
8798
9354
  },
8799
9355
  children: /* @__PURE__ */ jsx("input", {
9356
+ textColor: theme.fg,
9357
+ focusedTextColor: theme.fg,
9358
+ placeholderColor: theme.dim,
8800
9359
  placeholder: "Leave a comment…",
8801
9360
  focused: true,
8802
9361
  width: Math.max(8, sourceWidth - 8),
@@ -9321,6 +9880,10 @@ function routeInput(raw) {
9321
9880
  kind: "open-conversation",
9322
9881
  ref: match.rest
9323
9882
  } : { kind: "conversation-picker" };
9883
+ case "rename-conversation": return {
9884
+ kind: "rename-conversation",
9885
+ title: match.rest
9886
+ };
9324
9887
  case "plan": {
9325
9888
  const arg = match.rest.toLowerCase();
9326
9889
  return {
@@ -9547,9 +10110,10 @@ function ChatScreen({ agent, conversation }) {
9547
10110
  hadTodosRef.current = hasTodos;
9548
10111
  }, [todos]);
9549
10112
  const [historyLoaded, setHistoryLoaded] = useState(initialConversationId === null);
9550
- const [input, setInput] = useState("");
10113
+ const [input, setInput] = useState(() => useStore.getState().takeEarlyInput());
9551
10114
  const [run, setRun] = useState({ kind: "idle" });
9552
10115
  const [model, setModel] = useState(agent.model ?? null);
10116
+ const [modelLocked, setModelLocked] = useState(agent.modelLocked ?? false);
9553
10117
  const [cardPickerOpen, setCardPickerOpen] = useState(false);
9554
10118
  const [modelPickerOpen, setModelPickerOpen] = useState(false);
9555
10119
  const [themePickerOpen, setThemePickerOpen] = useState(false);
@@ -9848,6 +10412,12 @@ function ChatScreen({ agent, conversation }) {
9848
10412
  onCompactionEvent(event.status);
9849
10413
  return;
9850
10414
  }
10415
+ if (event.kind === "agent") {
10416
+ if (event.id !== agent.id) return;
10417
+ setModel(event.model);
10418
+ setModelLocked(event.modelLocked);
10419
+ return;
10420
+ }
9851
10421
  if (event.kind === "conversation" && event.title) setChatTitle(event.title);
9852
10422
  }
9853
10423
  }).catch(() => {});
@@ -9857,6 +10427,7 @@ function ChatScreen({ agent, conversation }) {
9857
10427
  }, [
9858
10428
  rest,
9859
10429
  conversationId,
10430
+ agent.id,
9860
10431
  setChatTitle,
9861
10432
  attachToRun,
9862
10433
  onCompactionEvent
@@ -9970,12 +10541,12 @@ function ChatScreen({ agent, conversation }) {
9970
10541
  if (autoGrantMachine && portalClient && portalStatus === "connected" && !portalClient.isGranted(agent.id)) try {
9971
10542
  await portalClient.grantAgent(agent.id);
9972
10543
  useStore.getState().showToast({
9973
- 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}`,
9974
10545
  durationMs: 1e4
9975
10546
  });
9976
10547
  } catch (err) {
9977
10548
  useStore.getState().showToast({
9978
- 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.`,
9979
10550
  variant: "warning"
9980
10551
  });
9981
10552
  }
@@ -10315,7 +10886,7 @@ function ChatScreen({ agent, conversation }) {
10315
10886
  setItems((prev) => [...prev, {
10316
10887
  kind: "error",
10317
10888
  id: crypto.randomUUID(),
10318
- text: `attachment limit reached (${maxAttachments}) ${fileName} skipped`
10889
+ text: `attachment limit reached (${maxAttachments}); ${fileName} skipped`
10319
10890
  }]);
10320
10891
  return;
10321
10892
  }
@@ -10441,7 +11012,7 @@ function ChatScreen({ agent, conversation }) {
10441
11012
  const openInBrowser = useCallback(() => {
10442
11013
  if (!appUrl) return;
10443
11014
  if (!conversationId) {
10444
- 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" });
10445
11016
  return;
10446
11017
  }
10447
11018
  const url = `${resolveWebUrl(appUrl)}/c/${encodeURIComponent(conversationId)}`;
@@ -10451,7 +11022,7 @@ function ChatScreen({ agent, conversation }) {
10451
11022
  })).catch((err) => setItems((prev) => [...prev, {
10452
11023
  kind: "error",
10453
11024
  id: crypto.randomUUID(),
10454
- text: `couldn't open a browser visit ${url} (${errorMessage(err)})`
11025
+ text: `couldn't open a browser; visit ${url} (${errorMessage(err)})`
10455
11026
  }]));
10456
11027
  }, [appUrl, conversationId]);
10457
11028
  const forkAndContinue = useCallback(() => {
@@ -10497,7 +11068,7 @@ function ChatScreen({ agent, conversation }) {
10497
11068
  return;
10498
11069
  }
10499
11070
  if (!conversationId) {
10500
- 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" });
10501
11072
  return;
10502
11073
  }
10503
11074
  if (!rest) return;
@@ -10507,7 +11078,7 @@ function ChatScreen({ agent, conversation }) {
10507
11078
  }).then(() => {
10508
11079
  useStore.getState().showToast({
10509
11080
  variant: "success",
10510
- 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)"
10511
11082
  });
10512
11083
  goTo({
10513
11084
  kind: "conversation-picker",
@@ -10515,7 +11086,7 @@ function ChatScreen({ agent, conversation }) {
10515
11086
  });
10516
11087
  }).catch((err) => useStore.getState().showToast({
10517
11088
  variant: "error",
10518
- 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)}`
10519
11090
  }));
10520
11091
  }, [
10521
11092
  grantPrompt,
@@ -10525,6 +11096,38 @@ function ChatScreen({ agent, conversation }) {
10525
11096
  goTo,
10526
11097
  agent
10527
11098
  ]);
11099
+ const renameConversation = useCallback((rawTitle) => {
11100
+ const title = rawTitle.trim();
11101
+ if (!title) {
11102
+ useStore.getState().showToast({ message: "usage: /rename <new title>" });
11103
+ return;
11104
+ }
11105
+ if (title.length > 100) {
11106
+ useStore.getState().showToast({
11107
+ variant: "error",
11108
+ message: `title is too long (${String(title.length)}/100 characters)`
11109
+ });
11110
+ return;
11111
+ }
11112
+ if (!conversationId) {
11113
+ useStore.getState().showToast({ message: "nothing to rename yet; send a message to start this one" });
11114
+ return;
11115
+ }
11116
+ if (!rest) return;
11117
+ rest.renameConversation({
11118
+ conversationId,
11119
+ title
11120
+ }).then(() => {
11121
+ useStore.getState().setChatTitle(title);
11122
+ useStore.getState().showToast({
11123
+ variant: "success",
11124
+ message: `renamed to “${title}”`
11125
+ });
11126
+ }).catch((err) => useStore.getState().showToast({
11127
+ variant: "error",
11128
+ message: `couldn't rename: ${errorMessage(err)}`
11129
+ }));
11130
+ }, [conversationId, rest]);
10528
11131
  /**
10529
11132
  * Leave this conversation for another screen without ending its run: drop
10530
11133
  * our own SSE subscription and nothing else — never `rest.cancelRun` — so
@@ -10536,7 +11139,7 @@ function ChatScreen({ agent, conversation }) {
10536
11139
  const leaveForScreen = useCallback((next) => {
10537
11140
  const current = runRef.current;
10538
11141
  if (current.kind === "streaming") current.abort.abort();
10539
- 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` });
10540
11143
  goTo(next);
10541
11144
  }, [agent.name, goTo]);
10542
11145
  /**
@@ -10555,7 +11158,7 @@ function ChatScreen({ agent, conversation }) {
10555
11158
  setItems((prev) => [...prev, {
10556
11159
  kind: "error",
10557
11160
  id: crypto.randomUUID(),
10558
- 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`
10559
11162
  }]);
10560
11163
  return;
10561
11164
  }
@@ -10585,7 +11188,7 @@ function ChatScreen({ agent, conversation }) {
10585
11188
  ]);
10586
11189
  const toggleShare = useCallback(() => {
10587
11190
  if (!portalClient) {
10588
- 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" });
10589
11192
  return;
10590
11193
  }
10591
11194
  if (portal.status === "off") {
@@ -10628,7 +11231,7 @@ function ChatScreen({ agent, conversation }) {
10628
11231
  }, [agent.id, setPortalPrompt]);
10629
11232
  const openPortalAccess = useCallback(() => {
10630
11233
  if (!portalClient) {
10631
- 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" });
10632
11235
  return;
10633
11236
  }
10634
11237
  switch (portal.status) {
@@ -10693,6 +11296,9 @@ function ChatScreen({ agent, conversation }) {
10693
11296
  case "archive-conversation":
10694
11297
  archiveConversation();
10695
11298
  break;
11299
+ case "rename-conversation":
11300
+ renameConversation(routed.title);
11301
+ break;
10696
11302
  case "new-conversation":
10697
11303
  if (runRef.current.kind !== "idle") {
10698
11304
  useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
@@ -10714,7 +11320,7 @@ function ChatScreen({ agent, conversation }) {
10714
11320
  break;
10715
11321
  }
10716
11322
  if (!conversationId) {
10717
- 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" });
10718
11324
  break;
10719
11325
  }
10720
11326
  forkAndContinue();
@@ -10806,6 +11412,7 @@ function ChatScreen({ agent, conversation }) {
10806
11412
  runSandboxExec,
10807
11413
  runSandboxPty,
10808
11414
  archiveConversation,
11415
+ renameConversation,
10809
11416
  forkAndContinue,
10810
11417
  leaveForScreen,
10811
11418
  openConversationRef,
@@ -11044,7 +11651,7 @@ function ChatScreen({ agent, conversation }) {
11044
11651
  if (runRef.current.kind === "streaming") {
11045
11652
  if (cancelLatestSteer()) return;
11046
11653
  runRef.current.abort.abort();
11047
- useStore.getState().showToast({ message: `${agent.name} keeps working reopen to reattach` });
11654
+ useStore.getState().showToast({ message: `${agent.name} keeps working; reopen to reattach` });
11048
11655
  goTo({
11049
11656
  kind: "conversation-picker",
11050
11657
  agent
@@ -11058,6 +11665,10 @@ function ChatScreen({ agent, conversation }) {
11058
11665
  }
11059
11666
  });
11060
11667
  const hasPendingSteer = items.some((m) => m.kind === "pending-steer");
11668
+ const conversationContext = useMemo(() => ({
11669
+ rest,
11670
+ conversationId
11671
+ }), [rest, conversationId]);
11061
11672
  const actionableCards = useMemo(() => items.filter(isActionableCard), [items]);
11062
11673
  const statusText = useMemo(() => formatChatFooterStatus({
11063
11674
  runKind: run.kind,
@@ -11086,7 +11697,7 @@ function ChatScreen({ agent, conversation }) {
11086
11697
  if (modelPickerOpen) return /* @__PURE__ */ jsx(ModelPicker, {
11087
11698
  agentId: agent.id,
11088
11699
  currentModel: model,
11089
- modelLocked: agent.modelLocked ?? false,
11700
+ modelLocked,
11090
11701
  onSelected: (modelId) => {
11091
11702
  setModel(modelId);
11092
11703
  setModelPickerOpen(false);
@@ -11140,7 +11751,7 @@ function ChatScreen({ agent, conversation }) {
11140
11751
  setReviewFocused(false);
11141
11752
  }
11142
11753
  }) : null;
11143
- const chatView = /* @__PURE__ */ jsxs("box", {
11754
+ const chatViewBody = /* @__PURE__ */ jsxs("box", {
11144
11755
  onMouseDown: (event) => {
11145
11756
  const paneBox = reviewBelowBoxRef.current;
11146
11757
  if (reviewOpen && reviewPlacement === "below" && paneBox && event.y === paneBox.screenY + paneBox.height - 1) {
@@ -11388,6 +11999,9 @@ function ChatScreen({ agent, conversation }) {
11388
11999
  },
11389
12000
  children: /* @__PURE__ */ jsx("textarea", {
11390
12001
  ref: composerRef,
12002
+ textColor: theme.fg,
12003
+ focusedTextColor: theme.fg,
12004
+ placeholderColor: theme.dim,
11391
12005
  placeholder: forking ? "forking into a new chat…" : `message ${agent.name}…`,
11392
12006
  keyBindings: composerKeyBindings,
11393
12007
  onContentChange: handleComposerChange,
@@ -11427,6 +12041,10 @@ function ChatScreen({ agent, conversation }) {
11427
12041
  })
11428
12042
  ]
11429
12043
  });
12044
+ const chatView = /* @__PURE__ */ jsx(ConversationContext.Provider, {
12045
+ value: conversationContext,
12046
+ children: chatViewBody
12047
+ });
11430
12048
  if (!reviewView || reviewPlacement === "below") return chatView;
11431
12049
  const fullscreen = reviewPlacement === "fullscreen";
11432
12050
  return /* @__PURE__ */ jsxs("box", {
@@ -11520,12 +12138,18 @@ function CredentialPromptBox({ prompt, height, onInput, onSubmit }) {
11520
12138
  ]
11521
12139
  }),
11522
12140
  field.secret ? /* @__PURE__ */ jsx("input", {
12141
+ textColor: theme.fg,
12142
+ focusedTextColor: theme.fg,
12143
+ placeholderColor: theme.dim,
11523
12144
  value: MASK_CHAR.repeat(prompt.current.length),
11524
12145
  placeholder: field.placeholder ?? "",
11525
12146
  onInput,
11526
12147
  onSubmit,
11527
12148
  focused: !prompt.submitting
11528
12149
  }, field.key) : /* @__PURE__ */ jsx("input", {
12150
+ textColor: theme.fg,
12151
+ focusedTextColor: theme.fg,
12152
+ placeholderColor: theme.dim,
11529
12153
  placeholder: field.placeholder ?? "",
11530
12154
  onInput,
11531
12155
  onSubmit,
@@ -11765,7 +12389,12 @@ function App({ appUrl, sessionToken, authKind, shareMachine, promptHistoryPath,
11765
12389
  setClients,
11766
12390
  fail
11767
12391
  ]);
12392
+ const appendEarlyInput = useStore((s) => s.appendEarlyInput);
11768
12393
  useKeyboard((key) => {
12394
+ if (screen.kind === "splash" && !(key.name === "c" && key.ctrl)) {
12395
+ appendEarlyInput(key);
12396
+ return;
12397
+ }
11769
12398
  if (key.name === "c" && key.ctrl && screen.kind !== "chat") {
11770
12399
  useStore.getState().portalClient?.dispose();
11771
12400
  agentHost.dispose();
@@ -12233,6 +12862,97 @@ async function settleBootThemeMode({ source, applyThemeMode, graceMs = THEME_QUE
12233
12862
  if (answered) applyThemeMode(answered);
12234
12863
  }
12235
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
+
12236
12956
  //#endregion
12237
12957
  //#region src/chat/boot.tsx
12238
12958
  /**
@@ -12245,7 +12965,12 @@ async function runChat({ appUrl, sessionToken, authKind = "session", shareMachin
12245
12965
  await registerGrammars();
12246
12966
  const watchdog = startMemoryWatchdog();
12247
12967
  installCrashHandler({ memoryProvider: () => watchdog.snapshot() });
12248
- const renderer = await createCliRenderer({ exitOnCtrlC: false });
12968
+ const renderer = await createCliRenderer({
12969
+ exitOnCtrlC: false,
12970
+ ...profilingRendererOptions()
12971
+ });
12972
+ startRendererStatsCapture(renderer);
12973
+ startStateCapture(useStore);
12249
12974
  extend({ markdown: LinkifiedMarkdownRenderable });
12250
12975
  useStore.setState({
12251
12976
  authKind,
@@ -12265,16 +12990,20 @@ async function runChat({ appUrl, sessionToken, authKind = "session", shareMachin
12265
12990
  source: renderer,
12266
12991
  applyThemeMode: (mode) => useStore.getState().setThemeMode(mode)
12267
12992
  });
12268
- createRoot(renderer).render(/* @__PURE__ */ jsx(App, {
12269
- appUrl,
12270
- sessionToken,
12271
- authKind,
12272
- shareMachine,
12273
- promptHistoryPath,
12274
- notifications,
12275
- agentSelector: agentSelector ?? null,
12276
- conversationId: conversationId ?? null,
12277
- 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
+ })
12278
13007
  }));
12279
13008
  }
12280
13009
  /** Open the same workspace picker used by the initial agent screen. */