@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.3

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 (154) hide show
  1. package/CHANGELOG.md +113 -1
  2. package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
  3. package/dist/types/cli/startup-cwd.d.ts +2 -0
  4. package/dist/types/commands/launch.d.ts +3 -0
  5. package/dist/types/config/keybindings.d.ts +2 -2
  6. package/dist/types/config/model-provider-priority.d.ts +1 -0
  7. package/dist/types/config/model-resolver.d.ts +4 -1
  8. package/dist/types/config/settings.d.ts +7 -2
  9. package/dist/types/debug/report-bundle.d.ts +3 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  11. package/dist/types/edit/index.d.ts +0 -1
  12. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  14. package/dist/types/lsp/client.d.ts +10 -0
  15. package/dist/types/lsp/index.d.ts +0 -5
  16. package/dist/types/main.d.ts +14 -9
  17. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +0 -9
  19. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  20. package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
  21. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  22. package/dist/types/modes/components/session-selector.d.ts +16 -7
  23. package/dist/types/modes/components/status-line.d.ts +2 -0
  24. package/dist/types/modes/components/tool-execution.d.ts +0 -18
  25. package/dist/types/modes/controllers/event-controller.d.ts +17 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -0
  27. package/dist/types/modes/magic-keywords.d.ts +1 -1
  28. package/dist/types/modes/markdown-prose.d.ts +1 -1
  29. package/dist/types/modes/types.d.ts +7 -0
  30. package/dist/types/modes/workflow.d.ts +3 -3
  31. package/dist/types/session/auth-storage.d.ts +1 -1
  32. package/dist/types/session/messages.d.ts +11 -8
  33. package/dist/types/session/session-manager.d.ts +5 -2
  34. package/dist/types/session/yield-queue.d.ts +10 -1
  35. package/dist/types/task/executor.d.ts +10 -0
  36. package/dist/types/tools/eval-render.d.ts +0 -1
  37. package/dist/types/tools/eval.d.ts +8 -0
  38. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  39. package/dist/types/tools/github-cache.d.ts +12 -0
  40. package/dist/types/tools/index.d.ts +31 -0
  41. package/dist/types/tools/path-utils.d.ts +13 -1
  42. package/dist/types/tools/read.d.ts +2 -1
  43. package/dist/types/tools/render-utils.d.ts +3 -1
  44. package/dist/types/tools/renderers.d.ts +0 -15
  45. package/dist/types/tools/search.d.ts +2 -2
  46. package/dist/types/tools/write.d.ts +0 -2
  47. package/dist/types/tools/yield.d.ts +8 -0
  48. package/dist/types/tui/code-cell.d.ts +0 -2
  49. package/dist/types/tui/hyperlink.d.ts +5 -7
  50. package/dist/types/tui/output-block.d.ts +0 -18
  51. package/package.json +9 -9
  52. package/src/cli/args.ts +3 -1
  53. package/src/cli/dry-balance-cli.ts +2 -4
  54. package/src/cli/gallery-cli.ts +4 -0
  55. package/src/cli/gallery-fixtures/codeintel.ts +0 -1
  56. package/src/cli/gallery-fixtures/fs.ts +68 -1
  57. package/src/cli/gallery-fixtures/types.ts +8 -1
  58. package/src/cli/startup-cwd.ts +68 -0
  59. package/src/commands/launch.ts +3 -0
  60. package/src/commit/agentic/agent.ts +1 -0
  61. package/src/commit/model-selection.ts +3 -2
  62. package/src/config/model-provider-priority.ts +55 -0
  63. package/src/config/model-registry.ts +4 -22
  64. package/src/config/model-resolver.ts +39 -7
  65. package/src/config/settings.ts +86 -41
  66. package/src/debug/index.ts +8 -0
  67. package/src/debug/raw-sse-buffer.ts +7 -4
  68. package/src/debug/report-bundle.ts +9 -0
  69. package/src/edit/file-snapshot-store.ts +33 -1
  70. package/src/edit/hashline/diff.ts +86 -0
  71. package/src/edit/hashline/execute.ts +14 -1
  72. package/src/edit/hashline/filesystem.ts +2 -1
  73. package/src/edit/index.ts +31 -17
  74. package/src/edit/renderer.ts +116 -31
  75. package/src/eval/__tests__/llm-bridge.test.ts +20 -0
  76. package/src/eval/js/context-manager.ts +32 -15
  77. package/src/eval/js/shared/prelude.txt +26 -10
  78. package/src/eval/llm-bridge.ts +14 -3
  79. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  80. package/src/eval/py/executor.ts +23 -11
  81. package/src/eval/py/prelude.py +1 -1
  82. package/src/extensibility/extensions/types.ts +10 -1
  83. package/src/internal-urls/docs-index.generated.ts +7 -7
  84. package/src/lsp/client.ts +23 -11
  85. package/src/lsp/config.ts +11 -1
  86. package/src/lsp/index.ts +189 -61
  87. package/src/main.ts +144 -78
  88. package/src/mcp/tool-bridge.ts +2 -0
  89. package/src/memories/index.ts +2 -2
  90. package/src/modes/components/assistant-message.ts +3 -15
  91. package/src/modes/components/custom-editor.ts +143 -111
  92. package/src/modes/components/late-diagnostics-message.ts +60 -0
  93. package/src/modes/components/model-selector.ts +59 -13
  94. package/src/modes/components/oauth-selector.ts +33 -7
  95. package/src/modes/components/plan-review-overlay.ts +26 -5
  96. package/src/modes/components/read-tool-group.ts +415 -35
  97. package/src/modes/components/session-selector.ts +89 -35
  98. package/src/modes/components/status-line.ts +19 -4
  99. package/src/modes/components/tips.txt +1 -1
  100. package/src/modes/components/tool-execution.ts +7 -49
  101. package/src/modes/components/transcript-container.ts +108 -32
  102. package/src/modes/components/user-message.ts +1 -1
  103. package/src/modes/controllers/event-controller.ts +32 -1
  104. package/src/modes/controllers/input-controller.ts +56 -9
  105. package/src/modes/interactive-mode.ts +107 -20
  106. package/src/modes/magic-keywords.ts +1 -1
  107. package/src/modes/markdown-prose.ts +1 -1
  108. package/src/modes/theme/shimmer.ts +20 -9
  109. package/src/modes/types.ts +7 -0
  110. package/src/modes/utils/ui-helpers.ts +26 -5
  111. package/src/modes/workflow.ts +10 -10
  112. package/src/prompts/system/manual-continue.md +7 -0
  113. package/src/prompts/system/plan-mode-active.md +56 -72
  114. package/src/prompts/system/workflow-notice.md +1 -1
  115. package/src/prompts/tools/bash.md +9 -0
  116. package/src/prompts/tools/browser.md +1 -1
  117. package/src/prompts/tools/eval.md +5 -2
  118. package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
  119. package/src/prompts/tools/read.md +2 -2
  120. package/src/sdk.ts +85 -10
  121. package/src/session/agent-session.ts +42 -15
  122. package/src/session/auth-storage.ts +2 -0
  123. package/src/session/messages.ts +21 -14
  124. package/src/session/session-manager.ts +98 -25
  125. package/src/session/yield-queue.ts +20 -2
  126. package/src/task/executor.ts +72 -36
  127. package/src/task/render.ts +3 -4
  128. package/src/tiny/title-client.ts +6 -1
  129. package/src/tools/bash.ts +7 -7
  130. package/src/tools/browser/tab-supervisor.ts +13 -1
  131. package/src/tools/browser/tab-worker.ts +33 -4
  132. package/src/tools/eval-render.ts +4 -23
  133. package/src/tools/eval.ts +13 -2
  134. package/src/tools/find.ts +148 -99
  135. package/src/tools/gh-cache-invalidation.ts +200 -0
  136. package/src/tools/github-cache.ts +25 -0
  137. package/src/tools/index.ts +32 -0
  138. package/src/tools/inspect-image.ts +2 -2
  139. package/src/tools/path-utils.ts +47 -24
  140. package/src/tools/plan-mode-guard.ts +52 -7
  141. package/src/tools/read.ts +41 -20
  142. package/src/tools/render-utils.ts +3 -1
  143. package/src/tools/renderers.ts +0 -15
  144. package/src/tools/search.ts +38 -3
  145. package/src/tools/ssh.ts +0 -1
  146. package/src/tools/todo.ts +1 -0
  147. package/src/tools/write.ts +5 -14
  148. package/src/tools/yield.ts +10 -1
  149. package/src/tui/code-cell.ts +1 -6
  150. package/src/tui/hyperlink.ts +13 -23
  151. package/src/tui/output-block.ts +2 -97
  152. package/src/utils/commit-message-generator.ts +2 -2
  153. package/src/utils/enhanced-paste.ts +30 -2
  154. package/src/web/search/providers/codex.ts +37 -8
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  type Component,
3
3
  Container,
