skydive-cli 0.5.0-beta.33 → 0.5.0-beta.36

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 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-BPUAm8bS.mjs";
3
- import { B as resolveWebUrl, C as getConfigPath, O as getReviewStateDir, P as recordDefaultAgent, U as saveTheme, b as DEFAULT_APP_URL, c as cardActionErrorMessage, d as reconcileMaskedInput, f as resolveConnectUrl, h as specKeyFor, i as resolveAgent, k as getSavedTheme, l as parseExternalOauthConnectParams, m as parseConnectCard, p as computeSettledLabel, s as MASK_CHAR, u as parseOauthConnectParams, y as DEFAULT_API_URL } from "./print-Clgq46GU.mjs";
2
+ 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-aZ751iYf.mjs";
3
+ import { B as resolveWebUrl, C as getConfigPath, O as getReviewStateDir, P as recordDefaultAgent, U as saveTheme, b as DEFAULT_APP_URL, c as cardActionErrorMessage, d as reconcileMaskedInput, f as resolveConnectUrl, h as specKeyFor, i as resolveAgent, k as getSavedTheme, l as parseExternalOauthConnectParams, m as parseConnectCard, p as computeSettledLabel, s as MASK_CHAR, u as parseOauthConnectParams, y as DEFAULT_API_URL } from "./print-h22UbIfZ.mjs";
4
4
  import { t as HttpError } from "./http-error-UHH3mVBF.mjs";
5
- import { a as isRecord, i as errorMessage, n as errorDetail, r as sendErrorMessage, t as createRestClient } from "./rest-C6E4mS2A.mjs";
5
+ import { a as isRecord, i as errorMessage, n as errorDetail, r as sendErrorMessage, t as createRestClient } from "./rest-ClVPZquN.mjs";
6
6
  import { i as billingBlockedOutcomeFromSendResponse } from "./billing-blocked-D3l5kJlX.mjs";
7
7
  import { t as PortalClient } from "./client-d6K9qm6B.mjs";
8
- import "./daemon-BPGZufS1.mjs";
8
+ import "./daemon-CH95gDij.mjs";
9
9
  import { t as defaultTlsCertSource } from "./tls-cert-CV-pwxVN.mjs";
10
10
  import "./api-DQCaztBg.mjs";
11
- import { t as SandboxStream } from "./client-BTQ1fwzM.mjs";
12
- import { t as PortalDaemonClient } from "./daemon-client-MpAGVkqP.mjs";
13
- import { t as runRawPtyPassthrough } from "./raw-pty-B9Bue8gg.mjs";
11
+ import { t as SandboxStream } from "./client-Btq6bMzX.mjs";
12
+ import { t as PortalDaemonClient } from "./daemon-client-BNDnEGw6.mjs";
13
+ import { t as runRawPtyPassthrough } from "./raw-pty-D5PhKZSl.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, win32 } from "node:path";
@@ -21,13 +21,13 @@ import { execFile, spawn } from "node:child_process";
21
21
  import { createHash } from "node:crypto";
22
22
  import { connect, createConnection } from "node:net";
