skydive-cli 0.2.0 → 0.3.0

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,26 +1,25 @@
1
1
  #!/usr/bin/env node
2
- import { O as getReviewStateDir, R as resolveWebUrl, S as DEFAULT_APP_URL, T as getConfigPath, V as saveTheme, _ as setActiveWorkspace, a as noColorRequested, c as themeMode, f as themesForMode, g as listWorkspaces, i as monoTheme, k as getSavedTheme, l as themeModeFromColorFgBg, m as getActiveWorkspaceId, n as applyTheme, o as theme, r as findTheme, s as themeForMode, t as DEFAULT_THEME_ID, u as themeVersion, x as DEFAULT_API_URL } from "./theme-CuQhvqzN.mjs";
3
- import { n as createRestClient, r as errorDetail, t as HttpError } from "./rest-CamHVOce.mjs";
4
- import { n as isRecord, t as errorMessage } from "./util-CeisaZVY.mjs";
5
- import { c as cardActionErrorMessage, d as reconcileMaskedInput, f as resolveConnectUrl, i as resolveAgent, l as parseExternalOauthConnectParams, p as parseConnectCard, s as MASK_CHAR, u as parseOauthConnectParams } from "./print-Bin4u16d.mjs";
6
- import "./api-CDTKq_5Q.mjs";
7
- import { t as SandboxStream } from "./client-CpEvH2Pq.mjs";
8
- import { t as PortalClient } from "./client-B3ZhhF7e.mjs";
9
- import { t as runRawPtyPassthrough } from "./raw-pty-GAvxm2ol.mjs";
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-CMYBFvR2.mjs";
3
+ import { L as saveTheme, P as resolveWebUrl, T as getSavedTheme, 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, w as getReviewStateDir, x as getConfigPath, y as DEFAULT_APP_URL } from "./print-Cd-bVSEJ.mjs";
4
+ import { a as errorMessage, i as sendErrorMessage, n as createRestClient, o as isRecord, r as errorDetail, t as HttpError } from "./rest-BlN_uWmL.mjs";
5
+ import { t as PortalClient } from "./client-Dc7GZ3PG.mjs";
6
+ import { d as makeLineParser, f as parseDaemonMessage, l as daemonPaths, n as ensureDaemonRunning, s as LOCAL_PROTOCOL_VERSION, u as encodeLine } from "./daemon-Co4CtpXZ.mjs";
7
+ import { t as SandboxStream } from "./client-DfcJFEbh.mjs";
8
+ import { t as runRawPtyPassthrough } from "./raw-pty-BcjbTjHJ.mjs";
10
9
  import * as os$1 from "node:os";
11
10
  import { homedir, platform, release, tmpdir } from "node:os";
12
11
  import path, { basename, extname, isAbsolute, join, win32 } from "node:path";
13
12
  import { z } from "zod";
14
13
  import open from "open";
15
- import { execFile, spawn } from "node:child_process";
16
- import { createHash } from "node:crypto";
17
14
  import { constants } from "node:fs";
