skydive-cli 0.2.0 → 0.3.0-beta.764

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +114 -22
  3. package/dist/js/bin.mjs +1261 -128
  4. package/dist/js/{boot-CGaXUEer.mjs → boot-Bs-IF23f.mjs} +2573 -199
  5. package/dist/js/chat/tui/chunks/grammars/assets/bash.highlights.scm +56 -0
  6. package/dist/js/chat/tui/chunks/grammars/assets/bash.wasm +0 -0
  7. package/dist/js/chat/tui/chunks/grammars/assets/c.highlights.scm +81 -0
  8. package/dist/js/chat/tui/chunks/grammars/assets/c.wasm +0 -0
  9. package/dist/js/chat/tui/chunks/grammars/assets/cpp.highlights.scm +70 -0
  10. package/dist/js/chat/tui/chunks/grammars/assets/cpp.wasm +0 -0
  11. package/dist/js/chat/tui/chunks/grammars/assets/css.highlights.scm +76 -0
  12. package/dist/js/chat/tui/chunks/grammars/assets/css.wasm +0 -0
  13. package/dist/js/chat/tui/chunks/grammars/assets/go.highlights.scm +123 -0
  14. package/dist/js/chat/tui/chunks/grammars/assets/go.wasm +0 -0
  15. package/dist/js/chat/tui/chunks/grammars/assets/html.highlights.scm +13 -0
  16. package/dist/js/chat/tui/chunks/grammars/assets/html.wasm +0 -0
  17. package/dist/js/chat/tui/chunks/grammars/assets/java.highlights.scm +149 -0
  18. package/dist/js/chat/tui/chunks/grammars/assets/java.wasm +0 -0
  19. package/dist/js/chat/tui/chunks/grammars/assets/json.highlights.scm +16 -0
  20. package/dist/js/chat/tui/chunks/grammars/assets/json.wasm +0 -0
  21. package/dist/js/chat/tui/chunks/grammars/assets/lua.highlights.scm +201 -0
  22. package/dist/js/chat/tui/chunks/grammars/assets/lua.wasm +0 -0
  23. package/dist/js/chat/tui/chunks/grammars/assets/python.highlights.scm +137 -0
  24. package/dist/js/chat/tui/chunks/grammars/assets/python.wasm +0 -0
  25. package/dist/js/chat/tui/chunks/grammars/assets/ruby.highlights.scm +154 -0
  26. package/dist/js/chat/tui/chunks/grammars/assets/ruby.wasm +0 -0
  27. package/dist/js/chat/tui/chunks/grammars/assets/rust.highlights.scm +161 -0
  28. package/dist/js/chat/tui/chunks/grammars/assets/rust.wasm +0 -0
  29. package/dist/js/chat/tui/chunks/grammars/assets/toml.highlights.scm +53 -0
  30. package/dist/js/chat/tui/chunks/grammars/assets/toml.wasm +0 -0
  31. package/dist/js/chat/tui/chunks/grammars/assets/yaml.highlights.scm +79 -0
  32. package/dist/js/chat/tui/chunks/grammars/assets/yaml.wasm +0 -0
  33. package/dist/js/chat/tui/chunks/grammars/grammars.ts +146 -0
  34. package/dist/js/{client-B3ZhhF7e.mjs → client-Dc7GZ3PG.mjs} +148 -8
  35. package/dist/js/client-DuwxEDG4.mjs +5 -0
  36. package/dist/js/daemon-Ch_MSDmQ.mjs +6 -0
  37. package/dist/js/daemon-Co4CtpXZ.mjs +607 -0
  38. package/dist/js/{theme-CuQhvqzN.mjs → install-DEZmJLGH.mjs} +256 -186
  39. package/dist/js/{print-BZH0b3KB.mjs → print-CPCFci60.mjs} +2 -2
  40. package/dist/js/{print-Bin4u16d.mjs → print-Cd-bVSEJ.mjs} +224 -10
  41. package/dist/js/{print-share-D7OSxvE2.mjs → print-share-7qfpQtWK.mjs} +3 -3
  42. package/dist/js/{raw-pty-GAvxm2ol.mjs → raw-pty-BcjbTjHJ.mjs} +1 -1
  43. package/dist/js/raw-pty-ChUHav4d.mjs +5 -0
  44. package/dist/js/{rest-CamHVOce.mjs → rest-BlN_uWmL.mjs} +150 -19
  45. package/dist/js/rest-CDTXCmUb.mjs +4 -0
  46. package/package.json +3 -1
  47. package/dist/js/api-CDTKq_5Q.mjs +0 -145
  48. package/dist/js/client-D6NAkL9e.mjs +0 -6
  49. package/dist/js/raw-pty-2_VA1kw_.mjs +0 -5
  50. package/dist/js/rest-COkLEZOB.mjs +0 -4
  51. package/dist/js/util-CeisaZVY.mjs +0 -15
  52. /package/dist/js/{client-CpEvH2Pq.mjs → client-DfcJFEbh.mjs} +0 -0
  53. /package/dist/js/{output-B4cW10Ph.mjs → output-DYzzdXYV.mjs} +0 -0
