@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.17

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 (143) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/cli.js +3531 -3812
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/model-discovery.d.ts +1 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/settings-schema.d.ts +12 -1
  11. package/dist/types/eval/agent-bridge.d.ts +19 -0
  12. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  13. package/dist/types/eval/js/shared/types.d.ts +1 -1
  14. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  15. package/dist/types/eval/py/executor.d.ts +1 -1
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  17. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  18. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  19. package/dist/types/hindsight/content.d.ts +2 -10
  20. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  21. package/dist/types/main.d.ts +2 -0
  22. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  25. package/dist/types/modes/components/session-selector.d.ts +25 -0
  26. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  30. package/dist/types/session/agent-session.d.ts +6 -6
  31. package/dist/types/session/provider-image-budget.d.ts +3 -0
  32. package/dist/types/session/session-context.d.ts +6 -5
  33. package/dist/types/task/isolation-runner.d.ts +128 -0
  34. package/dist/types/task/parallel.d.ts +4 -0
  35. package/dist/types/task/worktree.d.ts +14 -1
  36. package/dist/types/thinking.d.ts +23 -1
  37. package/dist/types/tiny/title-client.d.ts +2 -2
  38. package/dist/types/tools/eval-render.d.ts +3 -0
  39. package/dist/types/tools/eval.d.ts +11 -17
  40. package/dist/types/tools/todo.d.ts +26 -28
  41. package/dist/types/tui/output-block.d.ts +8 -0
  42. package/dist/types/utils/image-resize.d.ts +2 -0
  43. package/dist/types/utils/tools-manager.d.ts +2 -0
  44. package/dist/types/web/search/providers/exa.d.ts +2 -0
  45. package/package.json +12 -12
  46. package/scripts/build-binary.ts +18 -4
  47. package/src/auto-thinking/classifier.ts +7 -2
  48. package/src/cli/args.ts +4 -5
  49. package/src/cli/flag-tables.ts +3 -3
  50. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  51. package/src/cli/gallery-fixtures/shell.ts +15 -23
  52. package/src/cli/profile-alias.ts +38 -7
  53. package/src/cli/session-picker.ts +17 -3
  54. package/src/cli/usage-cli.ts +5 -1
  55. package/src/commands/launch.ts +3 -3
  56. package/src/config/model-discovery.ts +59 -8
  57. package/src/config/model-registry.ts +74 -3
  58. package/src/config/settings-schema.ts +13 -1
  59. package/src/discovery/omp-extension-roots.ts +1 -3
  60. package/src/edit/renderer.ts +34 -12
  61. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  62. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  63. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  64. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  65. package/src/eval/agent-bridge.ts +301 -71
  66. package/src/eval/jl/prelude.jl +32 -227
  67. package/src/eval/jl/runner.jl +38 -12
  68. package/src/eval/js/shared/helpers.ts +1 -114
  69. package/src/eval/js/shared/prelude.txt +13 -27
  70. package/src/eval/js/shared/runtime.ts +0 -6
  71. package/src/eval/js/shared/types.ts +1 -1
  72. package/src/eval/js/worker-protocol.ts +1 -1
  73. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  74. package/src/eval/py/executor.ts +1 -1
  75. package/src/eval/py/prelude.py +47 -105
  76. package/src/eval/py/runner.py +0 -6
  77. package/src/eval/rb/prelude.rb +21 -189
  78. package/src/eval/rb/runner.rb +116 -9
  79. package/src/export/html/tool-views.generated.js +29 -29
  80. package/src/extensibility/extensions/wrapper.ts +3 -2
  81. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  83. package/src/extensibility/plugins/loader.ts +71 -23
  84. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  85. package/src/extensibility/tool-event-input.ts +57 -0
  86. package/src/hindsight/content.ts +21 -12
  87. package/src/internal-urls/docs-index.generated.txt +1 -1
  88. package/src/internal-urls/local-protocol.ts +100 -53
  89. package/src/main.ts +15 -4
  90. package/src/mcp/tool-bridge.ts +27 -2
  91. package/src/mnemopi/state.ts +5 -2
  92. package/src/modes/acp/acp-event-mapper.ts +7 -2
  93. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  94. package/src/modes/components/chat-transcript-builder.ts +6 -0
  95. package/src/modes/components/custom-editor.test.ts +18 -1
  96. package/src/modes/components/custom-editor.ts +77 -45
  97. package/src/modes/components/hook-editor.ts +15 -2
  98. package/src/modes/components/plugin-settings.ts +7 -1
  99. package/src/modes/components/session-selector.ts +143 -29
  100. package/src/modes/components/settings-selector.ts +2 -2
  101. package/src/modes/components/status-line/component.ts +52 -8
  102. package/src/modes/components/status-line/segments.ts +5 -1
  103. package/src/modes/components/status-line/types.ts +1 -0
  104. package/src/modes/components/welcome.ts +12 -14
  105. package/src/modes/controllers/command-controller.ts +21 -5
  106. package/src/modes/controllers/input-controller.ts +115 -3
  107. package/src/modes/controllers/selector-controller.ts +19 -1
  108. package/src/modes/interactive-mode.ts +3 -3
  109. package/src/modes/rpc/rpc-mode.ts +6 -0
  110. package/src/modes/utils/copy-targets.ts +7 -2
  111. package/src/modes/utils/ui-helpers.ts +3 -3
  112. package/src/prompts/system/system-prompt.md +3 -3
  113. package/src/prompts/system/workflow-notice.md +3 -3
  114. package/src/prompts/tools/bash.md +16 -0
  115. package/src/prompts/tools/eval.md +19 -19
  116. package/src/prompts/tools/todo.md +1 -1
  117. package/src/sdk.ts +8 -10
  118. package/src/session/agent-session.ts +422 -97
  119. package/src/session/provider-image-budget.ts +86 -0
  120. package/src/session/session-context.ts +14 -7
  121. package/src/session/session-storage.ts +24 -2
  122. package/src/session/snapcompact-inline.ts +19 -3
  123. package/src/slash-commands/builtin-registry.ts +0 -22
  124. package/src/slash-commands/helpers/usage-report.ts +9 -1
  125. package/src/task/index.ts +61 -207
  126. package/src/task/isolation-runner.ts +354 -0
  127. package/src/task/parallel.ts +6 -1
  128. package/src/task/worktree.ts +46 -9
  129. package/src/thinking.ts +29 -2
  130. package/src/tiny/title-client.ts +75 -21
  131. package/src/tools/ask.ts +44 -38
  132. package/src/tools/bash.ts +9 -2
  133. package/src/tools/browser/tab-worker.ts +1 -1
  134. package/src/tools/eval-render.ts +34 -27
  135. package/src/tools/eval.ts +100 -103
  136. package/src/tools/index.ts +8 -1
  137. package/src/tools/read.ts +136 -60
  138. package/src/tools/todo.ts +60 -64
  139. package/src/tui/code-cell.ts +1 -1
  140. package/src/tui/output-block.ts +11 -0
  141. package/src/utils/image-resize.ts +30 -0
  142. package/src/utils/tools-manager.ts +67 -10
  143. package/src/web/search/providers/exa.ts +85 -1
