scream-code 0.15.4 → 0.15.6

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.
@@ -4,8 +4,8 @@ import { dirname as __cjsShimDirname } from 'node:path';
4
4
  const __filename = __cjsShimFileURLToPath(import.meta.url);
5
5
  const __dirname = __cjsShimDirname(__filename);
6
6
  import "./suppress-sqlite-warning-C2VB0doZ.mjs";
7
- import { $t as isTodoItemShape, A as renderDiffLinesClustered, An as getInputHistoryFile, At as ENABLE_TERMINAL_FOCUS_REPORTING, B as BackgroundAgentStatusComponent, Bn as ScreamHarness, Bt as TERMINAL_THEME_LIGHT, Cn as PULSE_WAVE_FRAMES, Ct as createThemeStyles, D as toggleEmptySessionHint, Dn as saveTuiConfig, Dt as parseOsc11BackgroundTheme, E as isTurnElapsedEnabled, En as loadTuiConfig, Et as detectTerminalTheme, F as estimateTokens, Fn as CLI_USER_AGENT_PRODUCT, Ft as OSC11_RESPONSE_PREFIX_NO_ESC, G as getBreathingFrame, Gn as log, H as AgentGroupComponent, Hn as resolveScreamHome, Ht as isStreaming, I as getSharedSpeedTracker, In as PRODUCT_NAME, It as QUERY_TERMINAL_THEME, J as refineGoal, Jn as isOrphanedToolCallError, Jt as printableChar, K as resetBreathingClock, Kn as resolveGlobalLogPath, L as SkillActivationComponent, Ln as DEFAULT_CATALOG_URL, Lt as TERMINAL_FOCUS_IN, M as langFromPath, Mn as detectInstallSource, Mt as OSC11_QUERY, N as CachedContainer, Nn as CLI_COMMAND_NAME, Nt as OSC11_RESPONSE, O as ToolCallComponent, On as detectShellEnvironment, Ot as DISABLE_TERMINAL_FOCUS_REPORTING, P as ThinkingComponent, Pn as CLI_UI_MODE, Pt as OSC11_RESPONSE_PREFIX, Qt as formatErrorMessage, R as ReadGroupComponent, Rn as fetchCatalog, Rt as TERMINAL_FOCUS_OUT, S as getDaemonInstructions, Sn as PIXEL_PULSE_FRAMES, St as createMarkdownTheme, T as isEmptySessionHintDismissed, Tn as TuiLikePreferencesSchema, Tt as getColorPalette, U as WelcomeComponent, Un as MemoryMemoStore, Ut as FooterComponent, V as AssistantMessageComponent, Vt as isBusy, W as BREATHE_CYCLE_MS, Wn as flushDiagnosticLogs, Xn as SCREAM_ERROR_INFO, Xt as appendStreamingArgsPreview, Yn as ErrorCodes, Yt as STATUS_BULLET, Zt as argsRecord, _ as refreshUpdateCache, _n as isExperimentalFlagEnabled, _t as clearInfoPanelState, an as EMPTY_SESSION_HINT_URL, b as readJsonlFile, bn as isEmbeddingModelCached, bt as resolveThemeSync, c as hasDispose, cn as SESSION_TIPS, ct as handlePlanCommand, d as formatMemoryMemoForInjection, dn as getCtrlDHint, en as parseStreamingArgs, et as toTerminalHyperlink, fn as getLlmNotSetMessage, g as selectUpdateTarget, gn as sortSlashCommands, gt as supportsBalance, hn as BUILTIN_SLASH_COMMANDS, ht as refreshProviderBalance, i as buildRoleAdditionalText, in as CHARS_PER_TOKEN, j as highlightLines, jn as getLogDir, jt as ENABLE_TERMINAL_THEME_REPORTING, k as renderDiffLines, kn as getDataDir, kt as DISABLE_TERMINAL_THEME_REPORTING, l as isPlanExpandable, ln as TIP_ROTATION_INTERVAL_MS, mn as buildSkillSlashCommands, n as clearEvalPanelState, nn as stringValue, nt as getModelCycleLevel, on as EXIT_CONFIRM_WINDOW_MS, ot as handleFusionPlanCommand, pn as getNoActiveSessionMessage, q as clearGoalState, qn as isScreamError, r as openUrl, rn as truncateErrorMessage, s as disposeChildren, sn as MAIN_AGENT_ID, t as dispatchInput, tn as serializeToolResultOutput, tt as changeThinkingLevel, u as MoonLoader, un as getCtrlCHint, v as readUpdateCache, vn as setExperimentalFlags, w as UserMessageComponent, wn as TuiConfigParseError, wt as contrastTextHex, xn as startManualEmbeddingDownload, xt as createEditorTheme, y as appendJsonlLine, yn as getKnowledgeStore, z as parseReadGroupOutput, zn as saveCatalogCache, zt as TERMINAL_THEME_DARK } from "./dispatch-m6mYTI1M.mjs";
8
- import { a as setLocale, o as t } from "./text-input-dialog-B2a6Ks8f.mjs";
7
+ import { $t as formatErrorMessage, A as renderDiffLinesClustered, An as getDataDir, At as ENABLE_TERMINAL_FOCUS_REPORTING, B as BackgroundAgentStatusComponent, Bn as saveCatalogCache, Bt as TERMINAL_THEME_LIGHT, Cn as PIXEL_PULSE_FRAMES, Ct as createThemeStyles, D as toggleEmptySessionHint, Dn as loadTuiConfig, Dt as parseOsc11BackgroundTheme, E as isTurnElapsedEnabled, En as TuiLikePreferencesSchema, Et as detectTerminalTheme, F as estimateTokens, Fn as CLI_UI_MODE, Ft as OSC11_RESPONSE_PREFIX_NO_ESC, G as getBreathingFrame, Gn as flushDiagnosticLogs, H as AgentGroupComponent, Ht as isStreaming, I as getSharedSpeedTracker, In as CLI_USER_AGENT_PRODUCT, It as QUERY_TERMINAL_THEME, J as refineGoal, Jn as isScreamError, K as resetBreathingClock, Kn as log, L as SkillActivationComponent, Ln as PRODUCT_NAME, Lt as TERMINAL_FOCUS_IN, M as langFromPath, Mn as getLogDir, Mt as OSC11_QUERY, N as CachedContainer, Nn as detectInstallSource, Nt as OSC11_RESPONSE, O as ToolCallComponent, On as saveTuiConfig, Ot as DISABLE_TERMINAL_FOCUS_REPORTING, P as ThinkingComponent, Pn as CLI_COMMAND_NAME, Pt as OSC11_RESPONSE_PREFIX, Qt as argsRecord, R as ReadGroupComponent, Rn as DEFAULT_CATALOG_URL, Rt as TERMINAL_FOCUS_OUT, S as getDaemonInstructions, Sn as startManualEmbeddingDownload, St as createMarkdownTheme, T as isEmptySessionHintDismissed, Tn as TuiConfigParseError, Tt as getColorPalette, U as WelcomeComponent, Un as resolveScreamHome, Ut as FooterComponent, V as AssistantMessageComponent, Vn as ScreamHarness, Vt as isBusy, W as BREATHE_CYCLE_MS, Wn as MemoryMemoStore, Wt as lerpGradient, Xn as ErrorCodes, Xt as STATUS_BULLET, Yn as isOrphanedToolCallError, Yt as printableChar, Zn as SCREAM_ERROR_INFO, Zt as appendStreamingArgsPreview, _ as refreshUpdateCache, _n as sortSlashCommands, _t as clearInfoPanelState, an as CHARS_PER_TOKEN, b as readJsonlFile, bn as getKnowledgeStore, bt as resolveThemeSync, c as hasDispose, cn as MAIN_AGENT_ID, ct as handlePlanCommand, d as formatMemoryMemoForInjection, dn as getCtrlCHint, en as isTodoItemShape, et as toTerminalHyperlink, fn as getCtrlDHint, g as selectUpdateTarget, gn as BUILTIN_SLASH_COMMANDS, gt as supportsBalance, hn as buildSkillSlashCommands, ht as refreshProviderBalance, i as buildRoleAdditionalText, in as truncateErrorMessage, j as highlightLines, jn as getInputHistoryFile, jt as ENABLE_TERMINAL_THEME_REPORTING, k as renderDiffLines, kn as detectShellEnvironment, kt as DISABLE_TERMINAL_THEME_REPORTING, l as isPlanExpandable, ln as SESSION_TIPS, mn as getNoActiveSessionMessage, n as clearEvalPanelState, nn as serializeToolResultOutput, nt as getModelCycleLevel, on as EMPTY_SESSION_HINT_URL, ot as handleFusionPlanCommand, pn as getLlmNotSetMessage, q as clearGoalState, qn as resolveGlobalLogPath, r as openUrl, rn as stringValue, s as disposeChildren, sn as EXIT_CONFIRM_WINDOW_MS, t as dispatchInput, tn as parseStreamingArgs, tt as changeThinkingLevel, u as MoonLoader, un as TIP_ROTATION_INTERVAL_MS, v as readUpdateCache, vn as isExperimentalFlagEnabled, w as UserMessageComponent, wn as PULSE_WAVE_FRAMES, wt as contrastTextHex, xn as isEmbeddingModelCached, xt as createEditorTheme, y as appendJsonlLine, yn as setExperimentalFlags, z as parseReadGroupOutput, zn as fetchCatalog, zt as TERMINAL_THEME_DARK } from "./dispatch-jQiqBKV2.mjs";
8
+ import { a as setLocale, i as getLocale, o as t } from "./text-input-dialog-LOMBSusF.mjs";
9
9
  import { createRequire } from "node:module";
10
10
  import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
