skydive-cli 0.6.0-beta.48 → 0.6.0-beta.59

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,26 +1,26 @@
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-UJz2FS1U.mjs";
3
- import { C as DEFAULT_API_URL, D as getConfigPath, M as getReviewStateDir, N as getSavedTheme, R as recordDefaultAgent, W as resolveWebUrl, _ as computeSettledLabel, c as actionableCardVerb, d as parseOauthConnectParams, f as prioritizeActionableCards, g as composeQuestionAnswerEntries, h as composeQuestionAnswer, i as resolveAgent, l as cardActionErrorMessage, m as resolveConnectUrl, p as reconcileMaskedInput, q as saveTheme, s as MASK_CHAR, u as parseExternalOauthConnectParams, v as parseConnectCard, w as DEFAULT_APP_URL, y as specKeyFor } from "./print-B38Wx3CM.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-CBFdOTjb.mjs";
2
+ import { a as errorMessage, i as sendErrorMessage, n as errorDetail, o as isRecord, t as createRestClient } from "./rest-DdTf06cT.mjs";
3
+ import { C as DEFAULT_API_URL, D as getConfigPath, M as getReviewStateDir, N as getSavedTheme, R as recordDefaultAgent, W as resolveWebUrl, _ as computeSettledLabel, c as actionableCardVerb, d as parseOauthConnectParams, f as prioritizeActionableCards, g as composeQuestionAnswerEntries, h as composeQuestionAnswer, i as resolveAgent, l as cardActionErrorMessage, m as resolveConnectUrl, p as reconcileMaskedInput, q as saveTheme, s as MASK_CHAR, u as parseExternalOauthConnectParams, v as parseConnectCard, w as DEFAULT_APP_URL, y as specKeyFor } from "./print-CDDNlUPH.mjs";
4
+ import { A as aliasActivationHint, C as themeMode, D as themesForMode, L as detectShell, M as installAgentAlias, N as slugifyAliasName, P as takenAliasNames, S as themeForMode, T as themeVersion, U as setActiveWorkspace, V as listWorkspaces, _ as applyTheme, a as writeCrashReport, b as noColorRequested, g as DEFAULT_THEME_ID, h as writeArtifact, i as buildCrashReport, j as dedupeAliasName, k as machineOsFromPlatform, l as splashFitsWidth, m as record, n as disableTerminalAutowrap, o as MARK_CELLS, p as profilingEnabled, r as restoreTerminalAutowrap, s as WORDMARK, t as installCrashHandler, v as findTheme, w as themeModeFromColorFgBg, x as theme, y as monoTheme, z as getActiveWorkspaceId } from "./install-aadv_dwX.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 { n as PortalClient } from "./client-DzD76Qd7.mjs";
7
+ import { n as PortalClient } from "./client-CwrwJrXX.mjs";
8
8
  import { t as defaultTlsCertSource } from "./tls-cert-Rua2oV7n.mjs";
9
9
  import "./api-BFQ4PQDA.mjs";
10
- import "./daemon-D7hURPaU.mjs";
11
- import { t as SandboxStream } from "./client-CFnT0cAy.mjs";
12
- import { t as PortalDaemonClient } from "./daemon-client-GaxCwNSy.mjs";
13
- import { t as runRawPtyPassthrough } from "./raw-pty-Cj-O0pyb.mjs";
10
+ import "./daemon-DyzekuZq.mjs";
11
+ import { t as SandboxStream } from "./client-CAX6sE_Y.mjs";
12
+ import { t as PortalDaemonClient } from "./daemon-client-BYB_2WYU.mjs";
13
+ import { t as runRawPtyPassthrough } from "./raw-pty-BcZNOOGh.mjs";
14
14
  import * as os$1 from "node:os";
15
15
  import { homedir, platform, release, tmpdir } from "node:os";
16
16
  import path, { basename, extname, isAbsolute, join, posix, win32 } from "node:path";
