skydive-cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +114 -22
- package/dist/js/bin.mjs +1261 -128
- package/dist/js/{boot-CGaXUEer.mjs → boot-HAgDg_b4.mjs} +2507 -194
- package/dist/js/{client-B3ZhhF7e.mjs → client-Dc7GZ3PG.mjs} +148 -8
- package/dist/js/client-DuwxEDG4.mjs +5 -0
- package/dist/js/daemon-Ch_MSDmQ.mjs +6 -0
- package/dist/js/daemon-Co4CtpXZ.mjs +607 -0
- package/dist/js/{theme-CuQhvqzN.mjs → install-CMYBFvR2.mjs} +256 -186
- package/dist/js/{print-BZH0b3KB.mjs → print-CPCFci60.mjs} +2 -2
- package/dist/js/{print-Bin4u16d.mjs → print-Cd-bVSEJ.mjs} +224 -10
- package/dist/js/{print-share-D7OSxvE2.mjs → print-share-7qfpQtWK.mjs} +3 -3
- package/dist/js/{raw-pty-GAvxm2ol.mjs → raw-pty-BcjbTjHJ.mjs} +1 -1
- package/dist/js/raw-pty-ChUHav4d.mjs +5 -0
- package/dist/js/{rest-CamHVOce.mjs → rest-BlN_uWmL.mjs} +150 -19
- package/dist/js/rest-CDTXCmUb.mjs +4 -0
- package/package.json +3 -1
- package/dist/js/api-CDTKq_5Q.mjs +0 -145
- package/dist/js/client-D6NAkL9e.mjs +0 -6
- package/dist/js/raw-pty-2_VA1kw_.mjs +0 -5
- package/dist/js/rest-COkLEZOB.mjs +0 -4
- package/dist/js/util-CeisaZVY.mjs +0 -15
- /package/dist/js/{client-CpEvH2Pq.mjs → client-DfcJFEbh.mjs} +0 -0
- /package/dist/js/{output-B4cW10Ph.mjs → output-DYzzdXYV.mjs} +0 -0
|
@@ -1,26 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { n as isRecord, t as
|
|
5
|
-
import {
|
|
6
|
-
import "./
|
|
7
|
-
import { t as SandboxStream } from "./client-
|
|
8
|
-
import { t as
|
|
9
|
-
import { t as runRawPtyPassthrough } from "./raw-pty-GAvxm2ol.mjs";
|
|
2
|
+
import { C as setActiveWorkspace, S as listWorkspaces, a as WORDMARK, b as getActiveWorkspaceId, c as applyTheme, d as noColorRequested, f as theme, g as themeVersion, h as themeModeFromColorFgBg, i as MARK_CELLS, l as findTheme, m as themeMode, n as buildCrashReport, p as themeForMode, r as writeCrashReport, s as DEFAULT_THEME_ID, t as installCrashHandler, u as monoTheme, v as themesForMode } from "./install-CMYBFvR2.mjs";
|
|
3
|
+
import { L as saveTheme, P as resolveWebUrl, T as getSavedTheme, c as cardActionErrorMessage, d as reconcileMaskedInput, f as resolveConnectUrl, i as resolveAgent, l as parseExternalOauthConnectParams, m as specKeyFor, p as parseConnectCard, s as MASK_CHAR, u as parseOauthConnectParams, v as DEFAULT_API_URL, w as getReviewStateDir, x as getConfigPath, y as DEFAULT_APP_URL } from "./print-Cd-bVSEJ.mjs";
|
|
4
|
+
import { a as errorMessage, i as sendErrorMessage, n as createRestClient, o as isRecord, r as errorDetail, t as HttpError } from "./rest-BlN_uWmL.mjs";
|
|
5
|
+
import { t as PortalClient } from "./client-Dc7GZ3PG.mjs";
|
|
6
|
+
import { d as makeLineParser, f as parseDaemonMessage, l as daemonPaths, n as ensureDaemonRunning, s as LOCAL_PROTOCOL_VERSION, u as encodeLine } from "./daemon-Co4CtpXZ.mjs";
|
|
7
|
+
import { t as SandboxStream } from "./client-DfcJFEbh.mjs";
|
|
8
|
+
import { t as runRawPtyPassthrough } from "./raw-pty-BcjbTjHJ.mjs";
|
|
10
9
|
import * as os$1 from "node:os";
|
|
11
10
|
import { homedir, platform, release, tmpdir } from "node:os";
|
|
12
11
|
import path, { basename, extname, isAbsolute, join, win32 } from "node:path";
|
|
13
12
|
import { z } from "zod";
|
|
14
13
|
import open from "open";
|
|
15
|
-
import { execFile, spawn } from "node:child_process";
|
|
16
|
-
import { createHash } from "node:crypto";
|
|
17
14
|
import { constants } from "node:fs";
|
|
18
|
-
import {
|
|
15
|
+
import { execFile, spawn } from "node:child_process";
|
|
16
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
17
|
+
import { connect, createConnection } from "node:net";
|
|
18
|
+
import { access, appendFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
19
|
+
import { MarkdownRenderable, RenderableEvents, StyledText, SyntaxStyle, createCliRenderer, decodePasteBytes, detectLinks, fg, link } from "@opentui/core";
|
|
19
20
|
import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
20
|
-
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
21
|
+
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
21
22
|
import { create } from "zustand";
|
|
22
|
-
import { createConnection } from "node:net";
|
|
23
|
-
import { access, appendFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
24
23
|
import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
|
|
25
24
|
import fuzzysort from "fuzzysort";
|
|
26
25
|
import { fileURLToPath } from "node:url";
|
|
@@ -79,6 +78,7 @@ const useStore = create((set, get) => ({
|
|
|
79
78
|
toast: null,
|
|
80
79
|
seedPrompt: null,
|
|
81
80
|
autoGrantMachine: false,
|
|
81
|
+
newConversationIntent: false,
|
|
82
82
|
goTo: (screen) => set({
|
|
83
83
|
screen,
|
|
84
84
|
chatTitle: initialChatTitle(screen)
|
|
@@ -183,8 +183,8 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
|
|
|
183
183
|
createdAt: conversation.createdAt,
|
|
184
184
|
updatedAt: conversation.updatedAt,
|
|
185
185
|
preview: null,
|
|
186
|
-
channel:
|
|
187
|
-
channelLabel:
|
|
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
|
|
235
|
-
* sync with
|
|
236
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
855
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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("
|
|
1006
|
-
|
|
1007
|
-
|
|
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__ */
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
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("
|
|
1629
|
-
|
|
1630
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
2584
|
+
text: truncate(error, innerWidth)
|
|
1667
2585
|
} : {
|
|
1668
2586
|
fg: theme.dim,
|
|
1669
2587
|
text: fitHintLine([
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|
|
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
|
-
|
|
1700
|
-
|
|
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
|
-
|
|
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 === "
|
|
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
|
|
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
|
-
|
|
2759
|
+
/**
|
|
2760
|
+
* Tags a row with the thread's other agents (`+Grace, Ada`). The picker is
|
|
2761
|
+
* entered per agent, so that agent stays implicit and only its co-participants
|
|
2762
|
+
* are named — this tag is the only thing in the list distinguishing a
|
|
2763
|
+
* multi-agent thread from a solo one. Empty for a solo thread.
|
|
2764
|
+
*
|
|
2765
|
+
* A string rather than a component because the row's one-line budget has to
|
|
2766
|
+
* account for it (see list-line.ts).
|
|
2767
|
+
*/
|
|
2768
|
+
function coAgentsTag(conv, viewerAgentId) {
|
|
2769
|
+
const names = conversationAgents(conv).filter((a) => a.id !== viewerAgentId).map((a) => a.name);
|
|
2770
|
+
return names.length > 0 ? `+${names.join(", ")}` : "";
|
|
2771
|
+
}
|
|
2772
|
+
function conversationAgents(c) {
|
|
2773
|
+
return c.agents && c.agents.length > 0 ? c.agents : [c.agent];
|
|
2774
|
+
}
|
|
2775
|
+
const conversationKeys = [
|
|
2776
|
+
{
|
|
2777
|
+
get: (c) => singleLine(c.title ?? ""),
|
|
2778
|
+
weight: 1
|
|
2779
|
+
},
|
|
2780
|
+
{
|
|
2781
|
+
get: (c) => c.preview ?? "",
|
|
2782
|
+
weight: .6
|
|
2783
|
+
},
|
|
2784
|
+
{
|
|
2785
|
+
get: (c) => conversationAgents(c).map((a) => a.name).join(" "),
|
|
2786
|
+
weight: .4
|
|
2787
|
+
}
|
|
2788
|
+
];
|
|
2789
|
+
/**
|
|
2790
|
+
* Character indexes of the first case-insensitive occurrence of `needle` in
|
|
2791
|
+
* `haystack`, for highlighting a literal title match under server search (the
|
|
2792
|
+
* server's title branch is a substring ILIKE). Null when it doesn't occur — a
|
|
2793
|
+
* server row can match on message body or participant name without its title
|
|
2794
|
+
* containing the query, in which case nothing in the title is highlighted.
|
|
2795
|
+
*/
|
|
2796
|
+
function substringIndexes(haystack, needle) {
|
|
2797
|
+
const trimmed = needle.trim();
|
|
2798
|
+
if (!trimmed) return null;
|
|
2799
|
+
const at = haystack.toLowerCase().indexOf(trimmed.toLowerCase());
|
|
2800
|
+
if (at < 0) return null;
|
|
2801
|
+
return Array.from({ length: trimmed.length }, (_, i) => at + i);
|
|
2802
|
+
}
|
|
1796
2803
|
function previewLine(c) {
|
|
1797
2804
|
const when = relativeTime(c.updatedAt);
|
|
1798
2805
|
const prefix = c.preview ? c.preview.replace(/\s+/g, " ").trim() : "";
|
|
@@ -1807,6 +2814,33 @@ function relativeTime(iso) {
|
|
|
1807
2814
|
return `${Math.floor(diffMs / 864e5)}d ago`;
|
|
1808
2815
|
}
|
|
1809
2816
|
|
|
2817
|
+
//#endregion
|
|
2818
|
+
//#region src/chat/conversation-ref.ts
|
|
2819
|
+
/**
|
|
2820
|
+
* Parsing the conversation reference a user hands `/conversation`.
|
|
2821
|
+
*
|
|
2822
|
+
* The whole point of the command is that you paste whatever you already have
|
|
2823
|
+
* in front of you, so every form the platform hands out has to work: a bare
|
|
2824
|
+
* id, the `https://skydive.com/c/<id>` link an agent replies with, or the
|
|
2825
|
+
* `skydive chat --resume <id>` line the TUI prints on exit. All three carry
|
|
2826
|
+
* exactly one id, so we pull the first uuid out of the string rather than
|
|
2827
|
+
* teaching this about URL shapes and command syntax.
|
|
2828
|
+
*
|
|
2829
|
+
* Validating here (instead of letting the API answer) is what turns a typo
|
|
2830
|
+
* into "not a conversation id" in the composer rather than a request that
|
|
2831
|
+
* cannot succeed.
|
|
2832
|
+
*/
|
|
2833
|
+
const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
2834
|
+
/**
|
|
2835
|
+
* The conversation id in `input`, lowercased, or null when there isn't one.
|
|
2836
|
+
* Trailing punctuation, query strings and surrounding prose are tolerated —
|
|
2837
|
+
* a pasted link or command line is the expected input, not the exception.
|
|
2838
|
+
*/
|
|
2839
|
+
function parseConversationRef(input) {
|
|
2840
|
+
const match = uuidPattern.exec(input);
|
|
2841
|
+
return match ? match[0].toLowerCase() : null;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
1810
2844
|
//#endregion
|
|
1811
2845
|
//#region src/chat/paste.ts
|
|
1812
2846
|
const MAX_PATH_PASTE_CHARS = 4096;
|
|
@@ -3264,6 +4298,149 @@ function formatElapsed(totalSeconds) {
|
|
|
3264
4298
|
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
3265
4299
|
}
|
|
3266
4300
|
|
|
4301
|
+
//#endregion
|
|
4302
|
+
//#region src/chat/tui/chat/clamp.ts
|
|
4303
|
+
/** Max bytes we keep for a single clamped string field (head + tail combined). */
|
|
4304
|
+
const MAX_FIELD_CHARS = 24e3;
|
|
4305
|
+
/** Below this a string is never worth clamping (avoids churn on small fields). */
|
|
4306
|
+
const MIN_CLAMP_CHARS = MAX_FIELD_CHARS + 2e3;
|
|
4307
|
+
/** Fraction of the budget kept from the head; the rest is kept from the tail. */
|
|
4308
|
+
const HEAD_FRACTION = .7;
|
|
4309
|
+
/**
|
|
4310
|
+
* Clamp one string to a head + tail with an elision marker in the middle.
|
|
4311
|
+
* Idempotent: a string already carrying our marker is returned unchanged, so
|
|
4312
|
+
* re-clamping a loaded-from-history item (or a re-render) never re-slices.
|
|
4313
|
+
*/
|
|
4314
|
+
function clampString(text) {
|
|
4315
|
+
if (text.length <= MIN_CLAMP_CHARS) return text;
|
|
4316
|
+
if (text.includes(ELISION_MARKER_TAG)) return text;
|
|
4317
|
+
const headLen = Math.floor(MAX_FIELD_CHARS * HEAD_FRACTION);
|
|
4318
|
+
const tailLen = MAX_FIELD_CHARS - headLen;
|
|
4319
|
+
const elided = text.length - MAX_FIELD_CHARS;
|
|
4320
|
+
const head = text.slice(0, headLen);
|
|
4321
|
+
const tail = text.slice(text.length - tailLen);
|
|
4322
|
+
return `${head}\n${elisionMarker(elided)}\n${tail}`;
|
|
4323
|
+
}
|
|
4324
|
+
/**
|
|
4325
|
+
* Append to a growing output buffer while keeping it bounded, preferring the
|
|
4326
|
+
* TAIL — for live streaming shell output the newest bytes are what you want on
|
|
4327
|
+
* screen. Used by the `!` and `/sandbox` streams, whose output would otherwise
|
|
4328
|
+
* grow without limit for a long-running command.
|
|
4329
|
+
*/
|
|
4330
|
+
function clampedAppend(current, chunk) {
|
|
4331
|
+
const next = current + chunk;
|
|
4332
|
+
if (next.length <= MIN_CLAMP_CHARS) return next;
|
|
4333
|
+
const priorMarkerLen = leadingMarkerLength(current);
|
|
4334
|
+
const bodyLen = next.length - priorMarkerLen;
|
|
4335
|
+
const kept = stripLeadingMarker(next.slice(next.length - MAX_FIELD_CHARS));
|
|
4336
|
+
return `${elisionMarker(Math.max(0, bodyLen - kept.length))}\n${kept}`;
|
|
4337
|
+
}
|
|
4338
|
+
/** Length (incl. trailing newline) of a marker line at the very start of `s`. */
|
|
4339
|
+
function leadingMarkerLength(s) {
|
|
4340
|
+
if (!s.startsWith(ELISION_MARKER_TAG)) return 0;
|
|
4341
|
+
const nl = s.indexOf("\n");
|
|
4342
|
+
return nl === -1 ? s.length : nl + 1;
|
|
4343
|
+
}
|
|
4344
|
+
function stripLeadingMarker(s) {
|
|
4345
|
+
return s.slice(leadingMarkerLength(s));
|
|
4346
|
+
}
|
|
4347
|
+
const ELISION_MARKER_TAG = "⋯ elided";
|
|
4348
|
+
function elisionMarker(bytes) {
|
|
4349
|
+
return `${ELISION_MARKER_TAG} ${formatBytes$2(bytes)} to save memory ⋯`;
|
|
4350
|
+
}
|
|
4351
|
+
function formatBytes$2(n) {
|
|
4352
|
+
if (n < 1024) return `${n} chars`;
|
|
4353
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
4354
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
4355
|
+
}
|
|
4356
|
+
/**
|
|
4357
|
+
* Recursively clamp the large string fields of an arbitrary tool payload
|
|
4358
|
+
* (input or output `value`). Structure is preserved so the tool renderers,
|
|
4359
|
+
* which reach for known keys like `.stdout` / `.output` / `.content`, still
|
|
4360
|
+
* find them — only the *size* of long strings is bounded. Depth and breadth
|
|
4361
|
+
* are capped so a pathological payload can't make clamping itself expensive.
|
|
4362
|
+
*/
|
|
4363
|
+
function clampPayload(value, depth = 0) {
|
|
4364
|
+
if (typeof value === "string") return clampString(value);
|
|
4365
|
+
if (depth >= 6) return value;
|
|
4366
|
+
if (Array.isArray(value)) {
|
|
4367
|
+
const overLong = value.length > 5e3;
|
|
4368
|
+
const source = overLong ? value.slice(0, 5e3) : value;
|
|
4369
|
+
let changed = overLong;
|
|
4370
|
+
const mapped = source.map((v) => {
|
|
4371
|
+
const c = clampPayload(v, depth + 1);
|
|
4372
|
+
if (c !== v) changed = true;
|
|
4373
|
+
return c;
|
|
4374
|
+
});
|
|
4375
|
+
return changed ? mapped : value;
|
|
4376
|
+
}
|
|
4377
|
+
if (isRecord(value)) {
|
|
4378
|
+
let changed = false;
|
|
4379
|
+
const out = {};
|
|
4380
|
+
for (const [k, v] of Object.entries(value)) {
|
|
4381
|
+
const c = clampPayload(v, depth + 1);
|
|
4382
|
+
if (c !== v) changed = true;
|
|
4383
|
+
out[k] = c;
|
|
4384
|
+
}
|
|
4385
|
+
return changed ? out : value;
|
|
4386
|
+
}
|
|
4387
|
+
return value;
|
|
4388
|
+
}
|
|
4389
|
+
/**
|
|
4390
|
+
* Clamp a single MessageItem in place-safe (returns a new object only when it
|
|
4391
|
+
* actually changed). Applied by the reducer when a tool settles and by the
|
|
4392
|
+
* history loader for every loaded item. Streaming text bubbles are left alone
|
|
4393
|
+
* — they're bounded by the model's output and clamping mid-stream would fight
|
|
4394
|
+
* the append path; only large tool/shell payloads and finished long texts are
|
|
4395
|
+
* worth clamping.
|
|
4396
|
+
*/
|
|
4397
|
+
function clampItem(item) {
|
|
4398
|
+
switch (item.kind) {
|
|
4399
|
+
case "tool": {
|
|
4400
|
+
const input = item.input == null ? item.input : clampPayload(item.input);
|
|
4401
|
+
const inputText = item.inputText.length > MIN_CLAMP_CHARS ? clampString(item.inputText) : item.inputText;
|
|
4402
|
+
let output = item.output;
|
|
4403
|
+
if (output?.kind === "ok") {
|
|
4404
|
+
const value = clampPayload(output.value);
|
|
4405
|
+
if (value !== output.value) output = {
|
|
4406
|
+
kind: "ok",
|
|
4407
|
+
value
|
|
4408
|
+
};
|
|
4409
|
+
} else if (output?.kind === "error") {
|
|
4410
|
+
const errorText = clampString(output.errorText);
|
|
4411
|
+
if (errorText !== output.errorText) output = {
|
|
4412
|
+
kind: "error",
|
|
4413
|
+
errorText
|
|
4414
|
+
};
|
|
4415
|
+
}
|
|
4416
|
+
if (input === item.input && inputText === item.inputText && output === item.output) return item;
|
|
4417
|
+
return {
|
|
4418
|
+
...item,
|
|
4419
|
+
input,
|
|
4420
|
+
inputText,
|
|
4421
|
+
output
|
|
4422
|
+
};
|
|
4423
|
+
}
|
|
4424
|
+
case "shell": {
|
|
4425
|
+
const clamped = clampString(item.output);
|
|
4426
|
+
return clamped === item.output ? item : {
|
|
4427
|
+
...item,
|
|
4428
|
+
output: clamped
|
|
4429
|
+
};
|
|
4430
|
+
}
|
|
4431
|
+
case "assistant-text":
|
|
4432
|
+
case "reasoning": {
|
|
4433
|
+
if (!item.done) return item;
|
|
4434
|
+
const clamped = clampString(item.text);
|
|
4435
|
+
return clamped === item.text ? item : {
|
|
4436
|
+
...item,
|
|
4437
|
+
text: clamped
|
|
4438
|
+
};
|
|
4439
|
+
}
|
|
4440
|
+
default: return item;
|
|
4441
|
+
}
|
|
4442
|
+
}
|
|
4443
|
+
|
|
3267
4444
|
//#endregion
|
|
3268
4445
|
//#region src/chat/tui/chat/reducer.ts
|
|
3269
4446
|
function str(chunk, key) {
|
|
@@ -3346,7 +4523,7 @@ function applyChunk(items, chunk, agentName = null) {
|
|
|
3346
4523
|
case "tool-input-available": {
|
|
3347
4524
|
const toolCallId = str(chunk, "toolCallId");
|
|
3348
4525
|
const toolName = str(chunk, "toolName");
|
|
3349
|
-
const input = chunk.input
|
|
4526
|
+
const input = chunk.input == null ? null : clampPayload(chunk.input);
|
|
3350
4527
|
if (!toolCallId) return [...items];
|
|
3351
4528
|
if (items.find((m) => m.kind === "tool" && m.id === toolCallId)) return items.map((m) => m.kind === "tool" && m.id === toolCallId ? {
|
|
3352
4529
|
...m,
|
|
@@ -3367,7 +4544,7 @@ function applyChunk(items, chunk, agentName = null) {
|
|
|
3367
4544
|
case "tool-output-available": {
|
|
3368
4545
|
const toolCallId = str(chunk, "toolCallId");
|
|
3369
4546
|
if (!toolCallId) return [...items];
|
|
3370
|
-
const output = chunk.output
|
|
4547
|
+
const output = chunk.output == null ? null : clampPayload(chunk.output);
|
|
3371
4548
|
return items.map((m) => m.kind === "tool" && m.id === toolCallId ? {
|
|
3372
4549
|
...m,
|
|
3373
4550
|
output: {
|
|
@@ -3380,7 +4557,7 @@ function applyChunk(items, chunk, agentName = null) {
|
|
|
3380
4557
|
case "tool-output-error": {
|
|
3381
4558
|
const toolCallId = str(chunk, "toolCallId");
|
|
3382
4559
|
if (!toolCallId) return [...items];
|
|
3383
|
-
const errorText = str(chunk, "errorText") ?? "tool error";
|
|
4560
|
+
const errorText = clampString(str(chunk, "errorText") ?? "tool error");
|
|
3384
4561
|
return items.map((m) => m.kind === "tool" && m.id === toolCallId ? {
|
|
3385
4562
|
...m,
|
|
3386
4563
|
output: {
|
|
@@ -3412,17 +4589,19 @@ function applyChunk(items, chunk, agentName = null) {
|
|
|
3412
4589
|
if (!isRecord(data)) return [...items];
|
|
3413
4590
|
const card = parseConnectCard(data.spec);
|
|
3414
4591
|
if (!card) return [...items];
|
|
3415
|
-
const
|
|
3416
|
-
const
|
|
4592
|
+
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : null;
|
|
4593
|
+
const specKey = specKeyFor(data.spec);
|
|
4594
|
+
if (items.some((m) => m.kind === "card" && m.toolCallId === toolCallId && m.specKey === specKey)) return [...items];
|
|
4595
|
+
return [...items, {
|
|
3417
4596
|
kind: "card",
|
|
3418
|
-
id,
|
|
4597
|
+
id: nextCardId(items, toolCallId),
|
|
4598
|
+
toolCallId,
|
|
4599
|
+
specKey,
|
|
3419
4600
|
card,
|
|
3420
4601
|
status: "idle",
|
|
3421
4602
|
detail: null,
|
|
3422
4603
|
agentName
|
|
3423
|
-
};
|
|
3424
|
-
if (items.some((m) => m.kind === "card" && m.id === id)) return items.map((m) => m.kind === "card" && m.id === id ? next : m);
|
|
3425
|
-
return [...items, next];
|
|
4604
|
+
}];
|
|
3426
4605
|
}
|
|
3427
4606
|
case "data-anyone-conversation-started": return [...items];
|
|
3428
4607
|
default: return [...items];
|
|
@@ -3525,6 +4704,38 @@ function directiveIdsFrom(data) {
|
|
|
3525
4704
|
function cryptoId() {
|
|
3526
4705
|
return crypto.randomUUID();
|
|
3527
4706
|
}
|
|
4707
|
+
/** Distinct id per card, so cards from one tool call stay independently
|
|
4708
|
+
* addressable (status updates and the React key both go by `id`). */
|
|
4709
|
+
function nextCardId(items, toolCallId) {
|
|
4710
|
+
if (!toolCallId) return cryptoId();
|
|
4711
|
+
return `${toolCallId}#c${items.filter((m) => m.kind === "card" && m.toolCallId === toolCallId).length}`;
|
|
4712
|
+
}
|
|
4713
|
+
|
|
4714
|
+
//#endregion
|
|
4715
|
+
//#region src/chat/tui/chat/window.ts
|
|
4716
|
+
/** Max transcript rows mounted at once. */
|
|
4717
|
+
const MAX_MOUNTED_ROWS = 400;
|
|
4718
|
+
/**
|
|
4719
|
+
* Keep the most recent `max` rows mounted; report how many older ones were
|
|
4720
|
+
* dropped from the mount so the UI can show a "N earlier messages" marker.
|
|
4721
|
+
*/
|
|
4722
|
+
function windowItems(items, max = MAX_MOUNTED_ROWS) {
|
|
4723
|
+
if (items.length <= max) return {
|
|
4724
|
+
visible: items.map((item, index) => ({
|
|
4725
|
+
item,
|
|
4726
|
+
index
|
|
4727
|
+
})),
|
|
4728
|
+
hiddenCount: 0
|
|
4729
|
+
};
|
|
4730
|
+
const start = items.length - max;
|
|
4731
|
+
return {
|
|
4732
|
+
visible: items.slice(start).map((item, offset) => ({
|
|
4733
|
+
item,
|
|
4734
|
+
index: start + offset
|
|
4735
|
+
})),
|
|
4736
|
+
hiddenCount: start
|
|
4737
|
+
};
|
|
4738
|
+
}
|
|
3528
4739
|
|
|
3529
4740
|
//#endregion
|
|
3530
4741
|
//#region src/chat/tui/chat/attribution.ts
|
|
@@ -3638,11 +4849,16 @@ function uiMessagesToItems(messages) {
|
|
|
3638
4849
|
if (part.type === "data-anyone-render-spec") {
|
|
3639
4850
|
const data = isRecord(part) && isRecord(part.data) ? part.data : null;
|
|
3640
4851
|
const card = data ? parseConnectCard(data.spec) : null;
|
|
3641
|
-
if (card) {
|
|
3642
|
-
const toolCallId =
|
|
4852
|
+
if (card && data) {
|
|
4853
|
+
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : null;
|
|
4854
|
+
const specKey = specKeyFor(data.spec);
|
|
4855
|
+
if (out.some((item) => item.kind === "card" && item.toolCallId === toolCallId && item.specKey === specKey)) continue;
|
|
4856
|
+
const ordinal = out.filter((item) => item.kind === "card" && item.toolCallId === toolCallId).length;
|
|
3643
4857
|
out.push({
|
|
3644
4858
|
kind: "card",
|
|
3645
|
-
id: toolCallId
|
|
4859
|
+
id: toolCallId ? `${toolCallId}#c${ordinal}` : `${m.id}#c${textIdx++}`,
|
|
4860
|
+
toolCallId,
|
|
4861
|
+
specKey,
|
|
3646
4862
|
card,
|
|
3647
4863
|
status: "idle",
|
|
3648
4864
|
detail: null,
|
|
@@ -3680,7 +4896,7 @@ function uiMessagesToItems(messages) {
|
|
|
3680
4896
|
});
|
|
3681
4897
|
}
|
|
3682
4898
|
}
|
|
3683
|
-
return out;
|
|
4899
|
+
return out.map(clampItem);
|
|
3684
4900
|
}
|
|
3685
4901
|
function mapToolState(raw) {
|
|
3686
4902
|
switch (raw) {
|
|
@@ -3883,13 +5099,128 @@ function TodoCardView({ todos, isActive }) {
|
|
|
3883
5099
|
});
|
|
3884
5100
|
}
|
|
3885
5101
|
|
|
5102
|
+
//#endregion
|
|
5103
|
+
//#region src/chat/tui/chat/pr-links.tsx
|
|
5104
|
+
/**
|
|
5105
|
+
* Most PRs to show inline before collapsing the rest into a `+N` count. The
|
|
5106
|
+
* chip shares one row with the send/steer hint, so more than a couple would
|
|
5107
|
+
* crowd or wrap that row; the newest few are the useful ones.
|
|
5108
|
+
*/
|
|
5109
|
+
const MAX_PR_CHIPS = 3;
|
|
5110
|
+
/**
|
|
5111
|
+
* Builds the styled `· PR #123 #124` chip as OpenTUI chunks. The number chunks
|
|
5112
|
+
* carry a first-class `link` (OpenTUI emits it as a zero-width OSC 8 hyperlink,
|
|
5113
|
+
* clickable in terminals that support it) — the same mechanism the transcript's
|
|
5114
|
+
* linkified markdown uses. We do NOT hand-roll OSC 8 escape strings into text
|
|
5115
|
+
* children: OpenTUI measures width by string length and emits its own SGR, so a
|
|
5116
|
+
* raw escape corrupts layout and leaks bytes on screen.
|
|
5117
|
+
*
|
|
5118
|
+
* Caps at {@link MAX_PR_CHIPS} shown; any beyond that collapse into a trailing
|
|
5119
|
+
* `+N` so the footer row can't be flooded by a run that links many PRs. Shows
|
|
5120
|
+
* the most recent (last-seen) PRs, since those are what's in play.
|
|
5121
|
+
*/
|
|
5122
|
+
function prLinksStyledText(prs) {
|
|
5123
|
+
const dot = fg(theme.dim ?? "#565f89");
|
|
5124
|
+
const label = fg(theme.muted ?? "#7a7a7a");
|
|
5125
|
+
const accent = fg(theme.accent ?? "#7aa2f7");
|
|
5126
|
+
const shown = prs.slice(-MAX_PR_CHIPS);
|
|
5127
|
+
const overflow = prs.length - shown.length;
|
|
5128
|
+
const chunks = [dot("· "), label("PR ")];
|
|
5129
|
+
shown.forEach((pr, i) => {
|
|
5130
|
+
if (i > 0) chunks.push(label(" "));
|
|
5131
|
+
chunks.push(accent(link(pr.url)(`#${pr.number}`)));
|
|
5132
|
+
});
|
|
5133
|
+
if (overflow > 0) chunks.push(label(` +${overflow}`));
|
|
5134
|
+
return new StyledText(chunks);
|
|
5135
|
+
}
|
|
5136
|
+
/**
|
|
5137
|
+
* An inline chip for the conversation's linked PR(s), rendered on the
|
|
5138
|
+
* composer's status/hint row just after the send/steer hint — mirrors the
|
|
5139
|
+
* Claude Code affordance. Renders nothing when there are no PRs.
|
|
5140
|
+
*/
|
|
5141
|
+
function PrLinksView({ prs }) {
|
|
5142
|
+
if (prs.length === 0) return null;
|
|
5143
|
+
return /* @__PURE__ */ jsx("text", { content: prLinksStyledText(prs) });
|
|
5144
|
+
}
|
|
5145
|
+
|
|
5146
|
+
//#endregion
|
|
5147
|
+
//#region src/chat/tui/chat/use-compaction.ts
|
|
5148
|
+
const COMPACTING_INDICATOR_TIMEOUT_MS = 12e4;
|
|
5149
|
+
/**
|
|
5150
|
+
* Owns everything about manual conversation compaction (`/compact`): the
|
|
5151
|
+
* persistent "compacting…" indicator state, the fire-and-forget command, the
|
|
5152
|
+
* reaction to conversation-stream `compaction` events, and the safety timeout
|
|
5153
|
+
* that clears a stranded `started` so the indicator can never pin forever.
|
|
5154
|
+
*
|
|
5155
|
+
* The screen keeps a single `streamConversation` subscription (which also
|
|
5156
|
+
* carries `run`/`conversation` events), so compaction events arrive there and
|
|
5157
|
+
* are forwarded in via {@link UseCompaction.onCompactionEvent} rather than this
|
|
5158
|
+
* hook opening its own stream.
|
|
5159
|
+
*/
|
|
5160
|
+
function useCompaction({ rest, conversationId }) {
|
|
5161
|
+
const [compacting, setCompacting] = useState(false);
|
|
5162
|
+
const timerRef = useRef(null);
|
|
5163
|
+
const clearTimer = useCallback(() => {
|
|
5164
|
+
if (timerRef.current) {
|
|
5165
|
+
clearTimeout(timerRef.current);
|
|
5166
|
+
timerRef.current = null;
|
|
5167
|
+
}
|
|
5168
|
+
}, []);
|
|
5169
|
+
const armIndicator = useCallback(() => {
|
|
5170
|
+
clearTimer();
|
|
5171
|
+
setCompacting(true);
|
|
5172
|
+
timerRef.current = setTimeout(() => setCompacting(false), COMPACTING_INDICATOR_TIMEOUT_MS);
|
|
5173
|
+
}, [clearTimer]);
|
|
5174
|
+
const onCompactionEvent = useCallback((status) => {
|
|
5175
|
+
if (status === "started") {
|
|
5176
|
+
armIndicator();
|
|
5177
|
+
return;
|
|
5178
|
+
}
|
|
5179
|
+
clearTimer();
|
|
5180
|
+
setCompacting(false);
|
|
5181
|
+
if (status === "failed") useStore.getState().showToast({
|
|
5182
|
+
variant: "error",
|
|
5183
|
+
message: "compaction failed"
|
|
5184
|
+
});
|
|
5185
|
+
}, [armIndicator, clearTimer]);
|
|
5186
|
+
const compactConversation = useCallback(() => {
|
|
5187
|
+
if (!rest || !conversationId) {
|
|
5188
|
+
useStore.getState().showToast({ message: "nothing to compact yet — send a message first" });
|
|
5189
|
+
return;
|
|
5190
|
+
}
|
|
5191
|
+
armIndicator();
|
|
5192
|
+
rest.compactConversation({ conversationId }).catch((err) => {
|
|
5193
|
+
clearTimer();
|
|
5194
|
+
setCompacting(false);
|
|
5195
|
+
useStore.getState().showToast({
|
|
5196
|
+
variant: "error",
|
|
5197
|
+
message: `couldn't compact: ${errorDetail(err)}`
|
|
5198
|
+
});
|
|
5199
|
+
});
|
|
5200
|
+
}, [
|
|
5201
|
+
rest,
|
|
5202
|
+
conversationId,
|
|
5203
|
+
armIndicator,
|
|
5204
|
+
clearTimer
|
|
5205
|
+
]);
|
|
5206
|
+
useEffect(() => clearTimer, [clearTimer]);
|
|
5207
|
+
return {
|
|
5208
|
+
compacting,
|
|
5209
|
+
compactConversation,
|
|
5210
|
+
onCompactionEvent
|
|
5211
|
+
};
|
|
5212
|
+
}
|
|
5213
|
+
|
|
3886
5214
|
//#endregion
|
|
3887
5215
|
//#region src/chat/tui/chat/footer-hints.ts
|
|
3888
5216
|
/** Build the chat footer copy independently of its colors and layout. */
|
|
3889
|
-
function formatChatFooterStatus({ runKind, ctrlCArmed, isShared, hasPendingSteer,
|
|
3890
|
-
const connectHint =
|
|
5217
|
+
function formatChatFooterStatus({ runKind, ctrlCArmed, isShared, hasPendingSteer, actionableCardCount, nonWebChannel }) {
|
|
5218
|
+
const connectHint = actionableCardCount > 1 ? `ctrl+r connect (${actionableCardCount}) · ` : actionableCardCount === 1 ? "ctrl+r connect · " : "";
|
|
3891
5219
|
switch (runKind) {
|
|
3892
|
-
case "idle":
|
|
5220
|
+
case "idle":
|
|
5221
|
+
if (ctrlCArmed) return "press ctrl+c again to quit";
|
|
5222
|
+
if (nonWebChannel) return `esc back · ctrl+c quit · ctrl+t ${isShared ? "revoke access" : "local access"} · ? keybinds`;
|
|
5223
|
+
return `${connectHint}↵ send · / commands · esc back · ctrl+c quit · ctrl+t ${isShared ? "revoke access" : "local access"} · ? keybinds`;
|
|
3893
5224
|
case "sending": return "sending…";
|
|
3894
5225
|
case "streaming": return hasPendingSteer ? `${connectHint}↵ steer · esc cancel steer · ctrl+c cancel run` : `${connectHint}↵ steer · esc leave (keeps running) · ctrl+c cancel`;
|
|
3895
5226
|
}
|
|
@@ -3928,9 +5259,29 @@ function formatShare(share) {
|
|
|
3928
5259
|
//#region src/chat/notification-preview.ts
|
|
3929
5260
|
const MAX_NOTIFICATION_PREVIEW_CHARS = 240;
|
|
3930
5261
|
/**
|
|
5262
|
+
* Flattens common inline markdown to plain text for a notification / sidebar
|
|
5263
|
+
* preview, which renders as raw text (no markdown). Without this the preview
|
|
5264
|
+
* shows literal `**bold**` and `*italic*` markers. Intentionally lightweight —
|
|
5265
|
+
* not a full parser, just the inline markers that show up in a one-line
|
|
5266
|
+
* preview: emphasis, inline code, links/images, headings, blockquotes, and
|
|
5267
|
+
* list bullets. Block structure is already gone by the time we preview (we
|
|
5268
|
+
* collapse whitespace to a single line).
|
|
5269
|
+
*/
|
|
5270
|
+
function stripInlineMarkdown(input) {
|
|
5271
|
+
return input.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/(\*\*\*|\*\*|\*|___|__|_)(?=\S)(.*?\S)\1/g, "$2").replace(/(\*\*\*|\*\*|\*|___|__|_)(?=\S)(.*?\S)\1/g, "$2").replace(/~~(?=\S)(.*?\S)~~/g, "$1").replace(/^\s{0,3}#{1,6}\s+/gm, "").replace(/^\s{0,3}>\s?/gm, "").replace(/^\s{0,3}(?:[-*+]|\d+\.)\s+/gm, "");
|
|
5272
|
+
}
|
|
5273
|
+
/**
|
|
3931
5274
|
* Collects assistant `text-delta` chunks for one run and turns them into a
|
|
3932
|
-
*
|
|
5275
|
+
* notification/side-panel-sized preview. Reasoning, tool output, cards, errors,
|
|
3933
5276
|
* and old transcript items are intentionally excluded.
|
|
5277
|
+
*
|
|
5278
|
+
* A run is a sequence of text parts split by tool calls and reasoning
|
|
5279
|
+
* (text → tool → text → … → final text). The meaningful summary of a run is
|
|
5280
|
+
* its **last** text part: the closing answer the agent lands on after all its
|
|
5281
|
+
* tool work, not the "let me look into this…" preamble it opened with. So the
|
|
5282
|
+
* preview keeps only the final part's text, and when that part is longer than
|
|
5283
|
+
* the cap it keeps the **beginning** with a trailing ellipsis (the
|
|
5284
|
+
* conventional truncation) — the block already opens on the answer.
|
|
3934
5285
|
*/
|
|
3935
5286
|
function createResponsePreview() {
|
|
3936
5287
|
let text = "";
|
|
@@ -3940,12 +5291,12 @@ function createResponsePreview() {
|
|
|
3940
5291
|
if (chunk.type !== "text-delta") return;
|
|
3941
5292
|
const delta = typeof chunk.delta === "string" ? chunk.delta : typeof chunk.text === "string" ? chunk.text : "";
|
|
3942
5293
|
const partId = typeof chunk.id === "string" ? chunk.id : void 0;
|
|
3943
|
-
if (
|
|
5294
|
+
if (partId && lastPartId && partId !== lastPartId) text = "";
|
|
3944
5295
|
text += delta;
|
|
3945
5296
|
lastPartId = partId ?? lastPartId;
|
|
3946
5297
|
},
|
|
3947
5298
|
get() {
|
|
3948
|
-
const normalized = text.replace(/\s+/g, " ").trim();
|
|
5299
|
+
const normalized = stripInlineMarkdown(text).replace(/\s+/g, " ").trim();
|
|
3949
5300
|
if (!normalized) return void 0;
|
|
3950
5301
|
const characters = Array.from(normalized);
|
|
3951
5302
|
if (characters.length <= MAX_NOTIFICATION_PREVIEW_CHARS) return normalized;
|
|
@@ -4197,6 +5548,10 @@ const keybindGroups = [
|
|
|
4197
5548
|
keys: "exit",
|
|
4198
5549
|
action: "quit (type it as a message)"
|
|
4199
5550
|
},
|
|
5551
|
+
{
|
|
5552
|
+
keys: "ctrl+a",
|
|
5553
|
+
action: "archive this conversation (composer empty; hides it for you only)"
|
|
5554
|
+
},
|
|
4200
5555
|
{
|
|
4201
5556
|
keys: "ctrl+p",
|
|
4202
5557
|
action: "switch model"
|
|
@@ -4225,14 +5580,26 @@ const keybindGroups = [
|
|
|
4225
5580
|
keys: "ctrl+l",
|
|
4226
5581
|
action: "open this conversation in the browser"
|
|
4227
5582
|
},
|
|
5583
|
+
{
|
|
5584
|
+
keys: "ctrl+f",
|
|
5585
|
+
action: "fork a channel conversation into a chat you can continue here"
|
|
5586
|
+
},
|
|
4228
5587
|
{
|
|
4229
5588
|
keys: "ctrl+r",
|
|
4230
|
-
action: "
|
|
5589
|
+
action: "act on a waiting connect card (auth cards)"
|
|
4231
5590
|
},
|
|
4232
5591
|
{
|
|
4233
5592
|
keys: "pgup / pgdn",
|
|
4234
5593
|
action: "scroll transcript"
|
|
4235
5594
|
},
|
|
5595
|
+
{
|
|
5596
|
+
keys: "end",
|
|
5597
|
+
action: "jump to latest (when scrolled up)"
|
|
5598
|
+
},
|
|
5599
|
+
{
|
|
5600
|
+
keys: "/plan [show|hide]",
|
|
5601
|
+
action: "show / hide the pinned plan card"
|
|
5602
|
+
},
|
|
4236
5603
|
{
|
|
4237
5604
|
keys: "/",
|
|
4238
5605
|
action: "open the command menu (start of message)"
|
|
@@ -4294,6 +5661,10 @@ const keybindGroups = [
|
|
|
4294
5661
|
keys: "1 / 2",
|
|
4295
5662
|
action: "switch Changes / Files"
|
|
4296
5663
|
},
|
|
5664
|
+
{
|
|
5665
|
+
keys: "\\",
|
|
5666
|
+
action: "toggle side / stacked layout"
|
|
5667
|
+
},
|
|
4297
5668
|
{
|
|
4298
5669
|
keys: "↵ / o / l",
|
|
4299
5670
|
action: "focus selected file preview"
|
|
@@ -4369,14 +5740,22 @@ const keybindGroups = [
|
|
|
4369
5740
|
keys: "↵",
|
|
4370
5741
|
action: "open conversation"
|
|
4371
5742
|
},
|
|
5743
|
+
{
|
|
5744
|
+
keys: "ctrl+a",
|
|
5745
|
+
action: "archive conversation (hides it for you only; again to restore)"
|
|
5746
|
+
},
|
|
4372
5747
|
{
|
|
4373
5748
|
keys: "ctrl+d",
|
|
4374
|
-
action: "delete conversation (y/↵ confirm, n/esc keep)"
|
|
5749
|
+
action: "delete conversation for everyone (y/↵ confirm, n/esc keep)"
|
|
4375
5750
|
},
|
|
4376
5751
|
{
|
|
4377
5752
|
keys: "tab",
|
|
4378
5753
|
action: "show / hide the agent's own runs (schedules, webhooks, subagents)"
|
|
4379
5754
|
},
|
|
5755
|
+
{
|
|
5756
|
+
keys: "shift+tab",
|
|
5757
|
+
action: "show / hide the conversations you archived"
|
|
5758
|
+
},
|
|
4380
5759
|
{
|
|
4381
5760
|
keys: "esc",
|
|
4382
5761
|
action: "back to agent picker"
|
|
@@ -4659,6 +6038,27 @@ const slashCommands = [
|
|
|
4659
6038
|
description: "start a new conversation with this agent",
|
|
4660
6039
|
action: { kind: "new-conversation" }
|
|
4661
6040
|
},
|
|
6041
|
+
{
|
|
6042
|
+
name: "fork",
|
|
6043
|
+
aliases: [],
|
|
6044
|
+
argsHint: null,
|
|
6045
|
+
description: "fork this conversation into a new one (full copy)",
|
|
6046
|
+
action: { kind: "fork" }
|
|
6047
|
+
},
|
|
6048
|
+
{
|
|
6049
|
+
name: "archive",
|
|
6050
|
+
aliases: [],
|
|
6051
|
+
argsHint: null,
|
|
6052
|
+
description: "archive this conversation and hide it from your list",
|
|
6053
|
+
action: { kind: "archive-conversation" }
|
|
6054
|
+
},
|
|
6055
|
+
{
|
|
6056
|
+
name: "conversation",
|
|
6057
|
+
aliases: ["conversations", "conv"],
|
|
6058
|
+
argsHint: "[id or link]",
|
|
6059
|
+
description: "switch conversation (no id: pick from the list)",
|
|
6060
|
+
action: { kind: "conversation" }
|
|
6061
|
+
},
|
|
4662
6062
|
{
|
|
4663
6063
|
name: "model",
|
|
4664
6064
|
aliases: [],
|
|
@@ -4680,6 +6080,16 @@ const slashCommands = [
|
|
|
4680
6080
|
description: "show connection and session status",
|
|
4681
6081
|
action: { kind: "status" }
|
|
4682
6082
|
},
|
|
6083
|
+
{
|
|
6084
|
+
name: "plan",
|
|
6085
|
+
aliases: ["todos", "todo"],
|
|
6086
|
+
argsHint: "[show|hide]",
|
|
6087
|
+
description: "show or hide the pinned plan card",
|
|
6088
|
+
action: {
|
|
6089
|
+
kind: "plan",
|
|
6090
|
+
mode: "toggle"
|
|
6091
|
+
}
|
|
6092
|
+
},
|
|
4683
6093
|
{
|
|
4684
6094
|
name: "share",
|
|
4685
6095
|
aliases: [],
|
|
@@ -4698,9 +6108,30 @@ const slashCommands = [
|
|
|
4698
6108
|
name: "connect",
|
|
4699
6109
|
aliases: [],
|
|
4700
6110
|
argsHint: null,
|
|
4701
|
-
description: "
|
|
6111
|
+
description: "act on a waiting connect card (auth cards)",
|
|
4702
6112
|
action: { kind: "connect" }
|
|
4703
6113
|
},
|
|
6114
|
+
{
|
|
6115
|
+
name: "copy",
|
|
6116
|
+
aliases: ["cp"],
|
|
6117
|
+
argsHint: null,
|
|
6118
|
+
description: "copy the agent's last message to the clipboard",
|
|
6119
|
+
action: { kind: "copy" }
|
|
6120
|
+
},
|
|
6121
|
+
{
|
|
6122
|
+
name: "portal",
|
|
6123
|
+
aliases: ["local"],
|
|
6124
|
+
argsHint: null,
|
|
6125
|
+
description: "allow this agent to access your computer",
|
|
6126
|
+
action: { kind: "portal" }
|
|
6127
|
+
},
|
|
6128
|
+
{
|
|
6129
|
+
name: "compact",
|
|
6130
|
+
aliases: [],
|
|
6131
|
+
argsHint: null,
|
|
6132
|
+
description: "summarize older turns to free up context now",
|
|
6133
|
+
action: { kind: "compact" }
|
|
6134
|
+
},
|
|
4704
6135
|
{
|
|
4705
6136
|
name: "help",
|
|
4706
6137
|
aliases: [],
|
|
@@ -4723,7 +6154,7 @@ const slashCommands = [
|
|
|
4723
6154
|
*
|
|
4724
6155
|
* An argument-less command only matches as the exact line — `/help me write
|
|
4725
6156
|
* a plan` stays an ordinary message rather than swallowing the user's text
|
|
4726
|
-
* into the help modal. Only `sandbox`
|
|
6157
|
+
* into the help modal. Only `sandbox` and `conversation` accept a rest.
|
|
4727
6158
|
*/
|
|
4728
6159
|
function matchSlashCommand(line) {
|
|
4729
6160
|
if (!line.startsWith("/")) return null;
|
|
@@ -4796,6 +6227,93 @@ function printResumeHint(write = (text) => process.stdout.write(text)) {
|
|
|
4796
6227
|
lastConversationId = null;
|
|
4797
6228
|
}
|
|
4798
6229
|
|
|
6230
|
+
//#endregion
|
|
6231
|
+
//#region src/chat/tui/screens/card-picker.tsx
|
|
6232
|
+
/**
|
|
6233
|
+
* Asks which connect card ctrl+r should act on. A conversation can hold
|
|
6234
|
+
* several cards waiting at once (a batch of `platform auth configure` calls),
|
|
6235
|
+
* and the chord can't guess which one the user means — picking the newest
|
|
6236
|
+
* silently strands the rest, since a card left in `launched` or `error` stays
|
|
6237
|
+
* actionable forever. Rendered as an overlay in place of the transcript, like
|
|
6238
|
+
* the model and theme pickers, so the conversation and any live run survive.
|
|
6239
|
+
*/
|
|
6240
|
+
function CardPicker({ cards, onSelect, onCancel }) {
|
|
6241
|
+
const { height } = useTerminalDimensions();
|
|
6242
|
+
const [highlight, setHighlight] = useState(0);
|
|
6243
|
+
const clamped = Math.min(highlight, Math.max(0, cards.length - 1));
|
|
6244
|
+
const visibleRows = Math.max(3, height - 6);
|
|
6245
|
+
const start = windowStart(clamped, cards.length, visibleRows);
|
|
6246
|
+
const windowed = cards.slice(start, start + visibleRows);
|
|
6247
|
+
useKeyboard((key) => {
|
|
6248
|
+
if (key.name === "escape") {
|
|
6249
|
+
onCancel();
|
|
6250
|
+
return;
|
|
6251
|
+
}
|
|
6252
|
+
if (key.name === "up") setHighlight(Math.max(0, clamped - 1));
|
|
6253
|
+
else if (key.name === "down") setHighlight(Math.min(Math.max(0, cards.length - 1), clamped + 1));
|
|
6254
|
+
else if (key.name === "return") {
|
|
6255
|
+
const card = cards[clamped];
|
|
6256
|
+
if (card) onSelect(card);
|
|
6257
|
+
}
|
|
6258
|
+
});
|
|
6259
|
+
useEffect(() => {
|
|
6260
|
+
if (cards.length === 0) onCancel();
|
|
6261
|
+
}, [cards.length, onCancel]);
|
|
6262
|
+
if (cards.length === 0) return null;
|
|
6263
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
6264
|
+
style: {
|
|
6265
|
+
flexDirection: "column",
|
|
6266
|
+
flexGrow: 1
|
|
6267
|
+
},
|
|
6268
|
+
children: [
|
|
6269
|
+
/* @__PURE__ */ jsx("text", {
|
|
6270
|
+
fg: theme.accent,
|
|
6271
|
+
children: "connect"
|
|
6272
|
+
}),
|
|
6273
|
+
/* @__PURE__ */ jsx("box", {
|
|
6274
|
+
style: {
|
|
6275
|
+
flexDirection: "column",
|
|
6276
|
+
flexGrow: 1,
|
|
6277
|
+
marginTop: 1
|
|
6278
|
+
},
|
|
6279
|
+
children: windowed.map((card, i) => {
|
|
6280
|
+
const selected = start + i === clamped;
|
|
6281
|
+
const waiting = waitingOn(card);
|
|
6282
|
+
const label = card.card.button?.label ?? "";
|
|
6283
|
+
return /* @__PURE__ */ jsxs("text", {
|
|
6284
|
+
fg: selected ? theme.accent : theme.fg,
|
|
6285
|
+
children: [
|
|
6286
|
+
selected ? "› " : " ",
|
|
6287
|
+
"⚿ ",
|
|
6288
|
+
card.card.title,
|
|
6289
|
+
label ? /* @__PURE__ */ jsxs("span", {
|
|
6290
|
+
fg: theme.muted,
|
|
6291
|
+
children: [" · ", label]
|
|
6292
|
+
}) : null,
|
|
6293
|
+
waiting ? /* @__PURE__ */ jsxs("span", {
|
|
6294
|
+
fg: theme.dim,
|
|
6295
|
+
children: [" · ", waiting]
|
|
6296
|
+
}) : null
|
|
6297
|
+
]
|
|
6298
|
+
}, card.id);
|
|
6299
|
+
})
|
|
6300
|
+
}),
|
|
6301
|
+
/* @__PURE__ */ jsxs("text", {
|
|
6302
|
+
fg: theme.dim,
|
|
6303
|
+
children: [cards.length, " waiting · ↑/↓ move · ↵ select · esc cancel"]
|
|
6304
|
+
})
|
|
6305
|
+
]
|
|
6306
|
+
});
|
|
6307
|
+
}
|
|
6308
|
+
/** Why a card is still on the list, for the rows that aren't simply untouched. */
|
|
6309
|
+
function waitingOn(card) {
|
|
6310
|
+
switch (card.status) {
|
|
6311
|
+
case "launched": return "finish in your browser";
|
|
6312
|
+
case "error": return card.detail ?? "failed";
|
|
6313
|
+
default: return null;
|
|
6314
|
+
}
|
|
6315
|
+
}
|
|
6316
|
+
|
|
4799
6317
|
//#endregion
|
|
4800
6318
|
//#region src/chat/tui/screens/model-picker.tsx
|
|
4801
6319
|
/**
|
|
@@ -5008,9 +6526,18 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
|
|
|
5008
6526
|
});
|
|
5009
6527
|
}
|
|
5010
6528
|
const modelKeys = [
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
6529
|
+
{
|
|
6530
|
+
get: (m) => m.displayName,
|
|
6531
|
+
weight: 1
|
|
6532
|
+
},
|
|
6533
|
+
{
|
|
6534
|
+
get: (m) => m.providerDisplay ?? "",
|
|
6535
|
+
weight: .9
|
|
6536
|
+
},
|
|
6537
|
+
{
|
|
6538
|
+
get: (m) => m.id,
|
|
6539
|
+
weight: .9
|
|
6540
|
+
}
|
|
5014
6541
|
];
|
|
5015
6542
|
/**
|
|
5016
6543
|
* The REST error routes return `{ "error": "message" }`. Pull that out for a
|
|
@@ -6676,7 +8203,7 @@ function gcOnce() {
|
|
|
6676
8203
|
gcStarted = true;
|
|
6677
8204
|
store.gc();
|
|
6678
8205
|
}
|
|
6679
|
-
function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHeight, active, shellLayout, tab, onTabChange, selectedChangeFile, onSelectedChangeFileChange, selectedWorkspaceFile, onSelectedWorkspaceFileChange, navigatorSize, onNavigatorResize, onInsertToComposer, onRequestChatFocus, onClose }) {
|
|
8206
|
+
function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHeight, active, shellLayout, tab, onTabChange, selectedChangeFile, onSelectedChangeFileChange, selectedWorkspaceFile, onSelectedWorkspaceFileChange, navigatorSize, onNavigatorResize, onToggleOrientation, onInsertToComposer, onRequestChatFocus, onClose }) {
|
|
6680
8207
|
const width = viewportWidth;
|
|
6681
8208
|
const height = viewportHeight;
|
|
6682
8209
|
const shellHeight = Math.max(6, height - 2);
|
|
@@ -6827,6 +8354,10 @@ function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHei
|
|
|
6827
8354
|
onTabChange("files");
|
|
6828
8355
|
return;
|
|
6829
8356
|
}
|
|
8357
|
+
if (key.name === "\\") {
|
|
8358
|
+
if (onToggleOrientation() === "unavailable") setStatus("no room to change layout in this terminal");
|
|
8359
|
+
return;
|
|
8360
|
+
}
|
|
6830
8361
|
if (key.name === "s") {
|
|
6831
8362
|
if (comments.length === 0) {
|
|
6832
8363
|
setStatus("no pending comments");
|
|
@@ -7217,18 +8748,93 @@ function PaneRowView({ row, width, isCursor, inSelection, isAnchor, isCommented
|
|
|
7217
8748
|
//#endregion
|
|
7218
8749
|
//#region src/chat/tui/file-pane-layout.ts
|
|
7219
8750
|
/**
|
|
8751
|
+
* Terminal cells are about twice as tall as they are wide, so a raw
|
|
8752
|
+
* cols-vs-rows compare would call almost any terminal "wide". Scale rows by
|
|
8753
|
+
* this factor to compare the *visual* aspect: a square-looking terminal has
|
|
8754
|
+
* `width ≈ height * CELL_ASPECT`.
|
|
8755
|
+
*/
|
|
8756
|
+
const CELL_ASPECT = 2;
|
|
8757
|
+
/**
|
|
8758
|
+
* The automatic split follows the terminal's visual aspect: wider than tall
|
|
8759
|
+
* places review beside the chat (a vertical divider), taller than wide stacks
|
|
8760
|
+
* it underneath (a horizontal divider). This is the responsive default when
|
|
8761
|
+
* the user hasn't pinned an orientation.
|
|
8762
|
+
*/
|
|
8763
|
+
function autoPrefersSide({ width, height }) {
|
|
8764
|
+
return width >= height * CELL_ASPECT;
|
|
8765
|
+
}
|
|
8766
|
+
/**
|
|
8767
|
+
* Bare-minimum room to render a split at all (as opposed to the higher
|
|
8768
|
+
* comfort thresholds the automatic choice uses). A user pin is honored down
|
|
8769
|
+
* to these: chat + review each need a usable strip, but we don't insist on
|
|
8770
|
+
* the roomier auto breakpoints. Mirrors the clamps in clampSidePaneWidth /
|
|
8771
|
+
* clampBelowPaneHeight so a pinned split never renders narrower than those.
|
|
8772
|
+
*/
|
|
8773
|
+
function canRenderSide({ width, height }) {
|
|
8774
|
+
return width >= 76 && height >= 8;
|
|
8775
|
+
}
|
|
8776
|
+
function canRenderBelow({ width, height }) {
|
|
8777
|
+
return height >= 24 && width >= 24;
|
|
8778
|
+
}
|
|
8779
|
+
/**
|
|
7220
8780
|
* Responsive placement for the file pane.
|
|
7221
8781
|
*
|
|
7222
|
-
*
|
|
7223
|
-
*
|
|
7224
|
-
*
|
|
7225
|
-
*
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
8782
|
+
* With no user pin the split follows the terminal's visual aspect (see
|
|
8783
|
+
* autoPrefersSide): wider than tall -> side, taller than wide -> stacked.
|
|
8784
|
+
* The preferred split still has to be renderable; if it isn't we use the
|
|
8785
|
+
* other, and cramped terminals that can render neither get the full body.
|
|
8786
|
+
*
|
|
8787
|
+
* A user `orientation` pin always wins as long as the terminal can render
|
|
8788
|
+
* that split at all (the lower canRender* bar) — so you get the layout you
|
|
8789
|
+
* asked for even on a smallish terminal. Only when the pinned split
|
|
8790
|
+
* genuinely can't fit do we fall back: to the other split if it can render,
|
|
8791
|
+
* else to `fullscreen`.
|
|
8792
|
+
*/
|
|
8793
|
+
function filePanePlacement({ width, height, orientation }) {
|
|
8794
|
+
const renderSide = canRenderSide({
|
|
8795
|
+
width,
|
|
8796
|
+
height
|
|
8797
|
+
});
|
|
8798
|
+
const renderBelow = canRenderBelow({
|
|
8799
|
+
width,
|
|
8800
|
+
height
|
|
8801
|
+
});
|
|
8802
|
+
if (orientation === "side") {
|
|
8803
|
+
if (renderSide) return "side";
|
|
8804
|
+
if (renderBelow) return "below";
|
|
8805
|
+
return "fullscreen";
|
|
8806
|
+
}
|
|
8807
|
+
if (orientation === "below") {
|
|
8808
|
+
if (renderBelow) return "below";
|
|
8809
|
+
if (renderSide) return "side";
|
|
8810
|
+
return "fullscreen";
|
|
8811
|
+
}
|
|
8812
|
+
if (autoPrefersSide({
|
|
8813
|
+
width,
|
|
8814
|
+
height
|
|
8815
|
+
})) {
|
|
8816
|
+
if (renderSide) return "side";
|
|
8817
|
+
if (renderBelow) return "below";
|
|
8818
|
+
return "fullscreen";
|
|
8819
|
+
}
|
|
8820
|
+
if (renderBelow) return "below";
|
|
8821
|
+
if (renderSide) return "side";
|
|
7230
8822
|
return "fullscreen";
|
|
7231
8823
|
}
|
|
8824
|
+
/**
|
|
8825
|
+
* Whether toggling the orientation would actually change anything: both
|
|
8826
|
+
* splits must be renderable. When only one (or neither) can render, the
|
|
8827
|
+
* caller surfaces a hint instead of a silent no-op.
|
|
8828
|
+
*/
|
|
8829
|
+
function canToggleOrientation({ width, height }) {
|
|
8830
|
+
return canRenderSide({
|
|
8831
|
+
width,
|
|
8832
|
+
height
|
|
8833
|
+
}) && canRenderBelow({
|
|
8834
|
+
width,
|
|
8835
|
+
height
|
|
8836
|
+
});
|
|
8837
|
+
}
|
|
7232
8838
|
/** Keep a dragged side pane wide enough to use and leave the chat usable. */
|
|
7233
8839
|
function clampSidePaneWidth(width, bodyWidth) {
|
|
7234
8840
|
return Math.max(44, Math.min(bodyWidth - 30, width));
|
|
@@ -7575,13 +9181,29 @@ function routeInput(raw) {
|
|
|
7575
9181
|
kind: "sandbox-exec",
|
|
7576
9182
|
command: match.rest
|
|
7577
9183
|
} : { kind: "sandbox-pty" };
|
|
9184
|
+
case "conversation": return match.rest ? {
|
|
9185
|
+
kind: "open-conversation",
|
|
9186
|
+
ref: match.rest
|
|
9187
|
+
} : { kind: "conversation-picker" };
|
|
9188
|
+
case "plan": {
|
|
9189
|
+
const arg = match.rest.toLowerCase();
|
|
9190
|
+
return {
|
|
9191
|
+
kind: "plan",
|
|
9192
|
+
mode: arg === "show" ? "show" : arg === "hide" ? "hide" : "toggle"
|
|
9193
|
+
};
|
|
9194
|
+
}
|
|
7578
9195
|
case "new-conversation":
|
|
9196
|
+
case "fork":
|
|
9197
|
+
case "archive-conversation":
|
|
7579
9198
|
case "model-picker":
|
|
7580
9199
|
case "theme-picker":
|
|
7581
9200
|
case "status":
|
|
7582
9201
|
case "share":
|
|
7583
9202
|
case "browser":
|
|
7584
9203
|
case "connect":
|
|
9204
|
+
case "copy":
|
|
9205
|
+
case "portal":
|
|
9206
|
+
case "compact":
|
|
7585
9207
|
case "help":
|
|
7586
9208
|
case "quit": return { kind: action.kind };
|
|
7587
9209
|
default: {
|
|
@@ -7643,6 +9265,47 @@ async function runPtySession(opts) {
|
|
|
7643
9265
|
}
|
|
7644
9266
|
}
|
|
7645
9267
|
|
|
9268
|
+
//#endregion
|
|
9269
|
+
//#region src/chat/tui/screens/conversation-channel.ts
|
|
9270
|
+
function isNewConversation(c) {
|
|
9271
|
+
return "kind" in c && c.kind === "new";
|
|
9272
|
+
}
|
|
9273
|
+
/**
|
|
9274
|
+
* The channel a conversation belongs to when it isn't the web channel, else
|
|
9275
|
+
* null. A brand-new conversation is always web, and the list route reports the
|
|
9276
|
+
* web channel as either `'web'` or (legacy) null — both mean "post from here".
|
|
9277
|
+
* Anything else (slack, email, imessage, webhook, cron) is owned by that
|
|
9278
|
+
* channel: the message has to go through it, so the TUI can't send here.
|
|
9279
|
+
* Mirrors the web app's `channel !== null && channel !== 'web'` gate.
|
|
9280
|
+
*/
|
|
9281
|
+
function nonWebChannelLabel(c) {
|
|
9282
|
+
if (isNewConversation(c)) return null;
|
|
9283
|
+
if (c.channel === null || c.channel === "web") return null;
|
|
9284
|
+
return c.channelLabel ?? c.channel;
|
|
9285
|
+
}
|
|
9286
|
+
/**
|
|
9287
|
+
* The list-row shape for a conversation the TUI just forked, so the chat
|
|
9288
|
+
* screen can reopen on the copy without waiting for a list refetch. A fork
|
|
9289
|
+
* carries none of the source's channel binding — that's what makes it
|
|
9290
|
+
* sendable from here — so it is always a web conversation.
|
|
9291
|
+
*/
|
|
9292
|
+
function forkedConversationRef({ agent, forked }) {
|
|
9293
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
9294
|
+
return {
|
|
9295
|
+
id: forked.id,
|
|
9296
|
+
title: forked.title,
|
|
9297
|
+
createdAt: now,
|
|
9298
|
+
updatedAt: now,
|
|
9299
|
+
preview: null,
|
|
9300
|
+
channel: "web",
|
|
9301
|
+
channelLabel: null,
|
|
9302
|
+
agent: {
|
|
9303
|
+
id: agent.id,
|
|
9304
|
+
name: agent.name
|
|
9305
|
+
}
|
|
9306
|
+
};
|
|
9307
|
+
}
|
|
9308
|
+
|
|
7646
9309
|
//#endregion
|
|
7647
9310
|
//#region src/chat/tui/screens/chat.tsx
|
|
7648
9311
|
/**
|
|
@@ -7679,8 +9342,14 @@ const pageScrollFraction = .75;
|
|
|
7679
9342
|
/** Cap composer growth; past this the textarea scrolls its content instead. */
|
|
7680
9343
|
const maxComposerRows = 8;
|
|
7681
9344
|
const maxAttachments = 10;
|
|
7682
|
-
|
|
7683
|
-
|
|
9345
|
+
/**
|
|
9346
|
+
* A server timestamp as local epoch ms, or null when it is missing or
|
|
9347
|
+
* unparseable so the caller can fall back to its own clock.
|
|
9348
|
+
*/
|
|
9349
|
+
function parseStampMs(iso) {
|
|
9350
|
+
if (!iso) return null;
|
|
9351
|
+
const ms = Date.parse(iso);
|
|
9352
|
+
return Number.isNaN(ms) ? null : ms;
|
|
7684
9353
|
}
|
|
7685
9354
|
function formatBytes(bytes) {
|
|
7686
9355
|
if (bytes < 1024) return `${bytes} B`;
|
|
@@ -7725,13 +9394,27 @@ function ChatScreen({ agent, conversation }) {
|
|
|
7725
9394
|
const isShared = portal.status !== "off";
|
|
7726
9395
|
const initialConversationId = isNewConversation(conversation) ? null : conversation.id;
|
|
7727
9396
|
const agentHost = useAgentHost();
|
|
9397
|
+
const nonWebChannel = nonWebChannelLabel(conversation);
|
|
7728
9398
|
const [conversationId, setConversationId] = useState(initialConversationId);
|
|
9399
|
+
const { compacting, compactConversation, onCompactionEvent } = useCompaction({
|
|
9400
|
+
rest,
|
|
9401
|
+
conversationId
|
|
9402
|
+
});
|
|
7729
9403
|
const [items, setItems] = useState([]);
|
|
7730
9404
|
const [todos, setTodos] = useState(null);
|
|
9405
|
+
const [planHidden, setPlanHidden] = useState(false);
|
|
9406
|
+
const [scrolledUp, setScrolledUp] = useState(false);
|
|
9407
|
+
const hadTodosRef = useRef(false);
|
|
9408
|
+
useEffect(() => {
|
|
9409
|
+
const hasTodos = !!todos && todos.length > 0;
|
|
9410
|
+
if (hasTodos && !hadTodosRef.current) setPlanHidden(false);
|
|
9411
|
+
hadTodosRef.current = hasTodos;
|
|
9412
|
+
}, [todos]);
|
|
7731
9413
|
const [historyLoaded, setHistoryLoaded] = useState(initialConversationId === null);
|
|
7732
9414
|
const [input, setInput] = useState("");
|
|
7733
9415
|
const [run, setRun] = useState({ kind: "idle" });
|
|
7734
9416
|
const [model, setModel] = useState(agent.model ?? null);
|
|
9417
|
+
const [cardPickerOpen, setCardPickerOpen] = useState(false);
|
|
7735
9418
|
const [modelPickerOpen, setModelPickerOpen] = useState(false);
|
|
7736
9419
|
const [themePickerOpen, setThemePickerOpen] = useState(false);
|
|
7737
9420
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
@@ -7747,6 +9430,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
7747
9430
|
side: null,
|
|
7748
9431
|
stacked: null
|
|
7749
9432
|
});
|
|
9433
|
+
const [reviewOrientation, setReviewOrientation] = useState(null);
|
|
7750
9434
|
const [reviewWorkspaceFile, setReviewWorkspaceFile] = useState(null);
|
|
7751
9435
|
useEffect(() => {
|
|
7752
9436
|
setReviewChangeFile(null);
|
|
@@ -7756,6 +9440,8 @@ function ChatScreen({ agent, conversation }) {
|
|
|
7756
9440
|
const [composerRows, setComposerRows] = useState(1);
|
|
7757
9441
|
const [attachments, setAttachments] = useState([]);
|
|
7758
9442
|
const uploadsRef = useRef(/* @__PURE__ */ new Map());
|
|
9443
|
+
const [forking, setForking] = useState(false);
|
|
9444
|
+
const forkingRef = useRef(false);
|
|
7759
9445
|
const [credPrompt, setCredPrompt] = useState(null);
|
|
7760
9446
|
const credPromptOpen = credPrompt !== null;
|
|
7761
9447
|
const [menuDismissed, setMenuDismissed] = useState(false);
|
|
@@ -7784,25 +9470,62 @@ function ChatScreen({ agent, conversation }) {
|
|
|
7784
9470
|
agentHost.transition({ state: "idle" });
|
|
7785
9471
|
agentHost.setSession(null);
|
|
7786
9472
|
}, [agentHost]);
|
|
9473
|
+
const [prLinks, setPrLinks] = useState([]);
|
|
9474
|
+
const reportedPrsRef = useRef(/* @__PURE__ */ new Map());
|
|
9475
|
+
useEffect(() => () => {
|
|
9476
|
+
for (const pr of reportedPrsRef.current.values()) agentHost.removePullRequest(pr);
|
|
9477
|
+
reportedPrsRef.current = /* @__PURE__ */ new Map();
|
|
9478
|
+
}, [agentHost]);
|
|
7787
9479
|
useEffect(() => {
|
|
7788
9480
|
agentHost.setSession(conversationId);
|
|
7789
9481
|
setResumeConversation(conversationId);
|
|
7790
9482
|
}, [agentHost, conversationId]);
|
|
9483
|
+
const bindPortalCwd = useCallback(() => {
|
|
9484
|
+
if (!conversationId || !portalClient) return;
|
|
9485
|
+
if (portal.status === "off") return;
|
|
9486
|
+
const cwd = shellSessionRef.current?.cwd ?? process.cwd();
|
|
9487
|
+
portalClient.bind(conversationId, cwd);
|
|
9488
|
+
}, [
|
|
9489
|
+
conversationId,
|
|
9490
|
+
portalClient,
|
|
9491
|
+
portal.status
|
|
9492
|
+
]);
|
|
7791
9493
|
const scrollRef = useRef(null);
|
|
7792
9494
|
const composerRef = useRef(null);
|
|
9495
|
+
const jumpToBottom = useCallback(() => {
|
|
9496
|
+
const box = scrollRef.current;
|
|
9497
|
+
if (!box) return;
|
|
9498
|
+
box.scrollTo({
|
|
9499
|
+
x: box.scrollLeft,
|
|
9500
|
+
y: box.scrollHeight
|
|
9501
|
+
});
|
|
9502
|
+
setScrolledUp(false);
|
|
9503
|
+
}, []);
|
|
9504
|
+
useEffect(() => {
|
|
9505
|
+
const id = setInterval(() => {
|
|
9506
|
+
const box = scrollRef.current;
|
|
9507
|
+
if (!box) return;
|
|
9508
|
+
const viewport = box.viewport.height;
|
|
9509
|
+
const atBottom = box.scrollTop + viewport >= box.scrollHeight - 1;
|
|
9510
|
+
setScrolledUp((prev) => prev === !atBottom ? prev : !atBottom);
|
|
9511
|
+
}, 150);
|
|
9512
|
+
return () => clearInterval(id);
|
|
9513
|
+
}, []);
|
|
7793
9514
|
const reviewSideBoxRef = useRef(null);
|
|
7794
9515
|
const reviewBelowBoxRef = useRef(null);
|
|
7795
9516
|
const paneDragRef = useRef(null);
|
|
7796
9517
|
const composerBoxHeight = composerRows + 2;
|
|
7797
|
-
const pendingVisible = !grantPrompt && !credPrompt && attachments.length > 0;
|
|
9518
|
+
const pendingVisible = !grantPrompt && !credPrompt && !nonWebChannel && attachments.length > 0;
|
|
7798
9519
|
const pendingRows = pendingVisible ? attachments.length + 2 : 0;
|
|
7799
9520
|
const credPromptHeight = 4 + (credPrompt && (credPrompt.error || credPrompt.submitting) ? 1 : 0);
|
|
7800
|
-
const
|
|
9521
|
+
const nonWebNoticeHeight = 5;
|
|
9522
|
+
const bottomBoxHeight = credPrompt ? credPromptHeight : nonWebChannel ? nonWebNoticeHeight : composerBoxHeight;
|
|
7801
9523
|
const bodyWidth = Math.max(1, width - 2);
|
|
7802
9524
|
const bodyHeight = Math.max(1, height - 3);
|
|
7803
9525
|
const reviewPlacement = filePanePlacement({
|
|
7804
9526
|
width,
|
|
7805
|
-
height
|
|
9527
|
+
height,
|
|
9528
|
+
orientation: reviewOrientation
|
|
7806
9529
|
});
|
|
7807
9530
|
const reviewWidth = reviewPlacement === "side" ? clampSidePaneWidth(reviewSizePref.side ?? sidePaneWidth(bodyWidth), bodyWidth) : bodyWidth;
|
|
7808
9531
|
const reviewHeight = reviewPlacement === "below" ? clampBelowPaneHeight(reviewSizePref.below ?? belowPaneHeight(bodyHeight), bodyHeight) : bodyHeight;
|
|
@@ -7811,9 +9534,11 @@ function ChatScreen({ agent, conversation }) {
|
|
|
7811
9534
|
const chatHeight = bodyHeight;
|
|
7812
9535
|
const inlineReviewHeight = reviewOpen && reviewPlacement === "below" ? reviewHeight : 0;
|
|
7813
9536
|
const menuRows = menuOpen ? completionMenuHeight(menuCommands.length) : 0;
|
|
7814
|
-
const todoCardVisible = !credPrompt && !!todos && todos.length > 0;
|
|
9537
|
+
const todoCardVisible = !credPrompt && !planHidden && !!todos && todos.length > 0;
|
|
7815
9538
|
const todoRows = todoCardVisible ? todoCardHeight(todos) : 0;
|
|
7816
|
-
const
|
|
9539
|
+
const jumpHintVisible = scrolledUp && !credPrompt;
|
|
9540
|
+
const jumpHintRows = jumpHintVisible ? 1 : 0;
|
|
9541
|
+
const scrollHeight = Math.max(3, chatHeight - 2 - bottomBoxHeight - pendingRows - inlineReviewHeight - menuRows - jumpHintRows - todoRows);
|
|
7817
9542
|
useEffect(() => {
|
|
7818
9543
|
if (reviewOpen && reviewFocused) {
|
|
7819
9544
|
const composer = composerRef.current;
|
|
@@ -7840,11 +9565,13 @@ function ChatScreen({ agent, conversation }) {
|
|
|
7840
9565
|
};
|
|
7841
9566
|
}, [
|
|
7842
9567
|
grantPrompt,
|
|
9568
|
+
cardPickerOpen,
|
|
7843
9569
|
modelPickerOpen,
|
|
7844
9570
|
themePickerOpen,
|
|
7845
9571
|
helpOpen,
|
|
7846
9572
|
reviewOpen,
|
|
7847
9573
|
reviewFocused,
|
|
9574
|
+
reviewPlacement,
|
|
7848
9575
|
credPromptOpen
|
|
7849
9576
|
]);
|
|
7850
9577
|
useEffect(() => {
|
|
@@ -7861,6 +9588,16 @@ function ChatScreen({ agent, conversation }) {
|
|
|
7861
9588
|
text: recapText
|
|
7862
9589
|
});
|
|
7863
9590
|
setItems(loaded);
|
|
9591
|
+
for (const item of loaded) {
|
|
9592
|
+
const text = item.kind === "assistant-text" ? item.text : item.kind === "tool" && item.output?.kind === "ok" ? stringifyToolOutput(item.output.value) : null;
|
|
9593
|
+
if (text === null) continue;
|
|
9594
|
+
for (const pr of extractPullRequests(text, { requireCompleteNumber: false })) {
|
|
9595
|
+
if (reportedPrsRef.current.has(pr.key)) continue;
|
|
9596
|
+
reportedPrsRef.current.set(pr.key, pr);
|
|
9597
|
+
setPrLinks((prev) => [...prev, pr]);
|
|
9598
|
+
agentHost.addPullRequest(pr);
|
|
9599
|
+
}
|
|
9600
|
+
}
|
|
7864
9601
|
setTodos(latestTodosFromHistory(messages));
|
|
7865
9602
|
} catch (err) {
|
|
7866
9603
|
if (cancelled) return;
|
|
@@ -7876,24 +9613,36 @@ function ChatScreen({ agent, conversation }) {
|
|
|
7876
9613
|
return () => {
|
|
7877
9614
|
cancelled = true;
|
|
7878
9615
|
};
|
|
7879
|
-
}, [
|
|
7880
|
-
|
|
9616
|
+
}, [
|
|
9617
|
+
rest,
|
|
9618
|
+
initialConversationId,
|
|
9619
|
+
agentHost
|
|
9620
|
+
]);
|
|
9621
|
+
const attachToRun = useCallback((runId, runAgentName, runStartedAtMs) => {
|
|
7881
9622
|
if (!rest) return;
|
|
7882
9623
|
seenRunsRef.current.add(runId);
|
|
7883
9624
|
const abort = new AbortController();
|
|
7884
9625
|
const responsePreview = createResponsePreview();
|
|
9626
|
+
const prScanner = createPullRequestScanner((pr) => {
|
|
9627
|
+
if (reportedPrsRef.current.has(pr.key)) return;
|
|
9628
|
+
reportedPrsRef.current.set(pr.key, pr);
|
|
9629
|
+
setPrLinks((prev) => [...prev, pr]);
|
|
9630
|
+
agentHost.addPullRequest(pr);
|
|
9631
|
+
});
|
|
7885
9632
|
const streamAgentName = runAgentName ?? agent.name;
|
|
7886
|
-
setRun({
|
|
9633
|
+
setRun((prev) => ({
|
|
7887
9634
|
kind: "streaming",
|
|
7888
9635
|
runId,
|
|
7889
|
-
abort
|
|
7890
|
-
|
|
9636
|
+
abort,
|
|
9637
|
+
startedAtMs: runStartedAtMs ?? (prev.kind === "idle" ? Date.now() : prev.startedAtMs)
|
|
9638
|
+
}));
|
|
7891
9639
|
rest.streamRun({
|
|
7892
9640
|
runId,
|
|
7893
9641
|
signal: abort.signal,
|
|
7894
9642
|
onEvent: (event) => {
|
|
7895
9643
|
if (event.kind === "chunk") {
|
|
7896
9644
|
responsePreview.addChunk(event.chunk);
|
|
9645
|
+
prScanner.addChunk(event.chunk);
|
|
7897
9646
|
if (event.chunk.type === "data-anyone-todos-updated") {
|
|
7898
9647
|
const next = todosFromPayload(event.chunk.data);
|
|
7899
9648
|
if (next) setTodos(next.length > 0 ? next : null);
|
|
@@ -7938,6 +9687,8 @@ function ChatScreen({ agent, conversation }) {
|
|
|
7938
9687
|
agentHost
|
|
7939
9688
|
]);
|
|
7940
9689
|
const multiAgent = useMemo(() => conversationAgentNames(items, agent.name), [items, agent.name]).size > 1;
|
|
9690
|
+
const transcriptWindow = useMemo(() => windowItems(items), [items]);
|
|
9691
|
+
const themeGeneration = themeVersion();
|
|
7941
9692
|
useEffect(() => {
|
|
7942
9693
|
if (!rest || !conversationId) return;
|
|
7943
9694
|
const abort = new AbortController();
|
|
@@ -7948,18 +9699,25 @@ function ChatScreen({ agent, conversation }) {
|
|
|
7948
9699
|
if (event.kind === "run") {
|
|
7949
9700
|
if (seenRunsRef.current.has(event.runId)) return;
|
|
7950
9701
|
if (runRef.current.kind !== "idle") return;
|
|
7951
|
-
attachToRun(event.runId);
|
|
9702
|
+
attachToRun(event.runId, null, parseStampMs(event.createdAt));
|
|
9703
|
+
return;
|
|
9704
|
+
}
|
|
9705
|
+
if (event.kind === "compaction") {
|
|
9706
|
+
onCompactionEvent(event.status);
|
|
7952
9707
|
return;
|
|
7953
9708
|
}
|
|
7954
|
-
if (event.title) setChatTitle(event.title);
|
|
9709
|
+
if (event.kind === "conversation" && event.title) setChatTitle(event.title);
|
|
7955
9710
|
}
|
|
7956
9711
|
}).catch(() => {});
|
|
7957
|
-
return () =>
|
|
9712
|
+
return () => {
|
|
9713
|
+
abort.abort();
|
|
9714
|
+
};
|
|
7958
9715
|
}, [
|
|
7959
9716
|
rest,
|
|
7960
9717
|
conversationId,
|
|
7961
9718
|
setChatTitle,
|
|
7962
|
-
attachToRun
|
|
9719
|
+
attachToRun,
|
|
9720
|
+
onCompactionEvent
|
|
7963
9721
|
]);
|
|
7964
9722
|
const sendContent = useCallback(async (content, staged, opts) => {
|
|
7965
9723
|
if (!rest) return;
|
|
@@ -7975,7 +9733,10 @@ function ChatScreen({ agent, conversation }) {
|
|
|
7975
9733
|
...stagedNames.length > 0 ? { attachments: stagedNames } : {}
|
|
7976
9734
|
}]);
|
|
7977
9735
|
const wasStreaming = runRef.current.kind === "streaming";
|
|
7978
|
-
if (!wasStreaming) setRun({
|
|
9736
|
+
if (!wasStreaming) setRun({
|
|
9737
|
+
kind: "sending",
|
|
9738
|
+
startedAtMs: Date.now()
|
|
9739
|
+
});
|
|
7979
9740
|
try {
|
|
7980
9741
|
const attachmentIds = [];
|
|
7981
9742
|
const restorable = [];
|
|
@@ -8029,7 +9790,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8029
9790
|
setItems((prev) => [...prev.filter((m) => m.id !== optimisticId), {
|
|
8030
9791
|
kind: "error",
|
|
8031
9792
|
id: crypto.randomUUID(),
|
|
8032
|
-
text:
|
|
9793
|
+
text: sendErrorMessage(err)
|
|
8033
9794
|
}]);
|
|
8034
9795
|
if (echo) setInput((existing) => existing ? existing : trimmed);
|
|
8035
9796
|
const restore = staged.filter((a) => uploadsRef.current.has(a.tempId));
|
|
@@ -8075,6 +9836,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8075
9836
|
portalStatus,
|
|
8076
9837
|
portalClient,
|
|
8077
9838
|
agent.id,
|
|
9839
|
+
agent.name,
|
|
8078
9840
|
sendContent
|
|
8079
9841
|
]);
|
|
8080
9842
|
const cancelLatestSteer = useCallback(() => {
|
|
@@ -8161,7 +9923,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8161
9923
|
detail: "already connected"
|
|
8162
9924
|
});
|
|
8163
9925
|
} else if (action.kind === "open_github_app") {
|
|
8164
|
-
const url = resolveConnectUrl(action.url, appUrl);
|
|
9926
|
+
const url = resolveConnectUrl(action.url, resolveWebUrl(appUrl));
|
|
8165
9927
|
await openUrlInBrowser(url);
|
|
8166
9928
|
updateCard(item.id, {
|
|
8167
9929
|
status: "launched",
|
|
@@ -8190,10 +9952,15 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8190
9952
|
portalClient,
|
|
8191
9953
|
portal.status
|
|
8192
9954
|
]);
|
|
8193
|
-
const
|
|
8194
|
-
const
|
|
8195
|
-
|
|
8196
|
-
|
|
9955
|
+
const promptForCards = useCallback(() => {
|
|
9956
|
+
const targets = itemsRef.current.filter(isActionableCard);
|
|
9957
|
+
const [only] = targets;
|
|
9958
|
+
if (!only) return false;
|
|
9959
|
+
if (targets.length === 1) {
|
|
9960
|
+
runCardAction(only);
|
|
9961
|
+
return true;
|
|
9962
|
+
}
|
|
9963
|
+
setCardPickerOpen(true);
|
|
8197
9964
|
return true;
|
|
8198
9965
|
}, [runCardAction]);
|
|
8199
9966
|
const handleCredInput = useCallback((displayed) => {
|
|
@@ -8277,10 +10044,10 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8277
10044
|
}]);
|
|
8278
10045
|
let captured = "";
|
|
8279
10046
|
const appendOutput = (chunk) => {
|
|
8280
|
-
captured
|
|
10047
|
+
captured = clampedAppend(captured, chunk);
|
|
8281
10048
|
setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
|
|
8282
10049
|
...m,
|
|
8283
|
-
output: m.output
|
|
10050
|
+
output: clampedAppend(m.output, chunk)
|
|
8284
10051
|
} : m));
|
|
8285
10052
|
};
|
|
8286
10053
|
let exitCode;
|
|
@@ -8294,6 +10061,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8294
10061
|
...m,
|
|
8295
10062
|
exit: exitCode
|
|
8296
10063
|
} : m));
|
|
10064
|
+
bindPortalCwd();
|
|
8297
10065
|
} catch (err) {
|
|
8298
10066
|
appendOutput(`\n${errorMessage(err)}`);
|
|
8299
10067
|
exitCode = 1;
|
|
@@ -8303,7 +10071,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8303
10071
|
} : m));
|
|
8304
10072
|
}
|
|
8305
10073
|
sendContent(formatShellContext(command, captured, exitCode), [], { echo: false });
|
|
8306
|
-
}, [sendContent]);
|
|
10074
|
+
}, [sendContent, bindPortalCwd]);
|
|
8307
10075
|
const runSandboxExec = useCallback((command) => {
|
|
8308
10076
|
if (!appUrl || !sessionToken) return;
|
|
8309
10077
|
const id = crypto.randomUUID();
|
|
@@ -8317,7 +10085,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8317
10085
|
const dec = new TextDecoder();
|
|
8318
10086
|
const append = (chunk) => setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
|
|
8319
10087
|
...m,
|
|
8320
|
-
output: m.output
|
|
10088
|
+
output: clampedAppend(m.output, chunk)
|
|
8321
10089
|
} : m));
|
|
8322
10090
|
const stamp = (exit) => setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
|
|
8323
10091
|
...m,
|
|
@@ -8438,7 +10206,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8438
10206
|
stageAttachment(image);
|
|
8439
10207
|
}, [stageAttachment]);
|
|
8440
10208
|
usePaste((event) => {
|
|
8441
|
-
if (modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt) return;
|
|
10209
|
+
if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt || nonWebChannel) return;
|
|
8442
10210
|
const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
|
|
8443
10211
|
const route = routePaste({
|
|
8444
10212
|
kind: event.metadata?.kind,
|
|
@@ -8487,6 +10255,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8487
10255
|
if (composer.plainText === "" && inputRef.current !== "") applyComposerText(inputRef.current, "end");
|
|
8488
10256
|
}, [
|
|
8489
10257
|
grantPrompt,
|
|
10258
|
+
cardPickerOpen,
|
|
8490
10259
|
modelPickerOpen,
|
|
8491
10260
|
themePickerOpen,
|
|
8492
10261
|
helpOpen,
|
|
@@ -8508,6 +10277,17 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8508
10277
|
} : m));
|
|
8509
10278
|
useStore.getState().showToast({ message: "run cancelled" });
|
|
8510
10279
|
}, [rest]);
|
|
10280
|
+
const copyLastMessage = useCallback(() => {
|
|
10281
|
+
const last = [...items].reverse().find((m) => m.kind === "assistant-text" && m.done && m.text.trim() !== "");
|
|
10282
|
+
if (!last) {
|
|
10283
|
+
useStore.getState().showToast({ message: "no agent message to copy yet" });
|
|
10284
|
+
return;
|
|
10285
|
+
}
|
|
10286
|
+
writeClipboardText(last.text).then((ok) => useStore.getState().showToast(ok ? {
|
|
10287
|
+
variant: "success",
|
|
10288
|
+
message: "copied last message to clipboard"
|
|
10289
|
+
} : { message: "clipboard unavailable" }));
|
|
10290
|
+
}, [items]);
|
|
8511
10291
|
const openInBrowser = useCallback(() => {
|
|
8512
10292
|
if (!appUrl) return;
|
|
8513
10293
|
if (!conversationId) {
|
|
@@ -8524,11 +10304,141 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8524
10304
|
text: `couldn't open a browser — visit ${url} (${errorMessage(err)})`
|
|
8525
10305
|
}]));
|
|
8526
10306
|
}, [appUrl, conversationId]);
|
|
10307
|
+
const forkAndContinue = useCallback(() => {
|
|
10308
|
+
if (!rest || !conversationId || forkingRef.current) return;
|
|
10309
|
+
forkingRef.current = true;
|
|
10310
|
+
setForking(true);
|
|
10311
|
+
useStore.getState().showToast({ message: "forking into a new chat…" });
|
|
10312
|
+
const screenAtFork = useStore.getState().screen;
|
|
10313
|
+
const stillHere = () => useStore.getState().screen === screenAtFork;
|
|
10314
|
+
rest.forkConversation({ conversationId }).then((forked) => {
|
|
10315
|
+
if (!stillHere()) return;
|
|
10316
|
+
goTo({
|
|
10317
|
+
kind: "chat",
|
|
10318
|
+
agent,
|
|
10319
|
+
conversation: forkedConversationRef({
|
|
10320
|
+
agent,
|
|
10321
|
+
forked
|
|
10322
|
+
})
|
|
10323
|
+
});
|
|
10324
|
+
useStore.getState().showToast({
|
|
10325
|
+
variant: "success",
|
|
10326
|
+
message: "Forked conversation"
|
|
10327
|
+
});
|
|
10328
|
+
}).catch((err) => {
|
|
10329
|
+
forkingRef.current = false;
|
|
10330
|
+
setForking(false);
|
|
10331
|
+
if (!stillHere()) return;
|
|
10332
|
+
useStore.getState().showToast({
|
|
10333
|
+
variant: "error",
|
|
10334
|
+
message: `couldn't fork this conversation: ${errorMessage(err)}`
|
|
10335
|
+
});
|
|
10336
|
+
});
|
|
10337
|
+
}, [
|
|
10338
|
+
rest,
|
|
10339
|
+
conversationId,
|
|
10340
|
+
agent,
|
|
10341
|
+
goTo
|
|
10342
|
+
]);
|
|
10343
|
+
const archiveConversation = useCallback(() => {
|
|
10344
|
+
if (grantPrompt || credPrompt) return;
|
|
10345
|
+
if (runRef.current.kind !== "idle") {
|
|
10346
|
+
useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
|
|
10347
|
+
return;
|
|
10348
|
+
}
|
|
10349
|
+
if (!conversationId) {
|
|
10350
|
+
useStore.getState().showToast({ message: "nothing to archive yet — send a message to start this one" });
|
|
10351
|
+
return;
|
|
10352
|
+
}
|
|
10353
|
+
if (!rest) return;
|
|
10354
|
+
rest.setConversationArchived({
|
|
10355
|
+
conversationId,
|
|
10356
|
+
archived: true
|
|
10357
|
+
}).then(() => {
|
|
10358
|
+
useStore.getState().showToast({
|
|
10359
|
+
variant: "success",
|
|
10360
|
+
message: "archived — shift+tab in the picker shows it, ctrl+a restores"
|
|
10361
|
+
});
|
|
10362
|
+
goTo({
|
|
10363
|
+
kind: "conversation-picker",
|
|
10364
|
+
agent
|
|
10365
|
+
});
|
|
10366
|
+
}).catch((err) => useStore.getState().showToast({
|
|
10367
|
+
variant: "error",
|
|
10368
|
+
message: err instanceof HttpError && err.status === 404 ? "can't archive this one — it's already gone from your list" : `couldn't archive: ${errorMessage(err)}`
|
|
10369
|
+
}));
|
|
10370
|
+
}, [
|
|
10371
|
+
grantPrompt,
|
|
10372
|
+
credPrompt,
|
|
10373
|
+
conversationId,
|
|
10374
|
+
rest,
|
|
10375
|
+
goTo,
|
|
10376
|
+
agent
|
|
10377
|
+
]);
|
|
10378
|
+
/**
|
|
10379
|
+
* Leave this conversation for another screen without ending its run: drop
|
|
10380
|
+
* our own SSE subscription and nothing else — never `rest.cancelRun` — so
|
|
10381
|
+
* the agent keeps working server-side and reopening the thread reattaches.
|
|
10382
|
+
* Same contract as `esc`, which is why `/conversation` is allowed mid-run
|
|
10383
|
+
* where `/new` is not: what you land on still lists the thread you left,
|
|
10384
|
+
* so the run is never stranded out of reach.
|
|
10385
|
+
*/
|
|
10386
|
+
const leaveForScreen = useCallback((next) => {
|
|
10387
|
+
const current = runRef.current;
|
|
10388
|
+
if (current.kind === "streaming") current.abort.abort();
|
|
10389
|
+
if (current.kind !== "idle") useStore.getState().showToast({ message: `${agent.name} keeps working — reopen to reattach` });
|
|
10390
|
+
goTo(next);
|
|
10391
|
+
}, [agent.name, goTo]);
|
|
10392
|
+
/**
|
|
10393
|
+
* `/conversation <id or link>`: open that conversation without leaving the
|
|
10394
|
+
* TUI — the point being that the id usually arrives *from* an agent, in the
|
|
10395
|
+
* session you started to go find it.
|
|
10396
|
+
*
|
|
10397
|
+
* Resolution goes through `resolveInitialScreen`, the same path
|
|
10398
|
+
* `--resume <id>` takes, so an id belonging to a different agent opens
|
|
10399
|
+
* under that agent and the not-found / no-access wording lives in one
|
|
10400
|
+
* place.
|
|
10401
|
+
*/
|
|
10402
|
+
const openConversationRef = useCallback(async (ref) => {
|
|
10403
|
+
const target = parseConversationRef(ref);
|
|
10404
|
+
if (!target) {
|
|
10405
|
+
setItems((prev) => [...prev, {
|
|
10406
|
+
kind: "error",
|
|
10407
|
+
id: crypto.randomUUID(),
|
|
10408
|
+
text: `not a conversation id: ${ref} — pass an id, its /c/<id> link, or run /conversation on its own to pick from the list`
|
|
10409
|
+
}]);
|
|
10410
|
+
return;
|
|
10411
|
+
}
|
|
10412
|
+
if (!rest) return;
|
|
10413
|
+
if (target === conversationId) {
|
|
10414
|
+
useStore.getState().showToast({ message: "already in this conversation" });
|
|
10415
|
+
return;
|
|
10416
|
+
}
|
|
10417
|
+
try {
|
|
10418
|
+
leaveForScreen(await resolveInitialScreen({
|
|
10419
|
+
rest,
|
|
10420
|
+
agentSelector: null,
|
|
10421
|
+
conversationId: target,
|
|
10422
|
+
newConversation: false
|
|
10423
|
+
}));
|
|
10424
|
+
} catch (err) {
|
|
10425
|
+
setItems((prev) => [...prev, {
|
|
10426
|
+
kind: "error",
|
|
10427
|
+
id: crypto.randomUUID(),
|
|
10428
|
+
text: `couldn't open ${target}: ${errorMessage(err)}`
|
|
10429
|
+
}]);
|
|
10430
|
+
}
|
|
10431
|
+
}, [
|
|
10432
|
+
rest,
|
|
10433
|
+
conversationId,
|
|
10434
|
+
leaveForScreen
|
|
10435
|
+
]);
|
|
8527
10436
|
const toggleShare = useCallback(() => {
|
|
8528
10437
|
if (!portalClient) return;
|
|
8529
10438
|
if (portal.status === "off") {
|
|
8530
10439
|
declinedRef.current.delete(agent.id);
|
|
8531
10440
|
portalClient.enable();
|
|
10441
|
+
if (conversationId) portalClient.bind(conversationId, shellSessionRef.current?.cwd ?? process.cwd());
|
|
8532
10442
|
} else {
|
|
8533
10443
|
portalClient.disable();
|
|
8534
10444
|
setPortalPrompt(null);
|
|
@@ -8537,7 +10447,8 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8537
10447
|
portalClient,
|
|
8538
10448
|
portal.status,
|
|
8539
10449
|
agent.id,
|
|
8540
|
-
setPortalPrompt
|
|
10450
|
+
setPortalPrompt,
|
|
10451
|
+
conversationId
|
|
8541
10452
|
]);
|
|
8542
10453
|
const confirmGrant = useCallback(() => {
|
|
8543
10454
|
if (!portalClient) return;
|
|
@@ -8562,8 +10473,54 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8562
10473
|
setPortalPrompt(null);
|
|
8563
10474
|
useStore.getState().showToast({ message: "kept this machine private" });
|
|
8564
10475
|
}, [agent.id, setPortalPrompt]);
|
|
10476
|
+
const openPortalAccess = useCallback(() => {
|
|
10477
|
+
if (!portalClient) {
|
|
10478
|
+
useStore.getState().showToast({ message: "local access is unavailable in this session" });
|
|
10479
|
+
return;
|
|
10480
|
+
}
|
|
10481
|
+
switch (portal.status) {
|
|
10482
|
+
case "off":
|
|
10483
|
+
declinedRef.current.delete(agent.id);
|
|
10484
|
+
portalClient.enable();
|
|
10485
|
+
useStore.getState().showToast({ message: "enabling local access on this machine…" });
|
|
10486
|
+
break;
|
|
10487
|
+
case "connecting":
|
|
10488
|
+
useStore.getState().showToast({ message: "local access is still connecting…" });
|
|
10489
|
+
break;
|
|
10490
|
+
case "connected":
|
|
10491
|
+
if (portal.grantedAgentIds.includes(agent.id)) {
|
|
10492
|
+
useStore.getState().showToast({ message: `${agent.name} can already run commands on this machine` });
|
|
10493
|
+
break;
|
|
10494
|
+
}
|
|
10495
|
+
declinedRef.current.delete(agent.id);
|
|
10496
|
+
setPortalPrompt({
|
|
10497
|
+
agentId: agent.id,
|
|
10498
|
+
agentName: agent.name
|
|
10499
|
+
});
|
|
10500
|
+
break;
|
|
10501
|
+
case "error":
|
|
10502
|
+
useStore.getState().showToast({ message: portal.error ? `local access error: ${portal.error}` : "local access hit an error" });
|
|
10503
|
+
break;
|
|
10504
|
+
default: {
|
|
10505
|
+
const exhaustive = portal.status;
|
|
10506
|
+
throw new Error(`unhandled portal status: ${String(exhaustive)}`);
|
|
10507
|
+
}
|
|
10508
|
+
}
|
|
10509
|
+
}, [
|
|
10510
|
+
portalClient,
|
|
10511
|
+
portal.status,
|
|
10512
|
+
portal.grantedAgentIds,
|
|
10513
|
+
portal.error,
|
|
10514
|
+
agent.id,
|
|
10515
|
+
agent.name,
|
|
10516
|
+
setPortalPrompt
|
|
10517
|
+
]);
|
|
8565
10518
|
const executeLine = useCallback((content, staged) => {
|
|
8566
10519
|
if (!content && staged.length === 0) return;
|
|
10520
|
+
if (forkingRef.current) {
|
|
10521
|
+
useStore.getState().showToast({ message: "forking… hang on" });
|
|
10522
|
+
return;
|
|
10523
|
+
}
|
|
8567
10524
|
const routed = routeInput(content);
|
|
8568
10525
|
if (routed.kind !== "message") {
|
|
8569
10526
|
history.append(content);
|
|
@@ -8580,6 +10537,9 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8580
10537
|
case "sandbox-pty":
|
|
8581
10538
|
runSandboxPty();
|
|
8582
10539
|
break;
|
|
10540
|
+
case "archive-conversation":
|
|
10541
|
+
archiveConversation();
|
|
10542
|
+
break;
|
|
8583
10543
|
case "new-conversation":
|
|
8584
10544
|
if (runRef.current.kind !== "idle") {
|
|
8585
10545
|
useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
|
|
@@ -8595,6 +10555,26 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8595
10555
|
}
|
|
8596
10556
|
});
|
|
8597
10557
|
break;
|
|
10558
|
+
case "fork":
|
|
10559
|
+
if (runRef.current.kind !== "idle") {
|
|
10560
|
+
useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
|
|
10561
|
+
break;
|
|
10562
|
+
}
|
|
10563
|
+
if (!conversationId) {
|
|
10564
|
+
useStore.getState().showToast({ message: "nothing to fork yet — send a message first" });
|
|
10565
|
+
break;
|
|
10566
|
+
}
|
|
10567
|
+
forkAndContinue();
|
|
10568
|
+
break;
|
|
10569
|
+
case "conversation-picker":
|
|
10570
|
+
leaveForScreen({
|
|
10571
|
+
kind: "conversation-picker",
|
|
10572
|
+
agent
|
|
10573
|
+
});
|
|
10574
|
+
break;
|
|
10575
|
+
case "open-conversation":
|
|
10576
|
+
openConversationRef(routed.ref);
|
|
10577
|
+
break;
|
|
8598
10578
|
case "model-picker":
|
|
8599
10579
|
setModelPickerOpen(true);
|
|
8600
10580
|
break;
|
|
@@ -8619,14 +10599,33 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8619
10599
|
})
|
|
8620
10600
|
}]);
|
|
8621
10601
|
break;
|
|
10602
|
+
case "plan": {
|
|
10603
|
+
if (!(!!todos && todos.length > 0)) {
|
|
10604
|
+
useStore.getState().showToast({ message: "no plan to show yet" });
|
|
10605
|
+
break;
|
|
10606
|
+
}
|
|
10607
|
+
const nextHidden = routed.mode === "toggle" ? !planHidden : routed.mode === "hide";
|
|
10608
|
+
setPlanHidden(nextHidden);
|
|
10609
|
+
useStore.getState().showToast({ message: nextHidden ? "plan hidden (/plan show)" : "plan shown" });
|
|
10610
|
+
break;
|
|
10611
|
+
}
|
|
8622
10612
|
case "share":
|
|
8623
10613
|
toggleShare();
|
|
8624
10614
|
break;
|
|
8625
10615
|
case "browser":
|
|
8626
10616
|
openInBrowser();
|
|
8627
10617
|
break;
|
|
10618
|
+
case "copy":
|
|
10619
|
+
copyLastMessage();
|
|
10620
|
+
break;
|
|
10621
|
+
case "portal":
|
|
10622
|
+
openPortalAccess();
|
|
10623
|
+
break;
|
|
10624
|
+
case "compact":
|
|
10625
|
+
compactConversation();
|
|
10626
|
+
break;
|
|
8628
10627
|
case "connect":
|
|
8629
|
-
if (!
|
|
10628
|
+
if (!promptForCards()) useStore.getState().showToast({ message: "no connect card waiting for action" });
|
|
8630
10629
|
break;
|
|
8631
10630
|
case "help":
|
|
8632
10631
|
setHelpOpen(true);
|
|
@@ -8653,8 +10652,15 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8653
10652
|
runLocalShell,
|
|
8654
10653
|
runSandboxExec,
|
|
8655
10654
|
runSandboxPty,
|
|
10655
|
+
archiveConversation,
|
|
10656
|
+
forkAndContinue,
|
|
10657
|
+
leaveForScreen,
|
|
10658
|
+
openConversationRef,
|
|
8656
10659
|
toggleShare,
|
|
8657
10660
|
openInBrowser,
|
|
10661
|
+
copyLastMessage,
|
|
10662
|
+
openPortalAccess,
|
|
10663
|
+
compactConversation,
|
|
8658
10664
|
quit,
|
|
8659
10665
|
sendContent,
|
|
8660
10666
|
goTo,
|
|
@@ -8665,7 +10671,9 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8665
10671
|
model,
|
|
8666
10672
|
portal.status,
|
|
8667
10673
|
portal.grantedAgentIds,
|
|
8668
|
-
|
|
10674
|
+
promptForCards,
|
|
10675
|
+
todos,
|
|
10676
|
+
planHidden
|
|
8669
10677
|
]);
|
|
8670
10678
|
const submit = useCallback(() => {
|
|
8671
10679
|
const staged = attachments.filter((a) => a.status !== "error");
|
|
@@ -8720,7 +10728,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8720
10728
|
}
|
|
8721
10729
|
if (reviewFocused) return;
|
|
8722
10730
|
}
|
|
8723
|
-
if (modelPickerOpen || themePickerOpen || helpOpen) return;
|
|
10731
|
+
if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen) return;
|
|
8724
10732
|
if (grantPrompt) {
|
|
8725
10733
|
if (key.name === "y") {
|
|
8726
10734
|
confirmGrant();
|
|
@@ -8812,18 +10820,24 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8812
10820
|
return;
|
|
8813
10821
|
}
|
|
8814
10822
|
if (key.name === "r" && key.ctrl) {
|
|
8815
|
-
|
|
10823
|
+
promptForCards();
|
|
8816
10824
|
return;
|
|
8817
10825
|
}
|
|
8818
10826
|
if (key.name === "t" && key.ctrl) {
|
|
8819
10827
|
toggleShare();
|
|
8820
10828
|
return;
|
|
8821
10829
|
}
|
|
10830
|
+
if (nonWebChannel && key.name === "f" && key.ctrl) {
|
|
10831
|
+
forkAndContinue();
|
|
10832
|
+
return;
|
|
10833
|
+
}
|
|
8822
10834
|
if (key.name === "v" && key.ctrl) {
|
|
10835
|
+
if (nonWebChannel) return;
|
|
8823
10836
|
pasteImage();
|
|
8824
10837
|
return;
|
|
8825
10838
|
}
|
|
8826
10839
|
if (key.name === "x" && key.ctrl || key.name === "backspace" && attachmentsRef.current.length > 0 && !composerRef.current?.plainText) {
|
|
10840
|
+
if (nonWebChannel) return;
|
|
8827
10841
|
const last = attachmentsRef.current.at(-1);
|
|
8828
10842
|
if (last) {
|
|
8829
10843
|
uploadsRef.current.delete(last.tempId);
|
|
@@ -8836,6 +10850,11 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8836
10850
|
setThemePickerOpen(true);
|
|
8837
10851
|
return;
|
|
8838
10852
|
}
|
|
10853
|
+
if (key.name === "a" && key.ctrl && input.trim() === "") {
|
|
10854
|
+
key.preventDefault();
|
|
10855
|
+
archiveConversation();
|
|
10856
|
+
return;
|
|
10857
|
+
}
|
|
8839
10858
|
if (key.name === "?" && !key.ctrl && !key.meta && input.trim() === "") {
|
|
8840
10859
|
composerRef.current?.clear();
|
|
8841
10860
|
setInput("");
|
|
@@ -8851,6 +10870,10 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8851
10870
|
scrollRef.current?.scrollBy(pageScrollFraction, "viewport");
|
|
8852
10871
|
return;
|
|
8853
10872
|
}
|
|
10873
|
+
if (key.name === "end" && scrolledUp) {
|
|
10874
|
+
jumpToBottom();
|
|
10875
|
+
return;
|
|
10876
|
+
}
|
|
8854
10877
|
if (key.name === "c" && key.ctrl) {
|
|
8855
10878
|
if (runRef.current.kind === "streaming") {
|
|
8856
10879
|
cancelRun();
|
|
@@ -8882,21 +10905,31 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8882
10905
|
}
|
|
8883
10906
|
});
|
|
8884
10907
|
const hasPendingSteer = items.some((m) => m.kind === "pending-steer");
|
|
8885
|
-
const
|
|
10908
|
+
const actionableCards = useMemo(() => items.filter(isActionableCard), [items]);
|
|
8886
10909
|
const statusText = useMemo(() => formatChatFooterStatus({
|
|
8887
10910
|
runKind: run.kind,
|
|
8888
10911
|
ctrlCArmed,
|
|
8889
10912
|
isShared,
|
|
8890
10913
|
hasPendingSteer,
|
|
8891
|
-
|
|
10914
|
+
actionableCardCount: actionableCards.length,
|
|
10915
|
+
nonWebChannel: nonWebChannel !== null
|
|
8892
10916
|
}), [
|
|
8893
10917
|
run.kind,
|
|
8894
10918
|
ctrlCArmed,
|
|
8895
10919
|
isShared,
|
|
8896
10920
|
hasPendingSteer,
|
|
8897
|
-
|
|
10921
|
+
actionableCards.length,
|
|
10922
|
+
nonWebChannel
|
|
8898
10923
|
]);
|
|
8899
10924
|
const modelHint = formatModelHint(model);
|
|
10925
|
+
if (cardPickerOpen) return /* @__PURE__ */ jsx(CardPicker, {
|
|
10926
|
+
cards: actionableCards,
|
|
10927
|
+
onSelect: (card) => {
|
|
10928
|
+
setCardPickerOpen(false);
|
|
10929
|
+
runCardAction(card);
|
|
10930
|
+
},
|
|
10931
|
+
onCancel: () => setCardPickerOpen(false)
|
|
10932
|
+
});
|
|
8900
10933
|
if (modelPickerOpen) return /* @__PURE__ */ jsx(ModelPicker, {
|
|
8901
10934
|
agentId: agent.id,
|
|
8902
10935
|
currentModel: model,
|
|
@@ -8935,6 +10968,14 @@ function ChatScreen({ agent, conversation }) {
|
|
|
8935
10968
|
...prev,
|
|
8936
10969
|
side: size
|
|
8937
10970
|
}),
|
|
10971
|
+
onToggleOrientation: () => {
|
|
10972
|
+
if (!canToggleOrientation({
|
|
10973
|
+
width,
|
|
10974
|
+
height
|
|
10975
|
+
})) return "unavailable";
|
|
10976
|
+
setReviewOrientation(reviewPlacement === "below" ? "side" : "below");
|
|
10977
|
+
return "toggled";
|
|
10978
|
+
},
|
|
8938
10979
|
onInsertToComposer: (review) => {
|
|
8939
10980
|
const next = [inputRef.current.trimEnd(), review].filter((part) => part.length > 0).join("\n\n");
|
|
8940
10981
|
setInput(next);
|
|
@@ -9019,28 +11060,69 @@ function ChatScreen({ agent, conversation }) {
|
|
|
9019
11060
|
agent.name,
|
|
9020
11061
|
" below."
|
|
9021
11062
|
]
|
|
9022
|
-
}) : /* @__PURE__ */
|
|
9023
|
-
|
|
9024
|
-
|
|
9025
|
-
|
|
9026
|
-
|
|
9027
|
-
|
|
9028
|
-
|
|
9029
|
-
|
|
9030
|
-
|
|
9031
|
-
|
|
9032
|
-
|
|
9033
|
-
|
|
9034
|
-
|
|
9035
|
-
|
|
9036
|
-
}
|
|
9037
|
-
})
|
|
11063
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [transcriptWindow.hiddenCount > 0 ? /* @__PURE__ */ jsx("box", {
|
|
11064
|
+
style: {
|
|
11065
|
+
paddingLeft: 1,
|
|
11066
|
+
paddingRight: 1
|
|
11067
|
+
},
|
|
11068
|
+
children: /* @__PURE__ */ jsxs("text", {
|
|
11069
|
+
fg: theme.dim,
|
|
11070
|
+
children: [
|
|
11071
|
+
"↑ ",
|
|
11072
|
+
transcriptWindow.hiddenCount,
|
|
11073
|
+
" earlier message",
|
|
11074
|
+
transcriptWindow.hiddenCount === 1 ? "" : "s",
|
|
11075
|
+
" · kept in this session, not shown"
|
|
11076
|
+
]
|
|
11077
|
+
})
|
|
11078
|
+
}) : null, transcriptWindow.visible.map(({ item, index }) => /* @__PURE__ */ jsx(TranscriptRow, {
|
|
11079
|
+
item,
|
|
11080
|
+
label: multiAgent ? agentLabelFor(items, index) : null,
|
|
11081
|
+
themeVersion: themeGeneration
|
|
11082
|
+
}, item.id))] }), run.kind !== "idle" ? /* @__PURE__ */ jsx(ActivityRow, {
|
|
11083
|
+
label: run.kind === "sending" ? "sending" : "working",
|
|
11084
|
+
startedAtMs: run.startedAtMs
|
|
11085
|
+
}) : null]
|
|
9038
11086
|
})
|
|
9039
11087
|
}),
|
|
11088
|
+
jumpHintVisible ? /* @__PURE__ */ jsxs("box", {
|
|
11089
|
+
onMouseDown: jumpToBottom,
|
|
11090
|
+
style: {
|
|
11091
|
+
flexDirection: "row",
|
|
11092
|
+
paddingLeft: 1,
|
|
11093
|
+
paddingRight: 1,
|
|
11094
|
+
height: 1,
|
|
11095
|
+
flexShrink: 0
|
|
11096
|
+
},
|
|
11097
|
+
children: [/* @__PURE__ */ jsx("text", {
|
|
11098
|
+
fg: theme.accent,
|
|
11099
|
+
children: "↓ jump to latest"
|
|
11100
|
+
}), /* @__PURE__ */ jsxs("text", {
|
|
11101
|
+
fg: theme.dim,
|
|
11102
|
+
children: [" ", "end"]
|
|
11103
|
+
})]
|
|
11104
|
+
}) : null,
|
|
9040
11105
|
todoCardVisible && todos ? /* @__PURE__ */ jsx(TodoCardView, {
|
|
9041
11106
|
todos,
|
|
9042
11107
|
isActive: run.kind !== "idle"
|
|
9043
11108
|
}) : null,
|
|
11109
|
+
compacting ? /* @__PURE__ */ jsxs("box", {
|
|
11110
|
+
style: {
|
|
11111
|
+
flexDirection: "row",
|
|
11112
|
+
paddingLeft: 1,
|
|
11113
|
+
paddingRight: 1,
|
|
11114
|
+
height: 1,
|
|
11115
|
+
flexShrink: 0,
|
|
11116
|
+
marginTop: 1
|
|
11117
|
+
},
|
|
11118
|
+
children: [/* @__PURE__ */ jsx("text", {
|
|
11119
|
+
fg: theme.accent,
|
|
11120
|
+
children: "● compacting conversation…"
|
|
11121
|
+
}), /* @__PURE__ */ jsxs("text", {
|
|
11122
|
+
fg: theme.dim,
|
|
11123
|
+
children: [" ", "you can keep typing"]
|
|
11124
|
+
})]
|
|
11125
|
+
}) : null,
|
|
9044
11126
|
pendingVisible ? /* @__PURE__ */ jsxs("box", {
|
|
9045
11127
|
style: {
|
|
9046
11128
|
paddingLeft: 1,
|
|
@@ -9112,6 +11194,35 @@ function ChatScreen({ agent, conversation }) {
|
|
|
9112
11194
|
children: "n"
|
|
9113
11195
|
})
|
|
9114
11196
|
]
|
|
11197
|
+
}) : nonWebChannel ? /* @__PURE__ */ jsxs("box", {
|
|
11198
|
+
style: {
|
|
11199
|
+
border: true,
|
|
11200
|
+
borderColor: theme.faint,
|
|
11201
|
+
paddingLeft: 1,
|
|
11202
|
+
paddingRight: 1,
|
|
11203
|
+
height: nonWebNoticeHeight,
|
|
11204
|
+
flexShrink: 0,
|
|
11205
|
+
marginTop: 1,
|
|
11206
|
+
flexDirection: "column"
|
|
11207
|
+
},
|
|
11208
|
+
children: [
|
|
11209
|
+
/* @__PURE__ */ jsxs("text", {
|
|
11210
|
+
fg: theme.muted,
|
|
11211
|
+
children: [
|
|
11212
|
+
"this conversation is on ",
|
|
11213
|
+
nonWebChannel,
|
|
11214
|
+
"."
|
|
11215
|
+
]
|
|
11216
|
+
}),
|
|
11217
|
+
/* @__PURE__ */ jsx("text", {
|
|
11218
|
+
fg: theme.dim,
|
|
11219
|
+
children: "reply there to continue it."
|
|
11220
|
+
}),
|
|
11221
|
+
/* @__PURE__ */ jsx("text", {
|
|
11222
|
+
fg: theme.dim,
|
|
11223
|
+
children: forking ? "forking into a new chat…" : "ctrl+f fork it into a chat you can continue here."
|
|
11224
|
+
})
|
|
11225
|
+
]
|
|
9115
11226
|
}) : /* @__PURE__ */ jsx("box", {
|
|
9116
11227
|
style: {
|
|
9117
11228
|
border: true,
|
|
@@ -9124,7 +11235,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
9124
11235
|
},
|
|
9125
11236
|
children: /* @__PURE__ */ jsx("textarea", {
|
|
9126
11237
|
ref: composerRef,
|
|
9127
|
-
placeholder: `message ${agent.name}…`,
|
|
11238
|
+
placeholder: forking ? "forking into a new chat…" : `message ${agent.name}…`,
|
|
9128
11239
|
keyBindings: composerKeyBindings,
|
|
9129
11240
|
onContentChange: handleComposerChange,
|
|
9130
11241
|
onSubmit: submit,
|
|
@@ -9153,6 +11264,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
9153
11264
|
fg: theme.muted,
|
|
9154
11265
|
children: statusText
|
|
9155
11266
|
}),
|
|
11267
|
+
prLinks.length > 0 ? /* @__PURE__ */ jsx(PrLinksView, { prs: prLinks }) : null,
|
|
9156
11268
|
/* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }),
|
|
9157
11269
|
/* @__PURE__ */ jsx("text", {
|
|
9158
11270
|
fg: theme.dim,
|
|
@@ -9277,18 +11389,48 @@ function CredentialPromptBox({ prompt, height, onInput, onSubmit }) {
|
|
|
9277
11389
|
});
|
|
9278
11390
|
}
|
|
9279
11391
|
/**
|
|
11392
|
+
* One transcript row: the optional agent-attribution label plus the item's own
|
|
11393
|
+
* rendering. Memoized on the item because the reducer rebuilds the `items`
|
|
11394
|
+
* array on every stream chunk while leaving finished items' objects untouched.
|
|
11395
|
+
* Without the memo, one text delta re-rendered every row in the scrollback, and
|
|
11396
|
+
* each re-render pushes content through that row's native OpenTUI text buffers,
|
|
11397
|
+
* which never hand their memory back — so a long session grew O(turns^2)
|
|
11398
|
+
* resident memory (~1GB after 6k deltas in a soak; ~190MB with the memo).
|
|
11399
|
+
*
|
|
11400
|
+
* `themeVersion` is a prop rather than a read because rows resolve colors from
|
|
11401
|
+
* the mutable `theme` binding during render: a swap mutates that object in
|
|
11402
|
+
* place, so the memo needs the version bump to know its output is stale.
|
|
11403
|
+
*/
|
|
11404
|
+
const TranscriptRow = memo(function TranscriptRow({ item, label }) {
|
|
11405
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
11406
|
+
style: { flexDirection: "column" },
|
|
11407
|
+
children: [label ? /* @__PURE__ */ jsx("box", {
|
|
11408
|
+
style: {
|
|
11409
|
+
paddingLeft: 3,
|
|
11410
|
+
paddingRight: 1
|
|
11411
|
+
},
|
|
11412
|
+
children: /* @__PURE__ */ jsx("text", {
|
|
11413
|
+
fg: theme.muted,
|
|
11414
|
+
children: /* @__PURE__ */ jsx("b", { children: label })
|
|
11415
|
+
})
|
|
11416
|
+
}) : null, /* @__PURE__ */ jsx(RenderItem, { item })]
|
|
11417
|
+
});
|
|
11418
|
+
});
|
|
11419
|
+
/**
|
|
9280
11420
|
* A live indicator at the foot of the transcript while a run is in flight: an
|
|
9281
11421
|
* animated spinner plus a label and a seconds counter, so it's obvious the
|
|
9282
|
-
* agent is still working even during long gaps between chunks.
|
|
9283
|
-
*
|
|
11422
|
+
* agent is still working even during long gaps between chunks. The counter
|
|
11423
|
+
* measures from the run's own start, not from this row appearing, so reopening
|
|
11424
|
+
* a conversation mid-run shows how long the agent has really been at it. Owns
|
|
11425
|
+
* its own clock so ticking it doesn't re-render the transcript.
|
|
9284
11426
|
*/
|
|
9285
|
-
function ActivityRow({ label }) {
|
|
9286
|
-
const [
|
|
11427
|
+
function ActivityRow({ label, startedAtMs }) {
|
|
11428
|
+
const [now, setNow] = useState(() => Date.now());
|
|
9287
11429
|
useEffect(() => {
|
|
9288
|
-
const
|
|
9289
|
-
const id = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1e3)), 250);
|
|
11430
|
+
const id = setInterval(() => setNow(Date.now()), 250);
|
|
9290
11431
|
return () => clearInterval(id);
|
|
9291
11432
|
}, []);
|
|
11433
|
+
const elapsed = Math.floor((now - startedAtMs) / 1e3);
|
|
9292
11434
|
return /* @__PURE__ */ jsxs("box", {
|
|
9293
11435
|
style: {
|
|
9294
11436
|
flexDirection: "row",
|
|
@@ -9357,9 +11499,10 @@ function ToastView() {
|
|
|
9357
11499
|
|
|
9358
11500
|
//#endregion
|
|
9359
11501
|
//#region src/chat/tui/app.tsx
|
|
9360
|
-
function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notifications, agentSelector, conversationId }) {
|
|
11502
|
+
function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notifications, agentSelector, conversationId, newConversation }) {
|
|
9361
11503
|
const renderer = useRenderer();
|
|
9362
11504
|
const screen = useStore((s) => s.screen);
|
|
11505
|
+
const chatTitle = useStore((s) => s.chatTitle);
|
|
9363
11506
|
useStore((s) => s.themeId);
|
|
9364
11507
|
const rest = useStore((s) => s.rest);
|
|
9365
11508
|
const goTo = useStore((s) => s.goTo);
|
|
@@ -9378,6 +11521,13 @@ function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notificati
|
|
|
9378
11521
|
agentHost.transition({ state: "idle" });
|
|
9379
11522
|
return () => agentHost.dispose();
|
|
9380
11523
|
}, [agentHost]);
|
|
11524
|
+
useEffect(() => {
|
|
11525
|
+
agentHost.setTitle(titleLabel(screen, chatTitle));
|
|
11526
|
+
}, [
|
|
11527
|
+
agentHost,
|
|
11528
|
+
screen,
|
|
11529
|
+
chatTitle
|
|
11530
|
+
]);
|
|
9381
11531
|
useEffect(() => {
|
|
9382
11532
|
const selectionCopy = createSelectionCopy({
|
|
9383
11533
|
renderer,
|
|
@@ -9431,7 +11581,7 @@ function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notificati
|
|
|
9431
11581
|
rest,
|
|
9432
11582
|
agentSelector,
|
|
9433
11583
|
conversationId,
|
|
9434
|
-
newConversation: useStore.getState().seedPrompt !== null
|
|
11584
|
+
newConversation: newConversation || useStore.getState().seedPrompt !== null
|
|
9435
11585
|
});
|
|
9436
11586
|
if (cancelled) return;
|
|
9437
11587
|
goTo(screen);
|
|
@@ -9453,6 +11603,7 @@ function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notificati
|
|
|
9453
11603
|
promptHistoryPath,
|
|
9454
11604
|
agentSelector,
|
|
9455
11605
|
conversationId,
|
|
11606
|
+
newConversation,
|
|
9456
11607
|
goTo,
|
|
9457
11608
|
setClients,
|
|
9458
11609
|
fail
|
|
@@ -9748,6 +11899,165 @@ var LinkifiedMarkdownRenderable = class extends MarkdownRenderable {
|
|
|
9748
11899
|
}
|
|
9749
11900
|
};
|
|
9750
11901
|
|
|
11902
|
+
//#endregion
|
|
11903
|
+
//#region src/chat/memory-watchdog.ts
|
|
11904
|
+
const MB = 1024 * 1024;
|
|
11905
|
+
/** How often to sample RSS. */
|
|
11906
|
+
const DEFAULT_SAMPLE_INTERVAL_MS = 15e3;
|
|
11907
|
+
/**
|
|
11908
|
+
* Soft threshold: cross it and we warn the user once. A healthy chat session
|
|
11909
|
+
* sits in the low hundreds of MB, so 2 GB is "something is wrong" without
|
|
11910
|
+
* being so eager it fires on a legitimately large conversation.
|
|
11911
|
+
*/
|
|
11912
|
+
const DEFAULT_WARN_THRESHOLD_BYTES = 2 * 1024 * MB;
|
|
11913
|
+
/**
|
|
11914
|
+
* A clean exit still writes a report if the peak got this high — so we learn
|
|
11915
|
+
* about near-misses that didn't quite OOM, not just the crashes.
|
|
11916
|
+
*/
|
|
11917
|
+
const DEFAULT_REPORT_THRESHOLD_BYTES = 4 * 1024 * MB;
|
|
11918
|
+
/** Keep the last N samples so the report shows a growth curve, not a point. */
|
|
11919
|
+
const MAX_SAMPLES = 240;
|
|
11920
|
+
function envBytes(name, fallback) {
|
|
11921
|
+
const raw = process.env[name];
|
|
11922
|
+
if (!raw) return fallback;
|
|
11923
|
+
const n = Number(raw);
|
|
11924
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
11925
|
+
}
|
|
11926
|
+
function envMs(name, fallback) {
|
|
11927
|
+
const raw = process.env[name];
|
|
11928
|
+
if (!raw) return fallback;
|
|
11929
|
+
const n = Number(raw);
|
|
11930
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
11931
|
+
}
|
|
11932
|
+
/**
|
|
11933
|
+
* Start the memory watchdog. Returns a handle; call `stop()` on teardown.
|
|
11934
|
+
*
|
|
11935
|
+
* Disabled entirely when `SKYDIVE_NO_MEMORY_WATCHDOG` is set — an escape
|
|
11936
|
+
* hatch that also keeps the sampler out of the way in tests and CI.
|
|
11937
|
+
*/
|
|
11938
|
+
function startMemoryWatchdog(opts = {}) {
|
|
11939
|
+
const disabled = Boolean(process.env["SKYDIVE_NO_MEMORY_WATCHDOG"]);
|
|
11940
|
+
const now = opts.now ?? (() => Date.now());
|
|
11941
|
+
const readMemory = opts.readMemory ?? (() => {
|
|
11942
|
+
const u = process.memoryUsage();
|
|
11943
|
+
return {
|
|
11944
|
+
rss: u.rss,
|
|
11945
|
+
heapUsed: u.heapUsed
|
|
11946
|
+
};
|
|
11947
|
+
});
|
|
11948
|
+
const sampleIntervalMs = opts.sampleIntervalMs ?? envMs("SKYDIVE_MEMORY_SAMPLE_MS", DEFAULT_SAMPLE_INTERVAL_MS);
|
|
11949
|
+
const warnThreshold = opts.warnThresholdBytes ?? envBytes("SKYDIVE_MEMORY_WARN_BYTES", DEFAULT_WARN_THRESHOLD_BYTES);
|
|
11950
|
+
const reportThreshold = opts.reportThresholdBytes ?? envBytes("SKYDIVE_MEMORY_REPORT_BYTES", DEFAULT_REPORT_THRESHOLD_BYTES);
|
|
11951
|
+
const startedAt = now();
|
|
11952
|
+
const samples = [];
|
|
11953
|
+
let peakRss = 0;
|
|
11954
|
+
let peakHeapUsed = 0;
|
|
11955
|
+
let warned = false;
|
|
11956
|
+
function sample() {
|
|
11957
|
+
const { rss, heapUsed } = readMemory();
|
|
11958
|
+
if (rss > peakRss) peakRss = rss;
|
|
11959
|
+
if (heapUsed > peakHeapUsed) peakHeapUsed = heapUsed;
|
|
11960
|
+
const s = {
|
|
11961
|
+
at: now() - startedAt,
|
|
11962
|
+
rss,
|
|
11963
|
+
heapUsed
|
|
11964
|
+
};
|
|
11965
|
+
samples.push(s);
|
|
11966
|
+
if (samples.length > MAX_SAMPLES) samples.shift();
|
|
11967
|
+
if (!warned && rss >= warnThreshold) {
|
|
11968
|
+
warned = true;
|
|
11969
|
+
opts.onWarn?.({
|
|
11970
|
+
rss,
|
|
11971
|
+
threshold: warnThreshold
|
|
11972
|
+
});
|
|
11973
|
+
}
|
|
11974
|
+
return s;
|
|
11975
|
+
}
|
|
11976
|
+
function snapshot() {
|
|
11977
|
+
if (disabled) return null;
|
|
11978
|
+
sample();
|
|
11979
|
+
return {
|
|
11980
|
+
peakRss,
|
|
11981
|
+
peakHeapUsed,
|
|
11982
|
+
warnThreshold,
|
|
11983
|
+
uptimeMs: now() - startedAt,
|
|
11984
|
+
samples: samples.slice()
|
|
11985
|
+
};
|
|
11986
|
+
}
|
|
11987
|
+
if (disabled) return {
|
|
11988
|
+
peakRss: () => 0,
|
|
11989
|
+
samples: () => [],
|
|
11990
|
+
snapshot: () => null,
|
|
11991
|
+
stop: () => {}
|
|
11992
|
+
};
|
|
11993
|
+
sample();
|
|
11994
|
+
const timer = setInterval(sample, sampleIntervalMs);
|
|
11995
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
11996
|
+
const onExit = () => {
|
|
11997
|
+
if (peakRss < reportThreshold) return;
|
|
11998
|
+
writeCrashReport(buildCrashReport({
|
|
11999
|
+
reason: "high-memory-exit",
|
|
12000
|
+
memory: {
|
|
12001
|
+
peakRss,
|
|
12002
|
+
peakHeapUsed,
|
|
12003
|
+
warnThreshold,
|
|
12004
|
+
uptimeMs: now() - startedAt,
|
|
12005
|
+
samples: samples.slice()
|
|
12006
|
+
}
|
|
12007
|
+
}));
|
|
12008
|
+
};
|
|
12009
|
+
const installExitHandler = opts.installExitHandler ?? true;
|
|
12010
|
+
if (installExitHandler) process.on("exit", onExit);
|
|
12011
|
+
return {
|
|
12012
|
+
peakRss: () => peakRss,
|
|
12013
|
+
samples: () => samples.slice(),
|
|
12014
|
+
snapshot,
|
|
12015
|
+
stop: () => {
|
|
12016
|
+
clearInterval(timer);
|
|
12017
|
+
if (installExitHandler) process.off("exit", onExit);
|
|
12018
|
+
}
|
|
12019
|
+
};
|
|
12020
|
+
}
|
|
12021
|
+
|
|
12022
|
+
//#endregion
|
|
12023
|
+
//#region src/chat/theme-boot.ts
|
|
12024
|
+
/**
|
|
12025
|
+
* How long the first paint waits for the terminal to report its background
|
|
12026
|
+
* colour via OSC 10/11.
|
|
12027
|
+
*
|
|
12028
|
+
* OpenTUI's own default is 1000ms, and `createCliRenderer` has already switched
|
|
12029
|
+
* to the alternate screen by the time we wait — so on a terminal that never
|
|
12030
|
+
* answers the query (no OSC theme support, or a passthrough that eats it) the
|
|
12031
|
+
* user watched a blank cleared screen for a flat second on every launch, which
|
|
12032
|
+
* measured as 68% of cold start. Bound it by our budget rather than the
|
|
12033
|
+
* dependency's: 20ms is a typical real answer, so 100ms is generous.
|
|
12034
|
+
*/
|
|
12035
|
+
const THEME_QUERY_GRACE_MS = 100;
|
|
12036
|
+
/**
|
|
12037
|
+
* Settle the theme for the first paint, and keep following the terminal after
|
|
12038
|
+
* it.
|
|
12039
|
+
*
|
|
12040
|
+
* Order is the whole point and is load-bearing:
|
|
12041
|
+
*
|
|
12042
|
+
* 1. Subscribe to `theme_mode` FIRST. It carries both a late OSC answer and
|
|
12043
|
+
* every later system light/dark flip, so with the subscription in place,
|
|
12044
|
+
* giving up on the query early can only cost a briefly-wrong first paint —
|
|
12045
|
+
* never a permanently wrong theme.
|
|
12046
|
+
* 2. Apply the synchronous `COLORFGBG` sniff (iTerm2 and friends set it) so
|
|
12047
|
+
* there is something correct to paint at t=0.
|
|
12048
|
+
* 3. Only then wait, briefly, for the real answer.
|
|
12049
|
+
*
|
|
12050
|
+
* Inverting 2 and 3 is the bug this replaced: `COLORFGBG` was consulted only
|
|
12051
|
+
* after the 1000ms timeout expired, i.e. we blocked the launch waiting for
|
|
12052
|
+
* information we already had.
|
|
12053
|
+
*/
|
|
12054
|
+
async function settleBootThemeMode({ source, applyThemeMode, graceMs = THEME_QUERY_GRACE_MS, env }) {
|
|
12055
|
+
source.on("theme_mode", applyThemeMode);
|
|
12056
|
+
applyThemeMode(themeModeFromColorFgBg(env) ?? "dark");
|
|
12057
|
+
const answered = await source.waitForThemeMode(graceMs);
|
|
12058
|
+
if (answered) applyThemeMode(answered);
|
|
12059
|
+
}
|
|
12060
|
+
|
|
9751
12061
|
//#endregion
|
|
9752
12062
|
//#region src/chat/boot.tsx
|
|
9753
12063
|
/**
|
|
@@ -9756,12 +12066,15 @@ var LinkifiedMarkdownRenderable = class extends MarkdownRenderable {
|
|
|
9756
12066
|
* module — and the heavy OpenTUI dependency graph it pulls in — is
|
|
9757
12067
|
* dynamically imported and never loaded on the Node management path.
|
|
9758
12068
|
*/
|
|
9759
|
-
async function runChat({ appUrl, sessionToken, shareMachine, promptHistoryPath, theme: themeOverride, notifications, agentSelector, conversationId, seedPrompt }) {
|
|
12069
|
+
async function runChat({ appUrl, sessionToken, shareMachine, promptHistoryPath, theme: themeOverride, notifications, agentSelector, conversationId, newConversation, seedPrompt }) {
|
|
12070
|
+
const watchdog = startMemoryWatchdog();
|
|
12071
|
+
installCrashHandler({ memoryProvider: () => watchdog.snapshot() });
|
|
9760
12072
|
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
|
9761
12073
|
extend({ markdown: LinkifiedMarkdownRenderable });
|
|
9762
12074
|
useStore.setState({
|
|
9763
12075
|
seedPrompt: seedPrompt ?? null,
|
|
9764
|
-
autoGrantMachine: Boolean(seedPrompt && shareMachine)
|
|
12076
|
+
autoGrantMachine: Boolean(seedPrompt && shareMachine),
|
|
12077
|
+
newConversationIntent: newConversation
|
|
9765
12078
|
});
|
|
9766
12079
|
const store = useStore.getState();
|
|
9767
12080
|
if (noColorRequested()) {
|
|
@@ -9771,13 +12084,10 @@ async function runChat({ appUrl, sessionToken, shareMachine, promptHistoryPath,
|
|
|
9771
12084
|
themeId: monoTheme.id
|
|
9772
12085
|
});
|
|
9773
12086
|
} else if (themeOverride && findTheme(themeOverride)) store.lockTheme(themeOverride);
|
|
9774
|
-
else {
|
|
9775
|
-
|
|
9776
|
-
|
|
9777
|
-
|
|
9778
|
-
useStore.getState().setThemeMode(mode);
|
|
9779
|
-
});
|
|
9780
|
-
}
|
|
12087
|
+
else await settleBootThemeMode({
|
|
12088
|
+
source: renderer,
|
|
12089
|
+
applyThemeMode: (mode) => useStore.getState().setThemeMode(mode)
|
|
12090
|
+
});
|
|
9781
12091
|
createRoot(renderer).render(/* @__PURE__ */ jsx(App, {
|
|
9782
12092
|
appUrl,
|
|
9783
12093
|
sessionToken,
|
|
@@ -9785,14 +12095,17 @@ async function runChat({ appUrl, sessionToken, shareMachine, promptHistoryPath,
|
|
|
9785
12095
|
promptHistoryPath,
|
|
9786
12096
|
notifications,
|
|
9787
12097
|
agentSelector: agentSelector ?? null,
|
|
9788
|
-
conversationId: conversationId ?? null
|
|
12098
|
+
conversationId: conversationId ?? null,
|
|
12099
|
+
newConversation
|
|
9789
12100
|
}));
|
|
9790
12101
|
}
|
|
9791
12102
|
/** Open the same workspace picker used by the initial agent screen. */
|
|
9792
12103
|
async function runWorkspacePicker({ appUrl, sessionToken }) {
|
|
9793
12104
|
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
|
9794
|
-
|
|
9795
|
-
|
|
12105
|
+
await settleBootThemeMode({
|
|
12106
|
+
source: renderer,
|
|
12107
|
+
applyThemeMode: (mode) => useStore.getState().setThemeMode(mode)
|
|
12108
|
+
});
|
|
9796
12109
|
const close = () => renderer.destroy();
|
|
9797
12110
|
createRoot(renderer).render(/* @__PURE__ */ jsx("box", {
|
|
9798
12111
|
style: {
|