@oh-my-pi/pi-coding-agent 15.7.5 → 15.8.0

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 (146) hide show
  1. package/CHANGELOG.md +159 -182
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +11 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  12. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  13. package/dist/types/eval/heartbeat.d.ts +45 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  16. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  18. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  19. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  20. package/dist/types/lsp/index.d.ts +2 -0
  21. package/dist/types/lsp/utils.d.ts +4 -0
  22. package/dist/types/main.d.ts +5 -0
  23. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  24. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  25. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  26. package/dist/types/modes/components/hook-selector.d.ts +10 -1
  27. package/dist/types/modes/components/session-selector.d.ts +32 -5
  28. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  29. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -3
  30. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  31. package/dist/types/modes/interactive-mode.d.ts +10 -3
  32. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  33. package/dist/types/modes/theme/theme.d.ts +1 -1
  34. package/dist/types/modes/types.d.ts +5 -3
  35. package/dist/types/registry/agent-registry.d.ts +1 -1
  36. package/dist/types/sdk.d.ts +2 -2
  37. package/dist/types/session/agent-session.d.ts +12 -3
  38. package/dist/types/session/history-storage.d.ts +16 -1
  39. package/dist/types/session/session-manager.d.ts +4 -0
  40. package/dist/types/task/output-manager.d.ts +6 -15
  41. package/dist/types/tools/ask.d.ts +8 -6
  42. package/dist/types/tools/eval-backends.d.ts +12 -0
  43. package/dist/types/tools/eval-render.d.ts +52 -0
  44. package/dist/types/tools/eval.d.ts +2 -35
  45. package/dist/types/tools/find.d.ts +0 -9
  46. package/dist/types/tools/index.d.ts +5 -12
  47. package/dist/types/tools/path-utils.d.ts +16 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  49. package/dist/types/tui/output-block.d.ts +4 -3
  50. package/dist/types/utils/clipboard.d.ts +4 -0
  51. package/dist/types/web/kagi.d.ts +76 -0
  52. package/dist/types/web/search/providers/exa.d.ts +7 -1
  53. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  54. package/examples/extensions/README.md +1 -0
  55. package/examples/extensions/thinking-note.ts +13 -0
  56. package/package.json +9 -9
  57. package/src/async/job-manager.ts +3 -3
  58. package/src/cli/args.ts +6 -2
  59. package/src/cli/claude-trace-cli.ts +783 -0
  60. package/src/cli/session-picker.ts +36 -10
  61. package/src/cli/update-cli.ts +35 -2
  62. package/src/commands/launch.ts +3 -0
  63. package/src/config/keybindings.ts +6 -0
  64. package/src/config/model-registry.ts +33 -4
  65. package/src/config/settings-schema.ts +12 -2
  66. package/src/config/settings.ts +23 -0
  67. package/src/discovery/claude-plugins.ts +7 -9
  68. package/src/discovery/claude.ts +41 -22
  69. package/src/edit/index.ts +23 -3
  70. package/src/eval/__tests__/agent-bridge.test.ts +148 -4
  71. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  72. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  73. package/src/eval/agent-bridge.ts +44 -38
  74. package/src/eval/concurrency-bridge.ts +34 -0
  75. package/src/eval/heartbeat.ts +74 -0
  76. package/src/eval/js/executor.ts +13 -9
  77. package/src/eval/js/shared/prelude.txt +20 -17
  78. package/src/eval/js/tool-bridge.ts +5 -0
  79. package/src/eval/llm-bridge.ts +20 -14
  80. package/src/eval/py/executor.ts +14 -18
  81. package/src/eval/py/prelude.py +23 -15
  82. package/src/exec/bash-executor.ts +31 -5
  83. package/src/extensibility/extensions/loader.ts +16 -18
  84. package/src/extensibility/extensions/runner.ts +22 -17
  85. package/src/extensibility/extensions/types.ts +39 -5
  86. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  87. package/src/extensibility/skills.ts +0 -1
  88. package/src/internal-urls/docs-index.generated.ts +14 -13
  89. package/src/lsp/diagnostics-ledger.ts +51 -0
  90. package/src/lsp/index.ts +9 -22
  91. package/src/lsp/utils.ts +21 -0
  92. package/src/main.ts +92 -24
  93. package/src/modes/acp/acp-agent.ts +8 -4
  94. package/src/modes/acp/acp-event-mapper.ts +54 -4
  95. package/src/modes/components/assistant-message.ts +28 -1
  96. package/src/modes/components/custom-editor.ts +19 -7
  97. package/src/modes/components/hook-selector.ts +229 -44
  98. package/src/modes/components/oauth-selector.ts +12 -6
  99. package/src/modes/components/session-selector.ts +179 -24
  100. package/src/modes/components/tool-execution.ts +36 -7
  101. package/src/modes/controllers/command-controller.ts +2 -11
  102. package/src/modes/controllers/event-controller.ts +5 -2
  103. package/src/modes/controllers/extension-ui-controller.ts +6 -4
  104. package/src/modes/controllers/input-controller.ts +19 -16
  105. package/src/modes/controllers/selector-controller.ts +61 -21
  106. package/src/modes/interactive-mode.ts +127 -16
  107. package/src/modes/rpc/rpc-mode.ts +17 -6
  108. package/src/modes/theme/theme-schema.json +30 -0
  109. package/src/modes/theme/theme.ts +39 -2
  110. package/src/modes/types.ts +7 -3
  111. package/src/modes/utils/ui-helpers.ts +5 -2
  112. package/src/prompts/system/orchestrate-notice.md +5 -3
  113. package/src/prompts/system/workflow-notice.md +2 -2
  114. package/src/prompts/tools/ask.md +2 -1
  115. package/src/prompts/tools/eval.md +6 -6
  116. package/src/prompts/tools/find.md +1 -1
  117. package/src/prompts/tools/irc.md +6 -6
  118. package/src/prompts/tools/search.md +1 -1
  119. package/src/prompts/tools/task.md +1 -1
  120. package/src/registry/agent-registry.ts +1 -1
  121. package/src/sdk.ts +85 -31
  122. package/src/session/agent-session.ts +127 -57
  123. package/src/session/history-storage.ts +56 -12
  124. package/src/session/session-manager.ts +34 -0
  125. package/src/task/output-manager.ts +40 -48
  126. package/src/task/render.ts +3 -8
  127. package/src/tools/ask.ts +74 -32
  128. package/src/tools/browser/tab-worker.ts +8 -5
  129. package/src/tools/eval-backends.ts +38 -0
  130. package/src/tools/eval-render.ts +750 -0
  131. package/src/tools/eval.ts +27 -754
  132. package/src/tools/find.ts +5 -29
  133. package/src/tools/index.ts +8 -38
  134. package/src/tools/path-utils.ts +144 -1
  135. package/src/tools/read.ts +47 -0
  136. package/src/tools/renderers.ts +1 -1
  137. package/src/tools/search.ts +2 -27
  138. package/src/tools/sqlite-reader.ts +92 -9
  139. package/src/tools/write.ts +9 -1
  140. package/src/tui/output-block.ts +5 -4
  141. package/src/utils/clipboard.ts +38 -1
  142. package/src/utils/open.ts +37 -2
  143. package/src/web/kagi.ts +168 -49
  144. package/src/web/search/providers/anthropic.ts +1 -1
  145. package/src/web/search/providers/exa.ts +20 -86
  146. package/src/web/search/providers/kagi.ts +4 -0
