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

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 (143) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/dist/cli.js +3616 -3676
  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/exec/bash-executor.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  17. package/dist/types/launch/spawn-options.d.ts +10 -0
  18. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  19. package/dist/types/modes/components/status-line/component.d.ts +10 -3
  20. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  21. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  22. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  23. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  24. package/dist/types/modes/components/usage-row.d.ts +1 -1
  25. package/dist/types/modes/interactive-mode.d.ts +2 -0
  26. package/dist/types/modes/print-mode.d.ts +4 -0
  27. package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
  28. package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
  29. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  30. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  31. package/dist/types/modes/setup-wizard/index.d.ts +1 -1
  32. package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
  33. package/dist/types/modes/types.d.ts +2 -0
  34. package/dist/types/sdk.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +42 -6
  36. package/dist/types/session/messages.d.ts +6 -0
  37. package/dist/types/session/session-paths.d.ts +21 -4
  38. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  39. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  40. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  41. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  42. package/dist/types/tools/gh.d.ts +5 -3
  43. package/dist/types/tools/hub/index.d.ts +1 -1
  44. package/dist/types/tools/hub/jobs.d.ts +1 -0
  45. package/dist/types/tools/hub/types.d.ts +2 -0
  46. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  47. package/dist/types/tools/xdev.d.ts +5 -3
  48. package/dist/types/utils/git.d.ts +33 -0
  49. package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
  50. package/dist/types/vibe/runtime.d.ts +23 -0
  51. package/dist/types/web/search/providers/codex.d.ts +1 -1
  52. package/package.json +12 -12
  53. package/src/advisor/__tests__/advisor.test.ts +150 -0
  54. package/src/advisor/advise-tool.ts +4 -0
  55. package/src/advisor/runtime.ts +38 -14
  56. package/src/async/job-manager.ts +3 -0
  57. package/src/cli/bench-cli.ts +8 -2
  58. package/src/cli/dry-balance-cli.ts +1 -0
  59. package/src/config/model-registry.ts +89 -8
  60. package/src/config/model-resolver.ts +78 -14
  61. package/src/config/models-config-schema.ts +1 -0
  62. package/src/config/settings.ts +3 -1
  63. package/src/dap/client.ts +168 -1
  64. package/src/dap/config.ts +51 -1
  65. package/src/dap/session.ts +575 -234
  66. package/src/dap/types.ts +6 -5
  67. package/src/discovery/agents.ts +2 -2
  68. package/src/exec/bash-executor.ts +68 -8
  69. package/src/extensibility/extensions/load-errors.ts +13 -0
  70. package/src/extensibility/extensions/runner.ts +6 -4
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  72. package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
  73. package/src/launch/broker.ts +34 -31
  74. package/src/launch/client.ts +7 -1
  75. package/src/launch/spawn-options.test.ts +31 -0
  76. package/src/launch/spawn-options.ts +17 -0
  77. package/src/lsp/types.ts +5 -1
  78. package/src/main.ts +25 -4
  79. package/src/mcp/transports/stdio.test.ts +9 -1
  80. package/src/modes/components/ask-dialog.ts +137 -73
  81. package/src/modes/components/chat-transcript-builder.ts +10 -1
  82. package/src/modes/components/status-line/component.ts +57 -3
  83. package/src/modes/components/status-line/segments.ts +20 -3
  84. package/src/modes/components/status-line/types.ts +3 -1
  85. package/src/modes/components/tool-execution.ts +5 -0
  86. package/src/modes/components/transcript-container.ts +23 -122
  87. package/src/modes/components/tree-selector.ts +10 -4
  88. package/src/modes/components/usage-row.ts +17 -1
  89. package/src/modes/controllers/command-controller.ts +41 -1
  90. package/src/modes/controllers/event-controller.ts +7 -3
  91. package/src/modes/controllers/input-controller.ts +1 -1
  92. package/src/modes/controllers/selector-controller.ts +29 -8
  93. package/src/modes/interactive-mode.ts +102 -60
  94. package/src/modes/noninteractive-dispose.test.ts +14 -1
  95. package/src/modes/print-mode.ts +81 -9
  96. package/src/modes/rpc/rpc-client.ts +76 -35
  97. package/src/modes/rpc/rpc-frame.ts +156 -0
  98. package/src/modes/rpc/rpc-input.ts +38 -0
  99. package/src/modes/rpc/rpc-mode.ts +11 -4
  100. package/src/modes/setup-wizard/index.ts +2 -0
  101. package/src/modes/setup-wizard/scenes/model.ts +134 -0
  102. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  103. package/src/modes/types.ts +2 -0
  104. package/src/modes/utils/ui-helpers.ts +9 -3
  105. package/src/prompts/system/workflow-notice.md +89 -74
  106. package/src/prompts/tools/browser.md +3 -2
  107. package/src/prompts/tools/debug.md +2 -7
  108. package/src/prompts/tools/github.md +6 -1
  109. package/src/prompts/tools/hub.md +4 -2
  110. package/src/prompts/tools/task-async-contract.md +1 -0
  111. package/src/prompts/tools/task.md +9 -2
  112. package/src/sdk.ts +38 -6
  113. package/src/session/agent-session.ts +484 -188
  114. package/src/session/messages.test.ts +91 -0
  115. package/src/session/messages.ts +248 -110
  116. package/src/session/session-manager.ts +34 -3
  117. package/src/session/session-paths.ts +38 -9
  118. package/src/slash-commands/builtin-registry.ts +1 -0
  119. package/src/task/executor.ts +104 -33
  120. package/src/task/index.ts +46 -9
  121. package/src/task/worktree.ts +10 -0
  122. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  123. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  124. package/src/tools/browser/cmux/socket-client.ts +139 -3
  125. package/src/tools/browser/run-cancellation.ts +4 -0
  126. package/src/tools/browser/tab-protocol.ts +2 -0
  127. package/src/tools/browser/tab-supervisor.ts +21 -11
  128. package/src/tools/browser/tab-worker.ts +199 -33
  129. package/src/tools/debug.ts +3 -0
  130. package/src/tools/gh.ts +40 -2
  131. package/src/tools/hub/index.ts +4 -1
  132. package/src/tools/hub/jobs.ts +42 -1
  133. package/src/tools/hub/types.ts +2 -0
  134. package/src/tools/tool-timeouts.ts +1 -1
  135. package/src/tools/xdev.ts +11 -4
  136. package/src/tools/yield.ts +4 -1
  137. package/src/utils/git.ts +237 -0
  138. package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
  139. package/src/vibe/__tests__/token-rate.test.ts +96 -0
  140. package/src/vibe/runtime.ts +50 -0
  141. package/src/web/search/index.ts +9 -5
  142. package/src/web/search/providers/codex.ts +195 -99
  143. /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
