@oh-my-pi/pi-coding-agent 15.11.3 → 15.11.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 (135) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/dist/cli.js +692 -607
  3. package/dist/types/cli/usage-cli.d.ts +10 -1
  4. package/dist/types/commands/usage.d.ts +9 -0
  5. package/dist/types/config/api-key-resolver.d.ts +9 -3
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/model-discovery.d.ts +6 -4
  8. package/dist/types/config/model-registry.d.ts +7 -4
  9. package/dist/types/config/settings-schema.d.ts +508 -155
  10. package/dist/types/export/html/template.generated.d.ts +1 -1
  11. package/dist/types/mnemopi/config.d.ts +3 -1
  12. package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/components/settings-defs.d.ts +9 -2
  15. package/dist/types/modes/components/settings-selector.d.ts +9 -4
  16. package/dist/types/modes/components/tool-execution.d.ts +26 -1
  17. package/dist/types/modes/components/transcript-container.d.ts +12 -0
  18. package/dist/types/modes/controllers/input-controller.d.ts +9 -1
  19. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  20. package/dist/types/modes/interactive-mode.d.ts +10 -0
  21. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  22. package/dist/types/modes/theme/theme.d.ts +23 -3
  23. package/dist/types/modes/types.d.ts +2 -0
  24. package/dist/types/modes/utils/context-usage.d.ts +6 -1
  25. package/dist/types/session/agent-session.d.ts +28 -8
  26. package/dist/types/session/auth-storage.d.ts +1 -1
  27. package/dist/types/session/codex-auto-reset.d.ts +107 -0
  28. package/dist/types/session/snapcompact-inline.d.ts +129 -0
  29. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
  30. package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
  31. package/dist/types/system-prompt.d.ts +3 -1
  32. package/dist/types/task/render.d.ts +17 -6
  33. package/dist/types/tools/gh.d.ts +3 -0
  34. package/dist/types/tools/render-utils.d.ts +8 -16
  35. package/dist/types/tools/todo.d.ts +0 -11
  36. package/dist/types/utils/session-color.d.ts +15 -3
  37. package/dist/types/web/kagi.d.ts +1 -2
  38. package/dist/types/web/search/providers/codex.d.ts +1 -1
  39. package/dist/types/web/search/providers/gemini.d.ts +9 -6
  40. package/package.json +11 -11
  41. package/src/auto-thinking/classifier.ts +1 -5
  42. package/src/cli/usage-cli.ts +187 -16
  43. package/src/commands/usage.ts +8 -0
  44. package/src/commit/model-selection.ts +3 -6
  45. package/src/config/api-key-resolver.ts +10 -3
  46. package/src/config/keybindings.ts +1 -1
  47. package/src/config/model-discovery.ts +60 -46
  48. package/src/config/model-registry.ts +21 -8
  49. package/src/config/model-resolver.ts +57 -3
  50. package/src/config/settings-schema.ts +654 -153
  51. package/src/config/settings.ts +9 -0
  52. package/src/eval/completion-bridge.ts +1 -5
  53. package/src/export/html/template.generated.ts +1 -1
  54. package/src/export/html/template.js +13 -6
  55. package/src/internal-urls/docs-index.generated.ts +6 -6
  56. package/src/internal-urls/issue-pr-protocol.ts +10 -4
  57. package/src/memories/index.ts +2 -10
  58. package/src/mnemopi/backend.ts +30 -8
  59. package/src/mnemopi/config.ts +6 -1
  60. package/src/mnemopi/state.ts +6 -0
  61. package/src/modes/components/extensions/inspector-panel.ts +6 -2
  62. package/src/modes/components/plan-review-overlay.ts +15 -17
  63. package/src/modes/components/plugin-settings.ts +22 -5
  64. package/src/modes/components/reset-usage-selector.ts +161 -0
  65. package/src/modes/components/session-selector.ts +8 -2
  66. package/src/modes/components/settings-defs.ts +19 -4
  67. package/src/modes/components/settings-selector.ts +510 -95
  68. package/src/modes/components/status-line/component.ts +3 -1
  69. package/src/modes/components/status-line/segments.ts +3 -1
  70. package/src/modes/components/tool-execution.ts +87 -12
  71. package/src/modes/components/transcript-container.ts +49 -1
  72. package/src/modes/components/tree-selector.ts +16 -6
  73. package/src/modes/controllers/command-controller.ts +61 -8
  74. package/src/modes/controllers/event-controller.ts +1 -0
  75. package/src/modes/controllers/input-controller.ts +68 -6
  76. package/src/modes/controllers/selector-controller.ts +149 -61
  77. package/src/modes/interactive-mode.ts +63 -2
  78. package/src/modes/rpc/rpc-mode.ts +2 -1
  79. package/src/modes/session-observer-registry.ts +61 -3
  80. package/src/modes/shared.ts +2 -0
  81. package/src/modes/theme/theme.ts +102 -9
  82. package/src/modes/types.ts +2 -0
  83. package/src/modes/utils/context-usage.ts +78 -2
  84. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  85. package/src/modes/utils/ui-helpers.ts +9 -5
  86. package/src/prompts/system/personalities/default.md +26 -0
  87. package/src/prompts/system/personalities/friendly.md +17 -0
  88. package/src/prompts/system/personalities/pragmatic.md +15 -0
  89. package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
  90. package/src/prompts/system/snapcompact-context-stub.md +1 -0
  91. package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
  92. package/src/prompts/system/snapcompact-system-stub.md +1 -0
  93. package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
  94. package/src/prompts/system/system-prompt.md +5 -22
  95. package/src/prompts/tools/browser.md +33 -43
  96. package/src/prompts/tools/eval.md +27 -50
  97. package/src/prompts/tools/irc.md +29 -31
  98. package/src/prompts/tools/read.md +31 -37
  99. package/src/prompts/tools/task.md +3 -3
  100. package/src/prompts/tools/todo.md +1 -2
  101. package/src/sdk.ts +23 -1
  102. package/src/session/agent-session.ts +221 -29
  103. package/src/session/auth-storage.ts +4 -0
  104. package/src/session/codex-auto-reset.ts +190 -0
  105. package/src/session/session-dump-format.ts +8 -1
  106. package/src/session/session-manager.ts +5 -5
  107. package/src/session/snapcompact-inline.ts +524 -0
  108. package/src/slash-commands/builtin-registry.ts +145 -8
  109. package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
  110. package/src/slash-commands/helpers/context-report.ts +28 -1
  111. package/src/slash-commands/helpers/reset-usage.ts +66 -0
  112. package/src/slash-commands/helpers/usage-report.ts +36 -3
  113. package/src/system-prompt.ts +15 -1
  114. package/src/task/index.ts +30 -7
  115. package/src/task/render.ts +57 -32
  116. package/src/tool-discovery/tool-index.ts +2 -0
  117. package/src/tools/bash.ts +10 -3
  118. package/src/tools/eval-render.ts +13 -8
  119. package/src/tools/gh.ts +39 -1
  120. package/src/tools/image-gen.ts +114 -78
  121. package/src/tools/inspect-image.ts +1 -5
  122. package/src/tools/job.ts +25 -5
  123. package/src/tools/read.ts +1 -57
  124. package/src/tools/render-utils.ts +29 -31
  125. package/src/tools/ssh.ts +3 -3
  126. package/src/tools/todo.ts +8 -128
  127. package/src/tools/tts.ts +40 -20
  128. package/src/utils/clipboard.ts +56 -4
  129. package/src/utils/commit-message-generator.ts +1 -5
  130. package/src/utils/session-color.ts +83 -9
  131. package/src/utils/title-generator.ts +1 -1
  132. package/src/web/kagi.ts +26 -27
  133. package/src/web/search/providers/codex.ts +42 -40
  134. package/src/web/search/providers/gemini.ts +42 -22
  135. package/src/web/search/providers/perplexity.ts +22 -10
