@oh-my-pi/pi-coding-agent 15.11.3 → 15.11.4

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 (103) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +353 -294
  3. package/dist/types/config/api-key-resolver.d.ts +9 -3
  4. package/dist/types/config/keybindings.d.ts +1 -1
  5. package/dist/types/config/model-discovery.d.ts +6 -4
  6. package/dist/types/config/model-registry.d.ts +7 -4
  7. package/dist/types/config/settings-schema.d.ts +458 -155
  8. package/dist/types/export/html/template.generated.d.ts +1 -1
  9. package/dist/types/mnemopi/config.d.ts +3 -1
  10. package/dist/types/modes/components/settings-defs.d.ts +9 -2
  11. package/dist/types/modes/components/settings-selector.d.ts +9 -4
  12. package/dist/types/modes/components/tool-execution.d.ts +12 -1
  13. package/dist/types/modes/components/transcript-container.d.ts +12 -0
  14. package/dist/types/modes/controllers/input-controller.d.ts +9 -1
  15. package/dist/types/modes/theme/theme.d.ts +23 -3
  16. package/dist/types/session/agent-session.d.ts +14 -7
  17. package/dist/types/session/auth-storage.d.ts +1 -1
  18. package/dist/types/session/snapcompact-inline.d.ts +28 -0
  19. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
  20. package/dist/types/system-prompt.d.ts +3 -1
  21. package/dist/types/task/render.d.ts +16 -6
  22. package/dist/types/tools/gh.d.ts +3 -0
  23. package/dist/types/tools/render-utils.d.ts +8 -16
  24. package/dist/types/utils/session-color.d.ts +15 -3
  25. package/dist/types/web/kagi.d.ts +1 -2
  26. package/dist/types/web/search/providers/codex.d.ts +1 -1
  27. package/dist/types/web/search/providers/gemini.d.ts +9 -6
  28. package/package.json +11 -11
  29. package/src/auto-thinking/classifier.ts +1 -5
  30. package/src/commit/model-selection.ts +3 -6
  31. package/src/config/api-key-resolver.ts +10 -3
  32. package/src/config/keybindings.ts +1 -1
  33. package/src/config/model-discovery.ts +60 -46
  34. package/src/config/model-registry.ts +21 -8
  35. package/src/config/model-resolver.ts +57 -3
  36. package/src/config/settings-schema.ts +601 -153
  37. package/src/eval/completion-bridge.ts +1 -5
  38. package/src/export/html/template.generated.ts +1 -1
  39. package/src/export/html/template.js +13 -6
  40. package/src/internal-urls/docs-index.generated.ts +5 -5
  41. package/src/internal-urls/issue-pr-protocol.ts +10 -4
  42. package/src/memories/index.ts +2 -10
  43. package/src/mnemopi/backend.ts +30 -8
  44. package/src/mnemopi/config.ts +6 -1
  45. package/src/mnemopi/state.ts +6 -0
  46. package/src/modes/components/extensions/inspector-panel.ts +6 -2
  47. package/src/modes/components/plan-review-overlay.ts +15 -17
  48. package/src/modes/components/plugin-settings.ts +22 -5
  49. package/src/modes/components/settings-defs.ts +19 -4
  50. package/src/modes/components/settings-selector.ts +493 -93
  51. package/src/modes/components/status-line/component.ts +3 -1
  52. package/src/modes/components/status-line/segments.ts +3 -1
  53. package/src/modes/components/tool-execution.ts +69 -12
  54. package/src/modes/components/transcript-container.ts +26 -0
  55. package/src/modes/components/tree-selector.ts +16 -6
  56. package/src/modes/controllers/command-controller.ts +37 -7
  57. package/src/modes/controllers/event-controller.ts +1 -0
  58. package/src/modes/controllers/input-controller.ts +68 -6
  59. package/src/modes/controllers/selector-controller.ts +81 -61
  60. package/src/modes/interactive-mode.ts +4 -2
  61. package/src/modes/rpc/rpc-mode.ts +2 -1
  62. package/src/modes/shared.ts +2 -0
  63. package/src/modes/theme/theme.ts +100 -7
  64. package/src/modes/utils/context-usage.ts +3 -1
  65. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  66. package/src/modes/utils/ui-helpers.ts +9 -5
  67. package/src/prompts/system/personalities/default.md +26 -0
  68. package/src/prompts/system/personalities/friendly.md +17 -0
  69. package/src/prompts/system/personalities/pragmatic.md +15 -0
  70. package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
  71. package/src/prompts/system/snapcompact-system-stub.md +1 -0
  72. package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
  73. package/src/prompts/system/system-prompt.md +5 -22
  74. package/src/prompts/tools/task.md +3 -3
  75. package/src/sdk.ts +22 -1
  76. package/src/session/agent-session.ts +91 -24
  77. package/src/session/auth-storage.ts +1 -0
  78. package/src/session/session-dump-format.ts +8 -1
  79. package/src/session/session-manager.ts +5 -5
  80. package/src/session/snapcompact-inline.ts +187 -0
  81. package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
  82. package/src/slash-commands/helpers/usage-report.ts +24 -3
  83. package/src/system-prompt.ts +15 -1
  84. package/src/task/render.ts +29 -19
  85. package/src/tool-discovery/tool-index.ts +2 -0
  86. package/src/tools/bash.ts +10 -3
  87. package/src/tools/eval-render.ts +13 -8
  88. package/src/tools/gh.ts +39 -1
  89. package/src/tools/image-gen.ts +114 -78
  90. package/src/tools/inspect-image.ts +1 -5
  91. package/src/tools/job.ts +25 -5
  92. package/src/tools/read.ts +1 -57
  93. package/src/tools/render-utils.ts +29 -31
  94. package/src/tools/ssh.ts +3 -3
  95. package/src/tools/tts.ts +40 -20
  96. package/src/utils/clipboard.ts +56 -4
  97. package/src/utils/commit-message-generator.ts +1 -5
  98. package/src/utils/session-color.ts +83 -9
  99. package/src/utils/title-generator.ts +1 -1
  100. package/src/web/kagi.ts +26 -27
  101. package/src/web/search/providers/codex.ts +42 -40
  102. package/src/web/search/providers/gemini.ts +42 -22
  103. 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();
