@oh-my-pi/pi-coding-agent 12.6.0 → 12.7.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.7.0] - 2026-02-16
6
+ ### Added
7
+
8
+ - Added Z.AI web search provider support via remote MCP endpoint (webSearchPrime)
9
+ - Added `zai` as a selectable web search provider option in settings
10
+ - Added Z.AI to automatic provider fallback chain for web search
11
+
5
12
  ## [12.6.0] - 2026-02-16
6
13
  ### Added
7
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "12.6.0",
3
+ "version": "12.7.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,12 +84,12 @@
84
84
  },
85
85
  "dependencies": {
86
86
  "@mozilla/readability": "0.6.0",
87
- "@oh-my-pi/omp-stats": "12.6.0",
88
- "@oh-my-pi/pi-agent-core": "12.6.0",
89
- "@oh-my-pi/pi-ai": "12.6.0",
90
- "@oh-my-pi/pi-natives": "12.6.0",
91
- "@oh-my-pi/pi-tui": "12.6.0",
92
- "@oh-my-pi/pi-utils": "12.6.0",
87
+ "@oh-my-pi/omp-stats": "12.7.0",
88
+ "@oh-my-pi/pi-agent-core": "12.7.0",
89
+ "@oh-my-pi/pi-ai": "12.7.0",
90
+ "@oh-my-pi/pi-natives": "12.7.0",
91
+ "@oh-my-pi/pi-tui": "12.7.0",
92
+ "@oh-my-pi/pi-utils": "12.7.0",
93
93
  "@sinclair/typebox": "^0.34.48",
94
94
  "@xterm/headless": "^6.0.0",
95
95
  "ajv": "^8.18.0",
@@ -59,23 +59,28 @@ function hasBun(): boolean {
59
59
  }
60
60
 
61
61
  /**
62
- * Get the latest release info from GitHub.
62
+ * Get the latest release info from the npm registry.
63
+ * Uses npm instead of GitHub API to avoid unauthenticated rate limiting.
63
64
  */
64
65
  async function getLatestRelease(): Promise<ReleaseInfo> {
65
- const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`);
66
+ const response = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`);
66
67
  if (!response.ok) {
67
68
  throw new Error(`Failed to fetch release info: ${response.statusText}`);
68
69
  }
69
70
 
70
- const data = (await response.json()) as {
71
- tag_name: string;
72
- assets: Array<{ name: string; browser_download_url: string }>;
73
- };
71
+ const data = (await response.json()) as { version: string };
72
+ const version = data.version;
73
+ const tag = `v${version}`;
74
74
 
75
+ // Construct deterministic GitHub release download URLs for the current platform
76
+ const makeAsset = (name: string) => ({
77
+ name,
78
+ url: `https://github.com/${REPO}/releases/download/${tag}/${name}`,
79
+ });
75
80
  return {
76
- tag: data.tag_name,
77
- version: data.tag_name.replace(/^v/, ""),
78
- assets: data.assets.map(a => ({ name: a.name, url: a.browser_download_url })),
81
+ tag,
82
+ version,
83
+ assets: [makeAsset(getBinaryName()), makeAsset(getNativeAddonName())],
79
84
  };
80
85
  }
81
86
 
@@ -25,6 +25,7 @@ const PROVIDERS: Array<SearchProviderId | "auto"> = [
25
25
  "perplexity",
26
26
  "exa",
27
27
  "jina",
28
+ "zai",
28
29
  "gemini",
29
30
  "codex",
30
31
  ];
@@ -99,6 +100,7 @@ export async function runSearchCommand(cmd: SearchCommandArgs): Promise<void> {
99
100
  provider: cmd.provider,
100
101
  recency: cmd.recency,
101
102
  limit: cmd.limit,
103
+ no_fallback: cmd.provider !== undefined && cmd.provider !== "auto",
102
104
  };
103
105
 
104
106
  const result = await runSearchQuery(params);
@@ -11,6 +11,7 @@ const PROVIDERS: Array<SearchProviderId | "auto"> = [
11
11
  "perplexity",
12
12
  "exa",
13
13
  "jina",
14
+ "zai",
14
15
  "gemini",
15
16
  "codex",
16
17
  ];
