@oh-my-pi/pi-tui 18.13.0 → 18.13.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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.13.1] - 2026-10-27
6
+
7
+ ### Added
8
+
9
+ - ANSI and TSP `/pause` screens show unsettled turns and jobs, hide settled counters, and preserve counts and resume controls on short terminals ([#17073](https://github.com/can1357/oh-my-pi/pull/17073) by [@anatoli-tsinovoy](https://github.com/anatoli-tsinovoy)).
10
+ - Added `noteOption` to `ExtensionAskDialogResultItem` and `QuestionResult`: the label of the option an ask note is on (unset for a note on the custom answer) ([#17126](https://github.com/can1357/oh-my-pi/pull/17126) by [@H4vC](https://github.com/H4vC))
11
+
12
+ ### Changed
13
+
14
+ - `StatusLineComponent.setRunningSubagents()` and `setSubagentTreeCost()` now report whether their state changed, allowing callers to skip redundant redraws ([#17131](https://github.com/can1357/oh-my-pi/pull/17131) by [@iliaal](https://github.com/iliaal)).
15
+
16
+ ### Fixed
17
+
18
+ - Fixed the ask dialog's Note (button or `n`) right after a pick moved to the next question annotating that question's highlighted row instead of the answer just picked ([#17126](https://github.com/can1357/oh-my-pi/pull/17126) by [@H4vC](https://github.com/H4vC))
19
+ - Fixed the ask dialog's Next button reopening the custom-answer editor after Other was cancelled instead of moving on to the next question or Review tab
20
+
5
21
  ## [18.13.0] - 2026-10-26
6
22
 
7
23
  ### Breaking Changes
@@ -2453,35 +2469,4 @@
2453
2469
 
2454
2470
  - Fixed native Windows + Windows Terminal scrollback being yanked to the top when a streaming response triggered a TUI full redraw. Under ConPTY the `kernel32` `GetConsoleScreenBufferInfo` probe answers about the pseudo-console (always at the buffer tail) and not about WT's host scrollback, so `isNativeViewportAtBottom()` falsely returned `true` while the user was scrolled up and the shrink-across-viewport branch issued a destructive `historyRebuild` (`\x1b[2J\x1b[H\x1b[3J`). The probe now short-circuits to `undefined` whenever `WT_SESSION` is set, letting the existing deferred-rebuild path keep streaming-time mutations non-destructive and reconcile native history at the next prompt-submit checkpoint. ([#1635](https://github.com/can1357/oh-my-pi/issues/1635))
2455
2471
 
2456
- ## [15.7.3] - 2026-05-31
2457
-
2458
- ### Added
2459
-
2460
- - Added `overflowSearch` to `SelectListLayoutOptions` to let consumers enable or disable type-to-filter search and search-status rendering per SelectList instance
2461
- - Added fuzzy type-to-filter search to overflowing `SelectList` pickers, with search status and result counts.
2462
- - Added `TUI.setEagerNativeScrollbackRebuild(enabled)` — while enabled, live render frames rebuild native scrollback on offscreen/structural changes even when the viewport position is unobservable (POSIX), instead of deferring to a non-destructive repaint. Trades the anti-yank guarantee for clean, duplicate-free history; intended for windows where output above the fold is actively re-laying out (e.g. a tool whose result is still streaming). A terminal that reports a known-scrolled viewport still defers.
2463
-
2464
- ### Changed
2465
-
2466
- - Disabled interactive search filtering for editor autocomplete and slash-command `SelectList`s by passing `overflowSearch: false` in their layout options
2467
-
2468
- ### Fixed
2469
-
2470
- - Preserved hidden tmux overlays in the live viewport by removing overlay content from view when an overlay was hidden while keeping pane history intact
2471
- - Preserved native scrollback when forced TUI renders coalesce with content growth, and deferred pure tail appends while readers are scrolled into history.
2472
- - Preserved existing terminal scrollback during forced and structural TUI renders so preexisting shell lines remained visible after component mutations
2473
- - Rebuilt native scrollback for safe bottom-anchored offscreen edits and high-water preview collapses instead of repainting only the viewport, preventing stale or duplicated rows above the live viewport.
2474
- - Stripped internal cursor marker sentinels from all rendered lines so offscreen focus markers no longer leak into terminal output
2475
- - Truncated all painted lines to terminal width during viewport repaints and append-tail updates so long content no longer overflows or wraps unexpectedly
2476
- - Fixed `tui.select.cancel` handling in `SelectList` so pressing Escape or Ctrl+C closes the list even when no matches are currently shown
2477
- - Fixed native scrollback corruption when an offscreen row edit and repeated-tail append land in one render frame; ambiguous appended tails now rebuild history instead of splicing stale rows into the buffer.
2478
- - Fixed scrolled-up readers being yanked back to the tail whenever streaming content arrived on POSIX terminals (macOS/Linux). Native viewport position is unobservable there (`isNativeViewportAtBottom()` returns `undefined`), and the planner optimistically treated "unknown" as "at bottom", so every offscreen streaming edit ran a destructive `historyRebuild` that cleared scrollback and snapped the view to the bottom. Live render frames now treat an unknown viewport as unsafe for a destructive rebuild — they defer to a non-destructive viewport repaint and reconcile native scrollback at the next explicit checkpoint (prompt submit). Resize and checkpoint replays keep the prior behavior.
2479
- - Fixed native scrollback not rewrapping when the terminal widens on POSIX. A width increase reflows the transcript to fewer lines, which the shrink-across-boundary branch intercepted and (after the unknown-viewport deferral) repainted only the viewport — leaving committed history wrapped at the old width and duplicated above the live viewport. Width changes now rebuild native scrollback at the new geometry even when the viewport position is unknown (a yank is acceptable on an explicit resize); a terminal that can report a scrolled viewport still defers.
2480
-
2481
- ## [15.7.0] - 2026-05-31
2482
-
2483
- ### Fixed
2484
-
2485
- - Fixed slash-command autocomplete repainting when a Windows Terminal session cannot report native scrollback position; live input renders can now bypass the unknown-viewport deferral without weakening background scrollback protection. ([#1550](https://github.com/can1357/oh-my-pi/issues/1550))
2486
-
2487
- Older entries are archived in [packages/tui/CHANGELOG.md@539f05ae39f8](https://github.com/can1357/oh-my-pi/blob/539f05ae39f81319dc75d650757dad3c76482343/packages/tui/CHANGELOG.md).
2472
+ Older entries are archived in [packages/tui/CHANGELOG.md@3d45d8557cd7](https://github.com/can1357/oh-my-pi/blob/3d45d8557cd7493221b69c7ed3c6b6a2307f46e7/packages/tui/CHANGELOG.md).
@@ -31,6 +31,8 @@ export interface ExtensionAskDialogResultItem {
31
31
  customInputImages?: ImageContent[];
32
32
  note?: string;
33
33
  noteImages?: ImageContent[];
34
+ /** Label of the option the note is on; unset when it is on the custom answer. */
35
+ noteOption?: string;
34
36
  timedOut?: boolean;
35
37
  }
36
38
  /** Answers submitted from the dialog. */
@@ -116,7 +118,7 @@ export declare class AskDialogComponent implements Component {
116
118
  * Pointer actions: a tab click switches to it; a click on an answer row is
117
119
  * Enter on it (Space for a multi-select option, so it toggles; its
118
120
  * double-click `activate` is Enter); the action bar's Submit, Note and Skip
119
- * run exactly what Enter, `n` and Esc run on the highlighted row; Hide/Show
121
+ * run what Enter (see {@link #pressEnter} for Other), `n` and Esc run on the highlighted row; Hide/Show
120
122
  * collapse and restore the dialog.
121
123
  */
122
124
  handleNativeEvent(event: NativeUiEvent): void;
@@ -1,5 +1,14 @@
1
1
  import { type Component, type OverlayFocusOwner, type OverlayHandle, type OverlayOptions } from "../index.js";
2
2
  import type { NativeNode, NativeUiEvent } from "../native/node.js";
3
+ /** Outstanding turn and job counts supplied by the pause-screen host. */
4
+ export interface PauseWorkStatus {
5
+ unsettledTurns: number;
6
+ unfinishedJobs: number;
7
+ }
8
+ /** Optional live work source for the pause overlay. */
9
+ export interface PauseScreenOptions {
10
+ getWorkStatus?: () => PauseWorkStatus;
11
+ }
3
12
  /**
4
13
  * Slice of `InteractiveModeContext` the pause screen drives. Narrow so tests
5
14
  * can exercise the full engage → hold → release lifecycle without a real TUI.
@@ -22,12 +31,13 @@ export interface PauseScreenHost {
22
31
  * Paint the pause scene as exactly `height` rows, vertically centered.
23
32
  * Exported for tests.
24
33
  */
25
- export declare function renderPauseScreen(width: number, height: number, elapsedMs: number, sessionName?: string): string[];
34
+ export declare function renderPauseScreen(width: number, height: number, elapsedMs: number, sessionName?: string, workStatus?: PauseWorkStatus): string[];
26
35
  /** Fullscreen overlay component; resolves {@link run} when a resume key lands. */
27
36
  export declare class PauseScreenComponent implements Component, OverlayFocusOwner {
28
37
  #private;
29
38
  readonly host: PauseScreenHost;
30
- constructor(host: PauseScreenHost);
39
+ readonly options: PauseScreenOptions;
40
+ constructor(host: PauseScreenHost, options?: PauseScreenOptions);
31
41
  /** Start the clock; resolves once the user asks to resume. */
32
42
  run(): Promise<void>;
33
43
  dispose(): void;
@@ -57,4 +67,4 @@ export declare class PauseScreenComponent implements Component, OverlayFocusOwne
57
67
  * gate on the way out (including teardown throws) — a leaked pause would
58
68
  * freeze every agent in the process with no UI left to release it.
59
69
  */
60
- export declare function runPauseScreen(host: PauseScreenHost): Promise<void>;
70
+ export declare function runPauseScreen(host: PauseScreenHost, options?: PauseScreenOptions): Promise<void>;
@@ -30,9 +30,9 @@ export declare class StatusLineComponent<TSession extends StatusLineSession = St
30
30
  updateSettings(settings: StatusLineSettings): void;
31
31
  getEffectiveSettingsForTest(): EffectiveStatusLineSettings;
32
32
  setAutoCompactEnabled(enabled: boolean): void;
33
- setRunningSubagents(agentIds: readonly string[]): void;
33
+ setRunningSubagents(agentIds: readonly string[]): boolean;
34
34
  /** Host-computed spend of the main session's whole subagent tree (Agent Hub projection). */
35
- setSubagentTreeCost(cost: number): void;
35
+ setSubagentTreeCost(cost: number): boolean;
36
36
  /**
37
37
  * Compatibility shim for callers predating the simplified subagent badge.
38
38
  * The status line now intentionally shows only the active count.
@@ -12,6 +12,8 @@ export interface QuestionResult {
12
12
  customInput?: string;
13
13
  /** Optional note attached to the selected answer in the rich ask dialog. */
14
14
  note?: string;
15
+ /** Label of the option the note is on; unset when it is on the custom answer. */
16
+ noteOption?: string;
15
17
  /** True when the answer was auto-selected because the dialog timed out. */
16
18
  timedOut?: boolean;
17
19
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.13.0",
4
+ "version": "18.13.1",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.13.0",
45
- "@oh-my-pi/pi-agent-core": "18.13.0",
46
- "@oh-my-pi/pi-ai": "18.13.0",
47
- "@oh-my-pi/pi-catalog": "18.13.0",
48
- "@oh-my-pi/pi-natives": "18.13.0",
49
- "@oh-my-pi/pi-utils": "18.13.0",
50
- "@oh-my-pi/pi-wire": "18.13.0",
51
- "@oh-my-pi/snapcompact": "18.13.0"
44
+ "@oh-my-pi/omptype": "18.13.1",
45
+ "@oh-my-pi/pi-agent-core": "18.13.1",
46
+ "@oh-my-pi/pi-ai": "18.13.1",
47
+ "@oh-my-pi/pi-catalog": "18.13.1",
48
+ "@oh-my-pi/pi-natives": "18.13.1",
49
+ "@oh-my-pi/pi-utils": "18.13.1",
50
+ "@oh-my-pi/pi-wire": "18.13.1",
51
+ "@oh-my-pi/snapcompact": "18.13.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -51,6 +51,8 @@ export interface ExtensionAskDialogResultItem {
51
51
  customInputImages?: ImageContent[];
52
52
  note?: string;
53
53
  noteImages?: ImageContent[];
54
+ /** Label of the option the note is on; unset when it is on the custom answer. */
55
+ noteOption?: string;
54
56
  timedOut?: boolean;
55
57
  }
56
58
 
@@ -404,7 +406,7 @@ function describeAnswer(question: ExtensionAskDialogQuestion, state: QuestionSta
404
406
  push(optionLabelSpans(question, index, labels[index] ?? option.label));
405
407
  });
406
408
  if (state.customInput !== undefined) push([span(`“${normalizedInlineInput(state.customInput)}”`)]);
407
- const note = noteForSubmittedAnswer(question, state)?.trim();
409
+ const note = noteForSubmittedAnswer(question, state)?.text.trim();
408
410
  if (note) spans.push(span(" ✎ ", "dim"), span(normalizedInlineInput(note), "em"));
409
411
  return spans;
410
412
  }
@@ -448,13 +450,17 @@ function clearNoteUnlessRow(state: QuestionState, rowKey: string): void {
448
450
  if (state.noteRowKey !== undefined && state.noteRowKey !== rowKey) clearNote(state);
449
451
  }
450
452
 
451
- function noteForSubmittedAnswer(question: ExtensionAskDialogQuestion, state: QuestionState): string | undefined {
453
+ /** The note that goes out with the answer (its row is part of it), and the option it is on (unset for Other). */
454
+ function noteForSubmittedAnswer(
455
+ question: ExtensionAskDialogQuestion,
456
+ state: QuestionState,
457
+ ): { text: string; option?: string } | undefined {
452
458
  if (state.note === undefined || state.noteRowKey === undefined) return undefined;
453
- if (state.noteRowKey === "other") return state.customInput !== undefined ? state.note : undefined;
459
+ if (state.noteRowKey === "other") return state.customInput !== undefined ? { text: state.note } : undefined;
454
460
  const match = /^option:(\d+)$/.exec(state.noteRowKey);
455
461
  const optionIndex = match?.[1] === undefined ? Number.NaN : Number.parseInt(match[1], 10);
456
462
  const option = Number.isInteger(optionIndex) ? question.options[optionIndex] : undefined;
457
- return option && state.selectedOptions.has(option.label) ? state.note : undefined;
463
+ return option && state.selectedOptions.has(option.label) ? { text: state.note, option: option.label } : undefined;
458
464
  }
459
465
 
460
466
  function renderRowLabel(
@@ -585,6 +591,12 @@ export class AskDialogComponent implements Component {
585
591
  #countdownDeadline = 0;
586
592
  /** Once-a-second re-describe while the native countdown ring is shown. */
587
593
  #ringTick: NodeJS.Timeout | undefined;
594
+ /**
595
+ * The answer whose pick just moved the dialog on to the next question. Note annotates it, not the
596
+ * next question's highlighted row, until that question is answered or any other input arrives:
597
+ * a Tern click commits a single-choice row, so picking then pressing Note is the mouse flow.
598
+ */
599
+ #justAnswered: { questionIndex: number; rowKey: string } | undefined;
588
600
  readonly #panel: OverlayPanel;
589
601
  readonly #headerRegion: PanelRows;
590
602
  readonly #bodyRegion: PanelRows;
@@ -682,6 +694,7 @@ export class AskDialogComponent implements Component {
682
694
  // closed/prompt guards, matching HookSelector/HookInput semantics.
683
695
  this.#countdown?.reset();
684
696
  this.#native = undefined;
697
+ if (keyData !== "n" && keyData !== "N") this.#justAnswered = undefined;
685
698
  if (matchesSelectCancel(keyData)) {
686
699
  this.#finishCancel();
687
700
  return;
@@ -1006,7 +1019,10 @@ export class AskDialogComponent implements Component {
1006
1019
  tone: "accent",
1007
1020
  title: question?.multi ? `${enterLabel} enter · Toggle space` : `${enterLabel} enter`,
1008
1021
  });
1009
- const note = actionButton("Note", "note", { keys: "n", title: "Add a note to the highlighted answer n" });
1022
+ const note = actionButton("Note", "note", {
1023
+ keys: "n",
1024
+ title: "Add a note to the highlighted answer, or to the one just picked n",
1025
+ });
1010
1026
  return actionBar([...lead, null, note, skip, submit]);
1011
1027
  }
1012
1028
 
@@ -1014,11 +1030,12 @@ export class AskDialogComponent implements Component {
1014
1030
  * Pointer actions: a tab click switches to it; a click on an answer row is
1015
1031
  * Enter on it (Space for a multi-select option, so it toggles; its
1016
1032
  * double-click `activate` is Enter); the action bar's Submit, Note and Skip
1017
- * run exactly what Enter, `n` and Esc run on the highlighted row; Hide/Show
1033
+ * run what Enter (see {@link #pressEnter} for Other), `n` and Esc run on the highlighted row; Hide/Show
1018
1034
  * collapse and restore the dialog.
1019
1035
  */
1020
1036
  handleNativeEvent(event: NativeUiEvent): void {
1021
1037
  if (this.#closed || this.#promptActive) return;
1038
+ if (event.type !== "action" || event.act !== "note") this.#justAnswered = undefined;
1022
1039
  if (event.type === "action") {
1023
1040
  if (event.act === "cancel") {
1024
1041
  this.#finishCancel();
@@ -1062,7 +1079,13 @@ export class AskDialogComponent implements Component {
1062
1079
  this.#requestRender();
1063
1080
  }
1064
1081
 
1065
- /** Enter: submit on the Review tab, else commit the highlighted row. */
1082
+ /**
1083
+ * The action bar's Submit/Next: submit on the Review tab, else commit the highlighted row —
1084
+ * except Other, whose row (click or Enter) already opens the editor. Reopening it here trapped a
1085
+ * cancelled Other: Next kept bringing the editor back instead of moving on. With a Review tab to
1086
+ * land on, Next keeps any custom answer and moves on; a lone single-select question has nowhere
1087
+ * to go but submitting nothing, so it still asks.
1088
+ */
1066
1089
  #pressEnter(): void {
1067
1090
  this.#countdown?.reset();
1068
1091
  if (this.#isSubmitTab()) {
@@ -1072,18 +1095,38 @@ export class AskDialogComponent implements Component {
1072
1095
  const active = this.#activeQuestionState();
1073
1096
  const rowItem = active ? this.#questionRows(active.question)[active.state.cursorIndex] : undefined;
1074
1097
  if (!active || !rowItem) return;
1098
+ if (rowItem.kind === "other" && this.#hasSubmitTab()) {
1099
+ this.#advanceAfterQuestion(active.state.customInput === undefined ? undefined : rowItem.key);
1100
+ return;
1101
+ }
1075
1102
  this.#commitRow(active.question, active.state, rowItem, true);
1076
1103
  this.#requestRender();
1077
1104
  }
1078
1105
 
1079
- /** `n`: a note on the highlighted row (questions only). */
1106
+ /** `n`: a note on the {@link #noteTarget} row (questions only). */
1080
1107
  #pressNote(): void {
1081
1108
  if (this.#isSubmitTab()) return;
1082
1109
  this.#countdown?.reset();
1110
+ const target = this.#noteTarget();
1111
+ if (target) void this.#promptForNote(target.question, target.state, target.rowItem);
1112
+ }
1113
+
1114
+ /**
1115
+ * The row a note goes to: the answer just picked while the question it moved on to is still
1116
+ * unanswered, else the highlighted row.
1117
+ */
1118
+ #noteTarget(): { question: ExtensionAskDialogQuestion; state: QuestionState; rowItem: QuestionRow } | undefined {
1083
1119
  const active = this.#activeQuestionState();
1084
- const rowItem = active ? this.#questionRows(active.question)[active.state.cursorIndex] : undefined;
1085
- if (!active || !rowItem) return;
1086
- void this.#promptForNote(active.question, active.state, rowItem);
1120
+ if (!active) return undefined;
1121
+ const last = this.#justAnswered;
1122
+ if (last && last.questionIndex !== this.#currentQuestionIndex() && !isAnswered(active.state)) {
1123
+ const question = this.#questions[last.questionIndex];
1124
+ const state = this.#states[last.questionIndex];
1125
+ const rowItem = question ? this.#questionRows(question).find(row => row.key === last.rowKey) : undefined;
1126
+ if (question && state && rowItem) return { question, state, rowItem };
1127
+ }
1128
+ const rowItem = this.#questionRows(active.question)[active.state.cursorIndex];
1129
+ return rowItem ? { question: active.question, state: active.state, rowItem } : undefined;
1087
1130
  }
1088
1131
 
1089
1132
  /** Re-describe once a second while a countdown ring is shown (the ring's value is data, not a terminal clock). */
@@ -1334,9 +1377,7 @@ export class AskDialogComponent implements Component {
1334
1377
  const rowItem = rows[state.cursorIndex];
1335
1378
  if (!rowItem) return;
1336
1379
  if (keyData === "n" || keyData === "N") {
1337
- if (rowItem.kind === "option" || rowItem.kind === "other") {
1338
- void this.#promptForNote(question, state, rowItem);
1339
- }
1380
+ this.#pressNote();
1340
1381
  return;
1341
1382
  }
1342
1383
  const isEnter = matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n";
@@ -1364,7 +1405,7 @@ export class AskDialogComponent implements Component {
1364
1405
  // focused option; Space toggles. Advances to the next question
1365
1406
  // (submitting only for a single-question dialog), matching
1366
1407
  // single-select Enter (#8252).
1367
- this.#advanceAfterQuestion();
1408
+ this.#advanceAfterQuestion(state.selectedOptions.has(option.label) ? rowItem.key : undefined);
1368
1409
  return;
1369
1410
  }
1370
1411
  if (state.selectedOptions.has(option.label)) {
@@ -1380,7 +1421,7 @@ export class AskDialogComponent implements Component {
1380
1421
  state.customInput = undefined;
1381
1422
  state.customInputImages = undefined;
1382
1423
  clearNoteUnlessRow(state, rowItem.key);
1383
- this.#advanceAfterQuestion();
1424
+ this.#advanceAfterQuestion(rowItem.key);
1384
1425
  }
1385
1426
 
1386
1427
  #handleSubmitTabInput(keyData: string): void {
@@ -1405,8 +1446,13 @@ export class AskDialogComponent implements Component {
1405
1446
  this.#submitScrollOffset = 0;
1406
1447
  }
1407
1448
 
1408
- #advanceAfterQuestion(): void {
1449
+ /** Move past the current question; `answeredRowKey` is the row whose pick did it, for {@link #noteTarget}. */
1450
+ #advanceAfterQuestion(answeredRowKey?: string): void {
1409
1451
  const current = this.#currentQuestionIndex();
1452
+ this.#justAnswered =
1453
+ answeredRowKey !== undefined && this.#questions.length > 1
1454
+ ? { questionIndex: current, rowKey: answeredRowKey }
1455
+ : undefined;
1410
1456
  if (this.#questions.length === 1) {
1411
1457
  this.#finishSubmit();
1412
1458
  return;
@@ -1458,7 +1504,7 @@ export class AskDialogComponent implements Component {
1458
1504
  this.#activeTabIndex = this.#submitTabIndex();
1459
1505
  this.#submitScrollOffset = 0;
1460
1506
  } else {
1461
- this.#advanceAfterQuestion();
1507
+ this.#advanceAfterQuestion(rowItem.key);
1462
1508
  }
1463
1509
  } finally {
1464
1510
  this.#promptActive = false;
@@ -1590,7 +1636,7 @@ export class AskDialogComponent implements Component {
1590
1636
  const label = questionTabLabel(question, index);
1591
1637
  const answer = renderAnswerSummary(question, state);
1592
1638
  allLines.push(`${theme.fg("dim", `${index + 1}. ${label}:`)} ${answer}`);
1593
- const submittedNote = noteForSubmittedAnswer(question, state);
1639
+ const submittedNote = noteForSubmittedAnswer(question, state)?.text;
1594
1640
  if (submittedNote?.trim()) {
1595
1641
  const note = normalizedInlineInput(submittedNote);
1596
1642
  allLines.push(
@@ -1724,8 +1770,9 @@ export class AskDialogComponent implements Component {
1724
1770
  selectedOptions,
1725
1771
  customInput: state.customInput,
1726
1772
  customInputImages: state.customInputImages,
1727
- note,
1773
+ note: note?.text,
1728
1774
  noteImages: note === undefined ? undefined : state.noteImages,
1775
+ noteOption: note?.option,
1729
1776
  timedOut: state.timedOut || undefined,
1730
1777
  });
1731
1778
  }
@@ -14,7 +14,7 @@
14
14
  import { agentPauseGate } from "@oh-my-pi/pi-agent-core";
15
15
  import { type Component, matchesKey, type OverlayFocusOwner, type OverlayHandle, type OverlayOptions } from "../index";
16
16
  import { formatCoarseDuration } from "../chrome/format";
17
- import { centerLine } from "../utils";
17
+ import { centerLine, visibleWidth } from "../utils";
18
18
  import { theme } from "../theme/theme";
19
19
  import { matchesAppInterrupt } from "../keybinding-matchers";
20
20
  import { formatKeyHint, formatTooltipKey } from "../app-keybindings";
@@ -24,6 +24,17 @@ import { col, keyed, node, row, span, text } from "../native/describe";
24
24
  import { actionBar, actionButton } from "../native/overlay";
25
25
  import { isNativeRendering } from "../native/state";
26
26
 
27
+ /** Outstanding turn and job counts supplied by the pause-screen host. */
28
+ export interface PauseWorkStatus {
29
+ unsettledTurns: number;
30
+ unfinishedJobs: number;
31
+ }
32
+
33
+ /** Optional live work source for the pause overlay. */
34
+ export interface PauseScreenOptions {
35
+ getWorkStatus?: () => PauseWorkStatus;
36
+ }
37
+
27
38
  /**
28
39
  * Slice of `InteractiveModeContext` the pause screen drives. Narrow so tests
29
40
  * can exercise the full engage → hold → release lifecycle without a real TUI.
@@ -39,9 +50,10 @@ export interface PauseScreenHost {
39
50
  readonly sessionName?: string;
40
51
  }
41
52
 
42
- /** Refresh cadence for the live "paused for" clock. */
43
- const TICK_MS = 1_000;
53
+ const EMPTY_WORK_STATUS: PauseWorkStatus = { unsettledTurns: 0, unfinishedJobs: 0 };
44
54
 
55
+ /** Refresh cadence for the live hold clock and work-status snapshot. */
56
+ const TICK_MS = 1_000;
45
57
  /** Pause-bar glyph geometry (rows × columns of full blocks per bar). */
46
58
  const BAR_ROWS = 7;
47
59
  const BAR_WIDTH = 5;
@@ -49,7 +61,7 @@ const BAR_GAP = 4;
49
61
 
50
62
  /** Below either bound the full scene cannot breathe; drop to the compact card. */
51
63
  const MIN_FULL_WIDTH = 64;
52
- const MIN_FULL_HEIGHT = 18;
64
+ const MIN_FULL_HEIGHT = 23;
53
65
 
54
66
  const TITLE = "P A U S E D";
55
67
  const BODY_LINES = [
@@ -79,51 +91,110 @@ function formatClock(ms: number): string {
79
91
  * Paint the pause scene as exactly `height` rows, vertically centered.
80
92
  * Exported for tests.
81
93
  */
82
- export function renderPauseScreen(width: number, height: number, elapsedMs: number, sessionName?: string): string[] {
83
- const compact = width < MIN_FULL_WIDTH || height < MIN_FULL_HEIGHT;
94
+ export function renderPauseScreen(
95
+ width: number,
96
+ height: number,
97
+ elapsedMs: number,
98
+ sessionName?: string,
99
+ workStatus: PauseWorkStatus = EMPTY_WORK_STATUS,
100
+ ): string[] {
101
+ const screenWidth = Math.max(1, Math.floor(width));
102
+ const screenHeight = Math.max(1, Math.floor(height));
103
+ const compact = screenWidth < MIN_FULL_WIDTH || screenHeight < MIN_FULL_HEIGHT;
104
+ const outstanding = workStatus.unsettledTurns > 0 || workStatus.unfinishedJobs > 0;
105
+ const status = outstanding ? "Pausing — waiting for work to settle" : "Paused — ready to suspend";
106
+ const workCounts = [
107
+ `Unsettled turns: ${workStatus.unsettledTurns}`,
108
+ `Unfinished jobs: ${workStatus.unfinishedJobs}`,
109
+ ];
84
110
  const content: string[] = [];
85
111
  // Resume keys mirror PauseScreenComponent#handleInput: `app.interrupt` (raw Escape when unbound), Enter, Space.
86
112
  const esc = interruptKey();
87
113
 
88
114
  if (compact) {
89
- if (sessionName) {
90
- content.push(centerLine(theme.bold(sessionName), width).trimEnd());
91
- content.push("");
115
+ const sessionLine = sessionName
116
+ ? centerLine(theme.bold(sessionName.replace(/\s+/g, " ").trim()), screenWidth).trimEnd()
117
+ : undefined;
118
+ const titleLine = centerLine(theme.bold(theme.fg("accent", `▌▌ ${TITLE}`)), screenWidth).trimEnd();
119
+ const clockLine = centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), screenWidth).trimEnd();
120
+ const statusLine = centerLine(theme.fg(outstanding ? "warning" : "success", status), screenWidth).trimEnd();
121
+ const resumeLine = centerLine(theme.fg("dim", `${esc} to resume`), screenWidth).trimEnd();
122
+ const fullContent: string[] = [];
123
+ if (sessionLine) fullContent.push(sessionLine);
124
+ fullContent.push(titleLine, "", clockLine, statusLine);
125
+ if (outstanding)
126
+ fullContent.push(...workCounts.map(line => centerLine(theme.fg("muted", line), screenWidth).trimEnd()));
127
+ fullContent.push(resumeLine);
128
+
129
+ if (screenHeight >= fullContent.length) {
130
+ content.push(...fullContent);
131
+ } else {
132
+ // Keep the live state and a way out on screen before the decorative card details.
133
+ if (screenHeight === 1) {
134
+ const counts = `T:${workStatus.unsettledTurns} J:${workStatus.unfinishedJobs}`;
135
+ const summary = outstanding ? `${counts} · ${esc} to resume` : `Paused · ${esc} to resume`;
136
+ const abbreviatedSummary = outstanding
137
+ ? `T${workStatus.unsettledTurns} J${workStatus.unfinishedJobs} ${esc} resume`
138
+ : `${esc} resume`;
139
+ const visibleSummary = visibleWidth(summary) > screenWidth ? abbreviatedSummary : summary;
140
+ content.push(
141
+ centerLine(theme.fg(outstanding ? "warning" : "success", visibleSummary), screenWidth).trimEnd(),
142
+ );
143
+ } else if (screenHeight === 2 && outstanding) {
144
+ const counts = `Pausing · T:${workStatus.unsettledTurns} J:${workStatus.unfinishedJobs}`;
145
+ content.push(centerLine(theme.fg("warning", counts), screenWidth).trimEnd(), resumeLine);
146
+ } else {
147
+ content.push(statusLine);
148
+ if (outstanding && screenHeight === 3) {
149
+ content.push(
150
+ centerLine(
151
+ theme.fg("muted", `Turns: ${workStatus.unsettledTurns} · Jobs: ${workStatus.unfinishedJobs}`),
152
+ screenWidth,
153
+ ).trimEnd(),
154
+ );
155
+ } else if (outstanding) {
156
+ content.push(...workCounts.map(line => centerLine(theme.fg("muted", line), screenWidth).trimEnd()));
157
+ }
158
+ content.push(resumeLine);
159
+ }
160
+
161
+ if (content.length < screenHeight && sessionLine) content.push(sessionLine);
162
+ if (content.length < screenHeight) content.push(titleLine);
163
+ if (content.length < screenHeight) content.push(clockLine);
92
164
  }
93
- content.push(centerLine(theme.bold(theme.fg("accent", `▌▌ ${TITLE}`)), width).trimEnd());
94
- content.push("");
95
- content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width).trimEnd());
96
- content.push(centerLine(theme.fg("dim", `${esc} to resume`), width).trimEnd());
97
165
  } else {
98
166
  if (sessionName) {
99
- content.push(centerLine(theme.bold(sessionName), width).trimEnd());
100
- content.push("");
167
+ content.push(centerLine(theme.bold(sessionName.replace(/\s+/g, " ").trim()), screenWidth).trimEnd());
101
168
  content.push("");
102
169
  }
103
170
  const bar = "█".repeat(BAR_WIDTH);
104
171
  const glyphRow = `${bar}${" ".repeat(BAR_GAP)}${bar}`;
105
172
  for (let i = 0; i < BAR_ROWS; i++) {
106
- content.push(centerLine(theme.fg("accent", glyphRow), width).trimEnd());
173
+ content.push(centerLine(theme.fg("accent", glyphRow), screenWidth).trimEnd());
107
174
  }
108
175
  content.push("");
109
- content.push(centerLine(theme.bold(theme.fg("accent", TITLE)), width).trimEnd());
176
+ content.push(centerLine(theme.bold(theme.fg("accent", TITLE)), screenWidth).trimEnd());
110
177
  content.push("");
111
178
  for (const line of BODY_LINES) {
112
- content.push(centerLine(theme.fg("muted", line), width).trimEnd());
179
+ content.push(centerLine(theme.fg("muted", line), screenWidth).trimEnd());
113
180
  }
114
- content.push("");
115
- content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width).trimEnd());
181
+ content.push(centerLine(theme.fg(outstanding ? "warning" : "success", status), screenWidth).trimEnd());
182
+ if (outstanding) {
183
+ content.push(centerLine(theme.fg("muted", workCounts[0]!), screenWidth).trimEnd());
184
+ content.push(centerLine(theme.fg("muted", workCounts[1]!), screenWidth).trimEnd());
185
+ }
186
+ content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), screenWidth).trimEnd());
116
187
  content.push("");
117
188
  const resumeHint = `${esc} · ${formatKeyHint("enter")} · ${formatKeyHint("space")} — resume`;
118
- content.push(centerLine(theme.fg("dim", resumeHint), width).trimEnd());
189
+ content.push(centerLine(theme.fg("dim", resumeHint), screenWidth).trimEnd());
119
190
  }
120
191
 
121
- const topPad = Math.max(0, Math.floor((height - content.length) / 2));
192
+ const topPad = Math.max(0, Math.floor((screenHeight - content.length) / 2));
122
193
  // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
123
194
  const lines: string[] = new Array(topPad).fill("");
124
195
  lines.push(...content);
125
- while (lines.length < height) lines.push("");
126
- return lines.slice(0, Math.max(1, height));
196
+ while (lines.length < screenHeight) lines.push("");
197
+ return lines.slice(0, screenHeight);
127
198
  }
128
199
 
129
200
  /** Fullscreen overlay component; resolves {@link run} when a resume key lands. */
@@ -133,19 +204,25 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
133
204
  #disposed = false;
134
205
  #startedAt = Date.now();
135
206
  #native: NativeNode | undefined;
207
+ #workSnapshot: PauseWorkStatus;
136
208
 
137
- constructor(readonly host: PauseScreenHost) {}
209
+ constructor(
210
+ readonly host: PauseScreenHost,
211
+ readonly options: PauseScreenOptions = {},
212
+ ) {
213
+ this.#workSnapshot = this.#readWorkSnapshot();
214
+ }
138
215
 
139
216
  /** Start the clock; resolves once the user asks to resume. */
140
217
  run(): Promise<void> {
141
218
  this.#startedAt = agentPauseGate.pausedAt ?? Date.now();
142
219
  this.#native = undefined;
143
- // The tick only repaints the hold clock; natively the `elapsed` node counts on its own.
144
- if (!isNativeRendering()) {
145
- this.#timer ??= setInterval(() => {
146
- if (!this.#disposed) this.host.ui.requestRender();
147
- }, TICK_MS);
148
- }
220
+ this.#refreshWorkSnapshot();
221
+ this.#timer ??= setInterval(() => {
222
+ if (this.#disposed) return;
223
+ const statusChanged = this.#refreshWorkSnapshot();
224
+ if (statusChanged || !isNativeRendering()) this.host.ui.requestRender();
225
+ }, TICK_MS);
149
226
  this.host.ui.requestRender();
150
227
  return this.#done.promise;
151
228
  }
@@ -181,6 +258,25 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
181
258
  this.#native = undefined;
182
259
  }
183
260
 
261
+ #readWorkSnapshot(): PauseWorkStatus {
262
+ const workStatus = this.options.getWorkStatus?.() ?? EMPTY_WORK_STATUS;
263
+ return {
264
+ unsettledTurns: workStatus.unsettledTurns,
265
+ unfinishedJobs: workStatus.unfinishedJobs,
266
+ };
267
+ }
268
+
269
+ #refreshWorkSnapshot(): boolean {
270
+ const next = this.#readWorkSnapshot();
271
+ const previous = this.#workSnapshot;
272
+ if (next.unsettledTurns === previous.unsettledTurns && next.unfinishedJobs === previous.unfinishedJobs) {
273
+ return false;
274
+ }
275
+ this.#workSnapshot = next;
276
+ this.#native = undefined;
277
+ return true;
278
+ }
279
+
184
280
  /** A glass sheet titled "Paused" (not the whole pane: the transcript stays visible, frozen). */
185
281
  readonly nativeOverlay = { role: "omp.overlay.pause", size: "md", anchor: "center", head: "Paused" } as const;
186
282
 
@@ -191,6 +287,41 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
191
287
  */
192
288
  describe(): NativeNode {
193
289
  if (this.#native) return this.#native;
290
+ const snapshot = this.#workSnapshot;
291
+ const outstanding = snapshot.unsettledTurns > 0 || snapshot.unfinishedJobs > 0;
292
+ const status = col(
293
+ [
294
+ keyed(
295
+ text(
296
+ [
297
+ span(
298
+ outstanding ? "Pausing — waiting for work to settle" : "Paused — ready to suspend",
299
+ outstanding ? "warning" : "success",
300
+ ),
301
+ ],
302
+ { role: "omp.pause.settlement" },
303
+ ),
304
+ "settlement",
305
+ ),
306
+ ...(outstanding
307
+ ? [
308
+ keyed(
309
+ text([span(`Unsettled turns: ${snapshot.unsettledTurns}`, "muted")], {
310
+ role: "omp.pause.unsettled-turn-count",
311
+ }),
312
+ "unsettled-turns",
313
+ ),
314
+ keyed(
315
+ text([span(`Unfinished jobs: ${snapshot.unfinishedJobs}`, "muted")], {
316
+ role: "omp.pause.unfinished-job-count",
317
+ }),
318
+ "unfinished-jobs",
319
+ ),
320
+ ]
321
+ : []),
322
+ ],
323
+ { role: "omp.pause.settlement-status", gap: "sm" },
324
+ );
194
325
  const resumeKey = boundKeys("app.interrupt", ["escape"])[0] ?? "escape";
195
326
  const hero = row(
196
327
  [
@@ -244,12 +375,9 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
244
375
  this.#native = col(
245
376
  [
246
377
  keyed(hero, "hero"),
378
+ keyed(status, "settlement-status"),
247
379
  keyed(facts, "facts"),
248
380
  actionBar([
249
- keyed(
250
- text([span("Each agent continues where it stopped.", "muted")], { role: "omp.pause.note" }),
251
- "note",
252
- ),
253
381
  null,
254
382
  actionButton("Resume", "resume", {
255
383
  keys: resumeKey,
@@ -276,6 +404,7 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
276
404
  Math.max(1, this.host.ui.terminal.rows),
277
405
  elapsed,
278
406
  this.host.sessionName,
407
+ this.#workSnapshot,
279
408
  );
280
409
  }
281
410
  }
@@ -286,9 +415,9 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
286
415
  * gate on the way out (including teardown throws) — a leaked pause would
287
416
  * freeze every agent in the process with no UI left to release it.
288
417
  */
289
- export async function runPauseScreen(host: PauseScreenHost): Promise<void> {
418
+ export async function runPauseScreen(host: PauseScreenHost, options?: PauseScreenOptions): Promise<void> {
290
419
  if (!agentPauseGate.pause()) return;
291
- const component = new PauseScreenComponent(host);
420
+ const component = new PauseScreenComponent(host, options);
292
421
  const overlay = host.ui.showOverlay(component, {
293
422
  width: "100%",
294
423
  maxHeight: "100%",
@@ -990,24 +990,26 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
990
990
  this.#invalidateStatusLineRenderCache();
991
991
  }
992
992
 
993
- setRunningSubagents(agentIds: readonly string[]): void {
993
+ setRunningSubagents(agentIds: readonly string[]): boolean {
994
994
  if (
995
995
  agentIds.length === this.#runningSubagentIds.size &&
996
996
  agentIds.every(agentId => this.#runningSubagentIds.has(agentId))
997
997
  ) {
998
- return;
998
+ return false;
999
999
  }
1000
1000
  this.#subagentCount = agentIds.length;
1001
1001
  this.#runningSubagentIds = new Set(agentIds);
1002
1002
  this.#invalidateStatusLineRenderCache();
1003
+ return true;
1003
1004
  }
1004
1005
 
1005
1006
  /** Host-computed spend of the main session's whole subagent tree (Agent Hub projection). */
1006
- setSubagentTreeCost(cost: number): void {
1007
+ setSubagentTreeCost(cost: number): boolean {
1007
1008
  const next = Number.isFinite(cost) && cost > 0 ? cost : 0;
1008
- if (next === this.#subagentTreeCost) return;
1009
+ if (next === this.#subagentTreeCost) return false;
1009
1010
  this.#subagentTreeCost = next;
1010
1011
  this.#invalidateStatusLineRenderCache();
1012
+ return true;
1011
1013
  }
1012
1014
 
1013
1015
  /**
package/src/tools/ask.ts CHANGED
@@ -37,6 +37,8 @@ export interface QuestionResult {
37
37
  customInput?: string;
38
38
  /** Optional note attached to the selected answer in the rich ask dialog. */
39
39
  note?: string;
40
+ /** Label of the option the note is on; unset when it is on the custom answer. */
41
+ noteOption?: string;
40
42
  /** True when the answer was auto-selected because the dialog timed out. */
41
43
  timedOut?: boolean;
42
44
  }