@oh-my-pi/pi-coding-agent 15.7.6 → 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 (95) hide show
  1. package/CHANGELOG.md +146 -198
  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 +2 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  12. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  13. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  14. package/dist/types/main.d.ts +5 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/hook-selector.d.ts +3 -0
  17. package/dist/types/modes/components/session-selector.d.ts +32 -5
  18. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  20. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  21. package/dist/types/modes/interactive-mode.d.ts +9 -2
  22. package/dist/types/modes/types.d.ts +4 -2
  23. package/dist/types/registry/agent-registry.d.ts +1 -1
  24. package/dist/types/sdk.d.ts +2 -2
  25. package/dist/types/session/agent-session.d.ts +4 -2
  26. package/dist/types/session/history-storage.d.ts +16 -1
  27. package/dist/types/session/session-manager.d.ts +4 -0
  28. package/dist/types/task/output-manager.d.ts +6 -15
  29. package/dist/types/tools/find.d.ts +0 -9
  30. package/dist/types/tools/index.d.ts +1 -1
  31. package/dist/types/tools/path-utils.d.ts +16 -0
  32. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  33. package/dist/types/utils/clipboard.d.ts +4 -0
  34. package/dist/types/web/kagi.d.ts +76 -0
  35. package/dist/types/web/search/providers/exa.d.ts +7 -1
  36. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  37. package/package.json +9 -9
  38. package/src/async/job-manager.ts +3 -3
  39. package/src/cli/args.ts +6 -2
  40. package/src/cli/claude-trace-cli.ts +783 -0
  41. package/src/cli/session-picker.ts +36 -10
  42. package/src/cli/update-cli.ts +35 -2
  43. package/src/commands/launch.ts +3 -0
  44. package/src/config/keybindings.ts +6 -0
  45. package/src/config/settings-schema.ts +2 -2
  46. package/src/config/settings.ts +23 -0
  47. package/src/discovery/claude-plugins.ts +7 -9
  48. package/src/eval/__tests__/agent-bridge.test.ts +58 -4
  49. package/src/eval/concurrency-bridge.ts +34 -0
  50. package/src/eval/js/shared/prelude.txt +20 -17
  51. package/src/eval/js/tool-bridge.ts +5 -0
  52. package/src/eval/py/prelude.py +23 -15
  53. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  54. package/src/extensibility/skills.ts +0 -1
  55. package/src/internal-urls/docs-index.generated.ts +11 -10
  56. package/src/main.ts +92 -24
  57. package/src/modes/acp/acp-event-mapper.ts +54 -4
  58. package/src/modes/components/custom-editor.ts +10 -0
  59. package/src/modes/components/hook-selector.ts +89 -31
  60. package/src/modes/components/oauth-selector.ts +12 -6
  61. package/src/modes/components/session-selector.ts +179 -24
  62. package/src/modes/components/tool-execution.ts +16 -3
  63. package/src/modes/controllers/command-controller.ts +2 -11
  64. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  65. package/src/modes/controllers/input-controller.ts +19 -1
  66. package/src/modes/controllers/selector-controller.ts +61 -21
  67. package/src/modes/interactive-mode.ts +125 -15
  68. package/src/modes/types.ts +5 -2
  69. package/src/prompts/system/orchestrate-notice.md +5 -3
  70. package/src/prompts/system/workflow-notice.md +2 -2
  71. package/src/prompts/tools/eval.md +5 -5
  72. package/src/prompts/tools/find.md +1 -1
  73. package/src/prompts/tools/irc.md +6 -6
  74. package/src/prompts/tools/search.md +1 -1
  75. package/src/prompts/tools/task.md +1 -1
  76. package/src/registry/agent-registry.ts +1 -1
  77. package/src/sdk.ts +85 -31
  78. package/src/session/agent-session.ts +62 -46
  79. package/src/session/history-storage.ts +56 -12
  80. package/src/session/session-manager.ts +34 -0
  81. package/src/task/output-manager.ts +40 -48
  82. package/src/task/render.ts +3 -8
  83. package/src/tools/browser/tab-worker.ts +8 -5
  84. package/src/tools/find.ts +5 -29
  85. package/src/tools/index.ts +1 -1
  86. package/src/tools/path-utils.ts +144 -1
  87. package/src/tools/read.ts +47 -0
  88. package/src/tools/search.ts +2 -27
  89. package/src/tools/sqlite-reader.ts +92 -9
  90. package/src/utils/clipboard.ts +38 -1
  91. package/src/utils/open.ts +37 -2
  92. package/src/web/kagi.ts +168 -49
  93. package/src/web/search/providers/anthropic.ts +1 -1
  94. package/src/web/search/providers/exa.ts +20 -86
  95. 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
  }
