@oh-my-pi/pi-coding-agent 12.5.1 → 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.
@@ -8,7 +8,19 @@
8
8
  * - Interact with the user via UI primitives
9
9
  */
10
10
  import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
11
- import type { ImageContent, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
11
+ import type {
12
+ Api,
13
+ AssistantMessageEvent,
14
+ AssistantMessageEventStream,
15
+ Context,
16
+ ImageContent,
17
+ Model,
18
+ OAuthCredentials,
19
+ OAuthLoginCallbacks,
20
+ SimpleStreamOptions,
21
+ TextContent,
22
+ ToolResultMessage,
23
+ } from "@oh-my-pi/pi-ai";
12
24
  import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
13
25
  import type { AutocompleteItem, Component, EditorComponent, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
14
26
  import type { Static, TSchema } from "@sinclair/typebox";
@@ -64,6 +76,9 @@ export interface ExtensionUIDialogOptions {
64
76
  outline?: boolean;
65
77
  }
66
78
 
79
+ /** Raw terminal input listener for extensions. */
80
+ export type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined;
81
+
67
82
  /**
68
83
  * UI context for extensions to request interactive UI.
69
84
  * Each mode (interactive, RPC, print) provides its own implementation.
@@ -81,6 +96,9 @@ export interface ExtensionUIContext {
81
96
  /** Show a notification to the user. */
82
97
  notify(message: string, type?: "info" | "warning" | "error"): void;
83
98
 
99
+ /** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */
100
+ onTerminalInput(handler: TerminalInputHandler): () => void;
101
+
84
102
  /** Set status text in the footer/status bar. Pass undefined to clear. */
85
103
  setStatus(key: string, text: string | undefined): void;
86
104
 
@@ -157,12 +175,11 @@ export interface ExtensionUIContext {
157
175
  // ============================================================================
158
176
 
159
177
  export interface ContextUsage {
160
- tokens: number;
178
+ /** Estimated context tokens, or null if unknown (e.g. right after compaction, before next LLM response). */
179
+ tokens: number | null;
161
180
  contextWindow: number;
162
- percent: number;
163
- usageTokens: number;
164
- trailingTokens: number;
165
- lastUsageIndex: number | null;
181
+ /** Context usage as percentage of context window, or null if tokens is unknown. */
182
+ percent: number | null;
166
183
  }
167
184
 
168
185
  export interface CompactOptions {
@@ -461,6 +478,51 @@ export interface TurnEndEvent {
461
478
  toolResults: ToolResultMessage[];
462
479
  }
463
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
+
464
526
  /** Fired when auto-compaction starts */
465
527
  export interface AutoCompactionStartEvent {
466
528
  type: "auto_compaction_start";
@@ -700,6 +762,12 @@ export type ExtensionEvent =
700
762
  | AgentEndEvent
701
763
  | TurnStartEvent
702
764
  | TurnEndEvent
765
+ | MessageStartEvent
766
+ | MessageUpdateEvent
767
+ | MessageEndEvent
768
+ | ToolExecutionStartEvent
769
+ | ToolExecutionUpdateEvent
770
+ | ToolExecutionEndEvent
703
771
  | AutoCompactionStartEvent
704
772
  | AutoCompactionEndEvent
705
773
  | AutoRetryStartEvent
@@ -868,6 +936,12 @@ export interface ExtensionAPI {
868
936
  on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
869
937
  on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
870
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;
871
945
  on(event: "auto_compaction_start", handler: ExtensionHandler<AutoCompactionStartEvent>): void;
872
946
  on(event: "auto_compaction_end", handler: ExtensionHandler<AutoCompactionEndEvent>): void;
873
947
  on(event: "auto_retry_start", handler: ExtensionHandler<AutoRetryStartEvent>): void;
@@ -976,10 +1050,108 @@ export interface ExtensionAPI {
976
1050
  /** Set thinking level (clamped to model capabilities). */
977
1051
  setThinkingLevel(level: ThinkingLevel): void;
978
1052
 
1053
+ // =========================================================================
1054
+ // Provider Registration
1055
+ // =========================================================================
1056
+
1057
+ /**
1058
+ * Register or override a model provider.
1059
+ *
1060
+ * If `models` is provided: replaces all existing models for this provider.
1061
+ * If only `baseUrl` is provided: overrides the URL for existing models.
1062
+ * If `streamSimple` is provided: registers a custom API stream handler.
1063
+ *
1064
+ * @example
1065
+ * // Register a new provider with custom models and streaming
1066
+ * pi.registerProvider("google-vertex-claude", {
1067
+ * baseUrl: "https://us-east5-aiplatform.googleapis.com",
1068
+ * apiKey: "GOOGLE_CLOUD_PROJECT",
1069
+ * api: "vertex-claude-api",
1070
+ * streamSimple: myStreamFunction,
1071
+ * models: [
1072
+ * {
1073
+ * id: "claude-sonnet-4@20250514",
1074
+ * name: "Claude Sonnet 4 (Vertex)",
1075
+ * reasoning: true,
1076
+ * input: ["text", "image"],
1077
+ * cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
1078
+ * contextWindow: 200000,
1079
+ * maxTokens: 64000,
1080
+ * }
1081
+ * ]
1082
+ * });
1083
+ *
1084
+ * @example
1085
+ * // Override baseUrl for an existing provider
1086
+ * pi.registerProvider("anthropic", {
1087
+ * baseUrl: "https://proxy.example.com"
1088
+ * });
1089
+ */
1090
+ registerProvider(name: string, config: ProviderConfig): void;
1091
+
979
1092
  /** Shared event bus for extension communication. */
980
1093
  events: EventBus;
981
1094
  }
982
1095
 
1096
+ // ============================================================================
1097
+ // Provider Registration Types
1098
+ // ============================================================================
1099
+
1100
+ /** Configuration for registering a provider via pi.registerProvider(). */
1101
+ export interface ProviderConfig {
1102
+ /** Base URL for the API endpoint. Required when defining models. */
1103
+ baseUrl?: string;
1104
+ /** API key or environment variable name. Required when defining models unless oauth is provided. */
1105
+ apiKey?: string;
1106
+ /** API type identifier. Required when registering streamSimple or when models don't specify one. */
1107
+ api?: Api;
1108
+ /** Custom streaming function for non-built-in APIs. */
1109
+ streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
1110
+ /** Custom headers to include in requests. */
1111
+ headers?: Record<string, string>;
1112
+ /** If true, adds Authorization: Bearer header with the resolved API key. */
1113
+ authHeader?: boolean;
1114
+ /** Models to register. If provided, replaces all existing models for this provider. */
1115
+ models?: ProviderModelConfig[];
1116
+ /** OAuth provider for /login support. */
1117
+ oauth?: {
1118
+ /** Display name in login UI. */
1119
+ name: string;
1120
+ /** Run the provider login flow and return credentials (or a plain API key) to persist. */
1121
+ login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials | string>;
1122
+ /** Refresh expired credentials. */
1123
+ refreshToken?(credentials: OAuthCredentials): Promise<OAuthCredentials>;
1124
+ /** Convert credentials to an API key string for requests. */
1125
+ getApiKey?(credentials: OAuthCredentials): string;
1126
+ /** Optional model rewrite hook for credential-aware routing (e.g., enterprise URLs). */
1127
+ modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
1128
+ };
1129
+ }
1130
+
1131
+ /** Configuration for a model within a provider. */
1132
+ export interface ProviderModelConfig {
1133
+ /** Model ID (e.g., "claude-sonnet-4@20250514"). */
1134
+ id: string;
1135
+ /** Display name (e.g., "Claude Sonnet 4 (Vertex)"). */
1136
+ name: string;
1137
+ /** API type override for this model. */
1138
+ api?: Api;
1139
+ /** Whether the model supports extended thinking. */
1140
+ reasoning: boolean;
1141
+ /** Supported input types. */
1142
+ input: ("text" | "image")[];
1143
+ /** Cost per million tokens. */
1144
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
1145
+ /** Maximum context window size in tokens. */
1146
+ contextWindow: number;
1147
+ /** Maximum output tokens. */
1148
+ maxTokens: number;
1149
+ /** Custom headers for this model. */
1150
+ headers?: Record<string, string>;
1151
+ /** OpenAI compatibility settings. */
1152
+ compat?: Model<Api>["compat"];
1153
+ }
1154
+
983
1155
  /** Extension factory function type. Supports both sync and async initialization. */
984
1156
  export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
985
1157
 
@@ -1038,6 +1210,8 @@ export type SetThinkingLevelHandler = (level: ThinkingLevel, persist?: boolean)
1038
1210
  /** Shared state created by loader, used during registration and runtime. */
1039
1211
  export interface ExtensionRuntimeState {
1040
1212
  flagValues: Map<string, boolean | string>;
1213
+ /** Provider registrations queued during extension loading, processed during session initialization */
1214
+ pendingProviderRegistrations: Array<{ name: string; config: ProviderConfig; sourceId: string }>;
1041
1215
  }
1042
1216
 
1043
1217
  /** Action implementations for ExtensionAPI methods. */
@@ -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/index.ts CHANGED
@@ -62,6 +62,8 @@ export type {
62
62
  LoadExtensionsResult,
63
63
  MessageRenderer,
64
64
  MessageRenderOptions,
65
+ ProviderConfig,
66
+ ProviderModelConfig,
65
67
  RegisteredCommand,
66
68
  ToolCallEvent,
67
69
  ToolResultEvent,
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,22 +371,39 @@ 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.getAvailable();
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
- writeStderr(chalk.red(`Model "${parsed.model}" not found`));
385
- process.exit(1);
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
+ }
386
406
  }
387
- options.model = model;
388
- settings.overrideModelRoles({ default: `${model.provider}/${model.id}` });
389
407
  } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
390
408
  const remembered = settings.getModelRole("default");
391
409
  if (remembered) {
@@ -470,7 +488,7 @@ async function buildSessionOptions(
470
488
  options.additionalExtensionPaths = [];
471
489
  }
472
490
 
473
- return options;
491
+ return { options, cliThinkingFromModel };
474
492
  }
475
493
 
476
494
  export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<void> {
@@ -602,7 +620,12 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
602
620
  sessionManager = await SessionManager.open(selectedPath);
603
621
  }
604
622
 
605
- const sessionOptions = await buildSessionOptions(parsedArgs, scopedModels, sessionManager, modelRegistry);
623
+ const { options: sessionOptions, cliThinkingFromModel } = await buildSessionOptions(
624
+ parsedArgs,
625
+ scopedModels,
626
+ sessionManager,
627
+ modelRegistry,
628
+ );
606
629
  debugStartup("main:buildSessionOptions");
607
630
  sessionOptions.authStorage = authStorage;
608
631
  sessionOptions.modelRegistry = modelRegistry;
@@ -610,11 +633,15 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
610
633
 
611
634
  // Handle CLI --api-key as runtime override (not persisted)
612
635
  if (parsedArgs.apiKey) {
613
- if (!sessionOptions.model) {
614
- writeStderr(chalk.red("--api-key requires a model to be specified via --provider/--model or -m/--models"));
636
+ if (!sessionOptions.model && !sessionOptions.modelPattern) {
637
+ writeStderr(
638
+ chalk.red("--api-key requires a model to be specified via --model, --provider/--model, or --models"),
639
+ );
615
640
  process.exit(1);
616
641
  }
617
- authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsedArgs.apiKey);
642
+ if (sessionOptions.model) {
643
+ authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsedArgs.apiKey);
644
+ }
618
645
  }
619
646
 
620
647
  time("buildSessionOptions");
@@ -622,6 +649,9 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
622
649
  await createAgentSession(sessionOptions);
623
650
  debugStartup("main:createAgentSession");
624
651
  time("createAgentSession");
652
+ if (parsedArgs.apiKey && !sessionOptions.model && session.model) {
653
+ authStorage.setRuntimeApiKey(session.model.provider, parsedArgs.apiKey);
654
+ }
625
655
 
626
656
  if (modelFallbackMessage) {
627
657
  notifs.push({ kind: "warn", message: modelFallbackMessage });
@@ -660,16 +690,22 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
660
690
  debugStartup("main:applyExtensionFlags");
661
691
 
662
692
  if (!isInteractive && !session.model) {
663
- writeStderr(chalk.red("No models available."));
693
+ if (modelFallbackMessage) {
694
+ writeStderr(chalk.red(modelFallbackMessage));
695
+ } else {
696
+ writeStderr(chalk.red("No models available."));
697
+ }
664
698
  writeStderr(chalk.yellow("\nSet an API key environment variable:"));
665
699
  writeStderr(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
666
700
  writeStderr(chalk.yellow(`\nOr create ${ModelsConfigFile.path()}`));
667
701
  process.exit(1);
668
702
  }
669
703
 
670
- // Clamp thinking level to model capabilities (for CLI override case)
671
- if (session.model && parsedArgs.thinking) {
672
- 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;
673
709
  if (!session.model.reasoning) {
674
710
  effectiveThinking = "off";
675
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" },
@@ -41,12 +41,31 @@ export class WelcomeComponent implements Component {
41
41
  }
42
42
 
43
43
  render(termWidth: number): string[] {
44
- // Box dimensions - responsive with min/max
45
- const minWidth = 80;
44
+ // Box dimensions - responsive with max width and small-terminal support
46
45
  const maxWidth = 100;
47
- const boxWidth = Math.max(minWidth, Math.min(termWidth - 2, maxWidth));
48
- const leftCol = 26;
49
- const rightCol = boxWidth - leftCol - 3; // 3 = │ + │ + │
46
+ const boxWidth = Math.min(maxWidth, Math.max(0, termWidth - 2));
47
+ if (boxWidth < 4) {
48
+ return [];
49
+ }
50
+ const dualContentWidth = boxWidth - 3; // 3 = │ + │ + │
51
+ const preferredLeftCol = 26;
52
+ const minLeftCol = 14; // logo width
53
+ const minRightCol = 20;
54
+ const leftMinContentWidth = Math.max(
55
+ minLeftCol,
56
+ visibleWidth("Welcome back!"),
57
+ visibleWidth(this.modelName),
58
+ visibleWidth(this.providerName),
59
+ );
60
+ const desiredLeftCol = Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.35)));
61
+ const dualLeftCol =
62
+ dualContentWidth >= minRightCol + 1
63
+ ? Math.min(desiredLeftCol, dualContentWidth - minRightCol)
64
+ : Math.max(1, dualContentWidth - 1);
65
+ const dualRightCol = Math.max(1, dualContentWidth - dualLeftCol);
66
+ const showRightColumn = dualLeftCol >= leftMinContentWidth && dualRightCol >= minRightCol;
67
+ const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
68
+ const rightCol = showRightColumn ? dualRightCol : 0;
50
69
 
51
70
  // Block-based OMP logo (gradient: magenta → cyan)
52
71
  // biome-ignore format: preserve ASCII art layout
@@ -67,7 +86,7 @@ export class WelcomeComponent implements Component {
67
86
  ];
68
87
 
69
88
  // Right column separator
70
- const separatorWidth = rightCol - 2; // padding on each side
89
+ const separatorWidth = Math.max(0, rightCol - 2); // padding on each side
71
90
  const separator = ` ${theme.fg("dim", theme.boxRound.horizontal.repeat(separatorWidth))}`;
72
91
 
73
92
  // Recent sessions content
@@ -131,20 +150,31 @@ export class WelcomeComponent implements Component {
131
150
  const titlePrefixRaw = hChar.repeat(3);
132
151
  const titleStyled = theme.fg("dim", titlePrefixRaw) + theme.fg("muted", title);
133
152
  const titleVisLen = visibleWidth(titlePrefixRaw) + visibleWidth(title);
134
- const afterTitle = boxWidth - 2 - titleVisLen;
135
- const afterTitleText = afterTitle > 0 ? theme.fg("dim", hChar.repeat(afterTitle)) : "";
136
- lines.push(tl + titleStyled + afterTitleText + tr);
153
+ const titleSpace = boxWidth - 2;
154
+ if (titleVisLen >= titleSpace) {
155
+ lines.push(tl + truncateToWidth(titleStyled, titleSpace) + tr);
156
+ } else {
157
+ const afterTitle = titleSpace - titleVisLen;
158
+ lines.push(tl + titleStyled + theme.fg("dim", hChar.repeat(afterTitle)) + tr);
159
+ }
137
160
 
138
161
  // Content rows
139
- const maxRows = Math.max(leftLines.length, rightLines.length);
162
+ const maxRows = showRightColumn ? Math.max(leftLines.length, rightLines.length) : leftLines.length;
140
163
  for (let i = 0; i < maxRows; i++) {
141
164
  const left = this.#fitToWidth(leftLines[i] ?? "", leftCol);
142
- const right = this.#fitToWidth(rightLines[i] ?? "", rightCol);
143
- lines.push(v + left + v + right + v);
165
+ if (showRightColumn) {
166
+ const right = this.#fitToWidth(rightLines[i] ?? "", rightCol);
167
+ lines.push(v + left + v + right + v);
168
+ } else {
169
+ lines.push(v + left + v);
170
+ }
144
171
  }
145
-
146
172
  // Bottom border
147
- lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxSharp.teeUp) + h.repeat(rightCol) + br);
173
+ if (showRightColumn) {
174
+ lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxSharp.teeUp) + h.repeat(rightCol) + br);
175
+ } else {
176
+ lines.push(bl + h.repeat(leftCol) + br);
177
+ }
148
178
 
149
179
  return lines;
150
180
  }