diff --git a/crates/pi-natives/src/keys.rs b/crates/pi-natives/src/keys.rs index 4f94c6e96..6dac2f779 100644 --- a/crates/pi-natives/src/keys.rs +++ b/crates/pi-natives/src/keys.rs @@ -227,7 +227,64 @@ pub fn matches_kitty_sequence( return false; } - parsed.codepoint == expected_codepoint || parsed.base_layout_key == Some(expected_codepoint) + if parsed.codepoint == expected_codepoint { + return true; + } + + // Only fall back to base layout key when the codepoint is NOT already a + // recognized Latin letter (a-z) or symbol. This prevents remapped layouts + // (Dvorak, Colemak) from causing false matches. + if let Some(base) = parsed.base_layout_key + && base == expected_codepoint + { + let cp = parsed.codepoint; + let is_latin_letter = (97..=122).contains(&cp); // a-z + let is_known_symbol = is_symbol_key(cp); + if !is_latin_letter && !is_known_symbol { + return true; + } + } + + false +} + +/// Check if a codepoint corresponds to a known symbol key. +#[inline] +const fn is_symbol_key(cp: i32) -> bool { + matches!( + cp, + 96 | // ` + 45 | // - + 61 | // = + 91 | // [ + 93 | // ] + 92 | // \ + 59 | // ; + 39 | // ' + 44 | // , + 46 | // . + 47 | // / + 33 | // ! + 64 | // @ + 35 | // # + 36 | // $ + 37 | // % + 94 | // ^ + 38 | // & + 42 | // * + 40 | // ( + 41 | // ) + 95 | // _ + 43 | // + + 124 | // | + 126 | // ~ + 123 | // { + 125 | // } + 58 | // : + 60 | // < + 62 | // > + 63 // ? + ) } /// Parse terminal input and return a normalized key identifier. @@ -1104,7 +1161,16 @@ fn parse_functional(bytes: &[u8]) -> Option { fn format_kitty_key(parsed: &ParsedKittySequence) -> Option> { let effective_mod = parsed.modifier & !LOCK_MASK; - let effective_codepoint = parsed.base_layout_key.unwrap_or(parsed.codepoint); + let effective_codepoint = { + let cp = parsed.codepoint; + let is_latin_letter = (97..=122).contains(&cp); // a-z + let is_known_symbol = is_symbol_key(cp); + if is_latin_letter || is_known_symbol { + cp + } else { + parsed.base_layout_key.unwrap_or(cp) + } + }; // No modifiers - return static string if effective_mod == 0 { diff --git a/docs/porting-from-pi-mono.md b/docs/porting-from-pi-mono.md index 137df6e6e..738a42565 100644 --- a/docs/porting-from-pi-mono.md +++ b/docs/porting-from-pi-mono.md @@ -5,8 +5,8 @@ Use it for any merge: single file, feature branch, or full release sync. ## Last Sync Point -**Commit:** `82d7da878` -**Date:** 2026-01-30 +**Commit:** `9ce00079` +**Date:** 2026-02-15 Update this section after each sync; do not reuse the previous range. diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index d7b307d9a..94fbb0ff3 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog ## [Unreleased] +### Added + +- Added `maxRetryDelayMs` option to AgentOptions to cap server-requested retry delays, allowing higher-level retry logic to handle long waits with user visibility + +### Changed + +- Updated ThinkingLevel documentation to include support for gpt-5.3 and gpt-5.3-codex models with 'xhigh' thinking level ## [11.2.0] - 2026-02-05 ### Fixed diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 9310d66da..8613cd12e 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -96,6 +96,14 @@ export interface AgentOptions { */ thinkingBudgets?: ThinkingBudgets; + /** + * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. + * If the server's requested delay exceeds this value, the request fails immediately, + * allowing higher-level retry logic to handle it with user visibility. + * Default: 60000 (60 seconds). Set to 0 to disable the cap. + */ + maxRetryDelayMs?: number; + /** * Provides tool execution context, resolved per tool call. * Use for late-bound UI or session state access. @@ -148,6 +156,7 @@ export class Agent { public streamFn: StreamFn; private _sessionId?: string; private _thinkingBudgets?: ThinkingBudgets; + private _maxRetryDelayMs?: number; public getApiKey?: (provider: string) => Promise | string | undefined; private getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined; private cursorExecHandlers?: CursorExecHandlers; @@ -169,6 +178,7 @@ export class Agent { this.streamFn = opts.streamFn || streamSimple; this._sessionId = opts.sessionId; this._thinkingBudgets = opts.thinkingBudgets; + this._maxRetryDelayMs = opts.maxRetryDelayMs; this.getApiKey = opts.getApiKey; this.getToolContext = opts.getToolContext; this.cursorExecHandlers = opts.cursorExecHandlers; @@ -205,6 +215,21 @@ export class Agent { this._thinkingBudgets = value; } + /** + * Get the current max retry delay in milliseconds. + */ + get maxRetryDelayMs(): number | undefined { + return this._maxRetryDelayMs; + } + + /** + * Set the maximum delay to wait for server-requested retries. + * Set to 0 to disable the cap. + */ + set maxRetryDelayMs(value: number | undefined) { + this._maxRetryDelayMs = value; + } + get state(): AgentState { return this._state; } @@ -493,6 +518,7 @@ export class Agent { interruptMode: this.interruptMode, sessionId: this._sessionId, thinkingBudgets: this._thinkingBudgets, + maxRetryDelayMs: this._maxRetryDelayMs, kimiApiFormat: this.kimiApiFormat, toolChoice: options?.toolChoice, convertToLlm: this.convertToLlm, diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 8a53ed013..22c394c24 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -121,7 +121,7 @@ export interface ToolCallContext { /** * Thinking/reasoning level for models that support it. - * Note: "xhigh" is only supported by OpenAI gpt-5.1-codex-max, gpt-5.2, and gpt-5.2-codex models. + * Note: "xhigh" is only supported by OpenAI gpt-5.1-codex-max, gpt-5.2, gpt-5.2-codex, gpt-5.3, and gpt-5.3-codex models. */ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ee4b68dae..90426b501 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,13 +1,28 @@ # Changelog ## [Unreleased] + ### Added +- Added `cacheRetention` option to control prompt cache retention preference ('none', 'short', 'long') across providers +- Added `maxRetryDelayMs` option to cap server-requested retry delays and fail fast when delays exceed the limit +- Added `effort` option for Anthropic Opus 4.6+ models to control adaptive thinking effort levels ('low', 'medium', 'high', 'max') +- Added support for Anthropic Opus 4.6+ adaptive thinking mode that lets Claude decide when and how much to think +- Added `PI_AI_ANTIGRAVITY_VERSION` environment variable to customize Antigravity sandbox endpoint version - Exported `convertAnthropicMessages` function for converting message formats to Anthropic API - Automatic fallback for Anthropic assistant-prefill requests: appends synthetic user "Continue." message when conversation ends with assistant turn to maintain API compatibility +### Changed + +- Changed Anthropic thinking mode to use adaptive thinking for Opus 4.6+ models instead of budget-based thinking +- Changed `supportsXhigh()` to support GPT-5.2/5.3 and Anthropic Opus 4.6+ models with adaptive thinking +- Changed prompt caching to respect `cacheRetention` option and support TTL configuration for Anthropic +- Changed OpenAI tool definitions to conditionally include `strict` field only when provider supports it +- Changed Qwen model support to use `enable_thinking` boolean parameter instead of OpenAI-style reasoning_effort + ### Fixed +- Fixed indentation and formatting in `convertAnthropicMessages` function - Fixed handling of conversations ending with assistant messages on Anthropic-routed models that reject assistant prefill requests ## [11.2.3] - 2026-02-05 diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 24d4afb2b..067c5f792 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -47,15 +47,23 @@ export function calculateCost(model: Model, usage: Usage return usage.cost; } -/** Models that support xhigh thinking level */ -const XHIGH_MODELS = new Set(["gpt-5.1-codex-max", "gpt-5.2", "gpt-5.2-codex"]); - /** * Check if a model supports xhigh thinking level. - * Currently only certain OpenAI Codex models support this. + * + * Supported today: + * - GPT-5.2 / GPT-5.3 model families + * - Anthropic Messages API Opus 4.6 models (xhigh maps to adaptive effort "max") */ export function supportsXhigh(model: Model): boolean { - return XHIGH_MODELS.has(model.id) || model.api === "anthropic-messages"; + if (model.id.includes("gpt-5.2") || model.id.includes("gpt-5.3")) { + return true; + } + + if (model.api === "anthropic-messages") { + return model.id.includes("opus-4-6") || model.id.includes("opus-4.6"); + } + + return false; } /** diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 22a3131c5..745f789cb 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -9,6 +9,7 @@ import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream"; import type { Api, AssistantMessage, + CacheRetention, Context, ImageContent, Message, @@ -28,6 +29,35 @@ import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; import { sanitizeSurrogates } from "../utils/sanitize-unicode"; import { transformMessages } from "./transform-messages"; +/** + * Resolve cache retention preference. + * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. + */ +function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { + if (cacheRetention) { + return cacheRetention; + } + if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + return "long"; + } + return "short"; +} + +function getCacheControl( + baseUrl: string, + cacheRetention?: CacheRetention, +): { retention: CacheRetention; cacheControl?: { type: "ephemeral"; ttl?: "1h" } } { + const retention = resolveCacheRetention(cacheRetention); + if (retention === "none") { + return { retention }; + } + const ttl = retention === "long" && baseUrl.includes("api.anthropic.com") ? "1h" : undefined; + return { + retention, + cacheControl: { type: "ephemeral", ...(ttl && { ttl }) }, + }; +} + // Stealth mode: Mimic Claude Code headers and tool prefixing. export const claudeCodeVersion = "1.0.83"; export const claudeToolPrefix = "proxy_"; @@ -123,9 +153,30 @@ function convertContentBlocks(content: (TextContent | ImageContent)[]): return blocks; } +export type AnthropicEffort = "low" | "medium" | "high" | "max"; + export interface AnthropicOptions extends StreamOptions { + /** + * Enable extended thinking. + * For Opus 4.6+: uses adaptive thinking (Claude decides when/how much to think). + * For older models: uses budget-based thinking with thinkingBudgetTokens. + */ thinkingEnabled?: boolean; + /** + * Token budget for extended thinking (older models only). + * Ignored for Opus 4.6+ which uses adaptive thinking. + */ thinkingBudgetTokens?: number; + /** + * Effort level for adaptive thinking (Opus 4.6+ only). + * Controls how much thinking Claude allocates: + * - "max": Always thinks with no constraints + * - "high": Always thinks, deep reasoning (default) + * - "medium": Moderate thinking, may skip for simple queries + * - "low": Minimal thinking, skips for simple tasks + * Ignored for older models. + */ + effort?: AnthropicEffort; interleavedThinking?: boolean; toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string }; betas?: string[] | string; @@ -487,11 +538,11 @@ function createClient( export type AnthropicSystemBlock = { type: "text"; text: string; - cache_control?: { type: "ephemeral" }; + cache_control?: { type: "ephemeral"; ttl?: "1h" }; }; type CacheControlBlock = { - cache_control?: { type: "ephemeral" } | null; + cache_control?: { type: "ephemeral"; ttl?: "1h" | "5m" } | null; }; const cacheControlEphemeral = { type: "ephemeral" as const }; @@ -544,6 +595,14 @@ function disableThinkingIfToolChoiceForced(params: MessageCreateParamsStreaming) } } +/** + * Check if a model supports adaptive thinking (Opus 4.6+) + */ +function supportsAdaptiveThinking(modelId: string): boolean { + // Opus 4.6 model IDs (with or without date suffix) + return modelId.includes("opus-4-6") || modelId.includes("opus-4.6"); +} + function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, model: Model<"anthropic-messages">): void { const thinking = params.thinking; if (!thinking || thinking.type !== "enabled") return; @@ -566,7 +625,7 @@ function buildParams( ): MessageCreateParamsStreaming { const params: MessageCreateParamsStreaming = { model: model.id, - messages: convertAnthropicMessages(context.messages, model, isOAuthToken), + messages: convertAnthropicMessages(context.messages, model, isOAuthToken), max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0, stream: true, }; @@ -587,11 +646,21 @@ function buildParams( params.tools = convertTools(context.tools, isOAuthToken); } + // Configure thinking mode: adaptive (Opus 4.6+) or budget-based (older models) if (options?.thinkingEnabled && model.reasoning) { - params.thinking = { - type: "enabled", - budget_tokens: options.thinkingBudgetTokens || 1024, - }; + if (supportsAdaptiveThinking(model.id)) { + // Adaptive thinking: Claude decides when and how much to think + (params as any).thinking = { type: "adaptive" }; + if (options.effort) { + (params as any).output_config = { effort: options.effort }; + } + } else { + // Budget-based thinking for older models + params.thinking = { + type: "enabled", + budget_tokens: options.thinkingBudgetTokens || 1024, + }; + } } if (options?.toolChoice) { @@ -611,7 +680,10 @@ function buildParams( ensureMaxTokensForThinking(params, model); } - applyPromptCaching(params); + const { retention, cacheControl } = getCacheControl(model.baseUrl, options?.cacheRetention); + if (retention !== "none") { + applyPromptCaching(params, cacheControl); + } return params; } @@ -630,24 +702,33 @@ function stripCacheControl(blocks: T[]): void { } } -function applyCacheControlToLastBlock(blocks: T[]): void { +function applyCacheControlToLastBlock( + blocks: T[], + cc: CacheControlBlock["cache_control"] = cacheControlEphemeral, +): void { if (blocks.length === 0) return; const lastIndex = blocks.length - 1; - blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControlEphemeral }; + blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cc }; } -function applyCacheControlToLastTextBlock(blocks: Array): void { +function applyCacheControlToLastTextBlock( + blocks: Array, + cc: CacheControlBlock["cache_control"] = cacheControlEphemeral, +): void { if (blocks.length === 0) return; for (let i = blocks.length - 1; i >= 0; i--) { if (blocks[i].type === "text") { - blocks[i] = { ...blocks[i], cache_control: cacheControlEphemeral }; + blocks[i] = { ...blocks[i], cache_control: cc }; return; } } - applyCacheControlToLastBlock(blocks); + applyCacheControlToLastBlock(blocks, cc); } -function applyPromptCaching(params: MessageCreateParamsStreaming): void { +function applyPromptCaching( + params: MessageCreateParamsStreaming, + cc: CacheControlBlock["cache_control"] = cacheControlEphemeral, +): void { // Anthropic allows max 4 cache breakpoints const MAX_CACHE_BREAKPOINTS = 4; @@ -675,7 +756,7 @@ function applyPromptCaching(params: MessageCreateParamsStreaming): void { // 1. Cache tools - place breakpoint on last tool definition if (params.tools && params.tools.length > 0) { - applyCacheControlToLastBlock(params.tools as Array); + applyCacheControlToLastBlock(params.tools as Array, cc); cacheBreakpointsUsed++; } @@ -683,7 +764,7 @@ function applyPromptCaching(params: MessageCreateParamsStreaming): void { // 2. Cache system prompt if (params.system && Array.isArray(params.system) && params.system.length > 0) { - applyCacheControlToLastBlock(params.system); + applyCacheControlToLastBlock(params.system, cc); cacheBreakpointsUsed++; } @@ -699,12 +780,13 @@ function applyPromptCaching(params: MessageCreateParamsStreaming): void { const penultimateUser = params.messages[penultimateUserIndex]; if (penultimateUser) { if (typeof penultimateUser.content === "string") { - penultimateUser.content = [ - { type: "text", text: penultimateUser.content, cache_control: cacheControlEphemeral }, - ]; + penultimateUser.content = [{ type: "text", text: penultimateUser.content, cache_control: cc }]; cacheBreakpointsUsed++; } else if (Array.isArray(penultimateUser.content) && penultimateUser.content.length > 0) { - applyCacheControlToLastTextBlock(penultimateUser.content as Array); + applyCacheControlToLastTextBlock( + penultimateUser.content as Array, + cc, + ); cacheBreakpointsUsed++; } } @@ -718,9 +800,9 @@ function applyPromptCaching(params: MessageCreateParamsStreaming): void { const lastUser = params.messages[lastUserIndex]; if (lastUser) { if (typeof lastUser.content === "string") { - lastUser.content = [{ type: "text", text: lastUser.content, cache_control: cacheControlEphemeral }]; + lastUser.content = [{ type: "text", text: lastUser.content, cache_control: cc }]; } else if (Array.isArray(lastUser.content) && lastUser.content.length > 0) { - applyCacheControlToLastTextBlock(lastUser.content as Array); + applyCacheControlToLastTextBlock(lastUser.content as Array, cc); } } } @@ -893,15 +975,15 @@ export function convertAnthropicMessages( } } - // If the conversation ends with an assistant message, Anthropic treats that - // as an assistant prefill. Many Anthropic-routed models reject this and - // require the conversation to end with a user message. - // - // Best-effort fallback: append a synthetic user "Continue." turn so the model - // can continue generation from the prior assistant content. - if (params.length > 0 && params[params.length - 1]?.role === "assistant") { - params.push({ role: "user", content: "Continue." }); -} + // If the conversation ends with an assistant message, Anthropic treats that + // as an assistant prefill. Many Anthropic-routed models reject this and + // require the conversation to end with a user message. + // + // Best-effort fallback: append a synthetic user "Continue." turn so the model + // can continue generation from the prior assistant content. + if (params.length > 0 && params[params.length - 1]?.role === "assistant") { + params.push({ role: "user", content: "Continue." }); + } // Final validation: filter out any messages with invalid content return params.filter(msg => { diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index e39658b6c..bb7f69d3f 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -69,15 +69,20 @@ const GEMINI_CLI_HEADERS = { }; // Headers for Antigravity (sandbox endpoint) - requires specific User-Agent -const ANTIGRAVITY_HEADERS = { - "User-Agent": "antigravity/1.15.8 darwin/arm64", - "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", - "Client-Metadata": JSON.stringify({ - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }), -}; +const DEFAULT_ANTIGRAVITY_VERSION = "1.15.8"; + +function getAntigravityHeaders() { + const version = process.env.PI_AI_ANTIGRAVITY_VERSION || DEFAULT_ANTIGRAVITY_VERSION; + return { + "User-Agent": `antigravity/${version} darwin/arm64`, + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; +} // Antigravity system instruction (ported from CLIProxyAPI v6.6.89). const ANTIGRAVITY_SYSTEM_INSTRUCTION = ` @@ -413,7 +418,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( const requestBody = buildRequest(model, context, projectId, options, isAntigravity); options?.onPayload?.(requestBody); - const headers = isAntigravity ? ANTIGRAVITY_HEADERS : GEMINI_CLI_HEADERS; + const headers = isAntigravity ? getAntigravityHeaders() : GEMINI_CLI_HEADERS; const requestHeaders = { Authorization: `Bearer ${accessToken}`, @@ -456,6 +461,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( // Use server-provided delay or exponential backoff const serverDelay = extractRetryDelay(errorText, response); const delayMs = serverDelay ?? BASE_DELAY_MS * 2 ** attempt; + + // Check if server delay exceeds max allowed (default: 60s) + const maxDelayMs = options?.maxRetryDelayMs ?? 60000; + if (maxDelayMs > 0 && serverDelay && serverDelay > maxDelayMs) { + const delaySeconds = Math.ceil(serverDelay / 1000); + throw new Error( + `Server requested ${delaySeconds}s retry delay (max: ${Math.ceil(maxDelayMs / 1000)}s). ${extractErrorMessage(errorText)}`, + ); + } + await abortableSleep(delayMs, options?.signal); continue; } diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index ce54c067b..d2753c1a3 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -432,7 +432,7 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio } if (context.tools) { - params.tools = convertTools(context.tools); + params.tools = convertTools(context.tools, compat); } else if (hasToolHistory(context.messages)) { // Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results params.tools = []; @@ -446,6 +446,9 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio // Z.ai uses binary thinking: { type: "enabled" | "disabled" } // Must explicitly disable since z.ai defaults to thinking enabled (params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; + } else if (compat.thinkingFormat === "qwen" && model.reasoning) { + // Qwen uses enable_thinking: boolean + (params as any).enable_thinking = !!options?.reasoningEffort; } else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { // OpenAI-style reasoning_effort params.reasoning_effort = options.reasoningEffort; @@ -757,14 +760,15 @@ export function convertMessages( return params; } -function convertTools(tools: Tool[]): OpenAI.Chat.Completions.ChatCompletionTool[] { +function convertTools(tools: Tool[], compat: ResolvedOpenAICompat): OpenAI.Chat.Completions.ChatCompletionTool[] { return tools.map(tool => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters as any, // TypeBox already generates JSON Schema - strict: false, // Disable strict mode to allow optional parameters without null unions + // Only include strict if provider supports it. Some reject unknown fields. + ...(compat.supportsStrictMode !== false && { strict: false }), }, })); } @@ -836,6 +840,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAICompat requiresAssistantContentForToolCalls: isOpenRouterKimi, openRouterRouting: undefined, vercelGatewayRouting: undefined, + supportsStrictMode: true, }; } @@ -867,5 +872,6 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompat { model.compat.requiresAssistantContentForToolCalls ?? detected.requiresAssistantContentForToolCalls, openRouterRouting: model.compat.openRouterRouting ?? detected.openRouterRouting, vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting, + supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode, }; } diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index b5a8514fd..cc983a086 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -16,6 +16,7 @@ import { getEnvApiKey } from "../stream"; import type { Api, AssistantMessage, + CacheRetention, Context, Model, StopReason, @@ -35,12 +36,28 @@ import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice"; import { transformMessages } from "./transform-messages"; /** - * Get prompt cache retention based on PI_CACHE_RETENTION env var. - * Only applies to direct OpenAI API calls (api.openai.com). - * Returns '24h' for long retention, undefined for default (in-memory). + * Resolve cache retention preference. + * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. */ -function getPromptCacheRetention(baseUrl: string): "24h" | undefined { - if ($env.PI_CACHE_RETENTION === "long" && baseUrl.includes("api.openai.com")) { +function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { + if (cacheRetention) { + return cacheRetention; + } + if ($env.PI_CACHE_RETENTION === "long") { + return "long"; + } + return "short"; +} + +/** + * Get prompt cache retention based on cacheRetention and base URL. + * Only applies to direct OpenAI API calls (api.openai.com). + */ +function getPromptCacheRetention(baseUrl: string, cacheRetention: CacheRetention): "24h" | undefined { + if (cacheRetention !== "long") { + return undefined; + } + if (baseUrl.includes("api.openai.com")) { return "24h"; } return undefined; @@ -403,12 +420,13 @@ function buildParams(model: Model<"openai-responses">, context: Context, options const strictResponsesPairing = options?.strictResponsesPairing ?? isAzureOpenAIBaseUrl(model.baseUrl ?? ""); const messages = convertMessages(model, context, strictResponsesPairing); + const cacheRetention = resolveCacheRetention(options?.cacheRetention); const params: ResponseCreateParamsStreaming = { model: model.id, input: messages, stream: true, - prompt_cache_key: options?.sessionId, - prompt_cache_retention: getPromptCacheRetention(model.baseUrl), + prompt_cache_key: cacheRetention === "none" ? undefined : options?.sessionId, + prompt_cache_retention: getPromptCacheRetention(model.baseUrl, cacheRetention), }; if (options?.maxTokens) { diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 3b9985cfc..ef27a5366 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -280,6 +280,26 @@ function mapAnthropicToolChoice(choice?: ToolChoice): AnthropicOptions["toolChoi return undefined; } +/** + * Map ThinkingLevel to Anthropic effort levels for adaptive thinking (Opus 4.6+) + */ +function mapThinkingLevelToAnthropicEffort(level: ThinkingLevel): AnthropicOptions["effort"] { + switch (level) { + case "minimal": + return "low"; + case "low": + return "low"; + case "medium": + return "medium"; + case "high": + return "high"; + case "xhigh": + return "max"; + default: + return "high"; + } +} + function mapGoogleToolChoice( choice?: ToolChoice, ): GoogleOptions["toolChoice"] | GoogleGeminiCliOptions["toolChoice"] | GoogleVertexOptions["toolChoice"] { @@ -319,7 +339,9 @@ function mapOptionsForApi( maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32000), signal: options?.signal, apiKey: apiKey || options?.apiKey, + cacheRetention: options?.cacheRetention, headers: options?.headers, + maxRetryDelayMs: options?.maxRetryDelayMs, sessionId: options?.sessionId, onPayload: options?.onPayload, execHandlers: options?.execHandlers, @@ -349,6 +371,18 @@ function mapOptionsForApi( } as OptionsForApi; } + // For Opus 4.6+: use adaptive thinking with effort level + // For older models: use budget-based thinking + if (model.id.includes("opus-4-6") || model.id.includes("opus-4.6")) { + const effort = mapThinkingLevelToAnthropicEffort(reasoning); + return { + ...base, + thinkingEnabled: true, + effort, + toolChoice: mapAnthropicToolChoice(options?.toolChoice), + } as OptionsForApi; + } + if (ANTHROPIC_USE_INTERLEAVED_THINKING) { return { ...base, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index ad8e3aa82..783d33483 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -105,16 +105,31 @@ export type ToolChoice = | { type: "tool"; name: string }; // Base options all providers share +export type CacheRetention = "none" | "short" | "long"; + export interface StreamOptions { temperature?: number; maxTokens?: number; signal?: AbortSignal; apiKey?: string; + /** + * Prompt cache retention preference. Providers map this to their supported values. + * Default: "short". + */ + cacheRetention?: CacheRetention; /** * Additional headers to include in provider requests. * These are merged on top of model-defined headers. */ headers?: Record; + /** + * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. + * If the server's requested delay exceeds this value, the request fails immediately + * with an error containing the requested delay, allowing higher-level retry logic + * to handle it with user visibility. + * Default: 60000 (60 seconds). Set to 0 to disable the cap. + */ + maxRetryDelayMs?: number; /** * Optional session identifier for providers that support session-based caching. * Providers can use this to enable prompt caching, request routing, or other @@ -309,7 +324,8 @@ export interface OpenAICompat { /** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */ requiresMistralToolIds?: boolean; /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }. Default: "openai". */ - thinkingFormat?: "openai" | "zai"; + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }, "qwen" uses enable_thinking: boolean. Default: "openai". */ + thinkingFormat?: "openai" | "zai" | "qwen"; /** Which reasoning content field to emit on assistant messages. Default: auto-detected. */ reasoningContentField?: "reasoning_content" | "reasoning" | "reasoning_text"; /** Whether assistant tool-call messages must include reasoning content. Default: false. */ @@ -322,6 +338,8 @@ export interface OpenAICompat { openRouterRouting?: OpenRouterRouting; /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ vercelGatewayRouting?: VercelGatewayRouting; + /** Whether the provider supports the `strict` field in tool definitions. Default: true. */ + supportsStrictMode?: boolean; } /** diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 1cf8a936d..136729d4c 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -1,6 +1,7 @@ // ============================================================================ // High-level API // ============================================================================ + import { refreshAnthropicToken } from "./anthropic"; import { refreshCursorToken } from "./cursor"; import { refreshGitHubCopilotToken } from "./github-copilot"; diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index e89f7c688..d1bef3a78 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -29,6 +29,7 @@ const compat: Required = { requiresAssistantContentForToolCalls: false, openRouterRouting: {}, vercelGatewayRouting: {}, + supportsStrictMode: true, }; function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessage { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d83441ec2..bdec8f374 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,28 @@ # Changelog ## [Unreleased] + ### Added +- Support for `PI_PACKAGE_DIR` environment variable to override package directory (useful for Nix/Guix store paths) +- New keybindings for session management: `toggleSessionNamedFilter` (Ctrl+N), `newSession`, `tree`, `fork`, and `resume` actions +- Support for shell command execution in configuration values (API keys, headers) using `!` prefix, with result caching +- New `clearOnShrink` display setting to control whether empty rows are cleared when content shrinks +- New `SlashCommandInfo`, `SlashCommandLocation`, and `SlashCommandSource` types for extension slash command discovery +- New `getCommands()` method in ExtensionAPI to retrieve available slash commands +- New `switchSession()` action in ExtensionCommandContext to switch between sessions +- New `SwitchSessionHandler` type for extension session switching handlers +- New `getSystemPrompt()` method in ExtensionUIContext to access current system prompt +- New `getToolsExpanded()` and `setToolsExpanded()` methods in ExtensionUIContext for tool output expansion control +- New `WriteToolCallEvent` type for write tool call events +- New `isToolCallEventType()` type guard for tool call events +- Support for image content in RPC `steer` and `followUp` commands +- New `GitSource` type and `parseGitUrl()` function for parsing git URLs in plugin system +- Tool input types exported: `BashToolInput`, `FindToolInput`, `GrepToolInput`, `ReadToolInput`, `WriteToolInput` +- Support for `@` prefix normalization in file paths (strips leading `@` character) +- New `parentSessionPath` field in SessionInfo to track forked session origins +- Skill file relative path resolution against skill directory in system prompt +- Support for Termux/Android package installation guidance for missing tools - Support for puppeteer query handlers (aria/, text/, xpath/, pierce/) in selector parameters across all browser actions - Automatic normalization of legacy p- prefixed selectors (p-aria/, p-text/, p-xpath/, p-pierce/) to modern puppeteer query handler syntax - Improved click action with intelligent element selection that prioritizes visible, actionable candidates and retries until timeout @@ -10,15 +30,40 @@ ### Changed +- Updated default model IDs across providers: Claude Sonnet 4.5 → Claude Opus 4.6, Gemini 2.5 Pro → Gemini 3 Pro variants, and others +- Made model definition fields optional with sensible defaults for local models (Ollama, LM Studio, etc.) +- Modified custom tool execute signature to reorder parameters: `(toolCallId, params, signal, onUpdate, ctx)` instead of `(toolCallId, params, onUpdate, ctx, signal)` +- Changed `--version` and `--list-models` flags to exit with `process.exit(0)` instead of returning +- Improved `--export` flag to exit with `process.exit(0)` on success +- Enhanced tree selector to preserve last selected ID across filter changes +- Modified tree navigation to use real leaf ID instead of skipping metadata entries +- Improved footer path truncation logic to prevent invalid truncation at boundary +- Enhanced model selector to display selected model name when no matches found +- Improved RPC client `steer()` and `followUp()` methods to accept optional image content +- Updated extension loader to check for explicit extension entries in root directory before discovering subdirectories +- Removed line limiting in custom message component when collapsed +- Improved API key resolution to support shell command execution via `resolveConfigValue()` +- Enhanced session branching to preserve parent session path reference - Updated selector parameter descriptions to document support for CSS selectors and puppeteer query handlers - Modified viewport handling in headless mode to respect custom viewport parameters while disabling viewport in headed mode for better window management - Improved click action to use specialized text query handler logic with retry mechanism for better reliability with dynamic content ### Fixed +- Fixed HTML export template to safely handle invalid argument types in tool rendering +- Fixed path shortening in HTML export to handle non-string paths +- Fixed custom message rendering to properly display full content without artificial line limits +- Fixed tree navigation to only restore editor text when editor is empty +- Fixed session creation to properly track parent session when forking +- Fixed thinking level initialization to only append change entry for new sessions without existing thinking entries +- Fixed tool expansion state management to properly propagate through UI context - Fixed click action to properly handle text/ query handlers with timeout and retry logic instead of failing immediately - Fixed viewport application to only apply when in headless mode or when explicitly requested, preventing conflicts in headed browser mode +### Security + +- Added support for shell command execution in configuration values with caching to enable secure credential resolution patterns + ## [11.2.1] - 2026-02-05 ### Fixed diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 62d395d8c..abf94283c 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -87,9 +87,9 @@ "test": "bun test" }, "dependencies": { + "@mozilla/readability": "0.6.0", "@oclif/core": "^4.8.0", "@oclif/plugin-autocomplete": "^3.2.40", - "@mozilla/readability": "0.6.0", "@oh-my-pi/omp-stats": "workspace:*", "@oh-my-pi/pi-agent-core": "workspace:*", "@oh-my-pi/pi-ai": "workspace:*", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 7ec4fa50c..e474aa063 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -214,6 +214,7 @@ export function getExtraHelpText(): string { ${chalk.dim("# Configuration")} PI_CODING_AGENT_DIR - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent) + PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths) PI_SMOL_MODEL - Override smol/fast model (see --smol) PI_SLOW_MODEL - Override slow/reasoning model (see --slow) PI_PLAN_MODEL - Override planning model (see --plan) diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 60df6a55b..b8cd32213 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -35,6 +35,14 @@ const priorityList = [ * Walk up from import.meta.dir until we find package.json, or fall back to cwd. */ export function getPackageDir(): string { + // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) + const envDir = process.env.PI_PACKAGE_DIR; + if (envDir) { + if (envDir === "~") return os.homedir(); + if (envDir.startsWith("~/")) return os.homedir() + envDir.slice(1); + return envDir; + } + let dir = import.meta.dir; while (dir !== path.dirname(dir)) { if (fs.existsSync(path.join(dir, "package.json"))) { diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 4ed85edad..f63489949 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -26,11 +26,16 @@ export type AppAction = | "togglePlanMode" | "expandTools" | "toggleThinking" + | "toggleSessionNamedFilter" | "externalEditor" | "historySearch" | "followUp" | "dequeue" - | "pasteImage"; + | "pasteImage" + | "newSession" + | "tree" + | "fork" + | "resume"; /** * All configurable actions. @@ -60,10 +65,15 @@ export const DEFAULT_APP_KEYBINDINGS: Record = { historySearch: "ctrl+r", expandTools: "ctrl+o", toggleThinking: "ctrl+t", + toggleSessionNamedFilter: "ctrl+n", externalEditor: "ctrl+g", followUp: "ctrl+enter", dequeue: "alt+up", pasteImage: "ctrl+v", + newSession: [], + tree: [], + fork: [], + resume: [], }; /** @@ -88,10 +98,15 @@ const APP_ACTIONS: AppAction[] = [ "historySearch", "expandTools", "toggleThinking", + "toggleSessionNamedFilter", "externalEditor", "followUp", "dequeue", "pasteImage", + "newSession", + "tree", + "fork", + "resume", ]; function isAppAction(action: string): action is AppAction { diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 0bd87cbd2..2f0ba02eb 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -54,9 +54,10 @@ const OpenAICompatSchema = Type.Object({ }); // Schema for custom model definition +// Most fields are optional with sensible defaults for local models (Ollama, LM Studio, etc.) const ModelDefinitionSchema = Type.Object({ id: Type.String({ minLength: 1 }), - name: Type.String({ minLength: 1 }), + name: Type.Optional(Type.String({ minLength: 1 })), api: Type.Optional( Type.Union([ Type.Literal("openai-completions"), @@ -68,16 +69,18 @@ const ModelDefinitionSchema = Type.Object({ Type.Literal("google-vertex"), ]), ), - reasoning: Type.Boolean(), - input: Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")])), - cost: Type.Object({ - input: Type.Number(), - output: Type.Number(), - cacheRead: Type.Number(), - cacheWrite: Type.Number(), - }), - contextWindow: Type.Number(), - maxTokens: Type.Number(), + reasoning: Type.Optional(Type.Boolean()), + input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))), + cost: Type.Optional( + Type.Object({ + input: Type.Number(), + output: Type.Number(), + cacheRead: Type.Number(), + cacheWrite: Type.Number(), + }), + ), + contextWindow: Type.Optional(Type.Number()), + maxTokens: Type.Optional(Type.Number()), headers: Type.Optional(Type.Record(Type.String(), Type.String())), compat: Type.Optional(OpenAICompatSchema), }); @@ -141,10 +144,10 @@ export const ModelsConfigFile = new ConfigFile("models", ModelsCon } if (!modelDef.id) throw new Error(`Provider ${providerName}: model missing "id"`); - if (!modelDef.name) throw new Error(`Provider ${providerName}: model missing "name"`); - if (modelDef.contextWindow <= 0) + // Validate contextWindow/maxTokens only if provided (they have defaults) + if (modelDef.contextWindow !== undefined && modelDef.contextWindow <= 0) throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid contextWindow`); - if (modelDef.maxTokens <= 0) + if (modelDef.maxTokens !== undefined && modelDef.maxTokens <= 0) throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`); } } @@ -377,17 +380,19 @@ export class ModelRegistry { } // baseUrl is validated to exist for providers with models + // Apply defaults for optional fields + const defaultCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; models.push({ id: modelDef.id, - name: modelDef.name, + name: modelDef.name ?? modelDef.id, api: api as Api, provider: providerName, baseUrl: providerConfig.baseUrl!, - reasoning: modelDef.reasoning, - input: modelDef.input as ("text" | "image")[], - cost: modelDef.cost, - contextWindow: modelDef.contextWindow, - maxTokens: modelDef.maxTokens, + reasoning: modelDef.reasoning ?? false, + input: (modelDef.input ?? ["text"]) as ("text" | "image")[], + cost: modelDef.cost ?? defaultCost, + contextWindow: modelDef.contextWindow ?? 128000, + maxTokens: modelDef.maxTokens ?? 16384, headers, compat: modelDef.compat, } as Model); diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 55694f896..352aa069d 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -11,25 +11,25 @@ import type { Settings } from "./settings"; /** Default model IDs for each known provider */ export const defaultModelPerProvider: Record = { - "amazon-bedrock": "us.anthropic.claude-sonnet-4-5-20250514-v1:0", - anthropic: "claude-sonnet-4-5", + "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1", + anthropic: "claude-opus-4-6", openai: "gpt-5.1-codex", - "openai-codex": "codex-max", + "openai-codex": "gpt-5.3-codex", google: "gemini-2.5-pro", "google-gemini-cli": "gemini-2.5-pro", "google-antigravity": "gemini-3-pro-high", - "google-vertex": "gemini-2.5-pro", + "google-vertex": "gemini-3-pro-preview", "github-copilot": "gpt-4o", - cursor: "claude-sonnet-4-5", + cursor: "claude-opus-4-6", openrouter: "openai/gpt-5.1-codex", - "vercel-ai-gateway": "claude-sonnet-4-5", + "vercel-ai-gateway": "anthropic/claude-opus-4-6", xai: "grok-4-fast-non-reasoning", groq: "openai/gpt-oss-120b", cerebras: "zai-glm-4.6", zai: "glm-4.6", mistral: "devstral-medium-latest", - minimax: "MiniMax-M2", - opencode: "claude-opus-4-5", + minimax: "MiniMax-M2.1", + opencode: "claude-opus-4-6", "kimi-code": "kimi-k2.5", }; diff --git a/packages/coding-agent/src/config/resolve-config-value.ts b/packages/coding-agent/src/config/resolve-config-value.ts new file mode 100644 index 000000000..cf506305f --- /dev/null +++ b/packages/coding-agent/src/config/resolve-config-value.ts @@ -0,0 +1,64 @@ +/** + * Resolve configuration values that may be shell commands, environment variables, or literals. + * Used by auth-storage.ts and model-registry.ts. + */ + +import * as childProcess from "node:child_process"; + +/** Cache for shell command results (persists for process lifetime) */ +const commandResultCache = new Map(); + +/** + * Resolve a config value (API key, header value, etc.) to an actual value. + * - If starts with "!", executes the rest as a shell command and uses stdout (cached) + * - Otherwise checks environment variable first, then treats as literal (not cached) + */ +export function resolveConfigValue(config: string): string | undefined { + if (config.startsWith("!")) { + return executeCommand(config); + } + const envValue = process.env[config]; + return envValue || config; +} + +function executeCommand(commandConfig: string): string | undefined { + if (commandResultCache.has(commandConfig)) { + return commandResultCache.get(commandConfig); + } + + const command = commandConfig.slice(1); + let result: string | undefined; + try { + const output = childProcess.execSync(command, { + encoding: "utf-8", + timeout: 10000, + stdio: ["ignore", "pipe", "ignore"], + }); + result = output.trim() || undefined; + } catch { + result = undefined; + } + + commandResultCache.set(commandConfig, result); + return result; +} + +/** + * Resolve all header values using the same resolution logic as API keys. + */ +export function resolveHeaders(headers: Record | undefined): Record | undefined { + if (!headers) return undefined; + const resolved: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const resolvedValue = resolveConfigValue(value); + if (resolvedValue) { + resolved[key] = resolvedValue; + } + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +/** Clear the config value command cache. Exported for testing. */ +export function clearConfigValueCache(): void { + commandResultCache.clear(); +} diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 653be4371..d61945850 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -242,6 +242,15 @@ export const SETTINGS_SCHEMA = { default: true, // will be computed based on platform if undefined ui: { tab: "display", label: "Hardware cursor", description: "Show terminal cursor for IME support" }, }, + clearOnShrink: { + type: "boolean", + default: false, + ui: { + tab: "display", + label: "Clear on shrink", + description: "Clear empty rows when content shrinks (may cause flicker)", + }, + }, extensions: { type: "array", default: [] as string[] }, enabledModels: { type: "array", default: [] as string[] }, disabledProviders: { type: "array", default: [] as string[] }, diff --git a/packages/coding-agent/src/export/html/template.css b/packages/coding-agent/src/export/html/template.css index 128abece1..d1d40b63f 100644 --- a/packages/coding-agent/src/export/html/template.css +++ b/packages/coding-agent/src/export/html/template.css @@ -473,6 +473,10 @@ display: block; } + .ansi-line { + white-space: pre-wrap; + } + .tool-images { } @@ -666,6 +670,9 @@ color: var(--error); padding: 0 var(--line-height); } + .tool-error { + color: var(--error); + } /* Images */ .message-images { diff --git a/packages/coding-agent/src/export/html/template.js b/packages/coding-agent/src/export/html/template.js index fe6624381..64f9e2815 100644 --- a/packages/coding-agent/src/export/html/template.js +++ b/packages/coding-agent/src/export/html/template.js @@ -376,6 +376,7 @@ // ============================================================ function shortenPath(p) { + if (typeof p !== 'string') return ''; if (p.startsWith('/Users/')) { const parts = p.split('/'); if (parts.length > 2) return '~' + p.slice(('/Users/' + parts[2]).length); @@ -603,6 +604,13 @@ return text.replace(/\t/g, ' '); } + /** Safely coerce value to string for display. Returns null if invalid type. */ + function str(value) { + if (typeof value === 'string') return value; + if (value == null) return ''; + return null; + } + function getLanguageFromPath(filePath) { const ext = filePath.split('.').pop()?.toLowerCase(); const extToLang = { @@ -712,10 +720,13 @@ const args = call.arguments || {}; const name = call.name; + const invalidArg = '[invalid arg]'; + switch (name) { case 'bash': { - const command = args.command || ''; - html += `
$ ${escapeHtml(command)}
`; + const command = str(args.command); + const cmdDisplay = command === null ? invalidArg : escapeHtml(command || '...'); + html += `
$ ${cmdDisplay}
`; if (result) { const output = getResultText().trim(); if (output) html += formatExpandableOutput(output, 5); @@ -723,13 +734,12 @@ break; } case 'read': { - const filePath = args.file_path || args.path || ''; + const filePath = str(args.file_path ?? args.path); const offset = args.offset; const limit = args.limit; - const lang = getLanguageFromPath(filePath); - let pathHtml = escapeHtml(shortenPath(filePath)); - if (offset !== undefined || limit !== undefined) { + let pathHtml = filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || '')); + if (filePath !== null && (offset !== undefined || limit !== undefined)) { const startLine = offset ?? 1; const endLine = limit !== undefined ? startLine + limit - 1 : ''; pathHtml += `:${startLine}${endLine ? '-' + endLine : ''}`; @@ -739,21 +749,28 @@ if (result) { html += renderResultImages(); const output = getResultText(); + const lang = filePath ? getLanguageFromPath(filePath) : null; if (output) html += formatExpandableOutput(output, 10, lang); } break; } case 'write': { - const filePath = args.file_path || args.path || ''; - const content = args.content || ''; - const lines = content.split('\n'); - const lang = getLanguageFromPath(filePath); + const filePath = str(args.file_path ?? args.path); + const content = str(args.content); - html += `
write ${escapeHtml(shortenPath(filePath))}`; - if (lines.length > 10) html += ` (${lines.length} lines)`; + html += `
write ${filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || ''))}`; + if (content !== null && content) { + const lines = content.split('\n'); + if (lines.length > 10) html += ` (${lines.length} lines)`; + } html += '
'; - if (content) html += formatExpandableOutput(content, 10, lang); + if (content === null) { + html += `
[invalid content arg - expected string]
`; + } else if (content) { + const lang = filePath ? getLanguageFromPath(filePath) : null; + html += formatExpandableOutput(content, 10, lang); + } if (result) { const output = getResultText().trim(); if (output) html += `
${escapeHtml(output)}
`; @@ -761,8 +778,8 @@ break; } case 'edit': { - const filePath = args.file_path || args.path || ''; - html += `
edit ${escapeHtml(shortenPath(filePath))}
`; + const filePath = str(args.file_path ?? args.path); + html += `
edit ${filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || ''))}
`; if (result?.details?.diff) { const diffLines = result.details.diff.split('\n'); diff --git a/packages/coding-agent/src/extensibility/extensions/index.ts b/packages/coding-agent/src/extensibility/extensions/index.ts index be642929f..1e9f381d2 100644 --- a/packages/coding-agent/src/extensibility/extensions/index.ts +++ b/packages/coding-agent/src/extensibility/extensions/index.ts @@ -2,6 +2,7 @@ * Extension system for lifecycle events and custom tools. */ +export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from "../slash-commands"; export { discoverAndLoadExtensions, ExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./loader"; export type { BranchHandler, @@ -9,6 +10,7 @@ export type { NavigateTreeHandler, NewSessionHandler, ShutdownHandler, + SwitchSessionHandler, } from "./runner"; export { ExtensionRunner } from "./runner"; export type { @@ -19,6 +21,8 @@ export type { AgentToolUpdateCallback, AppAction, AppendEntryHandler, + // Events - Tool (ToolCallEvent types) + BashToolCallEvent, BashToolResultEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, @@ -26,7 +30,10 @@ export type { ContextEvent, // Event Results ContextEventResult, + ContextUsage, + CustomToolCallEvent, CustomToolResultEvent, + EditToolCallEvent, EditToolResultEvent, ExecOptions, ExecResult, @@ -47,11 +54,15 @@ export type { ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, + FindToolCallEvent, FindToolResultEvent, GetActiveToolsHandler, GetAllToolsHandler, + GetCommandsHandler, GetThinkingLevelHandler, + GrepToolCallEvent, GrepToolResultEvent, + // Events - Input InputEvent, InputEventResult, KeybindingsManager, @@ -59,10 +70,14 @@ export type { // Message Rendering MessageRenderer, MessageRenderOptions, + ReadToolCallEvent, ReadToolResultEvent, // Commands RegisteredCommand, RegisteredTool, + // Events - Resources + ResourcesDiscoverEvent, + ResourcesDiscoverResult, SendMessageHandler, SendUserMessageHandler, SessionBeforeBranchEvent, @@ -101,6 +116,9 @@ export type { UserBashEventResult, UserPythonEvent, UserPythonEventResult, + WriteToolCallEvent, WriteToolResultEvent, } from "./types"; +// Type guards +export { isToolCallEventType } from "./types"; export { ExtensionToolWrapper, RegisteredToolAdapter, wrapRegisteredTool, wrapRegisteredTools } from "./wrapper"; diff --git a/packages/coding-agent/src/extensibility/extensions/loader.ts b/packages/coding-agent/src/extensibility/extensions/loader.ts index f177910eb..1241a7725 100644 --- a/packages/coding-agent/src/extensibility/extensions/loader.ts +++ b/packages/coding-agent/src/extensibility/extensions/loader.ts @@ -81,6 +81,10 @@ export class ExtensionRuntime implements IExtensionRuntime { throw new ExtensionRuntimeNotInitializedError(); } + getCommands(): never { + throw new ExtensionRuntimeNotInitializedError(); + } + setModel(): Promise { throw new ExtensionRuntimeNotInitializedError(); } @@ -203,6 +207,10 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime { return this.runtime.setActiveTools(toolNames); } + getCommands() { + return this.runtime.getCommands(); + } + setModel(model: Model): Promise { return this.runtime.setModel(model); } @@ -396,6 +404,13 @@ async function resolveExtensionEntries(dir: string): Promise { async function discoverExtensionsInDir(dir: string): Promise { const discovered: string[] = []; + // First check if this directory itself has explicit extension entries (package.json or index) + const rootEntries = await resolveExtensionEntries(dir); + if (rootEntries) { + return rootEntries; + } + + // Otherwise, discover extensions from directory contents let entries: fs1.Dirent[]; try { entries = await fs.readdir(dir, { withFileTypes: true }); diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index eeab115b2..49a213ab7 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -32,6 +32,8 @@ import type { MessageRenderer, RegisteredCommand, RegisteredTool, + ResourcesDiscoverEvent, + ResourcesDiscoverResult, SessionBeforeCompactResult, SessionBeforeTreeResult, SessionCompactingResult, @@ -64,6 +66,8 @@ export type NavigateTreeHandler = ( options?: { summarize?: boolean }, ) => Promise<{ cancelled: boolean }>; +export type SwitchSessionHandler = (sessionPath: string) => Promise<{ cancelled: boolean }>; + export type ShutdownHandler = () => void; /** @@ -102,6 +106,8 @@ const noOpUIContext: ExtensionUIContext = { getAllThemes: () => Promise.resolve([]), getTheme: () => Promise.resolve(undefined), setTheme: (_theme: string | Theme) => Promise.resolve({ success: false, error: "UI not available" }), + getToolsExpanded: () => false, + setToolsExpanded: () => {}, }; export class ExtensionRunner { @@ -114,10 +120,13 @@ export class ExtensionRunner { private hasPendingMessagesFn: () => boolean = () => false; private getContextUsageFn: () => ContextUsage | undefined = () => undefined; private compactFn: (instructionsOrOptions?: string | CompactOptions) => Promise = async () => {}; + private getSystemPromptFn: () => string = () => ""; private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); private branchHandler: BranchHandler = async () => ({ cancelled: false }); private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); + private switchSessionHandler: SwitchSessionHandler = async () => ({ cancelled: false }); private shutdownHandler: ShutdownHandler = () => {}; + private commandDiagnostics: Array<{ type: string; message: string; path: string }> = []; constructor( private readonly extensions: Extension[], @@ -142,6 +151,7 @@ export class ExtensionRunner { this.runtime.getActiveTools = actions.getActiveTools; this.runtime.getAllTools = actions.getAllTools; this.runtime.setActiveTools = actions.setActiveTools; + this.runtime.getCommands = actions.getCommands; this.runtime.setModel = actions.setModel; this.runtime.getThinkingLevel = actions.getThinkingLevel; this.runtime.setThinkingLevel = actions.setThinkingLevel; @@ -152,6 +162,7 @@ export class ExtensionRunner { this.abortFn = contextActions.abort; this.hasPendingMessagesFn = contextActions.hasPendingMessages; this.shutdownHandler = contextActions.shutdown; + this.getSystemPromptFn = contextActions.getSystemPrompt; // Command context actions (optional, only for interactive mode) if (commandContextActions) { @@ -159,6 +170,7 @@ export class ExtensionRunner { this.newSessionHandler = commandContextActions.newSession; this.branchHandler = commandContextActions.branch; this.navigateTreeHandler = commandContextActions.navigateTree; + this.switchSessionHandler = commandContextActions.switchSession; this.getContextUsageFn = commandContextActions.getContextUsage; this.compactFn = commandContextActions.compact; } @@ -279,16 +291,31 @@ export class ExtensionRunner { return undefined; } - getRegisteredCommands(): RegisteredCommand[] { + getRegisteredCommands(reserved?: Set): RegisteredCommand[] { + this.commandDiagnostics = []; + const commands: RegisteredCommand[] = []; for (const ext of this.extensions) { for (const command of ext.commands.values()) { + if (reserved?.has(command.name)) { + const message = `Extension command '${command.name}' from ${ext.path} conflicts with built-in commands. Skipping.`; + this.commandDiagnostics.push({ type: "warning", message, path: ext.path }); + if (!this.hasUI()) { + logger.warn(message); + } + continue; + } + commands.push(command); } } return commands; } + getCommandDiagnostics(): Array<{ type: string; message: string; path: string }> { + return this.commandDiagnostics; + } + getCommand(name: string): RegisteredCommand | undefined { for (const ext of this.extensions) { const command = ext.commands.get(name); @@ -316,6 +343,7 @@ export class ExtensionRunner { abort: () => this.abortFn(), hasPendingMessages: () => this.hasPendingMessagesFn(), shutdown: () => this.shutdownHandler(), + getSystemPrompt: () => this.getSystemPromptFn(), hasQueuedMessages: () => this.hasPendingMessagesFn(), // deprecated alias }; } @@ -335,6 +363,7 @@ export class ExtensionRunner { newSession: options => this.newSessionHandler(options), branch: entryId => this.branchHandler(entryId), navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options), + switchSession: sessionPath => this.switchSessionHandler(sessionPath), compact: instructionsOrOptions => this.compactFn(instructionsOrOptions), }; } @@ -493,6 +522,54 @@ export class ExtensionRunner { return undefined; } + async emitResourcesDiscover( + cwd: string, + reason: ResourcesDiscoverEvent["reason"], + ): Promise<{ + skillPaths: Array<{ path: string; extensionPath: string }>; + promptPaths: Array<{ path: string; extensionPath: string }>; + themePaths: Array<{ path: string; extensionPath: string }>; + }> { + const ctx = this.createContext(); + const skillPaths: Array<{ path: string; extensionPath: string }> = []; + const promptPaths: Array<{ path: string; extensionPath: string }> = []; + const themePaths: Array<{ path: string; extensionPath: string }> = []; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("resources_discover"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const event: ResourcesDiscoverEvent = { type: "resources_discover", cwd, reason }; + const handlerResult = await handler(event, ctx); + const result = handlerResult as ResourcesDiscoverResult | undefined; + + if (result?.skillPaths?.length) { + skillPaths.push(...result.skillPaths.map(path => ({ path, extensionPath: ext.path }))); + } + if (result?.promptPaths?.length) { + promptPaths.push(...result.promptPaths.map(path => ({ path, extensionPath: ext.path }))); + } + if (result?.themePaths?.length) { + themePaths.push(...result.themePaths.map(path => ({ path, extensionPath: ext.path }))); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "resources_discover", + error: message, + stack, + }); + } + } + } + + return { skillPaths, promptPaths, themePaths }; + } + /** Emit input event. Transforms chain, "handled" short-circuits. */ async emitInput( text: string, diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 1f7c72cff..17b20e26a 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -28,8 +28,19 @@ import type { SessionEntry, SessionManager, } from "../../session/session-manager"; -import type { BashToolDetails, FindToolDetails, GrepToolDetails, ReadToolDetails } from "../../tools"; +import type { + BashToolDetails, + BashToolInput, + FindToolDetails, + FindToolInput, + GrepToolDetails, + GrepToolInput, + ReadToolDetails, + ReadToolInput, + WriteToolInput, +} from "../../tools"; import type { EventBus } from "../../utils/event-bus"; +import type { SlashCommandInfo } from "../slash-commands"; export type { AppAction, KeybindingsManager } from "../../config/keybindings"; export type { ExecOptions, ExecResult } from "../../exec/exec"; @@ -123,6 +134,12 @@ export interface ExtensionUIContext { /** Set the current theme by name or Theme object. */ setTheme(theme: string | Theme): Promise<{ success: boolean; error?: string }>; + + /** Get current tool output expansion state. */ + getToolsExpanded(): boolean; + + /** Set tool output expansion state. */ + setToolsExpanded(expanded: boolean): void; } // ============================================================================ @@ -171,6 +188,8 @@ export interface ExtensionContext { hasPendingMessages(): boolean; /** Gracefully shutdown and exit. */ shutdown(): void; + /** Get the current effective system prompt. */ + getSystemPrompt(): string; /** @deprecated Use hasPendingMessages() instead */ hasQueuedMessages(): boolean; } @@ -198,6 +217,9 @@ export interface ExtensionCommandContext extends ExtensionContext { /** Navigate to a different point in the session tree. */ navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>; + /** Switch to a different session file. */ + switchSession(sessionPath: string): Promise<{ cancelled: boolean }>; + /** Compact the session context (interactive mode shows UI). */ compact(instructionsOrOptions?: string | CompactOptions): Promise; } @@ -243,9 +265,9 @@ export interface ToolDefinition, + signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, ctx: ExtensionContext, - signal?: AbortSignal, ): Promise>; /** Called on session lifecycle events - use to reconstruct state or cleanup resources */ @@ -263,6 +285,24 @@ export interface ToolDefinition Component; } +// ============================================================================ +// Resource Events +// ============================================================================ + +/** Fired after session_start to allow extensions to provide additional resource paths. */ +export interface ResourcesDiscoverEvent { + type: "resources_discover"; + cwd: string; + reason: "startup" | "reload"; +} + +/** Result from resources_discover event handler */ +export interface ResourcesDiscoverResult { + skillPaths?: string[]; + promptPaths?: string[]; + themePaths?: string[]; +} + // ============================================================================ // Session Events // ============================================================================ @@ -454,14 +494,56 @@ export interface InputEvent { // Tool Events // ============================================================================ -/** Fired before a tool executes. Can block. */ -export interface ToolCallEvent { +interface ToolCallEventBase { type: "tool_call"; - toolName: string; toolCallId: string; +} + +export interface BashToolCallEvent extends ToolCallEventBase { + toolName: "bash"; + input: BashToolInput; +} + +export interface ReadToolCallEvent extends ToolCallEventBase { + toolName: "read"; + input: ReadToolInput; +} + +export interface EditToolCallEvent extends ToolCallEventBase { + toolName: "edit"; input: Record; } +export interface WriteToolCallEvent extends ToolCallEventBase { + toolName: "write"; + input: WriteToolInput; +} + +export interface GrepToolCallEvent extends ToolCallEventBase { + toolName: "grep"; + input: GrepToolInput; +} + +export interface FindToolCallEvent extends ToolCallEventBase { + toolName: "find"; + input: FindToolInput; +} + +export interface CustomToolCallEvent extends ToolCallEventBase { + toolName: string; + input: Record; +} + +/** Fired before a tool executes. Can block. */ +export type ToolCallEvent = + | BashToolCallEvent + | ReadToolCallEvent + | EditToolCallEvent + | WriteToolCallEvent + | GrepToolCallEvent + | FindToolCallEvent + | CustomToolCallEvent; + interface ToolResultEventBase { type: "tool_result"; toolCallId: string; @@ -515,8 +597,43 @@ export type ToolResultEvent = | FindToolResultEvent | CustomToolResultEvent; +/** + * Type guard for narrowing ToolCallEvent by tool name. + * + * Built-in tools narrow automatically (no type params needed): + * ```ts + * if (isToolCallEventType("bash", event)) { + * event.input.command; // string + * } + * ``` + * + * Custom tools require explicit type parameters: + * ```ts + * if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) { + * event.input.action; // typed + * } + * ``` + * + * Note: Direct narrowing via `event.toolName === "bash"` doesn't work because + * CustomToolCallEvent.toolName is `string` which overlaps with all literals. + */ +export function isToolCallEventType(toolName: "bash", event: ToolCallEvent): event is BashToolCallEvent; +export function isToolCallEventType(toolName: "read", event: ToolCallEvent): event is ReadToolCallEvent; +export function isToolCallEventType(toolName: "edit", event: ToolCallEvent): event is EditToolCallEvent; +export function isToolCallEventType(toolName: "write", event: ToolCallEvent): event is WriteToolCallEvent; +export function isToolCallEventType(toolName: "grep", event: ToolCallEvent): event is GrepToolCallEvent; +export function isToolCallEventType(toolName: "find", event: ToolCallEvent): event is FindToolCallEvent; +export function isToolCallEventType>( + toolName: TName, + event: ToolCallEvent, +): event is ToolCallEvent & { toolName: TName; input: TInput }; +export function isToolCallEventType(toolName: string, event: ToolCallEvent): boolean { + return event.toolName === toolName; +} + /** Union of all event types */ export type ExtensionEvent = + | ResourcesDiscoverEvent | SessionEvent | ContextEvent | BeforeAgentStartEvent @@ -659,6 +776,7 @@ export interface ExtensionAPI { // Event Subscription // ========================================================================= + on(event: "resources_discover", handler: ExtensionHandler): void; on(event: "session_start", handler: ExtensionHandler): void; on( event: "session_before_switch", @@ -775,6 +893,9 @@ export interface ExtensionAPI { /** Set the active tools by name. */ setActiveTools(toolNames: string[]): Promise; + /** Get available slash commands in the current session. */ + getCommands(): SlashCommandInfo[]; + /** Set the current model. Returns false if no API key available. */ setModel(model: Model): Promise; @@ -833,6 +954,8 @@ export type GetActiveToolsHandler = () => string[]; export type GetAllToolsHandler = () => string[]; +export type GetCommandsHandler = () => SlashCommandInfo[]; + export type SetActiveToolsHandler = (toolNames: string[]) => Promise; export type SetModelHandler = (model: Model) => Promise; @@ -855,6 +978,7 @@ export interface ExtensionActions { getActiveTools: GetActiveToolsHandler; getAllTools: GetAllToolsHandler; setActiveTools: SetActiveToolsHandler; + getCommands: GetCommandsHandler; setModel: SetModelHandler; getThinkingLevel: GetThinkingLevelHandler; setThinkingLevel: SetThinkingLevelHandler; @@ -869,6 +993,7 @@ export interface ExtensionContextActions { shutdown: () => void; getContextUsage: () => ContextUsage | undefined; compact: (instructionsOrOptions?: string | CompactOptions) => Promise; + getSystemPrompt: () => string; } /** Actions for ExtensionCommandContext (ctx.* in command handlers). */ @@ -882,6 +1007,7 @@ export interface ExtensionCommandContextActions { branch: (entryId: string) => Promise<{ cancelled: boolean }>; navigateTree: (targetId: string, options?: { summarize?: boolean }) => Promise<{ cancelled: boolean }>; compact: (instructionsOrOptions?: string | CompactOptions) => Promise; + switchSession: (sessionPath: string) => Promise<{ cancelled: boolean }>; } /** Full runtime = state + actions. */ diff --git a/packages/coding-agent/src/extensibility/extensions/wrapper.ts b/packages/coding-agent/src/extensibility/extensions/wrapper.ts index 64ce0ad72..184ac1d15 100644 --- a/packages/coding-agent/src/extensibility/extensions/wrapper.ts +++ b/packages/coding-agent/src/extensibility/extensions/wrapper.ts @@ -32,7 +32,7 @@ export class RegisteredToolAdapter implements AgentTool { onUpdate?: AgentToolUpdateCallback, _context?: AgentToolContext, ) { - return this.registeredTool.definition.execute(toolCallId, params, onUpdate, this.runner.createContext(), signal); + return this.registeredTool.definition.execute(toolCallId, params, signal, onUpdate, this.runner.createContext()); } renderCall?(args: any, theme: any) { diff --git a/packages/coding-agent/src/extensibility/plugins/git-url.ts b/packages/coding-agent/src/extensibility/plugins/git-url.ts new file mode 100644 index 000000000..821d12e08 --- /dev/null +++ b/packages/coding-agent/src/extensibility/plugins/git-url.ts @@ -0,0 +1,218 @@ +/** + * Parsed git URL information. + */ +export type GitSource = { + /** Always "git" for git sources */ + type: "git"; + /** Clone URL (always valid for git clone, without ref suffix) */ + repo: string; + /** Git host domain (e.g., "github.com") */ + host: string; + /** Repository path (e.g., "user/repo") */ + path: string; + /** Git ref (branch, tag, commit) if specified */ + ref?: string; + /** True if ref was specified (package won't be auto-updated) */ + pinned: boolean; +}; + +/** Known git hosts and their URL extraction logic. */ +const KNOWN_HOSTS: Record { user: string; project: string } | null> = { + "github.com": extractStandard, + "gitlab.com": extractGitLab, + "bitbucket.org": extractStandard, + "git.sr.ht": extractStandard, + "codeberg.org": extractStandard, +}; + +function extractStandard(pathname: string, _hash: string): { user: string; project: string } | null { + const [, user, project] = pathname.split("/", 3); + if (!user || !project) return null; + return { user, project: project.replace(/\.git$/, "") }; +} + +function extractGitLab(pathname: string, _hash: string): { user: string; project: string } | null { + const path = pathname.startsWith("/") ? pathname.slice(1) : pathname; + if (path.includes("/-/") || path.includes("/archive.tar.gz")) return null; + const segments = path.split("/"); + let project = segments.pop(); + if (!project) return null; + project = project.replace(/\.git$/, ""); + const user = segments.join("/"); + if (!user || !project) return null; + return { user, project }; +} + +/** + * Try to parse a URL against known git hosts. + * Returns `{ domain, user, project, committish }` or null. + */ +function tryKnownHost(candidate: string): { domain: string; user: string; project: string; committish: string } | null { + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return null; + } + + const hostname = parsed.hostname.startsWith("www.") ? parsed.hostname.slice(4) : parsed.hostname; + const extractor = KNOWN_HOSTS[hostname]; + if (!extractor) return null; + + const segments = extractor(parsed.pathname, parsed.hash); + if (!segments) return null; + + return { + domain: hostname, + user: segments.user, + project: segments.project, + committish: parsed.hash ? decodeURIComponent(parsed.hash.slice(1)) : "", + }; +} + +function splitRef(url: string): { repo: string; ref?: string } { + const scpLikeMatch = url.match(/^git@([^:]+):(.+)$/); + if (scpLikeMatch) { + const pathWithMaybeRef = scpLikeMatch[2] ?? ""; + const refSeparator = pathWithMaybeRef.indexOf("@"); + if (refSeparator < 0) return { repo: url }; + const repoPath = pathWithMaybeRef.slice(0, refSeparator); + const ref = pathWithMaybeRef.slice(refSeparator + 1); + if (!repoPath || !ref) return { repo: url }; + return { + repo: `git@${scpLikeMatch[1] ?? ""}:${repoPath}`, + ref, + }; + } + + if (url.includes("://")) { + try { + const parsed = new URL(url); + const pathWithMaybeRef = parsed.pathname.replace(/^\/+/, ""); + const refSeparator = pathWithMaybeRef.indexOf("@"); + if (refSeparator < 0) return { repo: url }; + const repoPath = pathWithMaybeRef.slice(0, refSeparator); + const ref = pathWithMaybeRef.slice(refSeparator + 1); + if (!repoPath || !ref) return { repo: url }; + parsed.pathname = `/${repoPath}`; + return { + repo: parsed.toString().replace(/\/$/, ""), + ref, + }; + } catch { + return { repo: url }; + } + } + + const slashIndex = url.indexOf("/"); + if (slashIndex < 0) return { repo: url }; + const host = url.slice(0, slashIndex); + const pathWithMaybeRef = url.slice(slashIndex + 1); + const refSeparator = pathWithMaybeRef.indexOf("@"); + if (refSeparator < 0) return { repo: url }; + const repoPath = pathWithMaybeRef.slice(0, refSeparator); + const ref = pathWithMaybeRef.slice(refSeparator + 1); + if (!repoPath || !ref) return { repo: url }; + return { repo: `${host}/${repoPath}`, ref }; +} + +/** Try known-host parsing and build a GitSource from the result. */ +function tryKnownHostSource( + split: { repo: string; ref?: string }, + candidate: string, + repoUrl: string, +): GitSource | null { + const info = tryKnownHost(candidate); + if (!info) return null; + if (split.ref && info.project.includes("@")) return null; + return { + type: "git", + repo: repoUrl, + host: info.domain, + path: `${info.user}/${info.project}`.replace(/\.git$/, ""), + ref: info.committish || split.ref || undefined, + pinned: Boolean(info.committish || split.ref), + }; +} + +function parseGenericGitUrl(url: string): GitSource | null { + const { repo: repoWithoutRef, ref } = splitRef(url); + let repo = repoWithoutRef; + let host = ""; + let repoPath = ""; + + const scpLikeMatch = repoWithoutRef.match(/^git@([^:]+):(.+)$/); + if (scpLikeMatch) { + host = scpLikeMatch[1] ?? ""; + repoPath = scpLikeMatch[2] ?? ""; + } else if (/^https?:\/\/|^ssh:\/\//.test(repoWithoutRef)) { + try { + const parsed = new URL(repoWithoutRef); + host = parsed.hostname; + repoPath = parsed.pathname.replace(/^\/+/, ""); + } catch { + return null; + } + } else { + const slashIndex = repoWithoutRef.indexOf("/"); + if (slashIndex < 0) return null; + host = repoWithoutRef.slice(0, slashIndex); + repoPath = repoWithoutRef.slice(slashIndex + 1); + if (!host.includes(".") && host !== "localhost") return null; + repo = `https://${repoWithoutRef}`; + } + + const normalizedPath = repoPath.replace(/\.git$/, "").replace(/^\/+/, ""); + if (!host || !normalizedPath || normalizedPath.split("/").length < 2) return null; + + return { type: "git", repo, host, path: normalizedPath, ref, pinned: Boolean(ref) }; +} + +/** + * Parse any git URL (SSH or HTTPS) into a GitSource. + * + * Handles: + * - `git:` prefixed URLs (`git:github.com/user/repo`) + * - SSH SCP-like URLs (`git@github.com:user/repo`) + * - HTTPS/HTTP/SSH protocol URLs + * - Bare `host/user/repo` shorthand + * - Ref pinning via `@ref` suffix + * + * Recognizes GitHub, GitLab, Bitbucket, Sourcehut, and Codeberg natively. + * Falls back to generic URL parsing for other hosts. + */ +export function parseGitUrl(source: string): GitSource | null { + const url = source.startsWith("git:") ? source.slice(4).trim() : source; + const split = splitRef(url); + + // SCP-like SSH URLs (git@host:user/repo) — convert to https for host matching + const scpMatch = split.repo.match(/^git@([^:]+):(.+)$/); + + // Try known hosts with the repo URL directly + const directCandidates: string[] = []; + if (scpMatch) { + directCandidates.push(`https://${scpMatch[1]}/${scpMatch[2]}`); + } else if (/^https?:\/\/|^ssh:\/\//.test(split.repo)) { + directCandidates.push(split.repo); + } + + for (const candidate of directCandidates) { + const withRef = split.ref ? `${candidate.replace(/#.*$/, "")}#${split.ref}` : candidate; + const needsHttps = + !split.repo.startsWith("http://") && + !split.repo.startsWith("https://") && + !split.repo.startsWith("ssh://") && + !split.repo.startsWith("git@"); + const result = tryKnownHostSource(split, withRef, needsHttps ? `https://${split.repo}` : split.repo); + if (result) return result; + } + + // Try with https:// prefix for bare host/user/repo shorthand + if (!split.repo.includes("://") && !split.repo.startsWith("git@")) { + const httpsCandidate = split.ref ? `https://${split.repo}#${split.ref}` : `https://${url}`; + const result = tryKnownHostSource(split, httpsCandidate, `https://${split.repo}`); + if (result) return result; + } + + return parseGenericGitUrl(url); +} diff --git a/packages/coding-agent/src/extensibility/plugins/index.ts b/packages/coding-agent/src/extensibility/plugins/index.ts index 45adcd72f..c3b3c0e56 100644 --- a/packages/coding-agent/src/extensibility/plugins/index.ts +++ b/packages/coding-agent/src/extensibility/plugins/index.ts @@ -1,5 +1,7 @@ // Plugin system exports + export { formatDoctorResults, runDoctorChecks } from "./doctor"; +export { type GitSource, parseGitUrl } from "./git-url"; export { getAllPluginCommandPaths, getAllPluginHookPaths, diff --git a/packages/coding-agent/src/extensibility/slash-commands.ts b/packages/coding-agent/src/extensibility/slash-commands.ts index cd91ada58..5c08ef154 100644 --- a/packages/coding-agent/src/extensibility/slash-commands.ts +++ b/packages/coding-agent/src/extensibility/slash-commands.ts @@ -1,3 +1,48 @@ +export type SlashCommandSource = "extension" | "prompt" | "skill"; + +export type SlashCommandLocation = "user" | "project" | "path"; + +export interface SlashCommandInfo { + name: string; + description?: string; + source: SlashCommandSource; + location?: SlashCommandLocation; + path?: string; +} + +export interface BuiltinSlashCommand { + name: string; + description: string; +} + +export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ + { name: "settings", description: "Open settings menu" }, + { name: "plan", description: "Toggle plan mode (agent plans before executing)" }, + { name: "model", description: "Select model (opens selector UI)" }, + { name: "export", description: "Export session to HTML file" }, + { name: "dump", description: "Copy session transcript to clipboard" }, + { name: "share", description: "Share session as a secret GitHub gist" }, + { name: "browser", description: "Toggle browser headless vs visible mode" }, + { name: "copy", description: "Copy last agent message to clipboard" }, + { name: "session", description: "Show session info and stats" }, + { name: "usage", description: "Show provider usage and limits" }, + { name: "changelog", description: "Show changelog entries" }, + { name: "hotkeys", description: "Show all keyboard shortcuts" }, + { name: "extensions", description: "Open Extension Control Center dashboard" }, + { name: "branch", description: "Create a new branch from a previous message" }, + { name: "fork", description: "Create a new fork from a previous message" }, + { name: "tree", description: "Navigate session tree (switch branches)" }, + { name: "login", description: "Login with OAuth provider" }, + { name: "logout", description: "Logout from OAuth provider" }, + { name: "new", description: "Start a new session" }, + { name: "compact", description: "Manually compact the session context" }, + { name: "handoff", description: "Hand off session context to a new session" }, + { name: "resume", description: "Resume a different session" }, + { name: "background", description: "Detach UI and continue running in background" }, + { name: "debug", description: "Write debug log (TUI state and messages)" }, + { name: "exit", description: "Exit the application" }, +]; + import { slashCommandCapability } from "../capability/slash-command"; import { renderPromptTemplate } from "../config/prompt-templates"; import type { SlashCommand } from "../discovery"; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 089344f63..95b5c888c 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -40,6 +40,9 @@ export type { export { CustomToolLoader, discoverAndLoadCustomTools, loadCustomTools } from "./extensibility/custom-tools"; export type { AppAction, + BashToolCallEvent, + CustomToolCallEvent, + EditToolCallEvent, Extension, ExtensionActions, ExtensionAPI, @@ -55,13 +58,17 @@ export type { ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, + FindToolCallEvent, + GrepToolCallEvent, InputEvent, InputEventResult, KeybindingsManager, LoadExtensionsResult, MessageRenderer, MessageRenderOptions, + ReadToolCallEvent, RegisteredCommand, + ToolCallEvent, ToolResultEvent, TurnEndEvent, TurnStartEvent, @@ -69,12 +76,14 @@ export type { UserBashEventResult, UserPythonEvent, UserPythonEventResult, + WriteToolCallEvent, } from "./extensibility/extensions"; // Extension types and utilities export { discoverAndLoadExtensions, ExtensionRunner, ExtensionRuntime, + isToolCallEventType, } from "./extensibility/extensions"; // Hook system types (legacy re-export) export type * from "./extensibility/hooks"; @@ -238,22 +247,27 @@ export { // Tools (detail types and utilities) export { type BashToolDetails, + type BashToolInput, type BrowserToolDetails, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type FindOperations, type FindToolDetails, + type FindToolInput, type FindToolOptions, formatSize, type GrepOperations, type GrepToolDetails, + type GrepToolInput, type GrepToolOptions, type PythonToolDetails, type ReadToolDetails, + type ReadToolInput, type TruncationOptions, type TruncationResult, truncateHead, truncateLine, truncateTail, type WriteToolDetails, + type WriteToolInput, } from "./tools"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a24b692fe..30ad56de9 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -495,26 +495,27 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise 0 ? parsedArgs.messages[0] : undefined; - const result = await exportFromFile(parsedArgs.export, outputPath); - writeStdout(`Exported to: ${result}`); - return; + result = await exportFromFile(parsedArgs.export, outputPath); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Failed to export session"; writeStderr(chalk.red(`Error: ${message}`)); process.exit(1); } + writeStdout(`Exported to: ${result}`); + process.exit(0); } if (parsedArgs.mode === "rpc" && parsedArgs.fileArgs.length > 0) { diff --git a/packages/coding-agent/src/modes/components/custom-message.ts b/packages/coding-agent/src/modes/components/custom-message.ts index 4c968154a..7e4dff73c 100644 --- a/packages/coding-agent/src/modes/components/custom-message.ts +++ b/packages/coding-agent/src/modes/components/custom-message.ts @@ -82,14 +82,6 @@ export class CustomMessageComponent extends Container { .join("\n"); } - // Limit lines when collapsed - if (!this._expanded) { - const lines = text.split("\n"); - if (lines.length > 5) { - text = `${lines.slice(0, 5).join("\n")}\n…`; - } - } - this.box.addChild( new Markdown(text, 0, 0, getMarkdownTheme(), { color: (value: string) => theme.fg("customMessageText", value), diff --git a/packages/coding-agent/src/modes/components/footer.ts b/packages/coding-agent/src/modes/components/footer.ts index 4d0447a08..69e1b8d39 100644 --- a/packages/coding-agent/src/modes/components/footer.ts +++ b/packages/coding-agent/src/modes/components/footer.ts @@ -212,7 +212,7 @@ export class FooterComponent implements Component { // Truncate path if too long to fit width if (pwd.length > width) { const half = Math.floor(width / 2) - 2; - if (half > 0) { + if (half > 1) { const start = pwd.slice(0, half); const end = pwd.slice(-(half - 1)); pwd = `${start}...${end}`; diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index b5fb703b5..a81cfce9b 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -398,6 +398,10 @@ export class ModelSelectorComponent extends Container { } } else if (this.filteredModels.length === 0) { this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0)); + } else { + const selected = this.filteredModels[this.selectedIndex]; + this.listContainer.addChild(new Spacer(1)); + this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0)); } } diff --git a/packages/coding-agent/src/modes/components/tree-selector.ts b/packages/coding-agent/src/modes/components/tree-selector.ts index 945f8b979..0480eaf1a 100644 --- a/packages/coding-agent/src/modes/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/components/tree-selector.ts @@ -55,6 +55,7 @@ class TreeList implements Component { private toolCallMap: Map = new Map(); private multipleRoots = false; private activePathIds: Set = new Set(); + private lastSelectedId: string | null = null; public onSelect?: (entryId: string) => void; public onCancel?: () => void; @@ -64,19 +65,17 @@ class TreeList implements Component { tree: SessionTreeNode[], private readonly currentLeafId: string | null, private readonly maxVisibleLines: number, + initialSelectedId?: string, ) { this.multipleRoots = tree.length > 1; this.flatNodes = this.flattenTree(tree); this.buildActivePath(); this.applyFilter(); - // Start with current leaf selected - const leafIndex = this.filteredNodes.findIndex(n => n.node.entry.id === currentLeafId); - if (leafIndex !== -1) { - this.selectedIndex = leafIndex; - } else { - this.selectedIndex = Math.max(0, this.filteredNodes.length - 1); - } + // Start with initialSelectedId if provided, otherwise current leaf + const targetId = initialSelectedId ?? currentLeafId; + this.selectedIndex = this.findNearestVisibleIndex(targetId); + this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? null; } /** Build the set of entry IDs on the path from root to current leaf */ @@ -100,6 +99,36 @@ class TreeList implements Component { } } + /** + * Find the index of the nearest visible entry, walking up the parent chain if needed. + * Returns the index in filteredNodes, or the last index as fallback. + */ + private findNearestVisibleIndex(entryId: string | null): number { + if (this.filteredNodes.length === 0) return 0; + + // Build a map for parent lookup + const entryMap = new Map(); + for (const flatNode of this.flatNodes) { + entryMap.set(flatNode.node.entry.id, flatNode); + } + + // Build a map of visible entry IDs to their indices in filteredNodes + const visibleIdToIndex = new Map(this.filteredNodes.map((node, i) => [node.node.entry.id, i])); + + // Walk from entryId up to root, looking for a visible entry + let currentId = entryId; + while (currentId !== null) { + const index = visibleIdToIndex.get(currentId); + if (index !== undefined) return index; + const node = entryMap.get(currentId); + if (!node) break; + currentId = node.node.entry.parentId ?? null; + } + + // Fallback: last visible entry + return this.filteredNodes.length - 1; + } + private flattenTree(roots: SessionTreeNode[]): FlatNode[] { const result: FlatNode[] = []; this.toolCallMap.clear(); @@ -231,8 +260,11 @@ class TreeList implements Component { } private applyFilter(): void { - // Remember currently selected node to preserve cursor position - const previouslySelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id; + // Update lastSelectedId only when we have a valid selection (non-empty list) + // This preserves the selection when switching through empty filter results + if (this.filteredNodes.length > 0) { + this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId; + } const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean); @@ -295,18 +327,17 @@ class TreeList implements Component { return true; }); - // Try to preserve cursor on the same node after filtering - if (previouslySelectedId) { - const newIndex = this.filteredNodes.findIndex(n => n.node.entry.id === previouslySelectedId); - if (newIndex !== -1) { - this.selectedIndex = newIndex; - return; - } + // Try to preserve cursor on the same node, or find nearest visible ancestor + if (this.lastSelectedId) { + this.selectedIndex = this.findNearestVisibleIndex(this.lastSelectedId); + } else if (this.selectedIndex >= this.filteredNodes.length) { + // Clamp index if out of bounds + this.selectedIndex = Math.max(0, this.filteredNodes.length - 1); } - // Fall back: clamp index if out of bounds - if (this.selectedIndex >= this.filteredNodes.length) { - this.selectedIndex = Math.max(0, this.filteredNodes.length - 1); + // Update lastSelectedId to the actual selection (may have changed due to parent walk) + if (this.filteredNodes.length > 0) { + this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId; } } diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 5632add1f..0b41ce1ba 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -64,6 +64,8 @@ export class ExtensionUiController { setFooter: () => {}, setHeader: () => {}, setEditorComponent: () => {}, + getToolsExpanded: () => this.ctx.toolOutputExpanded, + setToolsExpanded: expanded => this.ctx.setToolsExpanded(expanded), }; this.ctx.setToolUIContext(uiContext, true); @@ -114,6 +116,7 @@ export class ExtensionUiController { }, getThinkingLevel: () => this.ctx.session.thinkingLevel, setThinkingLevel: level => this.ctx.session.setThinkingLevel(level), + getCommands: () => [], }; const contextActions: ExtensionContextActions = { getModel: () => this.ctx.session.model, @@ -130,6 +133,7 @@ export class ExtensionUiController { instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined; await this.ctx.session.compact(instructions, options); }, + getSystemPrompt: () => this.ctx.session.systemPrompt, }; const commandActions: ExtensionCommandContextActions = { getContextUsage: () => this.ctx.session.getContextUsage(), @@ -195,7 +199,7 @@ export class ExtensionUiController { this.ctx.chatContainer.clear(); this.ctx.renderInitialMessages(); await this.ctx.reloadTodos(); - if (result.editorText) { + if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setText(result.editorText); } this.ctx.showStatus("Navigated to selected point"); @@ -212,6 +216,16 @@ export class ExtensionUiController { } await this.ctx.executeCompaction(instructionsOrOptions, false); }, + switchSession: async sessionPath => { + const result = await this.ctx.session.switchSession(sessionPath); + if (!result) { + return { cancelled: true }; + } + this.ctx.chatContainer.clear(); + this.ctx.renderInitialMessages(); + await this.ctx.reloadTodos(); + return { cancelled: false }; + }, }; extensionRunner.initialize(actions, contextActions, commandActions, uiContext); @@ -283,6 +297,7 @@ export class ExtensionUiController { }, getThinkingLevel: () => this.ctx.session.thinkingLevel, setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist), + getCommands: () => [], }; const contextActions: ExtensionContextActions = { getModel: () => this.ctx.session.model, @@ -299,6 +314,7 @@ export class ExtensionUiController { instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined; await this.ctx.session.compact(instructions, options); }, + getSystemPrompt: () => this.ctx.session.systemPrompt, }; const commandActions: ExtensionCommandContextActions = { getContextUsage: () => this.ctx.session.getContextUsage(), @@ -373,7 +389,7 @@ export class ExtensionUiController { this.ctx.chatContainer.clear(); this.ctx.renderInitialMessages(); await this.ctx.reloadTodos(); - if (result.editorText) { + if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setText(result.editorText); } this.ctx.showStatus("Navigated to selected point"); @@ -390,6 +406,19 @@ export class ExtensionUiController { } await this.ctx.executeCompaction(instructionsOrOptions, false); }, + switchSession: async sessionPath => { + if (this.ctx.isBackgrounded) { + return { cancelled: true }; + } + const result = await this.ctx.session.switchSession(sessionPath); + if (!result) { + return { cancelled: true }; + } + this.ctx.chatContainer.clear(); + this.ctx.renderInitialMessages(); + await this.ctx.reloadTodos(); + return { cancelled: false }; + }, }; extensionRunner.initialize(actions, contextActions, commandActions, uiContext); @@ -418,6 +447,8 @@ export class ExtensionUiController { setFooter: () => {}, setHeader: () => {}, setEditorComponent: () => {}, + getToolsExpanded: () => false, + setToolsExpanded: () => {}, }; } @@ -461,6 +492,7 @@ export class ExtensionUiController { shutdown: () => { // Signal shutdown request }, + getSystemPrompt: () => this.ctx.session.systemPrompt, }); } catch (err) { this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err)); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 7ff3884d6..b0cf1e480 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -97,6 +97,19 @@ export class InputController { this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand()); } + for (const key of this.ctx.keybindings.getKeys("newSession")) { + this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.handleClearCommand()); + } + for (const key of this.ctx.keybindings.getKeys("tree")) { + this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showTreeSelector()); + } + for (const key of this.ctx.keybindings.getKeys("fork")) { + this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showUserMessageSelector()); + } + for (const key of this.ctx.keybindings.getKeys("resume")) { + this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionSelector()); + } + this.ctx.editor.onChange = (text: string) => { const wasBashMode = this.ctx.isBashMode; const wasPythonMode = this.ctx.isPythonMode; @@ -681,10 +694,14 @@ export class InputController { } toggleToolOutputExpansion(): void { - this.ctx.toolOutputExpanded = !this.ctx.toolOutputExpanded; + this.setToolsExpanded(!this.ctx.toolOutputExpanded); + } + + setToolsExpanded(expanded: boolean): void { + this.ctx.toolOutputExpanded = expanded; for (const child of this.ctx.chatContainer.children) { if (isExpandable(child)) { - child.setExpanded(this.ctx.toolOutputExpanded); + child.setExpanded(expanded); } } this.ctx.ui.requestRender(); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index ddefeea19..71ac78e2c 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -185,6 +185,10 @@ export class SelectorController { this.ctx.updateEditorBorderColor(); break; + case "clearOnShrink": + this.ctx.ui.setClearOnShrink(value as boolean); + break; + // Settings with UI side effects case "showImages": for (const child of this.ctx.chatContainer.children) { @@ -354,15 +358,6 @@ export class SelectorController { const tree = this.ctx.sessionManager.getTree(); const realLeafId = this.ctx.sessionManager.getLeafId(); - // Find the visible leaf for display (skip metadata entries like labels) - let visibleLeafId = realLeafId; - while (visibleLeafId) { - const entry = this.ctx.sessionManager.getEntry(visibleLeafId); - if (!entry) break; - if (entry.type !== "label" && entry.type !== "custom") break; - visibleLeafId = entry.parentId ?? null; - } - if (tree.length === 0) { this.ctx.showStatus("No entries in session"); return; @@ -371,11 +366,11 @@ export class SelectorController { this.showSelector(done => { const selector = new TreeSelectorComponent( tree, - visibleLeafId, + realLeafId, this.ctx.ui.terminal.rows, async entryId => { - // Selecting the visible leaf is a no-op (already there) - if (entryId === visibleLeafId) { + // Selecting the current leaf is a no-op (already there) + if (entryId === realLeafId) { done(); this.ctx.showStatus("Already at this point"); return; @@ -458,7 +453,7 @@ export class SelectorController { this.ctx.chatContainer.clear(); this.ctx.renderInitialMessages(); await this.ctx.reloadTodos(); - if (result.editorText) { + if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setText(result.editorText); } this.ctx.showStatus("Navigated to selected point"); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 4f5b9aed0..177eabc0f 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -22,7 +22,7 @@ import { renderPromptTemplate } from "../config/prompt-templates"; import { type Settings, settings } from "../config/settings"; import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../extensibility/extensions"; import type { CompactOptions } from "../extensibility/extensions/types"; -import { loadSlashCommands } from "../extensibility/slash-commands"; +import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands"; import { resolvePlanUrlToPath } from "../internal-urls"; import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" }; import type { AgentSession, AgentSessionEvent } from "../session/agent-session"; @@ -173,6 +173,7 @@ export class InteractiveMode implements InteractiveModeContext { this.mcpManager = mcpManager; this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor")); + this.ui.setClearOnShrink(settings.get("clearOnShrink")); setMermaidRenderCallback(() => this.ui.requestRender()); this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); @@ -199,39 +200,10 @@ export class InteractiveMode implements InteractiveModeContext { this.hideThinkingBlock = settings.get("hideThinkingBlock"); - // Define slash commands for autocomplete - const slashCommands: SlashCommand[] = [ - { name: "settings", description: "Open settings menu" }, - { name: "plan", description: "Toggle plan mode (agent plans before executing)" }, - { name: "model", description: "Select model (opens selector UI)" }, - { name: "export", description: "Export session to HTML file" }, - { name: "dump", description: "Copy session transcript to clipboard" }, - { name: "share", description: "Share session as a secret GitHub gist" }, - { name: "browser", description: "Toggle browser headless vs visible mode" }, - { name: "copy", description: "Copy last agent message to clipboard" }, - { name: "session", description: "Show session info and stats" }, - { name: "usage", description: "Show provider usage and limits" }, - { name: "extensions", description: "Open Extension Control Center dashboard" }, - { name: "status", description: "Alias for /extensions" }, - { name: "changelog", description: "Show changelog entries" }, - { name: "hotkeys", description: "Show all keyboard shortcuts" }, - { name: "branch", description: "Create a new branch from a previous message" }, - { name: "tree", description: "Navigate session tree (switch branches)" }, - { name: "login", description: "Login with OAuth provider" }, - { name: "logout", description: "Logout from OAuth provider" }, - { name: "new", description: "Start a new session" }, - { name: "fork", description: "Duplicate current session into a new session" }, - { name: "compact", description: "Manually compact the session context" }, - { name: "handoff", description: "Hand off the session context to a new session" }, - { name: "background", description: "Detach UI and continue running in background" }, - { name: "bg", description: "Alias for /background" }, - { name: "resume", description: "Resume a different session" }, - { name: "debug", description: "Write debug log (TUI state and messages)" }, - { name: "exit", description: "Exit the application" }, - ]; - - // Convert hook commands to SlashCommand format - const hookCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map(cmd => ({ + const builtinCommandNames = new Set(BUILTIN_SLASH_COMMANDS.map(c => c.name)); + const hookCommands: SlashCommand[] = ( + this.session.extensionRunner?.getRegisteredCommands(builtinCommandNames) ?? [] + ).map(cmd => ({ name: cmd.name, description: cmd.description ?? "(hook command)", getArgumentCompletions: cmd.getArgumentCompletions, @@ -254,7 +226,7 @@ export class InteractiveMode implements InteractiveModeContext { } // Store pending commands for init() where file commands are loaded async - this.pendingSlashCommands = [...slashCommands, ...hookCommands, ...customCommands, ...skillCommandList]; + this.pendingSlashCommands = [...BUILTIN_SLASH_COMMANDS, ...hookCommands, ...customCommands, ...skillCommandList]; this.uiHelpers = new UiHelpers(this); this.extensionUiController = new ExtensionUiController(this); @@ -738,6 +710,10 @@ export class InteractiveMode implements InteractiveModeContext { await this.ui.waitForRender(); } + // Drain any in-flight Kitty key release events before stopping. + // This prevents escape sequences from leaking to the parent shell over slow SSH. + await this.ui.terminal.drainInput(1000); + this.stop(); await postmortem.quit(0); } @@ -1011,6 +987,10 @@ export class InteractiveMode implements InteractiveModeContext { this.inputController.toggleToolOutputExpansion(); } + setToolsExpanded(expanded: boolean): void { + this.inputController.setToolsExpanded(expanded); + } + toggleThinkingBlockVisibility(): void { this.inputController.toggleThinkingBlockVisibility(); } diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 339d6b61e..73fe705d5 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -63,6 +63,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti getActiveTools: () => session.getActiveToolNames(), getAllTools: () => session.getAllToolNames(), setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), + getCommands: () => [], setModel: async model => { const key = await session.modelRegistry.getApiKey(model); if (!key) return false; @@ -80,6 +81,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti hasPendingMessages: () => session.queuedMessageCount > 0, shutdown: () => {}, getContextUsage: () => session.getContextUsage(), + getSystemPrompt: () => session.systemPrompt, compact: async instructionsOrOptions => { const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined; const options = @@ -108,6 +110,10 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti const result = await session.navigateTree(targetId, { summarize: options?.summarize }); return { cancelled: result.cancelled }; }, + switchSession: async sessionPath => { + const success = await session.switchSession(sessionPath); + return { cancelled: !success }; + }, compact: async instructionsOrOptions => { const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined; const options = diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index d7948257a..22e380489 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -189,15 +189,15 @@ export class RpcClient { /** * Queue a steering message to interrupt the agent mid-run. */ - async steer(message: string): Promise { - await this.send({ type: "steer", message }); + async steer(message: string, images?: ImageContent[]): Promise { + await this.send({ type: "steer", message, images }); } /** * Queue a follow-up message to be processed after the agent finishes. */ - async followUp(message: string): Promise { - await this.send({ type: "follow_up", message }); + async followUp(message: string, images?: ImageContent[]): Promise { + await this.send({ type: "follow_up", message, images }); } /** diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index f5064f6c7..ec26b6ad0 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -286,6 +286,15 @@ export async function runRpcMode(session: AgentSession): Promise { return Promise.resolve({ success: false, error: "Theme switching not supported in RPC mode" }); } + getToolsExpanded() { + // Tool expansion not supported in RPC mode - no TUI + return false; + } + + setToolsExpanded(_expanded: boolean) { + // Tool expansion not supported in RPC mode - no TUI + } + setEditorComponent(): void { // Custom editor components not supported in RPC mode } @@ -316,6 +325,7 @@ export async function runRpcMode(session: AgentSession): Promise { getActiveTools: () => session.getActiveToolNames(), getAllTools: () => session.getAllToolNames(), setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), + getCommands: () => [], setModel: async model => { const key = await session.modelRegistry.getApiKey(model); if (!key) return false; @@ -335,6 +345,7 @@ export async function runRpcMode(session: AgentSession): Promise { shutdownState.requested = true; }, getContextUsage: () => session.getContextUsage(), + getSystemPrompt: () => session.systemPrompt, compact: async instructionsOrOptions => { const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined; const options = @@ -364,6 +375,10 @@ export async function runRpcMode(session: AgentSession): Promise { const result = await session.navigateTree(targetId, { summarize: options?.summarize }); return { cancelled: result.cancelled }; }, + switchSession: async sessionPath => { + const success = await session.switchSession(sessionPath); + return { cancelled: !success }; + }, compact: async instructionsOrOptions => { const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined; const options = @@ -412,12 +427,12 @@ export async function runRpcMode(session: AgentSession): Promise { } case "steer": { - await session.steer(command.message); + await session.steer(command.message, command.images); return success(id, "steer"); } case "follow_up": { - await session.followUp(command.message); + await session.followUp(command.message, command.images); return success(id, "follow_up"); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 0c88eca24..988fe00e8 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -17,8 +17,8 @@ import type { CompactionResult } from "../../session/compaction"; export type RpcCommand = // Prompting | { id?: string; type: "prompt"; message: string; images?: ImageContent[]; streamingBehavior?: "steer" | "followUp" } - | { id?: string; type: "steer"; message: string } - | { id?: string; type: "follow_up"; message: string } + | { id?: string; type: "steer"; message: string; images?: ImageContent[] } + | { id?: string; type: "follow_up"; message: string; images?: ImageContent[] } | { id?: string; type: "abort" } | { id?: string; type: "new_session"; parentSession?: string } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 4e044eca6..0bcd08c54 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -176,6 +176,7 @@ export interface InteractiveModeContext { cycleThinkingLevel(): void; cycleRoleModel(options?: { temporary?: boolean }): Promise; toggleToolOutputExpansion(): void; + setToolsExpanded(expanded: boolean): void; toggleThinkingBlockVisibility(): void; openExternalEditor(): void; registerExtensionShortcuts(): void; diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index fc35c529b..e0fc4938d 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -221,6 +221,7 @@ Main branch: {{git.mainBranch}} {{#if skills.length}} Scan descriptions vs domain. Skill covers output? Read `skill://` first. +When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands. {{#list skills join="\n"}} diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index aac1a07d5..fc710b4af 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -387,7 +387,7 @@ function customToolToDefinition(tool: CustomTool): ToolDefinition { label: tool.label, description: tool.description, parameters: tool.parameters, - execute: (toolCallId, params, onUpdate, ctx, signal) => + execute: (toolCallId, params, signal, onUpdate, ctx) => tool.execute(toolCallId, params, onUpdate, createCustomToolContext(ctx), signal), onSession: tool.onSession ? (event, ctx) => tool.onSession?.(event, createCustomToolContext(ctx)) : undefined, renderCall: tool.renderCall, @@ -506,6 +506,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const existingSession = sessionManager.buildSessionContext(); time("loadSession"); const hasExistingSession = existingSession.messages.length > 0; + const hasThinkingEntry = sessionManager.getBranch().some(entry => entry.type === "thinking_level_change"); const hasExplicitModel = options.model !== undefined; let model = options.model; @@ -563,7 +564,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // If session has data, restore thinking level from it if (thinkingLevel === undefined && hasExistingSession) { - thinkingLevel = existingSession.thinkingLevel as ThinkingLevel; + thinkingLevel = hasThinkingEntry + ? (existingSession.thinkingLevel as ThinkingLevel) + : ((settingsInstance.get("defaultThinkingLevel") ?? "off") as ThinkingLevel); } // Fall back to settings default @@ -1016,14 +1019,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} thinkingBudgets: settingsInstance.getGroup("thinkingBudgets"), kimiApiFormat: settingsInstance.get("providers.kimiApiFormat") ?? "anthropic", getToolContext: tc => toolContextStore.getContext(tc), - getApiKey: async () => { - const currentModel = agent.state.model; - if (!currentModel) { - throw new Error("No model selected"); - } - const key = await modelRegistry.getApiKey(currentModel, sessionId); + getApiKey: async provider => { + // Use the provider argument from the in-flight request; + // agent.state.model may already be switched mid-turn. + const key = await modelRegistry.getApiKeyForProvider(provider, sessionId); if (!key) { - throw new Error(`No API key found for provider "${currentModel.provider}"`); + throw new Error(`No API key found for provider "${provider}"`); } return key; }, @@ -1036,6 +1037,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Restore messages if session has existing data if (hasExistingSession) { agent.replaceMessages(existingSession.messages); + if (!hasThinkingEntry) { + sessionManager.appendThinkingLevelChange(thinkingLevel); + } } else { // Save initial model and thinking level for new sessions so they can be restored on resume if (model) { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 07129c14e..f9c25e7a9 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -237,6 +237,8 @@ const noOpUIContext: ExtensionUIContext = { setFooter: () => {}, setHeader: () => {}, setEditorComponent: () => {}, + getToolsExpanded: () => false, + setToolsExpanded: () => {}, }; async function cleanupSshResources(): Promise { @@ -906,6 +908,11 @@ export class AgentSession { return this.agent.state.isStreaming || this._promptInFlight; } + /** Current effective system prompt (includes any per-turn extension modifications) */ + get systemPrompt(): string { + return this.agent.state.systemPrompt; + } + /** Current retry attempt (0 if not retrying) */ get retryAttempt(): number { return this._retryAttempt; @@ -1426,6 +1433,11 @@ export class AgentSession { instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined; await this.compact(instructions, options); }, + switchSession: async sessionPath => { + const success = await this.switchSession(sessionPath); + return { cancelled: !success }; + }, + getSystemPrompt: () => this.systemPrompt, }; } @@ -1477,25 +1489,25 @@ export class AgentSession { /** * Queue a steering message to interrupt the agent mid-run. */ - async steer(text: string): Promise { + async steer(text: string, images?: ImageContent[]): Promise { if (text.startsWith("/")) { this._throwIfExtensionCommand(text); } const expandedText = expandPromptTemplate(text, [...this._promptTemplates]); - await this._queueSteer(expandedText); + await this._queueSteer(expandedText, images); } /** * Queue a follow-up message to process after the agent would otherwise stop. */ - async followUp(text: string): Promise { + async followUp(text: string, images?: ImageContent[]): Promise { if (text.startsWith("/")) { this._throwIfExtensionCommand(text); } const expandedText = expandPromptTemplate(text, [...this._promptTemplates]); - await this._queueFollowUp(expandedText); + await this._queueFollowUp(expandedText, images); } /** @@ -1733,6 +1745,9 @@ export class AgentSession { this._steeringMessages = []; this._followUpMessages = []; this._pendingNextTurnMessages = []; + + this.sessionManager.appendThinkingLevelChange(this.thinkingLevel); + this._todoReminderCount = 0; this._planReferenceSent = false; this._reconnectToAgent(); @@ -1934,22 +1949,39 @@ export class AgentSession { return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role }; } + private async _getScopedModelsWithApiKey(): Promise> { + const apiKeysByProvider = new Map(); + const result: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = []; + + for (const scoped of this._scopedModels) { + const provider = scoped.model.provider; + let apiKey: string | undefined; + if (apiKeysByProvider.has(provider)) { + apiKey = apiKeysByProvider.get(provider); + } else { + apiKey = await this._modelRegistry.getApiKeyForProvider(provider, this.sessionId); + apiKeysByProvider.set(provider, apiKey); + } + + if (apiKey) { + result.push(scoped); + } + } + + return result; + } + private async _cycleScopedModel(direction: "forward" | "backward"): Promise { - if (this._scopedModels.length <= 1) return undefined; + const scopedModels = await this._getScopedModelsWithApiKey(); + if (scopedModels.length <= 1) return undefined; const currentModel = this.model; - let currentIndex = this._scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel)); + let currentIndex = scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel)); if (currentIndex === -1) currentIndex = 0; - const len = this._scopedModels.length; + const len = scopedModels.length; const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; - const next = this._scopedModels[nextIndex]; - - // Validate API key - const apiKey = await this._modelRegistry.getApiKey(next.model, this.sessionId); - if (!apiKey) { - throw new Error(`No API key for ${next.model.provider}/${next.model.id}`); - } + const next = scopedModels[nextIndex]; // Apply model this.agent.setModel(next.model); @@ -2005,15 +2037,22 @@ export class AgentSession { /** * Set thinking level. * Clamps to model capabilities based on available thinking levels. - * Saves to session, with optional persistence to settings. + * Saves to session and settings only if the level actually changes. */ setThinkingLevel(level: ThinkingLevel, persist: boolean = false): void { const availableLevels = this.getAvailableThinkingLevels(); const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); + + // Only persist if actually changing + const isChanging = effectiveLevel !== this.agent.state.thinkingLevel; + this.agent.setThinkingLevel(effectiveLevel); - this.sessionManager.appendThinkingLevelChange(effectiveLevel); - if (persist) { - this.settings.set("defaultThinkingLevel", effectiveLevel); + + if (isChanging) { + this.sessionManager.appendThinkingLevelChange(effectiveLevel); + if (persist) { + this.settings.set("defaultThinkingLevel", effectiveLevel); + } } } @@ -2903,8 +2942,8 @@ Be thorough - include exact file paths, function names, error messages, and tech } private _isRetryableErrorMessage(errorMessage: string): boolean { - // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed - return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|fetch failed/i.test( + // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed, retry delay exceeded + return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|fetch failed|retry delay/i.test( errorMessage, ); } @@ -3354,9 +3393,19 @@ Be thorough - include exact file paths, function names, error messages, and tech } } - // Restore thinking level if saved (setThinkingLevel clamps to model capabilities) - if (sessionContext.thinkingLevel) { + const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change"); + const defaultThinkingLevel = (this.settings.get("defaultThinkingLevel") ?? "off") as ThinkingLevel; + + if (hasThinkingEntry) { + // Restore thinking level if saved (setThinkingLevel clamps to model capabilities) this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel); + } else { + const availableLevels = this.getAvailableThinkingLevels(); + const effectiveLevel = availableLevels.includes(defaultThinkingLevel) + ? defaultThinkingLevel + : this._clampThinkingLevel(defaultThinkingLevel, availableLevels); + this.agent.setThinkingLevel(effectiveLevel); + this.sessionManager.appendThinkingLevelChange(effectiveLevel); } this._reconnectToAgent(); @@ -3404,7 +3453,7 @@ Be thorough - include exact file paths, function names, error messages, and tech await this.sessionManager.flush(); if (!selectedEntry.parentId) { - this.sessionManager.newSession(); + this.sessionManager.newSession({ parentSession: previousSessionFile }); } else { this.sessionManager.createBranchedSession(selectedEntry.parentId); } diff --git a/packages/coding-agent/src/session/auth-storage.ts b/packages/coding-agent/src/session/auth-storage.ts index 05e2be311..0d3eace28 100644 --- a/packages/coding-agent/src/session/auth-storage.ts +++ b/packages/coding-agent/src/session/auth-storage.ts @@ -34,6 +34,7 @@ import { } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; import { getAgentDbPath } from "../config"; +import { resolveConfigValue } from "../config/resolve-config-value"; import { AgentStorage } from "./agent-storage"; export type ApiKeyCredential = { @@ -1294,7 +1295,7 @@ export class AuthStorage { const apiKeySelection = this.selectCredentialByType(provider, "api_key", sessionId); if (apiKeySelection) { this.recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index); - return apiKeySelection.credential.key; + return resolveConfigValue(apiKeySelection.credential.key); } const oauthKey = await this.resolveOAuthApiKey(provider, sessionId, options); diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 5f0228fc8..626fea103 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -184,6 +184,8 @@ export interface SessionInfo { /** Working directory where the session was started. Empty string for old sessions. */ cwd: string; title?: string; + /** Path to the parent session (if this session was forked). */ + parentSessionPath?: string; created: Date; modified: Date; messageCount: number; @@ -933,6 +935,7 @@ async function collectSessionsFromFiles(files: string[], storage: SessionStorage id: header.id, cwd: typeof header.cwd === "string" ? header.cwd : "", title: header.title ?? shortSummary, + parentSessionPath: (header as SessionHeader).parentSession, created: new Date(header.timestamp), modified: stats.mtime, messageCount, @@ -1294,7 +1297,11 @@ export class SessionManager { if (this.persistError) throw this.persistError; const hasAssistant = this.fileEntries.some(e => e.type === "message" && e.message.role === "assistant"); - if (!hasAssistant && !this.flushed) return; + if (!hasAssistant) { + // Mark as not flushed so when assistant arrives, all entries get written + this.flushed = false; + return; + } if (!this.flushed) { this.flushed = true; @@ -1776,6 +1783,7 @@ export class SessionManager { * Returns the new session file path, or undefined if not persisting. */ createBranchedSession(leafId: string): string | undefined { + const previousSessionFile = this.sessionFile; const branchPath = this.getBranch(leafId); if (branchPath.length === 0) { throw new Error(`Entry ${leafId} not found`); @@ -1795,7 +1803,7 @@ export class SessionManager { id: newSessionId, timestamp, cwd: this.cwd, - parentSession: this.persist ? this.sessionFile : undefined, + parentSession: this.persist ? previousSessionFile : undefined, }; // Collect labels for entries in the path @@ -1834,6 +1842,8 @@ export class SessionManager { this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`); this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; this.sessionId = newSessionId; + this.sessionFile = newSessionFile; + this.flushed = true; this._buildIndex(); return newSessionFile; } diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 80817c365..5bc113570 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -940,6 +940,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise session.getActiveToolNames(), getAllTools: () => session.getAllToolNames(), setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), + getCommands: () => [], setModel: async model => { const key = await session.modelRegistry.getApiKey(model); if (!key) return false; @@ -956,6 +957,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise session.queuedMessageCount > 0, shutdown: () => {}, getContextUsage: () => session.getContextUsage(), + getSystemPrompt: () => session.systemPrompt, compact: async instructionsOrOptions => { const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined; const options = diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index be2fec7f6..7c3737c99 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -2,7 +2,7 @@ import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; -import { Type } from "@sinclair/typebox"; +import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; import { type BashExecutorOptions, executeBash } from "../exec/bash-executor"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -31,6 +31,8 @@ const bashSchema = Type.Object({ tail: Type.Optional(Type.Number({ description: "Return only last N lines of output" })), }); +export type BashToolInput = Static; + export interface BashToolDetails { meta?: OutputMeta; } diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index 2a55c5584..924a41521 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -27,6 +27,8 @@ const findSchema = Type.Object({ limit: Type.Optional(Type.Number({ description: "Max results (default: 1000)" })), }); +export type FindToolInput = Static; + const DEFAULT_LIMIT = 1000; const GLOB_TIMEOUT_MS = 5000; diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 4d5823e1d..83c1b108a 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -32,6 +32,8 @@ const grepSchema = Type.Object({ offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit (default: 0)" })), }); +export type GrepToolInput = Static; + const DEFAULT_MATCH_LIMIT = 100; export interface GrepToolDetails { diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index ad1bef683..8700f9a58 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -67,17 +67,17 @@ export { webSearchLinkedinTool, } from "../web/search"; export { AskTool, type AskToolDetails } from "./ask"; -export { BashTool, type BashToolDetails, type BashToolOptions } from "./bash"; +export { BashTool, type BashToolDetails, type BashToolInput, type BashToolOptions } from "./bash"; export { BrowserTool, type BrowserToolDetails } from "./browser"; export { CalculatorTool, type CalculatorToolDetails } from "./calculator"; export { type ExitPlanModeDetails, ExitPlanModeTool } from "./exit-plan-mode"; export { FetchTool, type FetchToolDetails } from "./fetch"; -export { type FindOperations, FindTool, type FindToolDetails, type FindToolOptions } from "./find"; +export { type FindOperations, FindTool, type FindToolDetails, type FindToolInput, type FindToolOptions } from "./find"; export { setPreferredImageProvider } from "./gemini-image"; -export { type GrepOperations, GrepTool, type GrepToolDetails, type GrepToolOptions } from "./grep"; +export { type GrepOperations, GrepTool, type GrepToolDetails, type GrepToolInput, type GrepToolOptions } from "./grep"; export { NotebookTool, type NotebookToolDetails } from "./notebook"; export { PythonTool, type PythonToolDetails, type PythonToolOptions } from "./python"; -export { ReadTool, type ReadToolDetails } from "./read"; +export { ReadTool, type ReadToolDetails, type ReadToolInput } from "./read"; export { reportFindingTool, type SubmitReviewDetails } from "./review"; export { loadSshTool, type SSHToolDetails, SshTool } from "./ssh"; export { SubmitResultTool } from "./submit-result"; @@ -92,7 +92,7 @@ export { truncateLine, truncateTail, } from "./truncate"; -export { WriteTool, type WriteToolDetails } from "./write"; +export { WriteTool, type WriteToolDetails, type WriteToolInput } from "./write"; /** Tool type (AgentTool from pi-ai) */ export type Tool = AgentTool; diff --git a/packages/coding-agent/src/tools/path-utils.ts b/packages/coding-agent/src/tools/path-utils.ts index 71e7cf579..12d56eb7c 100644 --- a/packages/coding-agent/src/tools/path-utils.ts +++ b/packages/coding-agent/src/tools/path-utils.ts @@ -33,8 +33,12 @@ function fileExists(filePath: string): boolean { } } +function normalizeAtPrefix(filePath: string): string { + return filePath.startsWith("@") ? filePath.slice(1) : filePath; +} + export function expandPath(filePath: string): string { - const normalized = normalizeUnicodeSpaces(filePath); + const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath)); if (normalized === "~") { return os.homedir(); } diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index f67ef8aad..c4e403a75 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -7,7 +7,7 @@ import { FileType, glob } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { ptree, untilAborted } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; +import { type Static, Type } from "@sinclair/typebox"; import { CONFIG_DIR_NAME } from "../config"; import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -516,6 +516,8 @@ const readSchema = Type.Object({ lines: Type.Optional(Type.Boolean({ description: "Prepend line numbers to output (default: false)" })), }); +export type ReadToolInput = Static; + export interface ReadToolDetails { truncation?: TruncationResult; isDirectory?: boolean; diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 3339538e4..e045a5802 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -8,7 +8,7 @@ import type { import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { untilAborted } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; +import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp"; @@ -33,6 +33,8 @@ const writeSchema = Type.Object({ content: Type.String({ description: "Content to write to the file" }), }); +export type WriteToolInput = Static; + /** Details returned by the write tool for TUI rendering */ export interface WriteToolDetails { diagnostics?: FileDiagnosticsResult; diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 4692a23bb..3fb89a9f4 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -269,6 +269,12 @@ async function installPythonPackage(pkg: string, signal?: AbortSignal): Promise< return false; } +// Termux package names for tools +const TERMUX_PACKAGES: Partial> = { + sd: "sd", + sg: "ast-grep", +}; + // Ensure a tool is available, downloading if necessary // Returns the path to the tool, or null if unavailable type EnsureToolOptions = { @@ -284,6 +290,16 @@ export async function ensureTool(tool: ToolName, silentOrOptions?: EnsureToolOpt return existingPath; } + // On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility. + // Users must install via pkg. + if (os.platform() === "android") { + const pkgName = TERMUX_PACKAGES[tool] ?? tool; + if (!silent) { + logger.warn(`${TOOLS[tool]?.name ?? tool} not found. Install with: pkg install ${pkgName}`); + } + return undefined; + } + // Handle Python tools const pythonConfig = PYTHON_TOOLS[tool]; if (pythonConfig) { diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 01cb03f42..5218b47c0 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog ## [Unreleased] +### Added + +- OSC 52 fallback for clipboard operations over SSH/mosh connections +- Termux support with `termux-clipboard-set` integration +- Headless environment guards to prevent clipboard errors when no display server is available +- Async clipboard API with improved error handling and fallback strategies + +### Changed + +- Clipboard functions now return promises for better async handling +- Native clipboard operations are now best-effort with graceful degradation ## [11.0.0] - 2026-02-05 ### Removed diff --git a/packages/natives/src/clipboard/index.ts b/packages/natives/src/clipboard/index.ts index 7e862593c..79c07e73d 100644 --- a/packages/natives/src/clipboard/index.ts +++ b/packages/natives/src/clipboard/index.ts @@ -1,9 +1,68 @@ /** * Clipboard helpers backed by native arboard bindings. + * + * Adds OSC 52 fallback for SSH/mosh, Termux support, and headless guards + * on top of the native arboard layer. */ +import { execSync } from "node:child_process"; + import { native } from "../native"; +import type { ClipboardImage } from "./types"; + export type { ClipboardImage } from "./types"; -export const { copyToClipboard, readImageFromClipboard } = native; +/** Whether a display server is available on Linux. */ +const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); + +/** + * Copy text to the system clipboard. + * + * Always emits OSC 52 first (works over SSH/mosh, harmless locally), + * then attempts native clipboard copy as best-effort for local sessions. + * On Termux, tries `termux-clipboard-set` before native. + * + * @param text - UTF-8 text to place on the clipboard. + */ +export async function copyToClipboard(text: string): Promise { + // Always emit OSC 52 — works over SSH/mosh, harmless locally + const encoded = Buffer.from(text).toString("base64"); + process.stdout.write(`\x1b]52;c;${encoded}\x07`); + + // Also try native tools (best effort for local sessions) + try { + if (process.env.TERMUX_VERSION) { + try { + execSync("termux-clipboard-set", { input: text, timeout: 5000 }); + return; + } catch { + // Fall through to native + } + } + + await native.copyToClipboard(text); + } catch { + // Ignore — OSC 52 already emitted as fallback + } +} + +/** + * Read an image from the system clipboard. + * + * Returns null on Termux (no image clipboard support) or when no display + * server is available (headless/SSH without forwarding). + * + * @returns PNG payload or null when no image is available. + */ +export async function readImageFromClipboard(): Promise { + if (process.env.TERMUX_VERSION) { + return null; + } + + if (!hasDisplay) { + return null; + } + + return native.readImageFromClipboard(); +} diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 746684de8..e123a9849 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,35 @@ # Changelog ## [Unreleased] +### Added + +- Added sticky column behavior for vertical cursor movement, preserving target column when navigating through lines of varying lengths +- Added `drainInput()` method to Terminal interface to prevent Kitty key release events from leaking to parent shell over slow SSH connections +- Added `setClearOnShrink()` method to control whether full re-render occurs when content shrinks below working area +- Added support for hidden paths (e.g., `.pi`, `.github`) in autocomplete while excluding `.git` directories + +### Changed + +- Changed autocomplete to include hidden paths but filter out `.git` and its contents +- Changed Input component to properly handle surrogate pairs in Unicode text, preventing cursor display corruption with emoji and multi-byte characters +- Changed Editor to use `setCursorCol()` for all cursor column updates, enabling sticky column tracking +- Changed Editor's vertical navigation to implement sticky column logic via `moveToVisualLine()` and `computeVerticalMoveColumn()` +- Changed Editor's Enter key handling to extract submit logic into `submitValue()` method for better code organization +- Changed SettingsList to truncate long lines to viewport width, preventing text overflow +- Changed Terminal's `stop()` method to drain stdin before restoring raw mode, fixing race condition where Ctrl+D could close parent shell over SSH +- Changed TUI rendering to add `clearOnShrink` option (controlled by `PI_CLEAR_ON_SHRINK` env var) for reducing redraws on slower terminals +- Changed TUI rendering to detect when extra lines exceed viewport height and trigger full re-render instead of incremental updates + +### Fixed + +- Fixed cursor position corruption in Input component when displaying text with emoji and combining characters +- Fixed `.git` directory appearing in autocomplete suggestions +- Fixed race condition where Kitty key release events could leak to parent shell after TUI exit over slow SSH connections +- Fixed Editor's word movement (Ctrl+Left/Right) to properly reset sticky column for subsequent vertical navigation +- Fixed Editor's undo operation to reset sticky column state when restoring cursor position +- Fixed Editor's right arrow key at end of last line to set sticky column for subsequent up/down navigation +- Fixed TUI rendering to correctly detect viewport changes and avoid false full-redraws after content shrinks +- Fixed Kitty protocol key parsing to prefer codepoint over base layout for Latin letters and symbols, fixing keyboard layout issues (e.g., Dvorak) ## [11.0.0] - 2026-02-05 diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index c962c3648..2662e0634 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -627,11 +627,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { query, path: this.basePath, maxResults: 100, - hidden: false, + hidden: true, gitignore: true, }); - const scoredEntries = result.matches + const filteredMatches = result.matches.filter(entry => { + const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path; + return p !== ".git" && !p.startsWith(".git/") && !p.includes("/.git/"); + }); + + const scoredEntries = filteredMatches .map(entry => ({ path: entry.path, isDirectory: entry.isDirectory, diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 5de6c3e3f..523ecd75c 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -306,6 +306,9 @@ export class Editor implements Component, Focusable { // Character jump mode private jumpMode: "forward" | "backward" | null = null; + // Preferred visual column for vertical cursor movement (sticky column) + private preferredVisualCol: number | null = null; + // Border color (can be changed dynamically) public borderColor: (str: string) => string; @@ -455,7 +458,7 @@ export class Editor implements Component, Focusable { const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); this.state.lines = lines.length === 0 ? [""] : lines; this.state.cursorLine = this.state.lines.length - 1; - this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0; + this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0); if (this.onChange) { this.onChange(this.getText()); @@ -768,7 +771,7 @@ export class Editor implements Component, Focusable { this.state.lines = result.lines; this.state.cursorLine = result.cursorLine; - this.state.cursorCol = result.cursorCol; + this.setCursorCol(result.cursorCol); this.cancelAutocomplete(); @@ -803,7 +806,7 @@ export class Editor implements Component, Focusable { this.state.lines = result.lines; this.state.cursorLine = result.cursorLine; - this.state.cursorCol = result.cursorCol; + this.setCursorCol(result.cursorCol); } this.cancelAutocomplete(); } @@ -823,7 +826,7 @@ export class Editor implements Component, Focusable { this.state.lines = result.lines; this.state.cursorLine = result.cursorLine; - this.state.cursorCol = result.cursorCol; + this.setCursorCol(result.cursorCol); this.cancelAutocomplete(); @@ -885,7 +888,7 @@ export class Editor implements Component, Focusable { this.addNewLine(); } } - // New line shortcuts (but not plain LF/CR which should be submit) + // New line else if ( (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers data === "\x1b[13;5u" || // Ctrl+Enter (Kitty protocol) @@ -896,7 +899,11 @@ export class Editor implements Component, Focusable { (data.length > 1 && data.includes("\x1b") && data.includes("\r")) || (data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping ) { - // Modifier + Enter = new line + if (this.shouldSubmitOnBackslashEnter(data, kb)) { + this.handleBackspace(); + this.submitValue(); + return; + } this.addNewLine(); } // Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits) @@ -906,46 +913,7 @@ export class Editor implements Component, Focusable { return; } - // Workaround for terminals without Shift+Enter support: - // If char before cursor is \, delete it and insert newline instead of submitting. - const currentLine = this.state.lines[this.state.cursorLine] || ""; - if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") { - this.handleBackspace(); - this.addNewLine(); - return; - } - - this.resetKillSequence(); - - // Get text and substitute paste markers with actual content - let result = this.state.lines.join("\n").trim(); - - // Replace all [paste #N +xxx lines] or [paste #N xxx chars] markers with actual paste content - for (const [pasteId, pasteContent] of this.pastes) { - // Match formats: [paste #N], [paste #N +xxx lines], or [paste #N xxx chars] - const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g"); - result = result.replace(markerRegex, pasteContent); - } - - // Reset editor and clear pastes - this.state = { - lines: [""], - cursorLine: 0, - cursorCol: 0, - }; - this.clearUndoStack(); - this.pastes.clear(); - this.pasteCounter = 0; - this.historyIndex = -1; // Exit history browsing mode - - // Notify that editor is now empty - if (this.onChange) { - this.onChange(""); - } - - if (this.onSubmit) { - this.onSubmit(result); - } + this.submitValue(); } // Backspace (including Shift+Backspace) else if (matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) { @@ -1155,7 +1123,7 @@ export class Editor implements Component, Focusable { const after = line.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = before + text + after; - this.state.cursorCol += text.length; + this.setCursorCol(this.state.cursorCol + text.length); if (this.onChange) { this.onChange(this.getText()); @@ -1174,7 +1142,7 @@ export class Editor implements Component, Focusable { const after = line.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = before + char + after; - this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string + this.setCursorCol(this.state.cursorCol + char.length); if (this.onChange) { this.onChange(this.getText()); @@ -1292,13 +1260,44 @@ export class Editor implements Component, Focusable { // Move cursor to start of new line this.state.cursorLine++; - this.state.cursorCol = 0; + this.setCursorCol(0); if (this.onChange) { this.onChange(this.getText()); } } + private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType): boolean { + if (this.disableSubmit) return false; + if (!matchesKey(data, "enter")) return false; + const submitKeys = kb.getKeys("submit"); + const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return"); + if (!hasShiftEnter) return false; + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\"; + } + + private submitValue(): void { + this.resetKillSequence(); + + let result = this.state.lines.join("\n").trim(); + for (const [pasteId, pasteContent] of this.pastes) { + const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g"); + result = result.replace(markerRegex, pasteContent); + } + + this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; + this.pastes.clear(); + this.pasteCounter = 0; + this.historyIndex = -1; + this.scrollOffset = 0; + this.undoStack.length = 0; + + if (this.onChange) this.onChange(""); + if (this.onSubmit) this.onSubmit(result); + } + private handleBackspace(): void { this.historyIndex = -1; // Exit history browsing mode this.resetKillSequence(); @@ -1318,7 +1317,7 @@ export class Editor implements Component, Focusable { const after = line.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = before + after; - this.state.cursorCol -= graphemeLength; + this.setCursorCol(this.state.cursorCol - graphemeLength); } else if (this.state.cursorLine > 0) { // Merge with previous line const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1328,7 +1327,7 @@ export class Editor implements Component, Focusable { this.state.lines.splice(this.state.cursorLine, 1); this.state.cursorLine--; - this.state.cursorCol = previousLine.length; + this.setCursorCol(previousLine.length); } if (this.onChange) { @@ -1353,15 +1352,96 @@ export class Editor implements Component, Focusable { } } + /** + * Set cursor column and clear preferredVisualCol. + * Use this for all non-vertical cursor movements to reset sticky column behavior. + */ + private setCursorCol(col: number): void { + this.state.cursorCol = col; + this.preferredVisualCol = null; + } + + /** + * Move cursor to a target visual line, applying sticky column logic. + * Shared by moveCursor() and pageScroll(). + */ + private moveToVisualLine( + visualLines: Array<{ logicalLine: number; startCol: number; length: number }>, + currentVisualLine: number, + targetVisualLine: number, + ): void { + const currentVL = visualLines[currentVisualLine]; + const targetVL = visualLines[targetVisualLine]; + + if (currentVL && targetVL) { + const currentVisualCol = this.state.cursorCol - currentVL.startCol; + + // For non-last segments, clamp to length-1 to stay within the segment + const isLastSourceSegment = + currentVisualLine === visualLines.length - 1 || + visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine; + const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1); + + const isLastTargetSegment = + targetVisualLine === visualLines.length - 1 || + visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine; + const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1); + + const moveToVisualCol = this.computeVerticalMoveColumn( + currentVisualCol, + sourceMaxVisualCol, + targetMaxVisualCol, + ); + + // Set cursor position + this.state.cursorLine = targetVL.logicalLine; + const targetCol = targetVL.startCol + moveToVisualCol; + const logicalLine = this.state.lines[targetVL.logicalLine] || ""; + this.state.cursorCol = Math.min(targetCol, logicalLine.length); + } + } + + /** + * Compute the target visual column for vertical cursor movement. + * Implements the sticky column decision table. + */ + private computeVerticalMoveColumn( + currentVisualCol: number, + sourceMaxVisualCol: number, + targetMaxVisualCol: number, + ): number { + const hasPreferred = this.preferredVisualCol !== null; + const cursorInMiddle = currentVisualCol < sourceMaxVisualCol; + const targetTooShort = targetMaxVisualCol < currentVisualCol; + + if (!hasPreferred || cursorInMiddle) { + if (targetTooShort) { + this.preferredVisualCol = currentVisualCol; + return targetMaxVisualCol; + } + this.preferredVisualCol = null; + return currentVisualCol; + } + + const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol!; + if (targetTooShort || targetCantFitPreferred) { + return targetMaxVisualCol; + } + + const result = this.preferredVisualCol!; + this.preferredVisualCol = null; + return result; + } + private moveToLineStart(): void { this.resetKillSequence(); - this.state.cursorCol = 0; + this.setCursorCol(0); } private moveToLineEnd(): void { this.resetKillSequence(); const currentLine = this.state.lines[this.state.cursorLine] || ""; - this.state.cursorCol = currentLine.length; + this.setCursorCol(currentLine.length); } private resetKillSequence(): void { @@ -1412,6 +1492,7 @@ export class Editor implements Component, Focusable { this.historyIndex = -1; this.resetKillSequence(); + this.preferredVisualCol = null; this.state = { lines: [...snapshot.lines], cursorLine: snapshot.cursorLine, @@ -1462,7 +1543,7 @@ export class Editor implements Component, Focusable { const before = line.slice(0, this.state.cursorCol); const after = line.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = before + normalized + after; - this.state.cursorCol += normalized.length; + this.setCursorCol(this.state.cursorCol + normalized.length); } else { const currentLine = this.state.lines[this.state.cursorLine] || ""; const beforeCursor = currentLine.slice(0, this.state.cursorCol); @@ -1485,7 +1566,7 @@ export class Editor implements Component, Focusable { this.state.lines = newLines; this.state.cursorLine += lines.length - 1; - this.state.cursorCol = (lines[lines.length - 1] || "").length; + this.setCursorCol((lines[lines.length - 1] || "").length); } if (this.onChange) { @@ -1509,7 +1590,7 @@ export class Editor implements Component, Focusable { // Delete from start of line up to cursor deletedText = currentLine.slice(0, this.state.cursorCol); this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol); - this.state.cursorCol = 0; + this.setCursorCol(0); } else if (this.state.cursorLine > 0) { // At start of line - merge with previous line deletedText = "\n"; @@ -1517,7 +1598,7 @@ export class Editor implements Component, Focusable { this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; this.state.lines.splice(this.state.cursorLine, 1); this.state.cursorLine--; - this.state.cursorCol = previousLine.length; + this.setCursorCol(previousLine.length); } this.recordKill(deletedText, "backward"); @@ -1567,18 +1648,18 @@ export class Editor implements Component, Focusable { this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; this.state.lines.splice(this.state.cursorLine, 1); this.state.cursorLine--; - this.state.cursorCol = previousLine.length; + this.setCursorCol(previousLine.length); } } else { const oldCursorCol = this.state.cursorCol; this.moveWordBackwards(); const deleteFrom = this.state.cursorCol; - this.state.cursorCol = oldCursorCol; + this.setCursorCol(oldCursorCol); const deletedText = currentLine.slice(deleteFrom, oldCursorCol); this.state.lines[this.state.cursorLine] = currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol); - this.state.cursorCol = deleteFrom; + this.setCursorCol(deleteFrom); this.recordKill(deletedText, "backward"); } @@ -1604,7 +1685,7 @@ export class Editor implements Component, Focusable { const oldCursorCol = this.state.cursorCol; this.moveWordForwards(); const deleteTo = this.state.cursorCol; - this.state.cursorCol = oldCursorCol; + this.setCursorCol(oldCursorCol); const deletedText = currentLine.slice(oldCursorCol, deleteTo); this.state.lines[this.state.cursorLine] = currentLine.slice(0, oldCursorCol) + currentLine.slice(deleteTo); @@ -1723,29 +1804,14 @@ export class Editor implements Component, Focusable { private moveCursor(deltaLine: number, deltaCol: number): void { this.resetKillSequence(); - const contentWidth = this.lastLayoutWidth; + const visualLines = this.buildVisualLineMap(this.lastLayoutWidth); + const currentVisualLine = this.findCurrentVisualLine(visualLines); if (deltaLine !== 0) { - // Build visual line map for navigation - const visualLines = this.buildVisualLineMap(contentWidth); - const currentVisualLine = this.findCurrentVisualLine(visualLines); - - // Calculate column position within current visual line - const currentVL = visualLines[currentVisualLine]; - const visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0; - - // Move to target visual line const targetVisualLine = currentVisualLine + deltaLine; if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) { - const targetVL = visualLines[targetVisualLine]; - if (targetVL) { - this.state.cursorLine = targetVL.logicalLine; - // Try to maintain visual column position, clamped to line length - const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length); - const logicalLine = this.state.lines[targetVL.logicalLine] || ""; - this.state.cursorCol = Math.min(targetCol, logicalLine.length); - } + this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine); } } @@ -1758,11 +1824,17 @@ export class Editor implements Component, Focusable { const afterCursor = currentLine.slice(this.state.cursorCol); const graphemes = [...segmenter.segment(afterCursor)]; const firstGrapheme = graphemes[0]; - this.state.cursorCol += firstGrapheme ? firstGrapheme.segment.length : 1; + this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1)); } else if (this.state.cursorLine < this.state.lines.length - 1) { // Wrap to start of next logical line this.state.cursorLine++; - this.state.cursorCol = 0; + this.setCursorCol(0); + } else { + // At end of last line - can't move, but set preferredVisualCol for up/down navigation + const currentVL = visualLines[currentVisualLine]; + if (currentVL) { + this.preferredVisualCol = this.state.cursorCol - currentVL.startCol; + } } } else { // Moving left - move by one grapheme (handles emojis, combining characters, etc.) @@ -1770,12 +1842,12 @@ export class Editor implements Component, Focusable { const beforeCursor = currentLine.slice(0, this.state.cursorCol); const graphemes = [...segmenter.segment(beforeCursor)]; const lastGrapheme = graphemes[graphemes.length - 1]; - this.state.cursorCol -= lastGrapheme ? lastGrapheme.segment.length : 1; + this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1)); } else if (this.state.cursorLine > 0) { // Wrap to end of previous logical line this.state.cursorLine--; const prevLine = this.state.lines[this.state.cursorLine] || ""; - this.state.cursorCol = prevLine.length; + this.setCursorCol(prevLine.length); } } } @@ -1789,7 +1861,7 @@ export class Editor implements Component, Focusable { if (this.state.cursorLine > 0) { this.state.cursorLine--; const prevLine = this.state.lines[this.state.cursorLine] || ""; - this.state.cursorCol = prevLine.length; + this.setCursorCol(prevLine.length); } return; } @@ -1822,7 +1894,7 @@ export class Editor implements Component, Focusable { } } - this.state.cursorCol = newCol; + this.setCursorCol(newCol); } /** @@ -1852,7 +1924,7 @@ export class Editor implements Component, Focusable { if (idx !== -1) { this.state.cursorLine = lineIdx; - this.state.cursorCol = idx; + this.setCursorCol(idx); return; } } @@ -1866,7 +1938,7 @@ export class Editor implements Component, Focusable { if (this.state.cursorCol >= currentLine.length) { if (this.state.cursorLine < this.state.lines.length - 1) { this.state.cursorLine++; - this.state.cursorCol = 0; + this.setCursorCol(0); } return; } @@ -1875,10 +1947,11 @@ export class Editor implements Component, Focusable { const segments = segmenter.segment(textAfterCursor); const iterator = segments[Symbol.iterator](); let next = iterator.next(); + let newCol = this.state.cursorCol; // Skip leading whitespace while (!next.done && isWhitespaceChar(next.value.segment)) { - this.state.cursorCol += next.value.segment.length; + newCol += next.value.segment.length; next = iterator.next(); } @@ -1887,17 +1960,19 @@ export class Editor implements Component, Focusable { if (isPunctuationChar(firstGrapheme)) { // Skip punctuation run while (!next.done && isPunctuationChar(next.value.segment)) { - this.state.cursorCol += next.value.segment.length; + newCol += next.value.segment.length; next = iterator.next(); } } else { // Skip word run while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) { - this.state.cursorCol += next.value.segment.length; + newCol += next.value.segment.length; next = iterator.next(); } } } + + this.setCursorCol(newCol); } // Helper method to check if cursor is at start of message (for slash command detection) @@ -2000,7 +2075,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ this.state.lines = result.lines; this.state.cursorLine = result.cursorLine; - this.state.cursorCol = result.cursorCol; + this.setCursorCol(result.cursorCol); if (this.onChange) { this.onChange(this.getText()); diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 52159b9b8..0ddbba199 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -307,27 +307,57 @@ export class Input implements Component, Focusable { const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth; const halfWidth = Math.floor(scrollWidth / 2); + const findValidStart = (start: number) => { + while (start < this.value.length) { + const charCode = this.value.charCodeAt(start); + // this is low surrogate, not a valid start + if (charCode >= 0xdc00 && charCode < 0xe000) { + start++; + continue; + } + break; + } + return start; + }; + + const findValidEnd = (end: number) => { + while (end > 0) { + const charCode = this.value.charCodeAt(end - 1); + // this is high surrogate, might be split. + if (charCode >= 0xd800 && charCode < 0xdc00) { + end--; + continue; + } + break; + } + return end; + }; + if (this.cursor < halfWidth) { // Cursor near start - visibleText = this.value.slice(0, scrollWidth); + visibleText = this.value.slice(0, findValidEnd(scrollWidth)); cursorDisplay = this.cursor; } else if (this.cursor > this.value.length - halfWidth) { // Cursor near end - visibleText = this.value.slice(this.value.length - scrollWidth); - cursorDisplay = scrollWidth - (this.value.length - this.cursor); + const start = findValidStart(this.value.length - scrollWidth); + visibleText = this.value.slice(start); + cursorDisplay = this.cursor - start; } else { // Cursor in middle - const start = this.cursor - halfWidth; - visibleText = this.value.slice(start, start + scrollWidth); + const start = findValidStart(this.cursor - halfWidth); + visibleText = this.value.slice(start, findValidEnd(start + scrollWidth)); cursorDisplay = halfWidth; } } // Build line with fake cursor // Insert cursor character at cursor position + const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))]; + const cursorGrapheme = graphemes[0]; + const beforeCursor = visibleText.slice(0, cursorDisplay); - const atCursor = visibleText[cursorDisplay] || " "; // Character at cursor, or space if at end - const afterCursor = visibleText.slice(cursorDisplay + 1); + const atCursor = cursorGrapheme?.segment ?? " "; + const afterCursor = visibleText.slice(cursorDisplay + atCursor.length); // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning) const marker = this.focused ? CURSOR_MARKER : ""; diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index d86e430ff..5e3620f68 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -113,7 +113,7 @@ export class SettingsList implements Component { isSelected, ); - lines.push(prefix + labelText + separator + valueText); + lines.push(truncateToWidth(prefix + labelText + separator + valueText, width)); } // Add scroll indicator if needed @@ -134,7 +134,7 @@ export class SettingsList implements Component { // Add hint lines.push(""); - lines.push(this.theme.hint("Enter/Space to change · Esc to cancel")); + lines.push(truncateToWidth(this.theme.hint(" Enter/Space to change · Esc to cancel"), width)); return lines; } diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index a9abad0fa..8beac899d 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -45,6 +45,14 @@ export interface Terminal { // Stop the terminal and restore state stop(): void; + /** + * Drain stdin before exiting to prevent Kitty key release events from + * leaking to the parent shell over slow SSH connections. + * @param maxMs - Maximum time to drain (default: 1000ms) + * @param idleMs - Exit early if no input arrives within this time (default: 50ms) + */ + drainInput(maxMs?: number, idleMs?: number): Promise; + // Write output to terminal write(data: string): void; @@ -187,6 +195,40 @@ export class ProcessTerminal implements Terminal { this.safeWrite("\x1b[?u"); } + async drainInput(maxMs = 1000, idleMs = 50): Promise { + if (this._kittyProtocolActive) { + // Disable Kitty keyboard protocol first so any late key releases + // do not generate new Kitty escape sequences. + process.stdout.write("\x1b[ { + lastDataTime = Date.now(); + }; + + process.stdin.on("data", onData); + const endTime = Date.now() + maxMs; + + try { + while (true) { + const now = Date.now(); + const timeLeft = endTime - now; + if (timeLeft <= 0) break; + if (now - lastDataTime >= idleMs) break; + await new Promise(resolve => setTimeout(resolve, Math.min(idleMs, timeLeft))); + } + } finally { + process.stdin.removeListener("data", onData); + this.inputHandler = previousHandler; + } + } + stop(): void { // Unregister from emergency cleanup if (activeTerminal === this) { @@ -196,7 +238,7 @@ export class ProcessTerminal implements Terminal { // Disable bracketed paste mode this.safeWrite("\x1b[?2004l"); - // Disable Kitty keyboard protocol (pop the flags we pushed) - only if we enabled it + // Disable Kitty keyboard protocol if not already done by drainInput() if (this._kittyProtocolActive) { this.safeWrite("\x1b[ void; private renderRequested = false; - private stopped = false; private rendering = false; private cursorRow = 0; // Logical cursor row (end of rendered content) private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning) private inputBuffer = ""; // Buffer for parsing terminal responses private cellSizeQueryPending = false; private showHardwareCursor = $env.PI_HARDWARE_CURSOR === "1"; + private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered) private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves private fullRedrawCount = 0; + private stopped = false; // Overlay stack for modal components rendered on top of base content private overlayStack: { @@ -267,6 +268,19 @@ export class TUI extends Container { this.requestRender(); } + getClearOnShrink(): boolean { + return this.clearOnShrink; + } + + /** + * Set whether to trigger full re-render when content shrinks. + * When true (default), empty rows are cleared when content shrinks. + * When false, empty rows remain (reduces redraws on slower terminals). + */ + setClearOnShrink(enabled: boolean): void { + this.clearOnShrink = enabled; + } + setFocus(component: Component | null): void { // Clear focused flag on old component if (isFocusable(this.focusedComponent)) { @@ -884,6 +898,7 @@ export class TUI extends Container { } private doRender(): void { + if (this.stopped) return; // Guard against re-entrant renders (can happen on Windows when Bun.spawnSync // yields to the event loop during a sync subprocess call) if (this.rendering) return; @@ -921,11 +936,20 @@ export class TUI extends Container { newLines = this.applyLineResets(newLines); - // Width changed - need full re-render + // Width changed - need full re-render (line wrapping changes) const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; + const debugRedraw = process.env.PI_DEBUG_REDRAW === "1"; + const logRedraw = (reason: string): void => { + if (!debugRedraw) return; + const logPath = path.join(os.homedir(), ".pi", "agent", "pi-debug.log"); + const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`; + fs.appendFileSync(logPath, msg); + }; + // First render - just output everything without clearing (assumes clean screen) if (this.previousLines.length === 0 && !widthChanged) { + logRedraw("first render"); this.fullRedrawCount += 1; let buffer = "\x1b[?2026h"; // Begin synchronized output for (let i = 0; i < newLines.length; i++) { @@ -945,8 +969,33 @@ export class TUI extends Container { return; } - // Width changed - full re-render + // Width changed - full re-render (line wrapping changes) if (widthChanged) { + logRedraw(`width changed (${this.previousWidth} -> ${width})`); + this.fullRedrawCount += 1; + let buffer = "\x1b[?2026h"; // Begin synchronized output + buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer += "\r\n"; + buffer += newLines[i]; + } + buffer += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer); + this.cursorRow = Math.max(0, newLines.length - 1); + this.hardwareCursorRow = this.cursorRow; + this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousWidth = width; + return; + } + + // Content shrunk below the working area and no overlays - re-render to clear empty rows + // (overlays need the padding, so only do this when no overlays are active) + // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK env var + if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) { + logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`); this.fullRedrawCount += 1; let buffer = "\x1b[?2026h"; // Begin synchronized output buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home @@ -1007,8 +1056,28 @@ export class TUI extends Container { if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; buffer += "\r"; - // Clear extra lines + // Clear extra lines without scrolling const extraLines = this.previousLines.length - newLines.length; + if (extraLines > height) { + logRedraw(`extraLines > height (${extraLines} > ${height})`); + this.fullRedrawCount += 1; + let buffer2 = "\x1b[?2026h"; // Begin synchronized output + buffer2 += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer2 += "\r\n"; + buffer2 += newLines[i]; + } + buffer2 += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer2); + this.cursorRow = Math.max(0, newLines.length - 1); + this.hardwareCursorRow = this.cursorRow; + this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousWidth = width; + return; + } for (let i = 0; i < extraLines; i++) { buffer += "\r\n\x1b[2K"; } @@ -1025,9 +1094,12 @@ export class TUI extends Container { return; } - // Check if firstChanged is outside the viewport (based on maxLinesRendered) - if (firstChanged < viewportTop) { - // First change is above viewport - need full re-render + // Check if firstChanged is above what was previously visible + // Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks + const previousContentViewportTop = Math.max(0, this.previousLines.length - height); + if (firstChanged < previousContentViewportTop) { + // First change is above previous viewport - need full re-render + logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`); this.fullRedrawCount += 1; let buffer = "\x1b[?2026h"; // Begin synchronized output buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index 742f23f24..ce69640b7 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete"; describe("CombinedAutocompleteProvider", () => { @@ -57,4 +60,35 @@ describe("CombinedAutocompleteProvider", () => { } }); }); + + describe("hidden paths", () => { + let baseDir: string; + + beforeEach(() => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "autocomplete-test-")); + }); + + afterEach(() => { + fs.rmSync(baseDir, { recursive: true, force: true }); + }); + + it("includes hidden paths but excludes .git", async () => { + for (const dir of [".pi", ".github", ".git"]) { + fs.mkdirSync(path.join(baseDir, dir), { recursive: true }); + } + fs.mkdirSync(path.join(baseDir, ".github", "workflows"), { recursive: true }); + fs.writeFileSync(path.join(baseDir, ".pi", "config.json"), "{}"); + fs.writeFileSync(path.join(baseDir, ".github", "workflows", "ci.yml"), "name: ci"); + fs.writeFileSync(path.join(baseDir, ".git", "config"), "[core]"); + + const provider = new CombinedAutocompleteProvider([], baseDir); + const line = "@"; + const result = await provider.getSuggestions([line], 0, line.length); + + const values = result?.items.map(item => item.value) ?? []; + expect(values).toContain("@.pi/"); + expect(values).toContain("@.github/"); + expect(values.some(value => value === "@.git" || value.startsWith("@.git/"))).toBe(false); + }); + }); }); diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 07f80fdc6..7795264e4 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -719,4 +719,439 @@ describe("Editor component", () => { expect(allText).toBe("1234567890"); }); }); + + describe("Sticky column", () => { + it("preserves target column when moving up through a shorter line", () => { + const editor = new Editor(defaultEditorTheme); + + // Line 0: "2222222222x222" (x at col 10) + // Line 1: "" (empty) + // Line 2: "1111111111_111111111111" (_ at col 10) + editor.setText("2222222222x222\n\n1111111111_111111111111"); + + // Position cursor on _ (line 2, col 10) + expect(editor.getCursor()).toEqual({ line: 2, col: 23 }); // At end + editor.handleInput("\x01"); // Ctrl+A - go to start of line + for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C"); // Move right to col 10 + expect(editor.getCursor()).toEqual({ line: 2, col: 10 }); + + // Press Up - should move to empty line (col clamped to 0) + editor.handleInput("\x1b[A"); // Up arrow + expect(editor.getCursor()).toEqual({ line: 1, col: 0 }); + + // Press Up again - should move to line 0 at col 10 (on 'x') + editor.handleInput("\x1b[A"); // Up arrow + expect(editor.getCursor()).toEqual({ line: 0, col: 10 }); + }); + + it("preserves target column when moving down through a shorter line", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("1111111111_111\n\n2222222222x222222222222"); + + // Position cursor on _ (line 0, col 10) + editor.handleInput("\x1b[A"); // Up to line 1 + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C"); + expect(editor.getCursor()).toEqual({ line: 0, col: 10 }); + + // Press Down - should move to empty line (col clamped to 0) + editor.handleInput("\x1b[B"); // Down arrow + expect(editor.getCursor()).toEqual({ line: 1, col: 0 }); + + // Press Down again - should move to line 2 at col 10 (on 'x') + editor.handleInput("\x1b[B"); // Down arrow + expect(editor.getCursor()).toEqual({ line: 2, col: 10 }); + }); + + it("resets sticky column on horizontal movement (left arrow)", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 2, col 5 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); + expect(editor.getCursor()).toEqual({ line: 2, col: 5 }); + + // Move up through empty line + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 5 (sticky) + expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); + + // Move left - resets sticky column + editor.handleInput("\x1b[D"); // Left + expect(editor.getCursor()).toEqual({ line: 0, col: 4 }); + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 4 (new sticky from col 4) + expect(editor.getCursor()).toEqual({ line: 2, col: 4 }); + }); + + it("resets sticky column on horizontal movement (right arrow)", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 0, col 5 + editor.handleInput("\x1b[A"); // Up to line 1 + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); + expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); + + // Move down through empty line + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 5 (sticky) + expect(editor.getCursor()).toEqual({ line: 2, col: 5 }); + + // Move right - resets sticky column + editor.handleInput("\x1b[C"); // Right + expect(editor.getCursor()).toEqual({ line: 2, col: 6 }); + + // Move up twice + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 6 (new sticky from col 6) + expect(editor.getCursor()).toEqual({ line: 0, col: 6 }); + }); + + it("resets sticky column on typing", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 2, col 8 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + + // Move up through empty line + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up - line 0, col 8 + expect(editor.getCursor()).toEqual({ line: 0, col: 8 }); + + // Type a character - resets sticky column + editor.handleInput("X"); + expect(editor.getCursor()).toEqual({ line: 0, col: 9 }); + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 9 (new sticky from col 9) + expect(editor.getCursor()).toEqual({ line: 2, col: 9 }); + }); + + it("resets sticky column on backspace", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 2, col 8 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + + // Move up through empty line + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up - line 0, col 8 + expect(editor.getCursor()).toEqual({ line: 0, col: 8 }); + + // Backspace - resets sticky column + editor.handleInput("\x7f"); // Backspace + expect(editor.getCursor()).toEqual({ line: 0, col: 7 }); + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 7 (new sticky from col 7) + expect(editor.getCursor()).toEqual({ line: 2, col: 7 }); + }); + + it("resets sticky column on Ctrl+A (move to line start)", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 2, col 8 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + + // Move up - establishes sticky col 8 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + + // Ctrl+A - resets sticky column to 0 + editor.handleInput("\x01"); // Ctrl+A + expect(editor.getCursor()).toEqual({ line: 1, col: 0 }); + + // Move up + editor.handleInput("\x1b[A"); // Up - line 0, col 0 (new sticky from col 0) + expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); + }); + + it("resets sticky column on Ctrl+E (move to line end)", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("12345\n\n1234567890"); + + // Start at line 2, col 3 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 3; i++) editor.handleInput("\x1b[C"); + + // Move up through empty line - establishes sticky col 3 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 3 + expect(editor.getCursor()).toEqual({ line: 0, col: 3 }); + + // Ctrl+E - resets sticky column to end + editor.handleInput("\x05"); // Ctrl+E + expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 5 (new sticky from col 5) + expect(editor.getCursor()).toEqual({ line: 2, col: 5 }); + }); + + it("resets sticky column on word movement (Ctrl+Left)", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("hello world\n\nhello world"); + + // Start at end of line 2 (col 11) + expect(editor.getCursor()).toEqual({ line: 2, col: 11 }); + + // Move up through empty line - establishes sticky col 11 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 11 + expect(editor.getCursor()).toEqual({ line: 0, col: 11 }); + + // Ctrl+Left - word movement resets sticky column + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + expect(editor.getCursor()).toEqual({ line: 0, col: 6 }); // Before "world" + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 6 (new sticky from col 6) + expect(editor.getCursor()).toEqual({ line: 2, col: 6 }); + }); + + it("resets sticky column on word movement (Ctrl+Right)", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("hello world\n\nhello world"); + + // Start at line 0, col 0 + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x01"); // Ctrl+A + expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); + + // Move down through empty line - establishes sticky col 0 + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 0 + expect(editor.getCursor()).toEqual({ line: 2, col: 0 }); + + // Ctrl+Right - word movement resets sticky column + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + expect(editor.getCursor()).toEqual({ line: 2, col: 5 }); // After "hello" + + // Move up twice + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 5 (new sticky from col 5) + expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); + }); + + it("resets sticky column on undo", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Go to line 0, col 8 + editor.handleInput("\x1b[A"); // Up to line 1 + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + expect(editor.getCursor()).toEqual({ line: 0, col: 8 }); + + // Move down through empty line - establishes sticky col 8 + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 8 (sticky) + expect(editor.getCursor()).toEqual({ line: 2, col: 8 }); + + // Type something to create undo state - this clears sticky and sets col to 9 + editor.handleInput("X"); + expect(editor.getText()).toBe("1234567890\n\n12345678X90"); + expect(editor.getCursor()).toEqual({ line: 2, col: 9 }); + + // Move up - establishes new sticky col 9 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 9 + expect(editor.getCursor()).toEqual({ line: 0, col: 9 }); + + // Undo - resets sticky column and restores cursor to line 2, col 8 + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + expect(editor.getText()).toBe("1234567890\n\n1234567890"); + expect(editor.getCursor()).toEqual({ line: 2, col: 8 }); + + // Move up - should capture new sticky from restored col 8, not old col 9 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 8 (new sticky from restored position) + expect(editor.getCursor()).toEqual({ line: 0, col: 8 }); + }); + + it("handles multiple consecutive up/down movements", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("1234567890\nab\ncd\nef\n1234567890"); + + // Start at line 4, col 7 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 7; i++) editor.handleInput("\x1b[C"); + expect(editor.getCursor()).toEqual({ line: 4, col: 7 }); + + // Move up multiple times through short lines + editor.handleInput("\x1b[A"); // Up - line 3, col 2 (clamped) + editor.handleInput("\x1b[A"); // Up - line 2, col 2 (clamped) + editor.handleInput("\x1b[A"); // Up - line 1, col 2 (clamped) + editor.handleInput("\x1b[A"); // Up - line 0, col 7 (restored) + expect(editor.getCursor()).toEqual({ line: 0, col: 7 }); + + // Move down multiple times - sticky should still be 7 + editor.handleInput("\x1b[B"); // Down - line 1, col 2 + editor.handleInput("\x1b[B"); // Down - line 2, col 2 + editor.handleInput("\x1b[B"); // Down - line 3, col 2 + editor.handleInput("\x1b[B"); // Down - line 4, col 7 (restored) + expect(editor.getCursor()).toEqual({ line: 4, col: 7 }); + }); + + it("moves correctly through wrapped visual lines without getting stuck", () => { + const editor = new Editor(defaultEditorTheme); + + // Line 0: short + // Line 1: 30 chars = wraps to multiple visual lines at narrow width + editor.setText("short\n123456789012345678901234567890"); + editor.render(15); // Narrow width to force wrapping + + // Position at end of line 1 (col 30) + expect(editor.getCursor()).toEqual({ line: 1, col: 30 }); + + // Move up repeatedly - should traverse all visual lines of the wrapped text + // and eventually reach line 0 + editor.handleInput("\x1b[A"); // Up - to previous visual line within line 1 + expect(editor.getCursor().line).toBe(1); + + editor.handleInput("\x1b[A"); // Up - another visual line + expect(editor.getCursor().line).toBe(1); + + editor.handleInput("\x1b[A"); // Up - should reach line 0 + expect(editor.getCursor().line).toBe(0); + }); + + it("handles setText resetting sticky column", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Establish sticky column + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + editor.handleInput("\x1b[A"); // Up + + // setText should reset sticky column + editor.setText("abcdefghij\n\nabcdefghij"); + expect(editor.getCursor()).toEqual({ line: 2, col: 10 }); // At end + + // Move up - should capture new sticky from current position (10) + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 10 + expect(editor.getCursor()).toEqual({ line: 0, col: 10 }); + }); + + it("sets preferredVisualCol when pressing right at end of prompt (last line)", () => { + const editor = new Editor(defaultEditorTheme); + + // Line 0: 20 chars with 'x' at col 10 + // Line 1: empty + // Line 2: 10 chars ending with '_' + editor.setText("111111111x1111111111\n\n333333333_"); + + // Go to line 0, press Ctrl+E (end of line) - col 20 + editor.handleInput("\x1b[A"); // Up to line 1 + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x05"); // Ctrl+E - move to end of line + expect(editor.getCursor()).toEqual({ line: 0, col: 20 }); + + // Move down to line 2 - cursor clamped to col 10 (end of line) + editor.handleInput("\x1b[B"); // Down to line 1, col 0 + editor.handleInput("\x1b[B"); // Down to line 2, col 10 (clamped) + expect(editor.getCursor()).toEqual({ line: 2, col: 10 }); + + // Press Right at end of prompt - nothing visible happens, but sets preferredVisualCol to 10 + editor.handleInput("\x1b[C"); // Right - can't move, but sets preferredVisualCol + expect(editor.getCursor()).toEqual({ line: 2, col: 10 }); // Still at same position + + // Move up twice to line 0 - should use preferredVisualCol (10) to land on 'x' + editor.handleInput("\x1b[A"); // Up to line 1, col 0 + editor.handleInput("\x1b[A"); // Up to line 0, col 10 (on 'x') + expect(editor.getCursor()).toEqual({ line: 0, col: 10 }); + }); + + it("handles editor resizes when preferredVisualCol is on the same line", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("12345678901234567890\n\n12345678901234567890"); + + // Start at line 2, col 15 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 15; i++) editor.handleInput("\x1b[C"); + + // Move up through empty line - establishes sticky col 15 + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up - line 0, col 15 + expect(editor.getCursor()).toEqual({ line: 0, col: 15 }); + + // Render with narrower width to simulate resize + editor.render(12); // Width 12 + + // Move down - sticky should be clamped to new width + editor.handleInput("\x1b[B"); // Down - line 1 + editor.handleInput("\x1b[B"); // Down - line 2, col should be clamped + expect(editor.getCursor().col).toBe(4); + }); + + it("handles editor resizes when preferredVisualCol is on a different line", () => { + const editor = new Editor(defaultEditorTheme); + + // Create a line that wraps into multiple visual lines at width 10 + // "12345678901234567890" = 20 chars, wraps to 2 visual lines at width 10 + editor.setText("short\n12345678901234567890"); + + // Go to line 1, col 15 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 15; i++) editor.handleInput("\x1b[C"); + expect(editor.getCursor()).toEqual({ line: 1, col: 15 }); + + // Move up to establish sticky col 15 + editor.handleInput("\x1b[A"); // Up to line 0 + // Line 0 has only 5 chars, so cursor at col 5 + expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); + + // Narrow the editor + editor.render(10); + + // Move down - preferredVisualCol was 15, but width is 10 + // Should land on line 1, clamped to width (visual col 9, which is logical col 9) + editor.handleInput("\x1b[B"); // Down to line 1 + expect(editor.getCursor()).toEqual({ line: 1, col: 8 }); + + // Move up + editor.handleInput("\x1b[A"); // Up - should go to line 0 + expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); // Line 0 only has 5 chars + + // Restore the original width + editor.render(80); + + // Move down - preferredVisualCol was kept at 15 + editor.handleInput("\x1b[B"); // Down to line 1 + expect(editor.getCursor()).toEqual({ line: 1, col: 15 }); + }); + }); }); diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index 113535d76..c26a8d7cd 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { matchesKey, setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; +import { matchesKey, parseKey, setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; describe("matchesKey", () => { it("matches ctrl+letter sequences", () => { @@ -17,4 +17,38 @@ describe("matchesKey", () => { setKittyProtocolActive(false); expect(matchesKey("\x1b[5~", "pageUp")).toBe(true); }); + + it("should prefer codepoint for Latin letters even when base layout differs", () => { + setKittyProtocolActive(true); + // Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118) + const dvorakCtrlK = "\x1b[107::118;5u"; + expect(matchesKey(dvorakCtrlK, "ctrl+k")).toBe(true); + expect(matchesKey(dvorakCtrlK, "ctrl+v")).toBe(false); + setKittyProtocolActive(false); + }); + + it("should prefer codepoint for symbol keys even when base layout differs", () => { + setKittyProtocolActive(true); + // Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91) + const dvorakCtrlSlash = "\x1b[47::91;5u"; + expect(matchesKey(dvorakCtrlSlash, "ctrl+/")).toBe(true); + expect(matchesKey(dvorakCtrlSlash, "ctrl+[")).toBe(false); + setKittyProtocolActive(false); + }); +}); + +describe("parseKey", () => { + it("should prefer codepoint for Latin letters when base layout differs", () => { + setKittyProtocolActive(true); + const dvorakCtrlK = "\x1b[107::118;5u"; + expect(parseKey(dvorakCtrlK)).toBe("ctrl+k"); + setKittyProtocolActive(false); + }); + + it("should prefer codepoint for symbol keys when base layout differs", () => { + setKittyProtocolActive(true); + const dvorakCtrlSlash = "\x1b[47::91;5u"; + expect(parseKey(dvorakCtrlSlash)).toBe("ctrl+/"); + setKittyProtocolActive(false); + }); }); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index f85fc6024..1478259b5 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -692,6 +692,157 @@ again, hello world`, }); }); + describe("Blockquotes with multiline content", () => { + it("should apply consistent styling to all lines in lazy continuation blockquote", () => { + // Markdown "lazy continuation" - second line without > is still part of the quote + const markdown = new Markdown( + `>Foo +bar`, + 0, + 0, + defaultMarkdownTheme, + { + color: text => chalk.magenta(text), // This should NOT be applied to blockquotes + }, + ); + + const lines = markdown.render(80); + + // Both lines should have the quote border + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "")); + const quotedLines = plainLines.filter(line => line.startsWith("│ ")); + expect(quotedLines.length).toBe(2); + + // Both lines should have italic (from theme.quote styling) + const fooLine = lines.find(line => line.includes("Foo")); + const barLine = lines.find(line => line.includes("bar")); + expect(fooLine).toBeTruthy(); + expect(barLine).toBeTruthy(); + + // Check that both have italic (\x1b[3m) - blockquotes use theme styling, not default message color + expect(fooLine?.includes("\x1b[3m")).toBeTruthy(); + expect(barLine?.includes("\x1b[3m")).toBeTruthy(); + + // Blockquotes should NOT have the default message color (magenta) + expect(fooLine?.includes("\x1b[35m")).toBeFalsy(); + expect(barLine?.includes("\x1b[35m")).toBeFalsy(); + }); + + it("should apply consistent styling to explicit multiline blockquote", () => { + const markdown = new Markdown( + `>Foo +>bar`, + 0, + 0, + defaultMarkdownTheme, + { + color: text => chalk.cyan(text), // This should NOT be applied to blockquotes + }, + ); + + const lines = markdown.render(80); + + // Both lines should have the quote border + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "")); + const quotedLines = plainLines.filter(line => line.startsWith("│ ")); + expect(quotedLines.length).toBe(2); + + // Both lines should have italic (from theme.quote styling) + const fooLine = lines.find(line => line.includes("Foo")); + const barLine = lines.find(line => line.includes("bar")); + expect(fooLine?.includes("\x1b[3m")).toBeTruthy(); + expect(barLine?.includes("\x1b[3m")).toBeTruthy(); + + // Blockquotes should NOT have the default message color (cyan) + expect(fooLine?.includes("\x1b[36m")).toBeFalsy(); + expect(barLine?.includes("\x1b[36m")).toBeFalsy(); + }); + + it("should wrap long blockquote lines and add border to each wrapped line", () => { + const longText = "This is a very long blockquote line that should wrap to multiple lines when rendered"; + const markdown = new Markdown(`> ${longText}`, 0, 0, defaultMarkdownTheme); + + // Render at narrow width to force wrapping + const lines = markdown.render(30); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + // Filter to non-empty lines (exclude trailing blank line after blockquote) + const contentLines = plainLines.filter(line => line.length > 0); + + // Should have multiple lines due to wrapping + expect(contentLines.length > 1).toBeTruthy(); + + // Every content line should start with the quote border + for (const line of contentLines) { + expect(line.startsWith("│ ")).toBeTruthy(); + } + + // All content should be preserved + const allText = contentLines.join(" "); + expect(allText.includes("very long")).toBeTruthy(); + expect(allText.includes("blockquote")).toBeTruthy(); + expect(allText.includes("multiple")).toBeTruthy(); + }); + + it("should properly indent wrapped blockquote lines with styling", () => { + const markdown = new Markdown( + "> This is styled text that is long enough to wrap", + 0, + 0, + defaultMarkdownTheme, + { + color: text => chalk.yellow(text), // This should NOT be applied to blockquotes + italic: true, + }, + ); + + const lines = markdown.render(25); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + // Filter to non-empty lines + const contentLines = plainLines.filter(line => line.length > 0); + + // All lines should have the quote border + for (const line of contentLines) { + expect(line.startsWith("│ ")).toBeTruthy(); + } + + // Check that italic is applied (from theme.quote) + const allOutput = lines.join("\n"); + expect(allOutput.includes("\x1b[3m")).toBeTruthy(); + + // Blockquotes should NOT have the default message color (yellow) + expect(allOutput.includes("\x1b[33m")).toBeFalsy(); + }); + + it("should render inline formatting inside blockquotes and reapply quote styling after", () => { + const markdown = new Markdown("> Quote with **bold** and `code`", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "")); + + // Should have the quote border + expect(plainLines.some(line => line.startsWith("│ "))).toBeTruthy(); + + // Content should be preserved + const allPlain = plainLines.join(" "); + expect(allPlain.includes("Quote with")).toBeTruthy(); + expect(allPlain.includes("bold")).toBeTruthy(); + expect(allPlain.includes("code")).toBeTruthy(); + + const allOutput = lines.join("\n"); + + // Should have bold styling (\x1b[1m) + expect(allOutput.includes("\x1b[1m")).toBeTruthy(); + + // Should have code styling (yellow = \x1b[33m from defaultMarkdownTheme) + expect(allOutput.includes("\x1b[33m")).toBeTruthy(); + + // Should have italic from quote styling (\x1b[3m) + expect(allOutput.includes("\x1b[3m")).toBeTruthy(); + }); + }); + describe("Links", () => { it("should not duplicate URL for autolinked emails", () => { const markdown = new Markdown("Contact user@example.com for help", 0, 0, defaultMarkdownTheme); diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index eefba8765..f1ca7e78f 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -36,6 +36,10 @@ export class VirtualTerminal implements Terminal { this.xterm.write("\x1b[?2004h"); } + async drainInput(_maxMs?: number, _idleMs?: number): Promise { + // No-op for virtual terminal - no stdin to drain + } + stop(): void { // Disable bracketed paste mode this.xterm.write("\x1b[?2004l");