@@ -15,9 +15,52 @@ import { formatBytes } from "@oh-my-pi/pi-utils";
15
15
  import { theme } from "../../modes/theme/theme";
16
16
  import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
17
17
  import type { SessionInfo } from "../../session/session-manager";
18
+ import { shortenPath } from "../../tools/render-utils";
18
19
  import { DynamicBorder } from "./dynamic-border";
19
20
  import { HookSelectorComponent } from "./hook-selector";
20
21
 
22
+ /** Returns the IDs of sessions whose recorded prompts match a query, best first. */
23
+ export type SessionHistoryMatcher = (query: string) => string[];
24
+
25
+ /**
26
+ * Combine fuzzy session matches with prompt-history matches for ranking, using
27
+ * both signals rather than replacing one with the other.
28
+ *
29
+ * - `fuzzy` is the ordered fuzzy-filter result over session metadata (best first).
30
+ * - `historyIds` are session IDs whose recorded prompts matched the query,
31
+ * ordered by history relevance (best first); duplicates are tolerated.
32
+ *
33
+ * Ranking: sessions matched by **both** signals lead (keeping fuzzy order), then
34
+ * fuzzy-only matches, then history-only matches (by history order). A fuzzy match
35
+ * is never dropped, and history matches not present in `allSessions` (e.g. deleted
36
+ * or out-of-scope sessions) are ignored since they cannot be resumed from here.
37
+ */
38
+ export function mergeSessionRanking(
39
+ allSessions: SessionInfo[],
40
+ fuzzy: SessionInfo[],
41
+ historyIds: string[],
42
+ ): SessionInfo[] {
43
+ const historyRank = new Map<string, number>();
44
+ historyIds.forEach((id, index) => {
45
+ if (!historyRank.has(id)) historyRank.set(id, index);
46
+ });
47
+ if (historyRank.size === 0) return fuzzy;
48
+
49
+ const both: SessionInfo[] = [];
50
+ const fuzzyOnly: SessionInfo[] = [];
51
+ const fuzzyPaths = new Set<string>();
52
+ for (const session of fuzzy) {
53
+ fuzzyPaths.add(session.path);
54
+ (historyRank.has(session.id) ? both : fuzzyOnly).push(session);
55
+ }
56
+
57
+ const historyOnly = allSessions
58
+ .filter(session => historyRank.has(session.id) && !fuzzyPaths.has(session.path))
59
+ .sort((a, b) => (historyRank.get(a.id) ?? 0) - (historyRank.get(b.id) ?? 0));
60
+
61
+ return [...both, ...fuzzyOnly, ...historyOnly];
62
+ }
63
+
21
64
  /**
22
65
  * Custom session list component with multi-line items and search
23
66
  */
