@oh-my-pi/pi-coding-agent 17.0.4 → 17.0.5

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.
Files changed (112) hide show
  1. package/CHANGELOG.md +86 -43
  2. package/dist/cli.js +3540 -3521
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  13. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  14. package/dist/types/launch/spawn-options.d.ts +10 -0
  15. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  16. package/dist/types/modes/components/status-line/component.d.ts +2 -3
  17. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  18. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  20. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  21. package/dist/types/modes/interactive-mode.d.ts +2 -0
  22. package/dist/types/modes/print-mode.d.ts +4 -0
  23. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  25. package/dist/types/modes/types.d.ts +2 -0
  26. package/dist/types/sdk.d.ts +2 -0
  27. package/dist/types/session/agent-session.d.ts +19 -1
  28. package/dist/types/session/messages.d.ts +6 -0
  29. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  30. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  31. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  32. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  33. package/dist/types/tools/gh.d.ts +5 -3
  34. package/dist/types/tools/hub/index.d.ts +1 -1
  35. package/dist/types/tools/hub/jobs.d.ts +1 -0
  36. package/dist/types/tools/hub/types.d.ts +2 -0
  37. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  38. package/dist/types/utils/git.d.ts +33 -0
  39. package/dist/types/web/search/providers/codex.d.ts +1 -1
  40. package/package.json +12 -12
  41. package/src/advisor/__tests__/advisor.test.ts +150 -0
  42. package/src/advisor/advise-tool.ts +4 -0
  43. package/src/advisor/runtime.ts +38 -14
  44. package/src/async/job-manager.ts +3 -0
  45. package/src/cli/bench-cli.ts +8 -2
  46. package/src/cli/dry-balance-cli.ts +1 -0
  47. package/src/config/model-registry.ts +89 -8
  48. package/src/config/model-resolver.ts +78 -14
  49. package/src/config/models-config-schema.ts +1 -0
  50. package/src/config/settings.ts +3 -1
  51. package/src/dap/client.ts +168 -1
  52. package/src/dap/config.ts +51 -1
  53. package/src/dap/session.ts +575 -234
  54. package/src/dap/types.ts +6 -5
  55. package/src/discovery/agents.ts +2 -2
  56. package/src/extensibility/extensions/runner.ts +6 -4
  57. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  58. package/src/launch/broker.ts +34 -31
  59. package/src/launch/client.ts +7 -1
  60. package/src/launch/spawn-options.test.ts +31 -0
  61. package/src/launch/spawn-options.ts +17 -0
  62. package/src/lsp/types.ts +5 -1
  63. package/src/main.ts +17 -4
  64. package/src/mcp/transports/stdio.test.ts +9 -1
  65. package/src/modes/components/ask-dialog.ts +137 -73
  66. package/src/modes/components/status-line/component.ts +2 -2
  67. package/src/modes/components/status-line/segments.ts +20 -3
  68. package/src/modes/components/status-line/types.ts +3 -1
  69. package/src/modes/components/tool-execution.ts +5 -0
  70. package/src/modes/components/transcript-container.ts +23 -122
  71. package/src/modes/components/tree-selector.ts +10 -4
  72. package/src/modes/controllers/event-controller.ts +1 -2
  73. package/src/modes/controllers/input-controller.ts +1 -1
  74. package/src/modes/controllers/selector-controller.ts +29 -8
  75. package/src/modes/interactive-mode.ts +40 -8
  76. package/src/modes/noninteractive-dispose.test.ts +12 -1
  77. package/src/modes/print-mode.ts +21 -9
  78. package/src/modes/rpc/rpc-input.ts +38 -0
  79. package/src/modes/rpc/rpc-mode.ts +7 -2
  80. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  81. package/src/modes/types.ts +2 -0
  82. package/src/modes/utils/ui-helpers.ts +3 -2
  83. package/src/prompts/tools/browser.md +3 -2
  84. package/src/prompts/tools/debug.md +2 -7
  85. package/src/prompts/tools/github.md +6 -1
  86. package/src/prompts/tools/hub.md +4 -2
  87. package/src/prompts/tools/task-async-contract.md +1 -0
  88. package/src/prompts/tools/task.md +9 -2
  89. package/src/sdk.ts +38 -6
  90. package/src/session/agent-session.ts +395 -162
  91. package/src/session/messages.test.ts +91 -0
  92. package/src/session/messages.ts +248 -110
  93. package/src/slash-commands/builtin-registry.ts +1 -0
  94. package/src/task/executor.ts +59 -33
  95. package/src/task/index.ts +46 -9
  96. package/src/task/worktree.ts +10 -0
  97. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  98. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  99. package/src/tools/browser/cmux/socket-client.ts +139 -3
  100. package/src/tools/browser/run-cancellation.ts +4 -0
  101. package/src/tools/browser/tab-protocol.ts +2 -0
  102. package/src/tools/browser/tab-supervisor.ts +21 -11
  103. package/src/tools/browser/tab-worker.ts +199 -33
  104. package/src/tools/debug.ts +3 -0
  105. package/src/tools/gh.ts +40 -2
  106. package/src/tools/hub/index.ts +4 -1
  107. package/src/tools/hub/jobs.ts +42 -1
  108. package/src/tools/hub/types.ts +2 -0
  109. package/src/tools/tool-timeouts.ts +1 -1
  110. package/src/utils/git.ts +237 -0
  111. package/src/web/search/index.ts +9 -5
  112. package/src/web/search/providers/codex.ts +195 -99
