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

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 +82 -1
  2. package/dist/cli.js +520 -451
  3. package/dist/types/cli/bench-cli.d.ts +78 -0
  4. package/dist/types/cli/usage-cli.d.ts +10 -1
  5. package/dist/types/commands/bench.d.ts +29 -0
  6. package/dist/types/commands/usage.d.ts +9 -0
  7. package/dist/types/config/model-resolver.d.ts +3 -2
  8. package/dist/types/config/settings-schema.d.ts +125 -3
  9. package/dist/types/edit/renderer.d.ts +1 -0
  10. package/dist/types/modes/components/oauth-selector.d.ts +10 -1
  11. package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
  12. package/dist/types/modes/components/session-selector.d.ts +1 -1
  13. package/dist/types/modes/components/settings-selector.d.ts +8 -1
  14. package/dist/types/modes/components/snapcompact-shape-preview.d.ts +31 -0
  15. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +10 -0
  18. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  19. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +3 -0
  20. package/dist/types/modes/setup-wizard/scenes/types.d.ts +10 -1
  21. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +3 -0
  22. package/dist/types/modes/types.d.ts +2 -0
  23. package/dist/types/modes/utils/context-usage.d.ts +6 -1
  24. package/dist/types/session/agent-session.d.ts +14 -1
  25. package/dist/types/session/auth-storage.d.ts +1 -1
  26. package/dist/types/session/codex-auto-reset.d.ts +107 -0
  27. package/dist/types/session/snapcompact-inline.d.ts +107 -4
  28. package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
  29. package/dist/types/task/render.d.ts +1 -0
  30. package/dist/types/tools/bash.d.ts +2 -0
  31. package/dist/types/tools/eval-render.d.ts +1 -0
  32. package/dist/types/tools/renderers.d.ts +13 -0
  33. package/dist/types/tools/ssh.d.ts +1 -0
  34. package/dist/types/tools/todo.d.ts +0 -11
  35. package/package.json +11 -11
  36. package/src/cli/bench-cli.ts +437 -0
  37. package/src/cli/usage-cli.ts +187 -16
  38. package/src/cli-commands.ts +1 -0
  39. package/src/commands/bench.ts +42 -0
  40. package/src/commands/usage.ts +8 -0
  41. package/src/config/model-registry.ts +52 -5
  42. package/src/config/model-resolver.ts +36 -5
  43. package/src/config/settings-schema.ts +148 -3
  44. package/src/config/settings.ts +9 -0
  45. package/src/edit/renderer.ts +5 -0
  46. package/src/hindsight/client.ts +26 -1
  47. package/src/hindsight/state.ts +6 -2
  48. package/src/internal-urls/docs-index.generated.ts +2 -2
  49. package/src/mcp/transports/stdio.ts +81 -7
  50. package/src/modes/components/oauth-selector.ts +67 -7
  51. package/src/modes/components/reset-usage-selector.ts +161 -0
  52. package/src/modes/components/session-selector.ts +8 -2
  53. package/src/modes/components/settings-selector.ts +89 -47
  54. package/src/modes/components/snapcompact-shape-preview-doc.md +11 -0
  55. package/src/modes/components/snapcompact-shape-preview.ts +192 -0
  56. package/src/modes/components/tool-execution.ts +26 -0
  57. package/src/modes/components/transcript-container.ts +23 -1
  58. package/src/modes/controllers/command-controller.ts +24 -1
  59. package/src/modes/controllers/input-controller.ts +8 -6
  60. package/src/modes/controllers/selector-controller.ts +72 -2
  61. package/src/modes/interactive-mode.ts +83 -0
  62. package/src/modes/session-observer-registry.ts +61 -3
  63. package/src/modes/setup-wizard/index.ts +1 -0
  64. package/src/modes/setup-wizard/scenes/glyph.ts +24 -6
  65. package/src/modes/setup-wizard/scenes/providers.ts +36 -2
  66. package/src/modes/setup-wizard/scenes/sign-in.ts +10 -1
  67. package/src/modes/setup-wizard/scenes/theme.ts +28 -1
  68. package/src/modes/setup-wizard/scenes/types.ts +10 -1
  69. package/src/modes/setup-wizard/scenes/web-search.ts +22 -6
  70. package/src/modes/setup-wizard/wizard-overlay.ts +38 -1
  71. package/src/modes/theme/theme.ts +2 -2
  72. package/src/modes/types.ts +2 -0
  73. package/src/modes/utils/context-usage.ts +75 -1
  74. package/src/prompts/bench.md +7 -0
  75. package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
  76. package/src/prompts/system/snapcompact-context-stub.md +1 -0
  77. package/src/prompts/system/snapcompact-toolresult-note.md +1 -1
  78. package/src/prompts/tools/browser.md +33 -43
  79. package/src/prompts/tools/eval.md +27 -50
  80. package/src/prompts/tools/irc.md +29 -31
  81. package/src/prompts/tools/read.md +31 -37
  82. package/src/prompts/tools/todo.md +1 -2
  83. package/src/sdk.ts +4 -2
  84. package/src/session/agent-session.ts +136 -6
  85. package/src/session/auth-storage.ts +3 -0
  86. package/src/session/codex-auto-reset.ts +190 -0
  87. package/src/session/snapcompact-inline.ts +404 -75
  88. package/src/slash-commands/builtin-registry.ts +145 -8
  89. package/src/slash-commands/helpers/context-report.ts +28 -1
  90. package/src/slash-commands/helpers/reset-usage.ts +66 -0
  91. package/src/slash-commands/helpers/usage-report.ts +12 -0
  92. package/src/task/index.ts +30 -7
  93. package/src/task/render.ts +34 -19
  94. package/src/tools/bash.ts +3 -0
  95. package/src/tools/eval-render.ts +4 -0
  96. package/src/tools/renderers.ts +13 -0
  97. package/src/tools/ssh.ts +3 -0
  98. package/src/tools/todo.ts +8 -128
