brl-subagent 2.4.0 → 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/state.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  CIRCUIT_DEGRADED_THINKING,
36
36
  } from "./types";
37
37
  import { cleanupRuns } from "./history";
38
+ import { persistRunRecord, type InflightRunKind } from "./run-registry";
38
39
  import type { Logger } from "./logging";
39
40
 
40
41
  // ---------------------------------------------------------------------------
@@ -69,14 +70,21 @@ export const STALE_FINALIZE_GRACE_MS = POLLER_TICK_MS + 1000;
69
70
 
70
71
  /**
71
72
  * Terminal-preferring run-entry lookup — the ONE implementation of the
72
- * preference rule.
73
+ * preference rule. Recovery's `dedupeRunEntriesById` delegates here, so the
74
+ * boot scan and every display/retry lookup agree by construction.
73
75
  *
74
76
  * Each run writes TWO custom entries sharing its id, in append order: a spawn
75
77
  * entry (status "running", carrying `originalParams`) FIRST, then the final
76
78
  * entry (status "done"/"failed", stamped with fullOutput/cost/duration) at
77
- * settle. Prefer the terminal entry; when none is terminal yet, fall back to
78
- * the first match (the pre-finalize spawn shape). Returns undefined when no
79
- * entry matches.
79
+ * settle. Preference, in order:
80
+ *
81
+ * 1. a TERMINAL entry (status !== "running") — the run is resolved;
82
+ * 2. among non-terminal entries, one carrying the D3 `interruptedAt` mark —
83
+ * append-only stores keep the original spawn entry forever, so the marked
84
+ * clone is the authoritative RECOVERED state U3's shared lookup reads;
85
+ * 3. otherwise the first match (the pre-finalize spawn shape).
86
+ *
87
+ * Returns undefined when no entry matches.
80
88
  */