@@ -849,7 +849,9 @@ export class StatusLineComponent implements Component {
849
849
  const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
850
850
  const sessionName =
851
851
  effectiveSettings.sessionAccent !== false ? this.session.sessionManager?.getSessionName() : undefined;
852
- const accentHex = sessionName ? getSessionAccentHex(sessionName, theme.accentSurfaceLuminance) : undefined;
852
+ const accentHex = sessionName
853
+ ? getSessionAccentHex(sessionName, theme.getMajorThemeColorHexes(), theme.accentSurfaceLuminance)
854
+ : undefined;
853
855
  const gapColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("border");
854
856
  const gapFill = `${gapColor}${theme.boxRound.horizontal.repeat(gapWidth)}\x1b[39m`;
855
857
  return leftGroup + gapFill + rightGroup;
@@ -486,7 +486,9 @@ const sessionNameSegment: StatusLineSegment = {
486
486
  if (!name) return { content: "", visible: false };
487
487
 
488
488
  const ansi =
489
- getSessionAccentAnsi(getSessionAccentHex(name, theme.accentSurfaceLuminance)) ?? theme.getFgAnsi("accent");
489
+ getSessionAccentAnsi(
490
+ getSessionAccentHex(name, theme.getMajorThemeColorHexes(), theme.accentSurfaceLuminance),
491
+ ) ?? theme.getFgAnsi("accent");
490
492
  return { content: `${ansi}${sanitizeStatusText(name)}\x1b[39m`, visible: true };
491
493
  },
492
494
  };
@@ -111,11 +111,23 @@ function getArgsWithStreamedTextInput(args: unknown): unknown {
111
111
  return input === undefined ? args : { ...record, input };
112
112
  }
113
113
 