11
11
  import { access, chmod, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
@@ -24,7 +24,7 @@ import process$1 from "node:process";
24
24
  import { Command, Option } from "commander";
25
25
  import { createInterface } from "node:readline/promises";
26
26
  import chalk, { chalkStderr } from "chalk";
27
- import { CombinedAutocompleteProvider, Container, Editor, Input, Key, ProcessTerminal, ScrollView, Spacer, Text, TuiAltScreen, VStack, decodeKittyPrintable, deleteAllKittyImages, fuzzyFilter, fuzzyMatch, getCapabilities, isKeyRelease, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@liutod-scream/pi-tui";
27
+ import { CombinedAutocompleteProvider, Container, Editor, HStack, Input, Key, ProcessTerminal, ScrollView, Spacer, Text, TuiAltScreen, VStack, decodeKittyPrintable, deleteAllKittyImages, fuzzyFilter, fuzzyMatch, getCapabilities, isKeyRelease, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@liutod-scream/pi-tui";
28
28
  import { createInterface as createInterface$1 } from "node:readline";
29
29
  import { toString } from "qrcode";
30
30
  //#region src/cli/build-info.ts
@@ -32,7 +32,7 @@ function optionalBuildString(value) {
32
32
  return typeof value === "string" && value.length > 0 ? value : void 0;
33
33
  }
34
34
  const SCREAM_BUILD_INFO = {
35
- version: optionalBuildString("0.15.4"),
35
+ version: optionalBuildString("0.15.6"),
36
36
  channel: optionalBuildString(""),
37
37
  commit: optionalBuildString(""),
38
38
  buildTarget: optionalBuildString("darwin-arm64")
@@ -193,7 +193,8 @@ function createProgram(version, onMain, onPluginNodeRunner = () => {}, onStreamJ
193
193
  pluginDirs: subOpts["pluginDir"] ?? []
194
194
  });
195
195
  });
196
- program.command("web").description("启动浏览器 Web UI").option("--port <port>", "端口号", "3210").option("--model <model>", "LLM 模型别名").option("-y, --yolo", "自动批准所有操作", false).option("--auto", "以自动权限模式启动", false).option("--no-open", "不自动打开浏览器").option("--lan", "开启局域网共享(同网设备凭网关密钥访问)", false).option("--token <key>", "自定义网关访问密钥(保存后供 --lan 使用)").option("--reset-password", "交互式重设网关访问密钥", false).option("--skills-dir <dir>", "从该目录加载技能(可多次指定)", (value, previous) => [...previous ?? [], value], []).action((subOpts) => {
196
+ program.command("web").description("启动浏览器 Web UI").option("--port <port>", "端口号", "3210").option("--model <model>", "LLM 模型别名").option("-y, --yolo", "自动批准所有操作", false).option("--auto", "以自动权限模式启动", false).option("--no-open", "不自动打开浏览器").option("--lan", "开启局域网共享(同网设备凭网关密钥访问)", false).option("--token <key>", "自定义网关访问密钥(保存后供 --lan 使用)").option("--reset-password", "交互式重设网关访问密钥", false).option("--idle-minutes <minutes>", "无浏览器连接 N 分钟后自动退出(0=禁用,默认 15)", "15").option("--skills-dir <dir>", "从该目录加载技能(可多次指定)", (value, previous) => [...previous ?? [], value], []).action((subOpts) => {
197
+ const idleMinutes = Number(subOpts["idleMinutes"] ?? "15");
197
198
  onWeb({
198
199
  port: parseInt(subOpts["port"], 10) || 3210,
199
200
  model: subOpts["model"],
@@ -203,7 +204,8 @@ function createProgram(version, onMain, onPluginNodeRunner = () => {}, onStreamJ
203
204
  skillsDirs: subOpts["skillsDir"] ?? [],
204
205
  lan: subOpts["lan"] ?? false,
205
206
  token: subOpts["token"],
206
- resetPassword: subOpts["resetPassword"] ?? false
207
+ resetPassword: subOpts["resetPassword"] ?? false,
208
+ idleMinutes: Number.isFinite(idleMinutes) && idleMinutes >= 0 ? idleMinutes : 15
207
209
  });
208
210
  });
209
211
  program.command("channel").description("管理 cc-connect 消息平台通道").command("setup").description("配置 cc-connect 并选择要连接的平台").action(() => {
@@ -2023,6 +2025,200 @@ function formatBackgroundAgentTranscript(phase, meta, extras = void 0) {
2023
2025
  };
2024
2026
  }
2025
2027
  //#endregion
2028
+ //#region src/tui/utils/subagent-slots.ts
2029
+ /** Default subagent types, in the fixed display order of the slots. */
2030
+ const DEFAULT_SUBAGENT_TYPES = [
2031
+ "coder",
2032
+ "explore",
2033
+ "plan",
2034
+ "verify",
2035
+ "reviewer",
2036
+ "oracle",
2037
+ "worker",
2038
+ "writer"
2039
+ ];
2040
+ var SubagentSlots = class {
2041
+ slots = /* @__PURE__ */ new Map();
2042
+ instances = /* @__PURE__ */ new Map();
2043
+ /** Live instance count per type: a slot only returns to idle when the LAST
2044
+ * instance of that type terminates (WolfPack spawns several coders at once
2045
+ * and they share one visual slot). */
2046
+ runningByType = /* @__PURE__ */ new Map();
2047
+ /** main-side SendSubagentMessage tool callId → target subagent agentId. */
2048
+ messagingByCall = /* @__PURE__ */ new Map();
2049
+ /** Status to restore per TYPE when a messaging window closes, captured from
2050
+ * the first open window; concurrent windows on the same type keep counting
2051
+ * and the slot only exits messaging when the last window closes. */
2052
+ messagingPrev = /* @__PURE__ */ new Map();
2053
+ /** Count of open messaging windows per type (handles same-type siblings). */
2054
+ messagingCount = /* @__PURE__ */ new Map();
2055
+ reset() {
2056
+ this.slots.clear();
2057
+ this.instances.clear();
2058
+ this.runningByType.clear();
2059
+ this.messagingByCall.clear();
2060
+ this.messagingPrev.clear();
2061
+ this.messagingCount.clear();
2062
+ }
2063
+ /**
2064
+ * subagent.spawned. A first spawn starts the slot as working; a second
2065
+ * spawn of the SAME agentId means the main agent resumed it (返工中),
2066
+ * tracked via an instance generation counter.
2067
+ */
2068
+ onSpawned(agentId, subagentType, description) {
2069
+ const instance = this.instances.get(agentId);
2070
+ if (instance !== void 0) {
2071
+ instance.generation += 1;
2072
+ this.runningByType.set(instance.type, (this.runningByType.get(instance.type) ?? 0) + 1);
2073
+ this.setState(instance.type, "reworking", agentId, description === void 0 ? void 0 : `resume: ${description}`);
2074
+ return;
2075
+ }
2076
+ this.instances.set(agentId, {
2077
+ type: subagentType,
2078
+ generation: 1
2079
+ });
2080
+ this.runningByType.set(subagentType, (this.runningByType.get(subagentType) ?? 0) + 1);
2081
+ this.setState(subagentType, "working", agentId, description);
2082
+ }
2083
+ /** subagent.started. Keeps the current state (spawned already set it); only
2084
+ * used to materialize a slot if lifecycle events raced past us. */
2085
+ onStarted(agentId) {
2086
+ const instance = this.instances.get(agentId);
2087
+ if (instance === void 0) return;
2088
+ const slot = this.ensureSlot(instance.type);
2089
+ if (slot.status === "idle") {
2090
+ slot.status = "working";
2091
+ slot.agentId = agentId;
2092
+ slot.lastActivityAt = Date.now();
2093
+ }
2094
+ }
2095
+ /** subagent.completed / subagent.failed: the slot goes back to resting only
2096
+ * when the LAST live instance of that type terminates (siblings spawned by
2097
+ * the same tool call keep the slot busy). */
2098
+ onTerminated(agentId) {
2099
+ const instance = this.instances.get(agentId);
2100
+ if (instance === void 0) return;
2101
+ const remaining = (this.runningByType.get(instance.type) ?? 1) - 1;
2102
+ if (remaining > 0) {
2103
+ this.runningByType.set(instance.type, remaining);
2104
+ const slot = this.ensureSlot(instance.type);
2105
+ slot.detail = void 0;
2106
+ slot.lastActivityAt = Date.now();
2107
+ return;
2108
+ }
2109
+ this.runningByType.delete(instance.type);
2110
+ this.setState(instance.type, "idle", void 0, void 0);
2111
+ }
2112
+ /**
2113
+ * Routed subagent activity (agentId !== main):
2114
+ * - kind 'tool' → working (tool call in flight)
2115
+ * - kind 'output' → outputting (assistant/thinking delta)
2116
+ * - kind 'toolResult'→ detail only (the agent may keep working/outputting)
2117
+ */
2118
+ onActivity(agentId, kind, detail) {
2119
+ const instance = this.instances.get(agentId);
2120
+ if (instance === void 0) return;
2121
+ if (kind === "toolResult") {
2122
+ const slot = this.ensureSlot(instance.type);
2123
+ slot.detail = detail;
2124
+ slot.lastActivityAt = Date.now();
2125
+ return;
2126
+ }
2127
+ this.setState(instance.type, kind === "tool" ? "working" : "outputting", agentId, detail);
2128
+ }
2129
+ /**
2130
+ * Main-side SendSubagentMessage tool call started: the target subagent's
2131
+ * slot shows messaging until the tool call returns.
2132
+ */
2133
+ onMessagingStart(toolCallId, targetAgentId, operation) {
2134
+ if (targetAgentId === void 0) return;
2135
+ const instance = this.instances.get(targetAgentId);
2136
+ if (instance === void 0) return;
2137
+ this.messagingByCall.set(toolCallId, targetAgentId);
2138
+ const slot = this.ensureSlot(instance.type);
2139
+ if (slot.status !== "messaging") this.messagingPrev.set(instance.type, slot.status);
2140
+ this.messagingCount.set(instance.type, (this.messagingCount.get(instance.type) ?? 0) + 1);
2141
+ this.setState(instance.type, "messaging", targetAgentId, operation === void 0 ? void 0 : `message (${operation})`);
2142
+ }
2143
+ /** Tool call finished: the messaging window closes; restore the state the
2144
+ * slot had before the chat (working/outputting), else idle. */
2145
+ onMessagingEnd(toolCallId) {
2146
+ const target = this.messagingByCall.get(toolCallId);
2147
+ if (target === void 0) return;
2148
+ this.messagingByCall.delete(toolCallId);
2149
+ const instance = this.instances.get(target);
2150
+ if (instance === void 0) return;
2151
+ const slot = this.ensureSlot(instance.type);
2152
+ if (slot.status !== "messaging") return;
2153
+ const remaining = (this.messagingCount.get(instance.type) ?? 1) - 1;
2154
+ if (remaining > 0) {
2155
+ this.messagingCount.set(instance.type, remaining);
2156
+ return;
2157
+ }
2158
+ this.messagingCount.delete(instance.type);
2159
+ const prev = this.messagingPrev.get(instance.type);
2160
+ this.messagingPrev.delete(instance.type);
2161
+ slot.status = prev === "working" || prev === "outputting" || prev === "reworking" ? prev : "idle";
2162
+ slot.detail = void 0;
2163
+ slot.lastActivityAt = Date.now();
2164
+ }
2165
+ /**
2166
+ * All slots in fixed display order: the eight default types first (always
2167
+ * present, idle when never used), then extra types in spawn order.
2168
+ */
2169
+ getSlots() {
2170
+ const ordered = [];
2171
+ for (const type of DEFAULT_SUBAGENT_TYPES) {
2172
+ const slot = this.ensureSlot(type);
2173
+ ordered.push({
2174
+ ...slot,
2175
+ count: this.runningByType.get(type) ?? 0
2176
+ });
2177
+ }
2178
+ for (const slot of this.slots.values()) {
2179
+ if (DEFAULT_SUBAGENT_TYPES.includes(slot.type)) continue;
2180
+ ordered.push({
2181
+ ...slot,
2182
+ count: this.runningByType.get(slot.type) ?? 0
2183
+ });
2184
+ }
2185
+ return ordered;
2186
+ }
2187
+ ensureSlot(type) {
2188
+ let slot = this.slots.get(type);
2189
+ if (slot === void 0) {
2190
+ if (this.slots.size >= 16) {
2191
+ slot = {
2192
+ type,
2193
+ status: "idle",
2194
+ agentId: void 0,
2195
+ detail: void 0,
2196
+ count: 0,
2197
+ lastActivityAt: 0
2198
+ };
2199
+ return slot;
2200
+ }
2201
+ slot = {
2202
+ type,
2203
+ status: "idle",
2204
+ agentId: void 0,
2205
+ detail: void 0,
2206
+ count: 0,
2207
+ lastActivityAt: Date.now()
2208
+ };
2209
+ this.slots.set(type, slot);
2210
+ }
2211
+ return slot;
2212
+ }
2213
+ setState(type, status, agentId, detail) {
2214
+ const slot = this.ensureSlot(type);
2215
+ slot.status = status;
2216
+ slot.agentId = agentId;
2217
+ slot.detail = detail;
2218
+ slot.lastActivityAt = Date.now();
2219
+ }
2220
+ };
2221
+ //#endregion
2026
2222
  //#region src/tui/utils/background-task-status.ts
2027
2223
  const MAX_DETAIL_LENGTH = 240;
2028
2224
  function truncate(value) {
@@ -2237,15 +2433,15 @@ function formatStepDebugTiming(input) {
2237
2433
  const latency = input.llmFirstTokenLatencyMs;
2238
2434
  const streamMs = input.llmStreamDurationMs;
2239
2435
  if (latency === void 0 || streamMs === void 0) return void 0;
2240
- const parts = [`TTFT: ${formatDuration(latency)}`];
2436
+ const parts = [`TTFT: ${formatDuration$1(latency)}`];
2241
2437
  const outputTokens = input.usage?.output;
2242
2438
  if (outputTokens !== void 0 && outputTokens > 0 && streamMs > 0) {
2243
2439
  const tps = (outputTokens / (streamMs / 1e3)).toFixed(1);
2244
- parts.push(`TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)})`);
2440
+ parts.push(`TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration$1(streamMs)})`);
2245
2441
  }
2246
2442
  return `[Debug] ${parts.join(" | ")}`;
2247
2443
  }
2248
- function formatDuration(ms) {
2444
+ function formatDuration$1(ms) {
2249
2445
  if (ms < 1e3) return `${Math.round(ms)}ms`;
2250
2446
  return `${(ms / 1e3).toFixed(1)}s`;
2251
2447
  }
@@ -2310,7 +2506,30 @@ function detectCompactionAnomaly(input) {
2310
2506
  return null;
2311
2507
  }
2312
2508
  //#endregion
2509
+ //#region src/tui/types.ts
2510
+ /** Goal snapshot status, kept in sync with agent-core's GoalStatus union
2511
+ * (the wire type is plain string, hence the explicit union + normalizer). */
2512
+ const GOAL_STATUSES = [
2513
+ "active",
2514
+ "paused",
2515
+ "blocked",
2516
+ "complete"
2517
+ ];
2518
+ /** Clamp an unknown wire status to a safe display value: anything outside the
2519
+ * four known states is rendered as paused (never as completed). */
2520
+ function normalizeGoalStatus(status) {
2521
+ return GOAL_STATUSES.includes(status) ? status : "paused";
2522
+ }
2523
+ const INITIAL_LIVE_PANE = {
2524
+ pendingApproval: null,
2525
+ pendingQuestion: null
2526
+ };
2527
+ //#endregion
2313
2528
  //#region src/tui/controllers/session-event-handler.ts
2529
+ /** Short preview slice for subagent activity detail (keeps deltas small). */
2530
+ function sliceLeading(text, max = 48) {
2531
+ return text.length > max ? `${text.slice(0, max)}…` : text;
2532
+ }
2314
2533
  function addTokenUsage(a, b) {
2315
2534
  return {
2316
2535
  inputOther: a.inputOther + b.inputOther,
@@ -2319,6 +2538,20 @@ function addTokenUsage(a, b) {
2319
2538
  output: a.output + b.output
2320
2539
  };
2321
2540
  }
2541
+ /** Subtract cumulative usage: current minus previous (component-wise, clamped
2542
+ * at zero). Used to avoid double-counting a resumed subagent's cumulative
2543
+ * usage (each completed event carries the running total, not the delta). */
2544
+ function subtractTokenUsage(current, previous) {
2545
+ return {
2546
+ inputOther: Math.max(0, current.inputOther - previous.inputOther),
2547
+ inputCacheRead: Math.max(0, current.inputCacheRead - previous.inputCacheRead),
2548
+ inputCacheCreation: Math.max(0, current.inputCacheCreation - previous.inputCacheCreation),
2549
+ output: Math.max(0, current.output - previous.output)
2550
+ };
2551
+ }
2552
+ function isZeroUsage(u) {
2553
+ return u.inputOther === 0 && u.inputCacheRead === 0 && u.inputCacheCreation === 0 && u.output === 0;
2554
+ }
2322
2555
  var SessionEventHandler = class {
2323
2556
  host;
2324
2557
  constructor(host) {
@@ -2343,6 +2576,10 @@ var SessionEventHandler = class {
2343
2576
  * (spawned by the current turn's Agent tool), background agents persist. */
2344
2577
  foregroundSubagentCount = 0;
2345
2578
  subagentInfo = /* @__PURE__ */ new Map();
2579
+ /** Sidebar subagent slot state machine (see utils/subagent-slots.ts). */
2580
+ subagentSlots = new SubagentSlots();
2581
+ /** Tool callId of the in-flight UpdateGoal(complete) (judging), if any. */
2582
+ goalJudgeCallId;
2346
2583
  renderedSkillActivationIds = /* @__PURE__ */ new Set();
2347
2584
  renderedMcpServerStatusKeys = /* @__PURE__ */ new Map();
2348
2585
  mcpServerStatusSpinners = /* @__PURE__ */ new Map();
@@ -2351,12 +2588,18 @@ var SessionEventHandler = class {
2351
2588
  this.backgroundTasks.clear();
2352
2589
  this.backgroundTaskTranscriptedTerminal.clear();
2353
2590
  this.subagentInfo.clear();
2591
+ this.subagentSlots.reset();
2354
2592
  this.renderedSkillActivationIds.clear();
2355
2593
  this.renderedMcpServerStatusKeys.clear();
2356
2594
  this.stopAllMcpServerStatusSpinners();
2357
2595
  this.pendingSkillCandidates = [];
2358
2596
  this.promptedSkillCandidates.clear();
2359
- this.host.setAppState({ subagentUsage: {} });
2597
+ this.goalJudgeCallId = void 0;
2598
+ this.subagentUsageSeen.clear();
2599
+ this.host.setAppState({
2600
+ subagentUsage: {},
2601
+ goalJudge: "awaiting"
2602
+ });
2360
2603
  }
2361
2604
  startSubscription() {
2362
2605
  const { host } = this;
@@ -2520,9 +2763,42 @@ var SessionEventHandler = class {
2520
2763
  if (this.mcpServerStatusSpinners.size > 0) state.transcriptContainer.invalidate();
2521
2764
  this.mcpServerStatusSpinners.clear();
2522
2765
  }
2766
+ /** Read-only snapshot of the sidebar subagent slots (8 default types +
2767
+ * extras in spawn order). Consumed by the sidebar data provider. */
2768
+ getSubagentSlots() {
2769
+ return this.subagentSlots.getSlots();
2770
+ }
2771
+ /** Feed routed subagent activity into the slot state machine. */
2772
+ feedSubagentSlots(event, agentId) {
2773
+ switch (event.type) {
2774
+ case "subagent.spawned":
2775
+ this.subagentSlots.onSpawned(event.subagentId, event.subagentName, event.description);
2776
+ break;
2777
+ case "subagent.started":
2778
+ this.subagentSlots.onStarted(event.subagentId);
2779
+ break;
2780
+ case "subagent.completed":
2781
+ case "subagent.failed":
2782
+ this.subagentSlots.onTerminated(event.subagentId);
2783
+ break;
2784
+ case "tool.call.started":
2785
+ this.subagentSlots.onActivity(agentId, "tool", `tool: ${event.name}`);
2786
+ break;
2787
+ case "tool.call.delta": break;
2788
+ case "tool.result":
2789
+ this.subagentSlots.onActivity(agentId, "toolResult", "tool result");
2790
+ break;
2791
+ case "assistant.delta":
2792
+ case "thinking.delta":
2793
+ this.subagentSlots.onActivity(agentId, "output", sliceLeading(event.delta ?? ""));
2794
+ break;
2795
+ default: break;
2796
+ }
2797
+ }
2523
2798
  routeSubagentEvent(event) {
2524
2799
  const subagentId = event.agentId;
2525
2800
  if (subagentId === "main") return false;
2801
+ this.feedSubagentSlots(event, subagentId);
2526
2802
  const { streamingUI } = this.host;
2527
2803
  const info = this.subagentInfo.get(subagentId);
2528
2804
  if (info === void 0 || info.parentToolCallId.length === 0) return true;
@@ -2750,6 +3026,16 @@ var SessionEventHandler = class {
2750
3026
  handleToolCall(event) {
2751
3027
  const { streamingUI } = this.host;
2752
3028
  streamingUI.flushNow();
3029
+ if (event.name === "SendSubagentMessage") {
3030
+ const args = argsRecord(event.args);
3031
+ const target = stringValue(args["agent_id"]);
3032
+ const operation = stringValue(args["operation"]);
3033
+ this.subagentSlots.onMessagingStart(event.toolCallId, target === void 0 || target.length === 0 ? void 0 : target, operation === void 0 || operation.length === 0 ? void 0 : operation);
3034
+ }
3035
+ if (event.name === "UpdateGoal" && stringValue(argsRecord(event.args)["status"]) === "complete") {
3036
+ this.goalJudgeCallId = event.toolCallId;
3037
+ this.host.setAppState({ goalJudge: "judging" });
3038
+ }
2753
3039
  const { turnId, step } = streamingUI.getTurnContext();
2754
3040
  const toolCall = {
2755
3041
  id: event.toolCallId,
@@ -2808,6 +3094,12 @@ var SessionEventHandler = class {
2808
3094
  }
2809
3095
  handleToolResult(event) {
2810
3096
  const { streamingUI } = this.host;
3097
+ this.subagentSlots.onMessagingEnd(event.toolCallId);
3098
+ if (event.toolCallId === this.goalJudgeCallId) {
3099
+ this.goalJudgeCallId = void 0;
3100
+ const goalStatus = this.host.state.appState.goal?.status;
3101
+ this.host.setAppState({ goalJudge: goalStatus === "complete" ? "adjudicated" : "awaiting" });
3102
+ }
2811
3103
  streamingUI.flushNow();
2812
3104
  const resultData = {
2813
3105
  tool_call_id: event.toolCallId,
@@ -2948,16 +3240,28 @@ var SessionEventHandler = class {
2948
3240
  }
2949
3241
  handleGoalUpdated(event) {
2950
3242
  const snapshot = event.snapshot;
2951
- if (snapshot === null) this.host.setAppState({
2952
- goal: null,
2953
- goalActive: false
2954
- });
2955
- else this.host.setAppState({
3243
+ if (snapshot === null) {
3244
+ this.host.setAppState({
3245
+ goal: null,
3246
+ goalActive: false
3247
+ });
3248
+ return;
3249
+ }
3250
+ if (snapshot.status === "complete") {
3251
+ this.goalJudgeCallId = void 0;
3252
+ this.host.setAppState({ goalJudge: "adjudicated" });
3253
+ } else if (this.host.state.appState.goalJudge === "adjudicated") this.host.setAppState({ goalJudge: "awaiting" });
3254
+ this.host.setAppState({
2956
3255
  goal: {
2957
3256
  objective: snapshot.objective,
3257
+ status: normalizeGoalStatus(snapshot.status),
2958
3258
  turnsUsed: snapshot.turnsUsed ?? 0,
2959
3259
  wallClockMs: snapshot.wallClockMs ?? 0,
2960
- wallClockBaseAt: Date.now()
3260
+ wallClockBaseAt: Date.now(),
3261
+ completionCriterion: snapshot.completionCriterion ?? null,
3262
+ tokensUsed: snapshot.tokensUsed ?? 0,
3263
+ inputTokens: snapshot.inputTokens ?? null,
3264
+ outputTokens: snapshot.outputTokens ?? null
2961
3265
  },
2962
3266
  goalActive: snapshot.status === "active"
2963
3267
  });
@@ -3059,6 +3363,7 @@ var SessionEventHandler = class {
3059
3363
  } else this.host.setAppState({ isCompacting: false });
3060
3364
  }
3061
3365
  handleSubagentSpawned(event) {
3366
+ this.subagentSlots.onSpawned(event.subagentId, event.subagentName, event.description);
3062
3367
  const { streamingUI } = this.host;
3063
3368
  if (streamingUI.getActiveToolCall(event.parentToolCallId)?.name === "WolfPack") {
3064
3369
  const routingId = event.subagentId;
@@ -3119,6 +3424,7 @@ var SessionEventHandler = class {
3119
3424
  });
3120
3425
  }
3121
3426
  handleSubagentStarted(event) {
3427
+ this.subagentSlots.onStarted(event.subagentId);
3122
3428
  if (event.runInBackground) return;
3123
3429
  const routingId = this.subagentInfo.get(event.subagentId)?.parentToolCallId ?? event.parentToolCallId;
3124
3430
  const tc = this.host.streamingUI.getToolComponent(routingId);
@@ -3130,6 +3436,7 @@ var SessionEventHandler = class {
3130
3436
  });
3131
3437
  }
3132
3438
  handleSubagentCompleted(event) {
3439
+ this.subagentSlots.onTerminated(event.subagentId);
3133
3440
  this.recordSubagentUsage(event.subagentId, void 0, event.usage);
3134
3441
  const { streamingUI } = this.host;
3135
3442
  const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId);
@@ -3151,11 +3458,15 @@ var SessionEventHandler = class {
3151
3458
  tc.onSubagentCompleted({
3152
3459
  contextTokens: event.contextTokens,
3153
3460
  usage: event.usage,
3154
- resultSummary: event.resultSummary
3461
+ resultSummary: event.resultSummary,
3462
+ turns: event.turns,
3463
+ durationMs: event.durationMs,
3464
+ toolCallCount: event.toolCallCount
3155
3465
  });
3156
3466
  streamingUI.removeToolComponentIfInactive(event.parentToolCallId);
3157
3467
  }
3158
3468
  handleSubagentFailed(event) {
3469
+ this.subagentSlots.onTerminated(event.subagentId);
3159
3470
  this.recordSubagentUsage(event.subagentId, void 0, event.usage);
3160
3471
  const { streamingUI } = this.host;
3161
3472
  const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId);
@@ -3181,13 +3492,21 @@ var SessionEventHandler = class {
3181
3492
  tc.onSubagentFailed({ error: event.error });
3182
3493
  streamingUI.removeToolComponentIfInactive(this.subagentInfo.get(event.subagentId)?.parentToolCallId ?? event.parentToolCallId);
3183
3494
  }
3495
+ /** Cumulative usage already recorded per subagent instance (completed
3496
+ * events carry the running total — we only add the delta since the last
3497
+ * completed/failed event, avoiding double-counting after resume). */
3498
+ subagentUsageSeen = /* @__PURE__ */ new Map();
3184
3499
  recordSubagentUsage(subagentId, subagentName, usage) {
3185
3500
  if (usage === void 0) return;
3501
+ const previous = this.subagentUsageSeen.get(subagentId);
3502
+ const delta = previous === void 0 ? usage : subtractTokenUsage(usage, previous);
3503
+ this.subagentUsageSeen.set(subagentId, usage);
3504
+ if (isZeroUsage(delta)) return;
3186
3505
  const name = this.subagentInfo.get(subagentId)?.name ?? subagentName ?? subagentId;
3187
3506
  const current = this.host.state.appState.subagentUsage[name];
3188
3507
  const next = {
3189
3508
  ...this.host.state.appState.subagentUsage,
3190
- [name]: current === void 0 ? usage : addTokenUsage(current, usage)
3509
+ [name]: current === void 0 ? delta : addTokenUsage(current, delta)
3191
3510
  };
3192
3511
  this.host.setAppState({ subagentUsage: next });
3193
3512
  }
@@ -5993,7 +6312,10 @@ var TranscriptController = class TranscriptController {
5993
6312
  committedComponent;
5994
6313
  liveComponentToEntry = /* @__PURE__ */ new Map();
5995
6314
  pendingComponents = /* @__PURE__ */ new Set();
5996
- static LIVE_LIMIT = 150;
6315
+ /** Max live transcript children before the oldest are folded into the
6316
+ * committed single-line summary. Overridable via SCREAM_TRANSCRIPT_LIVE_LIMIT
6317
+ * (mirrors a commit-fold approach for bounding ultra-long sessions). */
6318
+ static LIVE_LIMIT = Number.isFinite(Number(process.env["SCREAM_TRANSCRIPT_LIVE_LIMIT"])) && Number(process.env["SCREAM_TRANSCRIPT_LIVE_LIMIT"]) > 0 ? Math.floor(Number(process.env["SCREAM_TRANSCRIPT_LIVE_LIMIT"])) : 150;
5997
6319
  constructor(host) {
5998
6320
  this.host = host;
5999
6321
  }
@@ -6338,6 +6660,469 @@ var StatusBarPaneComponent = class extends Container {
6338
6660
  }
6339
6661
  }
6340
6662
  };
6663
+ function clampSidebarWidth(cols) {
6664
+ if (!Number.isFinite(cols)) return 30;
6665
+ return Math.min(60, Math.max(24, Math.round(cols)));
6666
+ }
6667
+ //#endregion
6668
+ //#region src/tui/components/sidebar/panels/git-panel.ts
6669
+ /**
6670
+ * Summary-only Git panel (摘要式): the workdir name plus added (green) /
6671
+ * deleted (red) line counts and the changed-file count. Reads the current
6672
+ * snapshot in render so the panel never shows a stale working tree.
6673
+ */
6674
+ var GitPanelContent = class {
6675
+ ctx;
6676
+ colors;
6677
+ constructor(ctx, colors) {
6678
+ this.ctx = ctx;
6679
+ this.colors = colors;
6680
+ }
6681
+ invalidate() {}
6682
+ render(width) {
6683
+ const git = this.ctx.getData().git;
6684
+ if (git === void 0) return [chalk.hex(this.colors.textDim)(t("sidebar.git_no_repo"))];
6685
+ const dim = (s) => chalk.hex(this.colors.textDim)(s);
6686
+ const workDir = truncateToWidth(chalk.hex(this.colors.text)(basename(git.workDir)), width);
6687
+ if (git.diffAdded === 0 && git.diffDeleted === 0 && git.filesCount === 0) return [workDir, dim(t("sidebar.git_clean"))];
6688
+ return [workDir, truncateToWidth(`${chalk.hex(this.colors.success)(`+${git.diffAdded}`)} ${chalk.hex(this.colors.error)(`−${git.diffDeleted}`)} ${dim(t("sidebar.git_files", { count: git.filesCount }))}`, width)];
6689
+ }
6690
+ };
6691
+ const gitPanel = {
6692
+ id: "git",
6693
+ get title() {
6694
+ return t("sidebar.git");
6695
+ },
6696
+ width: 30,
6697
+ build(ctx) {
6698
+ return new GitPanelContent(ctx, ctx.colors);
6699
+ }
6700
+ };
6701
+ //#endregion
6702
+ //#region src/tui/utils/display-width.ts
6703
+ /** Display-column width of a string, matching the terminal layout engine
6704
+ * (ANSI sequences stripped, CJK/wide chars counted as 2 columns). */
6705
+ function displayWidth(text) {
6706
+ return visibleWidth(text);
6707
+ }
6708
+ /** Pad `label` to `target` display columns with spaces (CJK-aware). */
6709
+ function padLabel(label, target) {
6710
+ return label + " ".repeat(Math.max(0, target - displayWidth(label)));
6711
+ }
6712
+ //#endregion
6713
+ //#region src/tui/components/sidebar/panels/session-panel.ts
6714
+ function formatDuration(ms) {
6715
+ const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
6716
+ if (getLocale() === "zh") {
6717
+ if (totalSeconds < 60) return `${totalSeconds}秒`;
6718
+ const hours = Math.floor(totalSeconds / 3600);
6719
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
6720
+ return hours > 0 ? `${hours}小时${minutes}分` : `${minutes}分钟`;
6721
+ }
6722
+ if (totalSeconds < 60) return `${totalSeconds}s`;
6723
+ const hours = Math.floor(totalSeconds / 3600);
6724
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
6725
+ if (hours > 0) return `${hours}h ${minutes}m`;
6726
+ return `${minutes}m`;
6727
+ }
6728
+ /** 1,234 → "1,234" via the platform locale grouping. */
6729
+ function formatCount$1(n) {
6730
+ return n.toLocaleString("en-US");
6731
+ }
6732
+ /**
6733
+ * Session summary, live-rendered each frame: total token consumption and
6734
+ * total working time as the primary rows, turn/tool/message/compaction
6735
+ * counters as the secondary row. Uptime derives from `startedAt` so it ticks
6736
+ * between data updates; counts come from the snapshot in getData().
6737
+ */
6738
+ var SessionPanelContent = class {
6739
+ ctx;
6740
+ colors;
6741
+ constructor(ctx, colors) {
6742
+ this.ctx = ctx;
6743
+ this.colors = colors;
6744
+ }
6745
+ invalidate() {}
6746
+ render(width) {
6747
+ const stats = this.ctx.getData().sessionStats;
6748
+ if (stats === void 0) return [chalk.hex(this.colors.textDim)(t("sidebar.session_empty"))];
6749
+ const label = (s) => chalk.hex(this.colors.textDim)(s);
6750
+ const value = (s) => chalk.hex(this.colors.text)(s);
6751
+ const now = Date.now();
6752
+ const rows = [
6753
+ [t("sidebar.tokens"), formatCount$1(stats.tokensTotal)],
6754
+ [t("sidebar.tokens_cache_hit"), formatCount$1(stats.tokensInputCacheHit)],
6755
+ [t("sidebar.tokens_cache_miss"), formatCount$1(stats.tokensInputCacheMiss)],
6756
+ [t("sidebar.tokens_output"), formatCount$1(stats.tokensOutput)],
6757
+ [t("sidebar.uptime"), formatDuration(now - stats.startedAt)]
6758
+ ];
6759
+ const maxCols = Math.max(...rows.map(([k]) => displayWidth(k)));
6760
+ const lines = [];
6761
+ for (const [k, v] of rows) lines.push(label(padLabel(k, maxCols)) + value(v));
6762
+ lines.push(truncateToWidth(chalk.hex(this.colors.textDim)(`${t("sidebar.turns")} ${stats.turns} · ${t("sidebar.tools")} ${stats.toolCalls}`), width));
6763
+ lines.push(truncateToWidth(chalk.hex(this.colors.textDim)(`${t("sidebar.messages")} ${formatCount$1(stats.messages)} · ${t("sidebar.compacts")} ${stats.compactions}`), width));
6764
+ return lines;
6765
+ }
6766
+ };
6767
+ const sessionPanel = {
6768
+ id: "session",
6769
+ get title() {
6770
+ return t("sidebar.session");
6771
+ },
6772
+ width: 30,
6773
+ build(ctx) {
6774
+ return new SessionPanelContent(ctx, ctx.colors);
6775
+ }
6776
+ };
6777
+ //#endregion
6778
+ //#region src/tui/components/sidebar/panels/agents-panel.ts
6779
+ /** Gradient cycle for active agent slots. Shorter than the footer's 4s cycle:
6780
+ * with the 1s sidebar heartbeat each tick advances half the cycle, so the
6781
+ * colour jump is clearly visible instead of drifting imperceptibly. */
6782
+ const AGENT_GRADIENT_CYCLE_MS = 2e3;
6783
+ /** Localized status word per slot state. */
6784
+ function statusText(status) {
6785
+ switch (status) {
6786
+ case "idle": return t("sidebar.agent_idle");
6787
+ case "working": return t("sidebar.agent_working");
6788
+ case "outputting": return t("sidebar.agent_outputting");
6789
+ case "messaging": return t("sidebar.agent_messaging");
6790
+ case "reworking": return t("sidebar.agent_reworking");
6791
+ }
6792
+ }
6793
+ /**
6794
+ * Fixed 8-slot subagent panel: one row per default subagent type (plus any
6795
+ * custom types appended in spawn order). Live-rendered each frame from the
6796
+ * current snapshot; idle slots stay greyed out.
6797
+ */
6798
+ var AgentsPanelContent = class {
6799
+ ctx;
6800
+ colors;
6801
+ constructor(ctx, colors) {
6802
+ this.ctx = ctx;
6803
+ this.colors = colors;
6804
+ }
6805
+ invalidate() {}
6806
+ render(width) {
6807
+ const agents = this.ctx.getData().agents;
6808
+ if (agents === void 0 || agents.length === 0) return [];
6809
+ const lines = [];
6810
+ const nameWidth = Math.max(...agents.map((a) => displayWidth(a.type)));
6811
+ for (const [index, slot] of agents.entries()) lines.push(this.renderSlot(slot, index, nameWidth, width));
6812
+ return lines;
6813
+ }
6814
+ renderSlot(slot, index, nameWidth, width) {
6815
+ const dim = (s) => chalk.hex(this.colors.textDim)(s);
6816
+ if (slot.status === "idle") return `${dim("○")} ${dim(padLabel(slot.type, nameWidth))} ${dim(statusText("idle"))}`;
6817
+ const phase = (Date.now() % AGENT_GRADIENT_CYCLE_MS / AGENT_GRADIENT_CYCLE_MS + index * .125) % 1;
6818
+ const active = slot.status === "working" || slot.status === "outputting" ? chalk.hex(lerpGradient(phase)).bold : void 0;
6819
+ return truncateToWidth(`${active ? active("●") : chalk.hex(this.statusColor(slot.status))("●")} ${chalk.hex(this.colors.textStrong)(padLabel(slot.type, nameWidth))} ${active ? active(statusText(slot.status)) : chalk.hex(this.colors.text)(statusText(slot.status))}${slot.count > 1 ? ` ${dim("×" + slot.count)}` : ""}`, width);
6820
+ }
6821
+ statusColor(status) {
6822
+ switch (status) {
6823
+ case "working":
6824
+ case "outputting": return this.colors.primary;
6825
+ case "messaging": return this.colors.accent;
6826
+ case "reworking": return this.colors.warning;
6827
+ case "idle": return this.colors.textDim;
6828
+ }
6829
+ }
6830
+ };
6831
+ const agentsPanel = {
6832
+ id: "agents",
6833
+ get title() {
6834
+ return t("sidebar.agents");
6835
+ },
6836
+ width: 34,
6837
+ build(ctx) {
6838
+ return new AgentsPanelContent(ctx, ctx.colors);
6839
+ }
6840
+ };
6841
+ //#endregion
6842
+ //#region src/tui/components/sidebar/panels/goal-panel.ts
6843
+ function formatWallClock(ms) {
6844
+ const totalMinutes = Math.max(0, Math.floor(ms / 6e4));
6845
+ const hours = Math.floor(totalMinutes / 60);
6846
+ const minutes = totalMinutes % 60;
6847
+ if (getLocale() === "zh") return hours > 0 ? `${hours}小时${minutes}分` : `${minutes}分钟`;
6848
+ if (hours > 0) return `${hours}h ${minutes}m`;
6849
+ return `${minutes}m`;
6850
+ }
6851
+ function formatCount(n) {
6852
+ return n.toLocaleString("en-US");
6853
+ }
6854
+ function judgeText(state) {
6855
+ switch (state) {
6856
+ case "judging": return t("sidebar.goal_judge_judging");
6857
+ case "adjudicated": return t("sidebar.goal_judge_adjudicated");
6858
+ case "awaiting": return t("sidebar.goal_judge_awaiting");
6859
+ }
6860
+ }
6861
+ /**
6862
+ * Goal status panel, always visible: state badge (in-progress / completed /
6863
+ * paused / blocked / judging), objective, then the same two-column metric
6864
+ * layout as the Session panel (tokens spent / turns / wall-clock / judge),
6865
+ * and the completion criterion line.
6866
+ */
6867
+ var GoalPanelContent = class {
6868
+ ctx;
6869
+ colors;
6870
+ constructor(ctx, colors) {
6871
+ this.ctx = ctx;
6872
+ this.colors = colors;
6873
+ }
6874
+ invalidate() {}
6875
+ render(width) {
6876
+ const goal = this.ctx.getData().goal;
6877
+ if (goal === void 0) return [chalk.hex(this.colors.textDim)("○ " + t("sidebar.goal_idle"))];
6878
+ const dim = (s) => chalk.hex(this.colors.textDim)(s);
6879
+ const text = (s) => chalk.hex(this.colors.text)(s);
6880
+ const now = Date.now();
6881
+ const wallMs = goal.status === "active" ? goal.wallClockMs + Math.max(0, now - goal.wallClockBaseAt) : goal.wallClockMs;
6882
+ const lines = [];
6883
+ lines.push(this.renderBadge(goal.status, goal.judge, dim));
6884
+ lines.push(truncateToWidth(dim(goal.objective), width));
6885
+ const rows = [
6886
+ [t("sidebar.goal_tokens"), formatCount(goal.tokensUsed)],
6887
+ [t("sidebar.goal_tokens_input"), goal.inputTokens === null ? "—" : formatCount(goal.inputTokens)],
6888
+ [t("sidebar.goal_tokens_output"), goal.outputTokens === null ? "—" : formatCount(goal.outputTokens)],
6889
+ [t("sidebar.goal_turns"), String(goal.turnsUsed)],
6890
+ [t("sidebar.goal_wall"), formatWallClock(wallMs)],
6891
+ [t("sidebar.goal_judge"), judgeText(goal.judge)]
6892
+ ];
6893
+ const maxCols = Math.max(...rows.map(([k]) => displayWidth(k)));
6894
+ for (const [label, value] of rows) lines.push(dim(padLabel(label, maxCols)) + text(value));
6895
+ if (goal.completionCriterion !== null) lines.push(dim(padLabel(t("sidebar.goal_criterion"), maxCols)) + truncateToWidth(text(goal.completionCriterion), Math.max(4, width - maxCols)));
6896
+ return lines;
6897
+ }
6898
+ /** State badge: judging wins over everything; otherwise map the snapshot
6899
+ * status 1:1 (paused is NOT completed — fixes the interrupted-goal bug). */
6900
+ renderBadge(status, judge, dim) {
6901
+ if (judge === "judging") return chalk.hex(this.colors.accent)("⚖") + dim(" " + t("sidebar.goal_judging"));
6902
+ switch (status) {
6903
+ case "active": return chalk.hex(this.colors.primary)("●") + dim(" " + t("sidebar.goal_active"));
6904
+ case "complete": return chalk.hex(this.colors.success)("✓") + dim(" " + t("sidebar.goal_completed"));
6905
+ case "paused": return chalk.hex(this.colors.warning)("⏸") + dim(" " + t("sidebar.goal_paused"));
6906
+ case "blocked": return chalk.hex(this.colors.warning)("⚠") + dim(" " + t("sidebar.goal_blocked"));
6907
+ default: return dim(String(status));
6908
+ }
6909
+ }
6910
+ };
6911
+ const goalPanel = {
6912
+ id: "goal",
6913
+ get title() {
6914
+ return t("sidebar.goal");
6915
+ },
6916
+ width: 32,
6917
+ build(ctx) {
6918
+ return new GoalPanelContent(ctx, ctx.colors);
6919
+ }
6920
+ };
6921
+ //#endregion
6922
+ //#region src/utils/git/git-status.ts
6923
+ /**
6924
+ * Cached git branch + working-tree status (sidebar Git panel; previously
6925
+ * also the footer git badge, which the sidebar now replaces).
6926
+ *
6927
+ * Branch name refreshes every 5s, porcelain status every 15s. Branch
6928
+ * and status reads stay synchronous with short timeouts. Spawn failures
6929
+ * keep the previous snapshot instead of flashing an empty working tree.
6930
+ */
6931
+ const BRANCH_TTL_MS = 5e3;
6932
+ const STATUS_TTL_MS = 15e3;
6933
+ const SPAWN_TIMEOUT_MS = 500;
6934
+ const AHEAD_BEHIND_RE = /\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]/;
6935
+ function createGitStatusCache(workDir) {
6936
+ const isRepo = detectGitRepo(workDir);
6937
+ let branch = {
6938
+ value: null,
6939
+ fetchedAt: 0
6940
+ };
6941
+ let status = {
6942
+ dirty: false,
6943
+ ahead: 0,
6944
+ behind: 0,
6945
+ diffAdded: 0,
6946
+ diffDeleted: 0,
6947
+ files: [],
6948
+ fetchedAt: 0
6949
+ };
6950
+ return { getStatus: () => {
6951
+ if (!isRepo) return null;
6952
+ const now = Date.now();
6953
+ if (now - branch.fetchedAt >= BRANCH_TTL_MS) branch = {
6954
+ value: readBranch(workDir),
6955
+ fetchedAt: now
6956
+ };
6957
+ if (branch.value === null) return null;
6958
+ if (now - status.fetchedAt >= STATUS_TTL_MS) {
6959
+ const next = readStatus(workDir);
6960
+ if (next !== null) status = {
6961
+ ...next,
6962
+ fetchedAt: now
6963
+ };
6964
+ else status.fetchedAt = now;
6965
+ }
6966
+ return {
6967
+ branch: branch.value,
6968
+ dirty: status.dirty,
6969
+ ahead: status.ahead,
6970
+ behind: status.behind,
6971
+ diffAdded: status.diffAdded,
6972
+ diffDeleted: status.diffDeleted,
6973
+ files: status.files
6974
+ };
6975
+ } };
6976
+ }
6977
+ function detectGitRepo(workDir) {
6978
+ try {
6979
+ const result = spawnSync("git", [
6980
+ "-C",
6981
+ workDir,
6982
+ "rev-parse",
6983
+ "--is-inside-work-tree"
6984
+ ], {
6985
+ encoding: "utf8",
6986
+ timeout: SPAWN_TIMEOUT_MS
6987
+ });
6988
+ return result.status === 0 && result.stdout.trim() === "true";
6989
+ } catch {
6990
+ return false;
6991
+ }
6992
+ }
6993
+ function readBranch(workDir) {
6994
+ try {
6995
+ const result = spawnSync("git", [
6996
+ "-C",
6997
+ workDir,
6998
+ "branch",
6999
+ "--show-current"
7000
+ ], {
7001
+ encoding: "utf8",
7002
+ timeout: SPAWN_TIMEOUT_MS
7003
+ });
7004
+ if (result.status !== 0) return null;
7005
+ const name = result.stdout.trim();
7006
+ return name.length > 0 ? name : null;
7007
+ } catch {
7008
+ return null;
7009
+ }
7010
+ }
7011
+ /** Strip the quoting/escaping `git status --porcelain` applies when
7012
+ * `core.quotePath` is active and the path contains special characters. The
7013
+ * same semantics as the web server's parser: JSON.parse handles the C-style
7014
+ * escapes git emits; exotic cases stay literal, acceptable for display. */
7015
+ function unquotePorcelainPath(raw) {
7016
+ if (raw.length >= 2 && raw.startsWith("\"") && raw.endsWith("\"")) try {
7017
+ return JSON.parse(raw);
7018
+ } catch {
7019
+ return raw;
7020
+ }
7021
+ return raw;
7022
+ }
7023
+ /** Parse one porcelain entry line into a {@link GitFileEntry}. A rename entry
7024
+ * ("R old -> new") keeps the destination path, which is what the user wants
7025
+ * to see in a changed-files list. Spaces inside the XY code are dropped
7026
+ * ("M " → "M", "MM" stays), matching the web client's status label. */
7027
+ function parsePorcelainEntry(line) {
7028
+ const status = line.slice(0, 2).replaceAll(" ", "");
7029
+ let path = line.length > 3 ? line.slice(3).trim() : "";
7030
+ const arrow = path.indexOf(" -> ");
7031
+ if (arrow !== -1) path = path.slice(arrow + 4);
7032
+ return {
7033
+ status,
7034
+ path: unquotePorcelainPath(path)
7035
+ };
7036
+ }
7037
+ /** Returns null when the porcelain read fails (spawn error / non-zero exit);
7038
+ * the caller keeps the previous snapshot on failure. */
7039
+ function readStatus(workDir) {
7040
+ try {
7041
+ const result = spawnSync("git", [
7042
+ "-C",
7043
+ workDir,
7044
+ "status",
7045
+ "--porcelain",
7046
+ "-b"
7047
+ ], {
7048
+ encoding: "utf8",
7049
+ timeout: SPAWN_TIMEOUT_MS,
7050
+ maxBuffer: 4 * 1024 * 1024
7051
+ });
7052
+ if (result.status !== 0) return null;
7053
+ let dirty = false;
7054
+ let ahead = 0;
7055
+ let behind = 0;
7056
+ const files = [];
7057
+ for (const line of result.stdout.split("\n")) if (line.startsWith("## ")) {
7058
+ const m = AHEAD_BEHIND_RE.exec(line);
7059
+ if (m) {
7060
+ ahead = Number.parseInt(m[1] ?? "0", 10) || 0;
7061
+ behind = Number.parseInt(m[2] ?? "0", 10) || 0;
7062
+ }
7063
+ } else if (line.trim().length > 0) {
7064
+ dirty = true;
7065
+ files.push(parsePorcelainEntry(line));
7066
+ }
7067
+ const diff = dirty ? readDiffStats(workDir) : {
7068
+ added: 0,
7069
+ deleted: 0
7070
+ };
7071
+ return {
7072
+ dirty,
7073
+ ahead,
7074
+ behind,
7075
+ diffAdded: diff.added,
7076
+ diffDeleted: diff.deleted,
7077
+ files,
7078
+ fetchedAt: Date.now()
7079
+ };
7080
+ } catch {
7081
+ return null;
7082
+ }
7083
+ }
7084
+ function readDiffStats(workDir) {
7085
+ try {
7086
+ const result = spawnSync("git", [
7087
+ "-C",
7088
+ workDir,
7089
+ "diff",
7090
+ "--numstat",
7091
+ "HEAD",
7092
+ "--"
7093
+ ], {
7094
+ encoding: "utf8",
7095
+ timeout: SPAWN_TIMEOUT_MS,
7096
+ maxBuffer: 4 * 1024 * 1024
7097
+ });
7098
+ if (result.status !== 0) return {
7099
+ added: 0,
7100
+ deleted: 0
7101
+ };
7102
+ let added = 0;
7103
+ let deleted = 0;
7104
+ for (const line of result.stdout.split("\n")) {
7105
+ if (!line) continue;
7106
+ const [addedText, deletedText] = line.split(" ");
7107
+ added += parseDiffNumstatCount(addedText);
7108
+ deleted += parseDiffNumstatCount(deletedText);
7109
+ }
7110
+ return {
7111
+ added,
7112
+ deleted
7113
+ };
7114
+ } catch {
7115
+ return {
7116
+ added: 0,
7117
+ deleted: 0
7118
+ };
7119
+ }
7120
+ }
7121
+ function parseDiffNumstatCount(value) {
7122
+ if (value === void 0 || value === "-") return 0;
7123
+ const n = Number.parseInt(value, 10);
7124
+ return Number.isFinite(n) && n > 0 ? n : 0;
7125
+ }
6341
7126
  //#endregion
6342
7127
  //#region src/tui/theme/bundle.ts
6343
7128
  function createScreamTUIThemeBundle(theme, resolvedTheme) {
@@ -6683,8 +7468,23 @@ var LifecycleController = class LifecycleController {
6683
7468
  /** The active status-bar loader (PulseWaveLoader or MoonLoader). Owned by
6684
7469
  * this controller; stopped before replacement to avoid leaking timers. */
6685
7470
  statusBarLoader;
7471
+ /** Sum all four token buckets into the session's total token consumption. */
7472
+ static sumTokens(usage) {
7473
+ return usage.inputOther + usage.inputCacheRead + usage.inputCacheCreation + usage.output;
7474
+ }
6686
7475
  tipRotationTimer;
6687
7476
  currentTipIndex = Math.floor(Math.random() * SESSION_TIPS.length);
7477
+ /** Sidebar-only git cache (the footer owns its own). Rebuilt when the
7478
+ * workDir changes; `getStatus()` is TTL-cached so polling is cheap. */
7479
+ sidebarGitCache;
7480
+ sidebarGitCacheWorkDir = "";
7481
+ /** Session start time for the sidebar uptime counter. Reset on session
7482
+ * switch so a resumed old session doesn't show the TUI process age. */
7483
+ sidebarSessionStartedAt = Date.now();
7484
+ sidebarSessionId;
7485
+ /** Memo for the session-stats aggregation: recomputed only when the
7486
+ * transcript length, compaction count or usage object changes. */
7487
+ sidebarStatsMemo;
6688
7488
  static MEMORY_IDLE_MS = 900 * 1e3;
6689
7489
  static MEMORY_COUNTDOWN_MS = 15 * 1e3;
6690
7490
  static MEMORY_EXTRACT_COOLDOWN_MS = 600 * 1e3;
@@ -6867,8 +7667,30 @@ var LifecycleController = class LifecycleController {
6867
7667
  minSize: 1
6868
7668
  }
6869
7669
  ]);
7670
+ {
7671
+ const m = this.host.state.sidebarManager;
7672
+ if (!m.allPanels.some((p) => p.id === "git")) m.register(gitPanel);
7673
+ if (!m.allPanels.some((p) => p.id === "session")) m.register(sessionPanel);
7674
+ if (!m.allPanels.some((p) => p.id === "agents")) m.register(agentsPanel);
7675
+ if (!m.allPanels.some((p) => p.id === "goal")) m.register(goalPanel);
7676
+ m.setDataProvider(() => this.buildSidebarData());
7677
+ }
7678
+ const sidebar = this.host.state.sidebarPane;
6870
7679
  const layoutRoot = new VStack([{
6871
- component: transcriptScrollView,
7680
+ component: new HStack([{
7681
+ component: transcriptScrollView,
7682
+ basis: 0,
7683
+ grow: 1,
7684
+ shrink: 1,
7685
+ minSize: 1
7686
+ }, {
7687
+ component: sidebar,
7688
+ basis: "auto",
7689
+ grow: 0,
7690
+ shrink: 0,
7691
+ minSize: 0,
7692
+ visible: (viewport) => viewport.width >= 90
7693
+ }]),
6872
7694
  basis: 0,
6873
7695
  grow: 1,
6874
7696
  shrink: 1,
@@ -6883,6 +7705,86 @@ var LifecycleController = class LifecycleController {
6883
7705
  ui.setLayoutRoot(layoutRoot);
6884
7706
  this.host.state.layoutRoot = layoutRoot;
6885
7707
  }
7708
+ /** Build the sidebar data snapshot (git + session stats + goal) read by the
7709
+ * stacked panels. Panels re-read this every frame via getData(), so the
7710
+ * snapshot stays cheap: git is TTL-cached and stats are memoized. */
7711
+ buildSidebarData() {
7712
+ const appState = this.host.state.appState;
7713
+ return {
7714
+ git: this.readSidebarGit(appState.workDir),
7715
+ sessionStats: this.readSidebarSessionStats(appState),
7716
+ agents: this.host.sessionEventHandler.getSubagentSlots(),
7717
+ goal: this.readSidebarGoal(appState)
7718
+ };
7719
+ }
7720
+ readSidebarGit(workDir) {
7721
+ if (this.sidebarGitCache === void 0 || this.sidebarGitCacheWorkDir !== workDir) {
7722
+ this.sidebarGitCache = createGitStatusCache(workDir);
7723
+ this.sidebarGitCacheWorkDir = workDir;
7724
+ }
7725
+ const status = this.sidebarGitCache.getStatus();
7726
+ if (status === null) return void 0;
7727
+ return {
7728
+ workDir,
7729
+ diffAdded: status.diffAdded,
7730
+ diffDeleted: status.diffDeleted,
7731
+ filesCount: status.files.length
7732
+ };
7733
+ }
7734
+ readSidebarSessionStats(appState) {
7735
+ if (this.sidebarSessionId !== appState.sessionId) {
7736
+ this.sidebarSessionId = appState.sessionId;
7737
+ this.sidebarSessionStartedAt = Date.now();
7738
+ }
7739
+ const entries = this.host.state.transcriptEntries;
7740
+ const usage = appState.sessionUsage;
7741
+ const subagentUsage = appState.subagentUsage;
7742
+ const memo = this.sidebarStatsMemo;
7743
+ if (memo !== void 0 && memo.length === entries.length && memo.compactions === appState.autoCompactionCount && memo.usage === usage && memo.subagentUsage === subagentUsage) return memo.stats;
7744
+ let turns = 0;
7745
+ let toolCalls = 0;
7746
+ for (const entry of entries) {
7747
+ if (entry.kind === "user") turns += 1;
7748
+ if (entry.kind === "tool_call" && entry.compactionData === void 0) toolCalls += 1;
7749
+ }
7750
+ let subagentOutput = 0;
7751
+ for (const sub of Object.values(subagentUsage)) subagentOutput += sub.output;
7752
+ const stats = {
7753
+ turns,
7754
+ toolCalls,
7755
+ messages: entries.length,
7756
+ compactions: appState.autoCompactionCount,
7757
+ tokensTotal: LifecycleController.sumTokens(usage) + subagentOutput,
7758
+ tokensInputCacheHit: usage.inputCacheRead,
7759
+ tokensInputCacheMiss: usage.inputOther + usage.inputCacheCreation,
7760
+ tokensOutput: usage.output + subagentOutput,
7761
+ startedAt: this.sidebarSessionStartedAt
7762
+ };
7763
+ this.sidebarStatsMemo = {
7764
+ length: entries.length,
7765
+ compactions: appState.autoCompactionCount,
7766
+ usage,
7767
+ subagentUsage,
7768
+ stats
7769
+ };
7770
+ return stats;
7771
+ }
7772
+ readSidebarGoal(appState) {
7773
+ if (appState.goal === null) return void 0;
7774
+ return {
7775
+ objective: appState.goal.objective,
7776
+ status: appState.goal.status,
7777
+ turnsUsed: appState.goal.turnsUsed,
7778
+ wallClockMs: appState.goal.wallClockMs,
7779
+ wallClockBaseAt: appState.goal.wallClockBaseAt,
7780
+ continuationCount: appState.goalContinuationCount,
7781
+ completionCriterion: appState.goal.completionCriterion,
7782
+ tokensUsed: appState.goal.tokensUsed,
7783
+ inputTokens: appState.goal.inputTokens,
7784
+ outputTokens: appState.goal.outputTokens,
7785
+ judge: appState.goalJudge
7786
+ };
7787
+ }
6886
7788
  mountFooter() {
6887
7789
  this.ensureFooterWrap();
6888
7790
  this.footerWrap.addChild(this.host.state.footer);
@@ -7941,12 +8843,6 @@ var QuestionController = class extends ReverseRpcController {
7941
8843
  }
7942
8844
  };
7943
8845
  //#endregion
7944
- //#region src/tui/types.ts
7945
- const INITIAL_LIVE_PANE = {
7946
- pendingApproval: null,
7947
- pendingQuestion: null
7948
- };
7949
- //#endregion
7950
8846
  //#region src/utils/clipboard/clipboard-text.ts
7951
8847
  /**
7952
8848
  * Write plain text to the system clipboard with platform-specific commands.
@@ -8101,6 +8997,222 @@ function createRenderBatcher(doRender) {
8101
8997
  };
8102
8998
  }
8103
8999
  //#endregion
9000
+ //#region src/tui/components/sidebar/sidebar-container.ts
9001
+ const SIDE_PADDING = 1;
9002
+ /**
9003
+ * Renders the stacked sidebar: every visible registered panel gets its own
9004
+ * full box frame (┌─┐│└┘) with the panel title embedded in the top frame, one
9005
+ * below the other. Panels render live each frame (their body reads the
9006
+ * current data snapshot), so time-based values (uptime, goal wall-clock)
9007
+ * tick without rebuilding. The focused panel's title is bolded; focus is
9008
+ * moved by the SidebarManager (`/sidebar next/prev`, Ctrl+X toggles).
9009
+ * The whole frame is painted in the theme accent colour and the rendered
9010
+ * width follows the SidebarManager so `/sidebar width` takes effect.
9011
+ */
9012
+ var SidebarContainer = class extends Container {
9013
+ manager;
9014
+ panelContext;
9015
+ accentHex;
9016
+ /** Built panel bodies, keyed by panel id. Bodies render live so they are
9017
+ * only rebuilt when the visible panel set changes. */
9018
+ bodies = /* @__PURE__ */ new Map();
9019
+ builtStackKey = "";
9020
+ /** 1s heartbeat while the sidebar is open: keeps live values (uptime,
9021
+ * goal wall-clock, gradient phase of busy agent slots) ticking without
9022
+ * needing an event. Stopped when the sidebar closes. */
9023
+ heartbeat;
9024
+ constructor(manager, requestRender, colors) {
9025
+ super();
9026
+ this.manager = manager;
9027
+ this.accentHex = colors.primary;
9028
+ this.panelContext = {
9029
+ requestRender,
9030
+ getData: () => this.manager.getData(),
9031
+ colors
9032
+ };
9033
+ this.heartbeat = setInterval(() => {
9034
+ if (this.manager.isOpen) requestRender();
9035
+ }, 1e3);
9036
+ this.heartbeat.unref();
9037
+ }
9038
+ /** Stop the heartbeat. Called by the TUI teardown (see scream-tui.ts). */
9039
+ dispose() {
9040
+ if (this.heartbeat !== void 0) clearInterval(this.heartbeat);
9041
+ }
9042
+ /** Re-sync built bodies with the current visible stack (call on register/
9043
+ * unregister/activate changes; cheap idempotent check). */
9044
+ refresh() {
9045
+ const key = this.manager.isOpen ? this.manager.getStackPanels().map((panel) => panel.id).join("|") : "";
9046
+ if (key === this.builtStackKey) return;
9047
+ const keep = /* @__PURE__ */ new Set();
9048
+ if (this.manager.isOpen) for (const panel of this.manager.getStackPanels()) {
9049
+ keep.add(panel.id);
9050
+ if (!this.bodies.has(panel.id)) this.bodies.set(panel.id, panel.build(this.panelContext));
9051
+ }
9052
+ for (const id of [...this.bodies.keys()]) if (!keep.has(id)) this.bodies.delete(id);
9053
+ this.builtStackKey = key;
9054
+ this.clear();
9055
+ }
9056
+ render(_width) {
9057
+ if (!this.manager.isOpen) return [];
9058
+ this.refresh();
9059
+ const panels = this.manager.getStackPanels();
9060
+ if (panels.length === 0) return [];
9061
+ const lines = [""];
9062
+ const horzLen = Math.max(2, this.manager.currentWidth - 2);
9063
+ const contentWidth = Math.max(1, horzLen - 2 * SIDE_PADDING);
9064
+ const paint = (s) => chalk.hex(this.accentHex)(s);
9065
+ const activeId = this.manager.activePanel?.id ?? null;
9066
+ for (const panel of panels) {
9067
+ const body = this.bodies.get(panel.id);
9068
+ if (body === void 0) continue;
9069
+ const title = truncateToWidth(String(panel.title), horzLen);
9070
+ const titleText = panel.id === activeId ? chalk.bold(title) : title;
9071
+ const trailing = Math.max(0, horzLen - visibleWidth(title));
9072
+ lines.push(paint("┌") + paint(titleText) + paint("─".repeat(trailing)) + paint("┐"));
9073
+ for (const raw of body.render(contentWidth)) {
9074
+ const row = truncateToWidth(raw, contentWidth);
9075
+ const pad = Math.max(0, contentWidth - visibleWidth(row));
9076
+ lines.push(paint("│") + " " + row + " ".repeat(pad) + " " + paint("│"));
9077
+ }
9078
+ lines.push(paint("└") + paint("─".repeat(horzLen)) + paint("┘"));
9079
+ }
9080
+ return lines;
9081
+ }
9082
+ };
9083
+ //#endregion
9084
+ //#region src/tui/components/sidebar/sidebar-manager.ts
9085
+ /**
9086
+ * Owns which sidebar panels are registered, which one is active, whether the
9087
+ * sidebar is open, and its width. Rendering is delegated back to the host via
9088
+ * the injected `requestRender`; the manager itself holds no Component state.
9089
+ */
9090
+ var SidebarManager = class {
9091
+ panels = [];
9092
+ byId = /* @__PURE__ */ new Map();
9093
+ activeId = null;
9094
+ open = false;
9095
+ width = 30;
9096
+ widthStore;
9097
+ dataProvider;
9098
+ requestRender;
9099
+ constructor(requestRender, opts) {
9100
+ this.requestRender = requestRender;
9101
+ this.widthStore = opts?.widthStore;
9102
+ this.dataProvider = opts?.dataProvider;
9103
+ if (opts?.widthStore !== void 0) this.width = clampSidebarWidth(opts.widthStore.load());
9104
+ }
9105
+ getData() {
9106
+ return this.dataProvider?.() ?? {};
9107
+ }
9108
+ setDataProvider(provider) {
9109
+ this.dataProvider = provider;
9110
+ }
9111
+ register(panel) {
9112
+ if (this.byId.has(panel.id)) return;
9113
+ this.panels.push(panel);
9114
+ this.byId.set(panel.id, panel);
9115
+ }
9116
+ unregister(id) {
9117
+ const idx = this.panels.findIndex((p) => p.id === id);
9118
+ if (idx === -1) return;
9119
+ const [panel] = this.panels.splice(idx, 1);
9120
+ this.byId.delete(id);
9121
+ if (this.activeId === id) {
9122
+ this.activeId = null;
9123
+ this.open = false;
9124
+ panel?.onClose?.();
9125
+ }
9126
+ this.requestRender();
9127
+ }
9128
+ get isOpen() {
9129
+ return this.open;
9130
+ }
9131
+ get hasPanels() {
9132
+ return this.panels.length > 0;
9133
+ }
9134
+ get activePanel() {
9135
+ return this.activeId === null ? void 0 : this.byId.get(this.activeId);
9136
+ }
9137
+ get currentWidth() {
9138
+ return this.width;
9139
+ }
9140
+ get allPanels() {
9141
+ return this.panels;
9142
+ }
9143
+ /**
9144
+ * Panels that should render in the current stacked sidebar, in registration
9145
+ * order, filtered by each panel's `visible(data)` predicate (panels without
9146
+ * one are always visible).
9147
+ */
9148
+ getStackPanels() {
9149
+ const data = this.getData();
9150
+ return this.panels.filter((panel) => panel.visible?.(data) ?? true);
9151
+ }
9152
+ /** True when `panel` would currently render in the stack. */
9153
+ isStacked(panel) {
9154
+ const data = this.getData();
9155
+ return panel.visible?.(data) ?? true;
9156
+ }
9157
+ activate(id) {
9158
+ const panel = this.byId.get(id);
9159
+ if (panel === void 0) return false;
9160
+ if (this.open && this.activeId === id) return true;
9161
+ (this.activeId === null ? void 0 : this.byId.get(this.activeId))?.onClose?.();
9162
+ this.activeId = id;
9163
+ this.open = true;
9164
+ panel.onOpen?.();
9165
+ this.requestRender();
9166
+ return true;
9167
+ }
9168
+ toggle(id) {
9169
+ if (id === void 0 && this.activeId === null) {
9170
+ const [first] = this.getStackPanels();
9171
+ if (first === void 0) return;
9172
+ this.activate(first.id);
9173
+ return;
9174
+ }
9175
+ const target = id ?? this.activeId;
9176
+ if (target === null || !this.byId.has(target)) return;
9177
+ if (this.open && this.activeId === target) {
9178
+ this.close();
9179
+ return;
9180
+ }
9181
+ this.activate(target);
9182
+ }
9183
+ close() {
9184
+ if (!this.open) return;
9185
+ const panel = this.activePanel;
9186
+ this.open = false;
9187
+ this.activeId = null;
9188
+ panel?.onClose?.();
9189
+ this.requestRender();
9190
+ }
9191
+ next() {
9192
+ this.step(1);
9193
+ }
9194
+ prev() {
9195
+ this.step(-1);
9196
+ }
9197
+ step(dir) {
9198
+ const stack = this.getStackPanels();
9199
+ if (stack.length === 0) return;
9200
+ const idx = this.activeId === null ? -1 : stack.findIndex((p) => p.id === this.activeId);
9201
+ const nextIdx = idx === -1 ? dir === 1 ? 0 : stack.length - 1 : (idx + dir + stack.length) % stack.length;
9202
+ this.activate(stack[nextIdx].id);
9203
+ }
9204
+ setWidth(cols) {
9205
+ this.width = clampSidebarWidth(cols);
9206
+ this.widthStore?.save(this.width);
9207
+ this.requestRender();
9208
+ }
9209
+ resetWidth() {
9210
+ this.width = 30;
9211
+ this.widthStore?.save(this.width);
9212
+ this.requestRender();
9213
+ }
9214
+ };
9215
+ //#endregion
8104
9216
  //#region src/tui/components/chrome/error-banner.ts
8105
9217
  const MAX_BANNER_LINES = 3;
8106
9218
  const CONTINUATION_INDENT = " ";
@@ -8423,6 +9535,7 @@ var CustomEditor = class extends Editor {
8423
9535
  onCtrlB;
8424
9536
  onToggleToolExpand;
8425
9537
  onTogglePlanExpand;
9538
+ onToggleSidebar;
8426
9539
  onOpenExternalEditor;
8427
9540
  onCtrlS;
8428
9541
  onCtrlW;
@@ -8602,6 +9715,10 @@ var CustomEditor = class extends Editor {
8602
9715
  if (matchesKey(normalized, Key.ctrl("e"))) {
8603
9716
  if (this.onTogglePlanExpand?.() === true) return;
8604
9717
  }
9718
+ if (matchesKey(normalized, Key.ctrl("x"))) {
9719
+ this.onToggleSidebar?.();
9720
+ return;
9721
+ }
8605
9722
  if (matchesKey(normalized, Key.ctrl("s"))) {
8606
9723
  this.onCtrlS?.();
8607
9724
  return;
@@ -8928,7 +10045,7 @@ function createTUIState(options) {
8928
10045
  const base64 = Buffer.from(text, "utf8").toString("base64");
8929
10046
  if (base64.length > OSC52_MAX_TEXT_LENGTH) return false;
8930
10047
  try {
8931
- terminal.write(`\x1B]52;c;${base64}\u0007`);
10048
+ terminal.write(`\u001B]52;c;${base64}\u0007`);
8932
10049
  } catch {
8933
10050
  return false;
8934
10051
  }
@@ -8976,10 +10093,19 @@ function createTUIState(options) {
8976
10093
  editor.thinkingLevel = initialAppState.thinkingLevel;
8977
10094
  editor.permissionMode = initialAppState.permissionMode ?? "manual";
8978
10095
  editor.toolPriority = initialAppState.like?.toolPriority;
10096
+ const footer = new FooterComponent({ ...initialAppState }, theme.colors, ui);
10097
+ const sidebarManager = new SidebarManager(() => ui.requestRender());
10098
+ const sidebarContainer = new SidebarContainer(sidebarManager, () => ui.requestRender(), theme.colors);
10099
+ const sidebarPane = new GutterContainer(1, 1);
10100
+ sidebarPane.addChild(sidebarContainer);
10101
+ editor.onToggleSidebar = () => sidebarManager.toggle();
8979
10102
  return {
8980
10103
  ui,
8981
10104
  terminal,
8982
10105
  layoutRoot: void 0,
10106
+ sidebarManager,
10107
+ sidebarContainer,
10108
+ sidebarPane,
8983
10109
  transcriptContainer,
8984
10110
  activityContainer,
8985
10111
  statusBarContainer,
@@ -8991,9 +10117,7 @@ function createTUIState(options) {
8991
10117
  planModeBanner,
8992
10118
  planModeBannerContainer,
8993
10119
  editorContainer,
8994
- footer: new FooterComponent({ ...initialAppState }, theme.colors, ui, () => {
8995
- ui.requestRender();
8996
- }),
10120
+ footer,
8997
10121
  editor,
8998
10122
  theme,
8999
10123
  appState: { ...initialAppState },
@@ -9579,11 +10703,17 @@ var SessionManager$1 = class {
9579
10703
  sessionTitle: session.summary?.title ?? null,
9580
10704
  goal: goal ? {
9581
10705
  objective: goal.objective,
10706
+ status: normalizeGoalStatus(goal.status),
9582
10707
  turnsUsed: goal.turnsUsed ?? 0,
9583
10708
  wallClockMs: goal.wallClockMs ?? 0,
9584
- wallClockBaseAt: Date.now()
10709
+ wallClockBaseAt: Date.now(),
10710
+ completionCriterion: goal.completionCriterion ?? null,
10711
+ tokensUsed: goal.tokensUsed ?? 0,
10712
+ inputTokens: goal.inputTokens ?? null,
10713
+ outputTokens: goal.outputTokens ?? null
9585
10714
  } : null,
9586
10715
  goalActive: goal?.status === "active",
10716
+ goalJudge: "awaiting",
9587
10717
  goalContinuationCount: 0,
9588
10718
  providerBalance: null
9589
10719
  });
@@ -11940,6 +13070,7 @@ function createInitialAppState(input) {
11940
13070
  sessionTitle: null,
11941
13071
  goal: null,
11942
13072
  goalActive: false,
13073
+ goalJudge: "awaiting",
11943
13074
  goalContinuationCount: 0,
11944
13075
  ccConnectActive: false,
11945
13076
  wolfpackMode: input.cliOptions.wolfpack === true,
@@ -12170,6 +13301,7 @@ var ScreamTUI = class {
12170
13301
  this.lifecycleController.disposeTerminalTracking();
12171
13302
  this.inputController.dispose();
12172
13303
  this.state.footer.dispose();
13304
+ this.state.sidebarContainer.dispose();
12173
13305
  await this.closeSession();
12174
13306
  await this.harness.close();
12175
13307
  this.sessionEventHandler.stopAllMcpServerStatusSpinners();
@@ -12440,7 +13572,7 @@ var ScreamTUI = class {
12440
13572
  const card = new OnboardingCardComponent({
12441
13573
  colors: this.state.theme.colors,
12442
13574
  onConfigure: () => {
12443
- import("./dispatch-CULU8xVW.mjs").then(({ handleConnectCommand }) => handleConnectCommand(this, "")).finally(() => {
13575
+ import("./dispatch-CkPcMk4b.mjs").then(({ handleConnectCommand }) => handleConnectCommand(this, "")).finally(() => {
12444
13576
  this.restoreEditor();
12445
13577
  onDone?.();
12446
13578
  });
@@ -15002,6 +16134,12 @@ var WebSession = class {
15002
16134
  unsubscribe = null;
15003
16135
  busy = false;
15004
16136
  /**
16137
+ * In-flight turn count across all agents (main + subagents). Used by the
16138
+ * idle-exit watchdog: a single boolean is not enough because a subagent's
16139
+ * `turn.ended` must not clear the busy state of a still-running main turn.
16140
+ */
16141
+ activeTurns = 0;
16142
+ /**
15005
16143
  * Live assistant message under construction. Mirrors the same state machine
15006
16144
  * used by buildMessages() so turn.ended can persist a complete durable
15007
16145
  * snapshot (`web.message.finalized`) — the only source that survives a
@@ -15041,6 +16179,10 @@ var WebSession = class {
15041
16179
  get isBusy() {
15042
16180
  return this.busy;
15043
16181
  }
16182
+ /** True while any agent turn (main or subagent) is in flight. */
16183
+ get isTurnActive() {
16184
+ return this.activeTurns > 0;
16185
+ }
15044
16186
  getTitle() {
15045
16187
  return this.title;
15046
16188
  }
@@ -15170,6 +16312,7 @@ var WebSession = class {
15170
16312
  if ((event.type === "goal.updated" || event.type === "todo.updated") && event.agentId !== "main") return;
15171
16313
  if (event.type === "turn.started") {
15172
16314
  this.busy = true;
16315
+ this.activeTurns += 1;
15173
16316
  this.liveTurnCount += 1;
15174
16317
  this.liveAssistant = {
15175
16318
  role: "assistant",
@@ -15189,7 +16332,8 @@ var WebSession = class {
15189
16332
  }
15190
16333
  };
15191
16334
  } else if (event.type === "turn.ended") {
15192
- this.busy = false;
16335
+ this.activeTurns = Math.max(0, this.activeTurns - 1);
16336
+ this.busy = this.activeTurns > 0;
15193
16337
  const live = this.liveAssistant;
15194
16338
  if (event.reason === "failed" && live) live.isError = true;
15195
16339
  if (live) {
@@ -15407,6 +16551,7 @@ var WebSession = class {
15407
16551
  }, false);
15408
16552
  this.session.prompt(text).catch((error) => {
15409
16553
  this.busy = false;
16554
+ this.activeTurns = 0;
15410
16555
  this.sendError(ws, error, clientMessageId);
15411
16556
  });
15412
16557
  break;
@@ -15887,6 +17032,7 @@ var WebSession = class {
15887
17032
  await session.prompt(prompt);
15888
17033
  } catch (error) {
15889
17034
  this.busy = false;
17035
+ this.activeTurns = 0;
15890
17036
  throw error;
15891
17037
  }
15892
17038
  }
@@ -16266,6 +17412,10 @@ var SessionManager = class {
16266
17412
  list() {
16267
17413
  return Array.from(this.sessions.values()).map((s) => s.getListItem()).toSorted((a, b) => b.createdAt - a.createdAt);
16268
17414
  }
17415
+ /** True when any session has an agent turn (main or subagent) in flight (used to defer idle exit). */
17416
+ anyBusy() {
17417
+ return Array.from(this.sessions.values()).some((s) => s.isTurnActive);
17418
+ }
16269
17419
  async delete(sessionId) {
16270
17420
  const ws = this.sessions.get(sessionId);
16271
17421
  if (!ws) return false;
@@ -16480,6 +17630,48 @@ var SessionManager = class {
16480
17630
  return this.harness.preflight();
16481
17631
  }
16482
17632
  };
17633
+ /**
17634
+ * Watchdog shared by both web entry points. Detects "nobody is using the UI"
17635
+ * as: zero WebSocket connections AND no HTTP request for `idleMs`, with an
17636
+ * in-flight agent turn (`busy()`) deferring the exit so background work is
17637
+ * never interrupted. The `shutdown` call is awaited before `exit` and must be
17638
+ * idempotent (it can race with a manual SIGINT/SIGTERM stop).
17639
+ */
17640
+ function startWebIdleExit(opts) {
17641
+ const idleMs = opts.idleMs;
17642
+ if (!(idleMs > 0)) return {
17643
+ touch: () => {},
17644
+ noteConnection: () => {},
17645
+ noteDisconnect: () => {},
17646
+ stop: () => {}
17647
+ };
17648
+ const exitFn = opts.exit ?? ((code) => process.exit(code));
17649
+ let activeConnections = 0;
17650
+ let lastActivityAt = Date.now();
17651
+ const checkMs = Math.min(6e4, Math.max(100, idleMs / 2));
17652
+ const timer = setInterval(() => {
17653
+ if (activeConnections > 0) return;
17654
+ if (opts.busy()) return;
17655
+ if (Date.now() - lastActivityAt < idleMs) return;
17656
+ Promise.resolve(opts.shutdown()).finally(() => exitFn(0));
17657
+ }, checkMs);
17658
+ return {
17659
+ touch: () => {
17660
+ lastActivityAt = Date.now();
17661
+ },
17662
+ noteConnection: () => {
17663
+ activeConnections += 1;
17664
+ lastActivityAt = Date.now();
17665
+ },
17666
+ noteDisconnect: () => {
17667
+ activeConnections = Math.max(0, activeConnections - 1);
17668
+ lastActivityAt = Date.now();
17669
+ },
17670
+ stop: () => {
17671
+ clearInterval(timer);
17672
+ }
17673
+ };
17674
+ }
16483
17675
  async function handleSessionControlRoutes(req, res, url, method, manager) {
16484
17676
  const statusMatch = new RegExp(`^${API_PREFIX}/sessions/([^/]+)/status$`).exec(url);
16485
17677
  if (statusMatch && method === "GET") {
@@ -16935,9 +18127,15 @@ async function runWebServer(opts) {
16935
18127
  publicDir = sourcePublicDir;
16936
18128
  } catch {}
16937
18129
  }
18130
+ const idleExit = startWebIdleExit({
18131
+ idleMs: (opts.idleMinutes ?? 15) * 6e4,
18132
+ busy: () => manager.anyBusy(),
18133
+ shutdown: () => cleanup()
18134
+ });
16938
18135
  const httpServer = createServer(async (req, res) => {
16939
18136
  const url = req.url ?? "/";
16940
18137
  const method = req.method ?? "GET";
18138
+ idleExit.touch();
16941
18139
  if (gateway) {
16942
18140
  const verdict = gatewayVerdict({
16943
18141
  loopback: isLoopbackAddress(req.socket.remoteAddress),
@@ -17292,7 +18490,12 @@ async function runWebServer(opts) {
17292
18490
  }
17293
18491
  });
17294
18492
  const wss = new WebSocketServer({ server: httpServer });
18493
+ wss.on("error", () => {});
17295
18494
  wss.on("connection", (ws, req) => {
18495
+ idleExit.noteConnection();
18496
+ ws.once("close", () => {
18497
+ idleExit.noteDisconnect();
18498
+ });
17296
18499
  if (gateway && !isLoopbackAddress(req.socket.remoteAddress) && !gateway.verifySession(req.headers.cookie)) {
17297
18500
  ws.close(1008, "unauthorized");
17298
18501
  return;
@@ -17343,13 +18546,18 @@ async function runWebServer(opts) {
17343
18546
  }
17344
18547
  ws.send(JSON.stringify({ type: "server_empty" }));
17345
18548
  });
17346
- await new Promise((resolve, reject) => {
17347
- httpServer.once("error", (err) => {
17348
- if (err.code === "EADDRINUSE") reject(/* @__PURE__ */ new Error(`端口 ${opts.port} 已被占用,请先关闭占用该端口的进程,或使用 --port <port> 指定其他端口。`));
17349
- else reject(err);
18549
+ try {
18550
+ await new Promise((resolve, reject) => {
18551
+ httpServer.once("error", (err) => {
18552
+ if (err.code === "EADDRINUSE") reject(/* @__PURE__ */ new Error(`端口 ${opts.port} 已被占用,请先关闭占用该端口的进程,或使用 --port <port> 指定其他端口。`));
18553
+ else reject(err);
18554
+ });
18555
+ httpServer.listen(opts.port, host, resolve);
17350
18556
  });
17351
- httpServer.listen(opts.port, host, resolve);
17352
- });
18557
+ } catch (error) {
18558
+ idleExit.stop();
18559
+ throw error;
18560
+ }
17353
18561
  const address = httpServer.address();
17354
18562
  const realPort = typeof address === "object" && address !== null ? address.port : opts.port;
17355
18563
  const url = `http://localhost:${realPort}`;
@@ -17358,6 +18566,7 @@ async function runWebServer(opts) {
17358
18566
  const cleanup = async () => {
17359
18567
  if (cleaned) return;
17360
18568
  cleaned = true;
18569
+ idleExit.stop();
17361
18570
  try {
17362
18571
  wss.close();
17363
18572
  await manager.closeAll();
@@ -17373,7 +18582,8 @@ async function runWebServer(opts) {
17373
18582
  process.on("SIGTERM", () => {
17374
18583
  cleanup().finally(() => process.exit(0));
17375
18584
  });
17376
- let banner = `\n Scream Web UI ready: ${url}\n Working directory: ${workDir}\n Sessions: ${manager.list().length} (${manager.list().filter((s) => s.active).length} active)\n Permission: ${opts.yolo ? "yolo" : opts.auto ? "auto" : "manual"}\n`;
18585
+ const idleMinutes = opts.idleMinutes ?? 15;
18586
+ let banner = `\n Scream Web UI ready: ${url}\n Working directory: ${workDir}\n Sessions: ${manager.list().length} (${manager.list().filter((s) => s.active).length} active)\n Permission: ${opts.yolo ? "yolo" : opts.auto ? "auto" : "manual"}\n` + (idleMinutes > 0 ? ` Idle exit: ${idleMinutes}m without a browser connection (--idle-minutes <n> to change, 0 = disable)\n` : ` Idle exit: disabled (--idle-minutes 0)\n`);
17377
18587
  if (gateway) {
17378
18588
  const addrs = getLanAddresses();
17379
18589
  banner += ` LAN mode: on — non-local devices must present the access key\n`;
@@ -17445,7 +18655,8 @@ async function runWeb(opts) {
17445
18655
  open: opts.open,
17446
18656
  skillsDirs: opts.skillsDirs,
17447
18657
  lan: opts.lan,
17448
- token
18658
+ token,
18659
+ idleMinutes: opts.idleMinutes
17449
18660
  });
17450
18661
  }
17451
18662
  //#endregion