@@ -59,31 +59,23 @@ export const shellFixtures: Record<string, GalleryFixture> = {
59
59
  eval: {
60
60
  label: "Eval",
61
61
  streamingArgs: {
62
- cells: [
63
- {
64
- language: "py",
65
- code: 'import json\nfrom pathlib import Path\n\ndata = json.loads(Path("package.js',
66
- title: "load config",
67
- },
68
- ],
62
+ language: "py",
63
+ code: 'import json\nfrom pathlib import Path\n\ndata = json.loads(Path("package.js',
64
+ title: "load config",
69
65
  },
70
66
  args: {
71
- cells: [
72
- {
73
- language: "py",
74
- title: "load config",
75
- code: [
76
- "import json",
77
- "from pathlib import Path",
78
- "",
79
- 'data = json.loads(Path("package.json").read_text())',
80
- 'deps = data.get("dependencies", {})',
81
- 'print(f"{data[\\"name\\"]} v{data[\\"version\\"]}")',
82
- 'print(f"{len(deps)} dependencies")',
83
- "display(sorted(deps)[:3])",
84
- ].join("\n"),
85
- },
86
- ],
67
+ language: "py",
68
+ title: "load config",
69
+ code: [
70
+ "import json",
71
+ "from pathlib import Path",
72
+ "",
73
+ 'data = json.loads(Path("package.json").read_text())',
74
+ 'deps = data.get("dependencies", {})',
75
+ 'print(f"{data[\\"name\\"]} v{data[\\"version\\"]}")',
76
+ 'print(f"{len(deps)} dependencies")',
77
+ "display(sorted(deps)[:3])",
78
+ ].join("\n"),
87
79
  },
88
80
  result: {
89
81
  content: [
@@ -198,37 +198,68 @@ export function resolveProfileAliasCommandFromProcess(
198
198
  if (!runtime || !script || !/\.[cm]?[jt]s$/.test(script)) return DEFAULT_ALIAS_COMMAND;
199
199
 
200
200
  const scriptPath = path.resolve(cwd, script);
201
- const posix = `${quoteForShell(runtime)} ${quoteForShell(scriptPath)}`;
201
+ // Normalize to forward slashes for POSIX shell fields — bash/zsh/fish
202
+ // can't resolve backslash-separated paths, even on Windows (Git Bash, WSL).
203
+ const posixScriptPath = scriptPath.replace(/\\/g, "/");
204
+ const posixRuntime = runtime.replace(/\\/g, "/");
205
+ const posix = `${quoteForShell(posixRuntime)} ${quoteForShell(posixScriptPath)}`;
202
206
  return {
203
- display: `${runtime} ${scriptPath}`,
207
+ display: `${posixRuntime} ${posixScriptPath}`,
204
208
  posix,
205
209
  fish: posix,
206
210
  powerShell: `${quoteForPowerShell(runtime)} ${quoteForPowerShell(scriptPath)}`,
207
211
  };
208
212
  }
209
213
 
214
+ /** Normalize backslashes to forward slashes for POSIX-shell paths.
215
+ * path.posix.join only adds / separators — it preserves existing backslashes
216
+ * in input segments like homeDir ("C:\Users\me"), producing mixed paths.
217
+ * Windows UNC paths (\\server\share) become //server/share — path.posix.join
218
+ * would collapse the leading // to /, so we restore it after joining. */
219
+ function toPosix(p: string): string {
220
+ return p.replace(/\\/g, "/");
221
+ }
222
+
223
+ /** Like path.posix.join, but preserves leading // (UNC roots) which
224
+ * path.posix.join collapses to a single /. */
225
+ function posixJoinUnc(...segments: string[]): string {
226
+ const joined = path.posix.join(...segments);
227
+ // path.posix.join normalizes // at the start to /, breaking UNC roots.
228
+ // Restore it if any input segment started with // (a toPosix'd UNC path).
229
+ if (segments.some(s => s.startsWith("//") && !s.startsWith("///"))) {
230
+ return `/${joined}`;
231
+ }
232
+ return joined;
233
+ }
234
+
210
235
  function resolveShellConfigPath(
211
236
  shell: ProfileAliasShell,
212
237
  homeDir: string,
213
238
  platform: NodeJS.Platform,
214
239
  env: NodeJS.ProcessEnv,
215
240
  ): string {
241
+ // POSIX shells (bash/zsh/fish) always need forward-slash config paths,
242
+ // even on Windows. path.posix.join adds / separators but preserves existing
243
+ // backslashes in input segments, so we normalize each component with toPosix.
244
+ // PowerShell profiles use the platform-native path.join (backslashes on
245
+ // Windows, forward slashes elsewhere).
246
+ const posixHome = toPosix(homeDir);
216
247
  switch (shell) {
217
248
  case "zsh":
218
- return path.join(env.ZDOTDIR || homeDir, ".zshrc");
249
+ return posixJoinUnc(env.ZDOTDIR ? toPosix(env.ZDOTDIR) : posixHome, ".zshrc");
219
250
  case "bash":
220
- return platform === "darwin" ? path.join(homeDir, ".bash_profile") : path.join(homeDir, ".bashrc");
251
+ return platform === "darwin" ? posixJoinUnc(posixHome, ".bash_profile") : posixJoinUnc(posixHome, ".bashrc");
221
252
  case "fish": {
222
253
  // fish sources conf.d from $XDG_CONFIG_HOME/fish (default ~/.config/fish);
223
254
  // a hard-coded ~/.config would be silently ignored when the user relocates
224
255
  // their XDG config root, leaving the alias unsourced after a restart.
225
- const configHome = env.XDG_CONFIG_HOME || path.join(homeDir, ".config");
226
- return path.join(configHome, "fish", "conf.d", "omp-profiles.fish");
256
+ const configHome = env.XDG_CONFIG_HOME ? toPosix(env.XDG_CONFIG_HOME) : posixJoinUnc(posixHome, ".config");
257
+ return posixJoinUnc(configHome, "fish", "conf.d", "omp-profiles.fish");
227
258
  }
228
259
  case "pwsh":
229
260
  return platform === "win32"
230
261
  ? path.join(homeDir, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1")
231
- : path.join(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1");
262
+ : posixJoinUnc(posixHome, ".config", "powershell", "Microsoft.PowerShell_profile.ps1");
232
263
  case "powershell":
233
264
  return path.join(homeDir, "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1");
234
265
  }
@@ -8,8 +8,10 @@ import { FileSessionStorage } from "../session/session-storage";
8
8
 
9
9
  /**
10
10
  * Show the TUI session selector and return the selected session, or null if
11
- * cancelled. Tab toggles between current-folder and all-projects scope; the
12
- * all-projects list is loaded lazily via `SessionManager.listAll`.
11
+ * cancelled. Rendered as a fullscreen overlay on the terminal's alternate
12
+ * screen, so the list scrolls and rows are clickable with the mouse. Tab
13
+ * toggles between current-folder and all-projects scope; the all-projects list
14
+ * is loaded lazily via `SessionManager.listAll`.
13
15
  */
14
16
  export async function selectSession(
15
17
  sessions: SessionInfo[],
@@ -65,6 +67,7 @@ export async function selectSession(
65
67
  loadAllSessions: () => SessionManager.listAll(storage),
66
68
  allSessions: options?.allSessions,
67
69
  getTerminalRows: () => ui.terminal.rows,
70
+ fillHeight: true,
68
71
  },
69
72
  );
70
73
  return selector;
@@ -72,7 +75,18 @@ export async function selectSession(
72
75
 
73
76
  const selector = showSelector();
74
77
  selector.setOnRequestRender(() => ui.requestRender());
75
- ui.addChild(selector);
78
+ // Present as a fullscreen overlay so the picker borrows the terminal's
79
+ // alternate screen buffer (vim/less idiom): the list scrolls and rows are
80
+ // clickable via the mouse tracking the overlay enables for its lifetime.
81
+ // Anchored top-left at full size so a mouse row maps directly to a rendered
82
+ // line (the overlay paints from screen row 0).
83
+ ui.showOverlay(selector, {
84
+ anchor: "top-left",
85
+ width: "100%",
86
+ maxHeight: "100%",
87
+ margin: 0,
88
+ fullscreen: true,
89
+ });
76
90
  ui.setFocus(selector);
77
91
  ui.start();
78
92
  return promise;
@@ -15,7 +15,7 @@ import {
15
15
  type UsageReport,
16
16
  type UsageUnit,
17
17
  } from "@oh-my-pi/pi-ai";
18
- import { formatDuration, formatNumber } from "@oh-my-pi/pi-utils";
18
+ import { formatDuration, formatNumber, sanitizeText } from "@oh-my-pi/pi-utils";
19
19
  import chalk from "chalk";
20
20
  import { ModelRegistry } from "../config/model-registry";
21
21
  import { discoverAuthStorage } from "../sdk";
@@ -450,6 +450,10 @@ export function formatUsageBreakdown(
450
450
  lines.push(
451
451
  `${chalk.bold.cyan(formatProviderName(provider))} ${chalk.dim(`— ${accountCount} ${accountCount === 1 ? "account" : "accounts"}`)}`,
452
452
  );
453
+ // Provider-wide disclaimers render once per provider, not per limit.
454
+ const providerNotes = [...new Set(providerReports.flatMap(report => report.notes ?? []))];
455
+ for (const note of providerNotes)
456
+ lines.push(` ${chalk.dim(sanitizeText(note.replace(/[\r\n]+/g, " ").replace(/\t/g, " ")))}`);
453
457
 
454
458
  const labelWidth = providerReports
455
459
  .flatMap(report => report.limits)
@@ -2,12 +2,12 @@
2
2
  * Root command for the coding agent CLI.
3
3
  */
4
4
 
5
- import { THINKING_EFFORTS } from "@oh-my-pi/pi-catalog/effort";
6
5
  import { APP_NAME } from "@oh-my-pi/pi-utils";
7
6
  import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
8
7
  import { parseArgs } from "../cli/args";
9
8
  import { runRootCommand } from "../main";
10
9
  import { prepareAcpTerminalAuthArgs } from "../modes/acp/terminal-auth";
10
+ import { CLI_THINKING_LEVELS } from "../thinking";
11
11
 
12
12
  export default class Index extends Command {
13
13
  static description = "AI coding assistant";
@@ -100,8 +100,8 @@ export default class Index extends Command {
100
100
  description: "Comma-separated list of tools to enable (default: all)",
101
101
  }),
102
102
  thinking: Flags.string({
103
- description: `Set thinking level: ${THINKING_EFFORTS.join(", ")}`,
104
- options: [...THINKING_EFFORTS],
103
+ description: `Set thinking level: ${CLI_THINKING_LEVELS.join(", ")}`,
104
+ options: [...CLI_THINKING_LEVELS],
105
105
  }),
106
106
  "hide-thinking": Flags.boolean({
107
107
  description: "Hide thinking blocks in TUI output (display only, does not disable model thinking)",
@@ -117,6 +117,11 @@ type LlamaCppDiscoveredServerMetadata = {
117
117
  input?: ("text" | "image")[];
118
118
  };
119
119
 
120
+ type LlamaCppModelListEntry = {
121
+ id: string;
122
+ contextWindow?: number;
123
+ };
124
+
120
125
  function toPositiveNumberOrUndefined(value: unknown): number | undefined {
121
126
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
122
127
  return value;
@@ -162,6 +167,26 @@ function extractLlamaCppContextWindow(payload: Record<string, unknown>): number
162
167
  return toPositiveNumberOrUndefined(payload.n_ctx);
163
168
  }
164
169
 
170
+ function extractLlamaCppModelContextWindow(item: Record<string, unknown>): number | undefined {
171
+ const meta = item.meta;
172
+ if (!isRecord(meta)) {
173
+ return undefined;
174
+ }
175
+ return toPositiveNumberOrUndefined(meta.n_ctx) ?? toPositiveNumberOrUndefined(meta.n_ctx_train);
176
+ }
177
+
178
+ function parseLlamaCppModelList(payload: unknown): LlamaCppModelListEntry[] {
179
+ if (!isRecord(payload) || !Array.isArray(payload.data)) {
180
+ return [];
181
+ }
182
+ return payload.data.flatMap(item => {
183
+ if (!isRecord(item) || typeof item.id !== "string" || !item.id) {
184
+ return [];
185
+ }
186
+ return [{ id: item.id, contextWindow: extractLlamaCppModelContextWindow(item) }];
187
+ });
188
+ }
189
+
165
190
  function extractLlamaCppInputCapabilities(payload: Record<string, unknown>): ("text" | "image")[] | undefined {
166
191
  const modalities = payload.modalities;
167
192
  if (!isRecord(modalities)) {
@@ -338,12 +363,13 @@ export async function discoverLlamaCppModels(
338
363
  const [response, serverMetadata] = apiKey
339
364
  ? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
340
365
  : await attempt(baseHeaders);
341
- const payload = (await response.json()) as { data?: Array<{ id: string }> };
342
- const models = payload.data ?? [];
366
+ const payload = (await response.json()) as unknown;
367
+ const models = parseLlamaCppModelList(payload);
343
368
  const discovered: Model<Api>[] = [];
344
369
  for (const item of models) {
345
- const id = item.id;
370
+ const { id } = item;
346
371
  if (!id) continue;
372
+ const contextWindow = item.contextWindow ?? serverMetadata?.contextWindow ?? 128000;
347
373
  discovered.push(
348
374
  buildModel({
349
375
  id,
@@ -355,11 +381,8 @@ export async function discoverLlamaCppModels(
355
381
  input: serverMetadata?.input ?? ["text"],
356
382
  imageInputDecoder: "stb",
357
383
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
358
- contextWindow: serverMetadata?.contextWindow ?? 128000,
359
- maxTokens: Math.min(
360
- serverMetadata?.contextWindow ?? Number.POSITIVE_INFINITY,
361
- DISCOVERY_DEFAULT_MAX_TOKENS,
362
- ),
384
+ contextWindow,
385
+ maxTokens: Math.min(contextWindow, DISCOVERY_DEFAULT_MAX_TOKENS),
363
386
  headers,
364
387
  compat: {
365
388
  supportsStore: false,
@@ -372,6 +395,34 @@ export async function discoverLlamaCppModels(
372
395
  return discovered;
373
396
  }
374
397
 
398
+ export async function discoverLlamaCppModelContextWindow(
399
+ model: Pick<Model<Api>, "provider" | "id" | "baseUrl" | "headers">,
400
+ ctx: DiscoveryContext,
401
+ ): Promise<number | undefined> {
402
+ const baseUrl = normalizeLlamaCppBaseUrl(model.baseUrl);
403
+ const modelsUrl = `${baseUrl}/models`;
404
+ const baseHeaders: Record<string, string> = { ...(model.headers ?? {}) };
405
+ const attempt = async (headers: Record<string, string>) => {
406
+ const response = await ctx.fetch(modelsUrl, {
407
+ headers,
408
+ signal: AbortSignal.timeout(250),
409
+ });
410
+ if (!response.ok) {
411
+ return undefined;
412
+ }
413
+ const entries = parseLlamaCppModelList(await response.json());
414
+ return entries.find(entry => entry.id === model.id)?.contextWindow;
415
+ };
416
+ try {
417
+ const apiKey = await ctx.getBearerApiKeyResolver(model.provider);
418
+ return apiKey
419
+ ? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
420
+ : await attempt(baseHeaders);
421
+ } catch {
422
+ return undefined;
423
+ }
424
+ }
425
+
375
426
  export async function discoverOpenAIModelsList(
376
427
  providerConfig: DiscoveryProviderConfig,
377
428
  ctx: DiscoveryContext,
@@ -67,6 +67,7 @@ import {
67
67
  DISCOVERY_DEFAULT_MAX_TOKENS,
68
68
  type DiscoveryContext,
69
69
  type DiscoveryProviderConfig,
70
+ discoverLlamaCppModelContextWindow,
70
71
  discoverModelsByProviderType,
71
72
  getImplicitOllamaBaseUrl,
72
73
  getOllamaContextLengthOverride,
@@ -759,6 +760,50 @@ export class ModelRegistry {
759
760
  }
760
761
  }
761
762
 
763
+ /**
764
+ * Refresh dynamic metadata that can appear only after a local model loads.
765
+ */
766
+ async refreshSelectedModelMetadata(model: Model<Api>): Promise<Model<Api>> {
767
+ const isLlamaCppDiscovery = this.#discoverableProviders.some(
768
+ providerConfig => providerConfig.provider === model.provider && providerConfig.discovery.type === "llama.cpp",
769
+ );
770
+ if (!isLlamaCppDiscovery) {
771
+ return model;
772
+ }
773
+ const contextWindow = await discoverLlamaCppModelContextWindow(model, this.#nonResolvingDiscoveryContext());
774
+ if (contextWindow === undefined) {
775
+ return this.find(model.provider, model.id) ?? model;
776
+ }
777
+ const current = this.find(model.provider, model.id) ?? model;
778
+ const override = this.#resolveLiveModelOverride(current);
779
+ const customModel = this.#resolveLiveCustomModelOverlay(current);
780
+ const patch: ModelPatch = {};
781
+ if (
782
+ override?.contextWindow === undefined &&
783
+ customModel?.contextWindow === undefined &&
784
+ current.contextWindow !== contextWindow
785
+ ) {
786
+ patch.contextWindow = contextWindow;
787
+ }
788
+ const maxTokens = Math.min(contextWindow, DISCOVERY_DEFAULT_MAX_TOKENS);
789
+ if (
790
+ override?.maxTokens === undefined &&
791
+ customModel?.maxTokens === undefined &&
792
+ current.maxTokens !== maxTokens
793
+ ) {
794
+ patch.maxTokens = maxTokens;
795
+ }
796
+ if (patch.contextWindow === undefined && patch.maxTokens === undefined) {
797
+ return current;
798
+ }
799
+ const patched = applyModelPatch(current, patch, "merge");
800
+ this.#models = this.#models.map(candidate =>
801
+ candidate.provider === current.provider && candidate.id === current.id ? patched : candidate,
802
+ );
803
+ this.#rebuildCanonicalIndex();
804
+ return patched;
805
+ }
806
+
762
807
  /**
763
808
  * Discover models for providers registered at runtime via `fetchDynamicModels`
764
809
  * (extension providers). Merges the discovered catalog into the existing model
@@ -1008,7 +1053,7 @@ export class ModelRegistry {
1008
1053
  provider: providerConfig.provider,
1009
1054
  status: "cached",
1010
1055
  optional: providerConfig.optional ?? false,
1011
- stale: !cache.fresh || !cache.authoritative || configStale,
1056
+ stale: providerConfig.discovery.type === "llama.cpp" || !cache.fresh || !cache.authoritative || configStale,
1012
1057
  fetchedAt: cache.updatedAt,
1013
1058
  models: models.map(model => model.id),
1014
1059
  });
@@ -1272,7 +1317,9 @@ export class ModelRegistry {
1272
1317
  const cacheProviderId = this.#configuredDiscoveryCacheProviderId(providerConfig);
1273
1318
  const cached = readModelCache<Api>(cacheProviderId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
1274
1319
  const cacheOlderThanConfig = cached !== null && this.#isDiscoveryCacheOlderThanModelsConfig(cached.updatedAt);
1275
- const effectiveStrategy = strategy === "online-if-uncached" && cacheOlderThanConfig ? "online" : strategy;
1320
+ const bypassFreshCache = providerConfig.discovery.type === "llama.cpp" && strategy === "online-if-uncached";
1321
+ const effectiveStrategy =
1322
+ strategy === "online-if-uncached" && (cacheOlderThanConfig || bypassFreshCache) ? "online" : strategy;
1276
1323
  const requiresAuth = !this.#keylessProviders.has(providerConfig.provider);
1277
1324
  if (requiresAuth) {
1278
1325
  const apiKey = await this.#peekApiKeyForProvider(providerConfig.provider);
@@ -1335,7 +1382,7 @@ export class ModelRegistry {
1335
1382
  provider: providerId,
1336
1383
  status,
1337
1384
  optional: providerConfig.optional ?? false,
1338
- stale: result.stale || status === "cached" || (cacheOlderThanConfig && status !== "ok"),
1385
+ stale: result.stale || status === "cached" || ((cacheOlderThanConfig || bypassFreshCache) && status !== "ok"),
1339
1386
  fetchedAt: discoveryError ? cached?.updatedAt : Date.now(),
1340
1387
  models: result.models.map(model => model.id),
1341
1388
  error: discoveryError,
@@ -1365,6 +1412,13 @@ export class ModelRegistry {
1365
1412
  };
1366
1413
  }
1367
1414
 
1415
+ #nonResolvingDiscoveryContext(): DiscoveryContext {
1416
+ return {
1417
+ fetch: this.#fetch,
1418
+ getBearerApiKeyResolver: async () => undefined,
1419
+ };
1420
+ }
1421
+
1368
1422
  #warnProviderDiscoveryFailure(providerConfig: DiscoveryProviderConfig, error: string): void {
1369
1423
  const previous = this.#lastDiscoveryWarnings.get(providerConfig.provider);
1370
1424
  if (previous === error) {
@@ -1574,6 +1628,23 @@ export class ModelRegistry {
1574
1628
  return this.#applyProviderTransportOverride(model, override);
1575
1629
  });
1576
1630
  }
1631
+ #resolveLiveModelOverride(model: Model<Api>): ModelOverride | undefined {
1632
+ const providerOverrides = this.#modelOverrides.get(model.provider);
1633
+ if (!providerOverrides) return undefined;
1634
+ return resolveModelOverrideWithAliases(
1635
+ providerOverrides,
1636
+ model,
1637
+ (provider, id) => this.find(provider, id) !== undefined,
1638
+ );
1639
+ }
1640
+
1641
+ #resolveLiveCustomModelOverlay(model: Model<Api>): CustomModelOverlay | undefined {
1642
+ return (
1643
+ this.#customModelOverlays.find(overlay => overlay.provider === model.provider && overlay.id === model.id) ??
1644
+ this.#runtimeModelOverlays.find(overlay => overlay.provider === model.provider && overlay.id === model.id)
1645
+ );
1646
+ }
1647
+
1577
1648
  #applyModelOverrides(models: Model<Api>[], overrides: Map<string, Map<string, ModelOverride>>): Model<Api>[] {
1578
1649
  if (overrides.size === 0) return models;
1579
1650
  let liveKeys: Set<string> | null = null;
@@ -3572,7 +3572,7 @@ export const SETTINGS_SCHEMA = {
3572
3572
  group: "Discovery & MCP",
3573
3573
  label: "Essential Tools Override",
3574
3574
  description:
3575
- "Override the always-loaded built-in tools (default: read, bash, edit). Leave empty to use defaults.",
3575
+ "Override the always-loaded built-in tools (default: read, bash, edit, write, find, eval). Leave empty to use defaults.",
3576
3576
  },
3577
3577
  },
3578
3578
 
@@ -4479,6 +4479,17 @@ export const SETTINGS_SCHEMA = {
4479
4479
  },
4480
4480
  },
4481
4481
 
4482
+ "exa.searchDelayMs": {
4483
+ type: "number",
4484
+ default: 1_000,
4485
+ ui: {
4486
+ tab: "providers",
4487
+ group: "Services",
4488
+ label: "Exa Search Delay",
4489
+ description: "Minimum delay between Exa web search requests in milliseconds; set 0 to disable pacing",
4490
+ },
4491
+ },
4492
+
4482
4493
  "exa.enableResearcher": {
4483
4494
  type: "boolean",
4484
4495
  default: false,
@@ -4785,6 +4796,7 @@ export interface TtsrSettings {
4785
4796
  export interface ExaSettings {
4786
4797
  enabled: boolean;
4787
4798
  enableSearch: boolean;
4799
+ searchDelayMs: number;
4788
4800
  enableResearcher: boolean;
4789
4801
  enableWebsets: boolean;
4790
4802
  }
@@ -180,9 +180,7 @@ export async function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtens
180
180
  async function listInstalledPluginRoots(ctx: LoadContext): Promise<InjectedRoot[]> {
181
181
  try {
182
182
  const plugins = await getEnabledPlugins(ctx.cwd, { home: ctx.home });
183
- // Installed plugins are always user-scope; project disablement is already
184
- // honored by `getEnabledPlugins` via `loadProjectOverrides`.
185
- return plugins.map(({ path: p }) => ({ path: p, level: "user" }));
183
+ return plugins.map(({ path: p, scope }) => ({ path: p, level: scope }));
186
184
  } catch (err) {
187
185
  logger.debug("listInstalledPluginRoots: enumeration failed", { error: String(err) });
188
186
  return [];
@@ -22,6 +22,7 @@ import {
22
22
  invalidateRenderedStringCache,
23
23
  type LspBatchRequest,
24
24
  PREVIEW_LIMITS,
25
+ previewWindowRows,
25
26
  type RenderedStringCache,
26
27
  replaceTabs,
27
28
  shortenPath,
@@ -340,6 +341,7 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string
340
341
  function formatStreamingDiff(
341
342
  diff: string,
342
343
  rawPath: string,
344
+ width: number,
343
345
  uiTheme: Theme,
344
346
  expanded: boolean,
345
347
  label = "streaming",
@@ -347,15 +349,32 @@ function formatStreamingDiff(
347
349
  cache?: RenderedStringCache,
348
350
  ): string {
349
351
  if (!diff) return "";
350
- let text = cachedRenderedString(cache, uiTheme, expanded, rawPath, diff, () => {
351
- // Collapsed uses a "Cursor" tail window: pin the last
352
- // EDIT_STREAMING_PREVIEW_LINES rows to the bottom so freshly streamed changes
353
- // stay on screen. The whole-file diff is recomputed on every streamed chunk
354
- // and its Myers alignment is not monotonic in payload length, so a hunk-aware
355
- // window stutters as rows move between hunks. Expanded deliberately lifts that
356
- // cap for the approval-time full view.
352
+ // Clamp the collapsed tail to the viewport so a tall or fast-growing diff
353
+ // cannot outgrow the live window. Otherwise its mutating tail scrolls above
354
+ // the native-scrollback commit boundary and the engine re-commits a fresh
355
+ // snapshot every streamed frame, stacking duplicate "… more lines above"
356
+ // previews in history. The budget is VISUAL rows (a long wrapped line counts
357
+ // for more than one) at the framed block's inner width (border only —
358
+ // contentPaddingLeft is 0); only the visible suffix is syntax-colored, so the
359
+ // cheap raw-line wrap walk keeps the per-chunk cost bounded. innerWidth/budget
360
+ // are in the cache salt so a resize re-slices.
361
+ const innerWidth = Math.max(1, width - 2);
362
+ const budget = expanded ? Number.POSITIVE_INFINITY : Math.min(EDIT_STREAMING_PREVIEW_LINES, previewWindowRows());
363
+ let text = cachedRenderedString(cache, uiTheme, expanded, `${rawPath}:${innerWidth}:${budget}`, diff, () => {
364
+ // "Cursor" tail window: pin the last rows to the bottom so freshly streamed
365
+ // changes stay on screen. The whole-file diff is recomputed every chunk and
366
+ // its Myers alignment is not monotonic in payload length, so a hunk-aware
367
+ // window stutters as rows move between hunks. Expanded lifts the cap.
357
368
  const allLines = diff.replace(/\n+$/u, "").split("\n");
358
- const hiddenLines = expanded ? 0 : Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES);
369
+ let visualUsed = 0;
370
+ let cut = allLines.length;
371
+ for (let i = allLines.length - 1; i >= 0; i--) {
372
+ const lineRows = Math.max(1, wrapTextWithAnsi(replaceTabs(allLines[i]!), innerWidth).length);
373
+ if (visualUsed + lineRows > budget && visualUsed > 0) break;
374
+ visualUsed += lineRows;
375
+ cut = i;
376
+ }
377
+ const hiddenLines = cut;
359
378
  const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines;
360
379
  let rendered = "\n\n";
361
380
  if (hiddenLines > 0) {
@@ -384,6 +403,7 @@ function formatStreamingDiff(
384
403
 
385
404
  function formatMultiFileStreamingDiff(
386
405
  previews: PerFileDiffPreview[],
406
+ width: number,
387
407
  uiTheme: Theme,
388
408
  expanded: boolean,
389
409
  spinnerFrame?: number,
@@ -405,7 +425,7 @@ function formatMultiFileStreamingDiff(
405
425
  const isLast = index === previews.length - 1;
406
426
  const cache = previewCacheAt(caches, index);
407
427
  parts.push(
408
- `${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, expanded, "preview", isLast ? spinnerFrame : undefined, cache)}`,
428
+ `${header}${formatStreamingDiff(preview.diff, preview.path, width, uiTheme, expanded, "preview", isLast ? spinnerFrame : undefined, cache)}`,
409
429
  );
410
430
  }
411
431
  }
@@ -415,6 +435,7 @@ function formatMultiFileStreamingDiff(
415
435
  function getCallPreview(
416
436
  args: EditRenderArgs,
417
437
  rawPath: string,
438
+ width: number,
418
439
  uiTheme: Theme,
419
440
  renderContext: EditRenderContext | undefined,
420
441
  expanded: boolean,
@@ -423,14 +444,14 @@ function getCallPreview(
423
444
  ): string {
424
445
  const multi = renderContext?.perFileDiffPreview;
425
446
  if (multi && multi.length > 1 && multi.some(p => p.diff || p.error)) {
426
- return formatMultiFileStreamingDiff(multi, uiTheme, expanded, spinnerFrame, caches);
447
+ return formatMultiFileStreamingDiff(multi, width, uiTheme, expanded, spinnerFrame, caches);
427
448
  }
428
449
  const cache = previewCacheAt(caches, 0);
429
450
  if (args.previewDiff) {
430
- return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, expanded, "preview", spinnerFrame, cache);
451
+ return formatStreamingDiff(args.previewDiff, rawPath, width, uiTheme, expanded, "preview", spinnerFrame, cache);
431
452
  }
432
453
  if (args.diff && args.op) {
433
- return formatStreamingDiff(args.diff, rawPath, uiTheme, expanded, "streaming", spinnerFrame, cache);
454
+ return formatStreamingDiff(args.diff, rawPath, width, uiTheme, expanded, "streaming", spinnerFrame, cache);
434
455
  }
435
456
  if (args.diff) {
436
457
  return renderPlainTextPreview(args.diff, uiTheme, rawPath);
@@ -628,6 +649,7 @@ export const editToolRenderer = {
628
649
  let body = getCallPreview(
629
650
  editArgs,
630
651
  rawPath,
652
+ width,
631
653
  uiTheme,
632
654
  renderContext,
633
655
  options.expanded,