skydive-cli 0.6.0-beta.3 → 0.6.0-beta.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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-ChXt8YGA.mjs";
3
- import { A as getSavedTheme, F as recordDefaultAgent, V as resolveWebUrl, W as saveTheme, b as DEFAULT_API_URL, c as cardActionErrorMessage, d as reconcileMaskedInput, f as resolveConnectUrl, g as specKeyFor, h as parseConnectCard, i as resolveAgent, k as getReviewStateDir, l as parseExternalOauthConnectParams, m as computeSettledLabel, p as composeQuestionAnswer, s as MASK_CHAR, u as parseOauthConnectParams, w as getConfigPath, x as DEFAULT_APP_URL } from "./print-B3yhPm0N.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-D7MwRv0n.mjs";
2
+ import { a as errorMessage, i as sendErrorMessage, n as errorDetail, o as isRecord, t as createRestClient } from "./rest-DU74TXCi.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-D1CeaocN.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-CLgma6A8.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-CErCpXl9.mjs";
7
+ import { t as PortalClient } from "./client-B72n-W4g.mjs";
8
8
  import { t as defaultTlsCertSource } from "./tls-cert-Rua2oV7n.mjs";
9
9
  import "./api-BFQ4PQDA.mjs";
10
- import "./daemon-Cuy4S6Rw.mjs";
11
- import { t as SandboxStream } from "./client-Btq6bMzX.mjs";
12
- import { t as PortalDaemonClient } from "./daemon-client-b10uck-j.mjs";
13
- import { t as runRawPtyPassthrough } from "./raw-pty-D5PhKZSl.mjs";
10
+ import "./daemon-C4cZdILE.mjs";
11
+ import { t as SandboxStream } from "./client-l6orYfqq.mjs";
12
+ import { t as PortalDaemonClient } from "./daemon-client-P4qpHwFG.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 agent picker — unless the config stores a
225
- * `defaultAgent`, in which case a bare `skydive chat` opens a fresh
226
- * conversation with that agent directly. The default is maintained
227
- * automatically (every conversation the TUI opens records its agent, so a
228
- * bare launch returns to whoever you talked to last — the dominant launch
229
- * pattern), and `skydive config set defaultAgent` can seed or override it. A
230
- * stored selector that no longer resolves degrades silently to the agent
231
- * picker rather than an error screen: config goes stale (agent renamed,
232
- * archived, another one claiming the same name), and unlike a mistyped
233
- * `--agent` there is no command line to correct — and picking an agent
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 list, skipping
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 large org must never sit on
255
- * the `connecting…` splash while the roster streams in.
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: "first-run" };
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 newChat(agent);
314
- return { kind: "agent-picker" };
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: "agent-picker" };
346
- return newChat(only);
322
+ if (!only) return { kind: "welcome" };
323
+ return openAgentHome(only, seedPrompt);
347
324
  }