114
+ /**
115
+ * Transcript-side probe telling a block whether it is still inside the live
116
+ * (repaintable) region. Implemented by `TranscriptContainer`; injected rather
117
+ * than imported so the component stays decoupled from the transcript.
118
+ */
119
+ export interface TranscriptLiveRegionProbe {
120
+ isBlockInLiveRegion(component: Component): boolean;
121
+ }
122
+
114
123
  export interface ToolExecutionOptions {
115
124
  snapshots?: SnapshotStore;
116
125
  showImages?: boolean; // default: true (only used if terminal supports images)
117
126
  editFuzzyThreshold?: number;
118
127
  editAllowFuzzy?: boolean;
128
+ /** Live-region probe used to settle detached task progress once the block
129
+ * leaves the repaintable transcript region. */
130
+ liveRegion?: TranscriptLiveRegionProbe;
119
131
  }
120
132
 
121
133
  export interface ToolExecutionHandle {
@@ -133,10 +145,9 @@ export interface ToolExecutionHandle {
133
145
  setExpanded(expanded: boolean): void;
134
146
  }
135
147
 
136
- /** Drive pending-tool redraws at 30fps so the running `task` row's shimmered
137
- * subagent name stays smooth without spending twice the frame budget. The TUI
138
- * throttles at the same cadence, and static frames diff to a no-op redraw at
139
- * ~zero cost. */
148
+ /** Drive pending-tool redraws at 30fps for live tool headers and displaceable
149
+ * poll blocks. The TUI throttles at the same cadence, and static frames diff to
150
+ * a no-op redraw at ~zero cost. */
140
151
  const SPINNER_RENDER_INTERVAL_MS = 1000 / 30;
141
152
  /** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
142
153
  * render cadence (mirrors `Loader`). */
@@ -200,6 +211,14 @@ export class ToolExecutionComponent extends Container {
200
211
  // follow-up `job` call can displace it instead of stacking another
201
212
  // "waiting on N jobs" frame. Cleared by `seal()`.
202
213
  #displaceable = false;
214
+ // Probe into the owning transcript (absent outside the interactive
215
+ // transcript, e.g. in tests): whether this block is still repaintable.
216
+ #liveRegion?: TranscriptLiveRegionProbe;
217
+ // One-way latch for a detached (`async.state === "running"`) task block
218
+ // that left the transcript live region: its rows are commit-eligible
219
+ // history, so progress renders static gray and further partial snapshots are
220
+ // dropped (see #maybeFreezeBackgroundTask).
221
+ #backgroundTaskFrozen = false;
203
222
  #renderState: {
204
223
  spinnerFrame?: number;
205
224
  expanded: boolean;
@@ -226,6 +245,7 @@ export class ToolExecutionComponent extends Container {
226
245
  this.#editFuzzyThreshold = options.editFuzzyThreshold;
227
246
  this.#editAllowFuzzy = options.editAllowFuzzy;
228
247
  this.#snapshots = options.snapshots;
248
+ this.#liveRegion = options.liveRegion;
229
249
  this.#tool = tool;
230
250
  this.#ui = ui;
231
251
  this.#cwd = cwd;
@@ -363,6 +383,15 @@ export class ToolExecutionComponent extends Container {
363
383
  isPartial = false,
364
384
  _toolCallId?: string,
365
385
  ): void {
386
+ // A detached task spawn keeps streaming progress snapshots after the
387
+ // block froze (left the transcript live region). Drop them: the rows are
388
+ // static gray history now, and repainting would rewrite rows the engine
389
+ // may already have committed to native scrollback. The terminal snapshot
390
+ // (async completed/failed → isPartial=false) still applies so a block
391
+ // that is still on screen settles on real results.
392
+ if (isPartial && this.#toolName === "task" && this.#maybeFreezeBackgroundTask()) {
393
+ return;
394
+ }
366
395
  this.#result = result;
367
396
  this.#isPartial = isPartial;
368
397
  // A `job` poll that found every watched job still running is transient
@@ -436,10 +465,9 @@ export class ToolExecutionComponent extends Container {
436
465
  (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
437
466
  const isBackgroundAsyncTask = this.#toolName === "task" && isBackgroundAsyncRunning;
438
467
  const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
439
- // A displaceable waiting poll keeps its spinner ticking: it reads as one
440
- // persistent live poll, and the changing leading glyph keeps the
441
- // transcript's stable-prefix ratchet from committing rows of a block
442
- // that a follow-up `job` call may remove.
468
+ // Detached async task progress rows are static now; progress snapshots
469
+ // still call #maybeFreezeBackgroundTask before applying so rows settle
470
+ // once the block leaves the live region.
443
471
  const needsSpinner = isStreamingArgs || isPartialTask || this.isDisplaceableBlock();
444
472
  if (needsSpinner && !this.#spinnerInterval) {
445
473
  const now = performance.now();
@@ -450,12 +478,15 @@ export class ToolExecutionComponent extends Container {
450
478
  this.#renderState.spinnerFrame = 0;
451
479
  }
452
480
  this.#spinnerInterval = setInterval(() => {
481
+ // If a detached task interval from an older render path is still live,
482
+ // stop it the instant the block leaves the repaintable region.
483
+ if (this.#maybeFreezeBackgroundTask()) return;
453
484
  const now = performance.now();
454
485
  const frameCount = theme.spinnerFrames.length;
455
- // Redraw at 30fps for a smooth `task` name shimmer, but keep the spinner
456
- // glyph phase-locked to its classic ~12.5fps cadence. Advancing the
457
- // anchor by elapsed frames instead of resetting to `now` avoids the
458
- // 30fps timer quantizing the glyph down to one step every three ticks.
486
+ // Redraw at 30fps, but keep the spinner glyph phase-locked to its
487
+ // classic ~12.5fps cadence. Advancing the anchor by elapsed frames
488
+ // instead of resetting to `now` avoids the 30fps timer quantizing the
489
+ // glyph down to one step every three ticks.
459
490
  if (frameCount > 0) {
460
491
  const elapsed = now - this.#lastSpinnerAdvanceAt;
461
492
  if (elapsed >= SPINNER_GLYPH_ADVANCE_MS) {
@@ -480,6 +511,26 @@ export class ToolExecutionComponent extends Container {
480
511
  }
481
512
  }
482
513
 
514
+ /**
515
+ * Freeze a detached (`async.state === "running"`) task block once it leaves
516
+ * the transcript's live region. Past that seam its rows are commit-eligible
517
+ * native-scrollback history: repaint the progress rows static gray and drop
518
+ * further partial snapshots. One-way — blocks never re-enter the live
519
+ * region. Returns whether the block is frozen.
520
+ */
521
+ #maybeFreezeBackgroundTask(): boolean {
522
+ if (this.#backgroundTaskFrozen) return true;
523
+ if (this.#toolName !== "task" || this.#liveRegion === undefined) return false;
524
+ const asyncState = (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state;
525
+ if (asyncState !== "running") return false;
526
+ if (this.#liveRegion.isBlockInLiveRegion(this)) return false;
527
+ this.#backgroundTaskFrozen = true;
528
+ this.#updateSpinnerAnimation();
529
+ this.#updateDisplay();
530
+ this.#ui.requestRender();
531
+ return true;
532
+ }
533
+
483
534
  #updateTodoStrikeAnimation(): void {
484
535
  if (this.#toolName !== "todo" || this.#isPartial || this.#result?.isError) {
485
536
  this.#stopTodoStrikeAnimation();
@@ -538,6 +589,24 @@ export class ToolExecutionComponent extends Container {
538
589
  return (this.#result.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
539
590
  }
540
591
 
592
+ /**
593
+ * Whether this still-live block's settled rows may enter native scrollback
594
+ * (see `FinalizableBlock.isTranscriptBlockCommitStable`). A pending
595
+ * collapsed preview is provisional: the tail-window streaming views
596
+ * (edit/bash/eval caps) are re-anchored top-first by the result render, so
597
+ * promoting their visually static head — e.g. an edit preview idling on
598
+ * its last frame while the apply + LSP pass runs — would strand a stale
599
+ * copy of the call box above the final block the moment the result lands.
600
+ * Expanded pending blocks stream top-anchored append-shaped content whose
601
+ * rows the result render preserves byte-stable (the over-tall write/eval
602
+ * scrollback contract), so they stay commit-eligible. Displaceable waiting
603
+ * polls are removed wholesale by the next poll and must never commit.
604
+ */
605
+ isTranscriptBlockCommitStable(): boolean {
606
+ if (this.#displaceable) return false;
607
+ return this.#expanded || this.isTranscriptBlockFinalized();
608
+ }
609
+
541
610
  /**
542
611
  * Mark the tool terminal even though no result arrived (the turn aborted or
543
612
  * abandoned it) and stop animating, so it can freeze and stops pinning the
@@ -547,6 +616,9 @@ export class ToolExecutionComponent extends Container {
547
616
  if (this.#sealed) return;
548
617
  this.#sealed = true;
549
618
  this.#displaceable = false;
619
+ // A sealed detached task is abandoned history: settle its progress rows
620
+ // on static gray.
621
+ this.#backgroundTaskFrozen = true;
550
622
  this.stopAnimation();
551
623
  this.#updateDisplay();
552
624
  this.#ui.requestRender();
@@ -888,6 +960,9 @@ export class ToolExecutionComponent extends Container {
888
960
  // draws every dispatched agent as a progress/result line, so tell
889
961
  // `renderCall` to drop its duplicate streaming preview list.
890
962
  context.hasResult = Boolean(this.#result);
963
+ // Out of the transcript live region: progress rows render static gray
964
+ // (see task/render.ts).
965
+ context.frozen = this.#backgroundTaskFrozen;
891
966
  } else if (isEditLikeToolName(this.#toolName)) {
892
967
  context.editMode = this.#editMode;
893
968
  const previews = this.#editDiffPreview;
@@ -63,6 +63,19 @@ interface FinalizableBlock {
63
63
  * never mutate post-finalize simply omit the method.
64
64
  */
65
65
  getTranscriptBlockVersion?(): number;
66
+ /**
67
+ * Whether a still-live block's visually settled leading rows are durable —
68
+ * guaranteed to survive the block's remaining transitions (finalize,
69
+ * displacement) byte-stable — and may therefore be promoted as commit-safe
70
+ * by {@link deriveLiveCommitState}. Blocks whose pending render is
71
+ * provisional (a tool call's tail-window streaming preview, replaced
72
+ * wholesale by the result render) return `false`: committing such rows
73
+ * strands a stale copy in immutable terminal history the moment the real
74
+ * content re-lays-out the block (the engine audit recommits below it —
75
+ * "duplication, never loss"). Absent = `true`, the default for blocks
76
+ * whose live rows persist (a streaming assistant message).
77
+ */
78
+ isTranscriptBlockCommitStable?(): boolean;
66
79
  }
67
80
 
68
81
  function isBlockFinalized(child: Component): boolean {
@@ -75,6 +88,11 @@ function getBlockVersion(child: Component): number | undefined {
75
88
  return fn ? fn.call(child) : undefined;
76
89
  }
77
90
 
91
+ function isBlockCommitStable(child: Component): boolean {
92
+ const fn = (child as Component & FinalizableBlock).isTranscriptBlockCommitStable;
93
+ return fn ? fn.call(child) : true;
94
+ }
95
+
78
96
  // A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
79
97
  // separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
80
98
  // to a background-colored padding row, whose escape sequences contain `\S` and
@@ -476,6 +494,32 @@ export class TranscriptContainer
476
494
  return false;
477
495
  }
478
496
 
497
+ /**
498
+ * Whether `component` is inside the live (repaintable) region exactly as
499
+ * {@link render} computes it: at/after the first still-mutating block, or
500
+ * the transcript tail when every block has finalized. Unlike
501
+ * {@link isWithinLiveRegion} (strictly below a still-mutating block, i.e.
502
+ * guaranteed-uncommitted), this also counts the trailing block that anchors
503
+ * the live region. Self-animating finalized blocks (a detached task's
504
+ * shimmering progress rows) poll this to stop animating — and settle on
505
+ * static bytes — the moment they sit above the seam, where their rows
506
+ * become commit-eligible native-scrollback history.
507
+ */
508
+ isBlockInLiveRegion(component: Component): boolean {
509
+ const children = this.children;
510
+ const index = children.indexOf(component);
511
+ if (index < 0) return false;
512
+ for (let i = 0; i <= index; i++) {
513
+ if (!isBlockFinalized(children[i]!)) return true;
514
+ }
515
+ // Every block at/before `index` finalized: the live region starts at the
516
+ // first unfinalized block below it, or at the last child when none exists.
517
+ for (let i = index + 1; i < children.length; i++) {
518
+ if (!isBlockFinalized(children[i]!)) return false;
519
+ }
520
+ return index === children.length - 1;
521
+ }
522
+
479
523
  override render(width: number): readonly string[] {
480
524
  width = Math.max(1, width);
481
525
  this.#nativeScrollbackLiveRegionStart = undefined;
@@ -572,7 +616,11 @@ export class TranscriptContainer
572
616
  previous.generation === this.#generation);
573
617
  const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
574
618
  let liveCommitState: LiveCommitState | undefined;
575
- if (i >= liveStartIndex && !finalized) {
619
+ // Provisional live renders (commit-unstable blocks) never feed the
620
+ // promotion machinery: their settled-looking rows are replaced
621
+ // wholesale on finalize, so offering them would commit a stale
622
+ // preview the result render can only duplicate, never erase.
623
+ if (i >= liveStartIndex && !finalized && isBlockCommitStable(child)) {
576
624
  liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
577
625
  }
578
626
  // Cache the latest contribution as the next frame's diff input.
@@ -518,7 +518,16 @@ class TreeList implements Component {
518
518
  const renderedIndent = Math.min(displayIndent, maxIndentLevels);
519
519
  const scrollOffset = displayIndent - renderedIndent;
520
520
  const connectorPositionDisplay = hasConnector ? renderedIndent - 1 : -1;
521
- const chainGutter = !hasConnector ? flatNode.gutters[flatNode.gutters.length - 1] : undefined;
521
+ // Chain rows (no connector of their own) under a last-sibling (`└─`)
522
+ // branch stay anchored by a vertical drawn one level RIGHT of the
523
+ // suppressed gutter — the column where the row's own connector would
524
+ // sit, directly below the branch head's content. Drawing it in the
525
+ // `└─` column itself contradicts the corner and leaves dangling,
526
+ // drifting verticals once the chain branches deeper (#2298, #2325).
527
+ // Chains under `├─` heads need no extra anchor: the sibling line
528
+ // (`show: true` gutter) already ties them to their branch.
529
+ const nearestGutter = !hasConnector ? flatNode.gutters[flatNode.gutters.length - 1] : undefined;
530
+ const chainAnchorLevel = nearestGutter && !nearestGutter.show ? nearestGutter.position + 1 : -1;
522
531
 
523
532
  // Build prefix char by char, placing gutters and connector at their positions
524
533
  const totalChars = renderedIndent * 3;
@@ -531,15 +540,16 @@ class TreeList implements Component {
531
540
  // Check if there's a gutter at this level (translated to original tree depth)
532
541
  const gutter = flatNode.gutters.find(g => g.position === originalLevel);
533
542
  if (gutter) {
534
- // Chain rows (no connector of their own) extend only their
535
- // nearest connector gutter so the flattened conversation flow
536
- // stays anchored without reviving unrelated `└─` ancestors (#2298).
537
- const showVertical = gutter.show || gutter === chainGutter;
543
+ // Gutters follow standard tree semantics: `│` only while more
544
+ // siblings continue below (`show`), space below a `└─`.
538
545
  if (posInLevel === 0) {
539
- prefixChars.push(showVertical ? theme.tree.vertical : " ");
546
+ prefixChars.push(gutter.show ? theme.tree.vertical : " ");
540
547
  } else {
541
548
  prefixChars.push(" ");
542
549
  }
550
+ } else if (originalLevel === chainAnchorLevel) {
551
+ // Chain anchor for rows under a `└─` branch head.
552
+ prefixChars.push(posInLevel === 0 ? theme.tree.vertical : " ");
543
553
  } else if (hasConnector && level === connectorPositionDisplay) {
544
554
  // Connector at this level
545
555
  if (posInLevel === 0) {
@@ -36,9 +36,10 @@ import { computeContextBreakdown, renderContextUsage } from "../../modes/utils/c
36
36
  import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
37
37
  import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
38
38
  import type { AsyncJobSnapshotItem } from "../../session/agent-session";
39
- import type { AuthStorage } from "../../session/auth-storage";
39
+ import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage";
40
40
  import type { NewSessionOptions } from "../../session/session-manager";
41
41
  import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
42
+ import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
42
43
  import { outputMeta } from "../../tools/output-meta";
43
44
  import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
44
45
  import { replaceTabs } from "../../tools/render-utils";
@@ -404,7 +405,16 @@ export class CommandController {
404
405
  }
405
406
 
406
407
  const availableWidth = Math.max(40, (this.ctx.ui.terminal.columns ?? 100) - 2);
407
- const output = renderUsageReports(usageReports, theme, Date.now(), availableWidth);
408
+ const currentProvider = this.ctx.session.model?.provider;
409
+ const activeAccount = currentProvider
410
+ ? this.ctx.session.modelRegistry.authStorage.getOAuthAccountIdentity(
411
+ currentProvider,
412
+ this.ctx.session.sessionId,
413
+ )
414
+ : undefined;
415
+ const output = renderUsageReports(usageReports, theme, Date.now(), availableWidth, provider =>
416
+ provider === currentProvider ? activeAccount : undefined,
417
+ );
408
418
  this.ctx.present([new Spacer(1), new Text(output, 1, 0)]);
409
419
  }
410
420
 
@@ -446,7 +456,7 @@ export class CommandController {
446
456
  }
447
457
 
448
458
  handleContextCommand(): void {
449
- const breakdown = computeContextBreakdown(this.ctx.session);
459
+ const breakdown = computeContextBreakdown(this.ctx.session, { snapcompactSavings: true });
450
460
  if (breakdown.contextWindow <= 0) {
451
461
  this.ctx.showWarning("Context usage is unavailable: no model is selected for this session.");
452
462
  return;
@@ -1311,12 +1321,17 @@ function formatAccountHeaderRow(
1311
1321
  nowMs: number,
1312
1322
  columnWidth: number,
1313
1323
  uiTheme: typeof theme,
1324
+ activeAccount?: OAuthAccountIdentity,
1314
1325
  ): string[] {
1315
1326
  const parts = limits.map((limit, index) => {
1316
1327
  const reset = formatResetShort(limit, nowMs);
1328
+ const report = reports[index];
1329
+ const active = report !== undefined && limitMatchesActiveAccount(report, limit, activeAccount);
1330
+ const label = formatAccountLabel(limit, report, index);
1317
1331
  return {
1318
- label: formatAccountLabel(limit, reports[index], index),
1332
+ label: active ? `● ${label}` : label,
1319
1333
  suffix: reset ? `(${reset})` : "",
1334
+ active,
1320
1335
  };
1321
1336
  });
1322
1337
  const maxSuffixWidth = parts.reduce((max, p) => Math.max(max, visibleWidth(p.suffix)), 0);
@@ -1327,16 +1342,18 @@ function formatAccountHeaderRow(
1327
1342
  if (prefixBudget < 2) {
1328
1343
  return parts.map(p => {
1329
1344
  const full = p.suffix ? `${p.label} ${p.suffix}` : p.label;
1330
- return padColumn(truncateJobLabel(full, columnWidth), columnWidth);
1345
+ const cell = padColumn(truncateJobLabel(full, columnWidth), columnWidth);
1346
+ return p.active ? uiTheme.fg("accent", cell) : cell;
1331
1347
  });
1332
1348
  }
1333
1349
 
1334
1350
  return parts.map(p => {
1335
1351
  const prefix = truncateJobLabel(p.label, prefixBudget);
1336
1352
  const prefixCell = prefix + " ".repeat(prefixBudget - visibleWidth(prefix));
1337
- if (!p.suffix) return prefixCell + " ".repeat(maxSuffixWidth + gap);
1353
+ const styledPrefix = p.active ? uiTheme.fg("accent", prefixCell) : prefixCell;
1354
+ if (!p.suffix) return styledPrefix + " ".repeat(maxSuffixWidth + gap);
1338
1355
  const suffixPad = " ".repeat(maxSuffixWidth - visibleWidth(p.suffix));
1339
- return `${prefixCell} ${suffixPad}${uiTheme.fg("dim", p.suffix)}`;
1356
+ return `${styledPrefix} ${suffixPad}${uiTheme.fg("dim", p.suffix)}`;
1340
1357
  });
1341
1358
  }
1342
1359
 
@@ -1456,6 +1473,7 @@ function renderUsageReports(
1456
1473
  uiTheme: typeof theme,
1457
1474
  nowMs: number,
1458
1475
  availableWidth: number,
1476
+ resolveActiveAccount?: (provider: string) => OAuthAccountIdentity | undefined,
1459
1477
  ): string {
1460
1478
  const lines: string[] = [];
1461
1479
  const latestFetchedAt = Math.max(...reports.map(report => report.fetchedAt ?? 0));
@@ -1481,6 +1499,7 @@ function renderUsageReports(
1481
1499
  for (const { provider, providerReports } of providerEntries) {
1482
1500
  lines.push("");
1483
1501
  const providerName = formatProviderName(provider);
1502
+ const activeAccount = resolveActiveAccount?.(provider);
1484
1503
 
1485
1504
  const limitGroups = new Map<
1486
1505
  string,
@@ -1504,6 +1523,33 @@ function renderUsageReports(
1504
1523
  }
1505
1524
 
1506
1525
  lines.push(uiTheme.bold(uiTheme.fg("accent", providerName)));
1526
+ const activeAccountLabel = activeAccount?.email ?? activeAccount?.accountId ?? activeAccount?.projectId;
1527
+ if (activeAccountLabel) {
1528
+ lines.push(` ${uiTheme.fg("accent", "in use by this session:")} ${activeAccountLabel}`);
1529
+ }
1530
+
1531
+ const resetAccountLines: string[] = [];
1532
+ for (const report of providerReports) {
1533
+ const count = report.resetCredits?.availableCount ?? 0;
1534
+ if (count <= 0) continue;
1535
+ const label =
1536
+ (report.metadata?.email as string | undefined) ??
1537
+ (report.metadata?.accountId as string | undefined) ??
1538
+ "account";
1539
+ const isActive =
1540
+ !!activeAccount &&
1541
+ ((!!activeAccount.accountId && activeAccount.accountId === report.metadata?.accountId) ||
1542
+ (!!activeAccount.email && activeAccount.email === report.metadata?.email));
1543
+ resetAccountLines.push(
1544
+ ` • ${label}: ${count} saved reset${count === 1 ? "" : "s"}${isActive ? " (active)" : ""}`,
1545
+ );
1546
+ }
1547
+ if (resetAccountLines.length > 0) {
1548
+ lines.push(
1549
+ ` ${uiTheme.fg("accent", "Saved rate-limit resets")} ${uiTheme.fg("dim", "(/usage reset to spend)")}`,
1550
+ );
1551
+ for (const line of resetAccountLines) lines.push(uiTheme.fg("dim", line));
1552
+ }
1507
1553
 
1508
1554
  const renderableGroups = Array.from(limitGroups.values()).map(group => {
1509
1555
  const entries = group.limits.map((limit, index) => ({
@@ -1533,7 +1579,14 @@ function renderUsageReports(
1533
1579
 
1534
1580
  const windowSuffix = formatWindowSuffix(group.label, group.windowLabel, uiTheme);
1535
1581
  lines.push(`${statusIcon} ${uiTheme.bold(group.label)} ${windowSuffix}`.trim());
1536
- const accountLabels = formatAccountHeaderRow(sortedLimits, sortedReports, nowMs, sectionColumnWidth, uiTheme);
1582
+ const accountLabels = formatAccountHeaderRow(
1583
+ sortedLimits,
1584
+ sortedReports,
1585
+ nowMs,
1586
+ sectionColumnWidth,
1587
+ uiTheme,
1588
+ activeAccount,
1589
+ );
1537
1590
  lines.push(` ${accountLabels.join(" ")}`.trimEnd());
1538
1591
  const bars = sortedLimits.map(limit =>
1539
1592
  padColumn(renderUsageBar(limit, uiTheme, sectionColumnWidth), sectionColumnWidth),
@@ -673,6 +673,7 @@ export class EventController {
673
673
  showImages: settings.get("terminal.showImages"),
674
674
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
675
675
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
676
+ liveRegion: this.ctx.chatContainer,
676
677
  },
677
678
  tool,
678
679
  this.ctx.ui,
@@ -49,7 +49,14 @@ const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
49
49
  const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
50
50
 
51
51
  export class InputController {
52
- constructor(private ctx: InteractiveModeContext) {}
52
+ constructor(
53
+ private ctx: InteractiveModeContext,
54
+ /** Injectable clipboard reads so tests can drive paste flows without a real clipboard. */
55
+ private clipboard: {
56
+ readImage: typeof readImageFromClipboard;
57
+ readText: typeof readTextFromClipboard;
58
+ } = { readImage: readImageFromClipboard, readText: readTextFromClipboard },
59
+ ) {}
53
60
 
54
61
  #enhancedPaste?: EnhancedPasteController;
55
62
 
@@ -523,6 +530,36 @@ export class InputController {
523
530
  });
524
531
 
525
532
  this.ctx.onInputCallback(submission);
533
+ } else {
534
+ // No input waiter: the main loop is between turns (post-turn
535
+ // epilogue, retry backoff, or a scheduled continue) with the agent
536
+ // momentarily idle. The editor already cleared itself on Enter, so
537
+ // falling through here would silently swallow the message. Queue it
538
+ // as a steer instead: the idle drain in #queueSteer delivers it
539
+ // immediately when the session is resumable, and a retry/continue
540
+ // run picks it up at loop start otherwise.
541
+ this.ctx.editor.imageLinks = undefined;
542
+ const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
543
+ this.ctx.pendingImages = [];
544
+ this.ctx.pendingImageLinks = [];
545
+ try {
546
+ await this.ctx.withLocalSubmission(text, () => this.ctx.session.steer(text, images), {
547
+ imageCount: images?.length ?? 0,
548
+ });
549
+ } catch (error) {
550
+ // Don't lose the message: hand the text and images back to the
551
+ // editor so the user can retry (e.g. steer() rejecting an
552
+ // extension command).
553
+ this.ctx.editor.setText(text);
554
+ if (images && images.length > 0) {
555
+ this.ctx.pendingImages = [...images];
556
+ this.ctx.pendingImageLinks = inputImageLinks ? [...inputImageLinks] : images.map(() => undefined);
557
+ this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
558
+ }
559
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
560
+ }
561
+ this.ctx.updatePendingMessagesDisplay();
562
+ this.ctx.ui.requestRender();
526
563
  }
527
564
  this.ctx.editor.addToHistory(text);
528
565
  };
@@ -737,10 +774,19 @@ export class InputController {
737
774
  }
738
775
  return 0;
739
776
  }
740
- const queuedText = allQueued.join("\n\n");
777
+ const queuedText = allQueued.map(e => e.text).join("\n\n");
741
778
  const currentText = options?.currentText ?? this.ctx.editor.getText();
742
779
  const combinedText = [queuedText, currentText].filter(t => t.trim()).join("\n\n");
743
780
  this.ctx.editor.setText(combinedText);
781
+ // Hand queued images back to the pending-image buffer (links are
782
+ // re-materialized lazily; the restored text already carries the
783
+ // `[Image #N, WxH]` markers).
784
+ const queuedImages = allQueued.flatMap(e => e.images ?? []);
785
+ if (queuedImages.length > 0) {
786
+ this.ctx.pendingImages.push(...queuedImages);
787
+ this.ctx.pendingImageLinks.push(...queuedImages.map(() => undefined));
788
+ this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
789
+ }
744
790
  this.ctx.updatePendingMessagesDisplay();
745
791
  if (options?.abort) {
746
792
  this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
@@ -837,10 +883,25 @@ export class InputController {
837
883
 
838
884
  async handleImagePaste(): Promise<boolean> {
839
885
  try {
840
- const image = await readImageFromClipboard();
886
+ const image = await this.clipboard.readImage();
841
887
  if (!image) {
842
- this.ctx.showStatus("No image in clipboard (use terminal paste for text)");
843
- return false;
888
+ // Smart paste (#1628): no image on the clipboard fall back to
889
+ // pasting its text so the same chord covers both payload kinds.
890
+ // Hosts that pre-empt the terminal's own paste (VS Code's
891
+ // integrated terminal, Win+V clipboard history) deliver only
892
+ // this keypress, so a miss here must not dead-end.
893
+ const text = await this.clipboard.readText();
894
+ if (!text) {
895
+ this.ctx.showStatus("Clipboard is empty");
896
+ return false;
897
+ }
898
+ // Route to the focused component when it accepts pastes (modal
899
+ // Input prompts), matching the enhanced-paste text path (#2127).
900
+ const focused = this.ctx.ui.getFocused();
901
+ const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
902
+ target.pasteText(text);
903
+ this.ctx.ui.requestRender();
904
+ return true;
844
905
  }
845
906
  return await this.#normalizeAndInsertPastedImage(
846
907
  {
@@ -858,10 +919,11 @@ export class InputController {
858
919
 
859
920
  async handleClipboardTextRawPaste(): Promise<void> {
860
921
  try {
861
- const text = await readTextFromClipboard();
922
+ const text = await this.clipboard.readText();
862
923
  if (text) {
863
924
  this.ctx.editor.insertText(text);
864
925
  this.ctx.ui.requestRender();
926
+ } else {
865
927
  this.ctx.showStatus("No text in clipboard to paste raw");
866
928
  }
867
929
  } catch {