23
23
  import { access, appendFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
24
+ import { fileURLToPath } from "node:url";
24
25
  import { MarkdownRenderable, RenderableEvents, StyledText, SyntaxStyle, addDefaultParsers, createCliRenderer, decodePasteBytes, detectLinks, fg, link } from "@opentui/core";
25
26
  import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
26
27
  import { Profiler, createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
27
28
  import { create } from "zustand";
28
29
  import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
29
30
  import fuzzysort from "fuzzysort";
30
- import { fileURLToPath } from "node:url";
31
31
  import { fileTypeFromBuffer } from "file-type";
32
32
  import { structuredPatch } from "diff";
33
33
 
@@ -269,6 +269,7 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
269
269
  return {
270
270
  kind: "chat",
271
271
  agent,
272
+ attachRunId: null,
272
273
  conversation: {
273
274
  id: conversation.id,
274
275
  title: conversation.title,
@@ -313,6 +314,7 @@ async function resolveInitialScreen({ rest, agentSelector, conversationId, newCo
313
314
  if (newConversation) return {
314
315
  kind: "chat",
315
316
  agent,
317
+ attachRunId: null,
316
318
  conversation: {
317
319
  kind: "new",
318
320
  agentId: agent.id,
@@ -350,6 +352,7 @@ function newChat(agent) {
350
352
  return {
351
353
  kind: "chat",
352
354
  agent,
355
+ attachRunId: null,
353
356
  conversation: {
354
357
  kind: "new",
355
358
  agentId: agent.id,
@@ -2005,6 +2008,7 @@ function FilterableAgentList({ agents, scope, onPick }) {
2005
2008
  onPick(store.seedPrompt !== null || store.newConversationIntent ? {
2006
2009
  kind: "chat",
2007
2010
  agent,
2011
+ attachRunId: null,
2008
2012
  conversation: {
2009
2013
  kind: "new",
2010
2014
  agentId: agent.id,
@@ -2152,6 +2156,7 @@ function AgentCreateScreen() {
2152
2156
  goTo({
2153
2157
  kind: "chat",
2154
2158
  agent,
2159
+ attachRunId: null,
2155
2160
  conversation: {
2156
2161
  kind: "new",
2157
2162
  agentId: agent.id,
@@ -2415,6 +2420,7 @@ function FirstRunScreen() {
2415
2420
  goTo({
2416
2421
  kind: "chat",
2417
2422
  agent,
2423
+ attachRunId: null,
2418
2424
  conversation: {
2419
2425
  kind: "new",
2420
2426
  agentId: agent.id,
@@ -2422,11 +2428,12 @@ function FirstRunScreen() {
2422
2428
  }
2423
2429
  });
2424
2430
  };
2425
- const openOnboardingChat = (agent, conversationId, title) => {
2431
+ const openOnboardingChat = (agent, conversationId, title, attachRunId) => {
2426
2432
  const now = (/* @__PURE__ */ new Date()).toISOString();
2427
2433
  goTo({
2428
2434
  kind: "chat",
2429
2435
  agent,
2436
+ attachRunId,
2430
2437
  conversation: {
2431
2438
  id: conversationId,
2432
2439
  title,
@@ -2450,7 +2457,7 @@ function FirstRunScreen() {
2450
2457
  const { portalClient } = useStore.getState();
2451
2458
  try {
2452
2459
  const os = machineOsFromPlatform(process.platform);
2453
- const { conversationId } = await rest.createOnboardingConversation({
2460
+ const { conversationId, runId } = await rest.createOnboardingConversation({
2454
2461
  agentId: agent.id,
2455
2462
  projectDir: process.cwd(),
2456
2463
  os
@@ -2464,7 +2471,7 @@ function FirstRunScreen() {
2464
2471
  });
2465
2472
  });
2466
2473
  }
2467
- openOnboardingChat(agent, conversationId, `Onboarding ${agent.name}`);
2474
+ openOnboardingChat(agent, conversationId, `Onboarding ${agent.name}`, runId);
2468
2475
  } catch (err) {
2469
2476
  if (err instanceof HttpError && err.status === 401) {
2470
2477
  fail("Session expired. Run `skydive auth login --web`, then retry.");
@@ -3215,6 +3222,7 @@ function ConversationList({ agent, initialQuery, conversations, complete, showAu
3215
3222
  if (row.kind === "new") onPick({
3216
3223
  kind: "chat",
3217
3224
  agent,
3225
+ attachRunId: null,
3218
3226
  conversation: {
3219
3227
  kind: "new",
3220
3228
  agentId: agent.id,
@@ -3224,6 +3232,7 @@ function ConversationList({ agent, initialQuery, conversations, complete, showAu
3224
3232
  else onPick({
3225
3233
  kind: "chat",
3226
3234
  agent,
3235
+ attachRunId: null,
3227
3236
  conversation: row.hit.item
3228
3237
  });
3229
3238
  };
@@ -4900,8 +4909,6 @@ function toGauges(stats) {
4900
4909
  function renderBar(fraction, width) {
4901
4910
  const cells = Math.max(1, width);
4902
4911
  const exact = Math.min(1, Math.max(0, fraction)) * cells;
4903
- const full = Math.floor(exact);
4904
- const remainder = exact - full;
4905
4912
  const eighths = [
4906
4913
  "",
4907
4914
  "▏",
@@ -4912,7 +4919,12 @@ function renderBar(fraction, width) {
4912
4919
  "▊",
4913
4920
  "▉"
4914
4921
  ];
4915
- const partialIndex = Math.round(remainder * 8);
4922
+ let full = Math.floor(exact);
4923
+ let partialIndex = Math.round((exact - full) * 8);
4924
+ if (partialIndex === 8) {
4925
+ full += 1;
4926
+ partialIndex = 0;
4927
+ }
4916
4928
  const partial = full < cells ? eighths[partialIndex] : "";
4917
4929
  const filled = "█".repeat(Math.min(full, cells)) + partial;
4918
4930
  return (filled + "░".repeat(Math.max(0, cells - filled.length))).slice(0, cells);
@@ -6637,6 +6649,438 @@ function SubagentCardView({ items, isActive, onHeightChange, onBatchLiveChange }
6637
6649
  });
6638
6650
  }
6639
6651
 
6652
+ //#endregion
6653
+ //#region src/chat/tui/chat/bg-tasks.ts
6654
+ const FINISHED_GRACE_MS = 3e4;
6655
+ const MAX_RETAINED_FINISHED = 10;
6656
+ const bgTaskSchema = z.object({
6657
+ id: z.string(),
6658
+ command: z.string(),
6659
+ description: z.string().nullable(),
6660
+ startedAt: z.number(),
6661
+ state: z.enum(["running", "finished"]),
6662
+ exitCode: z.number().nullable(),
6663
+ killedReason: z.string().nullable(),
6664
+ outputTail: z.string()
6665
+ });
6666
+ const bgTasksPayloadSchema = z.object({ tasks: z.array(bgTaskSchema) });
6667
+ /**
6668
+ * The tasks carried by a `data-anyone-bg-tasks-updated` chunk/part `data`
6669
+ * payload, or null when the payload isn't the documented shape. The live stream
6670
+ * (chat.tsx) is the only producer today; history never carries these (bg tasks
6671
+ * are ephemeral live state, not persisted like todos).
6672
+ */
6673
+ function bgTasksFromPayload(data) {
6674
+ const parsed = bgTasksPayloadSchema.safeParse(data);
6675
+ return parsed.success ? parsed.data.tasks : null;
6676
+ }
6677
+ /** The running subset — what the pinned pill counts as live shells. */
6678
+ function runningTasks(tasks) {
6679
+ return tasks.filter((t) => t.state === "running");
6680
+ }
6681
+ /** Retained-finished subset: finished tasks still inside the grace window. */
6682
+ function finishedTasks(tasks) {
6683
+ return tasks.filter((t) => t.state === "finished");
6684
+ }
6685
+ /**
6686
+ * Merge a fresh server snapshot into the client's current task list, keeping
6687
+ * finished shells around for a short grace window so one that exits while you
6688
+ * glanced away isn't gone instantly (Claude Code's PANEL_GRACE_MS behavior).
6689
+ *
6690
+ * The harness sends only live/just-changed tasks and drops a finished task from
6691
+ * its snapshot soon after; so we can't mirror the snapshot 1:1 or finished
6692
+ * shells vanish immediately. Instead:
6693
+ * - every task in the snapshot is taken as-is (running tasks, and the one
6694
+ * finished frame the harness emits on completion);
6695
+ * - a task the client had that is now ABSENT from the snapshot, and was
6696
+ * running, is carried over as finished (the harness dropped it before we saw
6697
+ * a finished frame — treat disappearance as completion);
6698
+ * - each finished task is stamped `finishedAt` once (first time we see it
6699
+ * finished), and evicted `FINISHED_GRACE_MS` after that;
6700
+ * - finished tasks are capped to the `MAX_RETAINED_FINISHED` most recent.
6701
+ *
6702
+ * Pure and deterministic given `now`, so it unit-tests without timers.
6703
+ */
6704
+ function mergeBgTasks(prev, snapshot, now) {
6705
+ const snapshotIds = new Set(snapshot.map((t) => t.id));
6706
+ const prevById = new Map(prev.map((t) => [t.id, t]));
6707
+ const stampFinished = (t) => {
6708
+ if (t.state !== "finished") return {
6709
+ ...t,
6710
+ finishedAt: void 0
6711
+ };
6712
+ const existing = prevById.get(t.id)?.finishedAt;
6713
+ return {
6714
+ ...t,
6715
+ finishedAt: existing ?? now
6716
+ };
6717
+ };
6718
+ const merged = snapshot.map(stampFinished);
6719
+ for (const old of prev) {
6720
+ if (snapshotIds.has(old.id)) continue;
6721
+ if (old.state === "finished") merged.push({
6722
+ ...old,
6723
+ finishedAt: old.finishedAt ?? now
6724
+ });
6725
+ else merged.push({
6726
+ ...old,
6727
+ state: "finished",
6728
+ finishedAt: now
6729
+ });
6730
+ }
6731
+ const running = merged.filter((t) => t.state === "running");
6732
+ const finished = merged.filter((t) => t.state === "finished").filter((t) => now - (t.finishedAt ?? now) < FINISHED_GRACE_MS).sort((a, b) => (b.finishedAt ?? 0) - (a.finishedAt ?? 0)).slice(0, MAX_RETAINED_FINISHED);
6733
+ return [...running, ...finished];
6734
+ }
6735
+ /**
6736
+ * Whether the client should keep re-rendering to age out finished shells. True
6737
+ * while any finished task is retained, so the chat screen can run a light timer
6738
+ * that re-merges an empty snapshot to evict them on schedule.
6739
+ */
6740
+ function hasRetainedFinished(tasks) {
6741
+ return tasks.some((t) => t.state === "finished");
6742
+ }
6743
+ /**
6744
+ * A task's human label: the agent's description, else the command (the pill and
6745
+ * list rows show this). Never a bare id.
6746
+ */
6747
+ function taskLabel(task) {
6748
+ const raw = task.description?.trim() || task.command.trim();
6749
+ return raw.length > 0 ? raw : task.id;
6750
+ }
6751
+ /**
6752
+ * Elapsed runtime as a compact `1h2m`, `3m40s`, `12s` string, computed from
6753
+ * `startedAt` and a caller-supplied `now` (so the overlay can tick it locally
6754
+ * without any server round-trip). Clamped at zero for a startedAt in the future
6755
+ * (clock skew).
6756
+ */
6757
+ function formatRuntime(startedAt, now) {
6758
+ const totalSec = Math.max(0, Math.floor((now - startedAt) / 1e3));
6759
+ const h = Math.floor(totalSec / 3600);
6760
+ const m = Math.floor(totalSec % 3600 / 60);
6761
+ const s = totalSec % 60;
6762
+ if (h > 0) return `${h}h${m}m`;
6763
+ if (m > 0) return `${m}m${s}s`;
6764
+ return `${s}s`;
6765
+ }
6766
+ /**
6767
+ * A finished task's terminal state as a short phrase for the list/detail:
6768
+ * `exit 0`, `exit 1`, `killed`, or `killed: <reason>`. A running task returns
6769
+ * `running`.
6770
+ */
6771
+ function stateLabel(task) {
6772
+ if (task.state === "running") return "running";
6773
+ if (task.killedReason) return `killed: ${task.killedReason}`;
6774
+ if (task.exitCode !== null) return `exit ${task.exitCode}`;
6775
+ return "ended";
6776
+ }
6777
+ /** True when a finished task ended non-zero or was killed — rendered in error. */
6778
+ function isFailure(task) {
6779
+ return task.state === "finished" && (task.killedReason !== null || task.exitCode !== null && task.exitCode !== 0);
6780
+ }
6781
+ /**
6782
+ * The pinned indicator's summary line. Counts running shells and, separately,
6783
+ * finished shells still inside the grace window: `2 shells`, `2 shells · 1
6784
+ * done`, or `1 done` when only retained-finished remain. Returns null when
6785
+ * nothing is running and nothing is retained, so the indicator renders no
6786
+ * chrome.
6787
+ */
6788
+ function pillSummary(tasks) {
6789
+ const running = runningTasks(tasks).length;
6790
+ const done = finishedTasks(tasks).length;
6791
+ const parts = [];
6792
+ if (running > 0) parts.push(`${running} shell${running === 1 ? "" : "s"}`);
6793
+ if (done > 0) parts.push(`${done} done`);
6794
+ return parts.length > 0 ? parts.join(" · ") : null;
6795
+ }
6796
+
6797
+ //#endregion
6798
+ //#region src/chat/tui/chat/bg-tasks-chip.tsx
6799
+ /**
6800
+ * The background-shells indicator on the composer's bottom status row, placed
6801
+ * just before the PR chip (`· 2 shells · PR #123`) — the TUI analog of Claude
6802
+ * Code's shell count. Shows only while a shell is running; `ctrl+b` opens the
6803
+ * overlay (the binding lives in the chat screen). Renders nothing when nothing
6804
+ * is running, so an idle conversation shows no chrome for it.
6805
+ *
6806
+ * A plain `<text>` on the shared status row (not a pinned box above the
6807
+ * composer), matching PrLinksView — the `· ` dot separates it from the status
6808
+ * text to its left, and the PR chip's own leading `· ` separates it on the
6809
+ * right.
6810
+ */
6811
+ function BgShellsChip({ tasks, onOpen }) {
6812
+ const summary = pillSummary(tasks);
6813
+ if (!summary) return null;
6814
+ return /* @__PURE__ */ jsxs("box", {
6815
+ style: { flexDirection: "row" },
6816
+ onMouseDown: onOpen,
6817
+ children: [/* @__PURE__ */ jsx("text", {
6818
+ fg: theme.dim,
6819
+ children: "· "
6820
+ }), /* @__PURE__ */ jsxs("text", {
6821
+ fg: theme.accent,
6822
+ children: ["◍ ", summary]
6823
+ })]
6824
+ });
6825
+ }
6826
+
6827
+ //#endregion
6828
+ //#region src/chat/tui/screens/bg-tasks-overlay.tsx
6829
+ /**
6830
+ * The background-tasks overlay — the TUI analog of Claude Code's shells panel.
6831
+ * Rendered in place of the transcript+composer (like the model picker) while
6832
+ * the chat screen stays mounted, so the conversation and any live run survive.
6833
+ *
6834
+ * Two views:
6835
+ * - **list**: `Background` header + `N active shells`, one selectable row per
6836
+ * task (running first). ↑/↓ select, Enter opens detail, Esc closes.
6837
+ * - **detail**: one shell's Status / Runtime / Command / Output.
6838
+ * ← / Esc back to the list (or closes when we opened straight to detail).
6839
+ *
6840
+ * Claude-Code parity: when exactly one shell is running we skip the list and
6841
+ * open that shell's detail directly; ← from there closes the overlay rather
6842
+ * than dropping into a one-row list.
6843
+ *
6844
+ * View-only for now — `x to stop` needs a control channel back to the sandbox
6845
+ * (a follow-up), so we don't advertise or handle it here.
6846
+ */
6847
+ function BgTasksOverlay({ tasks, onClose }) {
6848
+ const ordered = orderTasks(tasks);
6849
+ const running = runningTasks(ordered);
6850
+ const singleRunning = running.length === 1 ? running[0] ?? null : null;
6851
+ const [view, setView] = useState(singleRunning ? {
6852
+ kind: "detail",
6853
+ id: singleRunning.id,
6854
+ openedDirect: true
6855
+ } : { kind: "list" });
6856
+ const [selected, setSelected] = useState(0);
6857
+ const detailTask = view.kind === "detail" ? ordered.find((t) => t.id === view.id) ?? null : null;
6858
+ useEffect(() => {
6859
+ if (view.kind === "detail" && !detailTask) setView({ kind: "list" });
6860
+ }, [view, detailTask]);
6861
+ useEffect(() => {
6862
+ if (ordered.length === 0) onClose();
6863
+ }, [ordered.length, onClose]);
6864
+ if (view.kind === "detail" && detailTask) return /* @__PURE__ */ jsx(BgTaskDetail, {
6865
+ task: detailTask,
6866
+ onBack: () => {
6867
+ if (view.openedDirect) onClose();
6868
+ else setView({ kind: "list" });
6869
+ },
6870
+ onClose
6871
+ });
6872
+ return /* @__PURE__ */ jsx(BgTaskList, {
6873
+ tasks: ordered,
6874
+ runningCount: running.length,
6875
+ selected,
6876
+ onSelectedChange: setSelected,
6877
+ onOpen: (id) => setView({
6878
+ kind: "detail",
6879
+ id,
6880
+ openedDirect: false
6881
+ }),
6882
+ onClose
6883
+ });
6884
+ }
6885
+ /** Running first, then finished; within each group most-recently-started first. */
6886
+ function orderTasks(tasks) {
6887
+ return [...tasks].sort((a, b) => {
6888
+ const ar = a.state === "running" ? 0 : 1;
6889
+ const br = b.state === "running" ? 0 : 1;
6890
+ if (ar !== br) return ar - br;
6891
+ return b.startedAt - a.startedAt;
6892
+ });
6893
+ }
6894
+ function BgTaskList({ tasks, runningCount, selected, onSelectedChange, onOpen, onClose }) {
6895
+ const { height } = useTerminalDimensions();
6896
+ const now = useNow();
6897
+ const clamped = Math.min(selected, Math.max(0, tasks.length - 1));
6898
+ useKeyboard((key) => {
6899
+ if (key.name === "escape") {
6900
+ onClose();
6901
+ return;
6902
+ }
6903
+ if (key.name === "up") onSelectedChange(Math.max(0, clamped - 1));
6904
+ else if (key.name === "down") onSelectedChange(Math.min(Math.max(0, tasks.length - 1), clamped + 1));
6905
+ else if (key.name === "return") {
6906
+ const task = tasks[clamped];
6907
+ if (task) onOpen(task.id);
6908
+ }
6909
+ });
6910
+ const visibleRows = Math.max(3, height - 6);
6911
+ const start = windowStart(clamped, tasks.length, visibleRows);
6912
+ const windowed = tasks.slice(start, start + visibleRows);
6913
+ return /* @__PURE__ */ jsxs("box", {
6914
+ style: {
6915
+ flexDirection: "column",
6916
+ flexGrow: 1
6917
+ },
6918
+ children: [
6919
+ /* @__PURE__ */ jsx("text", {
6920
+ fg: theme.accent,
6921
+ children: /* @__PURE__ */ jsx("b", { children: "Background" })
6922
+ }),
6923
+ /* @__PURE__ */ jsxs("text", {
6924
+ fg: theme.muted,
6925
+ children: [
6926
+ runningCount,
6927
+ " active shell",
6928
+ runningCount === 1 ? "" : "s",
6929
+ tasks.length > runningCount ? ` · ${tasks.length - runningCount} finished` : ""
6930
+ ]
6931
+ }),
6932
+ /* @__PURE__ */ jsx("box", {
6933
+ style: {
6934
+ flexDirection: "column",
6935
+ flexGrow: 1,
6936
+ marginTop: 1
6937
+ },
6938
+ children: windowed.map((task, i) => {
6939
+ const isSel = start + i === clamped;
6940
+ const running = task.state === "running";
6941
+ const stateFg = running ? theme.accent : isFailure(task) ? theme.error : theme.dim;
6942
+ const rt = running ? formatRuntime(task.startedAt, now) : null;
6943
+ return /* @__PURE__ */ jsxs("box", {
6944
+ style: { flexDirection: "row" },
6945
+ children: [
6946
+ /* @__PURE__ */ jsx("text", {
6947
+ fg: isSel ? theme.accent : theme.dim,
6948
+ children: isSel ? "› " : " "
6949
+ }),
6950
+ /* @__PURE__ */ jsx("text", {
6951
+ fg: running ? theme.success : theme.dim,
6952
+ children: "❯ "
6953
+ }),
6954
+ /* @__PURE__ */ jsx("text", {
6955
+ fg: isSel ? theme.fg : theme.muted,
6956
+ children: clampLabel(taskLabel(task))
6957
+ }),
6958
+ /* @__PURE__ */ jsxs("text", {
6959
+ fg: stateFg,
6960
+ children: [
6961
+ " ",
6962
+ "(",
6963
+ running ? `running · ${rt}` : stateLabel(task),
6964
+ ")"
6965
+ ]
6966
+ })
6967
+ ]
6968
+ }, task.id);
6969
+ })
6970
+ }),
6971
+ /* @__PURE__ */ jsx("text", {
6972
+ fg: theme.dim,
6973
+ children: "↑/↓ select · ↵ view · esc close"
6974
+ })
6975
+ ]
6976
+ });
6977
+ }
6978
+ function BgTaskDetail({ task, onBack, onClose }) {
6979
+ const { height } = useTerminalDimensions();
6980
+ const now = useNow();
6981
+ useKeyboard((key) => {
6982
+ if (key.name === "left") {
6983
+ onBack();
6984
+ return;
6985
+ }
6986
+ if (key.name === "escape") onClose();
6987
+ });
6988
+ const running = task.state === "running";
6989
+ const status = running ? "running" : stateLabel(task);
6990
+ const statusFg = running ? theme.accent : isFailure(task) ? theme.error : theme.success;
6991
+ const outputRows = Math.max(3, height - 9);
6992
+ const outputLines = (task.outputTail || "(no output yet)").split("\n").slice(-outputRows);
6993
+ return /* @__PURE__ */ jsxs("box", {
6994
+ style: {
6995
+ flexDirection: "column",
6996
+ flexGrow: 1
6997
+ },
6998
+ children: [
6999
+ /* @__PURE__ */ jsx("text", {
7000
+ fg: theme.accent,
7001
+ children: /* @__PURE__ */ jsx("b", { children: "Shell details" })
7002
+ }),
7003
+ /* @__PURE__ */ jsxs("box", {
7004
+ style: {
7005
+ flexDirection: "column",
7006
+ marginTop: 1
7007
+ },
7008
+ children: [
7009
+ /* @__PURE__ */ jsxs("box", {
7010
+ style: { flexDirection: "row" },
7011
+ children: [/* @__PURE__ */ jsx("text", {
7012
+ fg: theme.muted,
7013
+ children: "Status "
7014
+ }), /* @__PURE__ */ jsx("text", {
7015
+ fg: statusFg,
7016
+ children: status
7017
+ })]
7018
+ }),
7019
+ /* @__PURE__ */ jsxs("box", {
7020
+ style: { flexDirection: "row" },
7021
+ children: [/* @__PURE__ */ jsx("text", {
7022
+ fg: theme.muted,
7023
+ children: "Runtime "
7024
+ }), /* @__PURE__ */ jsx("text", {
7025
+ fg: theme.fg,
7026
+ children: formatRuntime(task.startedAt, now)
7027
+ })]
7028
+ }),
7029
+ /* @__PURE__ */ jsxs("box", {
7030
+ style: { flexDirection: "row" },
7031
+ children: [/* @__PURE__ */ jsx("text", {
7032
+ fg: theme.muted,
7033
+ children: "Command "
7034
+ }), /* @__PURE__ */ jsx("text", {
7035
+ fg: theme.fg,
7036
+ children: clampCommand(task.command)
7037
+ })]
7038
+ })
7039
+ ]
7040
+ }),
7041
+ /* @__PURE__ */ jsx("text", {
7042
+ fg: theme.muted,
7043
+ style: { marginTop: 1 },
7044
+ children: "Output"
7045
+ }),
7046
+ /* @__PURE__ */ jsx("box", {
7047
+ style: {
7048
+ flexDirection: "column",
7049
+ flexGrow: 1
7050
+ },
7051
+ children: outputLines.map((line, i) => /* @__PURE__ */ jsx("text", {
7052
+ fg: theme.dim,
7053
+ children: line.length > 0 ? line : " "
7054
+ }, i))
7055
+ }),
7056
+ /* @__PURE__ */ jsx("text", {
7057
+ fg: theme.dim,
7058
+ children: "← back · esc close"
7059
+ })
7060
+ ]
7061
+ });
7062
+ }
7063
+ /**
7064
+ * A 1Hz clock so the running Runtime ticks while the overlay is open, without
7065
+ * the server re-emitting a snapshot for elapsed time.
7066
+ */
7067
+ function useNow() {
7068
+ const [now, setNow] = useState(() => Date.now());
7069
+ useEffect(() => {
7070
+ const id = setInterval(() => setNow(Date.now()), 1e3);
7071
+ return () => clearInterval(id);
7072
+ }, []);
7073
+ return now;
7074
+ }
7075
+ function clampLabel(text) {
7076
+ const oneLine = text.replace(/\s+/g, " ").trim();
7077
+ return oneLine.length > 60 ? `${oneLine.slice(0, 59)}…` : oneLine;
7078
+ }
7079
+ function clampCommand(text) {
7080
+ const oneLine = text.replace(/\s+/g, " ").trim();
7081
+ return oneLine.length > 100 ? `${oneLine.slice(0, 99)}…` : oneLine;
7082
+ }
7083
+
6640
7084
  //#endregion
6641
7085
  //#region src/chat/tui/chat/pr-links.tsx
6642
7086
  /**
@@ -6669,7 +7113,7 @@ function prLinksStyledText(prs) {
6669
7113
  if (i > 0) chunks.push(label(" "));
6670
7114
  chunks.push(accent(link(pr.url)(`#${pr.number}`)));
6671
7115
  });
6672
- if (overflow > 0) chunks.push(label(` +${overflow}`));
7116
+ if (overflow > 0) chunks.push(label(` +${overflow} (/prs)`));
6673
7117
  return new StyledText(chunks);
6674
7118
  }
6675
7119
  /**
@@ -6764,9 +7208,14 @@ function formatChatFooterStatus({ runKind, ctrlCArmed, isShared, hasPendingSteer
6764
7208
  case "streaming": return hasPendingSteer ? `${connectHint}↵ steer · esc cancel steer · ctrl+c cancel run` : `${connectHint}↵ steer · esc leave (keeps running) · ctrl+c cancel`;
6765
7209
  }
6766
7210
  }
6767
- /** Keep model switching discoverable beside the current model. */
6768
- function formatModelHint(model) {
6769
- return `ctrl+p · ${model ?? "default"}`;
7211
+ /**
7212
+ * Keep model switching discoverable beside the current model. The effort is
7213
+ * appended only once something has chosen one — an agent with no stored level
7214
+ * runs at the platform default, and naming a level it never picked would be a
7215
+ * claim the footer can't back.
7216
+ */
7217
+ function formatModelHint(model, effort) {
7218
+ return `ctrl+p · ${model ?? "default"}${effort ? ` · ${effort}` : ""}`;
6770
7219
  }
6771
7220
 
6772
7221
  //#endregion
@@ -6776,15 +7225,16 @@ function formatModelHint(model) {
6776
7225
  * bar and footer can't fit, one aligned `label value` row each. Pure so the
6777
7226
  * layout is testable without a renderer.
6778
7227
  */
6779
- function formatStatusInfo({ appUrl, workspaceName, agentName, conversationId, model, share }) {
7228
+ function formatStatusInfo({ appUrl, workspaceName, agentName, conversationId, model, effort, share }) {
6780
7229
  const rows = [
6781
7230
  ["app", appUrl ?? "unknown"],
6782
7231
  ["workspace", workspaceName ?? "unknown"],
6783
7232
  ["agent", agentName],
6784
7233
  ["conversation", conversationId ?? "new (created when you send the first message)"],
6785
- ["model", model ?? "default"],
6786
- ["local access", formatShare(share)]
7234
+ ["model", model ?? "default"]
6787
7235
  ];
7236
+ if (effort) rows.push(["effort", effort]);
7237
+ rows.push(["local access", formatShare(share)]);
6788
7238
  const width = Math.max(...rows.map(([label]) => label.length));
6789
7239
  return rows.map(([label, value]) => `${label.padEnd(width)} ${value}`).join("\n");
6790
7240
  }
@@ -7735,6 +8185,13 @@ const slashCommands = [
7735
8185
  description: "switch the model for the next run",
7736
8186
  action: { kind: "model-picker" }
7737
8187
  },
8188
+ {
8189
+ name: "effort",
8190
+ aliases: ["reasoning"],
8191
+ argsHint: null,
8192
+ description: "change the current model's reasoning effort",
8193
+ action: { kind: "effort-picker" }
8194
+ },
7738
8195
  {
7739
8196
  name: "theme",
7740
8197
  aliases: [],
@@ -7742,6 +8199,13 @@ const slashCommands = [
7742
8199
  description: "switch the color theme",
7743
8200
  action: { kind: "theme-picker" }
7744
8201
  },
8202
+ {
8203
+ name: "prs",
8204
+ aliases: ["pr"],
8205
+ argsHint: null,
8206
+ description: "list this conversation's linked PRs; open or copy one",
8207
+ action: { kind: "pr-picker" }
8208
+ },
7745
8209
  {
7746
8210
  name: "status",
7747
8211
  aliases: [],
@@ -8051,19 +8515,65 @@ function waitingOn(card) {
8051
8515
  }
8052
8516
  }
8053
8517
 
8518
+ //#endregion
8519
+ //#region src/chat/tui/screens/model-effort.ts
8520
+ /**
8521
+ * Effort a run uses when nothing has chosen one. Mirrors the harness's
8522
+ * DEFAULT_THINKING_LEVEL, so the picker shows the level a run would actually
8523
+ * take rather than leaving it blank.
8524
+ */
8525
+ const DEFAULT_EFFORT = "medium";
8526
+ /**
8527
+ * Reasoning-effort levels a model can be run at, coarse to fine. Empty for a
8528
+ * model that takes no effort parameter at all — the catalog reports both the
8529
+ * capability flag and the level list, and a non-reasoning model must never be
8530
+ * sent an effort (providers reject it outright).
8531
+ */
8532
+ function effortLevels(model) {
8533
+ if (!model.reasoning) return [];
8534
+ return model.thinkingLevels ?? [];
8535
+ }
8536
+ /**
8537
+ * The effort a run on `model` would use right now, or null when the model
8538
+ * takes none.
8539
+ *
8540
+ * `agentEffort` is the level stored on the agent — what its next run really
8541
+ * uses — so it wins, but only for the model the agent is actually on; the
8542
+ * caller passes null for every other row. `savedEffort` is the user's
8543
+ * remembered choice for this model and decides what a switch TO it applies.
8544
+ * A level the model doesn't offer is skipped rather than shown, which is how
8545
+ * an effort carried over from a different model degrades.
8546
+ */
8547
+ function resolveEffort({ model, agentEffort, savedEffort }) {
8548
+ const levels = effortLevels(model);
8549
+ if (levels.length === 0) return null;
8550
+ for (const candidate of [
8551
+ agentEffort,
8552
+ savedEffort,
8553
+ DEFAULT_EFFORT
8554
+ ]) if (candidate && levels.includes(candidate)) return candidate;
8555
+ return levels[0] ?? null;
8556
+ }
8557
+ /** Display name for a level ("high" → "High"), falling back to the raw id. */
8558
+ function effortLabel(model, level) {
8559
+ return model.thinkingLevelLabels?.[level] ?? level;
8560
+ }
8561
+
8054
8562
  //#endregion
8055
8563
  //#region src/chat/tui/screens/model-picker.tsx
8056
8564
  /**
8057
- * In-chat model switcher (opened with ctrl+p). Rendered as an overlay in
8058
- * place of the transcript+composer while the chat screen stays mounted, so
8059
- * the conversation, input draft, and any live run survive picking a model.
8565
+ * In-chat model switcher (opened with ctrl+p, or `/model`). Rendered as an
8566
+ * overlay in place of the transcript+composer while the chat screen stays
8567
+ * mounted, so the conversation, input draft, and any live run survive picking
8568
+ * a model.
8060
8569
  *
8061
8570
  * Selecting a model PATCHes the agent, which changes the model for the
8062
- * agent's next run (send carries no per-message model). `onSelected` hands
8063
- * the settled model id back to the chat screen so its status line updates
8064
- * without a round-trip.
8571
+ * agent's next run (send carries no per-message model). `tab` on a reasoning
8572
+ * model opens its effort list instead, which is also where `/effort` lands
8573
+ * directly. Both callbacks hand the settled values back to the chat screen so
8574
+ * its status line updates without a round-trip.
8065
8575
  */
8066
- function ModelPicker({ agentId, currentModel, modelLocked, onSelected, onCancel }) {
8576
+ function ModelPicker({ agentId, currentModel, currentEffort, modelLocked, initialView, onModelSelected, onEffortSelected, onCancel }) {
8067
8577
  const rest = useStore((s) => s.rest);
8068
8578
  const [state, setState] = useState({ kind: "loading" });
8069
8579
  useEffect(() => {
@@ -8071,11 +8581,12 @@ function ModelPicker({ agentId, currentModel, modelLocked, onSelected, onCancel
8071
8581
  let cancelled = false;
8072
8582
  (async () => {
8073
8583
  try {
8074
- const models = await rest.listModels();
8584
+ const [models, savedEfforts] = await Promise.all([rest.listModels(), rest.listModelEfforts().catch(() => ({}))]);
8075
8585
  if (cancelled) return;
8076
8586
  setState({
8077
8587
  kind: "ready",
8078
- models
8588
+ models,
8589
+ savedEfforts
8079
8590
  });
8080
8591
  } catch (err) {
8081
8592
  if (cancelled) return;
@@ -8103,12 +8614,16 @@ function ModelPicker({ agentId, currentModel, modelLocked, onSelected, onCancel
8103
8614
  children: ["error: ", state.message]
8104
8615
  })
8105
8616
  });
8106
- return /* @__PURE__ */ jsx(FilterableModelList, {
8617
+ return /* @__PURE__ */ jsx(ModelSwitcher, {
8107
8618
  models: state.models,
8619
+ initialSavedEfforts: state.savedEfforts,
8108
8620
  currentModel,
8621
+ currentEffort,
8109
8622
  modelLocked,
8623
+ initialView,
8110
8624
  agentId,
8111
- onSelected,
8625
+ onModelSelected,
8626
+ onEffortSelected,
8112
8627
  onCancel
8113
8628
  });
8114
8629
  }
@@ -8138,38 +8653,126 @@ function ModelPickerFrame({ hint, children }) {
8138
8653
  ]
8139
8654
  });
8140
8655
  }
8141
- function FilterableModelList({ models, currentModel, modelLocked, agentId, onSelected, onCancel }) {
8142
- const { width, height } = useTerminalDimensions();
8143
- const [query, setQuery] = useState("");
8144
- const [highlight, setHighlight] = useState(0);
8656
+ function ModelSwitcher({ models, initialSavedEfforts, currentModel, currentEffort, modelLocked, initialView, agentId, onModelSelected, onEffortSelected, onCancel }) {
8657
+ const rest = useStore((s) => s.rest);
8658
+ const [savedEfforts, setSavedEfforts] = useState(initialSavedEfforts);
8659
+ const [view, setView] = useState(() => {
8660
+ if (initialView === "models") return { kind: "models" };
8661
+ const model = models.find((m) => m.id === currentModel);
8662
+ if (!model || effortLevels(model).length === 0) return { kind: "models" };
8663
+ return {
8664
+ kind: "effort",
8665
+ model
8666
+ };
8667
+ });
8145
8668
  const [saving, setSaving] = useState(false);
8146
8669
  const [saveError, setSaveError] = useState(null);
8147
- const rest = useStore((s) => s.rest);
8148
- const hits = fuzzyFilter(query, models, modelKeys);
8149
- const filtered = hits.map((h) => h.item);
8150
- const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
8151
- const visibleRows = Math.max(3, height - 8);
8152
- const start = windowStart(clamped, filtered.length, visibleRows);
8153
- const windowed = hits.slice(start, start + visibleRows);
8154
- async function select(model) {
8670
+ /** The effort this row would run at, or null when the model takes none. */
8671
+ function effortFor(model) {
8672
+ return resolveEffort({
8673
+ model,
8674
+ agentEffort: model.id === currentModel ? currentEffort : null,
8675
+ savedEffort: savedEfforts[model.id] ?? null
8676
+ });
8677
+ }
8678
+ function failed(err) {
8679
+ setSaveError(err instanceof HttpError ? parseApiError(err.body) ?? err.message : errorMessage(err));
8680
+ setSaving(false);
8681
+ }
8682
+ async function selectModel(model) {
8155
8683
  if (!rest || saving) return;
8156
- if (model.id === currentModel) {
8684
+ const effort = effortFor(model);
8685
+ if (model.id === currentModel && effort === currentEffort) {
8157
8686
  onCancel();
8158
8687
  return;
8159
8688
  }
8160
8689
  setSaving(true);
8161
8690
  setSaveError(null);
8162
8691
  try {
8163
- const { model: settled } = await rest.updateAgentModel({
8692
+ const settled = await rest.updateAgentModel({
8164
8693
  agentId,
8165
- model: model.id
8694
+ model: model.id,
8695
+ thinkingLevel: effort
8696
+ });
8697
+ onModelSelected({
8698
+ model: settled.model ?? model.id,
8699
+ effort: settled.thinkingLevel
8166
8700
  });
8167
- onSelected(settled ?? model.id);
8168
8701
  } catch (err) {
8169
- setSaveError(err instanceof HttpError ? parseApiError(err.body) ?? err.message : errorMessage(err));
8702
+ failed(err);
8703
+ }
8704
+ }
8705
+ async function selectEffort(model, level) {
8706
+ if (!rest || saving) return;
8707
+ setSaving(true);
8708
+ setSaveError(null);
8709
+ try {
8710
+ await rest.setModelEffort({
8711
+ modelId: model.id,
8712
+ thinkingLevel: level
8713
+ });
8714
+ if (model.id === currentModel) {
8715
+ await rest.updateAgentEffort({
8716
+ agentId,
8717
+ thinkingLevel: level
8718
+ });
8719
+ onEffortSelected({
8720
+ effort: level,
8721
+ label: effortLabel(model, level)
8722
+ });
8723
+ return;
8724
+ }
8725
+ setSavedEfforts((prev) => ({
8726
+ ...prev,
8727
+ [model.id]: level
8728
+ }));
8729
+ setView({ kind: "models" });
8170
8730
  setSaving(false);
8731
+ } catch (err) {
8732
+ failed(err);
8171
8733
  }
8172
8734
  }
8735
+ if (view.kind === "effort") return /* @__PURE__ */ jsx(EffortList, {
8736
+ model: view.model,
8737
+ activeEffort: effortFor(view.model),
8738
+ isCurrentModel: view.model.id === currentModel,
8739
+ saving,
8740
+ saveError,
8741
+ onSelect: (level) => void selectEffort(view.model, level),
8742
+ onBack: () => {
8743
+ setSaveError(null);
8744
+ if (initialView === "effort") onCancel();
8745
+ else setView({ kind: "models" });
8746
+ }
8747
+ });
8748
+ return /* @__PURE__ */ jsx(ModelList, {
8749
+ models,
8750
+ currentModel,
8751
+ modelLocked,
8752
+ saving,
8753
+ saveError,
8754
+ effortFor,
8755
+ onSelect: (model) => void selectModel(model),
8756
+ onEditEffort: (model) => {
8757
+ setSaveError(null);
8758
+ setView({
8759
+ kind: "effort",
8760
+ model
8761
+ });
8762
+ },
8763
+ onCancel
8764
+ });
8765
+ }
8766
+ function ModelList({ models, currentModel, modelLocked, saving, saveError, effortFor, onSelect, onEditEffort, onCancel }) {
8767
+ const { width, height } = useTerminalDimensions();
8768
+ const [query, setQuery] = useState("");
8769
+ const [highlight, setHighlight] = useState(0);
8770
+ const hits = fuzzyFilter(query, models, modelKeys);
8771
+ const filtered = hits.map((h) => h.item);
8772
+ const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
8773
+ const visibleRows = Math.max(3, height - 8);
8774
+ const start = windowStart(clamped, filtered.length, visibleRows);
8775
+ const windowed = hits.slice(start, start + visibleRows);
8173
8776
  useKeyboard((key) => {
8174
8777
  if (saving) return;
8175
8778
  if (key.name === "escape") {
@@ -8178,12 +8781,17 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
8178
8781
  }
8179
8782
  if (key.name === "up") setHighlight(Math.max(0, clamped - 1));
8180
8783
  else if (key.name === "down") setHighlight(Math.min(Math.max(0, filtered.length - 1), clamped + 1));
8181
- else if (key.name === "return") {
8784
+ else if (key.name === "tab") {
8785
+ const model = filtered[clamped];
8786
+ if (model && effortFor(model)) onEditEffort(model);
8787
+ } else if (key.name === "return") {
8182
8788
  const model = filtered[clamped];
8183
- if (model) select(model);
8789
+ if (model) onSelect(model);
8184
8790
  }
8185
8791
  });
8186
- const statusLine = saving ? "saving…" : saveError ? saveError : `${filtered.length}/${models.length} · ↑/↓ move · ↵ select · esc cancel`;
8792
+ const highlighted = filtered[clamped];
8793
+ const canEditEffort = !!highlighted && !!effortFor(highlighted);
8794
+ const statusLine = saving ? "saving…" : saveError ? saveError : `${filtered.length}/${models.length} · ↑/↓ move · ↵ select${canEditEffort ? " · tab effort" : ""} · esc cancel`;
8187
8795
  return /* @__PURE__ */ jsxs("box", {
8188
8796
  style: {
8189
8797
  flexDirection: "column",
@@ -8236,6 +8844,7 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
8236
8844
  const m = hit.item;
8237
8845
  const selected = start + i === clamped;
8238
8846
  const isCurrent = m.id === currentModel;
8847
+ const effort = effortFor(m);
8239
8848
  return /* @__PURE__ */ jsxs("text", {
8240
8849
  fg: selected ? theme.accent : theme.fg,
8241
8850
  children: [
@@ -8253,7 +8862,11 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
8253
8862
  /* @__PURE__ */ jsx(FuzzyText, {
8254
8863
  text: m.id,
8255
8864
  indexes: hit.highlights[2]
8256
- })
8865
+ }),
8866
+ effort ? /* @__PURE__ */ jsx("span", {
8867
+ fg: theme.dim,
8868
+ children: ` ${effortLabel(m, effort)}`
8869
+ }) : null
8257
8870
  ]
8258
8871
  }, m.id);
8259
8872
  })
@@ -8265,6 +8878,60 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
8265
8878
  ]
8266
8879
  });
8267
8880
  }
8881
+ function EffortList({ model, activeEffort, isCurrentModel, saving, saveError, onSelect, onBack }) {
8882
+ const levels = effortLevels(model);
8883
+ const [highlight, setHighlight] = useState(() => {
8884
+ const index = activeEffort ? levels.indexOf(activeEffort) : -1;
8885
+ return index < 0 ? 0 : index;
8886
+ });
8887
+ const clamped = Math.min(highlight, Math.max(0, levels.length - 1));
8888
+ useKeyboard((key) => {
8889
+ if (saving) return;
8890
+ if (key.name === "escape") onBack();
8891
+ else if (key.name === "up") setHighlight(Math.max(0, clamped - 1));
8892
+ else if (key.name === "down") setHighlight(Math.min(Math.max(0, levels.length - 1), clamped + 1));
8893
+ else if (key.name === "return") {
8894
+ const level = levels[clamped];
8895
+ if (level) onSelect(level);
8896
+ }
8897
+ });
8898
+ const statusLine = saving ? "saving…" : saveError ? saveError : "↑/↓ move · ↵ select · esc back";
8899
+ return /* @__PURE__ */ jsxs("box", {
8900
+ style: {
8901
+ flexDirection: "column",
8902
+ flexGrow: 1
8903
+ },
8904
+ children: [
8905
+ /* @__PURE__ */ jsxs("text", {
8906
+ fg: theme.accent,
8907
+ children: ["effort · ", model.displayName]
8908
+ }),
8909
+ /* @__PURE__ */ jsx("text", {
8910
+ fg: theme.muted,
8911
+ children: isCurrentModel ? "applies to this agent from its next run" : "applies when you switch to this model"
8912
+ }),
8913
+ /* @__PURE__ */ jsx("box", {
8914
+ style: {
8915
+ flexDirection: "column",
8916
+ flexGrow: 1,
8917
+ marginTop: 1
8918
+ },
8919
+ children: levels.map((level, i) => /* @__PURE__ */ jsxs("text", {
8920
+ fg: i === clamped ? theme.accent : theme.fg,
8921
+ children: [
8922
+ i === clamped ? "› " : " ",
8923
+ level === activeEffort ? "● " : " ",
8924
+ effortLabel(model, level)
8925
+ ]
8926
+ }, level))
8927
+ }),
8928
+ /* @__PURE__ */ jsx("text", {
8929
+ fg: saveError ? theme.error : theme.dim,
8930
+ children: statusLine
8931
+ })
8932
+ ]
8933
+ });
8934
+ }
8268
8935
  const modelKeys = [
8269
8936
  {
8270
8937
  get: (m) => m.displayName,
@@ -8291,6 +8958,106 @@ function parseApiError(body) {
8291
8958
  return null;
8292
8959
  }
8293
8960
 
8961
+ //#endregion
8962
+ //#region src/chat/tui/screens/pr-picker.tsx
8963
+ /**
8964
+ * The full list of PRs linked from the conversation, as a pickable overlay.
8965
+ *
8966
+ * The composer footer chip (see {@link ../chat/pr-links}) shows only the newest
8967
+ * few PR numbers before collapsing the rest into a `+N` count — there's no room
8968
+ * on that shared row for more, and no way from the chip to reach the ones the
8969
+ * `+N` swallowed. This overlay is the "expand the `+N`" surface: every PR the
8970
+ * conversation has linked, newest first, with the repo it belongs to, where you
8971
+ * can pick one to open in the browser or copy its URL.
8972
+ *
8973
+ * Rendered in place of the transcript like the model/theme/card pickers, so the
8974
+ * conversation and any live run survive underneath.
8975
+ */
8976
+ function PrPicker({ prs, onOpen, onCopy, onCancel }) {
8977
+ const { height } = useTerminalDimensions();
8978
+ const ordered = [...prs].reverse();
8979
+ const [highlight, setHighlight] = useState(0);
8980
+ const clamped = Math.min(highlight, Math.max(0, ordered.length - 1));
8981
+ const visibleRows = Math.max(3, height - 6);
8982
+ const start = windowStart(clamped, ordered.length, visibleRows);
8983
+ const windowed = ordered.slice(start, start + visibleRows);
8984
+ useKeyboard((key) => {
8985
+ if (key.name === "escape") {
8986
+ onCancel();
8987
+ return;
8988
+ }
8989
+ if (key.name === "up") setHighlight(Math.max(0, clamped - 1));
8990
+ else if (key.name === "down") setHighlight(Math.min(Math.max(0, ordered.length - 1), clamped + 1));
8991
+ else if (key.name === "return") {
8992
+ const pr = ordered[clamped];
8993
+ if (pr) onOpen(pr);
8994
+ } else if (key.name === "y") {
8995
+ const pr = ordered[clamped];
8996
+ if (pr) onCopy(pr);
8997
+ }
8998
+ });
8999
+ useEffect(() => {
9000
+ if (ordered.length === 0) onCancel();
9001
+ }, [ordered.length, onCancel]);
9002
+ if (ordered.length === 0) return null;
9003
+ return /* @__PURE__ */ jsxs("box", {
9004
+ style: {
9005
+ flexDirection: "column",
9006
+ flexGrow: 1
9007
+ },
9008
+ children: [
9009
+ /* @__PURE__ */ jsx("text", {
9010
+ fg: theme.accent,
9011
+ children: "pull requests"
9012
+ }),
9013
+ /* @__PURE__ */ jsx("box", {
9014
+ style: {
9015
+ flexDirection: "column",
9016
+ flexGrow: 1,
9017
+ marginTop: 1
9018
+ },
9019
+ children: windowed.map((pr, i) => {
9020
+ const selected = start + i === clamped;
9021
+ const repo = repoLabel(pr.key);
9022
+ return /* @__PURE__ */ jsxs("text", {
9023
+ fg: selected ? theme.accent : theme.fg,
9024
+ children: [
9025
+ selected ? "› " : " ",
9026
+ "⇄ #",
9027
+ pr.number,
9028
+ repo ? /* @__PURE__ */ jsxs("span", {
9029
+ fg: theme.muted,
9030
+ children: [" · ", repo]
9031
+ }) : null
9032
+ ]
9033
+ }, pr.key);
9034
+ })
9035
+ }),
9036
+ /* @__PURE__ */ jsxs("text", {
9037
+ fg: theme.dim,
9038
+ children: [
9039
+ ordered.length,
9040
+ " PR",
9041
+ ordered.length === 1 ? "" : "s",
9042
+ " · ↑/↓ move · ↵ open · y copy url · esc cancel"
9043
+ ]
9044
+ })
9045
+ ]
9046
+ });
9047
+ }
9048
+ /**
9049
+ * The `owner/repo` a PR belongs to, pulled from its `owner/repo#number`
9050
+ * identity key. Shown alongside the number so a conversation spanning several
9051
+ * repos never renders an ambiguous bare `#42`. Returns null if the key isn't in
9052
+ * the expected shape, so the row degrades to just the number rather than
9053
+ * printing a malformed label.
9054
+ */
9055
+ function repoLabel(key) {
9056
+ const hash = key.lastIndexOf("#");
9057
+ if (hash <= 0) return null;
9058
+ return key.slice(0, hash);
9059
+ }
9060
+
8294
9061
  //#endregion
8295
9062
  //#region src/chat/tui/screens/theme-preview.tsx
8296
9063
  /**
@@ -10953,7 +11720,9 @@ function routeInput(raw) {
10953
11720
  case "fork":
10954
11721
  case "archive-conversation":
10955
11722
  case "model-picker":
11723
+ case "effort-picker":
10956
11724
  case "theme-picker":
11725
+ case "pr-picker":
10957
11726
  case "status":
10958
11727
  case "computer":
10959
11728
  case "share":
@@ -11093,7 +11862,7 @@ async function openUrlInBrowser(url) {
11093
11862
  function isActionableCard(m) {
11094
11863
  return m.kind === "card" && m.status !== "done" && m.status !== "busy" && m.card.button !== null;
11095
11864
  }
11096
- function ChatScreen({ agent, conversation }) {
11865
+ function ChatScreen({ agent, conversation, attachRunId }) {
11097
11866
  const renderer = useRenderer();
11098
11867
  const { width, height } = useTerminalDimensions();
11099
11868
  const rest = useStore((s) => s.rest);
@@ -11121,6 +11890,16 @@ function ChatScreen({ agent, conversation }) {
11121
11890
  });
11122
11891
  const [items, setItems] = useState([]);
11123
11892
  const [todos, setTodos] = useState(null);
11893
+ const [bgTasks, setBgTasks] = useState([]);
11894
+ const [bgTasksOpen, setBgTasksOpen] = useState(false);
11895
+ const bgHasRetained = hasRetainedFinished(bgTasks);
11896
+ useEffect(() => {
11897
+ if (!bgHasRetained) return;
11898
+ const id = setInterval(() => {
11899
+ setBgTasks((prev) => mergeBgTasks(prev, prev, Date.now()));
11900
+ }, Math.min(FINISHED_GRACE_MS, 5e3));
11901
+ return () => clearInterval(id);
11902
+ }, [bgHasRetained]);
11124
11903
  const [planHidden, setPlanHidden] = useState(false);
11125
11904
  const [subagentRows, setSubagentRows] = useState(0);
11126
11905
  const [anchoredSubagentIds, setAnchoredSubagentIds] = useState([]);
@@ -11139,10 +11918,13 @@ function ChatScreen({ agent, conversation }) {
11139
11918
  const [input, setInput] = useState(() => useStore.getState().takeEarlyInput());
11140
11919
  const [run, setRun] = useState({ kind: "idle" });
11141
11920
  const [model, setModel] = useState(agent.model ?? null);
11921
+ const [effort, setEffort] = useState(agent.thinkingLevel ?? null);
11142
11922
  const [modelLocked, setModelLocked] = useState(agent.modelLocked ?? false);
11143
11923
  const [cardPickerOpen, setCardPickerOpen] = useState(false);
11144
- const [modelPickerOpen, setModelPickerOpen] = useState(false);
11924
+ const [modelPickerView, setModelPickerView] = useState(null);
11925
+ const modelPickerOpen = modelPickerView !== null;
11145
11926
  const [themePickerOpen, setThemePickerOpen] = useState(false);
11927
+ const [prPickerOpen, setPrPickerOpen] = useState(false);
11146
11928
  const [helpOpen, setHelpOpen] = useState(false);
11147
11929
  const [reviewOpen, setReviewOpen] = useState(false);
11148
11930
  const [reviewFocused, setReviewFocused] = useState(false);
@@ -11274,6 +12056,7 @@ function ChatScreen({ agent, conversation }) {
11274
12056
  const ids = new Set(anchoredSubagentIds);
11275
12057
  return items.filter((item) => item.kind === "tool" && item.toolName === "subagent" && ids.has(item.id));
11276
12058
  }, [items, anchoredSubagentIds]);
12059
+ const bgTasksActive = bgTasks.length > 0;
11277
12060
  const jumpHintVisible = scrolledUp && !credPrompt;
11278
12061
  const jumpHintRows = jumpHintVisible ? 1 : 0;
11279
12062
  const scrollHeight = Math.max(3, chatHeight - 2 - bottomBoxHeight - pendingRows - inlineReviewHeight - menuRows - jumpHintRows - subagentRows - todoRows);
@@ -11306,6 +12089,7 @@ function ChatScreen({ agent, conversation }) {
11306
12089
  cardPickerOpen,
11307
12090
  modelPickerOpen,
11308
12091
  themePickerOpen,
12092
+ prPickerOpen,
11309
12093
  helpOpen,
11310
12094
  reviewOpen,
11311
12095
  reviewFocused,
@@ -11413,6 +12197,11 @@ function ChatScreen({ agent, conversation }) {
11413
12197
  if (next) setTodos(next.length > 0 ? next : null);
11414
12198
  return;
11415
12199
  }
12200
+ if (event.chunk.type === "data-anyone-bg-tasks-updated") {
12201
+ const next = bgTasksFromPayload(event.chunk.data);
12202
+ if (next) setBgTasks((prev) => mergeBgTasks(prev, next, Date.now()));
12203
+ return;
12204
+ }
11416
12205
  setItems((prev) => applyChunk(prev, event.chunk, streamAgentName));
11417
12206
  return;
11418
12207
  }
@@ -11483,6 +12272,7 @@ function ChatScreen({ agent, conversation }) {
11483
12272
  if (event.kind === "agent") {
11484
12273
  if (event.id !== agent.id) return;
11485
12274
  setModel(event.model);
12275
+ setEffort(event.thinkingLevel ?? null);
11486
12276
  setModelLocked(event.modelLocked);
11487
12277
  return;
11488
12278
  }
@@ -11500,6 +12290,13 @@ function ChatScreen({ agent, conversation }) {
11500
12290
  attachToRun,
11501
12291
  onCompactionEvent
11502
12292
  ]);
12293
+ const attachedSeedRunRef = useRef(false);
12294
+ useEffect(() => {
12295
+ if (!attachRunId || attachedSeedRunRef.current) return;
12296
+ if (seenRunsRef.current.has(attachRunId)) return;
12297
+ attachedSeedRunRef.current = true;
12298
+ attachToRun(attachRunId, null, null);
12299
+ }, [attachRunId, attachToRun]);
11503
12300
  const sendContent = useCallback(async (content, staged, opts) => {
11504
12301
  if (!rest) return;
11505
12302
  const trimmed = content.trim();
@@ -12058,7 +12855,7 @@ function ChatScreen({ agent, conversation }) {
12058
12855
  stageAttachment(image);
12059
12856
  }, [stageAttachment]);
12060
12857
  usePaste((event) => {
12061
- if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt || computePromptOpen || nonWebChannel) return;
12858
+ if (cardPickerOpen || modelPickerOpen || themePickerOpen || prPickerOpen || helpOpen || bgTasksOpen || credPromptOpen || grantPrompt || computePromptOpen || nonWebChannel) return;
12062
12859
  const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
12063
12860
  const route = routePaste({
12064
12861
  kind: event.metadata?.kind,
@@ -12110,6 +12907,7 @@ function ChatScreen({ agent, conversation }) {
12110
12907
  cardPickerOpen,
12111
12908
  modelPickerOpen,
12112
12909
  themePickerOpen,
12910
+ prPickerOpen,
12113
12911
  helpOpen,
12114
12912
  credPromptOpen,
12115
12913
  computePromptOpen,
@@ -12170,6 +12968,7 @@ function ChatScreen({ agent, conversation }) {
12170
12968
  goTo({
12171
12969
  kind: "chat",
12172
12970
  agent,
12971
+ attachRunId: null,
12173
12972
  conversation: forkedConversationRef({
12174
12973
  agent,
12175
12974
  forked
@@ -12462,6 +13261,7 @@ function ChatScreen({ agent, conversation }) {
12462
13261
  goTo({
12463
13262
  kind: "chat",
12464
13263
  agent,
13264
+ attachRunId: null,
12465
13265
  conversation: {
12466
13266
  kind: "new",
12467
13267
  agentId: agent.id,
@@ -12498,11 +13298,18 @@ function ChatScreen({ agent, conversation }) {
12498
13298
  openConversationRef(routed.ref);
12499
13299
  break;
12500
13300
  case "model-picker":
12501
- setModelPickerOpen(true);
13301
+ setModelPickerView("models");
13302
+ break;
13303
+ case "effort-picker":
13304
+ setModelPickerView("effort");
12502
13305
  break;
12503
13306
  case "theme-picker":
12504
13307
  setThemePickerOpen(true);
12505
13308
  break;
13309
+ case "pr-picker":
13310
+ if (prLinks.length === 0) useStore.getState().showToast({ message: "no linked PRs in this conversation yet" });
13311
+ else setPrPickerOpen(true);
13312
+ break;
12506
13313
  case "status":
12507
13314
  setItems((prev) => [...prev, {
12508
13315
  kind: "info",
@@ -12514,6 +13321,7 @@ function ChatScreen({ agent, conversation }) {
12514
13321
  agentName: agent.name,
12515
13322
  conversationId,
12516
13323
  model,
13324
+ effort,
12517
13325
  share: {
12518
13326
  status: portal.status,
12519
13327
  grantedToAgent: portal.grantedAgentIds.includes(agent.id)
@@ -12600,7 +13408,8 @@ function ChatScreen({ agent, conversation }) {
12600
13408
  portal.grantedAgentIds,
12601
13409
  promptForCards,
12602
13410
  todos,
12603
- planHidden
13411
+ planHidden,
13412
+ prLinks.length
12604
13413
  ]);
12605
13414
  const submit = useCallback(() => {
12606
13415
  const staged = attachments.filter((a) => a.status !== "error");
@@ -12666,7 +13475,7 @@ function ChatScreen({ agent, conversation }) {
12666
13475
  }
12667
13476
  if (reviewFocused) return;
12668
13477
  }
12669
- if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen) return;
13478
+ if (cardPickerOpen || modelPickerOpen || themePickerOpen || prPickerOpen || helpOpen || bgTasksOpen) return;
12670
13479
  if (grantPrompt) {
12671
13480
  if (key.name === "y") {
12672
13481
  confirmGrant();
@@ -12768,13 +13577,17 @@ function ChatScreen({ agent, conversation }) {
12768
13577
  return;
12769
13578
  }
12770
13579
  if (key.name === "p" && key.ctrl) {
12771
- setModelPickerOpen(true);
13580
+ setModelPickerView("models");
12772
13581
  return;
12773
13582
  }
12774
13583
  if (key.name === "r" && key.ctrl) {
12775
13584
  promptForCards();
12776
13585
  return;
12777
13586
  }
13587
+ if (key.name === "b" && key.ctrl) {
13588
+ if (bgTasksActive) setBgTasksOpen(true);
13589
+ return;
13590
+ }
12778
13591
  if (key.name === "t" && key.ctrl) {
12779
13592
  toggleShare();
12780
13593
  return;
@@ -12869,10 +13682,10 @@ function ChatScreen({ agent, conversation }) {
12869
13682
  const boundCollectSandboxStats = useMemo(() => {
12870
13683
  if (!rest) return null;
12871
13684
  return () => collectSandboxStats({
12872
- read: () => rest.computeLiveStats(),
13685
+ read: () => rest.computeLiveStats(agent.id),
12873
13686
  now: () => Date.now()
12874
13687
  });
12875
- }, [rest]);
13688
+ }, [rest, agent.id]);
12876
13689
  const conversationContext = useMemo(() => ({
12877
13690
  rest,
12878
13691
  conversationId,
@@ -12902,7 +13715,7 @@ function ChatScreen({ agent, conversation }) {
12902
13715
  actionableCards.length,
12903
13716
  nonWebChannel
12904
13717
  ]);
12905
- const modelHint = formatModelHint(model);
13718
+ const modelHint = formatModelHint(model, effort);
12906
13719
  if (cardPickerOpen) return /* @__PURE__ */ jsx(CardPicker, {
12907
13720
  cards: actionableCards,
12908
13721
  onSelect: (card) => {
@@ -12911,22 +13724,56 @@ function ChatScreen({ agent, conversation }) {
12911
13724
  },
12912
13725
  onCancel: () => setCardPickerOpen(false)
12913
13726
  });
12914
- if (modelPickerOpen) return /* @__PURE__ */ jsx(ModelPicker, {
13727
+ if (modelPickerView) return /* @__PURE__ */ jsx(ModelPicker, {
12915
13728
  agentId: agent.id,
12916
13729
  currentModel: model,
13730
+ currentEffort: effort,
12917
13731
  modelLocked,
12918
- onSelected: (modelId) => {
12919
- setModel(modelId);
12920
- setModelPickerOpen(false);
13732
+ initialView: modelPickerView,
13733
+ onModelSelected: (next) => {
13734
+ setModel(next.model);
13735
+ setEffort(next.effort);
13736
+ setModelPickerView(null);
12921
13737
  useStore.getState().showToast({
12922
13738
  variant: "success",
12923
- message: `model changed to ${modelId}`
13739
+ message: `model changed to ${next.model}`
12924
13740
  });
12925
13741
  },
12926
- onCancel: () => setModelPickerOpen(false)
13742
+ onEffortSelected: (next) => {
13743
+ setEffort(next.effort);
13744
+ setModelPickerView(null);
13745
+ useStore.getState().showToast({
13746
+ variant: "success",
13747
+ message: `effort changed to ${next.label}`
13748
+ });
13749
+ },
13750
+ onCancel: () => setModelPickerView(null)
12927
13751
  });
12928
13752
  if (themePickerOpen) return /* @__PURE__ */ jsx(ThemePicker, { onClose: () => setThemePickerOpen(false) });
13753
+ if (prPickerOpen) return /* @__PURE__ */ jsx(PrPicker, {
13754
+ prs: prLinks,
13755
+ onOpen: (pr) => {
13756
+ setPrPickerOpen(false);
13757
+ openUrlInBrowser(pr.url);
13758
+ },
13759
+ onCopy: (pr) => {
13760
+ writeClipboardText(pr.url).then((ok) => {
13761
+ useStore.getState().showToast(ok ? {
13762
+ variant: "success",
13763
+ message: `copied #${pr.number} url`
13764
+ } : {
13765
+ variant: "error",
13766
+ message: "could not copy to clipboard"
13767
+ });
13768
+ });
13769
+ },
13770
+ onCancel: () => setPrPickerOpen(false)
13771
+ });
12929
13772
  if (helpOpen) return /* @__PURE__ */ jsx(HelpModal, { onClose: () => setHelpOpen(false) });
13773
+ if (bgTasksOpen) return /* @__PURE__ */ jsx(BgTasksOverlay, {
13774
+ tasks: bgTasks,
13775
+ onClose: () => setBgTasksOpen(false)
13776
+ });
12930
13777
  const reviewView = reviewOpen ? /* @__PURE__ */ jsx(ReviewPane, {
12931
13778
  agentId: agent.id,
12932
13779
  conversationId,
@@ -13292,6 +14139,10 @@ function ChatScreen({ agent, conversation }) {
13292
14139
  fg: theme.muted,
13293
14140
  children: statusText
13294
14141
  }),
14142
+ bgTasksActive ? /* @__PURE__ */ jsx(BgShellsChip, {
14143
+ tasks: bgTasks,
14144
+ onOpen: () => setBgTasksOpen(true)
14145
+ }) : null,
13295
14146
  prLinks.length > 0 ? /* @__PURE__ */ jsx(PrLinksView, { prs: prLinks }) : null,
13296
14147
  /* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }),
13297
14148
  /* @__PURE__ */ jsx("text", {
@@ -13714,7 +14565,8 @@ function App({ appUrl, sessionToken, authKind, workspaceId, shareMachine, prompt
13714
14565
  }),
13715
14566
  screen.kind === "chat" && rest && /* @__PURE__ */ jsx(ChatScreen, {
13716
14567
  agent: screen.agent,
13717
- conversation: screen.conversation
14568
+ conversation: screen.conversation,
14569
+ attachRunId: screen.attachRunId
13718
14570
  }, "id" in screen.conversation ? screen.conversation.id : screen.conversation.nonce)
13719
14571
  ]
13720
14572
  })