4
- fuzzyFilter,
4
+ fuzzyMatch,
5
5
  Input,
6
6
  matchesKey,
7
7
  padding,
@@ -46,43 +46,107 @@ function formatSessionStatus(status: SessionStatus | undefined): string | undefi
46
46
  /** Returns the IDs of sessions whose recorded prompts match a query, best first. */
47
47
  export type SessionHistoryMatcher = (query: string) => string[];
48
48
 
49
+ function sessionSearchText(session: SessionInfo): string {
50
+ const parts = [
51
+ session.id,
52
+ session.title ?? "",
53
+ session.cwd ?? "",
54
+ session.firstMessage ?? "",
55
+ session.allMessagesText,
56
+ session.path,
57
+ ];
58
+ return parts.filter(Boolean).join(" ");
59
+ }
60
+
61
+ function tokenizeSessionQuery(query: string): string[] {
62
+ const trimmed = query.trim().toLowerCase();
63
+ return trimmed ? trimmed.split(/\s+/) : [];
64
+ }
65
+
66
+ function compareSessionRecency(a: SessionInfo, b: SessionInfo): number {
67
+ return b.modified.getTime() - a.modified.getTime();
68
+ }
69
+
70
+ /**
71
+ * Filter and rank session picker search results.
72
+ *
73
+ * Resume search narrows a recency-sorted list: once every query token appears
74
+ * as a literal substring, newer sessions should beat a slightly better fuzzy
75
+ * position match. Pure fuzzy/acronym matches still sort by fuzzy score after
76
+ * literal matches.
77
+ */
78
+ export function rankSessionSearchMatches(allSessions: SessionInfo[], query: string): SessionInfo[] {
79
+ const tokens = tokenizeSessionQuery(query);
80
+ if (tokens.length === 0) return allSessions;
81
+
82
+ const results: Array<{ session: SessionInfo; score: number; literal: boolean; index: number }> = [];
83
+ for (let index = 0; index < allSessions.length; index++) {
84
+ const session = allSessions[index]!;
85
+ const text = sessionSearchText(session);
86
+ const textLower = text.toLowerCase();
87
+ let score = 0;
88
+ let literal = true;
89
+ let matches = true;
90
+
91
+ for (const token of tokens) {
92
+ const match = fuzzyMatch(token, textLower);
93
+ if (!match.matches) {
94
+ matches = false;
95
+ break;
96
+ }
97
+ score += match.score;
98
+ if (!textLower.includes(token)) literal = false;
99
+ }
100
+
101
+ if (matches) results.push({ session, score, literal, index });
102
+ }
103
+
104
+ results.sort((a, b) => {
105
+ if (a.literal !== b.literal) return a.literal ? -1 : 1;
106
+ if (a.literal) return compareSessionRecency(a.session, b.session) || a.index - b.index;
107
+ return a.score - b.score || compareSessionRecency(a.session, b.session) || a.index - b.index;
108
+ });
109
+
110
+ return results.map(result => result.session);
111
+ }
112
+
49
113
  /**
50
- * Combine fuzzy session matches with prompt-history matches for ranking, using
51
- * both signals rather than replacing one with the other.
114
+ * Combine metadata matches with prompt-history matches for ranking, using both
115
+ * signals rather than replacing one with the other.
52
116
  *
53
- * - `fuzzy` is the ordered fuzzy-filter result over session metadata (best first).
117
+ * - `fuzzy` is the ordered metadata/session-text result.
54
118
  * - `historyIds` are session IDs whose recorded prompts matched the query,
55
119
  * ordered by prompt-history rank (typically newest matching prompt first); duplicates are tolerated.
56
120
  *
57
- * Ranking: sessions matched by **both** signals lead (keeping fuzzy order), then
58
- * fuzzy-only matches, then history-only matches (by prompt-history order). A fuzzy match
59
- * is never dropped, and history matches not present in `allSessions` (e.g. deleted
60
- * or out-of-scope sessions) are ignored since they cannot be resumed from here.
121
+ * Ranking: prompt-history matches lead in history order, then remaining
122
+ * metadata matches keep their existing order. A metadata match is never dropped,
123
+ * and history matches not present in `allSessions` (e.g. deleted or out-of-scope
124
+ * sessions) are ignored since they cannot be resumed from here.
61
125
  */
