From 7f9e1198f2f7a8cf5ea18d5010ba081d4d60c333 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Thu, 8 Oct 2026 09:01:17 +0100 Subject: [PATCH] feat(coding-agent): publish configuration schemas (#9880) * feat(coding-agent): publish configuration schemas * fix(coding-agent): preserve config schema metadata * fix(coding-agent): align schemas with public contracts * fix(ai): place compatibility schema with providers * fix(ai,coding-agent): align configuration schema contracts * fix(ai): reuse session affinity schema * docs(coding-agent): document strict theme validation * fix(ai,coding-agent): align runtime and generated schemas * fix(ai,coding-agent): centralize configuration schema contracts * fix(ai,coding-agent): preserve schema validation contracts * fix(coding-agent): validate keybinding schema grammar * fix(ai,coding-agent): refine configuration schemas * fix(ai,tui,coding-agent): align schemas with runtime behavior * fix(tui): normalize shifted symbol keybindings * fix(ai): preserve compatibility auto-detection --- packages/ai/src/providers/compat-schema.ts | 629 +++++++++ packages/ai/src/providers/model-schema.ts | 96 ++ packages/ai/src/types.ts | 388 +----- packages/ai/test/compat-schema.test.ts | 40 + packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/themes.md | 6 +- .../extensions/dynamic-resources/dynamic.json | 2 +- packages/coding-agent/package.json | 2 + .../schemas/keybindings.schema.json | 393 ++++++ .../coding-agent/schemas/models.schema.json | 1157 +++++++++++++++++ .../coding-agent/schemas/settings.schema.json | 765 +++++++++++ .../coding-agent/schemas/theme.schema.json | 365 ++++++ .../coding-agent/scripts/generate-schemas.ts | 163 +++ .../coding-agent/src/core/key-id-schema.ts | 21 + .../src/core/keybindings-schema.ts | 21 + packages/coding-agent/src/core/keybindings.ts | 112 +- .../coding-agent/src/core/model-config.ts | 219 +--- .../coding-agent/src/core/project-trust.ts | 3 +- .../src/core/settings-defaults.ts | 66 + .../coding-agent/src/core/settings-manager.ts | 302 ++--- .../coding-agent/src/core/settings-schema.ts | 474 +++++++ .../src/extensions/codemode/index.ts | 3 +- .../src/extensions/codemode/tool.ts | 5 +- packages/coding-agent/src/main.ts | 6 +- .../components/settings-selector.ts | 4 +- .../src/modes/interactive/theme/dark.json | 2 +- .../src/modes/interactive/theme/light.json | 2 +- .../src/modes/interactive/theme/theme-json.ts | 148 --- .../modes/interactive/theme/theme-schema.json | 361 ----- .../modes/interactive/theme/theme-schema.ts | 146 +++ .../modes/interactive/theme/theme-tokens.ts | 121 ++ .../src/modes/interactive/theme/theme.ts | 162 +-- .../coding-agent/test/config-schemas.test.ts | 326 +++++ .../test/resource-loader-theme.test.ts | 47 +- .../coding-agent/test/scrollbar-theme.test.ts | 2 +- packages/tui/src/keys.ts | 92 +- packages/tui/test/keys.test.ts | 14 + 37 files changed, 5177 insertions(+), 1492 deletions(-) create mode 100644 packages/ai/src/providers/compat-schema.ts create mode 100644 packages/ai/src/providers/model-schema.ts create mode 100644 packages/ai/test/compat-schema.test.ts create mode 100644 packages/coding-agent/schemas/keybindings.schema.json create mode 100644 packages/coding-agent/schemas/models.schema.json create mode 100644 packages/coding-agent/schemas/settings.schema.json create mode 100644 packages/coding-agent/schemas/theme.schema.json create mode 100644 packages/coding-agent/scripts/generate-schemas.ts create mode 100644 packages/coding-agent/src/core/key-id-schema.ts create mode 100644 packages/coding-agent/src/core/keybindings-schema.ts create mode 100644 packages/coding-agent/src/core/settings-defaults.ts create mode 100644 packages/coding-agent/src/core/settings-schema.ts delete mode 100644 packages/coding-agent/src/modes/interactive/theme/theme-json.ts delete mode 100644 packages/coding-agent/src/modes/interactive/theme/theme-schema.json create mode 100644 packages/coding-agent/src/modes/interactive/theme/theme-schema.ts create mode 100644 packages/coding-agent/src/modes/interactive/theme/theme-tokens.ts create mode 100644 packages/coding-agent/test/config-schemas.test.ts diff --git a/packages/ai/src/providers/compat-schema.ts b/packages/ai/src/providers/compat-schema.ts new file mode 100644 index 000000000..4bea0c14f --- /dev/null +++ b/packages/ai/src/providers/compat-schema.ts @@ -0,0 +1,629 @@ +import { type Static, type TProperties, type TSchemaOptions, Type } from "typebox"; +import { ModelCostSchema } from "./model-schema.ts"; + +function sessionAffinityFormat(options?: TSchemaOptions) { + return Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")], options); +} + +export const SessionAffinityFormatSchema = sessionAffinityFormat({ + description: "Session-affinity header format used to route related requests consistently.", +}); + +function thinkingTokenBudgetField(options?: TSchemaOptions) { + return Type.Union( + [Type.Literal("thinking_token_budget"), Type.Literal("thinking_budget"), Type.Literal("thinking_budget_tokens")], + options, + ); +} + +export const ThinkingTokenBudgetFieldSchema = thinkingTokenBudgetField({ + description: + 'Top-level request field name used by OpenAI-compatible endpoints to cap reasoning tokens. "thinking_token_budget" is used by vLLM, "thinking_budget" by Qwen, DashScope, or SGLang, and "thinking_budget_tokens" by llama.cpp.', +}); + +export const ChatTemplateKwargValueSchema = Type.Union([ + Type.String(), + Type.Number(), + Type.Boolean(), + Type.Null(), + Type.Object({ + $var: Type.Union([ + Type.Literal("thinking.enabled"), + Type.Literal("thinking.effort"), + Type.Literal("thinking.budget"), + ]), + omitWhenOff: Type.Optional(Type.Boolean()), + }), +]); + +function percentileCutoffs(metric: string) { + return Type.Object({ + p50: Type.Optional(Type.Number({ description: `${metric} at the 50th percentile.` })), + p75: Type.Optional(Type.Number({ description: `${metric} at the 75th percentile.` })), + p90: Type.Optional(Type.Number({ description: `${metric} at the 90th percentile.` })), + p99: Type.Optional(Type.Number({ description: `${metric} at the 99th percentile.` })), + }); +} + +export const OpenRouterRoutingSchema = Type.Object( + { + allow_fallbacks: Type.Optional( + Type.Boolean({ description: "Whether to allow backup providers to serve requests.", default: true }), + ), + require_parameters: Type.Optional( + Type.Boolean({ + description: "Whether to filter providers to only those that support all parameters in the request.", + default: false, + }), + ), + data_collection: Type.Optional( + Type.Union([Type.Literal("deny"), Type.Literal("allow")], { + description: + 'Data collection setting. "allow": allow providers that may store or train on data. "deny": only use providers that do not collect user data.', + default: "allow", + }), + ), + zdr: Type.Optional( + Type.Boolean({ description: "Whether to restrict routing to only ZDR (Zero Data Retention) endpoints." }), + ), + enforce_distillable_text: Type.Optional( + Type.Boolean({ description: "Whether to restrict routing to only models that allow text distillation." }), + ), + order: Type.Optional( + Type.Array(Type.String(), { + description: + "An ordered list of provider names or slugs to try in sequence, falling back to the next if unavailable.", + }), + ), + only: Type.Optional( + Type.Array(Type.String(), { + description: "List of provider names or slugs to exclusively allow for this request.", + }), + ), + ignore: Type.Optional( + Type.Array(Type.String(), { description: "List of provider names or slugs to skip for this request." }), + ), + quantizations: Type.Optional( + Type.Array(Type.String(), { + description: + 'A list of quantization levels to filter providers by, for example ["fp16", "bf16", "fp8", "fp6", "int8", "int4", "fp4", "fp32"].', + }), + ), + sort: Type.Optional( + Type.Union( + [ + Type.String(), + Type.Object({ + by: Type.Optional( + Type.String({ description: 'The sorting metric, such as "price", "throughput", or "latency".' }), + ), + partition: Type.Optional( + Type.Union([Type.String(), Type.Null()], { + description: 'Partitioning strategy: "model" or "none".', + default: "model", + }), + ), + }), + ], + { + description: + 'Sorting strategy. Can be a string such as "price", "throughput", or "latency", or an object.', + }, + ), + ), + max_price: Type.Optional( + Type.Object( + { + prompt: Type.Optional( + Type.Union([Type.Number(), Type.String()], { description: "Price per million prompt tokens." }), + ), + completion: Type.Optional( + Type.Union([Type.Number(), Type.String()], { description: "Price per million completion tokens." }), + ), + image: Type.Optional(Type.Union([Type.Number(), Type.String()], { description: "Price per image." })), + audio: Type.Optional( + Type.Union([Type.Number(), Type.String()], { description: "Price per audio unit." }), + ), + request: Type.Optional( + Type.Union([Type.Number(), Type.String()], { description: "Price per request." }), + ), + }, + { description: "Maximum price per million tokens in USD." }, + ), + ), + preferred_min_throughput: Type.Optional( + Type.Union([Type.Number(), percentileCutoffs("Minimum tokens per second")], { + description: "Preferred minimum throughput in tokens per second. A number applies to p50.", + }), + ), + preferred_max_latency: Type.Optional( + Type.Union([Type.Number(), percentileCutoffs("Maximum latency in seconds")], { + description: "Preferred maximum latency in seconds. A number applies to p50.", + }), + ), + }, + { + description: + "OpenRouter provider routing preferences. Controls which upstream providers OpenRouter routes requests to. Sent as the provider field in the OpenRouter API request body. See https://openrouter.ai/docs/guides/routing/provider-selection.", + }, +); + +export const VercelGatewayRoutingSchema = Type.Object( + { + only: Type.Optional( + Type.Array(Type.String(), { + description: + 'List of provider slugs to exclusively use for this request, for example ["bedrock", "anthropic"].', + }), + ), + order: Type.Optional( + Type.Array(Type.String(), { + description: 'List of provider slugs to try in order, for example ["anthropic", "openai"].', + }), + ), + }, + { + description: + "Vercel AI Gateway routing preferences. Controls which upstream providers the gateway routes requests to. See https://vercel.com/docs/ai-gateway/models-and-providers/provider-options.", + }, +); + +export const AnthropicAllowedFallbackModelSchema = Type.Object( + { + provider: Type.String({ minLength: 1 }), + model: Type.String({ minLength: 1 }), + cost: ModelCostSchema, + }, + { description: "An Anthropic server-side refusal fallback model with local pricing metadata." }, +); + +function optionalCompatBoolean(options?: TSchemaOptions) { + return Type.Optional(Type.Boolean(options)); +} + +function optionalSessionAffinityFormat(options?: TSchemaOptions) { + return Type.Optional(sessionAffinityFormat(options)); +} + +export const OpenAICompletionsCompatSchema = Type.Object( + { + supportsStore: Type.Optional( + Type.Boolean({ + description: "Whether the provider supports the store field. Default: auto-detected from URL.", + }), + ), + supportsDeveloperRole: optionalCompatBoolean({ + description: + "Whether the provider supports the developer role instead of system. Default: auto-detected from URL.", + }), + supportsReasoningEffort: Type.Optional( + Type.Boolean({ + description: "Whether the provider supports reasoning_effort. Default: auto-detected from URL.", + }), + ), + supportsUsageInStreaming: Type.Optional( + Type.Boolean({ + description: + "Whether the provider supports stream_options.include_usage for token usage in streaming responses.", + default: true, + }), + ), + supportsFinishReason: Type.Optional( + Type.Boolean({ + description: + "Whether streamed responses include finish_reason. When false, pi infers stop or toolUse when the stream ends.", + default: true, + }), + ), + maxTokensField: Type.Optional( + Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")], { + description: "Which field to use for max tokens. Default: auto-detected from URL.", + }), + ), + requiresToolResultName: Type.Optional( + Type.Boolean({ description: "Whether tool results require the name field. Default: auto-detected from URL." }), + ), + requiresAssistantAfterToolResult: Type.Optional( + Type.Boolean({ + description: + "Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL.", + }), + ), + requiresThinkingAsText: Type.Optional( + Type.Boolean({ + description: + "Whether thinking blocks must be converted to text blocks with delimiters. Default: auto-detected from URL.", + }), + ), + requiresReasoningContentOnAssistantMessages: Type.Optional( + Type.Boolean({ + description: + "Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL.", + }), + ), + thinkingFormat: Type.Optional( + Type.Union( + [ + Type.Literal("openai"), + Type.Literal("openrouter"), + Type.Literal("deepseek"), + Type.Literal("together"), + Type.Literal("baseten"), + Type.Literal("zai"), + Type.Literal("qwen"), + Type.Literal("chat-template"), + Type.Literal("qwen-chat-template"), + Type.Literal("string-thinking"), + Type.Literal("ant-ling"), + ], + { + description: + 'Format for reasoning or thinking parameters. When omitted, Pi auto-detects the format from the provider URL. "openai" uses reasoning_effort, "openrouter" uses reasoning.effort, "deepseek" uses thinking.type plus reasoning_effort when supported, "together" uses reasoning.enabled plus reasoning_effort when supported, "baseten" uses configurable chat_template_args plus reasoning_effort when supported, "zai" uses thinking.type, "qwen" uses top-level enable_thinking, "qwen-chat-template" uses chat_template_kwargs.enable_thinking and preserve_thinking, "chat-template" uses configurable chat_template_kwargs, "string-thinking" uses top-level thinking, and "ant-ling" uses reasoning.effort only when the mapped effort is non-null.', + }, + ), + ), + chatTemplateKwargs: Type.Optional( + Type.Record(Type.String(), ChatTemplateKwargValueSchema, { + description: + 'Kwargs sent as chat_template_kwargs when thinkingFormat is "chat-template". Use $var with "thinking.enabled", "thinking.effort", or "thinking.budget" for pi-controlled values.', + }), + ), + chatTemplateArgs: Type.Optional( + Type.Record(Type.String(), ChatTemplateKwargValueSchema, { + description: + 'Arguments sent as chat_template_args when thinkingFormat is "baseten". Use $var with "thinking.enabled", "thinking.effort", or "thinking.budget" for pi-controlled values.', + }), + ), + openRouterRouting: Type.Optional(OpenRouterRoutingSchema), + vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema), + zaiToolStream: Type.Optional( + Type.Boolean({ + description: "Whether z.ai supports top-level tool_stream for streaming tool call deltas.", + default: false, + }), + ), + thinkingTokenBudgetField: Type.Optional( + thinkingTokenBudgetField({ + description: + 'Top-level request field used to cap reasoning tokens from thinkingBudgets. Reasoning and the answer share max_tokens on these endpoints. "thinking_token_budget" is vLLM, "thinking_budget" is Qwen, DashScope, or SGLang, and "thinking_budget_tokens" is llama.cpp. Off by default and not set on the generated catalog.', + }), + ), + supportsThinkingTokenBudget: Type.Optional( + Type.Boolean({ + description: + 'Alias for thinkingTokenBudgetField: "thinking_token_budget" (vLLM). Prefer thinkingTokenBudgetField.', + default: false, + }), + ), + supportsOpenAIGrammarTools: optionalCompatBoolean({ + description: + "Whether the provider supports OpenAI custom tools with Lark or regex grammar formats. When false, grammar-constrained tools fall back to normal function tools. The generated catalog enables this for capable models.", + default: false, + }), + supportsMidConvoSystemMessages: optionalCompatBoolean({ + description: + "Whether the exact model accepts system or developer messages after the conversation has started. When false, later system messages are folded into the leading system message. The generated catalog enables this for verified models.", + default: false, + }), + supportsMidConvoToolAdditions: Type.Optional( + Type.Boolean({ + description: + "Whether system messages can introduce additional tools mid-conversation. Requires supportsMidConvoSystemMessages. The generated catalog enables this for capable models.", + default: false, + }), + ), + supportsStrictMode: optionalCompatBoolean({ + description: + "Whether the provider supports the strict field in tool definitions. Generated capable models enable it explicitly.", + default: false, + }), + cacheControlFormat: Type.Optional( + Type.Literal("anthropic", { + description: + "Cache control convention for prompt caching. Anthropic applies cache_control markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content.", + }), + ), + sendSessionAffinityHeaders: optionalCompatBoolean({ + description: + "Whether to send session-affinity data from options.sessionId. Default: true for OpenRouter endpoints, false otherwise.", + }), + sessionAffinityFormat: optionalSessionAffinityFormat({ + description: + "Session-affinity header format. openai sends session_id, x-client-request-id, and x-session-affinity; openai-nosession sends x-client-request-id and x-session-affinity; openrouter sends x-session-id. Does not affect prompt_cache_key. Default: auto-detected.", + }), + supportsLongCacheRetention: optionalCompatBoolean({ + description: + 'Whether the provider supports long prompt cache retention (prompt_cache_retention: "24h" or Anthropic-style cache_control.ttl: "1h", depending on format). Default: auto-detected from provider and URL.', + }), + vllmPriority: Type.Optional( + Type.Number({ + description: + "vLLM scheduler priority sent as the top-level priority request field. Lower values are handled earlier and the server default is 0. Only meaningful with --scheduling-policy priority. Off by default and not set on the generated catalog.", + }), + ), + }, + { + description: + "Compatibility settings for OpenAI-compatible completions APIs. Use this to override URL-based auto-detection for custom providers.", + additionalProperties: true, + }, +); + +export const OpenAIResponsesCompatSchema = Type.Object( + { + supportsDeveloperRole: optionalCompatBoolean({ + description: "Whether the provider supports the developer role instead of system.", + default: true, + }), + supportsMidConvoSystemMessages: optionalCompatBoolean({ + description: + "Whether the exact model accepts developer or system messages after the conversation has started. When false, later system messages are folded into the leading system message. The generated catalog enables this for verified models.", + default: false, + }), + sessionAffinityFormat: optionalSessionAffinityFormat({ + description: + "Session-affinity header format. openai sends session_id and x-client-request-id; openai-nosession sends x-client-request-id; openrouter sends x-session-id. Does not affect prompt_cache_key. Default: auto-detected.", + }), + supportsLongCacheRetention: optionalCompatBoolean({ + description: + 'Whether the provider supports long prompt cache retention. This uses prompt_cache_options.ttl: "30m" on GPT-5.6+ and prompt_cache_retention: "24h" on earlier models.', + default: true, + }), + supportsStrictMode: optionalCompatBoolean({ + description: + "Whether the provider supports strict JSON-schema function tools. Defaults are API-specific; generated OpenAI models enable it explicitly.", + }), + supportsOpenAIGrammarTools: optionalCompatBoolean({ + description: + "Whether to emit OpenAI custom tools with Lark or regex grammar formats. When false, grammar-constrained tools fall back to normal function tools. The generated catalog enables this for capable models.", + default: false, + }), + supportsAdditionalTools: Type.Optional( + Type.Boolean({ + description: "Whether the model supports message-anchored additional_tools input items.", + default: false, + }), + ), + supportsToolSearch: Type.Optional( + Type.Boolean({ + description: "Whether the model supports client-executed tool search for transcript-anchored additions.", + default: false, + }), + ), + supportsExplicitPromptCacheMode: Type.Optional( + Type.Boolean({ + description: "Whether the model accepts prompt_cache_options. Older OpenAI models reject the parameter.", + default: false, + }), + ), + supportsMaxOutputTokens: Type.Optional( + Type.Boolean({ + description: "Whether the provider accepts max_output_tokens. Some Codex-protocol gateways reject it.", + default: true, + }), + ), + }, + { description: "Compatibility settings for OpenAI Responses APIs.", additionalProperties: true }, +); + +export const AnthropicMessagesCompatSchema = Type.Object( + { + supportsEagerToolInputStreaming: Type.Optional( + Type.Boolean({ + description: + "Whether the provider accepts per-tool eager_input_streaming. When false, the Anthropic provider omits tools[].eager_input_streaming and sends the legacy fine-grained-tool-streaming-2025-05-14 beta header for tool-enabled requests.", + default: true, + }), + ), + supportsLongCacheRetention: optionalCompatBoolean({ + description: "Whether the provider supports Anthropic long cache retention through cache_control.ttl.", + default: true, + }), + sendSessionAffinityHeaders: optionalCompatBoolean({ + description: + "Whether to send x-session-affinity from options.sessionId when caching is enabled. Required for providers like Fireworks that use session affinity for prompt cache routing; requests to the same replica maximize cache hits. Default: true for OpenRouter endpoints, false otherwise.", + }), + sessionAffinityFormat: Type.Optional( + Type.Literal("openrouter", { + description: + "Session-affinity format. openrouter sends x-session-id; when unset, sends x-session-affinity.", + }), + ), + supportsCacheControlOnTools: Type.Optional( + Type.Boolean({ + description: + "Whether the provider supports Anthropic-style cache_control markers on tool definitions. When false, cache_control is omitted from tool parameters. Some Anthropic-compatible providers, such as Fireworks, do not support this field on tools and may reject or ignore it.", + default: true, + }), + ), + supportsTemperature: Type.Optional( + Type.Boolean({ + description: + "Whether the model accepts the Anthropic temperature request field. Claude Opus 4.7+ rejects non-default values.", + default: true, + }), + ), + forceAdaptiveThinking: Type.Optional( + Type.Boolean({ + description: + "Whether to force adaptive thinking (thinking.type: adaptive plus output_config.effort) regardless of model ID. Built-in models that require adaptive thinking set this in generated metadata. Custom Anthropic-compatible providers can set this to true for any model whose upstream requires the adaptive format. Set false to opt out on overridden built-in models.", + default: false, + }), + ), + allowEmptySignature: Type.Optional( + Type.Boolean({ + description: "Whether to replay empty thinking signatures instead of converting thinking to text.", + default: false, + }), + ), + supportsStrictTools: Type.Optional( + Type.Boolean({ + description: + "Whether the provider supports Anthropic strict tool schemas. Generated Anthropic models enable it explicitly.", + default: false, + }), + ), + supportsMidConvoEffort: Type.Optional( + Type.Boolean({ + description: + "Whether the exact model transport supports effort-only system messages and thinking binding controls.", + default: false, + }), + ), + supportsMidConvoSystemMessages: optionalCompatBoolean({ + description: + "Whether the exact model accepts system-role messages inside the conversation. When false, later system messages are folded into the top-level system prompt.", + default: false, + }), + supportsMidConvoToolChanges: Type.Optional( + Type.Boolean({ + description: + "Whether the exact model accepts mid-conversation tool_addition and tool_removal blocks. Requires supportsMidConvoSystemMessages.", + default: false, + }), + ), + allowedFallbackModels: Type.Optional( + Type.Array(AnthropicAllowedFallbackModelSchema, { + maxItems: 3, + description: + "Models Anthropic accepts for server-side refusal fallback, with local pricing metadata for returned fallback responses. When absent or empty, callers must omit fallbacks; Anthropic rejects the field for models with no permitted fallback targets.", + }), + ), + }, + { description: "Compatibility settings for Anthropic Messages-compatible APIs.", additionalProperties: true }, +); + +export const BedrockCompatSchema = Type.Object( + { + supportsStrictMode: optionalCompatBoolean({ + description: "Whether the model supports Bedrock strict tool schemas.", + default: false, + }), + }, + { description: "Compatibility settings for Amazon Bedrock models.", additionalProperties: true }, +); + +export const MistralConversationsCompatSchema = Type.Object( + { + supportsMidConvoSystemMessages: optionalCompatBoolean({ + description: + "Whether the exact model accepts system messages after the conversation has started. When false, later system messages are folded into the leading system message.", + default: false, + }), + }, + { description: "Compatibility settings for the Mistral chat API.", additionalProperties: true }, +); + +const ProviderCompatPropertyOverrides = { + supportsDeveloperRole: optionalCompatBoolean({ + description: "Whether the provider supports the developer role instead of system. Defaults are API-specific.", + }), + supportsMidConvoSystemMessages: optionalCompatBoolean({ + description: + "Whether the exact model accepts system or developer messages after the conversation has started. When false, later system messages are folded into the leading system message.", + default: false, + }), + sessionAffinityFormat: optionalSessionAffinityFormat({ + description: "Session-affinity header format. Defaults are API-specific or auto-detected.", + }), + supportsLongCacheRetention: optionalCompatBoolean({ + description: + "Whether the provider supports long prompt cache retention. Defaults are API-specific or auto-detected.", + }), + supportsStrictMode: optionalCompatBoolean({ + description: "Whether the provider supports strict tool schemas. Defaults are API-specific.", + }), + supportsOpenAIGrammarTools: optionalCompatBoolean({ + description: + "Whether the provider supports OpenAI custom tools with Lark or regex grammar formats. When false, grammar-constrained tools fall back to normal function tools.", + default: false, + }), + sendSessionAffinityHeaders: optionalCompatBoolean({ + description: "Whether to send session-affinity data from options.sessionId. Defaults are API-specific.", + }), +} satisfies TProperties; + +type UnionToIntersection = (Union extends unknown ? (value: Union) => void : never) extends ( + value: infer Intersection, +) => void + ? Intersection + : never; + +type MergedCompatProperties = Omit< + UnionToIntersection, + keyof Overrides +> & + Overrides; + +/** + * Provider-level models.json compatibility is API-agnostic, so it needs one superset. Every + * duplicate property requires explicit generic metadata, while API-specific schemas retain their + * own defaults and descriptions. + */ +function mergeCompatProperties< + const PropertyGroups extends readonly TProperties[], + const Overrides extends TProperties, +>(propertyGroups: PropertyGroups, overrides: Overrides): MergedCompatProperties { + const merged: TProperties = {}; + const duplicateNames = new Set(); + + for (const properties of propertyGroups) { + for (const [name, schema] of Object.entries(properties)) { + if (merged[name] !== undefined) { + duplicateNames.add(name); + if (!(name in overrides)) { + throw new Error(`Duplicate compatibility schema property requires an override: ${name}`); + } + } + merged[name] = schema; + } + } + + for (const name of Object.keys(overrides)) { + if (!duplicateNames.has(name)) { + throw new Error(`Compatibility schema override does not resolve a duplicate property: ${name}`); + } + } + + return { ...merged, ...overrides } as MergedCompatProperties; +} + +const CompatSchemasByApi = { + "openai-completions": OpenAICompletionsCompatSchema, + "openai-responses": OpenAIResponsesCompatSchema, + "anthropic-messages": AnthropicMessagesCompatSchema, + "bedrock-converse-stream": BedrockCompatSchema, + "mistral-conversations": MistralConversationsCompatSchema, +} as const; + +const ProviderCompatProperties = mergeCompatProperties( + Object.values(CompatSchemasByApi).map((schema) => schema.properties), + ProviderCompatPropertyOverrides, +); + +export const ProviderCompatSchema = Type.Object(ProviderCompatProperties, { + description: "Provider and model compatibility overrides.", + additionalProperties: true, +}); + +export type ChatTemplateKwargValue = Static; +/** Top-level request field used to cap reasoning tokens on OpenAI-compatible servers. */ +export type ThinkingTokenBudgetField = Static; +export type SessionAffinityFormat = Static; +/** OpenRouter provider routing preferences. */ +export interface OpenRouterRouting extends Static {} +/** Vercel AI Gateway routing preferences. */ +export interface VercelGatewayRouting extends Static {} +/** An Anthropic server-side refusal fallback model with local pricing metadata. */ +export interface AnthropicAllowedFallbackModel extends Static {} + +/** Compatibility settings for OpenAI-compatible completions APIs. */ +export interface OpenAICompletionsCompat extends Static {} +/** Compatibility settings for OpenAI Responses APIs. */ +export interface OpenAIResponsesCompat extends Static {} +/** Compatibility settings for Anthropic Messages-compatible APIs. */ +export interface AnthropicMessagesCompat extends Static {} +/** Compatibility settings for Amazon Bedrock models. */ +export interface BedrockCompat extends Static {} +/** Compatibility settings for the Mistral chat API. */ +export interface MistralConversationsCompat extends Static {} diff --git a/packages/ai/src/providers/model-schema.ts b/packages/ai/src/providers/model-schema.ts new file mode 100644 index 000000000..43ce3d204 --- /dev/null +++ b/packages/ai/src/providers/model-schema.ts @@ -0,0 +1,96 @@ +import { type Static, type TSchemaOptions, Type } from "typebox"; + +export const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const; +export const MODEL_THINKING_LEVELS = ["off", ...THINKING_LEVELS] as const; +export const MODEL_INPUT_MODALITIES = ["text", "image"] as const; +export const CACHE_RETENTIONS = ["none", "short", "long"] as const; +export const MODEL_PROMPT_CACHE_RETENTIONS = ["short", "long"] as const; + +export const ThinkingLevelSchema = Type.Enum(THINKING_LEVELS); +export const ModelThinkingLevelSchema = Type.Enum(MODEL_THINKING_LEVELS); +export const ModelInputModalitySchema = Type.Enum(MODEL_INPUT_MODALITIES); +export const CacheRetentionSchema = Type.Enum(CACHE_RETENTIONS); + +const ThinkingLevelMapValueSchema = Type.Union([Type.String(), Type.Null()]); +export const ThinkingLevelMapSchema = Type.Partial(Type.Record(ModelThinkingLevelSchema, ThinkingLevelMapValueSchema)); + +export const ModelPromptCacheSchema = Type.Partial( + Type.Record(Type.Enum(MODEL_PROMPT_CACHE_RETENTIONS), Type.Number({ exclusiveMinimum: 0 })), + { + description: + "Best-effort prompt cache lifetime in seconds for each retention tier. A missing tier means the lifetime is unknown, so Pi does not warm it.", + }, +); + +const ModelCostRatesProperties = { + input: Type.Number({ description: "Input cost in USD per million tokens." }), + output: Type.Number({ description: "Output cost in USD per million tokens." }), + cacheRead: Type.Number({ description: "Cache-read cost in USD per million tokens." }), + cacheWrite: Type.Number({ description: "Cache-write cost in USD per million tokens." }), +}; + +export const ModelCostRatesSchema = Type.Object(ModelCostRatesProperties); +export const ModelCostTierSchema = Type.Object({ + inputTokensAbove: Type.Number({ + description: "Use this tier when total request input exceeds this token count.", + }), + ...ModelCostRatesProperties, +}); +export const ModelCostSchema = Type.Object({ + ...ModelCostRatesProperties, + tiers: Type.Optional( + Type.Array(ModelCostTierSchema, { + description: "Request-wide pricing tiers. The highest matching input threshold applies to the full request.", + }), + ), +}); + +function modelImageResizeOptions(options?: TSchemaOptions) { + return Type.Object( + { + maxWidth: Type.Optional(Type.Integer({ minimum: 1 })), + maxHeight: Type.Optional(Type.Integer({ minimum: 1 })), + maxBytes: Type.Optional( + Type.Integer({ minimum: 1, description: "Maximum base64-encoded payload size in bytes." }), + ), + jpegQuality: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), + }, + options, + ); +} + +export const ModelImageResizeOptionsSchema = modelImageResizeOptions(); + +export const ModelImageInputLimitsSchema = Type.Object({ + resize: Type.Optional( + modelImageResizeOptions({ + description: "Cache-safe resize profile applied before a new image enters conversation history.", + }), + ), + maxPerMessage: Type.Optional( + Type.Integer({ minimum: 1, description: "Maximum images accepted in one provider message." }), + ), + maxPerRequest: Type.Optional( + Type.Integer({ minimum: 1, description: "Maximum images accepted across one provider request." }), + ), +}); + +export const ModelInputLimitsSchema = Type.Object({ + maxRequestBytes: Type.Optional( + Type.Integer({ minimum: 1, description: "Maximum serialized provider request size in bytes." }), + ), + images: Type.Optional(ModelImageInputLimitsSchema), +}); + +export type ThinkingLevel = Static; +export type ModelThinkingLevel = Static; +export type ThinkingLevelMap = Static; +export type ModelInputModality = Static; +export type CacheRetention = Static; +export type ModelPromptCache = Static; +export type ModelCostRates = Static; +export type ModelCostTier = Static; +export type ModelCost = Static; +export type ModelImageResizeOptions = Static; +export type ModelImageInputLimits = Static; +export type ModelInputLimits = Static; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 4b5f2038a..6a9a1e57a 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -9,9 +9,53 @@ import type { OpenAICodexResponsesOptions } from "./api/openai-codex-responses.t import type { OpenAICompletionsOptions } from "./api/openai-completions.ts"; import type { OpenAIResponsesOptions } from "./api/openai-responses.ts"; import type { PiMessagesOptions } from "./api/pi-messages.ts"; +import type { + AnthropicMessagesCompat, + BedrockCompat, + MistralConversationsCompat, + OpenAICompletionsCompat, + OpenAIResponsesCompat, +} from "./providers/compat-schema.ts"; +import type { + CacheRetention, + ModelCost, + ModelInputLimits, + ModelInputModality, + ModelPromptCache, + ModelThinkingLevel, + ThinkingLevel, + ThinkingLevelMap, +} from "./providers/model-schema.ts"; import type { AssistantMessageDiagnostic } from "./utils/diagnostics.ts"; import type { AssistantMessageEventStream } from "./utils/event-stream.ts"; +export type { + AnthropicAllowedFallbackModel, + AnthropicMessagesCompat, + BedrockCompat, + ChatTemplateKwargValue, + MistralConversationsCompat, + OpenAICompletionsCompat, + OpenAIResponsesCompat, + OpenRouterRouting, + SessionAffinityFormat, + ThinkingTokenBudgetField, + VercelGatewayRouting, +} from "./providers/compat-schema.ts"; +export type { + CacheRetention, + ModelCost, + ModelCostRates, + ModelCostTier, + ModelImageInputLimits, + ModelImageResizeOptions, + ModelInputLimits, + ModelInputModality, + ModelPromptCache, + ModelThinkingLevel, + ThinkingLevel, + ThinkingLevelMap, +} from "./providers/model-schema.ts"; export type { AssistantMessageEventStream } from "./utils/event-stream.ts"; export type KnownApi = @@ -86,24 +130,8 @@ export type KnownProvider = export type ProviderId = KnownProvider | string; export type ToolChoice = "auto" | "none"; -export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; -export type ModelThinkingLevel = "off" | ThinkingLevel; -export type ThinkingLevelMap = Partial>; export type SamplingParams = Record; export type SamplingParamsByThinkingLevel = Partial>; -export type ChatTemplateKwargValue = - | string - | number - | boolean - | null - | { - $var: "thinking.enabled" | "thinking.effort" | "thinking.budget"; - omitWhenOff?: boolean; - }; - -/** Top-level request field used to cap reasoning tokens on OpenAI-compatible servers. */ -export type ThinkingTokenBudgetField = "thinking_token_budget" | "thinking_budget" | "thinking_budget_tokens"; - /** Token budgets for each thinking level (token-based providers only) */ export interface ThinkingBudgets { minimal?: number; @@ -113,21 +141,12 @@ export interface ThinkingBudgets { } // Base options all providers share -export type CacheRetention = "none" | "short" | "long"; - -/** - * Best-effort prompt cache lifetime in seconds for each retention tier a request can ask for. - * A missing tier means the lifetime is unknown; pi does not warm such caches. - */ -export type ModelPromptCache = Partial, number>>; - export type Transport = "sse" | "websocket" | "websocket-cached" | "auto"; /** Provider-scoped environment overrides. Values take precedence over process.env. */ export type ProviderEnv = Record; export type ProviderHeaders = Record; export type FetchFunction = typeof globalThis.fetch; -export type SessionAffinityFormat = "openai" | "openai-nosession" | "openrouter"; export interface ProviderResponse { status: number; @@ -346,12 +365,6 @@ export interface ImagesOptions extends ProviderRequestOptions; -export interface AnthropicAllowedFallbackModel { - provider: ProviderId; - model: string; - cost: ModelCost; -} - // Unified options with reasoning passed to streamSimple() and completeSimple() export interface SimpleStreamOptions extends StreamOptions { /** Provider-neutral tool selection for simple requests. When omitted, adapters use provider-specific behavior. */ @@ -803,317 +816,6 @@ export type AssistantMessageEvent = } | { type: "error"; reason: Extract; error: AssistantMessage }; -/** - * Compatibility settings for OpenAI-compatible completions APIs. - * Use this to override URL-based auto-detection for custom providers. - */ -export interface OpenAICompletionsCompat { - /** Whether the provider supports the `store` field. Default: auto-detected from URL. */ - supportsStore?: boolean; - /** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */ - supportsDeveloperRole?: boolean; - /** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */ - supportsReasoningEffort?: boolean; - /** Whether the provider supports `stream_options: { include_usage: true }` for token usage in streaming responses. Default: true. */ - supportsUsageInStreaming?: boolean; - /** Whether streamed responses include `finish_reason`. When false, pi infers `stop` or `toolUse` when the stream ends. Default: true. */ - supportsFinishReason?: boolean; - /** Which field to use for max tokens. Default: auto-detected from URL. */ - maxTokensField?: "max_completion_tokens" | "max_tokens"; - /** Whether tool results require the `name` field. Default: auto-detected from URL. */ - requiresToolResultName?: boolean; - /** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */ - requiresAssistantAfterToolResult?: boolean; - /** Whether thinking blocks must be converted to text blocks with delimiters. Default: auto-detected from URL. */ - requiresThinkingAsText?: boolean; - /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ - requiresReasoningContentOnAssistantMessages?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "baseten" uses configurable chat_template_args plus reasoning_effort when supported, "zai" uses thinking: { type }, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking and preserve_thinking, "chat-template" uses configurable chat_template_kwargs, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */ - thinkingFormat?: - | "openai" - | "openrouter" - | "deepseek" - | "together" - | "baseten" - | "zai" - | "qwen" - | "chat-template" - | "qwen-chat-template" - | "string-thinking" - | "ant-ling"; - /** Kwargs to send as `chat_template_kwargs` when `thinkingFormat` is `chat-template`. Use `{ "$var": "thinking.enabled" }`, `{ "$var": "thinking.effort" }`, or `{ "$var": "thinking.budget" }` for pi-controlled thinking values. */ - chatTemplateKwargs?: Record; - /** Arguments to send as `chat_template_args` when `thinkingFormat` is `baseten`. Use `{ "$var": "thinking.enabled" }`, `{ "$var": "thinking.effort" }`, or `{ "$var": "thinking.budget" }` for pi-controlled thinking values. */ - chatTemplateArgs?: Record; - /** OpenRouter-compatible routing preferences sent as the `provider` request field. */ - openRouterRouting?: OpenRouterRouting; - /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ - vercelGatewayRouting?: VercelGatewayRouting; - /** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */ - zaiToolStream?: boolean; - /** - * Top-level request field used to cap reasoning tokens from `thinkingBudgets`. - * Reasoning and the answer share `max_tokens` on these endpoints, so without a budget a - * reasoning-heavy turn can consume the whole response and emit no answer. - * `"thinking_token_budget"` is vLLM, `"thinking_budget"` is Qwen/DashScope/SGLang, - * `"thinking_budget_tokens"` is llama.cpp. Off by default; not set on the generated catalog. - */ - thinkingTokenBudgetField?: ThinkingTokenBudgetField; - /** Alias for `thinkingTokenBudgetField: "thinking_token_budget"` (vLLM). Prefer `thinkingTokenBudgetField`. Default: false. */ - supportsThinkingTokenBudget?: boolean; - /** Whether the provider supports OpenAI custom tools with Lark/regex grammar formats. When false, grammar-constrained tools fall back to normal function tools. Default: false; the generated model catalog enables it for capable models. */ - supportsOpenAIGrammarTools?: boolean; - /** Whether the exact model accepts system or developer messages after the conversation has started. When false, later system messages are folded into the leading system message. Default: false; the generated model catalog enables it for verified models. */ - supportsMidConvoSystemMessages?: boolean; - /** Whether system messages can introduce additional tools mid-conversation. Requires `supportsMidConvoSystemMessages`. Default: false; the generated model catalog enables it for capable models. */ - supportsMidConvoToolAdditions?: boolean; - /** Whether the provider supports the `strict` field in tool definitions. Default: false; generated capable models enable it explicitly. */ - supportsStrictMode?: boolean; - /** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content. */ - cacheControlFormat?: "anthropic"; - /** Whether to send session-affinity data from `options.sessionId`. Default: true for OpenRouter endpoints, false otherwise. */ - sendSessionAffinityHeaders?: boolean; - /** Session-affinity header format: `openai` sends `session_id`, `x-client-request-id`, and `x-session-affinity`; `openai-nosession` sends `x-client-request-id` and `x-session-affinity`; `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param, which is governed by cache retention. Default: auto-detected. */ - sessionAffinityFormat?: SessionAffinityFormat; - /** Whether the provider supports long prompt cache retention (`prompt_cache_retention: "24h"` or Anthropic-style `cache_control.ttl: "1h"`, depending on format). Default: true. */ - supportsLongCacheRetention?: boolean; - /** - * vLLM scheduler priority sent as the top-level `priority` request field (lower values are - * handled earlier; server default 0). Only meaningful when vLLM runs with - * `--scheduling-policy priority`; useful for keeping background/batch work from stalling - * interactive sessions. Off by default; not set on the generated catalog. - */ - vllmPriority?: number; -} - -/** Compatibility settings for OpenAI Responses APIs. */ -export interface OpenAIResponsesCompat { - /** Whether the provider supports the `developer` role (vs `system`). Default: true. */ - supportsDeveloperRole?: boolean; - /** Whether the exact model accepts developer or system messages after the conversation has started. When false, later system messages are folded into the leading system message. Default: false; the generated model catalog enables it for verified models. */ - supportsMidConvoSystemMessages?: boolean; - /** Session-affinity header format: `openai` sends `session_id` and `x-client-request-id`; `openai-nosession` sends `x-client-request-id`; `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param, which is governed by cache retention. Default: auto-detected. */ - sessionAffinityFormat?: SessionAffinityFormat; - /** Whether the provider supports long prompt cache retention. This uses `prompt_cache_options.ttl: "30m"` on GPT-5.6+ and `prompt_cache_retention: "24h"` on earlier models. Default: true. */ - supportsLongCacheRetention?: boolean; - /** Whether the provider supports strict JSON-schema function tools. Defaults are API-specific; generated OpenAI models enable it explicitly. */ - supportsStrictMode?: boolean; - /** Whether to emit OpenAI custom tools with Lark/regex grammar formats. When false, grammar-constrained tools fall back to normal function tools. Default: false; the generated model catalog enables it for capable models. */ - supportsOpenAIGrammarTools?: boolean; - /** Whether the model supports message-anchored `additional_tools` input items. Default: false. */ - supportsAdditionalTools?: boolean; - /** Whether the model supports client-executed tool search for transcript-anchored additions. Default: false. */ - supportsToolSearch?: boolean; - /** Whether the model accepts `prompt_cache_options` (OpenAI GPT-5.6+ prompt caching). Older OpenAI models reject the parameter. Default: false. */ - supportsExplicitPromptCacheMode?: boolean; - /** Whether the provider accepts the `max_output_tokens` parameter. Some Codex-protocol gateways reject it. Default: true. */ - supportsMaxOutputTokens?: boolean; -} - -/** Compatibility settings for Anthropic Messages-compatible APIs. */ -export interface AnthropicMessagesCompat { - /** - * Whether the provider accepts per-tool `eager_input_streaming`. - * When false, the Anthropic provider omits `tools[].eager_input_streaming` - * and sends the legacy `fine-grained-tool-streaming-2025-05-14` beta header - * for tool-enabled requests. - * Default: true. - */ - supportsEagerToolInputStreaming?: boolean; - /** Whether the provider supports Anthropic long cache retention (`cache_control.ttl: "1h"`). Default: true. */ - supportsLongCacheRetention?: boolean; - /** - * Whether to send the `x-session-affinity` header from `options.sessionId` - * when caching is enabled. Required for providers like Fireworks that use - * session affinity for prompt cache routing (requests to the same replica - * maximize cache hits). - * Default: false. - */ - sendSessionAffinityHeaders?: boolean; - /** Session-affinity format. `"openrouter"` sends `x-session-id`; when unset, sends `x-session-affinity`. */ - sessionAffinityFormat?: "openrouter"; - /** - * Whether the provider supports Anthropic-style `cache_control` markers on - * tool definitions. When false, `cache_control` is omitted from tool params. - * Some Anthropic-compatible providers (e.g., Fireworks) do not support this - * field on tools and may reject or ignore it. - * Default: true. - */ - supportsCacheControlOnTools?: boolean; - /** - * Whether the model accepts the Anthropic `temperature` request field. - * Claude Opus 4.7+ rejects non-default temperature values. - * Default: true. - */ - supportsTemperature?: boolean; - /** - * Whether to force adaptive thinking (`thinking.type: "adaptive"` plus - * `output_config.effort`) regardless of the model id. Built-in models that - * require adaptive thinking set this in generated metadata. Custom - * Anthropic-compatible providers can set this to `true` for any model whose - * upstream requires the adaptive format. Set to `false` to - * opt out on overridden built-in models. - * Default: false. - */ - forceAdaptiveThinking?: boolean; - /** Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: false. */ - allowEmptySignature?: boolean; - /** Whether the provider supports Anthropic strict tool schemas. Default: false; generated Anthropic models enable it explicitly. */ - supportsStrictTools?: boolean; - /** Whether the exact model transport supports effort-only system messages and thinking binding controls. Default: false. */ - supportsMidConvoEffort?: boolean; - /** Whether the exact model accepts system-role messages inside the conversation. When false, later system messages are folded into the top-level system prompt. Default: false. */ - supportsMidConvoSystemMessages?: boolean; - /** Whether the exact model accepts mid-conversation `tool_addition` blocks with inline tool definitions (`inline-tools-2026-09-15`) and `tool_removal` blocks. Requires `supportsMidConvoSystemMessages`. Default: false. */ - supportsMidConvoToolChanges?: boolean; - /** - * Models Anthropic accepts in `fallbacks` for server-side refusal fallback, - * with local pricing metadata for returned fallback responses. When absent or - * empty, callers must omit `fallbacks`; Anthropic rejects the field for models - * with no permitted fallback targets. - */ - allowedFallbackModels?: AnthropicAllowedFallbackModel[]; -} - -/** Compatibility settings for Amazon Bedrock models. */ -export interface BedrockCompat { - /** Whether the model supports Bedrock strict tool schemas. Default: false. */ - supportsStrictMode?: boolean; -} - -/** Compatibility settings for the Mistral chat API. */ -export interface MistralConversationsCompat { - /** Whether the exact model accepts system messages after the conversation has started. When false, later system messages are folded into the leading system message. Default: false. */ - supportsMidConvoSystemMessages?: boolean; -} - -/** - * OpenRouter provider routing preferences. - * Controls which upstream providers OpenRouter routes requests to. - * Sent as the `provider` field in the OpenRouter API request body. - * @see https://openrouter.ai/docs/guides/routing/provider-selection - */ -export interface OpenRouterRouting { - /** Whether to allow backup providers to serve requests. Default: true. */ - allow_fallbacks?: boolean; - /** Whether to filter providers to only those that support all parameters in the request. Default: false. */ - require_parameters?: boolean; - /** Data collection setting. "allow" (default): allow providers that may store/train on data. "deny": only use providers that don't collect user data. */ - data_collection?: "deny" | "allow"; - /** Whether to restrict routing to only ZDR (Zero Data Retention) endpoints. */ - zdr?: boolean; - /** Whether to restrict routing to only models that allow text distillation. */ - enforce_distillable_text?: boolean; - /** An ordered list of provider names/slugs to try in sequence, falling back to the next if unavailable. */ - order?: string[]; - /** List of provider names/slugs to exclusively allow for this request. */ - only?: string[]; - /** List of provider names/slugs to skip for this request. */ - ignore?: string[]; - /** A list of quantization levels to filter providers by (e.g., ["fp16", "bf16", "fp8", "fp6", "int8", "int4", "fp4", "fp32"]). */ - quantizations?: string[]; - /** Sorting strategy. Can be a string (e.g., "price", "throughput", "latency") or an object with `by` and `partition`. */ - sort?: - | string - | { - /** The sorting metric: "price", "throughput", "latency". */ - by?: string; - /** Partitioning strategy: "model" (default) or "none". */ - partition?: string | null; - }; - /** Maximum price per million tokens (USD). */ - max_price?: { - /** Price per million prompt tokens. */ - prompt?: number | string; - /** Price per million completion tokens. */ - completion?: number | string; - /** Price per image. */ - image?: number | string; - /** Price per audio unit. */ - audio?: number | string; - /** Price per request. */ - request?: number | string; - }; - /** Preferred minimum throughput (tokens/second). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */ - preferred_min_throughput?: - | number - | { - /** Minimum tokens/second at the 50th percentile. */ - p50?: number; - /** Minimum tokens/second at the 75th percentile. */ - p75?: number; - /** Minimum tokens/second at the 90th percentile. */ - p90?: number; - /** Minimum tokens/second at the 99th percentile. */ - p99?: number; - }; - /** Preferred maximum latency (seconds). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */ - preferred_max_latency?: - | number - | { - /** Maximum latency in seconds at the 50th percentile. */ - p50?: number; - /** Maximum latency in seconds at the 75th percentile. */ - p75?: number; - /** Maximum latency in seconds at the 90th percentile. */ - p90?: number; - /** Maximum latency in seconds at the 99th percentile. */ - p99?: number; - }; -} - -/** - * Vercel AI Gateway routing preferences. - * Controls which upstream providers the gateway routes requests to. - * @see https://vercel.com/docs/ai-gateway/models-and-providers/provider-options - */ -export interface VercelGatewayRouting { - /** List of provider slugs to exclusively use for this request (e.g., ["bedrock", "anthropic"]). */ - only?: string[]; - /** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */ - order?: string[]; -} - -export interface ModelCostRates { - input: number; // $/million tokens - output: number; // $/million tokens - cacheRead: number; // $/million tokens - cacheWrite: number; // $/million tokens -} - -export interface ModelCostTier extends ModelCostRates { - /** Use this tier for requests whose total input usage exceeds this token count. */ - inputTokensAbove: number; -} - -export interface ModelCost extends ModelCostRates { - /** Request-wide pricing tiers. The highest matching input threshold applies to the full request. */ - tiers?: ModelCostTier[]; -} - -export interface ModelImageResizeOptions { - maxWidth?: number; - maxHeight?: number; - /** Maximum base64-encoded payload size in bytes. */ - maxBytes?: number; - jpegQuality?: number; -} - -export interface ModelImageInputLimits { - /** Cache-safe resize profile applied before a new image enters conversation history. */ - resize?: ModelImageResizeOptions; - /** Maximum images accepted in one provider message. */ - maxPerMessage?: number; - /** Maximum images accepted across one provider request. */ - maxPerRequest?: number; -} - -export interface ModelInputLimits { - /** Maximum serialized provider request size in bytes. */ - maxRequestBytes?: number; - images?: ModelImageInputLimits; -} - /** Fields shared by every catalog entry, regardless of what you can do with it. */ export interface BaseModel { id: string; @@ -1121,7 +823,7 @@ export interface BaseModel { api: TApi; provider: ProviderId; baseUrl: string; - input: ("text" | "image")[]; + input: ModelInputModality[]; /** Provider input limits and cache-safe preprocessing metadata. */ inputLimits?: ModelInputLimits; cost: ModelCost; diff --git a/packages/ai/test/compat-schema.test.ts b/packages/ai/test/compat-schema.test.ts new file mode 100644 index 000000000..4ef40713e --- /dev/null +++ b/packages/ai/test/compat-schema.test.ts @@ -0,0 +1,40 @@ +import type { Static } from "typebox"; +import { Compile } from "typebox/compile"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + AnthropicMessagesCompatSchema, + BedrockCompatSchema, + OpenAICompletionsCompatSchema, + OpenAIResponsesCompatSchema, + ProviderCompatSchema, +} from "../src/providers/compat-schema.ts"; + +type ProviderCompat = Static; + +describe("compatibility schemas", () => { + it("preserves property types in the provider superset", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + "openai" | "openai-nosession" | "openrouter" | undefined + >(); + }); + + it("preserves API-specific defaults", () => { + const completionsDefaults = Compile(OpenAICompletionsCompatSchema).Default({}); + expect(completionsDefaults).not.toHaveProperty("thinkingFormat"); + expect(completionsDefaults).not.toHaveProperty("supportsLongCacheRetention"); + expect(Compile(OpenAIResponsesCompatSchema).Default({})).toMatchObject({ supportsDeveloperRole: true }); + const anthropicDefaults = Compile(AnthropicMessagesCompatSchema).Default({}); + expect(anthropicDefaults).not.toHaveProperty("sendSessionAffinityHeaders"); + expect(anthropicDefaults).toMatchObject({ supportsLongCacheRetention: true }); + expect(Compile(BedrockCompatSchema).Default({})).toMatchObject({ supportsStrictMode: false }); + }); + + it("does not assign API-specific defaults to the provider superset", () => { + const defaults = Compile(ProviderCompatSchema).Default({}); + expect(defaults).not.toHaveProperty("supportsDeveloperRole"); + expect(defaults).not.toHaveProperty("sendSessionAffinityHeaders"); + expect(defaults).not.toHaveProperty("supportsLongCacheRetention"); + expect(defaults).not.toHaveProperty("supportsStrictMode"); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e0375deaa..b753720ed 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Moved the published theme JSON Schema to `schemas/theme.schema.json` and made theme loading reject unknown top-level properties and unknown properties under `colors` or `export`. Update existing theme `$schema` references to the new path, define reusable custom colors under `vars`, and remove unsupported metadata. + ### Changed - Changed the `showHardwareCursor` setting to use only the terminal cursor instead of also drawing Pi's reverse-video cursor diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index 182b2b045..d8ed389cd 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -58,7 +58,7 @@ See [CLI resources](cli.md#resources) for the command-line option. ## Create a custom theme -Copy one of the [built-in themes](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/src/modes/interactive/theme) or create a new JSON file conforming to the [schema](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json). The built-in themes use OKHSL colors, with variables for colors that several roles share, so you can adjust a hue, saturation, or lightness directly. +Copy one of the [built-in themes](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/src/modes/interactive/theme) or create a new JSON file conforming to the [schema](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/schemas/theme.schema.json). The built-in themes use OKHSL colors, with variables for colors that several roles share, so you can adjust a hue, saturation, or lightness directly. 1. Save the file as `/themes/my-theme.json`. The agent directory defaults to `~/.pi/agent`. 2. Set its `name` to `my-theme`. @@ -78,6 +78,8 @@ Use the theme name as the filename. Pi hot-reloads the active user theme only fr | `colors` | Yes | Assigns colors to terminal UI roles. The schema identifies required and optional roles. | | `export` | No | Overrides page and panel backgrounds in HTML exports. | +Theme objects are strict: only documented top-level fields and color tokens are accepted. Define reusable custom colors under `vars`; custom keys under `colors` or `export` and additional top-level metadata are rejected. + A color can be written in six forms: | Form | Example | Meaning | @@ -93,7 +95,7 @@ Terminal default colors render as the terminal's own colors. Where Pi needs a co Pi resolves chained variable references. A missing variable or circular reference makes the theme invalid. Pi uses truecolor when available, gamut-maps OKLCH to sRGB, and approximates colors for 256-color terminals. HTML exports convert OKHSL values to hexadecimal because CSS does not support them. If colors differ from their source values, check your terminal's truecolor detection and contrast settings. See [Configure Your Terminal](terminal-setup.md#override-detected-capabilities). -Use the [theme JSON schema](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json) for the exact properties, required colors, and accepted value types. +Use the [theme JSON schema](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/schemas/theme.schema.json) for the exact properties, required colors, and accepted value types. Pi reports invalid theme files during startup and `/reload`. diff --git a/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.json b/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.json index c2ba55396..6adaa5fca 100644 --- a/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.json +++ b/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/schemas/theme.schema.json", "name": "dynamic-resources", "vars": { "cyan": "#00d7ff", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index cdb88d0c9..dddd82ce6 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -33,6 +33,7 @@ "!dist/cli/experimental", "docs", "examples", + "schemas", "containerization.md", "CHANGELOG.md" ], @@ -44,6 +45,7 @@ "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", "test": "vitest --run", + "generate:schemas": "node --import ./src/experimental/source-resolver.ts scripts/generate-schemas.ts", "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { diff --git a/packages/coding-agent/schemas/keybindings.schema.json b/packages/coding-agent/schemas/keybindings.schema.json new file mode 100644 index 000000000..e3743f3c0 --- /dev/null +++ b/packages/coding-agent/schemas/keybindings.schema.json @@ -0,0 +1,393 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/schemas/keybindings.schema.json", + "$comment": "This file is generated from TypeScript source. Do not edit it manually; run npm run generate:schemas.", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "tui.editor.cursorUp": { + "$ref": "#/$defs/KeybindingValue", + "description": "Move cursor up" + }, + "tui.editor.cursorDown": { + "$ref": "#/$defs/KeybindingValue", + "description": "Move cursor down" + }, + "tui.editor.historyPrevious": { + "$ref": "#/$defs/KeybindingValue", + "description": "Select previous prompt history entry" + }, + "tui.editor.historyNext": { + "$ref": "#/$defs/KeybindingValue", + "description": "Select next prompt history entry" + }, + "tui.editor.cursorLeft": { + "$ref": "#/$defs/KeybindingValue", + "description": "Move cursor left" + }, + "tui.editor.cursorRight": { + "$ref": "#/$defs/KeybindingValue", + "description": "Move cursor right" + }, + "tui.editor.cursorWordLeft": { + "$ref": "#/$defs/KeybindingValue", + "description": "Move cursor word left" + }, + "tui.editor.cursorWordRight": { + "$ref": "#/$defs/KeybindingValue", + "description": "Move cursor word right" + }, + "tui.editor.cursorLineStart": { + "$ref": "#/$defs/KeybindingValue", + "description": "Move to line start" + }, + "tui.editor.cursorLineEnd": { + "$ref": "#/$defs/KeybindingValue", + "description": "Move to line end" + }, + "tui.editor.jumpForward": { + "$ref": "#/$defs/KeybindingValue", + "description": "Jump forward to character" + }, + "tui.editor.jumpBackward": { + "$ref": "#/$defs/KeybindingValue", + "description": "Jump backward to character" + }, + "tui.editor.pageUp": { + "$ref": "#/$defs/KeybindingValue", + "description": "Page up" + }, + "tui.editor.pageDown": { + "$ref": "#/$defs/KeybindingValue", + "description": "Page down" + }, + "tui.editor.deleteCharBackward": { + "$ref": "#/$defs/KeybindingValue", + "description": "Delete character backward" + }, + "tui.editor.deleteCharForward": { + "$ref": "#/$defs/KeybindingValue", + "description": "Delete character forward" + }, + "tui.editor.deleteWordBackward": { + "$ref": "#/$defs/KeybindingValue", + "description": "Delete word backward" + }, + "tui.editor.deleteWordForward": { + "$ref": "#/$defs/KeybindingValue", + "description": "Delete word forward" + }, + "tui.editor.deleteToLineStart": { + "$ref": "#/$defs/KeybindingValue", + "description": "Delete to line start" + }, + "tui.editor.deleteToLineEnd": { + "$ref": "#/$defs/KeybindingValue", + "description": "Delete to line end" + }, + "tui.editor.yank": { + "$ref": "#/$defs/KeybindingValue", + "description": "Yank" + }, + "tui.editor.yankPop": { + "$ref": "#/$defs/KeybindingValue", + "description": "Yank pop" + }, + "tui.editor.undo": { + "$ref": "#/$defs/KeybindingValue", + "description": "Undo" + }, + "tui.input.newLine": { + "$ref": "#/$defs/KeybindingValue", + "description": "Insert newline" + }, + "tui.input.submit": { + "$ref": "#/$defs/KeybindingValue", + "description": "Submit input" + }, + "tui.input.tab": { + "$ref": "#/$defs/KeybindingValue", + "description": "Tab / autocomplete" + }, + "tui.input.copy": { + "$ref": "#/$defs/KeybindingValue", + "description": "Copy selection" + }, + "tui.select.up": { + "$ref": "#/$defs/KeybindingValue", + "description": "Move selection up" + }, + "tui.select.down": { + "$ref": "#/$defs/KeybindingValue", + "description": "Move selection down" + }, + "tui.select.pageUp": { + "$ref": "#/$defs/KeybindingValue", + "description": "Selection page up" + }, + "tui.select.pageDown": { + "$ref": "#/$defs/KeybindingValue", + "description": "Selection page down" + }, + "tui.select.confirm": { + "$ref": "#/$defs/KeybindingValue", + "description": "Confirm selection" + }, + "tui.select.cancel": { + "$ref": "#/$defs/KeybindingValue", + "description": "Cancel selection" + }, + "tui.altScreen.pageUp": { + "$ref": "#/$defs/KeybindingValue", + "description": "Scroll viewport up one page" + }, + "tui.altScreen.pageDown": { + "$ref": "#/$defs/KeybindingValue", + "description": "Scroll viewport down one page" + }, + "tui.altScreen.halfPageUp": { + "$ref": "#/$defs/KeybindingValue", + "description": "Scroll viewport up half a page" + }, + "tui.altScreen.halfPageDown": { + "$ref": "#/$defs/KeybindingValue", + "description": "Scroll viewport down half a page" + }, + "tui.altScreen.lineUp": { + "$ref": "#/$defs/KeybindingValue", + "description": "Scroll viewport up one line" + }, + "tui.altScreen.lineDown": { + "$ref": "#/$defs/KeybindingValue", + "description": "Scroll viewport down one line" + }, + "tui.altScreen.previousPrompt": { + "$ref": "#/$defs/KeybindingValue", + "description": "Jump to previous semantic prompt" + }, + "tui.altScreen.nextPrompt": { + "$ref": "#/$defs/KeybindingValue", + "description": "Jump to next semantic prompt" + }, + "tui.altScreen.search": { + "$ref": "#/$defs/KeybindingValue", + "description": "Search the primary scroll view" + }, + "tui.altScreen.searchNext": { + "$ref": "#/$defs/KeybindingValue", + "description": "Select the next search match" + }, + "tui.altScreen.searchPrevious": { + "$ref": "#/$defs/KeybindingValue", + "description": "Select the previous search match" + }, + "tui.altScreen.searchClose": { + "$ref": "#/$defs/KeybindingValue", + "description": "Close transcript search" + }, + "tui.altScreen.top": { + "$ref": "#/$defs/KeybindingValue", + "description": "Scroll viewport to top" + }, + "tui.altScreen.bottom": { + "$ref": "#/$defs/KeybindingValue", + "description": "Scroll viewport to bottom" + }, + "app.interrupt": { + "$ref": "#/$defs/KeybindingValue", + "description": "Cancel or abort" + }, + "app.clear": { + "$ref": "#/$defs/KeybindingValue", + "description": "Clear editor" + }, + "app.exit": { + "$ref": "#/$defs/KeybindingValue", + "description": "Exit when editor is empty" + }, + "app.suspend": { + "$ref": "#/$defs/KeybindingValue", + "description": "Suspend to background" + }, + "app.thinking.cycle": { + "$ref": "#/$defs/KeybindingValue", + "description": "Cycle thinking level" + }, + "app.thinking.save": { + "$ref": "#/$defs/KeybindingValue", + "description": "Save thinking level" + }, + "app.model.cycleForward": { + "$ref": "#/$defs/KeybindingValue", + "description": "Cycle to next model" + }, + "app.model.cycleBackward": { + "$ref": "#/$defs/KeybindingValue", + "description": "Cycle to previous model" + }, + "app.model.select": { + "$ref": "#/$defs/KeybindingValue", + "description": "Open model selector" + }, + "app.tools.expand": { + "$ref": "#/$defs/KeybindingValue", + "description": "Toggle tool output" + }, + "app.thinking.toggle": { + "$ref": "#/$defs/KeybindingValue", + "description": "Toggle thinking blocks" + }, + "app.session.toggleNamedFilter": { + "$ref": "#/$defs/KeybindingValue", + "description": "Toggle named session filter" + }, + "app.editor.external": { + "$ref": "#/$defs/KeybindingValue", + "description": "Open external editor" + }, + "app.message.copy": { + "$ref": "#/$defs/KeybindingValue", + "description": "Copy selection or last assistant message" + }, + "app.message.followUp": { + "$ref": "#/$defs/KeybindingValue", + "description": "Queue follow-up message" + }, + "app.message.dequeue": { + "$ref": "#/$defs/KeybindingValue", + "description": "Restore queued messages" + }, + "app.clipboard.pasteImage": { + "$ref": "#/$defs/KeybindingValue", + "description": "Paste files on macOS, images, or text from clipboard" + }, + "app.session.new": { + "$ref": "#/$defs/KeybindingValue", + "description": "Start a new session" + }, + "app.session.tree": { + "$ref": "#/$defs/KeybindingValue", + "description": "Open session tree" + }, + "app.session.fork": { + "$ref": "#/$defs/KeybindingValue", + "description": "Fork current session" + }, + "app.session.resume": { + "$ref": "#/$defs/KeybindingValue", + "description": "Resume a session" + }, + "app.tree.foldOrUp": { + "$ref": "#/$defs/KeybindingValue", + "description": "Fold tree branch or move up" + }, + "app.tree.unfoldOrDown": { + "$ref": "#/$defs/KeybindingValue", + "description": "Unfold tree branch or move down" + }, + "app.tree.editLabel": { + "$ref": "#/$defs/KeybindingValue", + "description": "Edit tree label" + }, + "app.tree.toggleLabelTimestamp": { + "$ref": "#/$defs/KeybindingValue", + "description": "Toggle tree label timestamps" + }, + "app.session.togglePath": { + "$ref": "#/$defs/KeybindingValue", + "description": "Toggle session path display" + }, + "app.session.toggleSort": { + "$ref": "#/$defs/KeybindingValue", + "description": "Toggle session sort mode" + }, + "app.session.rename": { + "$ref": "#/$defs/KeybindingValue", + "description": "Rename session" + }, + "app.session.delete": { + "$ref": "#/$defs/KeybindingValue", + "description": "Delete session" + }, + "app.session.deleteNoninvasive": { + "$ref": "#/$defs/KeybindingValue", + "description": "Delete session when query is empty" + }, + "app.models.save": { + "$ref": "#/$defs/KeybindingValue", + "description": "Save model selection" + }, + "app.models.enableAll": { + "$ref": "#/$defs/KeybindingValue", + "description": "Enable all models" + }, + "app.models.clearAll": { + "$ref": "#/$defs/KeybindingValue", + "description": "Clear all models" + }, + "app.models.toggleProvider": { + "$ref": "#/$defs/KeybindingValue", + "description": "Toggle all models for provider" + }, + "app.models.reorderUp": { + "$ref": "#/$defs/KeybindingValue", + "description": "Move model up in order" + }, + "app.models.reorderDown": { + "$ref": "#/$defs/KeybindingValue", + "description": "Move model down in order" + }, + "app.tree.filter.default": { + "$ref": "#/$defs/KeybindingValue", + "description": "Tree filter: default view" + }, + "app.tree.filter.noTools": { + "$ref": "#/$defs/KeybindingValue", + "description": "Tree filter: hide tool results" + }, + "app.tree.filter.userOnly": { + "$ref": "#/$defs/KeybindingValue", + "description": "Tree filter: user messages only" + }, + "app.tree.filter.labeledOnly": { + "$ref": "#/$defs/KeybindingValue", + "description": "Tree filter: labeled entries only" + }, + "app.tree.filter.all": { + "$ref": "#/$defs/KeybindingValue", + "description": "Tree filter: show all entries" + }, + "app.tree.filter.cycleForward": { + "$ref": "#/$defs/KeybindingValue", + "description": "Tree filter: cycle forward" + }, + "app.tree.filter.cycleBackward": { + "$ref": "#/$defs/KeybindingValue", + "description": "Tree filter: cycle backward" + } + }, + "additionalProperties": { + "$ref": "#/$defs/KeybindingValue" + }, + "$defs": { + "KeybindingValue": { + "anyOf": [ + { + "type": "string", + "pattern": "^(?!.*(?:ctrl\\+.*ctrl\\+|shift\\+.*shift\\+|alt\\+.*alt\\+|super\\+.*super\\+))(?:(?:ctrl|shift|alt|super)\\+){0,4}(?:[a-z0-9]|escape|esc|enter|return|tab|space|backspace|delete|insert|clear|home|end|pageUp|pageDown|up|down|left|right|f1|f2|f3|f4|f5|f6|f7|f8|f9|f10|f11|f12|`|-|=|\\[|\\]|\\\\|;|'|,|\\.|/|!|@|#|\\$|%|\\^|&|\\*|\\(|\\)|_|\\+|\\||~|\\{|\\}|:|<|>|\\?)$", + "description": "Key identifier with optional ctrl, shift, alt, or super modifiers." + }, + { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!.*(?:ctrl\\+.*ctrl\\+|shift\\+.*shift\\+|alt\\+.*alt\\+|super\\+.*super\\+))(?:(?:ctrl|shift|alt|super)\\+){0,4}(?:[a-z0-9]|escape|esc|enter|return|tab|space|backspace|delete|insert|clear|home|end|pageUp|pageDown|up|down|left|right|f1|f2|f3|f4|f5|f6|f7|f8|f9|f10|f11|f12|`|-|=|\\[|\\]|\\\\|;|'|,|\\.|/|!|@|#|\\$|%|\\^|&|\\*|\\(|\\)|_|\\+|\\||~|\\{|\\}|:|<|>|\\?)$", + "description": "Key identifier with optional ctrl, shift, alt, or super modifiers." + } + } + ] + } + } +} diff --git a/packages/coding-agent/schemas/models.schema.json b/packages/coding-agent/schemas/models.schema.json new file mode 100644 index 000000000..cb2b0c9c0 --- /dev/null +++ b/packages/coding-agent/schemas/models.schema.json @@ -0,0 +1,1157 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/schemas/models.schema.json", + "$comment": "This file is generated from TypeScript source. Do not edit it manually; run npm run generate:schemas.", + "type": "object", + "required": [ + "providers" + ], + "properties": { + "$schema": { + "type": "string" + }, + "providers": { + "type": "object", + "patternProperties": { + "^.*$": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "api": { + "type": "string", + "minLength": 1 + }, + "oauth": { + "type": "string", + "const": "radius" + }, + "headers": { + "type": "object", + "patternProperties": { + "^.*$": { + "type": "string" + } + } + }, + "compat": { + "$ref": "#/$defs/ProviderCompat", + "description": "Provider and model compatibility overrides." + }, + "authHeader": { + "type": "boolean" + }, + "models": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "api": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "reasoning": { + "type": "boolean" + }, + "thinkingLevelMap": { + "$ref": "#/$defs/ThinkingLevelMap" + }, + "input": { + "type": "array", + "items": { + "enum": [ + "text", + "image" + ] + } + }, + "inputLimits": { + "$ref": "#/$defs/ModelInputLimits" + }, + "cost": { + "$ref": "#/$defs/ModelCost" + }, + "promptCache": { + "$ref": "#/$defs/ModelPromptCache", + "description": "Best-effort prompt cache lifetime in seconds for each retention tier. A missing tier means the lifetime is unknown, so Pi does not warm it." + }, + "contextWindow": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maxTokens": { + "type": "number", + "exclusiveMinimum": 0 + }, + "samplingParams": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "samplingParamsByThinkingLevel": { + "type": "object", + "properties": { + "off": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "minimal": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "low": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "medium": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "high": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "xhigh": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "max": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + } + } + }, + "headers": { + "type": "object", + "patternProperties": { + "^.*$": { + "type": "string" + } + } + }, + "compat": { + "$ref": "#/$defs/ProviderCompat", + "description": "Provider and model compatibility overrides." + } + } + } + }, + "modelOverrides": { + "type": "object", + "patternProperties": { + "^.*$": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "reasoning": { + "type": "boolean" + }, + "thinkingLevelMap": { + "$ref": "#/$defs/ThinkingLevelMap" + }, + "input": { + "type": "array", + "items": { + "enum": [ + "text", + "image" + ] + } + }, + "inputLimits": { + "$ref": "#/$defs/ModelInputLimits" + }, + "cost": { + "type": "object", + "properties": { + "input": { + "type": "number", + "description": "Input cost in USD per million tokens." + }, + "output": { + "type": "number", + "description": "Output cost in USD per million tokens." + }, + "cacheRead": { + "type": "number", + "description": "Cache-read cost in USD per million tokens." + }, + "cacheWrite": { + "type": "number", + "description": "Cache-write cost in USD per million tokens." + }, + "tiers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "inputTokensAbove", + "input", + "output", + "cacheRead", + "cacheWrite" + ], + "properties": { + "inputTokensAbove": { + "type": "number", + "description": "Use this tier when total request input exceeds this token count." + }, + "input": { + "type": "number", + "description": "Input cost in USD per million tokens." + }, + "output": { + "type": "number", + "description": "Output cost in USD per million tokens." + }, + "cacheRead": { + "type": "number", + "description": "Cache-read cost in USD per million tokens." + }, + "cacheWrite": { + "type": "number", + "description": "Cache-write cost in USD per million tokens." + } + } + }, + "description": "Request-wide pricing tiers. The highest matching input threshold applies to the full request." + } + } + }, + "promptCache": { + "$ref": "#/$defs/ModelPromptCache", + "description": "Best-effort prompt cache lifetime in seconds for each retention tier. A missing tier means the lifetime is unknown, so Pi does not warm it." + }, + "contextWindow": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maxTokens": { + "type": "number", + "exclusiveMinimum": 0 + }, + "samplingParams": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "samplingParamsByThinkingLevel": { + "type": "object", + "properties": { + "off": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "minimal": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "low": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "medium": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "high": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "xhigh": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + }, + "max": { + "type": "object", + "patternProperties": { + "^.*$": {} + } + } + } + }, + "headers": { + "type": "object", + "patternProperties": { + "^.*$": { + "type": "string" + } + } + }, + "compat": { + "$ref": "#/$defs/ProviderCompat", + "description": "Provider and model compatibility overrides." + } + } + } + } + } + } + } + } + } + }, + "$defs": { + "ModelCost": { + "type": "object", + "required": [ + "input", + "output", + "cacheRead", + "cacheWrite" + ], + "properties": { + "input": { + "type": "number", + "description": "Input cost in USD per million tokens." + }, + "output": { + "type": "number", + "description": "Output cost in USD per million tokens." + }, + "cacheRead": { + "type": "number", + "description": "Cache-read cost in USD per million tokens." + }, + "cacheWrite": { + "type": "number", + "description": "Cache-write cost in USD per million tokens." + }, + "tiers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "inputTokensAbove", + "input", + "output", + "cacheRead", + "cacheWrite" + ], + "properties": { + "inputTokensAbove": { + "type": "number", + "description": "Use this tier when total request input exceeds this token count." + }, + "input": { + "type": "number", + "description": "Input cost in USD per million tokens." + }, + "output": { + "type": "number", + "description": "Output cost in USD per million tokens." + }, + "cacheRead": { + "type": "number", + "description": "Cache-read cost in USD per million tokens." + }, + "cacheWrite": { + "type": "number", + "description": "Cache-write cost in USD per million tokens." + } + } + }, + "description": "Request-wide pricing tiers. The highest matching input threshold applies to the full request." + } + } + }, + "ModelInputLimits": { + "type": "object", + "properties": { + "maxRequestBytes": { + "type": "integer", + "minimum": 1, + "description": "Maximum serialized provider request size in bytes." + }, + "images": { + "type": "object", + "properties": { + "resize": { + "type": "object", + "properties": { + "maxWidth": { + "type": "integer", + "minimum": 1 + }, + "maxHeight": { + "type": "integer", + "minimum": 1 + }, + "maxBytes": { + "type": "integer", + "minimum": 1, + "description": "Maximum base64-encoded payload size in bytes." + }, + "jpegQuality": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + "description": "Cache-safe resize profile applied before a new image enters conversation history." + }, + "maxPerMessage": { + "type": "integer", + "minimum": 1, + "description": "Maximum images accepted in one provider message." + }, + "maxPerRequest": { + "type": "integer", + "minimum": 1, + "description": "Maximum images accepted across one provider request." + } + } + } + } + }, + "ModelPromptCache": { + "type": "object", + "properties": { + "short": { + "type": "number", + "exclusiveMinimum": 0 + }, + "long": { + "type": "number", + "exclusiveMinimum": 0 + } + } + }, + "ProviderCompat": { + "type": "object", + "properties": { + "supportsStore": { + "type": "boolean", + "description": "Whether the provider supports the store field. Default: auto-detected from URL." + }, + "supportsDeveloperRole": { + "type": "boolean", + "description": "Whether the provider supports the developer role instead of system. Defaults are API-specific." + }, + "supportsReasoningEffort": { + "type": "boolean", + "description": "Whether the provider supports reasoning_effort. Default: auto-detected from URL." + }, + "supportsUsageInStreaming": { + "type": "boolean", + "description": "Whether the provider supports stream_options.include_usage for token usage in streaming responses.", + "default": true + }, + "supportsFinishReason": { + "type": "boolean", + "description": "Whether streamed responses include finish_reason. When false, pi infers stop or toolUse when the stream ends.", + "default": true + }, + "maxTokensField": { + "anyOf": [ + { + "type": "string", + "const": "max_completion_tokens" + }, + { + "type": "string", + "const": "max_tokens" + } + ], + "description": "Which field to use for max tokens. Default: auto-detected from URL." + }, + "requiresToolResultName": { + "type": "boolean", + "description": "Whether tool results require the name field. Default: auto-detected from URL." + }, + "requiresAssistantAfterToolResult": { + "type": "boolean", + "description": "Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL." + }, + "requiresThinkingAsText": { + "type": "boolean", + "description": "Whether thinking blocks must be converted to text blocks with delimiters. Default: auto-detected from URL." + }, + "requiresReasoningContentOnAssistantMessages": { + "type": "boolean", + "description": "Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL." + }, + "thinkingFormat": { + "anyOf": [ + { + "type": "string", + "const": "openai" + }, + { + "type": "string", + "const": "openrouter" + }, + { + "type": "string", + "const": "deepseek" + }, + { + "type": "string", + "const": "together" + }, + { + "type": "string", + "const": "baseten" + }, + { + "type": "string", + "const": "zai" + }, + { + "type": "string", + "const": "qwen" + }, + { + "type": "string", + "const": "chat-template" + }, + { + "type": "string", + "const": "qwen-chat-template" + }, + { + "type": "string", + "const": "string-thinking" + }, + { + "type": "string", + "const": "ant-ling" + } + ], + "description": "Format for reasoning or thinking parameters. When omitted, Pi auto-detects the format from the provider URL. \"openai\" uses reasoning_effort, \"openrouter\" uses reasoning.effort, \"deepseek\" uses thinking.type plus reasoning_effort when supported, \"together\" uses reasoning.enabled plus reasoning_effort when supported, \"baseten\" uses configurable chat_template_args plus reasoning_effort when supported, \"zai\" uses thinking.type, \"qwen\" uses top-level enable_thinking, \"qwen-chat-template\" uses chat_template_kwargs.enable_thinking and preserve_thinking, \"chat-template\" uses configurable chat_template_kwargs, \"string-thinking\" uses top-level thinking, and \"ant-ling\" uses reasoning.effort only when the mapped effort is non-null." + }, + "chatTemplateKwargs": { + "type": "object", + "patternProperties": { + "^.*$": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "object", + "required": [ + "$var" + ], + "properties": { + "$var": { + "anyOf": [ + { + "type": "string", + "const": "thinking.enabled" + }, + { + "type": "string", + "const": "thinking.effort" + }, + { + "type": "string", + "const": "thinking.budget" + } + ] + }, + "omitWhenOff": { + "type": "boolean" + } + } + } + ] + } + }, + "description": "Kwargs sent as chat_template_kwargs when thinkingFormat is \"chat-template\". Use $var with \"thinking.enabled\", \"thinking.effort\", or \"thinking.budget\" for pi-controlled values." + }, + "chatTemplateArgs": { + "type": "object", + "patternProperties": { + "^.*$": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "object", + "required": [ + "$var" + ], + "properties": { + "$var": { + "anyOf": [ + { + "type": "string", + "const": "thinking.enabled" + }, + { + "type": "string", + "const": "thinking.effort" + }, + { + "type": "string", + "const": "thinking.budget" + } + ] + }, + "omitWhenOff": { + "type": "boolean" + } + } + } + ] + } + }, + "description": "Arguments sent as chat_template_args when thinkingFormat is \"baseten\". Use $var with \"thinking.enabled\", \"thinking.effort\", or \"thinking.budget\" for pi-controlled values." + }, + "openRouterRouting": { + "type": "object", + "properties": { + "allow_fallbacks": { + "type": "boolean", + "description": "Whether to allow backup providers to serve requests.", + "default": true + }, + "require_parameters": { + "type": "boolean", + "description": "Whether to filter providers to only those that support all parameters in the request.", + "default": false + }, + "data_collection": { + "anyOf": [ + { + "type": "string", + "const": "deny" + }, + { + "type": "string", + "const": "allow" + } + ], + "description": "Data collection setting. \"allow\": allow providers that may store or train on data. \"deny\": only use providers that do not collect user data.", + "default": "allow" + }, + "zdr": { + "type": "boolean", + "description": "Whether to restrict routing to only ZDR (Zero Data Retention) endpoints." + }, + "enforce_distillable_text": { + "type": "boolean", + "description": "Whether to restrict routing to only models that allow text distillation." + }, + "order": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An ordered list of provider names or slugs to try in sequence, falling back to the next if unavailable." + }, + "only": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of provider names or slugs to exclusively allow for this request." + }, + "ignore": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of provider names or slugs to skip for this request." + }, + "quantizations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of quantization levels to filter providers by, for example [\"fp16\", \"bf16\", \"fp8\", \"fp6\", \"int8\", \"int4\", \"fp4\", \"fp32\"]." + }, + "sort": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "by": { + "type": "string", + "description": "The sorting metric, such as \"price\", \"throughput\", or \"latency\"." + }, + "partition": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Partitioning strategy: \"model\" or \"none\".", + "default": "model" + } + } + } + ], + "description": "Sorting strategy. Can be a string such as \"price\", \"throughput\", or \"latency\", or an object." + }, + "max_price": { + "type": "object", + "properties": { + "prompt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "description": "Price per million prompt tokens." + }, + "completion": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "description": "Price per million completion tokens." + }, + "image": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "description": "Price per image." + }, + "audio": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "description": "Price per audio unit." + }, + "request": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "description": "Price per request." + } + }, + "description": "Maximum price per million tokens in USD." + }, + "preferred_min_throughput": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "object", + "properties": { + "p50": { + "type": "number", + "description": "Minimum tokens per second at the 50th percentile." + }, + "p75": { + "type": "number", + "description": "Minimum tokens per second at the 75th percentile." + }, + "p90": { + "type": "number", + "description": "Minimum tokens per second at the 90th percentile." + }, + "p99": { + "type": "number", + "description": "Minimum tokens per second at the 99th percentile." + } + } + } + ], + "description": "Preferred minimum throughput in tokens per second. A number applies to p50." + }, + "preferred_max_latency": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "object", + "properties": { + "p50": { + "type": "number", + "description": "Maximum latency in seconds at the 50th percentile." + }, + "p75": { + "type": "number", + "description": "Maximum latency in seconds at the 75th percentile." + }, + "p90": { + "type": "number", + "description": "Maximum latency in seconds at the 90th percentile." + }, + "p99": { + "type": "number", + "description": "Maximum latency in seconds at the 99th percentile." + } + } + } + ], + "description": "Preferred maximum latency in seconds. A number applies to p50." + } + }, + "description": "OpenRouter provider routing preferences. Controls which upstream providers OpenRouter routes requests to. Sent as the provider field in the OpenRouter API request body. See https://openrouter.ai/docs/guides/routing/provider-selection." + }, + "vercelGatewayRouting": { + "type": "object", + "properties": { + "only": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of provider slugs to exclusively use for this request, for example [\"bedrock\", \"anthropic\"]." + }, + "order": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of provider slugs to try in order, for example [\"anthropic\", \"openai\"]." + } + }, + "description": "Vercel AI Gateway routing preferences. Controls which upstream providers the gateway routes requests to. See https://vercel.com/docs/ai-gateway/models-and-providers/provider-options." + }, + "zaiToolStream": { + "type": "boolean", + "description": "Whether z.ai supports top-level tool_stream for streaming tool call deltas.", + "default": false + }, + "thinkingTokenBudgetField": { + "anyOf": [ + { + "type": "string", + "const": "thinking_token_budget" + }, + { + "type": "string", + "const": "thinking_budget" + }, + { + "type": "string", + "const": "thinking_budget_tokens" + } + ], + "description": "Top-level request field used to cap reasoning tokens from thinkingBudgets. Reasoning and the answer share max_tokens on these endpoints. \"thinking_token_budget\" is vLLM, \"thinking_budget\" is Qwen, DashScope, or SGLang, and \"thinking_budget_tokens\" is llama.cpp. Off by default and not set on the generated catalog." + }, + "supportsThinkingTokenBudget": { + "type": "boolean", + "description": "Alias for thinkingTokenBudgetField: \"thinking_token_budget\" (vLLM). Prefer thinkingTokenBudgetField.", + "default": false + }, + "supportsOpenAIGrammarTools": { + "type": "boolean", + "description": "Whether the provider supports OpenAI custom tools with Lark or regex grammar formats. When false, grammar-constrained tools fall back to normal function tools.", + "default": false + }, + "supportsMidConvoSystemMessages": { + "type": "boolean", + "description": "Whether the exact model accepts system or developer messages after the conversation has started. When false, later system messages are folded into the leading system message.", + "default": false + }, + "supportsMidConvoToolAdditions": { + "type": "boolean", + "description": "Whether system messages can introduce additional tools mid-conversation. Requires supportsMidConvoSystemMessages. The generated catalog enables this for capable models.", + "default": false + }, + "supportsStrictMode": { + "type": "boolean", + "description": "Whether the provider supports strict tool schemas. Defaults are API-specific." + }, + "cacheControlFormat": { + "type": "string", + "const": "anthropic", + "description": "Cache control convention for prompt caching. Anthropic applies cache_control markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content." + }, + "sendSessionAffinityHeaders": { + "type": "boolean", + "description": "Whether to send session-affinity data from options.sessionId. Defaults are API-specific." + }, + "sessionAffinityFormat": { + "anyOf": [ + { + "type": "string", + "const": "openai" + }, + { + "type": "string", + "const": "openai-nosession" + }, + { + "type": "string", + "const": "openrouter" + } + ], + "description": "Session-affinity header format. Defaults are API-specific or auto-detected." + }, + "supportsLongCacheRetention": { + "type": "boolean", + "description": "Whether the provider supports long prompt cache retention. Defaults are API-specific or auto-detected." + }, + "vllmPriority": { + "type": "number", + "description": "vLLM scheduler priority sent as the top-level priority request field. Lower values are handled earlier and the server default is 0. Only meaningful with --scheduling-policy priority. Off by default and not set on the generated catalog." + }, + "supportsAdditionalTools": { + "type": "boolean", + "description": "Whether the model supports message-anchored additional_tools input items.", + "default": false + }, + "supportsToolSearch": { + "type": "boolean", + "description": "Whether the model supports client-executed tool search for transcript-anchored additions.", + "default": false + }, + "supportsExplicitPromptCacheMode": { + "type": "boolean", + "description": "Whether the model accepts prompt_cache_options. Older OpenAI models reject the parameter.", + "default": false + }, + "supportsMaxOutputTokens": { + "type": "boolean", + "description": "Whether the provider accepts max_output_tokens. Some Codex-protocol gateways reject it.", + "default": true + }, + "supportsEagerToolInputStreaming": { + "type": "boolean", + "description": "Whether the provider accepts per-tool eager_input_streaming. When false, the Anthropic provider omits tools[].eager_input_streaming and sends the legacy fine-grained-tool-streaming-2025-05-14 beta header for tool-enabled requests.", + "default": true + }, + "supportsCacheControlOnTools": { + "type": "boolean", + "description": "Whether the provider supports Anthropic-style cache_control markers on tool definitions. When false, cache_control is omitted from tool parameters. Some Anthropic-compatible providers, such as Fireworks, do not support this field on tools and may reject or ignore it.", + "default": true + }, + "supportsTemperature": { + "type": "boolean", + "description": "Whether the model accepts the Anthropic temperature request field. Claude Opus 4.7+ rejects non-default values.", + "default": true + }, + "forceAdaptiveThinking": { + "type": "boolean", + "description": "Whether to force adaptive thinking (thinking.type: adaptive plus output_config.effort) regardless of model ID. Built-in models that require adaptive thinking set this in generated metadata. Custom Anthropic-compatible providers can set this to true for any model whose upstream requires the adaptive format. Set false to opt out on overridden built-in models.", + "default": false + }, + "allowEmptySignature": { + "type": "boolean", + "description": "Whether to replay empty thinking signatures instead of converting thinking to text.", + "default": false + }, + "supportsStrictTools": { + "type": "boolean", + "description": "Whether the provider supports Anthropic strict tool schemas. Generated Anthropic models enable it explicitly.", + "default": false + }, + "supportsMidConvoEffort": { + "type": "boolean", + "description": "Whether the exact model transport supports effort-only system messages and thinking binding controls.", + "default": false + }, + "supportsMidConvoToolChanges": { + "type": "boolean", + "description": "Whether the exact model accepts mid-conversation tool_addition and tool_removal blocks. Requires supportsMidConvoSystemMessages.", + "default": false + }, + "allowedFallbackModels": { + "type": "array", + "items": { + "type": "object", + "required": [ + "provider", + "model", + "cost" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "cost": { + "$ref": "#/$defs/ModelCost" + } + }, + "description": "An Anthropic server-side refusal fallback model with local pricing metadata." + }, + "maxItems": 3, + "description": "Models Anthropic accepts for server-side refusal fallback, with local pricing metadata for returned fallback responses. When absent or empty, callers must omit fallbacks; Anthropic rejects the field for models with no permitted fallback targets." + } + }, + "additionalProperties": true + }, + "ThinkingLevelMap": { + "type": "object", + "properties": { + "off": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "minimal": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "low": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "medium": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "high": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "xhigh": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "max": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } +} diff --git a/packages/coding-agent/schemas/settings.schema.json b/packages/coding-agent/schemas/settings.schema.json new file mode 100644 index 000000000..3f3b2c368 --- /dev/null +++ b/packages/coding-agent/schemas/settings.schema.json @@ -0,0 +1,765 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/schemas/settings.schema.json", + "$comment": "This file is generated from TypeScript source. Do not edit it manually; run npm run generate:schemas.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference." + }, + "lastChangelogVersion": { + "type": "string" + }, + "defaultProvider": { + "type": "string" + }, + "defaultModel": { + "type": "string" + }, + "defaultThinkingLevel": { + "$ref": "#/$defs/ModelThinkingLevel" + }, + "modelThinkingLevels": { + "type": "object", + "patternProperties": { + "^.*$": { + "$ref": "#/$defs/ModelThinkingLevel" + } + }, + "description": "Per-model default thinking level overrides keyed by \"provider/modelId\"." + }, + "transport": { + "anyOf": [ + { + "type": "string", + "const": "auto" + }, + { + "type": "string", + "const": "sse" + }, + { + "type": "string", + "const": "websocket" + }, + { + "type": "string", + "const": "websocket-cached" + } + ], + "default": "auto" + }, + "steeringMode": { + "anyOf": [ + { + "type": "string", + "const": "all" + }, + { + "type": "string", + "const": "one-at-a-time" + } + ], + "default": "one-at-a-time" + }, + "followUpMode": { + "anyOf": [ + { + "type": "string", + "const": "all" + }, + { + "type": "string", + "const": "one-at-a-time" + } + ], + "default": "one-at-a-time" + }, + "theme": { + "type": "string" + }, + "compaction": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "reserveTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "default": 16384 + }, + "keepRecentTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "default": 20000 + }, + "modelOverrides": { + "type": "object", + "patternProperties": { + "^.*$": { + "type": "object", + "properties": { + "reserveTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "keepRecentTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + } + }, + "description": "Per-model overrides keyed by exact \"provider/modelId\" strings." + } + } + }, + "branchSummary": { + "type": "object", + "properties": { + "reserveTokens": { + "type": "number", + "description": "Tokens reserved for the prompt and LLM response.", + "default": 16384 + }, + "skipPrompt": { + "type": "boolean", + "description": "When true, skips the \"Summarize branch?\" prompt and defaults to no summary.", + "default": false + } + } + }, + "retry": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "maxRetries": { + "type": "number", + "default": 3 + }, + "baseDelayMs": { + "type": "number", + "description": "Exponential backoff base delay in milliseconds: 2s, 4s, 8s.", + "default": 2000 + }, + "maxAgentDelayMs": { + "type": "number", + "default": 60000 + }, + "provider": { + "type": "object", + "properties": { + "timeoutMs": { + "type": "number", + "description": "SDK or provider request timeout in milliseconds." + }, + "maxRetries": { + "type": "number", + "description": "SDK or provider retry attempts." + }, + "maxRetryDelayMs": { + "type": "number", + "description": "Maximum server-requested delay before failing.", + "default": 60000 + } + } + }, + "maxDelayMs": { + "type": "number", + "description": "Legacy retry delay setting. Use provider.maxRetryDelayMs instead.", + "deprecated": true + } + } + }, + "hideThinkingBlock": { + "type": "boolean", + "default": false + }, + "showCacheMissNotices": { + "type": "boolean", + "description": "Show cache cost and provider recovery notices.", + "default": false + }, + "externalEditor": { + "type": "string", + "description": "Command for Ctrl+G external editor; takes precedence over VISUAL and EDITOR." + }, + "shellPath": { + "type": "string", + "description": "Custom shell path, for example for Cygwin on Windows, with support for leading ~ expansion." + }, + "quietStartup": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "header" + } + ], + "description": "When true, hide all startup output. When \"header\", keep only the startup header.", + "default": false + }, + "defaultProjectTrust": { + "anyOf": [ + { + "type": "string", + "const": "ask" + }, + { + "type": "string", + "const": "always" + }, + { + "type": "string", + "const": "never" + } + ], + "description": "Global setting only.", + "default": "ask" + }, + "shellCommandPrefix": { + "type": "string", + "description": "Prefix prepended to every bash command, for example to enable shell aliases." + }, + "npmCommand": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Command used for npm package lookup and installation, in argv form such as [\"mise\", \"exec\", \"node@20\", \"--\", \"npm\"]." + }, + "collapseChangelog": { + "type": "boolean", + "description": "Show the condensed changelog after update; use /changelog for the full changelog.", + "default": false + }, + "enableInstallTelemetry": { + "type": "boolean", + "description": "Send an anonymous version and update ping after changelog-detected updates.", + "default": true + }, + "enableAnalytics": { + "type": "boolean", + "description": "Opt in to analytics data sharing.", + "default": false + }, + "trackingId": { + "type": "string", + "description": "Analytics tracking identifier, generated when analytics is enabled." + }, + "packages": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "description": "Load all resources from the package." + }, + { + "type": "object", + "required": [ + "source" + ], + "properties": { + "source": { + "type": "string" + }, + "autoload": { + "type": "boolean", + "description": "When false, start empty and only apply explicit resource patterns." + }, + "extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "prompts": { + "type": "array", + "items": { + "type": "string" + } + }, + "themes": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ], + "description": "Package source for npm or git packages. Use the string form to load all resources or the object form to filter resources." + }, + "description": "npm or git package sources, as strings or objects with resource filtering." + }, + "extensions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Local extension file paths or directories." + }, + "deviceId": { + "type": "string", + "description": "Stable installation UUID, generated when authentication first needs it. Global only." + }, + "skills": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "enableSkillCommands": { + "type": "boolean" + }, + "customDirectories": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "deprecated": true + } + ], + "description": "Local skill file paths or directories." + }, + "prompts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Local prompt template file paths or directories." + }, + "themes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Local theme file paths or directories." + }, + "enableSkillCommands": { + "type": "boolean", + "description": "Register skills as /skill:name commands.", + "default": true + }, + "terminal": { + "type": "object", + "properties": { + "showImages": { + "type": "boolean", + "description": "Show images when the terminal supports them.", + "default": true + }, + "imageWidthCells": { + "type": "number", + "description": "Preferred inline image width in terminal cells.", + "default": 60 + }, + "clearOnShrink": { + "type": "boolean", + "description": "Clear empty rows when content shrinks.", + "default": false + }, + "showTerminalProgress": { + "type": "boolean", + "description": "Show OSC 9;4 terminal progress indicators.", + "default": false + }, + "hyperlinks": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "auto" + } + ] + }, + "images": { + "anyOf": [ + { + "type": "string", + "const": "kitty" + }, + { + "type": "string", + "const": "iterm2" + }, + { + "type": "string", + "const": "auto" + }, + { + "type": "boolean", + "const": false + } + ] + }, + "trueColor": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "auto" + } + ] + } + } + }, + "images": { + "type": "object", + "properties": { + "autoResize": { + "type": "boolean", + "description": "Resize images to 2000x2000 maximum for better model compatibility.", + "default": true + }, + "blockImages": { + "type": "boolean", + "description": "When true, prevents all images from being sent to LLM providers.", + "default": false + } + } + }, + "enabledModels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Model patterns for cycling, in the same format as the --models CLI flag." + }, + "defaultTools": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Initial tool selection. Plain names replace the inherited selection; +name and -name entries add or remove tools." + }, + "doubleEscapeAction": { + "anyOf": [ + { + "type": "string", + "const": "fork" + }, + { + "type": "string", + "const": "tree" + }, + { + "type": "string", + "const": "none" + } + ], + "description": "Action for double-escape with an empty editor.", + "default": "tree" + }, + "treeFilterMode": { + "anyOf": [ + { + "type": "string", + "const": "default" + }, + { + "type": "string", + "const": "no-tools" + }, + { + "type": "string", + "const": "user-only" + }, + { + "type": "string", + "const": "labeled-only" + }, + { + "type": "string", + "const": "all" + } + ], + "description": "Default filter when opening /tree.", + "default": "default" + }, + "thinkingBudgets": { + "type": "object", + "properties": { + "minimal": { + "type": "number" + }, + "low": { + "type": "number" + }, + "medium": { + "type": "number" + }, + "high": { + "type": "number" + } + }, + "description": "Custom token budgets for thinking levels." + }, + "editorPaddingX": { + "type": "number", + "description": "Horizontal padding for the input editor.", + "default": 0 + }, + "outputPad": { + "anyOf": [ + { + "type": "number", + "const": 0 + }, + { + "type": "number", + "const": 1 + } + ], + "description": "Horizontal padding for transcript content.", + "default": 1 + }, + "autocompleteMaxVisible": { + "type": "number", + "description": "Maximum visible items in the autocomplete dropdown.", + "default": 5 + }, + "showHardwareCursor": { + "type": "boolean", + "description": "Show the terminal cursor while still positioning it for IME." + }, + "markdown": { + "type": "object", + "properties": { + "codeBlockIndent": { + "type": "string", + "default": " " + }, + "mermaid": { + "anyOf": [ + { + "type": "string", + "const": "off" + }, + { + "type": "string", + "const": "final" + }, + { + "type": "string", + "const": "streaming" + } + ], + "default": "streaming" + } + } + }, + "warnings": { + "type": "object", + "properties": { + "anthropicExtraUsage": { + "type": "boolean", + "default": true + } + } + }, + "codemode": { + "type": "object", + "properties": { + "mode": { + "anyOf": [ + { + "type": "string", + "const": "on" + }, + { + "type": "string", + "const": "only" + } + ], + "description": "How codemode presents tools. \"on\" keeps direct tools declared and annotates tools callable from scripts; codemode lists only tools without direct exposure. \"only\" lists every script-callable tool in codemode and does not declare active direct tools.", + "default": "on" + }, + "inlineBudget": { + "type": "number", + "minimum": 0, + "description": "Estimated tokens available for inline codemode tool declarations.", + "default": 3000 + } + } + }, + "sessionDir": { + "type": "string", + "description": "Custom session storage directory, in the same format as the --session-dir CLI flag." + }, + "httpProxy": { + "type": "string", + "description": "Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients." + }, + "httpIdleTimeoutMs": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "string", + "const": "disabled" + } + ], + "description": "HTTP header or body idle timeout in milliseconds; 0 or \"disabled\" disables it." + }, + "cacheWarming": { + "anyOf": [ + { + "type": "string", + "const": "off" + }, + { + "type": "string", + "const": "streaming" + }, + { + "type": "string", + "const": "idle" + } + ], + "description": "Cache-warming profile. \"idle\" also warms between agent runs. Global only because each refresh costs money.", + "default": "streaming" + }, + "websocketConnectTimeoutMs": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "string", + "const": "disabled" + } + ], + "description": "WebSocket connect or open handshake timeout in milliseconds; 0 or \"disabled\" disables it." + }, + "tuiMode": { + "anyOf": [ + { + "type": "string", + "const": "regular" + }, + { + "type": "string", + "const": "fullscreen" + } + ], + "default": "fullscreen" + }, + "fullscreenExitOutput": { + "anyOf": [ + { + "type": "string", + "const": "transcript" + }, + { + "type": "string", + "const": "resume-hint" + } + ], + "description": "No effect in regular TUI mode.", + "default": "transcript" + }, + "fullscreenScrollbar": { + "anyOf": [ + { + "type": "string", + "const": "auto" + }, + { + "type": "string", + "const": "always" + }, + { + "type": "string", + "const": "hidden" + } + ], + "description": "No effect in regular TUI mode.", + "default": "auto" + }, + "fullscreenCopyOnSelect": { + "type": "boolean", + "description": "No effect in regular TUI mode.", + "default": true + }, + "fullscreenWheelScrollLines": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "const": "auto" + } + ], + "description": "Lines scrolled per wheel event in fullscreen mode; numeric values are clamped from 1 to 100.", + "default": "auto" + }, + "queueMode": { + "anyOf": [ + { + "type": "string", + "const": "all" + }, + { + "type": "string", + "const": "one-at-a-time" + } + ], + "description": "Legacy setting migrated to steeringMode.", + "deprecated": true + }, + "websockets": { + "type": "boolean", + "description": "Legacy setting migrated to transport.", + "deprecated": true + } + }, + "additionalProperties": true, + "$defs": { + "ModelThinkingLevel": { + "enum": [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + } +} diff --git a/packages/coding-agent/schemas/theme.schema.json b/packages/coding-agent/schemas/theme.schema.json new file mode 100644 index 000000000..c816447df --- /dev/null +++ b/packages/coding-agent/schemas/theme.schema.json @@ -0,0 +1,365 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/schemas/theme.schema.json", + "$comment": "This file is generated from TypeScript source. Do not edit it manually; run npm run generate:schemas.", + "type": "object", + "required": [ + "name", + "colors" + ], + "properties": { + "$schema": { + "type": "string", + "description": "JSON schema reference" + }, + "name": { + "type": "string", + "pattern": "^[^/]+$", + "description": "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings." + }, + "appearance": { + "anyOf": [ + { + "type": "string", + "const": "dark" + }, + { + "type": "string", + "const": "light" + } + ], + "description": "Background the theme is designed for. Detected from the theme colors when omitted." + }, + "vars": { + "type": "object", + "patternProperties": { + "^.*$": { + "$ref": "#/$defs/ColorValue" + } + }, + "description": "Reusable color variables" + }, + "colors": { + "type": "object", + "required": [ + "accent", + "border", + "borderAccent", + "borderMuted", + "success", + "error", + "warning", + "muted", + "dim", + "text", + "thinkingText", + "selectedBg", + "userMessageBg", + "userMessageText", + "customMessageBg", + "customMessageText", + "customMessageLabel", + "toolPendingBg", + "toolSuccessBg", + "toolErrorBg", + "toolTitle", + "toolOutput", + "mdHeading", + "mdLink", + "mdLinkUrl", + "mdCode", + "mdCodeBlock", + "mdCodeBlockBorder", + "mdQuote", + "mdQuoteBorder", + "mdHr", + "mdListBullet", + "toolDiffAdded", + "toolDiffRemoved", + "toolDiffContext", + "syntaxComment", + "syntaxKeyword", + "syntaxFunction", + "syntaxVariable", + "syntaxString", + "syntaxNumber", + "syntaxType", + "syntaxOperator", + "syntaxPunctuation", + "thinkingOff", + "thinkingMinimal", + "thinkingLow", + "thinkingMedium", + "thinkingHigh", + "thinkingXhigh", + "bashMode" + ], + "properties": { + "accent": { + "$ref": "#/$defs/ColorValue", + "description": "Primary accent color (logo, selected items, cursor)" + }, + "border": { + "$ref": "#/$defs/ColorValue", + "description": "Normal borders" + }, + "borderAccent": { + "$ref": "#/$defs/ColorValue", + "description": "Highlighted borders" + }, + "borderMuted": { + "$ref": "#/$defs/ColorValue", + "description": "Subtle borders" + }, + "success": { + "$ref": "#/$defs/ColorValue", + "description": "Success states" + }, + "error": { + "$ref": "#/$defs/ColorValue", + "description": "Error states" + }, + "warning": { + "$ref": "#/$defs/ColorValue", + "description": "Warning states" + }, + "muted": { + "$ref": "#/$defs/ColorValue", + "description": "Secondary/dimmed text" + }, + "dim": { + "$ref": "#/$defs/ColorValue", + "description": "Very dimmed text (more subtle than muted)" + }, + "text": { + "$ref": "#/$defs/ColorValue", + "description": "Default text color (usually empty string)" + }, + "thinkingText": { + "$ref": "#/$defs/ColorValue", + "description": "Thinking block text color" + }, + "scrollbarTrack": { + "$ref": "#/$defs/ColorValue", + "description": "Fullscreen scrollbar track foreground (falls back to muted when omitted)" + }, + "scrollbarThumb": { + "$ref": "#/$defs/ColorValue", + "description": "Fullscreen scrollbar thumb foreground (falls back to text when omitted)" + }, + "selectedBg": { + "$ref": "#/$defs/ColorValue", + "description": "Selected item background" + }, + "searchMatchBg": { + "$ref": "#/$defs/ColorValue", + "description": "Transcript search match background and current-match text (falls back to selectedBg when omitted)" + }, + "searchMatchText": { + "$ref": "#/$defs/ColorValue", + "description": "Transcript search match text and current-match background (falls back to text when omitted)" + }, + "userMessageBg": { + "$ref": "#/$defs/ColorValue", + "description": "User message background" + }, + "userMessageText": { + "$ref": "#/$defs/ColorValue", + "description": "User message text color" + }, + "customMessageBg": { + "$ref": "#/$defs/ColorValue", + "description": "Custom message background (hook-injected messages)" + }, + "customMessageText": { + "$ref": "#/$defs/ColorValue", + "description": "Custom message text color" + }, + "customMessageLabel": { + "$ref": "#/$defs/ColorValue", + "description": "Custom message type label color" + }, + "toolPendingBg": { + "$ref": "#/$defs/ColorValue", + "description": "Tool execution box (pending state)" + }, + "toolSuccessBg": { + "$ref": "#/$defs/ColorValue", + "description": "Tool execution box (success state)" + }, + "toolErrorBg": { + "$ref": "#/$defs/ColorValue", + "description": "Tool execution box (error state)" + }, + "toolTitle": { + "$ref": "#/$defs/ColorValue", + "description": "Tool execution box title color" + }, + "toolOutput": { + "$ref": "#/$defs/ColorValue", + "description": "Tool execution box output text color" + }, + "mdHeading": { + "$ref": "#/$defs/ColorValue", + "description": "Markdown heading text" + }, + "mdLink": { + "$ref": "#/$defs/ColorValue", + "description": "Markdown link text" + }, + "mdLinkUrl": { + "$ref": "#/$defs/ColorValue", + "description": "Markdown link URL" + }, + "mdCode": { + "$ref": "#/$defs/ColorValue", + "description": "Markdown inline code" + }, + "mdCodeBlock": { + "$ref": "#/$defs/ColorValue", + "description": "Markdown code block content" + }, + "mdCodeBlockBorder": { + "$ref": "#/$defs/ColorValue", + "description": "Markdown code block fences" + }, + "mdQuote": { + "$ref": "#/$defs/ColorValue", + "description": "Markdown blockquote text" + }, + "mdQuoteBorder": { + "$ref": "#/$defs/ColorValue", + "description": "Markdown blockquote border" + }, + "mdHr": { + "$ref": "#/$defs/ColorValue", + "description": "Markdown horizontal rule" + }, + "mdListBullet": { + "$ref": "#/$defs/ColorValue", + "description": "Markdown list bullets/numbers" + }, + "toolDiffAdded": { + "$ref": "#/$defs/ColorValue", + "description": "Added lines in tool diffs" + }, + "toolDiffRemoved": { + "$ref": "#/$defs/ColorValue", + "description": "Removed lines in tool diffs" + }, + "toolDiffContext": { + "$ref": "#/$defs/ColorValue", + "description": "Context lines in tool diffs" + }, + "syntaxComment": { + "$ref": "#/$defs/ColorValue", + "description": "Syntax highlighting: comments" + }, + "syntaxKeyword": { + "$ref": "#/$defs/ColorValue", + "description": "Syntax highlighting: keywords" + }, + "syntaxFunction": { + "$ref": "#/$defs/ColorValue", + "description": "Syntax highlighting: function names" + }, + "syntaxVariable": { + "$ref": "#/$defs/ColorValue", + "description": "Syntax highlighting: variable names" + }, + "syntaxString": { + "$ref": "#/$defs/ColorValue", + "description": "Syntax highlighting: string literals" + }, + "syntaxNumber": { + "$ref": "#/$defs/ColorValue", + "description": "Syntax highlighting: number literals" + }, + "syntaxType": { + "$ref": "#/$defs/ColorValue", + "description": "Syntax highlighting: type names" + }, + "syntaxOperator": { + "$ref": "#/$defs/ColorValue", + "description": "Syntax highlighting: operators" + }, + "syntaxPunctuation": { + "$ref": "#/$defs/ColorValue", + "description": "Syntax highlighting: punctuation" + }, + "thinkingOff": { + "$ref": "#/$defs/ColorValue", + "description": "Thinking level border: off" + }, + "thinkingMinimal": { + "$ref": "#/$defs/ColorValue", + "description": "Thinking level border: minimal" + }, + "thinkingLow": { + "$ref": "#/$defs/ColorValue", + "description": "Thinking level border: low" + }, + "thinkingMedium": { + "$ref": "#/$defs/ColorValue", + "description": "Thinking level border: medium" + }, + "thinkingHigh": { + "$ref": "#/$defs/ColorValue", + "description": "Thinking level border: high" + }, + "thinkingXhigh": { + "$ref": "#/$defs/ColorValue", + "description": "Thinking level border: xhigh" + }, + "thinkingMax": { + "$ref": "#/$defs/ColorValue", + "description": "Thinking level border: max (falls back to thinkingXhigh when omitted)" + }, + "bashMode": { + "$ref": "#/$defs/ColorValue", + "description": "Editor border color in bash mode" + } + }, + "description": "Theme color definitions (scrollbar, thinkingMax, and search highlight colors are optional and use compatible fallbacks)", + "additionalProperties": false + }, + "export": { + "type": "object", + "properties": { + "pageBg": { + "$ref": "#/$defs/ColorValue", + "description": "Page background color" + }, + "cardBg": { + "$ref": "#/$defs/ColorValue", + "description": "Card/container background color" + }, + "infoBg": { + "$ref": "#/$defs/ColorValue", + "description": "Info sections background (system prompt, notices)" + } + }, + "description": "Optional colors for HTML export (defaults derived from userMessageBg if not specified)", + "additionalProperties": false + } + }, + "title": "Pi Coding Agent Theme", + "description": "Theme schema for Pi coding agent", + "additionalProperties": false, + "$defs": { + "ColorValue": { + "anyOf": [ + { + "type": "string", + "description": "Hex color (#RGB or #RRGGBB), OKLCH or OKHSL color, variable reference, or empty string for terminal default" + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "256-color palette index (0-255)" + } + ] + } + } +} diff --git a/packages/coding-agent/scripts/generate-schemas.ts b/packages/coding-agent/scripts/generate-schemas.ts new file mode 100644 index 000000000..672773cd1 --- /dev/null +++ b/packages/coding-agent/scripts/generate-schemas.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env node + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { ProviderCompatSchema } from "@earendil-works/pi-ai/providers/compat-schema"; +import { + ModelCostSchema, + ModelInputLimitsSchema, + ModelPromptCacheSchema, + ModelThinkingLevelSchema, + ThinkingLevelMapSchema, +} from "@earendil-works/pi-ai/providers/model-schema"; +import type { TSchema } from "typebox"; +import { KeybindingsSchema, KeybindingValueSchema } from "../src/core/keybindings-schema.ts"; +import { ModelsConfigSchema } from "../src/core/model-config.ts"; +import { SettingsSchema } from "../src/core/settings-schema.ts"; +import { ColorValueSchema, ThemeJsonSchema } from "../src/modes/interactive/theme/theme-schema.ts"; + +const packageDirectory = fileURLToPath(new URL("..", import.meta.url)); +const schemaBaseUrl = "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent"; +const schemaDraft = "https://json-schema.org/draft/2020-12/schema"; +const generatedComment = + "This file is generated from TypeScript source. Do not edit it manually; run npm run generate:schemas."; + +interface SchemaArtifact { + path: `schemas/${string}.schema.json`; + schema: TSchema; + definitions?: Readonly>; +} + +const schemaArtifacts: readonly SchemaArtifact[] = [ + { + path: "schemas/models.schema.json", + schema: ModelsConfigSchema, + definitions: { + ModelCost: ModelCostSchema, + ModelInputLimits: ModelInputLimitsSchema, + ModelPromptCache: ModelPromptCacheSchema, + ProviderCompat: ProviderCompatSchema, + ThinkingLevelMap: ThinkingLevelMapSchema, + }, + }, + { + path: "schemas/settings.schema.json", + schema: SettingsSchema, + definitions: { ModelThinkingLevel: ModelThinkingLevelSchema }, + }, + { + path: "schemas/keybindings.schema.json", + schema: KeybindingsSchema, + definitions: { KeybindingValue: KeybindingValueSchema }, + }, + { + path: "schemas/theme.schema.json", + schema: ThemeJsonSchema, + definitions: { ColorValue: ColorValueSchema }, + }, +]; + +const schemaAnnotationKeys = new Set([ + "default", + "deprecated", + "description", + "examples", + "readOnly", + "title", + "writeOnly", +]); + +function splitAnnotations(schema: object): { + shape: Record; + annotations: Record; +} { + const shape: Record = {}; + const annotations: Record = {}; + for (const [key, value] of Object.entries(schema)) { + (schemaAnnotationKeys.has(key) ? annotations : shape)[key] = value; + } + return { shape, annotations }; +} + +/** Replace selected repeated schemas with local references while retaining per-use annotations. */ +function addDefinitions(schema: TSchema, definitions: Readonly>): TSchema { + const preparedDefinitions = Object.entries(definitions).map(([name, definition]) => { + if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) { + throw new Error(`Invalid schema definition name: ${name}`); + } + const { shape } = splitAnnotations(definition); + return { name, shape, shapeKey: JSON.stringify(shape) }; + }); + + const referencedDefinitions = new Set(); + const replaceDefinitions = (value: unknown, excludedName?: string): unknown => { + if (Array.isArray(value)) return value.map((item) => replaceDefinitions(item, excludedName)); + if (typeof value !== "object" || value === null || Array.isArray(value)) return value; + + const { shape, annotations } = splitAnnotations(value); + const shapeKey = JSON.stringify(shape); + const match = preparedDefinitions.find( + (definition) => definition.name !== excludedName && definition.shapeKey === shapeKey, + ); + if (match) { + referencedDefinitions.add(match.name); + return { $ref: `#/$defs/${match.name}`, ...annotations }; + } + + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + schemaAnnotationKeys.has(key) ? child : replaceDefinitions(child, excludedName), + ]), + ); + }; + + const compactSchema = replaceDefinitions(schema) as TSchema; + const compactDefinitions = Object.fromEntries( + preparedDefinitions.map(({ name, shape }) => [name, replaceDefinitions(shape, name)]), + ); + const unusedDefinitions = preparedDefinitions + .map(({ name }) => name) + .filter((name) => !referencedDefinitions.has(name)); + if (unusedDefinitions.length > 0) { + throw new Error(`Unused schema definitions: ${unusedDefinitions.join(", ")}`); + } + return { ...compactSchema, $defs: compactDefinitions }; +} + +function serializeSchema(artifact: SchemaArtifact): string { + const schema = artifact.definitions ? addDefinitions(artifact.schema, artifact.definitions) : artifact.schema; + return `${JSON.stringify( + { + $schema: schemaDraft, + $id: `${schemaBaseUrl}/${artifact.path}`, + $comment: generatedComment, + ...schema, + }, + null, + 2, + )}\n`; +} + +export function renderConfigSchemas(): ReadonlyMap { + const rendered = new Map(); + for (const artifact of schemaArtifacts) { + const content = serializeSchema(artifact); + rendered.set(artifact.path, content); + } + return rendered; +} + +function generateSchemas(): void { + for (const [relativePath, content] of renderConfigSchemas()) { + const path = resolve(packageDirectory, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf-8"); + console.log(`Generated packages/coding-agent/${relativePath}`); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + generateSchemas(); +} diff --git a/packages/coding-agent/src/core/key-id-schema.ts b/packages/coding-agent/src/core/key-id-schema.ts new file mode 100644 index 000000000..7d6305735 --- /dev/null +++ b/packages/coding-agent/src/core/key-id-schema.ts @@ -0,0 +1,21 @@ +import { Key, type KeyId } from "@earendil-works/pi-tui"; +import { type TSchemaOptions, Type } from "typebox"; + +const modifiers = ["ctrl", "shift", "alt", "super"] as const; +const baseKeyPattern = `(?:[a-z0-9]|${Object.values(Key) + .flatMap((value) => (typeof value === "string" ? [value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")] : [])) + .join("|")})`; +const duplicateModifierPattern = modifiers.map((modifier) => `${modifier}\\+.*${modifier}\\+`).join("|"); + +export const KeyIdSchema = Type.Unsafe( + Type.String({ + pattern: `^(?!.*(?:${duplicateModifierPattern}))(?:(?:${modifiers.join("|")})\\+){0,4}${baseKeyPattern}$`, + description: "Key identifier with optional ctrl, shift, alt, or super modifiers.", + }), +); + +export function keybindingValueSchema(options?: TSchemaOptions) { + return Type.Union([KeyIdSchema, Type.Array(KeyIdSchema)], options); +} + +export const KeybindingValueSchema = keybindingValueSchema(); diff --git a/packages/coding-agent/src/core/keybindings-schema.ts b/packages/coding-agent/src/core/keybindings-schema.ts new file mode 100644 index 000000000..93f0c11f3 --- /dev/null +++ b/packages/coding-agent/src/core/keybindings-schema.ts @@ -0,0 +1,21 @@ +import { type TSchema, Type } from "typebox"; +import { KeybindingValueSchema, keybindingValueSchema } from "./key-id-schema.ts"; +import { KEYBINDINGS } from "./keybindings.ts"; + +export { KeybindingValueSchema } from "./key-id-schema.ts"; + +const properties: Record = { + $schema: Type.Optional(Type.String()), +}; + +for (const [id, definition] of Object.entries(KEYBINDINGS)) { + properties[id] = Type.Optional( + keybindingValueSchema({ + description: definition.description, + }), + ); +} + +export const KeybindingsSchema = Type.Object(properties, { + additionalProperties: KeybindingValueSchema, +}); diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index f67cbe5e3..75e540b37 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -8,56 +8,10 @@ import { } from "@earendil-works/pi-tui"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; +import { Compile } from "typebox/compile"; import { getAgentDir } from "../config.ts"; import { stripBom } from "../utils/text.ts"; - -export interface AppKeybindings { - "app.interrupt": true; - "app.clear": true; - "app.exit": true; - "app.suspend": true; - "app.thinking.cycle": true; - "app.thinking.save": true; - "app.model.cycleForward": true; - "app.model.cycleBackward": true; - "app.model.select": true; - "app.tools.expand": true; - "app.thinking.toggle": true; - "app.session.toggleNamedFilter": true; - "app.editor.external": true; - "app.message.copy": true; - "app.message.followUp": true; - "app.message.dequeue": true; - "app.clipboard.pasteImage": true; - "app.session.new": true; - "app.session.tree": true; - "app.session.fork": true; - "app.session.resume": true; - "app.tree.foldOrUp": true; - "app.tree.unfoldOrDown": true; - "app.tree.editLabel": true; - "app.tree.toggleLabelTimestamp": true; - "app.session.togglePath": true; - "app.session.toggleSort": true; - "app.session.rename": true; - "app.session.delete": true; - "app.session.deleteNoninvasive": true; - "app.models.save": true; - "app.models.enableAll": true; - "app.models.clearAll": true; - "app.models.toggleProvider": true; - "app.models.reorderUp": true; - "app.models.reorderDown": true; - "app.tree.filter.default": true; - "app.tree.filter.noTools": true; - "app.tree.filter.userOnly": true; - "app.tree.filter.labeledOnly": true; - "app.tree.filter.all": true; - "app.tree.filter.cycleForward": true; - "app.tree.filter.cycleBackward": true; -} - -export type AppKeybinding = keyof AppKeybindings; +import { KeybindingValueSchema } from "./key-id-schema.ts"; export function useWindowsKeybindings( platform: NodeJS.Platform = process.platform, @@ -66,30 +20,9 @@ export function useWindowsKeybindings( return platform === "win32" || (platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP)); } -declare module "@earendil-works/pi-tui" { - interface Keybindings extends AppKeybindings {} -} - const windowsKeybindings = useWindowsKeybindings(); -export const KEYBINDINGS = { - ...TUI_KEYBINDINGS, - "tui.editor.undo": { - ...TUI_KEYBINDINGS["tui.editor.undo"], - defaultKeys: process.platform === "win32" ? "ctrl+z" : windowsKeybindings ? "alt+z" : "ctrl+-", - }, - "tui.altScreen.previousPrompt": { - ...TUI_KEYBINDINGS["tui.altScreen.previousPrompt"], - defaultKeys: windowsKeybindings ? "ctrl+up" : ["ctrl+shift+up", "ctrl+up"], - }, - "tui.altScreen.nextPrompt": { - ...TUI_KEYBINDINGS["tui.altScreen.nextPrompt"], - defaultKeys: windowsKeybindings ? "ctrl+down" : ["ctrl+shift+down", "ctrl+down"], - }, - "tui.altScreen.search": { - ...TUI_KEYBINDINGS["tui.altScreen.search"], - defaultKeys: windowsKeybindings ? "ctrl+f" : "ctrl+shift+f", - }, +const APP_KEYBINDINGS = { "app.interrupt": { defaultKeys: "escape", description: "Cancel or abort" }, "app.clear": { defaultKeys: "ctrl+c", description: "Clear editor" }, "app.exit": { defaultKeys: "ctrl+d", description: "Exit when editor is empty" }, @@ -237,6 +170,34 @@ export const KEYBINDINGS = { }, } as const satisfies KeybindingDefinitions; +export type AppKeybinding = keyof typeof APP_KEYBINDINGS; +export type AppKeybindings = { [K in AppKeybinding]: true }; + +declare module "@earendil-works/pi-tui" { + interface Keybindings extends AppKeybindings {} +} + +export const KEYBINDINGS = { + ...TUI_KEYBINDINGS, + "tui.editor.undo": { + ...TUI_KEYBINDINGS["tui.editor.undo"], + defaultKeys: process.platform === "win32" ? "ctrl+z" : windowsKeybindings ? "alt+z" : "ctrl+-", + }, + "tui.altScreen.previousPrompt": { + ...TUI_KEYBINDINGS["tui.altScreen.previousPrompt"], + defaultKeys: windowsKeybindings ? "ctrl+up" : ["ctrl+shift+up", "ctrl+up"], + }, + "tui.altScreen.nextPrompt": { + ...TUI_KEYBINDINGS["tui.altScreen.nextPrompt"], + defaultKeys: windowsKeybindings ? "ctrl+down" : ["ctrl+shift+down", "ctrl+down"], + }, + "tui.altScreen.search": { + ...TUI_KEYBINDINGS["tui.altScreen.search"], + defaultKeys: windowsKeybindings ? "ctrl+f" : "ctrl+shift+f", + }, + ...APP_KEYBINDINGS, +} as const satisfies KeybindingDefinitions; + const KEYBINDING_NAME_MIGRATIONS = { cursorUp: "tui.editor.cursorUp", cursorDown: "tui.editor.cursorDown", @@ -303,16 +264,13 @@ function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAM return key in KEYBINDING_NAME_MIGRATIONS; } +const validateKeybindingValue = Compile(KeybindingValueSchema); + function toKeybindingsConfig(value: Record): KeybindingsConfig { const config: KeybindingsConfig = {}; for (const [key, binding] of Object.entries(value)) { - if (typeof binding === "string") { - config[key] = binding as KeyId; - continue; - } - if (Array.isArray(binding) && binding.every((entry) => typeof entry === "string")) { - config[key] = binding as KeyId[]; - } + if (key === "$schema") continue; + if (validateKeybindingValue.Check(binding)) config[key] = binding; } return config; } diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 8e06116a9..1681ea229 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -1,6 +1,14 @@ /** Immutable, credential-blind models.json snapshot. */ import { readFile } from "node:fs/promises"; +import { ProviderCompatSchema } from "@earendil-works/pi-ai/providers/compat-schema"; +import { + ModelCostSchema, + ModelInputLimitsSchema, + ModelInputModalitySchema, + ModelPromptCacheSchema, + ThinkingLevelMapSchema, +} from "@earendil-works/pi-ai/providers/model-schema"; import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; @@ -8,61 +16,6 @@ import { stripJsonComments } from "../utils/json.ts"; import { normalizePath } from "../utils/paths.ts"; import { stripBom } from "../utils/text.ts"; -const PercentileCutoffsSchema = Type.Object({ - p50: Type.Optional(Type.Number()), - p75: Type.Optional(Type.Number()), - p90: Type.Optional(Type.Number()), - p99: Type.Optional(Type.Number()), -}); - -const OpenRouterRoutingSchema = Type.Object({ - allow_fallbacks: Type.Optional(Type.Boolean()), - require_parameters: Type.Optional(Type.Boolean()), - data_collection: Type.Optional(Type.Union([Type.Literal("deny"), Type.Literal("allow")])), - zdr: Type.Optional(Type.Boolean()), - enforce_distillable_text: Type.Optional(Type.Boolean()), - order: Type.Optional(Type.Array(Type.String())), - only: Type.Optional(Type.Array(Type.String())), - ignore: Type.Optional(Type.Array(Type.String())), - quantizations: Type.Optional(Type.Array(Type.String())), - sort: Type.Optional( - Type.Union([ - Type.String(), - Type.Object({ - by: Type.Optional(Type.String()), - partition: Type.Optional(Type.Union([Type.String(), Type.Null()])), - }), - ]), - ), - max_price: Type.Optional( - Type.Object({ - prompt: Type.Optional(Type.Union([Type.Number(), Type.String()])), - completion: Type.Optional(Type.Union([Type.Number(), Type.String()])), - image: Type.Optional(Type.Union([Type.Number(), Type.String()])), - audio: Type.Optional(Type.Union([Type.Number(), Type.String()])), - request: Type.Optional(Type.Union([Type.Number(), Type.String()])), - }), - ), - preferred_min_throughput: Type.Optional(Type.Union([Type.Number(), PercentileCutoffsSchema])), - preferred_max_latency: Type.Optional(Type.Union([Type.Number(), PercentileCutoffsSchema])), -}); - -const VercelGatewayRoutingSchema = Type.Object({ - only: Type.Optional(Type.Array(Type.String())), - order: Type.Optional(Type.Array(Type.String())), -}); - -const ThinkingLevelMapValueSchema = Type.Union([Type.String(), Type.Null()]); -const ThinkingLevelMapSchema = Type.Object({ - off: Type.Optional(ThinkingLevelMapValueSchema), - minimal: Type.Optional(ThinkingLevelMapValueSchema), - low: Type.Optional(ThinkingLevelMapValueSchema), - medium: Type.Optional(ThinkingLevelMapValueSchema), - high: Type.Optional(ThinkingLevelMapValueSchema), - xhigh: Type.Optional(ThinkingLevelMapValueSchema), - max: Type.Optional(ThinkingLevelMapValueSchema), -}); - const SamplingParamsSchema = Type.Record(Type.String(), Type.Unknown()); const SamplingParamsByThinkingLevelSchema = Type.Object({ off: Type.Optional(SamplingParamsSchema), @@ -74,127 +27,7 @@ const SamplingParamsByThinkingLevelSchema = Type.Object({ max: Type.Optional(SamplingParamsSchema), }); -const ChatTemplateKwargScalarSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]); -const ChatTemplateKwargVariableSchema = Type.Object({ - $var: Type.Union([Type.Literal("thinking.enabled"), Type.Literal("thinking.effort")]), - omitWhenOff: Type.Optional(Type.Boolean()), -}); -const ChatTemplateKwargSchema = Type.Union([ChatTemplateKwargScalarSchema, ChatTemplateKwargVariableSchema]); - -const OpenAICompletionsCompatSchema = Type.Object({ - supportsStore: Type.Optional(Type.Boolean()), - supportsDeveloperRole: Type.Optional(Type.Boolean()), - supportsReasoningEffort: Type.Optional(Type.Boolean()), - supportsUsageInStreaming: Type.Optional(Type.Boolean()), - supportsFinishReason: Type.Optional(Type.Boolean()), - maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])), - requiresToolResultName: Type.Optional(Type.Boolean()), - requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()), - requiresThinkingAsText: Type.Optional(Type.Boolean()), - requiresReasoningContentOnAssistantMessages: Type.Optional(Type.Boolean()), - thinkingFormat: Type.Optional( - Type.Union([ - Type.Literal("openai"), - Type.Literal("openrouter"), - Type.Literal("together"), - Type.Literal("baseten"), - Type.Literal("deepseek"), - Type.Literal("zai"), - Type.Literal("qwen"), - Type.Literal("chat-template"), - Type.Literal("qwen-chat-template"), - Type.Literal("string-thinking"), - Type.Literal("ant-ling"), - ]), - ), - chatTemplateKwargs: Type.Optional(Type.Record(Type.String(), ChatTemplateKwargSchema)), - chatTemplateArgs: Type.Optional(Type.Record(Type.String(), ChatTemplateKwargSchema)), - cacheControlFormat: Type.Optional(Type.Literal("anthropic")), - openRouterRouting: Type.Optional(OpenRouterRoutingSchema), - vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema), - supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()), - supportsStrictMode: Type.Optional(Type.Boolean()), - sendSessionAffinityHeaders: Type.Optional(Type.Boolean()), - sessionAffinityFormat: Type.Optional( - Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]), - ), - supportsLongCacheRetention: Type.Optional(Type.Boolean()), - vllmPriority: Type.Optional(Type.Number()), -}); - -const OpenAIResponsesCompatSchema = Type.Object({ - supportsDeveloperRole: Type.Optional(Type.Boolean()), - sessionAffinityFormat: Type.Optional( - Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]), - ), - supportsLongCacheRetention: Type.Optional(Type.Boolean()), - supportsStrictMode: Type.Optional(Type.Boolean()), - supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()), - supportsMaxOutputTokens: Type.Optional(Type.Boolean()), -}); - -const ModelCostRatesSchema = { - input: Type.Number(), - output: Type.Number(), - cacheRead: Type.Number(), - cacheWrite: Type.Number(), -}; -const ModelCostTierSchema = Type.Object({ - inputTokensAbove: Type.Number(), - ...ModelCostRatesSchema, -}); -const ModelCostSchema = Type.Object({ - ...ModelCostRatesSchema, - tiers: Type.Optional(Type.Array(ModelCostTierSchema)), -}); -const ModelPromptCacheSchema = Type.Object({ - short: Type.Optional(Type.Number({ exclusiveMinimum: 0 })), - long: Type.Optional(Type.Number({ exclusiveMinimum: 0 })), -}); -const ImageResizeSchema = Type.Object({ - maxWidth: Type.Optional(Type.Integer({ minimum: 1 })), - maxHeight: Type.Optional(Type.Integer({ minimum: 1 })), - maxBytes: Type.Optional(Type.Integer({ minimum: 1 })), - jpegQuality: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), -}); -const ModelInputLimitsSchema = Type.Object({ - maxRequestBytes: Type.Optional(Type.Integer({ minimum: 1 })), - images: Type.Optional( - Type.Object({ - resize: Type.Optional(ImageResizeSchema), - maxPerMessage: Type.Optional(Type.Integer({ minimum: 1 })), - maxPerRequest: Type.Optional(Type.Integer({ minimum: 1 })), - }), - ), -}); - -const AnthropicMessagesCompatSchema = Type.Object({ - supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()), - supportsLongCacheRetention: Type.Optional(Type.Boolean()), - sendSessionAffinityHeaders: Type.Optional(Type.Boolean()), - supportsCacheControlOnTools: Type.Optional(Type.Boolean()), - supportsTemperature: Type.Optional(Type.Boolean()), - forceAdaptiveThinking: Type.Optional(Type.Boolean()), - allowEmptySignature: Type.Optional(Type.Boolean()), - supportsStrictTools: Type.Optional(Type.Boolean()), - supportsMidConvoEffort: Type.Optional(Type.Boolean()), - allowedFallbackModels: Type.Optional( - Type.Array( - Type.Object({ - provider: Type.String({ minLength: 1 }), - model: Type.String({ minLength: 1 }), - cost: ModelCostSchema, - }), - { maxItems: 3 }, - ), - ), -}); - -const ProviderCompatSchema = Type.Union([ - OpenAICompletionsCompatSchema, - OpenAIResponsesCompatSchema, - AnthropicMessagesCompatSchema, -]); +const PositiveTokenCountSchema = Type.Number({ exclusiveMinimum: 0 }); const ModelDefinitionSchema = Type.Object({ id: Type.String({ minLength: 1 }), @@ -203,12 +36,12 @@ const ModelDefinitionSchema = Type.Object({ baseUrl: Type.Optional(Type.String({ minLength: 1 })), reasoning: Type.Optional(Type.Boolean()), thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema), - input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))), + input: Type.Optional(Type.Array(ModelInputModalitySchema)), inputLimits: Type.Optional(ModelInputLimitsSchema), cost: Type.Optional(ModelCostSchema), promptCache: Type.Optional(ModelPromptCacheSchema), - contextWindow: Type.Optional(Type.Number()), - maxTokens: Type.Optional(Type.Number()), + contextWindow: Type.Optional(PositiveTokenCountSchema), + maxTokens: Type.Optional(PositiveTokenCountSchema), samplingParams: Type.Optional(SamplingParamsSchema), samplingParamsByThinkingLevel: Type.Optional(SamplingParamsByThinkingLevelSchema), headers: Type.Optional(Type.Record(Type.String(), Type.String())), @@ -219,20 +52,12 @@ const ModelOverrideSchema = Type.Object({ name: Type.Optional(Type.String({ minLength: 1 })), reasoning: Type.Optional(Type.Boolean()), thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema), - input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))), + input: Type.Optional(Type.Array(ModelInputModalitySchema)), inputLimits: Type.Optional(ModelInputLimitsSchema), - cost: Type.Optional( - Type.Object({ - input: Type.Optional(Type.Number()), - output: Type.Optional(Type.Number()), - cacheRead: Type.Optional(Type.Number()), - cacheWrite: Type.Optional(Type.Number()), - tiers: Type.Optional(Type.Array(ModelCostTierSchema)), - }), - ), + cost: Type.Optional(Type.Partial(ModelCostSchema)), promptCache: Type.Optional(ModelPromptCacheSchema), - contextWindow: Type.Optional(Type.Number()), - maxTokens: Type.Optional(Type.Number()), + contextWindow: Type.Optional(PositiveTokenCountSchema), + maxTokens: Type.Optional(PositiveTokenCountSchema), samplingParams: Type.Optional(SamplingParamsSchema), samplingParamsByThinkingLevel: Type.Optional(SamplingParamsByThinkingLevelSchema), headers: Type.Optional(Type.Record(Type.String(), Type.String())), @@ -252,15 +77,20 @@ const ProviderConfigSchema = Type.Object({ modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)), }); -const ModelsConfigSchema = Type.Object({ +const ModelsConfigProperties = { providers: Type.Record(Type.String(), ProviderConfigSchema), +}; + +export const ModelsConfigSchema = Type.Object({ + $schema: Type.Optional(Type.String()), + ...ModelsConfigProperties, }); + const validateModelsConfig = Compile(ModelsConfigSchema); export type ModelsJsonModel = Static; export type ModelsJsonModelOverride = Static; export type ModelsJsonProvider = Static; -type ModelsJson = Static; function formatValidationPath(error: TLocalizedValidationError): string { if (error.keyword === "required") { @@ -324,9 +154,8 @@ export class ModelConfig { return new ModelConfig(new Map(), `Invalid models.json schema:\n${errors}\n\nFile: ${path}`); } - const config = parsed as ModelsJson; const providers = new Map(); - for (const [providerId, provider] of Object.entries(config.providers)) { + for (const [providerId, provider] of Object.entries(parsed.providers)) { providers.set(providerId, deepFreeze(structuredClone(provider))); } return new ModelConfig(providers); diff --git a/packages/coding-agent/src/core/project-trust.ts b/packages/coding-agent/src/core/project-trust.ts index b9ece17e4..efb14be4f 100644 --- a/packages/coding-agent/src/core/project-trust.ts +++ b/packages/coding-agent/src/core/project-trust.ts @@ -1,6 +1,7 @@ import { APP_NAME, CONFIG_DIR_NAME } from "../config.ts"; import { emitProjectTrustEvent } from "./extensions/runner.ts"; import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts"; +import { SETTINGS_DEFAULTS } from "./settings-defaults.ts"; import type { DefaultProjectTrust } from "./settings-manager.ts"; import { getProjectTrustOptions, @@ -74,7 +75,7 @@ export async function resolveProjectTrusted(options: ResolveProjectTrustedOption return decision; } - switch (options.defaultProjectTrust ?? "ask") { + switch (options.defaultProjectTrust ?? SETTINGS_DEFAULTS.defaultProjectTrust) { case "always": return true; case "never": diff --git a/packages/coding-agent/src/core/settings-defaults.ts b/packages/coding-agent/src/core/settings-defaults.ts new file mode 100644 index 000000000..99fa90497 --- /dev/null +++ b/packages/coding-agent/src/core/settings-defaults.ts @@ -0,0 +1,66 @@ +import { DEFAULT_MAX_AGENT_RETRY_DELAY_MS } from "@earendil-works/pi-ai/utils/retry"; + +/** Runtime defaults shared by settings accessors and the published JSON Schema. */ +export const SETTINGS_DEFAULTS = { + transport: "auto", + steeringMode: "one-at-a-time", + followUpMode: "one-at-a-time", + compaction: { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 20000, + }, + branchSummary: { + reserveTokens: 16384, + skipPrompt: false, + }, + retry: { + enabled: true, + maxRetries: 3, + baseDelayMs: 2000, + maxAgentDelayMs: DEFAULT_MAX_AGENT_RETRY_DELAY_MS, + provider: { + maxRetryDelayMs: 60000, + }, + }, + hideThinkingBlock: false, + showCacheMissNotices: false, + quietStartup: false, + defaultProjectTrust: "ask", + collapseChangelog: false, + enableInstallTelemetry: true, + enableAnalytics: false, + enableSkillCommands: true, + terminal: { + showImages: true, + imageWidthCells: 60, + clearOnShrink: false, + showTerminalProgress: false, + }, + images: { + autoResize: true, + blockImages: false, + }, + doubleEscapeAction: "tree", + treeFilterMode: "default", + editorPaddingX: 0, + outputPad: 1, + autocompleteMaxVisible: 5, + markdown: { + codeBlockIndent: " ", + mermaid: "streaming", + }, + warnings: { + anthropicExtraUsage: true, + }, + codemode: { + mode: "on", + inlineBudget: 3000, + }, + cacheWarming: "streaming", + tuiMode: "fullscreen", + fullscreenExitOutput: "transcript", + fullscreenScrollbar: "auto", + fullscreenCopyOnSelect: true, + fullscreenWheelScrollLines: "auto", +} as const; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 2dd248eb4..7e2a75030 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,11 +1,6 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { DEFAULT_MAX_AGENT_RETRY_DELAY_MS, type Model, type Transport } from "@earendil-works/pi-ai"; -import type { - TuiMode as RendererTuiMode, - ScrollViewScrollbar, - TerminalCapabilities, - WheelScrollLines, -} from "@earendil-works/pi-tui"; +import type { Model } from "@earendil-works/pi-ai"; +import type { ScrollViewScrollbar, TerminalCapabilities, WheelScrollLines } from "@earendil-works/pi-tui"; import { randomUUID } from "crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; @@ -14,180 +9,53 @@ import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts"; import { stripBom } from "../utils/text.ts"; import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts"; +import { SETTINGS_DEFAULTS } from "./settings-defaults.ts"; +import type { + CacheWarmingMode, + CompactionModelOverride, + DefaultProjectTrust, + FullscreenExitOutput, + MermaidRenderingMode, + PackageSource, + QuietStartup, + Settings, + ThinkingBudgetsSettings, + TransportSetting, + TuiMode, + WarningSettings, +} from "./settings-schema.ts"; -export interface CompactionModelOverride { - reserveTokens?: number; - keepRecentTokens?: number; -} +export type { + BranchSummarySettings, + CacheWarmingMode, + CodemodeMode, + CodemodeSettings, + CompactionModelOverride, + CompactionSettings, + DefaultProjectTrust, + FullscreenExitOutput, + ImageSettings, + MarkdownSettings, + MermaidRenderingMode, + PackageSource, + ProviderRetrySettings, + QuietStartup, + RetrySettings, + Settings, + TerminalSettings, + ThinkingBudgetsSettings, + TransportSetting, + TuiMode, + WarningSettings, +} from "./settings-schema.ts"; +/** Cache-warming profile. "idle" also warms between agent runs. */ +export const CACHE_WARMING_MODES = ["off", "streaming", "idle"] as const satisfies readonly CacheWarmingMode[]; const DEFAULT_COMPACTION_TOKEN_SETTINGS: Required = { - reserveTokens: 16384, - keepRecentTokens: 20000, + reserveTokens: SETTINGS_DEFAULTS.compaction.reserveTokens, + keepRecentTokens: SETTINGS_DEFAULTS.compaction.keepRecentTokens, }; -export interface CompactionSettings { - enabled?: boolean; // default: true - reserveTokens?: number; // default: 16384 - keepRecentTokens?: number; // default: 20000 - modelOverrides?: Record; // exact "provider/modelId" keys -} - -export interface BranchSummarySettings { - reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response) - skipPrompt?: boolean; // default: false - when true, skips "Summarize branch?" prompt and defaults to no summary -} - -export interface ProviderRetrySettings { - timeoutMs?: number; // SDK/provider request timeout in milliseconds - maxRetries?: number; // SDK/provider retry attempts - maxRetryDelayMs?: number; // default: 60000 (max server-requested delay before failing) -} - -export interface RetrySettings { - enabled?: boolean; // default: true - maxRetries?: number; // default: 3 - baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s) - maxAgentDelayMs?: number; // default: 60000 - provider?: ProviderRetrySettings; -} - -export type TuiMode = RendererTuiMode; -export type FullscreenExitOutput = "transcript" | "resume-hint"; - -export interface TerminalSettings { - showImages?: boolean; // default: true (only relevant if terminal supports images) - imageWidthCells?: number; // default: 60 (preferred inline image width in terminal cells) - clearOnShrink?: boolean; // default: false (clear empty rows when content shrinks) - showTerminalProgress?: boolean; // default: false (OSC 9;4 terminal progress indicators) - hyperlinks?: boolean | "auto"; - images?: "kitty" | "iterm2" | "auto" | false; - trueColor?: boolean | "auto"; -} - -export interface ImageSettings { - autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility) - blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers -} - -export interface ThinkingBudgetsSettings { - minimal?: number; - low?: number; - medium?: number; - high?: number; -} - -export type MermaidRenderingMode = "off" | "final" | "streaming"; - -/** Cache-warming profile. "idle" also warms between agent runs. */ -export const CACHE_WARMING_MODES = ["off", "streaming", "idle"] as const; -export type CacheWarmingMode = (typeof CACHE_WARMING_MODES)[number]; - -export interface MarkdownSettings { - codeBlockIndent?: string; // default: " " - mermaid?: MermaidRenderingMode; // default: "streaming" -} - -export interface WarningSettings { - anthropicExtraUsage?: boolean; // default: true -} - -/** - * How the codemode tool presents tools while it is active. - * - `on`: declared tools that scripts can call get a note on calling them from scripts appended to - * their description; the codemode description lists only the tools without `direct` exposure. - * - `only`: the codemode description lists every tool scripts can call, and active `direct` tools are - * not declared to the model. - */ -export type CodemodeMode = "on" | "only"; - -export interface CodemodeSettings { - /** Default: `on`. */ - mode?: CodemodeMode; - /** Estimated tokens (characters / 4) the codemode description may spend on tool declarations. Default: 3000. */ - inlineBudget?: number; -} - -export type DefaultProjectTrust = "ask" | "always" | "never"; -/** true hides all startup output, "header" keeps only the startup header. */ -export type QuietStartup = boolean | "header"; - -export type TransportSetting = Transport; - -/** - * Package source for npm/git packages. - * - String form: load all resources from the package - * - Object form: filter which resources to load - * - autoload=false: start empty and only apply explicit resource patterns - */ -export type PackageSource = - | string - | { - source: string; - autoload?: boolean; - extensions?: string[]; - skills?: string[]; - prompts?: string[]; - themes?: string[]; - }; - -export interface Settings { - lastChangelogVersion?: string; - defaultProvider?: string; - defaultModel?: string; - defaultThinkingLevel?: ThinkingLevel; - modelThinkingLevels?: Record; // per-model default thinking level overrides keyed by "provider/modelId" - transport?: TransportSetting; // default: "auto" - steeringMode?: "all" | "one-at-a-time"; - followUpMode?: "all" | "one-at-a-time"; - theme?: string; - compaction?: CompactionSettings; - branchSummary?: BranchSummarySettings; - retry?: RetrySettings; - hideThinkingBlock?: boolean; - showCacheMissNotices?: boolean; // default: false - show cache cost and provider recovery notices - externalEditor?: string; // Command for Ctrl+G external editor; takes precedence over VISUAL/EDITOR - shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows); supports leading ~ expansion - quietStartup?: QuietStartup; // default: false - defaultProjectTrust?: DefaultProjectTrust; // default: "ask"; global setting only - shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) - npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"]) - collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) - enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates - enableAnalytics?: boolean; // default: false - opt-in analytics data sharing - trackingId?: string; // analytics tracking identifier, generated when analytics is enabled - deviceId?: string; // stable UUID of this installation, created when a login first needs it; global setting only - packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering) - extensions?: string[]; // Array of local extension file paths or directories - skills?: string[]; // Array of local skill file paths or directories - prompts?: string[]; // Array of local prompt template paths or directories - themes?: string[]; // Array of local theme file paths or directories - enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands - terminal?: TerminalSettings; - images?: ImageSettings; - enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) - defaultTools?: string[]; // Initial tool selection; `+name`/`-name` entries add to or remove from the inherited selection - doubleEscapeAction?: "fork" | "tree" | "none"; // Action for double-escape with empty editor (default: "tree") - treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; // Default filter when opening /tree - thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels - editorPaddingX?: number; // Horizontal padding for input editor (default: 0) - outputPad?: 0 | 1; // Horizontal padding for transcript content (default: 1) - autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5) - showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME - markdown?: MarkdownSettings; - warnings?: WarningSettings; - codemode?: CodemodeSettings; - sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag) - httpProxy?: string; // Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients - httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it - cacheWarming?: CacheWarmingMode; // default: "streaming"; global only because each refresh costs money - websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it - tuiMode?: TuiMode; // default: "fullscreen" - fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode - fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode - fullscreenCopyOnSelect?: boolean; // default: true; no effect in regular TUI mode - fullscreenWheelScrollLines?: WheelScrollLines; // default: "auto"; lines per wheel event, 1-100 -} - function isMergeableObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -851,7 +719,7 @@ export class SettingsManager { } getSteeringMode(): "all" | "one-at-a-time" { - return this.settings.steeringMode || "one-at-a-time"; + return this.settings.steeringMode || SETTINGS_DEFAULTS.steeringMode; } setSteeringMode(mode: "all" | "one-at-a-time"): void { @@ -861,7 +729,7 @@ export class SettingsManager { } getFollowUpMode(): "all" | "one-at-a-time" { - return this.settings.followUpMode || "one-at-a-time"; + return this.settings.followUpMode || SETTINGS_DEFAULTS.followUpMode; } setFollowUpMode(mode: "all" | "one-at-a-time"): void { @@ -925,7 +793,7 @@ export class SettingsManager { } getTransport(): TransportSetting { - return this.settings.transport ?? "auto"; + return this.settings.transport ?? SETTINGS_DEFAULTS.transport; } setTransport(transport: TransportSetting): void { @@ -935,7 +803,7 @@ export class SettingsManager { } getCompactionEnabled(): boolean { - return this.settings.compaction?.enabled ?? true; + return this.settings.compaction?.enabled ?? SETTINGS_DEFAULTS.compaction.enabled; } setCompactionEnabled(enabled: boolean): void { @@ -998,17 +866,17 @@ export class SettingsManager { getBranchSummarySettings(): { reserveTokens: number; skipPrompt: boolean } { return { - reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384, - skipPrompt: this.settings.branchSummary?.skipPrompt ?? false, + reserveTokens: this.settings.branchSummary?.reserveTokens ?? SETTINGS_DEFAULTS.branchSummary.reserveTokens, + skipPrompt: this.settings.branchSummary?.skipPrompt ?? SETTINGS_DEFAULTS.branchSummary.skipPrompt, }; } getBranchSummarySkipPrompt(): boolean { - return this.settings.branchSummary?.skipPrompt ?? false; + return this.settings.branchSummary?.skipPrompt ?? SETTINGS_DEFAULTS.branchSummary.skipPrompt; } getRetryEnabled(): boolean { - return this.settings.retry?.enabled ?? true; + return this.settings.retry?.enabled ?? SETTINGS_DEFAULTS.retry.enabled; } setRetryEnabled(enabled: boolean): void { @@ -1023,9 +891,9 @@ export class SettingsManager { getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number; maxAgentDelayMs: number } { return { enabled: this.getRetryEnabled(), - maxRetries: this.settings.retry?.maxRetries ?? 3, - baseDelayMs: this.settings.retry?.baseDelayMs ?? 2000, - maxAgentDelayMs: this.settings.retry?.maxAgentDelayMs ?? DEFAULT_MAX_AGENT_RETRY_DELAY_MS, + maxRetries: this.settings.retry?.maxRetries ?? SETTINGS_DEFAULTS.retry.maxRetries, + baseDelayMs: this.settings.retry?.baseDelayMs ?? SETTINGS_DEFAULTS.retry.baseDelayMs, + maxAgentDelayMs: this.settings.retry?.maxAgentDelayMs ?? SETTINGS_DEFAULTS.retry.maxAgentDelayMs, }; } @@ -1045,7 +913,7 @@ export class SettingsManager { /** Read from global settings only because warming costs money. */ getCacheWarmingMode(): CacheWarmingMode { const mode = this.globalSettings.cacheWarming; - return mode !== undefined && CACHE_WARMING_MODES.includes(mode) ? mode : "streaming"; + return mode !== undefined && CACHE_WARMING_MODES.includes(mode) ? mode : SETTINGS_DEFAULTS.cacheWarming; } setCacheWarmingMode(mode: CacheWarmingMode): void { @@ -1058,7 +926,8 @@ export class SettingsManager { return { timeoutMs: this.settings.retry?.provider?.timeoutMs, maxRetries: this.settings.retry?.provider?.maxRetries, - maxRetryDelayMs: this.settings.retry?.provider?.maxRetryDelayMs ?? 60000, + maxRetryDelayMs: + this.settings.retry?.provider?.maxRetryDelayMs ?? SETTINGS_DEFAULTS.retry.provider.maxRetryDelayMs, }; } @@ -1067,11 +936,11 @@ export class SettingsManager { } getHideThinkingBlock(): boolean { - return this.settings.hideThinkingBlock ?? false; + return this.settings.hideThinkingBlock ?? SETTINGS_DEFAULTS.hideThinkingBlock; } getShowCacheMissNotices(): boolean { - return this.settings.showCacheMissNotices ?? false; + return this.settings.showCacheMissNotices ?? SETTINGS_DEFAULTS.showCacheMissNotices; } getExternalEditorCommand(): string { @@ -1111,7 +980,7 @@ export class SettingsManager { getQuietStartup(): QuietStartup { const value = this.settings.quietStartup; - return value === true || value === "header" ? value : false; + return value === true || value === "header" ? value : SETTINGS_DEFAULTS.quietStartup; } setQuietStartup(quiet: QuietStartup): void { @@ -1122,7 +991,7 @@ export class SettingsManager { getDefaultProjectTrust(): DefaultProjectTrust { const value = this.globalSettings.defaultProjectTrust; - return value === "always" || value === "never" ? value : "ask"; + return value === "always" || value === "never" ? value : SETTINGS_DEFAULTS.defaultProjectTrust; } setDefaultProjectTrust(defaultProjectTrust: DefaultProjectTrust): void { @@ -1152,7 +1021,7 @@ export class SettingsManager { } getCollapseChangelog(): boolean { - return this.settings.collapseChangelog ?? false; + return this.settings.collapseChangelog ?? SETTINGS_DEFAULTS.collapseChangelog; } setCollapseChangelog(collapse: boolean): void { @@ -1162,7 +1031,7 @@ export class SettingsManager { } getEnableInstallTelemetry(): boolean { - return this.settings.enableInstallTelemetry ?? true; + return this.settings.enableInstallTelemetry ?? SETTINGS_DEFAULTS.enableInstallTelemetry; } setEnableInstallTelemetry(enabled: boolean): void { @@ -1172,7 +1041,7 @@ export class SettingsManager { } getEnableAnalytics(): boolean { - return this.settings.enableAnalytics ?? false; + return this.settings.enableAnalytics ?? SETTINGS_DEFAULTS.enableAnalytics; } getTrackingId(): string | undefined { @@ -1285,7 +1154,7 @@ export class SettingsManager { } getEnableSkillCommands(): boolean { - return this.settings.enableSkillCommands ?? true; + return this.settings.enableSkillCommands ?? SETTINGS_DEFAULTS.enableSkillCommands; } setEnableSkillCommands(enabled: boolean): void { @@ -1309,7 +1178,7 @@ export class SettingsManager { } getShowImages(): boolean { - return this.settings.terminal?.showImages ?? true; + return this.settings.terminal?.showImages ?? SETTINGS_DEFAULTS.terminal.showImages; } setShowImages(show: boolean): void { @@ -1324,7 +1193,7 @@ export class SettingsManager { getImageWidthCells(): number { const width = this.settings.terminal?.imageWidthCells; if (typeof width !== "number" || !Number.isFinite(width)) { - return 60; + return SETTINGS_DEFAULTS.terminal.imageWidthCells; } return Math.max(1, Math.floor(width)); } @@ -1343,7 +1212,8 @@ export class SettingsManager { if (this.settings.terminal?.clearOnShrink !== undefined) { return this.settings.terminal.clearOnShrink; } - return process.env.PI_CLEAR_ON_SHRINK === "1"; + if (process.env.PI_CLEAR_ON_SHRINK === "1") return true; + return SETTINGS_DEFAULTS.terminal.clearOnShrink; } setClearOnShrink(enabled: boolean): void { @@ -1356,7 +1226,7 @@ export class SettingsManager { } getShowTerminalProgress(): boolean { - return this.settings.terminal?.showTerminalProgress ?? false; + return this.settings.terminal?.showTerminalProgress ?? SETTINGS_DEFAULTS.terminal.showTerminalProgress; } setShowTerminalProgress(enabled: boolean): void { @@ -1369,7 +1239,7 @@ export class SettingsManager { } getTuiMode(): TuiMode { - return this.settings.tuiMode === "regular" ? "regular" : "fullscreen"; + return this.settings.tuiMode === "regular" ? "regular" : SETTINGS_DEFAULTS.tuiMode; } setTuiMode(mode: TuiMode): void { @@ -1379,7 +1249,9 @@ export class SettingsManager { } getFullscreenExitOutput(): FullscreenExitOutput { - return this.settings.fullscreenExitOutput === "resume-hint" ? "resume-hint" : "transcript"; + return this.settings.fullscreenExitOutput === "resume-hint" + ? "resume-hint" + : SETTINGS_DEFAULTS.fullscreenExitOutput; } setFullscreenExitOutput(output: FullscreenExitOutput): void { @@ -1390,7 +1262,7 @@ export class SettingsManager { getFullscreenScrollbar(): ScrollViewScrollbar { const mode = this.settings.fullscreenScrollbar; - return mode === "always" || mode === "hidden" ? mode : "auto"; + return mode === "always" || mode === "hidden" ? mode : SETTINGS_DEFAULTS.fullscreenScrollbar; } setFullscreenScrollbar(mode: ScrollViewScrollbar): void { @@ -1400,7 +1272,7 @@ export class SettingsManager { } getFullscreenCopyOnSelect(): boolean { - return this.settings.fullscreenCopyOnSelect ?? true; + return this.settings.fullscreenCopyOnSelect ?? SETTINGS_DEFAULTS.fullscreenCopyOnSelect; } setFullscreenCopyOnSelect(enabled: boolean): void { @@ -1413,7 +1285,7 @@ export class SettingsManager { const lines = this.settings.fullscreenWheelScrollLines; return typeof lines === "number" && Number.isFinite(lines) ? Math.max(1, Math.min(100, Math.floor(lines))) - : "auto"; + : SETTINGS_DEFAULTS.fullscreenWheelScrollLines; } setFullscreenWheelScrollLines(lines: WheelScrollLines): void { @@ -1424,7 +1296,7 @@ export class SettingsManager { } getImageAutoResize(): boolean { - return this.settings.images?.autoResize ?? true; + return this.settings.images?.autoResize ?? SETTINGS_DEFAULTS.images.autoResize; } setImageAutoResize(enabled: boolean): void { @@ -1437,7 +1309,7 @@ export class SettingsManager { } getBlockImages(): boolean { - return this.settings.images?.blockImages ?? false; + return this.settings.images?.blockImages ?? SETTINGS_DEFAULTS.images.blockImages; } setBlockImages(blocked: boolean): void { @@ -1467,7 +1339,7 @@ export class SettingsManager { } getDoubleEscapeAction(): "fork" | "tree" | "none" { - return this.settings.doubleEscapeAction ?? "tree"; + return this.settings.doubleEscapeAction ?? SETTINGS_DEFAULTS.doubleEscapeAction; } setDoubleEscapeAction(action: "fork" | "tree" | "none"): void { @@ -1479,7 +1351,7 @@ export class SettingsManager { getTreeFilterMode(): "default" | "no-tools" | "user-only" | "labeled-only" | "all" { const mode = this.settings.treeFilterMode; const valid = ["default", "no-tools", "user-only", "labeled-only", "all"]; - return mode && valid.includes(mode) ? mode : "default"; + return mode && valid.includes(mode) ? mode : SETTINGS_DEFAULTS.treeFilterMode; } setTreeFilterMode(mode: "default" | "no-tools" | "user-only" | "labeled-only" | "all"): void { @@ -1499,7 +1371,7 @@ export class SettingsManager { } getEditorPaddingX(): number { - return this.settings.editorPaddingX ?? 0; + return this.settings.editorPaddingX ?? SETTINGS_DEFAULTS.editorPaddingX; } setEditorPaddingX(padding: number): void { @@ -1509,7 +1381,7 @@ export class SettingsManager { } getOutputPad(): 0 | 1 { - return this.settings.outputPad === 0 ? 0 : 1; + return this.settings.outputPad === 0 ? 0 : SETTINGS_DEFAULTS.outputPad; } setOutputPad(padding: 0 | 1): void { @@ -1519,7 +1391,7 @@ export class SettingsManager { } getAutocompleteMaxVisible(): number { - return this.settings.autocompleteMaxVisible ?? 5; + return this.settings.autocompleteMaxVisible ?? SETTINGS_DEFAULTS.autocompleteMaxVisible; } setAutocompleteMaxVisible(maxVisible: number): void { @@ -1529,12 +1401,12 @@ export class SettingsManager { } getCodeBlockIndent(): string { - return this.settings.markdown?.codeBlockIndent ?? " "; + return this.settings.markdown?.codeBlockIndent ?? SETTINGS_DEFAULTS.markdown.codeBlockIndent; } getMermaidRenderingMode(): MermaidRenderingMode { const mode = this.settings.markdown?.mermaid; - return mode === "off" || mode === "final" ? mode : "streaming"; + return mode === "off" || mode === "final" ? mode : SETTINGS_DEFAULTS.markdown.mermaid; } setMermaidRenderingMode(mode: MermaidRenderingMode): void { diff --git a/packages/coding-agent/src/core/settings-schema.ts b/packages/coding-agent/src/core/settings-schema.ts new file mode 100644 index 000000000..7c800035d --- /dev/null +++ b/packages/coding-agent/src/core/settings-schema.ts @@ -0,0 +1,474 @@ +import { ModelThinkingLevelSchema } from "@earendil-works/pi-ai/providers/model-schema"; +import { type Static, type TSchemaOptions, Type } from "typebox"; +import { SETTINGS_DEFAULTS } from "./settings-defaults.ts"; + +function nonNegativeSafeInteger(options: { default?: number } = {}) { + return Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER, ...options }); +} + +function timeoutSetting(options?: TSchemaOptions) { + return Type.Union([Type.Number({ minimum: 0 }), Type.Literal("disabled")], options); +} + +const CompactionModelOverrideSchema = Type.Object({ + reserveTokens: Type.Optional(nonNegativeSafeInteger()), + keepRecentTokens: Type.Optional(nonNegativeSafeInteger()), +}); + +const CompactionSettingsSchema = Type.Object({ + enabled: Type.Optional(Type.Boolean({ default: SETTINGS_DEFAULTS.compaction.enabled })), + reserveTokens: Type.Optional(nonNegativeSafeInteger({ default: SETTINGS_DEFAULTS.compaction.reserveTokens })), + keepRecentTokens: Type.Optional(nonNegativeSafeInteger({ default: SETTINGS_DEFAULTS.compaction.keepRecentTokens })), + modelOverrides: Type.Optional( + Type.Record(Type.String(), CompactionModelOverrideSchema, { + description: 'Per-model overrides keyed by exact "provider/modelId" strings.', + }), + ), +}); + +const BranchSummarySettingsSchema = Type.Object({ + reserveTokens: Type.Optional( + Type.Number({ + description: "Tokens reserved for the prompt and LLM response.", + default: SETTINGS_DEFAULTS.branchSummary.reserveTokens, + }), + ), + skipPrompt: Type.Optional( + Type.Boolean({ + description: 'When true, skips the "Summarize branch?" prompt and defaults to no summary.', + default: SETTINGS_DEFAULTS.branchSummary.skipPrompt, + }), + ), +}); + +const ProviderRetrySettingsSchema = Type.Object({ + timeoutMs: Type.Optional(Type.Number({ description: "SDK or provider request timeout in milliseconds." })), + maxRetries: Type.Optional(Type.Number({ description: "SDK or provider retry attempts." })), + maxRetryDelayMs: Type.Optional( + Type.Number({ + description: "Maximum server-requested delay before failing.", + default: SETTINGS_DEFAULTS.retry.provider.maxRetryDelayMs, + }), + ), +}); + +const RetrySettingsSchema = Type.Object({ + enabled: Type.Optional(Type.Boolean({ default: SETTINGS_DEFAULTS.retry.enabled })), + maxRetries: Type.Optional(Type.Number({ default: SETTINGS_DEFAULTS.retry.maxRetries })), + baseDelayMs: Type.Optional( + Type.Number({ + description: "Exponential backoff base delay in milliseconds: 2s, 4s, 8s.", + default: SETTINGS_DEFAULTS.retry.baseDelayMs, + }), + ), + maxAgentDelayMs: Type.Optional(Type.Number({ default: SETTINGS_DEFAULTS.retry.maxAgentDelayMs })), + provider: Type.Optional(ProviderRetrySettingsSchema), + maxDelayMs: Type.Optional( + Type.Number({ + description: "Legacy retry delay setting. Use provider.maxRetryDelayMs instead.", + deprecated: true, + }), + ), +}); + +const TerminalSettingsSchema = Type.Object({ + showImages: Type.Optional( + Type.Boolean({ + description: "Show images when the terminal supports them.", + default: SETTINGS_DEFAULTS.terminal.showImages, + }), + ), + imageWidthCells: Type.Optional( + Type.Number({ + description: "Preferred inline image width in terminal cells.", + default: SETTINGS_DEFAULTS.terminal.imageWidthCells, + }), + ), + clearOnShrink: Type.Optional( + Type.Boolean({ + description: "Clear empty rows when content shrinks.", + default: SETTINGS_DEFAULTS.terminal.clearOnShrink, + }), + ), + showTerminalProgress: Type.Optional( + Type.Boolean({ + description: "Show OSC 9;4 terminal progress indicators.", + default: SETTINGS_DEFAULTS.terminal.showTerminalProgress, + }), + ), + hyperlinks: Type.Optional(Type.Union([Type.Boolean(), Type.Literal("auto")])), + images: Type.Optional( + Type.Union([Type.Literal("kitty"), Type.Literal("iterm2"), Type.Literal("auto"), Type.Literal(false)]), + ), + trueColor: Type.Optional(Type.Union([Type.Boolean(), Type.Literal("auto")])), +}); + +const ImageSettingsSchema = Type.Object({ + autoResize: Type.Optional( + Type.Boolean({ + description: "Resize images to 2000x2000 maximum for better model compatibility.", + default: SETTINGS_DEFAULTS.images.autoResize, + }), + ), + blockImages: Type.Optional( + Type.Boolean({ + description: "When true, prevents all images from being sent to LLM providers.", + default: SETTINGS_DEFAULTS.images.blockImages, + }), + ), +}); + +function thinkingBudgetsSettings(options?: TSchemaOptions) { + return Type.Object( + { + minimal: Type.Optional(Type.Number()), + low: Type.Optional(Type.Number()), + medium: Type.Optional(Type.Number()), + high: Type.Optional(Type.Number()), + }, + options, + ); +} + +const ThinkingBudgetsSettingsSchema = thinkingBudgetsSettings(); + +const MarkdownSettingsSchema = Type.Object({ + codeBlockIndent: Type.Optional(Type.String({ default: SETTINGS_DEFAULTS.markdown.codeBlockIndent })), + mermaid: Type.Optional( + Type.Union([Type.Literal("off"), Type.Literal("final"), Type.Literal("streaming")], { + default: SETTINGS_DEFAULTS.markdown.mermaid, + }), + ), +}); + +const WarningSettingsSchema = Type.Object({ + anthropicExtraUsage: Type.Optional(Type.Boolean({ default: SETTINGS_DEFAULTS.warnings.anthropicExtraUsage })), +}); + +function codemodeMode(options?: TSchemaOptions) { + return Type.Union([Type.Literal("on"), Type.Literal("only")], options); +} + +const CodemodeModeSchema = codemodeMode(); + +const CodemodeSettingsSchema = Type.Object({ + mode: Type.Optional( + codemodeMode({ + description: + 'How codemode presents tools. "on" keeps direct tools declared and annotates tools callable from scripts; codemode lists only tools without direct exposure. "only" lists every script-callable tool in codemode and does not declare active direct tools.', + default: SETTINGS_DEFAULTS.codemode.mode, + }), + ), + inlineBudget: Type.Optional( + Type.Number({ + minimum: 0, + description: "Estimated tokens available for inline codemode tool declarations.", + default: SETTINGS_DEFAULTS.codemode.inlineBudget, + }), + ), +}); + +const PackageSourceSchema = Type.Union( + [ + Type.String({ description: "Load all resources from the package." }), + Type.Object({ + source: Type.String(), + autoload: Type.Optional( + Type.Boolean({ description: "When false, start empty and only apply explicit resource patterns." }), + ), + extensions: Type.Optional(Type.Array(Type.String())), + skills: Type.Optional(Type.Array(Type.String())), + prompts: Type.Optional(Type.Array(Type.String())), + themes: Type.Optional(Type.Array(Type.String())), + }), + ], + { + description: + "Package source for npm or git packages. Use the string form to load all resources or the object form to filter resources.", + }, +); + +const StringArraySchema = Type.Array(Type.String()); +const SkillsInputSchema = Type.Union( + [ + StringArraySchema, + Type.Object( + { + enableSkillCommands: Type.Optional(Type.Boolean()), + customDirectories: Type.Optional(StringArraySchema), + }, + { deprecated: true }, + ), + ], + { description: "Local skill file paths or directories." }, +); + +export const SettingsSchema = Type.Object( + { + $schema: Type.Optional(Type.String({ description: "JSON Schema reference." })), + lastChangelogVersion: Type.Optional(Type.String()), + defaultProvider: Type.Optional(Type.String()), + defaultModel: Type.Optional(Type.String()), + defaultThinkingLevel: Type.Optional(ModelThinkingLevelSchema), + modelThinkingLevels: Type.Optional( + Type.Record(Type.String(), ModelThinkingLevelSchema, { + description: 'Per-model default thinking level overrides keyed by "provider/modelId".', + }), + ), + transport: Type.Optional( + Type.Union( + [Type.Literal("auto"), Type.Literal("sse"), Type.Literal("websocket"), Type.Literal("websocket-cached")], + { default: SETTINGS_DEFAULTS.transport }, + ), + ), + steeringMode: Type.Optional( + Type.Union([Type.Literal("all"), Type.Literal("one-at-a-time")], { + default: SETTINGS_DEFAULTS.steeringMode, + }), + ), + followUpMode: Type.Optional( + Type.Union([Type.Literal("all"), Type.Literal("one-at-a-time")], { + default: SETTINGS_DEFAULTS.followUpMode, + }), + ), + theme: Type.Optional(Type.String()), + compaction: Type.Optional(CompactionSettingsSchema), + branchSummary: Type.Optional(BranchSummarySettingsSchema), + retry: Type.Optional(RetrySettingsSchema), + hideThinkingBlock: Type.Optional(Type.Boolean({ default: SETTINGS_DEFAULTS.hideThinkingBlock })), + showCacheMissNotices: Type.Optional( + Type.Boolean({ + description: "Show cache cost and provider recovery notices.", + default: SETTINGS_DEFAULTS.showCacheMissNotices, + }), + ), + externalEditor: Type.Optional( + Type.String({ description: "Command for Ctrl+G external editor; takes precedence over VISUAL and EDITOR." }), + ), + shellPath: Type.Optional( + Type.String({ + description: "Custom shell path, for example for Cygwin on Windows, with support for leading ~ expansion.", + }), + ), + quietStartup: Type.Optional( + Type.Union([Type.Boolean(), Type.Literal("header")], { + description: 'When true, hide all startup output. When "header", keep only the startup header.', + default: SETTINGS_DEFAULTS.quietStartup, + }), + ), + defaultProjectTrust: Type.Optional( + Type.Union([Type.Literal("ask"), Type.Literal("always"), Type.Literal("never")], { + description: "Global setting only.", + default: SETTINGS_DEFAULTS.defaultProjectTrust, + }), + ), + shellCommandPrefix: Type.Optional( + Type.String({ description: "Prefix prepended to every bash command, for example to enable shell aliases." }), + ), + npmCommand: Type.Optional( + Type.Array(Type.String(), { + description: + 'Command used for npm package lookup and installation, in argv form such as ["mise", "exec", "node@20", "--", "npm"].', + }), + ), + collapseChangelog: Type.Optional( + Type.Boolean({ + description: "Show the condensed changelog after update; use /changelog for the full changelog.", + default: SETTINGS_DEFAULTS.collapseChangelog, + }), + ), + enableInstallTelemetry: Type.Optional( + Type.Boolean({ + description: "Send an anonymous version and update ping after changelog-detected updates.", + default: SETTINGS_DEFAULTS.enableInstallTelemetry, + }), + ), + enableAnalytics: Type.Optional( + Type.Boolean({ + description: "Opt in to analytics data sharing.", + default: SETTINGS_DEFAULTS.enableAnalytics, + }), + ), + trackingId: Type.Optional( + Type.String({ description: "Analytics tracking identifier, generated when analytics is enabled." }), + ), + packages: Type.Optional( + Type.Array(PackageSourceSchema, { + description: "npm or git package sources, as strings or objects with resource filtering.", + }), + ), + extensions: Type.Optional( + Type.Array(Type.String(), { description: "Local extension file paths or directories." }), + ), + deviceId: Type.Optional( + Type.String({ + description: "Stable installation UUID, generated when authentication first needs it. Global only.", + }), + ), + skills: Type.Optional(SkillsInputSchema), + prompts: Type.Optional( + Type.Array(Type.String(), { description: "Local prompt template file paths or directories." }), + ), + themes: Type.Optional(Type.Array(Type.String(), { description: "Local theme file paths or directories." })), + enableSkillCommands: Type.Optional( + Type.Boolean({ + description: "Register skills as /skill:name commands.", + default: SETTINGS_DEFAULTS.enableSkillCommands, + }), + ), + terminal: Type.Optional(TerminalSettingsSchema), + images: Type.Optional(ImageSettingsSchema), + enabledModels: Type.Optional( + Type.Array(Type.String(), { + description: "Model patterns for cycling, in the same format as the --models CLI flag.", + }), + ), + defaultTools: Type.Optional( + Type.Array(Type.String(), { + description: + "Initial tool selection. Plain names replace the inherited selection; +name and -name entries add or remove tools.", + }), + ), + doubleEscapeAction: Type.Optional( + Type.Union([Type.Literal("fork"), Type.Literal("tree"), Type.Literal("none")], { + description: "Action for double-escape with an empty editor.", + default: SETTINGS_DEFAULTS.doubleEscapeAction, + }), + ), + treeFilterMode: Type.Optional( + Type.Union( + [ + Type.Literal("default"), + Type.Literal("no-tools"), + Type.Literal("user-only"), + Type.Literal("labeled-only"), + Type.Literal("all"), + ], + { + description: "Default filter when opening /tree.", + default: SETTINGS_DEFAULTS.treeFilterMode, + }, + ), + ), + thinkingBudgets: Type.Optional( + thinkingBudgetsSettings({ + description: "Custom token budgets for thinking levels.", + }), + ), + editorPaddingX: Type.Optional( + Type.Number({ + description: "Horizontal padding for the input editor.", + default: SETTINGS_DEFAULTS.editorPaddingX, + }), + ), + outputPad: Type.Optional( + Type.Union([Type.Literal(0), Type.Literal(1)], { + description: "Horizontal padding for transcript content.", + default: SETTINGS_DEFAULTS.outputPad, + }), + ), + autocompleteMaxVisible: Type.Optional( + Type.Number({ + description: "Maximum visible items in the autocomplete dropdown.", + default: SETTINGS_DEFAULTS.autocompleteMaxVisible, + }), + ), + showHardwareCursor: Type.Optional( + Type.Boolean({ description: "Show the terminal cursor while still positioning it for IME." }), + ), + markdown: Type.Optional(MarkdownSettingsSchema), + warnings: Type.Optional(WarningSettingsSchema), + codemode: Type.Optional(CodemodeSettingsSchema), + sessionDir: Type.Optional( + Type.String({ + description: "Custom session storage directory, in the same format as the --session-dir CLI flag.", + }), + ), + httpProxy: Type.Optional( + Type.String({ description: "Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients." }), + ), + httpIdleTimeoutMs: Type.Optional( + timeoutSetting({ + description: 'HTTP header or body idle timeout in milliseconds; 0 or "disabled" disables it.', + }), + ), + cacheWarming: Type.Optional( + Type.Union([Type.Literal("off"), Type.Literal("streaming"), Type.Literal("idle")], { + description: + 'Cache-warming profile. "idle" also warms between agent runs. Global only because each refresh costs money.', + default: SETTINGS_DEFAULTS.cacheWarming, + }), + ), + websocketConnectTimeoutMs: Type.Optional( + timeoutSetting({ + description: 'WebSocket connect or open handshake timeout in milliseconds; 0 or "disabled" disables it.', + }), + ), + tuiMode: Type.Optional( + Type.Union([Type.Literal("regular"), Type.Literal("fullscreen")], { + default: SETTINGS_DEFAULTS.tuiMode, + }), + ), + fullscreenExitOutput: Type.Optional( + Type.Union([Type.Literal("transcript"), Type.Literal("resume-hint")], { + description: "No effect in regular TUI mode.", + default: SETTINGS_DEFAULTS.fullscreenExitOutput, + }), + ), + fullscreenScrollbar: Type.Optional( + Type.Union([Type.Literal("auto"), Type.Literal("always"), Type.Literal("hidden")], { + description: "No effect in regular TUI mode.", + default: SETTINGS_DEFAULTS.fullscreenScrollbar, + }), + ), + fullscreenCopyOnSelect: Type.Optional( + Type.Boolean({ + description: "No effect in regular TUI mode.", + default: SETTINGS_DEFAULTS.fullscreenCopyOnSelect, + }), + ), + fullscreenWheelScrollLines: Type.Optional( + Type.Union([Type.Number(), Type.Literal("auto")], { + description: "Lines scrolled per wheel event in fullscreen mode; numeric values are clamped from 1 to 100.", + default: SETTINGS_DEFAULTS.fullscreenWheelScrollLines, + }), + ), + queueMode: Type.Optional( + Type.Union([Type.Literal("all"), Type.Literal("one-at-a-time")], { + description: "Legacy setting migrated to steeringMode.", + deprecated: true, + }), + ), + websockets: Type.Optional( + Type.Boolean({ description: "Legacy setting migrated to transport.", deprecated: true }), + ), + }, + { additionalProperties: true }, +); + +type SettingsInput = Static; + +export interface CompactionModelOverride extends Static {} +export interface CompactionSettings extends Static {} +export interface BranchSummarySettings extends Static {} +export interface ProviderRetrySettings extends Static {} +export interface RetrySettings extends Omit, "maxDelayMs"> {} +export interface TerminalSettings extends Static {} +export interface ImageSettings extends Static {} +export interface ThinkingBudgetsSettings extends Static {} +export type MermaidRenderingMode = NonNullable["mermaid"]>; +export interface MarkdownSettings extends Static {} +export interface WarningSettings extends Static {} +export type CodemodeMode = Static; +export interface CodemodeSettings extends Static {} +export type DefaultProjectTrust = NonNullable; +export type QuietStartup = NonNullable; +export type TransportSetting = NonNullable; +export type PackageSource = Static; +export type FullscreenExitOutput = NonNullable; +export type TuiMode = NonNullable; +export type CacheWarmingMode = NonNullable; +export interface Settings extends Omit { + retry?: RetrySettings; + skills?: string[]; +} diff --git a/packages/coding-agent/src/extensions/codemode/index.ts b/packages/coding-agent/src/extensions/codemode/index.ts index b88352001..67492b65b 100644 --- a/packages/coding-agent/src/extensions/codemode/index.ts +++ b/packages/coding-agent/src/extensions/codemode/index.ts @@ -7,6 +7,7 @@ */ import type { ExtensionAPI, ExtensionFactory } from "../../core/extensions/types.ts"; +import { SETTINGS_DEFAULTS } from "../../core/settings-defaults.ts"; import type { CodemodeMode } from "../../core/settings-manager.ts"; import { createCodemodeToolDefinition } from "./tool.ts"; @@ -20,7 +21,7 @@ export interface CodemodeExtensionOptions { } function readMode(pi: ExtensionAPI): CodemodeMode { - return pi.getSettings().codemode?.mode === "only" ? "only" : "on"; + return pi.getSettings().codemode?.mode === "only" ? "only" : SETTINGS_DEFAULTS.codemode.mode; } function readInlineBudget(pi: ExtensionAPI): number | undefined { diff --git a/packages/coding-agent/src/extensions/codemode/tool.ts b/packages/coding-agent/src/extensions/codemode/tool.ts index ae8285a3e..f27811cd8 100644 --- a/packages/coding-agent/src/extensions/codemode/tool.ts +++ b/packages/coding-agent/src/extensions/codemode/tool.ts @@ -42,6 +42,7 @@ import type { ToolNamespace, } from "../../core/extensions/types.ts"; import type { ModelRegistry } from "../../core/model-registry.ts"; +import { SETTINGS_DEFAULTS } from "../../core/settings-defaults.ts"; import type { CodemodeMode } from "../../core/settings-manager.ts"; import { wrapToolDefinition } from "../../core/tools/tool-definition-wrapper.ts"; import { loadCodemodeExecutor } from "./execute.lazy.ts"; @@ -153,7 +154,7 @@ function describeGlobals(models: boolean): string { } /** Default for {@link CodemodeDescriptionOptions.inlineBudget}, in estimated tokens. */ -export const DEFAULT_CODEMODE_INLINE_BUDGET = 3000; +export const DEFAULT_CODEMODE_INLINE_BUDGET = SETTINGS_DEFAULTS.codemode.inlineBudget; /** Characters per token when estimating the cost of a tool section. */ const CHARS_PER_TOKEN = 4; @@ -340,7 +341,7 @@ function describeScriptCall(tool: AgentTool): string { * `tool_search` loads a tool, so loads do not redeclare codemode. */ function prepareCodemodeLoadout(loadout: ToolLoadout, options: CodemodeToolOptions): ToolLoadoutChanges { - const mode = options.getMode?.() ?? "on"; + const mode = options.getMode?.() ?? SETTINGS_DEFAULTS.codemode.mode; const isDirect = (tool: AgentTool) => loadout.getExposure(tool.name) === "direct"; const callable = getCodemodeCallableTools(loadout.callable); const callableNames = new Set(callable.map((tool) => tool.name)); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index dea9e1501..0710ed9e6 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -66,7 +66,7 @@ import { loadMcpCommand } from "./extensions/mcp/cli.lazy.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { initTheme, setThemeJsonValidator, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; -import { validateThemeJson } from "./modes/interactive/theme/theme-json.ts"; +import { validateThemeJson } from "./modes/interactive/theme/theme-schema.ts"; import { cleanupManagedInstall, handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; @@ -572,6 +572,8 @@ export interface MainOptions { export async function main(args: string[], options?: MainOptions) { resetTimings(); + // Commands and startup selectors can load user-authored themes before runtime creation. + setThemeJsonValidator(validateThemeJson); const extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])]; const offlineMode = args.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE); if (offlineMode) { @@ -900,8 +902,6 @@ export async function main(args: string[], options?: MainOptions) { const { initialMessage, initialImages } = await prepareInitialMessage(parsed, stdinContent); time("prepareInitialMessage"); - // pi reads user-authored themes, so it opts into full validation before any theme loads. - setThemeJsonValidator(validateThemeJson); initTheme(settingsManager.getTheme(), appMode === "interactive"); time("initTheme"); diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index ae28c57fb..cdeb80adb 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -13,6 +13,7 @@ import { type WheelScrollLines, } from "@earendil-works/pi-tui"; import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; +import { SETTINGS_DEFAULTS } from "../../../core/settings-defaults.ts"; import { CACHE_WARMING_MODES, type CacheWarmingMode, @@ -157,7 +158,8 @@ class WarningSettingsSubmenu extends Container { id: "anthropic-extra-usage", label: "Anthropic extra usage", description: "Warn when Anthropic subscription auth may use paid extra usage", - currentValue: (this.state.anthropicExtraUsage ?? true) ? "true" : "false", + currentValue: + (this.state.anthropicExtraUsage ?? SETTINGS_DEFAULTS.warnings.anthropicExtraUsage) ? "true" : "false", values: ["true", "false"], }, ]; diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 499c10e3c..e81c31768 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/schemas/theme.schema.json", "name": "dark", "appearance": "dark", "vars": { diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 9d29d503d..faf6e65c8 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/schemas/theme.schema.json", "name": "light", "appearance": "light", "vars": { diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-json.ts b/packages/coding-agent/src/modes/interactive/theme/theme-json.ts deleted file mode 100644 index a3581f90e..000000000 --- a/packages/coding-agent/src/modes/interactive/theme/theme-json.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Theme JSON validation, kept out of `theme.ts` on purpose. - * - * Validating user-authored theme files needs typebox, which costs ~17 MB of module graph to import. - * Palette lookup does not, so a presentation that only uses built-in themes should never pay for it. - * `interactive-mode.ts` installs this validator; anything that does not simply skips validation, as - * built-in themes already do. - */ - -import { type Static, Type } from "typebox"; -import { Compile } from "typebox/compile"; - -const ColorValueSchema = Type.Union([ - Type.String(), // hex, OKLCH, OKHSL, var ref "primary", or empty "" - Type.Integer({ minimum: 0, maximum: 255 }), // 256-color index -]); - -const ThemeJsonSchema = Type.Object({ - $schema: Type.Optional(Type.String()), - name: Type.String(), - // Background the theme is designed for. Detected from the theme colors when omitted. - appearance: Type.Optional(Type.Union([Type.Literal("dark"), Type.Literal("light")])), - vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)), - colors: Type.Object({ - // Core UI (11 colors) - accent: ColorValueSchema, - border: ColorValueSchema, - borderAccent: ColorValueSchema, - borderMuted: ColorValueSchema, - success: ColorValueSchema, - error: ColorValueSchema, - warning: ColorValueSchema, - muted: ColorValueSchema, - dim: ColorValueSchema, - text: ColorValueSchema, - thinkingText: ColorValueSchema, - // Scrollbar (2 optional colors) - scrollbarTrack: Type.Optional(ColorValueSchema), - scrollbarThumb: Type.Optional(ColorValueSchema), - // Backgrounds & Content Text (11 required, 2 optional) - selectedBg: ColorValueSchema, - searchMatchBg: Type.Optional(ColorValueSchema), - searchMatchText: Type.Optional(ColorValueSchema), - userMessageBg: ColorValueSchema, - userMessageText: ColorValueSchema, - customMessageBg: ColorValueSchema, - customMessageText: ColorValueSchema, - customMessageLabel: ColorValueSchema, - toolPendingBg: ColorValueSchema, - toolSuccessBg: ColorValueSchema, - toolErrorBg: ColorValueSchema, - toolTitle: ColorValueSchema, - toolOutput: ColorValueSchema, - // Markdown (10 colors) - mdHeading: ColorValueSchema, - mdLink: ColorValueSchema, - mdLinkUrl: ColorValueSchema, - mdCode: ColorValueSchema, - mdCodeBlock: ColorValueSchema, - mdCodeBlockBorder: ColorValueSchema, - mdQuote: ColorValueSchema, - mdQuoteBorder: ColorValueSchema, - mdHr: ColorValueSchema, - mdListBullet: ColorValueSchema, - // Tool Diffs (3 colors) - toolDiffAdded: ColorValueSchema, - toolDiffRemoved: ColorValueSchema, - toolDiffContext: ColorValueSchema, - // Syntax Highlighting (9 colors) - syntaxComment: ColorValueSchema, - syntaxKeyword: ColorValueSchema, - syntaxFunction: ColorValueSchema, - syntaxVariable: ColorValueSchema, - syntaxString: ColorValueSchema, - syntaxNumber: ColorValueSchema, - syntaxType: ColorValueSchema, - syntaxOperator: ColorValueSchema, - syntaxPunctuation: ColorValueSchema, - // Thinking Level Borders (6 colors) - thinkingOff: ColorValueSchema, - thinkingMinimal: ColorValueSchema, - thinkingLow: ColorValueSchema, - thinkingMedium: ColorValueSchema, - thinkingHigh: ColorValueSchema, - thinkingXhigh: ColorValueSchema, - thinkingMax: Type.Optional(ColorValueSchema), - // Bash Mode (1 color) - bashMode: ColorValueSchema, - }), - export: Type.Optional( - Type.Object({ - pageBg: Type.Optional(ColorValueSchema), - cardBg: Type.Optional(ColorValueSchema), - infoBg: Type.Optional(ColorValueSchema), - }), - ), -}); - -const compiledThemeSchema = Compile(ThemeJsonSchema); - -export type ThemeColorValue = Static; -export type ValidatedThemeJson = Static; - -/** Validate one theme document, throwing a message that names the offending tokens. */ -export function validateThemeJson(label: string, json: unknown): ValidatedThemeJson { - if (!compiledThemeSchema.Check(json)) { - const errors = Array.from(compiledThemeSchema.Errors(json)); - const missingColors = new Set(); - const otherErrors: string[] = []; - - for (const error of errors) { - if (error.keyword === "required" && error.instancePath === "/colors") { - const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties; - for (const requiredProperty of requiredProperties ?? []) { - missingColors.add(requiredProperty); - } - continue; - } - - const path = error.instancePath || "/"; - otherErrors.push(` - ${path}: ${error.message}`); - } - - let errorMessage = `Invalid theme "${label}":\n`; - if (missingColors.size > 0) { - errorMessage += "\nMissing required color tokens:\n"; - errorMessage += Array.from(missingColors) - .sort() - .map((color) => ` - ${color}`) - .join("\n"); - errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.'; - errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values."; - } - if (otherErrors.length > 0) { - errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`; - } - - throw new Error(errorMessage); - } - - const themeJson = json as ValidatedThemeJson; - if (themeJson.name.includes("/")) { - throw new Error( - `Invalid theme name "${themeJson.name}": theme names cannot contain "/" because it is reserved for automatic light/dark theme settings.`, - ); - } - return themeJson; -} diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json deleted file mode 100644 index 6fc29e942..000000000 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ /dev/null @@ -1,361 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Pi Coding Agent Theme", - "description": "Theme schema for Pi coding agent", - "type": "object", - "required": ["name", "colors"], - "properties": { - "$schema": { - "type": "string", - "description": "JSON schema reference" - }, - "name": { - "type": "string", - "pattern": "^[^/]+$", - "description": "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings." - }, - "appearance": { - "type": "string", - "enum": ["dark", "light"], - "description": "Background the theme is designed for. Detected from the theme colors when omitted." - }, - "vars": { - "type": "object", - "description": "Reusable color variables", - "additionalProperties": { - "oneOf": [ - { - "type": "string", - "description": "Hex color (#RGB or #RRGGBB), OKLCH or OKHSL color, variable reference, or empty string for terminal default" - }, - { - "type": "integer", - "minimum": 0, - "maximum": 255, - "description": "256-color palette index (0-255)" - } - ] - } - }, - "colors": { - "type": "object", - "description": "Theme color definitions (scrollbar, thinkingMax, and search highlight colors are optional and use compatible fallbacks)", - "required": [ - "accent", - "border", - "borderAccent", - "borderMuted", - "success", - "error", - "warning", - "muted", - "dim", - "text", - "thinkingText", - "selectedBg", - "userMessageBg", - "userMessageText", - "customMessageBg", - "customMessageText", - "customMessageLabel", - "toolPendingBg", - "toolSuccessBg", - "toolErrorBg", - "toolTitle", - "toolOutput", - "mdHeading", - "mdLink", - "mdLinkUrl", - "mdCode", - "mdCodeBlock", - "mdCodeBlockBorder", - "mdQuote", - "mdQuoteBorder", - "mdHr", - "mdListBullet", - "toolDiffAdded", - "toolDiffRemoved", - "toolDiffContext", - "syntaxComment", - "syntaxKeyword", - "syntaxFunction", - "syntaxVariable", - "syntaxString", - "syntaxNumber", - "syntaxType", - "syntaxOperator", - "syntaxPunctuation", - "thinkingOff", - "thinkingMinimal", - "thinkingLow", - "thinkingMedium", - "thinkingHigh", - "thinkingXhigh", - "bashMode" - ], - "properties": { - "accent": { - "$ref": "#/$defs/colorValue", - "description": "Primary accent color (logo, selected items, cursor)" - }, - "border": { - "$ref": "#/$defs/colorValue", - "description": "Normal borders" - }, - "borderAccent": { - "$ref": "#/$defs/colorValue", - "description": "Highlighted borders" - }, - "borderMuted": { - "$ref": "#/$defs/colorValue", - "description": "Subtle borders" - }, - "success": { - "$ref": "#/$defs/colorValue", - "description": "Success states" - }, - "error": { - "$ref": "#/$defs/colorValue", - "description": "Error states" - }, - "warning": { - "$ref": "#/$defs/colorValue", - "description": "Warning states" - }, - "muted": { - "$ref": "#/$defs/colorValue", - "description": "Secondary/dimmed text" - }, - "dim": { - "$ref": "#/$defs/colorValue", - "description": "Very dimmed text (more subtle than muted)" - }, - "text": { - "$ref": "#/$defs/colorValue", - "description": "Default text color (usually empty string)" - }, - "thinkingText": { - "$ref": "#/$defs/colorValue", - "description": "Thinking block text color" - }, - "selectedBg": { - "$ref": "#/$defs/colorValue", - "description": "Selected item background" - }, - "scrollbarTrack": { - "$ref": "#/$defs/colorValue", - "description": "Fullscreen scrollbar track foreground (falls back to muted when omitted)" - }, - "scrollbarThumb": { - "$ref": "#/$defs/colorValue", - "description": "Fullscreen scrollbar thumb foreground (falls back to text when omitted)" - }, - "searchMatchBg": { - "$ref": "#/$defs/colorValue", - "description": "Transcript search match background and current-match text (falls back to selectedBg when omitted)" - }, - "searchMatchText": { - "$ref": "#/$defs/colorValue", - "description": "Transcript search match text and current-match background (falls back to text when omitted)" - }, - "userMessageBg": { - "$ref": "#/$defs/colorValue", - "description": "User message background" - }, - "userMessageText": { - "$ref": "#/$defs/colorValue", - "description": "User message text color" - }, - "customMessageBg": { - "$ref": "#/$defs/colorValue", - "description": "Custom message background (hook-injected messages)" - }, - "customMessageText": { - "$ref": "#/$defs/colorValue", - "description": "Custom message text color" - }, - "customMessageLabel": { - "$ref": "#/$defs/colorValue", - "description": "Custom message type label color" - }, - "toolPendingBg": { - "$ref": "#/$defs/colorValue", - "description": "Tool execution box (pending state)" - }, - "toolSuccessBg": { - "$ref": "#/$defs/colorValue", - "description": "Tool execution box (success state)" - }, - "toolErrorBg": { - "$ref": "#/$defs/colorValue", - "description": "Tool execution box (error state)" - }, - "toolTitle": { - "$ref": "#/$defs/colorValue", - "description": "Tool execution box title color" - }, - "toolOutput": { - "$ref": "#/$defs/colorValue", - "description": "Tool execution box output text color" - }, - "mdHeading": { - "$ref": "#/$defs/colorValue", - "description": "Markdown heading text" - }, - "mdLink": { - "$ref": "#/$defs/colorValue", - "description": "Markdown link text" - }, - "mdLinkUrl": { - "$ref": "#/$defs/colorValue", - "description": "Markdown link URL" - }, - "mdCode": { - "$ref": "#/$defs/colorValue", - "description": "Markdown inline code" - }, - "mdCodeBlock": { - "$ref": "#/$defs/colorValue", - "description": "Markdown code block content" - }, - "mdCodeBlockBorder": { - "$ref": "#/$defs/colorValue", - "description": "Markdown code block fences" - }, - "mdQuote": { - "$ref": "#/$defs/colorValue", - "description": "Markdown blockquote text" - }, - "mdQuoteBorder": { - "$ref": "#/$defs/colorValue", - "description": "Markdown blockquote border" - }, - "mdHr": { - "$ref": "#/$defs/colorValue", - "description": "Markdown horizontal rule" - }, - "mdListBullet": { - "$ref": "#/$defs/colorValue", - "description": "Markdown list bullets/numbers" - }, - "toolDiffAdded": { - "$ref": "#/$defs/colorValue", - "description": "Added lines in tool diffs" - }, - "toolDiffRemoved": { - "$ref": "#/$defs/colorValue", - "description": "Removed lines in tool diffs" - }, - "toolDiffContext": { - "$ref": "#/$defs/colorValue", - "description": "Context lines in tool diffs" - }, - "syntaxComment": { - "$ref": "#/$defs/colorValue", - "description": "Syntax highlighting: comments" - }, - "syntaxKeyword": { - "$ref": "#/$defs/colorValue", - "description": "Syntax highlighting: keywords" - }, - "syntaxFunction": { - "$ref": "#/$defs/colorValue", - "description": "Syntax highlighting: function names" - }, - "syntaxVariable": { - "$ref": "#/$defs/colorValue", - "description": "Syntax highlighting: variable names" - }, - "syntaxString": { - "$ref": "#/$defs/colorValue", - "description": "Syntax highlighting: string literals" - }, - "syntaxNumber": { - "$ref": "#/$defs/colorValue", - "description": "Syntax highlighting: number literals" - }, - "syntaxType": { - "$ref": "#/$defs/colorValue", - "description": "Syntax highlighting: type names" - }, - "syntaxOperator": { - "$ref": "#/$defs/colorValue", - "description": "Syntax highlighting: operators" - }, - "syntaxPunctuation": { - "$ref": "#/$defs/colorValue", - "description": "Syntax highlighting: punctuation" - }, - "thinkingOff": { - "$ref": "#/$defs/colorValue", - "description": "Thinking level border: off" - }, - "thinkingMinimal": { - "$ref": "#/$defs/colorValue", - "description": "Thinking level border: minimal" - }, - "thinkingLow": { - "$ref": "#/$defs/colorValue", - "description": "Thinking level border: low" - }, - "thinkingMedium": { - "$ref": "#/$defs/colorValue", - "description": "Thinking level border: medium" - }, - "thinkingHigh": { - "$ref": "#/$defs/colorValue", - "description": "Thinking level border: high" - }, - "thinkingXhigh": { - "$ref": "#/$defs/colorValue", - "description": "Thinking level border: xhigh" - }, - "thinkingMax": { - "$ref": "#/$defs/colorValue", - "description": "Thinking level border: max (falls back to thinkingXhigh when omitted)" - }, - "bashMode": { - "$ref": "#/$defs/colorValue", - "description": "Editor border color in bash mode" - } - }, - "additionalProperties": false - }, - "export": { - "type": "object", - "description": "Optional colors for HTML export (defaults derived from userMessageBg if not specified)", - "properties": { - "pageBg": { - "$ref": "#/$defs/colorValue", - "description": "Page background color" - }, - "cardBg": { - "$ref": "#/$defs/colorValue", - "description": "Card/container background color" - }, - "infoBg": { - "$ref": "#/$defs/colorValue", - "description": "Info sections background (system prompt, notices)" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false, - "$defs": { - "colorValue": { - "oneOf": [ - { - "type": "string", - "description": "Hex color (#RGB or #RRGGBB), OKLCH or OKHSL color, variable reference, or empty string for terminal default" - }, - { - "type": "integer", - "minimum": 0, - "maximum": 255, - "description": "256-color palette index (0-255)" - } - ] - } - } -} diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.ts b/packages/coding-agent/src/modes/interactive/theme/theme-schema.ts new file mode 100644 index 000000000..4e0bf5702 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.ts @@ -0,0 +1,146 @@ +/** + * Theme JSON validation, kept out of `theme.ts` on purpose. + * + * Validating user-authored theme files needs typebox, which costs ~17 MB of module graph to import. + * Palette lookup does not, so a presentation that only uses built-in themes should never pay for it. + * `main.ts` installs this validator before runtime resource loading; other consumers can opt in with + * `setThemeJsonValidator()`. Built-in themes do not need validation. + */ + +import { type Static, type TProperties, Type } from "typebox"; +import { Compile } from "typebox/compile"; +import { THEME_TOKENS, type ThemeColorValues, type ThemeTokenDescriptors } from "./theme-tokens.ts"; + +function colorValue(description?: string) { + return Type.Union( + [ + Type.String({ + description: + "Hex color (#RGB or #RRGGBB), OKLCH or OKHSL color, variable reference, or empty string for terminal default", + }), + Type.Integer({ + minimum: 0, + maximum: 255, + description: "256-color palette index (0-255)", + }), + ], + description ? { description } : {}, + ); +} + +export const ColorValueSchema = colorValue(); + +export type ThemeColorValue = Static; + +function themeTokenProperties(tokens: ThemeTokenDescriptors): TProperties { + const properties: TProperties = {}; + for (const [name, descriptor] of Object.entries(tokens)) { + const schema = colorValue(descriptor.description); + properties[name] = descriptor.fallback === undefined ? schema : Type.Optional(schema); + } + return properties; +} + +const ThemeColorsSchema = Type.Unsafe>( + Type.Object(themeTokenProperties(THEME_TOKENS), { + description: + "Theme color definitions (scrollbar, thinkingMax, and search highlight colors are optional and use compatible fallbacks)", + additionalProperties: false, + }), +); + +export const ThemeJsonSchema = Type.Object( + { + $schema: Type.Optional(Type.String({ description: "JSON schema reference" })), + name: Type.String({ + pattern: "^[^/]+$", + description: + "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings.", + }), + appearance: Type.Optional( + Type.Union([Type.Literal("dark"), Type.Literal("light")], { + description: "Background the theme is designed for. Detected from the theme colors when omitted.", + }), + ), + vars: Type.Optional( + Type.Record(Type.String(), ColorValueSchema, { + description: "Reusable color variables", + }), + ), + colors: ThemeColorsSchema, + export: Type.Optional( + Type.Object( + { + pageBg: Type.Optional(colorValue("Page background color")), + cardBg: Type.Optional(colorValue("Card/container background color")), + infoBg: Type.Optional(colorValue("Info sections background (system prompt, notices)")), + }, + { + description: "Optional colors for HTML export (defaults derived from userMessageBg if not specified)", + additionalProperties: false, + }, + ), + ), + }, + { + title: "Pi Coding Agent Theme", + description: "Theme schema for Pi coding agent", + additionalProperties: false, + }, +); + +const compiledThemeSchema = Compile(ThemeJsonSchema); + +export type ValidatedThemeJson = Static; + +/** Validate one theme document, throwing a message that names the offending tokens. */ +export function validateThemeJson(label: string, json: unknown): ValidatedThemeJson { + if ( + typeof json === "object" && + json !== null && + "name" in json && + typeof json.name === "string" && + json.name.includes("/") + ) { + throw new Error( + `Invalid theme name "${json.name}": theme names cannot contain "/" because it is reserved for automatic light/dark theme settings.`, + ); + } + + if (!compiledThemeSchema.Check(json)) { + const errors = Array.from(compiledThemeSchema.Errors(json)); + const missingColors = new Set(); + const otherErrors: string[] = []; + + for (const error of errors) { + if (error.keyword === "required" && error.instancePath === "/colors") { + const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties; + for (const requiredProperty of requiredProperties ?? []) { + missingColors.add(requiredProperty); + } + continue; + } + + const path = error.instancePath || "/"; + otherErrors.push(` - ${path}: ${error.message}`); + } + + let errorMessage = `Invalid theme "${label}":\n`; + if (missingColors.size > 0) { + errorMessage += "\nMissing required color tokens:\n"; + errorMessage += Array.from(missingColors) + .sort() + .map((color) => ` - ${color}`) + .join("\n"); + errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.'; + errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values."; + } + if (otherErrors.length > 0) { + errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`; + } + + throw new Error(errorMessage); + } + + return json as ValidatedThemeJson; +} diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-tokens.ts b/packages/coding-agent/src/modes/interactive/theme/theme-tokens.ts new file mode 100644 index 000000000..a291cf134 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/theme/theme-tokens.ts @@ -0,0 +1,121 @@ +export interface ThemeTokenDescriptor { + slot: "foreground" | "background"; + description: string; + fallback?: string; +} + +export type ThemeTokenDescriptors = Readonly>; + +function defineThemeTokens(tokens: T): T { + for (const [name, descriptor] of Object.entries(tokens)) { + if (descriptor.fallback === undefined) continue; + const fallback = tokens[descriptor.fallback]; + if (fallback === undefined) { + throw new Error(`Theme token ${name} has unknown fallback ${descriptor.fallback}`); + } + if (fallback.slot !== descriptor.slot) { + throw new Error(`Theme token ${name} has fallback ${descriptor.fallback} in a different color slot`); + } + } + return tokens; +} + +/** Theme token metadata in the same order used by theme files and the published JSON Schema. */ +export const THEME_TOKENS = defineThemeTokens({ + accent: { slot: "foreground", description: "Primary accent color (logo, selected items, cursor)" }, + border: { slot: "foreground", description: "Normal borders" }, + borderAccent: { slot: "foreground", description: "Highlighted borders" }, + borderMuted: { slot: "foreground", description: "Subtle borders" }, + success: { slot: "foreground", description: "Success states" }, + error: { slot: "foreground", description: "Error states" }, + warning: { slot: "foreground", description: "Warning states" }, + muted: { slot: "foreground", description: "Secondary/dimmed text" }, + dim: { slot: "foreground", description: "Very dimmed text (more subtle than muted)" }, + text: { slot: "foreground", description: "Default text color (usually empty string)" }, + thinkingText: { slot: "foreground", description: "Thinking block text color" }, + scrollbarTrack: { + slot: "foreground", + description: "Fullscreen scrollbar track foreground (falls back to muted when omitted)", + fallback: "muted", + }, + scrollbarThumb: { + slot: "foreground", + description: "Fullscreen scrollbar thumb foreground (falls back to text when omitted)", + fallback: "text", + }, + selectedBg: { slot: "background", description: "Selected item background" }, + searchMatchBg: { + slot: "background", + description: "Transcript search match background and current-match text (falls back to selectedBg when omitted)", + fallback: "selectedBg", + }, + searchMatchText: { + slot: "foreground", + description: "Transcript search match text and current-match background (falls back to text when omitted)", + fallback: "text", + }, + userMessageBg: { slot: "background", description: "User message background" }, + userMessageText: { slot: "foreground", description: "User message text color" }, + customMessageBg: { slot: "background", description: "Custom message background (hook-injected messages)" }, + customMessageText: { slot: "foreground", description: "Custom message text color" }, + customMessageLabel: { slot: "foreground", description: "Custom message type label color" }, + toolPendingBg: { slot: "background", description: "Tool execution box (pending state)" }, + toolSuccessBg: { slot: "background", description: "Tool execution box (success state)" }, + toolErrorBg: { slot: "background", description: "Tool execution box (error state)" }, + toolTitle: { slot: "foreground", description: "Tool execution box title color" }, + toolOutput: { slot: "foreground", description: "Tool execution box output text color" }, + mdHeading: { slot: "foreground", description: "Markdown heading text" }, + mdLink: { slot: "foreground", description: "Markdown link text" }, + mdLinkUrl: { slot: "foreground", description: "Markdown link URL" }, + mdCode: { slot: "foreground", description: "Markdown inline code" }, + mdCodeBlock: { slot: "foreground", description: "Markdown code block content" }, + mdCodeBlockBorder: { slot: "foreground", description: "Markdown code block fences" }, + mdQuote: { slot: "foreground", description: "Markdown blockquote text" }, + mdQuoteBorder: { slot: "foreground", description: "Markdown blockquote border" }, + mdHr: { slot: "foreground", description: "Markdown horizontal rule" }, + mdListBullet: { slot: "foreground", description: "Markdown list bullets/numbers" }, + toolDiffAdded: { slot: "foreground", description: "Added lines in tool diffs" }, + toolDiffRemoved: { slot: "foreground", description: "Removed lines in tool diffs" }, + toolDiffContext: { slot: "foreground", description: "Context lines in tool diffs" }, + syntaxComment: { slot: "foreground", description: "Syntax highlighting: comments" }, + syntaxKeyword: { slot: "foreground", description: "Syntax highlighting: keywords" }, + syntaxFunction: { slot: "foreground", description: "Syntax highlighting: function names" }, + syntaxVariable: { slot: "foreground", description: "Syntax highlighting: variable names" }, + syntaxString: { slot: "foreground", description: "Syntax highlighting: string literals" }, + syntaxNumber: { slot: "foreground", description: "Syntax highlighting: number literals" }, + syntaxType: { slot: "foreground", description: "Syntax highlighting: type names" }, + syntaxOperator: { slot: "foreground", description: "Syntax highlighting: operators" }, + syntaxPunctuation: { slot: "foreground", description: "Syntax highlighting: punctuation" }, + thinkingOff: { slot: "foreground", description: "Thinking level border: off" }, + thinkingMinimal: { slot: "foreground", description: "Thinking level border: minimal" }, + thinkingLow: { slot: "foreground", description: "Thinking level border: low" }, + thinkingMedium: { slot: "foreground", description: "Thinking level border: medium" }, + thinkingHigh: { slot: "foreground", description: "Thinking level border: high" }, + thinkingXhigh: { slot: "foreground", description: "Thinking level border: xhigh" }, + thinkingMax: { + slot: "foreground", + description: "Thinking level border: max (falls back to thinkingXhigh when omitted)", + fallback: "thinkingXhigh", + }, + bashMode: { slot: "foreground", description: "Editor border color in bash mode" }, +}); + +export type ThemeToken = keyof typeof THEME_TOKENS; +export type ThemeColor = { + [K in ThemeToken]: (typeof THEME_TOKENS)[K]["slot"] extends "foreground" ? K : never; +}[ThemeToken]; +export type ThemeBg = Exclude; + +type OptionalToken = { + [K in ThemeToken]: (typeof THEME_TOKENS)[K] extends { fallback: string } ? K : never; +}[ThemeToken]; + +type RequiredToken = Exclude; + +export type ThemeColorValues = { + [K in RequiredToken]: Value; +} & { + [K in OptionalToken]?: Value; +}; + +export type ResolvedThemeColorValues = Record; diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 5fee354c9..876ba9e86 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -29,6 +29,14 @@ import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.ts" import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.ts"; import { stripBom } from "../../../utils/text.ts"; import { generateSystemThemeColors, SYSTEM_THEME_NAME, terminalAppearance } from "./system-theme.ts"; +import { + type ResolvedThemeColorValues, + THEME_TOKENS, + type ThemeBg, + type ThemeColor, + type ThemeColorValues, + type ThemeToken, +} from "./theme-tokens.ts"; export { SYSTEM_THEME_NAME } from "./system-theme.ts"; @@ -36,10 +44,20 @@ export { SYSTEM_THEME_NAME } from "./system-theme.ts"; // Types & Schema // ============================================================================ -/** The schema that validates this shape lives in `theme-json.ts`; importing the type is free. */ -import type { ThemeColorValue as ColorValue, ValidatedThemeJson as ThemeJson } from "./theme-json.ts"; +/** The schema that validates this shape lives in `theme-schema.ts`; importing the type is free. */ +import type { ThemeColorValue as ColorValue, ValidatedThemeJson as ThemeJson } from "./theme-schema.ts"; -export type { ValidatedThemeJson as ThemeJson } from "./theme-json.ts"; +export type { ValidatedThemeJson as ThemeJson } from "./theme-schema.ts"; +export type { ThemeBg, ThemeColor, ThemeToken } from "./theme-tokens.ts"; + +/** + * Tokens are only accepted in their own slot, because "" (terminal default) means the default foreground + * or background depending on the slot. Use `theme.colors[token]` to use a token's color in the other slot. + */ +export interface ThemeStyle extends TextAttributes { + fg?: ThemeColor | Color; + bg?: ThemeBg | Color; +} export type ThemeJsonValidator = (label: string, json: unknown) => ThemeJson; @@ -54,80 +72,6 @@ export function setThemeJsonValidator(validator: ThemeJsonValidator): void { themeJsonValidator = validator; } -export type ThemeColor = - | "accent" - | "border" - | "borderAccent" - | "borderMuted" - | "success" - | "error" - | "warning" - | "muted" - | "dim" - | "text" - | "thinkingText" - | "scrollbarTrack" - | "scrollbarThumb" - | "searchMatchText" - | "userMessageText" - | "customMessageText" - | "customMessageLabel" - | "toolTitle" - | "toolOutput" - | "mdHeading" - | "mdLink" - | "mdLinkUrl" - | "mdCode" - | "mdCodeBlock" - | "mdCodeBlockBorder" - | "mdQuote" - | "mdQuoteBorder" - | "mdHr" - | "mdListBullet" - | "toolDiffAdded" - | "toolDiffRemoved" - | "toolDiffContext" - | "syntaxComment" - | "syntaxKeyword" - | "syntaxFunction" - | "syntaxVariable" - | "syntaxString" - | "syntaxNumber" - | "syntaxType" - | "syntaxOperator" - | "syntaxPunctuation" - | "thinkingOff" - | "thinkingMinimal" - | "thinkingLow" - | "thinkingMedium" - | "thinkingHigh" - | "thinkingXhigh" - | "thinkingMax" - | "bashMode"; - -export type ThemeBg = - | "selectedBg" - | "searchMatchBg" - | "userMessageBg" - | "customMessageBg" - | "toolPendingBg" - | "toolSuccessBg" - | "toolErrorBg"; - -export type ThemeToken = ThemeColor | ThemeBg; - -/** - * Tokens are only accepted in their own slot, because "" (terminal default) means the default foreground - * or background depending on the slot. Use `theme.colors[token]` to use a token's color in the other slot. - */ -export interface ThemeStyle extends TextAttributes { - fg?: ThemeColor | Color; - bg?: ThemeBg | Color; -} - -type OptionalThemeColor = "scrollbarTrack" | "scrollbarThumb" | "thinkingMax" | "searchMatchText"; -type OptionalThemeBg = "searchMatchBg"; - // ============================================================================ // Color Utilities // ============================================================================ @@ -161,21 +105,20 @@ function resolveThemeColors>( return resolved as Record; } -function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors"] & { - scrollbarTrack: ColorValue; - scrollbarThumb: ColorValue; - thinkingMax: ColorValue; - searchMatchBg: ColorValue; - searchMatchText: ColorValue; -} { - return { - ...colors, - scrollbarTrack: colors.scrollbarTrack ?? colors.muted, - scrollbarThumb: colors.scrollbarThumb ?? colors.text, - thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh, - searchMatchBg: colors.searchMatchBg ?? colors.selectedBg, - searchMatchText: colors.searchMatchText ?? colors.text, - }; +function withThemeTokenFallbacks(values: ThemeColorValues): ResolvedThemeColorValues { + const resolved = { ...values } as Partial>; + for (const [rawName, descriptor] of Object.entries(THEME_TOKENS)) { + if (!("fallback" in descriptor)) continue; + const name = rawName as ThemeToken; + if (resolved[name] !== undefined) continue; + const fallback = descriptor.fallback as ThemeToken; + const fallbackValue = resolved[fallback]; + if (fallbackValue === undefined) { + throw new Error(`Theme token ${rawName} has unresolved fallback ${descriptor.fallback}`); + } + resolved[name] = fallbackValue; + } + return resolved as ResolvedThemeColorValues; } // ============================================================================ @@ -257,10 +200,8 @@ export class Theme { private resolvedColors: { terminal: TerminalColors; colors: Readonly> } | undefined; constructor( - fgColors: Record, string | number> & - Partial>, - bgColors: Record, string | number> & - Partial>, + fgColors: Pick, ThemeColor>, + bgColors: Pick, ThemeBg>, mode: TerminalColorMode, options: { name?: string; @@ -276,14 +217,9 @@ export class Theme { this.sourceInfo = options.sourceInfo; this.mode = mode; this.dimTokens = new Set(options.dim); - const foregrounds = { - ...fgColors, - scrollbarTrack: fgColors.scrollbarTrack ?? fgColors.muted, - scrollbarThumb: fgColors.scrollbarThumb ?? fgColors.text, - thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh, - searchMatchText: fgColors.searchMatchText ?? fgColors.text, - }; - const backgrounds = { ...bgColors, searchMatchBg: bgColors.searchMatchBg ?? bgColors.selectedBg }; + const { fgColors: foregrounds, bgColors: backgrounds } = splitThemeColors( + withThemeTokenFallbacks({ ...fgColors, ...bgColors }), + ); const concreteForegrounds: Color[] = []; const concreteBackgrounds: Color[] = []; // Returns the escape sequence for the token's own slot. @@ -570,24 +506,14 @@ function loadThemeJson(name: string): ThemeJson { return parseThemeJsonContent(name, content); } -const BACKGROUND_TOKENS: ReadonlySet = new Set([ - "selectedBg", - "searchMatchBg", - "userMessageBg", - "customMessageBg", - "toolPendingBg", - "toolSuccessBg", - "toolErrorBg", -]); - -function splitThemeColors(colors: Record): { +function splitThemeColors(colors: ResolvedThemeColorValues): { fgColors: Record; bgColors: Record; } { const fgColors = {} as Record; const bgColors = {} as Record; for (const [key, value] of Object.entries(colors)) { - if (BACKGROUND_TOKENS.has(key)) { + if (THEME_TOKENS[key as ThemeToken].slot === "background") { bgColors[key as ThemeBg] = value; } else { fgColors[key as ThemeColor] = value; @@ -598,7 +524,7 @@ function splitThemeColors(colors: Record): { function createTheme(themeJson: ThemeJson, mode?: TerminalColorMode, sourcePath?: string): Theme { const colorMode = mode ?? getTerminalColorMode(); - const resolvedColors = resolveThemeColors(withThemeColorFallbacks(themeJson.colors), themeJson.vars); + const resolvedColors = withThemeTokenFallbacks(resolveThemeColors(themeJson.colors, themeJson.vars)); const { fgColors, bgColors } = splitThemeColors(resolvedColors); return new Theme(fgColors, bgColors, colorMode, { name: themeJson.name, @@ -614,7 +540,7 @@ function createSystemTheme(mode?: TerminalColorMode): Theme { saturation: terminalColorsPending ? 0 : 1, appearanceHint: getTerminalTheme(), }); - const { fgColors, bgColors } = splitThemeColors(generated.colors); + const { fgColors, bgColors } = splitThemeColors(withThemeTokenFallbacks(generated.colors)); return new Theme(fgColors, bgColors, mode ?? getTerminalColorMode(), { name: SYSTEM_THEME_NAME, appearance: generated.appearance, diff --git a/packages/coding-agent/test/config-schemas.test.ts b/packages/coding-agent/test/config-schemas.test.ts new file mode 100644 index 000000000..ed6230204 --- /dev/null +++ b/packages/coding-agent/test/config-schemas.test.ts @@ -0,0 +1,326 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Compile } from "typebox/compile"; +import { afterEach, describe, expect, it } from "vitest"; +import { renderConfigSchemas } from "../scripts/generate-schemas.ts"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import { ModelConfig } from "../src/core/model-config.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { SettingsSchema } from "../src/core/settings-schema.ts"; +import { validateThemeJson } from "../src/modes/interactive/theme/theme-schema.ts"; + +const schemaBaseUrl = "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/schemas"; +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function createTemporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), "pi-config-schema-test-")); + temporaryDirectories.push(directory); + return directory; +} + +function schemaUrl(name: "models" | "settings" | "keybindings" | "theme"): string { + return `${schemaBaseUrl}/${name}.schema.json`; +} + +describe("generated configuration schemas", () => { + it("matches the four committed artifacts", () => { + const rendered = renderConfigSchemas(); + expect([...rendered.keys()]).toEqual([ + "schemas/models.schema.json", + "schemas/settings.schema.json", + "schemas/keybindings.schema.json", + "schemas/theme.schema.json", + ]); + for (const [relativePath, expected] of rendered) { + expect( + readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf-8"), + `${relativePath} is stale; run npm run generate:schemas`, + ).toBe(expected); + } + }); + + it("preserves editor guidance in the generated theme schema", () => { + const schema = JSON.parse(renderConfigSchemas().get("schemas/theme.schema.json") ?? ""); + const accent = schema.properties.colors.properties.accent; + const colorValue = accent.$ref ? schema.$defs.ColorValue : accent; + expect(schema).toMatchObject({ + title: "Pi Coding Agent Theme", + description: "Theme schema for Pi coding agent", + properties: { + colors: { + description: expect.stringContaining("use compatible fallbacks"), + properties: { + scrollbarTrack: { description: expect.stringContaining("falls back to muted") }, + accent: { description: "Primary accent color (logo, selected items, cursor)" }, + }, + }, + export: { description: expect.stringContaining("defaults derived from userMessageBg") }, + }, + }); + expect(colorValue).toMatchObject({ + anyOf: [{ description: expect.stringContaining("Hex color") }, expect.any(Object)], + }); + }); + + it("preserves moved model and settings guidance", () => { + const models = JSON.parse(renderConfigSchemas().get("schemas/models.schema.json") ?? ""); + expect(models.$defs.ModelCost.properties).toMatchObject({ + input: { description: expect.stringContaining("USD per million tokens") }, + tiers: { description: expect.stringContaining("highest matching input threshold") }, + }); + expect(models.$defs.ModelInputLimits.properties).toMatchObject({ + maxRequestBytes: { description: expect.stringContaining("serialized provider request size") }, + }); + + const settings = JSON.parse(renderConfigSchemas().get("schemas/settings.schema.json") ?? ""); + expect(settings.properties).toMatchObject({ + quietStartup: { description: expect.stringContaining("hide all startup output") }, + defaultTools: { description: expect.stringContaining("+name and -name") }, + fullscreenWheelScrollLines: { description: expect.stringContaining("1 to 100") }, + }); + }); + + it.each([ + { + name: "models.json", + artifact: "schemas/models.schema.json", + valid: { + providers: { + local: { + compat: { + zaiToolStream: true, + thinkingTokenBudgetField: "thinking_budget", + chatTemplateKwargs: { budget: { $var: "thinking.budget" } }, + supportsMidConvoSystemMessages: true, + }, + models: [ + { + id: "model", + inputLimits: { maxRequestBytes: 1024, images: { maxPerRequest: 2 } }, + promptCache: { short: 300 }, + }, + ], + }, + }, + }, + invalid: { providers: { local: { models: [{ id: "" }] } } }, + }, + { + name: "settings.json", + artifact: "schemas/settings.schema.json", + valid: { + theme: "dark", + cacheWarming: "idle", + extensionSetting: { enabled: true }, + queueMode: "all", + websockets: true, + skills: { enableSkillCommands: true, customDirectories: ["./skills"] }, + retry: { maxDelayMs: 1000 }, + }, + invalid: { cacheWarming: "always" }, + }, + { + name: "keybindings.json", + artifact: "schemas/keybindings.schema.json", + valid: { "app.session.new": "ctrl+n", "extension.action": ["alt+x"] }, + invalid: { "app.session.new": 42 }, + }, + ])("validates representative $name documents", ({ artifact, valid, invalid }) => { + const schema = JSON.parse(renderConfigSchemas().get(artifact) ?? ""); + const validator = Compile(schema); + expect(validator.Check(valid)).toBe(true); + expect(validator.Check(invalid)).toBe(false); + }); + + it("rejects non-positive model token limits", () => { + const schema = JSON.parse(renderConfigSchemas().get("schemas/models.schema.json") ?? ""); + const validator = Compile(schema); + for (const field of ["contextWindow", "maxTokens"] as const) { + for (const value of [0, -1]) { + expect( + validator.Check({ providers: { local: { models: [{ id: "model", [field]: value }] } } }), + `models[].${field}=${value}`, + ).toBe(false); + expect( + validator.Check({ providers: { local: { modelOverrides: { model: { [field]: value } } } } }), + `modelOverrides.${field}=${value}`, + ).toBe(false); + } + } + expect( + validator.Check({ + providers: { + local: { + models: [{ id: "model", contextWindow: 1, maxTokens: 1 }], + modelOverrides: { model: { contextWindow: 1, maxTokens: 1 } }, + }, + }, + }), + ).toBe(true); + }); + + it("validates keybinding syntax", () => { + const schema = JSON.parse(renderConfigSchemas().get("schemas/keybindings.schema.json") ?? ""); + const validator = Compile(schema); + for (const binding of ["a", "9", "pageUp", "+", "ctrl+shift+x", "alt+ctrl+?", "ctrl+shift+alt+super+f12"]) { + expect(validator.Check({ "extension.action": binding }), binding).toBe(true); + } + for (const binding of [ + "", + "Ctrl+x", + "control+x", + "ctrl+not-a-key", + "ctrl+ctrl+x", + "ctrl+shift+alt+super+ctrl+x", + ]) { + expect(validator.Check({ "extension.action": binding }), binding).toBe(false); + } + }); + + it("rejects settings values that runtime accessors reject", () => { + const validator = Compile(SettingsSchema); + for (const invalid of [ + { compaction: { reserveTokens: -1 } }, + { compaction: { keepRecentTokens: 1.5 } }, + { + compaction: { + modelOverrides: { "provider/model": { reserveTokens: Number.MAX_SAFE_INTEGER + 1 } }, + }, + }, + { httpIdleTimeoutMs: -1 }, + { websocketConnectTimeoutMs: -1 }, + { httpIdleTimeoutMs: "bogus" }, + { websocketConnectTimeoutMs: "30000" }, + { codemode: { inlineBudget: -1 } }, + ]) { + expect(validator.Check(invalid)).toBe(false); + } + expect( + validator.Check({ + compaction: { + reserveTokens: 0, + keepRecentTokens: Number.MAX_SAFE_INTEGER, + modelOverrides: { "provider/model": { reserveTokens: 0 } }, + }, + httpIdleTimeoutMs: 0, + websocketConnectTimeoutMs: "disabled", + codemode: { inlineBudget: 0 }, + }), + ).toBe(true); + }); + + it("describes legacy settings migrations accurately", () => { + const schema = JSON.parse(renderConfigSchemas().get("schemas/settings.schema.json") ?? "") as { + properties: { + queueMode: { description: string }; + retry: { properties: { maxDelayMs: { description: string } } }; + }; + }; + expect(schema.properties.retry.properties.maxDelayMs.description).toContain("provider.maxRetryDelayMs"); + expect(schema.properties.queueMode.description).toBe("Legacy setting migrated to steeringMode."); + }); + + it("accepts custom compatibility settings and rejects malformed known fields", async () => { + const directory = createTemporaryDirectory(); + const path = join(directory, "models.json"); + const compat = { + customOption: "value", + supportsStore: true, + supportsAdditionalTools: true, + }; + writeFileSync(path, JSON.stringify({ providers: { demo: { api: "custom-api", compat } } })); + + const config = await ModelConfig.load(path); + expect(config.getError()).toBeUndefined(); + expect(config.getProvider("demo")?.compat).toEqual(compat); + + writeFileSync(path, JSON.stringify({ providers: { demo: { compat: { supportsLongCacheRetention: "yes" } } } })); + expect((await ModelConfig.load(path)).getError()).toContain("Invalid models.json schema"); + }); + + it("validates $schema in models.json at runtime", async () => { + const directory = createTemporaryDirectory(); + const path = join(directory, "models.json"); + writeFileSync( + path, + JSON.stringify({ + $schema: schemaUrl("models"), + providers: { demo: { baseUrl: "http://localhost:8080/v1", models: [{ id: "demo" }] } }, + }), + ); + + const config = await ModelConfig.load(path); + expect(config.getError()).toBeUndefined(); + expect(config.getProvider("demo")?.models?.[0]?.id).toBe("demo"); + + writeFileSync( + path, + JSON.stringify({ $schema: schemaUrl("models"), providers: { demo: { models: [{ id: "" }] } } }), + ); + expect((await ModelConfig.load(path)).getError()).toContain("Invalid models.json schema"); + + writeFileSync(path, JSON.stringify({ $schema: 42, providers: {} })); + expect((await ModelConfig.load(path)).getError()).toContain("$schema"); + }); + + it("accepts $schema in settings.json and preserves unknown settings", () => { + const directory = createTemporaryDirectory(); + writeFileSync( + join(directory, "settings.json"), + JSON.stringify({ $schema: schemaUrl("settings"), theme: "light", extensionSetting: { enabled: true } }), + ); + + const manager = SettingsManager.create(directory, directory); + expect(manager.getTheme()).toBe("light"); + expect(manager.getGlobalSettings()).toMatchObject({ + $schema: schemaUrl("settings"), + extensionSetting: { enabled: true }, + }); + }); + + it("accepts $schema in keybindings.json without treating it as an action", () => { + const directory = createTemporaryDirectory(); + writeFileSync( + join(directory, "keybindings.json"), + JSON.stringify({ + $schema: schemaUrl("keybindings"), + "app.session.new": "ctrl+n", + "extension.invalid": "ctrl+not-a-key", + }), + ); + + const manager = KeybindingsManager.create(directory); + expect(manager.getUserBindings()).toEqual({ "app.session.new": "ctrl+n" }); + expect(manager.getEffectiveConfig()["app.session.new"]).toBe("ctrl+n"); + }); + + it("accepts $schema in themes and rejects malformed themes", () => { + const builtIn = JSON.parse( + readFileSync(new URL("../src/modes/interactive/theme/dark.json", import.meta.url), "utf-8"), + ) as Record; + expect(validateThemeJson("custom", { ...builtIn, $schema: schemaUrl("theme"), name: "custom" }).name).toBe( + "custom", + ); + expect(() => validateThemeJson("invalid", { ...builtIn, name: "invalid/name" })).toThrow( + "theme names cannot contain", + ); + expect(() => validateThemeJson("invalid", { ...builtIn, colors: {} })).toThrow("Missing required color tokens"); + for (const invalid of [ + { ...builtIn, unexpected: true }, + { + ...builtIn, + colors: { ...(builtIn.colors as Record), scrollbarThmb: "" }, + }, + { ...builtIn, export: { unexpected: "" } }, + ]) { + expect(() => validateThemeJson("invalid", invalid)).toThrow(/additional properties/i); + } + }); +}); diff --git a/packages/coding-agent/test/resource-loader-theme.test.ts b/packages/coding-agent/test/resource-loader-theme.test.ts index f24ebf649..2e4aab5dc 100644 --- a/packages/coding-agent/test/resource-loader-theme.test.ts +++ b/packages/coding-agent/test/resource-loader-theme.test.ts @@ -5,12 +5,23 @@ import { resetCapabilitiesCache, setCapabilityOverrides } from "@earendil-works/ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DefaultResourceLoader } from "../src/core/resource-loader.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; +import { setThemeJsonValidator } from "../src/modes/interactive/theme/theme.ts"; +import { validateThemeJson } from "../src/modes/interactive/theme/theme-schema.ts"; + +setThemeJsonValidator(validateThemeJson); + +interface TestThemeJson { + name: string; + colors: Record; + unsupported?: boolean; +} describe("DefaultResourceLoader theme color mode", () => { let tempDir: string; let agentDir: string; let cwd: string; let themePath: string; + let themeJson: TestThemeJson; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "resource-loader-theme-")); @@ -19,9 +30,9 @@ describe("DefaultResourceLoader theme color mode", () => { mkdirSync(agentDir, { recursive: true }); mkdirSync(cwd, { recursive: true }); - const themeJson = JSON.parse( + themeJson = JSON.parse( readFileSync(join(process.cwd(), "src", "modes", "interactive", "theme", "dark.json"), "utf-8"), - ) as { name: string; colors: Record }; + ) as TestThemeJson; themeJson.name = "capability-test"; themeJson.colors.userMessageBg = "#3c3544"; themePath = join(tempDir, "capability-test.json"); @@ -100,4 +111,36 @@ describe("DefaultResourceLoader theme color mode", () => { const loadedTheme = loader.getThemes().themes.find((theme) => theme.name === "capability-test"); expect(loadedTheme?.bg("userMessageBg", "x")).toBe("\x1b[48;2;60;53;68mx\x1b[49m"); }); + + it("applies strict validation on initial load and reload", async () => { + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager: SettingsManager.inMemory(), + additionalThemePaths: [themePath], + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noContextFiles: true, + }); + + themeJson.unsupported = true; + writeFileSync(themePath, JSON.stringify(themeJson)); + await loader.reload(); + const initialDiagnostics = loader.getThemes().diagnostics; + expect(initialDiagnostics).toHaveLength(1); + expect(initialDiagnostics[0]).toMatchObject({ type: "warning", path: themePath }); + expect(initialDiagnostics[0]?.message).toContain("/unsupported"); + + delete themeJson.unsupported; + writeFileSync(themePath, JSON.stringify(themeJson)); + await loader.reload(); + expect(loader.getThemes().diagnostics).toEqual([]); + expect(loader.getThemes().themes.some((theme) => theme.name === themeJson.name)).toBe(true); + + themeJson.unsupported = true; + writeFileSync(themePath, JSON.stringify(themeJson)); + await loader.reload(); + expect(loader.getThemes().diagnostics).toEqual(initialDiagnostics); + }); }); diff --git a/packages/coding-agent/test/scrollbar-theme.test.ts b/packages/coding-agent/test/scrollbar-theme.test.ts index 02d618451..76bd0fd21 100644 --- a/packages/coding-agent/test/scrollbar-theme.test.ts +++ b/packages/coding-agent/test/scrollbar-theme.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { loadThemeFromPath, setThemeJsonValidator } from "../src/modes/interactive/theme/theme.ts"; -import { validateThemeJson } from "../src/modes/interactive/theme/theme-json.ts"; +import { validateThemeJson } from "../src/modes/interactive/theme/theme-schema.ts"; setThemeJsonValidator(validateThemeJson); diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 8b6f8a0a7..734fbfae5 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -289,6 +289,10 @@ const SYMBOL_KEYS = new Set([ "?", ]); +const SHIFTED_SYMBOL_KEYS = new Set("~!@#$%^&*()_+|{}:<>?"); + +const MODIFIER_NAMES = ["shift", "alt", "ctrl", "super"] as const; + const MODIFIERS = { shift: 1, alt: 2, @@ -365,6 +369,17 @@ function normalizeShiftedLetterIdentityCodepoint(codepoint: number, modifier: nu return codepoint; } +function getLogicalShiftedSymbolCandidate( + codepoint: number | undefined, + modifier: number, + allowedSymbols: ReadonlySet, +): { codepoint: number; modifier: number } | undefined { + if (codepoint === undefined || (modifier & MODIFIERS.shift) === 0) return undefined; + const normalizedCodepoint = normalizeKittyFunctionalCodepoint(codepoint); + if (!allowedSymbols.has(String.fromCharCode(normalizedCodepoint))) return undefined; + return { codepoint: normalizedCodepoint, modifier: modifier & ~MODIFIERS.shift }; +} + const LEGACY_KEY_SEQUENCES = { up: ["\x1b[A", "\x1bOA"], down: ["\x1b[B", "\x1bOB"], @@ -656,9 +671,6 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM const actualMod = parsed.modifier & ~LOCK_MASK; const expectedMod = expectedModifier & ~LOCK_MASK; - // Check if modifiers match - if (actualMod !== expectedMod) return false; - const normalizedCodepoint = normalizeShiftedLetterIdentityCodepoint( normalizeKittyFunctionalCodepoint(parsed.codepoint), parsed.modifier, @@ -668,8 +680,22 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM expectedModifier, ); - // Primary match: codepoint matches directly after normalizing functional keys - if (normalizedCodepoint === normalizedExpectedCodepoint) return true; + // Physical match: primary codepoint and every reported modifier match. + if (actualMod === expectedMod && normalizedCodepoint === normalizedExpectedCodepoint) return true; + + // Logical match: Kitty reports symbols such as "+" as Shift plus the physical "=" key. + // Bindings name the produced symbol, so consume that implicit Shift while preserving + // Ctrl, Alt, and Super. The physical "shift+=" spelling remains valid through the match above. + const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(parsed.shiftedKey, actualMod, SYMBOL_KEYS); + if ( + logicalShiftedSymbol && + (logicalShiftedSymbol.modifier & ~LOCK_MASK) === expectedMod && + logicalShiftedSymbol.codepoint === normalizeKittyFunctionalCodepoint(expectedCodepoint) + ) { + return true; + } + + if (actualMod !== expectedMod) return false; // Alternate match: use base layout key for non-Latin keyboard layouts. // This allows Ctrl+ะก (Cyrillic) to match Ctrl+c (Latin) when terminal reports @@ -764,13 +790,19 @@ function isDigitKey(key: string): boolean { } function matchesPrintableModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean { - if (expectedModifier === 0) return false; const parsed = parseModifyOtherKeysSequence(data); - if (!parsed || parsed.modifier !== expectedModifier) return false; - return ( + if (!parsed) return false; + const physicalMatch = + parsed.modifier === expectedModifier && normalizeShiftedLetterIdentityCodepoint(parsed.codepoint, parsed.modifier) === - normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier) + normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier); + if (physicalMatch) return true; + const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate( + parsed.codepoint, + parsed.modifier, + SHIFTED_SYMBOL_KEYS, ); + return logicalShiftedSymbol?.modifier === expectedModifier && logicalShiftedSymbol.codepoint === expectedKeycode; } function formatKeyNameWithModifiers(keyName: string, modifier: number): string | undefined { @@ -788,15 +820,22 @@ function formatKeyNameWithModifiers(keyName: string, modifier: number): string | function parseKeyId( keyId: string, ): { key: string; ctrl: boolean; shift: boolean; alt: boolean; super: boolean } | null { - const parts = keyId.toLowerCase().split("+"); - const key = parts[parts.length - 1]; - if (!key) return null; + let key = keyId.toLowerCase(); + const modifiers = new Set<(typeof MODIFIER_NAMES)[number]>(); + while (true) { + const modifier = MODIFIER_NAMES.find((name) => key.startsWith(`${name}+`)); + if (!modifier) break; + if (modifiers.has(modifier)) return null; + modifiers.add(modifier); + key = key.slice(modifier.length + 1); + } + if (!key || (key.includes("+") && key !== "+")) return null; return { key, - ctrl: parts.includes("ctrl"), - shift: parts.includes("shift"), - alt: parts.includes("alt"), - super: parts.includes("super"), + ctrl: modifiers.has("ctrl"), + shift: modifiers.has("shift"), + alt: modifiers.has("alt"), + super: modifiers.has("super"), }; } @@ -1197,7 +1236,9 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } // Check both raw char and Kitty sequence (needed for release events) - return data === key || matchesKittySequence(data, codepoint, 0); + return ( + data === key || matchesKittySequence(data, codepoint, 0) || matchesPrintableModifyOtherKeys(data, codepoint, 0) + ); } return false; @@ -1251,12 +1292,25 @@ function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: nu export function parseKey(data: string): string | undefined { const kitty = parseKittySequence(data); if (kitty) { - return formatParsedKey(kitty.codepoint, kitty.modifier, kitty.baseLayoutKey); + const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(kitty.shiftedKey, kitty.modifier, SYMBOL_KEYS); + return formatParsedKey( + logicalShiftedSymbol?.codepoint ?? kitty.codepoint, + logicalShiftedSymbol?.modifier ?? kitty.modifier, + kitty.baseLayoutKey, + ); } const modifyOtherKeys = parseModifyOtherKeysSequence(data); if (modifyOtherKeys) { - return formatParsedKey(modifyOtherKeys.codepoint, modifyOtherKeys.modifier); + const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate( + modifyOtherKeys.codepoint, + modifyOtherKeys.modifier, + SHIFTED_SYMBOL_KEYS, + ); + return formatParsedKey( + logicalShiftedSymbol?.codepoint ?? modifyOtherKeys.codepoint, + logicalShiftedSymbol?.modifier ?? modifyOtherKeys.modifier, + ); } // Mode-aware legacy sequences diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index 68f594359..0045610bc 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -134,6 +134,16 @@ describe("matchesKey", () => { setKittyProtocolActive(false); }); + it("should match shifted symbols by their produced character", () => { + setKittyProtocolActive(true); + assert.strictEqual(matchesKey("\x1b[61:43;2u", "+"), true); + assert.strictEqual(matchesKey("\x1b[61:43;2u", "shift+="), true); + assert.strictEqual(parseKey("\x1b[61:43;2u"), "+"); + assert.strictEqual(matchesKey("\x1b[61:43;6u", "ctrl++"), true); + assert.strictEqual(parseKey("\x1b[61:43;6u"), "ctrl++"); + setKittyProtocolActive(false); + }); + it("should handle shifted key in format", () => { setKittyProtocolActive(true); // Format with shifted key: CSI codepoint:shifted:base;modifier u @@ -265,6 +275,8 @@ describe("matchesKey", () => { setKittyProtocolActive(false); assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true); assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/"); + assert.strictEqual(matchesKey("\x1b[27;6;43~", "ctrl++"), true); + assert.strictEqual(parseKey("\x1b[27;6;43~"), "ctrl++"); }); it("should match xterm modifyOtherKeys digit combos", () => { @@ -600,6 +612,8 @@ describe("parseKey", () => { assert.strictEqual(parseKey(" "), "space"); assert.strictEqual(parseKey("1"), "1"); assert.strictEqual(matchesKey("1", "1"), true); + assert.strictEqual(parseKey("+"), "+"); + assert.strictEqual(matchesKey("+", "+"), true); }); it("should parse arrow keys", () => {