@oh-my-pi/pi-coding-agent 15.9.5 → 15.9.67

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 (98) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/types/config/keybindings.d.ts +4 -1
  3. package/dist/types/config/settings-schema.d.ts +11 -1
  4. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  5. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  6. package/dist/types/eval/backend.d.ts +6 -6
  7. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  8. package/dist/types/eval/idle-timeout.d.ts +16 -14
  9. package/dist/types/eval/js/executor.d.ts +3 -3
  10. package/dist/types/eval/py/executor.d.ts +2 -2
  11. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  13. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  14. package/dist/types/modes/components/model-selector.d.ts +1 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +1 -1
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  20. package/dist/types/tools/eval-render.d.ts +8 -0
  21. package/dist/types/tools/render-utils.d.ts +25 -0
  22. package/dist/types/tui/code-cell.d.ts +6 -0
  23. package/dist/types/tui/output-block.d.ts +11 -0
  24. package/package.json +9 -9
  25. package/src/autoresearch/dashboard.ts +11 -21
  26. package/src/cli/claude-trace-cli.ts +13 -1
  27. package/src/config/keybindings.ts +58 -1
  28. package/src/config/settings-schema.ts +11 -1
  29. package/src/debug/raw-sse.ts +18 -4
  30. package/src/edit/file-snapshot-store.ts +1 -1
  31. package/src/edit/index.ts +1 -1
  32. package/src/edit/renderer.ts +7 -7
  33. package/src/edit/streaming.ts +1 -1
  34. package/src/eval/__tests__/agent-bridge.test.ts +28 -27
  35. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  36. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  37. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  38. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  39. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  40. package/src/eval/agent-bridge.ts +4 -5
  41. package/src/eval/backend.ts +6 -6
  42. package/src/eval/bridge-timeout.ts +44 -0
  43. package/src/eval/idle-timeout.ts +33 -15
  44. package/src/eval/js/executor.ts +10 -10
  45. package/src/eval/llm-bridge.ts +4 -5
  46. package/src/eval/py/executor.ts +6 -6
  47. package/src/eval/py/kernel.ts +11 -1
  48. package/src/eval/py/spawn-options.ts +126 -0
  49. package/src/export/ttsr.ts +9 -0
  50. package/src/extensibility/extensions/runner.ts +2 -0
  51. package/src/internal-urls/docs-index.generated.ts +6 -5
  52. package/src/lsp/client.ts +80 -2
  53. package/src/lsp/index.ts +38 -4
  54. package/src/lsp/render.ts +3 -3
  55. package/src/main.ts +1 -1
  56. package/src/modes/components/agent-dashboard.ts +13 -4
  57. package/src/modes/components/assistant-message.ts +22 -1
  58. package/src/modes/components/copy-selector.ts +249 -0
  59. package/src/modes/components/extensions/extension-list.ts +17 -8
  60. package/src/modes/components/history-search.ts +19 -11
  61. package/src/modes/components/model-selector.ts +125 -29
  62. package/src/modes/components/oauth-selector.ts +28 -12
  63. package/src/modes/components/session-observer-overlay.ts +13 -15
  64. package/src/modes/components/session-selector.ts +24 -13
  65. package/src/modes/components/tool-execution.ts +27 -13
  66. package/src/modes/components/tree-selector.ts +19 -7
  67. package/src/modes/components/user-message-selector.ts +25 -14
  68. package/src/modes/controllers/command-controller.ts +0 -116
  69. package/src/modes/controllers/event-controller.ts +26 -10
  70. package/src/modes/controllers/selector-controller.ts +38 -1
  71. package/src/modes/interactive-mode.ts +4 -4
  72. package/src/modes/theme/theme.ts +46 -10
  73. package/src/modes/types.ts +1 -1
  74. package/src/modes/utils/copy-targets.ts +254 -0
  75. package/src/prompts/tools/ast-edit.md +1 -1
  76. package/src/prompts/tools/ast-grep.md +1 -1
  77. package/src/prompts/tools/read.md +1 -1
  78. package/src/prompts/tools/search.md +1 -1
  79. package/src/session/agent-session.ts +6 -2
  80. package/src/slash-commands/builtin-registry.ts +3 -11
  81. package/src/task/render.ts +38 -11
  82. package/src/tools/bash.ts +18 -8
  83. package/src/tools/browser/render.ts +5 -4
  84. package/src/tools/debug.ts +3 -3
  85. package/src/tools/eval-render.ts +24 -9
  86. package/src/tools/eval.ts +14 -19
  87. package/src/tools/fetch.ts +5 -5
  88. package/src/tools/read.ts +7 -7
  89. package/src/tools/render-utils.ts +46 -0
  90. package/src/tools/ssh.ts +21 -8
  91. package/src/tools/write.ts +17 -8
  92. package/src/tui/code-cell.ts +19 -4
  93. package/src/tui/output-block.ts +14 -0
  94. package/src/web/search/render.ts +3 -3
  95. package/dist/types/eval/heartbeat.d.ts +0 -45
  96. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  97. package/src/eval/heartbeat.ts +0 -74
  98. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
