brl-subagent 2.3.9 → 2.5.0

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.
package/src/tui.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: All TUI surfaces: config menus, preset/template managers, history, live monitor, and result rendering.
1
2
  /**
2
3
  * brl-subagent — TUI Components
3
4
  *
@@ -13,11 +14,10 @@ import type {
13
14
  ToolRenderResultOptions,
14
15
  } from "@earendil-works/pi-coding-agent";
15
16
  import { DynamicBorder, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
16
- import { Container, type SelectItem, SelectList, Spacer, Text, Markdown } from "@earendil-works/pi-tui";
17
+ import { Container, type Component, type SelectItem, SelectList, Spacer, Text, Markdown, matchesKey } from "@earendil-works/pi-tui";
17
18
  import type { Api, Model } from "@earendil-works/pi-ai";
18
19
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
19
20
  import type {
20
- SubagentPreset,
21
21
  TaskTemplate,
22
22
  SubagentResult,
23
23
  SubagentRun,
@@ -28,7 +28,6 @@ import type {
28
28
  GraphDetails,
29
29
  DelegateTaskDetails,
30
30
  GraphTask,
31
- SubTaskResult,
32
31
  ThinkingLevel,
33
32
  UsageStats,
34
33
  FileDiff,
@@ -42,8 +41,7 @@ import {
42
41
  COLLAPSED_OUTPUT_LINES,
43
42
  COLLAPSED_DIFF_FILES_PREVIEW,
44
43
  EXPANDED_HUNKS_PER_FILE,
45
- RESERVED_NAME_PATTERN,
46
- RESERVED_COMMAND_NAMES,
44
+ DEFAULT_OUTPUT_CAP_BYTES,
47
45
  formatTokens,
48
46
  formatUsageStats,
49
47
  formatModel,
@@ -52,15 +50,16 @@ import {
52
50
  isSubagentError,
53
51
  isMultiSubagentDetails,
54
52
  isGraphDetails,
53
+ isUnsettledPartial,
55
54
  } from "./types";
56
55
  import { buildFileAccessReport, buildSecretsExposureReport, generateComplianceSummary } from "./reports";
57
- import { extractParamNames, loadAllTemplates, validateTemplatePresetRefs } from "./templates";
56
+ import { extractParamNames } from "./templates";
58
57
  import { parseDiff } from "./diff";
59
- import { formatPresetSummary, getPreset, getAllPresets, writePresetFile, loadCustomPresets, parseFrontmatter } from "./presets";
60
- import { formatRunDuration } from "./history";
58
+ import { formatPresetSummary, getPreset, loadCustomPresets } from "./presets";
59
+ import { buildOutputHonestyLines, formatRunDuration } from "./history";
61
60
  import * as fs from "node:fs";
62
61
  import * as path from "node:path";
63
- import { sweepStaleLiveSubagents, type SessionState } from "./state";
62
+ import { collapseRunsForHistory, sweepStaleLiveSubagents, type SessionState } from "./state";
64
63
  import { getAgent } from "./session-manager";
65
64
  import { computeSLAMetrics, computeCostTrend, formatSparkline } from "./metrics";
66
65
  import { formatElapsed, liveRowName, liveSpinner, formatLiveRowDim } from "./tui-format";
@@ -638,185 +637,6 @@ export async function showComplianceMenu(
638
637
 
639
638
  // Preset management UI
640
639
 
641
- export async function showAddPreset(
642
- ctx: ExtensionContext,
643
- state: SessionState,
644
- ): Promise<void> {
645
- // 1. Prompt for name
646
- const name = await ctx.ui.input("Preset name (e.g., read-only-audit):");
647
- if (!name?.trim()) return;
648
- const trimmedName = name.trim();
649
-
650
- // 2. Validate name (reserved patterns, existing presets)
651
- if (RESERVED_NAME_PATTERN.test(trimmedName)) {
652
- ctx.ui.notify("Names starting and ending with '__' are reserved.", "error");
653
- return;
654
- }
655
- if (RESERVED_COMMAND_NAMES.has(trimmedName)) {
656
- ctx.ui.notify("Name '" + trimmedName + "' is reserved for a command.", "error");
657
- return;
658
- }
659
- if (getPreset(trimmedName, state.builtinPresets, state.customPresets)) {
660
- ctx.ui.notify(`Preset "${trimmedName}" already exists.`, "error");
661
- return;
662
- }
663
-
664
- // 3. Description
665
- const description = await ctx.ui.input("Description (optional):");
666
-
667
- // 4. Thinking level
668
- const thinkingItems: SelectItem[] = [
669
- { value: "", label: "(not set — use conductor's choice)" },
670
- ...THINKING_LEVELS.map((level) => ({ value: level, label: level })),
671
- ];
672
- const thinkingResult = await showSelectList(ctx, "Default Thinking Level", thinkingItems, 8);
673
-
674
- // 5. Tool scope
675
- const scopeItems: SelectItem[] = [
676
- { value: "all", label: "All tools (default)" },
677
- { value: "readonly", label: "Read-only (read, grep, find, ls)" },
678
- { value: "custom", label: "Custom tool list..." },
679
- ];
680
- const scopeResult = await showSelectList(ctx, "Tool Scope", scopeItems, 5);
681
-
682
- let tools: string[] | undefined;
683
- let excludeTools: string[] | undefined;
684
- let noBuiltinTools: boolean | undefined;
685
-
686
- if (scopeResult === "readonly") {
687
- tools = ["read", "grep", "find", "ls"];
688
- excludeTools = ["write", "edit", "bash"];
689
- } else if (scopeResult === "custom") {
690
- const toolsStr = await ctx.ui.input("Tools (comma-separated):");
691
- if (toolsStr?.trim()) tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean);
692
- }
693
-
694
- // 6. Inheritance
695
- const inheritItems: SelectItem[] = [
696
- { value: "true", label: "Inherit system prompt (default)" },
697
- { value: "false", label: "No inheritance (standalone)" },
698
- ];
699
- const inheritResult = await showSelectList(ctx, "System Prompt Inheritance", inheritItems, 3);
700
-
701
- // 7. System prompt body
702
- const systemPrompt = await ctx.ui.input("System prompt (optional, multi-line not supported yet):");
703
-
704
- // 8. Build preset object
705
- const preset: SubagentPreset = {
706
- name: trimmedName,
707
- description: description?.trim() || undefined,
708
- thinkingLevel: thinkingResult || undefined,
709
- inheritSystemPrompt: inheritResult === "false" ? false : undefined,
710
- tools,
711
- excludeTools,
712
- noBuiltinTools,
713
- systemPrompt: systemPrompt?.trim() || undefined,
714
- };
715
-
716
- // 9. Ask where to save
717
- const locationItems: SelectItem[] = [
718
- { value: "project", label: "Project (.pi/brl-subagent/presets/)" },
719
- { value: "global", label: "Global (~/.pi/agent/brl-subagent/presets/)" },
720
- ];
721
- const location = await showSelectList(ctx, "Save Location", locationItems, 3);
722
- if (!location) return;
723
-
724
- const homedir = process.env.HOME || process.env.USERPROFILE || "";
725
- const targetDir = location === "project"
726
- ? path.join(ctx.cwd, ".pi", "brl-subagent", "presets")
727
- : path.join(homedir, ".pi", "agent", "brl-subagent", "presets");
728
-
729
- // 10. Write file
730
- try {
731
- writePresetFile(preset, targetDir);
732
- // 11. Refresh custom presets
733
- state.customPresets = loadCustomPresets(ctx.cwd, state.log);
734
- // Reload templates alongside presets — both are file-derived, and a
735
- // preset add/remove may fix (or break) a template's `preset:` reference.
736
- // Full stack: custom (project+global) merged over builtins.
737
- state.config.templates = loadAllTemplates(ctx.cwd, state.log);
738
- // Issue #81: re-check template `preset:` refs after the mutation — warn
739
- // (never skip) for dangling references instead of preset-less runs.
740
- validateTemplatePresetRefs(
741
- state.config.templates,
742
- getAllPresets(state.builtinPresets, state.customPresets),
743
- state.log,
744
- );
745
- ctx.ui.notify(`Preset "${trimmedName}" saved to ${location === "project" ? "project" : "global"} directory`, "info");
746
- } catch (err) {
747
- ctx.ui.notify(`Failed to save preset: ${(err as Error).message}`, "error");
748
- }
749
- }
750
-
751
- export async function showRemovePreset(
752
- ctx: ExtensionContext,
753
- state: SessionState,
754
- ): Promise<void> {
755
- const homedir = process.env.HOME || process.env.USERPROFILE || "";
756
- const projectDir = path.join(ctx.cwd, ".pi", "brl-subagent", "presets");
757
- const globalDir = path.join(homedir, ".pi", "agent", "brl-subagent", "presets");
758
-
759
- // Scan both directories for .md files
760
- const entries: Array<{ name: string; source: "project" | "global"; filePath: string }> = [];
761
-
762
- for (const dir of [projectDir, globalDir]) {
763
- const source = dir === projectDir ? "project" : "global";
764
- try {
765
- const files = fs.readdirSync(dir);
766
- for (const file of files) {
767
- if (!file.endsWith(".md")) continue;
768
- const filePath = path.join(dir, file);
769
- try {
770
- const content = fs.readFileSync(filePath, "utf-8");
771
- const { meta } = parseFrontmatter(content);
772
- if (meta.name && typeof meta.name === "string") {
773
- entries.push({ name: meta.name as string, source, filePath });
774
- }
775
- } catch {
776
- // Skip unparseable files
777
- }
778
- }
779
- } catch {
780
- // Directory doesn't exist — fine
781
- }
782
- }
783
-
784
- if (entries.length === 0) {
785
- ctx.ui.notify("No custom presets to remove.", "info");
786
- return;
787
- }
788
-
789
- // Show list with source indicators
790
- const items: SelectItem[] = entries.map((e) => ({
791
- value: e.filePath,
792
- label: `[${e.source === "project" ? "P" : "G"}] ${e.name}`,
793
- description: e.source === "project" ? "Project-local" : "Global",
794
- }));
795
-
796
- const result = await showSelectList(ctx, "Remove Preset", items, 10);
797
- if (!result) return;
798
-
799
- // Delete the file
800
- try {
801
- fs.unlinkSync(result);
802
- state.customPresets = loadCustomPresets(ctx.cwd, state.log);
803
- // Reload templates too — a removed preset may orphan a template's
804
- // `preset:` reference, so both file-derived collections refresh together.
805
- // Full stack: custom (project+global) merged over builtins.
806
- state.config.templates = loadAllTemplates(ctx.cwd, state.log);
807
- // Issue #81: re-check template `preset:` refs after the mutation — warn
808
- // (never skip) for dangling references instead of preset-less runs.
809
- validateTemplatePresetRefs(
810
- state.config.templates,
811
- getAllPresets(state.builtinPresets, state.customPresets),
812
- state.log,
813
- );
814
- ctx.ui.notify("Preset removed", "info");
815
- } catch (err) {
816
- ctx.ui.notify(`Failed to remove preset: ${(err as Error).message}`, "error");
817
- }
818
- }
819
-
820
640
  export async function showPresetManager(
821
641
  ctx: ExtensionContext,
822
642
  state: SessionState,
@@ -1270,127 +1090,253 @@ export async function showRunHistory(
1270
1090
  state: SessionState,
1271
1091
  persistState: () => void,
1272
1092
  ): Promise<void> {
1273
- const runs = state.getRunEntries(ctx).reverse();
1274
-
1275
- if (runs.length === 0) {
1276
- ctx.ui.notify("No subagent runs recorded yet. Delegate a task to see history.", "info");
1277
- return;
1278
- }
1279
-
1280
1093
  const statusIcon: Record<string, string> = {
1281
1094
  running: ctx.ui.theme.fg("accent", "◉"),
1282
1095
  done: ctx.ui.theme.fg("success", "✓"),
1283
1096
  failed: ctx.ui.theme.fg("error", "✗"),
1284
1097
  };
1285
1098
 
1286
- const items: SelectItem[] = runs.map((r) => {
1287
- const icon = statusIcon[r.status] || "·";
1288
- const name = r.label || r.task.slice(0, 60);
1289
- const model = r.model.split("/").pop() || r.model;
1290
- const when = r.finishedAt ? formatRunDuration(r.durationMs || 0) : "running...";
1291
- const cost = r.cost ? ` $${r.cost.toFixed(4)}` : "";
1292
- const desc = `${r.thinkingLevel} · ${model} · ${when}${cost}`;
1293
- return { value: r.id, label: `${icon} ${name}`, description: desc };
1294
- });
1099
+ // Browse loop (issue #260): list → detail → list … until the LIST is exited
1100
+ // with Esc. Closing a detail returns here, not to the /brl-subagent menu.
1101
+ while (true) {
1102
+ // One row per SETTLED run (issue #259): collapse each run's spawn+terminal
1103
+ // entry pair to its terminal-preferred record; in-flight runs are omitted
1104
+ // (the live monitor owns running state).
1105
+ const runs = collapseRunsForHistory(state.getRunEntries(ctx));
1106
+
1107
+ if (runs.length === 0) {
1108
+ ctx.ui.notify("No subagent runs recorded yet. Delegate a task to see history.", "info");
1109
+ return;
1110
+ }
1295
1111
 
1296
- const selectedId = await showSelectList(ctx, "Subagent History", items, 15);
1297
- if (!selectedId) return;
1112
+ const items: SelectItem[] = runs.map((r) => {
1113
+ const icon = statusIcon[r.status] || "·";
1114
+ const name = r.label || r.task.slice(0, 60);
1115
+ const model = r.model.split("/").pop() || r.model;
1116
+ const when = r.finishedAt ? formatRunDuration(r.durationMs || 0) : "running...";
1117
+ const cost = r.cost ? ` $${r.cost.toFixed(4)}` : "";
1118
+ const desc = `${r.thinkingLevel} · ${model} · ${when}${cost}`;
1119
+ return { value: r.id, label: `${icon} ${name}`, description: desc };
1120
+ });
1298
1121
 
1299
- const run = runs.find((r) => r.id === selectedId);
1300
- if (!run) return;
1122
+ const selectedId = await showSelectList(ctx, "Subagent History", items, 15);
1123
+ if (!selectedId) return; // Esc at the list exits to the menu
1301
1124
 
1302
- // Mark as seen
1303
- if (state.markRunSeen(run.id)) {
1304
- persistState();
1305
- import("./concurrency").then(({ updateProgressStatus }) => {
1306
- updateProgressStatus(state, ctx);
1307
- });
1125
+ const run = runs.find((r) => r.id === selectedId);
1126
+ if (!run) continue; // selected run pruned since listing — re-show the list
1127
+
1128
+ // Mark as seen
1129
+ if (state.markRunSeen(run.id)) {
1130
+ persistState();
1131
+ import("./concurrency").then(({ updateProgressStatus }) => {
1132
+ updateProgressStatus(state, ctx);
1133
+ });
1134
+ }
1135
+
1136
+ await showRunDetail(ctx, run);
1308
1137
  }
1138
+ }
1309
1139
 
1310
- // Show detail view
1311
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
1312
- const container = new Container();
1313
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1140
+ /**
1141
+ * Number of output lines a run-detail viewport shows at once (issue #261):
1142
+ * the stored text can be up to the 100 KB cap, so the panel is height-capped
1143
+ * and scrolled rather than dumped whole (which would push the footer and
1144
+ * header off-screen).
1145
+ */
1146
+ const RUN_DETAIL_OUTPUT_VIEWPORT_LINES = 12;
1314
1147
 
