brl-subagent 2.4.0 → 2.5.1

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-format.ts CHANGED
@@ -7,7 +7,8 @@
7
7
  * without a TUI harness.
8
8
  */
9
9
 
10
- import { formatTokens } from "./types";
10
+ import { formatTokens, isInterruptedRun } from "./types";
11
+ import type { SubagentRun } from "./types";
11
12
 
12
13
  /** Braille spinner frames, indexed by Math.floor(now / 150) % 10. */
13
14
  export const LIVE_SPINNER_FRAMES: readonly string[] = [
@@ -50,3 +51,58 @@ export function formatLiveRowDim(
50
51
  ): string {
51
52
  return ` [${id.slice(0, 8)}] \u2191${formatTokens(usage.input)} \u2193${formatTokens(usage.output)} ${elapsedStr}`;
52
53
  }
54
+
55
+ /**
56
+ * Display name for a Run History / retry-surface row, shared by BOTH surfaces
57
+ * so they can never name the same run differently (Fix B item 5). Explicit
58
+ * label wins, else the task truncated to 60 chars, else the short id — the last
59
+ * case being a task-less foreground-interrupted row (no task persisted).
60
+ */
61
+ export function runDisplayName(run: Pick<SubagentRun, "id" | "label" | "task">): string {
62
+ return run.label || run.task.slice(0, 60) || run.id.slice(0, 8);
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // U3 (D1/D3): Run History / retry-surface row projection
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * The human status of a Run History row. Keys on `isInterruptedRun` FIRST, so a
71
+ * run marked interrupted renders as `interrupted` and NEVER falls through to its
72
+ * raw `\"running\"` status (D1). The raw status is only consulted for an
73
+ * unmarked record.
74
+ */
75
+ export type RunHistoryStatus = "interrupted" | "running" | "done" | "failed";
76
+
77
+ export function runHistoryStatus(run: SubagentRun): RunHistoryStatus {
78
+ if (isInterruptedRun(run)) return "interrupted";
79
+ if (run.status === "done" || run.status === "failed") return run.status;
80
+ return "running";
81
+ }
82
+
83
+ /** Human label for a Run History row's status. */
84
+ export function runHistoryStatusLabel(run: SubagentRun): string {
85
+ switch (runHistoryStatus(run)) {
86
+ case "interrupted":
87
+ return "Interrupted";
88
+ case "done":
89
+ return "Completed";
90
+ case "failed":
91
+ return "Failed";
92
+ default:
93
+ return "Running";
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The offer text shown when a retry-surface row is selected. Names the run id
99
+ * and the `retryRunId` parameter explicitly (D3), so the conductor re-dispatches
100
+ * the recorded task/params without a new delegate_task parameter.
101
+ */
102
+ export function buildRetryOfferMessage(run: SubagentRun): string {
103
+ const statusWord = isInterruptedRun(run) ? "interrupted" : "failed";
104
+ return (
105
+ `Ask the LLM to retry: "Use delegate_task with retryRunId: ${run.id} ` +
106
+ `to retry the ${statusWord} run ${run.id.slice(0, 8)} with its recorded params."`
107
+ );
108
+ }
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 {
@@ -32,15 +32,18 @@ import type {
32
32
  UsageStats,
33
33
  FileDiff,
34
34
  CompletionNotifyMode,
35
+ RecoveryMode,
35
36
  } from "./types";
36
37
  import {
37
38
  THINKING_LEVELS,
38
39
  NAV_FOOTER,
39
40
  COMPLETION_NOTIFY_MODES,
41
+ RECOVERY_MODES,
40
42
  TASK_PREVIEW_MAX_LENGTH,
41
43
  COLLAPSED_OUTPUT_LINES,
42
44
  COLLAPSED_DIFF_FILES_PREVIEW,
43
45
  EXPANDED_HUNKS_PER_FILE,
46
+ DEFAULT_OUTPUT_CAP_BYTES,
44
47
  formatTokens,
45
48
  formatUsageStats,
46
49
  formatModel,
@@ -49,18 +52,21 @@ import {
49
52
  isSubagentError,
50
53
  isMultiSubagentDetails,
51
54
  isGraphDetails,
55
+ isUnsettledPartial,
56
+ isInterruptedRun,
52
57
  } from "./types";
53
58
  import { buildFileAccessReport, buildSecretsExposureReport, generateComplianceSummary } from "./reports";
54
59
  import { extractParamNames } from "./templates";
55
60
  import { parseDiff } from "./diff";
56
61
  import { formatPresetSummary, getPreset, loadCustomPresets } from "./presets";
57
- import { formatRunDuration } from "./history";
62
+ import { buildOutputHonestyLines, formatRunDuration } from "./history";
58
63
  import * as fs from "node:fs";
59
64
  import * as path from "node:path";
60
- import { sweepStaleLiveSubagents, type SessionState } from "./state";
65
+ import { collapseRunsForHistory, retryableRuns, sweepStaleLiveSubagents, type SessionState } from "./state";
61
66
  import { getAgent } from "./session-manager";
67
+ import { visibleRunEntriesForSession } from "./recovery-surface";
62
68
  import { computeSLAMetrics, computeCostTrend, formatSparkline } from "./metrics";
63
- import { formatElapsed, liveRowName, liveSpinner, formatLiveRowDim } from "./tui-format";
69
+ import { formatElapsed, liveRowName, liveSpinner, formatLiveRowDim, runHistoryStatus, runHistoryStatusLabel, buildRetryOfferMessage, runDisplayName } from "./tui-format";
64
70
  import {
65
71
  renderTranscriptMessages,
66
72
  type TranscriptLineStyle,
@@ -334,6 +340,32 @@ export async function showCompletionNotifySelector(
334
340
  onConfigChanged(ctx, `Completion notify mode set to ${result}`);
335
341
  }
336
342
 
343
+ /**
344
+ * Option B U3 (D4): recovery posture selector. Modeled on the completionNotify
345
+ * selector; `RECOVERY_MODES` excludes "auto" by construction.
346
+ */
347
+ export async function showRecoverySelector(
348
+ ctx: ExtensionContext,
349
+ state: SessionState,
350
+ onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
351
+ ): Promise<void> {
352
+ const modeDescriptions: Record<RecoveryMode, string> = {
353
+ offer: "Surface interrupted runs (Run History row + one boot notice) - default",
354
+ off: "Do not surface interrupted runs",
355
+ };
356
+ const items: SelectItem[] = RECOVERY_MODES.map((mode) => ({
357
+ value: mode,
358
+ label: mode,
359
+ description: modeDescriptions[mode],
360
+ }));
361
+
362
+ const result = await showSelectList(ctx, "Recovery Posture", items, 5);
363
+ if (!result) return;
364
+
365
+ state.config.recovery = result as RecoveryMode;
366
+ onConfigChanged(ctx, `Recovery posture set to ${result}`);
367
+ }
368
+
337
369
  // ---------------------------------------------------------------------------
338
370
  // E4: SLA tracking configuration
339
371
  // ---------------------------------------------------------------------------
@@ -936,6 +968,13 @@ export function getConfigMenuItems(state: SessionState): SelectItem[] {
936
968
  ? "Wake only on failed/stopped"
937
969
  : "Never wake (passive delivery)",
938
970
  },
971
+ {
972
+ value: "recovery",
973
+ label: "Set Recovery Posture",
974
+ description: state.config.recovery === "offer"
975
+ ? "Surface interrupted runs (default)"
976
+ : "Do not surface interrupted runs",
977
+ },
939
978
 
940
979
  {
941
980
  value: "historyentries",
@@ -1083,132 +1122,274 @@ export async function showConfigMenu(
1083
1122
  // Run history viewer
1084
1123
  // ---------------------------------------------------------------------------
1085
1124
 
1125
+ /**
1126
+ * U3 Fix A: the recovery visibility bridge wired for a TUI surface. Session
1127
+ * run entries UNIONED with synthetic durable interrupted records the session
1128
+ * has never seen (a genuine fresh boot), deduped by id. Both the history list
1129
+ * and the retry menu read THIS, so they can never disagree about an interrupted
1130
+ * run that exists only in the durable registry.
1131
+ */
1132
+ function recoverableRunEntries(ctx: ExtensionContext, state: SessionState): SubagentRun[] {
1133
+ return visibleRunEntriesForSession(state.getRunEntries(ctx), getAgent);
1134
+ }
1135
+
1086
1136
  export async function showRunHistory(
1087
1137
  ctx: ExtensionContext,
1088
1138
  state: SessionState,
1089
1139
  persistState: () => void,
1090
1140
  ): 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
1141
  const statusIcon: Record<string, string> = {
1099
1142
  running: ctx.ui.theme.fg("accent", "◉"),
1100
1143
  done: ctx.ui.theme.fg("success", "✓"),
1101
1144
  failed: ctx.ui.theme.fg("error", "✗"),
1145
+ interrupted: ctx.ui.theme.fg("warning", "◌"),
1102
1146
  };
1103
1147
 
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
- });
1148
+ // Browse loop (issue #260): list → detail → list … until the LIST is exited
1149
+ // with Esc. Closing a detail returns here, not to the /brl-subagent menu.
1150
+ while (true) {
1151
+ // One row per SETTLED run (issue #259): collapse each run's spawn+terminal
1152
+ // entry pair to its terminal-preferred record; in-flight runs are omitted
1153
+ // (the live monitor owns running state).
1154
+ const runs = collapseRunsForHistory(recoverableRunEntries(ctx, state));
1155
+
1156
+ if (runs.length === 0) {
1157
+ ctx.ui.notify("No subagent runs recorded yet. Delegate a task to see history.", "info");
1158
+ return;
1159
+ }
1113
1160
 
1114
- const selectedId = await showSelectList(ctx, "Subagent History", items, 15);
1115
- if (!selectedId) return;
1161
+ const items: SelectItem[] = runs.map((r) => {
1162
+ const status = runHistoryStatus(r);
1163
+ const icon = statusIcon[status] || "·";
1164
+ const name = runDisplayName(r);
1165
+ const model = r.model.split("/").pop() || r.model;
1166
+ const when = r.finishedAt
1167
+ ? formatRunDuration(r.durationMs || 0)
1168
+ : status === "interrupted"
1169
+ ? "interrupted"
1170
+ : "running...";
1171
+ const cost = r.cost ? ` $${r.cost.toFixed(4)}` : "";
1172
+ const desc = `${r.thinkingLevel} · ${model} · ${when}${cost}`;
1173
+ return { value: r.id, label: `${icon} ${name}`, description: desc };
1174
+ });
1116
1175
 
1117
- const run = runs.find((r) => r.id === selectedId);
1118
- if (!run) return;
1176
+ const selectedId = await showSelectList(ctx, "Subagent History", items, 15);
1177
+ if (!selectedId) return; // Esc at the list exits to the menu
1119
1178
 
1120
- // Mark as seen
1121
- if (state.markRunSeen(run.id)) {
1122
- persistState();
1123
- import("./concurrency").then(({ updateProgressStatus }) => {
1124
- updateProgressStatus(state, ctx);
1125
- });
1179
+ const run = runs.find((r) => r.id === selectedId);
1180
+ if (!run) continue; // selected run pruned since listing — re-show the list
1181
+
1182
+ // Mark as seen
1183
+ if (state.markRunSeen(run.id)) {
1184
+ persistState();
1185
+ import("./concurrency").then(({ updateProgressStatus }) => {
1186
+ updateProgressStatus(state, ctx);
1187
+ });
1188
+ }
1189
+
1190
+ await showRunDetail(ctx, run);
1126
1191
  }
1192
+ }
1127
1193
 
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)));
1194
+ /**
1195
+ * Number of output lines a run-detail viewport shows at once (issue #261):
1196
+ * the stored text can be up to the 100 KB cap, so the panel is height-capped
1197
+ * and scrolled rather than dumped whole (which would push the footer and
1198
+ * header off-screen).
1199
+ */
1200
+ const RUN_DETAIL_OUTPUT_VIEWPORT_LINES = 12;
1132
1201
 
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(
1202
+ /**
1203
+ * Detail view for one settled run (issue #260, #261): ↑/↓ and PgUp/PgDn scroll
1204
+ * the stored output; any OTHER key — including Esc — closes it. The
1205
+ * showRunHistory browse loop re-shows the list on return.
1206
+ *
1207
+ * The output region follows the approval dialog's full-diff view structure
1208
+ * (see `showApprovalDialog.buildDiffView` / `withDiffKeybinding`: a bordered
1209
+ * Container with a title, the content, and a return footer) but adds the
1210
+ * height cap + scroll state that view lacks. Output is wrapped through the
1211
+ * same `Text` component those views use, so wrapping/padding stay consistent.
1212
+ */
1213
+ async function showRunDetail(ctx: ExtensionContext, run: SubagentRun): Promise<void> {
1214
+ const outputText = run.fullOutput ?? run.outputSummary ?? "";
1215
+ await ctx.ui.custom<void>((tui, theme, kb, done) => {
1216
+ // First line of the viewport (0 = top). Mutated by scroll keys and
1217
+ // clamped to the wrapped-content length on every render.
1218
+ let scrollTop = 0;
1219
+ // Wrapped output lines for the current width, and the width they were
1220
+ // produced at, so a resize re-wraps and a redraw does not.
1221
+ let wrappedLines: string[] = [];
1222
+ let wrappedWidth = -1;
1223
+
1224
+ const wrapOutput = (width: number): string[] => {
1225
+ if (width === wrappedWidth) return wrappedLines;
1226
+ wrappedLines = new Text(theme.fg("toolOutput", outputText), 1, 0).render(width);
1227
+ wrappedWidth = width;
1228
+ return wrappedLines;
1229
+ };
1230
+
1231
+ const buildOutputSection = (width: number): Container => {
1232
+ const section = new Container();
1233
+ const lines = wrapOutput(width);
1234
+ const total = lines.length;
1235
+ const maxScroll = Math.max(0, total - RUN_DETAIL_OUTPUT_VIEWPORT_LINES);
1236
+ if (scrollTop > maxScroll) scrollTop = maxScroll;
1237
+ if (scrollTop < 0) scrollTop = 0;
1238
+ const from = total === 0 ? 0 : Math.min(scrollTop + 1, total);
1239
+ const to = Math.min(scrollTop + RUN_DETAIL_OUTPUT_VIEWPORT_LINES, total);
1240
+ const indicator =
1241
+ total > RUN_DETAIL_OUTPUT_VIEWPORT_LINES
1242
+ ? ` (lines ${from}\u2013${to} of ${total} \u00b7 \u2191\u2193 / PgUp\u00b7PgDn)`
1243
+ : ` (${total} line${total === 1 ? "" : "s"})`;
1244
+
1245
+ section.addChild(
1159
1246
  new Text(
1160
- theme.fg(
1161
- "dim",
1162
- `Finished: ${run.finishedAt} (${formatRunDuration(run.durationMs || 0)})`,
1163
- ),
1247
+ theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500") + theme.fg("dim", indicator),
1164
1248
  1,
1165
1249
  0,
1166
1250
  ),
1167
1251
  );
1168
- }
1169
- if (run.cost) {
1252
+
1253
+ if (total === 0) {
1254
+ section.addChild(new Text(theme.fg("dim", "(no output recorded)"), 1, 0));
1255
+ return section;
1256
+ }
1257
+
1258
+ // Height-capped viewport: the output was already wrapped to `width`
1259
+ // above, so hand the sliced lines back directly instead of feeding them
1260
+ // through another Text (which would re-wrap at the padded width).
1261
+ const viewport: Component = {
1262
+ render: () => lines.slice(scrollTop, scrollTop + RUN_DETAIL_OUTPUT_VIEWPORT_LINES),
1263
+ invalidate: () => {
1264
+ wrappedWidth = -1;
1265
+ },
1266
+ };
1267
+ section.addChild(viewport);
1268
+ return section;
1269
+ };
1270
+
1271
+ const buildDetailView = (width: number): Container => {
1272
+ const container = new Container();
1273
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1274
+
1275
+ const statusLabel = runHistoryStatusLabel(run);
1170
1276
  container.addChild(
1171
1277
  new Text(
1172
1278
  theme.fg(
1173
- "dim",
1174
- `Cost: $${run.cost.toFixed(4)} · ↑${formatTokens(run.tokensIn || 0)} ↓${formatTokens(run.tokensOut || 0)}`,
1279
+ "toolTitle",
1280
+ theme.bold(`${run.label || "Subagent"} — ${statusLabel}`),
1175
1281
  ),
1176
1282
  1,
1177
1283
  0,
1178
1284
  ),
1179
1285
  );
1180
- }
1181
- if (run.errorMessage) {
1182
- container.addChild(new Spacer(1));
1286
+ container.addChild(new Text("", 0, 0));
1183
1287
  container.addChild(
1184
- new Text(theme.fg("error", `Error: ${run.errorMessage}`), 1, 0),
1288
+ new Text(theme.fg("dim", `Task: ${run.task.slice(0, 200)}`), 1, 0),
1185
1289
  );
1186
- }
1187
- if (run.outputSummary) {
1188
- container.addChild(new Spacer(1));
1189
1290
  container.addChild(
1190
1291
  new Text(
1191
- theme.fg("muted", "\u2500\u2500\u2500 Output Preview \u2500\u2500\u2500"),
1292
+ theme.fg("dim", `Model: ${run.model} · Thinking: ${run.thinkingLevel}`),
1192
1293
  1,
1193
1294
  0,
1194
1295
  ),
1195
1296
  );
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));
1297
+ container.addChild(new Text(theme.fg("dim", `Started: ${run.startedAt}`), 1, 0));
1298
+ if (run.finishedAt) {
1299
+ container.addChild(
1300
+ new Text(
1301
+ theme.fg(
1302
+ "dim",
1303
+ `Finished: ${run.finishedAt} (${formatRunDuration(run.durationMs || 0)})`,
1304
+ ),
1305
+ 1,
1306
+ 0,
1307
+ ),
1308
+ );
1309
+ }
1310
+ if (run.cost) {
1311
+ container.addChild(
1312
+ new Text(
1313
+ theme.fg(
1314
+ "dim",
1315
+ `Cost: $${run.cost.toFixed(4)} · ↑${formatTokens(run.tokensIn || 0)} ↓${formatTokens(run.tokensOut || 0)}`,
1316
+ ),
1317
+ 1,
1318
+ 0,
1319
+ ),
1320
+ );
1321
+ }
1322
+ if (run.errorMessage) {
1323
+ container.addChild(new Spacer(1));
1324
+ container.addChild(
1325
+ new Text(theme.fg("error", `Error: ${run.errorMessage}`), 1, 0),
1326
+ );
1201
1327
  }
1202
- }
1203
1328
 
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)));
1329
+ container.addChild(new Spacer(1));
1330
+ container.addChild(buildOutputSection(width));
1331
+ // Honest disclosures (issue #261): the transcript is the only place the
1332
+ // uncapped text lives, and the cap is stated when it was hit.
1333
+ for (const line of buildOutputHonestyLines(run, DEFAULT_OUTPUT_CAP_BYTES)) {
1334
+ container.addChild(new Text(theme.fg("dim", line), 1, 0));
1335
+ }
1336
+
1337
+ container.addChild(new Spacer(1));
1338
+ container.addChild(
1339
+ new Text(
1340
+ theme.fg("dim", "\u2191\u2193 scroll \u00b7 alt+\u2191\u2193 page \u00b7 any other key to close"),
1341
+ 1,
1342
+ 0,
1343
+ ),
1344
+ );
1345
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1346
+ return container;
1347
+ };
1207
1348
 
1208
1349
  return {
1209
- render: (w: number) => container.render(w),
1210
- invalidate: () => container.invalidate(),
1211
- handleInput: (_data: string) => done(),
1350
+ render: (w: number) => buildDetailView(w).render(w),
1351
+ invalidate: () => {},
1352
+ handleInput: (data: string) => {
1353
+ // Paging: alt+↑ / alt+↓. Checked BEFORE the plain arrows so an
1354
+ // alt-modified sequence can never be consumed as a line step.
1355
+ // PgUp/PgDn are unusable here: the host binds them to the
1356
+ // alt-screen transcript scroll (`tui.altScreen.pageUp/pageDown`)
1357
+ // and consumes them before a `ctx.ui.custom` overlay sees input —
1358
+ // pi 1.0.2 has no viewport-owner registration for overlays. The
1359
+ // PgUp/PgDn checks below stay for inline mode, where they do
1360
+ // reach the overlay.
1361
+ if (matchesKey(data, "alt+up")) {
1362
+ scrollTop -= RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
1363
+ tui.requestRender();
1364
+ return;
1365
+ }
1366
+ if (matchesKey(data, "alt+down")) {
1367
+ scrollTop += RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
1368
+ tui.requestRender();
1369
+ return;
1370
+ }
1371
+ if (kb.matches(data, "tui.select.up")) {
1372
+ scrollTop -= 1;
1373
+ tui.requestRender();
1374
+ return;
1375
+ }
1376
+ if (kb.matches(data, "tui.select.down")) {
1377
+ scrollTop += 1;
1378
+ tui.requestRender();
1379
+ return;
1380
+ }
1381
+ if (kb.matches(data, "tui.select.pageUp")) {
1382
+ scrollTop -= RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
1383
+ tui.requestRender();
1384
+ return;
1385
+ }
1386
+ if (kb.matches(data, "tui.select.pageDown")) {
1387
+ scrollTop += RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
1388
+ tui.requestRender();
1389
+ return;
1390
+ }
1391
+ done();
1392
+ },
1212
1393
  };
1213
1394
  });