@@ -6,6 +6,7 @@ import {
6
6
  fuzzyMatch,
7
7
  Input,
8
8
  matchesKey,
9
+ ScrollView,
9
10
  Spacer,
10
11
  Text,
11
12
  TruncatedText,
@@ -492,6 +493,10 @@ class TreeList implements Component {
492
493
  const contentReserve = Math.max(MIN_CONTENT_COLS, Math.floor(width / 2));
493
494
  const maxIndentLevels = Math.max(1, Math.floor((width - contentReserve - OVERHEAD_COLS) / 3));
494
495
 
496
+ const overflow = this.#filteredNodes.length > this.maxVisibleLines;
497
+ const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
498
+ const rows: string[] = [];
499
+
495
500
  for (let i = startIndex; i < endIndex; i++) {
496
501
  const flatNode = this.#filteredNodes[i];
497
502
  const entry = flatNode.node.entry;
@@ -560,15 +565,22 @@ class TreeList implements Component {
560
565
  if (isSelected) {
561
566
  line = theme.bg("selectedBg", line);
562
567
  }
563
- lines.push(truncateToWidth(line, width));
568
+ rows.push(truncateToWidth(line, rowWidth));
564
569
  }
565
570
 
566
- lines.push(
567
- truncateToWidth(
568
- theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.#filteredNodes.length})${this.#getFilterLabel()}`),
569
- width,
570
- ),
571
- );
571
+ const sv = new ScrollView(rows, {
572
+ height: rows.length,
573
+ scrollbar: "auto",
574
+ totalRows: this.#filteredNodes.length,
575
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
576
+ });
577
+ sv.setScrollOffset(startIndex);
578
+ lines.push(...sv.render(width));
579
+
580
+ const filterLabel = this.#getFilterLabel();
581
+ if (filterLabel) {
582
+ lines.push(truncateToWidth(theme.fg("muted", ` ${filterLabel.trim()}`), width));
583
+ }
572
584
 
573
585
  return lines;
574
586
  }
@@ -4,6 +4,7 @@ import {
4
4
  extractPrintableText,
5
5
  fuzzyFilter,
6
6
  matchesKey,
7
+ ScrollView,
7
8
  Spacer,
8
9
  Text,
9
10
  truncateToWidth,
@@ -48,14 +49,10 @@ class UserMessageList implements Component {
48
49
  return this.#isSearchEnabled() || this.#searchQuery.length > 0;
49
50
  }
50
51
 
51
- #renderStatusLine(total: number): string {
52
- const selectedCount = total === 0 ? 0 : this.#selectedIndex + 1;
53
- const count =
54
- this.#searchQuery.trim() && total !== this.messages.length
55
- ? `${selectedCount}/${total} of ${this.messages.length}`
56
- : `${selectedCount}/${total}`;
57
- const suffix = this.#searchQuery.trim() ? ` Search: ${this.#searchQuery}` : " Type to search";
58
- return theme.fg("muted", ` (${count})${suffix}`);
52
+ #renderStatusLine(_total: number): string {
53
+ const query = this.#searchQuery.trim();
54
+ const suffix = query ? `Search: ${this.#searchQuery}` : "Type to search";
55
+ return theme.fg("muted", ` ${suffix}`);
59
56
  }
60
57
 
61
58
  #setSearchQuery(query: string): void {
@@ -103,6 +100,9 @@ class UserMessageList implements Component {
103
100
  const endIndex = Math.min(startIndex + this.#maxVisible, total);
104
101
 
105
102
  // Render visible messages (2 lines per message + blank line)
103
+ const overflow = total > this.#maxVisible;
104
+ const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
105
+ const messageLines: string[] = [];
106
106
  for (let i = startIndex; i < endIndex; i++) {
107
107
  const message = this.#filteredMessages[i];
108
108
  if (!message) continue;
@@ -113,26 +113,37 @@ class UserMessageList implements Component {
113
113
 
114
114
  // First line: cursor + message
115
115
  const cursor = isSelected ? theme.fg("accent", "› ") : " ";
116
- const maxMsgWidth = width - 2; // Account for cursor (2 chars)
116
+ const maxMsgWidth = rowWidth - 2; // Account for cursor (2 chars)
117
117
  const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth);
118
118
  const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
119
119
 
120
- lines.push(messageLine);
120
+ messageLines.push(messageLine);
121
121
 
122
122
  // Second line: metadata (position in history)
123
123
  const position = this.messages.indexOf(message) + 1;
124
124
  const metadata = ` Message ${position} of ${this.messages.length}`;
125
125
  const metadataLine = theme.fg("muted", metadata);
126
- lines.push(metadataLine);
127
- lines.push(""); // Blank line between messages
126
+ messageLines.push(metadataLine);
127
+ messageLines.push(""); // Blank line between messages
128
128
  }
129
129
 
130
130
  if (total === 0) {
131
131
  lines.push(theme.fg("muted", " No matching messages"));
132
+ } else {
133
+ const visibleCount = endIndex - startIndex;
134
+ const linesPerItem = visibleCount > 0 ? messageLines.length / visibleCount : 1;
135
+ const sv = new ScrollView(messageLines, {
136
+ height: messageLines.length,
137
+ scrollbar: "auto",
138
+ totalRows: Math.round(total * linesPerItem),
139
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
140
+ });
141
+ sv.setScrollOffset(Math.round(startIndex * linesPerItem));
142
+ lines.push(...sv.render(width));
132
143
  }
133
144
 
134
- // Add scroll/search indicator if needed
135
- if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus()) {
145
+ // Add search indicator if needed
146
+ if (this.#shouldRenderSearchStatus()) {
136
147
  lines.push(this.#renderStatusLine(total));
137
148
  }
138
149
 
@@ -6,7 +6,6 @@ import {
6
6
  getEnvApiKey,
7
7
  getProviderDetails,
8
8
  type ProviderDetails,
9
- type ToolCall,
10
9
  type UsageLimit,
11
10
  type UsageReport,
12
11
  } from "@oh-my-pi/pi-ai";
@@ -239,121 +238,6 @@ export class CommandController {
239
238
  }
240
239
  }
241
240
 
242
- handleCopyCommand(sub?: string) {
243
- switch (sub) {
244
- case "code":
245
- return this.#copyCode();
246
- case "all":
247
- return this.#copyAllCode();
248
- case "cmd":
249
- return this.#copyLastCommand();
250
- case "last":
251
- case undefined:
252
- return this.#copyLastMessage();
253
- default:
254
- this.ctx.showError(`Unknown subcommand: ${sub}. Use code, all, cmd, or last.`);
255
- }
256
- }
257
-
258
- #copyLastMessage() {
259
- const assistantText = this.ctx.session.getLastAssistantText();
260
- if (assistantText) {
261
- this.#doCopy(assistantText, "Copied last agent message to clipboard");
262
- return;
263
- }
264
-
265
- if (!this.ctx.session.hasCopyCandidateAssistantMessage()) {
266
- const handoffText = this.ctx.session.getLastVisibleHandoffText();
267
- if (handoffText) {
268
- this.#doCopy(handoffText, "Copied handoff context to clipboard");
269
- return;
270
- }
271
- }
272
-
273
- this.ctx.showError("No agent messages to copy yet.");
274
- }
275
-
276
- #copyCode() {
277
- const text = this.ctx.session.getLastAssistantText();
278
- if (!text) {
279
- this.ctx.showError("No agent messages to copy yet.");
280
- return;
281
- }
282
- const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)];
283
- const lastMatch = matches.at(-1);
284
- if (!lastMatch) {
285
- this.ctx.showWarning("No code block found in the last agent message.");
286
- return;
287
- }
288
- this.#doCopy(lastMatch[1].replace(/\n$/, ""), "Copied last code block to clipboard");
289
- }
290
-
291
- #copyAllCode() {
292
- const text = this.ctx.session.getLastAssistantText();
293
- if (!text) {
294
- this.ctx.showError("No agent messages to copy yet.");
295
- return;
296
- }
297
- const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)];
298
- if (matches.length === 0) {
299
- this.ctx.showWarning("No code blocks found in the last agent message.");
300
- return;
301
- }
302
- const combined = matches.map(m => m[1].replace(/\n$/, "")).join("\n\n");
303
- this.#doCopy(combined, `Copied ${matches.length} code block${matches.length > 1 ? "s" : ""} to clipboard`);
304
- }
305
-
306
- #extractEvalCode(args: unknown): string | undefined {
307
- if (!args || typeof args !== "object") return undefined;
308
- const cells = (args as { cells?: unknown }).cells;
309
- if (!Array.isArray(cells)) return undefined;
310
-
311
- const codeBlocks: string[] = [];
312
- for (const cell of cells) {
313
- if (!cell || typeof cell !== "object") continue;
314
- const code = (cell as { code?: unknown }).code;
315
- if (typeof code === "string" && code.length > 0) {
316
- codeBlocks.push(code);
317
- }
318
- }
319
-
320
- return codeBlocks.length > 0 ? codeBlocks.join("\n\n") : undefined;
321
- }
322
-
323
- #copyLastCommand() {
324
- const messages = this.ctx.session.messages;
325
- // Walk backwards to find the last bash/eval tool call
326
- for (let i = messages.length - 1; i >= 0; i--) {
327
- const msg = messages[i];
328
- if (msg.role !== "assistant") continue;
329
- const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall");
330
- for (let j = toolCalls.length - 1; j >= 0; j--) {
331
- const tc = toolCalls[j];
332
- if (tc.name === "bash" && typeof tc.arguments.command === "string") {
333
- this.#doCopy(tc.arguments.command, "Copied last bash command to clipboard");
334
- return;
335
- }
336
- if (tc.name === "eval") {
337
- const code = this.#extractEvalCode(tc.arguments);
338
- if (code) {
339
- this.#doCopy(code, "Copied last eval code to clipboard");
340
- return;
341
- }
342
- }
343
- }
344
- }
345
- this.ctx.showWarning("No bash or eval command found in the conversation.");
346
- }
347
-
348
- #doCopy(content: string, label: string) {
349
- try {
350
- copyToClipboard(content);
351
- this.ctx.showStatus(label);
352
- } catch (error) {
353
- this.ctx.showError(error instanceof Error ? error.message : String(error));
354
- }
355
- }
356
-
357
241
  async handleSessionCommand(): Promise<void> {
358
242
  const stats = this.ctx.session.getSessionStats();
359
243
  const premiumRequests =
@@ -49,9 +49,15 @@ export class EventController {
49
49
  #lastIntent: string | undefined = undefined;
50
50
  #backgroundToolCallIds = new Set<string>();
51
51
  #assistantMessageStreaming = false;
52
+ #agentTurnActive = false;
52
53
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
53
54
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
54
55
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
56
+ // Assistant component whose turn-ending error is currently mirrored in the
57
+ // pinned banner. Its inline `Error: …` line is suppressed while pinned and
58
+ // restored when the banner clears at the next `agent_start` (see
59
+ // #handleMessageEnd / #handleAgentStart).
60
+ #pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
55
61
  #idleCompactionTimer?: NodeJS.Timeout;
56
62
  #ircExpiryTimers = new Map<string, NodeJS.Timeout>();
57
63
  #handlers: AgentSessionEventHandlers;
@@ -172,21 +178,21 @@ export class EventController {
172
178
 
173
179
  const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
174
180
  await run(event);
175
- // While assistant text or a foreground tool is streaming, rows above the
176
- // viewport can re-layout after they have already entered native scrollback
177
- // (Markdown fences, wrapping, previews). Let the TUI rebuild history on
178
- // those offscreen edits instead of deferring, which otherwise leaves stale
179
- // tail rows duplicated above the live viewport.
180
- // Background-running tools are excluded so late async updates outside the
181
- // active foreground stream keep the no-yank deferral; agent_start resets
182
- // the mode at every turn boundary.
181
+ // While an assistant turn is active, visible status chrome and foreground
182
+ // transcript blocks can re-render after rows have entered native scrollback
183
+ // (idle Working loader, Markdown fences, wrapping, tool previews). Let the
184
+ // TUI use its foreground live-region path instead of idle deferral, which
185
+ // can otherwise leave the loader/status frame frozen until the next input.
186
+ // Background-running tools after the turn ends are excluded so late async
187
+ // updates keep the no-yank deferral; agent_start/agent_end bracket the
188
+ // foreground turn.
183
189
  if (STREAM_RENDER_MODE_EVENTS[event.type]) {
184
190
  this.#refreshToolRenderMode();
185
191
  }
186
192
  }
187
193
 
188
194
  #refreshToolRenderMode(): void {
189
- let foregroundToolActive = this.#assistantMessageStreaming;
195
+ let foregroundToolActive = this.#agentTurnActive || this.#assistantMessageStreaming;
190
196
  if (!foregroundToolActive) {
191
197
  for (const toolCallId of this.ctx.pendingTools.keys()) {
192
198
  if (!this.#backgroundToolCallIds.has(toolCallId)) {
@@ -199,11 +205,16 @@ export class EventController {
199
205
  }
200
206
 
201
207
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
208
+ this.#agentTurnActive = true;
202
209
  this.#lastIntent = undefined;
203
210
  this.#readToolCallArgs.clear();
204
211
  this.#readToolCallAssistantComponents.clear();
205
212
  this.#assistantMessageStreaming = false;
206
213
  this.#lastAssistantComponent = undefined;
214
+ // Restore the previous turn's inline error in the transcript before dropping
215
+ // the banner, so the error stays in history once the banner is gone.
216
+ this.#pinnedErrorComponent?.setErrorPinned(false);
217
+ this.#pinnedErrorComponent = undefined;
207
218
  this.ctx.clearPinnedError();
208
219
  if (this.ctx.retryEscapeHandler) {
209
220
  this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
@@ -215,6 +226,7 @@ export class EventController {
215
226
  this.ctx.statusContainer.clear();
216
227
  }
217
228
  this.#cancelIdleCompaction();
229
+ this.#refreshToolRenderMode();
218
230
  this.ctx.ensureLoadingAnimation();
219
231
  this.ctx.ui.requestRender();
220
232
  }
@@ -493,12 +505,15 @@ export class EventController {
493
505
  this.ctx.streamingMessage = undefined;
494
506
  // Pin a turn-ending provider error (e.g. Anthropic content-filter block)
495
507
  // above the editor so it survives transcript scroll. Cleared at the next
496
- // turn's agent_start.
508
+ // turn's agent_start. Suppress the transcript's inline `Error: …` line for
509
+ // the same message while pinned so the error isn't rendered twice.
497
510
  if (
498
511
  event.message.stopReason === "error" &&
499
512
  event.message.errorMessage &&
500
513
  !isSilentAbort(event.message.errorMessage)
501
514
  ) {
515
+ this.#lastAssistantComponent?.setErrorPinned(true);
516
+ this.#pinnedErrorComponent = this.#lastAssistantComponent;
502
517
  this.ctx.showPinnedError(event.message.errorMessage);
503
518
  }
504
519
  this.ctx.statusLine.invalidate();
@@ -646,6 +661,7 @@ export class EventController {
646
661
  }
647
662
  }
648
663
  async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
664
+ this.#agentTurnActive = false;
649
665
  this.#assistantMessageStreaming = false;
650
666
  if (this.ctx.loadingAnimation) {
651
667
  this.ctx.loadingAnimation.stop();
@@ -37,9 +37,11 @@ import {
37
37
  setPreferredSearchProvider,
38
38
  } from "../../tools";
39
39
  import { shortenPath } from "../../tools/render-utils";
40
+ import { copyToClipboard } from "../../utils/clipboard";
40
41
  import { setSessionTerminalTitle } from "../../utils/title-generator";
41
42
  import { AgentDashboard } from "../components/agent-dashboard";
42
43
  import { AssistantMessageComponent } from "../components/assistant-message";
44
+ import { CopySelectorComponent } from "../components/copy-selector";
43
45
  import { ExtensionDashboard } from "../components/extensions";
44
46
  import { HistorySearchComponent } from "../components/history-search";
45
47
  import { ModelSelectorComponent } from "../components/model-selector";
@@ -52,6 +54,8 @@ import { ToolExecutionComponent } from "../components/tool-execution";
52
54
  import { TreeSelectorComponent } from "../components/tree-selector";
53
55
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
54
56
  import type { SessionObserverRegistry } from "../session-observer-registry";
57
+ import { computeContextBreakdown } from "../utils/context-usage";
58
+ import { buildCopyTargets } from "../utils/copy-targets";
55
59
 
56
60
  const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
57
61
  "anthropic",
@@ -407,6 +411,7 @@ export class SelectorController {
407
411
  }
408
412
 
409
413
  showModelSelector(options?: { temporaryOnly?: boolean }): void {
414
+ const currentContextTokens = computeContextBreakdown(this.ctx.session).usedTokens;
410
415
  this.showSelector(done => {
411
416
  const selector = new ModelSelectorComponent(
412
417
  this.ctx.ui,
@@ -470,7 +475,7 @@ export class SelectorController {
470
475
  done();
471
476
  this.ctx.ui.requestRender();
472
477
  },
473
- options,
478
+ { ...options, currentContextTokens },
474
479
  );
475
480
  return { component: selector, focus: selector };
476
481
  });
@@ -598,6 +603,38 @@ export class SelectorController {
598
603
  });
599
604
  }
600
605
 
606
+ showCopySelector(): void {
607
+ const targets = buildCopyTargets(this.ctx.session);
608
+ if (targets.length === 0) {
609
+ this.ctx.showStatus("Nothing to copy yet.");
610
+ return;
611
+ }
612
+
613
+ let overlayHandle: OverlayHandle | undefined;
614
+ const done = () => {
615
+ overlayHandle?.hide();
616
+ this.ctx.ui.requestRender();
617
+ };
618
+ const selector = new CopySelectorComponent(targets, {
619
+ onPick: target => {
620
+ done();
621
+ if (target.content === undefined) return;
622
+ void copyToClipboard(target.content);
623
+ this.ctx.showStatus(target.copyMessage ?? "Copied to clipboard");
624
+ },
625
+ onCancel: done,
626
+ });
627
+
628
+ overlayHandle = this.ctx.ui.showOverlay(selector, {
629
+ anchor: "bottom-center",
630
+ width: "100%",
631
+ maxHeight: "100%",
632
+ margin: 0,
633
+ });
634
+ this.ctx.ui.setFocus(selector);
635
+ this.ctx.ui.requestRender();
636
+ }
637
+
601
638
  showTreeSelector(): void {
602
639
  const tree = this.ctx.sessionManager.getTree();
603
640
  const realLeafId = this.ctx.sessionManager.getLeafId();
@@ -2700,10 +2700,6 @@ export class InteractiveMode implements InteractiveModeContext {
2700
2700
  return this.#commandController.handleShareCommand();
2701
2701
  }
2702
2702
 
2703
- handleCopyCommand(sub?: string) {
2704
- return this.#commandController.handleCopyCommand(sub);
2705
- }
2706
-
2707
2703
  handleTodoCommand(args: string): Promise<void> {
2708
2704
  return this.#todoCommandController.handleTodoCommand(args);
2709
2705
  }
@@ -2936,6 +2932,10 @@ export class InteractiveMode implements InteractiveModeContext {
2936
2932
  this.#selectorController.showUserMessageSelector();
2937
2933
  }
2938
2934
 
2935
+ showCopySelector(): void {
2936
+ this.#selectorController.showCopySelector();
2937
+ }
2938
+
2939
2939
  showTreeSelector(): void {
2940
2940
  this.#selectorController.showTreeSelector();
2941
2941
  }
@@ -12,6 +12,7 @@ import {
12
12
  import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@oh-my-pi/pi-tui";
13
13
  import { adjustHsv, colorLuma, getCustomThemesDir, isEnoent, logger, relativeLuminance } from "@oh-my-pi/pi-utils";
14
14
  import chalk from "chalk";
15
+ import { LRUCache } from "lru-cache/raw";
15
16
  import * as z from "zod/v4";
16
17
  // Embed theme JSON files at build time
17
18
  import darkThemeJson from "./dark.json" with { type: "json" };
@@ -2429,17 +2430,54 @@ function getHighlightColors(t: Theme): NativeHighlightColors {
2429
2430
  return cachedHighlightColors;
2430
2431
  }
2431
2432
 
2433
+ /**
2434
+ * Memoized native syntax highlight. Returns the joined ANSI string, or `null`
2435
+ * when the native tokenizer throws so callers can apply their own fallback.
2436
+ *
2437
+ * Keyed on `(lang, code)` and reset whenever the active `theme` instance
2438
+ * changes — the ANSI colors are baked into the highlighted output, so a theme
2439
+ * switch (which always reassigns `theme`) must invalidate every entry.
2440
+ *
2441
+ * Why this exists: animated tool blocks (eval/bash) repaint their box on every
2442
+ * ~16ms border-shimmer frame, and markdown re-lexes on every streamed delta.
2443
+ * Without memoization each frame re-tokenizes an unchanged code body through the
2444
+ * Rust FFI — ~26ms for 100 lines, ~40ms for 150 — overrunning the 16ms frame
2445
+ * budget and starving the spinner/render timers (the "TUI freeze").
2446
+ */
2447
+ const HIGHLIGHT_CACHE_MAX = 256;
2448
+ const highlightCache = new LRUCache<string, string>({ max: HIGHLIGHT_CACHE_MAX });
2449
+ let highlightCacheTheme: Theme | undefined;
2450
+
2451
+ function highlightCached(code: string, validLang: string | undefined): string | null {
2452
+ if (highlightCacheTheme !== theme) {
2453
+ highlightCache.clear();
2454
+ highlightCacheTheme = theme;
2455
+ }
2456
+ const key = `${validLang ?? ""}\x00${code}`;
2457
+ const hit = highlightCache.get(key);
2458
+ if (hit !== undefined) {
2459
+ return hit;
2460
+ }
2461
+ let highlighted: string;
2462
+ try {
2463
+ highlighted = nativeHighlightCode(code, validLang, getHighlightColors(theme));
2464
+ } catch {
2465
+ return null;
2466
+ }
2467
+ highlightCache.set(key, highlighted);
2468
+ return highlighted;
2469
+ }
2470
+
2432
2471
  /**
2433
2472
  * Highlight code with syntax coloring based on file extension or language.
2434
2473
  * Returns array of highlighted lines.
2435
2474
  */
2436
2475
  export function highlightCode(code: string, lang?: string): string[] {
2437
2476
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2438
- try {
2439
- return nativeHighlightCode(code, validLang, getHighlightColors(theme)).split("\n");
2440
- } catch {
2441
- return code.split("\n");
2442
- }
2477
+ const highlighted = highlightCached(code, validLang);
2478
+ // Always return a fresh array: callers (e.g. renderCodeCell) push extra lines
2479
+ // onto the result, which would corrupt the cached string otherwise.
2480
+ return (highlighted ?? code).split("\n");
2443
2481
  }
2444
2482
 
2445
2483
  export function getSymbolTheme(): SymbolTheme {
@@ -2484,11 +2522,9 @@ export function getMarkdownTheme(): MarkdownTheme {
2484
2522
  resolveMermaidAscii,
2485
2523
  highlightCode: (code: string, lang?: string): string[] => {
2486
2524
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2487
- try {
2488
- return nativeHighlightCode(code, validLang, getHighlightColors(theme)).split("\n");
2489
- } catch {
2490
- return code.split("\n").map(line => theme.fg("mdCodeBlock", line));
2491
- }
2525
+ const highlighted = highlightCached(code, validLang);
2526
+ if (highlighted !== null) return highlighted.split("\n");
2527
+ return code.split("\n").map(line => theme.fg("mdCodeBlock", line));
2492
2528
  },
2493
2529
  };
2494
2530
  cachedMarkdownTheme = markdownTheme;
@@ -222,7 +222,6 @@ export interface InteractiveModeContext {
222
222
  // Command handling
223
223
  handleExportCommand(text: string): Promise<void>;
224
224
  handleShareCommand(): Promise<void>;
225
- handleCopyCommand(sub?: string): void;
226
225
  handleTodoCommand(args: string): Promise<void>;
227
226
  handleSessionCommand(): Promise<void>;
228
227
  handleJobsCommand(): Promise<void>;
@@ -263,6 +262,7 @@ export interface InteractiveModeContext {
263
262
  showModelSelector(options?: { temporaryOnly?: boolean }): void;
264
263
  showPluginSelector(mode?: "install" | "uninstall"): void;
265
264
  showUserMessageSelector(): void;
265
+ showCopySelector(): void;
266
266
  showTreeSelector(): void;
267
267
  showSessionSelector(): void;
268
268
  handleResumeSession(sessionPath: string): Promise<void>;