@oh-my-pi/pi-tui 18.4.9 → 18.4.10

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.
@@ -44,6 +44,12 @@ import { col, node, span, text } from "../native/describe";
44
44
  import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
45
45
  import { actionHint, hintsRow, type NativeHint, overlayCard } from "../native/overlay";
46
46
 
47
+ export const ADVISOR_REVIEW_MODES = ["turn", "agent-end"] as const;
48
+ export const ADVISOR_SYNC_BACKLOG_MODES = ["off", "1", "3", "5", "strict"] as const;
49
+
50
+ export type AdvisorReviewMode = (typeof ADVISOR_REVIEW_MODES)[number];
51
+ export type AdvisorSyncBacklog = (typeof ADVISOR_SYNC_BACKLOG_MODES)[number];
52
+
47
53
  /** One advisor declared in `WATCHDOG.yml`; its instructions specialize the shared baseline. */
48
54
  export interface AdvisorConfig {
49
55
  name: string;
@@ -56,6 +62,9 @@ export interface AdvisorConfig {
56
62
  enabled?: boolean;
57
63
  /** Maximum non-blocker notes per advisor prompt update (default 4); blockers are exempt. */
58
64
  maxNotesPerUpdate?: number;
65
+ reviewMode?: AdvisorReviewMode;
66
+ reviewInterval?: number;
67
+ syncBacklog?: AdvisorSyncBacklog;
59
68
  }
60
69
 
61
70
  /** Which level a `WATCHDOG.yml` lives at: the project root or the user agent dir. */
@@ -115,6 +124,7 @@ export interface AdvisorConfigDeps {
115
124
  externalEditor?: (text: string) => Promise<string | null>;
116
125
  scopedModels: ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }>;
117
126
  availableToolNames: string[];
127
+ syncBacklog?: AdvisorSyncBacklog;
118
128
  /** Formatted advisor-role model shown on the seeded default row (e.g. "anthropic/claude-..."). */
119
129
  defaultModelLabel?: string;
120
130
  }
@@ -192,13 +202,44 @@ function formatAdvisorTools(tools: readonly string[] | undefined, emptyLabel: st
192
202
  return tools.length > 0 ? tools.join(", ") : emptyLabel;
193
203
  }
194
204
 
205
+ /** Choice value for "no per-advisor override"; matches no {@link ADVISOR_SYNC_BACKLOG_MODES} entry. */
206
+ const SYNC_BACKLOG_INHERIT = "inherit";
207
+
208
+ /** One-line cadence label: mode plus interval. */
209
+ function formatReviewCadence(advisor: AdvisorConfig): string {
210
+ const reviewInterval = advisor.reviewInterval ?? 1;
211
+ return `${advisor.reviewMode ?? "turn"} · every ${reviewInterval} eligible update${reviewInterval === 1 ? "" : "s"}`;
212
+ }
213
+
214
+ /** Picker description for one explicit catch-up policy value. */
215
+ function describeSyncBacklogMode(mode: AdvisorSyncBacklog): string {
216
+ switch (mode) {
217
+ case "off":
218
+ return "Never wait for this advisor's backlog.";
219
+ case "strict":
220
+ return "Wait for all of this advisor's scheduled reviews; no wall-clock cap.";
221
+ default:
222
+ return `Wait until fewer than ${mode} scheduled reviews remain (30s cap).`;
223
+ }
224
+ }
225
+
195
226
  /** Soft-wrap plain text to `width`, returning at least one (possibly empty) line. */
196
227
  function wrap(text: string, width: number): string[] {
197
228
  if (!text) return [""];
198
229
  return Bun.wrapAnsi(text, Math.max(1, width), { trim: false }).split("\n");
199
230
  }
200
231
 
201
- type Screen = "list" | "detail" | "name" | "model" | "tools" | "thinking" | "instructions";
232
+ type Screen =
233
+ | "list"
234
+ | "detail"
235
+ | "name"
236
+ | "model"
237
+ | "tools"
238
+ | "thinking"
239
+ | "review-mode"
240
+ | "review-interval"
241
+ | "sync-backlog"
242
+ | "instructions";
202
243
 
203
244
  /** Footer key hints per screen for the described overlay (the ANSI footer strings carry the same keys). */