81
89
  export function resolveTerminalRunEntry(
82
90
  entries: SubagentRun[],
@@ -84,7 +92,37 @@ export function resolveTerminalRunEntry(
84
92
  ): SubagentRun | undefined {
85
93
  const matching = entries.filter((r) => r.id === id);
86
94
  if (matching.length === 0) return undefined;
87
- return matching.find((r) => r.status !== "running") ?? matching[0];
95
+ return (
96
+ matching.find((r) => r.status !== "running") ??
97
+ matching.find((r) => r.interruptedAt) ??
98
+ matching[0]
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Run-history display projection (issue #259): ONE record per SETTLED run.
104
+ *
105
+ * Each run appends two entries sharing its id — a spawn entry (status
106
+ * "running") first, then the terminal entry at settle — so a raw entry list
107
+ * renders every run twice and the spawn row shows a stale "running" forever.
108
+ * This collapses each id to its terminal-preferred entry via the ONE shared
109
+ * rule (`resolveTerminalRunEntry`). Runs still in flight (spawn entry, no
110
+ * terminal entry yet) are omitted: live state belongs to the monitor.
111
+ *
112
+ * Ordering is preserved from `entries`; callers pass `getRunEntries` output
113
+ * (newest-first via `cleanupRuns`), which stays the single ordering authority —
114
+ * no re-sort is performed here.
115
+ */
116
+ export function collapseRunsForHistory(entries: SubagentRun[]): SubagentRun[] {
117
+ const display: SubagentRun[] = [];
118
+ const seen = new Set<string>();
119
+ for (const entry of entries) {
120
+ if (seen.has(entry.id)) continue;
121
+ seen.add(entry.id);
122
+ const resolved = resolveTerminalRunEntry(entries, entry.id);
123
+ if (resolved && resolved.status !== "running") display.push(resolved);
124
+ }
125
+ return display;
88
126
  }
89
127
 
90
128
  // ---------------------------------------------------------------------------
@@ -194,8 +232,14 @@ export class SessionState {
194
232
  });
195
233
  }
196
234
 
197
- persistRun(pi: ExtensionAPI, run: SubagentRun): void {
198
- pi.appendEntry(CUSTOM_ENTRY_TYPES.run, run);
235
+ /**
236
+ * Persist a run record through the ONE choke point (`persistRunRecord`),
237
+ * which appends the session entry AND mirrors the record into the durable
238
+ * in-flight registry. `kind` is supplied at dispatch; terminal writes clear
239
+ * the registry entry.
240
+ */
241
+ persistRun(pi: ExtensionAPI, run: SubagentRun, kind?: InflightRunKind): void {
242
+ persistRunRecord(pi, run, kind);
199
243
  }
200
244
 
201
245
  // -------------------------------------------------------------------
@@ -0,0 +1,41 @@
1
+ // Purpose: The single declaration of the transcript path format — filesystem (`join`) and display (POSIX) forms.
2
+ import { join } from "node:path";
3
+ import { assertSafeAgentId } from "./sanitize";
4
+
5
+ /**
6
+ * The transcript directory, relative to the project root. This is the ONE
7
+ * declaration of the format; every other module derives its path from here
8
+ * rather than re-inlining `.pi/output` in a template string (issue #276).
9
+ */
10
+ export const TRANSCRIPT_DIR = ".pi/output";
11
+
12
+ /**
13
+ * Filesystem path to an agent's transcript: `<dir>/agent-<id>.jsonl`.
14
+ *
15
+ * SECURITY (F24): the id is validated through `assertSafeAgentId` BEFORE it
16
+ * reaches `path.join` — join does NOT sanitize `../` or absolute segments, so
17
+ * a traversal id would otherwise escape `dir`. Keep this chokepoint for every
18
+ * filesystem caller (`transcript.ts`, the `get_agent_result` re-export).
19
+ *
20
+ * `dir` is a parameter so `transcript.ts` can forward its mutable test-only
21
+ * `OUTPUT_DIR`; production callers take the `TRANSCRIPT_DIR` default.
22
+ */
23
+ export function transcriptPath(agentId: string, dir: string = TRANSCRIPT_DIR): string {
24
+ assertSafeAgentId(agentId);
25
+ return join(dir, `agent-${agentId}.jsonl`);
26
+ }
27
+
28
+ /**
29
+ * Display form of the transcript path: `<TRANSCRIPT_DIR>/agent-<id>.jsonl`,
30
+ * always with forward slashes so the rendered panel is identical on every
31
+ * platform.
32
+ *
33
+ * This is deliberately format-only: it does NOT call `assertSafeAgentId` and
34
+ * must never throw. It is called during TUI rendering (`tui.ts` →
35
+ * `buildOutputHonestyLines`) from a stored run record, where a throw would
36
+ * break the panel. Display text is POSIX while filesystem access uses `join` —
37
+ * the two forms intentionally differ (settled in issue #276).
38
+ */
39
+ export function transcriptDisplayPath(agentId: string): string {
40
+ return `${TRANSCRIPT_DIR}/agent-${agentId}.jsonl`;
41
+ }
package/src/transcript.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  // Purpose: JSONL transcript recording for every agent run (`.pi/output/agent-<id>.jsonl`).
2
- import { join } from 'path';
3
2
  import { mkdirSync, appendFileSync, readFileSync, existsSync } from 'fs';
4
3
  import type { TranscriptEntry, TranscriptEntryType } from './types';
5
- import { assertSafeAgentId } from './sanitize';
4
+ import { transcriptPath, TRANSCRIPT_DIR } from './transcript-path';
5
+ import { createLogger } from './logging';
6
+
7
+ // Issue #265: route the settle-path warning through the logger (file-only by
8
+ // default) instead of a raw console.warn that corrupts the pi TUI.
9
+ const log = createLogger('brl-subagent');
6
10
 
7
11
  // Output directory — overridable for tests (issue #52): unit tests must NOT
8
- // write into the real repo .pi/ dir; the setter is test-only.
9
- let OUTPUT_DIR = '.pi/output';
12
+ // write into the real repo .pi/ dir; the setter is test-only. Defaults to the
13
+ // single-sourced TRANSCRIPT_DIR (issue #276).
14
+ let OUTPUT_DIR = TRANSCRIPT_DIR;
10
15
 
11
16
  /**
12
17
  * TEST-ONLY: point the transcript output at an alternate directory.
@@ -30,13 +35,9 @@ function ensureOutputDir(): void {
30
35
  * Get transcript file path for an agent
31
36
  */
32
37
  export function getTranscriptPath(agentId: string): string {
33
- // F24: single chokepoint for every transcript file path. All internal callers
34
- // (startTranscript/appendEntry/getTranscript/completeTranscript) pass
35
- // generateUUID() ids, but get_agent_result feeds LLM-controlled ids in via
36
- // getTranscript. path.join does not sanitize "../" or absolute segments, so
37
- // throw on anything that is not a UUID before it reaches the filesystem.
38
- assertSafeAgentId(agentId);
39
- return join(OUTPUT_DIR, `agent-${agentId}.jsonl`);
38
+ // Issue #276: the format and the F24 `assertSafeAgentId` chokepoint live in
39
+ // transcript-path.ts; here we only forward the mutable test-only OUTPUT_DIR.
40
+ return transcriptPath(agentId, OUTPUT_DIR);
40
41
  }
41
42
 
42
43
  /**
@@ -118,7 +119,7 @@ export function completeTranscript(agentId: string, status: string): void {
118
119
  // appending — that invariant is load-bearing for caller validation).
119
120
  const path = getTranscriptPath(agentId);
120
121
  if (!existsSync(path)) {
121
- console.warn(`[brl-subagent] completeTranscript: transcript missing for agent ${agentId} — skipping completion entry`);
122
+ log.warn(`completeTranscript: transcript missing for agent ${agentId} — skipping completion entry`);
122
123
  return;
123
124
  }
124
125
  appendEntry(agentId, 'system', `Transcript completed: ${status}`);
package/src/tui.ts CHANGED
@@ -14,7 +14,7 @@ import type {
14
14
  ToolRenderResultOptions,
15
15
  } from "@earendil-works/pi-coding-agent";
16
16
  import { DynamicBorder, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
17
- 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";
18
18
  import type { Api, Model } from "@earendil-works/pi-ai";
19
19
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
20
20
  import type {
@@ -41,6 +41,7 @@ import {
41
41
  COLLAPSED_OUTPUT_LINES,
42
42
  COLLAPSED_DIFF_FILES_PREVIEW,
43
43
  EXPANDED_HUNKS_PER_FILE,
44
+ DEFAULT_OUTPUT_CAP_BYTES,
44
45
  formatTokens,
45
46
  formatUsageStats,
46
47
  formatModel,
@@ -49,15 +50,16 @@ import {
49
50
  isSubagentError,
50
51
  isMultiSubagentDetails,
51
52
  isGraphDetails,
53
+ isUnsettledPartial,
52
54
  } from "./types";
53
55
  import { buildFileAccessReport, buildSecretsExposureReport, generateComplianceSummary } from "./reports";
54
56
  import { extractParamNames } from "./templates";
55
57
  import { parseDiff } from "./diff";
56
58
  import { formatPresetSummary, getPreset, loadCustomPresets } from "./presets";
57
- import { formatRunDuration } from "./history";
59
+ import { buildOutputHonestyLines, formatRunDuration } from "./history";
58
60
  import * as fs from "node:fs";
59
61
  import * as path from "node:path";
60
- import { sweepStaleLiveSubagents, type SessionState } from "./state";
62
+ import { collapseRunsForHistory, sweepStaleLiveSubagents, type SessionState } from "./state";
61
63
  import { getAgent } from "./session-manager";
62
64
  import { computeSLAMetrics, computeCostTrend, formatSparkline } from "./metrics";
63
65
  import { formatElapsed, liveRowName, liveSpinner, formatLiveRowDim } from "./tui-format";
@@ -1088,127 +1090,253 @@ export async function showRunHistory(
1088
1090
  state: SessionState,
1089
1091
  persistState: () => void,
1090
1092
  ): Promise<void> {
1091
- const runs = state.getRunEntries(ctx).reverse();
1092
-
1093
- if (runs.length === 0) {
1094
- ctx.ui.notify("No subagent runs recorded yet. Delegate a task to see history.", "info");
1095
- return;
1096
- }
1097
-
1098
1093
  const statusIcon: Record<string, string> = {
1099
1094
  running: ctx.ui.theme.fg("accent", "◉"),
1100
1095
  done: ctx.ui.theme.fg("success", "✓"),
1101
1096
  failed: ctx.ui.theme.fg("error", "✗"),
1102
1097
  };
1103
1098
 
1104
- const items: SelectItem[] = runs.map((r) => {
1105
- const icon = statusIcon[r.status] || "·";
1106
- const name = r.label || r.task.slice(0, 60);
1107
- const model = r.model.split("/").pop() || r.model;
1108
- const when = r.finishedAt ? formatRunDuration(r.durationMs || 0) : "running...";
1109
- const cost = r.cost ? ` $${r.cost.toFixed(4)}` : "";
1110
- const desc = `${r.thinkingLevel} · ${model} · ${when}${cost}`;
1111
- return { value: r.id, label: `${icon} ${name}`, description: desc };
1112
- });
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
+ }
1113
1111
 
1114
- const selectedId = await showSelectList(ctx, "Subagent History", items, 15);
1115
- 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
+ });
1116
1121
 
1117
- const run = runs.find((r) => r.id === selectedId);
1118
- 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
1119
1124
 
1120
- // Mark as seen
1121
- if (state.markRunSeen(run.id)) {
1122
- persistState();
1123
- import("./concurrency").then(({ updateProgressStatus }) => {
1124
- updateProgressStatus(state, ctx);
1125
- });
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);
1126
1137
  }
1138
+ }
1127
1139
 
1128
- // Show detail view
1129
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
1130
- const container = new Container();
1131
- 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;
1132
1147
 
1133
- const statusLabel =
1134
- run.status === "done" ? "Completed" : run.status === "failed" ? "Failed" : "Running";
1135
- container.addChild(
1136
- new Text(
1137
- theme.fg(
1138
- "toolTitle",
1139
- theme.bold(`${run.label || "Subagent"} — ${statusLabel}`),
1140
- ),
1141
- 1,
1142
- 0,
1143
- ),
1144
- );
1145
- container.addChild(new Text("", 0, 0));
1146
- container.addChild(
1147
- new Text(theme.fg("dim", `Task: ${run.task.slice(0, 200)}`), 1, 0),
1148
- );
1149
- container.addChild(
1150
- new Text(
1151
- theme.fg("dim", `Model: ${run.model} · Thinking: ${run.thinkingLevel}`),
1152
- 1,
1153
- 0,
1154
- ),
1155
- );
1156
- container.addChild(new Text(theme.fg("dim", `Started: ${run.startedAt}`), 1, 0));
1157
- if (run.finishedAt) {
1158
- 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(
1159
1192
  new Text(
1160
- theme.fg(
1161
- "dim",
1162
- `Finished: ${run.finishedAt} (${formatRunDuration(run.durationMs || 0)})`,
1163
- ),
1193
+ theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500") + theme.fg("dim", indicator),
1164
1194
  1,
1165
1195
  0,
1166
1196
  ),
1167
1197
  );
1168
- }
1169
- 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";
1170
1223
  container.addChild(
1171
1224
  new Text(
1172
1225
  theme.fg(
1173
- "dim",
1174
- `Cost: $${run.cost.toFixed(4)} · ↑${formatTokens(run.tokensIn || 0)} ↓${formatTokens(run.tokensOut || 0)}`,
1226
+ "toolTitle",
1227
+ theme.bold(`${run.label || "Subagent"} — ${statusLabel}`),
1175
1228
  ),
1176
1229
  1,
1177
1230
  0,
1178
1231
  ),
1179
1232
  );
1180
- }
1181
- if (run.errorMessage) {
1182
- container.addChild(new Spacer(1));
1233
+ container.addChild(new Text("", 0, 0));
1183
1234
  container.addChild(
1184
- new Text(theme.fg("error", `Error: ${run.errorMessage}`), 1, 0),
1235
+ new Text(theme.fg("dim", `Task: ${run.task.slice(0, 200)}`), 1, 0),
1185
1236
  );
1186
- }
1187
- if (run.outputSummary) {
1188
- container.addChild(new Spacer(1));
1189
1237
  container.addChild(
1190
1238
  new Text(
1191
- theme.fg("muted", "\u2500\u2500\u2500 Output Preview \u2500\u2500\u2500"),
1239
+ theme.fg("dim", `Model: ${run.model} · Thinking: ${run.thinkingLevel}`),
1192
1240
  1,
1193
1241
  0,
1194
1242
  ),
1195
1243
  );
1196
- container.addChild(
1197
- new Text(theme.fg("toolOutput", run.outputSummary), 1, 0),
1198
- );
1199
- if ((run.outputSummary || "").length >= 200) {
1200
- 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
+ );
1201
1274
  }
1202
- }
1203
1275
 
1204
- container.addChild(new Spacer(1));
1205
- container.addChild(new Text(theme.fg("dim", "Press any key to close"), 1, 0));
1206
- 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
+ };
1207
1295
 
1208
1296
  return {
1209
- render: (w: number) => container.render(w),
1210
- invalidate: () => container.invalidate(),
1211
- 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
+ },
1212
1340
  };
1213
1341
  });
1214
1342
  }
@@ -2803,7 +2931,19 @@ export function renderDelegateResult(
2803
2931
  }
2804
2932
 
2805
2933
  // Single subagent mode (original)
2806
- 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)) {
2807
2947
  const text = result.content[0];
2808
2948
  return new Text(text?.type === "text" ? text.text : "(running\u2026)", 0, 0);
2809
2949
  }