@@ -25,33 +68,44 @@ class SessionList implements Component {
25
68
  #filteredSessions: SessionInfo[] = [];
26
69
  #selectedIndex: number = 0;
27
70
  readonly #searchInput: Input;
28
- onSelect?: (sessionPath: string) => void;
71
+ onSelect?: (session: SessionInfo) => void;
29
72
  onCancel?: () => void;
30
73
  onExit: () => void = () => {};
74
+ onToggleScope?: () => void;
31
75
  #maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
32
76
 
33
77
  onDeleteRequest?: (session: SessionInfo) => void;
34
78
 
35
- constructor(
36
- private readonly allSessions: SessionInfo[],
37
- private readonly showCwd = false,
38
- ) {
39
- this.#filteredSessions = allSessions;
79
+ #allSessions: SessionInfo[];
80
+ #showCwd: boolean;
81
+ readonly #historyMatcher?: SessionHistoryMatcher;
82
+
83
+ constructor(sessions: SessionInfo[], showCwd = false, historyMatcher?: SessionHistoryMatcher) {
84
+ this.#allSessions = sessions;
85
+ this.#showCwd = showCwd;
86
+ this.#historyMatcher = historyMatcher;
87
+ this.#filteredSessions = sessions;
40
88
  this.#searchInput = new Input();
41
89
 
42
90
  // Handle Enter in search input - select current item
43
91
  this.#searchInput.onSubmit = () => {
44
- if (this.#filteredSessions[this.#selectedIndex]) {
45
- const selected = this.#filteredSessions[this.#selectedIndex];
46
- if (this.onSelect) {
47
- this.onSelect(selected.path);
48
- }
92
+ const selected = this.#filteredSessions[this.#selectedIndex];
93
+ if (selected) {
94
+ this.onSelect?.(selected);
49
95
  }
50
96
  };
51
97
  }
52
98
 
99
+ /** Replace the visible dataset, e.g. when toggling folder/all-projects scope. */
100
+ setSessions(sessions: SessionInfo[], showCwd: boolean): void {
101
+ this.#allSessions = sessions;
102
+ this.#showCwd = showCwd;
103
+ this.#selectedIndex = 0;
104
+ this.#filterSessions(this.#searchInput.getValue());
105
+ }
106
+
53
107
  #filterSessions(query: string): void {
54
- this.#filteredSessions = fuzzyFilter(this.allSessions, query, session => {
108
+ const fuzzy = fuzzyFilter(this.#allSessions, query, session => {
55
109
  const parts = [
56
110
  session.id,
57
111
  session.title ?? "",
@@ -62,13 +116,28 @@ class SessionList implements Component {
62
116
  ];
63
117
  return parts.filter(Boolean).join(" ");
64
118
  });
119
+ this.#filteredSessions = this.#mergeHistoryMatches(query, fuzzy);
65
120
  this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1));
66
121
  }
67
122
 