@@ -85,27 +85,99 @@ async function resolveWindowsCommandPath(
85
85
  env: Record<string, string | undefined>,
86
86
  ): Promise<string | null> {
87
87
  const extensions = getWindowsPathExt(env);
88
- if (hasExecutableExtension(command, extensions)) return command;
88
+ const hasExt = hasExecutableExtension(command, extensions);
89
+ const candidates = hasExt ? [command] : extensions.map(ext => `${command}${ext}`);
89
90
 
90
- const candidates = extensions.map(ext => `${command}${ext}`);
91
91
  if (hasPathSegment(command)) {
92
92
  for (const candidate of candidates) {
93
93
  const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
94
94
  if (await fileExists(resolved)) return resolved;
95
95
  }
96
- return null;
96
+ return hasExt ? command : null;
97
97
  }
98
98
 
99
+ // Match cmd.exe's lookup order for an unqualified name: current directory
100
+ // first, then PATH. Skipping cwd would launch a global shim instead of a
101
+ // project-local one with the same name.
102
+ const searchDirs = [cwd];
99
103
  const pathValue = getCaseInsensitiveEnv(env, "PATH");
100
- if (!pathValue) return null;
101
- for (const dir of pathValue.split(";")) {
102
- if (!dir) continue;
104
+ if (pathValue) {
105
+ for (const dir of pathValue.split(";")) {
106
+ if (dir) searchDirs.push(dir);
107
+ }
108
+ }
109
+ for (const dir of searchDirs) {
103
110
  for (const candidate of candidates) {
104
111
  const resolved = path.join(dir, candidate);
105
112
  if (await fileExists(resolved)) return resolved;
106
113
  }
107
114
  }
108
- return null;
115
+ return hasExt ? command : null;
116
+ }
117
+
118
+ function resolveWindowsShimPath(value: string, shimDir: string): string | null {
119
+ const match = /^%dp0%[\\/]*(.*)$/i.exec(value);
120
+ if (!match) return null;
121
+ const suffix = match[1];
122
+ if (!suffix) return shimDir;
123
+ return path.join(shimDir, ...suffix.split(/[\\/]+/).filter(Boolean));
124
+ }
125
+
126
+ function extractWindowsNpmShimTarget(content: string): string | null {
127
+ const match = /"%_prog%"\s+"([^"]+)"\s+%\*/i.exec(content);
128
+ return match?.[1] ?? null;
129
+ }
130
+
131
+ /**
132
+ * Extract the shim's PATH-fallback interpreter (`SET "_prog=node"`). The
133
+ * `IF EXIST` branch assigns a `%dp0%`-prefixed value, so requiring a
134
+ * non-`%`-leading value picks the bare program name.
135
+ */
136
+ function extractWindowsNpmShimProg(content: string): string | null {
137
+ const match = /SET\s+"_prog=([^%"][^"]*)"/i.exec(content);
138
+ return match?.[1] ?? null;
139
+ }
140
+
141
+ async function resolveWindowsNpmShimCommand(
142
+ command: string,
143
+ args: readonly string[],
144
+ cwd: string,
145
+ ): Promise<StdioSpawnCommand | null> {
146
+ if (!isWindowsBatchCommand(command)) return null;
147
+ if (!hasPathSegment(command)) return null;
148
+ const commandPath = path.resolve(cwd, command);
149
+
150
+ let content: string;
151
+ try {
152
+ content = await Bun.file(commandPath).text();
153
+ } catch {
154
+ return null;
155
+ }
156
+
157
+ // cmd-shim emits the same invocation line for every interpreter; only
158
+ // bypass cmd.exe when the shim's fallback interpreter is actually node.
159
+ const prog = extractWindowsNpmShimProg(content);
160
+ if (
161
+ !prog ||
162
+ path
163
+ .basename(prog)
164
+ .replace(/\.exe$/i, "")
165
+ .toLowerCase() !== "node"
166
+ )
167
+ return null;
168
+
169
+ const rawTarget = extractWindowsNpmShimTarget(content);
170
+ if (!rawTarget) return null;
171
+
172
+ const target = resolveWindowsShimPath(rawTarget, path.dirname(commandPath));
173
+ if (!target) return null;
174
+
175
+ const siblingNode = path.join(path.dirname(commandPath), "node.exe");
176
+ const nodeCommand = (await fileExists(siblingNode)) ? siblingNode : "node";
177
+ return {
178
+ cmd: [nodeCommand, target, ...args],
179
+ windowsHide: true,
180
+ };
109
181
  }