62
126
  export function mergeSessionRanking(
63
127
  allSessions: SessionInfo[],
64
128
  fuzzy: SessionInfo[],
65
129
  historyIds: string[],
66
130
  ): SessionInfo[] {
67
- const historyRank = new Map<string, number>();
68
- historyIds.forEach((id, index) => {
69
- if (!historyRank.has(id)) historyRank.set(id, index);
70
- });
71
- if (historyRank.size === 0) return fuzzy;
72
-
73
- const both: SessionInfo[] = [];
74
- const fuzzyOnly: SessionInfo[] = [];
75
- const fuzzyPaths = new Set<string>();
76
- for (const session of fuzzy) {
77
- fuzzyPaths.add(session.path);
78
- (historyRank.has(session.id) ? both : fuzzyOnly).push(session);
131
+ if (historyIds.length === 0) return fuzzy;
132
+
133
+ const sessionsById = new Map<string, SessionInfo>();
134
+ for (const session of allSessions) {
135
+ if (!sessionsById.has(session.id)) sessionsById.set(session.id, session);
79
136
  }
80
137
 
81
- const historyOnly = allSessions
82
- .filter(session => historyRank.has(session.id) && !fuzzyPaths.has(session.path))
83
- .sort((a, b) => (historyRank.get(a.id) ?? 0) - (historyRank.get(b.id) ?? 0));
138
+ const historyMatches: SessionInfo[] = [];
139
+ const historyPaths = new Set<string>();
140
+ for (const id of historyIds) {
141
+ const session = sessionsById.get(id);
142
+ if (!session || historyPaths.has(session.path)) continue;
143
+ historyMatches.push(session);
144
+ historyPaths.add(session.path);
145
+ }
146
+ if (historyMatches.length === 0) return fuzzy;
84
147
 
85
- return [...both, ...fuzzyOnly, ...historyOnly];
148
+ const metadataOnly = fuzzy.filter(session => !historyPaths.has(session.path));
149
+ return [...historyMatches, ...metadataOnly];
86
150
  }
87
151
 
88
152
  /**
@@ -156,17 +220,7 @@ class SessionList implements Component {
156
220
  }
157
221
 
158
222
  #filterSessions(query: string): void {
159
- const fuzzy = fuzzyFilter(this.#allSessions, query, session => {
160
- const parts = [
161
- session.id,
162
- session.title ?? "",
163
- session.cwd ?? "",
164
- session.firstMessage ?? "",
165
- session.allMessagesText,
166
- session.path,
167
- ];
168
- return parts.filter(Boolean).join(" ");
169
- });
223
+ const fuzzy = rankSessionSearchMatches(this.#allSessions, query);
170
224
  this.#filteredSessions = this.#mergeHistoryMatches(query, fuzzy);
171
225
  this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1));
172
226
  }
@@ -40,6 +40,11 @@ export interface StatusLineSettings {
40
40
  sessionAccent?: boolean;
41
41
  }
42
42
 
43
+ export type EffectiveStatusLineSettings = Required<
44
+ Pick<StatusLineSettings, "leftSegments" | "rightSegments" | "separator" | "segmentOptions">
45
+ > &
46
+ StatusLineSettings;
47
+
43
48
  // ═══════════════════════════════════════════════════════════════════════════
44
49
  // Per-message token cache
45
50
  // ═══════════════════════════════════════════════════════════════════════════
@@ -143,6 +148,7 @@ function tokensForMessage(msg: AgentMessage): number {
143
148
 
144
149
  export class StatusLineComponent implements Component {
145
150
  #settings: StatusLineSettings = {};
151
+ #effectiveSettings: EffectiveStatusLineSettings | undefined;
146
152
  #cachedBranch: string | null | undefined = undefined;
147
153
  #cachedBranchRepoId: string | null | undefined = undefined;
148
154
  #gitWatcher: fs.FSWatcher | null = null;
@@ -204,6 +210,11 @@ export class StatusLineComponent implements Component {
204
210
 
205
211
  updateSettings(settings: StatusLineSettings): void {
206
212
  this.#settings = settings;
213
+ this.#effectiveSettings = undefined;
214
+ }
215
+
216
+ getEffectiveSettingsForTest(): EffectiveStatusLineSettings {
217
+ return this.#resolveSettings();
207
218
  }
208
219
 
209
220
  setAutoCompactEnabled(enabled: boolean): void {
@@ -594,10 +605,14 @@ export class StatusLineComponent implements Component {
594
605
  };
595
606
  }
596
607
 
597
- #resolveSettings(): Required<
598
- Pick<StatusLineSettings, "leftSegments" | "rightSegments" | "separator" | "segmentOptions">
599
- > &
600
- StatusLineSettings {
608
+ #resolveSettings(): EffectiveStatusLineSettings {
609
+ if (this.#effectiveSettings === undefined) {
610
+ this.#effectiveSettings = this.#computeEffectiveSettings();
611
+ }
612
+ return this.#effectiveSettings;
613
+ }
614
+
615
+ #computeEffectiveSettings(): EffectiveStatusLineSettings {
601
616
  const preset = this.#settings.preset ?? "default";
602
617
  const presetDef = getPreset(preset);
603
618
  const useCustomSegments = preset === "custom";
@@ -9,7 +9,7 @@ Spaghetti code? Try complaining with /omfg
9
9
  Did you know? Each kitty/tmux/cmux split keeps its own session — `omp -c` resumes the right one
10
10
  Drop the word `ultrathink` in your message for harder multi-step reasoning — watch it glow rainbow as you type
11
11
  Say `orchestrate` in your message to drive a multi-phase task with parallel subagents — watch it glow as you type
12
- Say `workflow` in your message to drive the task with parallel subagents in eval — watch it glow as you type
12
+ Say `workflowz` in your message to drive the task with parallel subagents in eval — watch it glow as you type
13
13
  Log in to several accounts of the same provider — `/login` again — and omp load-balances across them automatically
14
14
  Run `omp auth-broker serve` once and every machine pulls live tokens over the wire — refresh keys never leave the host; `omp auth-gateway` fronts it as a drop-in proxy any OpenAI-compatible client can hit
15
15
  Press alt+p (or /switch) to switch provider, and ctrl+p to cycle role models smol -> slow -> etc
@@ -15,7 +15,6 @@ import {
15
15
  } from "@oh-my-pi/pi-tui";
16
16
  import { getProjectDir, logger, sanitizeText } from "@oh-my-pi/pi-utils";
17
17
  import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "../../edit";
18
- import { shimmerEnabled } from "../../modes/theme/shimmer";
19
18
  import type { Theme } from "../../modes/theme/theme";
20
19
  import { theme } from "../../modes/theme/theme";
21
20
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
@@ -31,7 +30,7 @@ import {
31
30
  renderJsonTreeLines,
32
31
  } from "../../tools/json-tree";
33
32
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
34
- import { type ToolRenderer, toolRenderers } from "../../tools/renderers";
33
+ import { toolRenderers } from "../../tools/renderers";
35
34
  import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
36
35
  import { isFramedBlockComponent, renderStatusLine } from "../../tui";
37
36
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
@@ -133,9 +132,10 @@ export interface ToolExecutionHandle {
133
132
  setExpanded(expanded: boolean): void;
134
133
  }
135
134
 
136
- /** Drive pending-tool redraws at 30fps so the animated border sweep stays
137
- * smooth without spending twice the frame budget. The TUI throttles at the same
138
- * cadence, and static frames diff to a no-op redraw at ~zero cost. */
135
+ /** Drive pending-tool redraws at 30fps so the running `task` row's shimmered
136
+ * subagent name stays smooth without spending twice the frame budget. The TUI
137
+ * throttles at the same cadence, and static frames diff to a no-op redraw at
138
+ * ~zero cost. */
139
139
  const SPINNER_RENDER_INTERVAL_MS = 1000 / 30;
140
140
  /** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
141
141
  * render cadence (mirrors `Loader`). */
@@ -425,16 +425,7 @@ export class ToolExecutionComponent extends Container {
425
425
  (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
426
426
  const isBackgroundAsyncTask = this.#toolName === "task" && isBackgroundAsyncRunning;
427
427
  const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
428
- // Sweep the border of bash/eval execution blocks while they're pending — but
429
- // not once they've been backgrounded: a backgrounded job's block gets
430
- // committed to scrollback and finalizes later via the async update path, so a
431
- // mid-sweep frame would freeze a stray dark "bar" segment into the border.
432
- const isPendingExecBlock =
433
- this.#isPartial &&
434
- shimmerEnabled() &&
435
- (this.#toolName === "bash" || this.#toolName === "eval") &&
436
- !isBackgroundAsyncRunning;
437
- const needsSpinner = isStreamingArgs || isPartialTask || isPendingExecBlock;
428
+ const needsSpinner = isStreamingArgs || isPartialTask;
438
429
  if (needsSpinner && !this.#spinnerInterval) {
439
430
  const now = performance.now();
440
431
  const frameCount = theme.spinnerFrames.length;
@@ -446,7 +437,7 @@ export class ToolExecutionComponent extends Container {
446
437
  this.#spinnerInterval = setInterval(() => {
447
438
  const now = performance.now();
448
439
  const frameCount = theme.spinnerFrames.length;
449
- // Redraw at 30fps for a smooth border sweep, but keep the spinner
440
+ // Redraw at 30fps for a smooth `task` name shimmer, but keep the spinner
450
441
  // glyph phase-locked to its classic ~12.5fps cadence. Advancing the
451
442
  // anchor by elapsed frames instead of resetting to `now` avoids the
452
443
  // 30fps timer quantizing the glyph down to one step every three ticks.
@@ -529,39 +520,6 @@ export class ToolExecutionComponent extends Container {
529
520
  return (this.#result.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
530
521
  }
531
522
 
532
- /**
533
- * While a tool's preview is still streaming, a block whose preview is
534
- * append-only (rows only grow at the bottom, never re-layout) lets the
535
- * renderer commit the scrolled-off head of an over-tall preview to native
536
- * scrollback instead of dropping it — the same anti-yank path a streaming
537
- * assistant reply uses (see {@link TranscriptContainer} +
538
- * `NativeScrollbackLiveRegion`). Covers both phases: a pre-result call preview
539
- * (a `write` whose content streams in) and a partial-result preview that
540
- * streams output below fixed input (an `eval`/`bash` whose stdout grows under
541
- * its code cell). Gated on {@link isTranscriptBlockFinalized} so the boundary
542
- * closes the instant the block reaches a terminal state — a final result that
543
- * may collapse to a compact view, a backgrounded async tool, or a seal — and
544
- * the renderer decides whether its current preview shape qualifies via
545
- * `isStreamingPreviewAppendOnly` (typically: only the expanded full view,
546
- * which is top-anchored; the collapsed tail window re-layouts but is bounded
547
- * so it never overflows anyway).
548
- */
549
- isTranscriptBlockAppendOnly(): boolean {
550
- // A finalized block's preview can collapse/re-layout; only a live,
551
- // still-streaming block is a candidate.
552
- if (this.isTranscriptBlockFinalized()) return false;
553
- const predicate =
554
- (this.#tool as { isStreamingPreviewAppendOnly?: ToolRenderer["isStreamingPreviewAppendOnly"] } | undefined)
555
- ?.isStreamingPreviewAppendOnly ?? toolRenderers[this.#toolName]?.isStreamingPreviewAppendOnly;
556
- if (!predicate) return false;
557
- try {
558
- return predicate(this.#getCallArgsForRender(), this.#renderState, this.#result);
559
- } catch (err) {
560
- logger.warn("Tool append-only predicate failed", { tool: this.#toolName, error: String(err) });
561
- return false;
562
- }
563
- }
564
-
565
523
  /**
566
524
  * Mark the tool terminal even though no result arrived (the turn aborted or
567
525
  * abandoned it) and stop animating, so it can freeze and stops pinning the
@@ -6,6 +6,8 @@ interface FrozenRender {
6
6
  width: number;
7
7
  lines: string[];
8
8
  generation: number;
9
+ appendOnly: boolean;
10
+ volatile: boolean;
9
11
  }
10
12
 
11
13
  interface SnapshotCarrier {
@@ -17,16 +19,9 @@ interface SnapshotCarrier {
17
19
  * result, an assistant message mid-stream) reports `false` so the container
18
20
  * keeps it inside the live (repaintable) region instead of freezing it. Blocks
19
21
  * without the method are treated as finalized — the default, stable behavior.
20
- *
21
- * `isTranscriptBlockAppendOnly` marks a still-live block whose rendered rows
22
- * only grow at the bottom and never re-layout (a streaming assistant reply).
23
- * Such a block's scrolled-off head is safe to commit to native scrollback even
24
- * while live; blocks that omit it (tool previews that collapse to a compact
25
- * result) keep their mutable rows deferred. Default is `false`.
26
22
  */
27
23
  interface FinalizableBlock {
28
24
  isTranscriptBlockFinalized?(): boolean;
29
- isTranscriptBlockAppendOnly?(): boolean;
30
25
  }
31
26
 
32
27
  function isBlockFinalized(child: Component): boolean {
@@ -34,11 +29,6 @@ function isBlockFinalized(child: Component): boolean {
34
29
  return fn ? fn.call(child) : true;
35
30
  }
36
31
 
37
- function isBlockAppendOnly(child: Component): boolean {
38
- const fn = (child as Component & FinalizableBlock).isTranscriptBlockAppendOnly;
39
- return fn ? fn.call(child) : false;
40
- }
41
-
42
32
  // A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
43
33
  // separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
44
34
  // to a background-colored padding row, whose escape sequences contain `\S` and
@@ -59,6 +49,73 @@ function stripPlainBlankEdges(lines: string[]): string[] {
59
49
  return start === 0 && end === lines.length ? lines : lines.slice(start, end);
60
50
  }
61
51
 
52
+ interface LiveCommitState {
53
+ appendOnly: boolean;
54
+ volatile: boolean;
55
+ safeLength: number;
56
+ }
57
+
58
+ function hasValidSnapshot(
59
+ snapshot: FrozenRender | undefined,
60
+ width: number,
61
+ generation: number,
62
+ ): snapshot is FrozenRender {
63
+ return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
64
+ }
65
+
66
+ function commonPrefixLength(prev: string[], cur: string[]): number {
67
+ const limit = Math.min(prev.length, cur.length);
68
+ let i = 0;
69
+ while (i < limit && prev[i] === cur[i]) i++;
70
+ return i;
71
+ }
72
+
73
+ function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number {
74
+ const prevLimit = prev.length - prefixLength;
75
+ const curLimit = cur.length - prefixLength;
76
+ const limit = Math.min(prevLimit, curLimit);
77
+ let i = 0;
78
+ while (i < limit && prev[prev.length - 1 - i] === cur[cur.length - 1 - i]) i++;
79
+ return i;
80
+ }
81
+
82
+ function deriveLiveCommitState(
83
+ previous: FrozenRender | undefined,
84
+ current: string[],
85
+ width: number,
86
+ generation: number,
87
+ ): LiveCommitState {
88
+ let appendOnly = false;
89
+ let volatile = false;
90
+ if (hasValidSnapshot(previous, width, generation)) {
91
+ appendOnly = previous.appendOnly;
92
+ volatile = previous.volatile;
93
+
94
+ const prefixLength = commonPrefixLength(previous.lines, current);
95
+ const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
96
+ if (!staticRender) {
97
+ const suffixLength = commonSuffixLength(previous.lines, current, prefixLength);
98
+ const stablePreviousLength = prefixLength + suffixLength;
99
+ const appendGrew =
100
+ previous.lines.length > 0 &&
101
+ current.length > previous.lines.length &&
102
+ stablePreviousLength >= previous.lines.length;
103
+ if (appendGrew && !volatile) {
104
+ appendOnly = true;
105
+ } else if (stablePreviousLength < previous.lines.length) {
106
+ volatile = true;
107
+ appendOnly = false;
108
+ }
109
+ }
110
+ }
111
+
112
+ return {
113
+ appendOnly,
114
+ volatile,
115
+ safeLength: volatile ? 0 : appendOnly ? current.length : 0,
116
+ };
117
+ }
118
+
62
119
  /**
63
120
  * Transcript container that freezes the rendered output of every block except
64
121
  * the bottom-most (live) one on terminals where committed native scrollback is
@@ -97,11 +154,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
97
154
  // render. TUI extends the native-scrollback pinned region from this point
98
155
  // through the live blocks and the root chrome rendered below them.
99
156
  #nativeScrollbackLiveRegionStart: number | undefined;
100
- // Local line index up to which the leading run of live blocks is append-only
101
- // (a streaming assistant reply): everything in [liveRegionStart,
102
- // commitSafeEnd) only grows at the bottom and never re-layouts, so its
103
- // scrolled-off head is safe to commit to native scrollback. `undefined` when
104
- // the first live block is volatile (a tool preview).
157
+ // Local line index up to which the leading run of live blocks is safe to
158
+ // commit. Finalized blocks contribute their full frozen body; still-live
159
+ // blocks contribute only after their stripped render has been observed
160
+ // growing without changing a previously rendered interior row.
105
161
  #nativeScrollbackCommitSafeEnd: number | undefined;
106
162
 
107
163
  override invalidate(): void {
@@ -164,8 +220,9 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
164
220
  if (risk) this.#prevLiveStartIndex = liveStartIndex;
165
221
 
166
222
  const lines: string[] = [];
167
- // Tracks whether we are still inside the leading run of append-only live
168
- // blocks. The first non-append-only live block closes it.
223
+ // Tracks whether we are still inside the leading run of commit-safe live
224
+ // blocks. The first still-live volatile block closes it, but rendering
225
+ // continues so lower blocks remain visible.
169
226
  let commitSafeOpen = true;
170
227
  // The live-region start is recorded at the first visible row at/after the
171
228
  // cutoff; empty leading blocks (or a separator) must not claim it early.
@@ -179,24 +236,41 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
179
236
  // instead of recomputing; a stale generation (post-thaw) or width
180
237
  // mismatch (resize) recomputes, as does a block still live last frame.
181
238
  let contribution: string[] | undefined;
239
+ const previousSnapshot = risk ? child[kSnapshot] : undefined;
182
240
  if (risk && i < liveStartIndex && i < replayCutoff) {
183
- const snapshot = child[kSnapshot];
184
- if (snapshot && snapshot.generation === this.#generation && snapshot.width === width) {
185
- contribution = snapshot.lines;
241
+ if (hasValidSnapshot(previousSnapshot, width, this.#generation)) {
242
+ contribution = previousSnapshot.lines;
186
243
  }
187
244
  }
245
+ let liveCommitState: LiveCommitState | undefined;
188
246
  if (contribution === undefined) {
189
247
  const rendered = child.render(width);
190
248
  contribution = stripPlainBlankEdges(rendered);
249
+ if (risk && i >= liveStartIndex && !isBlockFinalized(child)) {
250
+ liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
251
+ }
191
252
  // Cache every block's latest contribution. While a block is in the
192
253
  // live region this keeps its snapshot current; on the frame it crosses
193
254
  // out, the recompute above refreshes it before it freezes.
194
- if (risk) child[kSnapshot] = { width, lines: contribution, generation: this.#generation };
255
+ if (risk) {
256
+ child[kSnapshot] = {
257
+ width,
258
+ lines: contribution,
259
+ generation: this.#generation,
260
+ appendOnly: liveCommitState?.appendOnly ?? false,
261
+ volatile: liveCommitState?.volatile ?? false,
262
+ };
263
+ }
195
264
  }
196
265
 
197
266
  // Empty (or stripped-to-nothing) children contribute nothing and never
198
- // affect spacing or the live-region offsets.
199
- if (contribution.length === 0) continue;
267
+ // affect spacing or the live-region offsets. An empty still-live child
268
+ // still closes the commit-safe run: if it later gains rows, it pushes
269
+ // everything below it.
270
+ if (contribution.length === 0) {
271
+ if (risk && i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
272
+ continue;
273
+ }
200
274
 
201
275
  // Every block is separated from preceding visible content by exactly one
202
276
  // blank row — skipped when it opens the transcript or the prior row is
@@ -212,17 +286,19 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
212
286
  }
213
287
 
214
288
  if (sep) lines.push("");
289
+ const blockStart = lines.length;
215
290
  for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
216
291
 
217
- // Extend the commit-safe boundary through each leading append-only live
218
- // block. The first volatile live block closes the run so its mutable
219
- // rows stay deferred.
220
292
  if (risk && i >= liveStartIndex && commitSafeOpen) {
221
- if (isBlockAppendOnly(child)) {
222
- this.#nativeScrollbackCommitSafeEnd = lines.length;
223
- } else {
224
- commitSafeOpen = false;
293
+ const finalized = isBlockFinalized(child);
294
+ const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
295
+ if (safeLength > 0) {
296
+ this.#nativeScrollbackCommitSafeEnd = blockStart + safeLength;
225
297
  }
298
+ // A finalized, fully safe block may let the contiguous safe run extend
299
+ // into blocks rendered below it. A still-live block keeps pushing lower
300
+ // rows around as it grows, so the run closes there.
301
+ if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
226
302
  }
227
303
  }
228
304
  return lines;
@@ -15,7 +15,7 @@ export class UserMessageComponent extends Container {
15
15
  constructor(text: string, synthetic = false, imageLinks?: readonly (string | undefined)[]) {
16
16
  super();
17
17
  const bgColor = (value: string) => theme.bg("userMessageBg", value);
18
- // Paint the magic keywords ("ultrathink"/"orchestrate"/"workflow") inside the rendered
18
+ // Paint the magic keywords ("ultrathink"/"orchestrate"/"workflowz") inside the rendered
19
19
  // bubble too — matching the live editor glow. The Markdown component routes code spans and
20
20
  // fenced blocks through its own code styling (never `color`), so those are already excluded;
21
21
  // `highlightMagicKeywords` additionally restores the bubble's own foreground after each
@@ -26,6 +26,16 @@ type AgentSessionEventKind = AgentSessionEvent["type"];
26
26
 
27
27
  const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
28
28
 
29
+ /**
30
+ * Loader label shown the instant a user interrupt (Esc) is requested, kept until
31
+ * the agent turn fully unwinds. Esc fires the abort synchronously, but the loop
32
+ * only stops the spinner at `agent_end`, which it cannot reach until every
33
+ * in-flight tool settles its abort in `executeToolCalls` (`Promise.allSettled`).
34
+ * Swapping the steady "Working…" for this acknowledges the keypress instead of
35
+ * reading as an ignored Esc for the seconds a slow tool takes to tear down.
36
+ */
37
+ export const INTERRUPTING_WORKING_MESSAGE = "Interrupting…";
38
+
29
39
  // Events that change foreground streaming state, or that reset a turn. The TUI
30
40
  // eager native-scrollback rebuild mode is recomputed only on these so unrelated
31
41
  // IRC/notices/status refreshes do not toggle scrollback replay policy.
@@ -57,6 +67,7 @@ export class EventController {
57
67
  #backgroundToolCallIds = new Set<string>();
58
68
  #assistantMessageStreaming = false;
59
69
  #agentTurnActive = false;
70
+ #interrupting = false;
60
71
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
61
72
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
62
73
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
@@ -167,6 +178,7 @@ export class EventController {
167
178
  return true;
168
179
  }
169
180
  #updateWorkingMessageFromIntent(intent: unknown): void {
181
+ if (this.#interrupting) return;
170
182
  // Streamed JSON can deliver non-string `_i` (object, number, boolean) before
171
183
  // schema validation; `?.` only guards null/undefined, so guard the type too.
172
184
  if (typeof intent !== "string") return;
@@ -176,6 +188,19 @@ export class EventController {
176
188
  this.ctx.setWorkingMessage(`${trimmed}${interruptHint()}`);
177
189
  }
178
190
 
191
+ /**
192
+ * Acknowledge a user interrupt (Esc) immediately: switch the loader to
193
+ * `INTERRUPTING_WORKING_MESSAGE` and freeze intent-driven working-message
194
+ * updates for the rest of the turn so a late `tool_execution_start` intent
195
+ * cannot repaint a "Working…/<intent>" line over the acknowledgment. Reset at
196
+ * the next `agent_start`. No-op outside an active turn or if already set.
197
+ */
198
+ notifyInterrupting(): void {
199
+ if (!this.#agentTurnActive || this.#interrupting) return;
200
+ this.#interrupting = true;
201
+ this.ctx.setWorkingMessage(INTERRUPTING_WORKING_MESSAGE);
202
+ }
203
+
179
204
  subscribeToAgent(): void {
180
205
  this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
181
206
  await this.handleEvent(event);
@@ -220,6 +245,7 @@ export class EventController {
220
245
 
221
246
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
222
247
  this.#agentTurnActive = true;
248
+ this.#interrupting = false;
223
249
  this.#lastIntent = undefined;
224
250
  this.#readToolCallArgs.clear();
225
251
  this.#readToolCallAssistantComponents.clear();
@@ -694,7 +720,12 @@ export class EventController {
694
720
  // seal it so it freezes (and stops animating) rather than lingering in
695
721
  // the transcript live region as a streaming preview until the next thaw.
696
722
  const component = this.ctx.pendingTools.get(toolCallId);
697
- if (component instanceof ToolExecutionComponent) component.seal();
723
+ // A foreground read still pending at turn end shares a group component
724
+ // keyed by every read's id; seal it too so a never-delivered read does
725
+ // not keep the group live (and pinning the live region) indefinitely.
726
+ if (component instanceof ToolExecutionComponent || component instanceof ReadToolGroupComponent) {
727
+ component.seal();
728
+ }
698
729
  this.ctx.pendingTools.delete(toolCallId);
699
730
  }
700
731
  }