204
245
  function screenHints(screen: Screen): (NativeHint | undefined)[] {
@@ -220,7 +261,11 @@ function screenHints(screen: Screen): (NativeHint | undefined)[] {
220
261
  actionHint("tui.select.cancel", "back"),
221
262
  ];
222
263
  case "thinking":
264
+ case "review-mode":
265
+ case "sync-backlog":
223
266
  return [actionHint("tui.select.confirm", "pick"), actionHint("tui.select.cancel", "back")];
267
+ case "review-interval":
268
+ return [actionHint("tui.input.submit", "save"), actionHint("tui.select.cancel", "cancel")];
224
269
  case "tools":
225
270
  return [
226
271
  actionHint("tui.select.confirm", "toggle"),
@@ -432,6 +477,44 @@ export class AdvisorConfigOverlayComponent implements Component {
432
477
  defaultLabel: model ? (this.#defaultModelLabel ?? "advisor role default") : undefined,
433
478
  control: { k: "action", label: model || this.#defaultModelLabel || "advisor role default", act: "edit" },
434
479
  },
480
+ {
481
+ id: "reviewMode",
482
+ label: "Review mode",
483
+ hint: "turn reviews every primary turn; agent-end reviews only final yields.",
484
+ changed: advisor.reviewMode !== undefined ? true : undefined,
485
+ control: {
486
+ k: "choice",
487
+ value: advisor.reviewMode ?? "turn",
488
+ options: ADVISOR_REVIEW_MODES.map(mode => ({ value: mode, label: mode })),
489
+ style: "segmented",
490
+ },
491
+ },
492
+ {
493
+ id: "reviewInterval",
494
+ label: "Review interval",
495
+ hint: "Review every Nth eligible update; skipped updates join the next review.",
496
+ changed: advisor.reviewInterval !== undefined ? true : undefined,
497
+ control: { k: "number", value: advisor.reviewInterval ?? 1, min: 1, step: 1 },
498
+ },
499
+ {
500
+ id: "syncBacklog",
501
+ label: "Sync backlog",
502
+ hint: `Catch-up policy; inherit follows advisor.syncBacklog (currently ${this.#deps.syncBacklog ?? "off"}).`,
503
+ changed: advisor.syncBacklog !== undefined ? true : undefined,
504
+ control: {
505
+ k: "choice",
506
+ value: advisor.syncBacklog ?? SYNC_BACKLOG_INHERIT,
507
+ options: [
508
+ { value: SYNC_BACKLOG_INHERIT, label: "inherit" },
509
+ ...ADVISOR_SYNC_BACKLOG_MODES.map(mode => ({
510
+ value: mode,
511
+ label: mode,
512
+ detail: describeSyncBacklogMode(mode),
513
+ })),
514
+ ],
515
+ style: "menu",
516
+ },
517
+ },
435
518
  {
436
519
  id: "tools",
437
520
  label: "Tools",
@@ -499,6 +582,9 @@ export class AdvisorConfigOverlayComponent implements Component {
499
582
  const overlay =
500
583
  this.#screen === "model" ||
501
584
  this.#screen === "thinking" ||
585
+ this.#screen === "review-mode" ||
586
+ this.#screen === "review-interval" ||
587
+ this.#screen === "sync-backlog" ||
502
588
  this.#screen === "tools" ||
503
589
  this.#screen === "instructions";
504
590
  const editor = overlay ? this.#active : undefined;
@@ -573,6 +659,24 @@ export class AdvisorConfigOverlayComponent implements Component {
573
659
  advisor.name = event.value.trim();
574
660
  this.#dirty = true;
575
661
  this.#showDetail(index);
662
+ } else if (event.item === "reviewMode" && (typeof event.value === "string" || event.value === null)) {
663
+ // `turn` is the default, stored as an omitted field; `null` resets to it.
664
+ const mode = event.value === null ? "turn" : ADVISOR_REVIEW_MODES.find(m => m === event.value);
665
+ if (!mode) return;
666
+ advisor.reviewMode = mode === "turn" ? undefined : mode;
667
+ this.#dirty = true;
668
+ this.#showDetail(index);
669
+ } else if (event.item === "reviewInterval" && (typeof event.value === "number" || event.value === null)) {
670
+ const interval = event.value ?? 1;
671
+ if (!Number.isSafeInteger(interval) || interval < 1) return;
672
+ advisor.reviewInterval = interval === 1 ? undefined : interval;
673
+ this.#dirty = true;
674
+ this.#showDetail(index);
675
+ } else if (event.item === "syncBacklog" && (typeof event.value === "string" || event.value === null)) {
676
+ // The inherit sentinel and `null` match no mode, so the override clears.
677
+ advisor.syncBacklog = ADVISOR_SYNC_BACKLOG_MODES.find(mode => mode === event.value);
678
+ this.#dirty = true;
679
+ this.#showDetail(index);
576
680
  }
577
681
  return;
578
682
  }
@@ -689,6 +793,8 @@ export class AdvisorConfigOverlayComponent implements Component {
689
793
  { k: "Enabled", v: advisor.enabled === false ? "○ off" : "● on" },
690
794
  { k: "Model", v: model },
691
795
  { k: "Tools", v: formatAdvisorTools(advisor.tools, "no tools") },
796
+ { k: "Review", v: formatReviewCadence(advisor) },
797
+ { k: "Sync backlog", v: this.#syncBacklogLabel(advisor) },
692
798
  ],
693
799
  }),
694
800
  text([span("Instructions:", "dim")]),
@@ -822,12 +928,15 @@ export class AdvisorConfigOverlayComponent implements Component {
822
928
  #advisorPreview(advisor: AdvisorConfig, bodyWidth: number): string[] {
823
929
  const model = advisor.model?.trim() || this.#defaultModelLabel || "advisor role default";
824
930
  const tools = formatAdvisorTools(advisor.tools, "no tools");
931
+ const syncBacklog = this.#syncBacklogLabel(advisor);
825
932
  const lines = [
826
933
  theme.bold(advisor.name || "(unnamed)"),
827
934
  "",
828
935
  `${theme.fg("dim", "Enabled:")} ${advisor.enabled === false ? "○ off" : "● on"}`,
829
936
  `${theme.fg("dim", "Model:")} ${model}`,
830
937
  `${theme.fg("dim", "Tools:")} ${tools}`,
938
+ `${theme.fg("dim", "Review:")} ${formatReviewCadence(advisor)}`,
939
+ `${theme.fg("dim", "Sync backlog:")} ${syncBacklog}`,
831
940
  "",
832
941
  theme.fg("dim", "Instructions:"),
833
942
  ];
@@ -893,6 +1002,9 @@ export class AdvisorConfigOverlayComponent implements Component {
893
1002
  advisor?.name === "default" &&
894
1003
  !advisor.model?.trim() &&
895
1004
  advisor.tools === undefined &&
1005
+ advisor.reviewMode === undefined &&
1006
+ advisor.reviewInterval === undefined &&
1007
+ advisor.syncBacklog === undefined &&
896
1008
  !advisor.instructions?.trim() &&
897
1009
  advisor.enabled !== false &&
898
1010
  advisor.maxNotesPerUpdate === undefined
@@ -902,7 +1014,12 @@ export class AdvisorConfigOverlayComponent implements Component {
902
1014
  #advisorSummary(advisor: AdvisorConfig): string {
903
1015
  const model = advisor.model?.trim() || this.#defaultModelLabel || "advisor role default";
904
1016
  const tools = formatAdvisorTools(advisor.tools, "no tools");
905
- return `${model} · ${tools}`;
1017
+ return `${model} · ${tools} · review ${advisor.reviewMode ?? "turn"}/${advisor.reviewInterval ?? 1}`;
1018
+ }
1019
+
1020
+ /** The advisor's explicit catch-up override, else the inherited global policy. */
1021
+ #syncBacklogLabel(advisor: AdvisorConfig): string {
1022
+ return advisor.syncBacklog ?? `${this.#deps.syncBacklog ?? "off"} (inherited)`;
906
1023
  }
907
1024
 
908
1025
  #showList(): void {
@@ -1000,6 +1117,8 @@ export class AdvisorConfigOverlayComponent implements Component {
1000
1117
  this.#detailIndex = index;
1001
1118
  const modelDescription = advisor.model?.trim() || this.#defaultModelLabel || "advisor role default";
1002
1119
  const toolsDescription = formatAdvisorTools(advisor.tools, "no tools");
1120
+ const reviewMode = advisor.reviewMode ?? "turn";
1121
+ const reviewInterval = advisor.reviewInterval ?? 1;
1003
1122
  const items: SelectItem[] = [
1004
1123
  { value: "name", label: "Name", description: advisor.name },
1005
1124
  {
@@ -1008,6 +1127,13 @@ export class AdvisorConfigOverlayComponent implements Component {
1008
1127
  description: advisor.enabled === false ? "○ off" : "● on",
1009
1128
  },
1010
1129
  { value: "model", label: "Model", description: modelDescription },
1130
+ { value: "reviewMode", label: "Review mode", description: reviewMode },
1131
+ { value: "reviewInterval", label: "Review interval", description: String(reviewInterval) },
1132
+ {
1133
+ value: "syncBacklog",
1134
+ label: "Sync backlog",
1135
+ description: this.#syncBacklogLabel(advisor),
1136
+ },
1011
1137
  ];
1012
1138
  if (advisor.model?.trim()) {
1013
1139
  items.push({ value: "resetModel", label: "Reset model to advisor-role default" });
@@ -1043,6 +1169,15 @@ export class AdvisorConfigOverlayComponent implements Component {
1043
1169
  case "model":
1044
1170
  this.#showModelPicker(index);
1045
1171
  return;
1172
+ case "reviewMode":
1173
+ this.#showReviewModePicker(index);
1174
+ return;
1175
+ case "reviewInterval":
1176
+ this.#showReviewIntervalEditor(index);
1177
+ return;
1178
+ case "syncBacklog":
1179
+ this.#showSyncBacklogPicker(index);
1180
+ return;
1046
1181
  case "tools":
1047
1182
  this.#showToolsEditor(index, new Set(this.#doc.advisors[index].tools ?? this.#deps.defaultToolNames), 0);
1048
1183
  return;
@@ -1137,6 +1272,97 @@ export class AdvisorConfigOverlayComponent implements Component {
1137
1272
  `Thinking effort for ${selector} · ${editorKey("tui.select.confirm")} / click pick · ${editorKey("tui.select.cancel")} back`,
1138
1273
  );
1139
1274
  }
1275
+ #showReviewModePicker(index: number): void {
1276
+ const advisor = this.#doc.advisors[index];
1277
+ if (!advisor) {
1278
+ this.#showList();
1279
+ return;
1280
+ }
1281
+ const current = advisor.reviewMode ?? ADVISOR_REVIEW_MODES[0];
1282
+ const items: SelectItem[] = ADVISOR_REVIEW_MODES.map(mode => ({
1283
+ value: mode,
1284
+ label: mode === current ? `${mode} (current)` : mode,
1285
+ description:
1286
+ mode === "turn"
1287
+ ? "Review every primary turn (tool-call round)."
1288
+ : "Review only at agent end (once per run).",
1289
+ }));
1290
+ const list = new SelectList(items, Math.max(1, items.length), getSelectListTheme());
1291
+ list.setSelectedIndex(ADVISOR_REVIEW_MODES.indexOf(current));
1292
+ list.onSelect = item => {
1293
+ advisor.reviewMode = item.value === "turn" ? undefined : "agent-end";
1294
+ this.#dirty = true;
1295
+ this.#showDetail(index);
1296
+ };
1297
+ list.onCancel = () => this.#showDetail(index);
1298
+ this.#setScreen(
1299
+ "review-mode",
1300
+ list,
1301
+ `${editorKey("tui.select.confirm")} / click choose review mode · ${editorKey("tui.select.cancel")} back`,
1302
+ );
1303
+ }
1304
+
1305
+ #showReviewIntervalEditor(index: number): void {
1306
+ const advisor = this.#doc.advisors[index];
1307
+ if (!advisor) {
1308
+ this.#showList();
1309
+ return;
1310
+ }
1311
+ const input = new Input();
1312
+ input.setValue(String(advisor.reviewInterval ?? 1));
1313
+ input.onSubmit = value => {
1314
+ const interval = Number(value.trim());
1315
+ if (!Number.isSafeInteger(interval) || interval < 1) {
1316
+ this.#cb.notify("Review interval must be a positive integer.");
1317
+ return;
1318
+ }
1319
+ advisor.reviewInterval = interval === 1 ? undefined : interval;
1320
+ this.#dirty = true;
1321
+ this.#showDetail(index);
1322
+ };
1323
+ input.onEscape = () => this.#showDetail(index);
1324
+ this.#setScreen(
1325
+ "review-interval",
1326
+ input,
1327
+ `Type a positive integer · ${editorKey("tui.input.submit")} save · ${editorKey("tui.select.cancel")} cancel`,
1328
+ );
1329
+ }
1330
+
1331
+ #showSyncBacklogPicker(index: number): void {
1332
+ const advisor = this.#doc.advisors[index];
1333
+ if (!advisor) {
1334
+ this.#showList();
1335
+ return;
1336
+ }
1337
+ const global = this.#deps.syncBacklog ?? "off";
1338
+ const current = advisor.syncBacklog;
1339
+ const items: SelectItem[] = [
1340
+ {
1341
+ value: SYNC_BACKLOG_INHERIT,
1342
+ label: current === undefined ? "inherit (current)" : "inherit",
1343
+ description: `Follow the global advisor.syncBacklog setting (currently "${global}").`,
1344
+ },
1345
+ ...ADVISOR_SYNC_BACKLOG_MODES.map(mode => ({
1346
+ value: mode,
1347
+ label: mode === current ? `${mode} (current)` : mode,
1348
+ description: describeSyncBacklogMode(mode),
1349
+ })),
1350
+ ];
1351
+ const list = new SelectList(items, Math.max(1, items.length), getSelectListTheme());
1352
+ list.setSelectedIndex(current === undefined ? 0 : ADVISOR_SYNC_BACKLOG_MODES.indexOf(current) + 1);
1353
+ list.onSelect = item => {
1354
+ // The inherit sentinel matches no mode, so `find` yields undefined — the override clears.
1355
+ advisor.syncBacklog = ADVISOR_SYNC_BACKLOG_MODES.find(mode => mode === item.value);
1356
+ this.#dirty = true;
1357
+ this.#showDetail(index);
1358
+ };
1359
+ list.onCancel = () => this.#showDetail(index);
1360
+ this.#setScreen(
1361
+ "sync-backlog",
1362
+ list,
1363
+ `${editorKey("tui.select.confirm")} / click choose catch-up policy · ${editorKey("tui.select.cancel")} back`,
1364
+ );
1365
+ }
1140
1366
 
1141
1367
  #showToolsEditor(index: number, selected: Set<string>, cursor: number): void {
1142
1368
  const all = this.#availableToolNames;
@@ -528,6 +528,7 @@ export function normalizeDialogQuestions(questions: ExtensionAskDialogQuestion[]
528
528
  }
529
529
 
530
530
  export class AskDialogComponent implements Component {
531
+ readonly retireDisplacedTranscript = true;
531
532
  #states: QuestionState[];
532
533
  #activeTabIndex = 0;
533
534
  #submitScrollOffset = 0;
@@ -23,6 +23,7 @@ import type {
23
23
  import type { KeysApi, Model } from "@oh-my-pi/pi-ai";
24
24
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
25
25
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
26
+ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
26
27
  import { providerEntry } from "@oh-my-pi/pi-catalog/compat/providers";
27
28
  import { MODEL_KINDS, modelKind, type ModelKind } from "@oh-my-pi/pi-catalog/types";
28
29
  import type { Component, TUI } from "../tui";
@@ -483,6 +484,22 @@ export class ModelHubComponent implements Component {
483
484
  return this.#settings.knownRoleIds.filter(role => !this.#settings.getRoleInfo(role).hidden);
484
485
  }
485
486
 
487
+ /**
488
+ * Models a `--models`/`enabledModels` scope exposes. The scope only resolves
489
+ * chat models (it feeds Ctrl+P cycling), so available non-chat runners
490
+ * (judge, search, image, …) join from the registry — runtime role
491
+ * resolution ignores the scope for them as well.
492
+ */
493
+ #scopedPool(): Model[] {
494
+ const pool = this.#scopedModels.map(scoped => scoped.model);
495
+ for (const model of this.#registry.getAvailable("all")) {
496
+ if (modelKind(model) === "chat") continue;
497
+ if (this.#scopedModels.some(scoped => modelsAreEqual(scoped.model, model))) continue;
498
+ pool.push(model);
499
+ }
500
+ return pool;
501
+ }
502
+
486
503
  /** Resolve every known role: configured values first, auto-selection for the rest. */
487
504
  #reloadRoles(autoCandidates: ReadonlyArray<Model>): void {
488
505
  const allModels = this.#scopedModels.length > 0 ? autoCandidates : this.#registry.getAll("all");
@@ -499,9 +516,14 @@ export class ModelHubComponent implements Component {
499
516
  let allModels: ReadonlyArray<Model>;
500
517
  let availableModels: ReadonlyArray<Model>;
501
518
  if (this.#scopedModels.length > 0) {
502
- allModels = this.#scopedModels.map(scoped => scoped.model);
503
- availableModels = allModels;
504
519
  this.#configError = undefined;
520
+ try {
521
+ allModels = this.#scopedPool();
522
+ } catch (error) {
523
+ this.#configError = error instanceof Error ? error.message : String(error);
524
+ allModels = this.#scopedModels.map(scoped => scoped.model);
525
+ }
526
+ availableModels = allModels;
505
527
  } else {
506
528
  const loadError = this.#registry.getError();
507
529
  this.#configError = loadError ? String(loadError) : undefined;
@@ -1081,8 +1103,7 @@ export class ModelHubComponent implements Component {
1081
1103
  #roleForScope(role: string, scope: ModelRoleSelectionScope): ResolvedModelRoleValue {
1082
1104
  const roleValue =
1083
1105
  scope === "project" ? this.#settings.getProjectModelRole(role) : this.#settings.getGlobalModelRole(role);
1084
- const allModels =
1085
- this.#scopedModels.length > 0 ? this.#scopedModels.map(scoped => scoped.model) : this.#registry.getAll("all");
1106
+ const allModels = this.#scopedModels.length > 0 ? this.#scopedPool() : this.#registry.getAll("all");
1086
1107
  const roleLookup: ModelRoleLookup = {
1087
1108
  getModelRole: scopedRole =>
1088
1109
  scope === "project"
@@ -4,9 +4,9 @@ import { matchesSelectCancel } from "../keybinding-matchers";
4
4
  import { OverlayPanel, PanelDivider, PanelRows } from "../chrome/overlay-box";
5
5
  import { formatKeyHints } from "../app-keybindings";
6
6
  import { editorKey } from "../chrome/keybinding-hints";
7
- import type { TspSpan } from "@oh-my-pi/pi-wire";
7
+ import type { TspProps, TspSpan } from "@oh-my-pi/pi-wire";
8
8
  import { formatNumber } from "@oh-my-pi/pi-utils";
9
- import { col, node, span, text } from "../native/describe";
9
+ import { col, node, row, span, text } from "../native/describe";
10
10
  import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
11
11
  import { actionBar, actionButton } from "../native/overlay";
12
12
  import { plainText, styledSpans } from "../native/spans";
@@ -52,6 +52,39 @@ function parseKvReport(report: string): KvReportSection[] {
52
52
  return sections.filter(section => section.head !== undefined || section.entries.length > 0);
53
53
  }
54
54
 
55
+ /** The plain text of `spans`. */
56
+ function joined(spans: readonly TspSpan[]): string {
57
+ return spans.map(part => part.t).join("");
58
+ }
59
+
60
+ /**
61
+ * A value with a copy button after it: a click anywhere on the line copies its
62
+ * text (the terminal-local `copy` action takes the leaf text; the icon has none).
63
+ */
64
+ function copyLine(
65
+ spans: TspSpan[],
66
+ title: string,
67
+ key: string,
68
+ props: Omit<TspProps<"text">, "text" | "spans"> = {},
69
+ ): NativeNode {
70
+ return node(
71
+ "row",
72
+ {
73
+ gap: "sm",
74
+ align: "center",
75
+ grow: 1,
76
+ role: "omp.info.copy",
77
+ actions: { click: "copy" },
78
+ title,
79
+ },
80
+ [
81
+ text(spans, { ...props, grow: 1, shrink: 1 }),
82
+ node("icon", { name: "copy", role: "omp.info.copy.ic", aria: title }),
83
+ ],
84
+ key,
85
+ );
86
+ }
87
+
55
88
  /** Terminal surface needed to size the session info viewport. */
56
89
  export interface SessionInfoOverlayHost {
57
90
  readonly terminal: {
@@ -155,16 +188,21 @@ export class SessionInfoOverlay implements Component {
155
188
  } else if (section.head === undefined && entry.k === "ID") {
156
189
  // The id leads the sheet, once, as a copyable mono line.
157
190
  body.push(
158
- node(
159
- "text",
191
+ copyLine([span(joined(entry.v), "mono muted")], "Copy session ID", "id", { truncate: "middle" }),
192
+ );
193
+ } else if (section.head === undefined && entry.k === "File") {
194
+ flush();
195
+ children.push(
196
+ row(
197
+ [
198
+ text([span("File", "muted")], { wrap: "none", shrink: 0 }),
199
+ copyLine([span(joined(entry.v), "mono")], "Copy file path", "path", { wrap: "char" }),
200
+ ],
160
201
  {
161
- spans: [span(entry.v.map(part => part.t).join(""), "mono muted")],
162
- actions: { click: "copy" },
163
- title: "Copy session ID",
164
- truncate: "middle",
202
+ gap: "md",
203
+ align: "start",
204
+ role: "omp.info.file",
165
205
  },
166
- undefined,
167
- "id",
168
206
  ),
169
207
  );
170
208
  } else {
@@ -195,7 +233,13 @@ export class SessionInfoOverlay implements Component {
195
233
  [
196
234
  text([span("Context", "muted")]),
197
235
  meter
198
- ? node("meter", { value, style: "bar", size: "md", thresholds: { warn: 0.7, bad: 0.9 }, grow: 1 })
236
+ ? node("meter", {
237
+ value,
238
+ style: "bar",
239
+ size: "md",
240
+ thresholds: { warn: 0.7, bad: 0.9 },
241
+ grow: 1,
242
+ })
199
243
  : node("progress", { value, grow: 1 }),
200
244
  text([span(figure, "mono")]),
201
245
  ],
@@ -98,7 +98,9 @@ export interface RuntimeChildrenOptions {
98
98
  * multi-line editor). They are billed at their smallest height so expansion
99
99
  * clips the live tail instead of retiring rows a later shrink could not
100
100
  * reclaim (#11007). Every other root is billed at its current height, so
101
- * settled rows it displaces retire to native scrollback.
101
+ * settled rows it displaces retire to native scrollback. Decision panels
102
+ * declaring `retireDisplacedTranscript` opt out of this floor even when
103
+ * mounted as a child of a transient editor container.
102
104
  */
103
105
  readonly transient?: readonly Component[];
104
106
  /**
@@ -266,7 +268,7 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
266
268
  #lastNormalRows = 0;
267
269
  // Roots from `RuntimeChildrenOptions.transient`, and the smallest height
268
270
  // they have rendered at since mount. Retirement bills transient roots at
269
- // this floor, never their peak, so a dialog or tall editor that later
271
+ // this floor, never their peak, so a transient dialog or tall editor that later
270
272
  // shrinks never leaves committed transcript rows the live viewport cannot
271
273
  // reclaim (#11007). Persistent roots (loader, todo/subagent HUDs) bill at
272
274
  // their current height: they stay up for a whole turn, and billing them at
@@ -276,6 +278,7 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
276
278
  // rediscovered from whatever chrome is expanded when the height changes.
277
279
  #transientChrome: ReadonlySet<Component> = new Set();
278
280
  #transientChromeFloor: number | undefined;
281
+ #anchorAfterInlineRetirement = false;
279
282
  #lastInterruptAt = 0;
280
283
  /** Last described surface; its arrays are reused while their children are unchanged. */
281
284
  #nativeSurface: NativeSurface = { main: [], dock: [] };
@@ -368,29 +371,43 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
368
371
  const after: string[] = [];
369
372
  const afterSpans: ViewportClickSpan[] = [];
370
373
  let transientRows = 0;
374
+ let displacingRows = 0;
375
+ let decisionPanelOpen = false;
371
376
  for (const root of afterRoots) {
377
+ const chrome: Component = root;
372
378
  const start = after.length;
373
379
  this.#renderBelowRoot(root, width, after, afterSpans);
374
380
  if (this.#transientChrome.has(root)) transientRows += after.length - start;
381
+ if (
382
+ chrome.retireDisplacedTranscript ||
383
+ (root instanceof Container && root.children.some(child => child.retireDisplacedTranscript))
384
+ ) {
385
+ if (this.#transientChrome.has(root)) displacingRows += after.length - start;
386
+ decisionPanelOpen = true;
387
+ }
375
388
  }
376
389
  // Offer history under capacity pressure only: blocks stay live (and keep
377
390
  // reflowing to the current width) while the screen has room. A batch
378
391
  // leaves the mutable viewport in the same frame it is appended, so its
379
392
  // rows are never painted twice.
380
393
  //
381
- // Retirement bills transient roots at their floor, not their peak: a
382
- // confirmation dialog or a tall multi-line editor clips the live tail for
394
+ // Retirement bills ordinary transient roots at their floor, not their
395
+ // peak: a confirmation dialog or a tall multi-line editor clips the live tail for
383
396
  // its lifetime, but must not permanently commit transcript rows to native
384
397
  // history — otherwise a later shrink cannot refill the freed rows and the
385
398
  // editor drifts up above a band of blank rows (#11007).
386
399
  this.#transientChromeFloor = Math.min(this.#transientChromeFloor ?? transientRows, transientRows);
387
- const belowFloor = after.length - transientRows + this.#transientChromeFloor;
400
+ // An ask panel remains open while the user reads the preceding response.
401
+ // Its displaced settled rows must be reachable in scrollback now, rather
402
+ // than clipped until the panel closes. Ordinary drafts retain their floor.
403
+ const belowFloor = after.length - transientRows + Math.max(this.#transientChromeFloor, displacingRows);
388
404
  const now = performance.now();
389
405
  const frame: AnimationFrame = { now, tick: Math.floor(now / 80) };
390
406
  // Retirement measures the same live blocks the viewport lays out below;
391
407
  // one open frame renders each of them once for both.
392
408
  transcript.beginFrame(frame);
393
409
  const history = this.#offerHistory(transcript, width, rows, preRoots.length + belowFloor);
410
+ if (decisionPanelOpen && history !== undefined) this.#anchorAfterInlineRetirement = true;
394
411
  const headerVisible = !this.#headerRetired && this.#offeredHistory?.source !== "header";
395
412
  const headerRows = headerVisible ? this.#header.render(width) : [];
396
413
  const before = [...headerRows, ...preRoots];
@@ -408,6 +425,13 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
408
425
  }
409
426
  const drop = Math.max(0, before.length + active.length + after.length - rows);
410
427
  const mutable = [...before, ...active, ...after].slice(drop);
428
+ // Once live rows fill the screen again, the retired gap is gone.
429
+ if (!decisionPanelOpen && mutable.length >= rows) this.#anchorAfterInlineRetirement = false;
430
+ // Rows retired during a decision panel cannot be pulled back from native
431
+ // history when it closes. Keep the input pinned to the bottom without
432
+ // replaying those rows (which would duplicate them) or clearing history.
433
+ const topPadding = this.#anchorAfterInlineRetirement ? Math.max(0, rows - mutable.length) : 0;
434
+ if (topPadding > 0) mutable.unshift(...Array<string>(topPadding).fill(""));
411
435
  const viewportLength = mutable.length;
412
436
  const spans: ViewportClickSpan[] = [];
413
437
  const shift = (span: ViewportClickSpan, base: number): void => {
@@ -421,8 +445,8 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
421
445
  spans.push({ start: clamped, end, candidates: (local: number) => span.candidates(local + skew) });
422
446
  }
423
447
  };
424
- for (const span of activeSpans) shift(span, before.length - drop);
425
- for (const span of afterSpans) shift(span, before.length + active.length - drop);
448
+ for (const span of activeSpans) shift(span, topPadding + before.length - drop);
449
+ for (const span of afterSpans) shift(span, topPadding + before.length + active.length - drop);
426
450
  this.#lastClickSpans = spans;
427
451
  if (history !== undefined && this.#offeredHistory?.source === "header") {
428
452
  const visibleHeaderRows = Math.max(0, rows - (mutable.length + drop));
@@ -901,6 +925,7 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
901
925
  if (this.#stopped) return;
902
926
  this.#transientChrome = new Set(options.transient);
903
927
  this.#transientChromeFloor = undefined;
928
+ this.#anchorAfterInlineRetirement = false;
904
929
  this.#nativeDock = options.nativeDock;
905
930
  this.ui.removeChild(this.#statusHost);
906
931
  if (this.#runtimeMounted) {
@@ -859,16 +859,32 @@ export function describeContextUsage(breakdown: ContextBreakdown): NativeNode {
859
859
  * has them and as the glyph grid otherwise.
860
860
  */
861
861
  export class ContextUsageView extends Container {
862
- readonly #breakdown: ContextBreakdown;
862
+ #breakdown: ContextBreakdown;
863
863
  #native: { meter: boolean; node: NativeNode } | undefined;
864
+ readonly #theme: Theme;
864
865
 
865
866
  constructor(breakdown: ContextBreakdown, theme: Theme) {
866
867
  super();
867
868
  this.#breakdown = breakdown;
869
+ this.#theme = theme;
870
+ this.#build();
871
+ }
872
+
873
+ /** Replace the shown breakdown in place, refreshing both the ANSI and native views. */
874
+ setBreakdown(breakdown: ContextBreakdown): void {
875
+ this.#breakdown = breakdown;
876
+ this.#native = undefined;
877
+ this.clear();
878
+ this.#build();
879
+ this.invalidate();
880
+ }
881
+
882
+ #build(): void {
883
+ const theme = this.#theme;
868
884
  this.addChild(new DynamicBorder());
869
885
  this.addChild(new Text(theme.bold(theme.fg("accent", "Context Usage")), 1, 0));
870
886
  this.addChild(new Spacer(1));
871
- this.addChild(new Text(renderContextUsage(breakdown, theme), 1, 0));
887
+ this.addChild(new Text(renderContextUsage(this.#breakdown, theme), 1, 0));
872
888
  this.addChild(new DynamicBorder());
873
889
  }
874
890
 
@@ -283,6 +283,14 @@ export function isInsideZellij(env: NodeJS.ProcessEnv = Bun.env): boolean {
283
283
  return Boolean(env.ZELLIJ);
284
284
  }
285
285
 
286
+ /**
287
+ * Whether the agent process runs in an SSH session, so the terminal emulator
288
+ * is remote and host-local input/keyboard modes cannot be assumed.
289
+ */
290
+ export function isSshSession(env: NodeJS.ProcessEnv = Bun.env): boolean {
291
+ return Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
292
+ }
293
+
286
294
  export function isNotificationSuppressed(): boolean {
287
295
  const value = $env.PI_NOTIFICATIONS;
288
296
  if (!value) return false;