110
182
 
111
183
  function quoteCmdArg(value: string): string {
@@ -150,6 +222,8 @@ export async function resolveStdioSpawnCommand(
150
222
 
151
223
  const resolvedCommand =
152
224
  (await resolveWindowsCommandPath(config.command, options.cwd, options.env)) ?? config.command;
225
+ const npmShimCommand = await resolveWindowsNpmShimCommand(resolvedCommand, args, options.cwd);
226
+ if (npmShimCommand) return npmShimCommand;
153
227
  if (!isWindowsBatchCommand(resolvedCommand)) return { cmd: [resolvedCommand, ...args] };
154
228
 
155
229
  return {
@@ -6,6 +6,7 @@ import {
6
6
  fuzzyFilter,
7
7
  matchesKey,
8
8
  ScrollView,
9
+ type SgrMouseEvent,
9
10
  Spacer,
10
11
  TruncatedText,
11
12
  } from "@oh-my-pi/pi-tui";
@@ -16,6 +17,12 @@ import { DynamicBorder } from "./dynamic-border";
16
17
 
17
18
  const OAUTH_SELECTOR_MAX_VISIBLE = 10;
18
19
 
20
+ /**
21
+ * Rendered lines before the provider rows: top border, spacer, title, spacer
22
+ * (must mirror the constructor's addChild order).
23
+ */
24
+ const LIST_ROW_OFFSET = 4;
25
+
19
26
  /** Compact, human-readable tag for each credential-origin leg. */
20
27
  const ORIGIN_LABELS: Record<CredentialOriginKind, string> = {
21
28
  runtime: "--api-key",
@@ -34,6 +41,10 @@ export class OAuthSelectorComponent extends Container {
34
41
  #filteredProviders: OAuthProviderInfo[] = [];
35
42
  #searchQuery = "";
36
43
  #selectedIndex: number = 0;
44
+ #hoveredIndex: number | null = null;
45
+ /** First provider index of the visible ScrollView window (last #updateList). */
46
+ #scrollStart = 0;
47
+ #visibleCount = 0;
37
48
  #mode: "login" | "logout";
38
49
  #authStorage: AuthStorage;
39
50
  #onSelectCallback: (providerId: string) => void;
@@ -252,6 +263,8 @@ export class OAuthSelectorComponent extends Container {
252
263
  ? 0
253
264
  : Math.max(0, Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
254
265
  const endIndex = Math.min(startIndex + maxVisible, total);
266
+ this.#scrollStart = startIndex;
267
+ this.#visibleCount = endIndex - startIndex;
255
268
 
256
269
  const rows: string[] = [];
257
270
  for (let i = startIndex; i < endIndex; i++) {
@@ -270,6 +283,9 @@ export class OAuthSelectorComponent extends Container {
270
283
  const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
271
284
  line = text + statusIndicator;
272
285
  }
286
+ if (!isSelected && i === this.#hoveredIndex) {
287
+ line = theme.bg("selectedBg", line);
288
+ }
273
289
  rows.push(line);
274
290
  }
275
291
 
@@ -354,15 +370,59 @@ export class OAuthSelectorComponent extends Container {
354
370
  }
355
371
  // Enter
356
372
  else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
357
- const selectedProvider = this.#filteredProviders[this.#selectedIndex];
358
- if (selectedProvider?.available) {
359
- this.#statusMessage = undefined;
360
- this.stopValidation();
361
- this.#onSelectCallback(selectedProvider.id);
362
- } else if (selectedProvider) {
363
- this.#statusMessage = "Provider unavailable in this environment.";
373
+ this.#confirmSelection();
374
+ }
375
+ }
376
+
377
+ /** Confirm the selected provider (Enter or mouse click). */
378
+ #confirmSelection(): void {
379
+ const selectedProvider = this.#filteredProviders[this.#selectedIndex];
380
+ if (selectedProvider?.available) {
381
+ this.#statusMessage = undefined;
382
+ this.stopValidation();
383
+ this.#onSelectCallback(selectedProvider.id);
384
+ } else if (selectedProvider) {
385
+ this.#statusMessage = "Provider unavailable in this environment.";
386
+ this.#updateList();
387
+ }
388
+ }
389
+
390
+ /** Move the selection one step for a wheel notch (clamped, no wrap). */
391
+ handleWheel(delta: -1 | 1): void {
392
+ if (this.#filteredProviders.length === 0) return;
393
+ const next = Math.max(0, Math.min(this.#selectedIndex + delta, this.#filteredProviders.length - 1));
394
+ if (next === this.#selectedIndex) return;
395
+ this.#selectedIndex = next;
396
+ this.#statusMessage = undefined;
397
+ this.#updateList();
398
+ }
399
+
400
+ /**
401
+ * Route an SGR mouse report at component-local coordinates. Provider rows
402
+ * start LIST_ROW_OFFSET lines into the render; the ScrollView window shows
403
+ * #visibleCount rows from #scrollStart. Wheel moves the selection, motion
404
+ * drives the hover band, and a left click selects and confirms like Enter.
405
+ */
406
+ routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
407
+ if (event.wheel !== null) {
408
+ this.handleWheel(event.wheel);
409
+ return;
410
+ }
411
+ const localRow = line - LIST_ROW_OFFSET;
412
+ const index = localRow >= 0 && localRow < this.#visibleCount ? this.#scrollStart + localRow : undefined;
413
+ const target = index !== undefined && index < this.#filteredProviders.length ? index : null;
414
+ if (event.motion) {
415
+ if (target !== this.#hoveredIndex) {
416
+ this.#hoveredIndex = target;
364
417
  this.#updateList();
365
418
  }
419
+ return;
420
+ }
421
+ if (!event.leftClick || target === null) return;
422
+ if (target !== this.#selectedIndex) {
423
+ this.#selectedIndex = target;
424
+ this.#statusMessage = undefined;
366
425
  }
426
+ this.#confirmSelection();
367
427
  }
368
428
  }
@@ -0,0 +1,161 @@
1
+ import { Container, matchesKey, ScrollView, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
2
+ import { theme } from "../../modes/theme/theme";
3
+ import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
4
+ import type { ResetUsageAccount } from "../../slash-commands/helpers/reset-usage";
5
+ import { DynamicBorder } from "./dynamic-border";
6
+
7
+ const RESET_SELECTOR_MAX_VISIBLE = 10;
8
+
9
+ /**
10
+ * Account picker for `/usage reset`. Lists Codex accounts with their saved
11
+ * rate-limit reset counts; selecting one redeems a reset. Because a reset is a
12
+ * scarce, irreversible credit, Enter requires a second press to confirm.
13
+ */
14
+ export class ResetUsageSelectorComponent extends Container {
15
+ #listContainer: Container;
16
+ #accounts: ResetUsageAccount[];
17
+ #selectedIndex = 0;
18
+ #pendingIndex: number | null = null;
19
+ #statusMessage: string | undefined;
20
+ #onSelectCallback: (account: ResetUsageAccount) => void;
21
+ #onCancelCallback: () => void;
22
+
23
+ constructor(accounts: ResetUsageAccount[], onSelect: (account: ResetUsageAccount) => void, onCancel: () => void) {
24
+ super();
25
+ this.#accounts = accounts;
26
+ this.#onSelectCallback = onSelect;
27
+ this.#onCancelCallback = onCancel;
28
+ const firstRedeemable = accounts.findIndex(account => account.availableCount > 0);
29
+ this.#selectedIndex = firstRedeemable >= 0 ? firstRedeemable : 0;
30
+
31
+ this.addChild(new DynamicBorder());
32
+ this.addChild(new Spacer(1));
33
+ this.addChild(new TruncatedText(theme.bold("Spend a saved rate-limit reset:")));
34
+ this.addChild(new Spacer(1));
35
+ this.#listContainer = new Container();
36
+ this.addChild(this.#listContainer);
37
+ this.addChild(new Spacer(1));
38
+ this.addChild(new DynamicBorder());
39
+ this.#updateList();
40
+ }
41
+
42
+ #updateList(): void {
43
+ this.#listContainer.clear();
44
+
45
+ const total = this.#accounts.length;
46
+ const maxVisible = RESET_SELECTOR_MAX_VISIBLE;
47
+ const startIndex =
48
+ total <= maxVisible
49
+ ? 0
50
+ : Math.max(0, Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
51
+ const endIndex = Math.min(startIndex + maxVisible, total);
52
+
53
+ const rows: string[] = [];
54
+ for (let i = startIndex; i < endIndex; i++) {
55
+ const account = this.#accounts[i];
56
+ if (!account) continue;
57
+ const isSelected = i === this.#selectedIndex;
58
+ const redeemable = account.availableCount > 0;
59
+ const countLabel = account.error
60
+ ? account.error
61
+ : `${account.availableCount} saved reset${account.availableCount === 1 ? "" : "s"}`;
62
+ const countText = account.error
63
+ ? theme.fg("error", countLabel)
64
+ : redeemable
65
+ ? theme.fg("success", countLabel)
66
+ : theme.fg("dim", countLabel);
67
+ const activeTag = account.active ? theme.fg("muted", " (active)") : "";
68
+ if (isSelected) {
69
+ const name = redeemable ? theme.fg("accent", account.label) : theme.fg("dim", account.label);
70
+ rows.push(`${theme.fg("accent", `${theme.nav.cursor} `)}${name}${activeTag} ${countText}`);
71
+ } else {
72
+ const name = redeemable ? ` ${account.label}` : theme.fg("dim", ` ${account.label}`);
73
+ rows.push(`${name}${activeTag} ${countText}`);
74
+ }
75
+ }
76
+
77
+ if (rows.length > 0) {
78
+ const sv = new ScrollView(rows, {
79
+ height: rows.length,
80
+ scrollbar: "auto",
81
+ totalRows: total,
82
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
83
+ });
84
+ sv.setScrollOffset(startIndex);
85
+ this.#listContainer.addChild(sv);
86
+ }
87
+
88
+ if (total === 0) {
89
+ this.#listContainer.addChild(
90
+ new TruncatedText(theme.fg("muted", " No Codex accounts with saved resets"), 0, 0),
91
+ );
92
+ }
93
+
94
+ const pending = this.#pendingIndex !== null ? this.#accounts[this.#pendingIndex] : undefined;
95
+ const hint = pending
96
+ ? theme.fg("warning", ` Press Enter again to spend 1 reset for ${pending.label}, Esc to cancel`)
97
+ : theme.fg("muted", " ↑/↓ select · ↵ spend a reset · Esc cancel");
98
+ this.#listContainer.addChild(new TruncatedText(hint, 0, 0));
99
+
100
+ if (this.#statusMessage) {
101
+ this.#listContainer.addChild(new Spacer(1));
102
+ this.#listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.#statusMessage}`), 0, 0));
103
+ }
104
+ }
105
+
106
+ handleInput(keyData: string): void {
107
+ if (matchesSelectCancel(keyData)) {
108
+ if (this.#pendingIndex !== null) {
109
+ this.#pendingIndex = null;
110
+ this.#statusMessage = undefined;
111
+ this.#updateList();
112
+ return;
113
+ }
114
+ this.#onCancelCallback();
115
+ return;
116
+ }
117
+
118
+ if (matchesSelectUp(keyData)) {
119
+ if (this.#accounts.length > 0) {
120
+ this.#selectedIndex = this.#selectedIndex === 0 ? this.#accounts.length - 1 : this.#selectedIndex - 1;
121
+ }
122
+ this.#pendingIndex = null;
123
+ this.#statusMessage = undefined;
124
+ this.#updateList();
125
+ } else if (matchesSelectDown(keyData)) {
126
+ if (this.#accounts.length > 0) {
127
+ this.#selectedIndex = this.#selectedIndex === this.#accounts.length - 1 ? 0 : this.#selectedIndex + 1;
128
+ }
129
+ this.#pendingIndex = null;
130
+ this.#statusMessage = undefined;
131
+ this.#updateList();
132
+ } else if (matchesKey(keyData, "pageUp")) {
133
+ if (this.#accounts.length > 0) {
134
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - RESET_SELECTOR_MAX_VISIBLE);
135
+ }
136
+ this.#pendingIndex = null;
137
+ this.#updateList();
138
+ } else if (matchesKey(keyData, "pageDown")) {
139
+ if (this.#accounts.length > 0) {
140
+ this.#selectedIndex = Math.min(this.#accounts.length - 1, this.#selectedIndex + RESET_SELECTOR_MAX_VISIBLE);
141
+ }
142
+ this.#pendingIndex = null;
143
+ this.#updateList();
144
+ } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
145
+ const account = this.#accounts[this.#selectedIndex];
146
+ if (!account) return;
147
+ if (account.availableCount <= 0) {
148
+ this.#statusMessage = "That account has no saved resets to spend.";
149
+ this.#updateList();
150
+ return;
151
+ }
152
+ if (this.#pendingIndex === this.#selectedIndex) {
153
+ this.#onSelectCallback(account);
154
+ return;
155
+ }
156
+ this.#pendingIndex = this.#selectedIndex;
157
+ this.#statusMessage = undefined;
158
+ this.#updateList();
159
+ }
160
+ }
161
+ }
@@ -67,13 +67,15 @@ function compareSessionRecency(a: SessionInfo, b: SessionInfo): number {
67
67
  return b.modified.getTime() - a.modified.getTime();
68
68
  }
69
69
 
70
+ const MIN_PURE_FUZZY_TOKEN_SCORE = -20;
71
+
70
72
  /**
71
73
  * Filter and rank session picker search results.
72
74
  *
73
75
  * Resume search narrows a recency-sorted list: once every query token appears
74
76
  * as a literal substring, newer sessions should beat a slightly better fuzzy
75
77
  * position match. Pure fuzzy/acronym matches still sort by fuzzy score after
76
- * literal matches.
78
+ * literal matches, but weak pure fuzzy tokens are dropped as noise.
77
79
  */
78
80
  export function rankSessionSearchMatches(allSessions: SessionInfo[], query: string): SessionInfo[] {
79
81
  const tokens = tokenizeSessionQuery(query);
@@ -85,6 +87,7 @@ export function rankSessionSearchMatches(allSessions: SessionInfo[], query: stri
85
87
  const text = sessionSearchText(session);
86
88
  const textLower = text.toLowerCase();
87
89
  let score = 0;
90
+ let worstTokenScore = Number.NEGATIVE_INFINITY;
88
91
  let literal = true;
89
92
  let matches = true;
90
93
 
@@ -95,10 +98,13 @@ export function rankSessionSearchMatches(allSessions: SessionInfo[], query: stri
95
98
  break;
96
99
  }
97
100
  score += match.score;
101
+ worstTokenScore = Math.max(worstTokenScore, match.score);
98
102
  if (!textLower.includes(token)) literal = false;
99
103
  }
100
104
 
101
- if (matches) results.push({ session, score, literal, index });
105
+ if (matches && (literal || worstTokenScore < MIN_PURE_FUZZY_TOKEN_SCORE)) {
106
+ results.push({ session, score, literal, index });
107
+ }
102
108
  }
103
109
 
104
110
  results.sort((a, b) => {