@@ -4,7 +4,6 @@ import {
4
4
  Markdown,
5
5
  type MarkdownTheme,
6
6
  matchesKey,
7
- padding,
8
7
  renderInlineMarkdown,
9
8
  replaceTabs,
10
9
  ScrollView,
@@ -13,7 +12,6 @@ import {
13
12
  Text,
14
13
  type TUI,
15
14
  truncateToWidth,
16
- visibleWidth,
17
15
  wrapTextWithAnsi,
18
16
  } from "@oh-my-pi/pi-tui";
19
17
  import type {
@@ -23,8 +21,15 @@ import type {
23
21
  } from "../../extensibility/extensions";
24
22
  import { getTabBarTheme } from "../shared";
25
23
  import { getMarkdownTheme, highlightCode, theme } from "../theme/theme";
26
- import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
24
+ import {
25
+ matchesSelectCancel,
26
+ matchesSelectDown,
27
+ matchesSelectPageDown,
28
+ matchesSelectPageUp,
29
+ matchesSelectUp,
30
+ } from "../utils/keybinding-matchers";
27
31
  import { CountdownTimer } from "./countdown-timer";
32
+ import { editorKey } from "./keybinding-hints";
28
33
  import { bottomBorder, divider, row, topBorder } from "./overlay-box";
29
34
  import { handleTabSwitchKey } from "./selector-helpers";
30
35
 
@@ -38,9 +43,6 @@ const SUBMIT_OPTION = "Submit";
38
43
  const DIALOG_HEIGHT_RATIO = 0.7;
39
44
  const MIN_DIALOG_ROWS = 12;
40
45
  const MIN_BODY_ROWS = 5;
41
- const PREVIEW_MIN_WIDTH = 40;
42
- const SIDE_BY_SIDE_LIST_MIN_WIDTH = 30;
43
- const SIDE_BY_SIDE_GAP_WIDTH = 3;
44
46
  const MAX_HEADER_CHIP_WIDTH = 16;
45
47
  /** Maximum number of title lines shown in the prompt editor overlay, so a
46
48
  * long or multi-line question cannot push the input row off-screen. Mirrors
@@ -90,6 +92,7 @@ interface QuestionState {
90
92
  noteRowKey: string | undefined;
91
93
  cursorIndex: number;
92
94
  scrollOffset: number;
95
+ manualScroll: boolean;
93
96
  timedOut: boolean;
94
97
  }
95
98
 
@@ -114,6 +117,8 @@ interface PreviewSegment {
114
117
  language: string | undefined;
115
118
  }
116
119
 
120
+ type PreviewRenderCache = Map<string, Map<number, readonly string[]>>;
121
+
117
122
  function clamp(value: number, min: number, max: number): number {
118
123
  return Math.max(min, Math.min(value, max));
119
124
  }
@@ -211,6 +216,31 @@ function renderPreviewContent(preview: string, width: number): string[] {
211
216
  return out;
212
217
  }
213
218
 
219
+ function renderCachedPreview(cache: PreviewRenderCache, preview: string, width: number): readonly string[] {
220
+ let byWidth = cache.get(preview);
221
+ if (!byWidth) {
222
+ byWidth = new Map();
223
+ cache.set(preview, byWidth);
224
+ }
225
+ let rendered = byWidth.get(width);
226
+ if (!rendered) {
227
+ rendered = renderPreviewContent(preview, width).map(line => ` ${theme.fg("border", "│")} ${line}`);
228
+ byWidth.set(width, rendered);
229
+ }
230
+ return rendered;
231
+ }
232
+
233
+ function pageKeysLabel(): string {
234
+ const pageUp = editorKey("tui.select.pageUp");
235
+ const pageDown = editorKey("tui.select.pageDown");
236
+ return `${pageUp === "pageup" ? "PgUp" : pageUp}/${pageDown === "pagedown" ? "PgDn" : pageDown}`;
237
+ }
238
+
239
+ function cancelKeyLabel(): string {
240
+ const [key = ""] = editorKey("tui.select.cancel").split("/");
241
+ return key === "escape" ? "Esc" : key;
242
+ }
243
+
214
244
  function normalizedInlineInput(input: string): string {
215
245
  return replaceTabs(input).replace(/\s+/g, " ").trim();
216
246
  }
@@ -260,6 +290,7 @@ function renderRowLabel(
260
290
  state: QuestionState,
261
291
  selected: boolean,
262
292
  mdTheme: MarkdownTheme,
293
+ previewCache: PreviewRenderCache,
263
294
  width: number,
264
295
  ): string[] {
265
296
  const isOption = rowItem.kind === "option";
@@ -283,6 +314,10 @@ function renderRowLabel(
283
314
  lines.push(` ${truncateToWidth(line, Math.max(1, width - 6), Ellipsis.Unicode)}`);
284
315
  }
285
316
  }
317
+ if (option?.preview?.trim()) {
318
+ const previewWidth = Math.max(1, width - 8);
319
+ lines.push(...renderCachedPreview(previewCache, option.preview, previewWidth));
320
+ }
286
321
  }
287
322
  if (isOther && state.customInput !== undefined) {
288
323
  const preview = replaceTabs(state.customInput).replace(/\s+/g, " ").trim();
@@ -295,6 +330,8 @@ export class AskDialogComponent implements Component {
295
330
  #states: QuestionState[];
296
331
  #activeTabIndex = 0;
297
332
  #submitScrollOffset = 0;
333
+ #bodyRows = MIN_BODY_ROWS;
334
+ #questionCanPage = false;
298
335
  #remainingSeconds: number | undefined;
299
336
  #countdown: CountdownTimer | undefined;
300
337
  #promptActive = false;
@@ -302,6 +339,8 @@ export class AskDialogComponent implements Component {
302
339
  #closed = false;
303
340
  #tabBar: TabBar | undefined;
304
341
  #stableHeight: { key: string; total: number } | undefined;
342
+ #previewCache: PreviewRenderCache = new Map();
343
+ #overflowLayouts = new WeakMap<ExtensionAskDialogQuestion, Set<string>>();
305
344
 
306
345
  constructor(
307
346
  private readonly questions: ExtensionAskDialogQuestion[],
@@ -318,6 +357,7 @@ export class AskDialogComponent implements Component {
318
357
  noteRowKey: undefined,
319
358
  cursorIndex: clamp(recommended ?? 0, 0, maxIndex),
320
359
  scrollOffset: 0,
360
+ manualScroll: false,
321
361
  timedOut: false,
322
362
  };
323
363
  });
@@ -335,6 +375,8 @@ export class AskDialogComponent implements Component {
335
375
 
336
376
  invalidate(): void {
337
377
  this.#stableHeight = undefined;
378
+ this.#previewCache.clear();
379
+ this.#overflowLayouts = new WeakMap();
338
380
  this.#tabBar?.invalidate();
339
381
  }
340
382
 
@@ -377,6 +419,7 @@ export class AskDialogComponent implements Component {
377
419
  // (PRRT_kwDOQxs0bc6OFbDY).
378
420
  const fixedRows = 1 + headerLines.length + 1 + 1 + 1 + 1;
379
421
  const bodyRows = Math.max(MIN_BODY_ROWS, totalRows - fixedRows);
422
+ this.#bodyRows = bodyRows;
380
423
  const bodyLines = this.#isSubmitTab()
381
424
  ? this.#renderSubmitBody(innerWidth, bodyRows)
382
425
  : this.#renderQuestionBody(innerWidth, bodyRows);
@@ -419,22 +462,11 @@ export class AskDialogComponent implements Component {
419
462
  const listRows = (listWidth: number): number => {
420
463
  let total = 0;
421
464
  for (const rowItem of rowItems) {
422
- total += renderRowLabel(rowItem, question, state, false, mdTheme, listWidth).length;
465
+ total += renderRowLabel(rowItem, question, state, false, mdTheme, this.#previewCache, listWidth).length;
423
466
  }
424
467
  return total;
425
468
  };
426
- let body = listRows(width);
427
- const previews = question.options.filter(option => option.preview?.trim());
428
- const sideBySide = width >= SIDE_BY_SIDE_LIST_MIN_WIDTH + PREVIEW_MIN_WIDTH + SIDE_BY_SIDE_GAP_WIDTH;
429
- if (previews.length > 0 && sideBySide) {
430
- const previewWidth = Math.max(PREVIEW_MIN_WIDTH, Math.floor(width * 0.45));
431
- const listWidth = Math.max(1, width - previewWidth - SIDE_BY_SIDE_GAP_WIDTH);
432
- let pane = 0;
433
- for (const option of previews) {
434
- pane = Math.max(pane, renderPreviewContent(option.preview ?? "", Math.max(1, previewWidth - 2)).length);
435
- }
436
- body = Math.max(body, listRows(listWidth), pane);
437
- }
469
+ const body = listRows(width);
438
470
  needed = Math.max(needed, chrome + headerRows + Math.max(MIN_BODY_ROWS, body));
439
471
  }
440
472
  if (this.#hasSubmitTab()) {
@@ -499,14 +531,19 @@ export class AskDialogComponent implements Component {
499
531
  }
500
532
 
501
533
  #footerHintText(indicator: string): string {
502
- const scroll = indicator ? ` ${indicator} scroll ·` : "";
534
+ const cancel = `${cancelKeyLabel()} cancel`;
503
535
  if (this.#isSubmitTab()) {
504
- return `Enter submit · ↑/↓ scroll ·${scroll} Esc cancel`;
536
+ const scroll = indicator ? ` ${indicator} scroll ·` : "";
537
+ return `Enter submit · ↑/↓ scroll ·${scroll} ${cancel}`;
505
538
  }
506
539
  const question = this.questions[this.#currentQuestionIndex()];
507
540
  const action = question?.multi ? "Space/Enter toggle · n note" : "Enter select · n note";
508
- const tabs = this.#hasSubmitTab() ? " · Tab/←/→ tabs" : "";
509
- return `${action} · ↑/↓ move${tabs} ·${scroll} Esc cancel`;
541
+ const tabs = this.#hasSubmitTab() ? " · Tab/←/→" : "";
542
+ if (this.#questionCanPage && indicator) {
543
+ return `${action} · ↑/↓${tabs} · ${cancel} · ${pageKeysLabel()} ${indicator}`;
544
+ }
545
+ const scroll = indicator ? ` ${indicator} scroll ·` : "";
546
+ return `${action} · ↑/↓ move${tabs} ·${scroll} ${cancel}`;
510
547
  }
511
548
 
512
549
  #questionRows(question: ExtensionAskDialogQuestion): QuestionRow[] {
@@ -536,13 +573,27 @@ export class AskDialogComponent implements Component {
536
573
  if (!active) return;
537
574
  const { question, state } = active;
538
575
  const rows = this.#questionRows(question);
576
+ if (matchesSelectPageUp(keyData)) {
577
+ state.scrollOffset = Math.max(0, state.scrollOffset - Math.max(1, this.#bodyRows - 1));
578
+ state.manualScroll = true;
579
+ this.#requestRender();
580
+ return;
581
+ }
582
+ if (matchesSelectPageDown(keyData)) {
583
+ state.scrollOffset += Math.max(1, this.#bodyRows - 1);
584
+ state.manualScroll = true;
585
+ this.#requestRender();
586
+ return;
587
+ }
539
588
  if (matchesSelectUp(keyData)) {
540
589
  state.cursorIndex = clamp(state.cursorIndex - 1, 0, Math.max(0, rows.length - 1));
590
+ state.manualScroll = false;
541
591
  this.#requestRender();
542
592
  return;
543
593
  }
544
594
  if (matchesSelectDown(keyData)) {
545
595
  state.cursorIndex = clamp(state.cursorIndex + 1, 0, Math.max(0, rows.length - 1));
596
+ state.manualScroll = false;
546
597
  this.#requestRender();
547
598
  return;
548
599
  }
@@ -672,33 +723,7 @@ export class AskDialogComponent implements Component {
672
723
  const { question, state } = active;
673
724
  const rowItems = this.#questionRows(question);
674
725
  state.cursorIndex = clamp(state.cursorIndex, 0, Math.max(0, rowItems.length - 1));
675
- const selectedRow = rowItems[state.cursorIndex];
676
- const preview =
677
- selectedRow?.kind === "option" ? question.options[selectedRow.optionIndex ?? -1]?.preview : undefined;
678
- // The preview pane exists only while the highlighted option carries a
679
- // preview; otherwise the list takes the full dialog width.
680
- if (!preview?.trim()) return this.#renderQuestionList(question, state, rowItems, width, maxRows);
681
- const sideBySide = width >= SIDE_BY_SIDE_LIST_MIN_WIDTH + PREVIEW_MIN_WIDTH + SIDE_BY_SIDE_GAP_WIDTH;
682
- if (sideBySide) {
683
- const previewWidth = Math.max(PREVIEW_MIN_WIDTH, Math.floor(width * 0.45));
684
- const listWidth = Math.max(1, width - previewWidth - SIDE_BY_SIDE_GAP_WIDTH);
685
- const list = this.#renderQuestionList(question, state, rowItems, listWidth, maxRows);
686
- const previewLines = this.#renderPreviewPane(preview, previewWidth, maxRows);
687
- const lines: string[] = [];
688
- for (let index = 0; index < maxRows; index++) {
689
- const left = truncateToWidth(list.lines[index] ?? "", listWidth, Ellipsis.Unicode);
690
- const right = truncateToWidth(previewLines[index] ?? "", previewWidth, Ellipsis.Unicode);
691
- const gap = padding(Math.max(1, listWidth - visibleWidth(left)) + 1);
692
- lines.push(`${left}${gap}${theme.fg("border", "│")} ${right}`);
693
- }
694
- return { lines, scrollOffset: list.scrollOffset, indicator: list.indicator };
695
- }
696
- const previewLines = this.#renderPreviewPane(preview, width, Math.max(3, Math.min(8, Math.floor(maxRows * 0.4))));
697
- const listRows = Math.max(3, maxRows - previewLines.length - 1);
698
- const list = this.#renderQuestionList(question, state, rowItems, width, listRows);
699
- const lines = [...list.lines, theme.fg("border", "─".repeat(Math.max(1, width))), ...previewLines];
700
- while (lines.length < maxRows) lines.push("");
701
- return { lines: lines.slice(0, maxRows), scrollOffset: list.scrollOffset, indicator: list.indicator };
726
+ return this.#renderQuestionList(question, state, rowItems, width, maxRows);
702
727
  }
703
728
 
704
729
  #renderQuestionList(
@@ -709,16 +734,51 @@ export class AskDialogComponent implements Component {
709
734
  rows: number,
710
735
  ): RenderedList {
711
736
  const mdTheme = getMarkdownTheme();
712
- const allLines: string[] = [];
713
- const lineStartByRow: number[] = [];
714
- for (let index = 0; index < rowItems.length; index++) {
715
- lineStartByRow.push(allLines.length);
716
- const rowItem = rowItems[index];
717
- if (!rowItem) continue;
718
- allLines.push(...renderRowLabel(rowItem, question, state, index === state.cursorIndex, mdTheme, width));
737
+ const renderRows = (contentWidth: number): { allLines: string[]; lineStartByRow: number[] } => {
738
+ const allLines: string[] = [];
739
+ const lineStartByRow: number[] = [];
740
+ for (let index = 0; index < rowItems.length; index++) {
741
+ lineStartByRow.push(allLines.length);
742
+ const rowItem = rowItems[index];
743
+ if (!rowItem) continue;
744
+ allLines.push(
745
+ ...renderRowLabel(
746
+ rowItem,
747
+ question,
748
+ state,
749
+ index === state.cursorIndex,
750
+ mdTheme,
751
+ this.#previewCache,
752
+ contentWidth,
753
+ ),
754
+ );
755
+ }
756
+ return { allLines, lineStartByRow };
757
+ };
758
+ const layoutKey = `${width}:${rows}:${state.customInput === undefined ? 0 : 1}`;
759
+ let overflowLayouts = this.#overflowLayouts.get(question);
760
+ const knownOverflow = overflowLayouts?.has(layoutKey) ?? false;
761
+ let renderedRows = renderRows(knownOverflow && width > 1 ? width - 1 : width);
762
+ if (!knownOverflow && width > 1 && renderedRows.allLines.length > rows) {
763
+ if (!overflowLayouts) {
764
+ overflowLayouts = new Set();
765
+ this.#overflowLayouts.set(question, overflowLayouts);
766
+ }
767
+ overflowLayouts.add(layoutKey);
768
+ renderedRows = renderRows(width - 1);
719
769
  }
770
+ const { allLines, lineStartByRow } = renderedRows;
720
771
  const cursorStart = lineStartByRow[state.cursorIndex] ?? 0;
721
- state.scrollOffset = this.#scrollOffsetForCursor(state.scrollOffset, cursorStart, rows, allLines.length);
772
+ const cursorEnd = lineStartByRow[state.cursorIndex + 1] ?? allLines.length;
773
+ this.#questionCanPage = cursorEnd - cursorStart > rows;
774
+ state.scrollOffset = this.#scrollOffsetForCursor(
775
+ state.scrollOffset,
776
+ cursorStart,
777
+ cursorEnd,
778
+ rows,
779
+ allLines.length,
780
+ state.manualScroll,
781
+ );
722
782
  const scrollView = new ScrollView(allLines, {
723
783
  height: rows,
724
784
  scrollbar: "auto",
@@ -734,15 +794,6 @@ export class AskDialogComponent implements Component {
734
794
  };
735
795
  }
736
796
 
737
- #renderPreviewPane(preview: string, width: number, maxRows: number): string[] {
738
- const bodyWidth = Math.max(1, width - 2);
739
- const content = renderPreviewContent(preview, bodyWidth);
740
- if (content.length <= maxRows) return content;
741
- const visibleCount = Math.max(1, maxRows - 1);
742
- const hidden = content.length - visibleCount;
743
- return [...content.slice(0, visibleCount), theme.fg("dim", `… ${hidden} more lines`)];
744
- }
745
-
746
797
  #renderSubmitBody(width: number, rows: number): RenderedList {
747
798
  const allLines: string[] = [];
748
799
  const unanswered = this.#unansweredCount();
@@ -789,12 +840,25 @@ export class AskDialogComponent implements Component {
789
840
  };
790
841
  }
791
842
 
792
- #scrollOffsetForCursor(currentOffset: number, cursorLine: number, rows: number, totalRows: number): number {
793
- if (totalRows <= rows) return 0;
794
- let nextOffset = clamp(currentOffset, 0, Math.max(0, totalRows - rows));
795
- if (cursorLine < nextOffset) nextOffset = cursorLine;
796
- if (cursorLine >= nextOffset + rows) nextOffset = cursorLine - rows + 1;
797
- return clamp(nextOffset, 0, Math.max(0, totalRows - rows));
843
+ #scrollOffsetForCursor(
844
+ currentOffset: number,
845
+ cursorStart: number,
846
+ cursorEnd: number,
847
+ rows: number,
848
+ totalRows: number,
849
+ manualScroll: boolean,
850
+ ): number {
851
+ const maxOffset = Math.max(0, totalRows - rows);
852
+ if (maxOffset === 0) return 0;
853
+ let nextOffset = clamp(currentOffset, 0, maxOffset);
854
+ const cursorRows = cursorEnd - cursorStart;
855
+ if (manualScroll && cursorRows > rows) {
856
+ // A page must not expose another option while Enter still targets this one.
857
+ nextOffset = clamp(nextOffset, cursorStart, cursorEnd - rows);
858
+ } else if (cursorStart < nextOffset || cursorEnd > nextOffset + rows) {
859
+ nextOffset = cursorRows <= rows ? cursorEnd - rows : cursorStart;
860
+ }
861
+ return clamp(nextOffset, 0, maxOffset);
798
862
  }
799
863
 
800
864
  #clipIndicator(offset: number, rows: number, totalRows: number): string {
@@ -273,7 +273,7 @@ export class StatusLineComponent implements Component {
273
273
  */
274
274
  #activeMeters: WeakMap<AgentSession, ActiveMeter> = new WeakMap();
275
275
  #planModeStatus: { enabled: boolean; paused: boolean } | null = null;
276
- #loopModeStatus: { enabled: boolean } | null = null;
276
+ #loopModeStatus: SegmentContext["loopMode"] = null;
277
277
  #goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
278
278
  #vibeModeStatus: { enabled: boolean } | null = null;
279
279
  #collabStatus: CollabStatus | null = null;
@@ -496,7 +496,7 @@ export class StatusLineComponent implements Component {
496
496
  this.#planModeStatus = status ?? null;
497
497
  }
498
498
 
499
- setLoopModeStatus(status: { enabled: boolean } | undefined): void {
499
+ setLoopModeStatus(status: NonNullable<SegmentContext["loopMode"]> | undefined): void {
500
500
  this.#loopModeStatus = status ?? null;
501
501
  }
502
502
 
@@ -210,6 +210,19 @@ function renderGoalMode(ctx: SegmentContext, mode: { enabled: boolean; paused: b
210
210
  return { content: theme.fg(color, parts.join(" ")), visible: true };
211
211
  }
212
212
 
213
+ function formatLoopLimit(limit: NonNullable<SegmentContext["loopMode"]>["limit"]): string | undefined {
214
+ if (!limit) return undefined;
215
+ if (limit.kind === "iterations") return `${limit.remaining}/${limit.initial}`;
216
+
217
+ const totalSeconds = Math.max(0, Math.ceil((limit.deadlineMs - Date.now()) / 1_000));
218
+ const hours = Math.floor(totalSeconds / 3_600);
219
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
220
+ const seconds = totalSeconds % 60;
221
+ if (hours > 0) return `${hours}h${minutes > 0 ? `${minutes}m` : ""} left`;
222
+ if (minutes > 0) return `${minutes}m${seconds > 0 ? `${seconds}s` : ""} left`;
223
+ return `${seconds}s left`;
224
+ }
225
+
213
226
  const modeSegment: StatusLineSegment = {
214
227
  id: "mode",
215
228
  render(ctx) {
@@ -241,9 +254,13 @@ const modeSegment: StatusLineSegment = {
241
254
  }
242
255
 
243
256
  const loop = ctx.loopMode;
244
- if (loop?.enabled) {
245
- const content = withIcon(theme.icon.loop, "Loop");
246
- return { content: theme.fg("customMessageLabel", content), visible: true };
257
+ if (loop) {
258
+ const icon = loop.state === "paused" ? theme.icon.pause || theme.icon.loop : theme.icon.loop;
259
+ const color: ThemeColor = loop.state === "paused" ? "warning" : "customMessageLabel";
260
+ const parts = [withIcon(icon, `Loop ${loop.state}`)];
261
+ const limit = formatLoopLimit(loop.limit);
262
+ if (limit) parts.push(limit);
263
+ return { content: theme.fg(color, parts.join(" ")), visible: true };
247
264
  }
248
265
 
249
266
  return { content: "", visible: false };
@@ -2,6 +2,7 @@ import type { CollabSessionState } from "../../../collab/protocol";
2
2
  import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../../config/settings-schema";
3
3
  import type { AgentSession } from "../../../session/agent-session";
4
4
  import type { ActiveRepoContext } from "../../../utils/active-repo-context";
5
+ import type { LoopLimitRuntime } from "../../loop-limit";
5
6
 
6
7
  export type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle };
7
8
 
@@ -64,7 +65,8 @@ export interface SegmentContext {
64
65
  enabled: boolean;
65
66
  } | null;
66
67
  loopMode: {
67
- enabled: boolean;
68
+ state: "waiting" | "running" | "paused";
69
+ limit?: LoopLimitRuntime;
68
70
  } | null;
69
71
  goalMode: {
70
72
  enabled: boolean;
@@ -782,6 +782,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
782
782
  return this.isTranscriptBlockFinalized() ? undefined : 0;
783
783
  }
784
784
 
785
+ /** Keeps the in-flight `vibe_wait` TV wall out of immutable native scrollback. */
786
+ isNativeScrollbackLiveRegionPinned(): boolean {
787
+ return this.#toolName === "vibe_wait" && !this.isTranscriptBlockFinalized();
788
+ }
789
+
785
790
  /**
786
791
  * Whether this block has reached a terminal state for transcript freezing.
787
792
  * Reports `false` while it can still visually change so the