skydive-cli 0.6.0-beta.3 → 0.6.0-beta.30
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 +4 -0
- package/dist/js/bin.mjs +25 -23
- package/dist/js/{boot-CleHADz9.mjs → boot-DZqQZFlg.mjs} +2219 -1208
- package/dist/js/client-AlyJpq9J.mjs +5 -0
- package/dist/js/{client-CErCpXl9.mjs → client-C6nIuyAZ.mjs} +34 -6
- package/dist/js/{client-Btq6bMzX.mjs → client-l6orYfqq.mjs} +22 -1
- package/dist/js/{daemon-BuTGs-Ns.mjs → daemon-7UmxwQfJ.mjs} +2 -2
- package/dist/js/{daemon-Cuy4S6Rw.mjs → daemon-DJLsd6X4.mjs} +23 -3
- package/dist/js/{daemon-client-b10uck-j.mjs → daemon-client-BPy3PPu_.mjs} +1 -1
- package/dist/js/daemon-client-CoFALl4c.mjs +8 -0
- package/dist/js/{install-D7MwRv0n.mjs → install-D4zmUhGt.mjs} +2 -2
- package/dist/js/{print-B3yhPm0N.mjs → print-DOGluqfs.mjs} +62 -2
- package/dist/js/{print-VulYMc7x.mjs → print-DjXrn_7U.mjs} +2 -2
- package/dist/js/{print-share-DfTHn1HL.mjs → print-share-BsJI8x3l.mjs} +1 -1
- package/dist/js/raw-pty-DRZuf1oW.mjs +5 -0
- package/dist/js/{raw-pty-D5PhKZSl.mjs → raw-pty-dndn1Kxc.mjs} +1 -1
- package/dist/js/{rest-CRRpVyot.mjs → rest--lkwhdUi.mjs} +1 -1
- package/dist/js/{rest-ChXt8YGA.mjs → rest-D-QhD4Ey.mjs} +38 -2
- package/package.json +8 -7
- package/dist/js/client-qY4nTj2Y.mjs +0 -5
- package/dist/js/daemon-client-BVX-rWfX.mjs +0 -8
- package/dist/js/raw-pty-Ci2qFR9F.mjs +0 -5
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as errorMessage, i as sendErrorMessage, n as errorDetail, o as isRecord, t as createRestClient } from "./rest-
|
|
3
|
-
import {
|
|
4
|
-
import { A as installAgentAlias, C as themeVersion, D as machineOsFromPlatform, F as detectShell, L as getActiveWorkspaceId, M as takenAliasNames, O as aliasActivationHint, S as themeModeFromColorFgBg, T as themesForMode, V as setActiveWorkspace, _ as monoTheme, a as WORDMARK, b as themeForMode, d as profilingEnabled, f as record, g as findTheme, h as applyTheme, i as MARK_CELLS, j as slugifyAliasName, k as dedupeAliasName, m as DEFAULT_THEME_ID, n as buildCrashReport, p as writeArtifact, r as writeCrashReport, s as splashFitsWidth, t as installCrashHandler, v as noColorRequested, x as themeMode, y as theme, z as listWorkspaces } from "./install-
|
|
2
|
+
import { a as errorMessage, i as sendErrorMessage, n as errorDetail, o as isRecord, t as createRestClient } from "./rest-D-QhD4Ey.mjs";
|
|
3
|
+
import { C as DEFAULT_APP_URL, E as getConfigPath, K as saveTheme, L as recordDefaultAgent, M as getSavedTheme, S as DEFAULT_API_URL, U as resolveWebUrl, _ as parseConnectCard, c as actionableCardVerb, d as parseOauthConnectParams, f as prioritizeActionableCards, g as computeSettledLabel, h as composeQuestionAnswer, i as resolveAgent, j as getReviewStateDir, l as cardActionErrorMessage, m as resolveConnectUrl, p as reconcileMaskedInput, s as MASK_CHAR, u as parseExternalOauthConnectParams, v as specKeyFor } from "./print-DOGluqfs.mjs";
|
|
4
|
+
import { A as installAgentAlias, C as themeVersion, D as machineOsFromPlatform, F as detectShell, L as getActiveWorkspaceId, M as takenAliasNames, O as aliasActivationHint, S as themeModeFromColorFgBg, T as themesForMode, V as setActiveWorkspace, _ as monoTheme, a as WORDMARK, b as themeForMode, d as profilingEnabled, f as record, g as findTheme, h as applyTheme, i as MARK_CELLS, j as slugifyAliasName, k as dedupeAliasName, m as DEFAULT_THEME_ID, n as buildCrashReport, p as writeArtifact, r as writeCrashReport, s as splashFitsWidth, t as installCrashHandler, v as noColorRequested, x as themeMode, y as theme, z as listWorkspaces } from "./install-D4zmUhGt.mjs";
|
|
5
5
|
import { t as HttpError } from "./http-error-BF2NZZE3.mjs";
|
|
6
6
|
import { i as billingBlockedOutcomeFromSendResponse } from "./billing-blocked-D3l5kJlX.mjs";
|
|
7
|
-
import { t as PortalClient } from "./client-
|
|
7
|
+
import { t as PortalClient } from "./client-C6nIuyAZ.mjs";
|
|
8
8
|
import { t as defaultTlsCertSource } from "./tls-cert-Rua2oV7n.mjs";
|
|
9
9
|
import "./api-BFQ4PQDA.mjs";
|
|
10
|
-
import "./daemon-
|
|
11
|
-
import { t as SandboxStream } from "./client-
|
|
12
|
-
import { t as PortalDaemonClient } from "./daemon-client-
|
|
13
|
-
import { t as runRawPtyPassthrough } from "./raw-pty-
|
|
10
|
+
import "./daemon-DJLsd6X4.mjs";
|
|
11
|
+
import { t as SandboxStream } from "./client-l6orYfqq.mjs";
|
|
12
|
+
import { t as PortalDaemonClient } from "./daemon-client-BPy3PPu_.mjs";
|
|
13
|
+
import { t as runRawPtyPassthrough } from "./raw-pty-dndn1Kxc.mjs";
|
|
14
14
|
import * as os$1 from "node:os";
|
|
15
15
|
import { homedir, platform, release, tmpdir } from "node:os";
|
|
16
16
|
import path, { basename, extname, isAbsolute, join, posix, win32 } from "node:path";
|
|
@@ -136,8 +136,8 @@ const useStore = create((set, get) => ({
|
|
|
136
136
|
portalClient: null,
|
|
137
137
|
toast: null,
|
|
138
138
|
seedPrompt: null,
|
|
139
|
+
seedAttachments: [],
|
|
139
140
|
autoGrantMachine: false,
|
|
140
|
-
newConversationIntent: false,
|
|
141
141
|
forcedOnboarding: false,
|
|
142
142
|
earlyInput: "",
|
|
143
143
|
goTo: (screen) => set({
|
|
@@ -221,20 +221,20 @@ const useStore = create((set, get) => ({
|
|
|
221
221
|
/**
|
|
222
222
|
* Decide the screen the chat TUI boots into.
|
|
223
223
|
*
|
|
224
|
-
* With no selector we open the
|
|
225
|
-
* `defaultAgent`, in which case a bare
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* stored selector that no longer resolves degrades silently
|
|
231
|
-
*
|
|
232
|
-
* archived, another one claiming the same name), and unlike a
|
|
233
|
-
* `--agent` there is no command line to correct — and picking an
|
|
234
|
-
* there heals the default on its own.
|
|
224
|
+
* With no selector we open the welcome screen (screen 1 of the three-screen
|
|
225
|
+
* flow) — unless the config stores a `defaultAgent`, in which case a bare
|
|
226
|
+
* `skydive chat` opens that agent's conversation home directly (its centered
|
|
227
|
+
* composer + conversation list). The default is maintained automatically
|
|
228
|
+
* (every conversation the TUI opens records its agent, so a bare launch
|
|
229
|
+
* returns to whoever you talked to last — the dominant launch pattern), and
|
|
230
|
+
* `skydive config set defaultAgent` can seed or override it. A stored selector that no longer resolves degrades silently
|
|
231
|
+
* to the welcome screen rather than an error screen: config goes stale (agent
|
|
232
|
+
* renamed, archived, another one claiming the same name), and unlike a
|
|
233
|
+
* mistyped `--agent` there is no command line to correct — and picking an
|
|
234
|
+
* agent there heals the default on its own.
|
|
235
235
|
*
|
|
236
236
|
* With `--agent` (`skydive chat --agent grace`, no `-p`) we resolve it
|
|
237
|
-
* against the org roster and open that agent's conversation
|
|
237
|
+
* against the org roster and open that agent's conversation home, skipping
|
|
238
238
|
* the picker. Resolution reuses the same `resolveAgent` rules as the `-p`
|
|
239
239
|
* path so a given selector behaves identically in both modes: an id, or a
|
|
240
240
|
* unique case-insensitive slug/name; ambiguous or unknown selectors throw
|
|
@@ -251,8 +251,9 @@ const useStore = create((set, get) => ({
|
|
|
251
251
|
* needs every candidate is disambiguating a fuzzy name/slug selector; an id
|
|
252
252
|
* (or the `--resume` conversation's agentId) is exact and resolves with a
|
|
253
253
|
* single `getAgent`, and `--new` with no selector only needs "exactly one
|
|
254
|
-
* agent, or not", which a two-row page answers. A
|
|
255
|
-
* the
|
|
254
|
+
* agent, or not", which a two-row page answers. A bare launch lands straight
|
|
255
|
+
* on the welcome screen, which loads the roster itself. A large org must never
|
|
256
|
+
* sit on the `connecting…` splash while the roster streams in.
|
|
256
257
|
*
|
|
257
258
|
* Kept out of the React component so it's unit-testable under Node (the
|
|
258
259
|
* component itself can't render without Bun + OpenTUI).
|
|
@@ -262,7 +263,7 @@ const useStore = create((set, get) => ({
|
|
|
262
263
|
* org roster. A name/slug selector is the only case that can be ambiguous and
|
|
263
264
|
* therefore still needs the full candidate list to disambiguate. */
|
|
264
265
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
265
|
-
async function resolveInitialScreen({ rest, agentSelector, conversationId, newConversation, defaultAgentSelector, forceOnboarding }) {
|
|
266
|
+
async function resolveInitialScreen({ rest, agentSelector, conversationId, newConversation, seedPrompt, defaultAgentSelector, forceOnboarding }) {
|
|
266
267
|
if (conversationId) {
|
|
267
268
|
const conversation = await rest.getConversation({ conversationId }).catch((err) => {
|
|
268
269
|
if (err instanceof HttpError && err.status === 404) throw new Error(`No conversation with id ${conversationId}. It may have been deleted, or belong to another workspace.`);
|
|
@@ -294,14 +295,8 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
|
|
|
294
295
|
parentConversationId: conversation.parentConversationId ?? null
|
|
295
296
|
};
|
|
296
297
|
}
|
|
297
|
-
if (forceOnboarding && !agentSelector) return { kind: "
|
|
298
|
-
if (!agentSelector && !newConversation && !defaultAgentSelector) {
|
|
299
|
-
if ((await rest.listAgents({
|
|
300
|
-
scope: "org",
|
|
301
|
-
onPage: null
|
|
302
|
-
})).length === 0) return { kind: "first-run" };
|
|
303
|
-
return { kind: "agent-picker" };
|
|
304
|
-
}
|
|
298
|
+
if (forceOnboarding && !agentSelector) return { kind: "welcome" };
|
|
299
|
+
if (!agentSelector && !newConversation && seedPrompt === null && !defaultAgentSelector) return { kind: "welcome" };
|
|
305
300
|
if (!agentSelector && defaultAgentSelector) {
|
|
306
301
|
const agent = UUID_RE.test(defaultAgentSelector) ? await rest.getAgent({ agentId: defaultAgentSelector }).catch((err) => {
|
|
307
302
|
if (err instanceof HttpError && (err.status === 404 || err.status === 403)) return null;
|
|
@@ -310,31 +305,13 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
|
|
|
310
305
|
scope: "org",
|
|
311
306
|
onPage: null
|
|
312
307
|
}), defaultAgentSelector);
|
|
313
|
-
if (agent) return
|
|
314
|
-
return { kind: "
|
|
315
|
-
}
|
|
316
|
-
if (agentSelector && UUID_RE.test(agentSelector)) {
|
|
317
|
-
const agent = await rest.getAgent({ agentId: agentSelector }).catch((err) => {
|
|
318
|
-
if (err instanceof HttpError && (err.status === 404 || err.status === 403)) throw new Error(`No agent with id ${agentSelector}. It may not exist, or belong to another workspace.`);
|
|
319
|
-
throw err;
|
|
320
|
-
});
|
|
321
|
-
if (newConversation) return {
|
|
322
|
-
kind: "chat",
|
|
323
|
-
agent,
|
|
324
|
-
attachRunId: null,
|
|
325
|
-
parentConversationId: null,
|
|
326
|
-
conversation: {
|
|
327
|
-
kind: "new",
|
|
328
|
-
agentId: agent.id,
|
|
329
|
-
nonce: crypto.randomUUID()
|
|
330
|
-
}
|
|
331
|
-
};
|
|
332
|
-
return {
|
|
333
|
-
kind: "conversation-picker",
|
|
334
|
-
agent,
|
|
335
|
-
initialQuery: ""
|
|
336
|
-
};
|
|
308
|
+
if (agent) return openAgentHome(agent, seedPrompt);
|
|
309
|
+
return { kind: "welcome" };
|
|
337
310
|
}
|
|
311
|
+
if (agentSelector && UUID_RE.test(agentSelector)) return openAgentHome(await rest.getAgent({ agentId: agentSelector }).catch((err) => {
|
|
312
|
+
if (err instanceof HttpError && (err.status === 404 || err.status === 403)) throw new Error(`No agent with id ${agentSelector}. It may not exist, or belong to another workspace.`);
|
|
313
|
+
throw err;
|
|
314
|
+
}), seedPrompt);
|
|
338
315
|
if (!agentSelector && newConversation) {
|
|
339
316
|
const firstTwo = await rest.listAgents({
|
|
340
317
|
scope: "org",
|
|
@@ -342,14 +319,24 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
|
|
|
342
319
|
limit: 2
|
|
343
320
|
});
|
|
344
321
|
const only = firstTwo.length === 1 ? firstTwo[0] : null;
|
|
345
|
-
if (!only) return { kind: "
|
|
346
|
-
return
|
|
322
|
+
if (!only) return { kind: "welcome" };
|
|
323
|
+
return openAgentHome(only, seedPrompt);
|
|
347
324
|
}
|
|
348
|
-
|
|
325
|
+
return openAgentHome(resolveAgent(await rest.listAgents({
|
|
349
326
|
scope: "org",
|
|
350
327
|
onPage: null
|
|
351
|
-
}), agentSelector);
|
|
352
|
-
|
|
328
|
+
}), agentSelector), seedPrompt);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Land on the agent's conversation home — the single new-conversation surface
|
|
332
|
+
* (centered composer + conversation list). The one exception is a seeded
|
|
333
|
+
* launch (`skydive import`): there is a message queued to auto-send, so boot
|
|
334
|
+
* straight into a fresh chat (`kind: 'new'`) that fires it. Without a seed
|
|
335
|
+
* there is nothing to send, so the composer on the home is where a new
|
|
336
|
+
* conversation starts — the legacy full-width empty-chat landing is gone.
|
|
337
|
+
*/
|
|
338
|
+
function openAgentHome(agent, seedPrompt) {
|
|
339
|
+
if (seedPrompt !== null) return newChat(agent);
|
|
353
340
|
return {
|
|
354
341
|
kind: "conversation-picker",
|
|
355
342
|
agent,
|
|
@@ -1831,9 +1818,7 @@ function screenLabelParts(screen, chatTitle) {
|
|
|
1831
1818
|
switch (screen.kind) {
|
|
1832
1819
|
case "splash": return { title: "" };
|
|
1833
1820
|
case "error": return { title: "" };
|
|
1834
|
-
case "
|
|
1835
|
-
case "agent-create": return { title: "create an agent" };
|
|
1836
|
-
case "first-run": return { title: "welcome" };
|
|
1821
|
+
case "welcome": return { title: "welcome" };
|
|
1837
1822
|
case "workspace-picker": return { title: "switch workspace" };
|
|
1838
1823
|
case "conversation-picker": return {
|
|
1839
1824
|
title: "pick a conversation",
|
|
@@ -2103,26 +2088,185 @@ function SkydiveMark({ variant = "full" }) {
|
|
|
2103
2088
|
}
|
|
2104
2089
|
|
|
2105
2090
|
//#endregion
|
|
2106
|
-
//#region src/chat/tui/
|
|
2107
|
-
|
|
2091
|
+
//#region src/chat/tui/use-terminal-ready.ts
|
|
2092
|
+
/**
|
|
2093
|
+
* The 80x24 size the CLI renderer boots on when it cannot read the terminal's
|
|
2094
|
+
* real size synchronously (emulators / multiplexers that report size via an
|
|
2095
|
+
* async `resize` after start). A terminal reporting its size synchronously
|
|
2096
|
+
* boots on its true size instead — which is almost never exactly 80x24. See
|
|
2097
|
+
* `@opentui/core`: the renderer seeds width from `process.stdout.columns || 80`
|
|
2098
|
+
* and height from `process.stdout.rows || 24`.
|
|
2099
|
+
*/
|
|
2100
|
+
const FALLBACK_WIDTH = 80;
|
|
2101
|
+
const FALLBACK_HEIGHT = 24;
|
|
2102
|
+
/**
|
|
2103
|
+
* Last-resort reveal delay for the ambiguous fallback boot (booted 80x24 with
|
|
2104
|
+
* no readable `process.stdout` size and no resize ever arriving — a non-tty
|
|
2105
|
+
* pipe, say). It is deliberately longer than the renderer's 100ms resize
|
|
2106
|
+
* debounce (`resizeDebounceDelay`), so a real async terminal's first resize
|
|
2107
|
+
* always wins this race and the brand's first *visible* paint is at the true
|
|
2108
|
+
* width. On the genuinely-80x24 path this timer never runs (we reveal from
|
|
2109
|
+
* `stdout.columns`), so its length costs a real terminal nothing.
|
|
2110
|
+
*/
|
|
2111
|
+
const REVEAL_FALLBACK_MS = 250;
|
|
2112
|
+
/** The terminal's real column/row count if the tty reports it synchronously,
|
|
2113
|
+
* else null. On an async-sizing emulator `process.stdout.columns` is populated
|
|
2114
|
+
* shortly after boot even while the renderer is still on its 80x24 fallback, so
|
|
2115
|
+
* this is the cheapest way to learn the true size without waiting on the
|
|
2116
|
+
* debounced resize. A non-tty (pipe/redirect) has no size and returns null. */
|
|
2117
|
+
function stdoutSize() {
|
|
2118
|
+
const width = process.stdout.columns;
|
|
2119
|
+
const height = process.stdout.rows;
|
|
2120
|
+
return typeof width === "number" && typeof height === "number" ? {
|
|
2121
|
+
width,
|
|
2122
|
+
height
|
|
2123
|
+
} : null;
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* Terminal dimensions plus a `ready` flag that gates the first centered paint,
|
|
2127
|
+
* to kill the welcome screen's left→center brand flash (PR #20856).
|
|
2128
|
+
*
|
|
2129
|
+
* `useTerminalDimensions` seeds from `renderer.width`/`height`. A terminal that
|
|
2130
|
+
* reports its size synchronously already has the real size on frame one, so
|
|
2131
|
+
* `ready` starts true and nothing is gated — no flash is possible because the
|
|
2132
|
+
* width never changes.
|
|
2133
|
+
*
|
|
2134
|
+
* A terminal that sizes asynchronously (tmux, cmux, some emulators) boots on
|
|
2135
|
+
* the 80x24 fallback and the real size lands via `resize` — but the renderer
|
|
2136
|
+
* DEBOUNCES that resize by 100ms, so a naive "reveal on the next tick" reveals
|
|
2137
|
+
* the brand centered-within-80 first and only jumps it to the real width a
|
|
2138
|
+
* tenth of a second later: exactly the flash reviewers saw. So on a fallback
|
|
2139
|
+
* boot we hold a blank full-size frame and reveal only once we actually know
|
|
2140
|
+
* the final width, from one of three signals, in order:
|
|
2141
|
+
*
|
|
2142
|
+
* 1. `process.stdout.columns` — the OS-reported tty size, populated well
|
|
2143
|
+
* before the debounced resize fires. If it reports a size other than
|
|
2144
|
+
* 80x24, that is the truth and the imminent resize will bring it, so we
|
|
2145
|
+
* stay gated until the resize actually lands (the width the brand centers
|
|
2146
|
+
* within must be the one on screen). If it reports exactly 80x24, the
|
|
2147
|
+
* terminal really is 80 wide — the 80-col centering is already final, so
|
|
2148
|
+
* reveal immediately with no flash.
|
|
2149
|
+
* 2. the `resize` event — the definitive real size for an async terminal;
|
|
2150
|
+
* reveal the instant it arrives, already at the final width.
|
|
2151
|
+
* 3. a `REVEAL_FALLBACK_MS` timer — only for a non-tty with no size and no
|
|
2152
|
+
* resize (a pipe), so it is never left blank forever. Longer than the
|
|
2153
|
+
* 100ms debounce so a real resize always beats it.
|
|
2154
|
+
*/
|
|
2155
|
+
function useTerminalReady() {
|
|
2156
|
+
const { width, height } = useTerminalDimensions();
|
|
2157
|
+
const renderer = useRenderer();
|
|
2158
|
+
const bootedOnFallback = width === FALLBACK_WIDTH && height === FALLBACK_HEIGHT;
|
|
2159
|
+
const [ready, setReady] = useState(() => {
|
|
2160
|
+
if (!bootedOnFallback) return true;
|
|
2161
|
+
const size = stdoutSize();
|
|
2162
|
+
return size?.width === FALLBACK_WIDTH && size?.height === FALLBACK_HEIGHT;
|
|
2163
|
+
});
|
|
2164
|
+
useEffect(() => {
|
|
2165
|
+
if (ready) return;
|
|
2166
|
+
let done = false;
|
|
2167
|
+
const settle = () => {
|
|
2168
|
+
if (done) return;
|
|
2169
|
+
done = true;
|
|
2170
|
+
setReady(true);
|
|
2171
|
+
};
|
|
2172
|
+
renderer.on("resize", settle);
|
|
2173
|
+
const size = stdoutSize();
|
|
2174
|
+
if (size?.width === FALLBACK_WIDTH && size?.height === FALLBACK_HEIGHT) settle();
|
|
2175
|
+
const t = setTimeout(settle, REVEAL_FALLBACK_MS);
|
|
2176
|
+
return () => {
|
|
2177
|
+
renderer.off("resize", settle);
|
|
2178
|
+
clearTimeout(t);
|
|
2179
|
+
};
|
|
2180
|
+
}, [renderer, ready]);
|
|
2181
|
+
return {
|
|
2182
|
+
width,
|
|
2183
|
+
height,
|
|
2184
|
+
ready
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
//#endregion
|
|
2189
|
+
//#region src/chat/tui/agent-name.ts
|
|
2190
|
+
function parseAgentName(value) {
|
|
2191
|
+
const name = value.trim();
|
|
2192
|
+
if (!name) return {
|
|
2193
|
+
ok: false,
|
|
2194
|
+
message: "Enter a name."
|
|
2195
|
+
};
|
|
2196
|
+
if (name.length > 120) return {
|
|
2197
|
+
ok: false,
|
|
2198
|
+
message: "Use 120 characters or fewer."
|
|
2199
|
+
};
|
|
2200
|
+
return {
|
|
2201
|
+
ok: true,
|
|
2202
|
+
name
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
//#endregion
|
|
2207
|
+
//#region src/chat/tui/screens/welcome.tsx
|
|
2208
|
+
/**
|
|
2209
|
+
* Screen 1 of the three-screen chat flow: **Welcome to Skydive**.
|
|
2210
|
+
*
|
|
2211
|
+
* A single centered screen (opencode fixed-width column) that is the entry
|
|
2212
|
+
* point whenever no agent is pinned. It folds together what used to be three
|
|
2213
|
+
* separate screens — the agent picker, the create-agent form, and the
|
|
2214
|
+
* first-run onboarding — so the flow is exactly: welcome → agent page → chat.
|
|
2215
|
+
*
|
|
2216
|
+
* Modes, all rendered in the same centered column:
|
|
2217
|
+
* - `list`: the workspace's agents, fuzzy-filterable. Enter opens the
|
|
2218
|
+
* highlighted agent's home (the agent page); tab flips mine/all scope;
|
|
2219
|
+
* ctrl+n switches to `create`; ctrl+w opens the workspace picker.
|
|
2220
|
+
* - `create`: an inline name field (prefilled from the server's suggestion).
|
|
2221
|
+
* Enter creates the agent, then offers a shell alias before opening it.
|
|
2222
|
+
* - `first-run`: reached automatically when the workspace has no agents at
|
|
2223
|
+
* all — auto-creates the account's first agent (server-named, like web
|
|
2224
|
+
* onboarding) and offers to have it learn from this machine's coding-agent
|
|
2225
|
+
* setup before dropping the user into its chat.
|
|
2226
|
+
*
|
|
2227
|
+
* Everything is centered both axes so a wide terminal gets even margins
|
|
2228
|
+
* instead of content stranded top-left.
|
|
2229
|
+
*/
|
|
2230
|
+
const CONTENT_MAX_COLS = 76;
|
|
2231
|
+
/**
|
|
2232
|
+
* Where to land after creating an agent (create mode / first-run). The
|
|
2233
|
+
* conversation home (centered composer + list) is the single new-conversation
|
|
2234
|
+
* surface, so a freshly created agent opens straight into its home. The one
|
|
2235
|
+
* exception is a seeded launch (`skydive import`): there is a message queued
|
|
2236
|
+
* to auto-send, so boot into a fresh chat (`kind: 'new'`) that fires it. A
|
|
2237
|
+
* brand-new agent has no conversations yet, so its home is just the composer
|
|
2238
|
+
* over an empty list — which is exactly the "start talking" prompt we want.
|
|
2239
|
+
*/
|
|
2240
|
+
function openAgentAfterCreate(agent) {
|
|
2241
|
+
if (useStore.getState().seedPrompt !== null) return {
|
|
2242
|
+
kind: "chat",
|
|
2243
|
+
agent,
|
|
2244
|
+
attachRunId: null,
|
|
2245
|
+
parentConversationId: null,
|
|
2246
|
+
conversation: {
|
|
2247
|
+
kind: "new",
|
|
2248
|
+
agentId: agent.id,
|
|
2249
|
+
nonce: crypto.randomUUID()
|
|
2250
|
+
}
|
|
2251
|
+
};
|
|
2252
|
+
return {
|
|
2253
|
+
kind: "conversation-picker",
|
|
2254
|
+
agent,
|
|
2255
|
+
initialQuery: ""
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
function WelcomeScreen() {
|
|
2108
2259
|
const rest = useStore((s) => s.rest);
|
|
2109
2260
|
const goTo = useStore((s) => s.goTo);
|
|
2110
2261
|
const fail = useStore((s) => s.fail);
|
|
2262
|
+
const forcedOnboarding = useStore((s) => s.forcedOnboarding);
|
|
2111
2263
|
const [scope, setScope] = useState("mine");
|
|
2112
2264
|
const [cache, setCache] = useState({});
|
|
2113
2265
|
const [state, setState] = useState({ kind: "loading" });
|
|
2114
|
-
|
|
2115
|
-
if (key.name === "w" && key.ctrl) {
|
|
2116
|
-
if (useStore.getState().authKind === "api-key") {
|
|
2117
|
-
useStore.getState().showToast({ message: "API keys are pinned to one workspace. Run `skydive auth login --web` to switch workspaces." });
|
|
2118
|
-
return;
|
|
2119
|
-
}
|
|
2120
|
-
goTo({ kind: "workspace-picker" });
|
|
2121
|
-
} else if (key.name === "n" && key.ctrl) goTo({ kind: "agent-create" });
|
|
2122
|
-
else if (key.name === "tab") setScope((s) => s === "mine" ? "org" : "mine");
|
|
2123
|
-
});
|
|
2266
|
+
const [mode, setMode] = useState(forcedOnboarding ? { kind: "first-run" } : { kind: "list" });
|
|
2124
2267
|
useEffect(() => {
|
|
2125
2268
|
if (!rest) return;
|
|
2269
|
+
if (mode.kind !== "list") return;
|
|
2126
2270
|
const cached = cache[scope];
|
|
2127
2271
|
if (cached) {
|
|
2128
2272
|
setState({
|
|
@@ -2153,6 +2297,15 @@ function AgentPickerScreen() {
|
|
|
2153
2297
|
kind: "ready",
|
|
2154
2298
|
agents
|
|
2155
2299
|
});
|
|
2300
|
+
if (agents.length === 0 && scope === "mine") {
|
|
2301
|
+
const org = await rest.listAgents({
|
|
2302
|
+
scope: "org",
|
|
2303
|
+
onPage: null
|
|
2304
|
+
});
|
|
2305
|
+
if (cancelled) return;
|
|
2306
|
+
if (org.length === 0) setMode({ kind: "first-run" });
|
|
2307
|
+
else setScope("org");
|
|
2308
|
+
}
|
|
2156
2309
|
} catch (err) {
|
|
2157
2310
|
if (cancelled) return;
|
|
2158
2311
|
if (err instanceof HttpError && err.status === 401) {
|
|
@@ -2172,56 +2325,124 @@ function AgentPickerScreen() {
|
|
|
2172
2325
|
rest,
|
|
2173
2326
|
fail,
|
|
2174
2327
|
scope,
|
|
2175
|
-
cache
|
|
2328
|
+
cache,
|
|
2329
|
+
mode.kind
|
|
2176
2330
|
]);
|
|
2177
|
-
|
|
2178
|
-
if (
|
|
2331
|
+
if (mode.kind === "create") return /* @__PURE__ */ jsx(CreateAgentMode, { onCancel: () => setMode({ kind: "list" }) });
|
|
2332
|
+
if (mode.kind === "first-run") return /* @__PURE__ */ jsx(FirstRunMode, {});
|
|
2333
|
+
return /* @__PURE__ */ jsx(AgentListMode, {
|
|
2334
|
+
state,
|
|
2335
|
+
scope,
|
|
2336
|
+
onToggleScope: () => setScope((s) => s === "mine" ? "org" : "mine"),
|
|
2337
|
+
onCreate: () => setMode({ kind: "create" }),
|
|
2338
|
+
onSwitchWorkspace: () => {
|
|
2339
|
+
if (useStore.getState().authKind === "api-key") {
|
|
2340
|
+
useStore.getState().showToast({ message: "API keys are pinned to one workspace. Run `skydive auth login --web` to switch workspaces." });
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
goTo({ kind: "workspace-picker" });
|
|
2344
|
+
},
|
|
2345
|
+
onPick: goTo
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
/** The centered welcome frame every mode renders inside: the brand mark and a
|
|
2349
|
+
* "Welcome to Skydive" heading over the mode's own content, in a fixed-width
|
|
2350
|
+
* column centered on both axes.
|
|
2351
|
+
*
|
|
2352
|
+
* The first paint is GATED on the terminal's real size being known (`ready`).
|
|
2353
|
+
* The renderer boots on an 80x24 fallback when the emulator reports its size
|
|
2354
|
+
* asynchronously (tmux and friends), so an ungated screen centers the brand
|
|
2355
|
+
* within 80 cols first, then jumps to centered-within-the-real-width once the
|
|
2356
|
+
* size lands — the "weird flashing transition" from left to center reviewers
|
|
2357
|
+
* see. Crucially the renderer DEBOUNCES that resize by 100ms, so revealing on
|
|
2358
|
+
* the next tick would still flash; `useTerminalReady` instead reveals from the
|
|
2359
|
+
* OS-reported `process.stdout` size or the resize event itself, whichever
|
|
2360
|
+
* confirms the true width first. While `ready` is false we hold a blank but
|
|
2361
|
+
* full-size, same-background frame, so the brand's first *visible* paint is
|
|
2362
|
+
* already at its final centered position and never reflows. The home
|
|
2363
|
+
* (conversation-picker) and the empty-chat splash gate the same way, so the
|
|
2364
|
+
* flash is fixed on every brand-centered surface the launch flow can land on.
|
|
2365
|
+
* See `useTerminalReady`. */
|
|
2366
|
+
function WelcomeFrame({ contentWidth, children }) {
|
|
2367
|
+
const { width, height, ready } = useTerminalReady();
|
|
2368
|
+
if (!ready) return /* @__PURE__ */ jsx("box", { style: {
|
|
2369
|
+
flexDirection: "column",
|
|
2370
|
+
flexGrow: 1
|
|
2371
|
+
} });
|
|
2372
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
2179
2373
|
style: {
|
|
2180
2374
|
flexDirection: "column",
|
|
2181
|
-
flexGrow: 1
|
|
2375
|
+
flexGrow: 1,
|
|
2376
|
+
alignItems: "center"
|
|
2182
2377
|
},
|
|
2183
|
-
children: [
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2378
|
+
children: [
|
|
2379
|
+
/* @__PURE__ */ jsx("box", { style: {
|
|
2380
|
+
flexGrow: 1,
|
|
2381
|
+
flexShrink: 1
|
|
2382
|
+
} }),
|
|
2383
|
+
/* @__PURE__ */ jsxs("box", {
|
|
2384
|
+
style: {
|
|
2385
|
+
flexDirection: "column",
|
|
2386
|
+
width: contentWidth
|
|
2387
|
+
},
|
|
2388
|
+
children: [/* @__PURE__ */ jsxs("box", {
|
|
2389
|
+
style: {
|
|
2390
|
+
flexDirection: "column",
|
|
2391
|
+
alignItems: "center"
|
|
2392
|
+
},
|
|
2393
|
+
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
2394
|
+
height,
|
|
2395
|
+
width
|
|
2396
|
+
}), height >= 24 ? /* @__PURE__ */ jsxs("box", {
|
|
2397
|
+
style: {
|
|
2398
|
+
flexDirection: "column",
|
|
2399
|
+
alignItems: "center"
|
|
2400
|
+
},
|
|
2401
|
+
children: [/* @__PURE__ */ jsx("text", {
|
|
2402
|
+
fg: theme.accent,
|
|
2403
|
+
attributes: 1,
|
|
2404
|
+
children: "Welcome to Skydive"
|
|
2405
|
+
}), /* @__PURE__ */ jsx("box", { style: { height: 1 } })]
|
|
2406
|
+
}) : null]
|
|
2407
|
+
}), children]
|
|
2408
|
+
}),
|
|
2409
|
+
/* @__PURE__ */ jsx("box", { style: {
|
|
2410
|
+
flexGrow: 1,
|
|
2411
|
+
flexShrink: 1
|
|
2412
|
+
} })
|
|
2413
|
+
]
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
function AgentListMode({ state, scope, onToggleScope, onCreate, onSwitchWorkspace, onPick }) {
|
|
2417
|
+
const { width } = useTerminalDimensions();
|
|
2418
|
+
const contentWidth = Math.min(CONTENT_MAX_COLS, Math.max(1, width - 2));
|
|
2419
|
+
if (state.kind === "loading") return /* @__PURE__ */ jsx(WelcomeFrame, {
|
|
2420
|
+
contentWidth,
|
|
2421
|
+
children: /* @__PURE__ */ jsx("text", {
|
|
2187
2422
|
fg: theme.muted,
|
|
2188
2423
|
children: "loading agents…"
|
|
2189
|
-
})
|
|
2424
|
+
})
|
|
2190
2425
|
});
|
|
2191
|
-
if (state.kind === "error") return /* @__PURE__ */
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
flexGrow: 1
|
|
2195
|
-
},
|
|
2196
|
-
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
2197
|
-
height,
|
|
2198
|
-
width
|
|
2199
|
-
}), /* @__PURE__ */ jsxs("text", {
|
|
2426
|
+
if (state.kind === "error") return /* @__PURE__ */ jsx(WelcomeFrame, {
|
|
2427
|
+
contentWidth,
|
|
2428
|
+
children: /* @__PURE__ */ jsxs("text", {
|
|
2200
2429
|
fg: theme.error,
|
|
2201
2430
|
children: ["error: ", state.message]
|
|
2202
|
-
})
|
|
2203
|
-
});
|
|
2204
|
-
if (state.agents.length === 0) return /* @__PURE__ */ jsxs("box", {
|
|
2205
|
-
style: {
|
|
2206
|
-
flexDirection: "column",
|
|
2207
|
-
flexGrow: 1
|
|
2208
|
-
},
|
|
2209
|
-
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
2210
|
-
height,
|
|
2211
|
-
width
|
|
2212
|
-
}), /* @__PURE__ */ jsx("text", {
|
|
2213
|
-
fg: theme.muted,
|
|
2214
|
-
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."
|
|
2215
|
-
})]
|
|
2431
|
+
})
|
|
2216
2432
|
});
|
|
2217
2433
|
return /* @__PURE__ */ jsx(FilterableAgentList, {
|
|
2218
2434
|
agents: state.agents,
|
|
2219
2435
|
scope,
|
|
2220
|
-
|
|
2436
|
+
contentWidth,
|
|
2437
|
+
onToggleScope,
|
|
2438
|
+
onCreate,
|
|
2439
|
+
onSwitchWorkspace,
|
|
2440
|
+
onPick
|
|
2221
2441
|
});
|
|
2222
2442
|
}
|
|
2223
|
-
function FilterableAgentList({ agents, scope, onPick }) {
|
|
2443
|
+
function FilterableAgentList({ agents, scope, contentWidth, onToggleScope, onCreate, onSwitchWorkspace, onPick }) {
|
|
2224
2444
|
const { width, height } = useTerminalDimensions();
|
|
2445
|
+
const composerRef = useRef(null);
|
|
2225
2446
|
const [query, setQuery] = useState("");
|
|
2226
2447
|
const [highlight, setHighlight] = useState(0);
|
|
2227
2448
|
useEffect(() => {
|
|
@@ -2230,87 +2451,100 @@ function FilterableAgentList({ agents, scope, onPick }) {
|
|
|
2230
2451
|
const hits = fuzzyFilter(query, agents, agentKeys);
|
|
2231
2452
|
const filtered = hits.map((h) => h.item);
|
|
2232
2453
|
const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
|
|
2233
|
-
const
|
|
2454
|
+
const identityRows = height >= 24 ? 2 : 0;
|
|
2455
|
+
const visibleRows = Math.max(3, height - 8 - brandHeaderRows(height, width) - identityRows);
|
|
2234
2456
|
const start = windowStart(clamped, filtered.length, visibleRows);
|
|
2235
2457
|
const windowed = hits.slice(start, start + visibleRows);
|
|
2458
|
+
const openAgent = () => {
|
|
2459
|
+
const agent = filtered[clamped];
|
|
2460
|
+
if (!agent) return;
|
|
2461
|
+
onPick(useStore.getState().seedPrompt !== null ? {
|
|
2462
|
+
kind: "chat",
|
|
2463
|
+
agent,
|
|
2464
|
+
attachRunId: null,
|
|
2465
|
+
parentConversationId: null,
|
|
2466
|
+
conversation: {
|
|
2467
|
+
kind: "new",
|
|
2468
|
+
agentId: agent.id,
|
|
2469
|
+
nonce: crypto.randomUUID()
|
|
2470
|
+
}
|
|
2471
|
+
} : {
|
|
2472
|
+
kind: "conversation-picker",
|
|
2473
|
+
agent,
|
|
2474
|
+
initialQuery: ""
|
|
2475
|
+
});
|
|
2476
|
+
};
|
|
2236
2477
|
useKeyboard((key) => {
|
|
2237
|
-
if (key.name === "up")
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
else if (key.name === "
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
initialQuery: ""
|
|
2259
|
-
});
|
|
2478
|
+
if (key.name === "up") {
|
|
2479
|
+
setHighlight(Math.max(0, clamped - 1));
|
|
2480
|
+
key.preventDefault();
|
|
2481
|
+
} else if (key.name === "down") {
|
|
2482
|
+
setHighlight(Math.min(Math.max(0, filtered.length - 1), clamped + 1));
|
|
2483
|
+
key.preventDefault();
|
|
2484
|
+
} else if (key.name === "pageup" || key.name === "pgup") {
|
|
2485
|
+
setHighlight(Math.max(0, clamped - Math.max(1, visibleRows - 1)));
|
|
2486
|
+
key.preventDefault();
|
|
2487
|
+
} else if (key.name === "pagedown" || key.name === "pgdn") {
|
|
2488
|
+
setHighlight(Math.min(Math.max(0, filtered.length - 1), clamped + Math.max(1, visibleRows - 1)));
|
|
2489
|
+
key.preventDefault();
|
|
2490
|
+
} else if (key.name === "n" && key.ctrl) {
|
|
2491
|
+
onCreate();
|
|
2492
|
+
key.preventDefault();
|
|
2493
|
+
} else if (key.name === "w" && key.ctrl) {
|
|
2494
|
+
onSwitchWorkspace();
|
|
2495
|
+
key.preventDefault();
|
|
2496
|
+
} else if (key.name === "tab") {
|
|
2497
|
+
onToggleScope();
|
|
2498
|
+
key.preventDefault();
|
|
2260
2499
|
}
|
|
2261
2500
|
});
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
flexGrow: 1
|
|
2266
|
-
},
|
|
2501
|
+
const empty = agents.length === 0;
|
|
2502
|
+
return /* @__PURE__ */ jsxs(WelcomeFrame, {
|
|
2503
|
+
contentWidth,
|
|
2267
2504
|
children: [
|
|
2268
|
-
/* @__PURE__ */ jsx(
|
|
2269
|
-
height,
|
|
2270
|
-
width
|
|
2271
|
-
}),
|
|
2272
|
-
/* @__PURE__ */ jsxs("box", {
|
|
2505
|
+
/* @__PURE__ */ jsx("box", {
|
|
2273
2506
|
style: {
|
|
2274
|
-
|
|
2275
|
-
|
|
2507
|
+
border: true,
|
|
2508
|
+
borderColor: theme.faint,
|
|
2509
|
+
paddingLeft: 1,
|
|
2510
|
+
paddingRight: 1,
|
|
2511
|
+
height: 3,
|
|
2512
|
+
flexShrink: 0,
|
|
2513
|
+
flexDirection: "column"
|
|
2276
2514
|
},
|
|
2277
|
-
children:
|
|
2278
|
-
|
|
2279
|
-
children: "search "
|
|
2280
|
-
}), /* @__PURE__ */ jsx("input", {
|
|
2515
|
+
children: /* @__PURE__ */ jsx("textarea", {
|
|
2516
|
+
ref: composerRef,
|
|
2281
2517
|
textColor: theme.fg,
|
|
2282
2518
|
focusedTextColor: theme.fg,
|
|
2283
2519
|
cursorColor: theme.cursor,
|
|
2284
2520
|
placeholderColor: theme.dim,
|
|
2285
|
-
placeholder: "
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
setQuery(value);
|
|
2521
|
+
placeholder: "Search agents…",
|
|
2522
|
+
keyBindings: searchKeyBindings,
|
|
2523
|
+
onContentChange: () => {
|
|
2524
|
+
setQuery(composerRef.current?.plainText ?? "");
|
|
2290
2525
|
setHighlight(0);
|
|
2526
|
+
},
|
|
2527
|
+
onSubmit: openAgent,
|
|
2528
|
+
focused: true,
|
|
2529
|
+
style: {
|
|
2530
|
+
height: 1,
|
|
2531
|
+
wrapMode: "word"
|
|
2291
2532
|
}
|
|
2292
|
-
})
|
|
2533
|
+
})
|
|
2293
2534
|
}),
|
|
2294
|
-
/* @__PURE__ */
|
|
2535
|
+
/* @__PURE__ */ jsx("text", {
|
|
2295
2536
|
fg: theme.dim,
|
|
2296
|
-
children:
|
|
2297
|
-
filtered.length,
|
|
2298
|
-
"/",
|
|
2299
|
-
agents.length,
|
|
2300
|
-
" · ",
|
|
2301
|
-
scope === "mine" ? "mine" : "all",
|
|
2302
|
-
" ",
|
|
2303
|
-
"· tab ",
|
|
2304
|
-
scope === "mine" ? "all" : "mine",
|
|
2305
|
-
" · ↑/↓ move · ↵ open · ctrl+w workspace · ctrl+n new"
|
|
2306
|
-
]
|
|
2537
|
+
children: truncate(`${scope === "mine" ? "mine" : "all agents"} · ↵ open · ctrl+n new agent · tab ${scope === "mine" ? "all" : "mine"} · ctrl+w workspace`, contentWidth)
|
|
2307
2538
|
}),
|
|
2308
2539
|
/* @__PURE__ */ jsx("box", {
|
|
2309
2540
|
style: {
|
|
2310
2541
|
flexDirection: "column",
|
|
2311
|
-
|
|
2542
|
+
flexShrink: 0
|
|
2312
2543
|
},
|
|
2313
|
-
children:
|
|
2544
|
+
children: empty ? /* @__PURE__ */ jsx("text", {
|
|
2545
|
+
fg: theme.muted,
|
|
2546
|
+
children: truncate(scope === "mine" ? "no agents of yours yet — press ctrl+n to create one, or tab to see all agents." : "no agents in this workspace — press ctrl+n to create one, or ctrl+w to switch workspace.", contentWidth)
|
|
2547
|
+
}) : windowed.length === 0 ? /* @__PURE__ */ jsxs("text", {
|
|
2314
2548
|
fg: theme.muted,
|
|
2315
2549
|
children: [
|
|
2316
2550
|
"no agents match “",
|
|
@@ -2339,45 +2573,12 @@ function FilterableAgentList({ agents, scope, onPick }) {
|
|
|
2339
2573
|
]
|
|
2340
2574
|
});
|
|
2341
2575
|
}
|
|
2342
|
-
|
|
2343
|
-
{
|
|
2344
|
-
get: (a) => a.name,
|
|
2345
|
-
weight: 1
|
|
2346
|
-
},
|
|
2347
|
-
{
|
|
2348
|
-
get: (a) => a.slug ?? "",
|
|
2349
|
-
weight: .6
|
|
2350
|
-
},
|
|
2351
|
-
{
|
|
2352
|
-
get: (a) => a.title ?? "",
|
|
2353
|
-
weight: .4
|
|
2354
|
-
}
|
|
2355
|
-
];
|
|
2356
|
-
|
|
2357
|
-
//#endregion
|
|
2358
|
-
//#region src/chat/tui/agent-name.ts
|
|
2359
|
-
function parseAgentName(value) {
|
|
2360
|
-
const name = value.trim();
|
|
2361
|
-
if (!name) return {
|
|
2362
|
-
ok: false,
|
|
2363
|
-
message: "Enter a name."
|
|
2364
|
-
};
|
|
2365
|
-
if (name.length > 120) return {
|
|
2366
|
-
ok: false,
|
|
2367
|
-
message: "Use 120 characters or fewer."
|
|
2368
|
-
};
|
|
2369
|
-
return {
|
|
2370
|
-
ok: true,
|
|
2371
|
-
name
|
|
2372
|
-
};
|
|
2373
|
-
}
|
|
2374
|
-
|
|
2375
|
-
//#endregion
|
|
2376
|
-
//#region src/chat/tui/screens/agent-create.tsx
|
|
2377
|
-
function AgentCreateScreen() {
|
|
2576
|
+
function CreateAgentMode({ onCancel }) {
|
|
2378
2577
|
const rest = useStore((s) => s.rest);
|
|
2379
2578
|
const goTo = useStore((s) => s.goTo);
|
|
2380
2579
|
const fail = useStore((s) => s.fail);
|
|
2580
|
+
const { width } = useTerminalDimensions();
|
|
2581
|
+
const contentWidth = Math.min(CONTENT_MAX_COLS, Math.max(1, width - 2));
|
|
2381
2582
|
const [name, setName] = useState("");
|
|
2382
2583
|
const [state, setState] = useState({ kind: "idle" });
|
|
2383
2584
|
useEffect(() => {
|
|
@@ -2392,17 +2593,7 @@ function AgentCreateScreen() {
|
|
|
2392
2593
|
};
|
|
2393
2594
|
}, [rest]);
|
|
2394
2595
|
const openChat = (agent) => {
|
|
2395
|
-
goTo(
|
|
2396
|
-
kind: "chat",
|
|
2397
|
-
agent,
|
|
2398
|
-
attachRunId: null,
|
|
2399
|
-
parentConversationId: null,
|
|
2400
|
-
conversation: {
|
|
2401
|
-
kind: "new",
|
|
2402
|
-
agentId: agent.id,
|
|
2403
|
-
nonce: crypto.randomUUID()
|
|
2404
|
-
}
|
|
2405
|
-
});
|
|
2596
|
+
goTo(openAgentAfterCreate(agent));
|
|
2406
2597
|
};
|
|
2407
2598
|
const acceptAlias = (agent, alias) => {
|
|
2408
2599
|
try {
|
|
@@ -2441,7 +2632,7 @@ function AgentCreateScreen() {
|
|
|
2441
2632
|
openChat(state.agent);
|
|
2442
2633
|
return;
|
|
2443
2634
|
}
|
|
2444
|
-
if (key.name === "escape" && state.kind !== "creating")
|
|
2635
|
+
if (key.name === "escape" && state.kind !== "creating") onCancel();
|
|
2445
2636
|
});
|
|
2446
2637
|
const createAgent = async () => {
|
|
2447
2638
|
if (!rest || state.kind === "creating") return;
|
|
@@ -2468,51 +2659,25 @@ function AgentCreateScreen() {
|
|
|
2468
2659
|
fail("Session expired. Run `skydive auth login --web`, then retry.");
|
|
2469
2660
|
return;
|
|
2470
2661
|
}
|
|
2471
|
-
if (err instanceof HttpError && err.status === 403) {
|
|
2472
|
-
setState({
|
|
2473
|
-
kind: "error",
|
|
2474
|
-
message: "You don't have permission to create an agent here."
|
|
2475
|
-
});
|
|
2476
|
-
return;
|
|
2477
|
-
}
|
|
2478
|
-
if (err instanceof HttpError && err.status === 400) {
|
|
2479
|
-
setState({
|
|
2480
|
-
kind: "error",
|
|
2481
|
-
message: "Skydive could not create this agent. Check your active workspace and try again."
|
|
2482
|
-
});
|
|
2483
|
-
return;
|
|
2484
|
-
}
|
|
2485
|
-
if (err instanceof HttpError && err.status === 409) {
|
|
2486
|
-
setState({
|
|
2487
|
-
kind: "error",
|
|
2488
|
-
message: "That name is unavailable. Choose another name and try again."
|
|
2489
|
-
});
|
|
2490
|
-
return;
|
|
2491
|
-
}
|
|
2492
|
-
if (err instanceof HttpError && err.status === 429) {
|
|
2493
|
-
setState({
|
|
2494
|
-
kind: "error",
|
|
2495
|
-
message: "Too many requests. Wait a moment and try again."
|
|
2496
|
-
});
|
|
2497
|
-
return;
|
|
2498
|
-
}
|
|
2499
2662
|
setState({
|
|
2500
2663
|
kind: "error",
|
|
2501
|
-
message: err
|
|
2664
|
+
message: createErrorMessage(err)
|
|
2502
2665
|
});
|
|
2503
2666
|
}
|
|
2504
2667
|
};
|
|
2505
|
-
if (state.kind === "offer-alias") return /* @__PURE__ */ jsx(
|
|
2506
|
-
|
|
2507
|
-
|
|
2668
|
+
if (state.kind === "offer-alias") return /* @__PURE__ */ jsx(WelcomeFrame, {
|
|
2669
|
+
contentWidth,
|
|
2670
|
+
children: /* @__PURE__ */ jsx(AliasOffer, {
|
|
2671
|
+
agentName: state.agent.name,
|
|
2672
|
+
alias: state.alias
|
|
2673
|
+
})
|
|
2508
2674
|
});
|
|
2509
|
-
if (state.kind === "aliased") return /* @__PURE__ */ jsx(
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
},
|
|
2675
|
+
if (state.kind === "aliased") return /* @__PURE__ */ jsx(WelcomeFrame, {
|
|
2676
|
+
contentWidth,
|
|
2677
|
+
children: /* @__PURE__ */ jsx(AliasResult, { message: state.message })
|
|
2678
|
+
});
|
|
2679
|
+
return /* @__PURE__ */ jsxs(WelcomeFrame, {
|
|
2680
|
+
contentWidth,
|
|
2516
2681
|
children: [
|
|
2517
2682
|
/* @__PURE__ */ jsx("text", {
|
|
2518
2683
|
fg: theme.fg,
|
|
@@ -2522,6 +2687,7 @@ function AgentCreateScreen() {
|
|
|
2522
2687
|
fg: theme.muted,
|
|
2523
2688
|
children: "Choose a name. You can change it later."
|
|
2524
2689
|
}),
|
|
2690
|
+
/* @__PURE__ */ jsx("box", { style: { height: 1 } }),
|
|
2525
2691
|
/* @__PURE__ */ jsx("box", {
|
|
2526
2692
|
style: {
|
|
2527
2693
|
border: true,
|
|
@@ -2568,7 +2734,6 @@ function AliasOffer({ agentName, alias }) {
|
|
|
2568
2734
|
return /* @__PURE__ */ jsxs("box", {
|
|
2569
2735
|
style: {
|
|
2570
2736
|
flexDirection: "column",
|
|
2571
|
-
flexGrow: 1,
|
|
2572
2737
|
gap: 1
|
|
2573
2738
|
},
|
|
2574
2739
|
children: [
|
|
@@ -2600,7 +2765,6 @@ function AliasResult({ message }) {
|
|
|
2600
2765
|
return /* @__PURE__ */ jsxs("box", {
|
|
2601
2766
|
style: {
|
|
2602
2767
|
flexDirection: "column",
|
|
2603
|
-
flexGrow: 1,
|
|
2604
2768
|
gap: 1
|
|
2605
2769
|
},
|
|
2606
2770
|
children: [/* @__PURE__ */ jsx("text", {
|
|
@@ -2612,22 +2776,14 @@ function AliasResult({ message }) {
|
|
|
2612
2776
|
})]
|
|
2613
2777
|
});
|
|
2614
2778
|
}
|
|
2615
|
-
|
|
2616
|
-
//#endregion
|
|
2617
|
-
//#region src/chat/tui/screens/first-run.tsx
|
|
2618
|
-
/** Rows the mark lockup needs before it's worth painting (matches the
|
|
2619
|
-
* new-chat splash): the 6-row mark, a spacer, and a couple of content rows.
|
|
2620
|
-
* Below this the mark is dropped and only the message renders. */
|
|
2621
|
-
const MARK_MIN_ROWS$1 = 12;
|
|
2622
|
-
/** Columns the full mark + wordmark lockup spans; below this the pinwheel
|
|
2623
|
-
* mark renders alone instead of wrapping the wordmark. */
|
|
2624
|
-
const FULL_MARK_MIN_COLS$1 = 70;
|
|
2625
|
-
function FirstRunScreen() {
|
|
2779
|
+
function FirstRunMode() {
|
|
2626
2780
|
const rest = useStore((s) => s.rest);
|
|
2627
2781
|
const goTo = useStore((s) => s.goTo);
|
|
2628
2782
|
const fail = useStore((s) => s.fail);
|
|
2629
2783
|
const authKind = useStore((s) => s.authKind);
|
|
2630
2784
|
const forced = useStore((s) => s.forcedOnboarding);
|
|
2785
|
+
const { width } = useTerminalDimensions();
|
|
2786
|
+
const contentWidth = Math.min(CONTENT_MAX_COLS, Math.max(1, width - 2));
|
|
2631
2787
|
const [state, setState] = useState({ kind: "creating" });
|
|
2632
2788
|
const canShareMachine = authKind === "session";
|
|
2633
2789
|
useEffect(() => {
|
|
@@ -2658,17 +2814,7 @@ function FirstRunScreen() {
|
|
|
2658
2814
|
};
|
|
2659
2815
|
}, [rest, fail]);
|
|
2660
2816
|
const openNewChat = (agent) => {
|
|
2661
|
-
goTo(
|
|
2662
|
-
kind: "chat",
|
|
2663
|
-
agent,
|
|
2664
|
-
attachRunId: null,
|
|
2665
|
-
parentConversationId: null,
|
|
2666
|
-
conversation: {
|
|
2667
|
-
kind: "new",
|
|
2668
|
-
agentId: agent.id,
|
|
2669
|
-
nonce: crypto.randomUUID()
|
|
2670
|
-
}
|
|
2671
|
-
});
|
|
2817
|
+
goTo(openAgentAfterCreate(agent));
|
|
2672
2818
|
};
|
|
2673
2819
|
const openOnboardingChat = (agent, conversationId, title, attachRunId) => {
|
|
2674
2820
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2740,84 +2886,100 @@ function FirstRunScreen() {
|
|
|
2740
2886
|
if (key.name === "return" || key.name === "y") acceptImport(state.agent);
|
|
2741
2887
|
else if (key.name === "n" || key.name === "escape") declineImport(state.agent);
|
|
2742
2888
|
});
|
|
2743
|
-
return /* @__PURE__ */ jsx(
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2889
|
+
return /* @__PURE__ */ jsx(WelcomeFrame, {
|
|
2890
|
+
contentWidth,
|
|
2891
|
+
children: /* @__PURE__ */ jsx(FirstRunBody, {
|
|
2892
|
+
state,
|
|
2893
|
+
forced,
|
|
2894
|
+
canShareMachine
|
|
2895
|
+
})
|
|
2896
|
+
});
|
|
2748
2897
|
}
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
function Centered({ children }) {
|
|
2752
|
-
const { width, height } = useTerminalDimensions();
|
|
2753
|
-
return /* @__PURE__ */ jsxs("box", {
|
|
2898
|
+
function FirstRunBody({ state, forced, canShareMachine }) {
|
|
2899
|
+
if (state.kind === "error") return /* @__PURE__ */ jsxs("box", {
|
|
2754
2900
|
style: {
|
|
2755
|
-
flexGrow: 1,
|
|
2756
2901
|
flexDirection: "column",
|
|
2757
|
-
|
|
2758
|
-
|
|
2902
|
+
alignItems: "center",
|
|
2903
|
+
gap: 1
|
|
2759
2904
|
},
|
|
2760
|
-
children: [
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
children: /* @__PURE__ */ jsx(SkydiveMark, { variant: width >= FULL_MARK_MIN_COLS$1 ? "full" : "mark" })
|
|
2767
|
-
}) : null, /* @__PURE__ */ jsx("box", {
|
|
2768
|
-
style: {
|
|
2769
|
-
flexDirection: "column",
|
|
2770
|
-
alignItems: "center",
|
|
2771
|
-
gap: 1
|
|
2772
|
-
},
|
|
2773
|
-
children
|
|
2905
|
+
children: [/* @__PURE__ */ jsx("text", {
|
|
2906
|
+
fg: theme.error,
|
|
2907
|
+
children: state.message
|
|
2908
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
2909
|
+
fg: theme.muted,
|
|
2910
|
+
children: "press ctrl+c to quit, then run `skydive` again to retry."
|
|
2774
2911
|
})]
|
|
2775
2912
|
});
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
children: "Welcome to Skydive."
|
|
2788
|
-
}), /* @__PURE__ */ jsx("text", {
|
|
2789
|
-
fg: theme.muted,
|
|
2790
|
-
children: forced ? "creating an agent…" : "setting up your first agent…"
|
|
2791
|
-
})] });
|
|
2913
|
+
if (state.kind === "creating") return /* @__PURE__ */ jsx("box", {
|
|
2914
|
+
style: {
|
|
2915
|
+
flexDirection: "column",
|
|
2916
|
+
alignItems: "center",
|
|
2917
|
+
gap: 1
|
|
2918
|
+
},
|
|
2919
|
+
children: /* @__PURE__ */ jsx("text", {
|
|
2920
|
+
fg: theme.muted,
|
|
2921
|
+
children: forced ? "creating an agent…" : "setting up your first agent…"
|
|
2922
|
+
})
|
|
2923
|
+
});
|
|
2792
2924
|
if (state.kind === "starting") return /* @__PURE__ */ jsx("text", {
|
|
2793
2925
|
fg: theme.muted,
|
|
2794
2926
|
children: "opening your agent…"
|
|
2795
2927
|
});
|
|
2796
2928
|
const { agent } = state;
|
|
2797
|
-
return /* @__PURE__ */ jsxs(
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2929
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
2930
|
+
style: {
|
|
2931
|
+
flexDirection: "column",
|
|
2932
|
+
alignItems: "center",
|
|
2933
|
+
gap: 1
|
|
2934
|
+
},
|
|
2935
|
+
children: [/* @__PURE__ */ jsx("text", {
|
|
2936
|
+
fg: theme.fg,
|
|
2937
|
+
children: forced ? `${agent.name} is ready.` : `Your first agent, ${agent.name}, is ready.`
|
|
2938
|
+
}), canShareMachine ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("text", {
|
|
2939
|
+
fg: theme.muted,
|
|
2940
|
+
children: [
|
|
2941
|
+
"Want ",
|
|
2942
|
+
agent.name,
|
|
2943
|
+
" to learn how you already work? It can look at the coding agents set up on this machine to get up to speed fast. It will connect to your computer to read your setup, show you a plan first, and never copy credentials."
|
|
2944
|
+
]
|
|
2945
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
2946
|
+
fg: theme.dim,
|
|
2947
|
+
children: "↵/y yes, learn from my setup · n skip"
|
|
2948
|
+
})] }) : /* @__PURE__ */ jsx("text", {
|
|
2949
|
+
fg: theme.dim,
|
|
2950
|
+
children: "press any key to open the chat"
|
|
2951
|
+
})]
|
|
2952
|
+
});
|
|
2814
2953
|
}
|
|
2815
2954
|
function createErrorMessage(err) {
|
|
2816
2955
|
if (err instanceof HttpError && err.status === 403) return "You don't have permission to create an agent in this workspace.";
|
|
2817
|
-
if (err instanceof HttpError && err.status === 429) return "Too many requests. Wait a moment, then
|
|
2956
|
+
if (err instanceof HttpError && err.status === 429) return "Too many requests. Wait a moment, then try again.";
|
|
2957
|
+
if (err instanceof HttpError && err.status === 409) return "That name is unavailable. Choose another name and try again.";
|
|
2958
|
+
if (err instanceof HttpError && err.status === 400) return "Skydive could not create this agent. Check your active workspace and try again.";
|
|
2818
2959
|
if (err instanceof HttpError) return "Skydive could not create your agent. Try again in a moment.";
|
|
2819
2960
|
return "Could not reach Skydive. Check your connection and try again.";
|
|
2820
2961
|
}
|
|
2962
|
+
const searchKeyBindings = [{
|
|
2963
|
+
name: "return",
|
|
2964
|
+
action: "submit"
|
|
2965
|
+
}, {
|
|
2966
|
+
name: "kpenter",
|
|
2967
|
+
action: "submit"
|
|
2968
|
+
}];
|
|
2969
|
+
const agentKeys = [
|
|
2970
|
+
{
|
|
2971
|
+
get: (a) => a.name,
|
|
2972
|
+
weight: 1
|
|
2973
|
+
},
|
|
2974
|
+
{
|
|
2975
|
+
get: (a) => a.slug ?? "",
|
|
2976
|
+
weight: .6
|
|
2977
|
+
},
|
|
2978
|
+
{
|
|
2979
|
+
get: (a) => a.title ?? "",
|
|
2980
|
+
weight: .4
|
|
2981
|
+
}
|
|
2982
|
+
];
|
|
2821
2983
|
|
|
2822
2984
|
//#endregion
|
|
2823
2985
|
//#region src/chat/tui/screens/workspace-picker.tsx
|
|
@@ -2960,85 +3122,430 @@ function WorkspacePicker({ appUrl, sessionToken, onSelect, onCancel }) {
|
|
|
2960
3122
|
}
|
|
2961
3123
|
|
|
2962
3124
|
//#endregion
|
|
2963
|
-
//#region src/chat/
|
|
3125
|
+
//#region src/chat/paste.ts
|
|
3126
|
+
const MAX_PATH_PASTE_CHARS = 4096;
|
|
2964
3127
|
/**
|
|
2965
|
-
*
|
|
2966
|
-
*
|
|
2967
|
-
*
|
|
2968
|
-
*
|
|
2969
|
-
*
|
|
2970
|
-
*
|
|
2971
|
-
* The replacement is 1:1 (one code point in, one out) so fuzzy-match highlight
|
|
2972
|
-
* indexes computed against the raw string still line up with the displayed
|
|
2973
|
-
* text — do the same substitution on the fuzzy key.
|
|
3128
|
+
* Decode a `file://` URL to a filesystem path, host-independently. Node's
|
|
3129
|
+
* `fileURLToPath` maps to the *running* OS's path shape, so a POSIX file URL
|
|
3130
|
+
* pasted on Windows would come back as a backslash drive-relative path. A
|
|
3131
|
+
* dropped-file URL is decided by its own shape, not by our host: a Windows
|
|
3132
|
+
* drive URL (`file:///C:/...`) yields a Windows path, anything else a POSIX
|
|
3133
|
+
* path. Returns null for a malformed URL.
|
|
2974
3134
|
*/
|
|
2975
|
-
function
|
|
2976
|
-
let
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
3135
|
+
function fileUrlToPath(url) {
|
|
3136
|
+
let parsed;
|
|
3137
|
+
try {
|
|
3138
|
+
parsed = new URL(url);
|
|
3139
|
+
} catch (_error) {
|
|
3140
|
+
return null;
|
|
2980
3141
|
}
|
|
2981
|
-
return
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
* `width` columns, each rendered separated by one space. The title identifies
|
|
2986
|
-
* the row so it gets the space first; the tag comes next because it carries
|
|
2987
|
-
* information the detail does not; the detail takes what is left and is
|
|
2988
|
-
* dropped when nothing is.
|
|
2989
|
-
*/
|
|
2990
|
-
function fitRowText(title, detail, width, tag = "") {
|
|
2991
|
-
if (width <= 0) return {
|
|
2992
|
-
title: "",
|
|
2993
|
-
detail: "",
|
|
2994
|
-
tag: ""
|
|
2995
|
-
};
|
|
2996
|
-
const segment = (text) => text ? text.length + 1 : 0;
|
|
2997
|
-
if (title.length + segment(tag) + segment(detail) <= width) return {
|
|
2998
|
-
title,
|
|
2999
|
-
detail,
|
|
3000
|
-
tag
|
|
3001
|
-
};
|
|
3002
|
-
const room = width - title.length - 1;
|
|
3003
|
-
if (room < 2) return {
|
|
3004
|
-
title: truncate(title, width),
|
|
3005
|
-
detail: "",
|
|
3006
|
-
tag: ""
|
|
3007
|
-
};
|
|
3008
|
-
if (title.length + segment(tag) > width) return {
|
|
3009
|
-
title,
|
|
3010
|
-
detail: "",
|
|
3011
|
-
tag: truncate(tag, room)
|
|
3012
|
-
};
|
|
3013
|
-
const detailRoom = room - segment(tag);
|
|
3014
|
-
if (detailRoom < 2) return {
|
|
3015
|
-
title,
|
|
3016
|
-
detail: "",
|
|
3017
|
-
tag
|
|
3018
|
-
};
|
|
3019
|
-
return {
|
|
3020
|
-
title,
|
|
3021
|
-
detail: truncate(detail, detailRoom),
|
|
3022
|
-
tag
|
|
3023
|
-
};
|
|
3142
|
+
if (parsed.protocol !== "file:") return null;
|
|
3143
|
+
const decoded = decodeURIComponent(parsed.pathname);
|
|
3144
|
+
if (/^\/[a-zA-Z]:\//.test(decoded)) return decoded.slice(1).replace(/\//g, "\\");
|
|
3145
|
+
return decoded;
|
|
3024
3146
|
}
|
|
3025
3147
|
/**
|
|
3026
|
-
*
|
|
3027
|
-
*
|
|
3028
|
-
* The first segment is always kept (truncated if it has to be).
|
|
3148
|
+
* Parse the text a terminal emits for dropped files. This only identifies
|
|
3149
|
+
* path-shaped candidates; filesystem and content validation happen later.
|
|
3029
3150
|
*/
|
|
3030
|
-
function
|
|
3031
|
-
const
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3151
|
+
function parseDroppedPaths(text) {
|
|
3152
|
+
const trimmed = text.trim();
|
|
3153
|
+
if (!trimmed || trimmed.length > MAX_PATH_PASTE_CHARS || /[\r\n]/.test(trimmed)) return null;
|
|
3154
|
+
const tokens = [];
|
|
3155
|
+
let current = "";
|
|
3156
|
+
let quote = null;
|
|
3157
|
+
for (let i = 0; i < trimmed.length; i += 1) {
|
|
3158
|
+
const ch = trimmed.charAt(i);
|
|
3159
|
+
if (quote) {
|
|
3160
|
+
if (ch === quote) quote = null;
|
|
3161
|
+
else if (ch === "\\" && quote === "\"" && i + 1 < trimmed.length) {
|
|
3162
|
+
current += trimmed.charAt(i + 1);
|
|
3163
|
+
i += 1;
|
|
3164
|
+
} else current += ch;
|
|
3165
|
+
continue;
|
|
3166
|
+
}
|
|
3167
|
+
if (ch === "'" || ch === "\"") {
|
|
3168
|
+
quote = ch;
|
|
3169
|
+
continue;
|
|
3170
|
+
}
|
|
3171
|
+
if (ch === "\\" && i + 1 < trimmed.length) {
|
|
3172
|
+
current += trimmed.charAt(i + 1);
|
|
3173
|
+
i += 1;
|
|
3174
|
+
continue;
|
|
3175
|
+
}
|
|
3176
|
+
if (ch === " " || ch === " ") {
|
|
3177
|
+
if (current) {
|
|
3178
|
+
tokens.push(current);
|
|
3179
|
+
current = "";
|
|
3180
|
+
}
|
|
3181
|
+
continue;
|
|
3182
|
+
}
|
|
3183
|
+
current += ch;
|
|
3035
3184
|
}
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3185
|
+
if (quote) return null;
|
|
3186
|
+
if (current) tokens.push(current);
|
|
3187
|
+
if (tokens.length === 0) return null;
|
|
3188
|
+
const paths = [];
|
|
3189
|
+
for (const token of tokens) {
|
|
3190
|
+
let path = token;
|
|
3191
|
+
if (path.startsWith("file://")) {
|
|
3192
|
+
const resolved = fileUrlToPath(path);
|
|
3193
|
+
if (resolved === null) return null;
|
|
3194
|
+
path = resolved;
|
|
3195
|
+
}
|
|
3196
|
+
if (path.startsWith("~/")) path = `${homedir()}${path.slice(1)}`;
|
|
3197
|
+
if (!isAbsolute(path) && !win32.isAbsolute(path) && !path.startsWith("./") && !path.startsWith("../")) return null;
|
|
3198
|
+
paths.push(path);
|
|
3199
|
+
}
|
|
3200
|
+
return paths;
|
|
3201
|
+
}
|
|
3202
|
+
const TEXT_MIME_BY_EXT = {
|
|
3203
|
+
".txt": "text/plain",
|
|
3204
|
+
".md": "text/markdown",
|
|
3205
|
+
".markdown": "text/markdown",
|
|
3206
|
+
".csv": "text/csv",
|
|
3207
|
+
".tsv": "text/tab-separated-values",
|
|
3208
|
+
".json": "application/json",
|
|
3209
|
+
".yaml": "application/yaml",
|
|
3210
|
+
".yml": "application/yaml",
|
|
3211
|
+
".xml": "application/xml",
|
|
3212
|
+
".html": "text/html",
|
|
3213
|
+
".htm": "text/html",
|
|
3214
|
+
".css": "text/css",
|
|
3215
|
+
".js": "text/javascript",
|
|
3216
|
+
".ts": "text/plain",
|
|
3217
|
+
".tsx": "text/plain",
|
|
3218
|
+
".jsx": "text/plain",
|
|
3219
|
+
".py": "text/x-python",
|
|
3220
|
+
".sh": "text/x-shellscript",
|
|
3221
|
+
".log": "text/plain",
|
|
3222
|
+
".svg": "image/svg+xml"
|
|
3223
|
+
};
|
|
3224
|
+
/** Decide how to handle a paste. `kind: 'binary'` events carry the mime of
|
|
3225
|
+
* bytes the terminal forwarded; text events carry the decoded paste text.
|
|
3226
|
+
*
|
|
3227
|
+
* The empty-text case is the fix for "can't paste an image": on macOS the
|
|
3228
|
+
* reflex is Cmd+V, which the terminal turns into a bracketed *text* paste, so
|
|
3229
|
+
* a screenshot on the clipboard arrives as empty bytes. We route that to an OS
|
|
3230
|
+
* clipboard read instead of dropping it, matching the explicit ctrl+v path. */
|
|
3231
|
+
function routePaste(input) {
|
|
3232
|
+
if (input.kind === "binary") {
|
|
3233
|
+
if (input.mimeType?.startsWith("image/")) return {
|
|
3234
|
+
kind: "binary-image",
|
|
3235
|
+
mediaType: input.mimeType
|
|
3236
|
+
};
|
|
3237
|
+
return { kind: "text" };
|
|
3238
|
+
}
|
|
3239
|
+
const paths = parseDroppedPaths(input.text);
|
|
3240
|
+
if (paths) return {
|
|
3241
|
+
kind: "dropped-paths",
|
|
3242
|
+
paths,
|
|
3243
|
+
text: input.text
|
|
3244
|
+
};
|
|
3245
|
+
if (input.text.trim() === "") return { kind: "clipboard-image" };
|
|
3246
|
+
return { kind: "text" };
|
|
3247
|
+
}
|
|
3248
|
+
/**
|
|
3249
|
+
* Resolve path candidates into attachable files. Every path must be an
|
|
3250
|
+
* existing regular file; otherwise the caller should restore the original
|
|
3251
|
+
* paste as text (an all-or-nothing rule so a paste meant as text — which
|
|
3252
|
+
* merely *looks* path-shaped — is never partially eaten).
|
|
3253
|
+
*
|
|
3254
|
+
* Any file type attaches. The mediaType comes from magic bytes when
|
|
3255
|
+
* detectable, an extension map for the plain-text formats magic can't see,
|
|
3256
|
+
* and application/octet-stream as the last resort — the attachments API
|
|
3257
|
+
* accepts any mediaType (100MB cap enforced server-side at presign).
|
|
3258
|
+
*/
|
|
3259
|
+
async function resolveDroppedFiles(paths) {
|
|
3260
|
+
try {
|
|
3261
|
+
const files = await Promise.all(paths.map(async (path) => {
|
|
3262
|
+
if (!(await stat(path)).isFile()) return null;
|
|
3263
|
+
const data = new Uint8Array(await readFile(path));
|
|
3264
|
+
const mediaType = (await fileTypeFromBuffer(data))?.mime ?? TEXT_MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
3265
|
+
return {
|
|
3266
|
+
fileName: basename(path),
|
|
3267
|
+
mediaType,
|
|
3268
|
+
data
|
|
3269
|
+
};
|
|
3270
|
+
}));
|
|
3271
|
+
if (!files.every((file) => file !== null)) return null;
|
|
3272
|
+
return files;
|
|
3273
|
+
} catch (_error) {
|
|
3274
|
+
return null;
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
//#endregion
|
|
3279
|
+
//#region src/chat/tui/composer-attachments.tsx
|
|
3280
|
+
/**
|
|
3281
|
+
* Attachments a composer can stage for its next send, shared by every screen
|
|
3282
|
+
* that owns a composer (the chat transcript and the agent home). Staging
|
|
3283
|
+
* starts the upload immediately — the web composer's behavior — so submit
|
|
3284
|
+
* only awaits the in-flight uploads and collects their server ids.
|
|
3285
|
+
*/
|
|
3286
|
+
/** Files one composer can hold at once, so a stray multi-file drop can't
|
|
3287
|
+
* queue an unbounded pile of uploads. */
|
|
3288
|
+
const MAX_COMPOSER_ATTACHMENTS = 10;
|
|
3289
|
+
/**
|
|
3290
|
+
* Owns a composer's staged attachments and the paste routing that feeds them.
|
|
3291
|
+
*
|
|
3292
|
+
* `pasteDisabled` suppresses attaching while the screen has something else in
|
|
3293
|
+
* front of the composer (an overlay, a prompt, a channel with no composer at
|
|
3294
|
+
* all); the paste then falls through to whatever is focused, untouched.
|
|
3295
|
+
* `onPasteText` restores a paste that only *looked* like dropped file paths —
|
|
3296
|
+
* the filesystem check is async, so the text has to be held and put back.
|
|
3297
|
+
* `onError` receives user-facing failures (the cap, a failed upload) because
|
|
3298
|
+
* each screen surfaces them differently: the transcript takes an error row,
|
|
3299
|
+
* the home takes its status line.
|
|
3300
|
+
*/
|
|
3301
|
+
function useComposerAttachments(input) {
|
|
3302
|
+
const { rest, agentId, pasteDisabled, onPasteText, onError } = input;
|
|
3303
|
+
const [staged, setStaged] = useState([]);
|
|
3304
|
+
const uploadsRef = useRef(/* @__PURE__ */ new Map());
|
|
3305
|
+
const stagedRef = useRef(staged);
|
|
3306
|
+
stagedRef.current = staged;
|
|
3307
|
+
const stageFile = useCallback(({ fileName, mediaType, data }) => {
|
|
3308
|
+
if (!rest) return;
|
|
3309
|
+
if (stagedRef.current.length >= MAX_COMPOSER_ATTACHMENTS) {
|
|
3310
|
+
onError(`attachment limit reached (${MAX_COMPOSER_ATTACHMENTS}); ${fileName} skipped`);
|
|
3311
|
+
return;
|
|
3312
|
+
}
|
|
3313
|
+
const tempId = crypto.randomUUID();
|
|
3314
|
+
setStaged((prev) => [...prev, {
|
|
3315
|
+
tempId,
|
|
3316
|
+
fileName,
|
|
3317
|
+
sizeBytes: data.byteLength,
|
|
3318
|
+
status: "uploading",
|
|
3319
|
+
errorText: null
|
|
3320
|
+
}]);
|
|
3321
|
+
const promise = rest.uploadAttachment({
|
|
3322
|
+
agentId,
|
|
3323
|
+
fileName,
|
|
3324
|
+
mediaType,
|
|
3325
|
+
data
|
|
3326
|
+
});
|
|
3327
|
+
uploadsRef.current.set(tempId, promise);
|
|
3328
|
+
promise.then(() => {
|
|
3329
|
+
setStaged((prev) => prev.map((a) => a.tempId === tempId ? {
|
|
3330
|
+
...a,
|
|
3331
|
+
status: "ready"
|
|
3332
|
+
} : a));
|
|
3333
|
+
}).catch((err) => {
|
|
3334
|
+
setStaged((prev) => prev.map((a) => a.tempId === tempId ? {
|
|
3335
|
+
...a,
|
|
3336
|
+
status: "error",
|
|
3337
|
+
errorText: errorMessage(err)
|
|
3338
|
+
} : a));
|
|
3339
|
+
});
|
|
3340
|
+
}, [
|
|
3341
|
+
rest,
|
|
3342
|
+
agentId,
|
|
3343
|
+
onError
|
|
3344
|
+
]);
|
|
3345
|
+
const stageFiles = useCallback((files) => {
|
|
3346
|
+
for (const file of files) stageFile(file);
|
|
3347
|
+
}, [stageFile]);
|
|
3348
|
+
const stageClipboardImage = useCallback(async () => {
|
|
3349
|
+
const image = await readClipboardImage();
|
|
3350
|
+
if (!image) return;
|
|
3351
|
+
stageFile(image);
|
|
3352
|
+
}, [stageFile]);
|
|
3353
|
+
const dropLast = useCallback(() => {
|
|
3354
|
+
const last = stagedRef.current.at(-1);
|
|
3355
|
+
if (!last) return;
|
|
3356
|
+
uploadsRef.current.delete(last.tempId);
|
|
3357
|
+
setStaged((prev) => prev.filter((a) => a.tempId !== last.tempId));
|
|
3358
|
+
}, []);
|
|
3359
|
+
const clearStaged = useCallback(() => setStaged([]), []);
|
|
3360
|
+
const collectForSend = useCallback(async (staged) => {
|
|
3361
|
+
const files = [];
|
|
3362
|
+
const rows = [];
|
|
3363
|
+
const failed = [];
|
|
3364
|
+
const settled = await Promise.allSettled(staged.map((a) => uploadsRef.current.get(a.tempId)));
|
|
3365
|
+
for (const [i, result] of settled.entries()) {
|
|
3366
|
+
const row = staged[i];
|
|
3367
|
+
if (!row) continue;
|
|
3368
|
+
if (result.status === "fulfilled" && result.value) {
|
|
3369
|
+
files.push(result.value);
|
|
3370
|
+
rows.push({
|
|
3371
|
+
...row,
|
|
3372
|
+
status: "ready",
|
|
3373
|
+
errorText: null
|
|
3374
|
+
});
|
|
3375
|
+
} else {
|
|
3376
|
+
uploadsRef.current.delete(row.tempId);
|
|
3377
|
+
failed.push({
|
|
3378
|
+
attachment: row,
|
|
3379
|
+
reason: result.status === "rejected" ? result.reason : null
|
|
3380
|
+
});
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
return {
|
|
3384
|
+
files,
|
|
3385
|
+
rows,
|
|
3386
|
+
failed
|
|
3387
|
+
};
|
|
3388
|
+
}, []);
|
|
3389
|
+
const releaseUploaded = useCallback((rows) => {
|
|
3390
|
+
for (const row of rows) uploadsRef.current.delete(row.tempId);
|
|
3391
|
+
}, []);
|
|
3392
|
+
const adoptUploaded = useCallback((uploaded) => uploaded.map((file) => {
|
|
3393
|
+
const tempId = crypto.randomUUID();
|
|
3394
|
+
uploadsRef.current.set(tempId, Promise.resolve(file));
|
|
3395
|
+
return {
|
|
3396
|
+
tempId,
|
|
3397
|
+
fileName: file.fileName,
|
|
3398
|
+
sizeBytes: file.sizeBytes,
|
|
3399
|
+
status: "ready",
|
|
3400
|
+
errorText: null
|
|
3401
|
+
};
|
|
3402
|
+
}), []);
|
|
3403
|
+
const restoreStaged = useCallback((rows) => {
|
|
3404
|
+
const restore = rows.filter((a) => uploadsRef.current.has(a.tempId));
|
|
3405
|
+
if (restore.length === 0) return;
|
|
3406
|
+
setStaged((prev) => [...restore.map((a) => ({
|
|
3407
|
+
...a,
|
|
3408
|
+
status: "ready",
|
|
3409
|
+
errorText: null
|
|
3410
|
+
})), ...prev]);
|
|
3411
|
+
}, []);
|
|
3412
|
+
usePaste((event) => {
|
|
3413
|
+
if (pasteDisabled) return;
|
|
3414
|
+
const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
|
|
3415
|
+
const route = routePaste({
|
|
3416
|
+
kind: event.metadata?.kind,
|
|
3417
|
+
mimeType: event.metadata?.mimeType,
|
|
3418
|
+
text
|
|
3419
|
+
});
|
|
3420
|
+
switch (route.kind) {
|
|
3421
|
+
case "binary-image":
|
|
3422
|
+
event.preventDefault();
|
|
3423
|
+
stageFiles([{
|
|
3424
|
+
fileName: pastedImageName(route.mediaType),
|
|
3425
|
+
mediaType: route.mediaType,
|
|
3426
|
+
data: event.bytes
|
|
3427
|
+
}]);
|
|
3428
|
+
return;
|
|
3429
|
+
case "clipboard-image":
|
|
3430
|
+
event.preventDefault();
|
|
3431
|
+
stageClipboardImage();
|
|
3432
|
+
return;
|
|
3433
|
+
case "dropped-paths":
|
|
3434
|
+
event.preventDefault();
|
|
3435
|
+
resolveDroppedFiles(route.paths).then((files) => {
|
|
3436
|
+
if (files) {
|
|
3437
|
+
stageFiles(files);
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
3440
|
+
onPasteText(route.text);
|
|
3441
|
+
});
|
|
3442
|
+
return;
|
|
3443
|
+
case "text": return;
|
|
3444
|
+
}
|
|
3445
|
+
});
|
|
3446
|
+
return {
|
|
3447
|
+
staged,
|
|
3448
|
+
stagedRef,
|
|
3449
|
+
stageFiles,
|
|
3450
|
+
stageClipboardImage,
|
|
3451
|
+
dropLast,
|
|
3452
|
+
clearStaged,
|
|
3453
|
+
collectForSend,
|
|
3454
|
+
adoptUploaded,
|
|
3455
|
+
releaseUploaded,
|
|
3456
|
+
restoreStaged
|
|
3457
|
+
};
|
|
3458
|
+
}
|
|
3459
|
+
/** Name for clipboard image bytes, which arrive with no filename of their
|
|
3460
|
+
* own: `pasted-image-<timestamp>.<ext>` from the paste's own media type. */
|
|
3461
|
+
function pastedImageName(mediaType) {
|
|
3462
|
+
const ext = mediaType.split("/")[1]?.split("+")[0] ?? "png";
|
|
3463
|
+
return `pasted-image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.${ext}`;
|
|
3464
|
+
}
|
|
3465
|
+
function formatBytes$2(bytes) {
|
|
3466
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
3467
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
3468
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
3469
|
+
}
|
|
3470
|
+
/** Status glyph for a staged attachment chip: uploading / ready / failed. */
|
|
3471
|
+
function attachmentGlyph(a) {
|
|
3472
|
+
switch (a.status) {
|
|
3473
|
+
case "uploading": return "↑";
|
|
3474
|
+
case "ready": return "✓";
|
|
3475
|
+
case "error": return `✗ ${a.errorText ?? "upload failed"}`;
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
/** Rows a rendered tray occupies: one per attachment plus the hint line and
|
|
3479
|
+
* its top margin, so a screen can budget its own layout around it. */
|
|
3480
|
+
function attachmentTrayRows(count) {
|
|
3481
|
+
return count === 0 ? 0 : count + 2;
|
|
3482
|
+
}
|
|
3483
|
+
/** The staged-attachment tray under a composer: a chip per file with its
|
|
3484
|
+
* upload state, and the chord that removes the last one. */
|
|
3485
|
+
function AttachmentTray({ attachments }) {
|
|
3486
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
3487
|
+
style: {
|
|
3488
|
+
paddingLeft: 1,
|
|
3489
|
+
paddingRight: 1,
|
|
3490
|
+
flexShrink: 0,
|
|
3491
|
+
marginTop: 1,
|
|
3492
|
+
flexDirection: "column"
|
|
3493
|
+
},
|
|
3494
|
+
children: [attachments.map((a) => /* @__PURE__ */ jsxs("text", {
|
|
3495
|
+
fg: a.status === "error" ? theme.error : theme.muted,
|
|
3496
|
+
children: [
|
|
3497
|
+
"📎 ",
|
|
3498
|
+
a.fileName,
|
|
3499
|
+
" (",
|
|
3500
|
+
formatBytes$2(a.sizeBytes),
|
|
3501
|
+
") ",
|
|
3502
|
+
attachmentGlyph(a)
|
|
3503
|
+
]
|
|
3504
|
+
}, a.tempId)), /* @__PURE__ */ jsx("text", {
|
|
3505
|
+
fg: theme.dim,
|
|
3506
|
+
children: "ctrl+x remove last"
|
|
3507
|
+
})]
|
|
3508
|
+
});
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
//#endregion
|
|
3512
|
+
//#region src/chat/tui/list-line.ts
|
|
3513
|
+
/**
|
|
3514
|
+
* Flatten `text` to a single terminal line: newlines, carriage returns, tabs
|
|
3515
|
+
* and other C0/C1 control characters each become one space. A picker row is
|
|
3516
|
+
* budgeted as exactly one line (see the header comment), so a title carrying a
|
|
3517
|
+
* literal newline would otherwise render as two lines, push the rows box past
|
|
3518
|
+
* the height flexbox gave it, and collapse the rows above onto each other.
|
|
3519
|
+
*
|
|
3520
|
+
* The replacement is 1:1 (one code point in, one out) so fuzzy-match highlight
|
|
3521
|
+
* indexes computed against the raw string still line up with the displayed
|
|
3522
|
+
* text — do the same substitution on the fuzzy key.
|
|
3523
|
+
*/
|
|
3524
|
+
function singleLine(text) {
|
|
3525
|
+
let out = "";
|
|
3526
|
+
for (const ch of text) {
|
|
3527
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
3528
|
+
out += code <= 31 || code >= 127 && code <= 159 ? " " : ch;
|
|
3529
|
+
}
|
|
3530
|
+
return out;
|
|
3531
|
+
}
|
|
3532
|
+
/**
|
|
3533
|
+
* Join hint segments with " · ", dropping trailing segments that do not fit
|
|
3534
|
+
* rather than wrapping onto a second line — which would cost the list a row.
|
|
3535
|
+
* The first segment is always kept (truncated if it has to be).
|
|
3536
|
+
*/
|
|
3537
|
+
function fitHintLine(segments, width) {
|
|
3538
|
+
const kept = [];
|
|
3539
|
+
for (const segment of segments) {
|
|
3540
|
+
if (kept.length > 0 && [...kept, segment].join(" · ").length > width) break;
|
|
3541
|
+
kept.push(segment);
|
|
3542
|
+
}
|
|
3543
|
+
return truncate(kept.join(" · "), width);
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
//#endregion
|
|
3547
|
+
//#region src/chat/tui/use-debounced-value.ts
|
|
3548
|
+
/**
|
|
3042
3549
|
* Returns a copy of `value` that only updates after it has stopped changing
|
|
3043
3550
|
* for `delayMs`. Use it to throttle work driven by fast-changing state — e.g.
|
|
3044
3551
|
* a search input whose every keystroke would otherwise fire a new request.
|
|
@@ -3091,6 +3598,19 @@ function isAgentRunConversation(c) {
|
|
|
3091
3598
|
return !humanChannels.some((human) => human === channel);
|
|
3092
3599
|
}
|
|
3093
3600
|
/**
|
|
3601
|
+
* Whether the signed-in user has their own participant row in this thread,
|
|
3602
|
+
* which is what per-user archive marks. An org admin's list includes every
|
|
3603
|
+
* thread in the org, so it can hold threads they never joined (a Slack thread
|
|
3604
|
+
* between a colleague and the agent, say); on those the server has no row to
|
|
3605
|
+
* mark and refuses, so the UI should not offer archive. Read from
|
|
3606
|
+
* the list row's `viewerParticipates`; an older server omits it, and a
|
|
3607
|
+
* brand-new conversation has no roster yet, so both read as true.
|
|
3608
|
+
*/
|
|
3609
|
+
function viewerIsParticipant(c) {
|
|
3610
|
+
if (isNewConversation(c)) return true;
|
|
3611
|
+
return c.viewerParticipates ?? true;
|
|
3612
|
+
}
|
|
3613
|
+
/**
|
|
3094
3614
|
* The channel a conversation belongs to when it isn't the web channel, else
|
|
3095
3615
|
* null. A brand-new conversation is always web, and the list route reports the
|
|
3096
3616
|
* web channel as either `'web'` or (legacy) null — both mean "post from here".
|
|
@@ -3130,6 +3650,7 @@ function forkedConversationRef({ agent, forked }) {
|
|
|
3130
3650
|
//#region src/chat/tui/screens/conversation-picker.tsx
|
|
3131
3651
|
const SEARCH_DEBOUNCE_MS = 250;
|
|
3132
3652
|
const SEARCH_QUERY_MAX_LENGTH = 200;
|
|
3653
|
+
const SEARCH_PREFIX = "/";
|
|
3133
3654
|
function ConversationPickerScreen({ agent, initialQuery }) {
|
|
3134
3655
|
const rest = useStore((s) => s.rest);
|
|
3135
3656
|
const goTo = useStore((s) => s.goTo);
|
|
@@ -3194,38 +3715,18 @@ function ConversationPickerScreen({ agent, initialQuery }) {
|
|
|
3194
3715
|
cacheKey,
|
|
3195
3716
|
cache
|
|
3196
3717
|
]);
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
children: [
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
}), /* @__PURE__ */ jsx("text", {
|
|
3207
|
-
fg: theme.muted,
|
|
3208
|
-
children: "loading conversations…"
|
|
3209
|
-
})]
|
|
3210
|
-
});
|
|
3211
|
-
if (state.kind === "error") return /* @__PURE__ */ jsxs("box", {
|
|
3212
|
-
style: {
|
|
3213
|
-
flexDirection: "column",
|
|
3214
|
-
flexGrow: 1
|
|
3215
|
-
},
|
|
3216
|
-
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
3217
|
-
height,
|
|
3218
|
-
width
|
|
3219
|
-
}), /* @__PURE__ */ jsxs("text", {
|
|
3220
|
-
fg: theme.error,
|
|
3221
|
-
children: ["error: ", state.message]
|
|
3222
|
-
})]
|
|
3223
|
-
});
|
|
3224
|
-
return /* @__PURE__ */ jsx(ConversationList, {
|
|
3718
|
+
if (state.kind === "loading") return /* @__PURE__ */ jsx(HomeFrame, { children: /* @__PURE__ */ jsx("text", {
|
|
3719
|
+
fg: theme.muted,
|
|
3720
|
+
children: "loading conversations…"
|
|
3721
|
+
}) });
|
|
3722
|
+
if (state.kind === "error") return /* @__PURE__ */ jsx(HomeFrame, { children: /* @__PURE__ */ jsxs("text", {
|
|
3723
|
+
fg: theme.error,
|
|
3724
|
+
children: ["error: ", state.message]
|
|
3725
|
+
}) });
|
|
3726
|
+
return /* @__PURE__ */ jsx(ConversationHome, {
|
|
3225
3727
|
agent,
|
|
3226
3728
|
initialQuery,
|
|
3227
3729
|
conversations: state.conversations,
|
|
3228
|
-
complete: state.complete,
|
|
3229
3730
|
showAutomated,
|
|
3230
3731
|
onToggleAutomated: () => setShowAutomated((shown) => !shown),
|
|
3231
3732
|
showArchived,
|
|
@@ -3233,12 +3734,84 @@ function ConversationPickerScreen({ agent, initialQuery }) {
|
|
|
3233
3734
|
onPick: goTo
|
|
3234
3735
|
});
|
|
3235
3736
|
}
|
|
3236
|
-
|
|
3237
|
-
|
|
3737
|
+
/**
|
|
3738
|
+
* The gated, centered brand frame the home renders inside — shared by the
|
|
3739
|
+
* loading and error legs so every leg paints the brand in the SAME centered
|
|
3740
|
+
* position at the SAME (real) width. Without this the loading leg painted an
|
|
3741
|
+
* ungated, left-aligned brand and then jumped it to centered when the loaded
|
|
3742
|
+
* home mounted: the left→center flash James saw (PR #20856).
|
|
3743
|
+
*
|
|
3744
|
+
* It rides `useTerminalReady`: on an async-sizing terminal (tmux/cmux) the
|
|
3745
|
+
* renderer boots on the 80x24 fallback and the real width lands via a debounced
|
|
3746
|
+
* resize, so we hold a blank same-size frame until the true width is known and
|
|
3747
|
+
* only then paint the centered brand — first VISIBLE paint already at its final
|
|
3748
|
+
* position, no reflow. The loaded `ConversationHome` gates identically, so the
|
|
3749
|
+
* brand never moves across loading → loaded. See `useTerminalReady`.
|
|
3750
|
+
*/
|
|
3751
|
+
function HomeFrame({ children }) {
|
|
3752
|
+
const { width, height, ready } = useTerminalReady();
|
|
3753
|
+
const contentWidth = Math.min(76, Math.max(1, width - 2));
|
|
3754
|
+
if (!ready) return /* @__PURE__ */ jsx("box", { style: {
|
|
3755
|
+
flexDirection: "column",
|
|
3756
|
+
flexGrow: 1
|
|
3757
|
+
} });
|
|
3758
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
3759
|
+
style: {
|
|
3760
|
+
flexDirection: "column",
|
|
3761
|
+
flexGrow: 1,
|
|
3762
|
+
alignItems: "center"
|
|
3763
|
+
},
|
|
3764
|
+
children: [
|
|
3765
|
+
/* @__PURE__ */ jsx("box", { style: {
|
|
3766
|
+
flexGrow: 1,
|
|
3767
|
+
flexShrink: 1
|
|
3768
|
+
} }),
|
|
3769
|
+
/* @__PURE__ */ jsxs("box", {
|
|
3770
|
+
style: {
|
|
3771
|
+
flexDirection: "column",
|
|
3772
|
+
width: contentWidth
|
|
3773
|
+
},
|
|
3774
|
+
children: [/* @__PURE__ */ jsx("box", {
|
|
3775
|
+
style: {
|
|
3776
|
+
flexDirection: "column",
|
|
3777
|
+
alignItems: "center"
|
|
3778
|
+
},
|
|
3779
|
+
children: /* @__PURE__ */ jsx(BrandHeader, {
|
|
3780
|
+
height,
|
|
3781
|
+
width
|
|
3782
|
+
})
|
|
3783
|
+
}), children]
|
|
3784
|
+
}),
|
|
3785
|
+
/* @__PURE__ */ jsx("box", { style: {
|
|
3786
|
+
flexGrow: 1,
|
|
3787
|
+
flexShrink: 1
|
|
3788
|
+
} })
|
|
3789
|
+
]
|
|
3790
|
+
});
|
|
3791
|
+
}
|
|
3792
|
+
/**
|
|
3793
|
+
* The agent's home screen: a composer to start a new conversation (mirroring
|
|
3794
|
+
* the web home's "Ask <agent> anything" field) over the list of existing
|
|
3795
|
+
* conversations. The composer is always focused; ↑/↓ move the list, enter on
|
|
3796
|
+
* a non-empty composer sends a new message, enter on an empty composer opens
|
|
3797
|
+
* the highlighted conversation. A leading '/' turns the composer into a live
|
|
3798
|
+
* list filter (server-side search) instead of a message.
|
|
3799
|
+
*/
|
|
3800
|
+
function ConversationHome({ agent, initialQuery, conversations, showAutomated, onToggleAutomated, showArchived, onToggleArchived, onPick }) {
|
|
3801
|
+
const { width, height, ready } = useTerminalReady();
|
|
3238
3802
|
const rest = useStore((s) => s.rest);
|
|
3239
|
-
const
|
|
3803
|
+
const composerRef = useRef(null);
|
|
3804
|
+
const [input, setInput] = useState(initialQuery ? `${SEARCH_PREFIX}${initialQuery}` : "");
|
|
3805
|
+
const seededRef = useRef(false);
|
|
3806
|
+
useEffect(() => {
|
|
3807
|
+
if (seededRef.current) return;
|
|
3808
|
+
seededRef.current = true;
|
|
3809
|
+
if (initialQuery) composerRef.current?.setText(`${SEARCH_PREFIX}${initialQuery}`);
|
|
3810
|
+
}, [initialQuery]);
|
|
3240
3811
|
const [highlight, setHighlight] = useState(0);
|
|
3241
|
-
const
|
|
3812
|
+
const [composerLines, setComposerLines] = useState(1);
|
|
3813
|
+
const isSearchInput = input.startsWith(SEARCH_PREFIX);
|
|
3814
|
+
const debouncedQuery = useDebouncedValue((isSearchInput ? input.slice(1).slice(0, SEARCH_QUERY_MAX_LENGTH) : "").trim(), SEARCH_DEBOUNCE_MS);
|
|
3242
3815
|
const isSearching = debouncedQuery.length > 0;
|
|
3243
3816
|
const [search, setSearch] = useState(null);
|
|
3244
3817
|
useEffect(() => {
|
|
@@ -3302,74 +3875,126 @@ function ConversationList({ agent, initialQuery, conversations, complete, showAu
|
|
|
3302
3875
|
const [archiveOverrides, setArchiveOverrides] = useState(/* @__PURE__ */ new Map());
|
|
3303
3876
|
const [confirmId, setConfirmId] = useState(null);
|
|
3304
3877
|
const [error, setError] = useState(null);
|
|
3878
|
+
const [sending, setSending] = useState(false);
|
|
3879
|
+
const { staged: attachments, stageClipboardImage, dropLast: dropLastAttachment, collectForSend } = useComposerAttachments({
|
|
3880
|
+
rest,
|
|
3881
|
+
agentId: agent.id,
|
|
3882
|
+
pasteDisabled: confirmId !== null || isSearchInput || sending,
|
|
3883
|
+
onPasteText: (text) => {
|
|
3884
|
+
composerRef.current?.insertText(text);
|
|
3885
|
+
handleComposerChange();
|
|
3886
|
+
},
|
|
3887
|
+
onError: setError
|
|
3888
|
+
});
|
|
3305
3889
|
const isArchived = (c) => archiveOverrides.get(c.id) ?? c.viewerArchivedAt != null;
|
|
3306
3890
|
const list = ((isSearching && search?.query === debouncedQuery && search.state.kind === "ready" ? search.state.conversations : null) ?? conversations).filter((c) => !removedIds.has(c.id) && (showArchived || !isArchived(c)));
|
|
3307
3891
|
useEffect(() => {
|
|
3308
3892
|
setHighlight(0);
|
|
3309
3893
|
}, [showAutomated, showArchived]);
|
|
3310
|
-
const
|
|
3894
|
+
const rows = list.map((item) => ({
|
|
3311
3895
|
item,
|
|
3312
|
-
highlights: [
|
|
3313
|
-
|
|
3314
|
-
null,
|
|
3315
|
-
null
|
|
3316
|
-
]
|
|
3317
|
-
})) : fuzzyFilter(query, list, conversationKeys);
|
|
3318
|
-
const rows = [{ kind: "new" }, ...hits.map((hit) => ({
|
|
3896
|
+
highlights: [isSearching ? substringIndexes(singleLine(item.title ?? ""), debouncedQuery) : null]
|
|
3897
|
+
})).map((hit) => ({
|
|
3319
3898
|
kind: "conv",
|
|
3320
3899
|
hit
|
|
3321
|
-
}))
|
|
3322
|
-
const
|
|
3900
|
+
}));
|
|
3901
|
+
const contentWidth = Math.min(76, Math.max(1, width - 2));
|
|
3902
|
+
const innerWidth = contentWidth;
|
|
3323
3903
|
const rowWidth = Math.max(1, innerWidth - 2);
|
|
3324
3904
|
const clamped = Math.min(highlight, Math.max(0, rows.length - 1));
|
|
3325
3905
|
const highlightRow = rows[clamped];
|
|
3326
3906
|
const highlightArchived = highlightRow?.kind === "conv" && isArchived(highlightRow.hit.item);
|
|
3327
3907
|
const highlightAgentRun = highlightRow?.kind === "conv" && isAgentRunConversation(highlightRow.hit.item);
|
|
3908
|
+
const highlightOutsider = highlightRow?.kind === "conv" && !viewerIsParticipant(highlightRow.hit.item);
|
|
3328
3909
|
const confirmConv = confirmId !== null ? list.find((c) => c.id === confirmId) : void 0;
|
|
3329
3910
|
const searchState = isSearching ? search?.state : void 0;
|
|
3330
|
-
const
|
|
3911
|
+
const searchStatusSegment = !isSearching ? "" : searchState?.kind === "loading" || search === null ? "searching…" : searchState?.kind === "error" ? `search failed: ${searchState.message}` : list.length === 0 ? "no matches" : "";
|
|
3912
|
+
const trimmedMessage = isSearchInput ? "" : input.trim();
|
|
3913
|
+
const hasMessage = trimmedMessage.length > 0 || attachments.length > 0;
|
|
3914
|
+
const enterHint = isSearchInput ? "filtering" : hasMessage ? "↵ send" : "↵ open";
|
|
3331
3915
|
const status = confirmConv ? {
|
|
3332
3916
|
fg: theme.error,
|
|
3333
3917
|
text: truncate(`delete “${confirmConv.title ?? "(untitled)"}”? y/n`, innerWidth)
|
|
3918
|
+
} : sending ? {
|
|
3919
|
+
fg: theme.muted,
|
|
3920
|
+
text: "uploading attachments…"
|
|
3334
3921
|
} : error ? {
|
|
3335
3922
|
fg: theme.error,
|
|
3336
3923
|
text: truncate(error, innerWidth)
|
|
3337
3924
|
} : {
|
|
3338
3925
|
fg: theme.dim,
|
|
3339
3926
|
text: fitHintLine([
|
|
3340
|
-
|
|
3341
|
-
|
|
3927
|
+
searchStatusSegment,
|
|
3928
|
+
enterHint,
|
|
3929
|
+
"/ search",
|
|
3930
|
+
...highlightAgentRun || highlightOutsider ? [] : [`ctrl+a ${highlightArchived ? "unarchive" : "archive"}`],
|
|
3342
3931
|
"ctrl+d delete",
|
|
3343
3932
|
`tab ${showAutomated ? "hide" : "show"} agent runs`,
|
|
3344
3933
|
`shift+tab ${showArchived ? "hide" : "show"} archived`,
|
|
3345
|
-
"esc back",
|
|
3346
3934
|
"↑/↓ move",
|
|
3347
|
-
"
|
|
3348
|
-
], innerWidth)
|
|
3935
|
+
"esc back"
|
|
3936
|
+
].filter((segment) => segment !== ""), innerWidth)
|
|
3349
3937
|
};
|
|
3350
|
-
const
|
|
3938
|
+
const identityRows = height >= 24 ? 3 : 0;
|
|
3939
|
+
const trayRows = attachmentTrayRows(attachments.length);
|
|
3940
|
+
const fixedRows = 6 + brandHeaderRows(height, width) + identityRows + trayRows;
|
|
3941
|
+
const composerRows = Math.max(1, Math.min(composerLines, height - fixedRows - 2 - MIN_LIST_ROWS));
|
|
3942
|
+
const visibleRows = Math.max(MIN_LIST_ROWS, height - fixedRows - (composerRows + 2));
|
|
3351
3943
|
const start = windowStart(clamped, rows.length, visibleRows);
|
|
3352
3944
|
const windowed = rows.slice(start, start + visibleRows);
|
|
3353
|
-
const
|
|
3354
|
-
|
|
3945
|
+
const openHighlighted = () => {
|
|
3946
|
+
const row = rows[clamped];
|
|
3947
|
+
if (row) onPick({
|
|
3355
3948
|
kind: "chat",
|
|
3356
3949
|
agent,
|
|
3357
3950
|
attachRunId: null,
|
|
3358
3951
|
parentConversationId: null,
|
|
3359
|
-
conversation:
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3952
|
+
conversation: row.hit.item
|
|
3953
|
+
});
|
|
3954
|
+
};
|
|
3955
|
+
const sendNewConversation = async (message) => {
|
|
3956
|
+
setSending(true);
|
|
3957
|
+
const { files, failed } = await collectForSend(attachments);
|
|
3958
|
+
setSending(false);
|
|
3959
|
+
if (!message && files.length === 0) {
|
|
3960
|
+
setError("attachment upload failed");
|
|
3961
|
+
return;
|
|
3962
|
+
}
|
|
3963
|
+
if (failed.length > 0) {
|
|
3964
|
+
const plural = failed.length === 1 ? "" : "s";
|
|
3965
|
+
setError(`${failed.length} attachment${plural} failed to upload; sending without ${plural ? "them" : "it"}`);
|
|
3966
|
+
}
|
|
3967
|
+
useStore.setState({
|
|
3968
|
+
seedPrompt: message,
|
|
3969
|
+
seedAttachments: files
|
|
3364
3970
|
});
|
|
3365
|
-
|
|
3971
|
+
onPick({
|
|
3366
3972
|
kind: "chat",
|
|
3367
3973
|
agent,
|
|
3368
3974
|
attachRunId: null,
|
|
3369
3975
|
parentConversationId: null,
|
|
3370
|
-
conversation:
|
|
3976
|
+
conversation: {
|
|
3977
|
+
kind: "new",
|
|
3978
|
+
agentId: agent.id,
|
|
3979
|
+
nonce: crypto.randomUUID()
|
|
3980
|
+
}
|
|
3371
3981
|
});
|
|
3372
3982
|
};
|
|
3983
|
+
const handleComposerChange = () => {
|
|
3984
|
+
const composer = composerRef.current;
|
|
3985
|
+
if (!composer) return;
|
|
3986
|
+
const raw = composer.plainText;
|
|
3987
|
+
const capped = raw.startsWith(SEARCH_PREFIX) ? raw.slice(0, 1 + SEARCH_QUERY_MAX_LENGTH) : raw;
|
|
3988
|
+
if (capped !== raw) composer.setText(capped);
|
|
3989
|
+
setInput(capped);
|
|
3990
|
+
setComposerLines(Math.max(1, composer.editorView.getTotalVirtualLineCount()));
|
|
3991
|
+
setHighlight(0);
|
|
3992
|
+
};
|
|
3993
|
+
const submit = () => {
|
|
3994
|
+
if (sending) return;
|
|
3995
|
+
if (hasMessage) sendNewConversation(trimmedMessage);
|
|
3996
|
+
else openHighlighted();
|
|
3997
|
+
};
|
|
3373
3998
|
/**
|
|
3374
3999
|
* Optimistically drop a row, run `mutate`, and put the row back if it fails.
|
|
3375
4000
|
* Delete only — archive keeps the row and flips its mark instead.
|
|
@@ -3422,17 +4047,34 @@ function ConversationList({ agent, initialQuery, conversations, complete, showAu
|
|
|
3422
4047
|
else if (key.name === "n" || key.name === "escape") setConfirmId(null);
|
|
3423
4048
|
return;
|
|
3424
4049
|
}
|
|
3425
|
-
if (key.name === "escape")
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
4050
|
+
if (key.name === "escape") {
|
|
4051
|
+
if (input) {
|
|
4052
|
+
composerRef.current?.setText("");
|
|
4053
|
+
setInput("");
|
|
4054
|
+
setComposerLines(1);
|
|
4055
|
+
setHighlight(0);
|
|
4056
|
+
} else onPick({ kind: "welcome" });
|
|
4057
|
+
return;
|
|
4058
|
+
}
|
|
4059
|
+
if (key.name === "up") {
|
|
4060
|
+
setHighlight(Math.max(0, clamped - 1));
|
|
4061
|
+
key.preventDefault();
|
|
4062
|
+
} else if (key.name === "down") {
|
|
4063
|
+
setHighlight(Math.min(Math.max(0, rows.length - 1), clamped + 1));
|
|
4064
|
+
key.preventDefault();
|
|
4065
|
+
} else if (key.name === "pageup" || key.name === "pgup") {
|
|
4066
|
+
setHighlight(Math.max(0, clamped - Math.max(1, visibleRows - 1)));
|
|
4067
|
+
key.preventDefault();
|
|
4068
|
+
} else if (key.name === "pagedown" || key.name === "pgdn") {
|
|
4069
|
+
setHighlight(Math.min(Math.max(0, rows.length - 1), clamped + Math.max(1, visibleRows - 1)));
|
|
4070
|
+
key.preventDefault();
|
|
4071
|
+
} else if (key.name === "d" && key.ctrl) {
|
|
3431
4072
|
const row = rows[clamped];
|
|
3432
4073
|
if (row?.kind === "conv") {
|
|
3433
4074
|
setError(null);
|
|
3434
4075
|
setConfirmId(row.hit.item.id);
|
|
3435
4076
|
}
|
|
4077
|
+
key.preventDefault();
|
|
3436
4078
|
} else if (key.name === "a" && key.ctrl) {
|
|
3437
4079
|
const row = rows[clamped];
|
|
3438
4080
|
if (row?.kind === "conv") {
|
|
@@ -3440,97 +4082,186 @@ function ConversationList({ agent, initialQuery, conversations, complete, showAu
|
|
|
3440
4082
|
setError("agent runs can't be archived — ctrl+d deletes");
|
|
3441
4083
|
return;
|
|
3442
4084
|
}
|
|
4085
|
+
if (!viewerIsParticipant(row.hit.item)) {
|
|
4086
|
+
setError("you're not in this thread, so there's nothing to archive");
|
|
4087
|
+
return;
|
|
4088
|
+
}
|
|
3443
4089
|
setError(null);
|
|
3444
4090
|
toggleArchived(row.hit.item);
|
|
3445
4091
|
}
|
|
3446
|
-
|
|
3447
|
-
else if (key.name === "
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
4092
|
+
key.preventDefault();
|
|
4093
|
+
} else if (key.name === "v" && key.ctrl) {
|
|
4094
|
+
if (confirmId === null && !isSearchInput) {
|
|
4095
|
+
setError(null);
|
|
4096
|
+
stageClipboardImage();
|
|
4097
|
+
}
|
|
4098
|
+
key.preventDefault();
|
|
4099
|
+
} else if (key.name === "x" && key.ctrl) {
|
|
4100
|
+
dropLastAttachment();
|
|
4101
|
+
key.preventDefault();
|
|
4102
|
+
} else if (key.name === "tab" && key.shift) {
|
|
4103
|
+
onToggleArchived();
|
|
4104
|
+
key.preventDefault();
|
|
4105
|
+
} else if (key.name === "tab") {
|
|
4106
|
+
onToggleAutomated();
|
|
4107
|
+
key.preventDefault();
|
|
3451
4108
|
}
|
|
3452
4109
|
});
|
|
4110
|
+
if (!ready) return /* @__PURE__ */ jsx("box", { style: {
|
|
4111
|
+
flexDirection: "column",
|
|
4112
|
+
flexGrow: 1
|
|
4113
|
+
} });
|
|
3453
4114
|
return /* @__PURE__ */ jsxs("box", {
|
|
3454
4115
|
style: {
|
|
3455
4116
|
flexDirection: "column",
|
|
3456
|
-
flexGrow: 1
|
|
4117
|
+
flexGrow: 1,
|
|
4118
|
+
alignItems: "center"
|
|
3457
4119
|
},
|
|
3458
4120
|
children: [
|
|
3459
|
-
/* @__PURE__ */ jsx(
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
}),
|
|
4121
|
+
/* @__PURE__ */ jsx("box", { style: {
|
|
4122
|
+
flexGrow: 1,
|
|
4123
|
+
flexShrink: 1
|
|
4124
|
+
} }),
|
|
3463
4125
|
/* @__PURE__ */ jsxs("box", {
|
|
3464
|
-
style: {
|
|
3465
|
-
flexDirection: "row",
|
|
3466
|
-
height: 1
|
|
3467
|
-
},
|
|
3468
|
-
children: [/* @__PURE__ */ jsx("text", {
|
|
3469
|
-
fg: theme.muted,
|
|
3470
|
-
children: "search "
|
|
3471
|
-
}), /* @__PURE__ */ jsx("input", {
|
|
3472
|
-
textColor: theme.fg,
|
|
3473
|
-
focusedTextColor: theme.fg,
|
|
3474
|
-
cursorColor: theme.cursor,
|
|
3475
|
-
placeholderColor: theme.dim,
|
|
3476
|
-
placeholder: "type to filter…",
|
|
3477
|
-
focused: confirmId === null,
|
|
3478
|
-
width: Math.max(10, width - 12),
|
|
3479
|
-
maxLength: SEARCH_QUERY_MAX_LENGTH,
|
|
3480
|
-
value: query,
|
|
3481
|
-
onInput: (value) => {
|
|
3482
|
-
setQuery(value.slice(0, SEARCH_QUERY_MAX_LENGTH));
|
|
3483
|
-
setHighlight(0);
|
|
3484
|
-
}
|
|
3485
|
-
})]
|
|
3486
|
-
}),
|
|
3487
|
-
/* @__PURE__ */ jsx("text", {
|
|
3488
|
-
fg: status.fg,
|
|
3489
|
-
children: status.text
|
|
3490
|
-
}),
|
|
3491
|
-
/* @__PURE__ */ jsx("box", {
|
|
3492
4126
|
style: {
|
|
3493
4127
|
flexDirection: "column",
|
|
3494
|
-
|
|
4128
|
+
width: contentWidth
|
|
3495
4129
|
},
|
|
3496
|
-
children:
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
children: [
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
4130
|
+
children: [
|
|
4131
|
+
/* @__PURE__ */ jsxs("box", {
|
|
4132
|
+
style: {
|
|
4133
|
+
flexDirection: "column",
|
|
4134
|
+
alignItems: "center"
|
|
4135
|
+
},
|
|
4136
|
+
children: [/* @__PURE__ */ jsx(BrandHeader, {
|
|
4137
|
+
height,
|
|
4138
|
+
width
|
|
4139
|
+
}), height >= 24 ? /* @__PURE__ */ jsxs("box", {
|
|
4140
|
+
style: {
|
|
4141
|
+
flexDirection: "column",
|
|
4142
|
+
alignItems: "center"
|
|
4143
|
+
},
|
|
4144
|
+
children: [
|
|
4145
|
+
/* @__PURE__ */ jsx("text", {
|
|
4146
|
+
fg: theme.accent,
|
|
4147
|
+
attributes: 1,
|
|
4148
|
+
children: truncate(`Hey, I'm ${agent.name}`, innerWidth)
|
|
4149
|
+
}),
|
|
4150
|
+
identitySubtitle(agent) ? /* @__PURE__ */ jsx("text", {
|
|
4151
|
+
fg: theme.muted,
|
|
4152
|
+
children: truncate(identitySubtitle(agent), innerWidth)
|
|
4153
|
+
}) : null,
|
|
4154
|
+
/* @__PURE__ */ jsx("box", { style: { height: 1 } })
|
|
4155
|
+
]
|
|
4156
|
+
}) : null]
|
|
4157
|
+
}),
|
|
4158
|
+
/* @__PURE__ */ jsx("box", {
|
|
4159
|
+
style: {
|
|
4160
|
+
border: true,
|
|
4161
|
+
borderColor: theme.faint,
|
|
4162
|
+
paddingLeft: 1,
|
|
4163
|
+
paddingRight: 1,
|
|
4164
|
+
height: composerRows + 2,
|
|
4165
|
+
flexShrink: 0,
|
|
4166
|
+
flexDirection: "column"
|
|
4167
|
+
},
|
|
4168
|
+
children: /* @__PURE__ */ jsx("textarea", {
|
|
4169
|
+
ref: composerRef,
|
|
4170
|
+
textColor: theme.fg,
|
|
4171
|
+
focusedTextColor: theme.fg,
|
|
4172
|
+
cursorColor: theme.cursor,
|
|
4173
|
+
placeholderColor: theme.dim,
|
|
4174
|
+
placeholder: `Ask ${agent.name} anything…`,
|
|
4175
|
+
keyBindings: composerKeyBindings$1,
|
|
4176
|
+
onContentChange: handleComposerChange,
|
|
4177
|
+
onSubmit: submit,
|
|
4178
|
+
focused: confirmId === null,
|
|
4179
|
+
style: {
|
|
4180
|
+
height: composerRows,
|
|
4181
|
+
wrapMode: "word"
|
|
4182
|
+
}
|
|
4183
|
+
})
|
|
4184
|
+
}),
|
|
4185
|
+
attachments.length > 0 ? /* @__PURE__ */ jsx(AttachmentTray, { attachments }) : null,
|
|
4186
|
+
/* @__PURE__ */ jsx("text", {
|
|
4187
|
+
fg: status.fg,
|
|
4188
|
+
children: status.text
|
|
4189
|
+
}),
|
|
4190
|
+
/* @__PURE__ */ jsx("box", {
|
|
4191
|
+
style: {
|
|
4192
|
+
flexDirection: "column",
|
|
4193
|
+
flexShrink: 0
|
|
4194
|
+
},
|
|
4195
|
+
children: rows.length === 0 ? /* @__PURE__ */ jsx("text", {
|
|
4196
|
+
fg: theme.dim,
|
|
4197
|
+
children: isSearching ? "no conversations match." : `no conversations yet — type a message above to start one with ${agent.name}.`
|
|
4198
|
+
}) : windowed.map((row, i) => {
|
|
4199
|
+
const selected = start + i === clamped;
|
|
4200
|
+
const fg = selected ? theme.accent : theme.fg;
|
|
4201
|
+
const marker = selected ? "› " : " ";
|
|
4202
|
+
const archived = isArchived(row.hit.item);
|
|
4203
|
+
const { title, time } = layoutRow(`${archived ? "archived · " : ""}${singleLine(row.hit.item.title ?? "(untitled)")}`, relativeTime(row.hit.item.updatedAt), coAgentsTag(row.hit.item, agent.id), rowWidth);
|
|
4204
|
+
const glyph = channelGlyph(row.hit.item.channel);
|
|
4205
|
+
const tag = coAgentsTag(row.hit.item, agent.id);
|
|
4206
|
+
const unread = row.hit.item.unread === true;
|
|
4207
|
+
const titleText = /* @__PURE__ */ jsx(FuzzyText, {
|
|
4208
|
+
text: title,
|
|
4209
|
+
indexes: row.hit.item.title ? row.hit.highlights[0] : null
|
|
4210
|
+
});
|
|
4211
|
+
return /* @__PURE__ */ jsxs("text", {
|
|
4212
|
+
fg: archived && !selected ? theme.dim : fg,
|
|
4213
|
+
children: [
|
|
4214
|
+
!selected && unread ? /* @__PURE__ */ jsx("span", {
|
|
4215
|
+
fg: theme.accent,
|
|
4216
|
+
children: "● "
|
|
4217
|
+
}) : marker,
|
|
4218
|
+
/* @__PURE__ */ jsxs("span", {
|
|
4219
|
+
fg: selected ? theme.accent : theme.muted,
|
|
4220
|
+
children: [glyph, " "]
|
|
4221
|
+
}),
|
|
4222
|
+
unread ? /* @__PURE__ */ jsx("b", { children: titleText }) : titleText,
|
|
4223
|
+
tag ? /* @__PURE__ */ jsxs("span", {
|
|
4224
|
+
fg: theme.muted,
|
|
4225
|
+
children: [" ", tag]
|
|
4226
|
+
}) : null,
|
|
4227
|
+
/* @__PURE__ */ jsx("span", {
|
|
4228
|
+
fg: theme.dim,
|
|
4229
|
+
children: time
|
|
4230
|
+
})
|
|
4231
|
+
]
|
|
4232
|
+
}, row.hit.item.id);
|
|
4233
|
+
})
|
|
4234
|
+
})
|
|
4235
|
+
]
|
|
4236
|
+
}),
|
|
4237
|
+
/* @__PURE__ */ jsx("box", { style: {
|
|
4238
|
+
flexGrow: 1,
|
|
4239
|
+
flexShrink: 1
|
|
4240
|
+
} })
|
|
3531
4241
|
]
|
|
3532
4242
|
});
|
|
3533
4243
|
}
|
|
4244
|
+
const MIN_LIST_ROWS = 3;
|
|
4245
|
+
const composerKeyBindings$1 = [
|
|
4246
|
+
{
|
|
4247
|
+
name: "return",
|
|
4248
|
+
action: "submit"
|
|
4249
|
+
},
|
|
4250
|
+
{
|
|
4251
|
+
name: "kpenter",
|
|
4252
|
+
action: "submit"
|
|
4253
|
+
},
|
|
4254
|
+
{
|
|
4255
|
+
name: "return",
|
|
4256
|
+
shift: true,
|
|
4257
|
+
action: "newline"
|
|
4258
|
+
},
|
|
4259
|
+
{
|
|
4260
|
+
name: "kpenter",
|
|
4261
|
+
shift: true,
|
|
4262
|
+
action: "newline"
|
|
4263
|
+
}
|
|
4264
|
+
];
|
|
3534
4265
|
/**
|
|
3535
4266
|
* Tags a row with the thread's other agents (`+Grace, Ada`). The picker is
|
|
3536
4267
|
* entered per agent, so that agent stays implicit and only its co-participants
|
|
@@ -3547,20 +4278,6 @@ function coAgentsTag(conv, viewerAgentId) {
|
|
|
3547
4278
|
function conversationAgents(c) {
|
|
3548
4279
|
return c.agents && c.agents.length > 0 ? c.agents : [c.agent];
|
|
3549
4280
|
}
|
|
3550
|
-
const conversationKeys = [
|
|
3551
|
-
{
|
|
3552
|
-
get: (c) => singleLine(c.title ?? ""),
|
|
3553
|
-
weight: 1
|
|
3554
|
-
},
|
|
3555
|
-
{
|
|
3556
|
-
get: (c) => c.preview ?? "",
|
|
3557
|
-
weight: .6
|
|
3558
|
-
},
|
|
3559
|
-
{
|
|
3560
|
-
get: (c) => conversationAgents(c).map((a) => a.name).join(" "),
|
|
3561
|
-
weight: .4
|
|
3562
|
-
}
|
|
3563
|
-
];
|
|
3564
4281
|
/**
|
|
3565
4282
|
* Character indexes of the first case-insensitive occurrence of `needle` in
|
|
3566
4283
|
* `haystack`, for highlighting a literal title match under server search (the
|
|
@@ -3575,11 +4292,69 @@ function substringIndexes(haystack, needle) {
|
|
|
3575
4292
|
if (at < 0) return null;
|
|
3576
4293
|
return Array.from({ length: trimmed.length }, (_, i) => at + i);
|
|
3577
4294
|
}
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
4295
|
+
/**
|
|
4296
|
+
* The subtitle under the “Hey, I'm <Agent>” greeting, mirroring the web home's
|
|
4297
|
+
* “<title> · <owner>'s agent” line. Either half may be missing (an older
|
|
4298
|
+
* server omits `title`/`creatorName`); we join whatever is present and return
|
|
4299
|
+
* an empty string when there's nothing to show, so the caller drops the row.
|
|
4300
|
+
*/
|
|
4301
|
+
function identitySubtitle(agent) {
|
|
4302
|
+
const parts = [];
|
|
4303
|
+
if (agent.title) parts.push(agent.title);
|
|
4304
|
+
if (agent.creatorName) parts.push(`${agent.creatorName}'s agent`);
|
|
4305
|
+
return parts.join(" · ");
|
|
4306
|
+
}
|
|
4307
|
+
/**
|
|
4308
|
+
* A single glyph marking a conversation's channel, echoing the web home's
|
|
4309
|
+
* leading channel icon. Web threads (the common case) get a globe; the other
|
|
4310
|
+
* human channels and agent-run channels get a recognizable mark, with a
|
|
4311
|
+
* neutral dot as the fallback so an unknown/again-renamed channel still lines
|
|
4312
|
+
* up. Kept to one display column so the row layout budget stays exact.
|
|
4313
|
+
*
|
|
4314
|
+
* Every glyph must exist in common terminal monospace fonts (JetBrains Mono,
|
|
4315
|
+
* Fira Code, Hack, DejaVu Sans Mono). A missing code point makes the terminal
|
|
4316
|
+
* substitute a fallback font whose glyph is wider than one cell and smears
|
|
4317
|
+
* into the title, which is how ▰ / ✉ / ⏲ rendered. Slack and email
|
|
4318
|
+
* use the ASCII marks that already mean them (# channels, @ addresses).
|
|
4319
|
+
*/
|
|
4320
|
+
function channelGlyph(channel) {
|
|
4321
|
+
switch (channel) {
|
|
4322
|
+
case "web":
|
|
4323
|
+
case null: return "◉";
|
|
4324
|
+
case "slack": return "#";
|
|
4325
|
+
case "email": return "@";
|
|
4326
|
+
case "imessage":
|
|
4327
|
+
case "sms": return "○";
|
|
4328
|
+
case "cron": return "◶";
|
|
4329
|
+
default: return "·";
|
|
4330
|
+
}
|
|
4331
|
+
}
|
|
4332
|
+
/**
|
|
4333
|
+
* Lay a row out web-home style: the title on the left, the relative time
|
|
4334
|
+
* flush-right, and an optional co-agent tag between them. The glyph (2 cols
|
|
4335
|
+
* incl. its trailing space) and marker (2 cols) are accounted for by the
|
|
4336
|
+
* caller via `width` (already `rowWidth`), so here we reserve the time (plus a
|
|
4337
|
+
* gap), the tag (plus a gap) and the glyph slot, then truncate the title into
|
|
4338
|
+
* what's left. The returned `time` carries its own leading padding so it sits
|
|
4339
|
+
* flush against the right edge; when the row is too narrow to fit the title
|
|
4340
|
+
* AND the time, the time is dropped rather than wrapping the row.
|
|
4341
|
+
*/
|
|
4342
|
+
function layoutRow(title, time, tag, width) {
|
|
4343
|
+
const GLYPH = 2;
|
|
4344
|
+
const tagCost = tag ? tag.length + 1 : 0;
|
|
4345
|
+
const timeCost = time.length + 1;
|
|
4346
|
+
const titleBudget = width - GLYPH - tagCost - timeCost;
|
|
4347
|
+
if (titleBudget < 8) return {
|
|
4348
|
+
title: truncate(title, Math.max(1, width - GLYPH - tagCost)),
|
|
4349
|
+
time: ""
|
|
4350
|
+
};
|
|
4351
|
+
const fitTitle = truncate(title, titleBudget);
|
|
4352
|
+
const used = GLYPH + fitTitle.length + tagCost;
|
|
4353
|
+
const pad = Math.max(1, width - used - time.length);
|
|
4354
|
+
return {
|
|
4355
|
+
title: fitTitle,
|
|
4356
|
+
time: `${" ".repeat(pad)}${time}`
|
|
4357
|
+
};
|
|
3583
4358
|
}
|
|
3584
4359
|
function relativeTime(iso) {
|
|
3585
4360
|
const diffMs = Date.now() - new Date(iso).getTime();
|
|
@@ -3611,199 +4386,45 @@ const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12
|
|
|
3611
4386
|
* Trailing punctuation, query strings and surrounding prose are tolerated —
|
|
3612
4387
|
* a pasted link or command line is the expected input, not the exception.
|
|
3613
4388
|
*/
|
|
3614
|
-
function parseConversationRef(input) {
|
|
3615
|
-
const match = uuidPattern.exec(input);
|
|
3616
|
-
return match ? match[0].toLowerCase() : null;
|
|
3617
|
-
}
|
|
3618
|
-
|
|
3619
|
-
//#endregion
|
|
3620
|
-
//#region src/chat/transcript-export.ts
|
|
3621
|
-
function messageText(message) {
|
|
3622
|
-
const chunks = [];
|
|
3623
|
-
for (const part of message.parts) if ((part.type === "text" || part.type === "reasoning") && "text" in part && typeof part.text === "string") chunks.push(part.text);
|
|
3624
|
-
else if (part.type === "dynamic-tool" && "toolName" in part) chunks.push(`[tool: ${typeof part.toolName === "string" ? part.toolName : "tool"}]`);
|
|
3625
|
-
return chunks.join("\n").trim();
|
|
3626
|
-
}
|
|
3627
|
-
function messageLabel(message) {
|
|
3628
|
-
if (message.role === "user") return "User";
|
|
3629
|
-
if (message.role === "assistant") return message.metadata?.custom?.agentName ?? "Assistant";
|
|
3630
|
-
return message.role.charAt(0).toUpperCase() + message.role.slice(1);
|
|
3631
|
-
}
|
|
3632
|
-
function parseTranscriptFormat(value) {
|
|
3633
|
-
const normalized = value.trim().toLowerCase();
|
|
3634
|
-
if (!normalized || normalized === "md" || normalized === "markdown") return "markdown";
|
|
3635
|
-
if (normalized === "txt" || normalized === "text") return "text";
|
|
3636
|
-
return null;
|
|
3637
|
-
}
|
|
3638
|
-
function formatTranscript(messages, format) {
|
|
3639
|
-
const entries = messages.flatMap((message) => {
|
|
3640
|
-
const text = messageText(message);
|
|
3641
|
-
if (!text) return [];
|
|
3642
|
-
const label = messageLabel(message);
|
|
3643
|
-
return [format === "markdown" ? `## ${label}\n\n${text}` : `[${label}]\n${text}`];
|
|
3644
|
-
});
|
|
3645
|
-
if (format === "markdown") return `# Conversation transcript\n\n${entries.join("\n\n")}\n`;
|
|
3646
|
-
return `${entries.join("\n\n")}\n`;
|
|
3647
|
-
}
|
|
3648
|
-
async function exportTranscript(options) {
|
|
3649
|
-
const extension = options.format === "markdown" ? "md" : "txt";
|
|
3650
|
-
const filePath = path.join(options.cwd, `skydive-conversation-${options.conversationId}.${extension}`);
|
|
3651
|
-
await writeFile(filePath, formatTranscript(options.messages, options.format), "utf8");
|
|
3652
|
-
return filePath;
|
|
3653
|
-
}
|
|
3654
|
-
|
|
3655
|
-
//#endregion
|
|
3656
|
-
//#region src/chat/paste.ts
|
|
3657
|
-
const MAX_PATH_PASTE_CHARS = 4096;
|
|
3658
|
-
/**
|
|
3659
|
-
* Decode a `file://` URL to a filesystem path, host-independently. Node's
|
|
3660
|
-
* `fileURLToPath` maps to the *running* OS's path shape, so a POSIX file URL
|
|
3661
|
-
* pasted on Windows would come back as a backslash drive-relative path. A
|
|
3662
|
-
* dropped-file URL is decided by its own shape, not by our host: a Windows
|
|
3663
|
-
* drive URL (`file:///C:/...`) yields a Windows path, anything else a POSIX
|
|
3664
|
-
* path. Returns null for a malformed URL.
|
|
3665
|
-
*/
|
|
3666
|
-
function fileUrlToPath(url) {
|
|
3667
|
-
let parsed;
|
|
3668
|
-
try {
|
|
3669
|
-
parsed = new URL(url);
|
|
3670
|
-
} catch (_error) {
|
|
3671
|
-
return null;
|
|
3672
|
-
}
|
|
3673
|
-
if (parsed.protocol !== "file:") return null;
|
|
3674
|
-
const decoded = decodeURIComponent(parsed.pathname);
|
|
3675
|
-
if (/^\/[a-zA-Z]:\//.test(decoded)) return decoded.slice(1).replace(/\//g, "\\");
|
|
3676
|
-
return decoded;
|
|
3677
|
-
}
|
|
3678
|
-
/**
|
|
3679
|
-
* Parse the text a terminal emits for dropped files. This only identifies
|
|
3680
|
-
* path-shaped candidates; filesystem and content validation happen later.
|
|
3681
|
-
*/
|
|
3682
|
-
function parseDroppedPaths(text) {
|
|
3683
|
-
const trimmed = text.trim();
|
|
3684
|
-
if (!trimmed || trimmed.length > MAX_PATH_PASTE_CHARS || /[\r\n]/.test(trimmed)) return null;
|
|
3685
|
-
const tokens = [];
|
|
3686
|
-
let current = "";
|
|
3687
|
-
let quote = null;
|
|
3688
|
-
for (let i = 0; i < trimmed.length; i += 1) {
|
|
3689
|
-
const ch = trimmed.charAt(i);
|
|
3690
|
-
if (quote) {
|
|
3691
|
-
if (ch === quote) quote = null;
|
|
3692
|
-
else if (ch === "\\" && quote === "\"" && i + 1 < trimmed.length) {
|
|
3693
|
-
current += trimmed.charAt(i + 1);
|
|
3694
|
-
i += 1;
|
|
3695
|
-
} else current += ch;
|
|
3696
|
-
continue;
|
|
3697
|
-
}
|
|
3698
|
-
if (ch === "'" || ch === "\"") {
|
|
3699
|
-
quote = ch;
|
|
3700
|
-
continue;
|
|
3701
|
-
}
|
|
3702
|
-
if (ch === "\\" && i + 1 < trimmed.length) {
|
|
3703
|
-
current += trimmed.charAt(i + 1);
|
|
3704
|
-
i += 1;
|
|
3705
|
-
continue;
|
|
3706
|
-
}
|
|
3707
|
-
if (ch === " " || ch === " ") {
|
|
3708
|
-
if (current) {
|
|
3709
|
-
tokens.push(current);
|
|
3710
|
-
current = "";
|
|
3711
|
-
}
|
|
3712
|
-
continue;
|
|
3713
|
-
}
|
|
3714
|
-
current += ch;
|
|
3715
|
-
}
|
|
3716
|
-
if (quote) return null;
|
|
3717
|
-
if (current) tokens.push(current);
|
|
3718
|
-
if (tokens.length === 0) return null;
|
|
3719
|
-
const paths = [];
|
|
3720
|
-
for (const token of tokens) {
|
|
3721
|
-
let path = token;
|
|
3722
|
-
if (path.startsWith("file://")) {
|
|
3723
|
-
const resolved = fileUrlToPath(path);
|
|
3724
|
-
if (resolved === null) return null;
|
|
3725
|
-
path = resolved;
|
|
3726
|
-
}
|
|
3727
|
-
if (path.startsWith("~/")) path = `${homedir()}${path.slice(1)}`;
|
|
3728
|
-
if (!isAbsolute(path) && !win32.isAbsolute(path) && !path.startsWith("./") && !path.startsWith("../")) return null;
|
|
3729
|
-
paths.push(path);
|
|
3730
|
-
}
|
|
3731
|
-
return paths;
|
|
3732
|
-
}
|
|
3733
|
-
const TEXT_MIME_BY_EXT = {
|
|
3734
|
-
".txt": "text/plain",
|
|
3735
|
-
".md": "text/markdown",
|
|
3736
|
-
".markdown": "text/markdown",
|
|
3737
|
-
".csv": "text/csv",
|
|
3738
|
-
".tsv": "text/tab-separated-values",
|
|
3739
|
-
".json": "application/json",
|
|
3740
|
-
".yaml": "application/yaml",
|
|
3741
|
-
".yml": "application/yaml",
|
|
3742
|
-
".xml": "application/xml",
|
|
3743
|
-
".html": "text/html",
|
|
3744
|
-
".htm": "text/html",
|
|
3745
|
-
".css": "text/css",
|
|
3746
|
-
".js": "text/javascript",
|
|
3747
|
-
".ts": "text/plain",
|
|
3748
|
-
".tsx": "text/plain",
|
|
3749
|
-
".jsx": "text/plain",
|
|
3750
|
-
".py": "text/x-python",
|
|
3751
|
-
".sh": "text/x-shellscript",
|
|
3752
|
-
".log": "text/plain",
|
|
3753
|
-
".svg": "image/svg+xml"
|
|
3754
|
-
};
|
|
3755
|
-
/** Decide how to handle a paste. `kind: 'binary'` events carry the mime of
|
|
3756
|
-
* bytes the terminal forwarded; text events carry the decoded paste text.
|
|
3757
|
-
*
|
|
3758
|
-
* The empty-text case is the fix for "can't paste an image": on macOS the
|
|
3759
|
-
* reflex is Cmd+V, which the terminal turns into a bracketed *text* paste, so
|
|
3760
|
-
* a screenshot on the clipboard arrives as empty bytes. We route that to an OS
|
|
3761
|
-
* clipboard read instead of dropping it, matching the explicit ctrl+v path. */
|
|
3762
|
-
function routePaste(input) {
|
|
3763
|
-
if (input.kind === "binary") {
|
|
3764
|
-
if (input.mimeType?.startsWith("image/")) return {
|
|
3765
|
-
kind: "binary-image",
|
|
3766
|
-
mediaType: input.mimeType
|
|
3767
|
-
};
|
|
3768
|
-
return { kind: "text" };
|
|
3769
|
-
}
|
|
3770
|
-
const paths = parseDroppedPaths(input.text);
|
|
3771
|
-
if (paths) return {
|
|
3772
|
-
kind: "dropped-paths",
|
|
3773
|
-
paths,
|
|
3774
|
-
text: input.text
|
|
3775
|
-
};
|
|
3776
|
-
if (input.text.trim() === "") return { kind: "clipboard-image" };
|
|
3777
|
-
return { kind: "text" };
|
|
3778
|
-
}
|
|
3779
|
-
/**
|
|
3780
|
-
* Resolve path candidates into attachable files. Every path must be an
|
|
3781
|
-
* existing regular file; otherwise the caller should restore the original
|
|
3782
|
-
* paste as text (an all-or-nothing rule so a paste meant as text — which
|
|
3783
|
-
* merely *looks* path-shaped — is never partially eaten).
|
|
3784
|
-
*
|
|
3785
|
-
* Any file type attaches. The mediaType comes from magic bytes when
|
|
3786
|
-
* detectable, an extension map for the plain-text formats magic can't see,
|
|
3787
|
-
* and application/octet-stream as the last resort — the attachments API
|
|
3788
|
-
* accepts any mediaType (100MB cap enforced server-side at presign).
|
|
3789
|
-
*/
|
|
3790
|
-
async function resolveDroppedFiles(paths) {
|
|
3791
|
-
try {
|
|
3792
|
-
const files = await Promise.all(paths.map(async (path) => {
|
|
3793
|
-
if (!(await stat(path)).isFile()) return null;
|
|
3794
|
-
const data = new Uint8Array(await readFile(path));
|
|
3795
|
-
const mediaType = (await fileTypeFromBuffer(data))?.mime ?? TEXT_MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
3796
|
-
return {
|
|
3797
|
-
fileName: basename(path),
|
|
3798
|
-
mediaType,
|
|
3799
|
-
data
|
|
3800
|
-
};
|
|
3801
|
-
}));
|
|
3802
|
-
if (!files.every((file) => file !== null)) return null;
|
|
3803
|
-
return files;
|
|
3804
|
-
} catch (_error) {
|
|
3805
|
-
return null;
|
|
3806
|
-
}
|
|
4389
|
+
function parseConversationRef(input) {
|
|
4390
|
+
const match = uuidPattern.exec(input);
|
|
4391
|
+
return match ? match[0].toLowerCase() : null;
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
//#endregion
|
|
4395
|
+
//#region src/chat/transcript-export.ts
|
|
4396
|
+
function messageText(message) {
|
|
4397
|
+
const chunks = [];
|
|
4398
|
+
for (const part of message.parts) if ((part.type === "text" || part.type === "reasoning") && "text" in part && typeof part.text === "string") chunks.push(part.text);
|
|
4399
|
+
else if (part.type === "dynamic-tool" && "toolName" in part) chunks.push(`[tool: ${typeof part.toolName === "string" ? part.toolName : "tool"}]`);
|
|
4400
|
+
return chunks.join("\n").trim();
|
|
4401
|
+
}
|
|
4402
|
+
function messageLabel(message) {
|
|
4403
|
+
if (message.role === "user") return "User";
|
|
4404
|
+
if (message.role === "assistant") return message.metadata?.custom?.agentName ?? "Assistant";
|
|
4405
|
+
return message.role.charAt(0).toUpperCase() + message.role.slice(1);
|
|
4406
|
+
}
|
|
4407
|
+
function parseTranscriptFormat(value) {
|
|
4408
|
+
const normalized = value.trim().toLowerCase();
|
|
4409
|
+
if (!normalized || normalized === "md" || normalized === "markdown") return "markdown";
|
|
4410
|
+
if (normalized === "txt" || normalized === "text") return "text";
|
|
4411
|
+
return null;
|
|
4412
|
+
}
|
|
4413
|
+
function formatTranscript(messages, format) {
|
|
4414
|
+
const entries = messages.flatMap((message) => {
|
|
4415
|
+
const text = messageText(message);
|
|
4416
|
+
if (!text) return [];
|
|
4417
|
+
const label = messageLabel(message);
|
|
4418
|
+
return [format === "markdown" ? `## ${label}\n\n${text}` : `[${label}]\n${text}`];
|
|
4419
|
+
});
|
|
4420
|
+
if (format === "markdown") return `# Conversation transcript\n\n${entries.join("\n\n")}\n`;
|
|
4421
|
+
return `${entries.join("\n\n")}\n`;
|
|
4422
|
+
}
|
|
4423
|
+
async function exportTranscript(options) {
|
|
4424
|
+
const extension = options.format === "markdown" ? "md" : "txt";
|
|
4425
|
+
const filePath = path.join(options.cwd, `skydive-conversation-${options.conversationId}.${extension}`);
|
|
4426
|
+
await writeFile(filePath, formatTranscript(options.messages, options.format), "utf8");
|
|
4427
|
+
return filePath;
|
|
3807
4428
|
}
|
|
3808
4429
|
|
|
3809
4430
|
//#endregion
|
|
@@ -5672,9 +6293,9 @@ function stripLeadingMarker(s) {
|
|
|
5672
6293
|
}
|
|
5673
6294
|
const ELISION_MARKER_TAG = "⋯ elided";
|
|
5674
6295
|
function elisionMarker(bytes) {
|
|
5675
|
-
return `${ELISION_MARKER_TAG} ${formatBytes$
|
|
6296
|
+
return `${ELISION_MARKER_TAG} ${formatBytes$1(bytes)} to save memory ⋯`;
|
|
5676
6297
|
}
|
|
5677
|
-
function formatBytes$
|
|
6298
|
+
function formatBytes$1(n) {
|
|
5678
6299
|
if (n < 1024) return `${n} chars`;
|
|
5679
6300
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
5680
6301
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
@@ -6696,6 +7317,14 @@ function latestTodosFromHistory(messages) {
|
|
|
6696
7317
|
}
|
|
6697
7318
|
return null;
|
|
6698
7319
|
}
|
|
7320
|
+
/**
|
|
7321
|
+
* Whether the plan has work left in it: any row still pending or in
|
|
7322
|
+
* progress. A plan with none is a record of finished work, which is what lets
|
|
7323
|
+
* a reopened conversation fold its card to the header row.
|
|
7324
|
+
*/
|
|
7325
|
+
function todosHaveLiveWork(todos) {
|
|
7326
|
+
return todos.some((todo) => todo.status === "pending" || todo.status === "in_progress");
|
|
7327
|
+
}
|
|
6699
7328
|
/** How many todo rows the card shows before it caps the tail with "+N more". */
|
|
6700
7329
|
const MAX_VISIBLE_ROWS$1 = 6;
|
|
6701
7330
|
/** Fit `rest` under `rowBudget`, spending the last row on a "+N more" tail. */
|
|
@@ -6737,9 +7366,14 @@ function todosCanExpand(todos, cap) {
|
|
|
6737
7366
|
* reserve exactly this much and keep the composer pinned. Header + optional
|
|
6738
7367
|
* done-summary line + rendered rows + optional "+N more" tail + top margin.
|
|
6739
7368
|
* Must track `windowTodos` and `TodoCardView`'s layout.
|
|
7369
|
+
*
|
|
7370
|
+
* `minimized` is the card folded down to its header row (label, progress
|
|
7371
|
+
* counter and the chevron that brings the rows back) plus the top margin, and
|
|
7372
|
+
* nothing else, however long the list is.
|
|
6740
7373
|
*/
|
|
6741
|
-
function todoCardHeight(todos, { expanded }) {
|
|
7374
|
+
function todoCardHeight(todos, { expanded, minimized }) {
|
|
6742
7375
|
if (todos.length === 0) return 0;
|
|
7376
|
+
if (minimized) return 2;
|
|
6743
7377
|
const w = windowTodos(todos, {
|
|
6744
7378
|
cap: MAX_VISIBLE_ROWS$1,
|
|
6745
7379
|
expanded
|
|
@@ -6780,15 +7414,24 @@ function rowColor$1(status) {
|
|
|
6780
7414
|
}
|
|
6781
7415
|
}
|
|
6782
7416
|
/**
|
|
6783
|
-
* The plan card's checklist content. The caller owns outer spacing and
|
|
6784
|
-
*
|
|
6785
|
-
*
|
|
7417
|
+
* The plan card's checklist content. The caller owns outer spacing and both
|
|
7418
|
+
* view states — the chat screen has to reserve the card's rows, so it can't
|
|
7419
|
+
* discover the height from a toggle held down here.
|
|
6786
7420
|
*
|
|
6787
|
-
*
|
|
6788
|
-
*
|
|
6789
|
-
*
|
|
7421
|
+
* The card has two independent click targets, one per axis of "how much do I
|
|
7422
|
+
* show":
|
|
7423
|
+
* - the **header** minimizes the card to that one row and back
|
|
7424
|
+
* (`minimized` / `onToggleMinimized`), which is what keeps a long plan from
|
|
7425
|
+
* eating the screen above the composer. The chevron carries the affordance.
|
|
7426
|
+
* - the **body** unfolds the completed work the window hides
|
|
7427
|
+
* (`expanded` / `onToggleExpanded`), the same interaction reasoning and
|
|
7428
|
+
* tool output already use. Only clickable when something is actually folded.
|
|
7429
|
+
*
|
|
7430
|
+
* Each surface names the other in its hint, since a chevron alone can't say
|
|
7431
|
+
* which click does which: folded, the `✔ N done` row offers the unfold;
|
|
7432
|
+
* unfolded, the header says the rows are what folds back.
|
|
6790
7433
|
*/
|
|
6791
|
-
function TaskCardContent({ label, todos, isActive, expanded, onToggleExpanded }) {
|
|
7434
|
+
function TaskCardContent({ label, todos, isActive, minimized, expanded, onToggleMinimized, onToggleExpanded }) {
|
|
6792
7435
|
const completed = todos.filter((todo) => todo.status === "completed").length;
|
|
6793
7436
|
const canExpand = todosCanExpand(todos, MAX_VISIBLE_ROWS$1);
|
|
6794
7437
|
const { doneSummary, rows, moreCount } = windowTodos(todos, {
|
|
@@ -6797,66 +7440,76 @@ function TaskCardContent({ label, todos, isActive, expanded, onToggleExpanded })
|
|
|
6797
7440
|
});
|
|
6798
7441
|
return /* @__PURE__ */ jsxs("box", {
|
|
6799
7442
|
style: { flexDirection: "column" },
|
|
6800
|
-
|
|
6801
|
-
|
|
6802
|
-
|
|
6803
|
-
|
|
6804
|
-
|
|
6805
|
-
/* @__PURE__ */ jsx("text", {
|
|
6806
|
-
fg: isActive ? theme.accent : theme.muted,
|
|
6807
|
-
children: /* @__PURE__ */ jsx("b", { children: label })
|
|
6808
|
-
}),
|
|
6809
|
-
/* @__PURE__ */ jsxs("text", {
|
|
6810
|
-
fg: theme.dim,
|
|
6811
|
-
children: [
|
|
6812
|
-
" ",
|
|
6813
|
-
completed,
|
|
6814
|
-
"/",
|
|
6815
|
-
todos.length
|
|
6816
|
-
]
|
|
6817
|
-
}),
|
|
6818
|
-
canExpand ? /* @__PURE__ */ jsxs("text", {
|
|
6819
|
-
fg: theme.dim,
|
|
6820
|
-
children: [
|
|
6821
|
-
" ",
|
|
6822
|
-
"· click to ",
|
|
6823
|
-
expanded ? "collapse" : "expand"
|
|
6824
|
-
]
|
|
6825
|
-
}) : null
|
|
6826
|
-
]
|
|
6827
|
-
}),
|
|
6828
|
-
doneSummary > 0 ? /* @__PURE__ */ jsxs("box", {
|
|
6829
|
-
style: { flexDirection: "row" },
|
|
6830
|
-
children: [/* @__PURE__ */ jsx("text", {
|
|
6831
|
-
fg: theme.success,
|
|
6832
|
-
children: "✔ "
|
|
6833
|
-
}), /* @__PURE__ */ jsxs("text", {
|
|
7443
|
+
children: [/* @__PURE__ */ jsxs("box", {
|
|
7444
|
+
style: { flexDirection: "row" },
|
|
7445
|
+
onMouseDown: onToggleMinimized,
|
|
7446
|
+
children: [
|
|
7447
|
+
/* @__PURE__ */ jsx("text", {
|
|
6834
7448
|
fg: theme.dim,
|
|
6835
|
-
children:
|
|
6836
|
-
})
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
7449
|
+
children: minimized ? "▸ " : "▾ "
|
|
7450
|
+
}),
|
|
7451
|
+
/* @__PURE__ */ jsx("text", {
|
|
7452
|
+
fg: isActive ? theme.accent : theme.muted,
|
|
7453
|
+
children: /* @__PURE__ */ jsx("b", { children: label })
|
|
7454
|
+
}),
|
|
7455
|
+
/* @__PURE__ */ jsxs("text", {
|
|
7456
|
+
fg: theme.dim,
|
|
7457
|
+
children: [
|
|
7458
|
+
" ",
|
|
7459
|
+
completed,
|
|
7460
|
+
"/",
|
|
7461
|
+
todos.length
|
|
7462
|
+
]
|
|
7463
|
+
}),
|
|
7464
|
+
!minimized && expanded && canExpand ? /* @__PURE__ */ jsxs("text", {
|
|
7465
|
+
fg: theme.dim,
|
|
7466
|
+
children: [" ", "· click a row to fold"]
|
|
7467
|
+
}) : null
|
|
7468
|
+
]
|
|
7469
|
+
}), minimized ? null : /* @__PURE__ */ jsxs("box", {
|
|
7470
|
+
style: { flexDirection: "column" },
|
|
7471
|
+
onMouseDown: canExpand ? onToggleExpanded : void 0,
|
|
7472
|
+
children: [
|
|
7473
|
+
doneSummary > 0 ? /* @__PURE__ */ jsxs("box", {
|
|
6841
7474
|
style: { flexDirection: "row" },
|
|
6842
|
-
children: [
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
6847
|
-
|
|
6848
|
-
|
|
6849
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
7475
|
+
children: [
|
|
7476
|
+
/* @__PURE__ */ jsx("text", {
|
|
7477
|
+
fg: theme.success,
|
|
7478
|
+
children: "✔ "
|
|
7479
|
+
}),
|
|
7480
|
+
/* @__PURE__ */ jsxs("text", {
|
|
7481
|
+
fg: theme.dim,
|
|
7482
|
+
children: [doneSummary, " done"]
|
|
7483
|
+
}),
|
|
7484
|
+
/* @__PURE__ */ jsxs("text", {
|
|
7485
|
+
fg: theme.dim,
|
|
7486
|
+
children: [" ", "· click to show"]
|
|
7487
|
+
})
|
|
7488
|
+
]
|
|
7489
|
+
}) : null,
|
|
7490
|
+
rows.map((todo) => {
|
|
7491
|
+
const g = glyph(todo.status);
|
|
7492
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
7493
|
+
style: { flexDirection: "row" },
|
|
7494
|
+
children: [/* @__PURE__ */ jsxs("text", {
|
|
7495
|
+
fg: g.fg,
|
|
7496
|
+
children: [g.char, " "]
|
|
7497
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
7498
|
+
fg: rowColor$1(todo.status),
|
|
7499
|
+
children: todo.content
|
|
7500
|
+
})]
|
|
7501
|
+
}, todo.id);
|
|
7502
|
+
}),
|
|
7503
|
+
moreCount > 0 ? /* @__PURE__ */ jsxs("text", {
|
|
7504
|
+
fg: theme.dim,
|
|
7505
|
+
children: [
|
|
7506
|
+
" … +",
|
|
7507
|
+
moreCount,
|
|
7508
|
+
" more"
|
|
7509
|
+
]
|
|
7510
|
+
}) : null
|
|
7511
|
+
]
|
|
7512
|
+
})]
|
|
6860
7513
|
});
|
|
6861
7514
|
}
|
|
6862
7515
|
|
|
@@ -6871,9 +7524,12 @@ function TaskCardContent({ label, todos, isActive, expanded, onToggleExpanded })
|
|
|
6871
7524
|
* The card can't scroll like the web card, so it windows completed and pending
|
|
6872
7525
|
* work. `isActive` dims the header while the run is idle. `expanded` unfolds
|
|
6873
7526
|
* the completed work the window hides — a settled plan collapses to `✔ N done`
|
|
6874
|
-
* otherwise, and clicking the
|
|
7527
|
+
* otherwise, and clicking the body (or `/plan expand`) brings the rows back.
|
|
7528
|
+
* `minimized` is the card folded down to its header row, so a long plan can be
|
|
7529
|
+
* kept out of the way (click the header, or `/plan minimize`) without losing
|
|
7530
|
+
* the label and progress counter the way `/plan hide` does.
|
|
6875
7531
|
*/
|
|
6876
|
-
function TodoCardView({ todos, isActive, expanded, onToggleExpanded }) {
|
|
7532
|
+
function TodoCardView({ todos, isActive, minimized, expanded, onToggleMinimized, onToggleExpanded }) {
|
|
6877
7533
|
if (todos.length === 0) return null;
|
|
6878
7534
|
return /* @__PURE__ */ jsx("box", {
|
|
6879
7535
|
style: {
|
|
@@ -6887,7 +7543,9 @@ function TodoCardView({ todos, isActive, expanded, onToggleExpanded }) {
|
|
|
6887
7543
|
label: "Plan",
|
|
6888
7544
|
todos,
|
|
6889
7545
|
isActive,
|
|
7546
|
+
minimized,
|
|
6890
7547
|
expanded,
|
|
7548
|
+
onToggleMinimized,
|
|
6891
7549
|
onToggleExpanded
|
|
6892
7550
|
})
|
|
6893
7551
|
});
|
|
@@ -6970,6 +7628,8 @@ function subagentTaskTitles(input) {
|
|
|
6970
7628
|
});
|
|
6971
7629
|
}
|
|
6972
7630
|
const QUEUED_TASK_ID_LINE = /^\s*-\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):\s/i;
|
|
7631
|
+
const QUEUED_TASK_ID_PARENS = /\(taskId\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)/i;
|
|
7632
|
+
const QUEUED_TASK_CONV_PREFIX = /^\s*-\s*conversation\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):\s/i;
|
|
6973
7633
|
const QUEUED_TASK_CONVERSATION_TAIL = /peek:\s*conversations show ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\s*$/i;
|
|
6974
7634
|
/**
|
|
6975
7635
|
* The runs a `subagent` call queued, read back out of its own output. The
|
|
@@ -6982,11 +7642,11 @@ function queuedSubagentTasks(output) {
|
|
|
6982
7642
|
if (!text) return [];
|
|
6983
7643
|
const tasks = [];
|
|
6984
7644
|
for (const line of text.split("\n")) {
|
|
6985
|
-
const
|
|
6986
|
-
if (!
|
|
7645
|
+
const rawId = line.match(QUEUED_TASK_ID_PARENS)?.[1] ?? line.match(QUEUED_TASK_ID_LINE)?.[1];
|
|
7646
|
+
if (!rawId) continue;
|
|
6987
7647
|
tasks.push({
|
|
6988
|
-
id:
|
|
6989
|
-
conversationId: line.match(QUEUED_TASK_CONVERSATION_TAIL)?.[1]?.toLowerCase() ?? null
|
|
7648
|
+
id: rawId.toLowerCase(),
|
|
7649
|
+
conversationId: (line.match(QUEUED_TASK_CONV_PREFIX)?.[1] ?? line.match(QUEUED_TASK_CONVERSATION_TAIL)?.[1])?.toLowerCase() ?? null
|
|
6990
7650
|
});
|
|
6991
7651
|
}
|
|
6992
7652
|
return tasks;
|
|
@@ -7028,9 +7688,9 @@ function flattenOutputText(value) {
|
|
|
7028
7688
|
* anchor set the screen renders is always at most one.
|
|
7029
7689
|
*/
|
|
7030
7690
|
function subagentAnchorsFromHistory(items) {
|
|
7031
|
-
|
|
7032
|
-
for (const item of items) if (item.kind === "tool" && item.toolName === "subagent")
|
|
7033
|
-
return
|
|
7691
|
+
const ids = /* @__PURE__ */ new Set();
|
|
7692
|
+
for (const item of items) if (item.kind === "tool" && item.toolName === "subagent") ids.add(item.id);
|
|
7693
|
+
return [...ids];
|
|
7034
7694
|
}
|
|
7035
7695
|
/**
|
|
7036
7696
|
* The next anchor set as a `subagent` call streams in live. Always the single
|
|
@@ -7038,8 +7698,8 @@ function subagentAnchorsFromHistory(items) {
|
|
|
7038
7698
|
* stacking beside it, so the composer only ever has one fan-out card above it.
|
|
7039
7699
|
*/
|
|
7040
7700
|
function nextAnchoredSubagentIds({ current, nextId }) {
|
|
7041
|
-
if (current.
|
|
7042
|
-
return [nextId];
|
|
7701
|
+
if (current.includes(nextId)) return current;
|
|
7702
|
+
return [...current, nextId];
|
|
7043
7703
|
}
|
|
7044
7704
|
/**
|
|
7045
7705
|
* The card's rows: one per queued run (call order), each carrying the
|
|
@@ -7051,7 +7711,7 @@ function nextAnchoredSubagentIds({ current, nextId }) {
|
|
|
7051
7711
|
* mid-turn.
|
|
7052
7712
|
*/
|
|
7053
7713
|
function subagentRows({ titles, queued, fetched }) {
|
|
7054
|
-
if (queued.length === 0) return titles.map((title, index) => ({
|
|
7714
|
+
if (fetched === null && queued.length === 0) return titles.map((title, index) => ({
|
|
7055
7715
|
id: `queued:${index}`,
|
|
7056
7716
|
title,
|
|
7057
7717
|
status: "queued",
|
|
@@ -7062,7 +7722,18 @@ function subagentRows({ titles, queued, fetched }) {
|
|
|
7062
7722
|
contextTokens: null,
|
|
7063
7723
|
latestActivity: null
|
|
7064
7724
|
}));
|
|
7065
|
-
|
|
7725
|
+
const tasks = new Map(queued.map((task, index) => [task.id, {
|
|
7726
|
+
task,
|
|
7727
|
+
index
|
|
7728
|
+
}]));
|
|
7729
|
+
for (const id of fetched?.keys() ?? []) if (!tasks.has(id)) tasks.set(id, {
|
|
7730
|
+
task: {
|
|
7731
|
+
id,
|
|
7732
|
+
conversationId: null
|
|
7733
|
+
},
|
|
7734
|
+
index: -1
|
|
7735
|
+
});
|
|
7736
|
+
return [...tasks.values()].map(({ task, index }) => {
|
|
7066
7737
|
const row = fetched?.get(task.id) ?? null;
|
|
7067
7738
|
return {
|
|
7068
7739
|
id: task.id,
|
|
@@ -7219,35 +7890,66 @@ function SubagentRowView({ row, selected, width, nowMs, onOpen }) {
|
|
|
7219
7890
|
* ↵ to view (the chat screen owns those keys and the esc-back bookkeeping;
|
|
7220
7891
|
* the card reports its rows up through ConversationContext so the screen
|
|
7221
7892
|
* knows what's selectable).
|
|
7893
|
+
*
|
|
7894
|
+
* `minimized` folds the card down to its header row: a wide fan-out is 10 rows
|
|
7895
|
+
* of the terminal for as long as the conversation lives, so clicking the
|
|
7896
|
+
* header (or `/subagent collapse`) folds the rows away and leaves the label,
|
|
7897
|
+
* the live glyph and the N/M counter. The chat screen owns the flag because it
|
|
7898
|
+
* reserves the card's rows to keep the composer pinned.
|
|
7222
7899
|
*/
|
|
7223
|
-
function ToolSubagent({
|
|
7900
|
+
function ToolSubagent({ items, isActive: _isActive, minimized, onToggleMinimized, onHeightChange }) {
|
|
7224
7901
|
const { rest, conversationId, openConversation, onSubagentBatch, subagentSelection } = useConversationContext();
|
|
7225
7902
|
const { width } = useTerminalDimensions();
|
|
7226
|
-
const
|
|
7227
|
-
|
|
7228
|
-
const
|
|
7903
|
+
const optimistic = /* @__PURE__ */ new Map();
|
|
7904
|
+
let pendingIndex = 0;
|
|
7905
|
+
for (const item of items) {
|
|
7906
|
+
const titles = subagentTaskTitles(item.input);
|
|
7907
|
+
const queued = item.output?.kind === "ok" ? queuedSubagentTasks(item.output.value) : [];
|
|
7908
|
+
if (queued.length === 0) {
|
|
7909
|
+
for (const title of titles) {
|
|
7910
|
+
optimistic.set(`pending:${pendingIndex}`, {
|
|
7911
|
+
title,
|
|
7912
|
+
conversationId: null
|
|
7913
|
+
});
|
|
7914
|
+
pendingIndex += 1;
|
|
7915
|
+
}
|
|
7916
|
+
continue;
|
|
7917
|
+
}
|
|
7918
|
+
for (const [index, task] of queued.entries()) if (!optimistic.has(task.id)) optimistic.set(task.id, {
|
|
7919
|
+
title: titles[index] ?? "Subagent task",
|
|
7920
|
+
conversationId: task.conversationId
|
|
7921
|
+
});
|
|
7922
|
+
}
|
|
7923
|
+
const queued = [...optimistic].map(([id, task]) => ({
|
|
7924
|
+
id,
|
|
7925
|
+
conversationId: task.conversationId
|
|
7926
|
+
}));
|
|
7927
|
+
const titles = [...optimistic.values()].map((task) => task.title);
|
|
7229
7928
|
const [fetched, setFetched] = useState(null);
|
|
7230
7929
|
const rows = subagentRows({
|
|
7231
7930
|
titles,
|
|
7232
7931
|
queued,
|
|
7233
7932
|
fetched
|
|
7234
7933
|
});
|
|
7235
|
-
const
|
|
7934
|
+
const resolved = fetched !== null;
|
|
7935
|
+
const live = !resolved || rows.some((row) => isLiveStatus(row.status));
|
|
7236
7936
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
7237
7937
|
useEffect(() => {
|
|
7238
7938
|
if (!live || rows.length === 0) return;
|
|
7239
7939
|
const id = setInterval(() => setNowMs(Date.now()), 1e3);
|
|
7240
7940
|
return () => clearInterval(id);
|
|
7241
7941
|
}, [live, rows.length]);
|
|
7242
|
-
useEffect(() => {
|
|
7243
|
-
onLiveChange?.(live);
|
|
7244
|
-
}, [live, onLiveChange]);
|
|
7245
7942
|
useEffect(() => {
|
|
7246
7943
|
const visibleRows = Math.min(rows.length, MAX_VISIBLE_ROWS);
|
|
7247
|
-
onHeightChange?.(rows.length === 0 ? 1 : 1 + visibleRows + (rows.length > MAX_VISIBLE_ROWS ? 1 : 0));
|
|
7248
|
-
}, [
|
|
7944
|
+
onHeightChange?.(rows.length === 0 ? items.length > 0 ? 1 : 0 : minimized ? 1 : 1 + visibleRows + (rows.length > MAX_VISIBLE_ROWS ? 1 : 0));
|
|
7945
|
+
}, [
|
|
7946
|
+
minimized,
|
|
7947
|
+
items.length,
|
|
7948
|
+
onHeightChange,
|
|
7949
|
+
rows.length
|
|
7950
|
+
]);
|
|
7249
7951
|
useEffect(() => {
|
|
7250
|
-
if (!rest || !conversationId ||
|
|
7952
|
+
if (!rest || !conversationId || !live) return;
|
|
7251
7953
|
let cancelled = false;
|
|
7252
7954
|
let timer = null;
|
|
7253
7955
|
const tick = async () => {
|
|
@@ -7266,15 +7968,14 @@ function ToolSubagent({ item, isActive: _isActive, onLiveChange, onHeightChange
|
|
|
7266
7968
|
}, [
|
|
7267
7969
|
rest,
|
|
7268
7970
|
conversationId,
|
|
7269
|
-
idsKey,
|
|
7270
7971
|
live
|
|
7271
7972
|
]);
|
|
7272
|
-
const reportKey = rows.map((row) => `${row.id}:${row.status}:${row.conversationId ?? ""}`).join("|");
|
|
7273
7973
|
useEffect(() => {
|
|
7274
|
-
if (!onSubagentBatch || rows.length === 0) return;
|
|
7974
|
+
if (!onSubagentBatch || !conversationId || rows.length === 0) return;
|
|
7275
7975
|
onSubagentBatch({
|
|
7276
|
-
toolCallId:
|
|
7976
|
+
toolCallId: conversationId,
|
|
7277
7977
|
live,
|
|
7978
|
+
resolved,
|
|
7278
7979
|
rows: rows.map((row) => ({
|
|
7279
7980
|
id: row.id,
|
|
7280
7981
|
conversationId: row.conversationId
|
|
@@ -7282,49 +7983,61 @@ function ToolSubagent({ item, isActive: _isActive, onLiveChange, onHeightChange
|
|
|
7282
7983
|
});
|
|
7283
7984
|
}, [
|
|
7284
7985
|
onSubagentBatch,
|
|
7285
|
-
|
|
7986
|
+
conversationId,
|
|
7286
7987
|
live,
|
|
7287
|
-
|
|
7988
|
+
resolved,
|
|
7989
|
+
rows.map((row) => `${row.id}:${row.status}:${row.conversationId ?? ""}`).join("|")
|
|
7288
7990
|
]);
|
|
7289
|
-
if (rows.length === 0)
|
|
7290
|
-
|
|
7291
|
-
children: "
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
|
|
7295
|
-
|
|
7296
|
-
|
|
7991
|
+
if (rows.length === 0) {
|
|
7992
|
+
if (items.length === 0 || fetched !== null) return null;
|
|
7993
|
+
return /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
|
|
7994
|
+
fg: theme.warning,
|
|
7995
|
+
children: "◐ "
|
|
7996
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
7997
|
+
fg: theme.tool,
|
|
7998
|
+
children: "Delegating to subagents…"
|
|
7999
|
+
})] });
|
|
8000
|
+
}
|
|
8001
|
+
const selectedIndex = subagentSelection && subagentSelection.toolCallId === conversationId ? Math.min(subagentSelection.index, rows.length - 1) : null;
|
|
7297
8002
|
const start = rows.length > MAX_VISIBLE_ROWS ? windowStart(selectedIndex ?? 0, rows.length, MAX_VISIBLE_ROWS) : 0;
|
|
7298
8003
|
const visible = rows.slice(start, start + MAX_VISIBLE_ROWS);
|
|
7299
8004
|
const moreBelow = rows.length - start - visible.length;
|
|
7300
8005
|
return /* @__PURE__ */ jsxs("box", {
|
|
7301
8006
|
style: { flexDirection: "column" },
|
|
7302
|
-
children: [/* @__PURE__ */
|
|
7303
|
-
|
|
7304
|
-
|
|
7305
|
-
|
|
7306
|
-
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
7312
|
-
|
|
7313
|
-
|
|
7314
|
-
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
|
|
7319
|
-
"
|
|
7320
|
-
|
|
7321
|
-
|
|
7322
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
|
|
8007
|
+
children: [/* @__PURE__ */ jsx("box", {
|
|
8008
|
+
style: { flexDirection: "row" },
|
|
8009
|
+
onMouseDown: onToggleMinimized,
|
|
8010
|
+
children: /* @__PURE__ */ jsxs("text", { children: [
|
|
8011
|
+
/* @__PURE__ */ jsx("span", {
|
|
8012
|
+
fg: theme.dim,
|
|
8013
|
+
children: minimized ? "▸ " : "▾ "
|
|
8014
|
+
}),
|
|
8015
|
+
live ? /* @__PURE__ */ jsx(Spinner, { color: theme.accent }) : rows.every((row) => row.status === "completed") ? /* @__PURE__ */ jsx("span", {
|
|
8016
|
+
fg: theme.success,
|
|
8017
|
+
children: "✔"
|
|
8018
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
8019
|
+
fg: theme.error,
|
|
8020
|
+
children: "✗"
|
|
8021
|
+
}),
|
|
8022
|
+
/* @__PURE__ */ jsxs("span", {
|
|
8023
|
+
fg: theme.tool,
|
|
8024
|
+
children: [" ", /* @__PURE__ */ jsx("b", { children: headerLabel(rows) })]
|
|
8025
|
+
}),
|
|
8026
|
+
/* @__PURE__ */ jsxs("span", {
|
|
8027
|
+
fg: theme.dim,
|
|
8028
|
+
children: [
|
|
8029
|
+
" ",
|
|
8030
|
+
rows.filter((row) => row.status === "completed").length,
|
|
8031
|
+
"/",
|
|
8032
|
+
rows.length
|
|
8033
|
+
]
|
|
8034
|
+
}),
|
|
8035
|
+
openConversation && !minimized ? /* @__PURE__ */ jsxs("span", {
|
|
8036
|
+
fg: theme.dim,
|
|
8037
|
+
children: [" ", selectedIndex !== null ? "↑/↓ select · ↵ view · esc back" : "/subagent to browse"]
|
|
8038
|
+
}) : null
|
|
8039
|
+
] })
|
|
8040
|
+
}), minimized ? null : /* @__PURE__ */ jsxs("box", {
|
|
7328
8041
|
style: { flexDirection: "column" },
|
|
7329
8042
|
children: [visible.map((row, i) => {
|
|
7330
8043
|
const target = row.conversationId;
|
|
@@ -7350,33 +8063,30 @@ function ToolSubagent({ item, isActive: _isActive, onLiveChange, onHeightChange
|
|
|
7350
8063
|
* Delegated work pinned above the plan. Each batch owns its status polling;
|
|
7351
8064
|
* a settled batch keeps its rows — titles and drill-in — until the next
|
|
7352
8065
|
* fan-out replaces the group.
|
|
8066
|
+
*
|
|
8067
|
+
* `minimized` folds the rows away and leaves the header (click it, or
|
|
8068
|
+
* `/subagent minimize`), so a ten-task fan-out stops owning ten rows above the
|
|
8069
|
+
* composer for the rest of the conversation.
|
|
7353
8070
|
*/
|
|
7354
|
-
function SubagentCardView({ items, isActive,
|
|
7355
|
-
const [
|
|
7356
|
-
|
|
7357
|
-
setHeightById((current) => current[id] === height ? current : {
|
|
7358
|
-
...current,
|
|
7359
|
-
[id]: height
|
|
7360
|
-
});
|
|
7361
|
-
}, []);
|
|
7362
|
-
const height = useMemo(() => items.length === 0 ? 0 : 1 + items.reduce((rows, item) => rows + (heightById[item.id] ?? 0), 0) + (items.length - 1), [heightById, items]);
|
|
7363
|
-
useEffect(() => onHeightChange?.(height), [height, onHeightChange]);
|
|
7364
|
-
if (items.length === 0) return null;
|
|
8071
|
+
function SubagentCardView({ items, isActive, minimized, onToggleMinimized, onHeightChange }) {
|
|
8072
|
+
const [height, setHeight] = useState(0);
|
|
8073
|
+
useEffect(() => onHeightChange?.(height > 0 ? height + 1 : 0), [height, onHeightChange]);
|
|
7365
8074
|
return /* @__PURE__ */ jsx("box", {
|
|
7366
8075
|
style: {
|
|
7367
8076
|
flexDirection: "column",
|
|
7368
8077
|
flexShrink: 0,
|
|
7369
|
-
marginTop: 1,
|
|
7370
|
-
paddingLeft: 1,
|
|
7371
|
-
paddingRight: 1,
|
|
8078
|
+
marginTop: height > 0 ? 1 : 0,
|
|
8079
|
+
paddingLeft: height > 0 ? 1 : 0,
|
|
8080
|
+
paddingRight: height > 0 ? 1 : 0,
|
|
7372
8081
|
gap: 1
|
|
7373
8082
|
},
|
|
7374
|
-
children:
|
|
7375
|
-
|
|
8083
|
+
children: /* @__PURE__ */ jsx(ToolSubagent, {
|
|
8084
|
+
items,
|
|
7376
8085
|
isActive,
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
8086
|
+
minimized,
|
|
8087
|
+
onToggleMinimized,
|
|
8088
|
+
onHeightChange: setHeight
|
|
8089
|
+
})
|
|
7380
8090
|
});
|
|
7381
8091
|
}
|
|
7382
8092
|
|
|
@@ -7928,9 +8638,10 @@ function useCompaction({ rest, conversationId }) {
|
|
|
7928
8638
|
//#endregion
|
|
7929
8639
|
//#region src/chat/tui/chat/footer-hints.ts
|
|
7930
8640
|
/** Build the chat footer copy independently of its colors and layout. */
|
|
7931
|
-
function formatChatFooterStatus({ runKind, ctrlCArmed, portalOpen, hasPendingSteer, actionableCardCount, nonWebChannel }) {
|
|
8641
|
+
function formatChatFooterStatus({ runKind, ctrlCArmed, portalOpen, hasPendingSteer, actionableCardCount, actionableCardVerb, nonWebChannel }) {
|
|
7932
8642
|
const portalHint = `ctrl+t ${portalOpen ? "close portal" : "portal"}`;
|
|
7933
|
-
const
|
|
8643
|
+
const verb = actionableCardVerb ?? "connect";
|
|
8644
|
+
const connectHint = actionableCardCount > 1 ? `ctrl+r ${verb} (${actionableCardCount}) · ` : actionableCardCount === 1 ? `ctrl+r ${verb} · ` : "";
|
|
7934
8645
|
switch (runKind) {
|
|
7935
8646
|
case "idle":
|
|
7936
8647
|
if (ctrlCArmed) return "press ctrl+c again to quit";
|
|
@@ -7945,9 +8656,19 @@ function formatChatFooterStatus({ runKind, ctrlCArmed, portalOpen, hasPendingSte
|
|
|
7945
8656
|
* appended only once something has chosen one — an agent with no stored level
|
|
7946
8657
|
* runs at the platform default, and naming a level it never picked would be a
|
|
7947
8658
|
* claim the footer can't back.
|
|
8659
|
+
*
|
|
8660
|
+
* A thread that serves its own model reads differently from one following the
|
|
8661
|
+
* agent (ANY-8702): a differing `agentModel` gets a `thread` marker plus the
|
|
8662
|
+
* default it replaces — the "set and not the same as the default" case James
|
|
8663
|
+
* asked the footer to surface. Provenance is derived from the pair, not
|
|
8664
|
+
* carried as a flag (James, #24018 review). When the thread follows the
|
|
8665
|
+
* default — or the server predates pins and sends no `agentModel` — the model
|
|
8666
|
+
* renders bare exactly as before.
|
|
7948
8667
|
*/
|
|
7949
|
-
function formatModelHint(model, effort) {
|
|
7950
|
-
|
|
8668
|
+
function formatModelHint({ model, effort, agentModel }) {
|
|
8669
|
+
const effortPart = effort ? ` · ${effort}` : "";
|
|
8670
|
+
if (model === null || agentModel === null || agentModel === model) return `ctrl+p · ${model ?? "default"}${effortPart}`;
|
|
8671
|
+
return `ctrl+p · ${model} (thread) · agent default ${agentModel}${effortPart}`;
|
|
7951
8672
|
}
|
|
7952
8673
|
|
|
7953
8674
|
//#endregion
|
|
@@ -8086,6 +8807,83 @@ function createResponsePreview() {
|
|
|
8086
8807
|
};
|
|
8087
8808
|
}
|
|
8088
8809
|
|
|
8810
|
+
//#endregion
|
|
8811
|
+
//#region src/chat/tui/side-panel-layout.ts
|
|
8812
|
+
/**
|
|
8813
|
+
* Layout policy for the chat side panel, split out of the component so it
|
|
8814
|
+
* stays testable without a renderer.
|
|
8815
|
+
*
|
|
8816
|
+
* The panel mimics OpenCode's sidebar: a fixed-width column docked on the
|
|
8817
|
+
* right of the chat, toggled by a keybind rather than shown automatically.
|
|
8818
|
+
* It renders only when the terminal has room for a usable chat strip next to
|
|
8819
|
+
* it; a cramped terminal keeps the toggle inert instead of squeezing the
|
|
8820
|
+
* conversation.
|
|
8821
|
+
*/
|
|
8822
|
+
/** Fixed panel width, matching OpenCode's sidebar (42 cols minus padding). */
|
|
8823
|
+
const SIDE_PANEL_WIDTH = 40;
|
|
8824
|
+
/**
|
|
8825
|
+
* The narrowest terminal that can host the panel: panel width plus the
|
|
8826
|
+
* bare-minimum chat strip (30 cols) and app chrome (2 padding columns).
|
|
8827
|
+
*/
|
|
8828
|
+
const SIDE_PANEL_MIN_WIDTH = SIDE_PANEL_WIDTH + 30 + 2;
|
|
8829
|
+
function sidePanelPlacement({ open, width }) {
|
|
8830
|
+
return open && width >= SIDE_PANEL_MIN_WIDTH ? "side" : "hidden";
|
|
8831
|
+
}
|
|
8832
|
+
|
|
8833
|
+
//#endregion
|
|
8834
|
+
//#region src/chat/tui/side-panel.tsx
|
|
8835
|
+
/**
|
|
8836
|
+
* The chat side panel: a fixed-width column docked on the right of the
|
|
8837
|
+
* conversation, modeled on OpenCode's sidebar (fixed width, background-color
|
|
8838
|
+
* separation instead of a border, full chat height, generous padding, a
|
|
8839
|
+
* blank row between sections). Toggled with ctrl+s; placement and room
|
|
8840
|
+
* checks live in side-panel-layout.ts.
|
|
8841
|
+
*
|
|
8842
|
+
* Sections are deliberately pluggable: the panel body is a stack of blocks,
|
|
8843
|
+
* so costs, computer stats, etc. land here later without re-touching the
|
|
8844
|
+
* chat.tsx layout.
|
|
8845
|
+
*/
|
|
8846
|
+
function SidePanel({ open, width, height, agentName, title }) {
|
|
8847
|
+
if (sidePanelPlacement({
|
|
8848
|
+
open,
|
|
8849
|
+
width
|
|
8850
|
+
}) === "hidden") return null;
|
|
8851
|
+
themeVersion();
|
|
8852
|
+
return /* @__PURE__ */ jsx("box", {
|
|
8853
|
+
style: {
|
|
8854
|
+
width: SIDE_PANEL_WIDTH,
|
|
8855
|
+
height,
|
|
8856
|
+
backgroundColor: theme.surface,
|
|
8857
|
+
paddingLeft: 2,
|
|
8858
|
+
paddingRight: 2,
|
|
8859
|
+
paddingTop: 1,
|
|
8860
|
+
paddingBottom: 1,
|
|
8861
|
+
flexShrink: 0,
|
|
8862
|
+
flexDirection: "column"
|
|
8863
|
+
},
|
|
8864
|
+
children: /* @__PURE__ */ jsx("box", {
|
|
8865
|
+
style: {
|
|
8866
|
+
flexDirection: "column",
|
|
8867
|
+
gap: 2,
|
|
8868
|
+
flexShrink: 0
|
|
8869
|
+
},
|
|
8870
|
+
children: /* @__PURE__ */ jsxs("box", {
|
|
8871
|
+
style: {
|
|
8872
|
+
flexDirection: "column",
|
|
8873
|
+
flexShrink: 0
|
|
8874
|
+
},
|
|
8875
|
+
children: [/* @__PURE__ */ jsx("text", {
|
|
8876
|
+
fg: theme.fg,
|
|
8877
|
+
children: /* @__PURE__ */ jsx("b", { children: title || agentName })
|
|
8878
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
8879
|
+
fg: theme.dim,
|
|
8880
|
+
children: agentName
|
|
8881
|
+
})]
|
|
8882
|
+
})
|
|
8883
|
+
})
|
|
8884
|
+
}, "side-panel");
|
|
8885
|
+
}
|
|
8886
|
+
|
|
8089
8887
|
//#endregion
|
|
8090
8888
|
//#region src/chat/tui/new-chat-splash.tsx
|
|
8091
8889
|
/** Rows the splash art needs before it's worth painting: the 6-row mark, a
|
|
@@ -8345,6 +9143,10 @@ const keybindGroups = [
|
|
|
8345
9143
|
keys: "ctrl+g",
|
|
8346
9144
|
action: "toggle file review pane"
|
|
8347
9145
|
},
|
|
9146
|
+
{
|
|
9147
|
+
keys: "ctrl+s",
|
|
9148
|
+
action: "toggle the side panel"
|
|
9149
|
+
},
|
|
8348
9150
|
{
|
|
8349
9151
|
keys: "exit",
|
|
8350
9152
|
action: "quit (type it as a message)"
|
|
@@ -8398,12 +9200,24 @@ const keybindGroups = [
|
|
|
8398
9200
|
action: "jump to latest (when scrolled up)"
|
|
8399
9201
|
},
|
|
8400
9202
|
{
|
|
8401
|
-
keys: "
|
|
9203
|
+
keys: "ctrl+y",
|
|
9204
|
+
action: "jump back to your last message"
|
|
9205
|
+
},
|
|
9206
|
+
{
|
|
9207
|
+
keys: "click card header",
|
|
9208
|
+
action: "minimize the pinned plan or subagents card, and restore it"
|
|
9209
|
+
},
|
|
9210
|
+
{
|
|
9211
|
+
keys: "click card body",
|
|
8402
9212
|
action: "expand a folded plan card back to its finished rows"
|
|
8403
9213
|
},
|
|
8404
9214
|
{
|
|
8405
|
-
keys: "/plan [show|hide|expand|collapse]",
|
|
8406
|
-
action: "show / hide /
|
|
9215
|
+
keys: "/plan [show|hide|minimize|expand|collapse]",
|
|
9216
|
+
action: "show / hide / minimize the pinned plan card, or unfold it"
|
|
9217
|
+
},
|
|
9218
|
+
{
|
|
9219
|
+
keys: "/subagent [minimize]",
|
|
9220
|
+
action: "browse the pinned subagents card, or minimize it"
|
|
8407
9221
|
},
|
|
8408
9222
|
{
|
|
8409
9223
|
keys: "/rename <title>",
|
|
@@ -8557,6 +9371,18 @@ const keybindGroups = [
|
|
|
8557
9371
|
keys: "↵",
|
|
8558
9372
|
action: "open conversation"
|
|
8559
9373
|
},
|
|
9374
|
+
{
|
|
9375
|
+
keys: "ctrl+v",
|
|
9376
|
+
action: "paste image from clipboard"
|
|
9377
|
+
},
|
|
9378
|
+
{
|
|
9379
|
+
keys: "drag-drop",
|
|
9380
|
+
action: "attach image files dropped onto the terminal"
|
|
9381
|
+
},
|
|
9382
|
+
{
|
|
9383
|
+
keys: "ctrl+x",
|
|
9384
|
+
action: "drop the last staged image"
|
|
9385
|
+
},
|
|
8560
9386
|
{
|
|
8561
9387
|
keys: "ctrl+a",
|
|
8562
9388
|
action: "archive conversation (hides it for you only; again to restore)"
|
|
@@ -9108,8 +9934,8 @@ const slashCommands = [
|
|
|
9108
9934
|
{
|
|
9109
9935
|
name: "subagent",
|
|
9110
9936
|
aliases: ["subagents"],
|
|
9111
|
-
argsHint:
|
|
9112
|
-
description: "
|
|
9937
|
+
argsHint: "[minimize]",
|
|
9938
|
+
description: "browse or minimize the fan-out card",
|
|
9113
9939
|
action: { kind: "subagent" }
|
|
9114
9940
|
},
|
|
9115
9941
|
{
|
|
@@ -9129,8 +9955,8 @@ const slashCommands = [
|
|
|
9129
9955
|
{
|
|
9130
9956
|
name: "plan",
|
|
9131
9957
|
aliases: ["todos", "todo"],
|
|
9132
|
-
argsHint: "[show|hide|expand|collapse]",
|
|
9133
|
-
description: "show, hide, or
|
|
9958
|
+
argsHint: "[show|hide|minimize|expand|collapse]",
|
|
9959
|
+
description: "show, hide, minimize, or unfold the pinned plan card",
|
|
9134
9960
|
action: {
|
|
9135
9961
|
kind: "plan",
|
|
9136
9962
|
mode: "toggle"
|
|
@@ -9717,21 +10543,113 @@ function effortLabel(model, level) {
|
|
|
9717
10543
|
return model.thinkingLevelLabels?.[level] ?? level;
|
|
9718
10544
|
}
|
|
9719
10545
|
|
|
10546
|
+
//#endregion
|
|
10547
|
+
//#region src/chat/tui/screens/model-floor.ts
|
|
10548
|
+
/** No floor on offer: the picker behaves as it did before the floor existed. */
|
|
10549
|
+
const NO_FREE_FLOOR = {
|
|
10550
|
+
freeModels: null,
|
|
10551
|
+
lock: null
|
|
10552
|
+
};
|
|
10553
|
+
/**
|
|
10554
|
+
* Derives the picker's floor view from the org's billing summary and the
|
|
10555
|
+
* floor's live state, with the same verdicts as the web switcher
|
|
10556
|
+
* (`use-models.ts`), so the two pickers never disagree about what is free
|
|
10557
|
+
* or locked.
|
|
10558
|
+
*
|
|
10559
|
+
* The floor is offered to ENFORCED orgs only (with enforcement off no gate
|
|
10560
|
+
* runs, so nothing is free and nothing is locked), and only while
|
|
10561
|
+
* `available`: in the capped state the gate refuses floor runs too, so the
|
|
10562
|
+
* picker falls back to its plain treatment instead of tagging models free
|
|
10563
|
+
* that the gate would bill.
|
|
10564
|
+
*/
|
|
10565
|
+
function resolveFreeFloor({ summary, floor }) {
|
|
10566
|
+
if (!summary.billingEnforcementEnabled || floor === null || !floor.available) return NO_FREE_FLOOR;
|
|
10567
|
+
const freeModels = new Set(floor.models);
|
|
10568
|
+
return {
|
|
10569
|
+
freeModels,
|
|
10570
|
+
lock: summary.blockReason === "insufficient_balance" ? {
|
|
10571
|
+
models: freeModels,
|
|
10572
|
+
pinnedModelId: floor.models[0] ?? null,
|
|
10573
|
+
onPaidPlan: summary.planKey !== null && summary.planKey !== "free"
|
|
10574
|
+
} : null
|
|
10575
|
+
};
|
|
10576
|
+
}
|
|
10577
|
+
/**
|
|
10578
|
+
* The model the picker marks as current. Under the lock the gate pins any
|
|
10579
|
+
* off-floor selection to the floor, so the marker follows the pin: the saved
|
|
10580
|
+
* model when it is on the floor, else the pinned floor model. Mirrors the
|
|
10581
|
+
* server (`resolveFreeFloor` pins off-floor requests) so the row with the
|
|
10582
|
+
* marker is the model the next run really uses.
|
|
10583
|
+
*/
|
|
10584
|
+
function effectiveCurrentModel({ currentModel, lock }) {
|
|
10585
|
+
if (lock === null) return currentModel;
|
|
10586
|
+
return currentModel !== null && lock.models.has(currentModel) ? currentModel : lock.pinnedModelId;
|
|
10587
|
+
}
|
|
10588
|
+
|
|
9720
10589
|
//#endregion
|
|
9721
10590
|
//#region src/chat/tui/screens/model-picker.tsx
|
|
9722
10591
|
/**
|
|
10592
|
+
* The free floor's view for the picker (ANY-8042 / ANY-8661), or none. Both
|
|
10593
|
+
* reads are best-effort and fail OPEN together: a billing read that errors
|
|
10594
|
+
* (an api that predates the routes, a session with no active org, a
|
|
10595
|
+
* transient failure) leaves the picker exactly as it was before the floor
|
|
10596
|
+
* existed. A fetch error must never lock a paying org's picker; the run gate
|
|
10597
|
+
* stays authoritative either way. The two reads run alongside each other and
|
|
10598
|
+
* the floor is discarded for a non-enforced org inside `resolveFreeFloor`,
|
|
10599
|
+
* which costs one small query on such orgs but keeps the picker's open to a
|
|
10600
|
+
* single round-trip.
|
|
10601
|
+
*/
|
|
10602
|
+
async function loadFreeFloor(rest) {
|
|
10603
|
+
try {
|
|
10604
|
+
const [summary, floor] = await Promise.all([rest.getBillingSummary(), rest.getFreeFloorState()]);
|
|
10605
|
+
return resolveFreeFloor({
|
|
10606
|
+
summary,
|
|
10607
|
+
floor
|
|
10608
|
+
});
|
|
10609
|
+
} catch (_error) {
|
|
10610
|
+
return NO_FREE_FLOOR;
|
|
10611
|
+
}
|
|
10612
|
+
}
|
|
10613
|
+
/**
|
|
10614
|
+
* A 404 from the pin route means the server predates it, not that the pick
|
|
10615
|
+
* failed: a canary CLI publishes minutes after its release push while the
|
|
10616
|
+
* API deploy on that same push takes far longer (ANY-8191, ANY-8202), so a
|
|
10617
|
+
* fresh canary can briefly call a route production doesn't serve yet. Fall
|
|
10618
|
+
* back to the agent-wide write the switcher used before pins existed — a
|
|
10619
|
+
* pick that lands with a notice beats a dead switcher.
|
|
10620
|
+
*/
|
|
10621
|
+
async function withAgentFallback(pin, agentWide) {
|
|
10622
|
+
try {
|
|
10623
|
+
return {
|
|
10624
|
+
settled: await pin(),
|
|
10625
|
+
pinUnsupported: false
|
|
10626
|
+
};
|
|
10627
|
+
} catch (err) {
|
|
10628
|
+
if (!(err instanceof HttpError) || err.status !== 404) throw err;
|
|
10629
|
+
return {
|
|
10630
|
+
settled: await agentWide(),
|
|
10631
|
+
pinUnsupported: true
|
|
10632
|
+
};
|
|
10633
|
+
}
|
|
10634
|
+
}
|
|
10635
|
+
/**
|
|
9723
10636
|
* In-chat model switcher (opened with ctrl+p, or `/model`). Rendered as an
|
|
9724
10637
|
* overlay in place of the transcript+composer while the chat screen stays
|
|
9725
10638
|
* mounted, so the conversation, input draft, and any live run survive picking
|
|
9726
10639
|
* a model.
|
|
9727
10640
|
*
|
|
9728
|
-
*
|
|
9729
|
-
*
|
|
9730
|
-
*
|
|
9731
|
-
*
|
|
9732
|
-
*
|
|
10641
|
+
* Where a pick LANDS depends on whether the conversation exists yet
|
|
10642
|
+
* (ANY-4652). In a live thread it pins the model to that thread alone, so the
|
|
10643
|
+
* other sessions you have open keep the model they were already using. Before
|
|
10644
|
+
* the first message there is no thread to pin, so the pick moves the agent's
|
|
10645
|
+
* own default and therefore seeds the conversation about to start — the
|
|
10646
|
+
* ChatGPT/Claude behaviour.
|
|
10647
|
+
*
|
|
10648
|
+
* `tab` on a reasoning model opens its effort list instead, which is also
|
|
10649
|
+
* where `/effort` lands directly. Both callbacks hand the settled values back
|
|
10650
|
+
* to the chat screen so its status line updates without a round-trip.
|
|
9733
10651
|
*/
|
|
9734
|
-
function ModelPicker({ agentId, currentModel, currentEffort, modelLocked, initialView, onModelSelected, onEffortSelected, onCancel }) {
|
|
10652
|
+
function ModelPicker({ agentId, conversationId, currentModel, currentEffort, modelLocked, initialView, onModelSelected, onEffortSelected, onCancel }) {
|
|
9735
10653
|
const rest = useStore((s) => s.rest);
|
|
9736
10654
|
const [state, setState] = useState({ kind: "loading" });
|
|
9737
10655
|
useEffect(() => {
|
|
@@ -9739,12 +10657,17 @@ function ModelPicker({ agentId, currentModel, currentEffort, modelLocked, initia
|
|
|
9739
10657
|
let cancelled = false;
|
|
9740
10658
|
(async () => {
|
|
9741
10659
|
try {
|
|
9742
|
-
const [models, savedEfforts] = await Promise.all([
|
|
10660
|
+
const [models, savedEfforts, floor] = await Promise.all([
|
|
10661
|
+
rest.listModels(),
|
|
10662
|
+
rest.listModelEfforts().catch(() => ({})),
|
|
10663
|
+
loadFreeFloor(rest)
|
|
10664
|
+
]);
|
|
9743
10665
|
if (cancelled) return;
|
|
9744
10666
|
setState({
|
|
9745
10667
|
kind: "ready",
|
|
9746
10668
|
models,
|
|
9747
|
-
savedEfforts
|
|
10669
|
+
savedEfforts,
|
|
10670
|
+
floor
|
|
9748
10671
|
});
|
|
9749
10672
|
} catch (err) {
|
|
9750
10673
|
if (cancelled) return;
|
|
@@ -9775,11 +10698,13 @@ function ModelPicker({ agentId, currentModel, currentEffort, modelLocked, initia
|
|
|
9775
10698
|
return /* @__PURE__ */ jsx(ModelSwitcher, {
|
|
9776
10699
|
models: state.models,
|
|
9777
10700
|
initialSavedEfforts: state.savedEfforts,
|
|
10701
|
+
floor: state.floor,
|
|
9778
10702
|
currentModel,
|
|
9779
10703
|
currentEffort,
|
|
9780
10704
|
modelLocked,
|
|
9781
10705
|
initialView,
|
|
9782
10706
|
agentId,
|
|
10707
|
+
conversationId,
|
|
9783
10708
|
onModelSelected,
|
|
9784
10709
|
onEffortSelected,
|
|
9785
10710
|
onCancel
|
|
@@ -9811,7 +10736,7 @@ function ModelPickerFrame({ hint, children }) {
|
|
|
9811
10736
|
]
|
|
9812
10737
|
});
|
|
9813
10738
|
}
|
|
9814
|
-
function ModelSwitcher({ models, initialSavedEfforts, currentModel, currentEffort, modelLocked, initialView, agentId, onModelSelected, onEffortSelected, onCancel }) {
|
|
10739
|
+
function ModelSwitcher({ models, initialSavedEfforts, floor, currentModel, currentEffort, modelLocked, initialView, agentId, conversationId, onModelSelected, onEffortSelected, onCancel }) {
|
|
9815
10740
|
const rest = useStore((s) => s.rest);
|
|
9816
10741
|
const [savedEfforts, setSavedEfforts] = useState(initialSavedEfforts);
|
|
9817
10742
|
const [view, setView] = useState(() => {
|
|
@@ -9847,14 +10772,28 @@ function ModelSwitcher({ models, initialSavedEfforts, currentModel, currentEffor
|
|
|
9847
10772
|
setSaving(true);
|
|
9848
10773
|
setSaveError(null);
|
|
9849
10774
|
try {
|
|
9850
|
-
const
|
|
10775
|
+
const agentWide = () => rest.updateAgentModel({
|
|
9851
10776
|
agentId,
|
|
9852
10777
|
model: model.id,
|
|
9853
10778
|
thinkingLevel: effort
|
|
9854
|
-
})
|
|
10779
|
+
}).then((settled) => ({
|
|
10780
|
+
...settled,
|
|
10781
|
+
scope: "agent"
|
|
10782
|
+
}));
|
|
10783
|
+
const pick = conversationId ? await withAgentFallback(() => rest.setConversationModel({
|
|
10784
|
+
conversationId,
|
|
10785
|
+
agentId,
|
|
10786
|
+
model: model.id,
|
|
10787
|
+
thinkingLevel: effort
|
|
10788
|
+
}), agentWide) : {
|
|
10789
|
+
settled: await agentWide(),
|
|
10790
|
+
pinUnsupported: false
|
|
10791
|
+
};
|
|
9855
10792
|
onModelSelected({
|
|
9856
|
-
model: settled.model ?? model.id,
|
|
9857
|
-
effort: settled.thinkingLevel
|
|
10793
|
+
model: pick.settled.model ?? model.id,
|
|
10794
|
+
effort: pick.settled.thinkingLevel,
|
|
10795
|
+
pinUnsupported: pick.pinUnsupported,
|
|
10796
|
+
scope: pick.pinUnsupported ? "agent" : pick.settled.scope
|
|
9858
10797
|
});
|
|
9859
10798
|
} catch (err) {
|
|
9860
10799
|
failed(err);
|
|
@@ -9870,13 +10809,35 @@ function ModelSwitcher({ models, initialSavedEfforts, currentModel, currentEffor
|
|
|
9870
10809
|
thinkingLevel: level
|
|
9871
10810
|
});
|
|
9872
10811
|
if (model.id === currentModel) {
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
|
|
10812
|
+
let pinUnsupported = false;
|
|
10813
|
+
let scope = null;
|
|
10814
|
+
if (conversationId) {
|
|
10815
|
+
const result = await withAgentFallback(() => rest.setConversationModel({
|
|
10816
|
+
conversationId,
|
|
10817
|
+
agentId,
|
|
10818
|
+
model: model.id,
|
|
10819
|
+
thinkingLevel: level
|
|
10820
|
+
}), () => rest.updateAgentEffort({
|
|
10821
|
+
agentId,
|
|
10822
|
+
thinkingLevel: level
|
|
10823
|
+
}).then((settled) => ({
|
|
10824
|
+
...settled,
|
|
10825
|
+
scope: "agent"
|
|
10826
|
+
})));
|
|
10827
|
+
pinUnsupported = result.pinUnsupported;
|
|
10828
|
+
scope = result.settled.scope ?? null;
|
|
10829
|
+
} else {
|
|
10830
|
+
await rest.updateAgentEffort({
|
|
10831
|
+
agentId,
|
|
10832
|
+
thinkingLevel: level
|
|
10833
|
+
});
|
|
10834
|
+
scope = "agent";
|
|
10835
|
+
}
|
|
9877
10836
|
onEffortSelected({
|
|
9878
10837
|
effort: level,
|
|
9879
|
-
label: effortLabel(model, level)
|
|
10838
|
+
label: effortLabel(model, level),
|
|
10839
|
+
pinUnsupported,
|
|
10840
|
+
scope
|
|
9880
10841
|
});
|
|
9881
10842
|
return;
|
|
9882
10843
|
}
|
|
@@ -9905,6 +10866,7 @@ function ModelSwitcher({ models, initialSavedEfforts, currentModel, currentEffor
|
|
|
9905
10866
|
});
|
|
9906
10867
|
return /* @__PURE__ */ jsx(ModelList, {
|
|
9907
10868
|
models,
|
|
10869
|
+
floor,
|
|
9908
10870
|
currentModel,
|
|
9909
10871
|
modelLocked,
|
|
9910
10872
|
saving,
|
|
@@ -9921,10 +10883,26 @@ function ModelSwitcher({ models, initialSavedEfforts, currentModel, currentEffor
|
|
|
9921
10883
|
onCancel
|
|
9922
10884
|
});
|
|
9923
10885
|
}
|
|
9924
|
-
function ModelList({ models, currentModel, modelLocked, saving, saveError, effortFor, onSelect, onEditEffort, onCancel }) {
|
|
10886
|
+
function ModelList({ models, floor, currentModel, modelLocked, saving, saveError, effortFor, onSelect, onEditEffort, onCancel }) {
|
|
9925
10887
|
const { width, height } = useTerminalDimensions();
|
|
9926
10888
|
const [query, setQuery] = useState("");
|
|
9927
|
-
const
|
|
10889
|
+
const { lock } = floor;
|
|
10890
|
+
const markedModel = effectiveCurrentModel({
|
|
10891
|
+
currentModel,
|
|
10892
|
+
lock
|
|
10893
|
+
});
|
|
10894
|
+
const [highlight, setHighlight] = useState(() => {
|
|
10895
|
+
if (lock === null) return 0;
|
|
10896
|
+
const index = models.findIndex((m) => m.id === markedModel);
|
|
10897
|
+
return index < 0 ? 0 : index;
|
|
10898
|
+
});
|
|
10899
|
+
/**
|
|
10900
|
+
* Locked rows stay visible (so the user sees what credits would unlock),
|
|
10901
|
+
* dimmed, navigable, and answer to no key but movement; the status line
|
|
10902
|
+
* says why while one is highlighted. No per-row marker: on a long catalog
|
|
10903
|
+
* it wraps every row for a fact the notice already states.
|
|
10904
|
+
*/
|
|
10905
|
+
const isLocked = (model) => lock !== null && !lock.models.has(model.id);
|
|
9928
10906
|
const hits = fuzzyFilter(query, models, modelKeys);
|
|
9929
10907
|
const filtered = hits.map((h) => h.item);
|
|
9930
10908
|
const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
|
|
@@ -9941,15 +10919,16 @@ function ModelList({ models, currentModel, modelLocked, saving, saveError, effor
|
|
|
9941
10919
|
else if (key.name === "down") setHighlight(Math.min(Math.max(0, filtered.length - 1), clamped + 1));
|
|
9942
10920
|
else if (key.name === "tab") {
|
|
9943
10921
|
const model = filtered[clamped];
|
|
9944
|
-
if (model && effortFor(model)) onEditEffort(model);
|
|
10922
|
+
if (model && !isLocked(model) && effortFor(model)) onEditEffort(model);
|
|
9945
10923
|
} else if (key.name === "return") {
|
|
9946
10924
|
const model = filtered[clamped];
|
|
9947
|
-
if (model) onSelect(model);
|
|
10925
|
+
if (model && !isLocked(model)) onSelect(model);
|
|
9948
10926
|
}
|
|
9949
10927
|
});
|
|
9950
10928
|
const highlighted = filtered[clamped];
|
|
9951
|
-
const
|
|
9952
|
-
const
|
|
10929
|
+
const highlightedLocked = !!highlighted && isLocked(highlighted);
|
|
10930
|
+
const canEditEffort = !!highlighted && !highlightedLocked && !!effortFor(highlighted);
|
|
10931
|
+
const statusLine = saving ? "saving…" : saveError ? saveError : `${filtered.length}/${models.length} · ↑/↓ move · ${highlightedLocked ? "↵ needs credits" : "↵ select"}${canEditEffort ? " · tab effort" : ""} · esc cancel`;
|
|
9953
10932
|
return /* @__PURE__ */ jsxs("box", {
|
|
9954
10933
|
style: {
|
|
9955
10934
|
flexDirection: "column",
|
|
@@ -9964,7 +10943,19 @@ function ModelList({ models, currentModel, modelLocked, saving, saveError, effor
|
|
|
9964
10943
|
fg: theme.warning,
|
|
9965
10944
|
children: "model choice is limited while this agent uses a Google integration. models without a zero-data-retention route will be rejected."
|
|
9966
10945
|
}) : null,
|
|
9967
|
-
/* @__PURE__ */ jsxs("box", {
|
|
10946
|
+
lock !== null ? /* @__PURE__ */ jsxs("box", {
|
|
10947
|
+
style: {
|
|
10948
|
+
flexDirection: "column",
|
|
10949
|
+
marginTop: 1
|
|
10950
|
+
},
|
|
10951
|
+
children: [/* @__PURE__ */ jsx("text", {
|
|
10952
|
+
fg: theme.fg,
|
|
10953
|
+
children: lock.onPaidPlan ? "You're out of credits" : "You're on the Free plan"
|
|
10954
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
10955
|
+
fg: theme.muted,
|
|
10956
|
+
children: "Some models are unavailable."
|
|
10957
|
+
})]
|
|
10958
|
+
}) : /* @__PURE__ */ jsxs("box", {
|
|
9968
10959
|
style: {
|
|
9969
10960
|
flexDirection: "row",
|
|
9970
10961
|
height: 1,
|
|
@@ -10002,10 +10993,11 @@ function ModelList({ models, currentModel, modelLocked, saving, saveError, effor
|
|
|
10002
10993
|
}) : windowed.map((hit, i) => {
|
|
10003
10994
|
const m = hit.item;
|
|
10004
10995
|
const selected = start + i === clamped;
|
|
10005
|
-
const isCurrent = m.id ===
|
|
10996
|
+
const isCurrent = m.id === markedModel;
|
|
10997
|
+
const locked = isLocked(m);
|
|
10006
10998
|
const effort = effortFor(m);
|
|
10007
10999
|
return /* @__PURE__ */ jsxs("text", {
|
|
10008
|
-
fg: selected ? theme.accent : theme.fg,
|
|
11000
|
+
fg: selected ? theme.accent : locked ? theme.dim : theme.fg,
|
|
10009
11001
|
children: [
|
|
10010
11002
|
selected ? "› " : " ",
|
|
10011
11003
|
isCurrent ? "● " : " ",
|
|
@@ -10025,6 +11017,10 @@ function ModelList({ models, currentModel, modelLocked, saving, saveError, effor
|
|
|
10025
11017
|
effort ? /* @__PURE__ */ jsx("span", {
|
|
10026
11018
|
fg: theme.dim,
|
|
10027
11019
|
children: ` ${effortLabel(m, effort)}`
|
|
11020
|
+
}) : null,
|
|
11021
|
+
floor.freeModels?.has(m.id) ? /* @__PURE__ */ jsx("span", {
|
|
11022
|
+
fg: theme.success,
|
|
11023
|
+
children: " free"
|
|
10028
11024
|
}) : null
|
|
10029
11025
|
]
|
|
10030
11026
|
}, m.id);
|
|
@@ -11392,7 +12388,7 @@ function canPreview(file) {
|
|
|
11392
12388
|
if (/\/(json|xml|javascript|typescript|x-sh|yaml)$/.test(file.mediaType)) return true;
|
|
11393
12389
|
return /\.(md|mdx|txt|json|jsonl|ya?ml|toml|ini|env|csv|ts|tsx|js|jsx|mjs|cjs|css|scss|html|xml|svg|py|rb|rs|go|java|kt|swift|c|cc|cpp|h|hpp|sh|zsh|fish|sql|graphql|gql)$/i.test(file.path);
|
|
11394
12390
|
}
|
|
11395
|
-
function formatBytes
|
|
12391
|
+
function formatBytes(bytes) {
|
|
11396
12392
|
if (bytes < 1024) return `${bytes} B`;
|
|
11397
12393
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
11398
12394
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
@@ -11460,7 +12456,7 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
|
|
|
11460
12456
|
]);
|
|
11461
12457
|
const visibleFiles = useMemo(() => load.kind === "ready" ? filterReviewFiles(load.files, fileSearch ?? "", (file) => file.path) : [], [load, fileSearch]);
|
|
11462
12458
|
const rows = useMemo(() => buildWorkspaceTree(visibleFiles), [visibleFiles]);
|
|
11463
|
-
const navigatorPreferredWidth = reviewTreePreferredWidth(useMemo(() => buildReviewTree(visibleFiles, (file) => file.path), [visibleFiles]), (file) => ` · ${formatBytes
|
|
12459
|
+
const navigatorPreferredWidth = reviewTreePreferredWidth(useMemo(() => buildReviewTree(visibleFiles, (file) => file.path), [visibleFiles]), (file) => ` · ${formatBytes(file.sizeBytes)}`.length);
|
|
11464
12460
|
const shellDimensions = reviewShellDimensions({
|
|
11465
12461
|
width,
|
|
11466
12462
|
height: shellHeight,
|
|
@@ -11499,7 +12495,7 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
|
|
|
11499
12495
|
if (!canPreview(selectedFile)) {
|
|
11500
12496
|
setPreview({
|
|
11501
12497
|
kind: "none",
|
|
11502
|
-
message: `${selectedFile.mediaType} · ${formatBytes
|
|
12498
|
+
message: `${selectedFile.mediaType} · ${formatBytes(selectedFile.sizeBytes)}\npreview unavailable for binary or large files`
|
|
11503
12499
|
});
|
|
11504
12500
|
return;
|
|
11505
12501
|
}
|
|
@@ -11738,7 +12734,7 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
|
|
|
11738
12734
|
row.name,
|
|
11739
12735
|
/* @__PURE__ */ jsxs("span", {
|
|
11740
12736
|
fg: theme.faint,
|
|
11741
|
-
children: [" · ", formatBytes
|
|
12737
|
+
children: [" · ", formatBytes(row.file.sizeBytes)]
|
|
11742
12738
|
})
|
|
11743
12739
|
]
|
|
11744
12740
|
}, row.file.id);
|
|
@@ -12920,9 +13916,13 @@ function routeInput(raw) {
|
|
|
12920
13916
|
const arg = match.rest.toLowerCase();
|
|
12921
13917
|
return {
|
|
12922
13918
|
kind: "plan",
|
|
12923
|
-
mode: arg === "show" ? "show" : arg === "hide" ? "hide" : arg === "expand" ? "expand" : arg === "collapse" ? "collapse" : "toggle"
|
|
13919
|
+
mode: arg === "show" ? "show" : arg === "hide" ? "hide" : arg === "expand" ? "expand" : arg === "collapse" ? "collapse" : arg === "minimize" ? "minimize" : "toggle"
|
|
12924
13920
|
};
|
|
12925
13921
|
}
|
|
13922
|
+
case "subagent": return {
|
|
13923
|
+
kind: "subagent",
|
|
13924
|
+
mode: match.rest.toLowerCase() === "minimize" ? "minimize" : "browse"
|
|
13925
|
+
};
|
|
12926
13926
|
case "new-conversation":
|
|
12927
13927
|
case "fork":
|
|
12928
13928
|
case "archive-conversation":
|
|
@@ -12930,7 +13930,6 @@ function routeInput(raw) {
|
|
|
12930
13930
|
case "effort-picker":
|
|
12931
13931
|
case "theme-picker":
|
|
12932
13932
|
case "pr-picker":
|
|
12933
|
-
case "subagent":
|
|
12934
13933
|
case "status":
|
|
12935
13934
|
case "share":
|
|
12936
13935
|
case "browser":
|
|
@@ -13044,7 +14043,6 @@ const pageScrollFraction = .75;
|
|
|
13044
14043
|
const HISTORY_PAGE_SIZE = 60;
|
|
13045
14044
|
/** Cap composer growth; past this the textarea scrolls its content instead. */
|
|
13046
14045
|
const maxComposerRows = 8;
|
|
13047
|
-
const maxAttachments = 10;
|
|
13048
14046
|
/**
|
|
13049
14047
|
* A server timestamp as local epoch ms, or null when it is missing or
|
|
13050
14048
|
* unparseable so the caller can fall back to its own clock.
|
|
@@ -13054,18 +14052,9 @@ function parseStampMs(iso) {
|
|
|
13054
14052
|
const ms = Date.parse(iso);
|
|
13055
14053
|
return Number.isNaN(ms) ? null : ms;
|
|
13056
14054
|
}
|
|
13057
|
-
|
|
13058
|
-
|
|
13059
|
-
|
|
13060
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
13061
|
-
}
|
|
13062
|
-
/** Status glyph for a staged attachment chip: uploading / ready / failed. */
|
|
13063
|
-
function attachmentGlyph(a) {
|
|
13064
|
-
switch (a.status) {
|
|
13065
|
-
case "uploading": return "↑";
|
|
13066
|
-
case "ready": return "✓";
|
|
13067
|
-
case "error": return `✗ ${a.errorText ?? "upload failed"}`;
|
|
13068
|
-
}
|
|
14055
|
+
/** State updater: undo an opened-finished fold, leave a reader's fold alone. */
|
|
14056
|
+
function unfoldSettled(current) {
|
|
14057
|
+
return current === "settled" ? null : current;
|
|
13069
14058
|
}
|
|
13070
14059
|
/**
|
|
13071
14060
|
* Open a URL in the user's local browser. Best-effort: over SSH there may be
|
|
@@ -13082,6 +14071,7 @@ function isActionableCard(m) {
|
|
|
13082
14071
|
function ChatScreen({ agent, conversation, attachRunId, parentConversationId }) {
|
|
13083
14072
|
const renderer = useRenderer();
|
|
13084
14073
|
const { width, height } = useTerminalDimensions();
|
|
14074
|
+
const { ready: terminalReady } = useTerminalReady();
|
|
13085
14075
|
const rest = useStore((s) => s.rest);
|
|
13086
14076
|
const history = usePromptHistory({ filePath: useStore((s) => s.promptHistoryPath) });
|
|
13087
14077
|
const appUrl = useStore((s) => s.appUrl);
|
|
@@ -13089,6 +14079,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13089
14079
|
const goTo = useStore((s) => s.goTo);
|
|
13090
14080
|
const drillTo = useStore((s) => s.drillTo);
|
|
13091
14081
|
const setChatTitle = useStore((s) => s.setChatTitle);
|
|
14082
|
+
const chatTitle = useStore((s) => s.chatTitle);
|
|
13092
14083
|
const workspaceName = useStore((s) => s.workspaceName);
|
|
13093
14084
|
const portal = useStore((s) => s.portal);
|
|
13094
14085
|
const portalClient = useStore((s) => s.portalClient);
|
|
@@ -13121,12 +14112,14 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13121
14112
|
const [planHidden, setPlanHidden] = useState(false);
|
|
13122
14113
|
const [planExpanded, setPlanExpanded] = useState(false);
|
|
13123
14114
|
const togglePlanExpanded = useCallback(() => setPlanExpanded((current) => !current), []);
|
|
14115
|
+
const [planMinimizedBy, setPlanMinimizedBy] = useState(null);
|
|
14116
|
+
const planMinimized = planMinimizedBy !== null;
|
|
14117
|
+
const togglePlanMinimized = useCallback(() => {
|
|
14118
|
+
setPlanMinimizedBy((current) => current === null ? "reader" : null);
|
|
14119
|
+
setPlanExpanded(false);
|
|
14120
|
+
}, []);
|
|
13124
14121
|
const [subagentRows, setSubagentRows] = useState(0);
|
|
13125
14122
|
const [anchoredSubagentIds, setAnchoredSubagentIds] = useState([]);
|
|
13126
|
-
const anchoredSubagentLiveRef = useRef({});
|
|
13127
|
-
const setAnchoredBatchLive = useCallback((id, live) => {
|
|
13128
|
-
anchoredSubagentLiveRef.current[id] = live;
|
|
13129
|
-
}, []);
|
|
13130
14123
|
const [scrolledUp, setScrolledUp] = useState(false);
|
|
13131
14124
|
const [stickyPromptId, setStickyPromptId] = useState(null);
|
|
13132
14125
|
const transcriptRowRefs = useRef(/* @__PURE__ */ new Map());
|
|
@@ -13145,6 +14138,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13145
14138
|
setPlanExpanded(false);
|
|
13146
14139
|
}
|
|
13147
14140
|
hadTodosRef.current = hasTodos;
|
|
14141
|
+
if (hasTodos && todosHaveLiveWork(todos)) setPlanMinimizedBy(unfoldSettled);
|
|
13148
14142
|
}, [todos]);
|
|
13149
14143
|
const [historyLoaded, setHistoryLoaded] = useState(initialConversationId === null);
|
|
13150
14144
|
const [input, setInput] = useState(() => useStore.getState().takeEarlyInput());
|
|
@@ -13152,10 +14146,17 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13152
14146
|
const [model, setModel] = useState(agent.model ?? null);
|
|
13153
14147
|
const [effort, setEffort] = useState(agent.thinkingLevel ?? null);
|
|
13154
14148
|
const [modelLocked, setModelLocked] = useState(agent.modelLocked ?? false);
|
|
14149
|
+
const [agentDefaultModel, setAgentDefaultModel] = useState(agent.model ?? null);
|
|
13155
14150
|
const [cardPickerOpen, setCardPickerOpen] = useState(false);
|
|
13156
14151
|
const [modelPickerView, setModelPickerView] = useState(null);
|
|
13157
14152
|
const modelPickerOpen = modelPickerView !== null;
|
|
13158
14153
|
const [subagentSelection, setSubagentSelection] = useState(null);
|
|
14154
|
+
const [subagentMinimizedBy, setSubagentMinimizedBy] = useState(null);
|
|
14155
|
+
const subagentMinimized = subagentMinimizedBy !== null;
|
|
14156
|
+
const toggleSubagentMinimized = useCallback(() => {
|
|
14157
|
+
setSubagentMinimizedBy((current) => current === null ? "reader" : null);
|
|
14158
|
+
setSubagentSelection(null);
|
|
14159
|
+
}, []);
|
|
13159
14160
|
const [subagentBatches, setSubagentBatches] = useState(() => /* @__PURE__ */ new Map());
|
|
13160
14161
|
const onSubagentBatch = useCallback((report) => {
|
|
13161
14162
|
setSubagentBatches((current) => {
|
|
@@ -13171,6 +14172,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13171
14172
|
const creditsSequenceRef = useRef(0);
|
|
13172
14173
|
const [reviewOpen, setReviewOpen] = useState(false);
|
|
13173
14174
|
const [reviewFocused, setReviewFocused] = useState(false);
|
|
14175
|
+
const [sidePanelOpen, setSidePanelOpen] = useState(false);
|
|
13174
14176
|
const [reviewTab, setReviewTab] = useState("changes");
|
|
13175
14177
|
const [reviewChangeFile, setReviewChangeFile] = useState(null);
|
|
13176
14178
|
const [reviewSizePref, setReviewSizePref] = useState({
|
|
@@ -13189,8 +14191,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13189
14191
|
}, [initialConversationId]);
|
|
13190
14192
|
const [ctrlCArmed, setCtrlCArmed] = useState(false);
|
|
13191
14193
|
const [composerRows, setComposerRows] = useState(1);
|
|
13192
|
-
const [attachments, setAttachments] = useState([]);
|
|
13193
|
-
const uploadsRef = useRef(/* @__PURE__ */ new Map());
|
|
13194
14194
|
const [forking, setForking] = useState(false);
|
|
13195
14195
|
const forkingRef = useRef(false);
|
|
13196
14196
|
const [credPrompt, setCredPrompt] = useState(null);
|
|
@@ -13200,6 +14200,20 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13200
14200
|
const [askPrompt, setAskPrompt] = useState(null);
|
|
13201
14201
|
const askPromptOpen = askPrompt !== null;
|
|
13202
14202
|
const [restartConfirm, setRestartConfirm] = useState(false);
|
|
14203
|
+
const { staged: attachments, stagedRef: attachmentsRef, stageClipboardImage, dropLast: dropLastAttachment, clearStaged: clearAttachments, collectForSend, adoptUploaded, releaseUploaded, restoreStaged } = useComposerAttachments({
|
|
14204
|
+
rest,
|
|
14205
|
+
agentId: agent.id,
|
|
14206
|
+
pasteDisabled: cardPickerOpen || modelPickerOpen || themePickerOpen || prPickerOpen || helpOpen || creditsOpen || bgTasksOpen || credPromptOpen || Boolean(grantPrompt) || computePromptOpen || askPromptOpen || restartConfirm || nonWebChannel !== null,
|
|
14207
|
+
onPasteText: (text) => {
|
|
14208
|
+
composerRef.current?.insertText(text);
|
|
14209
|
+
handleComposerChange();
|
|
14210
|
+
},
|
|
14211
|
+
onError: (message) => setItems((prev) => [...prev, {
|
|
14212
|
+
kind: "error",
|
|
14213
|
+
id: crypto.randomUUID(),
|
|
14214
|
+
text: message
|
|
14215
|
+
}])
|
|
14216
|
+
});
|
|
13203
14217
|
const [menuDismissed, setMenuDismissed] = useState(false);
|
|
13204
14218
|
const [menuHighlight, setMenuHighlight] = useState(0);
|
|
13205
14219
|
const commandQuery = grantPrompt || credPrompt || computePrompt || askPrompt || restartConfirm ? null : detectCommandTrigger(input);
|
|
@@ -13212,8 +14226,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13212
14226
|
conversationIdRef.current = conversationId;
|
|
13213
14227
|
const itemsRef = useRef(items);
|
|
13214
14228
|
itemsRef.current = items;
|
|
13215
|
-
const attachmentsRef = useRef(attachments);
|
|
13216
|
-
attachmentsRef.current = attachments;
|
|
13217
14229
|
const inputRef = useRef(input);
|
|
13218
14230
|
inputRef.current = input;
|
|
13219
14231
|
const credPromptRef = useRef(credPrompt);
|
|
@@ -13259,6 +14271,17 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13259
14271
|
});
|
|
13260
14272
|
setScrolledUp(false);
|
|
13261
14273
|
}, []);
|
|
14274
|
+
const jumpToLastOwnMessage = useCallback(() => {
|
|
14275
|
+
const box = scrollRef.current;
|
|
14276
|
+
if (!box) return;
|
|
14277
|
+
const prompt = promptForTranscriptIndex(itemsRef.current, itemsRef.current.length - 1);
|
|
14278
|
+
const row = prompt ? transcriptRowRefs.current.get(prompt.id)?.row : null;
|
|
14279
|
+
if (!row) return;
|
|
14280
|
+
box.scrollTo({
|
|
14281
|
+
x: box.scrollLeft,
|
|
14282
|
+
y: box.scrollTop + (row.screenY - box.viewport.screenY)
|
|
14283
|
+
});
|
|
14284
|
+
}, []);
|
|
13262
14285
|
const scrolledUpRef = useRef(false);
|
|
13263
14286
|
useEffect(() => {
|
|
13264
14287
|
const id = setInterval(() => {
|
|
@@ -13289,7 +14312,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13289
14312
|
const paneDragRef = useRef(null);
|
|
13290
14313
|
const composerBoxHeight = composerRows + 2;
|
|
13291
14314
|
const pendingVisible = !grantPrompt && !credPrompt && !computePrompt && !askPrompt && !nonWebChannel && attachments.length > 0;
|
|
13292
|
-
const pendingRows = pendingVisible ? attachments.length
|
|
14315
|
+
const pendingRows = pendingVisible ? attachmentTrayRows(attachments.length) : 0;
|
|
13293
14316
|
const credPromptHeight = 4 + (credPrompt && (credPrompt.error || credPrompt.submitting) ? 1 : 0);
|
|
13294
14317
|
const nonWebNoticeHeight = 5;
|
|
13295
14318
|
const computePromptHeight = 4;
|
|
@@ -13307,23 +14330,36 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13307
14330
|
const reviewContentWidth = reviewPlacement === "side" ? Math.max(1, reviewWidth - 4) : reviewPlacement === "below" ? Math.max(1, bodyWidth - 4) : bodyWidth;
|
|
13308
14331
|
const reviewContentHeight = reviewPlacement === "fullscreen" ? bodyHeight : Math.max(1, reviewHeight - 2);
|
|
13309
14332
|
const chatHeight = bodyHeight;
|
|
14333
|
+
const sidePanelShown = reviewPlacement !== "fullscreen" && sidePanelPlacement({
|
|
14334
|
+
open: sidePanelOpen,
|
|
14335
|
+
width
|
|
14336
|
+
}) === "side";
|
|
14337
|
+
const sidePanelWidth = sidePanelShown ? SIDE_PANEL_WIDTH : 0;
|
|
13310
14338
|
const inlineReviewHeight = reviewOpen && reviewPlacement === "below" ? reviewHeight : 0;
|
|
13311
14339
|
const menuRows = menuOpen ? completionMenuHeight(menuCommands.length) : 0;
|
|
13312
14340
|
const todoCardVisible = !credPrompt && !planHidden && !!todos && todos.length > 0;
|
|
13313
|
-
const todoRows = todoCardVisible ? todoCardHeight(todos, {
|
|
14341
|
+
const todoRows = todoCardVisible ? todoCardHeight(todos, {
|
|
14342
|
+
expanded: planExpanded,
|
|
14343
|
+
minimized: planMinimized
|
|
14344
|
+
}) : 0;
|
|
13314
14345
|
const subagentItems = useMemo(() => {
|
|
13315
14346
|
const ids = new Set(anchoredSubagentIds);
|
|
13316
14347
|
return items.filter((item) => item.kind === "tool" && item.toolName === "subagent" && ids.has(item.id));
|
|
13317
14348
|
}, [items, anchoredSubagentIds]);
|
|
13318
14349
|
const anchoredSubagentBatch = useMemo(() => {
|
|
13319
|
-
|
|
13320
|
-
|
|
13321
|
-
|
|
13322
|
-
|
|
13323
|
-
return null;
|
|
13324
|
-
}, [subagentItems, subagentBatches]);
|
|
14350
|
+
if (!conversationId) return null;
|
|
14351
|
+
const batch = subagentBatches.get(conversationId);
|
|
14352
|
+
return batch && batch.rows.length > 0 ? batch : null;
|
|
14353
|
+
}, [conversationId, subagentBatches]);
|
|
13325
14354
|
const anchoredSubagentBatchRef = useRef(anchoredSubagentBatch);
|
|
13326
14355
|
anchoredSubagentBatchRef.current = anchoredSubagentBatch;
|
|
14356
|
+
const subagentStatusResolvedRef = useRef(false);
|
|
14357
|
+
useEffect(() => {
|
|
14358
|
+
if (!anchoredSubagentBatch?.resolved) return;
|
|
14359
|
+
if (anchoredSubagentBatch.live) setSubagentMinimizedBy(unfoldSettled);
|
|
14360
|
+
else if (!subagentStatusResolvedRef.current) setSubagentMinimizedBy((current) => current ?? "settled");
|
|
14361
|
+
subagentStatusResolvedRef.current = true;
|
|
14362
|
+
}, [anchoredSubagentBatch]);
|
|
13327
14363
|
useEffect(() => {
|
|
13328
14364
|
setSubagentSelection((sel) => {
|
|
13329
14365
|
if (!sel) return sel;
|
|
@@ -13422,9 +14458,11 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13422
14458
|
});
|
|
13423
14459
|
setItems(loaded);
|
|
13424
14460
|
const anchors = subagentAnchorsFromHistory(loaded);
|
|
13425
|
-
if (anchors.length > 0) setAnchoredSubagentIds((current) =>
|
|
14461
|
+
if (anchors.length > 0) setAnchoredSubagentIds((current) => [...new Set([...anchors, ...current])]);
|
|
13426
14462
|
seedPrLinks(loaded);
|
|
13427
|
-
|
|
14463
|
+
const loadedTodos = latestTodosFromHistory(firstPage.messages);
|
|
14464
|
+
setTodos(loadedTodos);
|
|
14465
|
+
if (loadedTodos?.length && !todosHaveLiveWork(loadedTodos)) setPlanMinimizedBy("settled");
|
|
13428
14466
|
setHistoryLoaded(true);
|
|
13429
14467
|
let before = firstPage.hasMore ? firstPage.nextBefore : null;
|
|
13430
14468
|
while (before) {
|
|
@@ -13497,14 +14535,10 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13497
14535
|
prScanner.addChunk(event.chunk);
|
|
13498
14536
|
if ((event.chunk.type === "tool-input-start" || event.chunk.type === "tool-input-available") && event.chunk.toolName === "subagent" && typeof event.chunk.toolCallId === "string") {
|
|
13499
14537
|
const id = event.chunk.toolCallId;
|
|
13500
|
-
setAnchoredSubagentIds((current) => {
|
|
13501
|
-
|
|
13502
|
-
|
|
13503
|
-
|
|
13504
|
-
});
|
|
13505
|
-
if (next.length === 1 && next[0] === id) anchoredSubagentLiveRef.current = { [id]: true };
|
|
13506
|
-
return next;
|
|
13507
|
-
});
|
|
14538
|
+
setAnchoredSubagentIds((current) => nextAnchoredSubagentIds({
|
|
14539
|
+
current,
|
|
14540
|
+
nextId: id
|
|
14541
|
+
}));
|
|
13508
14542
|
}
|
|
13509
14543
|
if (event.chunk.type === "data-anyone-todos-updated") {
|
|
13510
14544
|
const next = todosFromPayload(event.chunk.data);
|
|
@@ -13588,6 +14622,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13588
14622
|
setModel(event.model);
|
|
13589
14623
|
setEffort(event.thinkingLevel ?? null);
|
|
13590
14624
|
setModelLocked(event.modelLocked);
|
|
14625
|
+
setAgentDefaultModel(event.agentModel ?? null);
|
|
13591
14626
|
return;
|
|
13592
14627
|
}
|
|
13593
14628
|
if (event.kind === "conversation" && event.title) setChatTitle(event.title);
|
|
@@ -13630,28 +14665,13 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13630
14665
|
startedAtMs: Date.now()
|
|
13631
14666
|
});
|
|
13632
14667
|
try {
|
|
13633
|
-
const
|
|
13634
|
-
const
|
|
13635
|
-
const
|
|
13636
|
-
|
|
13637
|
-
|
|
13638
|
-
|
|
13639
|
-
|
|
13640
|
-
attachmentIds.push(result.value.id);
|
|
13641
|
-
restorable.push({
|
|
13642
|
-
...row,
|
|
13643
|
-
status: "ready",
|
|
13644
|
-
errorText: null
|
|
13645
|
-
});
|
|
13646
|
-
} else {
|
|
13647
|
-
uploadsRef.current.delete(row.tempId);
|
|
13648
|
-
setItems((prev) => [...prev, {
|
|
13649
|
-
kind: "error",
|
|
13650
|
-
id: crypto.randomUUID(),
|
|
13651
|
-
text: `attachment failed: ${row.fileName}${result.status === "rejected" ? ` (${errorDetail(result.reason)})` : ""}`
|
|
13652
|
-
}]);
|
|
13653
|
-
}
|
|
13654
|
-
}
|
|
14668
|
+
const { files, rows, failed } = await collectForSend(staged);
|
|
14669
|
+
const attachmentIds = files.map((file) => file.id);
|
|
14670
|
+
for (const { attachment, reason } of failed) setItems((prev) => [...prev, {
|
|
14671
|
+
kind: "error",
|
|
14672
|
+
id: crypto.randomUUID(),
|
|
14673
|
+
text: `attachment failed: ${attachment.fileName}${reason === null ? "" : ` (${errorDetail(reason)})`}`
|
|
14674
|
+
}]);
|
|
13655
14675
|
if (!trimmed && attachmentIds.length === 0) {
|
|
13656
14676
|
setItems((prev) => prev.filter((m) => m.id !== optimisticId));
|
|
13657
14677
|
if (!wasStreaming) setRun({ kind: "idle" });
|
|
@@ -13664,7 +14684,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13664
14684
|
attachmentIds,
|
|
13665
14685
|
clientSurface: "tui"
|
|
13666
14686
|
});
|
|
13667
|
-
|
|
14687
|
+
releaseUploaded(rows);
|
|
13668
14688
|
if (result.isNewConversation) setConversationId(result.conversationId);
|
|
13669
14689
|
if (result.steered) {
|
|
13670
14690
|
const directiveId = result.directive?.id;
|
|
@@ -13694,27 +14714,26 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13694
14714
|
text: sendErrorMessage(err)
|
|
13695
14715
|
}]);
|
|
13696
14716
|
if (echo) setInput((existing) => existing ? existing : trimmed);
|
|
13697
|
-
|
|
13698
|
-
if (restore.length > 0) setAttachments((prev) => [...restore.map((a) => ({
|
|
13699
|
-
...a,
|
|
13700
|
-
status: "ready",
|
|
13701
|
-
errorText: null
|
|
13702
|
-
})), ...prev]);
|
|
14717
|
+
restoreStaged(staged);
|
|
13703
14718
|
if (!wasStreaming) setRun({ kind: "idle" });
|
|
13704
14719
|
}
|
|
13705
14720
|
}, [
|
|
13706
14721
|
rest,
|
|
13707
14722
|
agent.id,
|
|
13708
14723
|
conversationId,
|
|
13709
|
-
attachToRun
|
|
14724
|
+
attachToRun,
|
|
14725
|
+
collectForSend,
|
|
14726
|
+
releaseUploaded,
|
|
14727
|
+
restoreStaged
|
|
13710
14728
|
]);
|
|
13711
14729
|
const portalStatus = portal.status;
|
|
13712
14730
|
useEffect(() => {
|
|
13713
|
-
const { seedPrompt, autoGrantMachine } = useStore.getState();
|
|
13714
|
-
if (!seedPrompt || !isNewConversation(conversation)) return;
|
|
14731
|
+
const { seedPrompt, seedAttachments, autoGrantMachine } = useStore.getState();
|
|
14732
|
+
if (!seedPrompt && seedAttachments.length === 0 || !isNewConversation(conversation)) return;
|
|
13715
14733
|
if (autoGrantMachine && (portalStatus === "off" || portalStatus === "connecting")) return;
|
|
13716
14734
|
useStore.setState({
|
|
13717
14735
|
seedPrompt: null,
|
|
14736
|
+
seedAttachments: [],
|
|
13718
14737
|
autoGrantMachine: false
|
|
13719
14738
|
});
|
|
13720
14739
|
(async () => {
|
|
@@ -13732,7 +14751,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13732
14751
|
variant: "warning"
|
|
13733
14752
|
});
|
|
13734
14753
|
}
|
|
13735
|
-
await sendContent(seedPrompt,
|
|
14754
|
+
await sendContent(seedPrompt ?? "", adoptUploaded(seedAttachments));
|
|
13736
14755
|
})();
|
|
13737
14756
|
}, [
|
|
13738
14757
|
conversation,
|
|
@@ -13740,7 +14759,8 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13740
14759
|
portalClient,
|
|
13741
14760
|
agent.id,
|
|
13742
14761
|
agent.name,
|
|
13743
|
-
sendContent
|
|
14762
|
+
sendContent,
|
|
14763
|
+
adoptUploaded
|
|
13744
14764
|
]);
|
|
13745
14765
|
const cancelLatestSteer = useCallback(() => {
|
|
13746
14766
|
if (!rest) return false;
|
|
@@ -13916,7 +14936,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
13916
14936
|
if (!target || target.card.settled !== null || target.status === "done") setAskPrompt(null);
|
|
13917
14937
|
}, [askPrompt, items]);
|
|
13918
14938
|
const promptForCards = useCallback(() => {
|
|
13919
|
-
const targets = itemsRef.current.filter(isActionableCard);
|
|
14939
|
+
const targets = prioritizeActionableCards(itemsRef.current.filter(isActionableCard));
|
|
13920
14940
|
const [only] = targets;
|
|
13921
14941
|
if (!only) return false;
|
|
13922
14942
|
if (targets.length === 1) {
|
|
@@ -14147,95 +15167,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14147
15167
|
if (detectCommandTrigger(composer.plainText) === null) setMenuDismissed(false);
|
|
14148
15168
|
setComposerRows(Math.min(Math.max(composer.editorView.getTotalVirtualLineCount(), 1), maxComposerRows));
|
|
14149
15169
|
}, []);
|
|
14150
|
-
/**
|
|
14151
|
-
* Stage a pasted/dropped file in the composer tray and start its upload
|
|
14152
|
-
* immediately (the web composer's behavior). Submit awaits the in-flight
|
|
14153
|
-
* uploads and carries the resulting attachment ids; the composer never
|
|
14154
|
-
* blocks while an upload runs.
|
|
14155
|
-
*/
|
|
14156
|
-
const stageAttachment = useCallback(({ fileName, mediaType, data }) => {
|
|
14157
|
-
if (!rest) return;
|
|
14158
|
-
if (attachmentsRef.current.length >= maxAttachments) {
|
|
14159
|
-
setItems((prev) => [...prev, {
|
|
14160
|
-
kind: "error",
|
|
14161
|
-
id: crypto.randomUUID(),
|
|
14162
|
-
text: `attachment limit reached (${maxAttachments}); ${fileName} skipped`
|
|
14163
|
-
}]);
|
|
14164
|
-
return;
|
|
14165
|
-
}
|
|
14166
|
-
const tempId = crypto.randomUUID();
|
|
14167
|
-
setAttachments((prev) => [...prev, {
|
|
14168
|
-
tempId,
|
|
14169
|
-
fileName,
|
|
14170
|
-
sizeBytes: data.byteLength,
|
|
14171
|
-
status: "uploading",
|
|
14172
|
-
errorText: null
|
|
14173
|
-
}]);
|
|
14174
|
-
const promise = rest.uploadAttachment({
|
|
14175
|
-
agentId: agent.id,
|
|
14176
|
-
fileName,
|
|
14177
|
-
mediaType,
|
|
14178
|
-
data
|
|
14179
|
-
});
|
|
14180
|
-
uploadsRef.current.set(tempId, promise);
|
|
14181
|
-
promise.then(() => {
|
|
14182
|
-
setAttachments((prev) => prev.map((a) => a.tempId === tempId ? {
|
|
14183
|
-
...a,
|
|
14184
|
-
status: "ready"
|
|
14185
|
-
} : a));
|
|
14186
|
-
}).catch((err) => {
|
|
14187
|
-
setAttachments((prev) => prev.map((a) => a.tempId === tempId ? {
|
|
14188
|
-
...a,
|
|
14189
|
-
status: "error",
|
|
14190
|
-
errorText: errorMessage(err)
|
|
14191
|
-
} : a));
|
|
14192
|
-
});
|
|
14193
|
-
}, [rest, agent.id]);
|
|
14194
|
-
const stageFiles = useCallback((files) => {
|
|
14195
|
-
for (const file of files) stageAttachment(file);
|
|
14196
|
-
}, [stageAttachment]);
|
|
14197
|
-
const pasteImage = useCallback(async () => {
|
|
14198
|
-
const image = await readClipboardImage();
|
|
14199
|
-
if (!image) return;
|
|
14200
|
-
stageAttachment(image);
|
|
14201
|
-
}, [stageAttachment]);
|
|
14202
|
-
usePaste((event) => {
|
|
14203
|
-
if (cardPickerOpen || modelPickerOpen || themePickerOpen || prPickerOpen || helpOpen || creditsOpen || bgTasksOpen || credPromptOpen || grantPrompt || computePromptOpen || askPromptOpen || restartConfirm || nonWebChannel) return;
|
|
14204
|
-
const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
|
|
14205
|
-
const route = routePaste({
|
|
14206
|
-
kind: event.metadata?.kind,
|
|
14207
|
-
mimeType: event.metadata?.mimeType,
|
|
14208
|
-
text
|
|
14209
|
-
});
|
|
14210
|
-
switch (route.kind) {
|
|
14211
|
-
case "binary-image": {
|
|
14212
|
-
event.preventDefault();
|
|
14213
|
-
const ext = route.mediaType.split("/")[1]?.split("+")[0] ?? "png";
|
|
14214
|
-
stageFiles([{
|
|
14215
|
-
fileName: `pasted-image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.${ext}`,
|
|
14216
|
-
mediaType: route.mediaType,
|
|
14217
|
-
data: event.bytes
|
|
14218
|
-
}]);
|
|
14219
|
-
return;
|
|
14220
|
-
}
|
|
14221
|
-
case "clipboard-image":
|
|
14222
|
-
event.preventDefault();
|
|
14223
|
-
pasteImage();
|
|
14224
|
-
return;
|
|
14225
|
-
case "dropped-paths":
|
|
14226
|
-
event.preventDefault();
|
|
14227
|
-
resolveDroppedFiles(route.paths).then((files) => {
|
|
14228
|
-
if (files) {
|
|
14229
|
-
stageFiles(files);
|
|
14230
|
-
return;
|
|
14231
|
-
}
|
|
14232
|
-
composerRef.current?.insertText(route.text);
|
|
14233
|
-
handleComposerChange();
|
|
14234
|
-
});
|
|
14235
|
-
return;
|
|
14236
|
-
case "text": return;
|
|
14237
|
-
}
|
|
14238
|
-
});
|
|
14239
15170
|
const applyComposerText = useCallback((text, caret) => {
|
|
14240
15171
|
const composer = composerRef.current;
|
|
14241
15172
|
if (!composer) return;
|
|
@@ -14355,7 +15286,13 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14355
15286
|
const screenAtFork = useStore.getState().screen;
|
|
14356
15287
|
const stillHere = () => useStore.getState().screen === screenAtFork;
|
|
14357
15288
|
rest.forkConversation({ conversationId }).then((forked) => {
|
|
14358
|
-
if (!stillHere())
|
|
15289
|
+
if (!stillHere()) {
|
|
15290
|
+
forkingRef.current = false;
|
|
15291
|
+
setForking(false);
|
|
15292
|
+
return;
|
|
15293
|
+
}
|
|
15294
|
+
const current = runRef.current;
|
|
15295
|
+
if (current.kind === "streaming") current.abort.abort();
|
|
14359
15296
|
goTo({
|
|
14360
15297
|
kind: "chat",
|
|
14361
15298
|
agent,
|
|
@@ -14368,7 +15305,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14368
15305
|
});
|
|
14369
15306
|
useStore.getState().showToast({
|
|
14370
15307
|
variant: "success",
|
|
14371
|
-
message: "Forked conversation"
|
|
15308
|
+
message: current.kind === "idle" ? "Forked conversation" : `Forked conversation — ${agent.name} keeps working on the original; reopen to reattach`
|
|
14372
15309
|
});
|
|
14373
15310
|
}).catch((err) => {
|
|
14374
15311
|
forkingRef.current = false;
|
|
@@ -14391,6 +15328,10 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14391
15328
|
useStore.getState().showToast({ message: "agent runs can't be archived — ctrl+d in the picker deletes" });
|
|
14392
15329
|
return;
|
|
14393
15330
|
}
|
|
15331
|
+
if (!viewerIsParticipant(conversation)) {
|
|
15332
|
+
useStore.getState().showToast({ message: "you're not in this thread, so there's nothing to archive" });
|
|
15333
|
+
return;
|
|
15334
|
+
}
|
|
14394
15335
|
if (runRef.current.kind !== "idle") {
|
|
14395
15336
|
useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
|
|
14396
15337
|
return;
|
|
@@ -14424,6 +15365,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14424
15365
|
askPrompt,
|
|
14425
15366
|
restartConfirm,
|
|
14426
15367
|
agentRunConversation,
|
|
15368
|
+
conversation,
|
|
14427
15369
|
conversationId,
|
|
14428
15370
|
rest,
|
|
14429
15371
|
goTo,
|
|
@@ -14508,6 +15450,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14508
15450
|
agentSelector: null,
|
|
14509
15451
|
conversationId: target,
|
|
14510
15452
|
newConversation: false,
|
|
15453
|
+
seedPrompt: null,
|
|
14511
15454
|
defaultAgentSelector: null,
|
|
14512
15455
|
forceOnboarding: false
|
|
14513
15456
|
});
|
|
@@ -14548,6 +15491,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14548
15491
|
agentSelector: null,
|
|
14549
15492
|
conversationId: target,
|
|
14550
15493
|
newConversation: false,
|
|
15494
|
+
seedPrompt: null,
|
|
14551
15495
|
defaultAgentSelector: null,
|
|
14552
15496
|
forceOnboarding: false
|
|
14553
15497
|
}));
|
|
@@ -14695,22 +15639,12 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14695
15639
|
break;
|
|
14696
15640
|
}
|
|
14697
15641
|
goTo({
|
|
14698
|
-
kind: "
|
|
15642
|
+
kind: "conversation-picker",
|
|
14699
15643
|
agent,
|
|
14700
|
-
|
|
14701
|
-
parentConversationId: null,
|
|
14702
|
-
conversation: {
|
|
14703
|
-
kind: "new",
|
|
14704
|
-
agentId: agent.id,
|
|
14705
|
-
nonce: crypto.randomUUID()
|
|
14706
|
-
}
|
|
15644
|
+
initialQuery: ""
|
|
14707
15645
|
});
|
|
14708
15646
|
break;
|
|
14709
15647
|
case "fork":
|
|
14710
|
-
if (runRef.current.kind !== "idle") {
|
|
14711
|
-
useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
|
|
14712
|
-
break;
|
|
14713
|
-
}
|
|
14714
15648
|
if (!conversationId) {
|
|
14715
15649
|
useStore.getState().showToast({ message: "nothing to fork yet; send a message first" });
|
|
14716
15650
|
break;
|
|
@@ -14748,9 +15682,20 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14748
15682
|
else setPrPickerOpen(true);
|
|
14749
15683
|
break;
|
|
14750
15684
|
case "subagent": {
|
|
15685
|
+
if (routed.mode === "minimize") {
|
|
15686
|
+
const next = !subagentMinimized;
|
|
15687
|
+
setSubagentMinimizedBy(next ? "reader" : null);
|
|
15688
|
+
if (next) setSubagentSelection(null);
|
|
15689
|
+
useStore.getState().showToast({ message: next ? "subagents minimized (/subagent minimize again to restore)" : "subagents restored" });
|
|
15690
|
+
break;
|
|
15691
|
+
}
|
|
14751
15692
|
const batch = anchoredSubagentBatchRef.current;
|
|
14752
|
-
if (!batch || batch.rows.length === 0)
|
|
14753
|
-
|
|
15693
|
+
if (!batch || batch.rows.length === 0) {
|
|
15694
|
+
useStore.getState().showToast({ message: "no subagents in this conversation yet" });
|
|
15695
|
+
break;
|
|
15696
|
+
}
|
|
15697
|
+
setSubagentMinimizedBy(null);
|
|
15698
|
+
setSubagentSelection({
|
|
14754
15699
|
toolCallId: batch.toolCallId,
|
|
14755
15700
|
index: 0
|
|
14756
15701
|
});
|
|
@@ -14786,15 +15731,25 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14786
15731
|
useStore.getState().showToast({ message: "no plan to show yet" });
|
|
14787
15732
|
break;
|
|
14788
15733
|
}
|
|
15734
|
+
if (routed.mode === "minimize") {
|
|
15735
|
+
const next = !planMinimized;
|
|
15736
|
+
setPlanMinimizedBy(next ? "reader" : null);
|
|
15737
|
+
setPlanHidden(false);
|
|
15738
|
+
setPlanExpanded(false);
|
|
15739
|
+
useStore.getState().showToast({ message: next ? "plan minimized (/plan minimize again to restore)" : "plan restored" });
|
|
15740
|
+
break;
|
|
15741
|
+
}
|
|
14789
15742
|
if (routed.mode === "expand" || routed.mode === "collapse") {
|
|
14790
15743
|
const nextExpanded = routed.mode === "expand";
|
|
14791
15744
|
setPlanExpanded(nextExpanded);
|
|
14792
|
-
|
|
15745
|
+
setPlanHidden(false);
|
|
15746
|
+
setPlanMinimizedBy(null);
|
|
14793
15747
|
useStore.getState().showToast({ message: nextExpanded ? "plan expanded (/plan collapse)" : "plan collapsed" });
|
|
14794
15748
|
break;
|
|
14795
15749
|
}
|
|
14796
15750
|
const nextHidden = routed.mode === "toggle" ? !planHidden : routed.mode === "hide";
|
|
14797
15751
|
setPlanHidden(nextHidden);
|
|
15752
|
+
if (!nextHidden) setPlanMinimizedBy(null);
|
|
14798
15753
|
useStore.getState().showToast({ message: nextHidden ? "plan hidden (/plan show)" : "plan shown" });
|
|
14799
15754
|
break;
|
|
14800
15755
|
}
|
|
@@ -14862,7 +15817,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14862
15817
|
setInput("");
|
|
14863
15818
|
composerRef.current?.clear();
|
|
14864
15819
|
setComposerRows(1);
|
|
14865
|
-
|
|
15820
|
+
clearAttachments();
|
|
14866
15821
|
sendContent(content, staged);
|
|
14867
15822
|
}, [
|
|
14868
15823
|
history,
|
|
@@ -14882,6 +15837,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14882
15837
|
compactConversation,
|
|
14883
15838
|
quit,
|
|
14884
15839
|
sendContent,
|
|
15840
|
+
clearAttachments,
|
|
14885
15841
|
goTo,
|
|
14886
15842
|
agent,
|
|
14887
15843
|
appUrl,
|
|
@@ -14893,6 +15849,8 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14893
15849
|
promptForCards,
|
|
14894
15850
|
todos,
|
|
14895
15851
|
planHidden,
|
|
15852
|
+
planMinimized,
|
|
15853
|
+
subagentMinimized,
|
|
14896
15854
|
prLinks.length
|
|
14897
15855
|
]);
|
|
14898
15856
|
const submit = useCallback(() => {
|
|
@@ -14948,6 +15906,20 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14948
15906
|
setPortalPrompt
|
|
14949
15907
|
]);
|
|
14950
15908
|
useKeyboard((key) => {
|
|
15909
|
+
if (key.name === "s" && key.ctrl) {
|
|
15910
|
+
if (!sidePanelOpen && sidePanelPlacement({
|
|
15911
|
+
open: true,
|
|
15912
|
+
width
|
|
15913
|
+
}) === "hidden") {
|
|
15914
|
+
useStore.getState().showToast({
|
|
15915
|
+
message: `terminal too narrow for the side panel (needs ${SIDE_PANEL_MIN_WIDTH} cols)`,
|
|
15916
|
+
variant: "warning"
|
|
15917
|
+
});
|
|
15918
|
+
return;
|
|
15919
|
+
}
|
|
15920
|
+
setSidePanelOpen((o) => !o);
|
|
15921
|
+
return;
|
|
15922
|
+
}
|
|
14951
15923
|
if (reviewOpen) {
|
|
14952
15924
|
if (key.name === "g" && key.ctrl) {
|
|
14953
15925
|
if (reviewPlacement === "fullscreen" && !reviewFocused) setReviewFocused(true);
|
|
@@ -14961,7 +15933,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14961
15933
|
}
|
|
14962
15934
|
if (cardPickerOpen || askPromptOpen || modelPickerOpen || themePickerOpen || prPickerOpen || helpOpen || creditsOpen || bgTasksOpen) return;
|
|
14963
15935
|
if (grantPrompt) {
|
|
14964
|
-
if (key.name === "y") {
|
|
15936
|
+
if (key.name === "y" && !key.ctrl) {
|
|
14965
15937
|
confirmGrant();
|
|
14966
15938
|
return;
|
|
14967
15939
|
}
|
|
@@ -14971,7 +15943,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14971
15943
|
}
|
|
14972
15944
|
}
|
|
14973
15945
|
if (computePrompt) {
|
|
14974
|
-
if (key.name === "y") {
|
|
15946
|
+
if (key.name === "y" && !key.ctrl) {
|
|
14975
15947
|
decideCompute(computePrompt, "approve");
|
|
14976
15948
|
return;
|
|
14977
15949
|
}
|
|
@@ -14985,7 +15957,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
14985
15957
|
}
|
|
14986
15958
|
}
|
|
14987
15959
|
if (restartConfirm) {
|
|
14988
|
-
if (key.name === "y") {
|
|
15960
|
+
if (key.name === "y" && !key.ctrl) {
|
|
14989
15961
|
setRestartConfirm(false);
|
|
14990
15962
|
runRestartComputer();
|
|
14991
15963
|
return;
|
|
@@ -15135,16 +16107,12 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15135
16107
|
}
|
|
15136
16108
|
if (key.name === "v" && key.ctrl) {
|
|
15137
16109
|
if (nonWebChannel) return;
|
|
15138
|
-
|
|
16110
|
+
stageClipboardImage();
|
|
15139
16111
|
return;
|
|
15140
16112
|
}
|
|
15141
16113
|
if (key.name === "x" && key.ctrl || key.name === "backspace" && attachmentsRef.current.length > 0 && !composerRef.current?.plainText) {
|
|
15142
16114
|
if (nonWebChannel) return;
|
|
15143
|
-
|
|
15144
|
-
if (last) {
|
|
15145
|
-
uploadsRef.current.delete(last.tempId);
|
|
15146
|
-
setAttachments((prev) => prev.filter((a) => a.tempId !== last.tempId));
|
|
15147
|
-
}
|
|
16115
|
+
dropLastAttachment();
|
|
15148
16116
|
return;
|
|
15149
16117
|
}
|
|
15150
16118
|
if (key.name === "l" && key.ctrl) openInBrowser();
|
|
@@ -15176,6 +16144,10 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15176
16144
|
jumpToBottom();
|
|
15177
16145
|
return;
|
|
15178
16146
|
}
|
|
16147
|
+
if (key.name === "y" && key.ctrl) {
|
|
16148
|
+
jumpToLastOwnMessage();
|
|
16149
|
+
return;
|
|
16150
|
+
}
|
|
15179
16151
|
if (key.name === "c" && key.ctrl) {
|
|
15180
16152
|
if (runRef.current.kind === "streaming") {
|
|
15181
16153
|
cancelRun();
|
|
@@ -15203,6 +16175,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15203
16175
|
agentSelector: null,
|
|
15204
16176
|
conversationId: parentConversationId,
|
|
15205
16177
|
newConversation: false,
|
|
16178
|
+
seedPrompt: null,
|
|
15206
16179
|
defaultAgentSelector: null,
|
|
15207
16180
|
forceOnboarding: false
|
|
15208
16181
|
}).then((parentScreen) => goTo(parentScreen)).catch(() => {
|
|
@@ -15262,13 +16235,15 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15262
16235
|
latestComputerId,
|
|
15263
16236
|
scrolledUp
|
|
15264
16237
|
]);
|
|
15265
|
-
const actionableCards = useMemo(() => items.filter(isActionableCard), [items]);
|
|
16238
|
+
const actionableCards = useMemo(() => prioritizeActionableCards(items.filter(isActionableCard)), [items]);
|
|
16239
|
+
const cardVerb = useMemo(() => actionableCardVerb(actionableCards), [actionableCards]);
|
|
15266
16240
|
const statusText = useMemo(() => formatChatFooterStatus({
|
|
15267
16241
|
runKind: run.kind,
|
|
15268
16242
|
ctrlCArmed,
|
|
15269
16243
|
portalOpen,
|
|
15270
16244
|
hasPendingSteer,
|
|
15271
16245
|
actionableCardCount: actionableCards.length,
|
|
16246
|
+
actionableCardVerb: cardVerb,
|
|
15272
16247
|
nonWebChannel: nonWebChannel !== null
|
|
15273
16248
|
}), [
|
|
15274
16249
|
run.kind,
|
|
@@ -15276,9 +16251,14 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15276
16251
|
portalOpen,
|
|
15277
16252
|
hasPendingSteer,
|
|
15278
16253
|
actionableCards.length,
|
|
16254
|
+
cardVerb,
|
|
15279
16255
|
nonWebChannel
|
|
15280
16256
|
]);
|
|
15281
|
-
const modelHint = formatModelHint(
|
|
16257
|
+
const modelHint = formatModelHint({
|
|
16258
|
+
model,
|
|
16259
|
+
effort,
|
|
16260
|
+
agentModel: agentDefaultModel
|
|
16261
|
+
});
|
|
15282
16262
|
if (askPrompt) return /* @__PURE__ */ jsx(AskPicker, {
|
|
15283
16263
|
questions: askPrompt.questions,
|
|
15284
16264
|
onSubmit: (text) => {
|
|
@@ -15302,6 +16282,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15302
16282
|
});
|
|
15303
16283
|
if (modelPickerView) return /* @__PURE__ */ jsx(ModelPicker, {
|
|
15304
16284
|
agentId: agent.id,
|
|
16285
|
+
conversationId,
|
|
15305
16286
|
currentModel: model,
|
|
15306
16287
|
currentEffort: effort,
|
|
15307
16288
|
modelLocked,
|
|
@@ -15309,8 +16290,12 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15309
16290
|
onModelSelected: (next) => {
|
|
15310
16291
|
setModel(next.model);
|
|
15311
16292
|
setEffort(next.effort);
|
|
16293
|
+
if (next.scope === "agent") setAgentDefaultModel(next.model);
|
|
15312
16294
|
setModelPickerView(null);
|
|
15313
|
-
useStore.getState().showToast({
|
|
16295
|
+
useStore.getState().showToast(next.pinUnsupported ? {
|
|
16296
|
+
variant: "warning",
|
|
16297
|
+
message: `model changed to ${next.model} for all conversations — the server doesn't support per-conversation picks yet`
|
|
16298
|
+
} : {
|
|
15314
16299
|
variant: "success",
|
|
15315
16300
|
message: `model changed to ${next.model}`
|
|
15316
16301
|
});
|
|
@@ -15318,7 +16303,10 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15318
16303
|
onEffortSelected: (next) => {
|
|
15319
16304
|
setEffort(next.effort);
|
|
15320
16305
|
setModelPickerView(null);
|
|
15321
|
-
useStore.getState().showToast({
|
|
16306
|
+
useStore.getState().showToast(next.pinUnsupported ? {
|
|
16307
|
+
variant: "warning",
|
|
16308
|
+
message: `effort changed to ${next.label} for all conversations — the server doesn't support per-conversation picks yet`
|
|
16309
|
+
} : {
|
|
15322
16310
|
variant: "success",
|
|
15323
16311
|
message: `effort changed to ${next.label}`
|
|
15324
16312
|
});
|
|
@@ -15471,10 +16459,10 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15471
16459
|
children: [!historyLoaded ? /* @__PURE__ */ jsx("text", {
|
|
15472
16460
|
fg: theme.muted,
|
|
15473
16461
|
children: "loading history…"
|
|
15474
|
-
}) : items.length === 0 ? /* @__PURE__ */ jsx(NewChatSplash, {
|
|
16462
|
+
}) : items.length === 0 && !terminalReady ? /* @__PURE__ */ jsx("box", { style: { height: Math.max(1, scrollHeight - 1) } }) : items.length === 0 ? /* @__PURE__ */ jsx(NewChatSplash, {
|
|
15475
16463
|
agentName: agent.name,
|
|
15476
16464
|
agentTitle: agent.title ?? null,
|
|
15477
|
-
width: reviewView && reviewPlacement === "side" ? Math.max(1, bodyWidth - reviewWidth - 1) : bodyWidth,
|
|
16465
|
+
width: reviewView && reviewPlacement === "side" ? Math.max(1, bodyWidth - reviewWidth - 1 - sidePanelWidth) : Math.max(1, bodyWidth - sidePanelWidth),
|
|
15478
16466
|
height: Math.max(1, scrollHeight - 1)
|
|
15479
16467
|
}) : /* @__PURE__ */ jsxs(Fragment, { children: [transcriptWindow.hiddenCount > 0 ? /* @__PURE__ */ jsx("box", {
|
|
15480
16468
|
style: {
|
|
@@ -15524,13 +16512,16 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15524
16512
|
/* @__PURE__ */ jsx(SubagentCardView, {
|
|
15525
16513
|
items: subagentItems,
|
|
15526
16514
|
isActive: run.kind !== "idle",
|
|
15527
|
-
|
|
15528
|
-
|
|
16515
|
+
minimized: subagentMinimized,
|
|
16516
|
+
onToggleMinimized: toggleSubagentMinimized,
|
|
16517
|
+
onHeightChange: setSubagentRows
|
|
15529
16518
|
}),
|
|
15530
16519
|
todoCardVisible && todos ? /* @__PURE__ */ jsx(TodoCardView, {
|
|
15531
16520
|
todos,
|
|
15532
16521
|
isActive: run.kind !== "idle",
|
|
16522
|
+
minimized: planMinimized,
|
|
15533
16523
|
expanded: planExpanded,
|
|
16524
|
+
onToggleMinimized: togglePlanMinimized,
|
|
15534
16525
|
onToggleExpanded: togglePlanExpanded
|
|
15535
16526
|
}) : null,
|
|
15536
16527
|
compacting ? /* @__PURE__ */ jsxs("box", {
|
|
@@ -15550,29 +16541,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15550
16541
|
children: [" ", "you can keep typing"]
|
|
15551
16542
|
})]
|
|
15552
16543
|
}) : null,
|
|
15553
|
-
pendingVisible ? /* @__PURE__ */
|
|
15554
|
-
style: {
|
|
15555
|
-
paddingLeft: 1,
|
|
15556
|
-
paddingRight: 1,
|
|
15557
|
-
flexShrink: 0,
|
|
15558
|
-
marginTop: 1,
|
|
15559
|
-
flexDirection: "column"
|
|
15560
|
-
},
|
|
15561
|
-
children: [attachments.map((a) => /* @__PURE__ */ jsxs("text", {
|
|
15562
|
-
fg: a.status === "error" ? theme.error : theme.muted,
|
|
15563
|
-
children: [
|
|
15564
|
-
"📎 ",
|
|
15565
|
-
a.fileName,
|
|
15566
|
-
" (",
|
|
15567
|
-
formatBytes(a.sizeBytes),
|
|
15568
|
-
") ",
|
|
15569
|
-
attachmentGlyph(a)
|
|
15570
|
-
]
|
|
15571
|
-
}, a.tempId)), /* @__PURE__ */ jsx("text", {
|
|
15572
|
-
fg: theme.dim,
|
|
15573
|
-
children: "ctrl+x remove last"
|
|
15574
|
-
})]
|
|
15575
|
-
}) : null,
|
|
16544
|
+
pendingVisible ? /* @__PURE__ */ jsx(AttachmentTray, { attachments }) : null,
|
|
15576
16545
|
menuOpen ? /* @__PURE__ */ jsx(CompletionMenu, {
|
|
15577
16546
|
items: menuCommands.map((c) => ({
|
|
15578
16547
|
id: c.name,
|
|
@@ -15787,7 +16756,37 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15787
16756
|
value: conversationContext,
|
|
15788
16757
|
children: chatViewBody
|
|
15789
16758
|
});
|
|
15790
|
-
if (!reviewView || reviewPlacement === "below")
|
|
16759
|
+
if (!reviewView || reviewPlacement === "below") {
|
|
16760
|
+
if (!sidePanelShown) return chatView;
|
|
16761
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
16762
|
+
style: {
|
|
16763
|
+
flexDirection: "row",
|
|
16764
|
+
flexGrow: 1,
|
|
16765
|
+
width: bodyWidth,
|
|
16766
|
+
height: chatHeight
|
|
16767
|
+
},
|
|
16768
|
+
children: [
|
|
16769
|
+
/* @__PURE__ */ jsx("box", {
|
|
16770
|
+
style: {
|
|
16771
|
+
flexGrow: 1,
|
|
16772
|
+
flexShrink: 1
|
|
16773
|
+
},
|
|
16774
|
+
children: chatView
|
|
16775
|
+
}),
|
|
16776
|
+
sidePanelShown ? /* @__PURE__ */ jsx("box", { style: {
|
|
16777
|
+
width: 2,
|
|
16778
|
+
flexShrink: 0
|
|
16779
|
+
} }) : null,
|
|
16780
|
+
sidePanelShown ? /* @__PURE__ */ jsx(SidePanel, {
|
|
16781
|
+
open: sidePanelOpen,
|
|
16782
|
+
width,
|
|
16783
|
+
height: bodyHeight,
|
|
16784
|
+
agentName: agent.name,
|
|
16785
|
+
title: chatTitle
|
|
16786
|
+
}) : null
|
|
16787
|
+
]
|
|
16788
|
+
});
|
|
16789
|
+
}
|
|
15791
16790
|
const fullscreen = reviewPlacement === "fullscreen";
|
|
15792
16791
|
return /* @__PURE__ */ jsxs("box", {
|
|
15793
16792
|
onMouseDown: (event) => {
|
|
@@ -15818,26 +16817,40 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
|
|
|
15818
16817
|
flexDirection: reviewPlacement === "side" ? "row" : "column",
|
|
15819
16818
|
flexGrow: 1
|
|
15820
16819
|
},
|
|
15821
|
-
children: [
|
|
15822
|
-
|
|
15823
|
-
|
|
15824
|
-
|
|
15825
|
-
|
|
15826
|
-
|
|
15827
|
-
|
|
15828
|
-
|
|
15829
|
-
|
|
15830
|
-
|
|
15831
|
-
|
|
15832
|
-
|
|
16820
|
+
children: [
|
|
16821
|
+
!fullscreen || !reviewFocused ? /* @__PURE__ */ jsx("box", {
|
|
16822
|
+
onMouseDown: () => setReviewFocused(false),
|
|
16823
|
+
style: {
|
|
16824
|
+
flexGrow: 1,
|
|
16825
|
+
paddingRight: reviewPlacement === "side" ? 1 : 0
|
|
16826
|
+
},
|
|
16827
|
+
children: chatView
|
|
16828
|
+
}, "chat") : null,
|
|
16829
|
+
!fullscreen || reviewFocused ? /* @__PURE__ */ jsx("box", {
|
|
16830
|
+
ref: reviewSideBoxRef,
|
|
16831
|
+
onMouseDown: () => setReviewFocused(true),
|
|
16832
|
+
style: {
|
|
16833
|
+
width: reviewPlacement === "side" ? reviewWidth : bodyWidth,
|
|
16834
|
+
height: bodyHeight,
|
|
16835
|
+
border: !fullscreen,
|
|
16836
|
+
borderColor: reviewFocused ? theme.accent : theme.faint,
|
|
16837
|
+
paddingLeft: fullscreen ? 0 : 1,
|
|
16838
|
+
paddingRight: fullscreen ? 0 : 1
|
|
16839
|
+
},
|
|
16840
|
+
children: reviewView
|
|
16841
|
+
}, "review") : null,
|
|
16842
|
+
sidePanelShown ? /* @__PURE__ */ jsx("box", { style: {
|
|
16843
|
+
width: 2,
|
|
16844
|
+
flexShrink: 0
|
|
16845
|
+
} }) : null,
|
|
16846
|
+
sidePanelShown ? /* @__PURE__ */ jsx(SidePanel, {
|
|
16847
|
+
open: sidePanelOpen,
|
|
16848
|
+
width,
|
|
15833
16849
|
height: bodyHeight,
|
|
15834
|
-
|
|
15835
|
-
|
|
15836
|
-
|
|
15837
|
-
|
|
15838
|
-
},
|
|
15839
|
-
children: reviewView
|
|
15840
|
-
}, "review") : null]
|
|
16850
|
+
agentName: agent.name,
|
|
16851
|
+
title: chatTitle
|
|
16852
|
+
}) : null
|
|
16853
|
+
]
|
|
15841
16854
|
});
|
|
15842
16855
|
}
|
|
15843
16856
|
/**
|
|
@@ -16121,7 +17134,8 @@ function App({ appUrl, sessionToken, authKind, workspaceId, shareMachine, prompt
|
|
|
16121
17134
|
rest,
|
|
16122
17135
|
agentSelector,
|
|
16123
17136
|
conversationId,
|
|
16124
|
-
newConversation
|
|
17137
|
+
newConversation,
|
|
17138
|
+
seedPrompt: useStore.getState().seedPrompt,
|
|
16125
17139
|
defaultAgentSelector,
|
|
16126
17140
|
forceOnboarding
|
|
16127
17141
|
});
|
|
@@ -16187,16 +17201,14 @@ function App({ appUrl, sessionToken, authKind, workspaceId, shareMachine, prompt
|
|
|
16187
17201
|
children: [
|
|
16188
17202
|
screen.kind === "splash" && /* @__PURE__ */ jsx(Splash, {}),
|
|
16189
17203
|
screen.kind === "error" && /* @__PURE__ */ jsx(ErrorScreen, { message: screen.message }),
|
|
16190
|
-
screen.kind === "
|
|
16191
|
-
screen.kind === "agent-create" && rest && /* @__PURE__ */ jsx(AgentCreateScreen, {}),
|
|
16192
|
-
screen.kind === "first-run" && rest && /* @__PURE__ */ jsx(FirstRunScreen, {}),
|
|
17204
|
+
screen.kind === "welcome" && rest && /* @__PURE__ */ jsx(WelcomeScreen, {}),
|
|
16193
17205
|
screen.kind === "workspace-picker" && /* @__PURE__ */ jsx(WorkspacePicker, {
|
|
16194
17206
|
appUrl,
|
|
16195
17207
|
sessionToken,
|
|
16196
|
-
onCancel: () => goTo({ kind: "
|
|
17208
|
+
onCancel: () => goTo({ kind: "welcome" }),
|
|
16197
17209
|
onSelect: (workspace) => {
|
|
16198
17210
|
setWorkspaceName(workspace.name);
|
|
16199
|
-
goTo({ kind: "
|
|
17211
|
+
goTo({ kind: "welcome" });
|
|
16200
17212
|
}
|
|
16201
17213
|
}),
|
|
16202
17214
|
screen.kind === "conversation-picker" && rest && /* @__PURE__ */ jsx(ConversationPickerScreen, {
|
|
@@ -16747,7 +17759,6 @@ async function runChat({ appUrl, sessionToken, authKind = "session", workspaceId
|
|
|
16747
17759
|
authKind,
|
|
16748
17760
|
seedPrompt: seedPrompt ?? null,
|
|
16749
17761
|
autoGrantMachine: Boolean(seedPrompt && shareMachine),
|
|
16750
|
-
newConversationIntent: newConversation,
|
|
16751
17762
|
forcedOnboarding: forceOnboarding
|
|
16752
17763
|
});
|
|
16753
17764
|
const store = useStore.getState();
|