skydive-cli 0.6.0-beta.16 → 0.6.0-beta.18

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,15 +1,15 @@
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-ToPnfmTk.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-CdbXPgQk.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-iZWrGKZJ.mjs";
2
+ import { a as errorMessage, i as sendErrorMessage, n as errorDetail, o as isRecord, t as createRestClient } from "./rest-DZCq0tZw.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-CCXpClJw.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-Dvl9vzTP.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-CcN2hZil.mjs";
7
+ import { t as PortalClient } from "./client-DG0vUvWI.mjs";
8
8
  import { t as defaultTlsCertSource } from "./tls-cert-Rua2oV7n.mjs";
9
9
  import "./api-BFQ4PQDA.mjs";
10
- import "./daemon-BioFi0dP.mjs";
10
+ import "./daemon-rHe1xapN.mjs";
11
11
  import { t as SandboxStream } from "./client-l6orYfqq.mjs";
12
- import { t as PortalDaemonClient } from "./daemon-client-Bbjns6Wk.mjs";
12
+ import { t as PortalDaemonClient } from "./daemon-client-D-k67MEB.mjs";
13
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";
@@ -137,7 +137,6 @@ const useStore = create((set, get) => ({
137
137
  toast: null,
138
138
  seedPrompt: null,
139
139
  autoGrantMachine: false,
140
- newConversationIntent: false,
141
140
  forcedOnboarding: false,
142
141
  earlyInput: "",
143
142
  goTo: (screen) => set({
@@ -221,20 +220,20 @@ const useStore = create((set, get) => ({
221
220
  /**
222
221
  * Decide the screen the chat TUI boots into.
223
222
  *
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.
223
+ * With no selector we open the welcome screen (screen 1 of the three-screen
224
+ * flow) — unless the config stores a `defaultAgent`, in which case a bare
225
+ * `skydive chat` opens that agent's conversation home directly (its centered
226
+ * composer + conversation list). The default is maintained automatically
227
+ * (every conversation the TUI opens records its agent, so a bare launch
228
+ * returns to whoever you talked to last the dominant launch pattern), and
229
+ * `skydive config set defaultAgent` can seed or override it. A stored selector that no longer resolves degrades silently
230
+ * to the welcome screen rather than an error screen: config goes stale (agent
231
+ * renamed, archived, another one claiming the same name), and unlike a
232
+ * mistyped `--agent` there is no command line to correct — and picking an
233
+ * agent there heals the default on its own.
235
234
  *
236
235
  * With `--agent` (`skydive chat --agent grace`, no `-p`) we resolve it
237
- * against the org roster and open that agent's conversation list, skipping
236
+ * against the org roster and open that agent's conversation home, skipping
238
237
  * the picker. Resolution reuses the same `resolveAgent` rules as the `-p`
239
238
  * path so a given selector behaves identically in both modes: an id, or a
240
239
  * unique case-insensitive slug/name; ambiguous or unknown selectors throw
@@ -251,8 +250,9 @@ const useStore = create((set, get) => ({
251
250
  * needs every candidate is disambiguating a fuzzy name/slug selector; an id
252
251
  * (or the `--resume` conversation's agentId) is exact and resolves with a
253
252
  * 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.
253
+ * agent, or not", which a two-row page answers. A bare launch lands straight
254
+ * on the welcome screen, which loads the roster itself. A large org must never
255
+ * sit on the `connecting…` splash while the roster streams in.
256
256
  *
257
257
  * Kept out of the React component so it's unit-testable under Node (the
258
258
  * component itself can't render without Bun + OpenTUI).
@@ -262,7 +262,7 @@ const useStore = create((set, get) => ({
262
262
  * org roster. A name/slug selector is the only case that can be ambiguous and
263
263
  * therefore still needs the full candidate list to disambiguate. */
264
264
  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 }) {
265
+ async function resolveInitialScreen({ rest, agentSelector, conversationId, newConversation, seedPrompt, defaultAgentSelector, forceOnboarding }) {
266
266
  if (conversationId) {
267
267
  const conversation = await rest.getConversation({ conversationId }).catch((err) => {
268
268
  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 +294,8 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
294
294
  parentConversationId: conversation.parentConversationId ?? null
295
295
  };
296
296
  }
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
- }
297
+ if (forceOnboarding && !agentSelector) return { kind: "welcome" };
298
+ if (!agentSelector && !newConversation && seedPrompt === null && !defaultAgentSelector) return { kind: "welcome" };
305
299
  if (!agentSelector && defaultAgentSelector) {
306
300
  const agent = UUID_RE.test(defaultAgentSelector) ? await rest.getAgent({ agentId: defaultAgentSelector }).catch((err) => {
307
301
  if (err instanceof HttpError && (err.status === 404 || err.status === 403)) return null;
@@ -310,31 +304,13 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
310
304
  scope: "org",
311
305
  onPage: null
312
306
  }), 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
- };
307
+ if (agent) return openAgentHome(agent, seedPrompt);
308
+ return { kind: "welcome" };
337
309
  }
310
+ if (agentSelector && UUID_RE.test(agentSelector)) return openAgentHome(await rest.getAgent({ agentId: agentSelector }).catch((err) => {
311
+ 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.`);
312
+ throw err;
313
+ }), seedPrompt);
338
314
  if (!agentSelector && newConversation) {
339
315
  const firstTwo = await rest.listAgents({
340
316
  scope: "org",
@@ -342,14 +318,24 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
342
318
  limit: 2
343
319
  });
344
320
  const only = firstTwo.length === 1 ? firstTwo[0] : null;
345
- if (!only) return { kind: "agent-picker" };
346
- return newChat(only);
321
+ if (!only) return { kind: "welcome" };
322
+ return openAgentHome(only, seedPrompt);
347
323
  }
348
- const agent = resolveAgent(await rest.listAgents({
324
+ return openAgentHome(resolveAgent(await rest.listAgents({
349
325
  scope: "org",
350
326
  onPage: null
351
- }), agentSelector);
352
- if (newConversation) return newChat(agent);
327
+ }), agentSelector), seedPrompt);
328
+ }
329
+ /**
330
+ * Land on the agent's conversation home — the single new-conversation surface
331
+ * (centered composer + conversation list). The one exception is a seeded
332
+ * launch (`skydive import`): there is a message queued to auto-send, so boot
333
+ * straight into a fresh chat (`kind: 'new'`) that fires it. Without a seed
334
+ * there is nothing to send, so the composer on the home is where a new
335
+ * conversation starts — the legacy full-width empty-chat landing is gone.
336
+ */
337
+ function openAgentHome(agent, seedPrompt) {
338
+ if (seedPrompt !== null) return newChat(agent);
353
339
  return {
354
340
  kind: "conversation-picker",
355
341
  agent,
@@ -1831,9 +1817,7 @@ function screenLabelParts(screen, chatTitle) {
1831
1817
  switch (screen.kind) {
1832
1818
  case "splash": return { title: "" };
1833
1819
  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" };
1820
+ case "welcome": return { title: "welcome" };
1837
1821
  case "workspace-picker": return { title: "switch workspace" };
1838
1822
  case "conversation-picker": return {
1839
1823
  title: "pick a conversation",
@@ -2103,26 +2087,185 @@ function SkydiveMark({ variant = "full" }) {
2103
2087
  }
2104
2088
 
2105
2089
  //#endregion
2106
- //#region src/chat/tui/screens/agent-picker.tsx
2107
- function AgentPickerScreen() {
2090
+ //#region src/chat/tui/use-terminal-ready.ts
2091
+ /**
2092
+ * The 80x24 size the CLI renderer boots on when it cannot read the terminal's
2093
+ * real size synchronously (emulators / multiplexers that report size via an
2094
+ * async `resize` after start). A terminal reporting its size synchronously
2095
+ * boots on its true size instead — which is almost never exactly 80x24. See
2096
+ * `@opentui/core`: the renderer seeds width from `process.stdout.columns || 80`
2097
+ * and height from `process.stdout.rows || 24`.
2098
+ */
2099
+ const FALLBACK_WIDTH = 80;
2100
+ const FALLBACK_HEIGHT = 24;
2101
+ /**
2102
+ * Last-resort reveal delay for the ambiguous fallback boot (booted 80x24 with
2103
+ * no readable `process.stdout` size and no resize ever arriving — a non-tty
2104
+ * pipe, say). It is deliberately longer than the renderer's 100ms resize
2105
+ * debounce (`resizeDebounceDelay`), so a real async terminal's first resize
2106
+ * always wins this race and the brand's first *visible* paint is at the true
2107
+ * width. On the genuinely-80x24 path this timer never runs (we reveal from
2108
+ * `stdout.columns`), so its length costs a real terminal nothing.
2109
+ */
2110
+ const REVEAL_FALLBACK_MS = 250;
2111
+ /** The terminal's real column/row count if the tty reports it synchronously,
2112
+ * else null. On an async-sizing emulator `process.stdout.columns` is populated
2113
+ * shortly after boot even while the renderer is still on its 80x24 fallback, so
2114
+ * this is the cheapest way to learn the true size without waiting on the
2115
+ * debounced resize. A non-tty (pipe/redirect) has no size and returns null. */
2116
+ function stdoutSize() {
2117
+ const width = process.stdout.columns;
2118
+ const height = process.stdout.rows;
2119
+ return typeof width === "number" && typeof height === "number" ? {
2120
+ width,
2121
+ height
2122
+ } : null;
2123
+ }
2124
+ /**
2125
+ * Terminal dimensions plus a `ready` flag that gates the first centered paint,
2126
+ * to kill the welcome screen's left→center brand flash (PR #20856).
2127
+ *
2128
+ * `useTerminalDimensions` seeds from `renderer.width`/`height`. A terminal that
2129
+ * reports its size synchronously already has the real size on frame one, so
2130
+ * `ready` starts true and nothing is gated — no flash is possible because the
2131
+ * width never changes.
2132
+ *
2133
+ * A terminal that sizes asynchronously (tmux, cmux, some emulators) boots on
2134
+ * the 80x24 fallback and the real size lands via `resize` — but the renderer
2135
+ * DEBOUNCES that resize by 100ms, so a naive "reveal on the next tick" reveals
2136
+ * the brand centered-within-80 first and only jumps it to the real width a
2137
+ * tenth of a second later: exactly the flash reviewers saw. So on a fallback
2138
+ * boot we hold a blank full-size frame and reveal only once we actually know
2139
+ * the final width, from one of three signals, in order:
2140
+ *
2141
+ * 1. `process.stdout.columns` — the OS-reported tty size, populated well
2142
+ * before the debounced resize fires. If it reports a size other than
2143
+ * 80x24, that is the truth and the imminent resize will bring it, so we
2144
+ * stay gated until the resize actually lands (the width the brand centers
2145
+ * within must be the one on screen). If it reports exactly 80x24, the
2146
+ * terminal really is 80 wide — the 80-col centering is already final, so
2147
+ * reveal immediately with no flash.
2148
+ * 2. the `resize` event — the definitive real size for an async terminal;
2149
+ * reveal the instant it arrives, already at the final width.
2150
+ * 3. a `REVEAL_FALLBACK_MS` timer — only for a non-tty with no size and no
2151
+ * resize (a pipe), so it is never left blank forever. Longer than the
2152
+ * 100ms debounce so a real resize always beats it.
2153
+ */
2154
+ function useTerminalReady() {
2155
+ const { width, height } = useTerminalDimensions();
2156
+ const renderer = useRenderer();
2157
+ const bootedOnFallback = width === FALLBACK_WIDTH && height === FALLBACK_HEIGHT;
2158
+ const [ready, setReady] = useState(() => {
2159
+ if (!bootedOnFallback) return true;
2160
+ const size = stdoutSize();
2161
+ return size?.width === FALLBACK_WIDTH && size?.height === FALLBACK_HEIGHT;
2162
+ });
2163
+ useEffect(() => {
2164
+ if (ready) return;
2165
+ let done = false;
2166
+ const settle = () => {
2167
+ if (done) return;
2168
+ done = true;
2169
+ setReady(true);
2170
+ };
2171
+ renderer.on("resize", settle);
2172
+ const size = stdoutSize();
2173
+ if (size?.width === FALLBACK_WIDTH && size?.height === FALLBACK_HEIGHT) settle();
2174
+ const t = setTimeout(settle, REVEAL_FALLBACK_MS);
2175
+ return () => {
2176
+ renderer.off("resize", settle);
2177
+ clearTimeout(t);
2178
+ };
2179
+ }, [renderer, ready]);
2180
+ return {
2181
+ width,
2182
+ height,
2183
+ ready
2184
+ };
2185
+ }
2186
+
2187
+ //#endregion
2188
+ //#region src/chat/tui/agent-name.ts
2189
+ function parseAgentName(value) {
2190
+ const name = value.trim();
2191
+ if (!name) return {
2192
+ ok: false,
2193
+ message: "Enter a name."
2194
+ };
2195
+ if (name.length > 120) return {
2196
+ ok: false,
2197
+ message: "Use 120 characters or fewer."
2198
+ };
2199
+ return {
2200
+ ok: true,
2201
+ name
2202
+ };
2203
+ }
2204
+
2205
+ //#endregion
2206
+ //#region src/chat/tui/screens/welcome.tsx
2207
+ /**
2208
+ * Screen 1 of the three-screen chat flow: **Welcome to Skydive**.
2209
+ *
2210
+ * A single centered screen (opencode fixed-width column) that is the entry
2211
+ * point whenever no agent is pinned. It folds together what used to be three
2212
+ * separate screens — the agent picker, the create-agent form, and the
2213
+ * first-run onboarding — so the flow is exactly: welcome → agent page → chat.
2214
+ *
2215
+ * Modes, all rendered in the same centered column:
2216
+ * - `list`: the workspace's agents, fuzzy-filterable. Enter opens the
2217
+ * highlighted agent's home (the agent page); tab flips mine/all scope;
2218
+ * ctrl+n switches to `create`; ctrl+w opens the workspace picker.
2219
+ * - `create`: an inline name field (prefilled from the server's suggestion).
2220
+ * Enter creates the agent, then offers a shell alias before opening it.
2221
+ * - `first-run`: reached automatically when the workspace has no agents at
2222
+ * all — auto-creates the account's first agent (server-named, like web
2223
+ * onboarding) and offers to have it learn from this machine's coding-agent
2224
+ * setup before dropping the user into its chat.
2225
+ *
2226
+ * Everything is centered both axes so a wide terminal gets even margins
2227
+ * instead of content stranded top-left.
2228
+ */
2229
+ const CONTENT_MAX_COLS = 76;
2230
+ /**
2231
+ * Where to land after creating an agent (create mode / first-run). The
2232
+ * conversation home (centered composer + list) is the single new-conversation
2233
+ * surface, so a freshly created agent opens straight into its home. The one
2234
+ * exception is a seeded launch (`skydive import`): there is a message queued
2235
+ * to auto-send, so boot into a fresh chat (`kind: 'new'`) that fires it. A
2236
+ * brand-new agent has no conversations yet, so its home is just the composer
2237
+ * over an empty list — which is exactly the "start talking" prompt we want.
2238
+ */
2239
+ function openAgentAfterCreate(agent) {
2240
+ if (useStore.getState().seedPrompt !== null) return {
2241
+ kind: "chat",
2242
+ agent,
2243
+ attachRunId: null,
2244
+ parentConversationId: null,
2245
+ conversation: {
2246
+ kind: "new",
2247
+ agentId: agent.id,
2248
+ nonce: crypto.randomUUID()
2249
+ }
2250
+ };
2251
+ return {
2252
+ kind: "conversation-picker",
2253
+ agent,
2254
+ initialQuery: ""
2255
+ };
2256
+ }
2257
+ function WelcomeScreen() {
2108
2258
  const rest = useStore((s) => s.rest);
2109
2259
  const goTo = useStore((s) => s.goTo);
2110
2260
  const fail = useStore((s) => s.fail);
2261
+ const forcedOnboarding = useStore((s) => s.forcedOnboarding);
2111
2262
  const [scope, setScope] = useState("mine");
2112
2263
  const [cache, setCache] = useState({});
2113
2264
  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
- });
2265
+ const [mode, setMode] = useState(forcedOnboarding ? { kind: "first-run" } : { kind: "list" });
2124
2266
  useEffect(() => {
2125
2267
  if (!rest) return;
2268
+ if (mode.kind !== "list") return;
2126
2269
  const cached = cache[scope];
2127
2270
  if (cached) {
2128
2271
  setState({
@@ -2153,6 +2296,15 @@ function AgentPickerScreen() {
2153
2296
  kind: "ready",
2154
2297
  agents
2155
2298
  });
2299
+ if (agents.length === 0 && scope === "mine") {
2300
+ const org = await rest.listAgents({
2301
+ scope: "org",
2302
+ onPage: null
2303
+ });
2304
+ if (cancelled) return;
2305
+ if (org.length === 0) setMode({ kind: "first-run" });
2306
+ else setScope("org");
2307
+ }
2156
2308
  } catch (err) {
2157
2309
  if (cancelled) return;
2158
2310
  if (err instanceof HttpError && err.status === 401) {
@@ -2172,56 +2324,124 @@ function AgentPickerScreen() {
2172
2324
  rest,
2173
2325
  fail,
2174
2326
  scope,
2175
- cache
2327
+ cache,
2328
+ mode.kind
2176
2329
  ]);
2177
- const { width, height } = useTerminalDimensions();
2178
- if (state.kind === "loading") return /* @__PURE__ */ jsxs("box", {
2330
+ if (mode.kind === "create") return /* @__PURE__ */ jsx(CreateAgentMode, { onCancel: () => setMode({ kind: "list" }) });
2331
+ if (mode.kind === "first-run") return /* @__PURE__ */ jsx(FirstRunMode, {});
2332
+ return /* @__PURE__ */ jsx(AgentListMode, {
2333
+ state,
2334
+ scope,
2335
+ onToggleScope: () => setScope((s) => s === "mine" ? "org" : "mine"),
2336
+ onCreate: () => setMode({ kind: "create" }),
2337
+ onSwitchWorkspace: () => {
2338
+ if (useStore.getState().authKind === "api-key") {
2339
+ useStore.getState().showToast({ message: "API keys are pinned to one workspace. Run `skydive auth login --web` to switch workspaces." });
2340
+ return;
2341
+ }
2342
+ goTo({ kind: "workspace-picker" });
2343
+ },
2344
+ onPick: goTo
2345
+ });
2346
+ }
2347
+ /** The centered welcome frame every mode renders inside: the brand mark and a
2348
+ * "Welcome to Skydive" heading over the mode's own content, in a fixed-width
2349
+ * column centered on both axes.
2350
+ *
2351
+ * The first paint is GATED on the terminal's real size being known (`ready`).
2352
+ * The renderer boots on an 80x24 fallback when the emulator reports its size
2353
+ * asynchronously (tmux and friends), so an ungated screen centers the brand
2354
+ * within 80 cols first, then jumps to centered-within-the-real-width once the
2355
+ * size lands — the "weird flashing transition" from left to center reviewers
2356
+ * see. Crucially the renderer DEBOUNCES that resize by 100ms, so revealing on
2357
+ * the next tick would still flash; `useTerminalReady` instead reveals from the
2358
+ * OS-reported `process.stdout` size or the resize event itself, whichever
2359
+ * confirms the true width first. While `ready` is false we hold a blank but
2360
+ * full-size, same-background frame, so the brand's first *visible* paint is
2361
+ * already at its final centered position and never reflows. The home
2362
+ * (conversation-picker) and the empty-chat splash gate the same way, so the
2363
+ * flash is fixed on every brand-centered surface the launch flow can land on.
2364
+ * See `useTerminalReady`. */
2365
+ function WelcomeFrame({ contentWidth, children }) {
2366
+ const { width, height, ready } = useTerminalReady();
2367
+ if (!ready) return /* @__PURE__ */ jsx("box", { style: {
2368
+ flexDirection: "column",
2369
+ flexGrow: 1
2370
+ } });
2371
+ return /* @__PURE__ */ jsxs("box", {
2179
2372
  style: {
2180
2373
  flexDirection: "column",
2181
- flexGrow: 1
2374
+ flexGrow: 1,
2375
+ alignItems: "center"
2182
2376
  },
2183
- children: [/* @__PURE__ */ jsx(BrandHeader, {
2184
- height,
2185
- width
2186
- }), /* @__PURE__ */ jsx("text", {
2377
+ children: [
2378
+ /* @__PURE__ */ jsx("box", { style: {
2379
+ flexGrow: 1,
2380
+ flexShrink: 1
2381
+ } }),
2382
+ /* @__PURE__ */ jsxs("box", {
2383
+ style: {
2384
+ flexDirection: "column",
2385
+ width: contentWidth
2386
+ },
2387
+ children: [/* @__PURE__ */ jsxs("box", {
2388
+ style: {
2389
+ flexDirection: "column",
2390
+ alignItems: "center"
2391
+ },
2392
+ children: [/* @__PURE__ */ jsx(BrandHeader, {
2393
+ height,
2394
+ width
2395
+ }), height >= 24 ? /* @__PURE__ */ jsxs("box", {
2396
+ style: {
2397
+ flexDirection: "column",
2398
+ alignItems: "center"
2399
+ },
2400
+ children: [/* @__PURE__ */ jsx("text", {
2401
+ fg: theme.accent,
2402
+ attributes: 1,
2403
+ children: "Welcome to Skydive"
2404
+ }), /* @__PURE__ */ jsx("box", { style: { height: 1 } })]
2405
+ }) : null]
2406
+ }), children]
2407
+ }),
2408
+ /* @__PURE__ */ jsx("box", { style: {
2409
+ flexGrow: 1,
2410
+ flexShrink: 1
2411
+ } })
2412
+ ]
2413
+ });
2414
+ }
2415
+ function AgentListMode({ state, scope, onToggleScope, onCreate, onSwitchWorkspace, onPick }) {
2416
+ const { width } = useTerminalDimensions();
2417
+ const contentWidth = Math.min(CONTENT_MAX_COLS, Math.max(1, width - 2));
2418
+ if (state.kind === "loading") return /* @__PURE__ */ jsx(WelcomeFrame, {
2419
+ contentWidth,
2420
+ children: /* @__PURE__ */ jsx("text", {
2187
2421
  fg: theme.muted,
2188
2422
  children: "loading agents…"
2189
- })]
2423
+ })
2190
2424
  });
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", {
2425
+ if (state.kind === "error") return /* @__PURE__ */ jsx(WelcomeFrame, {
2426
+ contentWidth,
2427
+ children: /* @__PURE__ */ jsxs("text", {
2200
2428
  fg: theme.error,
2201
2429
  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
- })]
2430
+ })
2216
2431
  });
2217
2432
  return /* @__PURE__ */ jsx(FilterableAgentList, {
2218
2433
  agents: state.agents,
2219
2434
  scope,
2220
- onPick: goTo
2435
+ contentWidth,
2436
+ onToggleScope,
2437
+ onCreate,
2438
+ onSwitchWorkspace,
2439
+ onPick
2221
2440
  });
2222
2441
  }
2223
- function FilterableAgentList({ agents, scope, onPick }) {
2442
+ function FilterableAgentList({ agents, scope, contentWidth, onToggleScope, onCreate, onSwitchWorkspace, onPick }) {
2224
2443
  const { width, height } = useTerminalDimensions();
2444
+ const composerRef = useRef(null);
2225
2445
  const [query, setQuery] = useState("");
2226
2446
  const [highlight, setHighlight] = useState(0);
2227
2447
  useEffect(() => {
@@ -2230,87 +2450,100 @@ function FilterableAgentList({ agents, scope, onPick }) {
2230
2450
  const hits = fuzzyFilter(query, agents, agentKeys);
2231
2451
  const filtered = hits.map((h) => h.item);
2232
2452
  const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
2233
- const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height, width));
2453
+ const identityRows = height >= 24 ? 2 : 0;
2454
+ const visibleRows = Math.max(3, height - 8 - brandHeaderRows(height, width) - identityRows);
2234
2455
  const start = windowStart(clamped, filtered.length, visibleRows);
2235
2456
  const windowed = hits.slice(start, start + visibleRows);
2457
+ const openAgent = () => {
2458
+ const agent = filtered[clamped];
2459
+ if (!agent) return;
2460
+ onPick(useStore.getState().seedPrompt !== null ? {
2461
+ kind: "chat",
2462
+ agent,
2463
+ attachRunId: null,
2464
+ parentConversationId: null,
2465
+ conversation: {
2466
+ kind: "new",
2467
+ agentId: agent.id,
2468
+ nonce: crypto.randomUUID()
2469
+ }
2470
+ } : {
2471
+ kind: "conversation-picker",
2472
+ agent,
2473
+ initialQuery: ""
2474
+ });
2475
+ };
2236
2476
  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
- });
2477
+ if (key.name === "up") {
2478
+ setHighlight(Math.max(0, clamped - 1));
2479
+ key.preventDefault();
2480
+ } else if (key.name === "down") {
2481
+ setHighlight(Math.min(Math.max(0, filtered.length - 1), clamped + 1));
2482
+ key.preventDefault();
2483
+ } else if (key.name === "pageup" || key.name === "pgup") {
2484
+ setHighlight(Math.max(0, clamped - Math.max(1, visibleRows - 1)));
2485
+ key.preventDefault();
2486
+ } else if (key.name === "pagedown" || key.name === "pgdn") {
2487
+ setHighlight(Math.min(Math.max(0, filtered.length - 1), clamped + Math.max(1, visibleRows - 1)));
2488
+ key.preventDefault();
2489
+ } else if (key.name === "n" && key.ctrl) {
2490
+ onCreate();
2491
+ key.preventDefault();
2492
+ } else if (key.name === "w" && key.ctrl) {
2493
+ onSwitchWorkspace();
2494
+ key.preventDefault();
2495
+ } else if (key.name === "tab") {
2496
+ onToggleScope();
2497
+ key.preventDefault();
2260
2498
  }
2261
2499
  });
2262
- return /* @__PURE__ */ jsxs("box", {
2263
- style: {
2264
- flexDirection: "column",
2265
- flexGrow: 1
2266
- },
2500
+ const empty = agents.length === 0;
2501
+ return /* @__PURE__ */ jsxs(WelcomeFrame, {
2502
+ contentWidth,
2267
2503
  children: [
2268
- /* @__PURE__ */ jsx(BrandHeader, {
2269
- height,
2270
- width
2271
- }),
2272
- /* @__PURE__ */ jsxs("box", {
2504
+ /* @__PURE__ */ jsx("box", {
2273
2505
  style: {
2274
- flexDirection: "row",
2275
- height: 1
2506
+ border: true,
2507
+ borderColor: theme.faint,
2508
+ paddingLeft: 1,
2509
+ paddingRight: 1,
2510
+ height: 3,
2511
+ flexShrink: 0,
2512
+ flexDirection: "column"
2276
2513
  },
2277
- children: [/* @__PURE__ */ jsx("text", {
2278
- fg: theme.muted,
2279
- children: "search "
2280
- }), /* @__PURE__ */ jsx("input", {
2514
+ children: /* @__PURE__ */ jsx("textarea", {
2515
+ ref: composerRef,
2281
2516
  textColor: theme.fg,
2282
2517
  focusedTextColor: theme.fg,
2283
2518
  cursorColor: theme.cursor,
2284
2519
  placeholderColor: theme.dim,
2285
- placeholder: "type to filter…",
2286
- focused: true,
2287
- width: Math.max(10, width - 12),
2288
- onInput: (value) => {
2289
- setQuery(value);
2520
+ placeholder: "Search agents…",
2521
+ keyBindings: searchKeyBindings,
2522
+ onContentChange: () => {
2523
+ setQuery(composerRef.current?.plainText ?? "");
2290
2524
  setHighlight(0);
2525
+ },
2526
+ onSubmit: openAgent,
2527
+ focused: true,
2528
+ style: {
2529
+ height: 1,
2530
+ wrapMode: "word"
2291
2531
  }
2292
- })]
2532
+ })
2293
2533
  }),
2294
- /* @__PURE__ */ jsxs("text", {
2534
+ /* @__PURE__ */ jsx("text", {
2295
2535
  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
- ]
2536
+ children: truncate(`${scope === "mine" ? "mine" : "all agents"} · ↵ open · ctrl+n new agent · tab ${scope === "mine" ? "all" : "mine"} · ctrl+w workspace`, contentWidth)
2307
2537
  }),
2308
2538
  /* @__PURE__ */ jsx("box", {
2309
2539
  style: {
2310
2540
  flexDirection: "column",
2311
- flexGrow: 1
2541
+ flexShrink: 0
2312
2542
  },
2313
- children: windowed.length === 0 ? /* @__PURE__ */ jsxs("text", {
2543
+ children: empty ? /* @__PURE__ */ jsx("text", {
2544
+ fg: theme.muted,
2545
+ 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)
2546
+ }) : windowed.length === 0 ? /* @__PURE__ */ jsxs("text", {
2314
2547
  fg: theme.muted,
2315
2548
  children: [
2316
2549
  "no agents match “",
@@ -2339,45 +2572,12 @@ function FilterableAgentList({ agents, scope, onPick }) {
2339
2572
  ]
2340
2573
  });
2341
2574
  }
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() {
2575
+ function CreateAgentMode({ onCancel }) {
2378
2576
  const rest = useStore((s) => s.rest);
2379
2577
  const goTo = useStore((s) => s.goTo);
2380
2578
  const fail = useStore((s) => s.fail);
2579
+ const { width } = useTerminalDimensions();
2580
+ const contentWidth = Math.min(CONTENT_MAX_COLS, Math.max(1, width - 2));
2381
2581
  const [name, setName] = useState("");
2382
2582
  const [state, setState] = useState({ kind: "idle" });
2383
2583
  useEffect(() => {
@@ -2392,17 +2592,7 @@ function AgentCreateScreen() {
2392
2592
  };
2393
2593
  }, [rest]);
2394
2594
  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
- });
2595
+ goTo(openAgentAfterCreate(agent));
2406
2596
  };
2407
2597
  const acceptAlias = (agent, alias) => {
2408
2598
  try {
@@ -2441,7 +2631,7 @@ function AgentCreateScreen() {
2441
2631
  openChat(state.agent);
2442
2632
  return;
2443
2633
  }
2444
- if (key.name === "escape" && state.kind !== "creating") goTo({ kind: "agent-picker" });
2634
+ if (key.name === "escape" && state.kind !== "creating") onCancel();
2445
2635
  });
2446
2636
  const createAgent = async () => {
2447
2637
  if (!rest || state.kind === "creating") return;
@@ -2468,51 +2658,25 @@ function AgentCreateScreen() {
2468
2658
  fail("Session expired. Run `skydive auth login --web`, then retry.");
2469
2659
  return;
2470
2660
  }
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
2661
  setState({
2500
2662
  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."
2663
+ message: createErrorMessage(err)
2502
2664
  });
2503
2665
  }
2504
2666
  };
2505
- if (state.kind === "offer-alias") return /* @__PURE__ */ jsx(AliasOffer, {
2506
- agentName: state.agent.name,
2507
- alias: state.alias
2667
+ if (state.kind === "offer-alias") return /* @__PURE__ */ jsx(WelcomeFrame, {
2668
+ contentWidth,
2669
+ children: /* @__PURE__ */ jsx(AliasOffer, {
2670
+ agentName: state.agent.name,
2671
+ alias: state.alias
2672
+ })
2508
2673
  });
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
- },
2674
+ if (state.kind === "aliased") return /* @__PURE__ */ jsx(WelcomeFrame, {
2675
+ contentWidth,
2676
+ children: /* @__PURE__ */ jsx(AliasResult, { message: state.message })
2677
+ });
2678
+ return /* @__PURE__ */ jsxs(WelcomeFrame, {
2679
+ contentWidth,
2516
2680
  children: [
2517
2681
  /* @__PURE__ */ jsx("text", {
2518
2682
  fg: theme.fg,
@@ -2522,6 +2686,7 @@ function AgentCreateScreen() {
2522
2686
  fg: theme.muted,
2523
2687
  children: "Choose a name. You can change it later."
2524
2688
  }),
2689
+ /* @__PURE__ */ jsx("box", { style: { height: 1 } }),
2525
2690
  /* @__PURE__ */ jsx("box", {
2526
2691
  style: {
2527
2692
  border: true,
@@ -2568,7 +2733,6 @@ function AliasOffer({ agentName, alias }) {
2568
2733
  return /* @__PURE__ */ jsxs("box", {
2569
2734
  style: {
2570
2735
  flexDirection: "column",
2571
- flexGrow: 1,
2572
2736
  gap: 1
2573
2737
  },
2574
2738
  children: [
@@ -2600,7 +2764,6 @@ function AliasResult({ message }) {
2600
2764
  return /* @__PURE__ */ jsxs("box", {
2601
2765
  style: {
2602
2766
  flexDirection: "column",
2603
- flexGrow: 1,
2604
2767
  gap: 1
2605
2768
  },
2606
2769
  children: [/* @__PURE__ */ jsx("text", {
@@ -2612,22 +2775,14 @@ function AliasResult({ message }) {
2612
2775
  })]
2613
2776
  });
2614
2777
  }
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() {
2778
+ function FirstRunMode() {
2626
2779
  const rest = useStore((s) => s.rest);
2627
2780
  const goTo = useStore((s) => s.goTo);
2628
2781
  const fail = useStore((s) => s.fail);
2629
2782
  const authKind = useStore((s) => s.authKind);
2630
2783
  const forced = useStore((s) => s.forcedOnboarding);
2784
+ const { width } = useTerminalDimensions();
2785
+ const contentWidth = Math.min(CONTENT_MAX_COLS, Math.max(1, width - 2));
2631
2786
  const [state, setState] = useState({ kind: "creating" });
2632
2787
  const canShareMachine = authKind === "session";
2633
2788
  useEffect(() => {
@@ -2658,17 +2813,7 @@ function FirstRunScreen() {
2658
2813
  };
2659
2814
  }, [rest, fail]);
2660
2815
  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
- });
2816
+ goTo(openAgentAfterCreate(agent));
2672
2817
  };
2673
2818
  const openOnboardingChat = (agent, conversationId, title, attachRunId) => {
2674
2819
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -2740,84 +2885,100 @@ function FirstRunScreen() {
2740
2885
  if (key.name === "return" || key.name === "y") acceptImport(state.agent);
2741
2886
  else if (key.name === "n" || key.name === "escape") declineImport(state.agent);
2742
2887
  });
2743
- return /* @__PURE__ */ jsx(Centered, { children: renderBody({
2744
- state,
2745
- forced,
2746
- canShareMachine
2747
- }) });
2888
+ return /* @__PURE__ */ jsx(WelcomeFrame, {
2889
+ contentWidth,
2890
+ children: /* @__PURE__ */ jsx(FirstRunBody, {
2891
+ state,
2892
+ forced,
2893
+ canShareMachine
2894
+ })
2895
+ });
2748
2896
  }
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();
2897
+ function FirstRunBody({ state, forced, canShareMachine }) {
2898
+ if (state.kind === "error") return /* @__PURE__ */ jsxs("box", {
2899
+ style: {
2900
+ flexDirection: "column",
2901
+ alignItems: "center",
2902
+ gap: 1
2903
+ },
2904
+ children: [/* @__PURE__ */ jsx("text", {
2905
+ fg: theme.error,
2906
+ children: state.message
2907
+ }), /* @__PURE__ */ jsx("text", {
2908
+ fg: theme.muted,
2909
+ children: "press ctrl+c to quit, then run `skydive` again to retry."
2910
+ })]
2911
+ });
2912
+ if (state.kind === "creating") return /* @__PURE__ */ jsx("box", {
2913
+ style: {
2914
+ flexDirection: "column",
2915
+ alignItems: "center",
2916
+ gap: 1
2917
+ },
2918
+ children: /* @__PURE__ */ jsx("text", {
2919
+ fg: theme.muted,
2920
+ children: forced ? "creating an agent…" : "setting up your first agent…"
2921
+ })
2922
+ });
2923
+ if (state.kind === "starting") return /* @__PURE__ */ jsx("text", {
2924
+ fg: theme.muted,
2925
+ children: "opening your agent…"
2926
+ });
2927
+ const { agent } = state;
2753
2928
  return /* @__PURE__ */ jsxs("box", {
2754
2929
  style: {
2755
- flexGrow: 1,
2756
2930
  flexDirection: "column",
2757
- justifyContent: "center",
2758
- alignItems: "center"
2931
+ alignItems: "center",
2932
+ gap: 1
2759
2933
  },
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
2934
+ children: [/* @__PURE__ */ jsx("text", {
2935
+ fg: theme.fg,
2936
+ children: forced ? `${agent.name} is ready.` : `Your first agent, ${agent.name}, is ready.`
2937
+ }), canShareMachine ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("text", {
2938
+ fg: theme.muted,
2939
+ children: [
2940
+ "Want ",
2941
+ agent.name,
2942
+ " 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."
2943
+ ]
2944
+ }), /* @__PURE__ */ jsx("text", {
2945
+ fg: theme.dim,
2946
+ children: "↵/y yes, learn from my setup · n skip"
2947
+ })] }) : /* @__PURE__ */ jsx("text", {
2948
+ fg: theme.dim,
2949
+ children: "press any key to open the chat"
2774
2950
  })]
2775
2951
  });
2776
2952
  }
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
- })] });
2792
- if (state.kind === "starting") return /* @__PURE__ */ jsx("text", {
2793
- fg: theme.muted,
2794
- children: "opening your agent…"
2795
- });
2796
- 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
- })] });
2814
- }
2815
2953
  function createErrorMessage(err) {
2816
2954
  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.";
2955
+ if (err instanceof HttpError && err.status === 429) return "Too many requests. Wait a moment, then try again.";
2956
+ if (err instanceof HttpError && err.status === 409) return "That name is unavailable. Choose another name and try again.";
2957
+ if (err instanceof HttpError && err.status === 400) return "Skydive could not create this agent. Check your active workspace and try again.";
2818
2958
  if (err instanceof HttpError) return "Skydive could not create your agent. Try again in a moment.";
2819
2959
  return "Could not reach Skydive. Check your connection and try again.";
2820
2960
  }
2961
+ const searchKeyBindings = [{
2962
+ name: "return",
2963
+ action: "submit"
2964
+ }, {
2965
+ name: "kpenter",
2966
+ action: "submit"
2967
+ }];
2968
+ const agentKeys = [
2969
+ {
2970
+ get: (a) => a.name,
2971
+ weight: 1
2972
+ },
2973
+ {
2974
+ get: (a) => a.slug ?? "",
2975
+ weight: .6
2976
+ },
2977
+ {
2978
+ get: (a) => a.title ?? "",
2979
+ weight: .4
2980
+ }
2981
+ ];
2821
2982
 
2822
2983
  //#endregion
2823
2984
  //#region src/chat/tui/screens/workspace-picker.tsx
@@ -2981,48 +3142,6 @@ function singleLine(text) {
2981
3142
  return out;
2982
3143
  }
2983
3144
  /**
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
- };
3024
- }
3025
- /**
3026
3145
  * Join hint segments with " · ", dropping trailing segments that do not fit
3027
3146
  * rather than wrapping onto a second line — which would cost the list a row.
3028
3147
  * The first segment is always kept (truncated if it has to be).
@@ -3130,6 +3249,7 @@ function forkedConversationRef({ agent, forked }) {
3130
3249
  //#region src/chat/tui/screens/conversation-picker.tsx
3131
3250
  const SEARCH_DEBOUNCE_MS = 250;
3132
3251
  const SEARCH_QUERY_MAX_LENGTH = 200;
3252
+ const SEARCH_PREFIX = "/";
3133
3253
  function ConversationPickerScreen({ agent, initialQuery }) {
3134
3254
  const rest = useStore((s) => s.rest);
3135
3255
  const goTo = useStore((s) => s.goTo);
@@ -3194,38 +3314,18 @@ function ConversationPickerScreen({ agent, initialQuery }) {
3194
3314
  cacheKey,
3195
3315
  cache
3196
3316
  ]);
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, {
3317
+ if (state.kind === "loading") return /* @__PURE__ */ jsx(HomeFrame, { children: /* @__PURE__ */ jsx("text", {
3318
+ fg: theme.muted,
3319
+ children: "loading conversations…"
3320
+ }) });
3321
+ if (state.kind === "error") return /* @__PURE__ */ jsx(HomeFrame, { children: /* @__PURE__ */ jsxs("text", {
3322
+ fg: theme.error,
3323
+ children: ["error: ", state.message]
3324
+ }) });
3325
+ return /* @__PURE__ */ jsx(ConversationHome, {
3225
3326
  agent,
3226
3327
  initialQuery,
3227
3328
  conversations: state.conversations,
3228
- complete: state.complete,
3229
3329
  showAutomated,
3230
3330
  onToggleAutomated: () => setShowAutomated((shown) => !shown),
3231
3331
  showArchived,
@@ -3233,12 +3333,84 @@ function ConversationPickerScreen({ agent, initialQuery }) {
3233
3333
  onPick: goTo
3234
3334
  });
3235
3335
  }
3236
- function ConversationList({ agent, initialQuery, conversations, complete, showAutomated, onToggleAutomated, showArchived, onToggleArchived, onPick }) {
3237
- const { width, height } = useTerminalDimensions();
3336
+ /**
3337
+ * The gated, centered brand frame the home renders inside — shared by the
3338
+ * loading and error legs so every leg paints the brand in the SAME centered
3339
+ * position at the SAME (real) width. Without this the loading leg painted an
3340
+ * ungated, left-aligned brand and then jumped it to centered when the loaded
3341
+ * home mounted: the left→center flash James saw (PR #20856).
3342
+ *
3343
+ * It rides `useTerminalReady`: on an async-sizing terminal (tmux/cmux) the
3344
+ * renderer boots on the 80x24 fallback and the real width lands via a debounced
3345
+ * resize, so we hold a blank same-size frame until the true width is known and
3346
+ * only then paint the centered brand — first VISIBLE paint already at its final
3347
+ * position, no reflow. The loaded `ConversationHome` gates identically, so the
3348
+ * brand never moves across loading → loaded. See `useTerminalReady`.
3349
+ */
3350
+ function HomeFrame({ children }) {
3351
+ const { width, height, ready } = useTerminalReady();
3352
+ const contentWidth = Math.min(76, Math.max(1, width - 2));
3353
+ if (!ready) return /* @__PURE__ */ jsx("box", { style: {
3354
+ flexDirection: "column",
3355
+ flexGrow: 1
3356
+ } });
3357
+ return /* @__PURE__ */ jsxs("box", {
3358
+ style: {
3359
+ flexDirection: "column",
3360
+ flexGrow: 1,
3361
+ alignItems: "center"
3362
+ },
3363
+ children: [
3364
+ /* @__PURE__ */ jsx("box", { style: {
3365
+ flexGrow: 1,
3366
+ flexShrink: 1
3367
+ } }),
3368
+ /* @__PURE__ */ jsxs("box", {
3369
+ style: {
3370
+ flexDirection: "column",
3371
+ width: contentWidth
3372
+ },
3373
+ children: [/* @__PURE__ */ jsx("box", {
3374
+ style: {
3375
+ flexDirection: "column",
3376
+ alignItems: "center"
3377
+ },
3378
+ children: /* @__PURE__ */ jsx(BrandHeader, {
3379
+ height,
3380
+ width
3381
+ })
3382
+ }), children]
3383
+ }),
3384
+ /* @__PURE__ */ jsx("box", { style: {
3385
+ flexGrow: 1,
3386
+ flexShrink: 1
3387
+ } })
3388
+ ]
3389
+ });
3390
+ }
3391
+ /**
3392
+ * The agent's home screen: a composer to start a new conversation (mirroring
3393
+ * the web home's "Ask <agent> anything" field) over the list of existing
3394
+ * conversations. The composer is always focused; ↑/↓ move the list, enter on
3395
+ * a non-empty composer sends a new message, enter on an empty composer opens
3396
+ * the highlighted conversation. A leading '/' turns the composer into a live
3397
+ * list filter (server-side search) instead of a message.
3398
+ */
3399
+ function ConversationHome({ agent, initialQuery, conversations, showAutomated, onToggleAutomated, showArchived, onToggleArchived, onPick }) {
3400
+ const { width, height, ready } = useTerminalReady();
3238
3401
  const rest = useStore((s) => s.rest);
3239
- const [query, setQuery] = useState(initialQuery);
3402
+ const composerRef = useRef(null);
3403
+ const [input, setInput] = useState(initialQuery ? `${SEARCH_PREFIX}${initialQuery}` : "");
3404
+ const seededRef = useRef(false);
3405
+ useEffect(() => {
3406
+ if (seededRef.current) return;
3407
+ seededRef.current = true;
3408
+ if (initialQuery) composerRef.current?.setText(`${SEARCH_PREFIX}${initialQuery}`);
3409
+ }, [initialQuery]);
3240
3410
  const [highlight, setHighlight] = useState(0);
3241
- const debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
3411
+ const [composerLines, setComposerLines] = useState(1);
3412
+ const isSearchInput = input.startsWith(SEARCH_PREFIX);
3413
+ const debouncedQuery = useDebouncedValue((isSearchInput ? input.slice(1).slice(0, SEARCH_QUERY_MAX_LENGTH) : "").trim(), SEARCH_DEBOUNCE_MS);
3242
3414
  const isSearching = debouncedQuery.length > 0;
3243
3415
  const [search, setSearch] = useState(null);
3244
3416
  useEffect(() => {
@@ -3307,19 +3479,15 @@ function ConversationList({ agent, initialQuery, conversations, complete, showAu
3307
3479
  useEffect(() => {
3308
3480
  setHighlight(0);
3309
3481
  }, [showAutomated, showArchived]);
3310
- const hits = isSearching ? list.map((item) => ({
3482
+ const rows = list.map((item) => ({
3311
3483
  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) => ({
3484
+ highlights: [isSearching ? substringIndexes(singleLine(item.title ?? ""), debouncedQuery) : null]
3485
+ })).map((hit) => ({
3319
3486
  kind: "conv",
3320
3487
  hit
3321
- }))];
3322
- const innerWidth = Math.max(1, width - 2);
3488
+ }));
3489
+ const contentWidth = Math.min(76, Math.max(1, width - 2));
3490
+ const innerWidth = contentWidth;
3323
3491
  const rowWidth = Math.max(1, innerWidth - 2);
3324
3492
  const clamped = Math.min(highlight, Math.max(0, rows.length - 1));
3325
3493
  const highlightRow = rows[clamped];
@@ -3327,7 +3495,9 @@ function ConversationList({ agent, initialQuery, conversations, complete, showAu
3327
3495
  const highlightAgentRun = highlightRow?.kind === "conv" && isAgentRunConversation(highlightRow.hit.item);
3328
3496
  const confirmConv = confirmId !== null ? list.find((c) => c.id === confirmId) : void 0;
3329
3497
  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…)"}`;
3498
+ const searchStatusSegment = !isSearching ? "" : searchState?.kind === "loading" || search === null ? "searching…" : searchState?.kind === "error" ? `search failed: ${searchState.message}` : list.length === 0 ? "no matches" : "";
3499
+ const trimmedMessage = isSearchInput ? "" : input.trim();
3500
+ const enterHint = isSearchInput ? "filtering" : trimmedMessage ? "↵ send" : "↵ open";
3331
3501
  const status = confirmConv ? {
3332
3502
  fg: theme.error,
3333
3503
  text: truncate(`delete “${confirmConv.title ?? "(untitled)"}”? y/n`, innerWidth)
@@ -3337,39 +3507,61 @@ function ConversationList({ agent, initialQuery, conversations, complete, showAu
3337
3507
  } : {
3338
3508
  fg: theme.dim,
3339
3509
  text: fitHintLine([
3340
- countSegment,
3510
+ searchStatusSegment,
3511
+ enterHint,
3512
+ "/ search",
3341
3513
  ...highlightAgentRun ? [] : [`ctrl+a ${highlightArchived ? "unarchive" : "archive"}`],
3342
3514
  "ctrl+d delete",
3343
3515
  `tab ${showAutomated ? "hide" : "show"} agent runs`,
3344
3516
  `shift+tab ${showArchived ? "hide" : "show"} archived`,
3345
- "esc back",
3346
3517
  "↑/↓ move",
3347
- " open"
3348
- ], innerWidth)
3518
+ "esc back"
3519
+ ].filter((segment) => segment !== ""), innerWidth)
3349
3520
  };
3350
- const visibleRows = Math.max(3, height - 6 - brandHeaderRows(height, width));
3521
+ const identityRows = height >= 24 ? 3 : 0;
3522
+ const fixedRows = 6 + brandHeaderRows(height, width) + identityRows;
3523
+ const composerRows = Math.max(1, Math.min(composerLines, height - fixedRows - 2 - MIN_LIST_ROWS));
3524
+ const visibleRows = Math.max(MIN_LIST_ROWS, height - fixedRows - (composerRows + 2));
3351
3525
  const start = windowStart(clamped, rows.length, visibleRows);
3352
3526
  const windowed = rows.slice(start, start + visibleRows);
3353
- const open = (row) => {
3354
- if (row.kind === "new") onPick({
3527
+ const openHighlighted = () => {
3528
+ const row = rows[clamped];
3529
+ if (row) onPick({
3355
3530
  kind: "chat",
3356
3531
  agent,
3357
3532
  attachRunId: null,
3358
3533
  parentConversationId: null,
3359
- conversation: {
3360
- kind: "new",
3361
- agentId: agent.id,
3362
- nonce: crypto.randomUUID()
3363
- }
3534
+ conversation: row.hit.item
3364
3535
  });
3365
- else onPick({
3536
+ };
3537
+ const sendNewConversation = (message) => {
3538
+ useStore.setState({ seedPrompt: message });
3539
+ onPick({
3366
3540
  kind: "chat",
3367
3541
  agent,
3368
3542
  attachRunId: null,
3369
3543
  parentConversationId: null,
3370
- conversation: row.hit.item
3544
+ conversation: {
3545
+ kind: "new",
3546
+ agentId: agent.id,
3547
+ nonce: crypto.randomUUID()
3548
+ }
3371
3549
  });
3372
3550
  };
3551
+ const handleComposerChange = () => {
3552
+ const composer = composerRef.current;
3553
+ if (!composer) return;
3554
+ const raw = composer.plainText;
3555
+ const capped = raw.startsWith(SEARCH_PREFIX) ? raw.slice(0, 1 + SEARCH_QUERY_MAX_LENGTH) : raw;
3556
+ if (capped !== raw) composer.setText(capped);
3557
+ setInput(capped);
3558
+ setComposerLines(Math.max(1, composer.editorView.getTotalVirtualLineCount()));
3559
+ setHighlight(0);
3560
+ };
3561
+ const submit = () => {
3562
+ if (trimmedMessage) sendNewConversation(trimmedMessage);
3563
+ else openHighlighted();
3564
+ };
3373
3565
  /**
3374
3566
  * Optimistically drop a row, run `mutate`, and put the row back if it fails.
3375
3567
  * Delete only — archive keeps the row and flips its mark instead.
@@ -3422,17 +3614,34 @@ function ConversationList({ agent, initialQuery, conversations, complete, showAu
3422
3614
  else if (key.name === "n" || key.name === "escape") setConfirmId(null);
3423
3615
  return;
3424
3616
  }
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) {
3617
+ if (key.name === "escape") {
3618
+ if (input) {
3619
+ composerRef.current?.setText("");
3620
+ setInput("");
3621
+ setComposerLines(1);
3622
+ setHighlight(0);
3623
+ } else onPick({ kind: "welcome" });
3624
+ return;
3625
+ }
3626
+ if (key.name === "up") {
3627
+ setHighlight(Math.max(0, clamped - 1));
3628
+ key.preventDefault();
3629
+ } else if (key.name === "down") {
3630
+ setHighlight(Math.min(Math.max(0, rows.length - 1), clamped + 1));
3631
+ key.preventDefault();
3632
+ } else if (key.name === "pageup" || key.name === "pgup") {
3633
+ setHighlight(Math.max(0, clamped - Math.max(1, visibleRows - 1)));
3634
+ key.preventDefault();
3635
+ } else if (key.name === "pagedown" || key.name === "pgdn") {
3636
+ setHighlight(Math.min(Math.max(0, rows.length - 1), clamped + Math.max(1, visibleRows - 1)));
3637
+ key.preventDefault();
3638
+ } else if (key.name === "d" && key.ctrl) {
3431
3639
  const row = rows[clamped];
3432
3640
  if (row?.kind === "conv") {
3433
3641
  setError(null);
3434
3642
  setConfirmId(row.hit.item.id);
3435
3643
  }
3644
+ key.preventDefault();
3436
3645
  } else if (key.name === "a" && key.ctrl) {
3437
3646
  const row = rows[clamped];
3438
3647
  if (row?.kind === "conv") {
@@ -3443,94 +3652,169 @@ function ConversationList({ agent, initialQuery, conversations, complete, showAu
3443
3652
  setError(null);
3444
3653
  toggleArchived(row.hit.item);
3445
3654
  }
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);
3655
+ key.preventDefault();
3656
+ } else if (key.name === "tab" && key.shift) {
3657
+ onToggleArchived();
3658
+ key.preventDefault();
3659
+ } else if (key.name === "tab") {
3660
+ onToggleAutomated();
3661
+ key.preventDefault();
3451
3662
  }
3452
3663
  });
3664
+ if (!ready) return /* @__PURE__ */ jsx("box", { style: {
3665
+ flexDirection: "column",
3666
+ flexGrow: 1
3667
+ } });
3453
3668
  return /* @__PURE__ */ jsxs("box", {
3454
3669
  style: {
3455
3670
  flexDirection: "column",
3456
- flexGrow: 1
3671
+ flexGrow: 1,
3672
+ alignItems: "center"
3457
3673
  },
3458
3674
  children: [
3459
- /* @__PURE__ */ jsx(BrandHeader, {
3460
- height,
3461
- width
3462
- }),
3675
+ /* @__PURE__ */ jsx("box", { style: {
3676
+ flexGrow: 1,
3677
+ flexShrink: 1
3678
+ } }),
3463
3679
  /* @__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
3680
  style: {
3493
3681
  flexDirection: "column",
3494
- flexGrow: 1
3682
+ width: contentWidth
3495
3683
  },
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
- })
3684
+ children: [
3685
+ /* @__PURE__ */ jsxs("box", {
3686
+ style: {
3687
+ flexDirection: "column",
3688
+ alignItems: "center"
3689
+ },
3690
+ children: [/* @__PURE__ */ jsx(BrandHeader, {
3691
+ height,
3692
+ width
3693
+ }), height >= 24 ? /* @__PURE__ */ jsxs("box", {
3694
+ style: {
3695
+ flexDirection: "column",
3696
+ alignItems: "center"
3697
+ },
3698
+ children: [
3699
+ /* @__PURE__ */ jsx("text", {
3700
+ fg: theme.accent,
3701
+ attributes: 1,
3702
+ children: truncate(`Hey, I'm ${agent.name}`, innerWidth)
3703
+ }),
3704
+ identitySubtitle(agent) ? /* @__PURE__ */ jsx("text", {
3705
+ fg: theme.muted,
3706
+ children: truncate(identitySubtitle(agent), innerWidth)
3707
+ }) : null,
3708
+ /* @__PURE__ */ jsx("box", { style: { height: 1 } })
3709
+ ]
3710
+ }) : null]
3711
+ }),
3712
+ /* @__PURE__ */ jsx("box", {
3713
+ style: {
3714
+ border: true,
3715
+ borderColor: theme.faint,
3716
+ paddingLeft: 1,
3717
+ paddingRight: 1,
3718
+ height: composerRows + 2,
3719
+ flexShrink: 0,
3720
+ flexDirection: "column"
3721
+ },
3722
+ children: /* @__PURE__ */ jsx("textarea", {
3723
+ ref: composerRef,
3724
+ textColor: theme.fg,
3725
+ focusedTextColor: theme.fg,
3726
+ cursorColor: theme.cursor,
3727
+ placeholderColor: theme.dim,
3728
+ placeholder: `Ask ${agent.name} anything…`,
3729
+ keyBindings: composerKeyBindings$1,
3730
+ onContentChange: handleComposerChange,
3731
+ onSubmit: submit,
3732
+ focused: confirmId === null,
3733
+ style: {
3734
+ height: composerRows,
3735
+ wrapMode: "word"
3736
+ }
3737
+ })
3738
+ }),
3739
+ /* @__PURE__ */ jsx("text", {
3740
+ fg: status.fg,
3741
+ children: status.text
3742
+ }),
3743
+ /* @__PURE__ */ jsx("box", {
3744
+ style: {
3745
+ flexDirection: "column",
3746
+ flexShrink: 0
3747
+ },
3748
+ children: rows.length === 0 ? /* @__PURE__ */ jsx("text", {
3749
+ fg: theme.dim,
3750
+ children: isSearching ? "no conversations match." : `no conversations yet — type a message above to start one with ${agent.name}.`
3751
+ }) : windowed.map((row, i) => {
3752
+ const selected = start + i === clamped;
3753
+ const fg = selected ? theme.accent : theme.fg;
3754
+ const marker = selected ? "› " : " ";
3755
+ const archived = isArchived(row.hit.item);
3756
+ const { title, time } = layoutRow(`${archived ? "archived · " : ""}${singleLine(row.hit.item.title ?? "(untitled)")}`, relativeTime(row.hit.item.updatedAt), coAgentsTag(row.hit.item, agent.id), rowWidth);
3757
+ const glyph = channelGlyph(row.hit.item.channel);
3758
+ const tag = coAgentsTag(row.hit.item, agent.id);
3759
+ const unread = row.hit.item.unread === true;
3760
+ const titleText = /* @__PURE__ */ jsx(FuzzyText, {
3761
+ text: title,
3762
+ indexes: row.hit.item.title ? row.hit.highlights[0] : null
3763
+ });
3764
+ return /* @__PURE__ */ jsxs("text", {
3765
+ fg: archived && !selected ? theme.dim : fg,
3766
+ children: [
3767
+ !selected && unread ? /* @__PURE__ */ jsx("span", {
3768
+ fg: theme.accent,
3769
+ children: "● "
3770
+ }) : marker,
3771
+ /* @__PURE__ */ jsxs("span", {
3772
+ fg: selected ? theme.accent : theme.muted,
3773
+ children: [glyph, " "]
3774
+ }),
3775
+ unread ? /* @__PURE__ */ jsx("b", { children: titleText }) : titleText,
3776
+ tag ? /* @__PURE__ */ jsxs("span", {
3777
+ fg: theme.muted,
3778
+ children: [" ", tag]
3779
+ }) : null,
3780
+ /* @__PURE__ */ jsx("span", {
3781
+ fg: theme.dim,
3782
+ children: time
3783
+ })
3784
+ ]
3785
+ }, row.hit.item.id);
3786
+ })
3787
+ })
3788
+ ]
3789
+ }),
3790
+ /* @__PURE__ */ jsx("box", { style: {
3791
+ flexGrow: 1,
3792
+ flexShrink: 1
3793
+ } })
3531
3794
  ]
3532
3795
  });
3533
3796
  }
3797
+ const MIN_LIST_ROWS = 3;
3798
+ const composerKeyBindings$1 = [
3799
+ {
3800
+ name: "return",
3801
+ action: "submit"
3802
+ },
3803
+ {
3804
+ name: "kpenter",
3805
+ action: "submit"
3806
+ },
3807
+ {
3808
+ name: "return",
3809
+ shift: true,
3810
+ action: "newline"
3811
+ },
3812
+ {
3813
+ name: "kpenter",
3814
+ shift: true,
3815
+ action: "newline"
3816
+ }
3817
+ ];
3534
3818
  /**
3535
3819
  * Tags a row with the thread's other agents (`+Grace, Ada`). The picker is
3536
3820
  * entered per agent, so that agent stays implicit and only its co-participants
@@ -3547,20 +3831,6 @@ function coAgentsTag(conv, viewerAgentId) {
3547
3831
  function conversationAgents(c) {
3548
3832
  return c.agents && c.agents.length > 0 ? c.agents : [c.agent];
3549
3833
  }
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
3834
  /**
3565
3835
  * Character indexes of the first case-insensitive occurrence of `needle` in
3566
3836
  * `haystack`, for highlighting a literal title match under server search (the
@@ -3575,11 +3845,63 @@ function substringIndexes(haystack, needle) {
3575
3845
  if (at < 0) return null;
3576
3846
  return Array.from({ length: trimmed.length }, (_, i) => at + i);
3577
3847
  }
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;
3848
+ /**
3849
+ * The subtitle under the “Hey, I'm <Agent>” greeting, mirroring the web home's
3850
+ * “<title> · <owner>'s agent” line. Either half may be missing (an older
3851
+ * server omits `title`/`creatorName`); we join whatever is present and return
3852
+ * an empty string when there's nothing to show, so the caller drops the row.
3853
+ */
3854
+ function identitySubtitle(agent) {
3855
+ const parts = [];
3856
+ if (agent.title) parts.push(agent.title);
3857
+ if (agent.creatorName) parts.push(`${agent.creatorName}'s agent`);
3858
+ return parts.join(" · ");
3859
+ }
3860
+ /**
3861
+ * A single glyph marking a conversation's channel, echoing the web home's
3862
+ * leading channel icon. Web threads (the common case) get a globe; the other
3863
+ * human channels and agent-run channels get a recognizable mark, with a
3864
+ * neutral dot as the fallback so an unknown/again-renamed channel still lines
3865
+ * up. Kept to one display column so the row layout budget stays exact.
3866
+ */
3867
+ function channelGlyph(channel) {
3868
+ switch (channel) {
3869
+ case "web":
3870
+ case null: return "◉";
3871
+ case "slack": return "▰";
3872
+ case "email": return "✉";
3873
+ case "imessage":
3874
+ case "sms": return "○";
3875
+ case "cron": return "⏲";
3876
+ default: return "·";
3877
+ }
3878
+ }
3879
+ /**
3880
+ * Lay a row out web-home style: the title on the left, the relative time
3881
+ * flush-right, and an optional co-agent tag between them. The glyph (2 cols
3882
+ * incl. its trailing space) and marker (2 cols) are accounted for by the
3883
+ * caller via `width` (already `rowWidth`), so here we reserve the time (plus a
3884
+ * gap), the tag (plus a gap) and the glyph slot, then truncate the title into
3885
+ * what's left. The returned `time` carries its own leading padding so it sits
3886
+ * flush against the right edge; when the row is too narrow to fit the title
3887
+ * AND the time, the time is dropped rather than wrapping the row.
3888
+ */
3889
+ function layoutRow(title, time, tag, width) {
3890
+ const GLYPH = 2;
3891
+ const tagCost = tag ? tag.length + 1 : 0;
3892
+ const timeCost = time.length + 1;
3893
+ const titleBudget = width - GLYPH - tagCost - timeCost;
3894
+ if (titleBudget < 8) return {
3895
+ title: truncate(title, Math.max(1, width - GLYPH - tagCost)),
3896
+ time: ""
3897
+ };
3898
+ const fitTitle = truncate(title, titleBudget);
3899
+ const used = GLYPH + fitTitle.length + tagCost;
3900
+ const pad = Math.max(1, width - used - time.length);
3901
+ return {
3902
+ title: fitTitle,
3903
+ time: `${" ".repeat(pad)}${time}`
3904
+ };
3583
3905
  }
3584
3906
  function relativeTime(iso) {
3585
3907
  const diffMs = Date.now() - new Date(iso).getTime();
@@ -7393,7 +7715,6 @@ function SubagentCardView({ items, isActive, onHeightChange }) {
7393
7715
  style: {
7394
7716
  flexDirection: "column",
7395
7717
  flexShrink: 0,
7396
- height: height > 0 ? void 0 : 0,
7397
7718
  marginTop: height > 0 ? 1 : 0,
7398
7719
  paddingLeft: height > 0 ? 1 : 0,
7399
7720
  paddingRight: height > 0 ? 1 : 0,
@@ -13163,6 +13484,7 @@ function isActionableCard(m) {
13163
13484
  function ChatScreen({ agent, conversation, attachRunId, parentConversationId }) {
13164
13485
  const renderer = useRenderer();
13165
13486
  const { width, height } = useTerminalDimensions();
13487
+ const { ready: terminalReady } = useTerminalReady();
13166
13488
  const rest = useStore((s) => s.rest);
13167
13489
  const history = usePromptHistory({ filePath: useStore((s) => s.promptHistoryPath) });
13168
13490
  const appUrl = useStore((s) => s.appUrl);
@@ -14590,6 +14912,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14590
14912
  agentSelector: null,
14591
14913
  conversationId: target,
14592
14914
  newConversation: false,
14915
+ seedPrompt: null,
14593
14916
  defaultAgentSelector: null,
14594
14917
  forceOnboarding: false
14595
14918
  });
@@ -14630,6 +14953,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14630
14953
  agentSelector: null,
14631
14954
  conversationId: target,
14632
14955
  newConversation: false,
14956
+ seedPrompt: null,
14633
14957
  defaultAgentSelector: null,
14634
14958
  forceOnboarding: false
14635
14959
  }));
@@ -14777,15 +15101,9 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14777
15101
  break;
14778
15102
  }
14779
15103
  goTo({
14780
- kind: "chat",
15104
+ kind: "conversation-picker",
14781
15105
  agent,
14782
- attachRunId: null,
14783
- parentConversationId: null,
14784
- conversation: {
14785
- kind: "new",
14786
- agentId: agent.id,
14787
- nonce: crypto.randomUUID()
14788
- }
15106
+ initialQuery: ""
14789
15107
  });
14790
15108
  break;
14791
15109
  case "fork":
@@ -15289,6 +15607,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15289
15607
  agentSelector: null,
15290
15608
  conversationId: parentConversationId,
15291
15609
  newConversation: false,
15610
+ seedPrompt: null,
15292
15611
  defaultAgentSelector: null,
15293
15612
  forceOnboarding: false
15294
15613
  }).then((parentScreen) => goTo(parentScreen)).catch(() => {
@@ -15567,7 +15886,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15567
15886
  children: [!historyLoaded ? /* @__PURE__ */ jsx("text", {
15568
15887
  fg: theme.muted,
15569
15888
  children: "loading history…"
15570
- }) : items.length === 0 ? /* @__PURE__ */ jsx(NewChatSplash, {
15889
+ }) : items.length === 0 && !terminalReady ? /* @__PURE__ */ jsx("box", { style: { height: Math.max(1, scrollHeight - 1) } }) : items.length === 0 ? /* @__PURE__ */ jsx(NewChatSplash, {
15571
15890
  agentName: agent.name,
15572
15891
  agentTitle: agent.title ?? null,
15573
15892
  width: reviewView && reviewPlacement === "side" ? Math.max(1, bodyWidth - reviewWidth - 1) : bodyWidth,
@@ -16216,7 +16535,8 @@ function App({ appUrl, sessionToken, authKind, workspaceId, shareMachine, prompt
16216
16535
  rest,
16217
16536
  agentSelector,
16218
16537
  conversationId,
16219
- newConversation: newConversation || useStore.getState().seedPrompt !== null,
16538
+ newConversation,
16539
+ seedPrompt: useStore.getState().seedPrompt,
16220
16540
  defaultAgentSelector,
16221
16541
  forceOnboarding
16222
16542
  });
@@ -16282,16 +16602,14 @@ function App({ appUrl, sessionToken, authKind, workspaceId, shareMachine, prompt
16282
16602
  children: [
16283
16603
  screen.kind === "splash" && /* @__PURE__ */ jsx(Splash, {}),
16284
16604
  screen.kind === "error" && /* @__PURE__ */ jsx(ErrorScreen, { message: screen.message }),
16285
- screen.kind === "agent-picker" && rest && /* @__PURE__ */ jsx(AgentPickerScreen, {}),
16286
- screen.kind === "agent-create" && rest && /* @__PURE__ */ jsx(AgentCreateScreen, {}),
16287
- screen.kind === "first-run" && rest && /* @__PURE__ */ jsx(FirstRunScreen, {}),
16605
+ screen.kind === "welcome" && rest && /* @__PURE__ */ jsx(WelcomeScreen, {}),
16288
16606
  screen.kind === "workspace-picker" && /* @__PURE__ */ jsx(WorkspacePicker, {
16289
16607
  appUrl,
16290
16608
  sessionToken,
16291
- onCancel: () => goTo({ kind: "agent-picker" }),
16609
+ onCancel: () => goTo({ kind: "welcome" }),
16292
16610
  onSelect: (workspace) => {
16293
16611
  setWorkspaceName(workspace.name);
16294
- goTo({ kind: "agent-picker" });
16612
+ goTo({ kind: "welcome" });
16295
16613
  }
16296
16614
  }),
16297
16615
  screen.kind === "conversation-picker" && rest && /* @__PURE__ */ jsx(ConversationPickerScreen, {
@@ -16842,7 +17160,6 @@ async function runChat({ appUrl, sessionToken, authKind = "session", workspaceId
16842
17160
  authKind,
16843
17161
  seedPrompt: seedPrompt ?? null,
16844
17162
  autoGrantMachine: Boolean(seedPrompt && shareMachine),
16845
- newConversationIntent: newConversation,
16846
17163
  forcedOnboarding: forceOnboarding
16847
17164
  });
16848
17165
  const store = useStore.getState();