@@ -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-DEZmJLGH.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, addDefaultParsers, 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;
@@ -2022,8 +3056,13 @@ function syntaxStyleTable() {
2022
3056
  "keyword.function": { fg: theme.syntaxMagenta },
2023
3057
  "keyword.return": { fg: theme.syntaxMagenta },
2024
3058
  "keyword.operator": { fg: theme.syntaxMagenta },
3059
+ conditional: { fg: theme.syntaxMagenta },
3060
+ repeat: { fg: theme.syntaxMagenta },
3061
+ preproc: { fg: theme.syntaxMagenta },
2025
3062
  string: { fg: theme.syntaxGreen },
2026
3063
  "string.special": { fg: theme.syntaxTeal },
3064
+ escape: { fg: theme.syntaxTeal },
3065
+ "string.escape": { fg: theme.syntaxTeal },
2027
3066
  character: { fg: theme.syntaxGreen },
2028
3067
  comment: {
2029
3068
  fg: theme.dim,
@@ -2032,10 +3071,13 @@ function syntaxStyleTable() {
2032
3071
  function: { fg: theme.syntaxBlue },
2033
3072
  "function.method": { fg: theme.syntaxBlue },
2034
3073
  "function.builtin": { fg: theme.syntaxCyan },
3074
+ method: { fg: theme.syntaxBlue },
2035
3075
  variable: { fg: theme.fg },
2036
3076
  "variable.builtin": { fg: theme.syntaxRed },
2037
3077
  "variable.parameter": { fg: theme.syntaxYellow },
2038
3078
  "variable.member": { fg: theme.syntaxTeal },
3079
+ parameter: { fg: theme.syntaxYellow },
3080
+ field: { fg: theme.syntaxTeal },
2039
3081
  type: { fg: theme.syntaxCyan },
2040
3082
  "type.builtin": { fg: theme.syntaxCyan },
2041
3083
  constant: { fg: theme.syntaxOrange },
@@ -2050,6 +3092,7 @@ function syntaxStyleTable() {
2050
3092
  "punctuation.bracket": { fg: theme.muted },
2051
3093
  "punctuation.delimiter": { fg: theme.muted },
2052
3094
  "punctuation.special": { fg: theme.syntaxCyan },
3095
+ delimiter: { fg: theme.muted },
2053
3096
  constructor: { fg: theme.syntaxBlue },
2054
3097
  module: { fg: theme.syntaxBlue },
2055
3098
  label: { fg: theme.syntaxCyan },
@@ -2446,10 +3489,16 @@ function trimCommonIndent(hunks) {
2446
3489
  for (const hunk of hunks) hunk.lines = hunk.lines.map((line) => isContentLine(line) ? line[0] + line.slice(1 + min) : line);
2447
3490
  }
2448
3491
  /**
2449
- * OpenTUI 0.2.15 bundles tree-sitter parsers for javascript(+react),
2450
- * typescript(+react), markdown, and zig only anything else renders
2451
- * unhighlighted (but still red/green). Returning undefined skips the
2452
- * highlight pass entirely instead of asking for a parser that isn't there.
3492
+ * Maps a file extension to the tree-sitter filetype used to highlight it in
3493
+ * diffs and the file/review pane. OpenTUI bundles parsers for
3494
+ * javascript(+react), typescript(+react), markdown, and zig; the rest are
3495
+ * registered by ./grammars. An extension absent here returns
3496
+ * undefined, which skips the highlight pass (the diff still renders red/green)
3497
+ * rather than asking the client for a parser it doesn't have.
3498
+ *
3499
+ * Keep the filetype values in sync with the canonical `filetype`/`aliases`
3500
+ * registered in ./grammars/grammars.ts — an extension mapping to a filetype no
3501
+ * parser is registered for just renders plain.
2453
3502
  */
2454
3503
  const FILETYPE_BY_EXT = {
2455
3504
  ts: "typescript",
@@ -2462,7 +3511,34 @@ const FILETYPE_BY_EXT = {
2462
3511
  jsx: "javascriptreact",
2463
3512
  md: "markdown",
2464
3513
  markdown: "markdown",
2465
- zig: "zig"
3514
+ zig: "zig",
3515
+ py: "python",
3516
+ pyi: "python",
3517
+ pyw: "python",
3518
+ sh: "bash",
3519
+ bash: "bash",
3520
+ zsh: "bash",
3521
+ go: "go",
3522
+ rs: "rust",
3523
+ c: "c",
3524
+ h: "c",
3525
+ cpp: "cpp",
3526
+ cxx: "cpp",
3527
+ cc: "cpp",
3528
+ hpp: "cpp",
3529
+ hxx: "cpp",
3530
+ hh: "cpp",
3531
+ java: "java",
3532
+ rb: "ruby",
3533
+ css: "css",
3534
+ html: "html",
3535
+ htm: "html",
3536
+ json: "json",
3537
+ jsonc: "json",
3538
+ yaml: "yaml",
3539
+ yml: "yaml",
3540
+ toml: "toml",
3541
+ lua: "lua"
2466
3542
  };
2467
3543
  function filetypeForPath(filePath) {
2468
3544
  const base = filePath.split("/").pop() ?? filePath;
@@ -3264,6 +4340,149 @@ function formatElapsed(totalSeconds) {
3264
4340
  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
3265
4341
  }
3266
4342
 
4343
+ //#endregion
4344
+ //#region src/chat/tui/chat/clamp.ts
4345
+ /** Max bytes we keep for a single clamped string field (head + tail combined). */
4346
+ const MAX_FIELD_CHARS = 24e3;
4347
+ /** Below this a string is never worth clamping (avoids churn on small fields). */
4348
+ const MIN_CLAMP_CHARS = MAX_FIELD_CHARS + 2e3;
4349
+ /** Fraction of the budget kept from the head; the rest is kept from the tail. */
4350
+ const HEAD_FRACTION = .7;
4351
+ /**
4352
+ * Clamp one string to a head + tail with an elision marker in the middle.
4353
+ * Idempotent: a string already carrying our marker is returned unchanged, so
4354
+ * re-clamping a loaded-from-history item (or a re-render) never re-slices.
4355
+ */
4356
+ function clampString(text) {
4357
+ if (text.length <= MIN_CLAMP_CHARS) return text;
4358
+ if (text.includes(ELISION_MARKER_TAG)) return text;
4359
+ const headLen = Math.floor(MAX_FIELD_CHARS * HEAD_FRACTION);
4360
+ const tailLen = MAX_FIELD_CHARS - headLen;
4361
+ const elided = text.length - MAX_FIELD_CHARS;
4362
+ const head = text.slice(0, headLen);
4363
+ const tail = text.slice(text.length - tailLen);
4364
+ return `${head}\n${elisionMarker(elided)}\n${tail}`;
4365
+ }
4366
+ /**
4367
+ * Append to a growing output buffer while keeping it bounded, preferring the
4368
+ * TAIL — for live streaming shell output the newest bytes are what you want on
4369
+ * screen. Used by the `!` and `/sandbox` streams, whose output would otherwise
4370
+ * grow without limit for a long-running command.
4371
+ */
4372
+ function clampedAppend(current, chunk) {
4373
+ const next = current + chunk;
4374
+ if (next.length <= MIN_CLAMP_CHARS) return next;
4375
+ const priorMarkerLen = leadingMarkerLength(current);
4376
+ const bodyLen = next.length - priorMarkerLen;
4377
+ const kept = stripLeadingMarker(next.slice(next.length - MAX_FIELD_CHARS));
4378
+ return `${elisionMarker(Math.max(0, bodyLen - kept.length))}\n${kept}`;
4379
+ }
4380
+ /** Length (incl. trailing newline) of a marker line at the very start of `s`. */
4381
+ function leadingMarkerLength(s) {
4382
+ if (!s.startsWith(ELISION_MARKER_TAG)) return 0;
4383
+ const nl = s.indexOf("\n");
4384
+ return nl === -1 ? s.length : nl + 1;
4385
+ }
4386
+ function stripLeadingMarker(s) {
4387
+ return s.slice(leadingMarkerLength(s));
4388
+ }
4389
+ const ELISION_MARKER_TAG = "⋯ elided";
4390
+ function elisionMarker(bytes) {
4391
+ return `${ELISION_MARKER_TAG} ${formatBytes$2(bytes)} to save memory ⋯`;
4392
+ }
4393
+ function formatBytes$2(n) {
4394
+ if (n < 1024) return `${n} chars`;
4395
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4396
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4397
+ }
4398
+ /**
4399
+ * Recursively clamp the large string fields of an arbitrary tool payload
4400
+ * (input or output `value`). Structure is preserved so the tool renderers,
4401
+ * which reach for known keys like `.stdout` / `.output` / `.content`, still
4402
+ * find them — only the *size* of long strings is bounded. Depth and breadth
4403
+ * are capped so a pathological payload can't make clamping itself expensive.
4404
+ */
4405
+ function clampPayload(value, depth = 0) {
4406
+ if (typeof value === "string") return clampString(value);
4407
+ if (depth >= 6) return value;
4408
+ if (Array.isArray(value)) {
4409
+ const overLong = value.length > 5e3;
4410
+ const source = overLong ? value.slice(0, 5e3) : value;
4411
+ let changed = overLong;
4412
+ const mapped = source.map((v) => {
4413
+ const c = clampPayload(v, depth + 1);
4414
+ if (c !== v) changed = true;
4415
+ return c;
4416
+ });
4417
+ return changed ? mapped : value;
4418
+ }
4419
+ if (isRecord(value)) {
4420
+ let changed = false;
4421
+ const out = {};
4422
+ for (const [k, v] of Object.entries(value)) {
4423
+ const c = clampPayload(v, depth + 1);
4424
+ if (c !== v) changed = true;
4425
+ out[k] = c;
4426
+ }
4427
+ return changed ? out : value;
4428
+ }
4429
+ return value;
4430
+ }
4431
+ /**
4432
+ * Clamp a single MessageItem in place-safe (returns a new object only when it
4433
+ * actually changed). Applied by the reducer when a tool settles and by the
4434
+ * history loader for every loaded item. Streaming text bubbles are left alone
4435
+ * — they're bounded by the model's output and clamping mid-stream would fight
4436
+ * the append path; only large tool/shell payloads and finished long texts are
4437
+ * worth clamping.
4438
+ */
4439
+ function clampItem(item) {
4440
+ switch (item.kind) {
4441
+ case "tool": {
4442
+ const input = item.input == null ? item.input : clampPayload(item.input);
4443
+ const inputText = item.inputText.length > MIN_CLAMP_CHARS ? clampString(item.inputText) : item.inputText;
4444
+ let output = item.output;
4445
+ if (output?.kind === "ok") {
4446
+ const value = clampPayload(output.value);
4447
+ if (value !== output.value) output = {
4448
+ kind: "ok",
4449
+ value
4450
+ };
4451
+ } else if (output?.kind === "error") {
4452
+ const errorText = clampString(output.errorText);
4453
+ if (errorText !== output.errorText) output = {
4454
+ kind: "error",
4455
+ errorText
4456
+ };
4457
+ }
4458
+ if (input === item.input && inputText === item.inputText && output === item.output) return item;
4459
+ return {
4460
+ ...item,
4461
+ input,
4462
+ inputText,
4463
+ output
4464
+ };
4465
+ }
4466
+ case "shell": {
4467
+ const clamped = clampString(item.output);
4468
+ return clamped === item.output ? item : {
4469
+ ...item,
4470
+ output: clamped
4471
+ };
4472
+ }
4473
+ case "assistant-text":
4474
+ case "reasoning": {
4475
+ if (!item.done) return item;
4476
+ const clamped = clampString(item.text);
4477
+ return clamped === item.text ? item : {
4478
+ ...item,
4479
+ text: clamped
4480
+ };
4481
+ }
4482
+ default: return item;
4483
+ }
4484
+ }
4485
+
3267
4486
  //#endregion
3268
4487
  //#region src/chat/tui/chat/reducer.ts
3269
4488
  function str(chunk, key) {
@@ -3346,7 +4565,7 @@ function applyChunk(items, chunk, agentName = null) {
3346
4565
  case "tool-input-available": {
3347
4566
  const toolCallId = str(chunk, "toolCallId");
3348
4567
  const toolName = str(chunk, "toolName");
3349
- const input = chunk.input ?? null;
4568
+ const input = chunk.input == null ? null : clampPayload(chunk.input);
3350
4569
  if (!toolCallId) return [...items];
3351
4570
  if (items.find((m) => m.kind === "tool" && m.id === toolCallId)) return items.map((m) => m.kind === "tool" && m.id === toolCallId ? {
3352
4571
  ...m,
@@ -3367,7 +4586,7 @@ function applyChunk(items, chunk, agentName = null) {
3367
4586
  case "tool-output-available": {
3368
4587
  const toolCallId = str(chunk, "toolCallId");
3369
4588
  if (!toolCallId) return [...items];
3370
- const output = chunk.output ?? null;
4589
+ const output = chunk.output == null ? null : clampPayload(chunk.output);
3371
4590
  return items.map((m) => m.kind === "tool" && m.id === toolCallId ? {
3372
4591
  ...m,
3373
4592
  output: {
@@ -3380,7 +4599,7 @@ function applyChunk(items, chunk, agentName = null) {
3380
4599
  case "tool-output-error": {
3381
4600
  const toolCallId = str(chunk, "toolCallId");
3382
4601
  if (!toolCallId) return [...items];
3383
- const errorText = str(chunk, "errorText") ?? "tool error";
4602
+ const errorText = clampString(str(chunk, "errorText") ?? "tool error");
3384
4603
  return items.map((m) => m.kind === "tool" && m.id === toolCallId ? {
3385
4604
  ...m,
3386
4605
  output: {
@@ -3412,17 +4631,19 @@ function applyChunk(items, chunk, agentName = null) {
3412
4631
  if (!isRecord(data)) return [...items];
3413
4632
  const card = parseConnectCard(data.spec);
3414
4633
  if (!card) return [...items];
3415
- const id = (typeof data.toolCallId === "string" ? data.toolCallId : null) ?? cryptoId();
3416
- const next = {
4634
+ const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : null;
4635
+ const specKey = specKeyFor(data.spec);
4636
+ if (items.some((m) => m.kind === "card" && m.toolCallId === toolCallId && m.specKey === specKey)) return [...items];
4637
+ return [...items, {
3417
4638
  kind: "card",
3418
- id,
4639
+ id: nextCardId(items, toolCallId),
4640
+ toolCallId,
4641
+ specKey,
3419
4642
  card,
3420
4643
  status: "idle",
3421
4644
  detail: null,
3422
4645
  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];
4646
+ }];
3426
4647
  }
3427
4648
  case "data-anyone-conversation-started": return [...items];
3428
4649
  default: return [...items];
@@ -3525,6 +4746,38 @@ function directiveIdsFrom(data) {
3525
4746
  function cryptoId() {
3526
4747
  return crypto.randomUUID();
3527
4748
  }
4749
+ /** Distinct id per card, so cards from one tool call stay independently
4750
+ * addressable (status updates and the React key both go by `id`). */
4751
+ function nextCardId(items, toolCallId) {
4752
+ if (!toolCallId) return cryptoId();
4753
+ return `${toolCallId}#c${items.filter((m) => m.kind === "card" && m.toolCallId === toolCallId).length}`;
4754
+ }
4755
+
4756
+ //#endregion
4757
+ //#region src/chat/tui/chat/window.ts
4758
+ /** Max transcript rows mounted at once. */
4759
+ const MAX_MOUNTED_ROWS = 400;
4760
+ /**
4761
+ * Keep the most recent `max` rows mounted; report how many older ones were
4762
+ * dropped from the mount so the UI can show a "N earlier messages" marker.
4763
+ */
4764
+ function windowItems(items, max = MAX_MOUNTED_ROWS) {
4765
+ if (items.length <= max) return {
4766
+ visible: items.map((item, index) => ({
4767
+ item,
4768
+ index
4769
+ })),
4770
+ hiddenCount: 0
4771
+ };
4772
+ const start = items.length - max;
4773
+ return {
4774
+ visible: items.slice(start).map((item, offset) => ({
4775
+ item,
4776
+ index: start + offset
4777
+ })),
4778
+ hiddenCount: start
4779
+ };
4780
+ }
3528
4781
 
3529
4782
  //#endregion
3530
4783
  //#region src/chat/tui/chat/attribution.ts
@@ -3638,11 +4891,16 @@ function uiMessagesToItems(messages) {
3638
4891
  if (part.type === "data-anyone-render-spec") {
3639
4892
  const data = isRecord(part) && isRecord(part.data) ? part.data : null;
3640
4893
  const card = data ? parseConnectCard(data.spec) : null;
3641
- if (card) {
3642
- const toolCallId = data && typeof data.toolCallId === "string" ? data.toolCallId : null;
4894
+ if (card && data) {
4895
+ const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : null;
4896
+ const specKey = specKeyFor(data.spec);
4897
+ if (out.some((item) => item.kind === "card" && item.toolCallId === toolCallId && item.specKey === specKey)) continue;
4898
+ const ordinal = out.filter((item) => item.kind === "card" && item.toolCallId === toolCallId).length;
3643
4899
  out.push({
3644
4900
  kind: "card",
3645
- id: toolCallId ?? `${m.id}#c${textIdx++}`,
4901
+ id: toolCallId ? `${toolCallId}#c${ordinal}` : `${m.id}#c${textIdx++}`,
4902
+ toolCallId,
4903
+ specKey,
3646
4904
  card,
3647
4905
  status: "idle",
3648
4906
  detail: null,
@@ -3680,7 +4938,7 @@ function uiMessagesToItems(messages) {
3680
4938
  });
3681
4939
  }
3682
4940
  }
3683
- return out;
4941
+ return out.map(clampItem);
3684
4942
  }
3685
4943
  function mapToolState(raw) {
3686
4944
  switch (raw) {
@@ -3883,13 +5141,128 @@ function TodoCardView({ todos, isActive }) {
3883
5141
  });
3884
5142
  }
3885
5143
 
5144
+ //#endregion
5145
+ //#region src/chat/tui/chat/pr-links.tsx
5146
+ /**
5147
+ * Most PRs to show inline before collapsing the rest into a `+N` count. The
5148
+ * chip shares one row with the send/steer hint, so more than a couple would
5149
+ * crowd or wrap that row; the newest few are the useful ones.
5150
+ */
5151
+ const MAX_PR_CHIPS = 3;
5152
+ /**
5153
+ * Builds the styled `· PR #123 #124` chip as OpenTUI chunks. The number chunks
5154
+ * carry a first-class `link` (OpenTUI emits it as a zero-width OSC 8 hyperlink,
5155
+ * clickable in terminals that support it) — the same mechanism the transcript's
5156
+ * linkified markdown uses. We do NOT hand-roll OSC 8 escape strings into text
5157
+ * children: OpenTUI measures width by string length and emits its own SGR, so a
5158
+ * raw escape corrupts layout and leaks bytes on screen.
5159
+ *
5160
+ * Caps at {@link MAX_PR_CHIPS} shown; any beyond that collapse into a trailing
5161
+ * `+N` so the footer row can't be flooded by a run that links many PRs. Shows
5162
+ * the most recent (last-seen) PRs, since those are what's in play.
5163
+ */
5164
+ function prLinksStyledText(prs) {
5165
+ const dot = fg(theme.dim ?? "#565f89");
5166
+ const label = fg(theme.muted ?? "#7a7a7a");
5167
+ const accent = fg(theme.accent ?? "#7aa2f7");
5168
+ const shown = prs.slice(-MAX_PR_CHIPS);
5169
+ const overflow = prs.length - shown.length;
5170
+ const chunks = [dot("· "), label("PR ")];
5171
+ shown.forEach((pr, i) => {
5172
+ if (i > 0) chunks.push(label(" "));
5173
+ chunks.push(accent(link(pr.url)(`#${pr.number}`)));
5174
+ });
5175
+ if (overflow > 0) chunks.push(label(` +${overflow}`));
5176
+ return new StyledText(chunks);
5177
+ }
5178
+ /**
5179
+ * An inline chip for the conversation's linked PR(s), rendered on the
5180
+ * composer's status/hint row just after the send/steer hint — mirrors the
5181
+ * Claude Code affordance. Renders nothing when there are no PRs.
5182
+ */
5183
+ function PrLinksView({ prs }) {
5184
+ if (prs.length === 0) return null;
5185
+ return /* @__PURE__ */ jsx("text", { content: prLinksStyledText(prs) });
5186
+ }
5187
+
5188
+ //#endregion
5189
+ //#region src/chat/tui/chat/use-compaction.ts
5190
+ const COMPACTING_INDICATOR_TIMEOUT_MS = 12e4;
5191
+ /**
5192
+ * Owns everything about manual conversation compaction (`/compact`): the
5193
+ * persistent "compacting…" indicator state, the fire-and-forget command, the
5194
+ * reaction to conversation-stream `compaction` events, and the safety timeout
5195
+ * that clears a stranded `started` so the indicator can never pin forever.
5196
+ *
5197
+ * The screen keeps a single `streamConversation` subscription (which also
5198
+ * carries `run`/`conversation` events), so compaction events arrive there and
5199
+ * are forwarded in via {@link UseCompaction.onCompactionEvent} rather than this
5200
+ * hook opening its own stream.
5201
+ */
5202
+ function useCompaction({ rest, conversationId }) {
5203
+ const [compacting, setCompacting] = useState(false);
5204
+ const timerRef = useRef(null);
5205
+ const clearTimer = useCallback(() => {
5206
+ if (timerRef.current) {
5207
+ clearTimeout(timerRef.current);
5208
+ timerRef.current = null;
5209
+ }
5210
+ }, []);
5211
+ const armIndicator = useCallback(() => {
5212
+ clearTimer();
5213
+ setCompacting(true);
5214
+ timerRef.current = setTimeout(() => setCompacting(false), COMPACTING_INDICATOR_TIMEOUT_MS);
5215
+ }, [clearTimer]);
5216
+ const onCompactionEvent = useCallback((status) => {
5217
+ if (status === "started") {
5218
+ armIndicator();
5219
+ return;
5220
+ }
5221
+ clearTimer();
5222
+ setCompacting(false);
5223
+ if (status === "failed") useStore.getState().showToast({
5224
+ variant: "error",
5225
+ message: "compaction failed"
5226
+ });
5227
+ }, [armIndicator, clearTimer]);
5228
+ const compactConversation = useCallback(() => {
5229
+ if (!rest || !conversationId) {
5230
+ useStore.getState().showToast({ message: "nothing to compact yet — send a message first" });
5231
+ return;
5232
+ }
5233
+ armIndicator();
5234
+ rest.compactConversation({ conversationId }).catch((err) => {
5235
+ clearTimer();
5236
+ setCompacting(false);
5237
+ useStore.getState().showToast({
5238
+ variant: "error",
5239
+ message: `couldn't compact: ${errorDetail(err)}`
5240
+ });
5241
+ });
5242
+ }, [
5243
+ rest,
5244
+ conversationId,
5245
+ armIndicator,
5246
+ clearTimer
5247
+ ]);
5248
+ useEffect(() => clearTimer, [clearTimer]);
5249
+ return {
5250
+ compacting,
5251
+ compactConversation,
5252
+ onCompactionEvent
5253
+ };
5254
+ }
5255
+
3886
5256
  //#endregion
3887
5257
  //#region src/chat/tui/chat/footer-hints.ts
3888
5258
  /** 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 · " : "";
5259
+ function formatChatFooterStatus({ runKind, ctrlCArmed, isShared, hasPendingSteer, actionableCardCount, nonWebChannel }) {
5260
+ const connectHint = actionableCardCount > 1 ? `ctrl+r connect (${actionableCardCount}) · ` : actionableCardCount === 1 ? "ctrl+r connect · " : "";
3891
5261
  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`;
5262
+ case "idle":
5263
+ if (ctrlCArmed) return "press ctrl+c again to quit";
5264
+ if (nonWebChannel) return `esc back · ctrl+c quit · ctrl+t ${isShared ? "revoke access" : "local access"} · ? keybinds`;
5265
+ return `${connectHint}↵ send · / commands · esc back · ctrl+c quit · ctrl+t ${isShared ? "revoke access" : "local access"} · ? keybinds`;
3893
5266
  case "sending": return "sending…";
3894
5267
  case "streaming": return hasPendingSteer ? `${connectHint}↵ steer · esc cancel steer · ctrl+c cancel run` : `${connectHint}↵ steer · esc leave (keeps running) · ctrl+c cancel`;
3895
5268
  }
@@ -3928,9 +5301,29 @@ function formatShare(share) {
3928
5301
  //#region src/chat/notification-preview.ts
3929
5302
  const MAX_NOTIFICATION_PREVIEW_CHARS = 240;
3930
5303
  /**
5304
+ * Flattens common inline markdown to plain text for a notification / sidebar
5305
+ * preview, which renders as raw text (no markdown). Without this the preview
5306
+ * shows literal `**bold**` and `*italic*` markers. Intentionally lightweight —
5307
+ * not a full parser, just the inline markers that show up in a one-line
5308
+ * preview: emphasis, inline code, links/images, headings, blockquotes, and
5309
+ * list bullets. Block structure is already gone by the time we preview (we
5310
+ * collapse whitespace to a single line).
5311
+ */
5312
+ function stripInlineMarkdown(input) {
5313
+ 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, "");
5314
+ }
5315
+ /**
3931
5316
  * Collects assistant `text-delta` chunks for one run and turns them into a
3932
- * desktop-notification-sized preview. Reasoning, tool output, cards, errors,
5317
+ * notification/side-panel-sized preview. Reasoning, tool output, cards, errors,
3933
5318
  * and old transcript items are intentionally excluded.
5319
+ *
5320
+ * A run is a sequence of text parts split by tool calls and reasoning
5321
+ * (text → tool → text → … → final text). The meaningful summary of a run is
5322
+ * its **last** text part: the closing answer the agent lands on after all its
5323
+ * tool work, not the "let me look into this…" preamble it opened with. So the
5324
+ * preview keeps only the final part's text, and when that part is longer than
5325
+ * the cap it keeps the **beginning** with a trailing ellipsis (the
5326
+ * conventional truncation) — the block already opens on the answer.
3934
5327
  */
3935
5328
  function createResponsePreview() {
3936
5329
  let text = "";
@@ -3940,12 +5333,12 @@ function createResponsePreview() {
3940
5333
  if (chunk.type !== "text-delta") return;
3941
5334
  const delta = typeof chunk.delta === "string" ? chunk.delta : typeof chunk.text === "string" ? chunk.text : "";
3942
5335
  const partId = typeof chunk.id === "string" ? chunk.id : void 0;
3943
- if (text && partId && lastPartId && partId !== lastPartId) text += " ";
5336
+ if (partId && lastPartId && partId !== lastPartId) text = "";
3944
5337
  text += delta;
3945
5338
  lastPartId = partId ?? lastPartId;
3946
5339
  },
3947
5340
  get() {
3948
- const normalized = text.replace(/\s+/g, " ").trim();
5341
+ const normalized = stripInlineMarkdown(text).replace(/\s+/g, " ").trim();
3949
5342
  if (!normalized) return void 0;
3950
5343
  const characters = Array.from(normalized);
3951
5344
  if (characters.length <= MAX_NOTIFICATION_PREVIEW_CHARS) return normalized;
@@ -4197,6 +5590,10 @@ const keybindGroups = [
4197
5590
  keys: "exit",
4198
5591
  action: "quit (type it as a message)"
4199
5592
  },
5593
+ {
5594
+ keys: "ctrl+a",
5595
+ action: "archive this conversation (composer empty; hides it for you only)"
5596
+ },
4200
5597
  {
4201
5598
  keys: "ctrl+p",
4202
5599
  action: "switch model"
@@ -4225,14 +5622,26 @@ const keybindGroups = [
4225
5622
  keys: "ctrl+l",
4226
5623
  action: "open this conversation in the browser"
4227
5624
  },
5625
+ {
5626
+ keys: "ctrl+f",
5627
+ action: "fork a channel conversation into a chat you can continue here"
5628
+ },
4228
5629
  {
4229
5630
  keys: "ctrl+r",
4230
- action: "run the newest connect card (auth cards)"
5631
+ action: "act on a waiting connect card (auth cards)"
4231
5632
  },
4232
5633
  {
4233
5634
  keys: "pgup / pgdn",
4234
5635
  action: "scroll transcript"
4235
5636
  },
5637
+ {
5638
+ keys: "end",
5639
+ action: "jump to latest (when scrolled up)"
5640
+ },
5641
+ {
5642
+ keys: "/plan [show|hide]",
5643
+ action: "show / hide the pinned plan card"
5644
+ },
4236
5645
  {
4237
5646
  keys: "/",
4238
5647
  action: "open the command menu (start of message)"
@@ -4294,6 +5703,10 @@ const keybindGroups = [
4294
5703
  keys: "1 / 2",
4295
5704
  action: "switch Changes / Files"
4296
5705
  },
5706
+ {
5707
+ keys: "\\",
5708
+ action: "toggle side / stacked layout"
5709
+ },
4297
5710
  {
4298
5711
  keys: "↵ / o / l",
4299
5712
  action: "focus selected file preview"
@@ -4369,14 +5782,22 @@ const keybindGroups = [
4369
5782
  keys: "↵",
4370
5783
  action: "open conversation"
4371
5784
  },
5785
+ {
5786
+ keys: "ctrl+a",
5787
+ action: "archive conversation (hides it for you only; again to restore)"
5788
+ },
4372
5789
  {
4373
5790
  keys: "ctrl+d",
4374
- action: "delete conversation (y/↵ confirm, n/esc keep)"
5791
+ action: "delete conversation for everyone (y/↵ confirm, n/esc keep)"
4375
5792
  },
4376
5793
  {
4377
5794
  keys: "tab",
4378
5795
  action: "show / hide the agent's own runs (schedules, webhooks, subagents)"
4379
5796
  },
5797
+ {
5798
+ keys: "shift+tab",
5799
+ action: "show / hide the conversations you archived"
5800
+ },
4380
5801
  {
4381
5802
  keys: "esc",
4382
5803
  action: "back to agent picker"
@@ -4659,6 +6080,27 @@ const slashCommands = [
4659
6080
  description: "start a new conversation with this agent",
4660
6081
  action: { kind: "new-conversation" }
4661
6082
  },
6083
+ {
6084
+ name: "fork",
6085
+ aliases: [],
6086
+ argsHint: null,
6087
+ description: "fork this conversation into a new one (full copy)",
6088
+ action: { kind: "fork" }
6089
+ },
6090
+ {
6091
+ name: "archive",
6092
+ aliases: [],
6093
+ argsHint: null,
6094
+ description: "archive this conversation and hide it from your list",
6095
+ action: { kind: "archive-conversation" }
6096
+ },
6097
+ {
6098
+ name: "conversation",
6099
+ aliases: ["conversations", "conv"],
6100
+ argsHint: "[id or link]",
6101
+ description: "switch conversation (no id: pick from the list)",
6102
+ action: { kind: "conversation" }
6103
+ },
4662
6104
  {
4663
6105
  name: "model",
4664
6106
  aliases: [],
@@ -4680,6 +6122,16 @@ const slashCommands = [
4680
6122
  description: "show connection and session status",
4681
6123
  action: { kind: "status" }
4682
6124
  },
6125
+ {
6126
+ name: "plan",
6127
+ aliases: ["todos", "todo"],
6128
+ argsHint: "[show|hide]",
6129
+ description: "show or hide the pinned plan card",
6130
+ action: {
6131
+ kind: "plan",
6132
+ mode: "toggle"
6133
+ }
6134
+ },
4683
6135
  {
4684
6136
  name: "share",
4685
6137
  aliases: [],
@@ -4698,9 +6150,30 @@ const slashCommands = [
4698
6150
  name: "connect",
4699
6151
  aliases: [],
4700
6152
  argsHint: null,
4701
- description: "run the newest connect card (auth cards)",
6153
+ description: "act on a waiting connect card (auth cards)",
4702
6154
  action: { kind: "connect" }
4703
6155
  },
6156
+ {
6157
+ name: "copy",
6158
+ aliases: ["cp"],
6159
+ argsHint: null,
6160
+ description: "copy the agent's last message to the clipboard",
6161
+ action: { kind: "copy" }
6162
+ },
6163
+ {
6164
+ name: "portal",
6165
+ aliases: ["local"],
6166
+ argsHint: null,
6167
+ description: "allow this agent to access your computer",
6168
+ action: { kind: "portal" }
6169
+ },
6170
+ {
6171
+ name: "compact",
6172
+ aliases: [],
6173
+ argsHint: null,
6174
+ description: "summarize older turns to free up context now",
6175
+ action: { kind: "compact" }
6176
+ },
4704
6177
  {
4705
6178
  name: "help",
4706
6179
  aliases: [],
@@ -4723,7 +6196,7 @@ const slashCommands = [
4723
6196
  *
4724
6197
  * An argument-less command only matches as the exact line — `/help me write
4725
6198
  * a plan` stays an ordinary message rather than swallowing the user's text
4726
- * into the help modal. Only `sandbox` accepts a rest.
6199
+ * into the help modal. Only `sandbox` and `conversation` accept a rest.
4727
6200
  */
4728
6201
  function matchSlashCommand(line) {
4729
6202
  if (!line.startsWith("/")) return null;
@@ -4796,6 +6269,93 @@ function printResumeHint(write = (text) => process.stdout.write(text)) {
4796
6269
  lastConversationId = null;
4797
6270
  }
4798
6271
 
6272
+ //#endregion
6273
+ //#region src/chat/tui/screens/card-picker.tsx
6274
+ /**
6275
+ * Asks which connect card ctrl+r should act on. A conversation can hold
6276
+ * several cards waiting at once (a batch of `platform auth configure` calls),
6277
+ * and the chord can't guess which one the user means — picking the newest
6278
+ * silently strands the rest, since a card left in `launched` or `error` stays
6279
+ * actionable forever. Rendered as an overlay in place of the transcript, like
6280
+ * the model and theme pickers, so the conversation and any live run survive.
6281
+ */
6282
+ function CardPicker({ cards, onSelect, onCancel }) {
6283
+ const { height } = useTerminalDimensions();
6284
+ const [highlight, setHighlight] = useState(0);
6285
+ const clamped = Math.min(highlight, Math.max(0, cards.length - 1));
6286
+ const visibleRows = Math.max(3, height - 6);
6287
+ const start = windowStart(clamped, cards.length, visibleRows);
6288
+ const windowed = cards.slice(start, start + visibleRows);
6289
+ useKeyboard((key) => {
6290
+ if (key.name === "escape") {
6291
+ onCancel();
6292
+ return;
6293
+ }
6294
+ if (key.name === "up") setHighlight(Math.max(0, clamped - 1));
6295
+ else if (key.name === "down") setHighlight(Math.min(Math.max(0, cards.length - 1), clamped + 1));
6296
+ else if (key.name === "return") {
6297
+ const card = cards[clamped];
6298
+ if (card) onSelect(card);
6299
+ }
6300
+ });
6301
+ useEffect(() => {
6302
+ if (cards.length === 0) onCancel();
6303
+ }, [cards.length, onCancel]);
6304
+ if (cards.length === 0) return null;
6305
+ return /* @__PURE__ */ jsxs("box", {
6306
+ style: {
6307
+ flexDirection: "column",
6308
+ flexGrow: 1
6309
+ },
6310
+ children: [
6311
+ /* @__PURE__ */ jsx("text", {
6312
+ fg: theme.accent,
6313
+ children: "connect"
6314
+ }),
6315
+ /* @__PURE__ */ jsx("box", {
6316
+ style: {
6317
+ flexDirection: "column",
6318
+ flexGrow: 1,
6319
+ marginTop: 1
6320
+ },
6321
+ children: windowed.map((card, i) => {
6322
+ const selected = start + i === clamped;
6323
+ const waiting = waitingOn(card);
6324
+ const label = card.card.button?.label ?? "";
6325
+ return /* @__PURE__ */ jsxs("text", {
6326
+ fg: selected ? theme.accent : theme.fg,
6327
+ children: [
6328
+ selected ? "› " : " ",
6329
+ "⚿ ",
6330
+ card.card.title,
6331
+ label ? /* @__PURE__ */ jsxs("span", {
6332
+ fg: theme.muted,
6333
+ children: [" · ", label]
6334
+ }) : null,
6335
+ waiting ? /* @__PURE__ */ jsxs("span", {
6336
+ fg: theme.dim,
6337
+ children: [" · ", waiting]
6338
+ }) : null
6339
+ ]
6340
+ }, card.id);
6341
+ })
6342
+ }),
6343
+ /* @__PURE__ */ jsxs("text", {
6344
+ fg: theme.dim,
6345
+ children: [cards.length, " waiting · ↑/↓ move · ↵ select · esc cancel"]
6346
+ })
6347
+ ]
6348
+ });
6349
+ }
6350
+ /** Why a card is still on the list, for the rows that aren't simply untouched. */
6351
+ function waitingOn(card) {
6352
+ switch (card.status) {
6353
+ case "launched": return "finish in your browser";
6354
+ case "error": return card.detail ?? "failed";
6355
+ default: return null;
6356
+ }
6357
+ }
6358
+
4799
6359
  //#endregion
4800
6360
  //#region src/chat/tui/screens/model-picker.tsx
4801
6361
  /**
@@ -5008,9 +6568,18 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
5008
6568
  });
5009
6569
  }
5010
6570
  const modelKeys = [
5011
- (m) => m.displayName,
5012
- (m) => m.providerDisplay ?? "",
5013
- (m) => m.id
6571
+ {
6572
+ get: (m) => m.displayName,
6573
+ weight: 1
6574
+ },
6575
+ {
6576
+ get: (m) => m.providerDisplay ?? "",
6577
+ weight: .9
6578
+ },
6579
+ {
6580
+ get: (m) => m.id,
6581
+ weight: .9
6582
+ }
5014
6583
  ];
5015
6584
  /**
5016
6585
  * The REST error routes return `{ "error": "message" }`. Pull that out for a
@@ -6676,7 +8245,7 @@ function gcOnce() {
6676
8245
  gcStarted = true;
6677
8246
  store.gc();
6678
8247
  }
6679
- function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHeight, active, shellLayout, tab, onTabChange, selectedChangeFile, onSelectedChangeFileChange, selectedWorkspaceFile, onSelectedWorkspaceFileChange, navigatorSize, onNavigatorResize, onInsertToComposer, onRequestChatFocus, onClose }) {
8248
+ function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHeight, active, shellLayout, tab, onTabChange, selectedChangeFile, onSelectedChangeFileChange, selectedWorkspaceFile, onSelectedWorkspaceFileChange, navigatorSize, onNavigatorResize, onToggleOrientation, onInsertToComposer, onRequestChatFocus, onClose }) {
6680
8249
  const width = viewportWidth;
6681
8250
  const height = viewportHeight;
6682
8251
  const shellHeight = Math.max(6, height - 2);
@@ -6827,6 +8396,10 @@ function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHei
6827
8396
  onTabChange("files");
6828
8397
  return;
6829
8398
  }
8399
+ if (key.name === "\\") {
8400
+ if (onToggleOrientation() === "unavailable") setStatus("no room to change layout in this terminal");
8401
+ return;
8402
+ }
6830
8403
  if (key.name === "s") {
6831
8404
  if (comments.length === 0) {
6832
8405
  setStatus("no pending comments");
@@ -7217,18 +8790,93 @@ function PaneRowView({ row, width, isCursor, inSelection, isAnchor, isCommented
7217
8790
  //#endregion
7218
8791
  //#region src/chat/tui/file-pane-layout.ts
7219
8792
  /**
8793
+ * Terminal cells are about twice as tall as they are wide, so a raw
8794
+ * cols-vs-rows compare would call almost any terminal "wide". Scale rows by
8795
+ * this factor to compare the *visual* aspect: a square-looking terminal has
8796
+ * `width ≈ height * CELL_ASPECT`.
8797
+ */
8798
+ const CELL_ASPECT = 2;
8799
+ /**
8800
+ * The automatic split follows the terminal's visual aspect: wider than tall
8801
+ * places review beside the chat (a vertical divider), taller than wide stacks
8802
+ * it underneath (a horizontal divider). This is the responsive default when
8803
+ * the user hasn't pinned an orientation.
8804
+ */
8805
+ function autoPrefersSide({ width, height }) {
8806
+ return width >= height * CELL_ASPECT;
8807
+ }
8808
+ /**
8809
+ * Bare-minimum room to render a split at all (as opposed to the higher
8810
+ * comfort thresholds the automatic choice uses). A user pin is honored down
8811
+ * to these: chat + review each need a usable strip, but we don't insist on
8812
+ * the roomier auto breakpoints. Mirrors the clamps in clampSidePaneWidth /
8813
+ * clampBelowPaneHeight so a pinned split never renders narrower than those.
8814
+ */
8815
+ function canRenderSide({ width, height }) {
8816
+ return width >= 76 && height >= 8;
8817
+ }
8818
+ function canRenderBelow({ width, height }) {
8819
+ return height >= 24 && width >= 24;
8820
+ }
8821
+ /**
7220
8822
  * Responsive placement for the file pane.
7221
8823
  *
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";
8824
+ * With no user pin the split follows the terminal's visual aspect (see
8825
+ * autoPrefersSide): wider than tall -> side, taller than wide -> stacked.
8826
+ * The preferred split still has to be renderable; if it isn't we use the
8827
+ * other, and cramped terminals that can render neither get the full body.
8828
+ *
8829
+ * A user `orientation` pin always wins as long as the terminal can render
8830
+ * that split at all (the lower canRender* bar) so you get the layout you
8831
+ * asked for even on a smallish terminal. Only when the pinned split
8832
+ * genuinely can't fit do we fall back: to the other split if it can render,
8833
+ * else to `fullscreen`.
8834
+ */
8835
+ function filePanePlacement({ width, height, orientation }) {
8836
+ const renderSide = canRenderSide({
8837
+ width,
8838
+ height
8839
+ });
8840
+ const renderBelow = canRenderBelow({
8841
+ width,
8842
+ height
8843
+ });
8844
+ if (orientation === "side") {
8845
+ if (renderSide) return "side";
8846
+ if (renderBelow) return "below";
8847
+ return "fullscreen";
8848
+ }
8849
+ if (orientation === "below") {
8850
+ if (renderBelow) return "below";
8851
+ if (renderSide) return "side";
8852
+ return "fullscreen";
8853
+ }
8854
+ if (autoPrefersSide({
8855
+ width,
8856
+ height
8857
+ })) {
8858
+ if (renderSide) return "side";
8859
+ if (renderBelow) return "below";
8860
+ return "fullscreen";
8861
+ }
8862
+ if (renderBelow) return "below";
8863
+ if (renderSide) return "side";
7230
8864
  return "fullscreen";
7231
8865
  }
8866
+ /**
8867
+ * Whether toggling the orientation would actually change anything: both
8868
+ * splits must be renderable. When only one (or neither) can render, the
8869
+ * caller surfaces a hint instead of a silent no-op.
8870
+ */
8871
+ function canToggleOrientation({ width, height }) {
8872
+ return canRenderSide({
8873
+ width,
8874
+ height
8875
+ }) && canRenderBelow({
8876
+ width,
8877
+ height
8878
+ });
8879
+ }
7232
8880
  /** Keep a dragged side pane wide enough to use and leave the chat usable. */
7233
8881
  function clampSidePaneWidth(width, bodyWidth) {
7234
8882
  return Math.max(44, Math.min(bodyWidth - 30, width));
@@ -7575,13 +9223,29 @@ function routeInput(raw) {
7575
9223
  kind: "sandbox-exec",
7576
9224
  command: match.rest
7577
9225
  } : { kind: "sandbox-pty" };
9226
+ case "conversation": return match.rest ? {
9227
+ kind: "open-conversation",
9228
+ ref: match.rest
9229
+ } : { kind: "conversation-picker" };
9230
+ case "plan": {
9231
+ const arg = match.rest.toLowerCase();
9232
+ return {
9233
+ kind: "plan",
9234
+ mode: arg === "show" ? "show" : arg === "hide" ? "hide" : "toggle"
9235
+ };
9236
+ }
7578
9237
  case "new-conversation":
9238
+ case "fork":
9239
+ case "archive-conversation":
7579
9240
  case "model-picker":
7580
9241
  case "theme-picker":
7581
9242
  case "status":
7582
9243
  case "share":
7583
9244
  case "browser":
7584
9245
  case "connect":
9246
+ case "copy":
9247
+ case "portal":
9248
+ case "compact":
7585
9249
  case "help":
7586
9250
  case "quit": return { kind: action.kind };
7587
9251
  default: {
@@ -7643,6 +9307,47 @@ async function runPtySession(opts) {
7643
9307
  }
7644
9308
  }
7645
9309
 
9310
+ //#endregion
9311
+ //#region src/chat/tui/screens/conversation-channel.ts
9312
+ function isNewConversation(c) {
9313
+ return "kind" in c && c.kind === "new";
9314
+ }
9315
+ /**
9316
+ * The channel a conversation belongs to when it isn't the web channel, else
9317
+ * null. A brand-new conversation is always web, and the list route reports the
9318
+ * web channel as either `'web'` or (legacy) null — both mean "post from here".
9319
+ * Anything else (slack, email, imessage, webhook, cron) is owned by that
9320
+ * channel: the message has to go through it, so the TUI can't send here.
9321
+ * Mirrors the web app's `channel !== null && channel !== 'web'` gate.
9322
+ */
9323
+ function nonWebChannelLabel(c) {
9324
+ if (isNewConversation(c)) return null;
9325
+ if (c.channel === null || c.channel === "web") return null;
9326
+ return c.channelLabel ?? c.channel;
9327
+ }
9328
+ /**
9329
+ * The list-row shape for a conversation the TUI just forked, so the chat
9330
+ * screen can reopen on the copy without waiting for a list refetch. A fork
9331
+ * carries none of the source's channel binding — that's what makes it
9332
+ * sendable from here — so it is always a web conversation.
9333
+ */
9334
+ function forkedConversationRef({ agent, forked }) {
9335
+ const now = (/* @__PURE__ */ new Date()).toISOString();
9336
+ return {
9337
+ id: forked.id,
9338
+ title: forked.title,
9339
+ createdAt: now,
9340
+ updatedAt: now,
9341
+ preview: null,
9342
+ channel: "web",
9343
+ channelLabel: null,
9344
+ agent: {
9345
+ id: agent.id,
9346
+ name: agent.name
9347
+ }
9348
+ };
9349
+ }
9350
+
7646
9351
  //#endregion
7647
9352
  //#region src/chat/tui/screens/chat.tsx
7648
9353
  /**
@@ -7679,8 +9384,14 @@ const pageScrollFraction = .75;
7679
9384
  /** Cap composer growth; past this the textarea scrolls its content instead. */
7680
9385
  const maxComposerRows = 8;
7681
9386
  const maxAttachments = 10;
7682
- function isNewConversation(c) {
7683
- return "kind" in c && c.kind === "new";
9387
+ /**
9388
+ * A server timestamp as local epoch ms, or null when it is missing or
9389
+ * unparseable so the caller can fall back to its own clock.
9390
+ */
9391
+ function parseStampMs(iso) {
9392
+ if (!iso) return null;
9393
+ const ms = Date.parse(iso);
9394
+ return Number.isNaN(ms) ? null : ms;
7684
9395
  }
7685
9396
  function formatBytes(bytes) {
7686
9397
  if (bytes < 1024) return `${bytes} B`;
@@ -7725,13 +9436,27 @@ function ChatScreen({ agent, conversation }) {
7725
9436
  const isShared = portal.status !== "off";
7726
9437
  const initialConversationId = isNewConversation(conversation) ? null : conversation.id;
7727
9438
  const agentHost = useAgentHost();
9439
+ const nonWebChannel = nonWebChannelLabel(conversation);
7728
9440
  const [conversationId, setConversationId] = useState(initialConversationId);
9441
+ const { compacting, compactConversation, onCompactionEvent } = useCompaction({
9442
+ rest,
9443
+ conversationId
9444
+ });
7729
9445
  const [items, setItems] = useState([]);
7730
9446
  const [todos, setTodos] = useState(null);
9447
+ const [planHidden, setPlanHidden] = useState(false);
9448
+ const [scrolledUp, setScrolledUp] = useState(false);
9449
+ const hadTodosRef = useRef(false);
9450
+ useEffect(() => {
9451
+ const hasTodos = !!todos && todos.length > 0;
9452
+ if (hasTodos && !hadTodosRef.current) setPlanHidden(false);
9453
+ hadTodosRef.current = hasTodos;
9454
+ }, [todos]);
7731
9455
  const [historyLoaded, setHistoryLoaded] = useState(initialConversationId === null);
7732
9456
  const [input, setInput] = useState("");
7733
9457
  const [run, setRun] = useState({ kind: "idle" });
7734
9458
  const [model, setModel] = useState(agent.model ?? null);
9459
+ const [cardPickerOpen, setCardPickerOpen] = useState(false);
7735
9460
  const [modelPickerOpen, setModelPickerOpen] = useState(false);
7736
9461
  const [themePickerOpen, setThemePickerOpen] = useState(false);
7737
9462
  const [helpOpen, setHelpOpen] = useState(false);
@@ -7747,6 +9472,7 @@ function ChatScreen({ agent, conversation }) {
7747
9472
  side: null,
7748
9473
  stacked: null
7749
9474
  });
9475
+ const [reviewOrientation, setReviewOrientation] = useState(null);
7750
9476
  const [reviewWorkspaceFile, setReviewWorkspaceFile] = useState(null);
7751
9477
  useEffect(() => {
7752
9478
  setReviewChangeFile(null);
@@ -7756,6 +9482,8 @@ function ChatScreen({ agent, conversation }) {
7756
9482
  const [composerRows, setComposerRows] = useState(1);
7757
9483
  const [attachments, setAttachments] = useState([]);
7758
9484
  const uploadsRef = useRef(/* @__PURE__ */ new Map());
9485
+ const [forking, setForking] = useState(false);
9486
+ const forkingRef = useRef(false);
7759
9487
  const [credPrompt, setCredPrompt] = useState(null);
7760
9488
  const credPromptOpen = credPrompt !== null;
7761
9489
  const [menuDismissed, setMenuDismissed] = useState(false);
@@ -7784,25 +9512,62 @@ function ChatScreen({ agent, conversation }) {
7784
9512
  agentHost.transition({ state: "idle" });
7785
9513
  agentHost.setSession(null);
7786
9514
  }, [agentHost]);
9515
+ const [prLinks, setPrLinks] = useState([]);
9516
+ const reportedPrsRef = useRef(/* @__PURE__ */ new Map());
9517
+ useEffect(() => () => {
9518
+ for (const pr of reportedPrsRef.current.values()) agentHost.removePullRequest(pr);
9519
+ reportedPrsRef.current = /* @__PURE__ */ new Map();
9520
+ }, [agentHost]);
7787
9521
  useEffect(() => {
7788
9522
  agentHost.setSession(conversationId);
7789
9523
  setResumeConversation(conversationId);
7790
9524
  }, [agentHost, conversationId]);
9525
+ const bindPortalCwd = useCallback(() => {
9526
+ if (!conversationId || !portalClient) return;
9527
+ if (portal.status === "off") return;
9528
+ const cwd = shellSessionRef.current?.cwd ?? process.cwd();
9529
+ portalClient.bind(conversationId, cwd);
9530
+ }, [
9531
+ conversationId,
9532
+ portalClient,
9533
+ portal.status
9534
+ ]);
7791
9535
  const scrollRef = useRef(null);
7792
9536
  const composerRef = useRef(null);
9537
+ const jumpToBottom = useCallback(() => {
9538
+ const box = scrollRef.current;
9539
+ if (!box) return;
9540
+ box.scrollTo({
9541
+ x: box.scrollLeft,
9542
+ y: box.scrollHeight
9543
+ });
9544
+ setScrolledUp(false);
9545
+ }, []);
9546
+ useEffect(() => {
9547
+ const id = setInterval(() => {
9548
+ const box = scrollRef.current;
9549
+ if (!box) return;
9550
+ const viewport = box.viewport.height;
9551
+ const atBottom = box.scrollTop + viewport >= box.scrollHeight - 1;
9552
+ setScrolledUp((prev) => prev === !atBottom ? prev : !atBottom);
9553
+ }, 150);
9554
+ return () => clearInterval(id);
9555
+ }, []);
7793
9556
  const reviewSideBoxRef = useRef(null);
7794
9557
  const reviewBelowBoxRef = useRef(null);
7795
9558
  const paneDragRef = useRef(null);
7796
9559
  const composerBoxHeight = composerRows + 2;
7797
- const pendingVisible = !grantPrompt && !credPrompt && attachments.length > 0;
9560
+ const pendingVisible = !grantPrompt && !credPrompt && !nonWebChannel && attachments.length > 0;
7798
9561
  const pendingRows = pendingVisible ? attachments.length + 2 : 0;
7799
9562
  const credPromptHeight = 4 + (credPrompt && (credPrompt.error || credPrompt.submitting) ? 1 : 0);
7800
- const bottomBoxHeight = credPrompt ? credPromptHeight : composerBoxHeight;
9563
+ const nonWebNoticeHeight = 5;
9564
+ const bottomBoxHeight = credPrompt ? credPromptHeight : nonWebChannel ? nonWebNoticeHeight : composerBoxHeight;
7801
9565
  const bodyWidth = Math.max(1, width - 2);
7802
9566
  const bodyHeight = Math.max(1, height - 3);
7803
9567
  const reviewPlacement = filePanePlacement({
7804
9568
  width,
7805
- height
9569
+ height,
9570
+ orientation: reviewOrientation
7806
9571
  });
7807
9572
  const reviewWidth = reviewPlacement === "side" ? clampSidePaneWidth(reviewSizePref.side ?? sidePaneWidth(bodyWidth), bodyWidth) : bodyWidth;
7808
9573
  const reviewHeight = reviewPlacement === "below" ? clampBelowPaneHeight(reviewSizePref.below ?? belowPaneHeight(bodyHeight), bodyHeight) : bodyHeight;
@@ -7811,9 +9576,11 @@ function ChatScreen({ agent, conversation }) {
7811
9576
  const chatHeight = bodyHeight;
7812
9577
  const inlineReviewHeight = reviewOpen && reviewPlacement === "below" ? reviewHeight : 0;
7813
9578
  const menuRows = menuOpen ? completionMenuHeight(menuCommands.length) : 0;
7814
- const todoCardVisible = !credPrompt && !!todos && todos.length > 0;
9579
+ const todoCardVisible = !credPrompt && !planHidden && !!todos && todos.length > 0;
7815
9580
  const todoRows = todoCardVisible ? todoCardHeight(todos) : 0;
7816
- const scrollHeight = Math.max(3, chatHeight - 2 - bottomBoxHeight - pendingRows - inlineReviewHeight - menuRows - todoRows);
9581
+ const jumpHintVisible = scrolledUp && !credPrompt;
9582
+ const jumpHintRows = jumpHintVisible ? 1 : 0;
9583
+ const scrollHeight = Math.max(3, chatHeight - 2 - bottomBoxHeight - pendingRows - inlineReviewHeight - menuRows - jumpHintRows - todoRows);
7817
9584
  useEffect(() => {
7818
9585
  if (reviewOpen && reviewFocused) {
7819
9586
  const composer = composerRef.current;
@@ -7840,11 +9607,13 @@ function ChatScreen({ agent, conversation }) {
7840
9607
  };
7841
9608
  }, [
7842
9609
  grantPrompt,
9610
+ cardPickerOpen,
7843
9611
  modelPickerOpen,
7844
9612
  themePickerOpen,
7845
9613
  helpOpen,
7846
9614
  reviewOpen,
7847
9615
  reviewFocused,
9616
+ reviewPlacement,
7848
9617
  credPromptOpen
7849
9618
  ]);
7850
9619
  useEffect(() => {
@@ -7861,6 +9630,16 @@ function ChatScreen({ agent, conversation }) {
7861
9630
  text: recapText
7862
9631
  });
7863
9632
  setItems(loaded);
9633
+ for (const item of loaded) {
9634
+ const text = item.kind === "assistant-text" ? item.text : item.kind === "tool" && item.output?.kind === "ok" ? stringifyToolOutput(item.output.value) : null;
9635
+ if (text === null) continue;
9636
+ for (const pr of extractPullRequests(text, { requireCompleteNumber: false })) {
9637
+ if (reportedPrsRef.current.has(pr.key)) continue;
9638
+ reportedPrsRef.current.set(pr.key, pr);
9639
+ setPrLinks((prev) => [...prev, pr]);
9640
+ agentHost.addPullRequest(pr);
9641
+ }
9642
+ }
7864
9643
  setTodos(latestTodosFromHistory(messages));
7865
9644
  } catch (err) {
7866
9645
  if (cancelled) return;
@@ -7876,24 +9655,36 @@ function ChatScreen({ agent, conversation }) {
7876
9655
  return () => {
7877
9656
  cancelled = true;
7878
9657
  };
7879
- }, [rest, initialConversationId]);
7880
- const attachToRun = useCallback((runId, runAgentName) => {
9658
+ }, [
9659
+ rest,
9660
+ initialConversationId,
9661
+ agentHost
9662
+ ]);
9663
+ const attachToRun = useCallback((runId, runAgentName, runStartedAtMs) => {
7881
9664
  if (!rest) return;
7882
9665
  seenRunsRef.current.add(runId);
7883
9666
  const abort = new AbortController();
7884
9667
  const responsePreview = createResponsePreview();
9668
+ const prScanner = createPullRequestScanner((pr) => {
9669
+ if (reportedPrsRef.current.has(pr.key)) return;
9670
+ reportedPrsRef.current.set(pr.key, pr);
9671
+ setPrLinks((prev) => [...prev, pr]);
9672
+ agentHost.addPullRequest(pr);
9673
+ });
7885
9674
  const streamAgentName = runAgentName ?? agent.name;
7886
- setRun({
9675
+ setRun((prev) => ({
7887
9676
  kind: "streaming",
7888
9677
  runId,
7889
- abort
7890
- });
9678
+ abort,
9679
+ startedAtMs: runStartedAtMs ?? (prev.kind === "idle" ? Date.now() : prev.startedAtMs)
9680
+ }));
7891
9681
  rest.streamRun({
7892
9682
  runId,
7893
9683
  signal: abort.signal,
7894
9684
  onEvent: (event) => {
7895
9685
  if (event.kind === "chunk") {
7896
9686
  responsePreview.addChunk(event.chunk);
9687
+ prScanner.addChunk(event.chunk);
7897
9688
  if (event.chunk.type === "data-anyone-todos-updated") {
7898
9689
  const next = todosFromPayload(event.chunk.data);
7899
9690
  if (next) setTodos(next.length > 0 ? next : null);
@@ -7938,6 +9729,8 @@ function ChatScreen({ agent, conversation }) {
7938
9729
  agentHost
7939
9730
  ]);
7940
9731
  const multiAgent = useMemo(() => conversationAgentNames(items, agent.name), [items, agent.name]).size > 1;
9732
+ const transcriptWindow = useMemo(() => windowItems(items), [items]);
9733
+ const themeGeneration = themeVersion();
7941
9734
  useEffect(() => {
7942
9735
  if (!rest || !conversationId) return;
7943
9736
  const abort = new AbortController();
@@ -7948,18 +9741,25 @@ function ChatScreen({ agent, conversation }) {
7948
9741
  if (event.kind === "run") {
7949
9742
  if (seenRunsRef.current.has(event.runId)) return;
7950
9743
  if (runRef.current.kind !== "idle") return;
7951
- attachToRun(event.runId);
9744
+ attachToRun(event.runId, null, parseStampMs(event.createdAt));
7952
9745
  return;
7953
9746
  }
7954
- if (event.title) setChatTitle(event.title);
9747
+ if (event.kind === "compaction") {
9748
+ onCompactionEvent(event.status);
9749
+ return;
9750
+ }
9751
+ if (event.kind === "conversation" && event.title) setChatTitle(event.title);
7955
9752
  }
7956
9753
  }).catch(() => {});
7957
- return () => abort.abort();
9754
+ return () => {
9755
+ abort.abort();
9756
+ };
7958
9757
  }, [
7959
9758
  rest,
7960
9759
  conversationId,
7961
9760
  setChatTitle,
7962
- attachToRun
9761
+ attachToRun,
9762
+ onCompactionEvent
7963
9763
  ]);
7964
9764
  const sendContent = useCallback(async (content, staged, opts) => {
7965
9765
  if (!rest) return;
@@ -7975,7 +9775,10 @@ function ChatScreen({ agent, conversation }) {
7975
9775
  ...stagedNames.length > 0 ? { attachments: stagedNames } : {}
7976
9776
  }]);
7977
9777
  const wasStreaming = runRef.current.kind === "streaming";
7978
- if (!wasStreaming) setRun({ kind: "sending" });
9778
+ if (!wasStreaming) setRun({
9779
+ kind: "sending",
9780
+ startedAtMs: Date.now()
9781
+ });
7979
9782
  try {
7980
9783
  const attachmentIds = [];
7981
9784
  const restorable = [];
@@ -8029,7 +9832,7 @@ function ChatScreen({ agent, conversation }) {
8029
9832
  setItems((prev) => [...prev.filter((m) => m.id !== optimisticId), {
8030
9833
  kind: "error",
8031
9834
  id: crypto.randomUUID(),
8032
- text: errorDetail(err)
9835
+ text: sendErrorMessage(err)
8033
9836
  }]);
8034
9837
  if (echo) setInput((existing) => existing ? existing : trimmed);
8035
9838
  const restore = staged.filter((a) => uploadsRef.current.has(a.tempId));
@@ -8075,6 +9878,7 @@ function ChatScreen({ agent, conversation }) {
8075
9878
  portalStatus,
8076
9879
  portalClient,
8077
9880
  agent.id,
9881
+ agent.name,
8078
9882
  sendContent
8079
9883
  ]);
8080
9884
  const cancelLatestSteer = useCallback(() => {
@@ -8161,7 +9965,7 @@ function ChatScreen({ agent, conversation }) {
8161
9965
  detail: "already connected"
8162
9966
  });
8163
9967
  } else if (action.kind === "open_github_app") {
8164
- const url = resolveConnectUrl(action.url, appUrl);
9968
+ const url = resolveConnectUrl(action.url, resolveWebUrl(appUrl));
8165
9969
  await openUrlInBrowser(url);
8166
9970
  updateCard(item.id, {
8167
9971
  status: "launched",
@@ -8190,10 +9994,15 @@ function ChatScreen({ agent, conversation }) {
8190
9994
  portalClient,
8191
9995
  portal.status
8192
9996
  ]);
8193
- const runNewestCard = useCallback(() => {
8194
- const target = [...itemsRef.current].reverse().find(isActionableCard);
8195
- if (!target) return false;
8196
- runCardAction(target);
9997
+ const promptForCards = useCallback(() => {
9998
+ const targets = itemsRef.current.filter(isActionableCard);
9999
+ const [only] = targets;
10000
+ if (!only) return false;
10001
+ if (targets.length === 1) {
10002
+ runCardAction(only);
10003
+ return true;
10004
+ }
10005
+ setCardPickerOpen(true);
8197
10006
  return true;
8198
10007
  }, [runCardAction]);
8199
10008
  const handleCredInput = useCallback((displayed) => {
@@ -8277,10 +10086,10 @@ function ChatScreen({ agent, conversation }) {
8277
10086
  }]);
8278
10087
  let captured = "";
8279
10088
  const appendOutput = (chunk) => {
8280
- captured += chunk;
10089
+ captured = clampedAppend(captured, chunk);
8281
10090
  setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
8282
10091
  ...m,
8283
- output: m.output + chunk
10092
+ output: clampedAppend(m.output, chunk)
8284
10093
  } : m));
8285
10094
  };
8286
10095
  let exitCode;
@@ -8294,6 +10103,7 @@ function ChatScreen({ agent, conversation }) {
8294
10103
  ...m,
8295
10104
  exit: exitCode
8296
10105
  } : m));
10106
+ bindPortalCwd();
8297
10107
  } catch (err) {
8298
10108
  appendOutput(`\n${errorMessage(err)}`);
8299
10109
  exitCode = 1;
@@ -8303,7 +10113,7 @@ function ChatScreen({ agent, conversation }) {
8303
10113
  } : m));
8304
10114
  }
8305
10115
  sendContent(formatShellContext(command, captured, exitCode), [], { echo: false });
8306
- }, [sendContent]);
10116
+ }, [sendContent, bindPortalCwd]);
8307
10117
  const runSandboxExec = useCallback((command) => {
8308
10118
  if (!appUrl || !sessionToken) return;
8309
10119
  const id = crypto.randomUUID();
@@ -8317,7 +10127,7 @@ function ChatScreen({ agent, conversation }) {
8317
10127
  const dec = new TextDecoder();
8318
10128
  const append = (chunk) => setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
8319
10129
  ...m,
8320
- output: m.output + chunk
10130
+ output: clampedAppend(m.output, chunk)
8321
10131
  } : m));
8322
10132
  const stamp = (exit) => setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
8323
10133
  ...m,
@@ -8438,7 +10248,7 @@ function ChatScreen({ agent, conversation }) {
8438
10248
  stageAttachment(image);
8439
10249
  }, [stageAttachment]);
8440
10250
  usePaste((event) => {
8441
- if (modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt) return;
10251
+ if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt || nonWebChannel) return;
8442
10252
  const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
8443
10253
  const route = routePaste({
8444
10254
  kind: event.metadata?.kind,
@@ -8487,6 +10297,7 @@ function ChatScreen({ agent, conversation }) {
8487
10297
  if (composer.plainText === "" && inputRef.current !== "") applyComposerText(inputRef.current, "end");
8488
10298
  }, [
8489
10299
  grantPrompt,
10300
+ cardPickerOpen,
8490
10301
  modelPickerOpen,
8491
10302
  themePickerOpen,
8492
10303
  helpOpen,
@@ -8508,6 +10319,17 @@ function ChatScreen({ agent, conversation }) {
8508
10319
  } : m));
8509
10320
  useStore.getState().showToast({ message: "run cancelled" });
8510
10321
  }, [rest]);
10322
+ const copyLastMessage = useCallback(() => {
10323
+ const last = [...items].reverse().find((m) => m.kind === "assistant-text" && m.done && m.text.trim() !== "");
10324
+ if (!last) {
10325
+ useStore.getState().showToast({ message: "no agent message to copy yet" });
10326
+ return;
10327
+ }
10328
+ writeClipboardText(last.text).then((ok) => useStore.getState().showToast(ok ? {
10329
+ variant: "success",
10330
+ message: "copied last message to clipboard"
10331
+ } : { message: "clipboard unavailable" }));
10332
+ }, [items]);
8511
10333
  const openInBrowser = useCallback(() => {
8512
10334
  if (!appUrl) return;
8513
10335
  if (!conversationId) {
@@ -8524,11 +10346,141 @@ function ChatScreen({ agent, conversation }) {
8524
10346
  text: `couldn't open a browser — visit ${url} (${errorMessage(err)})`
8525
10347
  }]));
8526
10348
  }, [appUrl, conversationId]);
10349
+ const forkAndContinue = useCallback(() => {
10350
+ if (!rest || !conversationId || forkingRef.current) return;
10351
+ forkingRef.current = true;
10352
+ setForking(true);
10353
+ useStore.getState().showToast({ message: "forking into a new chat…" });
10354
+ const screenAtFork = useStore.getState().screen;
10355
+ const stillHere = () => useStore.getState().screen === screenAtFork;
10356
+ rest.forkConversation({ conversationId }).then((forked) => {
10357
+ if (!stillHere()) return;
10358
+ goTo({
10359
+ kind: "chat",
10360
+ agent,
10361
+ conversation: forkedConversationRef({
10362
+ agent,
10363
+ forked
10364
+ })
10365
+ });
10366
+ useStore.getState().showToast({
10367
+ variant: "success",
10368
+ message: "Forked conversation"
10369
+ });
10370
+ }).catch((err) => {
10371
+ forkingRef.current = false;
10372
+ setForking(false);
10373
+ if (!stillHere()) return;
10374
+ useStore.getState().showToast({
10375
+ variant: "error",
10376
+ message: `couldn't fork this conversation: ${errorMessage(err)}`
10377
+ });
10378
+ });
10379
+ }, [
10380
+ rest,
10381
+ conversationId,
10382
+ agent,
10383
+ goTo
10384
+ ]);
10385
+ const archiveConversation = useCallback(() => {
10386
+ if (grantPrompt || credPrompt) return;
10387
+ if (runRef.current.kind !== "idle") {
10388
+ useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
10389
+ return;
10390
+ }
10391
+ if (!conversationId) {
10392
+ useStore.getState().showToast({ message: "nothing to archive yet — send a message to start this one" });
10393
+ return;
10394
+ }
10395
+ if (!rest) return;
10396
+ rest.setConversationArchived({
10397
+ conversationId,
10398
+ archived: true
10399
+ }).then(() => {
10400
+ useStore.getState().showToast({
10401
+ variant: "success",
10402
+ message: "archived — shift+tab in the picker shows it, ctrl+a restores"
10403
+ });
10404
+ goTo({
10405
+ kind: "conversation-picker",
10406
+ agent
10407
+ });
10408
+ }).catch((err) => useStore.getState().showToast({
10409
+ variant: "error",
10410
+ message: err instanceof HttpError && err.status === 404 ? "can't archive this one — it's already gone from your list" : `couldn't archive: ${errorMessage(err)}`
10411
+ }));
10412
+ }, [
10413
+ grantPrompt,
10414
+ credPrompt,
10415
+ conversationId,
10416
+ rest,
10417
+ goTo,
10418
+ agent
10419
+ ]);
10420
+ /**
10421
+ * Leave this conversation for another screen without ending its run: drop
10422
+ * our own SSE subscription and nothing else — never `rest.cancelRun` — so
10423
+ * the agent keeps working server-side and reopening the thread reattaches.
10424
+ * Same contract as `esc`, which is why `/conversation` is allowed mid-run
10425
+ * where `/new` is not: what you land on still lists the thread you left,
10426
+ * so the run is never stranded out of reach.
10427
+ */
10428
+ const leaveForScreen = useCallback((next) => {
10429
+ const current = runRef.current;
10430
+ if (current.kind === "streaming") current.abort.abort();
10431
+ if (current.kind !== "idle") useStore.getState().showToast({ message: `${agent.name} keeps working — reopen to reattach` });
10432
+ goTo(next);
10433
+ }, [agent.name, goTo]);
10434
+ /**
10435
+ * `/conversation <id or link>`: open that conversation without leaving the
10436
+ * TUI — the point being that the id usually arrives *from* an agent, in the
10437
+ * session you started to go find it.
10438
+ *
10439
+ * Resolution goes through `resolveInitialScreen`, the same path
10440
+ * `--resume <id>` takes, so an id belonging to a different agent opens
10441
+ * under that agent and the not-found / no-access wording lives in one
10442
+ * place.
10443
+ */
10444
+ const openConversationRef = useCallback(async (ref) => {
10445
+ const target = parseConversationRef(ref);
10446
+ if (!target) {
10447
+ setItems((prev) => [...prev, {
10448
+ kind: "error",
10449
+ id: crypto.randomUUID(),
10450
+ text: `not a conversation id: ${ref} — pass an id, its /c/<id> link, or run /conversation on its own to pick from the list`
10451
+ }]);
10452
+ return;
10453
+ }
10454
+ if (!rest) return;
10455
+ if (target === conversationId) {
10456
+ useStore.getState().showToast({ message: "already in this conversation" });
10457
+ return;
10458
+ }
10459
+ try {
10460
+ leaveForScreen(await resolveInitialScreen({
10461
+ rest,
10462
+ agentSelector: null,
10463
+ conversationId: target,
10464
+ newConversation: false
10465
+ }));
10466
+ } catch (err) {
10467
+ setItems((prev) => [...prev, {
10468
+ kind: "error",
10469
+ id: crypto.randomUUID(),
10470
+ text: `couldn't open ${target}: ${errorMessage(err)}`
10471
+ }]);
10472
+ }
10473
+ }, [
10474
+ rest,
10475
+ conversationId,
10476
+ leaveForScreen
10477
+ ]);
8527
10478
  const toggleShare = useCallback(() => {
8528
10479
  if (!portalClient) return;
8529
10480
  if (portal.status === "off") {
8530
10481
  declinedRef.current.delete(agent.id);
8531
10482
  portalClient.enable();
10483
+ if (conversationId) portalClient.bind(conversationId, shellSessionRef.current?.cwd ?? process.cwd());
8532
10484
  } else {
8533
10485
  portalClient.disable();
8534
10486
  setPortalPrompt(null);
@@ -8537,7 +10489,8 @@ function ChatScreen({ agent, conversation }) {
8537
10489
  portalClient,
8538
10490
  portal.status,
8539
10491
  agent.id,
8540
- setPortalPrompt
10492
+ setPortalPrompt,
10493
+ conversationId
8541
10494
  ]);
8542
10495
  const confirmGrant = useCallback(() => {
8543
10496
  if (!portalClient) return;
@@ -8562,8 +10515,54 @@ function ChatScreen({ agent, conversation }) {
8562
10515
  setPortalPrompt(null);
8563
10516
  useStore.getState().showToast({ message: "kept this machine private" });
8564
10517
  }, [agent.id, setPortalPrompt]);
10518
+ const openPortalAccess = useCallback(() => {
10519
+ if (!portalClient) {
10520
+ useStore.getState().showToast({ message: "local access is unavailable in this session" });
10521
+ return;
10522
+ }
10523
+ switch (portal.status) {
10524
+ case "off":
10525
+ declinedRef.current.delete(agent.id);
10526
+ portalClient.enable();
10527
+ useStore.getState().showToast({ message: "enabling local access on this machine…" });
10528
+ break;
10529
+ case "connecting":
10530
+ useStore.getState().showToast({ message: "local access is still connecting…" });
10531
+ break;
10532
+ case "connected":
10533
+ if (portal.grantedAgentIds.includes(agent.id)) {
10534
+ useStore.getState().showToast({ message: `${agent.name} can already run commands on this machine` });
10535
+ break;
10536
+ }
10537
+ declinedRef.current.delete(agent.id);
10538
+ setPortalPrompt({
10539
+ agentId: agent.id,
10540
+ agentName: agent.name
10541
+ });
10542
+ break;
10543
+ case "error":
10544
+ useStore.getState().showToast({ message: portal.error ? `local access error: ${portal.error}` : "local access hit an error" });
10545
+ break;
10546
+ default: {
10547
+ const exhaustive = portal.status;
10548
+ throw new Error(`unhandled portal status: ${String(exhaustive)}`);
10549
+ }
10550
+ }
10551
+ }, [
10552
+ portalClient,
10553
+ portal.status,
10554
+ portal.grantedAgentIds,
10555
+ portal.error,
10556
+ agent.id,
10557
+ agent.name,
10558
+ setPortalPrompt
10559
+ ]);
8565
10560
  const executeLine = useCallback((content, staged) => {
8566
10561
  if (!content && staged.length === 0) return;
10562
+ if (forkingRef.current) {
10563
+ useStore.getState().showToast({ message: "forking… hang on" });
10564
+ return;
10565
+ }
8567
10566
  const routed = routeInput(content);
8568
10567
  if (routed.kind !== "message") {
8569
10568
  history.append(content);
@@ -8580,6 +10579,9 @@ function ChatScreen({ agent, conversation }) {
8580
10579
  case "sandbox-pty":
8581
10580
  runSandboxPty();
8582
10581
  break;
10582
+ case "archive-conversation":
10583
+ archiveConversation();
10584
+ break;
8583
10585
  case "new-conversation":
8584
10586
  if (runRef.current.kind !== "idle") {
8585
10587
  useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
@@ -8595,6 +10597,26 @@ function ChatScreen({ agent, conversation }) {
8595
10597
  }
8596
10598
  });
8597
10599
  break;
10600
+ case "fork":
10601
+ if (runRef.current.kind !== "idle") {
10602
+ useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
10603
+ break;
10604
+ }
10605
+ if (!conversationId) {
10606
+ useStore.getState().showToast({ message: "nothing to fork yet — send a message first" });
10607
+ break;
10608
+ }
10609
+ forkAndContinue();
10610
+ break;
10611
+ case "conversation-picker":
10612
+ leaveForScreen({
10613
+ kind: "conversation-picker",
10614
+ agent
10615
+ });
10616
+ break;
10617
+ case "open-conversation":
10618
+ openConversationRef(routed.ref);
10619
+ break;
8598
10620
  case "model-picker":
8599
10621
  setModelPickerOpen(true);
8600
10622
  break;
@@ -8619,14 +10641,33 @@ function ChatScreen({ agent, conversation }) {
8619
10641
  })
8620
10642
  }]);
8621
10643
  break;
10644
+ case "plan": {
10645
+ if (!(!!todos && todos.length > 0)) {
10646
+ useStore.getState().showToast({ message: "no plan to show yet" });
10647
+ break;
10648
+ }
10649
+ const nextHidden = routed.mode === "toggle" ? !planHidden : routed.mode === "hide";
10650
+ setPlanHidden(nextHidden);
10651
+ useStore.getState().showToast({ message: nextHidden ? "plan hidden (/plan show)" : "plan shown" });
10652
+ break;
10653
+ }
8622
10654
  case "share":
8623
10655
  toggleShare();
8624
10656
  break;
8625
10657
  case "browser":
8626
10658
  openInBrowser();
8627
10659
  break;
10660
+ case "copy":
10661
+ copyLastMessage();
10662
+ break;
10663
+ case "portal":
10664
+ openPortalAccess();
10665
+ break;
10666
+ case "compact":
10667
+ compactConversation();
10668
+ break;
8628
10669
  case "connect":
8629
- if (!runNewestCard()) useStore.getState().showToast({ message: "no connect card waiting for action" });
10670
+ if (!promptForCards()) useStore.getState().showToast({ message: "no connect card waiting for action" });
8630
10671
  break;
8631
10672
  case "help":
8632
10673
  setHelpOpen(true);
@@ -8653,8 +10694,15 @@ function ChatScreen({ agent, conversation }) {
8653
10694
  runLocalShell,
8654
10695
  runSandboxExec,
8655
10696
  runSandboxPty,
10697
+ archiveConversation,
10698
+ forkAndContinue,
10699
+ leaveForScreen,
10700
+ openConversationRef,
8656
10701
  toggleShare,
8657
10702
  openInBrowser,
10703
+ copyLastMessage,
10704
+ openPortalAccess,
10705
+ compactConversation,
8658
10706
  quit,
8659
10707
  sendContent,
8660
10708
  goTo,
@@ -8665,7 +10713,9 @@ function ChatScreen({ agent, conversation }) {
8665
10713
  model,
8666
10714
  portal.status,
8667
10715
  portal.grantedAgentIds,
8668
- runNewestCard
10716
+ promptForCards,
10717
+ todos,
10718
+ planHidden
8669
10719
  ]);
8670
10720
  const submit = useCallback(() => {
8671
10721
  const staged = attachments.filter((a) => a.status !== "error");
@@ -8720,7 +10770,7 @@ function ChatScreen({ agent, conversation }) {
8720
10770
  }
8721
10771
  if (reviewFocused) return;
8722
10772
  }
8723
- if (modelPickerOpen || themePickerOpen || helpOpen) return;
10773
+ if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen) return;
8724
10774
  if (grantPrompt) {
8725
10775
  if (key.name === "y") {
8726
10776
  confirmGrant();
@@ -8812,18 +10862,24 @@ function ChatScreen({ agent, conversation }) {
8812
10862
  return;
8813
10863
  }
8814
10864
  if (key.name === "r" && key.ctrl) {
8815
- runNewestCard();
10865
+ promptForCards();
8816
10866
  return;
8817
10867
  }
8818
10868
  if (key.name === "t" && key.ctrl) {
8819
10869
  toggleShare();
8820
10870
  return;
8821
10871
  }
10872
+ if (nonWebChannel && key.name === "f" && key.ctrl) {
10873
+ forkAndContinue();
10874
+ return;
10875
+ }
8822
10876
  if (key.name === "v" && key.ctrl) {
10877
+ if (nonWebChannel) return;
8823
10878
  pasteImage();
8824
10879
  return;
8825
10880
  }
8826
10881
  if (key.name === "x" && key.ctrl || key.name === "backspace" && attachmentsRef.current.length > 0 && !composerRef.current?.plainText) {
10882
+ if (nonWebChannel) return;
8827
10883
  const last = attachmentsRef.current.at(-1);
8828
10884
  if (last) {
8829
10885
  uploadsRef.current.delete(last.tempId);
@@ -8836,6 +10892,11 @@ function ChatScreen({ agent, conversation }) {
8836
10892
  setThemePickerOpen(true);
8837
10893
  return;
8838
10894
  }
10895
+ if (key.name === "a" && key.ctrl && input.trim() === "") {
10896
+ key.preventDefault();
10897
+ archiveConversation();
10898
+ return;
10899
+ }
8839
10900
  if (key.name === "?" && !key.ctrl && !key.meta && input.trim() === "") {
8840
10901
  composerRef.current?.clear();
8841
10902
  setInput("");
@@ -8851,6 +10912,10 @@ function ChatScreen({ agent, conversation }) {
8851
10912
  scrollRef.current?.scrollBy(pageScrollFraction, "viewport");
8852
10913
  return;
8853
10914
  }
10915
+ if (key.name === "end" && scrolledUp) {
10916
+ jumpToBottom();
10917
+ return;
10918
+ }
8854
10919
  if (key.name === "c" && key.ctrl) {
8855
10920
  if (runRef.current.kind === "streaming") {
8856
10921
  cancelRun();
@@ -8882,21 +10947,31 @@ function ChatScreen({ agent, conversation }) {
8882
10947
  }
8883
10948
  });
8884
10949
  const hasPendingSteer = items.some((m) => m.kind === "pending-steer");
8885
- const hasActionableCard = items.some(isActionableCard);
10950
+ const actionableCards = useMemo(() => items.filter(isActionableCard), [items]);
8886
10951
  const statusText = useMemo(() => formatChatFooterStatus({
8887
10952
  runKind: run.kind,
8888
10953
  ctrlCArmed,
8889
10954
  isShared,
8890
10955
  hasPendingSteer,
8891
- hasActionableCard
10956
+ actionableCardCount: actionableCards.length,
10957
+ nonWebChannel: nonWebChannel !== null
8892
10958
  }), [
8893
10959
  run.kind,
8894
10960
  ctrlCArmed,
8895
10961
  isShared,
8896
10962
  hasPendingSteer,
8897
- hasActionableCard
10963
+ actionableCards.length,
10964
+ nonWebChannel
8898
10965
  ]);
8899
10966
  const modelHint = formatModelHint(model);
10967
+ if (cardPickerOpen) return /* @__PURE__ */ jsx(CardPicker, {
10968
+ cards: actionableCards,
10969
+ onSelect: (card) => {
10970
+ setCardPickerOpen(false);
10971
+ runCardAction(card);
10972
+ },
10973
+ onCancel: () => setCardPickerOpen(false)
10974
+ });
8900
10975
  if (modelPickerOpen) return /* @__PURE__ */ jsx(ModelPicker, {
8901
10976
  agentId: agent.id,
8902
10977
  currentModel: model,
@@ -8935,6 +11010,14 @@ function ChatScreen({ agent, conversation }) {
8935
11010
  ...prev,
8936
11011
  side: size
8937
11012
  }),
11013
+ onToggleOrientation: () => {
11014
+ if (!canToggleOrientation({
11015
+ width,
11016
+ height
11017
+ })) return "unavailable";
11018
+ setReviewOrientation(reviewPlacement === "below" ? "side" : "below");
11019
+ return "toggled";
11020
+ },
8938
11021
  onInsertToComposer: (review) => {
8939
11022
  const next = [inputRef.current.trimEnd(), review].filter((part) => part.length > 0).join("\n\n");
8940
11023
  setInput(next);
@@ -9019,28 +11102,69 @@ function ChatScreen({ agent, conversation }) {
9019
11102
  agent.name,
9020
11103
  " below."
9021
11104
  ]
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]
11105
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [transcriptWindow.hiddenCount > 0 ? /* @__PURE__ */ jsx("box", {
11106
+ style: {
11107
+ paddingLeft: 1,
11108
+ paddingRight: 1
11109
+ },
11110
+ children: /* @__PURE__ */ jsxs("text", {
11111
+ fg: theme.dim,
11112
+ children: [
11113
+ "↑ ",
11114
+ transcriptWindow.hiddenCount,
11115
+ " earlier message",
11116
+ transcriptWindow.hiddenCount === 1 ? "" : "s",
11117
+ " · kept in this session, not shown"
11118
+ ]
11119
+ })
11120
+ }) : null, transcriptWindow.visible.map(({ item, index }) => /* @__PURE__ */ jsx(TranscriptRow, {
11121
+ item,
11122
+ label: multiAgent ? agentLabelFor(items, index) : null,
11123
+ themeVersion: themeGeneration
11124
+ }, item.id))] }), run.kind !== "idle" ? /* @__PURE__ */ jsx(ActivityRow, {
11125
+ label: run.kind === "sending" ? "sending" : "working",
11126
+ startedAtMs: run.startedAtMs
11127
+ }) : null]
9038
11128
  })
9039
11129
  }),
11130
+ jumpHintVisible ? /* @__PURE__ */ jsxs("box", {
11131
+ onMouseDown: jumpToBottom,
11132
+ style: {
11133
+ flexDirection: "row",
11134
+ paddingLeft: 1,
11135
+ paddingRight: 1,
11136
+ height: 1,
11137
+ flexShrink: 0
11138
+ },
11139
+ children: [/* @__PURE__ */ jsx("text", {
11140
+ fg: theme.accent,
11141
+ children: "↓ jump to latest"
11142
+ }), /* @__PURE__ */ jsxs("text", {
11143
+ fg: theme.dim,
11144
+ children: [" ", "end"]
11145
+ })]
11146
+ }) : null,
9040
11147
  todoCardVisible && todos ? /* @__PURE__ */ jsx(TodoCardView, {
9041
11148
  todos,
9042
11149
  isActive: run.kind !== "idle"
9043
11150
  }) : null,
11151
+ compacting ? /* @__PURE__ */ jsxs("box", {
11152
+ style: {
11153
+ flexDirection: "row",
11154
+ paddingLeft: 1,
11155
+ paddingRight: 1,
11156
+ height: 1,
11157
+ flexShrink: 0,
11158
+ marginTop: 1
11159
+ },
11160
+ children: [/* @__PURE__ */ jsx("text", {
11161
+ fg: theme.accent,
11162
+ children: "● compacting conversation…"
11163
+ }), /* @__PURE__ */ jsxs("text", {
11164
+ fg: theme.dim,
11165
+ children: [" ", "you can keep typing"]
11166
+ })]
11167
+ }) : null,
9044
11168
  pendingVisible ? /* @__PURE__ */ jsxs("box", {
9045
11169
  style: {
9046
11170
  paddingLeft: 1,
@@ -9112,6 +11236,35 @@ function ChatScreen({ agent, conversation }) {
9112
11236
  children: "n"
9113
11237
  })
9114
11238
  ]
11239
+ }) : nonWebChannel ? /* @__PURE__ */ jsxs("box", {
11240
+ style: {
11241
+ border: true,
11242
+ borderColor: theme.faint,
11243
+ paddingLeft: 1,
11244
+ paddingRight: 1,
11245
+ height: nonWebNoticeHeight,
11246
+ flexShrink: 0,
11247
+ marginTop: 1,
11248
+ flexDirection: "column"
11249
+ },
11250
+ children: [
11251
+ /* @__PURE__ */ jsxs("text", {
11252
+ fg: theme.muted,
11253
+ children: [
11254
+ "this conversation is on ",
11255
+ nonWebChannel,
11256
+ "."
11257
+ ]
11258
+ }),
11259
+ /* @__PURE__ */ jsx("text", {
11260
+ fg: theme.dim,
11261
+ children: "reply there to continue it."
11262
+ }),
11263
+ /* @__PURE__ */ jsx("text", {
11264
+ fg: theme.dim,
11265
+ children: forking ? "forking into a new chat…" : "ctrl+f fork it into a chat you can continue here."
11266
+ })
11267
+ ]
9115
11268
  }) : /* @__PURE__ */ jsx("box", {
9116
11269
  style: {
9117
11270
  border: true,
@@ -9124,7 +11277,7 @@ function ChatScreen({ agent, conversation }) {
9124
11277
  },
9125
11278
  children: /* @__PURE__ */ jsx("textarea", {
9126
11279
  ref: composerRef,
9127
- placeholder: `message ${agent.name}…`,
11280
+ placeholder: forking ? "forking into a new chat…" : `message ${agent.name}…`,
9128
11281
  keyBindings: composerKeyBindings,
9129
11282
  onContentChange: handleComposerChange,
9130
11283
  onSubmit: submit,
@@ -9153,6 +11306,7 @@ function ChatScreen({ agent, conversation }) {
9153
11306
  fg: theme.muted,
9154
11307
  children: statusText
9155
11308
  }),
11309
+ prLinks.length > 0 ? /* @__PURE__ */ jsx(PrLinksView, { prs: prLinks }) : null,
9156
11310
  /* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }),
9157
11311
  /* @__PURE__ */ jsx("text", {
9158
11312
  fg: theme.dim,
@@ -9277,18 +11431,48 @@ function CredentialPromptBox({ prompt, height, onInput, onSubmit }) {
9277
11431
  });
9278
11432
  }
9279
11433
  /**
11434
+ * One transcript row: the optional agent-attribution label plus the item's own
11435
+ * rendering. Memoized on the item because the reducer rebuilds the `items`
11436
+ * array on every stream chunk while leaving finished items' objects untouched.
11437
+ * Without the memo, one text delta re-rendered every row in the scrollback, and
11438
+ * each re-render pushes content through that row's native OpenTUI text buffers,
11439
+ * which never hand their memory back — so a long session grew O(turns^2)
11440
+ * resident memory (~1GB after 6k deltas in a soak; ~190MB with the memo).
11441
+ *
11442
+ * `themeVersion` is a prop rather than a read because rows resolve colors from
11443
+ * the mutable `theme` binding during render: a swap mutates that object in
11444
+ * place, so the memo needs the version bump to know its output is stale.
11445
+ */
11446
+ const TranscriptRow = memo(function TranscriptRow({ item, label }) {
11447
+ return /* @__PURE__ */ jsxs("box", {
11448
+ style: { flexDirection: "column" },
11449
+ children: [label ? /* @__PURE__ */ jsx("box", {
11450
+ style: {
11451
+ paddingLeft: 3,
11452
+ paddingRight: 1
11453
+ },
11454
+ children: /* @__PURE__ */ jsx("text", {
11455
+ fg: theme.muted,
11456
+ children: /* @__PURE__ */ jsx("b", { children: label })
11457
+ })
11458
+ }) : null, /* @__PURE__ */ jsx(RenderItem, { item })]
11459
+ });
11460
+ });
11461
+ /**
9280
11462
  * A live indicator at the foot of the transcript while a run is in flight: an
9281
11463
  * 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.
11464
+ * agent is still working even during long gaps between chunks. The counter
11465
+ * measures from the run's own start, not from this row appearing, so reopening
11466
+ * a conversation mid-run shows how long the agent has really been at it. Owns
11467
+ * its own clock so ticking it doesn't re-render the transcript.
9284
11468
  */
9285
- function ActivityRow({ label }) {
9286
- const [elapsed, setElapsed] = useState(0);
11469
+ function ActivityRow({ label, startedAtMs }) {
11470
+ const [now, setNow] = useState(() => Date.now());
9287
11471
  useEffect(() => {
9288
- const start = Date.now();
9289
- const id = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1e3)), 250);
11472
+ const id = setInterval(() => setNow(Date.now()), 250);
9290
11473
  return () => clearInterval(id);
9291
11474
  }, []);
11475
+ const elapsed = Math.floor((now - startedAtMs) / 1e3);
9292
11476
  return /* @__PURE__ */ jsxs("box", {
9293
11477
  style: {
9294
11478
  flexDirection: "row",
@@ -9357,9 +11541,10 @@ function ToastView() {
9357
11541
 
9358
11542
  //#endregion
9359
11543
  //#region src/chat/tui/app.tsx
9360
- function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notifications, agentSelector, conversationId }) {
11544
+ function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notifications, agentSelector, conversationId, newConversation }) {
9361
11545
  const renderer = useRenderer();
9362
11546
  const screen = useStore((s) => s.screen);
11547
+ const chatTitle = useStore((s) => s.chatTitle);
9363
11548
  useStore((s) => s.themeId);
9364
11549
  const rest = useStore((s) => s.rest);
9365
11550
  const goTo = useStore((s) => s.goTo);
@@ -9378,6 +11563,13 @@ function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notificati
9378
11563
  agentHost.transition({ state: "idle" });
9379
11564
  return () => agentHost.dispose();
9380
11565
  }, [agentHost]);
11566
+ useEffect(() => {
11567
+ agentHost.setTitle(titleLabel(screen, chatTitle));
11568
+ }, [
11569
+ agentHost,
11570
+ screen,
11571
+ chatTitle
11572
+ ]);
9381
11573
  useEffect(() => {
9382
11574
  const selectionCopy = createSelectionCopy({
9383
11575
  renderer,
@@ -9431,7 +11623,7 @@ function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notificati
9431
11623
  rest,
9432
11624
  agentSelector,
9433
11625
  conversationId,
9434
- newConversation: useStore.getState().seedPrompt !== null
11626
+ newConversation: newConversation || useStore.getState().seedPrompt !== null
9435
11627
  });
9436
11628
  if (cancelled) return;
9437
11629
  goTo(screen);
@@ -9453,6 +11645,7 @@ function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notificati
9453
11645
  promptHistoryPath,
9454
11646
  agentSelector,
9455
11647
  conversationId,
11648
+ newConversation,
9456
11649
  goTo,
9457
11650
  setClients,
9458
11651
  fail
@@ -9748,6 +11941,183 @@ var LinkifiedMarkdownRenderable = class extends MarkdownRenderable {
9748
11941
  }
9749
11942
  };
9750
11943
 
11944
+ //#endregion
11945
+ //#region src/chat/tui/chunks/grammars/index.ts
11946
+ /**
11947
+ * Register the extra tree-sitter grammars (python, go, rust, bash, …) before
11948
+ * the renderer boots its tree-sitter client, so fenced code blocks and diffs
11949
+ * in those languages highlight instead of rendering as plain text.
11950
+ *
11951
+ * `addDefaultParsers` merges by filetype into the set the client registers on
11952
+ * init, so this must run BEFORE `createCliRenderer`. The grammar definitions
11953
+ * live in ./grammars.ts, which is externalized from the tsdown bundle (it
11954
+ * imports `.wasm`/`.scm` assets rolldown can't parse) — so we reach it through
11955
+ * a dynamic import that both Bun-runtime (npm) and the compiled binary resolve.
11956
+ */
11957
+ async function registerGrammars() {
11958
+ const { EXTRA_GRAMMARS } = await import("./chat/tui/chunks/grammars/grammars.js");
11959
+ addDefaultParsers(EXTRA_GRAMMARS);
11960
+ }
11961
+
11962
+ //#endregion
11963
+ //#region src/chat/memory-watchdog.ts
11964
+ const MB = 1024 * 1024;
11965
+ /** How often to sample RSS. */
11966
+ const DEFAULT_SAMPLE_INTERVAL_MS = 15e3;
11967
+ /**
11968
+ * Soft threshold: cross it and we warn the user once. A healthy chat session
11969
+ * sits in the low hundreds of MB, so 2 GB is "something is wrong" without
11970
+ * being so eager it fires on a legitimately large conversation.
11971
+ */
11972
+ const DEFAULT_WARN_THRESHOLD_BYTES = 2 * 1024 * MB;
11973
+ /**
11974
+ * A clean exit still writes a report if the peak got this high — so we learn
11975
+ * about near-misses that didn't quite OOM, not just the crashes.
11976
+ */
11977
+ const DEFAULT_REPORT_THRESHOLD_BYTES = 4 * 1024 * MB;
11978
+ /** Keep the last N samples so the report shows a growth curve, not a point. */
11979
+ const MAX_SAMPLES = 240;
11980
+ function envBytes(name, fallback) {
11981
+ const raw = process.env[name];
11982
+ if (!raw) return fallback;
11983
+ const n = Number(raw);
11984
+ return Number.isFinite(n) && n > 0 ? n : fallback;
11985
+ }
11986
+ function envMs(name, fallback) {
11987
+ const raw = process.env[name];
11988
+ if (!raw) return fallback;
11989
+ const n = Number(raw);
11990
+ return Number.isFinite(n) && n > 0 ? n : fallback;
11991
+ }
11992
+ /**
11993
+ * Start the memory watchdog. Returns a handle; call `stop()` on teardown.
11994
+ *
11995
+ * Disabled entirely when `SKYDIVE_NO_MEMORY_WATCHDOG` is set — an escape
11996
+ * hatch that also keeps the sampler out of the way in tests and CI.
11997
+ */
11998
+ function startMemoryWatchdog(opts = {}) {
11999
+ const disabled = Boolean(process.env["SKYDIVE_NO_MEMORY_WATCHDOG"]);
12000
+ const now = opts.now ?? (() => Date.now());
12001
+ const readMemory = opts.readMemory ?? (() => {
12002
+ const u = process.memoryUsage();
12003
+ return {
12004
+ rss: u.rss,
12005
+ heapUsed: u.heapUsed
12006
+ };
12007
+ });
12008
+ const sampleIntervalMs = opts.sampleIntervalMs ?? envMs("SKYDIVE_MEMORY_SAMPLE_MS", DEFAULT_SAMPLE_INTERVAL_MS);
12009
+ const warnThreshold = opts.warnThresholdBytes ?? envBytes("SKYDIVE_MEMORY_WARN_BYTES", DEFAULT_WARN_THRESHOLD_BYTES);
12010
+ const reportThreshold = opts.reportThresholdBytes ?? envBytes("SKYDIVE_MEMORY_REPORT_BYTES", DEFAULT_REPORT_THRESHOLD_BYTES);
12011
+ const startedAt = now();
12012
+ const samples = [];
12013
+ let peakRss = 0;
12014
+ let peakHeapUsed = 0;
12015
+ let warned = false;
12016
+ function sample() {
12017
+ const { rss, heapUsed } = readMemory();
12018
+ if (rss > peakRss) peakRss = rss;
12019
+ if (heapUsed > peakHeapUsed) peakHeapUsed = heapUsed;
12020
+ const s = {
12021
+ at: now() - startedAt,
12022
+ rss,
12023
+ heapUsed
12024
+ };
12025
+ samples.push(s);
12026
+ if (samples.length > MAX_SAMPLES) samples.shift();
12027
+ if (!warned && rss >= warnThreshold) {
12028
+ warned = true;
12029
+ opts.onWarn?.({
12030
+ rss,
12031
+ threshold: warnThreshold
12032
+ });
12033
+ }
12034
+ return s;
12035
+ }
12036
+ function snapshot() {
12037
+ if (disabled) return null;
12038
+ sample();
12039
+ return {
12040
+ peakRss,
12041
+ peakHeapUsed,
12042
+ warnThreshold,
12043
+ uptimeMs: now() - startedAt,
12044
+ samples: samples.slice()
12045
+ };
12046
+ }
12047
+ if (disabled) return {
12048
+ peakRss: () => 0,
12049
+ samples: () => [],
12050
+ snapshot: () => null,
12051
+ stop: () => {}
12052
+ };
12053
+ sample();
12054
+ const timer = setInterval(sample, sampleIntervalMs);
12055
+ if (typeof timer.unref === "function") timer.unref();
12056
+ const onExit = () => {
12057
+ if (peakRss < reportThreshold) return;
12058
+ writeCrashReport(buildCrashReport({
12059
+ reason: "high-memory-exit",
12060
+ memory: {
12061
+ peakRss,
12062
+ peakHeapUsed,
12063
+ warnThreshold,
12064
+ uptimeMs: now() - startedAt,
12065
+ samples: samples.slice()
12066
+ }
12067
+ }));
12068
+ };
12069
+ const installExitHandler = opts.installExitHandler ?? true;
12070
+ if (installExitHandler) process.on("exit", onExit);
12071
+ return {
12072
+ peakRss: () => peakRss,
12073
+ samples: () => samples.slice(),
12074
+ snapshot,
12075
+ stop: () => {
12076
+ clearInterval(timer);
12077
+ if (installExitHandler) process.off("exit", onExit);
12078
+ }
12079
+ };
12080
+ }
12081
+
12082
+ //#endregion
12083
+ //#region src/chat/theme-boot.ts
12084
+ /**
12085
+ * How long the first paint waits for the terminal to report its background
12086
+ * colour via OSC 10/11.
12087
+ *
12088
+ * OpenTUI's own default is 1000ms, and `createCliRenderer` has already switched
12089
+ * to the alternate screen by the time we wait — so on a terminal that never
12090
+ * answers the query (no OSC theme support, or a passthrough that eats it) the
12091
+ * user watched a blank cleared screen for a flat second on every launch, which
12092
+ * measured as 68% of cold start. Bound it by our budget rather than the
12093
+ * dependency's: 20ms is a typical real answer, so 100ms is generous.
12094
+ */
12095
+ const THEME_QUERY_GRACE_MS = 100;
12096
+ /**
12097
+ * Settle the theme for the first paint, and keep following the terminal after
12098
+ * it.
12099
+ *
12100
+ * Order is the whole point and is load-bearing:
12101
+ *
12102
+ * 1. Subscribe to `theme_mode` FIRST. It carries both a late OSC answer and
12103
+ * every later system light/dark flip, so with the subscription in place,
12104
+ * giving up on the query early can only cost a briefly-wrong first paint —
12105
+ * never a permanently wrong theme.
12106
+ * 2. Apply the synchronous `COLORFGBG` sniff (iTerm2 and friends set it) so
12107
+ * there is something correct to paint at t=0.
12108
+ * 3. Only then wait, briefly, for the real answer.
12109
+ *
12110
+ * Inverting 2 and 3 is the bug this replaced: `COLORFGBG` was consulted only
12111
+ * after the 1000ms timeout expired, i.e. we blocked the launch waiting for
12112
+ * information we already had.
12113
+ */
12114
+ async function settleBootThemeMode({ source, applyThemeMode, graceMs = THEME_QUERY_GRACE_MS, env }) {
12115
+ source.on("theme_mode", applyThemeMode);
12116
+ applyThemeMode(themeModeFromColorFgBg(env) ?? "dark");
12117
+ const answered = await source.waitForThemeMode(graceMs);
12118
+ if (answered) applyThemeMode(answered);
12119
+ }
12120
+
9751
12121
  //#endregion
9752
12122
  //#region src/chat/boot.tsx
9753
12123
  /**
@@ -9756,12 +12126,16 @@ var LinkifiedMarkdownRenderable = class extends MarkdownRenderable {
9756
12126
  * module — and the heavy OpenTUI dependency graph it pulls in — is
9757
12127
  * dynamically imported and never loaded on the Node management path.
9758
12128
  */
9759
- async function runChat({ appUrl, sessionToken, shareMachine, promptHistoryPath, theme: themeOverride, notifications, agentSelector, conversationId, seedPrompt }) {
12129
+ async function runChat({ appUrl, sessionToken, shareMachine, promptHistoryPath, theme: themeOverride, notifications, agentSelector, conversationId, newConversation, seedPrompt }) {
12130
+ await registerGrammars();
12131
+ const watchdog = startMemoryWatchdog();
12132
+ installCrashHandler({ memoryProvider: () => watchdog.snapshot() });
9760
12133
  const renderer = await createCliRenderer({ exitOnCtrlC: false });
9761
12134
  extend({ markdown: LinkifiedMarkdownRenderable });
9762
12135
  useStore.setState({
9763
12136
  seedPrompt: seedPrompt ?? null,
9764
- autoGrantMachine: Boolean(seedPrompt && shareMachine)
12137
+ autoGrantMachine: Boolean(seedPrompt && shareMachine),
12138
+ newConversationIntent: newConversation
9765
12139
  });
9766
12140
  const store = useStore.getState();
9767
12141
  if (noColorRequested()) {
@@ -9771,13 +12145,10 @@ async function runChat({ appUrl, sessionToken, shareMachine, promptHistoryPath,
9771
12145
  themeId: monoTheme.id
9772
12146
  });
9773
12147
  } 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
- }
12148
+ else await settleBootThemeMode({
12149
+ source: renderer,
12150
+ applyThemeMode: (mode) => useStore.getState().setThemeMode(mode)
12151
+ });
9781
12152
  createRoot(renderer).render(/* @__PURE__ */ jsx(App, {
9782
12153
  appUrl,
9783
12154
  sessionToken,
@@ -9785,14 +12156,17 @@ async function runChat({ appUrl, sessionToken, shareMachine, promptHistoryPath,
9785
12156
  promptHistoryPath,
9786
12157
  notifications,
9787
12158
  agentSelector: agentSelector ?? null,
9788
- conversationId: conversationId ?? null
12159
+ conversationId: conversationId ?? null,
12160
+ newConversation
9789
12161
  }));
9790
12162
  }
9791
12163
  /** Open the same workspace picker used by the initial agent screen. */
9792
12164
  async function runWorkspacePicker({ appUrl, sessionToken }) {
9793
12165
  const renderer = await createCliRenderer({ exitOnCtrlC: false });
9794
- const detected = await renderer.waitForThemeMode() ?? themeModeFromColorFgBg() ?? "dark";
9795
- useStore.getState().setThemeMode(detected);
12166
+ await settleBootThemeMode({
12167
+ source: renderer,
12168
+ applyThemeMode: (mode) => useStore.getState().setThemeMode(mode)
12169
+ });
9796
12170
  const close = () => renderer.destroy();
9797
12171
  createRoot(renderer).render(/* @__PURE__ */ jsx("box", {
9798
12172
  style: {