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