diff --git a/crates/pi-natives/src/keys.rs b/crates/pi-natives/src/keys.rs index f53a96580..49d1b8701 100644 --- a/crates/pi-natives/src/keys.rs +++ b/crates/pi-natives/src/keys.rs @@ -1363,7 +1363,6 @@ mod tests { assert_eq!(parse_key_inner(b"\x1b[99;9u", true).as_deref(), None); } - #[test] fn parse_key_ignores_kitty_release_events() { assert_eq!(parse_key_inner(b"\x1b[127u", true).as_deref(), Some("backspace")); diff --git a/docs/porting-from-pi-mono.md b/docs/porting-from-pi-mono.md index 1582bfa67..1df74e32c 100644 --- a/docs/porting-from-pi-mono.md +++ b/docs/porting-from-pi-mono.md @@ -5,15 +5,15 @@ Use it for any merge: single file, feature branch, or full release sync. ## Last Sync Point -**Commit:** `5133697` -**Date:** 2026-02-16 +**Commit:** `15e0957b045d9e0d49253b2285cb585cf3a75c55` +**Date:** 2026-03-09 Update this section after each sync; do not reuse the previous range. When starting a new sync, generate patches from this commit forward: ```bash -git format-patch 82d7da878..HEAD --stdout > changes.patch +git format-patch 15e0957b045d9e0d49253b2285cb585cf3a75c55..HEAD --stdout > changes.patch ``` ## 0) Define the scope diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index a00037fdf..c9bbd12eb 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -1595,7 +1595,8 @@ export class AuthStorage { if (leftPlanPriority !== rightPlanPriority) return leftPlanPriority - rightPlanPriority; } if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1; - if (left.secondaryDrainRate !== right.secondaryDrainRate) return left.secondaryDrainRate - right.secondaryDrainRate; + if (left.secondaryDrainRate !== right.secondaryDrainRate) + return left.secondaryDrainRate - right.secondaryDrainRate; if (left.secondaryUsed !== right.secondaryUsed) return left.secondaryUsed - right.secondaryUsed; if (left.primaryDrainRate !== right.primaryDrainRate) return left.primaryDrainRate - right.primaryDrainRate; if (left.primaryUsed !== right.primaryUsed) return left.primaryUsed - right.primaryUsed; @@ -1636,7 +1637,6 @@ export class AuthStorage { // with the most headroom proactively and fall back intelligently when rate-limited. const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId); const sessionPreferredIsAvailable = - sessionPreferredIndex !== undefined && !this.#isCredentialBlocked(providerKey, sessionPreferredIndex); const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel); const candidates = shouldRank diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index ef9cfdc86..3f511fa87 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -240,9 +240,8 @@ function isClaudeModel(modelId: string): boolean { return modelId.toLowerCase().includes("claude"); } -function isClaudeThinkingModel(modelId: string): boolean { - const normalized = modelId.toLowerCase(); - return normalized.includes("claude") && normalized.includes("thinking"); +function needsClaudeThinkingBetaHeader(model: Model<"google-gemini-cli">): boolean { + return model.provider === "google-antigravity" && model.id.startsWith("claude-") && model.reasoning; } function shouldInjectAntigravitySystemInstruction(modelId: string): boolean { @@ -496,9 +495,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( "Content-Type": "application/json", Accept: "text/event-stream", ...headers, - ...(!isAntigravity && isClaudeThinkingModel(model.id) - ? { "anthropic-beta": CLAUDE_THINKING_BETA_HEADER } - : {}), + ...(needsClaudeThinkingBetaHeader(model) ? { "anthropic-beta": CLAUDE_THINKING_BETA_HEADER } : {}), ...(options?.headers ?? {}), }; const requestBodyJson = JSON.stringify(requestBody); diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index fe3c88261..f6b391b05 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -45,6 +45,8 @@ export function retainThoughtSignature(existing: string | undefined, incoming: s // Thought signatures must be base64 for Google APIs (TYPE_BYTES). const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/; +const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator"; + function isValidThoughtSignature(signature: string | undefined): boolean { if (!signature) return false; if (signature.length % 4 !== 0) return false; @@ -150,22 +152,8 @@ export function convertMessages(model: Model, contex } } else if (block.type === "toolCall") { const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature); - if (isGemini3Model(model.id) && !thoughtSignature) { - const params = Object.entries(block.arguments ?? {}) - .map(([key, value]) => { - const valueStr = typeof value === "string" ? value : JSON.stringify(value, null, 2); - return `${valueStr}`; - }) - .join("\n"); - - parts.push({ - text: ` -Historical context only. You cannot invoke tools this way—use proper function calling. -${params} -`.toWellFormed(), - }); - continue; - } + const effectiveSignature = + thoughtSignature || (isGemini3Model(model.id) ? SKIP_THOUGHT_SIGNATURE : undefined); const part: Part = { functionCall: { @@ -177,8 +165,8 @@ ${params} if (model.provider === "google-vertex" && part?.functionCall?.id) { delete part.functionCall.id; // Vertex AI does not support 'id' in functionCall } - if (thoughtSignature) { - part.thoughtSignature = thoughtSignature; + if (effectiveSignature) { + part.thoughtSignature = effectiveSignature; } parts.push(part); } diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 6271fc331..b3eaf6686 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -93,9 +93,10 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( let rawRequestDump: RawHttpRequestDump | undefined; try { - const project = resolveProject(options); - const location = resolveLocation(options); - const client = createClient(model, project, location); + const apiKey = resolveApiKey(options); + const project = apiKey ? undefined : resolveProject(options); + const location = apiKey ? undefined : resolveLocation(options); + const client = apiKey ? createClientWithApiKey(model, apiKey) : createClient(model, project!, location!); const params = buildParams(model, context, options); options?.onPayload?.(params); rawRequestDump = { @@ -103,7 +104,9 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( api: output.api, model: model.id, method: "POST", - url: `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`, + url: apiKey + ? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent` + : `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`, body: params, }; const googleStream = await client.models.generateContentStream(params); @@ -308,24 +311,36 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( return stream; }; -function createClient(model: Model<"google-vertex">, project: string, location: string): GoogleGenAI { - const httpOptions: { headers?: Record } = {}; - - if (model.headers) { - httpOptions.headers = { ...model.headers }; +function buildHttpOptions(model: Model<"google-vertex">): { headers?: Record } | undefined { + if (!model.headers) { + return undefined; } + return { headers: { ...model.headers } }; +} - const hasHttpOptions = Object.values(httpOptions).some(Boolean); - +function createClient(model: Model<"google-vertex">, project: string, location: string): GoogleGenAI { return new GoogleGenAI({ vertexai: true, project, location, apiVersion: API_VERSION, - httpOptions: hasHttpOptions ? httpOptions : undefined, + httpOptions: buildHttpOptions(model), }); } +function createClientWithApiKey(model: Model<"google-vertex">, apiKey: string): GoogleGenAI { + return new GoogleGenAI({ + vertexai: true, + apiKey, + apiVersion: API_VERSION, + httpOptions: buildHttpOptions(model), + }); +} + +function resolveApiKey(options?: GoogleVertexOptions): string | undefined { + return options?.apiKey || $env.GOOGLE_CLOUD_API_KEY; +} + function resolveProject(options?: GoogleVertexOptions): string { const project = options?.project || $env.GOOGLE_CLOUD_PROJECT || $env.GCLOUD_PROJECT; if (!project) { diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 7b8237271..79203565d 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -266,7 +266,9 @@ function isCodexWebSocketRetryableStreamError(error: unknown): boolean { message.includes("websocket closed before response completion") || message.includes("websocket connection is unavailable") || message.includes("idle timeout waiting for websocket") || - message.includes("timeout waiting for first websocket event") + message.includes("timeout waiting for first websocket event") || + message.includes("syntaxerror") || + message.includes("json") ); } @@ -521,14 +523,24 @@ async function openInitialCodexEventStream( options: OpenAICodexResponsesOptions | undefined, requestSetup: CodexRequestSetup, requestContext: CodexRequestContext, -): Promise<{ eventStream: AsyncGenerator>; requestBodyForState: RequestBody; transport: CodexTransport }> { +): Promise<{ + eventStream: AsyncGenerator>; + requestBodyForState: RequestBody; + transport: CodexTransport; +}> { const { transformedBody, websocketState } = requestContext; if (websocketState && shouldUseCodexWebSocket(model, websocketState, options?.preferWebsockets)) { const websocketRetryBudget = getCodexWebSocketRetryBudget(); let websocketRetries = 0; while (true) { try { - return await openCodexWebSocketTransport(requestContext, requestSetup, options, websocketState, websocketRetries); + return await openCodexWebSocketTransport( + requestContext, + requestSetup, + options, + websocketState, + websocketRetries, + ); } catch (error) { const websocketError = error instanceof Error ? error : new Error(String(error)); const isFatal = isCodexWebSocketFatalError(websocketError); @@ -558,7 +570,11 @@ async function openCodexWebSocketTransport( options: OpenAICodexResponsesOptions | undefined, websocketState: CodexWebSocketSessionState, retry: number, -): Promise<{ eventStream: AsyncGenerator>; requestBodyForState: RequestBody; transport: CodexTransport }> { +): Promise<{ + eventStream: AsyncGenerator>; + requestBodyForState: RequestBody; + transport: CodexTransport; +}> { const websocketRequest = buildCodexWebSocketRequest(requestContext.transformedBody, websocketState); const websocketHeaders = createCodexHeaders( requestContext.requestHeaders, @@ -596,7 +612,11 @@ async function openCodexSseTransport( options: OpenAICodexResponsesOptions | undefined, state: CodexWebSocketSessionState | undefined, body = requestContext.transformedBody, -): Promise<{ eventStream: AsyncGenerator>; requestBodyForState: RequestBody; transport: CodexTransport }> { +): Promise<{ + eventStream: AsyncGenerator>; + requestBodyForState: RequestBody; + transport: CodexTransport; +}> { const eventStream = requestSetup.wrapCodexSseStream( await openCodexSseEventStream( requestContext.url, @@ -665,7 +685,10 @@ function createCodexStreamRuntime(initial: { }; } -async function processCodexResponseStream(context: CodexStreamProcessingContext, runtime: CodexStreamRuntime): Promise { +async function processCodexResponseStream( + context: CodexStreamProcessingContext, + runtime: CodexStreamRuntime, +): Promise { const { output, stream } = context; stream.push({ type: "start", partial: output }); @@ -742,12 +765,28 @@ function handleCodexStreamEvent(args: { } if (eventType === "response.output_text.delta") { - handleMessageTextDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex, "output_text"); + handleMessageTextDelta( + runtime.currentItem, + runtime.currentBlock, + rawEvent, + stream, + output, + blockIndex, + "output_text", + ); return firstTokenTime; } if (eventType === "response.refusal.delta") { - handleMessageTextDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex, "refusal"); + handleMessageTextDelta( + runtime.currentItem, + runtime.currentBlock, + rawEvent, + stream, + output, + blockIndex, + "refusal", + ); return firstTokenTime; } @@ -933,7 +972,9 @@ function handleOutputItemDone( } if (item.type === "message" && runtime.currentBlock?.type === "text") { - runtime.currentBlock.text = item.content.map(content => (content.type === "output_text" ? content.text : content.refusal)).join(""); + runtime.currentBlock.text = item.content + .map(content => (content.type === "output_text" ? content.text : content.refusal)) + .join(""); runtime.currentBlock.textSignature = item.id; stream.push({ type: "text_end", @@ -1066,7 +1107,10 @@ async function tryReplayWebsocketFailureOverSse( if (!activateFallback) { runtime.websocketStreamRetries += 1; - await abortableSleep(getCodexWebSocketRetryDelayMs(runtime.websocketStreamRetries), context.requestSetup.requestSignal); + await abortableSleep( + getCodexWebSocketRetryDelayMs(runtime.websocketStreamRetries), + context.requestSetup.requestSignal, + ); await reopenCodexWebSocketRuntimeStream(context, runtime, state); return true; } @@ -1223,7 +1267,8 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" stream.end(); } catch (error) { const failureContext = - processingContext ?? { + processingContext ?? + ({ model, output, stream, @@ -1246,7 +1291,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" }, }, startTime, - } satisfies CodexStreamProcessingContext; + } satisfies CodexStreamProcessingContext); const failure = await handleCodexStreamFailure(failureContext, error); stream.push({ type: "error", reason: failure.stopReason as "error" | "aborted", error: failure }); stream.end(); @@ -1511,7 +1556,10 @@ class CodexWebSocketConnection { } close(reason = "done"): void { - if (this.#socket && (this.#socket.readyState === WebSocket.OPEN || this.#socket.readyState === WebSocket.CONNECTING)) { + if ( + this.#socket && + (this.#socket.readyState === WebSocket.OPEN || this.#socket.readyState === WebSocket.CONNECTING) + ) { this.#socket.close(1000, reason); } this.#socket = null; @@ -1618,7 +1666,10 @@ class CodexWebSocketConnection { } } - async *streamRequest(request: Record, signal?: AbortSignal): AsyncGenerator> { + async *streamRequest( + request: Record, + signal?: AbortSignal, + ): AsyncGenerator> { if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) { throw createCodexWebSocketTransportError("websocket connection is unavailable"); } @@ -1918,7 +1969,12 @@ function redactHeaders(headers: Headers): Record { redacted[key] = "Bearer [redacted]"; continue; } - if (lower.includes("account") || lower.includes("session") || lower.includes("conversation") || lower === "cookie") { + if ( + lower.includes("account") || + lower.includes("session") || + lower.includes("conversation") || + lower === "cookie" + ) { redacted[key] = "[redacted]"; continue; } diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 826104e6e..9487b68f0 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -93,9 +93,11 @@ const serviceProviderMap: Record = { ? $pickenv("ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY") : $pickenv("ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"), "gitlab-duo": "GITLAB_TOKEN", - // Vertex AI uses Application Default Credentials, not API keys. - // Auth is configured via `gcloud auth application-default login`. + // Vertex AI supports either GOOGLE_CLOUD_API_KEY or Application Default Credentials. "google-vertex": () => { + if ($env.GOOGLE_CLOUD_API_KEY) { + return $env.GOOGLE_CLOUD_API_KEY; + } const hasCredentials = hasVertexAdcCredentials(); const hasProject = !!($env.GOOGLE_CLOUD_PROJECT || $env.GCLOUD_PROJECT); const hasLocation = !!$env.GOOGLE_CLOUD_LOCATION; diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index 994509cbf..8e9be23fb 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -15,6 +15,8 @@ const COPILOT_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; +const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2; +const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4; type DeviceCodeResponse = { device_code: string; user_code: string; @@ -98,10 +100,10 @@ async function startDeviceFlow(domain: string): Promise { method: "POST", headers: { Accept: "application/json", - "Content-Type": "application/json", + "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "GitHubCopilotChat/0.35.0", }, - body: JSON.stringify({ + body: new URLSearchParams({ client_id: CLIENT_ID, scope: "read:user", }), @@ -136,6 +138,14 @@ async function startDeviceFlow(domain: string): Promise { }; } +async function sleepForGitHubAccessTokenPoll(ms: number, signal?: AbortSignal): Promise { + try { + await abortableSleep(ms, signal); + } catch { + throw new Error("Login cancelled"); + } +} + async function pollForGitHubAccessToken( domain: string, deviceCode: string, @@ -146,20 +156,26 @@ async function pollForGitHubAccessToken( const urls = getUrls(domain); const deadline = Date.now() + expiresIn * 1000; let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000)); + let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER; + let slowDownResponses = 0; while (Date.now() < deadline) { if (signal?.aborted) { throw new Error("Login cancelled"); } + const remainingMs = deadline - Date.now(); + const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs); + await sleepForGitHubAccessTokenPoll(waitMs, signal); + const raw = await fetchJson(urls.accessTokenUrl, { method: "POST", headers: { Accept: "application/json", - "Content-Type": "application/json", + "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "GitHubCopilotChat/0.35.0", }, - body: JSON.stringify({ + body: new URLSearchParams({ client_id: CLIENT_ID, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code", @@ -171,34 +187,28 @@ async function pollForGitHubAccessToken( } if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { - const err = (raw as DeviceTokenErrorResponse).error; - if (err === "authorization_pending") { - try { - await abortableSleep(intervalMs, signal); - } catch { - throw new Error("Login cancelled"); - } + const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse; + if (error === "authorization_pending") { continue; } - if (err === "slow_down") { - intervalMs += 5000; - try { - await abortableSleep(intervalMs, signal); - } catch { - throw new Error("Login cancelled"); - } + if (error === "slow_down") { + slowDownResponses += 1; + intervalMs = + typeof interval === "number" && interval > 0 ? interval * 1000 : Math.max(1000, intervalMs + 5000); + intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER; continue; } - throw new Error(`Device flow failed: ${err}`); + const descriptionSuffix = description ? `: ${description}` : ""; + throw new Error(`Device flow failed: ${error}${descriptionSuffix}`); } + } - try { - await abortableSleep(intervalMs, signal); - } catch { - throw new Error("Login cancelled"); - } + if (slowDownResponses > 0) { + throw new Error( + "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.", + ); } throw new Error("Device flow timed out"); diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index 4fac14d8b..d86143b28 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -317,7 +317,6 @@ describe("AuthStorage codex oauth ranking", () => { expect(apiKey).toBe("api-acct-pro"); }); - test("times out slow usage ranking instead of blocking first account selection", async () => { if (!store) throw new Error("test setup failed"); diff --git a/packages/ai/test/google-gemini-cli-alignment.test.ts b/packages/ai/test/google-gemini-cli-alignment.test.ts index 59fda9db4..b1b9f8bc5 100644 --- a/packages/ai/test/google-gemini-cli-alignment.test.ts +++ b/packages/ai/test/google-gemini-cli-alignment.test.ts @@ -176,6 +176,31 @@ describe("Google Gemini CLI alignment", () => { expect(parameters).toBeDefined(); expect(JSON.stringify(parameters)).not.toContain('"patternProperties"'); }); + it("adds anthropic-beta for Antigravity Claude reasoning models without relying on id suffix", async () => { + let requestHeaders: Headers | undefined; + using _hook = hookFetch(async (_url, init) => { + requestHeaders = new Headers(init?.headers); + return new Response('{"error":{"message":"bad request"}}', { status: 400 }); + }); + + const model: Model<"google-gemini-cli"> = { + ...createModel("google-antigravity"), + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + reasoning: true, + }; + + const result = await streamGoogleGeminiCli(model, createContext(), { + apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }), + }).result(); + + expect(result.stopReason).toBe("error"); + expect(requestHeaders).toBeDefined(); + expect(requestHeaders!.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14"); + expect(requestHeaders!.get("X-Goog-Api-Client")).toBeNull(); + expect(requestHeaders!.get("Client-Metadata")).toBeNull(); + }); + describe("retry guardrails", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index bbdbd5252..c7bc5089b 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -2104,7 +2104,9 @@ describe("openai-codex streaming", () => { `data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_sse", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Recovered over SSE" }] } })}`, `data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`, ].join("\n\n")}\n\n`; - const fetchMock = vi.fn(async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } })); + const fetchMock = vi.fn( + async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }), + ); global.fetch = fetchMock as unknown as typeof fetch; type WsListener = (event: Event) => void; @@ -2167,10 +2169,18 @@ describe("openai-codex streaming", () => { contextWindow: 128000, maxTokens: 128000, }; - const result = await streamOpenAICodexResponses(model, { - systemPrompt: "You are a helpful assistant.", - messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], - }, { apiKey: token, sessionId: "ws-malformed-json-session", providerSessionState: new Map() }).result(); + const result = await streamOpenAICodexResponses( + model, + { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }, + { + apiKey: token, + sessionId: "ws-malformed-json-session", + providerSessionState: new Map(), + }, + ).result(); expect(result.stopReason).toBe("stop"); expect(result.content.find(c => c.type === "text")?.text).toBe("Recovered over SSE"); @@ -2196,7 +2206,9 @@ describe("openai-codex streaming", () => { `data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_sse_replay", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Replay succeeded" }] } })}`, `data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`, ].join("\n\n")}\n\n`; - const fetchMock = vi.fn(async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } })); + const fetchMock = vi.fn( + async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }), + ); global.fetch = fetchMock as unknown as typeof fetch; type WsListener = (event: Event) => void; @@ -2230,7 +2242,16 @@ describe("openai-codex streaming", () => { send(): void { this.#emit("message", { - data: JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_ws_partial", role: "assistant", status: "in_progress", content: [] } }), + data: JSON.stringify({ + type: "response.output_item.added", + item: { + type: "message", + id: "msg_ws_partial", + role: "assistant", + status: "in_progress", + content: [], + }, + }), } as unknown as Event); this.#emit("message", { data: JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } }), @@ -2269,17 +2290,24 @@ describe("openai-codex streaming", () => { contextWindow: 128000, maxTokens: 128000, }; - const result = await streamOpenAICodexResponses(model, { - systemPrompt: "You are a helpful assistant.", - messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], - }, { apiKey: token, sessionId: "ws-buffered-close-session", providerSessionState: new Map() }).result(); + const result = await streamOpenAICodexResponses( + model, + { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }, + { + apiKey: token, + sessionId: "ws-buffered-close-session", + providerSessionState: new Map(), + }, + ).result(); expect(result.stopReason).toBe("stop"); expect(result.content.find(c => c.type === "text")?.text).toBe("Replay succeeded"); expect(fetchMock).toHaveBeenCalledTimes(1); }); - it("resets append state and stale turn headers when websocket requests diverge", async () => { const tempDir = TempDir.createSync("@pi-codex-stream-"); setAgentDir(tempDir.path()); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index d576724de..248a211e6 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -4,7 +4,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { Effort } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; -import { complete, stream } from "@oh-my-pi/pi-ai/stream"; +import { complete, getEnvApiKey, stream } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { StringEnum } from "@oh-my-pi/pi-ai/utils/schema"; import { Type } from "@sinclair/typebox"; @@ -404,13 +404,90 @@ describe("Generate E2E Tests", () => { ); }); + describe("google-vertex env auth", () => { + it("treats GOOGLE_CLOUD_API_KEY as a configured google-vertex credential", () => { + const originalApiKey = Bun.env.GOOGLE_CLOUD_API_KEY; + const originalProject = Bun.env.GOOGLE_CLOUD_PROJECT; + const originalGcloudProject = Bun.env.GCLOUD_PROJECT; + const originalLocation = Bun.env.GOOGLE_CLOUD_LOCATION; + const originalApplicationCredentials = Bun.env.GOOGLE_APPLICATION_CREDENTIALS; + + try { + Bun.env.GOOGLE_CLOUD_API_KEY = "vertex-test-key"; + delete Bun.env.GOOGLE_CLOUD_PROJECT; + delete Bun.env.GCLOUD_PROJECT; + delete Bun.env.GOOGLE_CLOUD_LOCATION; + delete Bun.env.GOOGLE_APPLICATION_CREDENTIALS; + + expect(getEnvApiKey("google-vertex")).toBe("vertex-test-key"); + } finally { + if (originalApiKey === undefined) delete Bun.env.GOOGLE_CLOUD_API_KEY; + else Bun.env.GOOGLE_CLOUD_API_KEY = originalApiKey; + if (originalProject === undefined) delete Bun.env.GOOGLE_CLOUD_PROJECT; + else Bun.env.GOOGLE_CLOUD_PROJECT = originalProject; + if (originalGcloudProject === undefined) delete Bun.env.GCLOUD_PROJECT; + else Bun.env.GCLOUD_PROJECT = originalGcloudProject; + if (originalLocation === undefined) delete Bun.env.GOOGLE_CLOUD_LOCATION; + else Bun.env.GOOGLE_CLOUD_LOCATION = originalLocation; + if (originalApplicationCredentials === undefined) delete Bun.env.GOOGLE_APPLICATION_CREDENTIALS; + else Bun.env.GOOGLE_APPLICATION_CREDENTIALS = originalApplicationCredentials; + } + }); + + it("allows explicit Vertex API keys without requiring project or location", async () => { + const originalApiKey = Bun.env.GOOGLE_CLOUD_API_KEY; + const originalProject = Bun.env.GOOGLE_CLOUD_PROJECT; + const originalGcloudProject = Bun.env.GCLOUD_PROJECT; + const originalLocation = Bun.env.GOOGLE_CLOUD_LOCATION; + const llm = getBundledModel("google-vertex", "gemini-3-flash-preview"); + const controller = new AbortController(); + controller.abort(); + + try { + delete Bun.env.GOOGLE_CLOUD_API_KEY; + delete Bun.env.GOOGLE_CLOUD_PROJECT; + delete Bun.env.GCLOUD_PROJECT; + delete Bun.env.GOOGLE_CLOUD_LOCATION; + + const response = await complete( + llm, + { messages: [{ role: "user", content: "Hello", timestamp: Date.now() }] }, + { apiKey: "vertex-test-key", signal: controller.signal }, + ); + + expect(response.stopReason).toBe("aborted"); + expect(response.errorMessage).toBeTruthy(); + expect(response.errorMessage).not.toContain("Vertex AI requires a project ID"); + expect(response.errorMessage).not.toContain("Vertex AI requires a location"); + } finally { + if (originalApiKey === undefined) delete Bun.env.GOOGLE_CLOUD_API_KEY; + else Bun.env.GOOGLE_CLOUD_API_KEY = originalApiKey; + if (originalProject === undefined) delete Bun.env.GOOGLE_CLOUD_PROJECT; + else Bun.env.GOOGLE_CLOUD_PROJECT = originalProject; + if (originalGcloudProject === undefined) delete Bun.env.GCLOUD_PROJECT; + else Bun.env.GCLOUD_PROJECT = originalGcloudProject; + if (originalLocation === undefined) delete Bun.env.GOOGLE_CLOUD_LOCATION; + else Bun.env.GOOGLE_CLOUD_LOCATION = originalLocation; + } + }); + }); + describe("Google Vertex Provider (gemini-3-flash-preview)", () => { + const vertexApiKey = Bun.env.GOOGLE_CLOUD_API_KEY; const vertexProject = Bun.env.GOOGLE_CLOUD_PROJECT || Bun.env.GCLOUD_PROJECT; const vertexLocation = Bun.env.GOOGLE_CLOUD_LOCATION; const isVertexConfigured = Boolean(vertexProject && vertexLocation); const vertexOptions = { project: vertexProject, location: vertexLocation } as const; const llm = getBundledModel("google-vertex", "gemini-3-flash-preview"); + it.skipIf(!vertexApiKey)( + "should complete basic text generation with Vertex API key", + async () => { + await basicTextGeneration(llm, { apiKey: vertexApiKey! }); + }, + { retry: 3 }, + ); + it.skipIf(!isVertexConfigured)( "should complete basic text generation", async () => { diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index ec5a02069..7b6c8312e 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -242,6 +242,16 @@ export const SETTINGS_SCHEMA = { description: "Action when pressing Escape twice with empty editor", }, }, + treeFilterMode: { + type: "enum", + values: ["default", "no-tools", "user-only", "labeled-only", "all"] as const, + default: "default", + ui: { + tab: "input", + label: "Tree filter mode", + description: "Default filter mode when opening the session tree", + }, + }, shellPath: { type: "string", default: undefined }, collapseChangelog: { type: "boolean", @@ -1310,6 +1320,9 @@ export type StatusLinePreset = SettingValue<"statusLine.preset">; /** Status line separator style - derived from schema */ export type StatusLineSeparatorStyle = SettingValue<"statusLine.separator">; +/** Tree selector filter mode - derived from schema */ +export type TreeFilterMode = SettingValue<"treeFilterMode">; + // ═══════════════════════════════════════════════════════════════════════════ // Typed Group Definitions // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 255e46eb4..64977e637 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -339,7 +339,7 @@ export class ExtensionRunner { getRegisteredCommands(reserved?: Set): RegisteredCommand[] { this.#commandDiagnostics = []; - const commands: RegisteredCommand[] = []; + const commands = new Map(); for (const ext of this.extensions) { for (const command of ext.commands.values()) { if (reserved?.has(command.name)) { @@ -351,10 +351,10 @@ export class ExtensionRunner { continue; } - commands.push(command); + commands.set(command.name, command); } } - return commands; + return [...commands.values()]; } getCommandDiagnostics(): Array<{ type: string; message: string; path: string }> { @@ -362,8 +362,8 @@ export class ExtensionRunner { } getCommand(name: string): RegisteredCommand | undefined { - for (const ext of this.extensions) { - const command = ext.commands.get(name); + for (let index = this.extensions.length - 1; index >= 0; index -= 1) { + const command = this.extensions[index]?.commands.get(name); if (command) { return command; } diff --git a/packages/coding-agent/src/modes/components/tree-selector.ts b/packages/coding-agent/src/modes/components/tree-selector.ts index 1796c44c1..a2491bc3a 100644 --- a/packages/coding-agent/src/modes/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/components/tree-selector.ts @@ -10,6 +10,7 @@ import { TruncatedText, truncateToWidth, } from "@oh-my-pi/pi-tui"; +import type { TreeFilterMode } from "../../config/settings-schema"; import { theme } from "../../modes/theme/theme"; import type { SessionTreeNode } from "../../session/session-manager"; import { shortenPath } from "../../tools/render-utils"; @@ -37,7 +38,7 @@ interface FlatNode { } /** Filter mode for tree display */ -type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all"; +type FilterMode = TreeFilterMode; /** * Tree list component with selection and ASCII art visualization @@ -52,7 +53,7 @@ class TreeList implements Component { #flatNodes: FlatNode[] = []; #filteredNodes: FlatNode[] = []; #selectedIndex = 0; - #filterMode: FilterMode = "default"; + #filterMode: FilterMode; #searchQuery = ""; #toolCallMap: Map = new Map(); #multipleRoots = false; @@ -67,8 +68,10 @@ class TreeList implements Component { tree: SessionTreeNode[], private readonly currentLeafId: string | null, private readonly maxVisibleLines: number, + initialFilterMode: FilterMode = "default", initialSelectedId?: string, ) { + this.#filterMode = initialFilterMode; this.#multipleRoots = tree.length > 1; this.#flatNodes = this.#flattenTree(tree); this.#buildActivePath(); @@ -828,11 +831,12 @@ export class TreeSelectorComponent extends Container { onSelect: (entryId: string) => void, onCancel: () => void, private readonly onLabelChangeCallback?: (entryId: string, label: string | undefined) => void, + initialFilterMode: FilterMode = "default", ) { super(); const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2)); - this.#treeList = new TreeList(tree, currentLeafId, maxVisibleLines); + this.#treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialFilterMode); this.#treeList.onSelect = onSelect; this.#treeList.onCancel = onCancel; this.#treeList.onLabelEdit = (entryId, currentLabel) => this.#showLabelInput(entryId, currentLabel); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index fd0f62415..d54abfad5 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -578,6 +578,7 @@ export class SelectorController { this.ctx.sessionManager.appendLabelChange(entryId, label); this.ctx.ui.requestRender(); }, + settings.get("treeFilterMode"), ); return { component: selector, focus: selector }; }); diff --git a/packages/coding-agent/src/session/compaction/utils.ts b/packages/coding-agent/src/session/compaction/utils.ts index 176a1e945..c1b80970f 100644 --- a/packages/coding-agent/src/session/compaction/utils.ts +++ b/packages/coding-agent/src/session/compaction/utils.ts @@ -104,6 +104,19 @@ export function upsertFileOperations(summary: string, readFiles: string[], modif // Message Serialization // ============================================================================ +/** Maximum characters for a tool result in serialized summaries. */ +const TOOL_RESULT_MAX_CHARS = 2000; + +/** + * Truncate text to a maximum character length for summarization. + * Keeps the beginning and appends a truncation marker. + */ +function truncateForSummary(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + const truncatedChars = text.length - maxChars; + return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`; +} + /** * Serialize LLM messages to text for summarization. * This prevents the model from treating it as a conversation to continue. @@ -156,7 +169,7 @@ export function serializeConversation(messages: Message[]): string { .map(c => c.text) .join(""); if (content) { - parts.push(`[Tool result]: ${content}`); + parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`); } } } diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 3f5afcaab..e75dc7ffc 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -152,9 +152,13 @@ function formatMetadataLine(lineCount: number | null, language: string | undefin return uiTheme.fg("dim", `${icon}`); } +function normalizeDisplayText(text: string): string { + return text.replace(/\r/g, ""); +} + function formatStreamingContent(content: string, uiTheme: Theme): string { if (!content) return ""; - const lines = content.split("\n"); + const lines = normalizeDisplayText(content).split("\n"); const displayLines = lines.slice(-WRITE_STREAMING_PREVIEW_LINES); const hidden = lines.length - displayLines.length; @@ -171,7 +175,7 @@ function formatStreamingContent(content: string, uiTheme: Theme): string { function renderContentPreview(content: string, expanded: boolean, uiTheme: Theme): string { if (!content) return ""; - const lines = content.split("\n"); + const lines = normalizeDisplayText(content).split("\n"); const maxLines = expanded ? lines.length : Math.min(lines.length, WRITE_PREVIEW_LINES); const displayLines = expanded ? lines : lines.slice(-maxLines); const hidden = lines.length - displayLines.length; diff --git a/packages/coding-agent/src/utils/external-editor.ts b/packages/coding-agent/src/utils/external-editor.ts index 47a15e66f..2650fa36b 100644 --- a/packages/coding-agent/src/utils/external-editor.ts +++ b/packages/coding-agent/src/utils/external-editor.ts @@ -39,7 +39,7 @@ export async function openInEditor( const [editor, ...editorArgs] = editorCmd.split(" "); const stdio = options?.stdio ?? ["inherit", "inherit", "inherit"]; - const child = spawn(editor, [...editorArgs, tmpFile], { stdio }); + const child = spawn(editor, [...editorArgs, tmpFile], { stdio, shell: process.platform === "win32" }); const exitCode = await new Promise((resolve, reject) => { child.once("exit", (code, signal) => resolve(code ?? (signal ? -1 : 0))); child.once("error", error => reject(error)); diff --git a/packages/coding-agent/test/compaction-serialization.test.ts b/packages/coding-agent/test/compaction-serialization.test.ts new file mode 100644 index 000000000..f1fe745eb --- /dev/null +++ b/packages/coding-agent/test/compaction-serialization.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +import type { Message } from "@oh-my-pi/pi-ai"; +import { serializeConversation } from "../src/session/compaction/utils"; + +describe("serializeConversation", () => { + it("truncates long tool results in serialized summaries", () => { + const longContent = "x".repeat(5000); + const messages: Message[] = [ + { + role: "toolResult", + toolCallId: "tc1", + toolName: "read", + content: [{ type: "text", text: longContent }], + isError: false, + timestamp: Date.now(), + }, + ]; + + const result = serializeConversation(messages); + + expect(result).toContain("[Tool result]:"); + expect(result).toContain("[... 3000 more characters truncated]"); + expect(result).toContain("x".repeat(2000)); + expect(result).not.toContain("x".repeat(3000)); + }); + + it("does not truncate short tool results", () => { + const shortContent = "x".repeat(1500); + const messages: Message[] = [ + { + role: "toolResult", + toolCallId: "tc1", + toolName: "read", + content: [{ type: "text", text: shortContent }], + isError: false, + timestamp: Date.now(), + }, + ]; + + const result = serializeConversation(messages); + + expect(result).toBe(`[Tool result]: ${shortContent}`); + expect(result).not.toContain("truncated"); + }); + + it("does not truncate assistant or user messages", () => { + const longText = "y".repeat(5000); + const messages: Message[] = [ + { + role: "user", + content: [{ type: "text", text: longText }], + timestamp: Date.now(), + }, + { + role: "assistant", + content: [{ type: "text", text: longText }], + api: "anthropic", + provider: "anthropic", + model: "test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + ]; + + const result = serializeConversation(messages); + + expect(result).not.toContain("truncated"); + expect(result).toContain(longText); + }); +}); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 27ae516de..d354ddb1a 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -32,8 +32,8 @@ describe("ExtensionRunner", () => { tempDir.removeSync(); }); - const loadTestExtensions = async () => { - const result = await discoverAndLoadExtensions([], tempDir.path()); + const loadTestExtensions = async (configuredPaths: string[] = []) => { + const result = await discoverAndLoadExtensions(configuredPaths, tempDir.path()); return { ...result, extensions: filterUserExtensions(result.extensions), @@ -199,6 +199,37 @@ describe("ExtensionRunner", () => { const missing = runner.getCommand("not-exists"); expect(missing).toBeUndefined(); }); + + it("prefers later-loaded explicit extensions for conflicting commands", async () => { + const deployCommand = (description: string) => ` + export default function(pi) { + pi.registerCommand("deploy", { + description: "${description}", + handler: async () => {}, + }); + } + `; + + fs.writeFileSync(path.join(extensionsDir, "discovered-deploy.ts"), deployCommand("Discovered deploy")); + const explicitExtensionPath = path.join(tempDir.path(), "explicit-deploy.ts"); + fs.writeFileSync(explicitExtensionPath, deployCommand("Explicit deploy")); + + const result = await loadTestExtensions([explicitExtensionPath]); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + + const commands = runner.getRegisteredCommands(); + expect(commands).toHaveLength(1); + expect(commands[0]?.description).toBe("Explicit deploy"); + + const command = runner.getCommand("deploy"); + expect(command?.description).toBe("Explicit deploy"); + }); }); describe("error handling", () => { diff --git a/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts b/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts index f7f56cb27..124e0b7c1 100644 --- a/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts +++ b/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts @@ -38,6 +38,7 @@ describe("ModelSelector role badge thinking display", () => { const modelRegistry = { getAll: () => [model], + getDiscoverableProviders: () => [], } as unknown as ModelRegistry; const ui = { requestRender: vi.fn(), diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 1212d9532..363614164 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -772,6 +772,7 @@ export class Editor implements Component, Focusable { if (matchesKey(data, "tab")) { const selected = this.#autocompleteList.getSelectedItem(); if (selected && this.#autocompleteProvider) { + const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection(); const result = this.#autocompleteProvider.applyCompletion( this.#state.lines, this.#state.cursorLine, @@ -791,6 +792,10 @@ export class Editor implements Component, Focusable { } result.onApplied?.(); + + if (shouldChainSlashCommandAutocomplete && this.#isCompletedSlashCommandAtCursor()) { + void this.#tryTriggerAutocomplete(); + } } return; } @@ -2037,6 +2042,26 @@ export class Editor implements Component, Focusable { return beforeCursor.trim() === "" || beforeCursor.trim() === "/"; } + #isSlashCommandNameAutocompleteSelection(): boolean { + if (this.#autocompleteState !== "regular") { + return false; + } + + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol).trimStart(); + return textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" "); + } + + #isCompletedSlashCommandAtCursor(): boolean { + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + if (this.#state.cursorCol !== currentLine.length) { + return false; + } + + const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol).trimStart(); + return /^\/\S+ $/.test(textBeforeCursor); + } + // Autocomplete methods async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise { if (!this.#autocompleteProvider) return; diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 426dea34d..fc7278eb8 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -9,6 +9,7 @@ import { moveWordLeft, moveWordRight, padding, + replaceTabs, sliceWithWidth, visibleWidth, } from "../utils"; @@ -356,8 +357,8 @@ export class Input implements Component, Focusable { this.#lastAction = null; this.#pushUndo(); - // Clean the pasted text - remove newlines and carriage returns - const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, ""); + // Clean the pasted text - remove newlines and carriage returns, then normalize tabs. + const cleanText = replaceTabs(pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "")); // Insert at cursor position this.#value = this.#value.slice(0, this.#cursor) + cleanText + this.#value.slice(this.#cursor); diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 76fb890f4..557cd27f2 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -57,6 +57,10 @@ interface InlineStyleContext { stylePrefix: string; } +type ListToken = Token & { items: Array<{ tokens?: Token[] }>; ordered: boolean; start?: number }; +type TableCellToken = { tokens?: Token[] }; +type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string }; + function formatHyperlink(text: string, target: string): string { if (!TERMINAL.hyperlinks || !target) { return text; @@ -285,14 +289,14 @@ export class Markdown implements Component { }; } - #renderToken(token: Token, width: number, nextTokenType?: string): string[] { + #renderToken(token: Token, width: number, nextTokenType?: string, styleContext?: InlineStyleContext): string[] { const lines: string[] = []; switch (token.type) { case "heading": { const headingLevel = token.depth; const headingPrefix = `${"#".repeat(headingLevel)} `; - const headingText = this.#renderInlineTokens(token.tokens || []); + const headingText = this.#renderInlineTokens(token.tokens || [], styleContext); let styledHeading: string; if (headingLevel === 1) { styledHeading = this.#theme.heading(this.#theme.bold(this.#theme.underline(headingText))); @@ -309,7 +313,7 @@ export class Markdown implements Component { } case "paragraph": { - const paragraphText = this.#renderInlineTokens(token.tokens || []); + const paragraphText = this.#renderInlineTokens(token.tokens || [], styleContext); lines.push(paragraphText); // Don't add spacing if next token is space or list if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") { @@ -357,7 +361,7 @@ export class Markdown implements Component { } case "list": { - const listLines = this.#renderList(token as any, 0); + const listLines = this.#renderList(token as ListToken, 0, styleContext); lines.push(...listLines); // Don't add spacing after lists if a space token follows // (the space token will handle it) @@ -365,26 +369,48 @@ export class Markdown implements Component { } case "table": { - const tableLines = this.#renderTable(token as any, width); + const tableLines = this.#renderTable(token as TableToken, width, styleContext); lines.push(...tableLines); break; } case "blockquote": { const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text)); - const quoteStyleContext: InlineStyleContext = { - applyText: quoteStyle, - stylePrefix: this.#getStylePrefix(quoteStyle), + const quoteStylePrefix = this.#getStylePrefix(quoteStyle); + const applyQuoteStyle = (line: string): string => { + if (!quoteStylePrefix) { + return quoteStyle(line); + } + + const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`); + return quoteStyle(lineWithReappliedStyle); }; - const quoteText = this.#renderInlineTokens(token.tokens || [], quoteStyleContext); - const quoteLines = quoteText.split("\n"); - // Calculate available width for quote content (subtract border + space = 2 chars) + // Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render + // children recursively and keep default message styling out of nested content. + const quoteInlineStyleContext: InlineStyleContext = { + applyText: (text: string) => text, + stylePrefix: "", + }; const quoteContentWidth = Math.max(1, width - 2); + const quoteTokens = token.tokens || []; + const renderedQuoteLines: string[] = []; - for (const quoteLine of quoteLines) { - // Wrap the styled line, then add border to each wrapped line - const wrappedLines = wrapTextWithAnsi(quoteLine, quoteContentWidth); + for (let i = 0; i < quoteTokens.length; i++) { + const quoteToken = quoteTokens[i]; + const nextQuoteToken = quoteTokens[i + 1]; + renderedQuoteLines.push( + ...this.#renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext), + ); + } + + while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") { + renderedQuoteLines.pop(); + } + + for (const quoteLine of renderedQuoteLines) { + const styledLine = applyQuoteStyle(quoteLine); + const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth); for (const wrappedLine of wrappedLines) { lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine); } @@ -514,7 +540,7 @@ export class Markdown implements Component { /** * Render a list with proper nesting support */ - #renderList(token: Token & { items: any[]; ordered: boolean; start?: number }, depth: number): string[] { + #renderList(token: ListToken, depth: number, styleContext?: InlineStyleContext): string[] { const lines: string[] = []; const indent = " ".repeat(depth); // Use the list's start property (defaults to 1 for ordered lists) @@ -525,7 +551,7 @@ export class Markdown implements Component { const bullet = token.ordered ? `${startNumber + i}. ` : "- "; // Process item tokens to handle nested lists - const itemLines = this.#renderListItem(item.tokens || [], depth); + const itemLines = this.#renderListItem(item.tokens || [], depth, styleContext); if (itemLines.length > 0) { // First line - check if it's a nested list @@ -566,23 +592,25 @@ export class Markdown implements Component { * Render list item tokens, handling nested lists * Returns lines WITHOUT the parent indent (renderList will add it) */ - #renderListItem(tokens: Token[], parentDepth: number): string[] { + #renderListItem(tokens: Token[], parentDepth: number, styleContext?: InlineStyleContext): string[] { const lines: string[] = []; for (const token of tokens) { if (token.type === "list") { // Nested list - render with one additional indent level // These lines will have their own indent, so we just add them as-is - const nestedLines = this.#renderList(token as any, parentDepth + 1); + const nestedLines = this.#renderList(token as ListToken, parentDepth + 1, styleContext); lines.push(...nestedLines); } else if (token.type === "text") { // Text content (may have inline tokens) const text = - token.tokens && token.tokens.length > 0 ? this.#renderInlineTokens(token.tokens) : token.text || ""; + token.tokens && token.tokens.length > 0 + ? this.#renderInlineTokens(token.tokens, styleContext) + : token.text || ""; lines.push(text); } else if (token.type === "paragraph") { // Paragraph in list item - const text = this.#renderInlineTokens(token.tokens || []); + const text = this.#renderInlineTokens(token.tokens || [], styleContext); lines.push(text); } else if (token.type === "code") { // Code block in list item @@ -602,7 +630,7 @@ export class Markdown implements Component { lines.push(this.#theme.codeBlockBorder("```")); } else { // Other token types - try to render as inline - const text = this.#renderInlineTokens([token]); + const text = this.#renderInlineTokens([token], styleContext); if (text) { lines.push(text); } @@ -641,7 +669,7 @@ export class Markdown implements Component { * Render a table with width-aware cell wrapping. * Cells that don't fit are wrapped to multiple lines. */ - #renderTable(token: Token & { header: any[]; rows: any[][]; raw?: string }, availableWidth: number): string[] { + #renderTable(token: TableToken, availableWidth: number, styleContext?: InlineStyleContext): string[] { const lines: string[] = []; const numCols = token.header.length; @@ -666,13 +694,13 @@ export class Markdown implements Component { const naturalWidths: number[] = []; const minWordWidths: number[] = []; for (let i = 0; i < numCols; i++) { - const headerText = this.#renderInlineTokens(token.header[i].tokens || []); + const headerText = this.#renderInlineTokens(token.header[i].tokens || [], styleContext); naturalWidths[i] = visibleWidth(headerText); minWordWidths[i] = Math.max(1, this.#getLongestWordWidth(headerText, maxUnbrokenWordWidth)); } for (const row of token.rows) { for (let i = 0; i < row.length; i++) { - const cellText = this.#renderInlineTokens(row[i].tokens || []); + const cellText = this.#renderInlineTokens(row[i].tokens || [], styleContext); naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText)); minWordWidths[i] = Math.max( minWordWidths[i] || 1, @@ -761,7 +789,7 @@ export class Markdown implements Component { // Render header with wrapping const headerCellLines: string[][] = token.header.map((cell, i) => { - const text = this.#renderInlineTokens(cell.tokens || []); + const text = this.#renderInlineTokens(cell.tokens || [], styleContext); return this.#wrapCellText(text, columnWidths[i]); }); const headerLineCount = Math.max(...headerCellLines.map(c => c.length)); @@ -784,7 +812,7 @@ export class Markdown implements Component { for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) { const row = token.rows[rowIndex]; const rowCellLines: string[][] = row.map((cell, i) => { - const text = this.#renderInlineTokens(cell.tokens || []); + const text = this.#renderInlineTokens(cell.tokens || [], styleContext); return this.#wrapCellText(text, columnWidths[i]); }); const rowLineCount = Math.max(...rowCellLines.map(c => c.length)); diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 6889ee6c3..b699b4b42 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -277,7 +277,9 @@ function decodeKittyPrintable(data: string): string | undefined { const modValue = match[4] ? Number.parseInt(match[4], 10) : 1; const modifier = Number.isFinite(modValue) ? modValue - 1 : 0; const effectiveMod = modifier & ~(64 + 128); + const supportedModifierMask = KITTY_MOD_SHIFT | KITTY_MOD_ALT | KITTY_MOD_CTRL; + if (effectiveMod & ~supportedModifierMask) return undefined; if (effectiveMod & (KITTY_MOD_ALT | KITTY_MOD_CTRL)) return undefined; const textField = match[6]; diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 25741ebc8..3f114f096 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -32,6 +32,7 @@ export function emergencyTerminalRestore(): void { "\x1b[?2004l" + // Disable bracketed paste "\x1b[?2031l" + // Disable Mode 2031 appearance notifications "\x1b[4;0m" + // Disable modifyOtherKeys fallback "\x1b[?25h", // Show cursor ); if (process.stdin.setRawMode) { @@ -103,6 +104,8 @@ export class ProcessTerminal implements Terminal { #inputHandler?: (data: string) => void; #resizeHandler?: () => void; #kittyProtocolActive = false; + #modifyOtherKeysActive = false; + #modifyOtherKeysTimeout?: ReturnType; #stdinBuffer?: StdinBuffer; #stdinDataHandler?: (data: string) => void; #dead = false; @@ -242,6 +245,10 @@ export class ProcessTerminal implements Terminal { if (!this.#kittyProtocolActive) { const match = sequence.match(kittyResponsePattern); if (match) { + if (this.#modifyOtherKeysTimeout) { + clearTimeout(this.#modifyOtherKeysTimeout); + this.#modifyOtherKeysTimeout = undefined; + } this.#kittyProtocolActive = true; setKittyProtocolActive(true); @@ -302,6 +309,14 @@ export class ProcessTerminal implements Terminal { this.#setupStdinBuffer(); process.stdin.on("data", this.#stdinDataHandler!); this.#safeWrite("\x1b[?u"); + this.#modifyOtherKeysTimeout = setTimeout(() => { + this.#modifyOtherKeysTimeout = undefined; + if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) { + return; + } + this.#safeWrite("\x1b[>4;2m"); + this.#modifyOtherKeysActive = true; + }, 150); } async drainInput(maxMs = 1000, idleMs = 50): Promise { @@ -312,6 +327,14 @@ export class ProcessTerminal implements Terminal { this.#kittyProtocolActive = false; setKittyProtocolActive(false); } + if (this.#modifyOtherKeysTimeout) { + clearTimeout(this.#modifyOtherKeysTimeout); + this.#modifyOtherKeysTimeout = undefined; + } + if (this.#modifyOtherKeysActive) { + this.#safeWrite("\x1b[>4;0m"); + this.#modifyOtherKeysActive = false; + } const previousHandler = this.#inputHandler; this.#inputHandler = undefined; @@ -357,6 +380,14 @@ export class ProcessTerminal implements Terminal { this.#kittyProtocolActive = false; setKittyProtocolActive(false); } + if (this.#modifyOtherKeysTimeout) { + clearTimeout(this.#modifyOtherKeysTimeout); + this.#modifyOtherKeysTimeout = undefined; + } + if (this.#modifyOtherKeysActive) { + this.#safeWrite("\x1b[>4;0m"); + this.#modifyOtherKeysActive = false; + } this.#restoreWindowsVTInput(); // Clean up StdinBuffer diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 3035a7d7b..9fd596152 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { stripVTControlCharacters } from "node:util"; +import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete"; import { Editor } from "@oh-my-pi/pi-tui/components/editor"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; import { defaultEditorTheme } from "./test-themes"; @@ -296,6 +297,74 @@ describe("Editor component", () => { await expect(promise).resolves.toBe("@"); }); + + it("chains into argument completions after tab-completing slash command names", async () => { + const editor = new Editor(defaultEditorTheme); + editor.setAutocompleteProvider( + new CombinedAutocompleteProvider( + [ + { + name: "model", + description: "Select a model", + getArgumentCompletions() { + return [{ label: "claude-opus", value: "claude-opus" }]; + }, + }, + { name: "help", description: "Show help" }, + ], + "/tmp", + ), + ); + + editor.handleInput("/"); + await Bun.sleep(0); + editor.handleInput("m"); + editor.handleInput("o"); + editor.handleInput("d"); + await Bun.sleep(110); + + editor.handleInput(" "); + await Bun.sleep(0); + + expect(editor.getText()).toBe("/model "); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput(" "); + + expect(editor.getText()).toBe("/model claude-opus"); + expect(editor.isShowingAutocomplete()).toBe(false); + }); + + it("does not show argument completions when command has no argument completer", async () => { + const editor = new Editor(defaultEditorTheme); + editor.setAutocompleteProvider( + new CombinedAutocompleteProvider( + [ + { + name: "model", + description: "Select a model", + getArgumentCompletions() { + return [{ label: "claude-opus", value: "claude-opus" }]; + }, + }, + { name: "help", description: "Show help" }, + ], + "/tmp", + ), + ); + + editor.handleInput("/"); + await Bun.sleep(0); + editor.handleInput("h"); + editor.handleInput("e"); + await Bun.sleep(110); + + editor.handleInput(" "); + await Bun.sleep(0); + + expect(editor.getText()).toBe("/help "); + expect(editor.isShowingAutocomplete()).toBe(false); + }); }); describe("Unicode text editing behavior", () => { diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index ed61efc95..94607570a 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -3,6 +3,7 @@ import { CURSOR_MARKER } from "@oh-my-pi/pi-tui"; import { Input } from "@oh-my-pi/pi-tui/components/input"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; +import { getIndentation } from "@oh-my-pi/pi-utils"; function renderedWidth(input: Input, width: number): number { const [line] = input.render(width); @@ -142,6 +143,19 @@ describe("Input component", () => { setKittyProtocolActive(false); }); + it("normalizes tabs in buffered bracketed paste using configured indentation", () => { + const input = setupAtEnd(""); + + input.handleInput("\x1b[200~a\t"); + expect(input.getValue()).toBe(""); + + input.handleInput("b\r\n"); + expect(input.getValue()).toBe(""); + + input.handleInput("c\x1b[201~"); + expect(input.getValue()).toBe(`a${getIndentation()}bc`); + }); + it("never renders a line wider than the terminal width (wide chars)", () => { const input = new Input(); input.focused = true; diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index f0e563b5a..138651e09 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -92,6 +92,12 @@ describe("parseKey", () => { expect(parseKey("\x1b[57400;133u")).toBe("ctrl+end"); setKittyProtocolActive(false); }); + + it("ignores Kitty sequences with unsupported modifiers", () => { + setKittyProtocolActive(true); + expect(parseKey("\x1b[99;9u")).toBeUndefined(); + setKittyProtocolActive(false); + }); }); describe("extractPrintableText", () => { @@ -102,4 +108,13 @@ describe("extractPrintableText", () => { it("does not treat modified NumLock keypad navigation keys as text", () => { expect(extractPrintableText("\x1b[57400;133u")).toBeUndefined(); }); + + it("ignores unsupported modifiers on Kitty CSI-u text", () => { + expect(extractPrintableText("\x1b[99;9u")).toBeUndefined(); + expect(extractPrintableText("\x1b[97;9;229u")).toBeUndefined(); + }); + + it("preserves Kitty CSI-u text-field decoding for supported modifiers", () => { + expect(extractPrintableText("\x1b[97;1;229u")).toBe("å"); + }); }); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 66ca71449..9eb1830c5 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -851,6 +851,50 @@ bar`, // Should have italic from quote styling (\x1b[3m) expect(allOutput.includes("\x1b[3m")).toBeTruthy(); }); + it("should render list content inside blockquotes", () => { + const markdown = new Markdown("> 1. bla bla\n> - nested bullet", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + const quotedLines = plainLines.filter(line => line.startsWith("│ ")); + + expect(quotedLines.some(line => line.includes("1. bla bla"))).toBeTruthy(); + expect(quotedLines.some(line => line.includes("- nested bullet"))).toBeTruthy(); + }); + + it("should render table content inside blockquotes", () => { + const markdown = new Markdown("> | A | B |\n> | --- | --- |\n> | 1 | 2 |", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + const quotedLines = plainLines.filter(line => line.startsWith("│ ")); + const quotedOutput = quotedLines.join("\n"); + + expect(quotedOutput.includes("A")).toBeTruthy(); + expect(quotedOutput.includes("B")).toBeTruthy(); + expect(quotedOutput.includes("1")).toBeTruthy(); + expect(quotedOutput.includes("2")).toBeTruthy(); + expect(quotedOutput.includes("+---+")).toBeTruthy(); + expect(quotedOutput.includes("| A")).toBeTruthy(); + }); + + it("should render fenced code blocks inside blockquotes without applying default text color", () => { + const markdown = new Markdown("> ```js\n> console.log(1)\n> ```", 0, 0, defaultMarkdownTheme, { + color: text => chalk.magenta(text), + }); + + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + const quotedLines = plainLines.filter(line => line.startsWith("│ ")); + const output = lines.join("\n"); + const plainOutput = quotedLines.join("\n"); + + expect(plainOutput.includes("```js")).toBeTruthy(); + expect(plainOutput.includes("console.log(1)")).toBeTruthy(); + expect(plainOutput.includes("```")).toBeTruthy(); + expect(output.includes("\x1b[35m")).toBeFalsy(); + expect(output.includes("\x1b[3m")).toBeTruthy(); + }); }); const stripTerminalSequences = (line: string): string =>