18
- import { MarkdownRenderable, RenderableEvents, SyntaxStyle, createCliRenderer, decodePasteBytes, detectLinks } from "@opentui/core";
15
+ import { execFile, spawn } from "node:child_process";
16
+ import { createHash, randomUUID } from "node:crypto";
17
+ import { connect, createConnection } from "node:net";
18
+ import { access, appendFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
19
+ import { MarkdownRenderable, RenderableEvents, StyledText, SyntaxStyle, createCliRenderer, decodePasteBytes, detectLinks, fg, link } from "@opentui/core";
19
20
  import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
20
- import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
21
+ import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
21
22
  import { create } from "zustand";
22
- import { createConnection } from "node:net";
23
- import { access, appendFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
24
23
  import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
25
24
  import fuzzysort from "fuzzysort";
26
25
  import { fileURLToPath } from "node:url";
@@ -79,6 +78,7 @@ const useStore = create((set, get) => ({
79
78
  toast: null,
80
79
  seedPrompt: null,
81
80
  autoGrantMachine: false,
81
+ newConversationIntent: false,
82
82
  goTo: (screen) => set({
83
83
  screen,
84
84
  chatTitle: initialChatTitle(screen)
@@ -183,8 +183,8 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
183
183
  createdAt: conversation.createdAt,
184
184
  updatedAt: conversation.updatedAt,
185
185
  preview: null,
186
- channel: null,
187
- channelLabel: null,
186
+ channel: conversation.channel,
187
+ channelLabel: conversation.channelLabel,
188
188
  agent: {
189
189
  id: agent.id,
190
190
  name: agent.name,
@@ -228,23 +228,222 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
228
228
  };
229
229
  }
230
230
 
231
+ //#endregion
232
+ //#region src/chat/portal/daemon-client.ts
233
+ var PortalDaemonClient = class {
234
+ sessionId = randomUUID();
235
+ socket = null;
236
+ disposed = false;
237
+ reconnectTimer = null;
238
+ lastBind = null;
239
+ wantEnabled = false;
240
+ pendingGrants = /* @__PURE__ */ new Set();
241
+ grantedAgentIds = /* @__PURE__ */ new Set();
242
+ constructor(opts) {
243
+ this.opts = opts;
244
+ }
245
+ /** Spawn the daemon if needed and attach. Idempotent. */
246
+ async start() {
247
+ if (this.disposed) return;
248
+ await ensureDaemonRunning(this.opts.appUrl);
249
+ this.connect();
250
+ }
251
+ connect() {
252
+ if (this.disposed) return;
253
+ const { socketPath } = daemonPaths(this.opts.appUrl);
254
+ const socket = connect(socketPath);
255
+ socket.setEncoding("utf8");
256
+ this.socket = socket;
257
+ const parse = makeLineParser();
258
+ socket.on("connect", () => {
259
+ this.send({
260
+ t: "hello",
261
+ v: LOCAL_PROTOCOL_VERSION,
262
+ sessionId: this.sessionId,
263
+ token: this.opts.sessionToken
264
+ });
265
+ if (this.wantEnabled) this.send({ t: "enable" });
266
+ if (this.lastBind) this.send({
267
+ t: "bind",
268
+ sessionId: this.sessionId,
269
+ conversationId: this.lastBind.conversationId,
270
+ cwd: this.lastBind.cwd
271
+ });
272
+ for (const agentId of this.pendingGrants) this.send({
273
+ t: "grant",
274
+ agentId
275
+ });
276
+ });
277
+ socket.on("data", (chunk) => {
278
+ for (const line of parse(chunk)) {
279
+ const msg = parseDaemonMessage(line);
280
+ if (msg) this.onMessage(msg);
281
+ }
282
+ });
283
+ const onClose = () => {
284
+ if (this.socket === socket) this.socket = null;
285
+ this.scheduleReconnect();
286
+ };
287
+ socket.on("close", onClose);
288
+ socket.on("error", onClose);
289
+ }
290
+ scheduleReconnect() {
291
+ if (this.disposed || this.reconnectTimer) return;
292
+ this.reconnectTimer = setTimeout(() => {
293
+ this.reconnectTimer = null;
294
+ this.start();
295
+ }, 500);
296
+ this.reconnectTimer.unref();
297
+ }
298
+ onMessage(msg) {
299
+ if (msg.t === "state") {
300
+ this.grantedAgentIds = new Set(msg.grantedAgentIds);
301
+ this.opts.onState({
302
+ status: msg.status,
303
+ machineName: msg.machineName,
304
+ friendlyName: msg.friendlyName,
305
+ error: msg.error,
306
+ grantedAgentIds: msg.grantedAgentIds
307
+ });
308
+ }
309
+ }
310
+ /** Whether this agent is currently authorized on the machine (last daemon state). */
311
+ isGranted(agentId) {
312
+ return this.grantedAgentIds.has(agentId);
313
+ }
314
+ /** Whether the user has turned machine sharing on from this CLI. */
315
+ isEnabled() {
316
+ return this.wantEnabled;
317
+ }
318
+ send(msg) {
319
+ if (this.socket?.writable) this.socket.write(encodeLine(msg));
320
+ }
321
+ /** Turn machine sharing on (the daemon connects the portal). */
322
+ enable() {
323
+ this.wantEnabled = true;
324
+ this.send({ t: "enable" });
325
+ }
326
+ /** Turn machine sharing off. */
327
+ disable() {
328
+ this.wantEnabled = false;
329
+ this.send({ t: "disable" });
330
+ }
331
+ /** Register / update this conversation's working directory for exec routing. */
332
+ bind(conversationId, cwd) {
333
+ this.lastBind = {
334
+ conversationId,
335
+ cwd
336
+ };
337
+ this.send({
338
+ t: "bind",
339
+ sessionId: this.sessionId,
340
+ conversationId,
341
+ cwd
342
+ });
343
+ }
344
+ /**
345
+ * Authorize one agent to run commands on this machine. Resolves once the
346
+ * request is sent to the daemon (the daemon performs the grant and pushes the
347
+ * updated state); kept async so it's a drop-in for the old in-process client's
348
+ * awaited `grantAgent`.
349
+ */
350
+ grantAgent(agentId) {
351
+ this.pendingGrants.add(agentId);
352
+ this.send({
353
+ t: "grant",
354
+ agentId
355
+ });
356
+ return Promise.resolve();
357
+ }
358
+ /** Detach this CLI. The daemon keeps running for other clients. */
359
+ dispose() {
360
+ this.disposed = true;
361
+ if (this.reconnectTimer) {
362
+ clearTimeout(this.reconnectTimer);
363
+ this.reconnectTimer = null;
364
+ }
365
+ this.send({
366
+ t: "bye",
367
+ sessionId: this.sessionId
368
+ });
369
+ try {
370
+ this.socket?.destroy();
371
+ } catch (_error) {}
372
+ this.socket = null;
373
+ }
374
+ };
375
+
376
+ //#endregion
377
+ //#region src/chat/portal/create-portal-client.ts
378
+ /**
379
+ * Escape hatch: setting `SKYDIVE_NO_DAEMON` (to any non-empty value)
380
+ * makes the CLI bypass the portal daemon and open the portal connection
381
+ * in-process, the pre-daemon behavior. This is a LOCAL env switch, not a remote
382
+ * flag: it takes effect on the next CLI start and lets a user (or us) fall back
383
+ * instantly if the daemon misbehaves in the wild, without shipping a new build.
384
+ * The daemon is the default because it fixes the multi-process presence flap.
385
+ */
386
+ function portalDaemonDisabled() {
387
+ return !!process.env.SKYDIVE_NO_DAEMON;
388
+ }
389
+ /**
390
+ * Build the portal client the app should use: the daemon client by default, or
391
+ * the in-process `PortalClient` when the kill switch is set. The in-process
392
+ * client always runs exec in the launch cwd (it has no conversation map), which
393
+ * is the exact pre-daemon behavior.
394
+ */
395
+ function createPortalClient(opts) {
396
+ if (portalDaemonDisabled()) {
397
+ const client = new PortalClient({
398
+ appUrl: opts.appUrl,
399
+ sessionToken: opts.sessionToken,
400
+ resolveCwd: () => process.cwd(),
401
+ onState: (state) => opts.onState({
402
+ status: state.status,
403
+ machineName: state.machineName,
404
+ friendlyName: state.friendlyName,
405
+ error: state.error,
406
+ grantedAgentIds: state.grantedAgentIds
407
+ })
408
+ });
409
+ return {
410
+ start: () => Promise.resolve(),
411
+ enable: () => client.enable(),
412
+ disable: () => client.disable(),
413
+ grantAgent: (agentId) => client.grantAgent(agentId),
414
+ isGranted: (agentId) => client.isGranted(agentId),
415
+ bind: () => {},
416
+ dispose: () => client.dispose()
417
+ };
418
+ }
419
+ return new PortalDaemonClient(opts);
420
+ }
421
+
231
422
  //#endregion
232
423
  //#region src/chat/portal/use-portal.ts
233
424
  /**
234
- * Owns the `PortalClient` lifecycle for the app session and keeps the store in
235
- * sync with its status. Constructed once from the resolved session; disabling
236
- * happens on unmount so quitting the TUI drops presence and kills any children.
425
+ * Owns the portal DAEMON CLIENT lifecycle for the app session and keeps the
426
+ * store in sync with the daemon's status.
427
+ *
428
+ * The CLI no longer opens the portal connection itself: it attaches to a
429
+ * per-host daemon (spawned on demand) that owns the single portal WebSocket, so
430
+ * multiple `skydive` processes on one machine share one portal identity instead
431
+ * of fighting over it (the flap). This hook just wires the daemon client into
432
+ * the store; the chat screen registers each conversation's working directory via
433
+ * `portalClient.bind(...)` so the daemon runs that conversation's exec in the
434
+ * right place.
435
+ *
237
436
  * `shareMachine` opts in from launch (the `--share-machine` flag); otherwise the
238
- * user turns sharing on from the chat screen.
437
+ * user turns sharing on from the chat screen. Detaching on unmount leaves the
438
+ * daemon running for any other attached CLIs.
239
439
  */
240
440
  function usePortal({ appUrl, sessionToken, shareMachine }) {
241
441
  const syncPortal = useStore((s) => s.syncPortal);
242
442
  const setPortalClient = useStore((s) => s.setPortalClient);
243
443
  useEffect(() => {
244
- const client = new PortalClient({
444
+ const client = createPortalClient({
245
445
  appUrl,
246
446
  sessionToken,
247
- cwd: process.cwd(),
248
447
  onState: (state) => syncPortal({
249
448
  status: state.status,
250
449
  machineName: state.machineName,
@@ -253,7 +452,9 @@ function usePortal({ appUrl, sessionToken, shareMachine }) {
253
452
  })
254
453
  });
255
454
  setPortalClient(client);
256
- if (shareMachine) client.enable();
455
+ client.start().then(() => {
456
+ if (shareMachine) client.enable();
457
+ });
257
458
  return () => {
258
459
  client.dispose();
259
460
  setPortalClient(null);
@@ -272,6 +473,9 @@ function usePortal({ appUrl, sessionToken, shareMachine }) {
272
473
  const noopAgentHost = {
273
474
  transition() {},
274
475
  setSession() {},
476
+ setTitle() {},
477
+ addPullRequest() {},
478
+ removePullRequest() {},
275
479
  dispose() {}
276
480
  };
277
481
  const AgentHostContext = createContext(noopAgentHost);
@@ -293,6 +497,18 @@ function composeAgentHost(adapters) {
293
497
  if (disposed) return;
294
498
  for (const adapter of adapters) adapter.setSession?.(sessionId);
295
499
  },
500
+ setTitle(title) {
501
+ if (disposed) return;
502
+ for (const adapter of adapters) adapter.setTitle?.(title);
503
+ },
504
+ addPullRequest(pr) {
505
+ if (disposed) return;
506
+ for (const adapter of adapters) adapter.addPullRequest?.(pr);
507
+ },
508
+ removePullRequest(pr) {
509
+ if (disposed) return;
510
+ for (const adapter of adapters) adapter.removePullRequest?.(pr);
511
+ },
296
512
  dispose() {
297
513
  if (disposed) return;
298
514
  disposed = true;
@@ -354,6 +570,264 @@ function createTerminalNotificationsAdapter({ renderer, enabled }) {
354
570
  };
355
571
  }
356
572
 
573
+ //#endregion
574
+ //#region src/chat/agent-host/adapters/terminal-title.ts
575
+ /**
576
+ * The title shown when there is no conversation title yet, or after the chat
577
+ * closes. Keeps the tab identifiable as this CLI rather than leaving a stale
578
+ * conversation name behind.
579
+ */
580
+ const DEFAULT_TERMINAL_TITLE = "skydive";
581
+ /**
582
+ * Collapses a conversation title to a single terminal-title line. Titles arrive
583
+ * from autoname / web / Slack and can carry newlines, tabs, or other control
584
+ * characters (the same reason picker rows sanitize them); a raw newline in an
585
+ * OSC title terminates the sequence early and corrupts the tab, so flatten
586
+ * whitespace and drop C0 controls. Empty or whitespace-only input falls back to
587
+ * the default so the tab is never blanked.
588
+ */
589
+ function terminalTitleFor(title) {
590
+ if (!title) return DEFAULT_TERMINAL_TITLE;
591
+ return title.replace(/[\u0000-\u001f\u007f]+/g, " ").trim() || DEFAULT_TERMINAL_TITLE;
592
+ }
593
+ /**
594
+ * Mirrors the live conversation title onto the terminal title (OSC), so a
595
+ * multiplexer tab or window carries the conversation name instead of a bare
596
+ * shell/binary label. OpenTUI owns the escape transport via
597
+ * `setTerminalTitle`, so this never interleaves with the rendered frame.
598
+ *
599
+ * Writes are deduped, and the title is reset to a neutral default on dispose so
600
+ * leaving the chat doesn't strand the last conversation's name on the tab.
601
+ */
602
+ function createTerminalTitleAdapter({ renderer }) {
603
+ let last;
604
+ const apply = (next) => {
605
+ if (next === last) return;
606
+ last = next;
607
+ try {
608
+ renderer.setTerminalTitle(next);
609
+ } catch (_error) {}
610
+ };
611
+ return {
612
+ setTitle(title) {
613
+ apply(terminalTitleFor(title));
614
+ },
615
+ dispose() {
616
+ apply(DEFAULT_TERMINAL_TITLE);
617
+ }
618
+ };
619
+ }
620
+
621
+ //#endregion
622
+ //#region src/chat/agent-host/adapters/cmux/workspace-title.ts
623
+ /**
624
+ * cmux is present only inside a cmux terminal. Reads the socket path and
625
+ * workspace id from the environment; returns null when either is missing, so
626
+ * the caller registers no adapter outside cmux.
627
+ */
628
+ function cmuxWorkspaceEnv(env = process.env) {
629
+ const socketPath = env.CMUX_SOCKET_PATH?.trim();
630
+ const workspaceId = env.CMUX_WORKSPACE_ID?.trim();
631
+ if (!socketPath || !workspaceId) return null;
632
+ if (!socketPath.startsWith("/")) return null;
633
+ return {
634
+ socketPath,
635
+ workspaceId
636
+ };
637
+ }
638
+ /** The fields of a `workspace.current` result we care about. */
639
+ const workspaceCurrentSchema = z.object({
640
+ workspace_id: z.string().optional(),
641
+ has_custom_title: z.boolean().optional()
642
+ });
643
+ /** True when the current workspace carries a user-set custom title. */
644
+ async function hasUserCustomTitle(socket, workspaceId) {
645
+ const res = await socket.request("workspace.current", {});
646
+ const parsed = res?.ok ? workspaceCurrentSchema.safeParse(res.result) : void 0;
647
+ if (!parsed?.success) return false;
648
+ const result = parsed.data;
649
+ if (result.workspace_id && result.workspace_id !== workspaceId) return false;
650
+ return result.has_custom_title === true;
651
+ }
652
+ function createCmuxWorkspaceTitleAdapter({ socket, workspaceId }) {
653
+ let lastApplied;
654
+ let chain = Promise.resolve();
655
+ let pending = null;
656
+ const flush = async () => {
657
+ const desired = pending;
658
+ pending = null;
659
+ if (desired === null || desired === lastApplied) return;
660
+ if (await hasUserCustomTitle(socket, workspaceId)) {
661
+ lastApplied = desired;
662
+ return;
663
+ }
664
+ if ((await socket.request("workspace.rename", {
665
+ workspace_id: workspaceId,
666
+ title: desired
667
+ }))?.ok) lastApplied = desired;
668
+ };
669
+ return { setTitle(title) {
670
+ const next = title?.trim();
671
+ if (!next) return;
672
+ pending = next;
673
+ chain = chain.then(flush, flush);
674
+ } };
675
+ }
676
+
677
+ //#endregion
678
+ //#region src/chat/agent-host/adapters/cmux/sidebar-pr.ts
679
+ /** Neutral gray — deliberately not green/purple; we don't render PR state. */
680
+ const PILL_COLOR = "#8b949e";
681
+ const PILL_ICON = "git-pull-request";
682
+ const PILL_PRIORITY = 90;
683
+ /**
684
+ * The cmux sidebar status key for a PR, derived from its repo-qualified
685
+ * identity (`owner/repo#number`) so PRs from different repos never collide on a
686
+ * shared `pr-<number>` slot. cmux keys are space-split tokens, so we sanitize
687
+ * to `pr-<owner>-<repo>-<number>` (any non-alphanumeric run becomes `-`).
688
+ */
689
+ function pillKey(prKey) {
690
+ return `pr-${prKey.replace(/[^a-z0-9]+/gi, "-")}`;
691
+ }
692
+ /**
693
+ * A V1 socket arg value. cmux's line protocol splits on spaces, so a value
694
+ * with spaces (our `PR #123` label) must be double-quoted; the flag values we
695
+ * send (hex color, url, integers, the workspace id) never contain spaces or
696
+ * quotes, so simple quoting of the label is sufficient and there is nothing to
697
+ * escape.
698
+ */
699
+ function quote(value) {
700
+ return `"${value}"`;
701
+ }
702
+ function createCmuxSidebarPrAdapter({ socket, workspaceId }) {
703
+ const applied = /* @__PURE__ */ new Set();
704
+ let chain = Promise.resolve();
705
+ const enqueue = (step) => {
706
+ chain = chain.then(step).catch((_error) => {});
707
+ };
708
+ const runSet = async (pr) => {
709
+ if (applied.has(pr.key)) return;
710
+ applied.add(pr.key);
711
+ 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}`;
712
+ if (!await socket.command(line)) applied.delete(pr.key);
713
+ };
714
+ const runClear = async (prKey) => {
715
+ if (!applied.has(prKey)) return;
716
+ if (await socket.command(`clear_status ${pillKey(prKey)} --tab=${workspaceId}`)) applied.delete(prKey);
717
+ };
718
+ return {
719
+ addPullRequest(pr) {
720
+ enqueue(() => runSet(pr));
721
+ },
722
+ removePullRequest(pr) {
723
+ enqueue(() => runClear(pr.key));
724
+ },
725
+ dispose() {
726
+ const keys = [...applied];
727
+ for (const key of keys) enqueue(() => runClear(key));
728
+ }
729
+ };
730
+ }
731
+
732
+ //#endregion
733
+ //#region src/chat/agent-host/adapters/cmux/socket.ts
734
+ const cmuxRpcResponseSchema = z.object({
735
+ ok: z.boolean(),
736
+ result: z.unknown().optional(),
737
+ error: z.unknown().optional()
738
+ });
739
+ /** How long to wait for a single round-trip before giving up. */
740
+ const RPC_TIMEOUT_MS = 1500;
741
+ function createCmuxSocket({ socketPath, timeoutMs = RPC_TIMEOUT_MS }) {
742
+ let seq = 0;
743
+ const roundTrip = (requestLine, parseReply, onFailure) => new Promise((resolve) => {
744
+ let settled = false;
745
+ const finish = (value) => {
746
+ if (settled) return;
747
+ settled = true;
748
+ clearTimeout(timer);
749
+ try {
750
+ socket.destroy();
751
+ } catch (_error) {}
752
+ resolve(value);
753
+ };
754
+ const timer = setTimeout(() => finish(onFailure), timeoutMs);
755
+ let buffer = "";
756
+ const socket = connect(socketPath);
757
+ socket.setEncoding("utf8");
758
+ socket.on("connect", () => {
759
+ try {
760
+ socket.write(requestLine);
761
+ } catch (_error) {
762
+ finish(onFailure);
763
+ }
764
+ });
765
+ socket.on("data", (chunk) => {
766
+ buffer += chunk;
767
+ const newline = buffer.indexOf("\n");
768
+ if (newline === -1) return;
769
+ const line = buffer.slice(0, newline);
770
+ try {
771
+ finish(parseReply(line));
772
+ } catch (_error) {
773
+ finish(onFailure);
774
+ }
775
+ });
776
+ socket.on("error", () => finish(onFailure));
777
+ socket.on("close", () => finish(onFailure));
778
+ });
779
+ return {
780
+ command(line) {
781
+ `${++seq}`;
782
+ return roundTrip(`${line}\n`, (reply) => reply.startsWith("OK") || reply === "OK", false);
783
+ },
784
+ request(method, params) {
785
+ return new Promise((resolve) => {
786
+ let settled = false;
787
+ const finish = (value) => {
788
+ if (settled) return;
789
+ settled = true;
790
+ clearTimeout(timer);
791
+ try {
792
+ socket.destroy();
793
+ } catch (_error) {}
794
+ resolve(value);
795
+ };
796
+ const timer = setTimeout(() => finish(null), timeoutMs);
797
+ const id = `skydive-${++seq}`;
798
+ let buffer = "";
799
+ const socket = connect(socketPath);
800
+ socket.setEncoding("utf8");
801
+ socket.on("connect", () => {
802
+ try {
803
+ socket.write(`${JSON.stringify({
804
+ id,
805
+ method,
806
+ params
807
+ })}\n`);
808
+ } catch (_error) {
809
+ finish(null);
810
+ }
811
+ });
812
+ socket.on("data", (chunk) => {
813
+ buffer += chunk;
814
+ const newline = buffer.indexOf("\n");
815
+ if (newline === -1) return;
816
+ const line = buffer.slice(0, newline);
817
+ try {
818
+ const parsed = cmuxRpcResponseSchema.safeParse(JSON.parse(line));
819
+ finish(parsed.success ? parsed.data : null);
820
+ } catch (_error) {
821
+ finish(null);
822
+ }
823
+ });
824
+ socket.on("error", () => finish(null));
825
+ socket.on("close", () => finish(null));
826
+ });
827
+ }
828
+ };
829
+ }
830
+
357
831
  //#endregion
358
832
  //#region src/chat/agent-host/adapters/herdr/adapter.ts
359
833
  /** Maps one complete semantic transition to one herdr state report. */
@@ -523,12 +997,161 @@ function createAgentHost({ renderer, notifications }) {
523
997
  const adapters = [createTerminalNotificationsAdapter({
524
998
  renderer,
525
999
  enabled: notifications
526
- })];
1000
+ }), createTerminalTitleAdapter({ renderer })];
1001
+ const cmux = cmuxWorkspaceEnv();
1002
+ if (cmux) {
1003
+ const socket = createCmuxSocket({ socketPath: cmux.socketPath });
1004
+ adapters.push(createCmuxWorkspaceTitleAdapter({
1005
+ socket,
1006
+ workspaceId: cmux.workspaceId
1007
+ }), createCmuxSidebarPrAdapter({
1008
+ socket,
1009
+ workspaceId: cmux.workspaceId
1010
+ }));
1011
+ }
527
1012
  const herdr = createHerdrReporter();
528
1013
  if (herdr) adapters.push(createHerdrAdapter(herdr));
529
1014
  return composeAgentHost(adapters);
530
1015
  }
531
1016
 
1017
+ //#endregion
1018
+ //#region src/chat/agent-host/pull-requests.ts
1019
+ /**
1020
+ * Matches a GitHub pull-request URL and captures its number. Scoped to
1021
+ * **github.com** on purpose, and this is a scope decision, not an oversight:
1022
+ *
1023
+ * cmux's native sidebar badge also resolves GitHub Enterprise (GHES) PRs, but
1024
+ * it does so from the workspace's **local git remote** (host-qualified
1025
+ * `(host, owner, repo)` + a per-host `gh auth token`). We parse a transcript
1026
+ * in a remote sandbox with no local `.git`, so from a bare URL we cannot tell
1027
+ * `git.acme.com/o/r/pull/5` is a GHES host from any other tool's `/pull/`
1028
+ * route — and a pill linking a misidentified URL is worse than no pill. So we
1029
+ * take the one host we can identify unambiguously (github.com, incl. `www.`),
1030
+ * which covers the common product case; GHES stays the native badge's job.
1031
+ *
1032
+ * Only the `/pull/<n>` form (not `/issues/`, not a bare `#123` — ambiguous with
1033
+ * issues without local repo context we don't have). A trailing path/query/
1034
+ * fragment (e.g. `/files`, `#discussion`) is allowed but not captured into the
1035
+ * canonical URL. Also matches inside markdown links, backticks, and trailing
1036
+ * punctuation.
1037
+ */
1038
+ const PR_URL_RE = /https?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/gi;
1039
+ /**
1040
+ * Extracts every distinct GitHub PR referenced in a block of text, in first-seen
1041
+ * order, de-duplicated by **repo-qualified identity** (`owner/repo#number`) —
1042
+ * NOT by number, since `#42` can exist in many repos. Each ref carries a
1043
+ * canonicalized URL (scheme + host + `/owner/repo/pull/number`, no trailing
1044
+ * path/query/fragment) so the same PR linked twice with different fragments
1045
+ * collapses to one pill.
1046
+ */
1047
+ function extractPullRequests(text, options) {
1048
+ const requireCompleteNumber = options?.requireCompleteNumber ?? true;
1049
+ const byKey = /* @__PURE__ */ new Map();
1050
+ for (const match of text.matchAll(PR_URL_RE)) {
1051
+ const owner = match[1];
1052
+ const repo = match[2];
1053
+ const numberText = match[3];
1054
+ if (!owner || !repo || !numberText) continue;
1055
+ if (requireCompleteNumber) {
1056
+ if (match.index + match[0].length >= text.length) continue;
1057
+ }
1058
+ const number = Number.parseInt(numberText, 10);
1059
+ if (!Number.isFinite(number) || number <= 0) continue;
1060
+ const key = `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`;
1061
+ if (byKey.has(key)) continue;
1062
+ byKey.set(key, {
1063
+ number,
1064
+ key,
1065
+ url: `https://github.com/${owner}/${repo}/pull/${number}`
1066
+ });
1067
+ }
1068
+ return [...byKey.values()];
1069
+ }
1070
+ /**
1071
+ * Flattens an arbitrary tool-output value into one string we can scan for PR
1072
+ * URLs. A tool's output is usually a string (bash stdout, a `gh pr create`
1073
+ * line) but can be a structured payload (an object/array of content parts), so
1074
+ * we walk it and concatenate every string we find. Depth- and size-bounded so
1075
+ * a pathological payload can't blow up the scan; the cap is generous because a
1076
+ * PR URL a tool prints is short and lands early in normal output.
1077
+ */
1078
+ function stringifyToolOutput(value) {
1079
+ const MAX_LEN = 2e5;
1080
+ const parts = [];
1081
+ let total = 0;
1082
+ const visit = (v, depth) => {
1083
+ if (total >= MAX_LEN || depth > 6) return;
1084
+ if (typeof v === "string") {
1085
+ parts.push(v);
1086
+ total += v.length;
1087
+ return;
1088
+ }
1089
+ if (Array.isArray(v)) {
1090
+ for (const item of v) {
1091
+ if (total >= MAX_LEN) return;
1092
+ visit(item, depth + 1);
1093
+ }
1094
+ return;
1095
+ }
1096
+ if (v && typeof v === "object") for (const item of Object.values(v)) {
1097
+ if (total >= MAX_LEN) return;
1098
+ visit(item, depth + 1);
1099
+ }
1100
+ };
1101
+ visit(value, 0);
1102
+ return parts.join("\n");
1103
+ }
1104
+ /**
1105
+ * A streaming PR scanner: fed run chunks as they stream, it reports each
1106
+ * distinct PR the moment its URL is seen, exactly once, without ever rescanning
1107
+ * the transcript. Two sources feed it:
1108
+ *
1109
+ * - **Assistant text** (`text-delta`): accumulated per part id (a URL can split
1110
+ * across two deltas) and only the current part's buffer is scanned, so cost
1111
+ * is bounded by one text block, not the whole scrollback.
1112
+ * - **Tool output** (`tool-output-available`): the settled output of a tool
1113
+ * call (e.g. `gh pr create` stdout) arrives whole, not delta-streamed, so we
1114
+ * flatten and scan it in one pass. This catches PRs a tool printed that the
1115
+ * agent never restated in prose — the common case for an opened PR.
1116
+ *
1117
+ * `onPullRequest` fires once per never-before-seen PR (keyed by
1118
+ * `owner/repo#number`) for the life of the scanner, regardless of which source
1119
+ * it came from. Construct one per run (or per conversation) and feed it the
1120
+ * same chunks the transcript reducer sees.
1121
+ */
1122
+ function createPullRequestScanner(onPullRequest) {
1123
+ let text = "";
1124
+ let lastPartId;
1125
+ const seen = /* @__PURE__ */ new Set();
1126
+ const report = (buffer, final) => {
1127
+ for (const pr of extractPullRequests(buffer, { requireCompleteNumber: !final })) {
1128
+ if (seen.has(pr.key)) continue;
1129
+ seen.add(pr.key);
1130
+ onPullRequest(pr);
1131
+ }
1132
+ };
1133
+ return { addChunk(chunk) {
1134
+ if (chunk["type"] === "tool-output-available") {
1135
+ const outputText = stringifyToolOutput(chunk["output"]);
1136
+ if (!outputText.includes("/pull/")) return;
1137
+ report(outputText, true);
1138
+ return;
1139
+ }
1140
+ if (chunk["type"] !== "text-delta") return;
1141
+ const delta = typeof chunk["delta"] === "string" ? chunk["delta"] : typeof chunk["text"] === "string" ? chunk["text"] : "";
1142
+ if (!delta) return;
1143
+ const partId = typeof chunk["id"] === "string" ? chunk["id"] : void 0;
1144
+ if (partId && lastPartId && partId !== lastPartId) {
1145
+ if (text.includes("/pull/")) report(text, true);
1146
+ text = "";
1147
+ }
1148
+ text += delta;
1149
+ lastPartId = partId ?? lastPartId;
1150
+ if (!text.includes("/pull/")) return;
1151
+ report(text, false);
1152
+ } };
1153
+ }
1154
+
532
1155
  //#endregion
533
1156
  //#region src/chat/clipboard.ts
534
1157
  /** Spawns a command with no stdin and collects its stdout. Resolves (never
@@ -840,22 +1463,52 @@ function portalIndicator(portal) {
840
1463
  };
841
1464
  }
842
1465
  }
843
- function screenLabel(screen, chatTitle) {
1466
+ /**
1467
+ * The per-screen label, split into its parts: the screen's own `title` and,
1468
+ * when the screen belongs to an agent, that `agentName`. Kept structured so the
1469
+ * two surfaces can order the parts differently — the status bar reads
1470
+ * agent-first (a header), the terminal/workspace title reads title-first.
1471
+ */
1472
+ function screenLabelParts(screen, chatTitle) {
844
1473
  switch (screen.kind) {
845
- case "splash": return "";
846
- case "error": return "";
847
- case "agent-picker": return "pick an agent";
848
- case "agent-create": return "create an agent";
849
- case "workspace-picker": return "switch workspace";
850
- case "conversation-picker": return `${screen.agent.name} · pick a conversation`;
1474
+ case "splash": return { title: "" };
1475
+ case "error": return { title: "" };
1476
+ case "agent-picker": return { title: "pick an agent" };
1477
+ case "agent-create": return { title: "create an agent" };
1478
+ case "workspace-picker": return { title: "switch workspace" };
1479
+ case "conversation-picker": return {
1480
+ title: "pick a conversation",
1481
+ agentName: screen.agent.name
1482
+ };
851
1483
  case "chat": {
852
1484
  const c = screen.conversation;
853
1485
  const known = isNewConv(c) ? null : c.title ?? null;
854
- const label = chatTitle ?? known ?? (isNewConv(c) ? "new conversation" : "untitled");
855
- return `${screen.agent.name} · ${label}`;
1486
+ return {
1487
+ title: chatTitle ?? known ?? (isNewConv(c) ? "new conversation" : "untitled"),
1488
+ agentName: screen.agent.name
1489
+ };
856
1490
  }
857
1491
  }
858
1492
  }
1493
+ /**
1494
+ * Status-bar label: agent-first (`agent · title`), which reads as a header of
1495
+ * who you're talking to and where.
1496
+ */
1497
+ function screenLabel(screen, chatTitle) {
1498
+ const { title, agentName } = screenLabelParts(screen, chatTitle);
1499
+ if (!title) return "";
1500
+ return agentName ? `${agentName} · ${title}` : title;
1501
+ }
1502
+ /**
1503
+ * Terminal tab / cmux workspace title: title-first (`title · agent`), so the
1504
+ * conversation reads first in a tab or sidebar entry, with the agent as the
1505
+ * trailing qualifier.
1506
+ */
1507
+ function titleLabel(screen, chatTitle) {
1508
+ const { title, agentName } = screenLabelParts(screen, chatTitle);
1509
+ if (!title) return "";
1510
+ return agentName ? `${title} · ${agentName}` : title;
1511
+ }
859
1512
  function isNewConv(c) {
860
1513
  return "kind" in c && c.kind === "new";
861
1514
  }
@@ -871,19 +1524,42 @@ function windowStart(highlight, total, visible) {
871
1524
 
872
1525
  //#endregion
873
1526
  //#region src/chat/tui/fuzzy.ts
1527
+ function keyAccessor(key) {
1528
+ return typeof key === "function" ? key : key.get;
1529
+ }
1530
+ function keyWeight(key) {
1531
+ return typeof key === "function" ? 1 : key.weight ?? 1;
1532
+ }
874
1533
  /**
875
1534
  * Rank `items` against `query` across multiple keys with fuzzysort — the
876
1535
  * OpenCode pattern. An empty/whitespace query returns everything in the
877
1536
  * caller's original order (fuzzysort has nothing to rank); otherwise items
878
1537
  * where no key matches are dropped and the rest come back best-first.
1538
+ *
1539
+ * fuzzysort's default multi-key score is the *best* single key's raw score,
1540
+ * with every key weighted equally — so an exact word sitting in a low-priority
1541
+ * field (a message preview, a co-participant's name) beats a genuine but
1542
+ * slightly-fuzzier match in the title. When keys carry weights we instead rank
1543
+ * by the best *weighted* key score, so a title match wins ties (and near-ties)
1544
+ * against secondary fields. Per-key highlight indexes are unchanged; only the
1545
+ * ordering shifts.
879
1546
  */
880
1547
  function fuzzyFilter(query, items, keys) {
881
1548
  const needle = query.trim();
1549
+ const accessors = keys.map(keyAccessor);
882
1550
  if (!needle) return items.map((item) => ({
883
1551
  item,
884
1552
  highlights: keys.map(() => null)
885
1553
  }));
886
- return fuzzysort.go(needle, items, { keys: [...keys] }).map((result) => ({
1554
+ const weights = keys.map(keyWeight);
1555
+ const weighted = weights.some((w) => w !== 1);
1556
+ return fuzzysort.go(needle, items, {
1557
+ keys: accessors,
1558
+ ...weighted ? { scoreFn: (r) => Math.max(...weights.map((w, i) => {
1559
+ const keyResult = r[i];
1560
+ return keyResult ? keyResult.score * w : 0;
1561
+ })) } : {}
1562
+ }).map((result) => ({
887
1563
  item: result.obj,
888
1564
  highlights: keys.map((_key, i) => {
889
1565
  const indexes = result[i]?.indexes;
@@ -931,6 +1607,76 @@ function FuzzyText({ text, indexes, fg }) {
931
1607
  }, i)) });
932
1608
  }
933
1609
 
1610
+ //#endregion
1611
+ //#region src/chat/tui/skydive-mark.tsx
1612
+ /**
1613
+ * The Skydive brand splash for the interactive TUI, rendered as OpenTUI React
1614
+ * nodes (not raw ANSI). Two variants:
1615
+ *
1616
+ * - `variant="full"` — the six-petal mark beside the SKYDIVE wordmark.
1617
+ * - `variant="mark"` — just the pinwheel mark (compact picker headers).
1618
+ *
1619
+ * The mark paints fixed brand hexes (a logo is a logo). The wordmark follows
1620
+ * the current theme's body text (`theme.fg`) so it reads white-ish on dark
1621
+ * themes and dark on light ones. Under NO_COLOR the theme palette is all
1622
+ * `undefined`; we detect that and render uncolored so it degrades with the
1623
+ * rest of the UI.
1624
+ */
1625
+ function noColor() {
1626
+ return theme.fg === void 0;
1627
+ }
1628
+ function MarkRows({ mono }) {
1629
+ return /* @__PURE__ */ jsx("box", {
1630
+ style: { flexDirection: "column" },
1631
+ children: MARK_CELLS.map((row, r) => /* @__PURE__ */ jsx("text", { children: row.map((cell, c) => cell === " " ? /* @__PURE__ */ jsx("span", { children: " " }, c) : /* @__PURE__ */ jsx("span", {
1632
+ fg: mono ? void 0 : cell[1],
1633
+ children: cell[0]
1634
+ }, c)) }, r))
1635
+ });
1636
+ }
1637
+ function WordmarkRows({ mono }) {
1638
+ const color = mono ? void 0 : theme.fg;
1639
+ return /* @__PURE__ */ jsx("box", {
1640
+ style: { flexDirection: "column" },
1641
+ children: WORDMARK.map((line, r) => /* @__PURE__ */ jsx("text", { children: /* @__PURE__ */ jsx("span", {
1642
+ fg: color,
1643
+ children: line
1644
+ }) }, r))
1645
+ });
1646
+ }
1647
+ /**
1648
+ * Height-aware brand header for the pickers. Always shown (including while the
1649
+ * list is loading / empty / erroring) so the splash is never gated on data.
1650
+ * Full mark + wordmark when the terminal is tall enough; compact one-liner on
1651
+ * short terminals so the list still has room.
1652
+ */
1653
+ function BrandHeader({ height }) {
1654
+ if (height >= 24) return /* @__PURE__ */ jsxs("box", {
1655
+ style: { flexDirection: "column" },
1656
+ children: [/* @__PURE__ */ jsx(SkydiveMark, { variant: "full" }), /* @__PURE__ */ jsx("box", { style: { height: 1 } })]
1657
+ });
1658
+ return /* @__PURE__ */ jsx("text", {
1659
+ fg: theme.fg,
1660
+ children: "✦ Skydive"
1661
+ });
1662
+ }
1663
+ /** Row budget the BrandHeader consumes; callers subtract this from visibleRows. */
1664
+ function brandHeaderRows(height) {
1665
+ return height >= 24 ? 7 : 1;
1666
+ }
1667
+ function SkydiveMark({ variant = "full" }) {
1668
+ const mono = noColor();
1669
+ if (variant === "mark") return /* @__PURE__ */ jsx(MarkRows, { mono });
1670
+ return /* @__PURE__ */ jsxs("box", {
1671
+ style: { flexDirection: "row" },
1672
+ children: [
1673
+ /* @__PURE__ */ jsx(MarkRows, { mono }),
1674
+ /* @__PURE__ */ jsx("box", { style: { width: 3 } }),
1675
+ /* @__PURE__ */ jsx(WordmarkRows, { mono })
1676
+ ]
1677
+ });
1678
+ }
1679
+
934
1680
  //#endregion
935
1681
  //#region src/chat/tui/screens/agent-picker.tsx
936
1682
  function AgentPickerScreen() {
@@ -998,17 +1744,36 @@ function AgentPickerScreen() {
998
1744
  scope,
999
1745
  cache
1000
1746
  ]);
1001
- if (state.kind === "loading") return /* @__PURE__ */ jsx("text", {
1002
- fg: theme.muted,
1003
- children: "loading agents…"
1747
+ const { height } = useTerminalDimensions();
1748
+ if (state.kind === "loading") return /* @__PURE__ */ jsxs("box", {
1749
+ style: {
1750
+ flexDirection: "column",
1751
+ flexGrow: 1
1752
+ },
1753
+ children: [/* @__PURE__ */ jsx(BrandHeader, { height }), /* @__PURE__ */ jsx("text", {
1754
+ fg: theme.muted,
1755
+ children: "loading agents…"
1756
+ })]
1004
1757
  });
1005
- if (state.kind === "error") return /* @__PURE__ */ jsxs("text", {
1006
- fg: theme.error,
1007
- children: ["error: ", state.message]
1758
+ if (state.kind === "error") return /* @__PURE__ */ jsxs("box", {
1759
+ style: {
1760
+ flexDirection: "column",
1761
+ flexGrow: 1
1762
+ },
1763
+ children: [/* @__PURE__ */ jsx(BrandHeader, { height }), /* @__PURE__ */ jsxs("text", {
1764
+ fg: theme.error,
1765
+ children: ["error: ", state.message]
1766
+ })]
1008
1767
  });
1009
- if (state.agents.length === 0) return /* @__PURE__ */ jsx("text", {
1010
- fg: theme.muted,
1011
- children: scope === "mine" ? "no agents of yours yet. press tab to see all agents, ctrl+w to switch workspace, or ctrl+n to create one." : "no agents in this workspace. press ctrl+w to switch workspace, or ctrl+n to create one."
1768
+ if (state.agents.length === 0) return /* @__PURE__ */ jsxs("box", {
1769
+ style: {
1770
+ flexDirection: "column",
1771
+ flexGrow: 1
1772
+ },
1773
+ children: [/* @__PURE__ */ jsx(BrandHeader, { height }), /* @__PURE__ */ jsx("text", {
1774
+ fg: theme.muted,
1775
+ children: scope === "mine" ? "no agents of yours yet. press tab to see all agents, ctrl+w to switch workspace, or ctrl+n to create one." : "no agents in this workspace. press ctrl+w to switch workspace, or ctrl+n to create one."
1776
+ })]
1012
1777
  });
1013
1778
  return /* @__PURE__ */ jsx(FilterableAgentList, {
1014
1779
  agents: state.agents,
@@ -1026,7 +1791,7 @@ function FilterableAgentList({ agents, scope, onPick }) {
1026
1791
  const hits = fuzzyFilter(query, agents, agentKeys);
1027
1792
  const filtered = hits.map((h) => h.item);
1028
1793
  const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
1029
- const visibleRows = Math.max(3, height - 6);
1794
+ const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height));
1030
1795
  const start = windowStart(clamped, filtered.length, visibleRows);
1031
1796
  const windowed = hits.slice(start, start + visibleRows);
1032
1797
  useKeyboard((key) => {
@@ -1035,7 +1800,8 @@ function FilterableAgentList({ agents, scope, onPick }) {
1035
1800
  else if (key.name === "return") {
1036
1801
  const agent = filtered[clamped];
1037
1802
  if (!agent) return;
1038
- onPick(useStore.getState().seedPrompt !== null ? {
1803
+ const store = useStore.getState();
1804
+ onPick(store.seedPrompt !== null || store.newConversationIntent ? {
1039
1805
  kind: "chat",
1040
1806
  agent,
1041
1807
  conversation: {
@@ -1055,6 +1821,7 @@ function FilterableAgentList({ agents, scope, onPick }) {
1055
1821
  flexGrow: 1
1056
1822
  },
1057
1823
  children: [
1824
+ /* @__PURE__ */ jsx(BrandHeader, { height }),
1058
1825
  /* @__PURE__ */ jsxs("box", {
1059
1826
  style: {
1060
1827
  flexDirection: "row",
@@ -1122,9 +1889,18 @@ function FilterableAgentList({ agents, scope, onPick }) {
1122
1889
  });
1123
1890
  }
1124
1891
  const agentKeys = [
1125
- (a) => a.name,
1126
- (a) => a.slug ?? "",
1127
- (a) => a.title ?? ""
1892
+ {
1893
+ get: (a) => a.name,
1894
+ weight: 1
1895
+ },
1896
+ {
1897
+ get: (a) => a.slug ?? "",
1898
+ weight: .6
1899
+ },
1900
+ {
1901
+ get: (a) => a.title ?? "",
1902
+ weight: .4
1903
+ }
1128
1904
  ];
1129
1905
 
1130
1906
  //#endregion
@@ -1153,6 +1929,17 @@ function AgentCreateScreen() {
1153
1929
  const fail = useStore((s) => s.fail);
1154
1930
  const [name, setName] = useState("");
1155
1931
  const [state, setState] = useState({ kind: "idle" });
1932
+ useEffect(() => {
1933
+ if (!rest) return;
1934
+ let cancelled = false;
1935
+ rest.suggestAgentIdentity().then((suggestion) => {
1936
+ if (cancelled || !suggestion) return;
1937
+ setName((current) => current ? current : suggestion.name);
1938
+ }).catch(() => {});
1939
+ return () => {
1940
+ cancelled = true;
1941
+ };
1942
+ }, [rest]);
1156
1943
  useKeyboard((key) => {
1157
1944
  if (key.name === "escape" && state.kind !== "creating") goTo({ kind: "agent-picker" });
1158
1945
  });
@@ -1515,27 +2302,45 @@ function singleLine(text) {
1515
2302
  return out;
1516
2303
  }
1517
2304
  /**
1518
- * Fit a row's title and trailing detail into `width` columns. The title
1519
- * identifies the row so it gets the space first; the detail takes what is
1520
- * left and is dropped when nothing is.
2305
+ * Fit a row's title, an optional mid-row `tag` and a trailing detail into
2306
+ * `width` columns, each rendered separated by one space. The title identifies
2307
+ * the row so it gets the space first; the tag comes next because it carries
2308
+ * information the detail does not; the detail takes what is left and is
2309
+ * dropped when nothing is.
1521
2310
  */
1522
- function fitRowText(title, detail, width) {
2311
+ function fitRowText(title, detail, width, tag = "") {
1523
2312
  if (width <= 0) return {
1524
2313
  title: "",
1525
- detail: ""
2314
+ detail: "",
2315
+ tag: ""
1526
2316
  };
1527
- if ((detail ? title.length + 1 + detail.length : title.length) <= width) return {
2317
+ const segment = (text) => text ? text.length + 1 : 0;
2318
+ if (title.length + segment(tag) + segment(detail) <= width) return {
1528
2319
  title,
1529
- detail
2320
+ detail,
2321
+ tag
1530
2322
  };
1531
2323
  const room = width - title.length - 1;
1532
2324
  if (room < 2) return {
1533
2325
  title: truncate(title, width),
1534
- detail: ""
2326
+ detail: "",
2327
+ tag: ""
2328
+ };
2329
+ if (title.length + segment(tag) > width) return {
2330
+ title,
2331
+ detail: "",
2332
+ tag: truncate(tag, room)
2333
+ };
2334
+ const detailRoom = room - segment(tag);
2335
+ if (detailRoom < 2) return {
2336
+ title,
2337
+ detail: "",
2338
+ tag
1535
2339
  };
1536
2340
  return {
1537
2341
  title,
1538
- detail: truncate(detail, room)
2342
+ detail: truncate(detail, detailRoom),
2343
+ tag
1539
2344
  };
1540
2345
  }
1541
2346
  /**
@@ -1552,8 +2357,29 @@ function fitHintLine(segments, width) {
1552
2357
  return truncate(kept.join(" · "), width);
1553
2358
  }
1554
2359
 
2360
+ //#endregion
2361
+ //#region src/chat/tui/use-debounced-value.ts
2362
+ /**
2363
+ * Returns a copy of `value` that only updates after it has stopped changing
2364
+ * for `delayMs`. Use it to throttle work driven by fast-changing state — e.g.
2365
+ * a search input whose every keystroke would otherwise fire a new request.
2366
+ *
2367
+ * Mirrors the web app's `useDebouncedValue` so the CLI conversation search
2368
+ * debounces its server query the same way the web sidebar does.
2369
+ */
2370
+ function useDebouncedValue(value, delayMs) {
2371
+ const [debounced, setDebounced] = useState(value);
2372
+ useEffect(() => {
2373
+ const timer = setTimeout(() => setDebounced(value), delayMs);
2374
+ return () => clearTimeout(timer);
2375
+ }, [value, delayMs]);
2376
+ return debounced;
2377
+ }
2378
+
1555
2379
  //#endregion
1556
2380
  //#region src/chat/tui/screens/conversation-picker.tsx
2381
+ const SEARCH_DEBOUNCE_MS = 250;
2382
+ const SEARCH_QUERY_MAX_LENGTH = 200;
1557
2383
  const humanChannels = [
1558
2384
  "web",
1559
2385
  "slack",
@@ -1564,9 +2390,10 @@ function ConversationPickerScreen({ agent }) {
1564
2390
  const rest = useStore((s) => s.rest);
1565
2391
  const goTo = useStore((s) => s.goTo);
1566
2392
  const [showAutomated, setShowAutomated] = useState(false);
2393
+ const [showArchived, setShowArchived] = useState(false);
1567
2394
  const [cache, setCache] = useState({});
1568
2395
  const [state, setState] = useState({ kind: "loading" });
1569
- const cacheKey = showAutomated ? "all" : "human";
2396
+ const cacheKey = `${showAutomated ? "all" : "human"}:${showArchived ? "with-archived" : "active"}`;
1570
2397
  useEffect(() => {
1571
2398
  if (!rest) return;
1572
2399
  const cached = cache[cacheKey];
@@ -1585,6 +2412,7 @@ function ConversationPickerScreen({ agent }) {
1585
2412
  const conversations = await rest.listConversations({
1586
2413
  agentId: agent.id,
1587
2414
  channels: showAutomated ? void 0 : humanChannels,
2415
+ archived: showArchived ? "include" : "exclude",
1588
2416
  onPage: (partial) => {
1589
2417
  if (!cancelled) setState({
1590
2418
  kind: "ready",
@@ -1618,16 +2446,30 @@ function ConversationPickerScreen({ agent }) {
1618
2446
  rest,
1619
2447
  agent.id,
1620
2448
  showAutomated,
2449
+ showArchived,
1621
2450
  cacheKey,
1622
2451
  cache
1623
2452
  ]);
1624
- if (state.kind === "loading") return /* @__PURE__ */ jsx("text", {
1625
- fg: theme.muted,
1626
- children: "loading conversations…"
2453
+ const { height } = useTerminalDimensions();
2454
+ if (state.kind === "loading") return /* @__PURE__ */ jsxs("box", {
2455
+ style: {
2456
+ flexDirection: "column",
2457
+ flexGrow: 1
2458
+ },
2459
+ children: [/* @__PURE__ */ jsx(BrandHeader, { height }), /* @__PURE__ */ jsx("text", {
2460
+ fg: theme.muted,
2461
+ children: "loading conversations…"
2462
+ })]
1627
2463
  });
1628
- if (state.kind === "error") return /* @__PURE__ */ jsxs("text", {
1629
- fg: theme.error,
1630
- children: ["error: ", state.message]
2464
+ if (state.kind === "error") return /* @__PURE__ */ jsxs("box", {
2465
+ style: {
2466
+ flexDirection: "column",
2467
+ flexGrow: 1
2468
+ },
2469
+ children: [/* @__PURE__ */ jsx(BrandHeader, { height }), /* @__PURE__ */ jsxs("text", {
2470
+ fg: theme.error,
2471
+ children: ["error: ", state.message]
2472
+ })]
1631
2473
  });
1632
2474
  return /* @__PURE__ */ jsx(ConversationList, {
1633
2475
  agent,
@@ -1635,49 +2477,125 @@ function ConversationPickerScreen({ agent }) {
1635
2477
  complete: state.complete,
1636
2478
  showAutomated,
1637
2479
  onToggleAutomated: () => setShowAutomated((shown) => !shown),
2480
+ showArchived,
2481
+ onToggleArchived: () => setShowArchived((shown) => !shown),
1638
2482
  onPick: goTo
1639
2483
  });
1640
2484
  }
1641
- function ConversationList({ agent, conversations, complete, showAutomated, onToggleAutomated, onPick }) {
2485
+ function ConversationList({ agent, conversations, complete, showAutomated, onToggleAutomated, showArchived, onToggleArchived, onPick }) {
1642
2486
  const { width, height } = useTerminalDimensions();
1643
2487
  const rest = useStore((s) => s.rest);
1644
2488
  const [query, setQuery] = useState("");
1645
2489
  const [highlight, setHighlight] = useState(0);
2490
+ const debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
2491
+ const isSearching = debouncedQuery.length > 0;
2492
+ const [search, setSearch] = useState(null);
2493
+ useEffect(() => {
2494
+ if (!rest || !isSearching) {
2495
+ setSearch(null);
2496
+ return;
2497
+ }
2498
+ let cancelled = false;
2499
+ setSearch({
2500
+ query: debouncedQuery,
2501
+ state: { kind: "loading" }
2502
+ });
2503
+ (async () => {
2504
+ try {
2505
+ const results = await rest.listConversations({
2506
+ agentId: agent.id,
2507
+ channels: showAutomated ? void 0 : humanChannels,
2508
+ archived: showArchived ? "include" : "exclude",
2509
+ query: debouncedQuery,
2510
+ onPage: (partial) => {
2511
+ if (!cancelled) setSearch({
2512
+ query: debouncedQuery,
2513
+ state: {
2514
+ kind: "ready",
2515
+ conversations: partial
2516
+ }
2517
+ });
2518
+ }
2519
+ });
2520
+ if (cancelled) return;
2521
+ setSearch({
2522
+ query: debouncedQuery,
2523
+ state: {
2524
+ kind: "ready",
2525
+ conversations: results
2526
+ }
2527
+ });
2528
+ } catch (err) {
2529
+ if (cancelled) return;
2530
+ setSearch({
2531
+ query: debouncedQuery,
2532
+ state: {
2533
+ kind: "error",
2534
+ message: err instanceof Error ? err.message : String(err)
2535
+ }
2536
+ });
2537
+ }
2538
+ })();
2539
+ return () => {
2540
+ cancelled = true;
2541
+ };
2542
+ }, [
2543
+ rest,
2544
+ agent.id,
2545
+ debouncedQuery,
2546
+ isSearching,
2547
+ showAutomated,
2548
+ showArchived
2549
+ ]);
1646
2550
  const [removedIds, setRemovedIds] = useState(/* @__PURE__ */ new Set());
2551
+ const [archiveOverrides, setArchiveOverrides] = useState(/* @__PURE__ */ new Map());
1647
2552
  const [confirmId, setConfirmId] = useState(null);
1648
2553
  const [error, setError] = useState(null);
1649
- const list = conversations.filter((c) => !removedIds.has(c.id));
2554
+ const isArchived = (c) => archiveOverrides.get(c.id) ?? c.viewerArchivedAt != null;
2555
+ const list = ((isSearching && search?.query === debouncedQuery && search.state.kind === "ready" ? search.state.conversations : null) ?? conversations).filter((c) => !removedIds.has(c.id) && (showArchived || !isArchived(c)));
1650
2556
  useEffect(() => {
1651
2557
  setHighlight(0);
1652
- }, [showAutomated]);
1653
- const hits = fuzzyFilter(query, list, conversationKeys);
2558
+ }, [showAutomated, showArchived]);
2559
+ const hits = isSearching ? list.map((item) => ({
2560
+ item,
2561
+ highlights: [
2562
+ substringIndexes(singleLine(item.title ?? ""), debouncedQuery),
2563
+ null,
2564
+ null
2565
+ ]
2566
+ })) : fuzzyFilter(query, list, conversationKeys);
1654
2567
  const rows = [{ kind: "new" }, ...hits.map((hit) => ({
1655
2568
  kind: "conv",
1656
2569
  hit
1657
2570
  }))];
1658
2571
  const innerWidth = Math.max(1, width - 2);
1659
2572
  const rowWidth = Math.max(1, innerWidth - 2);
2573
+ const clamped = Math.min(highlight, Math.max(0, rows.length - 1));
2574
+ const highlightRow = rows[clamped];
2575
+ const highlightArchived = highlightRow?.kind === "conv" && isArchived(highlightRow.hit.item);
1660
2576
  const confirmConv = confirmId !== null ? list.find((c) => c.id === confirmId) : void 0;
2577
+ const searchState = isSearching ? search?.state : void 0;
2578
+ const countSegment = isSearching && (searchState?.kind === "loading" || search === null) ? "searching…" : isSearching && searchState?.kind === "error" ? `search failed: ${searchState.message}` : `${hits.length}/${list.length}${complete || isSearching ? "" : " (loading…)"}`;
1661
2579
  const status = confirmConv ? {
1662
2580
  fg: theme.error,
1663
2581
  text: truncate(`delete “${confirmConv.title ?? "(untitled)"}”? y/n`, innerWidth)
1664
2582
  } : error ? {
1665
2583
  fg: theme.error,
1666
- text: truncate(`delete failed: ${error}`, innerWidth)
2584
+ text: truncate(error, innerWidth)
1667
2585
  } : {
1668
2586
  fg: theme.dim,
1669
2587
  text: fitHintLine([
1670
- `${hits.length}/${list.length}${complete ? "" : " (loading…)"}`,
1671
- showAutomated ? "incl. agent runs" : "yours",
2588
+ countSegment,
2589
+ `ctrl+a ${highlightArchived ? "unarchive" : "archive"}`,
2590
+ "ctrl+d delete",
1672
2591
  `tab ${showAutomated ? "hide" : "show"} agent runs`,
2592
+ `shift+tab ${showArchived ? "hide" : "show"} archived`,
2593
+ "esc back",
1673
2594
  "↑/↓ move",
1674
- "↵ open",
1675
- "ctrl+d delete",
1676
- "esc back"
2595
+ "↵ open"
1677
2596
  ], innerWidth)
1678
2597
  };
1679
- const clamped = Math.min(highlight, Math.max(0, rows.length - 1));
1680
- const visibleRows = Math.max(3, height - 6);
2598
+ const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height));
1681
2599
  const start = windowStart(clamped, rows.length, visibleRows);
1682
2600
  const windowed = rows.slice(start, start + visibleRows);
1683
2601
  const open = (row) => {
@@ -1696,18 +2614,50 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
1696
2614
  conversation: row.hit.item
1697
2615
  });
1698
2616
  };
1699
- const deleteConversation = (id) => {
1700
- if (!rest) return;
2617
+ /**
2618
+ * Optimistically drop a row, run `mutate`, and put the row back if it fails.
2619
+ * Delete only — archive keeps the row and flips its mark instead.
2620
+ */
2621
+ const removeRow = (id, mutate, failure) => {
1701
2622
  if (!list.some((c) => c.id === id)) return;
1702
- setConfirmId(null);
1703
2623
  setRemovedIds((prev) => new Set([...prev, id]));
1704
- rest.deleteConversation({ conversationId: id }).catch((err) => {
2624
+ mutate().catch((err) => {
1705
2625
  setRemovedIds((prev) => {
1706
2626
  const next = new Set(prev);
1707
2627
  next.delete(id);
1708
2628
  return next;
1709
2629
  });
1710
- setError(err instanceof Error ? err.message : String(err));
2630
+ setError(`${failure}: ${err instanceof Error ? err.message : String(err)}`);
2631
+ });
2632
+ };
2633
+ const deleteConversation = (id) => {
2634
+ if (!rest) return;
2635
+ setConfirmId(null);
2636
+ removeRow(id, () => rest.deleteConversation({ conversationId: id }), "delete failed");
2637
+ };
2638
+ /**
2639
+ * Archive is per-user, private and reversible, so unlike delete it needs no
2640
+ * y/n confirm. `ctrl+a` toggles: on a row you already archived (only
2641
+ * reachable with archived rows shown) it restores instead — which is the
2642
+ * only in-TUI way back.
2643
+ */
2644
+ const toggleArchived = (conversation) => {
2645
+ if (!rest) return;
2646
+ const id = conversation.id;
2647
+ const archived = !isArchived(conversation);
2648
+ const revert = archiveOverrides.get(id);
2649
+ setArchiveOverrides((prev) => new Map(prev).set(id, archived));
2650
+ rest.setConversationArchived({
2651
+ conversationId: id,
2652
+ archived
2653
+ }).catch((err) => {
2654
+ setArchiveOverrides((prev) => {
2655
+ const next = new Map(prev);
2656
+ if (revert === void 0) next.delete(id);
2657
+ else next.set(id, revert);
2658
+ return next;
2659
+ });
2660
+ setError(`${archived ? "archive" : "unarchive"} failed: ${err instanceof Error ? err.message : String(err)}`);
1711
2661
  });
1712
2662
  };
1713
2663
  useKeyboard((key) => {
@@ -1725,7 +2675,14 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
1725
2675
  setError(null);
1726
2676
  setConfirmId(row.hit.item.id);
1727
2677
  }
1728
- } else if (key.name === "tab") onToggleAutomated();
2678
+ } else if (key.name === "a" && key.ctrl) {
2679
+ const row = rows[clamped];
2680
+ if (row?.kind === "conv") {
2681
+ setError(null);
2682
+ toggleArchived(row.hit.item);
2683
+ }
2684
+ } else if (key.name === "tab" && key.shift) onToggleArchived();
2685
+ else if (key.name === "tab") onToggleAutomated();
1729
2686
  else if (key.name === "return") {
1730
2687
  const row = rows[clamped];
1731
2688
  if (row) open(row);
@@ -1737,6 +2694,7 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
1737
2694
  flexGrow: 1
1738
2695
  },
1739
2696
  children: [
2697
+ /* @__PURE__ */ jsx(BrandHeader, { height }),
1740
2698
  /* @__PURE__ */ jsxs("box", {
1741
2699
  style: {
1742
2700
  flexDirection: "row",
@@ -1749,8 +2707,9 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
1749
2707
  placeholder: "type to filter…",
1750
2708
  focused: confirmId === null,
1751
2709
  width: Math.max(10, width - 12),
2710
+ maxLength: SEARCH_QUERY_MAX_LENGTH,
1752
2711
  onInput: (value) => {
1753
- setQuery(value);
2712
+ setQuery(value.slice(0, SEARCH_QUERY_MAX_LENGTH));
1754
2713
  setHighlight(0);
1755
2714
  }
1756
2715
  })]
@@ -1772,15 +2731,20 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
1772
2731
  fg,
1773
2732
  children: [marker, truncate("+ new conversation", rowWidth)]
1774
2733
  }, "new");
1775
- const { title, detail } = fitRowText(singleLine(row.hit.item.title ?? "(untitled)"), previewLine(row.hit.item), rowWidth);
2734
+ const archived = isArchived(row.hit.item);
2735
+ const { title, detail, tag } = fitRowText(singleLine(row.hit.item.title ?? "(untitled)"), `${archived ? "archived · " : ""}${previewLine(row.hit.item)}`, rowWidth, coAgentsTag(row.hit.item, agent.id));
1776
2736
  return /* @__PURE__ */ jsxs("text", {
1777
- fg,
2737
+ fg: archived && !selected ? theme.dim : fg,
1778
2738
  children: [
1779
2739
  marker,
1780
2740
  /* @__PURE__ */ jsx(FuzzyText, {
1781
2741
  text: title,
1782
2742
  indexes: row.hit.item.title ? row.hit.highlights[0] : null
1783
2743
  }),
2744
+ tag ? /* @__PURE__ */ jsxs("span", {
2745
+ fg: theme.muted,
2746
+ children: [" ", tag]
2747
+ }) : null,
1784
2748
  detail ? /* @__PURE__ */ jsxs("span", {
1785
2749
  fg: theme.dim,
1786
2750
  children: [" ", detail]
@@ -1792,7 +2756,50 @@ function ConversationList({ agent, conversations, complete, showAutomated, onTog
1792
2756
  ]
1793
2757
  });
1794
2758
  }
1795
- const conversationKeys = [(c) => singleLine(c.title ?? ""), (c) => c.preview ?? ""];
2759
+ /**
2760
+ * Tags a row with the thread's other agents (`+Grace, Ada`). The picker is
2761
+ * entered per agent, so that agent stays implicit and only its co-participants
2762
+ * are named — this tag is the only thing in the list distinguishing a
2763
+ * multi-agent thread from a solo one. Empty for a solo thread.
2764
+ *
2765
+ * A string rather than a component because the row's one-line budget has to
2766
+ * account for it (see list-line.ts).
2767
+ */
2768
+ function coAgentsTag(conv, viewerAgentId) {
2769
+ const names = conversationAgents(conv).filter((a) => a.id !== viewerAgentId).map((a) => a.name);
2770
+ return names.length > 0 ? `+${names.join(", ")}` : "";
2771
+ }
2772
+ function conversationAgents(c) {
2773
+ return c.agents && c.agents.length > 0 ? c.agents : [c.agent];
2774
+ }
2775
+ const conversationKeys = [
2776
+ {
2777
+ get: (c) => singleLine(c.title ?? ""),
2778
+ weight: 1
2779
+ },
2780
+ {
2781
+ get: (c) => c.preview ?? "",
2782
+ weight: .6
2783
+ },
2784
+ {
2785
+ get: (c) => conversationAgents(c).map((a) => a.name).join(" "),
2786
+ weight: .4
2787
+ }
2788
+ ];
2789
+ /**
2790
+ * Character indexes of the first case-insensitive occurrence of `needle` in
2791
+ * `haystack`, for highlighting a literal title match under server search (the
2792
+ * server's title branch is a substring ILIKE). Null when it doesn't occur — a
2793
+ * server row can match on message body or participant name without its title
2794
+ * containing the query, in which case nothing in the title is highlighted.
2795
+ */
2796
+ function substringIndexes(haystack, needle) {
2797
+ const trimmed = needle.trim();
2798
+ if (!trimmed) return null;
2799
+ const at = haystack.toLowerCase().indexOf(trimmed.toLowerCase());
2800
+ if (at < 0) return null;
2801
+ return Array.from({ length: trimmed.length }, (_, i) => at + i);
2802
+ }
1796
2803
  function previewLine(c) {
1797
2804
  const when = relativeTime(c.updatedAt);
1798
2805
  const prefix = c.preview ? c.preview.replace(/\s+/g, " ").trim() : "";
@@ -1807,6 +2814,33 @@ function relativeTime(iso) {
1807
2814
  return `${Math.floor(diffMs / 864e5)}d ago`;
1808
2815
  }
1809
2816
 
2817
+ //#endregion
2818
+ //#region src/chat/conversation-ref.ts
2819
+ /**
2820
+ * Parsing the conversation reference a user hands `/conversation`.
2821
+ *
2822
+ * The whole point of the command is that you paste whatever you already have
2823
+ * in front of you, so every form the platform hands out has to work: a bare
2824
+ * id, the `https://skydive.com/c/<id>` link an agent replies with, or the
2825
+ * `skydive chat --resume <id>` line the TUI prints on exit. All three carry
2826
+ * exactly one id, so we pull the first uuid out of the string rather than
2827
+ * teaching this about URL shapes and command syntax.
2828
+ *
2829
+ * Validating here (instead of letting the API answer) is what turns a typo
2830
+ * into "not a conversation id" in the composer rather than a request that
2831
+ * cannot succeed.
2832
+ */
2833
+ const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
2834
+ /**
2835
+ * The conversation id in `input`, lowercased, or null when there isn't one.
2836
+ * Trailing punctuation, query strings and surrounding prose are tolerated —
2837
+ * a pasted link or command line is the expected input, not the exception.
2838
+ */
2839
+ function parseConversationRef(input) {
2840
+ const match = uuidPattern.exec(input);
2841
+ return match ? match[0].toLowerCase() : null;
2842
+ }
2843
+
1810
2844
  //#endregion
1811
2845
  //#region src/chat/paste.ts
1812
2846
  const MAX_PATH_PASTE_CHARS = 4096;
@@ -3264,6 +4298,149 @@ function formatElapsed(totalSeconds) {
3264
4298
  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
3265
4299
  }
3266
4300
 
4301
+ //#endregion
4302
+ //#region src/chat/tui/chat/clamp.ts
4303
+ /** Max bytes we keep for a single clamped string field (head + tail combined). */
4304
+ const MAX_FIELD_CHARS = 24e3;
4305
+ /** Below this a string is never worth clamping (avoids churn on small fields). */
4306
+ const MIN_CLAMP_CHARS = MAX_FIELD_CHARS + 2e3;
4307
+ /** Fraction of the budget kept from the head; the rest is kept from the tail. */
4308
+ const HEAD_FRACTION = .7;
4309
+ /**
4310
+ * Clamp one string to a head + tail with an elision marker in the middle.
4311
+ * Idempotent: a string already carrying our marker is returned unchanged, so
4312
+ * re-clamping a loaded-from-history item (or a re-render) never re-slices.
4313
+ */
4314
+ function clampString(text) {
4315
+ if (text.length <= MIN_CLAMP_CHARS) return text;
4316
+ if (text.includes(ELISION_MARKER_TAG)) return text;
4317
+ const headLen = Math.floor(MAX_FIELD_CHARS * HEAD_FRACTION);
4318
+ const tailLen = MAX_FIELD_CHARS - headLen;
4319
+ const elided = text.length - MAX_FIELD_CHARS;
4320
+ const head = text.slice(0, headLen);
4321
+ const tail = text.slice(text.length - tailLen);
4322
+ return `${head}\n${elisionMarker(elided)}\n${tail}`;
4323
+ }
4324
+ /**
4325
+ * Append to a growing output buffer while keeping it bounded, preferring the
4326
+ * TAIL — for live streaming shell output the newest bytes are what you want on
4327
+ * screen. Used by the `!` and `/sandbox` streams, whose output would otherwise
4328
+ * grow without limit for a long-running command.
4329
+ */
4330
+ function clampedAppend(current, chunk) {
4331
+ const next = current + chunk;
4332
+ if (next.length <= MIN_CLAMP_CHARS) return next;
4333
+ const priorMarkerLen = leadingMarkerLength(current);
4334
+ const bodyLen = next.length - priorMarkerLen;
4335
+ const kept = stripLeadingMarker(next.slice(next.length - MAX_FIELD_CHARS));
4336
+ return `${elisionMarker(Math.max(0, bodyLen - kept.length))}\n${kept}`;
4337
+ }
4338
+ /** Length (incl. trailing newline) of a marker line at the very start of `s`. */
4339
+ function leadingMarkerLength(s) {
4340
+ if (!s.startsWith(ELISION_MARKER_TAG)) return 0;
4341
+ const nl = s.indexOf("\n");
4342
+ return nl === -1 ? s.length : nl + 1;
4343
+ }
4344
+ function stripLeadingMarker(s) {
4345
+ return s.slice(leadingMarkerLength(s));
4346
+ }
4347
+ const ELISION_MARKER_TAG = "⋯ elided";
4348
+ function elisionMarker(bytes) {
4349
+ return `${ELISION_MARKER_TAG} ${formatBytes$2(bytes)} to save memory ⋯`;
4350
+ }
4351
+ function formatBytes$2(n) {
4352
+ if (n < 1024) return `${n} chars`;
4353
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4354
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4355
+ }
4356
+ /**
4357
+ * Recursively clamp the large string fields of an arbitrary tool payload
4358
+ * (input or output `value`). Structure is preserved so the tool renderers,
4359
+ * which reach for known keys like `.stdout` / `.output` / `.content`, still
4360
+ * find them — only the *size* of long strings is bounded. Depth and breadth
4361
+ * are capped so a pathological payload can't make clamping itself expensive.
4362
+ */
4363
+ function clampPayload(value, depth = 0) {
4364
+ if (typeof value === "string") return clampString(value);
4365
+ if (depth >= 6) return value;
4366
+ if (Array.isArray(value)) {
4367
+ const overLong = value.length > 5e3;
4368
+ const source = overLong ? value.slice(0, 5e3) : value;
4369
+ let changed = overLong;
4370
+ const mapped = source.map((v) => {
4371
+ const c = clampPayload(v, depth + 1);
4372
+ if (c !== v) changed = true;
4373
+ return c;
4374
+ });
4375
+ return changed ? mapped : value;
4376
+ }
4377
+ if (isRecord(value)) {
4378
+ let changed = false;
4379
+ const out = {};
4380
+ for (const [k, v] of Object.entries(value)) {
4381
+ const c = clampPayload(v, depth + 1);
4382
+ if (c !== v) changed = true;
4383
+ out[k] = c;
4384
+ }
4385
+ return changed ? out : value;
4386
+ }
4387
+ return value;
4388
+ }
4389
+ /**
4390
+ * Clamp a single MessageItem in place-safe (returns a new object only when it
4391
+ * actually changed). Applied by the reducer when a tool settles and by the
4392
+ * history loader for every loaded item. Streaming text bubbles are left alone
4393
+ * — they're bounded by the model's output and clamping mid-stream would fight
4394
+ * the append path; only large tool/shell payloads and finished long texts are
4395
+ * worth clamping.
4396
+ */
4397
+ function clampItem(item) {
4398
+ switch (item.kind) {
4399
+ case "tool": {
4400
+ const input = item.input == null ? item.input : clampPayload(item.input);
4401
+ const inputText = item.inputText.length > MIN_CLAMP_CHARS ? clampString(item.inputText) : item.inputText;
4402
+ let output = item.output;
4403
+ if (output?.kind === "ok") {
4404
+ const value = clampPayload(output.value);
4405
+ if (value !== output.value) output = {
4406
+ kind: "ok",
4407
+ value
4408
+ };
4409
+ } else if (output?.kind === "error") {
4410
+ const errorText = clampString(output.errorText);
4411
+ if (errorText !== output.errorText) output = {
4412
+ kind: "error",
4413
+ errorText
4414
+ };
4415
+ }
4416
+ if (input === item.input && inputText === item.inputText && output === item.output) return item;
4417
+ return {
4418
+ ...item,
4419
+ input,
4420
+ inputText,
4421
+ output
4422
+ };
4423
+ }
4424
+ case "shell": {
4425
+ const clamped = clampString(item.output);
4426
+ return clamped === item.output ? item : {
4427
+ ...item,
4428
+ output: clamped
4429
+ };
4430
+ }
4431
+ case "assistant-text":
4432
+ case "reasoning": {
4433
+ if (!item.done) return item;
4434
+ const clamped = clampString(item.text);
4435
+ return clamped === item.text ? item : {
4436
+ ...item,
4437
+ text: clamped
4438
+ };
4439
+ }
4440
+ default: return item;
4441
+ }
4442
+ }
4443
+
3267
4444
  //#endregion
3268
4445
  //#region src/chat/tui/chat/reducer.ts
3269
4446
  function str(chunk, key) {
@@ -3346,7 +4523,7 @@ function applyChunk(items, chunk, agentName = null) {
3346
4523
  case "tool-input-available": {
3347
4524
  const toolCallId = str(chunk, "toolCallId");
3348
4525
  const toolName = str(chunk, "toolName");
3349
- const input = chunk.input ?? null;
4526
+ const input = chunk.input == null ? null : clampPayload(chunk.input);
3350
4527
  if (!toolCallId) return [...items];
3351
4528
  if (items.find((m) => m.kind === "tool" && m.id === toolCallId)) return items.map((m) => m.kind === "tool" && m.id === toolCallId ? {
3352
4529
  ...m,
@@ -3367,7 +4544,7 @@ function applyChunk(items, chunk, agentName = null) {
3367
4544
  case "tool-output-available": {
3368
4545
  const toolCallId = str(chunk, "toolCallId");
3369
4546
  if (!toolCallId) return [...items];
3370
- const output = chunk.output ?? null;
4547
+ const output = chunk.output == null ? null : clampPayload(chunk.output);
3371
4548
  return items.map((m) => m.kind === "tool" && m.id === toolCallId ? {
3372
4549
  ...m,
3373
4550
  output: {
@@ -3380,7 +4557,7 @@ function applyChunk(items, chunk, agentName = null) {
3380
4557
  case "tool-output-error": {
3381
4558
  const toolCallId = str(chunk, "toolCallId");
3382
4559
  if (!toolCallId) return [...items];
3383
- const errorText = str(chunk, "errorText") ?? "tool error";
4560
+ const errorText = clampString(str(chunk, "errorText") ?? "tool error");
3384
4561
  return items.map((m) => m.kind === "tool" && m.id === toolCallId ? {
3385
4562
  ...m,
3386
4563
  output: {
@@ -3412,17 +4589,19 @@ function applyChunk(items, chunk, agentName = null) {
3412
4589
  if (!isRecord(data)) return [...items];
3413
4590
  const card = parseConnectCard(data.spec);
3414
4591
  if (!card) return [...items];
3415
- const id = (typeof data.toolCallId === "string" ? data.toolCallId : null) ?? cryptoId();
3416
- const next = {
4592
+ const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : null;
4593
+ const specKey = specKeyFor(data.spec);
4594
+ if (items.some((m) => m.kind === "card" && m.toolCallId === toolCallId && m.specKey === specKey)) return [...items];
4595
+ return [...items, {
3417
4596
  kind: "card",
3418
- id,
4597
+ id: nextCardId(items, toolCallId),
4598
+ toolCallId,
4599
+ specKey,
3419
4600
  card,
3420
4601
  status: "idle",
3421
4602
  detail: null,
3422
4603
  agentName
3423
- };
3424
- if (items.some((m) => m.kind === "card" && m.id === id)) return items.map((m) => m.kind === "card" && m.id === id ? next : m);
3425
- return [...items, next];
4604
+ }];
3426
4605
  }
3427
4606
  case "data-anyone-conversation-started": return [...items];
3428
4607
  default: return [...items];
@@ -3525,6 +4704,38 @@ function directiveIdsFrom(data) {
3525
4704
  function cryptoId() {
3526
4705
  return crypto.randomUUID();
3527
4706
  }
4707
+ /** Distinct id per card, so cards from one tool call stay independently
4708
+ * addressable (status updates and the React key both go by `id`). */
4709
+ function nextCardId(items, toolCallId) {
4710
+ if (!toolCallId) return cryptoId();
4711
+ return `${toolCallId}#c${items.filter((m) => m.kind === "card" && m.toolCallId === toolCallId).length}`;
4712
+ }
4713
+
4714
+ //#endregion
4715
+ //#region src/chat/tui/chat/window.ts
4716
+ /** Max transcript rows mounted at once. */
4717
+ const MAX_MOUNTED_ROWS = 400;
4718
+ /**
4719
+ * Keep the most recent `max` rows mounted; report how many older ones were
4720
+ * dropped from the mount so the UI can show a "N earlier messages" marker.
4721
+ */
4722
+ function windowItems(items, max = MAX_MOUNTED_ROWS) {
4723
+ if (items.length <= max) return {
4724
+ visible: items.map((item, index) => ({
4725
+ item,
4726
+ index
4727
+ })),
4728
+ hiddenCount: 0
4729
+ };
4730
+ const start = items.length - max;
4731
+ return {
4732
+ visible: items.slice(start).map((item, offset) => ({
4733
+ item,
4734
+ index: start + offset
4735
+ })),
4736
+ hiddenCount: start
4737
+ };
4738
+ }
3528
4739
 
3529
4740
  //#endregion
3530
4741
  //#region src/chat/tui/chat/attribution.ts
@@ -3638,11 +4849,16 @@ function uiMessagesToItems(messages) {
3638
4849
  if (part.type === "data-anyone-render-spec") {
3639
4850
  const data = isRecord(part) && isRecord(part.data) ? part.data : null;
3640
4851
  const card = data ? parseConnectCard(data.spec) : null;
3641
- if (card) {
3642
- const toolCallId = data && typeof data.toolCallId === "string" ? data.toolCallId : null;
4852
+ if (card && data) {
4853
+ const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : null;
4854
+ const specKey = specKeyFor(data.spec);
4855
+ if (out.some((item) => item.kind === "card" && item.toolCallId === toolCallId && item.specKey === specKey)) continue;
4856
+ const ordinal = out.filter((item) => item.kind === "card" && item.toolCallId === toolCallId).length;
3643
4857
  out.push({
3644
4858
  kind: "card",
3645
- id: toolCallId ?? `${m.id}#c${textIdx++}`,
4859
+ id: toolCallId ? `${toolCallId}#c${ordinal}` : `${m.id}#c${textIdx++}`,
4860
+ toolCallId,
4861
+ specKey,
3646
4862
  card,
3647
4863
  status: "idle",
3648
4864
  detail: null,
@@ -3680,7 +4896,7 @@ function uiMessagesToItems(messages) {
3680
4896
  });
3681
4897
  }
3682
4898
  }
3683
- return out;
4899
+ return out.map(clampItem);
3684
4900
  }
3685
4901
  function mapToolState(raw) {
3686
4902
  switch (raw) {
@@ -3883,13 +5099,128 @@ function TodoCardView({ todos, isActive }) {
3883
5099
  });
3884
5100
  }
3885
5101
 
5102
+ //#endregion
5103
+ //#region src/chat/tui/chat/pr-links.tsx
5104
+ /**
5105
+ * Most PRs to show inline before collapsing the rest into a `+N` count. The
5106
+ * chip shares one row with the send/steer hint, so more than a couple would
5107
+ * crowd or wrap that row; the newest few are the useful ones.
5108
+ */
5109
+ const MAX_PR_CHIPS = 3;
5110
+ /**
5111
+ * Builds the styled `· PR #123 #124` chip as OpenTUI chunks. The number chunks
5112
+ * carry a first-class `link` (OpenTUI emits it as a zero-width OSC 8 hyperlink,
5113
+ * clickable in terminals that support it) — the same mechanism the transcript's
5114
+ * linkified markdown uses. We do NOT hand-roll OSC 8 escape strings into text
5115
+ * children: OpenTUI measures width by string length and emits its own SGR, so a
5116
+ * raw escape corrupts layout and leaks bytes on screen.
5117
+ *
5118
+ * Caps at {@link MAX_PR_CHIPS} shown; any beyond that collapse into a trailing
5119
+ * `+N` so the footer row can't be flooded by a run that links many PRs. Shows
5120
+ * the most recent (last-seen) PRs, since those are what's in play.
5121
+ */
5122
+ function prLinksStyledText(prs) {
5123
+ const dot = fg(theme.dim ?? "#565f89");
5124
+ const label = fg(theme.muted ?? "#7a7a7a");
5125
+ const accent = fg(theme.accent ?? "#7aa2f7");
5126
+ const shown = prs.slice(-MAX_PR_CHIPS);
5127
+ const overflow = prs.length - shown.length;
5128
+ const chunks = [dot("· "), label("PR ")];
5129
+ shown.forEach((pr, i) => {
5130
+ if (i > 0) chunks.push(label(" "));
5131
+ chunks.push(accent(link(pr.url)(`#${pr.number}`)));
5132
+ });
5133
+ if (overflow > 0) chunks.push(label(` +${overflow}`));
5134
+ return new StyledText(chunks);
5135
+ }
5136
+ /**
5137
+ * An inline chip for the conversation's linked PR(s), rendered on the
5138
+ * composer's status/hint row just after the send/steer hint — mirrors the
5139
+ * Claude Code affordance. Renders nothing when there are no PRs.
5140
+ */
5141
+ function PrLinksView({ prs }) {
5142
+ if (prs.length === 0) return null;
5143
+ return /* @__PURE__ */ jsx("text", { content: prLinksStyledText(prs) });
5144
+ }
5145
+
5146
+ //#endregion
5147
+ //#region src/chat/tui/chat/use-compaction.ts
5148
+ const COMPACTING_INDICATOR_TIMEOUT_MS = 12e4;
5149
+ /**
5150
+ * Owns everything about manual conversation compaction (`/compact`): the
5151
+ * persistent "compacting…" indicator state, the fire-and-forget command, the
5152
+ * reaction to conversation-stream `compaction` events, and the safety timeout
5153
+ * that clears a stranded `started` so the indicator can never pin forever.
5154
+ *
5155
+ * The screen keeps a single `streamConversation` subscription (which also
5156
+ * carries `run`/`conversation` events), so compaction events arrive there and
5157
+ * are forwarded in via {@link UseCompaction.onCompactionEvent} rather than this
5158
+ * hook opening its own stream.
5159
+ */
5160
+ function useCompaction({ rest, conversationId }) {
5161
+ const [compacting, setCompacting] = useState(false);
5162
+ const timerRef = useRef(null);
5163
+ const clearTimer = useCallback(() => {
5164
+ if (timerRef.current) {
5165
+ clearTimeout(timerRef.current);
5166
+ timerRef.current = null;
5167
+ }
5168
+ }, []);
5169
+ const armIndicator = useCallback(() => {
5170
+ clearTimer();
5171
+ setCompacting(true);
5172
+ timerRef.current = setTimeout(() => setCompacting(false), COMPACTING_INDICATOR_TIMEOUT_MS);
5173
+ }, [clearTimer]);
5174
+ const onCompactionEvent = useCallback((status) => {
5175
+ if (status === "started") {
5176
+ armIndicator();
5177
+ return;
5178
+ }
5179
+ clearTimer();
5180
+ setCompacting(false);
5181
+ if (status === "failed") useStore.getState().showToast({
5182
+ variant: "error",
5183
+ message: "compaction failed"
5184
+ });
5185
+ }, [armIndicator, clearTimer]);
5186
+ const compactConversation = useCallback(() => {
5187
+ if (!rest || !conversationId) {
5188
+ useStore.getState().showToast({ message: "nothing to compact yet — send a message first" });
5189
+ return;
5190
+ }
5191
+ armIndicator();
5192
+ rest.compactConversation({ conversationId }).catch((err) => {
5193
+ clearTimer();
5194
+ setCompacting(false);
5195
+ useStore.getState().showToast({
5196
+ variant: "error",
5197
+ message: `couldn't compact: ${errorDetail(err)}`
5198
+ });
5199
+ });
5200
+ }, [
5201
+ rest,
5202
+ conversationId,
5203
+ armIndicator,
5204
+ clearTimer
5205
+ ]);
5206
+ useEffect(() => clearTimer, [clearTimer]);
5207
+ return {
5208
+ compacting,
5209
+ compactConversation,
5210
+ onCompactionEvent
5211
+ };
5212
+ }
5213
+
3886
5214
  //#endregion
3887
5215
  //#region src/chat/tui/chat/footer-hints.ts
3888
5216
  /** Build the chat footer copy independently of its colors and layout. */
3889
- function formatChatFooterStatus({ runKind, ctrlCArmed, isShared, hasPendingSteer, hasActionableCard }) {
3890
- const connectHint = hasActionableCard ? "ctrl+r connect · " : "";
5217
+ function formatChatFooterStatus({ runKind, ctrlCArmed, isShared, hasPendingSteer, actionableCardCount, nonWebChannel }) {
5218
+ const connectHint = actionableCardCount > 1 ? `ctrl+r connect (${actionableCardCount}) · ` : actionableCardCount === 1 ? "ctrl+r connect · " : "";
3891
5219
  switch (runKind) {
3892
- case "idle": return ctrlCArmed ? "press ctrl+c again to quit" : `${connectHint}↵ send · / commands · esc back · ctrl+c quit · ctrl+t ${isShared ? "revoke access" : "local access"} · ? keybinds`;
5220
+ case "idle":
5221
+ if (ctrlCArmed) return "press ctrl+c again to quit";
5222
+ if (nonWebChannel) return `esc back · ctrl+c quit · ctrl+t ${isShared ? "revoke access" : "local access"} · ? keybinds`;
5223
+ return `${connectHint}↵ send · / commands · esc back · ctrl+c quit · ctrl+t ${isShared ? "revoke access" : "local access"} · ? keybinds`;
3893
5224
  case "sending": return "sending…";
3894
5225
  case "streaming": return hasPendingSteer ? `${connectHint}↵ steer · esc cancel steer · ctrl+c cancel run` : `${connectHint}↵ steer · esc leave (keeps running) · ctrl+c cancel`;
3895
5226
  }
@@ -3928,9 +5259,29 @@ function formatShare(share) {
3928
5259
  //#region src/chat/notification-preview.ts
3929
5260
  const MAX_NOTIFICATION_PREVIEW_CHARS = 240;
3930
5261
  /**
5262
+ * Flattens common inline markdown to plain text for a notification / sidebar
5263
+ * preview, which renders as raw text (no markdown). Without this the preview
5264
+ * shows literal `**bold**` and `*italic*` markers. Intentionally lightweight —
5265
+ * not a full parser, just the inline markers that show up in a one-line
5266
+ * preview: emphasis, inline code, links/images, headings, blockquotes, and
5267
+ * list bullets. Block structure is already gone by the time we preview (we
5268
+ * collapse whitespace to a single line).
5269
+ */
5270
+ function stripInlineMarkdown(input) {
5271
+ return input.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/(\*\*\*|\*\*|\*|___|__|_)(?=\S)(.*?\S)\1/g, "$2").replace(/(\*\*\*|\*\*|\*|___|__|_)(?=\S)(.*?\S)\1/g, "$2").replace(/~~(?=\S)(.*?\S)~~/g, "$1").replace(/^\s{0,3}#{1,6}\s+/gm, "").replace(/^\s{0,3}>\s?/gm, "").replace(/^\s{0,3}(?:[-*+]|\d+\.)\s+/gm, "");
5272
+ }
5273
+ /**
3931
5274
  * Collects assistant `text-delta` chunks for one run and turns them into a
3932
- * desktop-notification-sized preview. Reasoning, tool output, cards, errors,
5275
+ * notification/side-panel-sized preview. Reasoning, tool output, cards, errors,
3933
5276
  * and old transcript items are intentionally excluded.
5277
+ *
5278
+ * A run is a sequence of text parts split by tool calls and reasoning
5279
+ * (text → tool → text → … → final text). The meaningful summary of a run is
5280
+ * its **last** text part: the closing answer the agent lands on after all its
5281
+ * tool work, not the "let me look into this…" preamble it opened with. So the
5282
+ * preview keeps only the final part's text, and when that part is longer than
5283
+ * the cap it keeps the **beginning** with a trailing ellipsis (the
5284
+ * conventional truncation) — the block already opens on the answer.
3934
5285
  */
3935
5286
  function createResponsePreview() {
3936
5287
  let text = "";
@@ -3940,12 +5291,12 @@ function createResponsePreview() {
3940
5291
  if (chunk.type !== "text-delta") return;
3941
5292
  const delta = typeof chunk.delta === "string" ? chunk.delta : typeof chunk.text === "string" ? chunk.text : "";
3942
5293
  const partId = typeof chunk.id === "string" ? chunk.id : void 0;
3943
- if (text && partId && lastPartId && partId !== lastPartId) text += " ";
5294
+ if (partId && lastPartId && partId !== lastPartId) text = "";
3944
5295
  text += delta;
3945
5296
  lastPartId = partId ?? lastPartId;
3946
5297
  },
3947
5298
  get() {
3948
- const normalized = text.replace(/\s+/g, " ").trim();
5299
+ const normalized = stripInlineMarkdown(text).replace(/\s+/g, " ").trim();
3949
5300
  if (!normalized) return void 0;
3950
5301
  const characters = Array.from(normalized);
3951
5302
  if (characters.length <= MAX_NOTIFICATION_PREVIEW_CHARS) return normalized;
@@ -4197,6 +5548,10 @@ const keybindGroups = [
4197
5548
  keys: "exit",
4198
5549
  action: "quit (type it as a message)"
4199
5550
  },
5551
+ {
5552
+ keys: "ctrl+a",
5553
+ action: "archive this conversation (composer empty; hides it for you only)"
5554
+ },
4200
5555
  {
4201
5556
  keys: "ctrl+p",
4202
5557
  action: "switch model"
@@ -4225,14 +5580,26 @@ const keybindGroups = [
4225
5580
  keys: "ctrl+l",
4226
5581
  action: "open this conversation in the browser"
4227
5582
  },
5583
+ {
5584
+ keys: "ctrl+f",
5585
+ action: "fork a channel conversation into a chat you can continue here"
5586
+ },
4228
5587
  {
4229
5588
  keys: "ctrl+r",
4230
- action: "run the newest connect card (auth cards)"
5589
+ action: "act on a waiting connect card (auth cards)"
4231
5590
  },
4232
5591
  {
4233
5592
  keys: "pgup / pgdn",
4234
5593
  action: "scroll transcript"
4235
5594
  },
5595
+ {
5596
+ keys: "end",
5597
+ action: "jump to latest (when scrolled up)"
5598
+ },
5599
+ {
5600
+ keys: "/plan [show|hide]",
5601
+ action: "show / hide the pinned plan card"
5602
+ },
4236
5603
  {
4237
5604
  keys: "/",
4238
5605
  action: "open the command menu (start of message)"
@@ -4294,6 +5661,10 @@ const keybindGroups = [
4294
5661
  keys: "1 / 2",
4295
5662
  action: "switch Changes / Files"
4296
5663
  },
5664
+ {
5665
+ keys: "\\",
5666
+ action: "toggle side / stacked layout"
5667
+ },
4297
5668
  {
4298
5669
  keys: "↵ / o / l",
4299
5670
  action: "focus selected file preview"
@@ -4369,14 +5740,22 @@ const keybindGroups = [
4369
5740
  keys: "↵",
4370
5741
  action: "open conversation"
4371
5742
  },
5743
+ {
5744
+ keys: "ctrl+a",
5745
+ action: "archive conversation (hides it for you only; again to restore)"
5746
+ },
4372
5747
  {
4373
5748
  keys: "ctrl+d",
4374
- action: "delete conversation (y/↵ confirm, n/esc keep)"
5749
+ action: "delete conversation for everyone (y/↵ confirm, n/esc keep)"
4375
5750
  },
4376
5751
  {
4377
5752
  keys: "tab",
4378
5753
  action: "show / hide the agent's own runs (schedules, webhooks, subagents)"
4379
5754
  },
5755
+ {
5756
+ keys: "shift+tab",
5757
+ action: "show / hide the conversations you archived"
5758
+ },
4380
5759
  {
4381
5760
  keys: "esc",
4382
5761
  action: "back to agent picker"
@@ -4659,6 +6038,27 @@ const slashCommands = [
4659
6038
  description: "start a new conversation with this agent",
4660
6039
  action: { kind: "new-conversation" }
4661
6040
  },
6041
+ {
6042
+ name: "fork",
6043
+ aliases: [],
6044
+ argsHint: null,
6045
+ description: "fork this conversation into a new one (full copy)",
6046
+ action: { kind: "fork" }
6047
+ },
6048
+ {
6049
+ name: "archive",
6050
+ aliases: [],
6051
+ argsHint: null,
6052
+ description: "archive this conversation and hide it from your list",
6053
+ action: { kind: "archive-conversation" }
6054
+ },
6055
+ {
6056
+ name: "conversation",
6057
+ aliases: ["conversations", "conv"],
6058
+ argsHint: "[id or link]",
6059
+ description: "switch conversation (no id: pick from the list)",
6060
+ action: { kind: "conversation" }
6061
+ },
4662
6062
  {
4663
6063
  name: "model",
4664
6064
  aliases: [],
@@ -4680,6 +6080,16 @@ const slashCommands = [
4680
6080
  description: "show connection and session status",
4681
6081
  action: { kind: "status" }
4682
6082
  },
6083
+ {
6084
+ name: "plan",
6085
+ aliases: ["todos", "todo"],
6086
+ argsHint: "[show|hide]",
6087
+ description: "show or hide the pinned plan card",
6088
+ action: {
6089
+ kind: "plan",
6090
+ mode: "toggle"
6091
+ }
6092
+ },
4683
6093
  {
4684
6094
  name: "share",
4685
6095
  aliases: [],
@@ -4698,9 +6108,30 @@ const slashCommands = [
4698
6108
  name: "connect",
4699
6109
  aliases: [],
4700
6110
  argsHint: null,
4701
- description: "run the newest connect card (auth cards)",
6111
+ description: "act on a waiting connect card (auth cards)",
4702
6112
  action: { kind: "connect" }
4703
6113
  },
6114
+ {
6115
+ name: "copy",
6116
+ aliases: ["cp"],
6117
+ argsHint: null,
6118
+ description: "copy the agent's last message to the clipboard",
6119
+ action: { kind: "copy" }
6120
+ },
6121
+ {
6122
+ name: "portal",
6123
+ aliases: ["local"],
6124
+ argsHint: null,
6125
+ description: "allow this agent to access your computer",
6126
+ action: { kind: "portal" }
6127
+ },
6128
+ {
6129
+ name: "compact",
6130
+ aliases: [],
6131
+ argsHint: null,
6132
+ description: "summarize older turns to free up context now",
6133
+ action: { kind: "compact" }
6134
+ },
4704
6135
  {
4705
6136
  name: "help",
4706
6137
  aliases: [],
@@ -4723,7 +6154,7 @@ const slashCommands = [
4723
6154
  *
4724
6155
  * An argument-less command only matches as the exact line — `/help me write
4725
6156
  * a plan` stays an ordinary message rather than swallowing the user's text
4726
- * into the help modal. Only `sandbox` accepts a rest.
6157
+ * into the help modal. Only `sandbox` and `conversation` accept a rest.
4727
6158
  */
4728
6159
  function matchSlashCommand(line) {
4729
6160
  if (!line.startsWith("/")) return null;
@@ -4796,6 +6227,93 @@ function printResumeHint(write = (text) => process.stdout.write(text)) {
4796
6227
  lastConversationId = null;
4797
6228
  }
4798
6229
 
6230
+ //#endregion
6231
+ //#region src/chat/tui/screens/card-picker.tsx
6232
+ /**
6233
+ * Asks which connect card ctrl+r should act on. A conversation can hold
6234
+ * several cards waiting at once (a batch of `platform auth configure` calls),
6235
+ * and the chord can't guess which one the user means — picking the newest
6236
+ * silently strands the rest, since a card left in `launched` or `error` stays
6237
+ * actionable forever. Rendered as an overlay in place of the transcript, like
6238
+ * the model and theme pickers, so the conversation and any live run survive.
6239
+ */
6240
+ function CardPicker({ cards, onSelect, onCancel }) {
6241
+ const { height } = useTerminalDimensions();
6242
+ const [highlight, setHighlight] = useState(0);
6243
+ const clamped = Math.min(highlight, Math.max(0, cards.length - 1));
6244
+ const visibleRows = Math.max(3, height - 6);
6245
+ const start = windowStart(clamped, cards.length, visibleRows);
6246
+ const windowed = cards.slice(start, start + visibleRows);
6247
+ useKeyboard((key) => {
6248
+ if (key.name === "escape") {
6249
+ onCancel();
6250
+ return;
6251
+ }
6252
+ if (key.name === "up") setHighlight(Math.max(0, clamped - 1));
6253
+ else if (key.name === "down") setHighlight(Math.min(Math.max(0, cards.length - 1), clamped + 1));
6254
+ else if (key.name === "return") {
6255
+ const card = cards[clamped];
6256
+ if (card) onSelect(card);
6257
+ }
6258
+ });
6259
+ useEffect(() => {
6260
+ if (cards.length === 0) onCancel();
6261
+ }, [cards.length, onCancel]);
6262
+ if (cards.length === 0) return null;
6263
+ return /* @__PURE__ */ jsxs("box", {
6264
+ style: {
6265
+ flexDirection: "column",
6266
+ flexGrow: 1
6267
+ },
6268
+ children: [
6269
+ /* @__PURE__ */ jsx("text", {
6270
+ fg: theme.accent,
6271
+ children: "connect"
6272
+ }),
6273
+ /* @__PURE__ */ jsx("box", {
6274
+ style: {
6275
+ flexDirection: "column",
6276
+ flexGrow: 1,
6277
+ marginTop: 1
6278
+ },
6279
+ children: windowed.map((card, i) => {
6280
+ const selected = start + i === clamped;
6281
+ const waiting = waitingOn(card);
6282
+ const label = card.card.button?.label ?? "";
6283
+ return /* @__PURE__ */ jsxs("text", {
6284
+ fg: selected ? theme.accent : theme.fg,
6285
+ children: [
6286
+ selected ? "› " : " ",
6287
+ "⚿ ",
6288
+ card.card.title,
6289
+ label ? /* @__PURE__ */ jsxs("span", {
6290
+ fg: theme.muted,
6291
+ children: [" · ", label]
6292
+ }) : null,
6293
+ waiting ? /* @__PURE__ */ jsxs("span", {
6294
+ fg: theme.dim,
6295
+ children: [" · ", waiting]
6296
+ }) : null
6297
+ ]
6298
+ }, card.id);
6299
+ })
6300
+ }),
6301
+ /* @__PURE__ */ jsxs("text", {
6302
+ fg: theme.dim,
6303
+ children: [cards.length, " waiting · ↑/↓ move · ↵ select · esc cancel"]
6304
+ })
6305
+ ]
6306
+ });
6307
+ }
6308
+ /** Why a card is still on the list, for the rows that aren't simply untouched. */
6309
+ function waitingOn(card) {
6310
+ switch (card.status) {
6311
+ case "launched": return "finish in your browser";
6312
+ case "error": return card.detail ?? "failed";
6313
+ default: return null;
6314
+ }
6315
+ }
6316
+
4799
6317
  //#endregion
4800
6318
  //#region src/chat/tui/screens/model-picker.tsx
4801
6319
  /**
@@ -5008,9 +6526,18 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
5008
6526
  });
5009
6527
  }
5010
6528
  const modelKeys = [
5011
- (m) => m.displayName,
5012
- (m) => m.providerDisplay ?? "",
5013
- (m) => m.id
6529
+ {
6530
+ get: (m) => m.displayName,
6531
+ weight: 1
6532
+ },
6533
+ {
6534
+ get: (m) => m.providerDisplay ?? "",
6535
+ weight: .9
6536
+ },
6537
+ {
6538
+ get: (m) => m.id,
6539
+ weight: .9
6540
+ }
5014
6541
  ];
5015
6542
  /**
5016
6543
  * The REST error routes return `{ "error": "message" }`. Pull that out for a
@@ -6676,7 +8203,7 @@ function gcOnce() {
6676
8203
  gcStarted = true;
6677
8204
  store.gc();
6678
8205
  }
6679
- function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHeight, active, shellLayout, tab, onTabChange, selectedChangeFile, onSelectedChangeFileChange, selectedWorkspaceFile, onSelectedWorkspaceFileChange, navigatorSize, onNavigatorResize, onInsertToComposer, onRequestChatFocus, onClose }) {
8206
+ function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHeight, active, shellLayout, tab, onTabChange, selectedChangeFile, onSelectedChangeFileChange, selectedWorkspaceFile, onSelectedWorkspaceFileChange, navigatorSize, onNavigatorResize, onToggleOrientation, onInsertToComposer, onRequestChatFocus, onClose }) {
6680
8207
  const width = viewportWidth;
6681
8208
  const height = viewportHeight;
6682
8209
  const shellHeight = Math.max(6, height - 2);
@@ -6827,6 +8354,10 @@ function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHei
6827
8354
  onTabChange("files");
6828
8355
  return;
6829
8356
  }
8357
+ if (key.name === "\\") {
8358
+ if (onToggleOrientation() === "unavailable") setStatus("no room to change layout in this terminal");
8359
+ return;
8360
+ }
6830
8361
  if (key.name === "s") {
6831
8362
  if (comments.length === 0) {
6832
8363
  setStatus("no pending comments");
@@ -7217,18 +8748,93 @@ function PaneRowView({ row, width, isCursor, inSelection, isAnchor, isCommented
7217
8748
  //#endregion
7218
8749
  //#region src/chat/tui/file-pane-layout.ts
7219
8750
  /**
8751
+ * Terminal cells are about twice as tall as they are wide, so a raw
8752
+ * cols-vs-rows compare would call almost any terminal "wide". Scale rows by
8753
+ * this factor to compare the *visual* aspect: a square-looking terminal has
8754
+ * `width ≈ height * CELL_ASPECT`.
8755
+ */
8756
+ const CELL_ASPECT = 2;
8757
+ /**
8758
+ * The automatic split follows the terminal's visual aspect: wider than tall
8759
+ * places review beside the chat (a vertical divider), taller than wide stacks
8760
+ * it underneath (a horizontal divider). This is the responsive default when
8761
+ * the user hasn't pinned an orientation.
8762
+ */
8763
+ function autoPrefersSide({ width, height }) {
8764
+ return width >= height * CELL_ASPECT;
8765
+ }
8766
+ /**
8767
+ * Bare-minimum room to render a split at all (as opposed to the higher
8768
+ * comfort thresholds the automatic choice uses). A user pin is honored down
8769
+ * to these: chat + review each need a usable strip, but we don't insist on
8770
+ * the roomier auto breakpoints. Mirrors the clamps in clampSidePaneWidth /
8771
+ * clampBelowPaneHeight so a pinned split never renders narrower than those.
8772
+ */
8773
+ function canRenderSide({ width, height }) {
8774
+ return width >= 76 && height >= 8;
8775
+ }
8776
+ function canRenderBelow({ width, height }) {
8777
+ return height >= 24 && width >= 24;
8778
+ }
8779
+ /**
7220
8780
  * Responsive placement for the file pane.
7221
8781
  *
7222
- * Wide terminals keep the conversation readable and place review beside it.
7223
- * Tall terminals without enough horizontal room stack review underneath.
7224
- * Cramped terminals give review the full body rather than rendering two
7225
- * unusably small surfaces.
7226
- */
7227
- function filePanePlacement({ width, height }) {
7228
- if (width >= 140 && height >= 24) return "side";
7229
- if (width >= 50 && height >= 40) return "below";
8782
+ * With no user pin the split follows the terminal's visual aspect (see
8783
+ * autoPrefersSide): wider than tall -> side, taller than wide -> stacked.
8784
+ * The preferred split still has to be renderable; if it isn't we use the
8785
+ * other, and cramped terminals that can render neither get the full body.
8786
+ *
8787
+ * A user `orientation` pin always wins as long as the terminal can render
8788
+ * that split at all (the lower canRender* bar) so you get the layout you
8789
+ * asked for even on a smallish terminal. Only when the pinned split
8790
+ * genuinely can't fit do we fall back: to the other split if it can render,
8791
+ * else to `fullscreen`.
8792
+ */
8793
+ function filePanePlacement({ width, height, orientation }) {
8794
+ const renderSide = canRenderSide({
8795
+ width,
8796
+ height
8797
+ });
8798
+ const renderBelow = canRenderBelow({
8799
+ width,
8800
+ height
8801
+ });
8802
+ if (orientation === "side") {
8803
+ if (renderSide) return "side";
8804
+ if (renderBelow) return "below";
8805
+ return "fullscreen";
8806
+ }
8807
+ if (orientation === "below") {
8808
+ if (renderBelow) return "below";
8809
+ if (renderSide) return "side";
8810
+ return "fullscreen";
8811
+ }
8812
+ if (autoPrefersSide({
8813
+ width,
8814
+ height
8815
+ })) {
8816
+ if (renderSide) return "side";
8817
+ if (renderBelow) return "below";
8818
+ return "fullscreen";
8819
+ }
8820
+ if (renderBelow) return "below";
8821
+ if (renderSide) return "side";
7230
8822
  return "fullscreen";
7231
8823
  }
8824
+ /**
8825
+ * Whether toggling the orientation would actually change anything: both
8826
+ * splits must be renderable. When only one (or neither) can render, the
8827
+ * caller surfaces a hint instead of a silent no-op.
8828
+ */
8829
+ function canToggleOrientation({ width, height }) {
8830
+ return canRenderSide({
8831
+ width,
8832
+ height
8833
+ }) && canRenderBelow({
8834
+ width,
8835
+ height
8836
+ });
8837
+ }
7232
8838
  /** Keep a dragged side pane wide enough to use and leave the chat usable. */
7233
8839
  function clampSidePaneWidth(width, bodyWidth) {
7234
8840
  return Math.max(44, Math.min(bodyWidth - 30, width));
@@ -7575,13 +9181,29 @@ function routeInput(raw) {
7575
9181
  kind: "sandbox-exec",
7576
9182
  command: match.rest
7577
9183
  } : { kind: "sandbox-pty" };
9184
+ case "conversation": return match.rest ? {
9185
+ kind: "open-conversation",
9186
+ ref: match.rest
9187
+ } : { kind: "conversation-picker" };
9188
+ case "plan": {
9189
+ const arg = match.rest.toLowerCase();
9190
+ return {
9191
+ kind: "plan",
9192
+ mode: arg === "show" ? "show" : arg === "hide" ? "hide" : "toggle"
9193
+ };
9194
+ }
7578
9195
  case "new-conversation":
9196
+ case "fork":
9197
+ case "archive-conversation":
7579
9198
  case "model-picker":
7580
9199
  case "theme-picker":
7581
9200
  case "status":
7582
9201
  case "share":
7583
9202
  case "browser":
7584
9203
  case "connect":
9204
+ case "copy":
9205
+ case "portal":
9206
+ case "compact":
7585
9207
  case "help":
7586
9208
  case "quit": return { kind: action.kind };
7587
9209
  default: {
@@ -7643,6 +9265,47 @@ async function runPtySession(opts) {
7643
9265
  }
7644
9266
  }
7645
9267
 
9268
+ //#endregion
9269
+ //#region src/chat/tui/screens/conversation-channel.ts
9270
+ function isNewConversation(c) {
9271
+ return "kind" in c && c.kind === "new";
9272
+ }
9273
+ /**
9274
+ * The channel a conversation belongs to when it isn't the web channel, else
9275
+ * null. A brand-new conversation is always web, and the list route reports the
9276
+ * web channel as either `'web'` or (legacy) null — both mean "post from here".
9277
+ * Anything else (slack, email, imessage, webhook, cron) is owned by that
9278
+ * channel: the message has to go through it, so the TUI can't send here.
9279
+ * Mirrors the web app's `channel !== null && channel !== 'web'` gate.
9280
+ */
9281
+ function nonWebChannelLabel(c) {
9282
+ if (isNewConversation(c)) return null;
9283
+ if (c.channel === null || c.channel === "web") return null;
9284
+ return c.channelLabel ?? c.channel;
9285
+ }
9286
+ /**
9287
+ * The list-row shape for a conversation the TUI just forked, so the chat
9288
+ * screen can reopen on the copy without waiting for a list refetch. A fork
9289
+ * carries none of the source's channel binding — that's what makes it
9290
+ * sendable from here — so it is always a web conversation.
9291
+ */
9292
+ function forkedConversationRef({ agent, forked }) {
9293
+ const now = (/* @__PURE__ */ new Date()).toISOString();
9294
+ return {
9295
+ id: forked.id,
9296
+ title: forked.title,
9297
+ createdAt: now,
9298
+ updatedAt: now,
9299
+ preview: null,
9300
+ channel: "web",
9301
+ channelLabel: null,
9302
+ agent: {
9303
+ id: agent.id,
9304
+ name: agent.name
9305
+ }
9306
+ };
9307
+ }
9308
+
7646
9309
  //#endregion
7647
9310
  //#region src/chat/tui/screens/chat.tsx
7648
9311
  /**
@@ -7679,8 +9342,14 @@ const pageScrollFraction = .75;
7679
9342
  /** Cap composer growth; past this the textarea scrolls its content instead. */
7680
9343
  const maxComposerRows = 8;
7681
9344
  const maxAttachments = 10;
7682
- function isNewConversation(c) {
7683
- return "kind" in c && c.kind === "new";
9345
+ /**
9346
+ * A server timestamp as local epoch ms, or null when it is missing or
9347
+ * unparseable so the caller can fall back to its own clock.
9348
+ */
9349
+ function parseStampMs(iso) {
9350
+ if (!iso) return null;
9351
+ const ms = Date.parse(iso);
9352
+ return Number.isNaN(ms) ? null : ms;
7684
9353
  }
7685
9354
  function formatBytes(bytes) {
7686
9355
  if (bytes < 1024) return `${bytes} B`;
@@ -7725,13 +9394,27 @@ function ChatScreen({ agent, conversation }) {
7725
9394
  const isShared = portal.status !== "off";
7726
9395
  const initialConversationId = isNewConversation(conversation) ? null : conversation.id;
7727
9396
  const agentHost = useAgentHost();
9397
+ const nonWebChannel = nonWebChannelLabel(conversation);
7728
9398
  const [conversationId, setConversationId] = useState(initialConversationId);
9399
+ const { compacting, compactConversation, onCompactionEvent } = useCompaction({
9400
+ rest,
9401
+ conversationId
9402
+ });
7729
9403
  const [items, setItems] = useState([]);
7730
9404
  const [todos, setTodos] = useState(null);
9405
+ const [planHidden, setPlanHidden] = useState(false);
9406
+ const [scrolledUp, setScrolledUp] = useState(false);
9407
+ const hadTodosRef = useRef(false);
9408
+ useEffect(() => {
9409
+ const hasTodos = !!todos && todos.length > 0;
9410
+ if (hasTodos && !hadTodosRef.current) setPlanHidden(false);
9411
+ hadTodosRef.current = hasTodos;
9412
+ }, [todos]);
7731
9413
  const [historyLoaded, setHistoryLoaded] = useState(initialConversationId === null);
7732
9414
  const [input, setInput] = useState("");
7733
9415
  const [run, setRun] = useState({ kind: "idle" });
7734
9416
  const [model, setModel] = useState(agent.model ?? null);
9417
+ const [cardPickerOpen, setCardPickerOpen] = useState(false);
7735
9418
  const [modelPickerOpen, setModelPickerOpen] = useState(false);
7736
9419
  const [themePickerOpen, setThemePickerOpen] = useState(false);
7737
9420
  const [helpOpen, setHelpOpen] = useState(false);
@@ -7747,6 +9430,7 @@ function ChatScreen({ agent, conversation }) {
7747
9430
  side: null,
7748
9431
  stacked: null
7749
9432
  });
9433
+ const [reviewOrientation, setReviewOrientation] = useState(null);
7750
9434
  const [reviewWorkspaceFile, setReviewWorkspaceFile] = useState(null);
7751
9435
  useEffect(() => {
7752
9436
  setReviewChangeFile(null);
@@ -7756,6 +9440,8 @@ function ChatScreen({ agent, conversation }) {
7756
9440
  const [composerRows, setComposerRows] = useState(1);
7757
9441
  const [attachments, setAttachments] = useState([]);
7758
9442
  const uploadsRef = useRef(/* @__PURE__ */ new Map());
9443
+ const [forking, setForking] = useState(false);
9444
+ const forkingRef = useRef(false);
7759
9445
  const [credPrompt, setCredPrompt] = useState(null);
7760
9446
  const credPromptOpen = credPrompt !== null;
7761
9447
  const [menuDismissed, setMenuDismissed] = useState(false);
@@ -7784,25 +9470,62 @@ function ChatScreen({ agent, conversation }) {
7784
9470
  agentHost.transition({ state: "idle" });
7785
9471
  agentHost.setSession(null);
7786
9472
  }, [agentHost]);
9473
+ const [prLinks, setPrLinks] = useState([]);
9474
+ const reportedPrsRef = useRef(/* @__PURE__ */ new Map());
9475
+ useEffect(() => () => {
9476
+ for (const pr of reportedPrsRef.current.values()) agentHost.removePullRequest(pr);
9477
+ reportedPrsRef.current = /* @__PURE__ */ new Map();
9478
+ }, [agentHost]);
7787
9479
  useEffect(() => {
7788
9480
  agentHost.setSession(conversationId);
7789
9481
  setResumeConversation(conversationId);
7790
9482
  }, [agentHost, conversationId]);
9483
+ const bindPortalCwd = useCallback(() => {
9484
+ if (!conversationId || !portalClient) return;
9485
+ if (portal.status === "off") return;
9486
+ const cwd = shellSessionRef.current?.cwd ?? process.cwd();
9487
+ portalClient.bind(conversationId, cwd);
9488
+ }, [
9489
+ conversationId,
9490
+ portalClient,
9491
+ portal.status
9492
+ ]);
7791
9493
  const scrollRef = useRef(null);
7792
9494
  const composerRef = useRef(null);
9495
+ const jumpToBottom = useCallback(() => {
9496
+ const box = scrollRef.current;
9497
+ if (!box) return;
9498
+ box.scrollTo({
9499
+ x: box.scrollLeft,
9500
+ y: box.scrollHeight
9501
+ });
9502
+ setScrolledUp(false);
9503
+ }, []);
9504
+ useEffect(() => {
9505
+ const id = setInterval(() => {
9506
+ const box = scrollRef.current;
9507
+ if (!box) return;
9508
+ const viewport = box.viewport.height;
9509
+ const atBottom = box.scrollTop + viewport >= box.scrollHeight - 1;
9510
+ setScrolledUp((prev) => prev === !atBottom ? prev : !atBottom);
9511
+ }, 150);
9512
+ return () => clearInterval(id);
9513
+ }, []);
7793
9514
  const reviewSideBoxRef = useRef(null);
7794
9515
  const reviewBelowBoxRef = useRef(null);
7795
9516
  const paneDragRef = useRef(null);
7796
9517
  const composerBoxHeight = composerRows + 2;
7797
- const pendingVisible = !grantPrompt && !credPrompt && attachments.length > 0;
9518
+ const pendingVisible = !grantPrompt && !credPrompt && !nonWebChannel && attachments.length > 0;
7798
9519
  const pendingRows = pendingVisible ? attachments.length + 2 : 0;
7799
9520
  const credPromptHeight = 4 + (credPrompt && (credPrompt.error || credPrompt.submitting) ? 1 : 0);
7800
- const bottomBoxHeight = credPrompt ? credPromptHeight : composerBoxHeight;
9521
+ const nonWebNoticeHeight = 5;
9522
+ const bottomBoxHeight = credPrompt ? credPromptHeight : nonWebChannel ? nonWebNoticeHeight : composerBoxHeight;
7801
9523
  const bodyWidth = Math.max(1, width - 2);
7802
9524
  const bodyHeight = Math.max(1, height - 3);
7803
9525
  const reviewPlacement = filePanePlacement({
7804
9526
  width,
7805
- height
9527
+ height,
9528
+ orientation: reviewOrientation
7806
9529
  });
7807
9530
  const reviewWidth = reviewPlacement === "side" ? clampSidePaneWidth(reviewSizePref.side ?? sidePaneWidth(bodyWidth), bodyWidth) : bodyWidth;
7808
9531
  const reviewHeight = reviewPlacement === "below" ? clampBelowPaneHeight(reviewSizePref.below ?? belowPaneHeight(bodyHeight), bodyHeight) : bodyHeight;
@@ -7811,9 +9534,11 @@ function ChatScreen({ agent, conversation }) {
7811
9534
  const chatHeight = bodyHeight;
7812
9535
  const inlineReviewHeight = reviewOpen && reviewPlacement === "below" ? reviewHeight : 0;
7813
9536
  const menuRows = menuOpen ? completionMenuHeight(menuCommands.length) : 0;
7814
- const todoCardVisible = !credPrompt && !!todos && todos.length > 0;
9537
+ const todoCardVisible = !credPrompt && !planHidden && !!todos && todos.length > 0;
7815
9538
  const todoRows = todoCardVisible ? todoCardHeight(todos) : 0;
7816
- const scrollHeight = Math.max(3, chatHeight - 2 - bottomBoxHeight - pendingRows - inlineReviewHeight - menuRows - todoRows);
9539
+ const jumpHintVisible = scrolledUp && !credPrompt;
9540
+ const jumpHintRows = jumpHintVisible ? 1 : 0;
9541
+ const scrollHeight = Math.max(3, chatHeight - 2 - bottomBoxHeight - pendingRows - inlineReviewHeight - menuRows - jumpHintRows - todoRows);
7817
9542
  useEffect(() => {
7818
9543
  if (reviewOpen && reviewFocused) {
7819
9544
  const composer = composerRef.current;
@@ -7840,11 +9565,13 @@ function ChatScreen({ agent, conversation }) {
7840
9565
  };
7841
9566
  }, [
7842
9567
  grantPrompt,
9568
+ cardPickerOpen,
7843
9569
  modelPickerOpen,
7844
9570
  themePickerOpen,
7845
9571
  helpOpen,
7846
9572
  reviewOpen,
7847
9573
  reviewFocused,
9574
+ reviewPlacement,
7848
9575
  credPromptOpen
7849
9576
  ]);
7850
9577
  useEffect(() => {
@@ -7861,6 +9588,16 @@ function ChatScreen({ agent, conversation }) {
7861
9588
  text: recapText
7862
9589
  });
7863
9590
  setItems(loaded);
9591
+ for (const item of loaded) {
9592
+ const text = item.kind === "assistant-text" ? item.text : item.kind === "tool" && item.output?.kind === "ok" ? stringifyToolOutput(item.output.value) : null;
9593
+ if (text === null) continue;
9594
+ for (const pr of extractPullRequests(text, { requireCompleteNumber: false })) {
9595
+ if (reportedPrsRef.current.has(pr.key)) continue;
9596
+ reportedPrsRef.current.set(pr.key, pr);
9597
+ setPrLinks((prev) => [...prev, pr]);
9598
+ agentHost.addPullRequest(pr);
9599
+ }
9600
+ }
7864
9601
  setTodos(latestTodosFromHistory(messages));
7865
9602
  } catch (err) {
7866
9603
  if (cancelled) return;
@@ -7876,24 +9613,36 @@ function ChatScreen({ agent, conversation }) {
7876
9613
  return () => {
7877
9614
  cancelled = true;
7878
9615
  };
7879
- }, [rest, initialConversationId]);
7880
- const attachToRun = useCallback((runId, runAgentName) => {
9616
+ }, [
9617
+ rest,
9618
+ initialConversationId,
9619
+ agentHost
9620
+ ]);
9621
+ const attachToRun = useCallback((runId, runAgentName, runStartedAtMs) => {
7881
9622
  if (!rest) return;
7882
9623
  seenRunsRef.current.add(runId);
7883
9624
  const abort = new AbortController();
7884
9625
  const responsePreview = createResponsePreview();
9626
+ const prScanner = createPullRequestScanner((pr) => {
9627
+ if (reportedPrsRef.current.has(pr.key)) return;
9628
+ reportedPrsRef.current.set(pr.key, pr);
9629
+ setPrLinks((prev) => [...prev, pr]);
9630
+ agentHost.addPullRequest(pr);
9631
+ });
7885
9632
  const streamAgentName = runAgentName ?? agent.name;
7886
- setRun({
9633
+ setRun((prev) => ({
7887
9634
  kind: "streaming",
7888
9635
  runId,
7889
- abort
7890
- });
9636
+ abort,
9637
+ startedAtMs: runStartedAtMs ?? (prev.kind === "idle" ? Date.now() : prev.startedAtMs)
9638
+ }));
7891
9639
  rest.streamRun({
7892
9640
  runId,
7893
9641
  signal: abort.signal,
7894
9642
  onEvent: (event) => {
7895
9643
  if (event.kind === "chunk") {
7896
9644
  responsePreview.addChunk(event.chunk);
9645
+ prScanner.addChunk(event.chunk);
7897
9646
  if (event.chunk.type === "data-anyone-todos-updated") {
7898
9647
  const next = todosFromPayload(event.chunk.data);
7899
9648
  if (next) setTodos(next.length > 0 ? next : null);
@@ -7938,6 +9687,8 @@ function ChatScreen({ agent, conversation }) {
7938
9687
  agentHost
7939
9688
  ]);
7940
9689
  const multiAgent = useMemo(() => conversationAgentNames(items, agent.name), [items, agent.name]).size > 1;
9690
+ const transcriptWindow = useMemo(() => windowItems(items), [items]);
9691
+ const themeGeneration = themeVersion();
7941
9692
  useEffect(() => {
7942
9693
  if (!rest || !conversationId) return;
7943
9694
  const abort = new AbortController();
@@ -7948,18 +9699,25 @@ function ChatScreen({ agent, conversation }) {
7948
9699
  if (event.kind === "run") {
7949
9700
  if (seenRunsRef.current.has(event.runId)) return;
7950
9701
  if (runRef.current.kind !== "idle") return;
7951
- attachToRun(event.runId);
9702
+ attachToRun(event.runId, null, parseStampMs(event.createdAt));
9703
+ return;
9704
+ }
9705
+ if (event.kind === "compaction") {
9706
+ onCompactionEvent(event.status);
7952
9707
  return;
7953
9708
  }
7954
- if (event.title) setChatTitle(event.title);
9709
+ if (event.kind === "conversation" && event.title) setChatTitle(event.title);
7955
9710
  }
7956
9711
  }).catch(() => {});
7957
- return () => abort.abort();
9712
+ return () => {
9713
+ abort.abort();
9714
+ };
7958
9715
  }, [
7959
9716
  rest,
7960
9717
  conversationId,
7961
9718
  setChatTitle,
7962
- attachToRun
9719
+ attachToRun,
9720
+ onCompactionEvent
7963
9721
  ]);
7964
9722
  const sendContent = useCallback(async (content, staged, opts) => {
7965
9723
  if (!rest) return;
@@ -7975,7 +9733,10 @@ function ChatScreen({ agent, conversation }) {
7975
9733
  ...stagedNames.length > 0 ? { attachments: stagedNames } : {}
7976
9734
  }]);
7977
9735
  const wasStreaming = runRef.current.kind === "streaming";
7978
- if (!wasStreaming) setRun({ kind: "sending" });
9736
+ if (!wasStreaming) setRun({
9737
+ kind: "sending",
9738
+ startedAtMs: Date.now()
9739
+ });
7979
9740
  try {
7980
9741
  const attachmentIds = [];
7981
9742
  const restorable = [];
@@ -8029,7 +9790,7 @@ function ChatScreen({ agent, conversation }) {
8029
9790
  setItems((prev) => [...prev.filter((m) => m.id !== optimisticId), {
8030
9791
  kind: "error",
8031
9792
  id: crypto.randomUUID(),
8032
- text: errorDetail(err)
9793
+ text: sendErrorMessage(err)
8033
9794
  }]);
8034
9795
  if (echo) setInput((existing) => existing ? existing : trimmed);
8035
9796
  const restore = staged.filter((a) => uploadsRef.current.has(a.tempId));
@@ -8075,6 +9836,7 @@ function ChatScreen({ agent, conversation }) {
8075
9836
  portalStatus,
8076
9837
  portalClient,
8077
9838
  agent.id,
9839
+ agent.name,
8078
9840
  sendContent
8079
9841
  ]);
8080
9842
  const cancelLatestSteer = useCallback(() => {
@@ -8161,7 +9923,7 @@ function ChatScreen({ agent, conversation }) {
8161
9923
  detail: "already connected"
8162
9924
  });
8163
9925
  } else if (action.kind === "open_github_app") {
8164
- const url = resolveConnectUrl(action.url, appUrl);
9926
+ const url = resolveConnectUrl(action.url, resolveWebUrl(appUrl));
8165
9927
  await openUrlInBrowser(url);
8166
9928
  updateCard(item.id, {
8167
9929
  status: "launched",
@@ -8190,10 +9952,15 @@ function ChatScreen({ agent, conversation }) {
8190
9952
  portalClient,
8191
9953
  portal.status
8192
9954
  ]);
8193
- const runNewestCard = useCallback(() => {
8194
- const target = [...itemsRef.current].reverse().find(isActionableCard);
8195
- if (!target) return false;
8196
- runCardAction(target);
9955
+ const promptForCards = useCallback(() => {
9956
+ const targets = itemsRef.current.filter(isActionableCard);
9957
+ const [only] = targets;
9958
+ if (!only) return false;
9959
+ if (targets.length === 1) {
9960
+ runCardAction(only);
9961
+ return true;
9962
+ }
9963
+ setCardPickerOpen(true);
8197
9964
  return true;
8198
9965
  }, [runCardAction]);
8199
9966
  const handleCredInput = useCallback((displayed) => {
@@ -8277,10 +10044,10 @@ function ChatScreen({ agent, conversation }) {
8277
10044
  }]);
8278
10045
  let captured = "";
8279
10046
  const appendOutput = (chunk) => {
8280
- captured += chunk;
10047
+ captured = clampedAppend(captured, chunk);
8281
10048
  setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
8282
10049
  ...m,
8283
- output: m.output + chunk
10050
+ output: clampedAppend(m.output, chunk)
8284
10051
  } : m));
8285
10052
  };
8286
10053
  let exitCode;
@@ -8294,6 +10061,7 @@ function ChatScreen({ agent, conversation }) {
8294
10061
  ...m,
8295
10062
  exit: exitCode
8296
10063
  } : m));
10064
+ bindPortalCwd();
8297
10065
  } catch (err) {
8298
10066
  appendOutput(`\n${errorMessage(err)}`);
8299
10067
  exitCode = 1;
@@ -8303,7 +10071,7 @@ function ChatScreen({ agent, conversation }) {
8303
10071
  } : m));
8304
10072
  }
8305
10073
  sendContent(formatShellContext(command, captured, exitCode), [], { echo: false });
8306
- }, [sendContent]);
10074
+ }, [sendContent, bindPortalCwd]);
8307
10075
  const runSandboxExec = useCallback((command) => {
8308
10076
  if (!appUrl || !sessionToken) return;
8309
10077
  const id = crypto.randomUUID();
@@ -8317,7 +10085,7 @@ function ChatScreen({ agent, conversation }) {
8317
10085
  const dec = new TextDecoder();
8318
10086
  const append = (chunk) => setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
8319
10087
  ...m,
8320
- output: m.output + chunk
10088
+ output: clampedAppend(m.output, chunk)
8321
10089
  } : m));
8322
10090
  const stamp = (exit) => setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
8323
10091
  ...m,
@@ -8438,7 +10206,7 @@ function ChatScreen({ agent, conversation }) {
8438
10206
  stageAttachment(image);
8439
10207
  }, [stageAttachment]);
8440
10208
  usePaste((event) => {
8441
- if (modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt) return;
10209
+ if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt || nonWebChannel) return;
8442
10210
  const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
8443
10211
  const route = routePaste({
8444
10212
  kind: event.metadata?.kind,
@@ -8487,6 +10255,7 @@ function ChatScreen({ agent, conversation }) {
8487
10255
  if (composer.plainText === "" && inputRef.current !== "") applyComposerText(inputRef.current, "end");
8488
10256
  }, [
8489
10257
  grantPrompt,
10258
+ cardPickerOpen,
8490
10259
  modelPickerOpen,
8491
10260
  themePickerOpen,
8492
10261
  helpOpen,
@@ -8508,6 +10277,17 @@ function ChatScreen({ agent, conversation }) {
8508
10277
  } : m));
8509
10278
  useStore.getState().showToast({ message: "run cancelled" });
8510
10279
  }, [rest]);
10280
+ const copyLastMessage = useCallback(() => {
10281
+ const last = [...items].reverse().find((m) => m.kind === "assistant-text" && m.done && m.text.trim() !== "");
10282
+ if (!last) {
10283
+ useStore.getState().showToast({ message: "no agent message to copy yet" });
10284
+ return;
10285
+ }
10286
+ writeClipboardText(last.text).then((ok) => useStore.getState().showToast(ok ? {
10287
+ variant: "success",
10288
+ message: "copied last message to clipboard"
10289
+ } : { message: "clipboard unavailable" }));
10290
+ }, [items]);
8511
10291
  const openInBrowser = useCallback(() => {
8512
10292
  if (!appUrl) return;
8513
10293
  if (!conversationId) {
@@ -8524,11 +10304,141 @@ function ChatScreen({ agent, conversation }) {
8524
10304
  text: `couldn't open a browser — visit ${url} (${errorMessage(err)})`
8525
10305
  }]));
8526
10306
  }, [appUrl, conversationId]);
10307
+ const forkAndContinue = useCallback(() => {
10308
+ if (!rest || !conversationId || forkingRef.current) return;
10309
+ forkingRef.current = true;
10310
+ setForking(true);
10311
+ useStore.getState().showToast({ message: "forking into a new chat…" });
10312
+ const screenAtFork = useStore.getState().screen;
10313
+ const stillHere = () => useStore.getState().screen === screenAtFork;
10314
+ rest.forkConversation({ conversationId }).then((forked) => {
10315
+ if (!stillHere()) return;
10316
+ goTo({
10317
+ kind: "chat",
10318
+ agent,
10319
+ conversation: forkedConversationRef({
10320
+ agent,
10321
+ forked
10322
+ })
10323
+ });
10324
+ useStore.getState().showToast({
10325
+ variant: "success",
10326
+ message: "Forked conversation"
10327
+ });
10328
+ }).catch((err) => {
10329
+ forkingRef.current = false;
10330
+ setForking(false);
10331
+ if (!stillHere()) return;
10332
+ useStore.getState().showToast({
10333
+ variant: "error",
10334
+ message: `couldn't fork this conversation: ${errorMessage(err)}`
10335
+ });
10336
+ });
10337
+ }, [
10338
+ rest,
10339
+ conversationId,
10340
+ agent,
10341
+ goTo
10342
+ ]);
10343
+ const archiveConversation = useCallback(() => {
10344
+ if (grantPrompt || credPrompt) return;
10345
+ if (runRef.current.kind !== "idle") {
10346
+ useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
10347
+ return;
10348
+ }
10349
+ if (!conversationId) {
10350
+ useStore.getState().showToast({ message: "nothing to archive yet — send a message to start this one" });
10351
+ return;
10352
+ }
10353
+ if (!rest) return;
10354
+ rest.setConversationArchived({
10355
+ conversationId,
10356
+ archived: true
10357
+ }).then(() => {
10358
+ useStore.getState().showToast({
10359
+ variant: "success",
10360
+ message: "archived — shift+tab in the picker shows it, ctrl+a restores"
10361
+ });
10362
+ goTo({
10363
+ kind: "conversation-picker",
10364
+ agent
10365
+ });
10366
+ }).catch((err) => useStore.getState().showToast({
10367
+ variant: "error",
10368
+ message: err instanceof HttpError && err.status === 404 ? "can't archive this one — it's already gone from your list" : `couldn't archive: ${errorMessage(err)}`
10369
+ }));
10370
+ }, [
10371
+ grantPrompt,
10372
+ credPrompt,
10373
+ conversationId,
10374
+ rest,
10375
+ goTo,
10376
+ agent
10377
+ ]);
10378
+ /**
10379
+ * Leave this conversation for another screen without ending its run: drop
10380
+ * our own SSE subscription and nothing else — never `rest.cancelRun` — so
10381
+ * the agent keeps working server-side and reopening the thread reattaches.
10382
+ * Same contract as `esc`, which is why `/conversation` is allowed mid-run
10383
+ * where `/new` is not: what you land on still lists the thread you left,
10384
+ * so the run is never stranded out of reach.
10385
+ */
10386
+ const leaveForScreen = useCallback((next) => {
10387
+ const current = runRef.current;
10388
+ if (current.kind === "streaming") current.abort.abort();
10389
+ if (current.kind !== "idle") useStore.getState().showToast({ message: `${agent.name} keeps working — reopen to reattach` });
10390
+ goTo(next);
10391
+ }, [agent.name, goTo]);
10392
+ /**
10393
+ * `/conversation <id or link>`: open that conversation without leaving the
10394
+ * TUI — the point being that the id usually arrives *from* an agent, in the
10395
+ * session you started to go find it.
10396
+ *
10397
+ * Resolution goes through `resolveInitialScreen`, the same path
10398
+ * `--resume <id>` takes, so an id belonging to a different agent opens
10399
+ * under that agent and the not-found / no-access wording lives in one
10400
+ * place.
10401
+ */
10402
+ const openConversationRef = useCallback(async (ref) => {
10403
+ const target = parseConversationRef(ref);
10404
+ if (!target) {
10405
+ setItems((prev) => [...prev, {
10406
+ kind: "error",
10407
+ id: crypto.randomUUID(),
10408
+ text: `not a conversation id: ${ref} — pass an id, its /c/<id> link, or run /conversation on its own to pick from the list`
10409
+ }]);
10410
+ return;
10411
+ }
10412
+ if (!rest) return;
10413
+ if (target === conversationId) {
10414
+ useStore.getState().showToast({ message: "already in this conversation" });
10415
+ return;
10416
+ }
10417
+ try {
10418
+ leaveForScreen(await resolveInitialScreen({
10419
+ rest,
10420
+ agentSelector: null,
10421
+ conversationId: target,
10422
+ newConversation: false
10423
+ }));
10424
+ } catch (err) {
10425
+ setItems((prev) => [...prev, {
10426
+ kind: "error",
10427
+ id: crypto.randomUUID(),
10428
+ text: `couldn't open ${target}: ${errorMessage(err)}`
10429
+ }]);
10430
+ }
10431
+ }, [
10432
+ rest,
10433
+ conversationId,
10434
+ leaveForScreen
10435
+ ]);
8527
10436
  const toggleShare = useCallback(() => {
8528
10437
  if (!portalClient) return;
8529
10438
  if (portal.status === "off") {
8530
10439
  declinedRef.current.delete(agent.id);
8531
10440
  portalClient.enable();
10441
+ if (conversationId) portalClient.bind(conversationId, shellSessionRef.current?.cwd ?? process.cwd());
8532
10442
  } else {
8533
10443
  portalClient.disable();
8534
10444
  setPortalPrompt(null);
@@ -8537,7 +10447,8 @@ function ChatScreen({ agent, conversation }) {
8537
10447
  portalClient,
8538
10448
  portal.status,
8539
10449
  agent.id,
8540
- setPortalPrompt
10450
+ setPortalPrompt,
10451
+ conversationId
8541
10452
  ]);
8542
10453
  const confirmGrant = useCallback(() => {
8543
10454
  if (!portalClient) return;
@@ -8562,8 +10473,54 @@ function ChatScreen({ agent, conversation }) {
8562
10473
  setPortalPrompt(null);
8563
10474
  useStore.getState().showToast({ message: "kept this machine private" });
8564
10475
  }, [agent.id, setPortalPrompt]);
10476
+ const openPortalAccess = useCallback(() => {
10477
+ if (!portalClient) {
10478
+ useStore.getState().showToast({ message: "local access is unavailable in this session" });
10479
+ return;
10480
+ }
10481
+ switch (portal.status) {
10482
+ case "off":
10483
+ declinedRef.current.delete(agent.id);
10484
+ portalClient.enable();
10485
+ useStore.getState().showToast({ message: "enabling local access on this machine…" });
10486
+ break;
10487
+ case "connecting":
10488
+ useStore.getState().showToast({ message: "local access is still connecting…" });
10489
+ break;
10490
+ case "connected":
10491
+ if (portal.grantedAgentIds.includes(agent.id)) {
10492
+ useStore.getState().showToast({ message: `${agent.name} can already run commands on this machine` });
10493
+ break;
10494
+ }
10495
+ declinedRef.current.delete(agent.id);
10496
+ setPortalPrompt({
10497
+ agentId: agent.id,
10498
+ agentName: agent.name
10499
+ });
10500
+ break;
10501
+ case "error":
10502
+ useStore.getState().showToast({ message: portal.error ? `local access error: ${portal.error}` : "local access hit an error" });
10503
+ break;
10504
+ default: {
10505
+ const exhaustive = portal.status;
10506
+ throw new Error(`unhandled portal status: ${String(exhaustive)}`);
10507
+ }
10508
+ }
10509
+ }, [
10510
+ portalClient,
10511
+ portal.status,
10512
+ portal.grantedAgentIds,
10513
+ portal.error,
10514
+ agent.id,
10515
+ agent.name,
10516
+ setPortalPrompt
10517
+ ]);
8565
10518
  const executeLine = useCallback((content, staged) => {
8566
10519
  if (!content && staged.length === 0) return;
10520
+ if (forkingRef.current) {
10521
+ useStore.getState().showToast({ message: "forking… hang on" });
10522
+ return;
10523
+ }
8567
10524
  const routed = routeInput(content);
8568
10525
  if (routed.kind !== "message") {
8569
10526
  history.append(content);
@@ -8580,6 +10537,9 @@ function ChatScreen({ agent, conversation }) {
8580
10537
  case "sandbox-pty":
8581
10538
  runSandboxPty();
8582
10539
  break;
10540
+ case "archive-conversation":
10541
+ archiveConversation();
10542
+ break;
8583
10543
  case "new-conversation":
8584
10544
  if (runRef.current.kind !== "idle") {
8585
10545
  useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
@@ -8595,6 +10555,26 @@ function ChatScreen({ agent, conversation }) {
8595
10555
  }
8596
10556
  });
8597
10557
  break;
10558
+ case "fork":
10559
+ if (runRef.current.kind !== "idle") {
10560
+ useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
10561
+ break;
10562
+ }
10563
+ if (!conversationId) {
10564
+ useStore.getState().showToast({ message: "nothing to fork yet — send a message first" });
10565
+ break;
10566
+ }
10567
+ forkAndContinue();
10568
+ break;
10569
+ case "conversation-picker":
10570
+ leaveForScreen({
10571
+ kind: "conversation-picker",
10572
+ agent
10573
+ });
10574
+ break;
10575
+ case "open-conversation":
10576
+ openConversationRef(routed.ref);
10577
+ break;
8598
10578
  case "model-picker":
8599
10579
  setModelPickerOpen(true);
8600
10580
  break;
@@ -8619,14 +10599,33 @@ function ChatScreen({ agent, conversation }) {
8619
10599
  })
8620
10600
  }]);
8621
10601
  break;
10602
+ case "plan": {
10603
+ if (!(!!todos && todos.length > 0)) {
10604
+ useStore.getState().showToast({ message: "no plan to show yet" });
10605
+ break;
10606
+ }
10607
+ const nextHidden = routed.mode === "toggle" ? !planHidden : routed.mode === "hide";
10608
+ setPlanHidden(nextHidden);
10609
+ useStore.getState().showToast({ message: nextHidden ? "plan hidden (/plan show)" : "plan shown" });
10610
+ break;
10611
+ }
8622
10612
  case "share":
8623
10613
  toggleShare();
8624
10614
  break;
8625
10615
  case "browser":
8626
10616
  openInBrowser();
8627
10617
  break;
10618
+ case "copy":
10619
+ copyLastMessage();
10620
+ break;
10621
+ case "portal":
10622
+ openPortalAccess();
10623
+ break;
10624
+ case "compact":
10625
+ compactConversation();
10626
+ break;
8628
10627
  case "connect":
8629
- if (!runNewestCard()) useStore.getState().showToast({ message: "no connect card waiting for action" });
10628
+ if (!promptForCards()) useStore.getState().showToast({ message: "no connect card waiting for action" });
8630
10629
  break;
8631
10630
  case "help":
8632
10631
  setHelpOpen(true);
@@ -8653,8 +10652,15 @@ function ChatScreen({ agent, conversation }) {
8653
10652
  runLocalShell,
8654
10653
  runSandboxExec,
8655
10654
  runSandboxPty,
10655
+ archiveConversation,
10656
+ forkAndContinue,
10657
+ leaveForScreen,
10658
+ openConversationRef,
8656
10659
  toggleShare,
8657
10660
  openInBrowser,
10661
+ copyLastMessage,
10662
+ openPortalAccess,
10663
+ compactConversation,
8658
10664
  quit,
8659
10665
  sendContent,
8660
10666
  goTo,
@@ -8665,7 +10671,9 @@ function ChatScreen({ agent, conversation }) {
8665
10671
  model,
8666
10672
  portal.status,
8667
10673
  portal.grantedAgentIds,
8668
- runNewestCard
10674
+ promptForCards,
10675
+ todos,
10676
+ planHidden
8669
10677
  ]);
8670
10678
  const submit = useCallback(() => {
8671
10679
  const staged = attachments.filter((a) => a.status !== "error");
@@ -8720,7 +10728,7 @@ function ChatScreen({ agent, conversation }) {
8720
10728
  }
8721
10729
  if (reviewFocused) return;
8722
10730
  }
8723
- if (modelPickerOpen || themePickerOpen || helpOpen) return;
10731
+ if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen) return;
8724
10732
  if (grantPrompt) {
8725
10733
  if (key.name === "y") {
8726
10734
  confirmGrant();
@@ -8812,18 +10820,24 @@ function ChatScreen({ agent, conversation }) {
8812
10820
  return;
8813
10821
  }
8814
10822
  if (key.name === "r" && key.ctrl) {
8815
- runNewestCard();
10823
+ promptForCards();
8816
10824
  return;
8817
10825
  }
8818
10826
  if (key.name === "t" && key.ctrl) {
8819
10827
  toggleShare();
8820
10828
  return;
8821
10829
  }
10830
+ if (nonWebChannel && key.name === "f" && key.ctrl) {
10831
+ forkAndContinue();
10832
+ return;
10833
+ }
8822
10834
  if (key.name === "v" && key.ctrl) {
10835
+ if (nonWebChannel) return;
8823
10836
  pasteImage();
8824
10837
  return;
8825
10838
  }
8826
10839
  if (key.name === "x" && key.ctrl || key.name === "backspace" && attachmentsRef.current.length > 0 && !composerRef.current?.plainText) {
10840
+ if (nonWebChannel) return;
8827
10841
  const last = attachmentsRef.current.at(-1);
8828
10842
  if (last) {
8829
10843
  uploadsRef.current.delete(last.tempId);
@@ -8836,6 +10850,11 @@ function ChatScreen({ agent, conversation }) {
8836
10850
  setThemePickerOpen(true);
8837
10851
  return;
8838
10852
  }
10853
+ if (key.name === "a" && key.ctrl && input.trim() === "") {
10854
+ key.preventDefault();
10855
+ archiveConversation();
10856
+ return;
10857
+ }
8839
10858
  if (key.name === "?" && !key.ctrl && !key.meta && input.trim() === "") {
8840
10859
  composerRef.current?.clear();
8841
10860
  setInput("");
@@ -8851,6 +10870,10 @@ function ChatScreen({ agent, conversation }) {
8851
10870
  scrollRef.current?.scrollBy(pageScrollFraction, "viewport");
8852
10871
  return;
8853
10872
  }
10873
+ if (key.name === "end" && scrolledUp) {
10874
+ jumpToBottom();
10875
+ return;
10876
+ }
8854
10877
  if (key.name === "c" && key.ctrl) {
8855
10878
  if (runRef.current.kind === "streaming") {
8856
10879
  cancelRun();
@@ -8882,21 +10905,31 @@ function ChatScreen({ agent, conversation }) {
8882
10905
  }
8883
10906
  });
8884
10907
  const hasPendingSteer = items.some((m) => m.kind === "pending-steer");
8885
- const hasActionableCard = items.some(isActionableCard);
10908
+ const actionableCards = useMemo(() => items.filter(isActionableCard), [items]);
8886
10909
  const statusText = useMemo(() => formatChatFooterStatus({
8887
10910
  runKind: run.kind,
8888
10911
  ctrlCArmed,
8889
10912
  isShared,
8890
10913
  hasPendingSteer,
8891
- hasActionableCard
10914
+ actionableCardCount: actionableCards.length,
10915
+ nonWebChannel: nonWebChannel !== null
8892
10916
  }), [
8893
10917
  run.kind,
8894
10918
  ctrlCArmed,
8895
10919
  isShared,
8896
10920
  hasPendingSteer,
8897
- hasActionableCard
10921
+ actionableCards.length,
10922
+ nonWebChannel
8898
10923
  ]);
8899
10924
  const modelHint = formatModelHint(model);
10925
+ if (cardPickerOpen) return /* @__PURE__ */ jsx(CardPicker, {
10926
+ cards: actionableCards,
10927
+ onSelect: (card) => {
10928
+ setCardPickerOpen(false);
10929
+ runCardAction(card);
10930
+ },
10931
+ onCancel: () => setCardPickerOpen(false)
10932
+ });
8900
10933
  if (modelPickerOpen) return /* @__PURE__ */ jsx(ModelPicker, {
8901
10934
  agentId: agent.id,
8902
10935
  currentModel: model,
@@ -8935,6 +10968,14 @@ function ChatScreen({ agent, conversation }) {
8935
10968
  ...prev,
8936
10969
  side: size
8937
10970
  }),
10971
+ onToggleOrientation: () => {
10972
+ if (!canToggleOrientation({
10973
+ width,
10974
+ height
10975
+ })) return "unavailable";
10976
+ setReviewOrientation(reviewPlacement === "below" ? "side" : "below");
10977
+ return "toggled";
10978
+ },
8938
10979
  onInsertToComposer: (review) => {
8939
10980
  const next = [inputRef.current.trimEnd(), review].filter((part) => part.length > 0).join("\n\n");
8940
10981
  setInput(next);
@@ -9019,28 +11060,69 @@ function ChatScreen({ agent, conversation }) {
9019
11060
  agent.name,
9020
11061
  " below."
9021
11062
  ]
9022
- }) : /* @__PURE__ */ jsx(Fragment, { children: items.map((item, i) => {
9023
- const label = multiAgent ? agentLabelFor(items, i) : null;
9024
- return /* @__PURE__ */ jsxs("box", {
9025
- style: { flexDirection: "column" },
9026
- children: [label ? /* @__PURE__ */ jsx("box", {
9027
- style: {
9028
- paddingLeft: 3,
9029
- paddingRight: 1
9030
- },
9031
- children: /* @__PURE__ */ jsx("text", {
9032
- fg: theme.muted,
9033
- children: /* @__PURE__ */ jsx("b", { children: label })
9034
- })
9035
- }) : null, /* @__PURE__ */ jsx(RenderItem, { item })]
9036
- }, item.id);
9037
- }) }), run.kind !== "idle" ? /* @__PURE__ */ jsx(ActivityRow, { label: run.kind === "sending" ? "sending" : "working" }) : null]
11063
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [transcriptWindow.hiddenCount > 0 ? /* @__PURE__ */ jsx("box", {
11064
+ style: {
11065
+ paddingLeft: 1,
11066
+ paddingRight: 1
11067
+ },
11068
+ children: /* @__PURE__ */ jsxs("text", {
11069
+ fg: theme.dim,
11070
+ children: [
11071
+ "↑ ",
11072
+ transcriptWindow.hiddenCount,
11073
+ " earlier message",
11074
+ transcriptWindow.hiddenCount === 1 ? "" : "s",
11075
+ " · kept in this session, not shown"
11076
+ ]
11077
+ })
11078
+ }) : null, transcriptWindow.visible.map(({ item, index }) => /* @__PURE__ */ jsx(TranscriptRow, {
11079
+ item,
11080
+ label: multiAgent ? agentLabelFor(items, index) : null,
11081
+ themeVersion: themeGeneration
11082
+ }, item.id))] }), run.kind !== "idle" ? /* @__PURE__ */ jsx(ActivityRow, {
11083
+ label: run.kind === "sending" ? "sending" : "working",
11084
+ startedAtMs: run.startedAtMs
11085
+ }) : null]
9038
11086
  })
9039
11087
  }),
11088
+ jumpHintVisible ? /* @__PURE__ */ jsxs("box", {
11089
+ onMouseDown: jumpToBottom,
11090
+ style: {
11091
+ flexDirection: "row",
11092
+ paddingLeft: 1,
11093
+ paddingRight: 1,
11094
+ height: 1,
11095
+ flexShrink: 0
11096
+ },
11097
+ children: [/* @__PURE__ */ jsx("text", {
11098
+ fg: theme.accent,
11099
+ children: "↓ jump to latest"
11100
+ }), /* @__PURE__ */ jsxs("text", {
11101
+ fg: theme.dim,
11102
+ children: [" ", "end"]
11103
+ })]
11104
+ }) : null,
9040
11105
  todoCardVisible && todos ? /* @__PURE__ */ jsx(TodoCardView, {
9041
11106
  todos,
9042
11107
  isActive: run.kind !== "idle"
9043
11108
  }) : null,
11109
+ compacting ? /* @__PURE__ */ jsxs("box", {
11110
+ style: {
11111
+ flexDirection: "row",
11112
+ paddingLeft: 1,
11113
+ paddingRight: 1,
11114
+ height: 1,
11115
+ flexShrink: 0,
11116
+ marginTop: 1
11117
+ },
11118
+ children: [/* @__PURE__ */ jsx("text", {
11119
+ fg: theme.accent,
11120
+ children: "● compacting conversation…"
11121
+ }), /* @__PURE__ */ jsxs("text", {
11122
+ fg: theme.dim,
11123
+ children: [" ", "you can keep typing"]
11124
+ })]
11125
+ }) : null,
9044
11126
  pendingVisible ? /* @__PURE__ */ jsxs("box", {
9045
11127
  style: {
9046
11128
  paddingLeft: 1,
@@ -9112,6 +11194,35 @@ function ChatScreen({ agent, conversation }) {
9112
11194
  children: "n"
9113
11195
  })
9114
11196
  ]
11197
+ }) : nonWebChannel ? /* @__PURE__ */ jsxs("box", {
11198
+ style: {
11199
+ border: true,
11200
+ borderColor: theme.faint,
11201
+ paddingLeft: 1,
11202
+ paddingRight: 1,
11203
+ height: nonWebNoticeHeight,
11204
+ flexShrink: 0,
11205
+ marginTop: 1,
11206
+ flexDirection: "column"
11207
+ },
11208
+ children: [
11209
+ /* @__PURE__ */ jsxs("text", {
11210
+ fg: theme.muted,
11211
+ children: [
11212
+ "this conversation is on ",
11213
+ nonWebChannel,
11214
+ "."
11215
+ ]
11216
+ }),
11217
+ /* @__PURE__ */ jsx("text", {
11218
+ fg: theme.dim,
11219
+ children: "reply there to continue it."
11220
+ }),
11221
+ /* @__PURE__ */ jsx("text", {
11222
+ fg: theme.dim,
11223
+ children: forking ? "forking into a new chat…" : "ctrl+f fork it into a chat you can continue here."
11224
+ })
11225
+ ]
9115
11226
  }) : /* @__PURE__ */ jsx("box", {
9116
11227
  style: {
9117
11228
  border: true,
@@ -9124,7 +11235,7 @@ function ChatScreen({ agent, conversation }) {
9124
11235
  },
9125
11236
  children: /* @__PURE__ */ jsx("textarea", {
9126
11237
  ref: composerRef,
9127
- placeholder: `message ${agent.name}…`,
11238
+ placeholder: forking ? "forking into a new chat…" : `message ${agent.name}…`,
9128
11239
  keyBindings: composerKeyBindings,
9129
11240
  onContentChange: handleComposerChange,
9130
11241
  onSubmit: submit,
@@ -9153,6 +11264,7 @@ function ChatScreen({ agent, conversation }) {
9153
11264
  fg: theme.muted,
9154
11265
  children: statusText
9155
11266
  }),
11267
+ prLinks.length > 0 ? /* @__PURE__ */ jsx(PrLinksView, { prs: prLinks }) : null,
9156
11268
  /* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }),
9157
11269
  /* @__PURE__ */ jsx("text", {
9158
11270
  fg: theme.dim,
@@ -9277,18 +11389,48 @@ function CredentialPromptBox({ prompt, height, onInput, onSubmit }) {
9277
11389
  });
9278
11390
  }
9279
11391
  /**
11392
+ * One transcript row: the optional agent-attribution label plus the item's own
11393
+ * rendering. Memoized on the item because the reducer rebuilds the `items`
11394
+ * array on every stream chunk while leaving finished items' objects untouched.
11395
+ * Without the memo, one text delta re-rendered every row in the scrollback, and
11396
+ * each re-render pushes content through that row's native OpenTUI text buffers,
11397
+ * which never hand their memory back — so a long session grew O(turns^2)
11398
+ * resident memory (~1GB after 6k deltas in a soak; ~190MB with the memo).
11399
+ *
11400
+ * `themeVersion` is a prop rather than a read because rows resolve colors from
11401
+ * the mutable `theme` binding during render: a swap mutates that object in
11402
+ * place, so the memo needs the version bump to know its output is stale.
11403
+ */
11404
+ const TranscriptRow = memo(function TranscriptRow({ item, label }) {
11405
+ return /* @__PURE__ */ jsxs("box", {
11406
+ style: { flexDirection: "column" },
11407
+ children: [label ? /* @__PURE__ */ jsx("box", {
11408
+ style: {
11409
+ paddingLeft: 3,
11410
+ paddingRight: 1
11411
+ },
11412
+ children: /* @__PURE__ */ jsx("text", {
11413
+ fg: theme.muted,
11414
+ children: /* @__PURE__ */ jsx("b", { children: label })
11415
+ })
11416
+ }) : null, /* @__PURE__ */ jsx(RenderItem, { item })]
11417
+ });
11418
+ });
11419
+ /**
9280
11420
  * A live indicator at the foot of the transcript while a run is in flight: an
9281
11421
  * animated spinner plus a label and a seconds counter, so it's obvious the
9282
- * agent is still working even during long gaps between chunks. Owns its own
9283
- * elapsed-time timer so ticking the counter doesn't re-render the transcript.
11422
+ * agent is still working even during long gaps between chunks. The counter
11423
+ * measures from the run's own start, not from this row appearing, so reopening
11424
+ * a conversation mid-run shows how long the agent has really been at it. Owns
11425
+ * its own clock so ticking it doesn't re-render the transcript.
9284
11426
  */
9285
- function ActivityRow({ label }) {
9286
- const [elapsed, setElapsed] = useState(0);
11427
+ function ActivityRow({ label, startedAtMs }) {
11428
+ const [now, setNow] = useState(() => Date.now());
9287
11429
  useEffect(() => {
9288
- const start = Date.now();
9289
- const id = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1e3)), 250);
11430
+ const id = setInterval(() => setNow(Date.now()), 250);
9290
11431
  return () => clearInterval(id);
9291
11432
  }, []);
11433
+ const elapsed = Math.floor((now - startedAtMs) / 1e3);
9292
11434
  return /* @__PURE__ */ jsxs("box", {
9293
11435
  style: {
9294
11436
  flexDirection: "row",
@@ -9357,9 +11499,10 @@ function ToastView() {
9357
11499
 
9358
11500
  //#endregion
9359
11501
  //#region src/chat/tui/app.tsx
9360
- function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notifications, agentSelector, conversationId }) {
11502
+ function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notifications, agentSelector, conversationId, newConversation }) {
9361
11503
  const renderer = useRenderer();
9362
11504
  const screen = useStore((s) => s.screen);
11505
+ const chatTitle = useStore((s) => s.chatTitle);
9363
11506
  useStore((s) => s.themeId);
9364
11507
  const rest = useStore((s) => s.rest);
9365
11508
  const goTo = useStore((s) => s.goTo);
@@ -9378,6 +11521,13 @@ function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notificati
9378
11521
  agentHost.transition({ state: "idle" });
9379
11522
  return () => agentHost.dispose();
9380
11523
  }, [agentHost]);
11524
+ useEffect(() => {
11525
+ agentHost.setTitle(titleLabel(screen, chatTitle));
11526
+ }, [
11527
+ agentHost,
11528
+ screen,
11529
+ chatTitle
11530
+ ]);
9381
11531
  useEffect(() => {
9382
11532
  const selectionCopy = createSelectionCopy({
9383
11533
  renderer,
@@ -9431,7 +11581,7 @@ function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notificati
9431
11581
  rest,
9432
11582
  agentSelector,
9433
11583
  conversationId,
9434
- newConversation: useStore.getState().seedPrompt !== null
11584
+ newConversation: newConversation || useStore.getState().seedPrompt !== null
9435
11585
  });
9436
11586
  if (cancelled) return;
9437
11587
  goTo(screen);
@@ -9453,6 +11603,7 @@ function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notificati
9453
11603
  promptHistoryPath,
9454
11604
  agentSelector,
9455
11605
  conversationId,
11606
+ newConversation,
9456
11607
  goTo,
9457
11608
  setClients,
9458
11609
  fail
@@ -9748,6 +11899,165 @@ var LinkifiedMarkdownRenderable = class extends MarkdownRenderable {
9748
11899
  }
9749
11900
  };
9750
11901
 
11902
+ //#endregion
11903
+ //#region src/chat/memory-watchdog.ts
11904
+ const MB = 1024 * 1024;
11905
+ /** How often to sample RSS. */
11906
+ const DEFAULT_SAMPLE_INTERVAL_MS = 15e3;
11907
+ /**
11908
+ * Soft threshold: cross it and we warn the user once. A healthy chat session
11909
+ * sits in the low hundreds of MB, so 2 GB is "something is wrong" without
11910
+ * being so eager it fires on a legitimately large conversation.
11911
+ */
11912
+ const DEFAULT_WARN_THRESHOLD_BYTES = 2 * 1024 * MB;
11913
+ /**
11914
+ * A clean exit still writes a report if the peak got this high — so we learn
11915
+ * about near-misses that didn't quite OOM, not just the crashes.
11916
+ */
11917
+ const DEFAULT_REPORT_THRESHOLD_BYTES = 4 * 1024 * MB;
11918
+ /** Keep the last N samples so the report shows a growth curve, not a point. */
11919
+ const MAX_SAMPLES = 240;
11920
+ function envBytes(name, fallback) {
11921
+ const raw = process.env[name];
11922
+ if (!raw) return fallback;
11923
+ const n = Number(raw);
11924
+ return Number.isFinite(n) && n > 0 ? n : fallback;
11925
+ }
11926
+ function envMs(name, fallback) {
11927
+ const raw = process.env[name];
11928
+ if (!raw) return fallback;
11929
+ const n = Number(raw);
11930
+ return Number.isFinite(n) && n > 0 ? n : fallback;
11931
+ }
11932
+ /**
11933
+ * Start the memory watchdog. Returns a handle; call `stop()` on teardown.
11934
+ *
11935
+ * Disabled entirely when `SKYDIVE_NO_MEMORY_WATCHDOG` is set — an escape
11936
+ * hatch that also keeps the sampler out of the way in tests and CI.
11937
+ */
11938
+ function startMemoryWatchdog(opts = {}) {
11939
+ const disabled = Boolean(process.env["SKYDIVE_NO_MEMORY_WATCHDOG"]);
11940
+ const now = opts.now ?? (() => Date.now());
11941
+ const readMemory = opts.readMemory ?? (() => {
11942
+ const u = process.memoryUsage();
11943
+ return {
11944
+ rss: u.rss,
11945
+ heapUsed: u.heapUsed
11946
+ };
11947
+ });
11948
+ const sampleIntervalMs = opts.sampleIntervalMs ?? envMs("SKYDIVE_MEMORY_SAMPLE_MS", DEFAULT_SAMPLE_INTERVAL_MS);
11949
+ const warnThreshold = opts.warnThresholdBytes ?? envBytes("SKYDIVE_MEMORY_WARN_BYTES", DEFAULT_WARN_THRESHOLD_BYTES);
11950
+ const reportThreshold = opts.reportThresholdBytes ?? envBytes("SKYDIVE_MEMORY_REPORT_BYTES", DEFAULT_REPORT_THRESHOLD_BYTES);
11951
+ const startedAt = now();
11952
+ const samples = [];
11953
+ let peakRss = 0;
11954
+ let peakHeapUsed = 0;
11955
+ let warned = false;
11956
+ function sample() {
11957
+ const { rss, heapUsed } = readMemory();
11958
+ if (rss > peakRss) peakRss = rss;
11959
+ if (heapUsed > peakHeapUsed) peakHeapUsed = heapUsed;
11960
+ const s = {
11961
+ at: now() - startedAt,
11962
+ rss,
11963
+ heapUsed
11964
+ };
11965
+ samples.push(s);
11966
+ if (samples.length > MAX_SAMPLES) samples.shift();
11967
+ if (!warned && rss >= warnThreshold) {
11968
+ warned = true;
11969
+ opts.onWarn?.({
11970
+ rss,
11971
+ threshold: warnThreshold
11972
+ });
11973
+ }
11974
+ return s;
11975
+ }
11976
+ function snapshot() {
11977
+ if (disabled) return null;
11978
+ sample();
11979
+ return {
11980
+ peakRss,
11981
+ peakHeapUsed,
11982
+ warnThreshold,
11983
+ uptimeMs: now() - startedAt,
11984
+ samples: samples.slice()
11985
+ };
11986
+ }
11987
+ if (disabled) return {
11988
+ peakRss: () => 0,
11989
+ samples: () => [],
11990
+ snapshot: () => null,
11991
+ stop: () => {}
11992
+ };
11993
+ sample();
11994
+ const timer = setInterval(sample, sampleIntervalMs);
11995
+ if (typeof timer.unref === "function") timer.unref();
11996
+ const onExit = () => {
11997
+ if (peakRss < reportThreshold) return;
11998
+ writeCrashReport(buildCrashReport({
11999
+ reason: "high-memory-exit",
12000
+ memory: {
12001
+ peakRss,
12002
+ peakHeapUsed,
12003
+ warnThreshold,
12004
+ uptimeMs: now() - startedAt,
12005
+ samples: samples.slice()
12006
+ }
12007
+ }));
12008
+ };
12009
+ const installExitHandler = opts.installExitHandler ?? true;
12010
+ if (installExitHandler) process.on("exit", onExit);
12011
+ return {
12012
+ peakRss: () => peakRss,
12013
+ samples: () => samples.slice(),
12014
+ snapshot,
12015
+ stop: () => {
12016
+ clearInterval(timer);
12017
+ if (installExitHandler) process.off("exit", onExit);
12018
+ }
12019
+ };
12020
+ }
12021
+
12022
+ //#endregion
12023
+ //#region src/chat/theme-boot.ts
12024
+ /**
12025
+ * How long the first paint waits for the terminal to report its background
12026
+ * colour via OSC 10/11.
12027
+ *
12028
+ * OpenTUI's own default is 1000ms, and `createCliRenderer` has already switched
12029
+ * to the alternate screen by the time we wait — so on a terminal that never
12030
+ * answers the query (no OSC theme support, or a passthrough that eats it) the
12031
+ * user watched a blank cleared screen for a flat second on every launch, which
12032
+ * measured as 68% of cold start. Bound it by our budget rather than the
12033
+ * dependency's: 20ms is a typical real answer, so 100ms is generous.
12034
+ */
12035
+ const THEME_QUERY_GRACE_MS = 100;
12036
+ /**
12037
+ * Settle the theme for the first paint, and keep following the terminal after
12038
+ * it.
12039
+ *
12040
+ * Order is the whole point and is load-bearing:
12041
+ *
12042
+ * 1. Subscribe to `theme_mode` FIRST. It carries both a late OSC answer and
12043
+ * every later system light/dark flip, so with the subscription in place,
12044
+ * giving up on the query early can only cost a briefly-wrong first paint —
12045
+ * never a permanently wrong theme.
12046
+ * 2. Apply the synchronous `COLORFGBG` sniff (iTerm2 and friends set it) so
12047
+ * there is something correct to paint at t=0.
12048
+ * 3. Only then wait, briefly, for the real answer.
12049
+ *
12050
+ * Inverting 2 and 3 is the bug this replaced: `COLORFGBG` was consulted only
12051
+ * after the 1000ms timeout expired, i.e. we blocked the launch waiting for
12052
+ * information we already had.
12053
+ */
12054
+ async function settleBootThemeMode({ source, applyThemeMode, graceMs = THEME_QUERY_GRACE_MS, env }) {
12055
+ source.on("theme_mode", applyThemeMode);
12056
+ applyThemeMode(themeModeFromColorFgBg(env) ?? "dark");
12057
+ const answered = await source.waitForThemeMode(graceMs);
12058
+ if (answered) applyThemeMode(answered);
12059
+ }
12060
+
9751
12061
  //#endregion
9752
12062
  //#region src/chat/boot.tsx
9753
12063
  /**
@@ -9756,12 +12066,15 @@ var LinkifiedMarkdownRenderable = class extends MarkdownRenderable {
9756
12066
  * module — and the heavy OpenTUI dependency graph it pulls in — is
9757
12067
  * dynamically imported and never loaded on the Node management path.
9758
12068
  */
9759
- async function runChat({ appUrl, sessionToken, shareMachine, promptHistoryPath, theme: themeOverride, notifications, agentSelector, conversationId, seedPrompt }) {
12069
+ async function runChat({ appUrl, sessionToken, shareMachine, promptHistoryPath, theme: themeOverride, notifications, agentSelector, conversationId, newConversation, seedPrompt }) {
12070
+ const watchdog = startMemoryWatchdog();
12071
+ installCrashHandler({ memoryProvider: () => watchdog.snapshot() });
9760
12072
  const renderer = await createCliRenderer({ exitOnCtrlC: false });
9761
12073
  extend({ markdown: LinkifiedMarkdownRenderable });
9762
12074
  useStore.setState({
9763
12075
  seedPrompt: seedPrompt ?? null,
9764
- autoGrantMachine: Boolean(seedPrompt && shareMachine)
12076
+ autoGrantMachine: Boolean(seedPrompt && shareMachine),
12077
+ newConversationIntent: newConversation
9765
12078
  });
9766
12079
  const store = useStore.getState();
9767
12080
  if (noColorRequested()) {
@@ -9771,13 +12084,10 @@ async function runChat({ appUrl, sessionToken, shareMachine, promptHistoryPath,
9771
12084
  themeId: monoTheme.id
9772
12085
  });
9773
12086
  } else if (themeOverride && findTheme(themeOverride)) store.lockTheme(themeOverride);
9774
- else {
9775
- const detected = await renderer.waitForThemeMode() ?? themeModeFromColorFgBg() ?? "dark";
9776
- store.setThemeMode(detected);
9777
- renderer.on("theme_mode", (mode) => {
9778
- useStore.getState().setThemeMode(mode);
9779
- });
9780
- }
12087
+ else await settleBootThemeMode({
12088
+ source: renderer,
12089
+ applyThemeMode: (mode) => useStore.getState().setThemeMode(mode)
12090
+ });
9781
12091
  createRoot(renderer).render(/* @__PURE__ */ jsx(App, {
9782
12092
  appUrl,
9783
12093
  sessionToken,
@@ -9785,14 +12095,17 @@ async function runChat({ appUrl, sessionToken, shareMachine, promptHistoryPath,
9785
12095
  promptHistoryPath,
9786
12096
  notifications,
9787
12097
  agentSelector: agentSelector ?? null,
9788
- conversationId: conversationId ?? null
12098
+ conversationId: conversationId ?? null,
12099
+ newConversation
9789
12100
  }));
9790
12101
  }
9791
12102
  /** Open the same workspace picker used by the initial agent screen. */
9792
12103
  async function runWorkspacePicker({ appUrl, sessionToken }) {
9793
12104
  const renderer = await createCliRenderer({ exitOnCtrlC: false });
9794
- const detected = await renderer.waitForThemeMode() ?? themeModeFromColorFgBg() ?? "dark";
9795
- useStore.getState().setThemeMode(detected);
12105
+ await settleBootThemeMode({
12106
+ source: renderer,
12107
+ applyThemeMode: (mode) => useStore.getState().setThemeMode(mode)
12108
+ });
9796
12109
  const close = () => renderer.destroy();
9797
12110
  createRoot(renderer).render(/* @__PURE__ */ jsx("box", {
9798
12111
  style: {