1315
- const statusLabel =
1316
- run.status === "done" ? "Completed" : run.status === "failed" ? "Failed" : "Running";
1317
- container.addChild(
1318
- new Text(
1319
- theme.fg(
1320
- "toolTitle",
1321
- theme.bold(`${run.label || "Subagent"} — ${statusLabel}`),
1322
- ),
1323
- 1,
1324
- 0,
1325
- ),
1326
- );
1327
- container.addChild(new Text("", 0, 0));
1328
- container.addChild(
1329
- new Text(theme.fg("dim", `Task: ${run.task.slice(0, 200)}`), 1, 0),
1330
- );
1331
- container.addChild(
1332
- new Text(
1333
- theme.fg("dim", `Model: ${run.model} · Thinking: ${run.thinkingLevel}`),
1334
- 1,
1335
- 0,
1336
- ),
1337
- );
1338
- container.addChild(new Text(theme.fg("dim", `Started: ${run.startedAt}`), 1, 0));
1339
- if (run.finishedAt) {
1340
- container.addChild(
1148
+ /**
1149
+ * Detail view for one settled run (issue #260, #261): ↑/↓ and PgUp/PgDn scroll
1150
+ * the stored output; any OTHER key — including Esc — closes it. The
1151
+ * showRunHistory browse loop re-shows the list on return.
1152
+ *
1153
+ * The output region follows the approval dialog's full-diff view structure
1154
+ * (see `showApprovalDialog.buildDiffView` / `withDiffKeybinding`: a bordered
1155
+ * Container with a title, the content, and a return footer) but adds the
1156
+ * height cap + scroll state that view lacks. Output is wrapped through the
1157
+ * same `Text` component those views use, so wrapping/padding stay consistent.
1158
+ */
1159
+ async function showRunDetail(ctx: ExtensionContext, run: SubagentRun): Promise<void> {
1160
+ const outputText = run.fullOutput ?? run.outputSummary ?? "";
1161
+ await ctx.ui.custom<void>((tui, theme, kb, done) => {
1162
+ // First line of the viewport (0 = top). Mutated by scroll keys and
1163
+ // clamped to the wrapped-content length on every render.
1164
+ let scrollTop = 0;
1165
+ // Wrapped output lines for the current width, and the width they were
1166
+ // produced at, so a resize re-wraps and a redraw does not.
1167
+ let wrappedLines: string[] = [];
1168
+ let wrappedWidth = -1;
1169
+
1170
+ const wrapOutput = (width: number): string[] => {
1171
+ if (width === wrappedWidth) return wrappedLines;
1172
+ wrappedLines = new Text(theme.fg("toolOutput", outputText), 1, 0).render(width);
1173
+ wrappedWidth = width;
1174
+ return wrappedLines;
1175
+ };
1176
+
1177
+ const buildOutputSection = (width: number): Container => {
1178
+ const section = new Container();
1179
+ const lines = wrapOutput(width);
1180
+ const total = lines.length;
1181
+ const maxScroll = Math.max(0, total - RUN_DETAIL_OUTPUT_VIEWPORT_LINES);
1182
+ if (scrollTop > maxScroll) scrollTop = maxScroll;
1183
+ if (scrollTop < 0) scrollTop = 0;
1184
+ const from = total === 0 ? 0 : Math.min(scrollTop + 1, total);
1185
+ const to = Math.min(scrollTop + RUN_DETAIL_OUTPUT_VIEWPORT_LINES, total);
1186
+ const indicator =
1187
+ total > RUN_DETAIL_OUTPUT_VIEWPORT_LINES
1188
+ ? ` (lines ${from}\u2013${to} of ${total} \u00b7 \u2191\u2193 / PgUp\u00b7PgDn)`
1189
+ : ` (${total} line${total === 1 ? "" : "s"})`;
1190
+
1191
+ section.addChild(
1341
1192
  new Text(
1342
- theme.fg(
1343
- "dim",
1344
- `Finished: ${run.finishedAt} (${formatRunDuration(run.durationMs || 0)})`,
1345
- ),
1193
+ theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500") + theme.fg("dim", indicator),
1346
1194
  1,
1347
1195
  0,
1348
1196
  ),
1349
1197
  );
1350
- }
1351
- if (run.cost) {
1198
+
1199
+ if (total === 0) {
1200
+ section.addChild(new Text(theme.fg("dim", "(no output recorded)"), 1, 0));
1201
+ return section;
1202
+ }
1203
+
1204
+ // Height-capped viewport: the output was already wrapped to `width`
1205
+ // above, so hand the sliced lines back directly instead of feeding them
1206
+ // through another Text (which would re-wrap at the padded width).
1207
+ const viewport: Component = {
1208
+ render: () => lines.slice(scrollTop, scrollTop + RUN_DETAIL_OUTPUT_VIEWPORT_LINES),
1209
+ invalidate: () => {
1210
+ wrappedWidth = -1;
1211
+ },
1212
+ };
1213
+ section.addChild(viewport);
1214
+ return section;
1215
+ };
1216
+
1217
+ const buildDetailView = (width: number): Container => {
1218
+ const container = new Container();
1219
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1220
+
1221
+ const statusLabel =
1222
+ run.status === "done" ? "Completed" : run.status === "failed" ? "Failed" : "Running";
1352
1223
  container.addChild(
1353
1224
  new Text(
1354
1225
  theme.fg(
1355
- "dim",
1356
- `Cost: $${run.cost.toFixed(4)} · ↑${formatTokens(run.tokensIn || 0)} ↓${formatTokens(run.tokensOut || 0)}`,
1226
+ "toolTitle",
1227
+ theme.bold(`${run.label || "Subagent"} — ${statusLabel}`),
1357
1228
  ),
1358
1229
  1,
1359
1230
  0,
1360
1231
  ),
1361
1232
  );
1362
- }
1363
- if (run.errorMessage) {
1364
- container.addChild(new Spacer(1));
1233
+ container.addChild(new Text("", 0, 0));
1365
1234
  container.addChild(
1366
- new Text(theme.fg("error", `Error: ${run.errorMessage}`), 1, 0),
1235
+ new Text(theme.fg("dim", `Task: ${run.task.slice(0, 200)}`), 1, 0),
1367
1236
  );
1368
- }
1369
- if (run.outputSummary) {
1370
- container.addChild(new Spacer(1));
1371
1237
  container.addChild(
1372
1238
  new Text(
1373
- theme.fg("muted", "\u2500\u2500\u2500 Output Preview \u2500\u2500\u2500"),
1239
+ theme.fg("dim", `Model: ${run.model} · Thinking: ${run.thinkingLevel}`),
1374
1240
  1,
1375
1241
  0,
1376
1242
  ),
1377
1243
  );
1378
- container.addChild(
1379
- new Text(theme.fg("toolOutput", run.outputSummary), 1, 0),
1380
- );
1381
- if ((run.outputSummary || "").length >= 200) {
1382
- container.addChild(new Text(theme.fg("dim", "(first 200 characters)"), 1, 0));
1244
+ container.addChild(new Text(theme.fg("dim", `Started: ${run.startedAt}`), 1, 0));
1245
+ if (run.finishedAt) {
1246
+ container.addChild(
1247
+ new Text(
1248
+ theme.fg(
1249
+ "dim",
1250
+ `Finished: ${run.finishedAt} (${formatRunDuration(run.durationMs || 0)})`,
1251
+ ),
1252
+ 1,
1253
+ 0,
1254
+ ),
1255
+ );
1256
+ }
1257
+ if (run.cost) {
1258
+ container.addChild(
1259
+ new Text(
1260
+ theme.fg(
1261
+ "dim",
1262
+ `Cost: $${run.cost.toFixed(4)} · ↑${formatTokens(run.tokensIn || 0)} ↓${formatTokens(run.tokensOut || 0)}`,
1263
+ ),
1264
+ 1,
1265
+ 0,
1266
+ ),
1267
+ );
1268
+ }
1269
+ if (run.errorMessage) {
1270
+ container.addChild(new Spacer(1));
1271
+ container.addChild(
1272
+ new Text(theme.fg("error", `Error: ${run.errorMessage}`), 1, 0),
1273
+ );
1383
1274
  }
1384
- }
1385
1275
 
1386
- container.addChild(new Spacer(1));
1387
- container.addChild(new Text(theme.fg("dim", "Press any key to close"), 1, 0));
1388
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1276
+ container.addChild(new Spacer(1));
1277
+ container.addChild(buildOutputSection(width));
1278
+ // Honest disclosures (issue #261): the transcript is the only place the
1279
+ // uncapped text lives, and the cap is stated when it was hit.
1280
+ for (const line of buildOutputHonestyLines(run, DEFAULT_OUTPUT_CAP_BYTES)) {
1281
+ container.addChild(new Text(theme.fg("dim", line), 1, 0));
1282
+ }
1283
+
1284
+ container.addChild(new Spacer(1));
1285
+ container.addChild(
1286
+ new Text(
1287
+ theme.fg("dim", "\u2191\u2193 scroll \u00b7 alt+\u2191\u2193 page \u00b7 any other key to close"),
1288
+ 1,
1289
+ 0,
1290
+ ),
1291
+ );
1292
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1293
+ return container;
1294
+ };
1389
1295
 
1390
1296
  return {
1391
- render: (w: number) => container.render(w),
1392
- invalidate: () => container.invalidate(),
1393
- handleInput: (_data: string) => done(),
1297
+ render: (w: number) => buildDetailView(w).render(w),
1298
+ invalidate: () => {},
1299
+ handleInput: (data: string) => {
1300
+ // Paging: alt+↑ / alt+↓. Checked BEFORE the plain arrows so an
1301
+ // alt-modified sequence can never be consumed as a line step.
1302
+ // PgUp/PgDn are unusable here: the host binds them to the
1303
+ // alt-screen transcript scroll (`tui.altScreen.pageUp/pageDown`)
1304
+ // and consumes them before a `ctx.ui.custom` overlay sees input —
1305
+ // pi 1.0.2 has no viewport-owner registration for overlays. The
1306
+ // PgUp/PgDn checks below stay for inline mode, where they do
1307
+ // reach the overlay.
1308
+ if (matchesKey(data, "alt+up")) {
1309
+ scrollTop -= RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
1310
+ tui.requestRender();
1311
+ return;
1312
+ }
1313
+ if (matchesKey(data, "alt+down")) {
1314
+ scrollTop += RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
1315
+ tui.requestRender();
1316
+ return;
1317
+ }
1318
+ if (kb.matches(data, "tui.select.up")) {
1319
+ scrollTop -= 1;
1320
+ tui.requestRender();
1321
+ return;
1322
+ }
1323
+ if (kb.matches(data, "tui.select.down")) {
1324
+ scrollTop += 1;
1325
+ tui.requestRender();
1326
+ return;
1327
+ }
1328
+ if (kb.matches(data, "tui.select.pageUp")) {
1329
+ scrollTop -= RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
1330
+ tui.requestRender();
1331
+ return;
1332
+ }
1333
+ if (kb.matches(data, "tui.select.pageDown")) {
1334
+ scrollTop += RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
1335
+ tui.requestRender();
1336
+ return;
1337
+ }
1338
+ done();
1339
+ },
1394
1340
  };
1395
1341
  });
1396
1342
  }
@@ -2179,29 +2125,6 @@ function renderCollapsedText(
2179
2125
  return text;
2180
2126
  }
2181
2127
 
2182
- // ---------------------------------------------------------------------------
2183
- // Chain / parallel helper: one-line subtask summary
2184
- // ---------------------------------------------------------------------------
2185
-
2186
- function renderSubTaskSummary(
2187
- result: SubTaskResult,
2188
- theme: {
2189
- fg: (color: string, text: string) => string;
2190
- },
2191
- maxLines: number,
2192
- ): string {
2193
- const isError = isSubagentError(result);
2194
- const icon = isError
2195
- ? theme.fg("error", "\u2717")
2196
- : theme.fg("success", "\u2713");
2197
- const label = result.label
2198
- ? theme.fg("accent", `${result.label}: `)
2199
- : "";
2200
- const output = getFinalOutput(result.messages);
2201
- const preview = output.split("\n").slice(0, maxLines).join("\n");
2202
- return `${icon} ${label}${theme.fg("toolOutput", preview)}`;
2203
- }
2204
-
2205
2128
  // ---------------------------------------------------------------------------
2206
2129
  // Chain / parallel collapsed rendering
2207
2130
  // ---------------------------------------------------------------------------
@@ -3008,7 +2931,19 @@ export function renderDelegateResult(
3008
2931
  }
3009
2932
 
3010
2933
  // Single subagent mode (original)
3011
- if (!details || details.exitCode === -1) {
2934
+ // Issue #298 (C2): the partial-vs-settled decision lives in ONE place —
2935
+ // `isUnsettledPartial` (src/types.ts) owns the overloaded `exitCode === -1`
2936
+ // disambiguation (the #206 streaming sentinel vs the #295 settled signal
2937
+ // death). Do not re-derive it here; the terminal-status structural ratchet
2938
+ // fails a local copy. A genuine partial (or a result with no details yet)
2939
+ // renders the raw running text; a classified -1 is a settled FAILURE and
2940
+ // takes the verdict branch below (✗ + error line + expanded-transcript
2941
+ // binding), never raw text.
2942
+ //
2943
+ // The leading `!details` is a type-narrowing aid for the settled branch below
2944
+ // (TS cannot narrow through a function call); it is NOT a second copy of the
2945
+ // decision — the predicate owns the no-details case too.
2946
+ if (!details || isUnsettledPartial(details)) {
3012
2947
  const text = result.content[0];
3013
2948
  return new Text(text?.type === "text" ? text.text : "(running\u2026)", 0, 0);
3014
2949
  }