@@ -153,13 +153,21 @@ describe.skipIf(process.platform === "win32")("StdioTransport request write stal
153
153
  }, 8000);
154
154
  });
155
155
 
156
+ // `kill(pid, 0)` succeeds for a zombie too: a grandchild whose parent (the
157
+ // killed leader) is gone sits as <defunct> until whatever reaps orphans
158
+ // (init/subreaper) gets around to it — which can lag on some hosts. A
159
+ // zombie already received and honored the group SIGKILL; it is just not
160
+ // harvested yet, so treating it as "still alive" would make the group-kill
161
+ // assertions below flaky rather than testing what they claim to test.
156
162
  function processExists(pid: number): boolean {
157
163
  try {
158
164
  process.kill(pid, 0);
159
- return true;
160
165
  } catch {
161
166
  return false;
162
167
  }
168
+ const result = Bun.spawnSync(["ps", "-o", "stat=", "-p", String(pid)]);
169
+ const state = result.stdout.toString().trim();
170
+ return result.exitCode === 0 && state.length > 0 && !state.startsWith("Z");
163
171
  }
164
172
 
165
173
  // Regression for #5578: `close()` used a bare `this.#process.kill()` (direct
@@ -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 {
@@ -85,6 +85,7 @@ export class ChatTranscriptBuilder {
85
85
  #pendingUsage: Usage | undefined;
86
86
  #pendingUsageDuration: number | undefined;
87
87
  #pendingUsageTtft: number | undefined;
88
+ #pendingUsageTimestamp: number | undefined;
88
89
  #lastAssistantUsage: Usage | undefined;
89
90
  #waitingPoll: ToolExecutionComponent | null = null;
90
91
  #todoSnapshot: ToolExecutionComponent | null = null;
@@ -133,6 +134,7 @@ export class ChatTranscriptBuilder {
133
134
  this.#pendingUsage = undefined;
134
135
  this.#pendingUsageDuration = undefined;
135
136
  this.#pendingUsageTtft = undefined;
137
+ this.#pendingUsageTimestamp = undefined;
136
138
  this.#lastAssistantUsage = undefined;
137
139
  this.#waitingPoll = null;
138
140
  this.#todoSnapshot = null;
@@ -201,11 +203,17 @@ export class ChatTranscriptBuilder {
201
203
  this.#readGroup?.seal();
202
204
  this.#readGroup = null;
203
205
  this.container.addChild(
204
- createUsageRowBlock(this.#pendingUsage, this.#pendingUsageDuration, this.#pendingUsageTtft),
206
+ createUsageRowBlock(
207
+ this.#pendingUsage,
208
+ this.#pendingUsageDuration,
209
+ this.#pendingUsageTtft,
210
+ this.#pendingUsageTimestamp,
211
+ ),
205
212
  );
206
213
  this.#pendingUsage = undefined;
207
214
  this.#pendingUsageDuration = undefined;
208
215
  this.#pendingUsageTtft = undefined;
216
+ this.#pendingUsageTimestamp = undefined;
209
217
  }
210
218
 
211
219
  #appendChatMessage(message: AgentMessage): void {
@@ -381,6 +389,7 @@ export class ChatTranscriptBuilder {
381
389
  settings.get("display.showTokenUsage") && assistantUsageIsBilled(message.usage) ? message.usage : undefined;
382
390
  this.#pendingUsageDuration = message.duration;
383
391
  this.#pendingUsageTtft = message.ttft;
392
+ this.#pendingUsageTimestamp = message.timestamp;
384
393
  }
385
394
 
386
395
  #appendToolResult(message: Extract<AgentMessage, { role: "toolResult" }>): void {
@@ -11,13 +11,13 @@ import { limitMatchesActiveAccount } from "../../../slash-commands/helpers/activ
11
11
  import { type ActiveRepoContext, resolveActiveRepoContextSync } from "../../../utils/active-repo-context";
12
12
  import * as git from "../../../utils/git";
13
13
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
14
+ import { calculateTokensPerSecond } from "../../../utils/token-rate";
14
15
  import { sanitizeStatusText } from "../../shared";
15
16
  import { theme } from "../../theme/theme";
16
17
  import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext } from "./git-utils";
17
18
  import { getPreset } from "./presets";
18
19
  import { renderSegment, type SegmentContext } from "./segments";
19
20
  import { getSeparator } from "./separators";
20
- import { calculateTokensPerSecond } from "./token-rate";
21
21
  import type {
22
22
  CollabStatus,
23
23
  EffectiveStatusLineSettings,
@@ -273,9 +273,16 @@ 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
+ /**
280
+ * Injected aggregator that returns the aggregate tok/s of this session's
281
+ * live vibe worker sessions, or null when no workers are streaming. Kept as
282
+ * a callback so the render layer doesn't import the heavy vibe/task
283
+ * dependency graph; interactive-mode wires it to VibeSessionRegistry.
284
+ */
285
+ #vibeWorkerTokenRate: (() => number | null) | null = null;
279
286
  #collabStatus: CollabStatus | null = null;
280
287
  #focusedAgentId: string | undefined;
281
288
  #activeRepoCache: ActiveRepoCache | undefined;
@@ -496,7 +503,7 @@ export class StatusLineComponent implements Component {
496
503
  this.#planModeStatus = status ?? null;
497
504
  }
498
505
 
499
- setLoopModeStatus(status: { enabled: boolean } | undefined): void {
506
+ setLoopModeStatus(status: NonNullable<SegmentContext["loopMode"]> | undefined): void {
500
507
  this.#loopModeStatus = status ?? null;
501
508
  }
502
509
 
@@ -508,6 +515,17 @@ export class StatusLineComponent implements Component {
508
515
  this.#vibeModeStatus = status ?? null;
509
516
  }
510
517
 
518
+ /**
519
+ * Inject the aggregator that returns the aggregate tok/s of this session's
520
+ * live vibe worker sessions (null when no workers are streaming). Wired by
521
+ * interactive-mode, which owns the VibeSessionRegistry coupling, so the
522
+ * render layer stays off the heavy vibe/task dependency graph. Pass
523
+ * `undefined` to clear.
524
+ */
525
+ setVibeWorkerTokenRateProvider(provider: (() => number | null) | undefined): void {
526
+ this.#vibeWorkerTokenRate = provider ?? null;
527
+ }
528
+
511
529
  setCollabStatus(status: CollabStatus | null): void {
512
530
  this.#collabStatus = status;
513
531
  }
@@ -747,6 +765,31 @@ export class StatusLineComponent implements Component {
747
765
  }
748
766
 
749
767
  #getTokensPerSecond(): number | null {
768
+ // Aggregate tok/s across the main session AND every live vibe worker.
769
+ // In vibe mode the director is often idle while workers stream, so the
770
+ // main session's own rate alone would show a stale/zero value while
771
+ // parallel work is actively generating tokens.
772
+ const workerRate = this.#getVibeWorkerTokensPerSecond();
773
+ if (workerRate !== null) {
774
+ // At least one worker is streaming — add the director's live rate
775
+ // only when it is itself streaming (a finalized last-turn rate would
776
+ // double-count and overstate throughput).
777
+ const mainRate = this.session.isStreaming ? calculateTokensPerSecond(this.session.state.messages, true) : 0;
778
+ return (mainRate ?? 0) + workerRate;
779
+ }
780
+
781
+ // No workers streaming — fall back to the main session's own rate with
782
+ // its sticky per-assistant-message cache so the badge doesn't flicker
783
+ // off in the brief gap between stream end and the finalized message.
784
+ return this.#getMainSessionTokensPerSecond();
785
+ }
786
+
787
+ /**
788
+ * Main session's tok/s with sticky caching keyed on the last assistant
789
+ * message timestamp. Preserves the pre-aggregation behavior when no vibe
790
+ * workers are active.
791
+ */
792
+ #getMainSessionTokensPerSecond(): number | null {
750
793
  let lastAssistantTimestamp: number | null = null;
751
794
  for (let i = this.session.state.messages.length - 1; i >= 0; i--) {
752
795
  const message = this.session.state.messages[i];
@@ -776,6 +819,17 @@ export class StatusLineComponent implements Component {
776
819
  return null;
777
820
  }
778
821
 
822
+ /**
823
+ * Aggregate tok/s across every live vibe worker session owned by this
824
+ * session. Returns null when no workers are streaming (so the main
825
+ * session's own rate shines through unchanged). The aggregation itself is
826
+ * injected via {@link setVibeWorkerTokenRateProvider} to keep this render
827
+ * layer off the heavy vibe/task dependency graph.
828
+ */
829
+ #getVibeWorkerTokensPerSecond(): number | null {
830
+ return this.#vibeWorkerTokenRate?.() ?? null;
831
+ }
832
+
779
833
  #getUsageContextKey(session: AgentSession): string {
780
834
  const activeProvider = session.state.model?.provider ?? session.model?.provider ?? "";
781
835
  if (!activeProvider) return "";
@@ -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