123
+ /**
124
+ * Augment fuzzy results with prompt-history matches without replacing them.
125
+ * The session-list corpus only sees the first 4KB of each session, so a prompt
126
+ * typed deep into a long session is invisible to fuzzy search; `historyMatcher`
127
+ * recovers those via `history.db`.
128
+ */
129
+ #mergeHistoryMatches(query: string, fuzzy: SessionInfo[]): SessionInfo[] {
130
+ const trimmed = query.trim();
131
+ if (!trimmed || !this.#historyMatcher) return fuzzy;
132
+ const historyIds = this.#historyMatcher(trimmed);
133
+ if (historyIds.length === 0) return fuzzy;
134
+ return mergeSessionRanking(this.#allSessions, fuzzy, historyIds);
135
+ }
136
+
68
137
  removeSession(sessionPath: string): void {
69
- const index = this.allSessions.findIndex(s => s.path === sessionPath);
138
+ const index = this.#allSessions.findIndex(s => s.path === sessionPath);
70
139
  if (index === -1) return;
71
- this.allSessions.splice(index, 1);
140
+ this.#allSessions.splice(index, 1);
72
141
  // Re-filter to update filteredSessions
73
142
  this.#filterSessions(this.#searchInput.getValue());
74
143
  // Adjust selectedIndex if we deleted the last item or beyond
@@ -89,7 +158,7 @@ class SessionList implements Component {
89
158
  lines.push(""); // Blank line after search
90
159
 
91
160
  if (this.#filteredSessions.length === 0) {
92
- if (this.showCwd) {
161
+ if (this.#showCwd) {
93
162
  // "All" scope - no sessions anywhere that match filter
94
163
  lines.push(truncateToWidth(theme.fg("muted", " No sessions found"), width));
95
164
  } else {
@@ -158,9 +227,12 @@ class SessionList implements Component {
158
227
  lines.push(messageLine);
159
228
  }
160
229
 
161
- // Metadata line: date + file size
230
+ // Metadata line: date + file size (+ project dir in all-projects scope)
162
231
  const modified = formatDate(session.modified);
163
- const metadata = ` ${modified} ${theme.sep.dot} ${formatBytes(session.size)}`;
232
+ let metadata = ` ${modified} ${theme.sep.dot} ${formatBytes(session.size)}`;
233
+ if (this.#showCwd && session.cwd) {
234
+ metadata += ` ${theme.sep.dot} ${shortenPath(session.cwd)}`;
235
+ }
164
236
  const metadataLine = theme.fg("dim", truncateToWidth(metadata, width));
165
237
 
166
238
  lines.push(metadataLine);
@@ -176,7 +248,12 @@ class SessionList implements Component {
176
248
 
177
249
  // Add keybinding hint
178
250
  lines.push("");
179
- lines.push(theme.fg("muted", " [Del to delete, Enter to select, Esc to cancel]"));
251
+ lines.push(
252
+ theme.fg(
253
+ "muted",
254
+ ` [Del delete · Enter select · Tab ${this.#showCwd ? "current folder" : "all projects"} · Esc cancel]`,
255
+ ),
256
+ );
180
257
 
181
258
  return lines;
182
259
  }
@@ -215,7 +292,7 @@ class SessionList implements Component {
215
292
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
216
293
  const selected = this.#filteredSessions[this.#selectedIndex];
217
294
  if (selected && this.onSelect) {
218
- this.onSelect(selected.path);
295
+ this.onSelect(selected);
219
296
  }
220
297
  return;
221
298
  }
@@ -231,12 +308,28 @@ class SessionList implements Component {
231
308
  this.onExit();
232
309
  return;
233
310
  }
311
+ // Tab - toggle folder / all-projects scope
312
+ if (matchesKey(keyData, "tab")) {
313
+ this.onToggleScope?.();
314
+ return;
315
+ }
234
316
  // Pass everything else to search input
235
317
  this.#searchInput.handleInput(keyData);
236
318
  this.#filterSessions(this.#searchInput.getValue());
237
319
  }
238
320
  }
239
321
 
322
+ export interface SessionSelectorOptions {
323
+ onDelete?: (session: SessionInfo) => Promise<boolean>;
324
+ historyMatcher?: SessionHistoryMatcher;
325
+ /** Loads sessions across all projects for the all-projects scope toggle (Tab). */
326
+ loadAllSessions?: () => Promise<SessionInfo[]>;
327
+ /** Preloaded all-projects list; cached so the first Tab toggle is instant. */
328
+ allSessions?: SessionInfo[];
329
+ /** Open directly in all-projects scope (e.g. the current folder has no sessions). */
330
+ startInAllScope?: boolean;
331
+ }
332
+
240
333
  /**
241
334
  * Component that renders a session selector with optional confirmation dialog
242
335
  */
@@ -244,35 +337,55 @@ export class SessionSelectorComponent extends Container {
244
337
  #sessionList: SessionList;
245
338
  #confirmationDialog: HookSelectorComponent | null = null;
246
339
  #messageContainer: Container;
340
+ #headerText: Text;
247
341
  #onDelete?: (session: SessionInfo) => Promise<boolean>;
248
342
  #onRequestRender?: () => void;
343
+ readonly #loadAllSessions?: () => Promise<SessionInfo[]>;
344
+ #folderSessions: SessionInfo[];
345
+ #globalSessions: SessionInfo[] | null = null;
346
+ #scope: "folder" | "all" = "folder";
347
+ #toggling = false;
249
348
 
250
349
  constructor(
251
350
  sessions: SessionInfo[],
252
- onSelect: (sessionPath: string) => void,
351
+ onSelect: (session: SessionInfo) => void,
253
352
  onCancel: () => void,
254
353
  onExit: () => void,
255
- onDelete?: (session: SessionInfo) => Promise<boolean>,
354
+ options: SessionSelectorOptions = {},
256
355
  ) {
257
356
  super();
258
357
 
259
358
  this.#messageContainer = new Container();
260
- this.#onDelete = onDelete;
359
+ this.#onDelete = options.onDelete;
360
+ this.#loadAllSessions = options.loadAllSessions;
361
+ this.#folderSessions = sessions;
362
+ this.#globalSessions = options.allSessions ?? null;
363
+ // Open in all-projects scope when asked and we already have that list
364
+ // (e.g. the current folder has no sessions to show).
365
+ const startAll = options.startInAllScope === true && this.#globalSessions !== null;
366
+ this.#scope = startAll ? "all" : "folder";
367
+ const initialSessions = startAll ? this.#globalSessions! : sessions;
261
368
  // Add header
262
369
  this.addChild(new Spacer(1));
263
- this.addChild(new Text(theme.bold("Resume Session"), 1, 0));
370
+ this.#headerText = new Text(this.#headerLabel(), 1, 0);
371
+ this.addChild(this.#headerText);
264
372
  this.addChild(new Spacer(1));
265
373
  this.addChild(new DynamicBorder());
266
374
  this.addChild(new Spacer(1));
267
375
  this.addChild(this.#messageContainer);
268
376
  // Create session list
269
- this.#sessionList = new SessionList(sessions);
377
+ this.#sessionList = new SessionList(initialSessions, startAll, options.historyMatcher);
270
378
  this.#sessionList.onSelect = onSelect;
271
379
  this.#sessionList.onCancel = onCancel;
272
380
  this.#sessionList.onExit = onExit;
273
381
  this.#sessionList.onDeleteRequest = (session: SessionInfo) => {
274
382
  this.#showDeleteConfirmation(session);
275
383
  };
384
+ if (this.#loadAllSessions || this.#globalSessions) {
385
+ this.#sessionList.onToggleScope = () => {
386
+ void this.#toggleScope();
387
+ };
388
+ }
276
389
  this.addChild(this.#sessionList);
277
390
 
278
391
  // Add bottom border
@@ -280,6 +393,48 @@ export class SessionSelectorComponent extends Container {
280
393
  this.addChild(new DynamicBorder());
281
394
  }
282
395
 
396
+ #headerLabel(): string {
397
+ const scopeLabel = this.#scope === "all" ? "all projects" : "current folder";
398
+ return `${theme.bold("Resume Session")} ${theme.fg("muted", `(${scopeLabel})`)}`;
399
+ }
400
+
401
+ /**
402
+ * Toggle between current-folder and all-projects scope. The global list is
403
+ * loaded lazily on first switch and cached, so the common folder-scope path
404
+ * never pays for the cross-project scan.
405
+ */
406
+ async #toggleScope(): Promise<void> {
407
+ if (this.#toggling || this.#confirmationDialog) return;
408
+ if (this.#scope === "folder") {
409
+ let global = this.#globalSessions;
410
+ if (!global) {
411
+ if (!this.#loadAllSessions) return;
412
+ this.#toggling = true;
413
+ this.#messageContainer.clear();
414
+ this.#messageContainer.addChild(new Text(theme.fg("muted", " Loading all projects…"), 1, 0));
415
+ this.#onRequestRender?.();
416
+ try {
417
+ global = await this.#loadAllSessions();
418
+ } catch (err) {
419
+ this.#showError(err instanceof Error ? err.message : String(err));
420
+ this.#toggling = false;
421
+ this.#onRequestRender?.();
422
+ return;
423
+ }
424
+ this.#globalSessions = global;
425
+ this.#messageContainer.clear();
426
+ this.#toggling = false;
427
+ }
428
+ this.#scope = "all";
429
+ this.#sessionList.setSessions(global, true);
430
+ } else {
431
+ this.#scope = "folder";
432
+ this.#sessionList.setSessions(this.#folderSessions, false);
433
+ }
434
+ this.#headerText.setText(this.#headerLabel());
435
+ this.#onRequestRender?.();
436
+ }
437
+
283
438
  setOnRequestRender(callback: () => void): void {
284
439
  this.#onRequestRender = callback;
285
440
  }
@@ -141,6 +141,14 @@ export interface ToolExecutionHandle {
141
141
  setExpanded(expanded: boolean): void;
142
142
  }
143
143
 
144
+ /** Drive pending-tool redraws at ~60fps so the animated border sweep is smooth.
145
+ * The TUI already throttles at its 16ms `MIN_RENDER_INTERVAL_MS`, so this is the
146
+ * natural upper bound and static frames diff to a no-op redraw at ~zero cost. */
147
+ const SPINNER_RENDER_INTERVAL_MS = 16;
148
+ /** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
149
+ * 60fps render cadence (mirrors `Loader`). */
150
+ const SPINNER_GLYPH_ADVANCE_MS = 80;
151
+
144
152
  /**
145
153
  * Component that renders a tool call with its result (updateable)
146
154
  */
@@ -172,11 +180,14 @@ export class ToolExecutionComponent extends Container {
172
180
  #editDiffPreview?: PerFileDiffPreview[];
173
181
  #editDiffAbort?: AbortController;
174
182
  #editDiffLastArgsKey?: string;
183
+ // Latest in-flight streaming diff recompute, captured so it can be awaited.
184
+ #editDiffInFlight?: Promise<void>;
175
185
  // Cached converted images for Kitty protocol (which requires PNG), keyed by index
176
186
  #convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
177
187
  // Spinner animation for partial task results
178
188
  #spinnerFrame?: number;
179
189
  #spinnerInterval?: NodeJS.Timeout;
190
+ #lastSpinnerAdvanceAt = 0;
180
191
  // Todo write completion strikethrough reveal animation
181
192
  #todoStrikeInterval?: NodeJS.Timeout;
182
193
  // Track if args are still being streamed (for edit/write spinner)
@@ -230,7 +241,7 @@ export class ToolExecutionComponent extends Container {
230
241
  this.#editMode = resolveEditModeForTool(toolName, tool);
231
242
 
232
243
  this.#updateDisplay();
233
- void this.#runPreviewDiff();
244
+ this.#editDiffInFlight = this.#runPreviewDiff();
234
245
  }
235
246
 
236
247
  updateArgs(args: any, _toolCallId?: string): void {
@@ -241,7 +252,7 @@ export class ToolExecutionComponent extends Container {
241
252
  if (args === this.#args) return;
242
253
  this.#args = args;
243
254
  this.#updateSpinnerAnimation();
244
- void this.#runPreviewDiff();
255
+ this.#editDiffInFlight = this.#runPreviewDiff();
245
256
  this.#updateDisplay();
246
257
  }
247
258
 
@@ -252,7 +263,18 @@ export class ToolExecutionComponent extends Container {
252
263
  setArgsComplete(_toolCallId?: string): void {
253
264
  this.#argsComplete = true;
254
265
  this.#updateSpinnerAnimation();
255
- void this.#runPreviewDiff();
266
+ this.#editDiffInFlight = this.#runPreviewDiff();
267
+ }
268
+
269
+ /**
270
+ * Await the streaming diff recompute kicked off by the most recent
271
+ * `updateArgs`/`setArgsComplete`. The recompute reads the file and re-runs the
272
+ * whole-file Myers diff off the render path, signalling completion only via a
273
+ * throttled `requestRender`. Tests await this to sample a *settled* preview
274
+ * deterministically instead of racing the spinner's render ticks.
275
+ */
276
+ async whenPreviewSettled(): Promise<void> {
277
+ await this.#editDiffInFlight;
256
278
  }
257
279
 
258
280
  async #runPreviewDiff(): Promise<void> {
@@ -404,13 +426,20 @@ export class ToolExecutionComponent extends Container {
404
426
  this.#isPartial && shimmerEnabled() && (this.#toolName === "bash" || this.#toolName === "eval");
405
427
  const needsSpinner = isStreamingArgs || isPartialTask || isPendingExecBlock;
406
428
  if (needsSpinner && !this.#spinnerInterval) {
429
+ this.#lastSpinnerAdvanceAt = performance.now();
407
430
  this.#spinnerInterval = setInterval(() => {
431
+ const now = performance.now();
408
432
  const frameCount = theme.spinnerFrames.length;
409
- if (frameCount === 0) return;
410
- this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
411
- this.#renderState.spinnerFrame = this.#spinnerFrame;
433
+ // Redraw at ~60fps for a smooth border sweep, but only step the spinner
434
+ // glyph at its classic ~12.5fps cadence. The TUI throttles renders at
435
+ // 16ms and the differ drops no-op redraws, so the extra ticks are free.
436
+ if (frameCount > 0 && now - this.#lastSpinnerAdvanceAt >= SPINNER_GLYPH_ADVANCE_MS) {
437
+ this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
438
+ this.#renderState.spinnerFrame = this.#spinnerFrame;
439
+ this.#lastSpinnerAdvanceAt = now;
440
+ }
412
441
  this.#ui.requestRender();
413
- }, 80);
442
+ }, SPINNER_RENDER_INTERVAL_MS);
414
443
  } else if (!needsSpinner && this.#spinnerInterval) {
415
444
  clearInterval(this.#spinnerInterval);
416
445
  this.#spinnerInterval = undefined;
@@ -11,10 +11,8 @@ import {
11
11
  type UsageReport,
12
12
  } from "@oh-my-pi/pi-ai";
13
13
  import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
14
- import { formatDuration, Snowflake, setProjectDir } from "@oh-my-pi/pi-utils";
14
+ import { formatDuration, Snowflake } from "@oh-my-pi/pi-utils";
15
15
  import { $ } from "bun";
16
- import { reset as resetCapabilities } from "../../capability";
17
- import { clearClaudePluginRootsCache } from "../../discovery/helpers";
18
16
  import { loadCustomShare } from "../../export/custom-share";
19
17
  import type { CompactOptions } from "../../extensibility/extensions/types";
20
18
  import {
@@ -993,14 +991,7 @@ export class CommandController {
993
991
  try {
994
992
  await this.ctx.sessionManager.flush();
995
993
  await this.ctx.sessionManager.moveTo(resolvedPath);
996
- setProjectDir(resolvedPath);
997
- clearClaudePluginRootsCache(); // re-warms preloadedPluginRoots with new project dir (async)
998
- resetCapabilities();
999
- await this.ctx.refreshSlashCommandState(resolvedPath);
1000
- await this.ctx.session.refreshSshTool({ activateIfAvailable: true });
1001
-
1002
- this.ctx.statusLine.invalidate();
1003
- this.ctx.updateEditorTopBorder();
994
+ await this.ctx.applyCwdChange(resolvedPath);
1004
995
 
1005
996
  this.ctx.chatContainer.addChild(new Spacer(1));
1006
997
  this.ctx.chatContainer.addChild(
@@ -277,8 +277,11 @@ export class EventController {
277
277
  this.#lastThinkingCount = 0;
278
278
  this.#assistantMessageStreaming = true;
279
279
  this.#resetReadGroup();
280
- this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock, () =>
281
- this.ctx.ui.requestRender(),
280
+ this.ctx.streamingComponent = new AssistantMessageComponent(
281
+ undefined,
282
+ this.ctx.hideThinkingBlock,
283
+ () => this.ctx.ui.requestRender(),
284
+ this.ctx.session.extensionRunner?.getAssistantThinkingRenderers(),
282
285
  );
283
286
  this.ctx.streamingMessage = event.message;
284
287
  this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
@@ -10,6 +10,7 @@ import type {
10
10
  ExtensionError,
11
11
  ExtensionUIContext,
12
12
  ExtensionUIDialogOptions,
13
+ ExtensionUISelectItem,
13
14
  ExtensionUiComponent,
14
15
  ExtensionWidgetContent,
15
16
  ExtensionWidgetOptions,
@@ -21,7 +22,7 @@ import { HookEditorComponent } from "../../modes/components/hook-editor";
21
22
  import { HookInputComponent } from "../../modes/components/hook-input";
22
23
  import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/components/hook-selector";
23
24
  import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
24
- import type { InteractiveModeContext } from "../../modes/types";
25
+ import type { InteractiveModeContext, InteractiveSelectorDialogOptions } from "../../modes/types";
25
26
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
26
27
 
27
28
  const MAX_WIDGET_LINES = 10;
@@ -483,7 +484,7 @@ export class ExtensionUiController {
483
484
 
484
485
  createBackgroundUiContext(): ExtensionUIContext {
485
486
  return {
486
- select: async (_title: string, _options: string[], _dialogOptions) => undefined,
487
+ select: async (_title: string, _options: ExtensionUISelectItem[], _dialogOptions) => undefined,
487
488
  confirm: async (_title: string, _message: string, _dialogOptions) => false,
488
489
  input: async (_title: string, _placeholder?: string, _dialogOptions?: unknown) => undefined,
489
490
  notify: () => {},
@@ -581,8 +582,8 @@ export class ExtensionUiController {
581
582
  */
582
583
  showHookSelector(
583
584
  title: string,
584
- options: string[],
585
- dialogOptions?: ExtensionUIDialogOptions,
585
+ options: ExtensionUISelectItem[],
586
+ dialogOptions?: InteractiveSelectorDialogOptions,
586
587
  extra?: { slider?: HookSelectorSlider },
587
588
  ): Promise<string | undefined> {
588
589
  const { promise, finish, attachAbort } = this.#createHookDialogState(
@@ -623,6 +624,7 @@ export class ExtensionUiController {
623
624
  onTimeout: dialogOptions?.onTimeout,
624
625
  tui: this.ctx.ui,
625
626
  outline: dialogOptions?.outline,
627
+ disabledIndices: dialogOptions?.disabledIndices,
626
628
  maxVisible,
627
629
  slider: extra?.slider,
628
630
  },
@@ -15,7 +15,7 @@ import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registr
15
15
  import { isTinyTitleLocalModelKey } from "../../tiny/models";
16
16
  import { tinyTitleClient } from "../../tiny/title-client";
17
17
  import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
18
- import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
18
+ import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
19
19
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
20
20
  import { ensureSupportedImageInput } from "../../utils/image-loading";
21
21
  import { resizeImage } from "../../utils/image-resize";
@@ -86,21 +86,6 @@ export class InputController {
86
86
 
87
87
  setupKeyHandlers(): void {
88
88
  this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
89
- this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
90
- Boolean(
91
- this.ctx.loadingAnimation ||
92
- this.ctx.hasActiveBtw() ||
93
- this.ctx.hasActiveOmfg() ||
94
- this.ctx.session.isStreaming ||
95
- this.ctx.session.isCompacting ||
96
- this.ctx.session.isGeneratingHandoff ||
97
- this.ctx.session.isBashRunning ||
98
- this.ctx.session.isEvalRunning ||
99
- this.ctx.autoCompactionLoader ||
100
- this.ctx.retryLoader ||
101
- this.ctx.autoCompactionEscapeHandler ||
102
- this.ctx.retryEscapeHandler,
103
- );
104
89
  this.ctx.editor.onEscape = () => {
105
90
  if (this.ctx.loopModeEnabled) {
106
91
  this.ctx.pauseLoop();
@@ -188,6 +173,11 @@ export class InputController {
188
173
  this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
189
174
  );
190
175
  this.ctx.editor.onPasteImage = () => this.handleImagePaste();
176
+ this.ctx.editor.setActionKeys(
177
+ "app.clipboard.pasteTextRaw",
178
+ this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"),
179
+ );
180
+ this.ctx.editor.onPasteTextRaw = () => void this.handleClipboardTextRawPaste();
191
181
  this.ctx.editor.setActionKeys(
192
182
  "app.clipboard.copyPrompt",
193
183
  this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"),
@@ -705,6 +695,19 @@ export class InputController {
705
695
  }
706
696
  }
707
697
 
698
+ async handleClipboardTextRawPaste(): Promise<void> {
699
+ try {
700
+ const text = await readTextFromClipboard();
701
+ if (text) {
702
+ this.ctx.editor.insertText(text);
703
+ this.ctx.ui.requestRender();
704
+ this.ctx.showStatus("No text in clipboard to paste raw");
705
+ }
706
+ } catch {
707
+ this.ctx.showStatus("Failed to paste raw text from clipboard");
708
+ }
709
+ }
710
+
708
711
  createAutocompleteProvider(commands: SlashCommand[], basePath: string): AutocompleteProvider {
709
712
  return createPromptActionAutocompleteProvider({
710
713
  commands,