17
+ import { access, appendFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
17
18
  import { z } from "zod";
18
- import open from "open";
19
+ import open$1 from "open";
19
20
  import { constants } from "node:fs";
20
21
  import { execFile, spawn } from "node:child_process";
21
22
  import { createHash } from "node:crypto";
22
23
  import { connect, createConnection } from "node:net";
23
- import { access, appendFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
24
24
  import { MarkdownRenderable, RenderableEvents, StyledText, SyntaxStyle, addDefaultParsers, createCliRenderer, decodePasteBytes, detectLinks, fg, link } from "@opentui/core";
25
25
  import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
26
26
  import { Profiler, createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
@@ -131,6 +131,7 @@ const useStore = create((set, get) => ({
131
131
  rest: null,
132
132
  promptHistoryPath: null,
133
133
  chatTitle: null,
134
+ chatSpend: null,
134
135
  chatBackTarget: null,
135
136
  portal: initialPortalUi,
136
137
  portalClient: null,
@@ -143,11 +144,13 @@ const useStore = create((set, get) => ({
143
144
  goTo: (screen) => set({
144
145
  screen,
145
146
  chatTitle: initialChatTitle(screen),
147
+ chatSpend: null,
146
148
  chatBackTarget: null
147
149
  }),
148
150
  drillTo: (screen, back) => set({
149
151
  screen,
150
152
  chatTitle: initialChatTitle(screen),
153
+ chatSpend: null,
151
154
  chatBackTarget: back
152
155
  }),
153
156
  setThemeMode: (mode) => {
@@ -182,13 +185,18 @@ const useStore = create((set, get) => ({
182
185
  promptHistoryPath
183
186
  }),
184
187
  setChatTitle: (title) => set((state) => state.screen.kind === "chat" ? { chatTitle: title } : {}),
188
+ setChatSpend: (cents, usage) => set((state) => state.screen.kind === "chat" ? { chatSpend: {
189
+ cents,
190
+ usage
191
+ } } : {}),
185
192
  setWorkspaceName: (workspaceName) => set({ workspaceName }),
186
193
  fail: (message) => set({
187
194
  screen: {
188
195
  kind: "error",
189
196
  message
190
197
  },
191
- chatTitle: null
198
+ chatTitle: null,
199
+ chatSpend: null
192
200
  }),
193
201
  setPortalClient: (portalClient) => set({ portalClient }),
194
202
  syncPortal: ({ status, machineName, error, grantedAgentIds, declinedAgentIds }) => set((state) => ({ portal: {
@@ -402,7 +410,7 @@ function createPortalClient(opts) {
402
410
  persistedMachineName: null,
403
411
  onMachineName: () => {},
404
412
  onState: (state) => opts.onState({
405
- status: state.status,
413
+ status: state.status === "stand-down" ? "connecting" : state.status,
406
414
  machineName: state.machineName,
407
415
  friendlyName: state.friendlyName,
408
416
  error: state.error,
@@ -1581,6 +1589,96 @@ function hostOf(url) {
1581
1589
  }
1582
1590
  }
1583
1591
 
1592
+ //#endregion
1593
+ //#region src/chat/tui/chat/spend.ts
1594
+ /**
1595
+ * Cents → `$1.23`. Spend that rounds to zero but isn't ("<$0.01") stays
1596
+ * visibly non-zero so a cheap turn never reads as a free one.
1597
+ */
1598
+ function formatCents(cents) {
1599
+ if (cents === 0) return "$0.00";
1600
+ if (cents < 1) return "<$0.01";
1601
+ return `$${(cents / 100).toFixed(2)}`;
1602
+ }
1603
+ /**
1604
+ * The status-bar readout: the conversation's spend so far including any
1605
+ * subagents it spawned. Prefixed `≈` because the compute share is an
1606
+ * estimate (see the api's conversationUsage) and the figure trails a live
1607
+ * turn by the telemetry lag. Null before the first read lands.
1608
+ */
1609
+ function formatSpendBadge(totalCents) {
1610
+ if (totalCents === null) return null;
1611
+ return `≈${formatCents(totalCents)}`;
1612
+ }
1613
+ /**
1614
+ * Body text for the `/cost` transcript block, for every outcome of the read.
1615
+ * `null` is "no conversation yet" (nothing has been sent).
1616
+ */
1617
+ function formatCostRead(read) {
1618
+ if (read === null) return "Nothing spent yet — this conversation is created when you send the first message.";
1619
+ switch (read.kind) {
1620
+ case "usage": return formatCostInfo(read.usage);
1621
+ case "off": return "Spend tracking is not turned on for this workspace yet.";
1622
+ case "unavailable": return "Couldn't read this conversation's spend right now — try again in a moment.";
1623
+ }
1624
+ }
1625
+ /**
1626
+ * Inference by model, the subagents' share, the compute estimate, and the
1627
+ * total, one aligned `label value` row each. Pure so the layout is testable
1628
+ * without a renderer.
1629
+ */
1630
+ function formatCostInfo(usage) {
1631
+ const rows = [];
1632
+ if (usage.own.byModel.length === 0) rows.push(["inference", formatCents(0)]);
1633
+ for (const line of usage.own.byModel) rows.push([line.model ?? "unknown model", `${formatCents(line.cents)} ${formatTokens(line)}`]);
1634
+ if (usage.children.conversationCount > 0) {
1635
+ const n = usage.children.conversationCount;
1636
+ rows.push(["subagents", `${formatCents(usage.children.inferenceCents)} inference across ${n} ${n === 1 ? "conversation" : "conversations"}`]);
1637
+ }
1638
+ rows.push(["compute", `${formatCents(usage.compute.cents)} est. from ${formatVcpuHours(usage.compute.vcpuSeconds)} of run time`]);
1639
+ rows.push(["total", formatCents(usage.totalCents)]);
1640
+ const width = Math.max(...rows.map(([label]) => label.length));
1641
+ const body = rows.map(([label, value]) => `${label.padEnd(width)} ${value}`).join("\n");
1642
+ const notes = ["as of the last metered call; compute is the time this conversation's runs (subagents included) kept the agent's computer busy, priced at your rates — idle time is not counted"];
1643
+ if (usage.unpriced.length > 0) notes.push(`no rate on file for ${usage.unpriced.join(", ")} — that usage is in the counts above but priced at $0, so the total understates`);
1644
+ return `${body}\n\n${notes.join("\n")}`;
1645
+ }
1646
+ /**
1647
+ * Compact lines for the side panel's costs section: the total first, then
1648
+ * the three buckets behind it. Wider notes live in `/cost`; the panel
1649
+ * names the caveat in one short line when a model has no rate on file.
1650
+ * Pure so the layout is testable without a renderer.
1651
+ */
1652
+ function formatSpendSection(usage) {
1653
+ const lines = [`${formatCents(usage.totalCents)} total`];
1654
+ if (usage.own.byModel.length === 0) lines.push(`inference ${formatCents(0)}`);
1655
+ else {
1656
+ lines.push(`inference ${formatCents(usage.own.inferenceCents)}${usage.own.byModel.length > 1 ? ` (${usage.own.byModel.length} models)` : ""}`);
1657
+ for (const line of usage.own.byModel) lines.push(` ${line.model ?? "unknown"} ${formatCents(line.cents)}`);
1658
+ }
1659
+ if (usage.children.conversationCount > 0) lines.push(`subagents ${formatCents(usage.children.inferenceCents)}`);
1660
+ lines.push(`compute ${formatCents(usage.compute.cents)} est. (${formatVcpuHours(usage.compute.vcpuSeconds)})`);
1661
+ if (usage.unpriced.length > 0) lines.push(`${usage.unpriced.join(", ")} unpriced — total understates (/cost)`);
1662
+ return lines;
1663
+ }
1664
+ function formatTokens(line) {
1665
+ const parts = [`${formatCount(line.promptTokens)} in`, `${formatCount(line.completionTokens)} out`];
1666
+ const cached = line.cacheReadTokens + line.cacheCreationTokens;
1667
+ if (cached > 0) parts.push(`${formatCount(cached)} cached`);
1668
+ return parts.join(", ");
1669
+ }
1670
+ function formatCount(n) {
1671
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
1672
+ if (n >= 1e4) return `${Math.round(n / 1e3)}k`;
1673
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
1674
+ return String(n);
1675
+ }
1676
+ function formatVcpuHours(vcpuSeconds) {
1677
+ if (vcpuSeconds < 60) return `${Math.round(vcpuSeconds)} vCPU-s`;
1678
+ if (vcpuSeconds < 3600) return `${(vcpuSeconds / 60).toFixed(1)} vCPU-min`;
1679
+ return `${(vcpuSeconds / 3600).toFixed(2)} vCPU-h`;
1680
+ }
1681
+
1584
1682
  //#endregion
1585
1683
  //#region src/chat/tui/chunks/frame.tsx
1586
1684
  /** Status glyph + color for a tool's lifecycle state. */
@@ -1775,7 +1873,8 @@ function portalIndicator({ portal, agentId, available }) {
1775
1873
  text: "○ portal off",
1776
1874
  color: theme.muted
1777
1875
  };
1778
- case "connecting": return {
1876
+ case "connecting":
1877
+ case "stand-down": return {
1779
1878
  text: "○ portal connecting",
1780
1879
  color: theme.muted
1781
1880
  };
@@ -1869,6 +1968,7 @@ function StatusBar({ rightHint }) {
1869
1968
  const workspaceName = useStore((s) => s.workspaceName);
1870
1969
  const screen = useStore((s) => s.screen);
1871
1970
  const chatTitle = useStore((s) => s.chatTitle);
1971
+ const chatSpend = useStore((s) => s.chatSpend);
1872
1972
  const portal = useStore((s) => s.portal);
1873
1973
  const authKind = useStore((s) => s.authKind);
1874
1974
  const { width } = useTerminalDimensions();
@@ -1878,10 +1978,11 @@ function StatusBar({ rightHint }) {
1878
1978
  agentId: screenAgentId(screen),
1879
1979
  available: authKind === "session"
1880
1980
  });
1981
+ const right = formatSpendBadge(chatSpend?.cents ?? null) ?? rightHint;
1881
1982
  const middle = fitMiddle({
1882
1983
  label: screenLabel(screen, chatTitle),
1883
1984
  width,
1884
- fixed: left.length + badge.text.length + (rightHint?.length ?? 0)
1985
+ fixed: left.length + badge.text.length + (right?.length ?? 0)
1885
1986
  });
1886
1987
  return /* @__PURE__ */ jsxs("box", {
1887
1988
  style: {
@@ -1910,9 +2011,9 @@ function StatusBar({ rightHint }) {
1910
2011
  fg: badge.color,
1911
2012
  children: [badge.text, " "]
1912
2013
  }),
1913
- rightHint ? /* @__PURE__ */ jsx("text", {
2014
+ right ? /* @__PURE__ */ jsx("text", {
1914
2015
  fg: theme.muted,
1915
- children: rightHint
2016
+ children: right
1916
2017
  }) : null
1917
2018
  ]
1918
2019
  });
@@ -2296,6 +2397,7 @@ async function loadFreeFloor(rest) {
2296
2397
  return NO_FREE_FLOOR;
2297
2398
  }
2298
2399
  }
2400
+ const SIGNAL_STEPS = 6;
2299
2401
  /**
2300
2402
  * A 404 from the pin route means the server predates it, not that the pick
2301
2403
  * failed: a canary CLI publishes minutes after its release push while the
@@ -2599,7 +2701,7 @@ function ModelList({ models, floor, currentModel, modelLocked, trainingOptedOut,
2599
2701
  const hits = fuzzyFilter(query, models, modelKeys);
2600
2702
  const filtered = hits.map((h) => h.item);
2601
2703
  const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
2602
- const visibleRows = Math.max(3, height - 8);
2704
+ const visibleRows = Math.max(3, height - 8 - (filtered[clamped]?.signals ? 1 : 0));
2603
2705
  const start = windowStart(clamped, filtered.length, visibleRows);
2604
2706
  const windowed = hits.slice(start, start + visibleRows);
2605
2707
  useKeyboard((key) => {
@@ -2723,6 +2825,7 @@ function ModelList({ models, floor, currentModel, modelLocked, trainingOptedOut,
2723
2825
  }, m.id);
2724
2826
  })
2725
2827
  }),
2828
+ highlighted?.signals ? /* @__PURE__ */ jsx(ModelSignals, { signals: highlighted.signals }) : null,
2726
2829
  /* @__PURE__ */ jsx("text", {
2727
2830
  fg: saveError ? theme.error : theme.dim,
2728
2831
  children: statusLine
@@ -2730,6 +2833,46 @@ function ModelList({ models, floor, currentModel, modelLocked, trainingOptedOut,
2730
2833
  ]
2731
2834
  });
2732
2835
  }
2836
+ function ModelSignals({ signals }) {
2837
+ return /* @__PURE__ */ jsx("box", {
2838
+ style: { flexDirection: "column" },
2839
+ children: /* @__PURE__ */ jsxs("box", {
2840
+ style: {
2841
+ flexDirection: "row",
2842
+ height: 1,
2843
+ gap: 2
2844
+ },
2845
+ children: [
2846
+ /* @__PURE__ */ jsx(Signal, {
2847
+ label: "Intelligence",
2848
+ value: signals.intelligence
2849
+ }),
2850
+ /* @__PURE__ */ jsx(Signal, {
2851
+ label: "Speed",
2852
+ value: signals.speed
2853
+ }),
2854
+ /* @__PURE__ */ jsx(Signal, {
2855
+ label: "Cost",
2856
+ value: signals.cost
2857
+ })
2858
+ ]
2859
+ })
2860
+ });
2861
+ }
2862
+ function Signal({ label, value }) {
2863
+ if (value === null) return null;
2864
+ return /* @__PURE__ */ jsxs("text", { children: [
2865
+ /* @__PURE__ */ jsxs("span", {
2866
+ fg: theme.muted,
2867
+ children: [label, " "]
2868
+ }),
2869
+ /* @__PURE__ */ jsx("span", { children: "●".repeat(value) }),
2870
+ /* @__PURE__ */ jsx("span", {
2871
+ fg: theme.dim,
2872
+ children: "○".repeat(SIGNAL_STEPS - value)
2873
+ })
2874
+ ] });
2875
+ }
2733
2876
  function EffortList({ model, activeEffort, isCurrentModel, saving, saveError, onSelect, onBack }) {
2734
2877
  const levels = effortLevels(model);
2735
2878
  const [highlight, setHighlight] = useState(() => {
@@ -2810,6 +2953,10 @@ function parseApiError(body) {
2810
2953
  return null;
2811
2954
  }
2812
2955
 
2956
+ //#endregion
2957
+ //#region ../agents/src/name.ts
2958
+ const AGENT_NAME_MAX_LENGTH = 120;
2959
+
2813
2960
  //#endregion
2814
2961
  //#region src/chat/tui/agent-name.ts
2815
2962
  function parseAgentName(value) {
@@ -2818,9 +2965,9 @@ function parseAgentName(value) {
2818
2965
  ok: false,
2819
2966
  message: "Enter a name."
2820
2967
  };
2821
- if (name.length > 120) return {
2968
+ if (name.length > AGENT_NAME_MAX_LENGTH) return {
2822
2969
  ok: false,
2823
- message: "Use 120 characters or fewer."
2970
+ message: `Use ${AGENT_NAME_MAX_LENGTH} characters or fewer.`
2824
2971
  };
2825
2972
  return {
2826
2973
  ok: true,
@@ -4644,9 +4791,12 @@ function ConversationHome({ agent, initialQuery, conversations, showAutomated, o
4644
4791
  };
4645
4792
  const identityRows = height >= 24 ? 3 : 0;
4646
4793
  const trayRows = attachmentTrayRows(attachments.length);
4647
- const fixedRows = 6 + brandHeaderRows(height, width) + identityRows + trayRows;
4648
- const composerRows = Math.max(1, Math.min(composerLines, height - fixedRows - 2 - MIN_LIST_ROWS));
4649
- const visibleRows = Math.max(MIN_LIST_ROWS, height - fixedRows - (composerRows + 2));
4794
+ const { composerRows, visibleRows, listGapRows } = pickerRowBudget({
4795
+ height,
4796
+ fixedRows: 6 + brandHeaderRows(height, width) + identityRows + trayRows,
4797
+ composerLines,
4798
+ listRowCount: rows.length
4799
+ });
4650
4800
  const start = windowStart(clamped, rows.length, visibleRows);
4651
4801
  const windowed = rows.slice(start, start + visibleRows);
4652
4802
  const openHighlighted = () => {
@@ -4958,6 +5108,7 @@ function ConversationHome({ agent, initialQuery, conversations, showAutomated, o
4958
5108
  fg: status.fg,
4959
5109
  children: status.text
4960
5110
  }),
5111
+ /* @__PURE__ */ jsx("box", { style: { height: listGapRows } }),
4961
5112
  /* @__PURE__ */ jsx("box", {
4962
5113
  style: {
4963
5114
  flexDirection: "column",
@@ -5013,6 +5164,29 @@ function ConversationHome({ agent, initialQuery, conversations, showAutomated, o
5013
5164
  });
5014
5165
  }
5015
5166
  const MIN_LIST_ROWS = 3;
5167
+ const LIST_GAP_ROWS = 5;
5168
+ /**
5169
+ * The picker's vertical budget in one place, so the composer cap, the list
5170
+ * window, and the gap can't drift apart into competing formulas. Priority,
5171
+ * top of the screen down: the composer grows to fit its content first (capped
5172
+ * so it can't eat the gap — past the cap the textarea scrolls instead of
5173
+ * growing), the gap then always keeps its LIST_GAP_ROWS empty rows, and the
5174
+ * list window gets the remaining spare (floored at MIN_LIST_ROWS for
5175
+ * terminals too short to afford both). The box is content-height, so a short
5176
+ * list's leftover spare becomes extra breathing room on top of the reserved
5177
+ * gap.
5178
+ */
5179
+ function pickerRowBudget(input) {
5180
+ const composerRows = Math.max(1, Math.min(input.composerLines, input.height - input.fixedRows - 2 - LIST_GAP_ROWS));
5181
+ const spareRows = input.height - input.fixedRows - (composerRows + 2);
5182
+ const visibleRows = Math.max(MIN_LIST_ROWS, spareRows - LIST_GAP_ROWS);
5183
+ const paintedRows = Math.min(Math.max(input.listRowCount, 1), visibleRows);
5184
+ return {
5185
+ composerRows,
5186
+ visibleRows,
5187
+ listGapRows: Math.max(0, Math.min(LIST_GAP_ROWS, spareRows - paintedRows))
5188
+ };
5189
+ }
5016
5190
  const composerKeyBindings$1 = [
5017
5191
  {
5018
5192
  name: "return",
@@ -8483,17 +8657,19 @@ function formatChatFooterStatus({ runKind, ctrlCArmed, portalOpen, hasPendingSte
8483
8657
  * claim the footer can't back.
8484
8658
  *
8485
8659
  * A thread that serves its own model reads differently from one following the
8486
- * agent (ANY-8702): a differing `agentModel` gets a `thread` marker plus the
8487
- * default it replaces — the "set and not the same as the default" case James
8488
- * asked the footer to surface. Provenance is derived from the pair, not
8489
- * carried as a flag (James, #24018 review). When the thread follows the
8490
- * default — or the server predates pins and sends no `agentModel` — the model
8491
- * renders bare exactly as before.
8660
+ * agent (ANY-8702): a differing `agentModel` gets a `thread` marker — the "set
8661
+ * and not the same as the default" case James asked the footer to surface.
8662
+ * Provenance is derived from the pair, not carried as a flag (James, #24018
8663
+ * review). The marker is the whole message: naming the agent's default beside
8664
+ * it read as a second model the footer might switch to (Marcus, 2026-10-03),
8665
+ * so the default is not spelled out. When the thread follows the default —
8666
+ * or the server predates pins and sends no `agentModel` — the model renders
8667
+ * bare exactly as before.
8492
8668
  */
8493
8669
  function formatModelHint({ model, effort, agentModel }) {
8494
8670
  const effortPart = effort ? ` · ${effort}` : "";
8495
8671
  if (model === null || agentModel === null || agentModel === model) return `ctrl+p · ${model ?? "default"}${effortPart}`;
8496
- return `ctrl+p · ${model} (thread) · agent default ${agentModel}${effortPart}`;
8672
+ return `ctrl+p · ${model} (thread)${effortPart}`;
8497
8673
  }
8498
8674
 
8499
8675
  //#endregion
@@ -8558,14 +8734,12 @@ function collectSandboxStats(cfg) {
8558
8734
  * conversation.
8559
8735
  */
8560
8736
  /**
8561
- * Wire-off for the whole panel (binding, help entry, render). The panel ships
8562
- * only a title line plus the sandbox gauges, so it is held behind this gate
8563
- * until it has enough content to be worth the screen column; flip to true and
8564
- * drop this comment to re-enable. The binding and the help registry are keyed
8565
- * on this directly; the render is unreachable because sidePanelOpen can only
8566
- * flip through the gated binding.
8737
+ * Wire-off for the whole panel (binding, help entry, render). Costs and the
8738
+ * sandbox gauges are both in, so the panel has enough content to earn the
8739
+ * screen column: the gate stays as the one-line home for a future panel-wide
8740
+ * disable.
8567
8741
  */
8568
- const SIDE_PANEL_ENABLED = false;
8742
+ const SIDE_PANEL_ENABLED = true;
8569
8743
  /** Fixed panel width, matching OpenCode's sidebar (42 cols minus padding). */
8570
8744
  const SIDE_PANEL_WIDTH = 40;
8571
8745
  /**
@@ -8623,27 +8797,59 @@ function SidePanel({ open, width, height, agentName, title, collectSandboxStats,
8623
8797
  gap: 2,
8624
8798
  flexShrink: 0
8625
8799
  },
8626
- children: [/* @__PURE__ */ jsxs("box", {
8627
- style: {
8628
- flexDirection: "column",
8629
- flexShrink: 0
8630
- },
8631
- children: [/* @__PURE__ */ jsx("text", {
8632
- fg: theme.fg,
8633
- children: /* @__PURE__ */ jsx("b", { children: title || agentName })
8634
- }), /* @__PURE__ */ jsx("text", {
8635
- fg: theme.dim,
8636
- children: agentName
8637
- })]
8638
- }), /* @__PURE__ */ jsx(ComputerSection, {
8639
- visible: placement === "side",
8640
- collectSandboxStats,
8641
- pollMs
8642
- })]
8800
+ children: [
8801
+ /* @__PURE__ */ jsxs("box", {
8802
+ style: {
8803
+ flexDirection: "column",
8804
+ flexShrink: 0
8805
+ },
8806
+ children: [/* @__PURE__ */ jsx("text", {
8807
+ fg: theme.fg,
8808
+ children: /* @__PURE__ */ jsx("b", { children: title || agentName })
8809
+ }), /* @__PURE__ */ jsx("text", {
8810
+ fg: theme.dim,
8811
+ children: agentName
8812
+ })]
8813
+ }),
8814
+ /* @__PURE__ */ jsx(CostsSection, {}),
8815
+ /* @__PURE__ */ jsx(ComputerSection, {
8816
+ visible: placement === "side",
8817
+ collectSandboxStats,
8818
+ pollMs
8819
+ })
8820
+ ]
8643
8821
  })
8644
8822
  }, "side-panel");
8645
8823
  }
8646
8824
  /**
8825
+ * The panel's Costs section: this conversation's spend so far, fed by the
8826
+ * same store read the status-bar badge shows — the chat screen's
8827
+ * useConversationSpend hook keeps it current (live poll while a run is
8828
+ * running, settle read after, slow poll while idle), so the section adds no
8829
+ * reads of its own. `usage: null` is the org-targeting answer: tracking
8830
+ * simply isn't on here.
8831
+ */
8832
+ function CostsSection() {
8833
+ const chatSpend = useStore((s) => s.chatSpend);
8834
+ const usage = chatSpend?.usage ?? null;
8835
+ return /* @__PURE__ */ jsxs("box", {
8836
+ style: {
8837
+ flexDirection: "column",
8838
+ flexShrink: 0
8839
+ },
8840
+ children: [/* @__PURE__ */ jsx("text", {
8841
+ fg: theme.muted,
8842
+ children: /* @__PURE__ */ jsx("b", { children: "costs" })
8843
+ }), usage === null ? /* @__PURE__ */ jsx("text", {
8844
+ fg: theme.dim,
8845
+ children: chatSpend === null ? "reading spend…" : "spend tracking is off for this workspace"
8846
+ }) : formatSpendSection(usage).map((line, i) => /* @__PURE__ */ jsx("text", {
8847
+ fg: i === 0 ? theme.fg : theme.muted,
8848
+ children: line
8849
+ }, i))]
8850
+ });
8851
+ }
8852
+ /**
8647
8853
  * The panel's Computer section: the sandbox cpu / memory / disk gauges, fed by
8648
8854
  * the same api-backed collector as the /computer card (no exec, no wake; an
8649
8855
  * idle agent gets an idle line at once). Polls on the card's 30s cadence while
@@ -9813,6 +10019,13 @@ const slashCommands = [
9813
10019
  description: "show the agent sandbox's cpu/memory/disk (restart: reboot it)",
9814
10020
  action: { kind: "computer" }
9815
10021
  },
10022
+ {
10023
+ name: "cost",
10024
+ aliases: [],
10025
+ argsHint: null,
10026
+ description: "what this conversation has cost so far: inference by model, compute",
10027
+ action: { kind: "cost" }
10028
+ },
9816
10029
  {
9817
10030
  name: "plan",
9818
10031
  aliases: ["todos", "todo"],
@@ -9970,6 +10183,7 @@ const TERMINAL_RESTORE = [
9970
10183
  "\x1B[?1015l",
9971
10184
  "\x1B[<u",
9972
10185
  "\x1B[?1049l",
10186
+ "\x1B[?7h",
9973
10187
  "\x1B[0m"
9974
10188
  ].join("");
9975
10189
  /**
@@ -12837,7 +13051,7 @@ function unfoldSettled(current) {
12837
13051
  * shortcut), which would otherwise shadow it inside the component body.
12838
13052
  */
12839
13053
  async function openUrlInBrowser(url) {
12840
- await open(url).catch(() => void 0);
13054
+ await open$1(url).catch(() => void 0);
12841
13055
  }
12842
13056
  function isActionableCard(m) {
12843
13057
  return m.kind === "card" && m.status !== "done" && m.status !== "busy" && m.card.button !== null;
@@ -15179,6 +15393,7 @@ function routeInput(raw) {
15179
15393
  case "theme-picker":
15180
15394
  case "pr-picker":
15181
15395
  case "status":
15396
+ case "cost":
15182
15397
  case "share":
15183
15398
  case "browser":
15184
15399
  case "connect":
@@ -15227,7 +15442,7 @@ function formatShellContext(command, output, exitCode) {
15227
15442
  * bar and footer can't fit, one aligned `label value` row each. Pure so the
15228
15443
  * layout is testable without a renderer.
15229
15444
  */
15230
- function formatStatusInfo({ appUrl, workspaceName, agentName, conversationId, model, effort, portal }) {
15445
+ function formatStatusInfo({ appUrl, workspaceName, agentName, conversationId, model, effort, portal, spendCents }) {
15231
15446
  const rows = [
15232
15447
  ["app", appUrl ?? "unknown"],
15233
15448
  ["workspace", workspaceName ?? "unknown"],
@@ -15237,6 +15452,7 @@ function formatStatusInfo({ appUrl, workspaceName, agentName, conversationId, mo
15237
15452
  ];
15238
15453
  if (effort) rows.push(["effort", effort]);
15239
15454
  rows.push(["portal", formatPortal(portal)]);
15455
+ if (spendCents !== null) rows.push(["spend", `${formatSpendBadge(spendCents)} so far (/cost for the breakdown)`]);
15240
15456
  const width = Math.max(...rows.map(([label]) => label.length));
15241
15457
  return rows.map(([label, value]) => `${label.padEnd(width)} ${value}`).join("\n");
15242
15458
  }
@@ -15258,7 +15474,7 @@ function formatPortal(portal) {
15258
15474
  * The slash-command dispatcher for the chat screen, extracted from ChatScreen
15259
15475
  * (ANY-8935 slice 6). Verbatim code motion; comments carried.
15260
15476
  */
15261
- function useExecuteLine({ agent, rest, conversationId, appUrl, goTo, history, setInput, setComposerRows, setItems, composerRef, runLocalShell, runSandboxExec, runSandboxPty, archiveConversation, renameConversation, forkAndContinue, compactConversation, openInBrowser, copyLastMessage, exportTranscript, sendContent, leaveForScreen, openConversationRef, toggleShare, openPortalAccess, quit, forkingRef, shellSessionRef, prLinks, subagentMinimized, planMinimized, anchoredSubagentBatchRef, clearAttachments, model, effort, planHidden, setPlanHidden, setPlanExpanded, setPlanMinimizedBy, portal, promptForCards, runComputerStats, runRef, setHelpOpen, setModelPickerView, setPrPickerOpen, setRestartConfirm, setSubagentMinimizedBy, setSubagentSelection, setThemePickerOpen, todos, workspaceName }) {
15477
+ function useExecuteLine({ agent, rest, conversationId, appUrl, goTo, history, setInput, setComposerRows, setItems, composerRef, runLocalShell, runSandboxExec, runSandboxPty, archiveConversation, renameConversation, forkAndContinue, compactConversation, openInBrowser, copyLastMessage, exportTranscript, sendContent, leaveForScreen, openConversationRef, toggleShare, openPortalAccess, quit, forkingRef, shellSessionRef, prLinks, subagentMinimized, planMinimized, anchoredSubagentBatchRef, clearAttachments, model, effort, planHidden, setPlanHidden, setPlanExpanded, setPlanMinimizedBy, portal, promptForCards, runComputerStats, showCost, runRef, setHelpOpen, setModelPickerView, setPrPickerOpen, setRestartConfirm, setSubagentMinimizedBy, setSubagentSelection, setThemePickerOpen, todos, workspaceName }) {
15262
15478
  return { executeLine: useCallback((content, staged) => {
15263
15479
  if (!content && staged.length === 0) return;
15264
15480
  if (forkingRef.current) {
@@ -15365,18 +15581,23 @@ function useExecuteLine({ agent, rest, conversationId, appUrl, goTo, history, se
15365
15581
  workspaceName,
15366
15582
  agentName: agent.name,
15367
15583
  conversationId,
15584
+ spendCents: useStore.getState().chatSpend?.cents ?? null,
15368
15585
  model,
15369
15586
  effort,
15370
15587
  portal: {
15371
15588
  status: portal.status,
15372
15589
  grantedToAgent: portal.grantedAgentIds.includes(agent.id)
15373
15590
  }
15374
- })
15591
+ }),
15592
+ spendCents: useStore.getState().chatSpend?.cents ?? null
15375
15593
  }]);
15376
15594
  break;
15377
15595
  case "computer":
15378
15596
  runComputerStats();
15379
15597
  break;
15598
+ case "cost":
15599
+ showCost();
15600
+ break;
15380
15601
  case "computer-restart":
15381
15602
  setRestartConfirm(true);
15382
15603
  break;
@@ -15478,6 +15699,7 @@ function useExecuteLine({ agent, rest, conversationId, appUrl, goTo, history, se
15478
15699
  runLocalShell,
15479
15700
  runSandboxExec,
15480
15701
  runComputerStats,
15702
+ showCost,
15481
15703
  runSandboxPty,
15482
15704
  archiveConversation,
15483
15705
  renameConversation,
@@ -15661,7 +15883,7 @@ function useConversationActions({ rest, appUrl, conversationId, agent, goTo, dri
15661
15883
  return;
15662
15884
  }
15663
15885
  const url = `${resolveWebUrl(appUrl)}/c/${encodeURIComponent(conversationId)}`;
15664
- open(url).then(() => useStore.getState().showToast({
15886
+ open$1(url).then(() => useStore.getState().showToast({
15665
15887
  message: `opened in browser: ${url}`,
15666
15888
  variant: "success"
15667
15889
  })).catch((err) => setItems((prev) => [...prev, {
@@ -15675,7 +15897,7 @@ function useConversationActions({ rest, appUrl, conversationId, agent, goTo, dri
15675
15897
  const target = itemsRef.current.find((m) => m.kind === "agent-file" && m.id === id);
15676
15898
  if (!target || target.status === "saving") return;
15677
15899
  if (target.status === "saved" && target.savedPath) {
15678
- open(target.savedPath).catch(() => void 0);
15900
+ open$1(target.savedPath).catch(() => void 0);
15679
15901
  useStore.getState().showToast({
15680
15902
  message: `opened ${basename(target.savedPath)}`,
15681
15903
  variant: "success"
@@ -15706,7 +15928,7 @@ function useConversationActions({ rest, appUrl, conversationId, agent, goTo, dri
15706
15928
  message: `saved to ${path}`,
15707
15929
  variant: "success"
15708
15930
  });
15709
- open(path).catch(() => void 0);
15931
+ open$1(path).catch(() => void 0);
15710
15932
  } catch (err) {
15711
15933
  patch({
15712
15934
  status: "error",
@@ -15999,6 +16221,7 @@ function useConversationActions({ rest, appUrl, conversationId, agent, goTo, dri
15999
16221
  useStore.getState().showToast({ message: "opening the portal to this machine…" });
16000
16222
  break;
16001
16223
  case "connecting":
16224
+ case "stand-down":
16002
16225
  useStore.getState().showToast({ message: "the portal is still connecting…" });
16003
16226
  break;
16004
16227
  case "connected":
@@ -16222,6 +16445,72 @@ function useLocalCommands({ setItems, shellSessionRef, bindPortalCwd, sendConten
16222
16445
  };
16223
16446
  }
16224
16447
 
16448
+ //#endregion
16449
+ //#region src/chat/tui/chat/use-conversation-spend.ts
16450
+ const LIVE_POLL_MS = 1e4;
16451
+ const SETTLE_AFTER_RUN_MS = 8e3;
16452
+ const IDLE_POLL_MS = 6e4;
16453
+ /**
16454
+ * Keeps the store's `chatSpend` — what the status bar shows and the side
16455
+ * panel's costs section reads — current for the open conversation: one read
16456
+ * when it opens, a slow poll while a run is live, a read at run completion
16457
+ * plus one settle read for the trailing telemetry, and a slow background
16458
+ * poll so a panel opened mid-idle converges. The store is the single copy
16459
+ * of the figure; `/status` reads it from there and `/cost` calls
16460
+ * {@link refresh} for a fresh breakdown.
16461
+ *
16462
+ * Reads are best effort — a failed one keeps the last figure rather than
16463
+ * surfacing an error in the transcript; `/cost` is where a user asks
16464
+ * explicitly and gets told.
16465
+ */
16466
+ function useConversationSpend({ rest, conversationId, runActive }) {
16467
+ const setChatSpend = useStore((s) => s.setChatSpend);
16468
+ const conversationIdRef = useRef(conversationId);
16469
+ conversationIdRef.current = conversationId;
16470
+ const refresh = useCallback(async () => {
16471
+ if (!rest || !conversationId) return { kind: "unavailable" };
16472
+ let usage;
16473
+ try {
16474
+ usage = await rest.getConversationUsage({ conversationId });
16475
+ } catch (_error) {
16476
+ return { kind: "unavailable" };
16477
+ }
16478
+ if (conversationIdRef.current !== conversationId) return { kind: "unavailable" };
16479
+ if (!usage) return { kind: "off" };
16480
+ setChatSpend(usage.totalCents, usage);
16481
+ return {
16482
+ kind: "usage",
16483
+ usage
16484
+ };
16485
+ }, [
16486
+ rest,
16487
+ conversationId,
16488
+ setChatSpend
16489
+ ]);
16490
+ useEffect(() => {
16491
+ setChatSpend(null, null);
16492
+ refresh();
16493
+ }, [refresh, setChatSpend]);
16494
+ const wasActiveRef = useRef(runActive);
16495
+ useEffect(() => {
16496
+ if (runActive) {
16497
+ const timer = setInterval(() => void refresh(), LIVE_POLL_MS);
16498
+ wasActiveRef.current = true;
16499
+ return () => clearInterval(timer);
16500
+ }
16501
+ if (!wasActiveRef.current) return;
16502
+ wasActiveRef.current = false;
16503
+ refresh();
16504
+ const settle = setTimeout(() => void refresh(), SETTLE_AFTER_RUN_MS);
16505
+ return () => clearTimeout(settle);
16506
+ }, [runActive, refresh]);
16507
+ useEffect(() => {
16508
+ const timer = setInterval(() => void refresh(), IDLE_POLL_MS);
16509
+ return () => clearInterval(timer);
16510
+ }, [refresh]);
16511
+ return { refresh };
16512
+ }
16513
+
16225
16514
  //#endregion
16226
16515
  //#region src/chat/tui/screens/chat/transcript-row.tsx
16227
16516
  /**
@@ -16379,6 +16668,11 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
16379
16668
  const [historyLoaded, setHistoryLoaded] = useState(initialConversationId === null);
16380
16669
  const [input, setInput] = useState(() => useStore.getState().takeEarlyInput());
16381
16670
  const [run, setRun] = useState({ kind: "idle" });
16671
+ const spend = useConversationSpend({
16672
+ rest,
16673
+ conversationId,
16674
+ runActive: run.kind !== "idle"
16675
+ });
16382
16676
  const [model, setModel] = useState(agent.model ?? null);
16383
16677
  const [effort, setEffort] = useState(agent.thinkingLevel ?? null);
16384
16678
  const [modelLocked, setModelLocked] = useState(agent.modelLocked ?? false);
@@ -16871,7 +17165,20 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
16871
17165
  rest,
16872
17166
  renderer
16873
17167
  });
17168
+ const refreshSpend = spend.refresh;
17169
+ const showCost = useCallback(async () => {
17170
+ const read = conversationId ? await refreshSpend() : null;
17171
+ setItems((prev) => [...prev, {
17172
+ kind: "info",
17173
+ id: crypto.randomUUID(),
17174
+ title: "cost",
17175
+ text: formatCostRead(read)
17176
+ }]);
17177
+ }, [conversationId, refreshSpend]);
16874
17178
  const { executeLine } = useExecuteLine({
17179
+ showCost: () => {
17180
+ showCost();
17181
+ },
16875
17182
  agent,
16876
17183
  rest,
16877
17184
  conversationId,
@@ -18459,6 +18766,22 @@ function onRenderCommit(id, phase, actualDuration, baseDuration, startTime, comm
18459
18766
  //#endregion
18460
18767
  //#region src/chat/boot.tsx
18461
18768
  /**
18769
+ * Disable host autowrap and create the renderer that owns restoring it.
18770
+ * If `createCliRenderer` throws (bad TERM, boot-time init failure), its
18771
+ * `onDestroy` never runs, so the disable is undone here before the error
18772
+ * propagates — otherwise the terminal is left without autowrap for
18773
+ * whatever prints next (see tui/autowrap.ts).
18774
+ */
18775
+ async function bootRenderer(options) {
18776
+ disableTerminalAutowrap();
18777
+ try {
18778
+ return await createCliRenderer(options);
18779
+ } catch (err) {
18780
+ restoreTerminalAutowrap();
18781
+ throw err;
18782
+ }
18783
+ }
18784
+ /**
18462
18785
  * Boots the OpenTUI chat interface. Called by the `chat` command only
18463
18786
  * after it has confirmed a Bun runtime and resolved a session, so this
18464
18787
  * module — and the heavy OpenTUI dependency graph it pulls in — is
@@ -18468,9 +18791,10 @@ async function runChat({ appUrl, sessionToken, authKind = "session", workspaceId
18468
18791
  await registerGrammars();
18469
18792
  const watchdog = startMemoryWatchdog();
18470
18793
  installCrashHandler({ memoryProvider: () => watchdog.snapshot() });
18471
- const renderer = await createCliRenderer({
18794
+ const renderer = await bootRenderer({
18472
18795
  exitOnCtrlC: false,
18473
- ...profilingRendererOptions()
18796
+ ...profilingRendererOptions(),
18797
+ onDestroy: restoreTerminalAutowrap
18474
18798
  });
18475
18799
  startRendererStatsCapture(renderer);
18476
18800
  startStateCapture(useStore);
@@ -18514,7 +18838,10 @@ async function runChat({ appUrl, sessionToken, authKind = "session", workspaceId
18514
18838
  }
18515
18839
  /** Open the same workspace picker used by the initial agent screen. */
18516
18840
  async function runWorkspacePicker({ appUrl, sessionToken }) {
18517
- const renderer = await createCliRenderer({ exitOnCtrlC: false });
18841
+ const renderer = await bootRenderer({
18842
+ exitOnCtrlC: false,
18843
+ onDestroy: restoreTerminalAutowrap
18844
+ });
18518
18845
  await settleBootThemeMode({
18519
18846
  source: renderer,
18520
18847
  applyThemeMode: (mode) => useStore.getState().setThemeMode(mode)