@@ -547,6 +598,9 @@ export class ToolExecutionComponent extends Container {
547
598
  if (this.#sealed) return;
548
599
  this.#sealed = true;
549
600
  this.#displaceable = false;
601
+ // A sealed detached task is abandoned history: settle its progress rows
602
+ // on static gray.
603
+ this.#backgroundTaskFrozen = true;
550
604
  this.stopAnimation();
551
605
  this.#updateDisplay();
552
606
  this.#ui.requestRender();
@@ -888,6 +942,9 @@ export class ToolExecutionComponent extends Container {
888
942
  // draws every dispatched agent as a progress/result line, so tell
889
943
  // `renderCall` to drop its duplicate streaming preview list.
890
944
  context.hasResult = Boolean(this.#result);
945
+ // Out of the transcript live region: progress rows render static gray
946
+ // (see task/render.ts).
947
+ context.frozen = this.#backgroundTaskFrozen;
891
948
  } else if (isEditLikeToolName(this.#toolName)) {
892
949
  context.editMode = this.#editMode;
893
950
  const previews = this.#editDiffPreview;
@@ -476,6 +476,32 @@ export class TranscriptContainer
476
476
  return false;
477
477
  }
478
478
 
479
+ /**
480
+ * Whether `component` is inside the live (repaintable) region exactly as
481
+ * {@link render} computes it: at/after the first still-mutating block, or
482
+ * the transcript tail when every block has finalized. Unlike
483
+ * {@link isWithinLiveRegion} (strictly below a still-mutating block, i.e.
484
+ * guaranteed-uncommitted), this also counts the trailing block that anchors
485
+ * the live region. Self-animating finalized blocks (a detached task's
486
+ * shimmering progress rows) poll this to stop animating — and settle on
487
+ * static bytes — the moment they sit above the seam, where their rows
488
+ * become commit-eligible native-scrollback history.
489
+ */
490
+ isBlockInLiveRegion(component: Component): boolean {
491
+ const children = this.children;
492
+ const index = children.indexOf(component);
493
+ if (index < 0) return false;
494
+ for (let i = 0; i <= index; i++) {
495
+ if (!isBlockFinalized(children[i]!)) return true;
496
+ }
497
+ // Every block at/before `index` finalized: the live region starts at the
498
+ // first unfinalized block below it, or at the last child when none exists.
499
+ for (let i = index + 1; i < children.length; i++) {
500
+ if (!isBlockFinalized(children[i]!)) return false;
501
+ }
502
+ return index === children.length - 1;
503
+ }
504
+
479
505
  override render(width: number): readonly string[] {
480
506
  width = Math.max(1, width);
481
507
  this.#nativeScrollbackLiveRegionStart = undefined;
@@ -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
 
@@ -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,10 @@ 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
+ }
1507
1530
 
1508
1531
  const renderableGroups = Array.from(limitGroups.values()).map(group => {
1509
1532
  const entries = group.limits.map((limit, index) => ({
@@ -1533,7 +1556,14 @@ function renderUsageReports(
1533
1556
 
1534
1557
  const windowSuffix = formatWindowSuffix(group.label, group.windowLabel, uiTheme);
1535
1558
  lines.push(`${statusIcon} ${uiTheme.bold(group.label)} ${windowSuffix}`.trim());
1536
- const accountLabels = formatAccountHeaderRow(sortedLimits, sortedReports, nowMs, sectionColumnWidth, uiTheme);
1559
+ const accountLabels = formatAccountHeaderRow(
1560
+ sortedLimits,
1561
+ sortedReports,
1562
+ nowMs,
1563
+ sectionColumnWidth,
1564
+ uiTheme,
1565
+ activeAccount,
1566
+ );
1537
1567
  lines.push(` ${accountLabels.join(" ")}`.trimEnd());
1538
1568
  const bars = sortedLimits.map(limit =>
1539
1569
  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 {
@@ -92,71 +92,86 @@ export class SelectorController {
92
92
 
93
93
  showSettingsSelector(): void {
94
94
  getAvailableThemes().then(availableThemes => {
95
- this.showSelector(done => {
96
- const selector = new SettingsSelectorComponent(
97
- {
98
- availableThinkingLevels: [...this.ctx.session.getAvailableThinkingLevels()],
99
- thinkingLevel: this.ctx.session.thinkingLevel,
100
- availableThemes,
101
- cwd: getProjectDir(),
102
- },
103
- {
104
- onChange: (id, value) => this.handleSettingChange(id, value),
105
- onThemePreview: async themeName => {
106
- const result = await previewTheme(themeName);
107
- if (result.success) {
108
- this.ctx.statusLine.invalidate();
109
- this.ctx.updateEditorTopBorder();
110
- this.ctx.ui.invalidate();
111
- this.ctx.ui.requestRender();
112
- }
113
- },
114
- onStatusLinePreview: previewSettings => {
115
- // Update status line with preview settings
116
- this.ctx.statusLine.updateSettings({
117
- preset: settings.get("statusLine.preset"),
118
- leftSegments: settings.get("statusLine.leftSegments"),
119
- rightSegments: settings.get("statusLine.rightSegments"),
120
- separator: settings.get("statusLine.separator"),
121
- showHookStatus: settings.get("statusLine.showHookStatus"),
122
- sessionAccent: settings.get("statusLine.sessionAccent"),
123
- transparent: settings.get("statusLine.transparent"),
124
- ...previewSettings,
125
- });
126
- this.ctx.updateEditorTopBorder();
127
- this.ctx.ui.requestRender();
128
- },
129
- getStatusLinePreview: () => {
130
- // Return the rendered status line for inline preview
131
- const availableWidth = this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns);
132
- return this.ctx.statusLine.getTopBorder(availableWidth).content;
133
- },
134
- onPluginsChanged: async () => {
135
- const projectPath = await resolveActiveProjectRegistryPath(this.ctx.sessionManager.getCwd());
136
- clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
137
- await this.ctx.refreshSlashCommandState();
138
- await this.ctx.session.refreshSshTool({ activateIfAvailable: true });
139
- this.ctx.ui.requestRender();
140
- },
141
- onCancel: () => {
142
- done();
143
- // Restore status line to saved settings
144
- this.ctx.statusLine.updateSettings({
145
- preset: settings.get("statusLine.preset"),
146
- leftSegments: settings.get("statusLine.leftSegments"),
147
- rightSegments: settings.get("statusLine.rightSegments"),
148
- separator: settings.get("statusLine.separator"),
149
- showHookStatus: settings.get("statusLine.showHookStatus"),
150
- sessionAccent: settings.get("statusLine.sessionAccent"),
151
- transparent: settings.get("statusLine.transparent"),
152
- });
95
+ // Fullscreen settings editor on the alternate screen: the overlay
96
+ // enables mouse tracking (click/hover/wheel) for its lifetime and
97
+ // the transcript stays untouched underneath.
98
+ let overlayHandle: OverlayHandle | undefined;
99
+ const done = () => {
100
+ overlayHandle?.hide();
101
+ this.ctx.ui.setFocus(this.ctx.editor);
102
+ this.ctx.ui.requestRender();
103
+ };
104
+ const selector = new SettingsSelectorComponent(
105
+ {
106
+ availableThinkingLevels: [...this.ctx.session.getAvailableThinkingLevels()],
107
+ thinkingLevel: this.ctx.session.thinkingLevel,
108
+ availableThemes,
109
+ cwd: getProjectDir(),
110
+ },
111
+ {
112
+ onChange: (id, value) => this.handleSettingChange(id, value),
113
+ onThemePreview: async themeName => {
114
+ const result = await previewTheme(themeName);
115
+ if (result.success) {
116
+ this.ctx.statusLine.invalidate();
153
117
  this.ctx.updateEditorTopBorder();
118
+ this.ctx.ui.invalidate();
154
119
  this.ctx.ui.requestRender();
155
- },
120
+ }
156
121
  },
157
- );
158
- return { component: selector, focus: selector };
122
+ onStatusLinePreview: previewSettings => {
123
+ // Update status line with preview settings
124
+ this.ctx.statusLine.updateSettings({
125
+ preset: settings.get("statusLine.preset"),
126
+ leftSegments: settings.get("statusLine.leftSegments"),
127
+ rightSegments: settings.get("statusLine.rightSegments"),
128
+ separator: settings.get("statusLine.separator"),
129
+ showHookStatus: settings.get("statusLine.showHookStatus"),
130
+ sessionAccent: settings.get("statusLine.sessionAccent"),
131
+ transparent: settings.get("statusLine.transparent"),
132
+ ...previewSettings,
133
+ });
134
+ this.ctx.updateEditorTopBorder();
135
+ this.ctx.ui.requestRender();
136
+ },
137
+ getStatusLinePreview: () => {
138
+ // Return the rendered status line for inline preview
139
+ const availableWidth = this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns);
140
+ return this.ctx.statusLine.getTopBorder(availableWidth).content;
141
+ },
142
+ onPluginsChanged: async () => {
143
+ const projectPath = await resolveActiveProjectRegistryPath(this.ctx.sessionManager.getCwd());
144
+ clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
145
+ await this.ctx.refreshSlashCommandState();
146
+ await this.ctx.session.refreshSshTool({ activateIfAvailable: true });
147
+ this.ctx.ui.requestRender();
148
+ },
149
+ onCancel: () => {
150
+ done();
151
+ // Restore status line to saved settings
152
+ this.ctx.statusLine.updateSettings({
153
+ preset: settings.get("statusLine.preset"),
154
+ leftSegments: settings.get("statusLine.leftSegments"),
155
+ rightSegments: settings.get("statusLine.rightSegments"),
156
+ separator: settings.get("statusLine.separator"),
157
+ showHookStatus: settings.get("statusLine.showHookStatus"),
158
+ sessionAccent: settings.get("statusLine.sessionAccent"),
159
+ transparent: settings.get("statusLine.transparent"),
160
+ });
161
+ this.ctx.updateEditorTopBorder();
162
+ this.ctx.ui.requestRender();
163
+ },
164
+ },
165
+ );
166
+ overlayHandle = this.ctx.ui.showOverlay(selector, {
167
+ anchor: "bottom-center",
168
+ width: "100%",
169
+ maxHeight: "100%",
170
+ margin: 0,
171
+ fullscreen: true,
159
172
  });
173
+ this.ctx.ui.setFocus(selector);
174
+ this.ctx.ui.requestRender();
160
175
  });
161
176
  }
162
177
 
@@ -267,6 +282,11 @@ export class SelectorController {
267
282
  this.ctx.statusLine.invalidate();
268
283
  this.ctx.updateEditorBorderColor();
269
284
  break;
285
+ case "personality":
286
+ void this.ctx.session.refreshBaseSystemPrompt().catch(err => {
287
+ this.ctx.showError(`Failed to apply personality: ${err}`);
288
+ });
289
+ break;
270
290
 
271
291
  case "autocompleteMaxVisible":
272
292
  this.ctx.editor.setAutocompleteMaxVisible(typeof value === "number" ? value : Number(value));