@@ -180,6 +180,8 @@ export class ToolExecutionComponent extends Container {
180
180
  #editDiffPreview?: PerFileDiffPreview[];
181
181
  #editDiffAbort?: AbortController;
182
182
  #editDiffLastArgsKey?: string;
183
+ // Latest in-flight streaming diff recompute, captured so it can be awaited.
184
+ #editDiffInFlight?: Promise<void>;
183
185
  // Cached converted images for Kitty protocol (which requires PNG), keyed by index
184
186
  #convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
185
187
  // Spinner animation for partial task results
@@ -239,7 +241,7 @@ export class ToolExecutionComponent extends Container {
239
241
  this.#editMode = resolveEditModeForTool(toolName, tool);
240
242
 
241
243
  this.#updateDisplay();
242
- void this.#runPreviewDiff();
244
+ this.#editDiffInFlight = this.#runPreviewDiff();
243
245
  }
244
246
 
245
247
  updateArgs(args: any, _toolCallId?: string): void {
@@ -250,7 +252,7 @@ export class ToolExecutionComponent extends Container {
250
252
  if (args === this.#args) return;
251
253
  this.#args = args;
252
254
  this.#updateSpinnerAnimation();
253
- void this.#runPreviewDiff();
255
+ this.#editDiffInFlight = this.#runPreviewDiff();
254
256
  this.#updateDisplay();
255
257
  }
256
258
 
@@ -261,7 +263,18 @@ export class ToolExecutionComponent extends Container {
261
263
  setArgsComplete(_toolCallId?: string): void {
262
264
  this.#argsComplete = true;
263
265
  this.#updateSpinnerAnimation();
264
- 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;
265
278
  }
266
279
 
267
280
  async #runPreviewDiff(): Promise<void> {
@@ -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(
@@ -22,7 +22,7 @@ import { HookEditorComponent } from "../../modes/components/hook-editor";
22
22
  import { HookInputComponent } from "../../modes/components/hook-input";
23
23
  import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/components/hook-selector";
24
24
  import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
25
- import type { InteractiveModeContext } from "../../modes/types";
25
+ import type { InteractiveModeContext, InteractiveSelectorDialogOptions } from "../../modes/types";
26
26
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
27
27
 
28
28
  const MAX_WIDGET_LINES = 10;
@@ -583,7 +583,7 @@ export class ExtensionUiController {
583
583
  showHookSelector(
584
584
  title: string,
585
585
  options: ExtensionUISelectItem[],
586
- dialogOptions?: ExtensionUIDialogOptions,
586
+ dialogOptions?: InteractiveSelectorDialogOptions,
587
587
  extra?: { slider?: HookSelectorSlider },
588
588
  ): Promise<string | undefined> {
589
589
  const { promise, finish, attachAbort } = this.#createHookDialogState(
@@ -624,6 +624,7 @@ export class ExtensionUiController {
624
624
  onTimeout: dialogOptions?.onTimeout,
625
625
  tui: this.ctx.ui,
626
626
  outline: dialogOptions?.outline,
627
+ disabledIndices: dialogOptions?.disabledIndices,
627
628
  maxVisible,
628
629
  slider: extra?.slider,
629
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";
@@ -173,6 +173,11 @@ export class InputController {
173
173
  this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
174
174
  );
175
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();
176
181
  this.ctx.editor.setActionKeys(
177
182
  "app.clipboard.copyPrompt",
178
183
  this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"),
@@ -690,6 +695,19 @@ export class InputController {
690
695
  }
691
696
  }
692
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
+
693
711
  createAutocompleteProvider(commands: SlashCommand[], basePath: string): AutocompleteProvider {
694
712
  return createPromptActionAutocompleteProvider({
695
713
  commands,
@@ -3,7 +3,7 @@ import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
3
3
  import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types";
4
4
  import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui";
5
5
  import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
6
- import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils";
6
+ import { getAgentDbPath, getProjectDir, normalizePathForComparison } from "@oh-my-pi/pi-utils";
7
7
  import { getRoleInfo } from "../../config/model-registry";
8
8
  import { formatModelSelectorValue } from "../../config/model-resolver";
9
9
  import { settings } from "../../config/settings";
@@ -36,6 +36,7 @@ import {
36
36
  setPreferredImageProvider,
37
37
  setPreferredSearchProvider,
38
38
  } from "../../tools";
39
+ import { shortenPath } from "../../tools/render-utils";
39
40
  import { setSessionTerminalTitle } from "../../utils/title-generator";
40
41
  import { AgentDashboard } from "../components/agent-dashboard";
41
42
  import { AssistantMessageComponent } from "../components/assistant-message";
@@ -719,12 +720,22 @@ export class SelectorController {
719
720
  this.ctx.sessionManager.getCwd(),
720
721
  this.ctx.sessionManager.getSessionDir(),
721
722
  );
723
+ // Current folder has no sessions: preload the global list so the picker
724
+ // can open straight into all-projects scope instead of dead-ending.
725
+ let allSessions: SessionInfo[] | undefined;
726
+ let startInAllScope = false;
727
+ if (sessions.length === 0) {
728
+ allSessions = await SessionManager.listAll();
729
+ startInAllScope = allSessions.length > 0;
730
+ }
731
+ const historyStorage = this.ctx.historyStorage;
732
+ const historyMatcher = historyStorage ? (query: string) => historyStorage.matchingSessionIds(query) : undefined;
722
733
  this.showSelector(done => {
723
734
  const selector = new SessionSelectorComponent(
724
735
  sessions,
725
- async sessionPath => {
736
+ async (session: SessionInfo) => {
726
737
  done();
727
- await this.handleResumeSession(sessionPath);
738
+ await this.handleResumeSession(session.path);
728
739
  },
729
740
  () => {
730
741
  done();
@@ -733,19 +744,25 @@ export class SelectorController {
733
744
  () => {
734
745
  void this.ctx.shutdown();
735
746
  },
736
- async (session: SessionInfo) => {
737
- if (!(await this.#detachActiveSessionBeforeDeletion(session.path))) {
738
- return false;
739
- }
740
- const storage = new FileSessionStorage();
741
- try {
742
- await storage.deleteSessionWithArtifacts(session.path);
743
- return true;
744
- } catch (err) {
745
- throw new Error(`Failed to delete session: ${err instanceof Error ? err.message : String(err)}`, {
746
- cause: err,
747
- });
748
- }
747
+ {
748
+ onDelete: async (session: SessionInfo) => {
749
+ if (!(await this.#detachActiveSessionBeforeDeletion(session.path))) {
750
+ return false;
751
+ }
752
+ const storage = new FileSessionStorage();
753
+ try {
754
+ await storage.deleteSessionWithArtifacts(session.path);
755
+ return true;
756
+ } catch (err) {
757
+ throw new Error(`Failed to delete session: ${err instanceof Error ? err.message : String(err)}`, {
758
+ cause: err,
759
+ });
760
+ }
761
+ },
762
+ historyMatcher,
763
+ loadAllSessions: () => SessionManager.listAll(),
764
+ allSessions,
765
+ startInAllScope,
749
766
  },
750
767
  );
751
768
  selector.setOnRequestRender(() => this.ctx.ui.requestRender());
@@ -801,8 +818,17 @@ export class SelectorController {
801
818
  async handleResumeSession(sessionPath: string): Promise<void> {
802
819
  this.#clearTransientSessionUi();
803
820
 
804
- // Switch session via AgentSession (emits hook and tool session events)
821
+ const previousCwd = this.ctx.sessionManager.getCwd();
822
+ // Switch session via AgentSession (emits hook and tool session events). The
823
+ // SessionManager adopts the resumed session's own cwd when it differs.
805
824
  await this.ctx.session.switchSession(sessionPath);
825
+ const newCwd = this.ctx.sessionManager.getCwd();
826
+ const movedProject = normalizePathForComparison(newCwd) !== normalizePathForComparison(previousCwd);
827
+ if (movedProject) {
828
+ // Resumed a session from another project: re-point the process and every
829
+ // cwd-derived cache at it before rendering.
830
+ await this.ctx.applyCwdChange(newCwd);
831
+ }
806
832
  this.#refreshSessionTerminalTitle();
807
833
  this.ctx.updateEditorBorderColor();
808
834
 
@@ -810,7 +836,7 @@ export class SelectorController {
810
836
  this.ctx.chatContainer.clear();
811
837
  this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
812
838
  await this.ctx.reloadTodos();
813
- this.ctx.showStatus("Resumed session");
839
+ this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session");
814
840
  }
815
841
 
816
842
  async handleSessionDeleteCommand(): Promise<void> {
@@ -919,7 +945,15 @@ export class SelectorController {
919
945
 
920
946
  async #handleOAuthLogout(providerId: string): Promise<void> {
921
947
  try {
922
- await this.ctx.session.modelRegistry.authStorage.logout(providerId);
948
+ const authStorage = this.ctx.session.modelRegistry.authStorage;
949
+ if (!authStorage.has(providerId)) {
950
+ const source = authStorage.describeCredentialSource(providerId, this.ctx.session.sessionId);
951
+ const suffix = source ? ` Current auth comes from ${source}; remove that source to log out.` : "";
952
+ this.ctx.showError(`Logout skipped: no stored credentials for ${providerId}.${suffix}`);
953
+ return;
954
+ }
955
+
956
+ await authStorage.logout(providerId);
923
957
  await this.ctx.session.modelRegistry.refresh();
924
958
  this.ctx.chatContainer.addChild(new Spacer(1));
925
959
  this.ctx.chatContainer.addChild(
@@ -928,6 +962,12 @@ export class SelectorController {
928
962
  this.ctx.chatContainer.addChild(
929
963
  new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0),
930
964
  );
965
+ const remainingSource = authStorage.describeCredentialSource(providerId, this.ctx.session.sessionId);
966
+ if (remainingSource) {
967
+ this.ctx.chatContainer.addChild(
968
+ new Text(theme.fg("warning", `${providerId} is still authenticated via ${remainingSource}`), 1, 0),
969
+ );
970
+ }
931
971
  this.ctx.ui.requestRender();
932
972
  } catch (error: unknown) {
933
973
  this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -948,10 +988,10 @@ export class SelectorController {
948
988
  await this.#refreshOAuthProviderAuthState();
949
989
  const oauthProviders = getOAuthProviders();
950
990
  const loggedInProviders = oauthProviders.filter(provider =>
951
- this.ctx.session.modelRegistry.authStorage.hasAuth(provider.id),
991
+ this.ctx.session.modelRegistry.authStorage.has(provider.id),
952
992
  );
953
993
  if (loggedInProviders.length === 0) {
954
- this.ctx.showStatus("No OAuth providers logged in. Use /login first.");
994
+ this.ctx.showStatus("No stored provider credentials to log out. Remove env or config auth at its source.");
955
995
  return;
956
996
  }
957
997
  }