1214
1395
  }
@@ -1865,32 +2046,59 @@ export async function showDashboard(
1865
2046
  // Retry menu
1866
2047
  // ---------------------------------------------------------------------------
1867
2048
 
1868
- export async function showRetryMenu(ctx: ExtensionContext, state: SessionState): Promise<void> {
1869
- const failedRuns = state.getRunEntries(ctx).filter((r) => r.status === "failed").reverse();
2049
+ export async function showRetryMenu(
2050
+ ctx: ExtensionContext,
2051
+ state: SessionState,
2052
+ // U3 (D5): the abandon action is injected by the extension entry point (it
2053
+ // owns the session-manager/registry wiring). Omitted in read-only contexts.
2054
+ onAbandon?: (id: string) => void | Promise<void>,
2055
+ ): Promise<void> {
2056
+ // U3 (D3): failed AND unresolved interrupted runs share this surface, one row
2057
+ // per run, built on the same terminal-preferred collapse the history uses.
2058
+ const candidates = retryableRuns(recoverableRunEntries(ctx, state));
1870
2059
 
1871
- if (failedRuns.length === 0) {
1872
- ctx.ui.notify("No failed subagent runs to retry.", "info");
2060
+ if (candidates.length === 0) {
2061
+ ctx.ui.notify("No failed or interrupted subagent runs to retry.", "info");
1873
2062
  return;
1874
2063
  }
1875
2064
 
1876
- const items: SelectItem[] = failedRuns.map((r) => {
1877
- const name = r.label || r.task.slice(0, 60);
2065
+ const items: SelectItem[] = candidates.map((r) => {
2066
+ const interrupted = isInterruptedRun(r);
2067
+ const name = runDisplayName(r);
1878
2068
  const model = r.model.split("/").pop() || r.model;
1879
- const err = r.errorMessage ? r.errorMessage.slice(0, 40) : "error";
2069
+ const err = r.errorMessage ? r.errorMessage.slice(0, 40) : interrupted ? "interrupted" : "error";
1880
2070
  return {
1881
2071
  value: r.id,
1882
- label: `✗ ${name}`,
1883
- description: `${model} · ${err}`,
2072
+ label: `${interrupted ? "◌" : "✗"} ${name}`,
2073
+ description: `${interrupted ? "interrupted · " : ""}${model} · ${err}`,
1884
2074
  };
1885
2075
  });
1886
2076
 
1887
- const selectedId = await showSelectList(ctx, "Retry Failed Run", items, 10);
2077
+ const selectedId = await showSelectList(ctx, "Retry Failed or Interrupted Run", items, 10);
1888
2078
  if (!selectedId) return;
1889
2079
 
1890
- ctx.ui.notify(
1891
- `Ask the LLM to retry: "Use delegate_task to retry failed run ${selectedId.slice(0, 8)}..."`,
1892
- "info",
1893
- );
2080
+ const run = candidates.find((r) => r.id === selectedId);
2081
+ if (!run) return;
2082
+
2083
+ if (isInterruptedRun(run) && onAbandon) {
2084
+ const action = await showSelectList(
2085
+ ctx,
2086
+ "Interrupted Run",
2087
+ [
2088
+ { value: "retry", label: "Retry", description: "Re-dispatch with its recorded params (retryRunId)" },
2089
+ { value: "abandon", label: "Abandon", description: "Finalize as stopped and prune its session file" },
2090
+ ],
2091
+ 5,
2092
+ );
2093
+ if (!action) return;
2094
+ if (action === "abandon") {
2095
+ await onAbandon(run.id);
2096
+ ctx.ui.notify(`Abandoned interrupted run ${run.id.slice(0, 8)}.`, "info");
2097
+ return;
2098
+ }
2099
+ }
2100
+
2101
+ ctx.ui.notify(buildRetryOfferMessage(run), "info");
1894
2102
  }
1895
2103
 
1896
2104
  // ---------------------------------------------------------------------------
@@ -2803,7 +3011,19 @@ export function renderDelegateResult(
2803
3011
  }
2804
3012
 
2805
3013
  // Single subagent mode (original)
2806
- if (!details || details.exitCode === -1) {
3014
+ // Issue #298 (C2): the partial-vs-settled decision lives in ONE place —
3015
+ // `isUnsettledPartial` (src/types.ts) owns the overloaded `exitCode === -1`
3016
+ // disambiguation (the #206 streaming sentinel vs the #295 settled signal
3017
+ // death). Do not re-derive it here; the terminal-status structural ratchet
3018
+ // fails a local copy. A genuine partial (or a result with no details yet)
3019
+ // renders the raw running text; a classified -1 is a settled FAILURE and
3020
+ // takes the verdict branch below (✗ + error line + expanded-transcript
3021
+ // binding), never raw text.
3022
+ //
3023
+ // The leading `!details` is a type-narrowing aid for the settled branch below
3024
+ // (TS cannot narrow through a function call); it is NOT a second copy of the
3025
+ // decision — the predicate owns the no-details case too.
3026
+ if (!details || isUnsettledPartial(details)) {
2807
3027
  const text = result.content[0];
2808
3028
  return new Text(text?.type === "text" ? text.text : "(running\u2026)", 0, 0);
2809
3029
  }