@@ -258,6 +258,7 @@ function parseModelPatternWithContext(
258
258
  pattern: string,
259
259
  availableModels: Model<Api>[],
260
260
  context: ModelPreferenceContext,
261
+ options?: { allowInvalidThinkingLevelFallback?: boolean },
261
262
  ): ParsedModelResult {
262
263
  // Try exact match first
263
264
  const exactMatch = tryMatchModel(pattern, availableModels, context);
@@ -277,7 +278,7 @@ function parseModelPatternWithContext(
277
278
 
278
279
  if (isValidThinkingLevel(suffix)) {
279
280
  // Valid thinking level - recurse on prefix and use this level
280
- const result = parseModelPatternWithContext(prefix, availableModels, context);
281
+ const result = parseModelPatternWithContext(prefix, availableModels, context, options);
281
282
  if (result.model) {
282
283
  // Only use this thinking level if no warning from inner recursion
283
284
  const explicitThinkingLevel = !result.warning;
@@ -291,8 +292,13 @@ function parseModelPatternWithContext(
291
292
  return result;
292
293
  }
293
294
 
295
+ const allowFallback = options?.allowInvalidThinkingLevelFallback ?? true;
296
+ if (!allowFallback) {
297
+ return { model: undefined, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false };
298
+ }
299
+
294
300
  // Invalid suffix - recurse on prefix and warn
295
- const result = parseModelPatternWithContext(prefix, availableModels, context);
301
+ const result = parseModelPatternWithContext(prefix, availableModels, context, options);
296
302
  if (result.model) {
297
303
  return {
298
304
  model: result.model,
@@ -308,9 +314,10 @@ export function parseModelPattern(
308
314
  pattern: string,
309
315
  availableModels: Model<Api>[],
310
316
  preferences?: ModelMatchPreferences,
317
+ options?: { allowInvalidThinkingLevelFallback?: boolean },
311
318
  ): ParsedModelResult {
312
319
  const context = buildPreferenceContext(availableModels, preferences);
313
- return parseModelPatternWithContext(pattern, availableModels, context);
320
+ return parseModelPatternWithContext(pattern, availableModels, context, options);
314
321
  }
315
322
 
316
323
  const PREFIX_MODEL_ROLE = "pi/";
@@ -486,6 +493,98 @@ export async function resolveModelScope(
486
493
  return scopedModels;
487
494
  }
488
495
 
496
+ export interface ResolveCliModelResult {
497
+ model: Model<Api> | undefined;
498
+ thinkingLevel?: ThinkingLevel;
499
+ warning: string | undefined;
500
+ error: string | undefined;
501
+ }
502
+
503
+ /**
504
+ * Resolve a single model from CLI flags.
505
+ */
506
+ export function resolveCliModel(options: {
507
+ cliProvider?: string;
508
+ cliModel?: string;
509
+ modelRegistry: ModelRegistry;
510
+ preferences?: ModelMatchPreferences;
511
+ }): ResolveCliModelResult {
512
+ const { cliProvider, cliModel, modelRegistry, preferences } = options;
513
+
514
+ if (!cliModel) {
515
+ return { model: undefined, warning: undefined, error: undefined };
516
+ }
517
+
518
+ const availableModels = modelRegistry.getAll();
519
+ if (availableModels.length === 0) {
520
+ return {
521
+ model: undefined,
522
+ warning: undefined,
523
+ error: "No models available. Check your installation or add models to models.json.",
524
+ };
525
+ }
526
+
527
+ const providerMap = new Map<string, string>();
528
+ for (const model of availableModels) {
529
+ providerMap.set(model.provider.toLowerCase(), model.provider);
530
+ }
531
+
532
+ let provider = cliProvider ? providerMap.get(cliProvider.toLowerCase()) : undefined;
533
+ if (cliProvider && !provider) {
534
+ return {
535
+ model: undefined,
536
+ warning: undefined,
537
+ error: `Unknown provider "${cliProvider}". Use --list-models to see available providers/models.`,
538
+ };
539
+ }
540
+
541
+ if (!provider) {
542
+ const lower = cliModel.toLowerCase();
543
+ const exact = availableModels.find(
544
+ model => model.id.toLowerCase() === lower || `${model.provider}/${model.id}`.toLowerCase() === lower,
545
+ );
546
+ if (exact) {
547
+ return { model: exact, warning: undefined, thinkingLevel: undefined, error: undefined };
548
+ }
549
+ }
550
+
551
+ let pattern = cliModel;
552
+
553
+ if (!provider) {
554
+ const slashIndex = cliModel.indexOf("/");
555
+ if (slashIndex !== -1) {
556
+ const maybeProvider = cliModel.substring(0, slashIndex);
557
+ const canonical = providerMap.get(maybeProvider.toLowerCase());
558
+ if (canonical) {
559
+ provider = canonical;
560
+ pattern = cliModel.substring(slashIndex + 1);
561
+ }
562
+ }
563
+ } else {
564
+ const prefix = `${provider}/`;
565
+ if (cliModel.toLowerCase().startsWith(prefix.toLowerCase())) {
566
+ pattern = cliModel.substring(prefix.length);
567
+ }
568
+ }
569
+
570
+ const candidates = provider ? availableModels.filter(model => model.provider === provider) : availableModels;
571
+ const { model, thinkingLevel, warning } = parseModelPattern(pattern, candidates, preferences, {
572
+ allowInvalidThinkingLevelFallback: false,
573
+ });
574
+
575
+ if (!model) {
576
+ const display = provider ? `${provider}/${pattern}` : cliModel;
577
+ return {
578
+ model: undefined,
579
+ thinkingLevel: undefined,
580
+ warning,
581
+ error: `Model "${display}" not found. Use --list-models to see available models.`,
582
+ };
583
+ }
584
+
585
+ return { model, thinkingLevel, warning, error: undefined };
586
+ }
587
+
489
588
  export interface InitialModelResult {
490
589
  model: Model<Api> | undefined;
491
590
  thinkingLevel: ThinkingLevel;
@@ -617,7 +617,7 @@ export const SETTINGS_SCHEMA = {
617
617
  // ─────────────────────────────────────────────────────────────────────────
618
618
  "providers.webSearch": {
619
619
  type: "enum",
620
- values: ["auto", "exa", "jina", "perplexity", "anthropic"] as const,
620
+ values: ["auto", "exa", "jina", "zai", "perplexity", "anthropic"] as const,
621
621
  default: "auto",
622
622
  ui: { tab: "services", label: "Web search provider", description: "Provider for web search tool", submenu: true },
623
623
  },
@@ -67,9 +67,13 @@ export type {
67
67
  InputEventResult,
68
68
  KeybindingsManager,
69
69
  LoadExtensionsResult,
70
+ // Events - Message
71
+ MessageEndEvent,
70
72
  // Message Rendering
71
73
  MessageRenderer,
72
74
  MessageRenderOptions,
75
+ MessageStartEvent,
76
+ MessageUpdateEvent,
73
77
  // Provider Registration
74
78
  ProviderConfig,
75
79
  ProviderModelConfig,
@@ -104,11 +108,16 @@ export type {
104
108
  SetActiveToolsHandler,
105
109
  SetModelHandler,
106
110
  SetThinkingLevelHandler,
111
+ TerminalInputHandler,
107
112
  // Events - Tool
108
113
  ToolCallEvent,
109
114
  ToolCallEventResult,
110
115
  // Tools
111
116
  ToolDefinition,
117
+ // Events - Tool Execution
118
+ ToolExecutionEndEvent,
119
+ ToolExecutionStartEvent,
120
+ ToolExecutionUpdateEvent,
112
121
  ToolRenderResultOptions,
113
122
  ToolResultEvent,
114
123
  ToolResultEventResult,
@@ -130,6 +130,7 @@ const noOpUIContext: ExtensionUIContext = {
130
130
  confirm: async (_title, _message, _dialogOptions) => false,
131
131
  input: async (_title, _placeholder, _dialogOptions) => undefined,
132
132
  notify: () => {},
133
+ onTerminalInput: () => () => {},
133
134
  setStatus: () => {},
134
135
  setWorkingMessage: () => {},
135
136
  setWidget: () => {},
@@ -10,6 +10,7 @@
10
10
  import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
11
11
  import type {
12
12
  Api,
13
+ AssistantMessageEvent,
13
14
  AssistantMessageEventStream,
14
15
  Context,
15
16
  ImageContent,
@@ -75,6 +76,9 @@ export interface ExtensionUIDialogOptions {
75
76
  outline?: boolean;
76
77
  }
77
78
 
79
+ /** Raw terminal input listener for extensions. */
80
+ export type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined;
81
+
78
82
  /**
79
83
  * UI context for extensions to request interactive UI.
80
84
  * Each mode (interactive, RPC, print) provides its own implementation.
@@ -92,6 +96,9 @@ export interface ExtensionUIContext {
92
96
  /** Show a notification to the user. */
93
97
  notify(message: string, type?: "info" | "warning" | "error"): void;
94
98
 
99
+ /** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */
100
+ onTerminalInput(handler: TerminalInputHandler): () => void;
101
+
95
102
  /** Set status text in the footer/status bar. Pass undefined to clear. */
96
103
  setStatus(key: string, text: string | undefined): void;
97
104
 
@@ -168,12 +175,11 @@ export interface ExtensionUIContext {
168
175
  // ============================================================================
169
176
 
170
177
  export interface ContextUsage {
171
- tokens: number;
178
+ /** Estimated context tokens, or null if unknown (e.g. right after compaction, before next LLM response). */
179
+ tokens: number | null;
172
180
  contextWindow: number;
173
- percent: number;
174
- usageTokens: number;
175
- trailingTokens: number;
176
- lastUsageIndex: number | null;
181
+ /** Context usage as percentage of context window, or null if tokens is unknown. */
182
+ percent: number | null;
177
183
  }
178
184
 
179
185
  export interface CompactOptions {
@@ -472,6 +478,51 @@ export interface TurnEndEvent {
472
478
  toolResults: ToolResultMessage[];
473
479
  }
474
480
 
481
+ /** Fired when a message starts (user, assistant, or toolResult) */
482
+ export interface MessageStartEvent {
483
+ type: "message_start";
484
+ message: AgentMessage;
485
+ }
486
+
487
+ /** Fired during assistant message streaming with token-by-token updates */
488
+ export interface MessageUpdateEvent {
489
+ type: "message_update";
490
+ message: AgentMessage;
491
+ assistantMessageEvent: AssistantMessageEvent;
492
+ }
493
+
494
+ /** Fired when a message ends */
495
+ export interface MessageEndEvent {
496
+ type: "message_end";
497
+ message: AgentMessage;
498
+ }
499
+
500
+ /** Fired when a tool starts executing */
501
+ export interface ToolExecutionStartEvent {
502
+ type: "tool_execution_start";
503
+ toolCallId: string;
504
+ toolName: string;
505
+ args: unknown;
506
+ }
507
+
508
+ /** Fired during tool execution with partial/streaming output */
509
+ export interface ToolExecutionUpdateEvent {
510
+ type: "tool_execution_update";
511
+ toolCallId: string;
512
+ toolName: string;
513
+ args: unknown;
514
+ partialResult: unknown;
515
+ }
516
+
517
+ /** Fired when a tool finishes executing */
518
+ export interface ToolExecutionEndEvent {
519
+ type: "tool_execution_end";
520
+ toolCallId: string;
521
+ toolName: string;
522
+ result: unknown;
523
+ isError: boolean;
524
+ }
525
+
475
526
  /** Fired when auto-compaction starts */
476
527
  export interface AutoCompactionStartEvent {
477
528
  type: "auto_compaction_start";
@@ -711,6 +762,12 @@ export type ExtensionEvent =
711
762
  | AgentEndEvent
712
763
  | TurnStartEvent
713
764
  | TurnEndEvent
765
+ | MessageStartEvent
766
+ | MessageUpdateEvent
767
+ | MessageEndEvent
768
+ | ToolExecutionStartEvent
769
+ | ToolExecutionUpdateEvent
770
+ | ToolExecutionEndEvent
714
771
  | AutoCompactionStartEvent
715
772
  | AutoCompactionEndEvent
716
773
  | AutoRetryStartEvent
@@ -879,6 +936,12 @@ export interface ExtensionAPI {
879
936
  on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
880
937
  on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
881
938
  on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
939
+ on(event: "message_start", handler: ExtensionHandler<MessageStartEvent>): void;
940
+ on(event: "message_update", handler: ExtensionHandler<MessageUpdateEvent>): void;
941
+ on(event: "message_end", handler: ExtensionHandler<MessageEndEvent>): void;
942
+ on(event: "tool_execution_start", handler: ExtensionHandler<ToolExecutionStartEvent>): void;
943
+ on(event: "tool_execution_update", handler: ExtensionHandler<ToolExecutionUpdateEvent>): void;
944
+ on(event: "tool_execution_end", handler: ExtensionHandler<ToolExecutionEndEvent>): void;
882
945
  on(event: "auto_compaction_start", handler: ExtensionHandler<AutoCompactionStartEvent>): void;
883
946
  on(event: "auto_compaction_end", handler: ExtensionHandler<AutoCompactionEndEvent>): void;
884
947
  on(event: "auto_retry_start", handler: ExtensionHandler<AutoRetryStartEvent>): void;
@@ -172,7 +172,7 @@ function parseGenericGitUrl(url: string): GitSource | null {
172
172
  if (scpLikeMatch) {
173
173
  host = scpLikeMatch[1] ?? "";
174
174
  repoPath = scpLikeMatch[2] ?? "";
175
- } else if (/^https?:\/\/|^ssh:\/\//.test(repoWithoutRef)) {
175
+ } else if (/^https?:\/\/|^ssh:\/\/|^git:\/\//.test(repoWithoutRef)) {
176
176
  try {
177
177
  const parsed = new URL(repoWithoutRef);
178
178
  if (parsed.hash) {
@@ -210,20 +210,30 @@ function parseGenericGitUrl(url: string): GitSource | null {
210
210
  }
211
211
 
212
212
  /**
213
- * Parse any git URL (SSH or HTTPS) into a GitSource.
213
+ * Parse git source into a GitSource.
214
+ *
215
+ * Rules:
216
+ * - With `git:` prefix, accept shorthand forms.
217
+ * - Without `git:` prefix, only accept explicit protocol URLs.
214
218
  *
215
219
  * Handles:
216
220
  * - `git:` prefixed URLs (`git:github.com/user/repo`)
217
- * - SSH SCP-like URLs (`git@github.com:user/repo`)
218
- * - HTTPS/HTTP/SSH protocol URLs
219
- * - Bare `host/user/repo` shorthand
221
+ * - SSH SCP-like URLs (`git:git@github.com:user/repo`)
222
+ * - HTTPS/HTTP/SSH/git protocol URLs
220
223
  * - Ref pinning via `@ref` suffix
221
224
  *
222
225
  * Recognizes GitHub, GitLab, Bitbucket, Sourcehut, and Codeberg natively.
223
226
  * Falls back to generic URL parsing for other hosts.
224
227
  */
225
228
  export function parseGitUrl(source: string): GitSource | null {
226
- const url = source.startsWith("git:") ? source.slice(4).trim() : source;
229
+ const trimmed = source.trim();
230
+ const hasGitPrefix = /^git:(?!\/\/)/i.test(trimmed);
231
+ const url = hasGitPrefix ? trimmed.slice(4).trim() : trimmed;
232
+
233
+ if (!hasGitPrefix && !/^(https?|ssh|git):\/\//i.test(url)) {
234
+ return null;
235
+ }
236
+
227
237
  const hashIndex = url.indexOf("#");
228
238
  if (hashIndex >= 0) {
229
239
  const hash = url.slice(hashIndex + 1);
@@ -244,7 +254,7 @@ export function parseGitUrl(source: string): GitSource | null {
244
254
  const directCandidates: string[] = [];
245
255
  if (scpMatch) {
246
256
  directCandidates.push(`https://${scpMatch[1]}/${scpMatch[2]}`);
247
- } else if (/^https?:\/\/|^ssh:\/\//.test(split.repo)) {
257
+ } else if (/^https?:\/\/|^ssh:\/\/|^git:\/\//.test(split.repo)) {
248
258
  directCandidates.push(split.repo);
249
259
  }
250
260
 
@@ -254,6 +264,7 @@ export function parseGitUrl(source: string): GitSource | null {
254
264
  !split.repo.startsWith("http://") &&
255
265
  !split.repo.startsWith("https://") &&
256
266
  !split.repo.startsWith("ssh://") &&
267
+ !split.repo.startsWith("git://") &&
257
268
  !split.repo.startsWith("git@");
258
269
  const result = tryKnownHostSource(split, withRef, needsHttps ? `https://${split.repo}` : split.repo);
259
270
  if (result) return result;
package/src/main.ts CHANGED
@@ -20,7 +20,7 @@ import { listModels } from "./cli/list-models";
20
20
  import { selectSession } from "./cli/session-picker";
21
21
  import { findConfigFile } from "./config";
22
22
  import { ModelRegistry, ModelsConfigFile } from "./config/model-registry";
23
- import { parseModelPattern, parseModelString, resolveModelScope, type ScopedModel } from "./config/model-resolver";
23
+ import { parseModelString, resolveCliModel, resolveModelScope, type ScopedModel } from "./config/model-resolver";
24
24
  import { Settings, settings } from "./config/settings";
25
25
  import { initializeWithSettings } from "./discovery";
26
26
  import { exportFromFile } from "./export/html";
@@ -355,10 +355,11 @@ async function buildSessionOptions(
355
355
  scopedModels: ScopedModel[],
356
356
  sessionManager: SessionManager | undefined,
357
357
  modelRegistry: ModelRegistry,
358
- ): Promise<CreateAgentSessionOptions> {
358
+ ): Promise<{ options: CreateAgentSessionOptions; cliThinkingFromModel: boolean }> {
359
359
  const options: CreateAgentSessionOptions = {
360
360
  cwd: parsed.cwd ?? getProjectDir(),
361
361
  };
362
+ let cliThinkingFromModel = false;
362
363
 
363
364
  // Auto-discover SYSTEM.md if no CLI system prompt provided
364
365
  const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile();
@@ -370,23 +371,38 @@ async function buildSessionOptions(
370
371
  options.sessionManager = sessionManager;
371
372
  }
372
373
 
373
- // Model from CLI (--model) - uses same fuzzy matching as --models
374
+ // Model from CLI
375
+ // - supports --provider <name> --model <pattern>
376
+ // - supports --model <provider>/<pattern>
374
377
  if (parsed.model) {
375
- const available = modelRegistry.getAll();
376
378
  const modelMatchPreferences = {
377
379
  usageOrder: settings.getStorage()?.getModelUsageOrder(),
378
380
  };
379
- const { model, warning } = parseModelPattern(parsed.model, available, modelMatchPreferences);
380
- if (warning) {
381
- writeStderr(chalk.yellow(`Warning: ${warning}`));
381
+ const resolved = resolveCliModel({
382
+ cliProvider: parsed.provider,
383
+ cliModel: parsed.model,
384
+ modelRegistry,
385
+ preferences: modelMatchPreferences,
386
+ });
387
+ if (resolved.warning) {
388
+ writeStderr(chalk.yellow(`Warning: ${resolved.warning}`));
382
389
  }
383
- if (!model) {
384
- // Model not found in built-in registry — defer resolution to after extensions load
385
- // (extensions may register additional providers/models via registerProvider)
386
- options.modelPattern = parsed.model;
387
- } else {
388
- options.model = model;
389
- settings.overrideModelRoles({ default: `${model.provider}/${model.id}` });
390
+ if (resolved.error) {
391
+ if (!parsed.provider && !parsed.model.includes(":")) {
392
+ // Model not found in built-in registry — defer resolution to after extensions load
393
+ // (extensions may register additional providers/models via registerProvider)
394
+ options.modelPattern = parsed.model;
395
+ } else {
396
+ writeStderr(chalk.red(resolved.error));
397
+ process.exit(1);
398
+ }
399
+ } else if (resolved.model) {
400
+ options.model = resolved.model;
401
+ settings.overrideModelRoles({ default: `${resolved.model.provider}/${resolved.model.id}` });
402
+ if (!parsed.thinking && resolved.thinkingLevel) {
403
+ options.thinkingLevel = resolved.thinkingLevel;
404
+ cliThinkingFromModel = true;
405
+ }
390
406
  }
391
407
  } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
392
408
  const remembered = settings.getModelRole("default");
@@ -472,7 +488,7 @@ async function buildSessionOptions(
472
488
  options.additionalExtensionPaths = [];
473
489
  }
474
490
 
475
- return options;
491
+ return { options, cliThinkingFromModel };
476
492
  }
477
493
 
478
494
  export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<void> {
@@ -604,7 +620,12 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
604
620
  sessionManager = await SessionManager.open(selectedPath);
605
621
  }
606
622
 
607
- const sessionOptions = await buildSessionOptions(parsedArgs, scopedModels, sessionManager, modelRegistry);
623
+ const { options: sessionOptions, cliThinkingFromModel } = await buildSessionOptions(
624
+ parsedArgs,
625
+ scopedModels,
626
+ sessionManager,
627
+ modelRegistry,
628
+ );
608
629
  debugStartup("main:buildSessionOptions");
609
630
  sessionOptions.authStorage = authStorage;
610
631
  sessionOptions.modelRegistry = modelRegistry;
@@ -613,7 +634,9 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
613
634
  // Handle CLI --api-key as runtime override (not persisted)
614
635
  if (parsedArgs.apiKey) {
615
636
  if (!sessionOptions.model && !sessionOptions.modelPattern) {
616
- writeStderr(chalk.red("--api-key requires a model to be specified via --provider/--model or -m/--models"));
637
+ writeStderr(
638
+ chalk.red("--api-key requires a model to be specified via --model, --provider/--model, or --models"),
639
+ );
617
640
  process.exit(1);
618
641
  }
619
642
  if (sessionOptions.model) {
@@ -678,9 +701,11 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
678
701
  process.exit(1);
679
702
  }
680
703
 
681
- // Clamp thinking level to model capabilities (for CLI override case)
682
- if (session.model && parsedArgs.thinking) {
683
- let effectiveThinking = parsedArgs.thinking;
704
+ // Clamp thinking level to model capabilities for CLI-provided thinking levels.
705
+ // This covers both --thinking <level> and --model <pattern>:<thinking>.
706
+ const cliThinkingOverride = parsedArgs.thinking !== undefined || cliThinkingFromModel;
707
+ if (session.model && cliThinkingOverride) {
708
+ let effectiveThinking = session.thinkingLevel;
684
709
  if (!session.model.reasoning) {
685
710
  effectiveThinking = "off";
686
711
  } else if (effectiveThinking === "xhigh" && !supportsXhigh(session.model)) {
@@ -1,6 +1,5 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
3
  import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
5
4
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
5
  import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
@@ -175,22 +174,12 @@ export class FooterComponent implements Component {
175
174
  }
176
175
  }
177
176
 
178
- // Get last assistant message for context percentage calculation (skip aborted messages)
179
- const lastAssistantMessage = state.messages
180
- .slice()
181
- .reverse()
182
- .find(m => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined;
183
-
184
- // Calculate context percentage from last message (input + output + cacheRead + cacheWrite)
185
- const contextTokens = lastAssistantMessage
186
- ? lastAssistantMessage.usage.input +
187
- lastAssistantMessage.usage.output +
188
- lastAssistantMessage.usage.cacheRead +
189
- lastAssistantMessage.usage.cacheWrite
190
- : 0;
191
- const contextWindow = state.model?.contextWindow || 0;
192
- const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
193
- const contextPercent = contextPercentValue.toFixed(1);
177
+ // Calculate context usage from session (handles compaction correctly).
178
+ // After compaction, tokens are unknown until the next LLM response.
179
+ const contextUsage = this.session.getContextUsage();
180
+ const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
181
+ const contextPercentValue = contextUsage?.percent ?? 0;
182
+ const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
194
183
 
195
184
  // Format token counts (similar to web-ui)
196
185
  const formatTokens = (count: number): string => {
@@ -239,7 +228,10 @@ export class FooterComponent implements Component {
239
228
  // Colorize context percentage based on usage
240
229
  let contextPercentStr: string;
241
230
  const autoIndicator = this.#autoCompactEnabled ? " (auto)" : "";
242
- const contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
231
+ const contextPercentDisplay =
232
+ contextPercent === "?"
233
+ ? `?/${formatTokens(contextWindow)}${autoIndicator}`
234
+ : `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
243
235
  if (contextPercentValue > 90) {
244
236
  contextPercentStr = theme.fg("error", contextPercentDisplay);
245
237
  } else if (contextPercentValue > 70) {
@@ -146,10 +146,16 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
146
146
  ],
147
147
  // Provider options
148
148
  "providers.webSearch": [
149
- { value: "auto", label: "Auto", description: "Priority: Exa > Perplexity > Anthropic" },
149
+ {
150
+ value: "auto",
151
+ label: "Auto",
152
+ description: "Priority: Exa > Jina > Perplexity > Anthropic > Gemini > Codex > Z.AI",
153
+ },
150
154
  { value: "exa", label: "Exa", description: "Requires EXA_API_KEY" },
155
+ { value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
151
156
  { value: "perplexity", label: "Perplexity", description: "Requires PERPLEXITY_API_KEY" },
152
157
  { value: "anthropic", label: "Anthropic", description: "Uses Anthropic web search" },
158
+ { value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
153
159
  ],
154
160
  "providers.image": [
155
161
  { value: "auto", label: "Auto", description: "Priority: OpenRouter > Gemini" },