348
- const agent = resolveAgent(await rest.listAgents({
325
+ return openAgentHome(resolveAgent(await rest.listAgents({
349
326
  scope: "org",
350
327
  onPage: null
351
- }), agentSelector);
352
- if (newConversation) return newChat(agent);
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 "agent-picker": return { title: "pick an agent" };
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/screens/agent-picker.tsx
2107
- function AgentPickerScreen() {
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
- useKeyboard((key) => {
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
- const { width, height } = useTerminalDimensions();
2178
- if (state.kind === "loading") return /* @__PURE__ */ jsxs("box", {
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: [/* @__PURE__ */ jsx(BrandHeader, {
2184
- height,
2185
- width
2186
- }), /* @__PURE__ */ jsx("text", {
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__ */ jsxs("box", {
2192
- style: {
2193
- flexDirection: "column",
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
- onPick: goTo
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 visibleRows = Math.max(3, height - 6 - brandHeaderRows(height, width));
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") setHighlight(Math.max(0, clamped - 1));
2238
- else if (key.name === "down") setHighlight(Math.min(Math.max(0, filtered.length - 1), clamped + 1));
2239
- else if (key.name === "pageup" || key.name === "pgup") setHighlight(Math.max(0, clamped - Math.max(1, visibleRows - 1)));
2240
- else if (key.name === "pagedown" || key.name === "pgdn") setHighlight(Math.min(Math.max(0, filtered.length - 1), clamped + Math.max(1, visibleRows - 1)));
2241
- else if (key.name === "return") {
2242
- const agent = filtered[clamped];
2243
- if (!agent) return;
2244
- const store = useStore.getState();
2245
- onPick(store.seedPrompt !== null || store.newConversationIntent ? {
2246
- kind: "chat",
2247
- agent,
2248
- attachRunId: null,
2249
- parentConversationId: null,
2250
- conversation: {
2251
- kind: "new",
2252
- agentId: agent.id,
2253
- nonce: crypto.randomUUID()
2254
- }
2255
- } : {
2256
- kind: "conversation-picker",
2257
- agent,
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
- return /* @__PURE__ */ jsxs("box", {
2263
- style: {
2264
- flexDirection: "column",
2265
- flexGrow: 1
2266
- },
2501
+ const empty = agents.length === 0;
2502
+ return /* @__PURE__ */ jsxs(WelcomeFrame, {
2503
+ contentWidth,
2267
2504
  children: [
2268
- /* @__PURE__ */ jsx(BrandHeader, {
2269
- height,
2270
- width
2271
- }),
2272
- /* @__PURE__ */ jsxs("box", {
2505
+ /* @__PURE__ */ jsx("box", {
2273
2506
  style: {
2274
- flexDirection: "row",
2275
- height: 1
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: [/* @__PURE__ */ jsx("text", {
2278
- fg: theme.muted,
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: "type to filter…",
2286
- focused: true,
2287
- width: Math.max(10, width - 12),
2288
- onInput: (value) => {
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__ */ jsxs("text", {
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
- flexGrow: 1
2542
+ flexShrink: 0
2312
2543
  },
2313
- children: windowed.length === 0 ? /* @__PURE__ */ jsxs("text", {
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
- const agentKeys = [
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") goTo({ kind: "agent-picker" });
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 instanceof HttpError ? "Skydive could not create the agent. Try again in a moment." : "Could not reach Skydive. Check your connection and try again."
2664
+ message: createErrorMessage(err)
2502
2665
  });
2503
2666
  }
2504
2667
  };
2505
- if (state.kind === "offer-alias") return /* @__PURE__ */ jsx(AliasOffer, {
2506
- agentName: state.agent.name,
2507
- alias: state.alias
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(AliasResult, { message: state.message });
2510
- return /* @__PURE__ */ jsxs("box", {
2511
- style: {
2512
- flexDirection: "column",
2513
- flexGrow: 1,
2514
- gap: 1
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(Centered, { children: renderBody({
2744
- state,
2745
- forced,
2746
- canShareMachine
2747
- }) });
2889
+ return /* @__PURE__ */ jsx(WelcomeFrame, {
2890
+ contentWidth,
2891
+ children: /* @__PURE__ */ jsx(FirstRunBody, {
2892
+ state,
2893
+ forced,
2894
+ canShareMachine
2895
+ })
2896
+ });
2748
2897
  }
2749
- /** The Skydive mark centered above the screen's content, matching the
2750
- * new-chat splash lockup and degrading by available space. */
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
- justifyContent: "center",
2758
- alignItems: "center"
2902
+ alignItems: "center",
2903
+ gap: 1
2759
2904
  },
2760
- children: [height >= MARK_MIN_ROWS$1 ? /* @__PURE__ */ jsx("box", {
2761
- style: {
2762
- flexDirection: "column",
2763
- alignItems: "center",
2764
- marginBottom: 1
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
- function renderBody({ state, forced, canShareMachine }) {
2778
- if (state.kind === "error") return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("text", {
2779
- fg: theme.error,
2780
- children: state.message
2781
- }), /* @__PURE__ */ jsx("text", {
2782
- fg: theme.muted,
2783
- children: "press ctrl+c to quit, then run `skydive` again to retry."
2784
- })] });
2785
- if (state.kind === "creating") return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("text", {
2786
- fg: theme.fg,
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(Fragment, { children: [/* @__PURE__ */ jsx("text", {
2798
- fg: theme.fg,
2799
- children: forced ? `${agent.name} is ready.` : `Your first agent, ${agent.name}, is ready.`
2800
- }), canShareMachine ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("text", {
2801
- fg: theme.muted,
2802
- children: [
2803
- "Want ",
2804
- agent.name,
2805
- " 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."
2806
- ]
2807
- }), /* @__PURE__ */ jsx("text", {
2808
- fg: theme.dim,
2809
- children: "↵/y yes, learn from my setup · n skip"
2810
- })] }) : /* @__PURE__ */ jsx("text", {
2811
- fg: theme.dim,
2812
- children: "press any key to open the chat"
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 run `skydive` again.";
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/tui/list-line.ts
3125
+ //#region src/chat/paste.ts
3126
+ const MAX_PATH_PASTE_CHARS = 4096;
2964
3127
  /**
2965
- * Flatten `text` to a single terminal line: newlines, carriage returns, tabs
2966
- * and other C0/C1 control characters each become one space. A picker row is
2967
- * budgeted as exactly one line (see the header comment), so a title carrying a
2968
- * literal newline would otherwise render as two lines, push the rows box past
2969
- * the height flexbox gave it, and collapse the rows above onto each other.
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 singleLine(text) {
2976
- let out = "";
2977
- for (const ch of text) {
2978
- const code = ch.codePointAt(0) ?? 0;
2979
- out += code <= 31 || code >= 127 && code <= 159 ? " " : ch;
3135
+ function fileUrlToPath(url) {
3136
+ let parsed;
3137
+ try {
3138
+ parsed = new URL(url);
3139
+ } catch (_error) {
3140
+ return null;
2980
3141
  }
2981
- return out;
2982
- }
2983
- /**
2984
- * Fit a row's title, an optional mid-row `tag` and a trailing detail into
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
- * Join hint segments with " · ", dropping trailing segments that do not fit
3027
- * rather than wrapping onto a second line — which would cost the list a row.
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 fitHintLine(segments, width) {
3031
- const kept = [];
3032
- for (const segment of segments) {
3033
- if (kept.length > 0 && [...kept, segment].join(" · ").length > width) break;
3034
- kept.push(segment);
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
- return truncate(kept.join(" · "), width);
3037
- }
3038
-
3039
- //#endregion
3040
- //#region src/chat/tui/use-debounced-value.ts
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
- const { width, height } = useTerminalDimensions();
3198
- if (state.kind === "loading") return /* @__PURE__ */ jsxs("box", {
3199
- style: {
3200
- flexDirection: "column",
3201
- flexGrow: 1
3202
- },
3203
- children: [/* @__PURE__ */ jsx(BrandHeader, {
3204
- height,
3205
- width
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
- function ConversationList({ agent, initialQuery, conversations, complete, showAutomated, onToggleAutomated, showArchived, onToggleArchived, onPick }) {
3237
- const { width, height } = useTerminalDimensions();
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 [query, setQuery] = useState(initialQuery);
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 debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
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 hits = isSearching ? list.map((item) => ({
3894
+ const rows = list.map((item) => ({
3311
3895
  item,
3312
- highlights: [
3313
- substringIndexes(singleLine(item.title ?? ""), debouncedQuery),
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 innerWidth = Math.max(1, width - 2);
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 countSegment = isSearching && (searchState?.kind === "loading" || search === null) ? "searching…" : isSearching && searchState?.kind === "error" ? `search failed: ${searchState.message}` : `${hits.length}/${list.length}${complete || isSearching ? "" : " (loading…)"}`;
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
- countSegment,
3341
- ...highlightAgentRun ? [] : [`ctrl+a ${highlightArchived ? "unarchive" : "archive"}`],
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
- "↵ open"
3348
- ], innerWidth)
3935
+ "esc back"
3936
+ ].filter((segment) => segment !== ""), innerWidth)
3349
3937
  };
3350
- const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height, width));
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 open = (row) => {
3354
- if (row.kind === "new") onPick({
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
- kind: "new",
3361
- agentId: agent.id,
3362
- nonce: crypto.randomUUID()
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
- else onPick({
3971
+ onPick({
3366
3972
  kind: "chat",
3367
3973
  agent,
3368
3974
  attachRunId: null,
3369
3975
  parentConversationId: null,
3370
- conversation: row.hit.item
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") onPick({ kind: "agent-picker" });
3426
- else if (key.name === "up") setHighlight(Math.max(0, clamped - 1));
3427
- else if (key.name === "down") setHighlight(Math.min(Math.max(0, rows.length - 1), clamped + 1));
3428
- else if (key.name === "pageup" || key.name === "pgup") setHighlight(Math.max(0, clamped - Math.max(1, visibleRows - 1)));
3429
- else if (key.name === "pagedown" || key.name === "pgdn") setHighlight(Math.min(Math.max(0, rows.length - 1), clamped + Math.max(1, visibleRows - 1)));
3430
- else if (key.name === "d" && key.ctrl) {
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
- } else if (key.name === "tab" && key.shift) onToggleArchived();
3447
- else if (key.name === "tab") onToggleAutomated();
3448
- else if (key.name === "return") {
3449
- const row = rows[clamped];
3450
- if (row) open(row);
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(BrandHeader, {
3460
- height,
3461
- width
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
- flexGrow: 1
4128
+ width: contentWidth
3495
4129
  },
3496
- children: windowed.map((row, i) => {
3497
- const selected = start + i === clamped;
3498
- const fg = selected ? theme.accent : theme.fg;
3499
- const marker = selected ? "› " : " ";
3500
- if (row.kind === "new") return /* @__PURE__ */ jsxs("text", {
3501
- fg,
3502
- children: [marker, truncate("+ new conversation", rowWidth)]
3503
- }, "new");
3504
- const archived = isArchived(row.hit.item);
3505
- const unread = row.hit.item.unread === true;
3506
- const { title, detail, tag } = fitRowText(singleLine(row.hit.item.title ?? "(untitled)"), `${archived ? "archived · " : ""}${previewLine(row.hit.item)}`, rowWidth, coAgentsTag(row.hit.item, agent.id));
3507
- const titleText = /* @__PURE__ */ jsx(FuzzyText, {
3508
- text: title,
3509
- indexes: row.hit.item.title ? row.hit.highlights[0] : null
3510
- });
3511
- return /* @__PURE__ */ jsxs("text", {
3512
- fg: archived && !selected ? theme.dim : fg,
3513
- children: [
3514
- !selected && unread ? /* @__PURE__ */ jsx("span", {
3515
- fg: theme.accent,
3516
- children: "● "
3517
- }) : marker,
3518
- unread ? /* @__PURE__ */ jsx("b", { children: titleText }) : titleText,
3519
- tag ? /* @__PURE__ */ jsxs("span", {
3520
- fg: theme.muted,
3521
- children: [" ", tag]
3522
- }) : null,
3523
- detail ? /* @__PURE__ */ jsxs("span", {
3524
- fg: theme.dim,
3525
- children: [" ", detail]
3526
- }) : null
3527
- ]
3528
- }, row.hit.item.id);
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
- function previewLine(c) {
3579
- const when = relativeTime(c.updatedAt);
3580
- const prefix = c.preview ? c.preview.replace(/\s+/g, " ").trim() : "";
3581
- const text = prefix ? `${when} · ${prefix}` : when;
3582
- return text.length > 60 ? `${text.slice(0, 59)}…` : text;
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$2(bytes)} to save memory ⋯`;
6296
+ return `${ELISION_MARKER_TAG} ${formatBytes$1(bytes)} to save memory ⋯`;
5676
6297
  }
5677
- function formatBytes$2(n) {
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
- * the expanded state — the chat screen has to reserve the card's rows, so it
6785
- * can't discover the height from a toggle held down here.
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
- * A card whose completed work is folded away is clickable: the header carries
6788
- * the affordance and clicking anywhere on the card toggles it, the same
6789
- * interaction reasoning and tool output already use.
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
- onMouseDown: canExpand ? onToggleExpanded : void 0,
6801
- children: [
6802
- /* @__PURE__ */ jsxs("box", {
6803
- style: { flexDirection: "row" },
6804
- children: [
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: [doneSummary, " done"]
6836
- })]
6837
- }) : null,
6838
- rows.map((todo) => {
6839
- const g = glyph(todo.status);
6840
- return /* @__PURE__ */ jsxs("box", {
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: [/* @__PURE__ */ jsxs("text", {
6843
- fg: g.fg,
6844
- children: [g.char, " "]
6845
- }), /* @__PURE__ */ jsx("text", {
6846
- fg: rowColor$1(todo.status),
6847
- children: todo.content
6848
- })]
6849
- }, todo.id);
6850
- }),
6851
- moreCount > 0 ? /* @__PURE__ */ jsxs("text", {
6852
- fg: theme.dim,
6853
- children: [
6854
- " … +",
6855
- moreCount,
6856
- " more"
6857
- ]
6858
- }) : null
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 card (or `/plan expand`) brings the rows back.
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 id = line.match(QUEUED_TASK_ID_LINE)?.[1];
6986
- if (!id) continue;
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: id.toLowerCase(),
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
- let latest = null;
7032
- for (const item of items) if (item.kind === "tool" && item.toolName === "subagent") latest = item.id;
7033
- return latest === null ? [] : [latest];
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.length === 1 && current[0] === nextId) return 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
- return queued.map((task, index) => {
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({ item, isActive: _isActive, onLiveChange, onHeightChange }) {
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 titles = subagentTaskTitles(item.input);
7227
- const queued = item.output?.kind === "ok" ? queuedSubagentTasks(item.output.value) : [];
7228
- const idsKey = queued.map((task) => task.id).join(",");
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 live = rows.length === 0 || rows.some((row) => isLiveStatus(row.status));
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
- }, [onHeightChange, rows.length]);
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 || idsKey.length === 0 || !live) return;
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: item.id,
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
- item.id,
7986
+ conversationId,
7286
7987
  live,
7287
- reportKey
7988
+ resolved,
7989
+ rows.map((row) => `${row.id}:${row.status}:${row.conversationId ?? ""}`).join("|")
7288
7990
  ]);
7289
- if (rows.length === 0) return /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
7290
- fg: theme.warning,
7291
- children: "◐ "
7292
- }), /* @__PURE__ */ jsx("span", {
7293
- fg: theme.tool,
7294
- children: "Delegating to subagents…"
7295
- })] });
7296
- const selectedIndex = subagentSelection && subagentSelection.toolCallId === item.id ? Math.min(subagentSelection.index, rows.length - 1) : null;
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__ */ jsxs("text", { children: [
7303
- live ? /* @__PURE__ */ jsx(Spinner, { color: theme.accent }) : rows.every((row) => row.status === "completed") ? /* @__PURE__ */ jsx("span", {
7304
- fg: theme.success,
7305
- children: "✔"
7306
- }) : /* @__PURE__ */ jsx("span", {
7307
- fg: theme.error,
7308
- children: "✗"
7309
- }),
7310
- /* @__PURE__ */ jsxs("span", {
7311
- fg: theme.tool,
7312
- children: [" ", /* @__PURE__ */ jsx("b", { children: headerLabel(rows) })]
7313
- }),
7314
- /* @__PURE__ */ jsxs("span", {
7315
- fg: theme.dim,
7316
- children: [
7317
- " ",
7318
- rows.filter((row) => row.status === "completed").length,
7319
- "/",
7320
- rows.length
7321
- ]
7322
- }),
7323
- openConversation ? /* @__PURE__ */ jsxs("span", {
7324
- fg: theme.dim,
7325
- children: [" ", selectedIndex !== null ? "↑/↓ select · ↵ view · esc back" : "/subagent to browse"]
7326
- }) : null
7327
- ] }), /* @__PURE__ */ jsxs("box", {
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, onHeightChange, onBatchLiveChange }) {
7355
- const [heightById, setHeightById] = useState({});
7356
- const setBatchHeight = useCallback((id, height) => {
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: items.map((item) => /* @__PURE__ */ jsx(ToolSubagent, {
7375
- item,
8083
+ children: /* @__PURE__ */ jsx(ToolSubagent, {
8084
+ items,
7376
8085
  isActive,
7377
- onLiveChange: (live) => onBatchLiveChange?.(item.id, live),
7378
- onHeightChange: (batchHeight) => setBatchHeight(item.id, batchHeight)
7379
- }, item.id))
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 connectHint = actionableCardCount > 1 ? `ctrl+r connect (${actionableCardCount}) · ` : actionableCardCount === 1 ? "ctrl+r connect · " : "";
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
- return `ctrl+p · ${model ?? "default"}${effort ? ` · ${effort}` : ""}`;
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: "click",
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 / unfold the pinned plan card"
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: null,
9112
- description: "select a subagent from the fan-out card and open its chat",
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 expand the pinned plan card",
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
- * Selecting a model PATCHes the agent, which changes the model for the
9729
- * agent's next run (send carries no per-message model). `tab` on a reasoning
9730
- * model opens its effort list instead, which is also where `/effort` lands
9731
- * directly. Both callbacks hand the settled values back to the chat screen so
9732
- * its status line updates without a round-trip.
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([rest.listModels(), rest.listModelEfforts().catch(() => ({}))]);
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 settled = await rest.updateAgentModel({
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
- await rest.updateAgentEffort({
9874
- agentId,
9875
- thinkingLevel: level
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 [highlight, setHighlight] = useState(0);
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 canEditEffort = !!highlighted && !!effortFor(highlighted);
9952
- const statusLine = saving ? "saving…" : saveError ? saveError : `${filtered.length}/${models.length} · ↑/↓ move · ↵ select${canEditEffort ? " · tab effort" : ""} · esc cancel`;
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 === currentModel;
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$1(bytes) {
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$1(file.sizeBytes)}`.length);
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$1(selectedFile.sizeBytes)}\npreview unavailable for binary or large files`
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$1(row.file.sizeBytes)]
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
- function formatBytes(bytes) {
13058
- if (bytes < 1024) return `${bytes} B`;
13059
- if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
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 + 2 : 0;
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, { expanded: planExpanded }) : 0;
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
- for (const item of subagentItems) {
13320
- const batch = subagentBatches.get(item.id);
13321
- if (batch && batch.rows.length > 0) return batch;
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) => current.length > 0 ? current : anchors);
14461
+ if (anchors.length > 0) setAnchoredSubagentIds((current) => [...new Set([...anchors, ...current])]);
13426
14462
  seedPrLinks(loaded);
13427
- setTodos(latestTodosFromHistory(firstPage.messages));
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
- const next = nextAnchoredSubagentIds({
13502
- current,
13503
- nextId: id
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 attachmentIds = [];
13634
- const restorable = [];
13635
- const settled = await Promise.allSettled(staged.map((a) => uploadsRef.current.get(a.tempId)));
13636
- for (const [i, result] of settled.entries()) {
13637
- const row = staged[i];
13638
- if (!row) continue;
13639
- if (result.status === "fulfilled" && result.value) {
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
- for (const row of restorable) uploadsRef.current.delete(row.tempId);
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
- const restore = staged.filter((a) => uploadsRef.current.has(a.tempId));
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()) return;
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: "chat",
15642
+ kind: "conversation-picker",
14699
15643
  agent,
14700
- attachRunId: null,
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) useStore.getState().showToast({ message: "no subagents in this conversation yet" });
14753
- else setSubagentSelection({
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
- if (nextExpanded) setPlanHidden(false);
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
- setAttachments([]);
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
- pasteImage();
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
- const last = attachmentsRef.current.at(-1);
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(model, effort);
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
- onHeightChange: setSubagentRows,
15528
- onBatchLiveChange: setAnchoredBatchLive
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__ */ jsxs("box", {
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") return chatView;
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: [!fullscreen || !reviewFocused ? /* @__PURE__ */ jsx("box", {
15822
- onMouseDown: () => setReviewFocused(false),
15823
- style: {
15824
- flexGrow: 1,
15825
- paddingRight: reviewPlacement === "side" ? 1 : 0
15826
- },
15827
- children: chatView
15828
- }, "chat") : null, !fullscreen || reviewFocused ? /* @__PURE__ */ jsx("box", {
15829
- ref: reviewSideBoxRef,
15830
- onMouseDown: () => setReviewFocused(true),
15831
- style: {
15832
- width: reviewPlacement === "side" ? reviewWidth : bodyWidth,
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
- border: !fullscreen,
15835
- borderColor: reviewFocused ? theme.accent : theme.faint,
15836
- paddingLeft: fullscreen ? 0 : 1,
15837
- paddingRight: fullscreen ? 0 : 1
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: newConversation || useStore.getState().seedPrompt !== null,
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 === "agent-picker" && rest && /* @__PURE__ */ jsx(AgentPickerScreen, {}),
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: "agent-picker" }),
17208
+ onCancel: () => goTo({ kind: "welcome" }),
16197
17209
  onSelect: (workspace) => {
16198
17210
  setWorkspaceName(workspace.name);
16199
- goTo({ kind: "agent-picker" });
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();