From 7fb18faf4c65d6f01ed478be6a67ad947a09db8d Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 22 Mar 2026 17:57:21 +0100 Subject: [PATCH] fix(coding-agent): backported pi-mono changes (1feccfed..b21b42d0) packages/ai: - feat: expose provider responseId on AssistantMessage - feat: lazy-load provider modules for faster startup - fix: hash foreign Responses API tool call IDs exceeding 64-char limit - fix: ignore null chunks in openai-completions streams - fix: keep image tool results inline for Gemini 3+ and Antigravity - fix: correct Bedrock Claude 4.6 context window to 200k - fix: support prompt caching for Bedrock application inference profiles - fix: add OpenRouter reasoning payload format - fix: ignore placeholder Vertex API keys - fix: skip AJV validation in restricted runtimes - fix: Anthropic OAuth client injection and responseId extraction - fix: Codex incomplete/failed response status handling packages/agent: - fix: defer steering until after tool execution completes packages/tui: - feat: namespaced keybinding IDs with KeybindingsManager conflict detection - feat: configurable select list column sizing (#2154 by @markusylisiurunen) - fix: stream truncateToWidth for large strings - fix: skip Termux height redraws - fix: stop evicting unrelated default keybindings - fix: resolve raw backspace ambiguity on Windows Terminal - fix: clear stale scrollback on session switch (#2155 by @Perlence) - fix: remove trailing markdown block spacing (#2152 by @markusylisiurunen) packages/coding-agent: - feat: add resizable share sidebar (#2435 by @dmmulroy) - feat: emit OSC 133 command-executed marker - feat: reload custom themes from disk watcher - feat: add --fork session flag - feat: file mutation queue for serialized writes - feat: initial message consolidation utility - fix: keybindings migrated to namespaced IDs - fix: resolve waitForRetry() race when auto-retry produces tool calls - fix: handle slash-delimited /model refs - fix: refresh active model after provider updates - fix: extended transient error patterns for retry --- docs/porting-from-pi-mono.md | 6 +- packages/agent/src/agent-loop.ts | 64 +- packages/agent/test/agent-loop.test.ts | 55 +- packages/ai/src/index.ts | 1 + packages/ai/src/providers/amazon-bedrock.ts | 10 + packages/ai/src/providers/anthropic.ts | 43 +- packages/ai/src/providers/google-shared.ts | 29 +- .../src/providers/openai-codex-responses.ts | 6 +- .../providers/openai-completions-compat.ts | 8 +- .../ai/src/providers/openai-completions.ts | 49 +- .../src/providers/openai-responses-shared.ts | 27 +- .../ai/src/providers/register-builtins.ts | 309 +++++++++ packages/ai/src/types.ts | 5 +- packages/coding-agent/src/cli/args.ts | 3 + .../coding-agent/src/cli/initial-message.ts | 58 ++ .../coding-agent/src/config/keybindings.ts | 627 ++++++++++++------ .../coding-agent/src/config/model-registry.ts | 1 + .../coding-agent/src/config/model-resolver.ts | 66 +- .../coding-agent/src/export/html/template.css | 56 +- .../src/export/html/template.generated.ts | 2 +- .../src/export/html/template.html | 1 + .../coding-agent/src/export/html/template.js | 107 +++ .../src/extensibility/extensions/types.ts | 2 +- packages/coding-agent/src/main.ts | 78 +-- .../src/modes/components/custom-editor.ts | 94 +-- .../src/modes/components/keybinding-hints.ts | 18 +- .../src/modes/components/login-dialog.ts | 6 +- .../src/modes/components/user-message.ts | 16 + .../src/modes/controllers/input-controller.ts | 52 +- .../src/modes/interactive-mode.ts | 2 +- packages/coding-agent/src/modes/print-mode.ts | 2 +- .../src/modes/prompt-action-autocomplete.ts | 14 +- .../coding-agent/src/modes/theme/theme.ts | 99 +-- .../src/modes/utils/hotkeys-markdown.ts | 38 +- .../src/modes/utils/ui-helpers.ts | 2 +- .../coding-agent/src/session/agent-session.ts | 6 +- .../coding-agent/src/utils/child-process.ts | 88 +++ packages/coding-agent/test/args.test.ts | 7 + .../coding-agent/test/core/hashline.test.ts | 102 +-- .../coding-agent/test/initial-message.test.ts | 40 ++ .../test/keybindings-display.test.ts | 12 +- .../test/keybindings-migration.test.ts | 50 ++ .../command-controller-hotkeys.test.ts | 34 +- .../test/prompt-action-autocomplete.test.ts | 26 +- .../tui/src/components/cancellable-loader.ts | 5 +- packages/tui/src/components/editor.ts | 115 ++-- packages/tui/src/components/input.ts | 38 +- packages/tui/src/components/markdown.ts | 27 +- packages/tui/src/components/select-list.ts | 194 +++--- packages/tui/src/components/settings-list.ts | 11 +- packages/tui/src/keybindings.ts | 350 ++++++---- packages/tui/src/keys.ts | 28 + packages/tui/src/tui.ts | 27 +- packages/tui/src/utils.ts | 12 +- packages/tui/test/editor.test.ts | 10 +- packages/tui/test/keybindings.test.ts | 37 ++ packages/tui/test/markdown.test.ts | 78 +++ packages/tui/test/select-list.test.ts | 171 +++++ packages/tui/test/settings-list.test.ts | 47 ++ packages/tui/test/truncate-to-width.test.ts | 49 ++ 60 files changed, 2581 insertions(+), 939 deletions(-) create mode 100644 packages/ai/src/providers/register-builtins.ts create mode 100644 packages/coding-agent/src/cli/initial-message.ts create mode 100644 packages/coding-agent/src/utils/child-process.ts create mode 100644 packages/coding-agent/test/initial-message.test.ts create mode 100644 packages/coding-agent/test/keybindings-migration.test.ts create mode 100644 packages/tui/test/keybindings.test.ts create mode 100644 packages/tui/test/select-list.test.ts create mode 100644 packages/tui/test/settings-list.test.ts create mode 100644 packages/tui/test/truncate-to-width.test.ts diff --git a/docs/porting-from-pi-mono.md b/docs/porting-from-pi-mono.md index fbc41b604..24a9f8704 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:** `1feccfedcb1eeeca91be0b9d389e8e5a9daee505` -**Date:** 2026-03-14 +**Commit:** `b21b42d032919de2f2e6920a76fa9a37c3920c0a` +**Date:** 2026-03-22 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 15e0957b045d9e0d49253b2285cb585cf3a75c55..HEAD --stdout > changes.patch +git format-patch b21b42d032919de2f2e6920a76fa9a37c3920c0a..HEAD --stdout > changes.patch ``` ## 0) Define the scope diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 0eac54e36..e32feaea3 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -196,7 +196,6 @@ async function runLoop( // Outer loop: continues when queued follow-up messages arrive after agent would stop while (true) { let hasMoreToolCalls = true; - let steeringAfterTools: AgentMessage[] | null = null; // Inner loop: process tool calls and steering messages while (hasMoreToolCalls || pendingMessages.length > 0) { @@ -225,6 +224,7 @@ async function runLoop( // Stream assistant response const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn); newMessages.push(message); + let steeringMessagesFromExecution: AgentMessage[] | undefined; if (message.stopReason === "error" || message.stopReason === "aborted") { // Create placeholder tool results for any tool calls in the aborted message @@ -250,19 +250,20 @@ async function runLoop( const toolResults: ToolResultMessage[] = []; if (hasMoreToolCalls) { - const toolExecution = await executeToolCalls( + const executionResult = await executeToolCalls( currentContext.tools, message, signal, stream, config.getSteeringMessages, - config.getToolContext, config.interruptMode, + config.getToolContext, config.transformToolCallArguments, config.intentTracing, ); - toolResults.push(...toolExecution.toolResults); - steeringAfterTools = toolExecution.steeringMessages ?? null; + + toolResults.push(...executionResult.toolResults); + steeringMessagesFromExecution = executionResult.steeringMessages; for (const result of toolResults) { currentContext.messages.push(result); @@ -272,13 +273,7 @@ async function runLoop( stream.push({ type: "turn_end", message, toolResults }); - // Get steering messages after turn completes - if (steeringAfterTools && steeringAfterTools.length > 0) { - pendingMessages = steeringAfterTools; - steeringAfterTools = null; - } else { - pendingMessages = (await config.getSteeringMessages?.()) || []; - } + pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []); } // Agent would stop here. Check for follow-up messages. @@ -433,25 +428,37 @@ async function executeToolCalls( signal: AbortSignal | undefined, stream: EventStream, getSteeringMessages?: AgentLoopConfig["getSteeringMessages"], - getToolContext?: AgentLoopConfig["getToolContext"], interruptMode: AgentLoopConfig["interruptMode"] = "immediate", + getToolContext?: AgentLoopConfig["getToolContext"], transformToolCallArguments?: AgentLoopConfig["transformToolCallArguments"], intentTracing?: AgentLoopConfig["intentTracing"], ): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> { type ToolCallContent = Extract; const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall"); const emittedToolResults: ToolResultMessage[] = []; - let steeringMessages: AgentMessage[] | undefined; - const shouldInterruptImmediately = interruptMode !== "wait"; const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name })); const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`; + const shouldInterruptImmediately = interruptMode !== "wait"; const steeringAbortController = new AbortController(); const toolSignal = signal ? AbortSignal.any([signal, steeringAbortController.signal]) : steeringAbortController.signal; const interruptState = { triggered: false }; + let steeringMessages: AgentMessage[] | undefined; let steeringCheck: Promise | null = null; + const records = toolCalls.map(toolCall => ({ + toolCall, + tool: tools?.find(t => t.name === toolCall.name), + args: toolCall.arguments as Record, + started: false, + result: undefined as AgentToolResult | undefined, + isError: false, + skipped: false, + toolResultMessage: undefined as ToolResultMessage | undefined, + resultEmitted: false, + })); + const checkSteering = async (): Promise => { if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered) { return; @@ -473,18 +480,6 @@ async function executeToolCalls( await steeringCheck; }; - const records = toolCalls.map(toolCall => ({ - toolCall, - tool: tools?.find(t => t.name === toolCall.name), - args: toolCall.arguments as Record, - started: false, - result: undefined as AgentToolResult | undefined, - isError: false, - skipped: false, - toolResultMessage: undefined as ToolResultMessage | undefined, - resultEmitted: false, - })); - const emitToolResult = (record: (typeof records)[number], result: AgentToolResult, isError: boolean): void => { if (record.resultEmitted) return; const { toolCall } = record; @@ -578,7 +573,6 @@ async function executeToolCalls( transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs, tool.nonAbortable ? undefined : toolSignal, partialResult => { - if (interruptState.triggered) return; stream.push({ type: "tool_execution_update", toolCallId: toolCall.id, @@ -637,13 +631,6 @@ async function executeToolCalls( return { toolResults: emittedToolResults, steeringMessages }; } -function createSkippedToolResult(): AgentToolResult { - return { - content: [{ type: "text", text: "Skipped due to queued user message." }], - details: {}, - }; -} - /** * Create a tool result for a tool call that was aborted or errored before execution. * Maintains the tool_use/tool_result pairing required by the API. @@ -690,3 +677,10 @@ function createAbortedToolResult( return toolResultMessage; } + +function createSkippedToolResult(): AgentToolResult { + return { + content: [{ type: "text", text: "Skipped due to queued user message." }], + details: {}, + }; +} diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index ba73a7f81..da002c77b 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -603,33 +603,16 @@ describe("agentLoop with AgentMessage", () => { expect(text).not.toContain("Tool execution was aborted.:"); } }); - it("should inject queued messages and skip remaining tool calls", async () => { + it("should skip remaining tool calls when steering is queued", async () => { const toolSchema = Type.Object({ value: Type.String() }); const executed: string[] = []; - const { promise: allowSecond, resolve: allowSecondResolve } = Promise.withResolvers(); const tool: AgentTool = { name: "echo", label: "Echo", description: "Echo tool", parameters: toolSchema, - async execute(_toolCallId, params, signal) { - if (params.value === "second") { - await new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Tool aborted")); - return; - } - const onAbort = () => reject(new Error("Tool aborted")); - signal?.addEventListener("abort", onAbort, { once: true }); - allowSecond.then(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }); - }); - if (signal?.aborted) { - throw new Error("Tool aborted"); - } - } + concurrency: "exclusive", + async execute(_toolCallId, params) { executed.push(params.value); return { content: [{ type: "text", text: `ok:${params.value}` }], @@ -654,11 +637,11 @@ describe("agentLoop with AgentMessage", () => { const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter, + interruptMode: "immediate", getSteeringMessages: async () => { - // Return queued message after first tool executes - if (executed.length === 1 && !queuedDelivered) { + // Return steering message after tool execution has started + if (executed.length >= 1 && !queuedDelivered) { queuedDelivered = true; - allowSecondResolve(); return [queuedUserMessage]; } return []; @@ -700,29 +683,31 @@ describe("agentLoop with AgentMessage", () => { events.push(event); } - // Only first tool should have executed + // Only the first tool should execute; the second is skipped after steering is queued. expect(executed).toEqual(["first"]); - // Second tool should be skipped const toolEnds = events.filter( (e): e is Extract => e.type === "tool_execution_end", ); expect(toolEnds.length).toBe(2); - expect(toolEnds[0].isError).toBeFalsy(); + expect(toolEnds[0].isError).toBe(false); expect(toolEnds[1].isError).toBe(true); if (toolEnds[1].result.content[0]?.type === "text") { expect(toolEnds[1].result.content[0].text).toContain("Skipped due to queued user message"); } - // Queued message should appear in events - const queuedMessageEvent = events.find( - e => - e.type === "message_start" && - e.message.role === "user" && - typeof e.message.content === "string" && - e.message.content === "interrupt", - ); - expect(queuedMessageEvent).toBeDefined(); + // Queued message should appear in events after the tool results and before the next model call. + const eventSequence = events.flatMap(event => { + if (event.type !== "message_start") return []; + if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`]; + if (event.message.role === "user" && typeof event.message.content === "string") { + return [event.message.content]; + } + return []; + }); + expect(eventSequence).toContain("interrupt"); + expect(eventSequence.indexOf("tool:tool-1")).toBeLessThan(eventSequence.indexOf("interrupt")); + expect(eventSequence.indexOf("tool:tool-2")).toBeLessThan(eventSequence.indexOf("interrupt")); // Interrupt message should be in context when second LLM call is made expect(sawInterruptInContext).toBe(true); diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index df719797a..7acc629dd 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -16,6 +16,7 @@ export * from "./providers/google"; export * from "./providers/google-gemini-cli"; export * from "./providers/google-vertex"; export * from "./providers/kimi"; +export type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses"; export * from "./providers/openai-completions"; export * from "./providers/openai-responses"; export * from "./providers/synthetic"; diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index a036c5e79..d33a3fb99 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -360,6 +360,13 @@ function handleContentBlockStop( /** * Check if the model supports prompt caching. * Supported: Claude 3.5 Haiku, Claude 3.7 Sonnet, Claude 4.x+ models, Haiku 4.5+ + * + * For base models and system-defined inference profiles the model ID / ARN + * contains the model name, so we can decide locally. + * + * For application inference profiles (whose ARNs don't contain the model name), + * set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points. Amazon Nova models + * have automatic caching and don't need explicit cache points. */ function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean { if (model.cost.cacheRead || model.cost.cacheWrite) return true; @@ -370,6 +377,9 @@ function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean if (id.includes("claude-3-7-sonnet") || id.includes("claude-3-5-haiku")) return true; // Claude Haiku 4.5+ (new naming) if (id.includes("claude-haiku")) return true; + // Application inference profiles don't contain the model name in the ARN. + // Allow users to force cache points via environment variable. + if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") return true; return false; } diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 9d409a5c4..3d42663f7 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -376,6 +376,12 @@ export interface AnthropicOptions extends StreamOptions { betas?: string[] | string; /** Force OAuth bearer auth mode for proxy tokens that don't match Anthropic token prefixes. */ isOAuth?: boolean; + /** + * Pre-built Anthropic client instance. When provided, skips internal client + * construction entirely. Use this to inject alternative SDK clients such as + * `AnthropicVertex` that shares the same messaging API. + */ + client?: Anthropic; } export type AnthropicClientOptionsArgs = { @@ -611,19 +617,31 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( let rawRequestDump: RawHttpRequestDump | undefined; try { - const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; - const baseUrl = resolveAnthropicBaseUrl(model, apiKey) ?? "https://api.anthropic.com"; + let client: Anthropic; + let isOAuthToken: boolean; - const { client, isOAuthToken } = createClient(model, { - model, - apiKey, - extraBetas: normalizeExtraBetas(options?.betas), - stream: true, - interleavedThinking: options?.interleavedThinking ?? true, - headers: options?.headers, - dynamicHeaders: copilotDynamicHeaders?.headers, - isOAuth: options?.isOAuth, - }); + if (options?.client) { + client = options.client; + isOAuthToken = false; + } else { + const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; + + const created = createClient(model, { + model, + apiKey, + extraBetas: normalizeExtraBetas(options?.betas), + stream: true, + interleavedThinking: options?.interleavedThinking ?? true, + headers: options?.headers, + dynamicHeaders: copilotDynamicHeaders?.headers, + isOAuth: options?.isOAuth, + }); + client = created.client; + isOAuthToken = created.isOAuthToken; + } + const baseUrl = + resolveAnthropicBaseUrl(model, options?.apiKey ?? getEnvApiKey(model.provider) ?? "") ?? + "https://api.anthropic.com"; let params = buildParams(model, baseUrl, context, isOAuthToken, options); const replacementPayload = await options?.onPayload?.(params, model); if (replacementPayload !== undefined) { @@ -661,6 +679,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( for await (const event of anthropicStream) { started = true; if (event.type === "message_start") { + output.responseId = event.message.id; // Capture initial token usage from message_start event // This ensures we have input token counts even if the stream is aborted early output.usage.input = event.message.usage.input_tokens || 0; diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 5a524486a..595aa29f0 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -68,6 +68,20 @@ export function requiresToolCallId(modelId: string): boolean { return modelId.startsWith("claude-"); } +function getGeminiMajorVersion(modelId: string): number | undefined { + const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/); + if (!match) return undefined; + return Number.parseInt(match[1], 10); +} + +function supportsMultimodalFunctionResponse(modelId: string): boolean { + const geminiMajorVersion = getGeminiMajorVersion(modelId); + if (geminiMajorVersion !== undefined) { + return geminiMajorVersion >= 3; + } + return true; +} + function isGemini3Model(modelId: string): boolean { return modelId.includes("gemini-3"); } @@ -189,10 +203,10 @@ export function convertMessages(model: Model, contex const hasText = textResult.length > 0; const hasImages = imageContent.length > 0; - // Gemini 3 supports multimodal function responses with images nested inside functionResponse.parts - // See: https://ai.google.dev/gemini-api/docs/function-calling#multimodal - // Older models don't support this, so we put images in a separate user message. - const supportsMultimodalFunctionResponse = model.id.includes("gemini-3"); + // Gemini 3+ models support multimodal function responses with images nested inside + // functionResponse.parts. Claude and other non-Gemini models behind Cloud Code Assist / + // Antigravity also accept this shape. Gemini < 3 still needs a separate user image turn. + const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id); // Use "output" key for success, "error" key for errors as per SDK documentation const responseValue = hasText ? textResult.toWellFormed() : hasImages ? "(see attached image)" : ""; @@ -209,8 +223,7 @@ export function convertMessages(model: Model, contex functionResponse: { name: msg.toolName, response: msg.isError ? { error: responseValue } : { output: responseValue }, - // Nest images inside functionResponse.parts for Gemini 3 - ...(hasImages && supportsMultimodalFunctionResponse && { parts: imageParts }), + ...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }), ...(includeId ? { id: msg.toolCallId } : {}), }, }; @@ -231,8 +244,8 @@ export function convertMessages(model: Model, contex }); } - // For older models, add images in a separate user message - if (hasImages && !supportsMultimodalFunctionResponse) { + // For Gemini < 3, add images in a separate user message + if (hasImages && !modelSupportsMultimodalFunctionResponse) { contents.push({ role: "user", parts: [{ text: "Tool result image:" }, ...imageParts], diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index f3f44b50b..fc5ace911 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -811,7 +811,7 @@ function handleCodexStreamEvent(args: { return handleResponseCreated(runtime, rawEvent); } - if (eventType === "response.completed" || eventType === "response.done") { + if (eventType === "response.completed" || eventType === "response.done" || eventType === "response.incomplete") { handleResponseCompleted(model, output, runtime, rawEvent); return firstTokenTime; } @@ -1046,6 +1046,9 @@ function handleResponseCompleted( cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } + if (typeof response?.id === "string" && response.id.length > 0) { + output.responseId = response.id; + } const state = runtime.websocketState; if (runtime.transport === "websocket" && state) { @@ -1764,6 +1767,7 @@ class CodexWebSocketConnection { if ( eventType === "response.completed" || eventType === "response.done" || + eventType === "response.incomplete" || eventType === "response.failed" || eventType === "error" ) { diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts index f75ce4b47..d6ff51bcb 100644 --- a/packages/ai/src/providers/openai-completions-compat.ts +++ b/packages/ai/src/providers/openai-completions-compat.ts @@ -89,7 +89,13 @@ export function detectOpenAICompat(model: Model<"openai-completions">): Resolved requiresAssistantAfterToolResult: false, requiresThinkingAsText: isMistral, requiresMistralToolIds: isMistral, - thinkingFormat: isZai ? "zai" : isAlibaba || isQwen ? "qwen" : "openai", + thinkingFormat: isZai + ? "zai" + : provider === "openrouter" || baseUrl.includes("openrouter.ai") + ? "openrouter" + : isAlibaba || isQwen + ? "qwen" + : "openai", reasoningContentField: "reasoning_content", requiresReasoningContentForToolCalls: isKimiModel, requiresAssistantContentForToolCalls: isKimiModel, diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index cd8e12bc8..f26545224 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -337,11 +337,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( errorMessage: "OpenAI completions stream stalled while waiting for the next event", onIdle: () => requestAbortController.abort(), })) { + if (!chunk || typeof chunk !== "object") continue; + + // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier, + // and each chunk in a streamed completion carries the same id. + output.responseId ||= chunk.id; + if (chunk.usage) { output.usage = parseChunkUsage(chunk.usage, model, copilotPremiumRequests); } - const choice = chunk.choices[0]; + const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined; if (!choice) continue; if (!chunk.usage) { @@ -352,7 +358,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( } if (choice.finish_reason) { - output.stopReason = mapStopReason(choice.finish_reason); + const finishReasonResult = mapStopReason(choice.finish_reason); + output.stopReason = finishReasonResult.stopReason; + if (finishReasonResult.errorMessage) { + output.errorMessage = finishReasonResult.errorMessage; + } } if (choice.delta) { @@ -463,8 +473,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( throw new Error("Request was aborted"); } - if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error("An unknown error occurred"); + if (output.stopReason === "aborted") { + throw new Error("Request was aborted"); + } + if (output.stopReason === "error") { + throw new Error(output.errorMessage || "Provider returned an error stop reason"); } output.duration = Date.now() - startTime; @@ -616,6 +629,12 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio Reflect.set(params, "enable_thinking", !!options?.reasoning); } else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) { Reflect.set(params, "chat_template_kwargs", { enable_thinking: !!options?.reasoning }); + } else if (compat.thinkingFormat === "openrouter" && options?.reasoning && model.reasoning) { + // OpenRouter normalizes reasoning across providers via a nested reasoning object. + const openRouterParams = params as typeof params & { reasoning?: { effort?: string } }; + openRouterParams.reasoning = { + effort: mapReasoningEffort(options.reasoning, compat.reasoningEffortMap), + }; } else if (options?.reasoning && model.reasoning && compat.supportsReasoningEffort) { // OpenAI-style reasoning_effort Reflect.set(params, "reasoning_effort", mapReasoningEffort(options.reasoning, compat.reasoningEffortMap)); @@ -1061,21 +1080,29 @@ function convertTools(tools: Tool[], compat: ResolvedOpenAICompat): OpenAI.Chat. }); } -function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): StopReason { - if (reason === null) return "stop"; +function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): { + stopReason: StopReason; + errorMessage?: string; +} { + if (reason === null) return { stopReason: "stop" }; switch (reason) { case "stop": case "end": - return "stop"; + return { stopReason: "stop" }; case "length": - return "length"; + return { stopReason: "length" }; case "function_call": case "tool_calls": - return "toolUse"; + return { stopReason: "toolUse" }; case "content_filter": - return "error"; + return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" }; + case "network_error": + return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" }; default: - throw new Error(`Unhandled stop reason: ${reason}`); + return { + stopReason: "error", + errorMessage: `Provider finish_reason: ${reason}`, + }; } } diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index 3ef822262..74341fcc7 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -52,8 +52,26 @@ export function parseTextSignature( return { id: signature }; } -export function normalizeResponsesToolCallIdForTransform(id: string): string { +export function normalizeResponsesToolCallIdForTransform( + id: string, + model?: Model, + source?: AssistantMessage, +): string { if (!id.includes("|")) return id; + const isForeignToolCall = + source != null && model != null && (source.provider !== model.provider || source.api !== model.api); + if (isForeignToolCall) { + const [callId, itemId] = id.split("|"); + const normalizeIdPart = (part: string): string => { + const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_"); + const truncated = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized; + return truncated.replace(/_+$/, ""); + }; + const normalizedCallId = normalizeIdPart(callId); + let normalizedItemId = `fc_${Bun.hash(itemId).toString(36)}`; + if (normalizedItemId.length > 64) normalizedItemId = normalizedItemId.slice(0, 64); + return `${normalizedCallId}|${normalizedItemId}`; + } const normalized = normalizeResponsesToolCallId(id); return `${normalized.callId}|${normalized.itemId}`; } @@ -221,7 +239,9 @@ export async function processResponsesStream( let sawFirstToken = false; for await (const event of openaiStream) { - if (event.type === "response.output_item.added") { + if (event.type === "response.created") { + output.responseId = event.response.id; + } else if (event.type === "response.output_item.added") { if (!sawFirstToken) { sawFirstToken = true; options?.onFirstToken?.(); @@ -376,6 +396,9 @@ export async function processResponsesStream( } } else if (event.type === "response.completed") { const response = event.response; + if (response?.id) { + output.responseId = response.id; + } if (response?.usage) { const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0; output.usage = { diff --git a/packages/ai/src/providers/register-builtins.ts b/packages/ai/src/providers/register-builtins.ts new file mode 100644 index 000000000..ab7ba9eef --- /dev/null +++ b/packages/ai/src/providers/register-builtins.ts @@ -0,0 +1,309 @@ +/** + * Lazy provider module loading. + * + * Each provider module is loaded only when its stream function is first called. + * This avoids eagerly importing heavy SDK dependencies (e.g., @anthropic-ai/sdk, + * openai) at startup. The loaded module promise is cached so subsequent calls + * reuse the same import. + * + * NOTE: stream.ts currently imports providers directly, so this file is not yet + * wired into the main streaming path. It provides the infrastructure for lazy + * loading that can be integrated when stream.ts is refactored. + */ +import type { + Api, + AssistantMessage, + AssistantMessageEvent, + AssistantMessageEventStream, + Context, + Model, + OptionsForApi, +} from "../types"; +import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream"; +import type { BedrockOptions } from "./amazon-bedrock"; +import type { AnthropicOptions } from "./anthropic"; +import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses"; +import type { CursorOptions } from "./cursor"; +import type { GoogleOptions } from "./google"; +import type { GoogleGeminiCliOptions } from "./google-gemini-cli"; +import type { GoogleVertexOptions } from "./google-vertex"; +import type { OpenAICodexResponsesOptions } from "./openai-codex-responses"; +import type { OpenAICompletionsOptions } from "./openai-completions"; +import type { OpenAIResponsesOptions } from "./openai-responses"; + +// --------------------------------------------------------------------------- +// Lazy provider module shape +// --------------------------------------------------------------------------- + +interface LazyProviderModule { + stream: (model: Model, context: Context, options: OptionsForApi) => AsyncIterable; +} + +interface AnthropicProviderModule { + streamAnthropic: ( + model: Model<"anthropic-messages">, + context: Context, + options: AnthropicOptions, + ) => AssistantMessageEventStream; +} + +interface AzureOpenAIResponsesProviderModule { + streamAzureOpenAIResponses: ( + model: Model<"azure-openai-responses">, + context: Context, + options: AzureOpenAIResponsesOptions, + ) => AssistantMessageEventStream; +} + +interface GoogleProviderModule { + streamGoogle: ( + model: Model<"google-generative-ai">, + context: Context, + options: GoogleOptions, + ) => AssistantMessageEventStream; +} + +interface GoogleGeminiCliProviderModule { + streamGoogleGeminiCli: ( + model: Model<"google-gemini-cli">, + context: Context, + options: GoogleGeminiCliOptions, + ) => AssistantMessageEventStream; +} + +interface GoogleVertexProviderModule { + streamGoogleVertex: ( + model: Model<"google-vertex">, + context: Context, + options: GoogleVertexOptions, + ) => AssistantMessageEventStream; +} + +interface OpenAICodexResponsesProviderModule { + streamOpenAICodexResponses: ( + model: Model<"openai-codex-responses">, + context: Context, + options: OpenAICodexResponsesOptions, + ) => AssistantMessageEventStream; +} + +interface OpenAICompletionsProviderModule { + streamOpenAICompletions: ( + model: Model<"openai-completions">, + context: Context, + options: OpenAICompletionsOptions, + ) => AssistantMessageEventStream; +} + +interface OpenAIResponsesProviderModule { + streamOpenAIResponses: ( + model: Model<"openai-responses">, + context: Context, + options: OpenAIResponsesOptions, + ) => AssistantMessageEventStream; +} + +interface CursorProviderModule { + streamCursor: ( + model: Model<"cursor-agent">, + context: Context, + options: CursorOptions, + ) => AssistantMessageEventStream; +} + +interface BedrockProviderModule { + streamBedrock: ( + model: Model<"bedrock-converse-stream">, + context: Context, + options: BedrockOptions, + ) => AssistantMessageEventStream; +} + +// --------------------------------------------------------------------------- +// Module-level lazy promise caches +// --------------------------------------------------------------------------- + +const importNodeOnlyProvider = (specifier: string): Promise => import(specifier); + +let anthropicProviderModulePromise: Promise> | undefined; +let azureOpenAIResponsesProviderModulePromise: Promise> | undefined; +let googleProviderModulePromise: Promise> | undefined; +let googleGeminiCliProviderModulePromise: Promise> | undefined; +let googleVertexProviderModulePromise: Promise> | undefined; +let openAICodexResponsesProviderModulePromise: Promise> | undefined; +let openAICompletionsProviderModulePromise: Promise> | undefined; +let openAIResponsesProviderModulePromise: Promise> | undefined; +let cursorProviderModulePromise: Promise> | undefined; +let bedrockProviderModuleOverride: LazyProviderModule<"bedrock-converse-stream"> | undefined; +let bedrockProviderModulePromise: Promise> | undefined; + +export function setBedrockProviderModule(module: BedrockProviderModule): void { + bedrockProviderModuleOverride = { + stream: module.streamBedrock, + }; +} + +// --------------------------------------------------------------------------- +// Stream forwarding / error helpers +// --------------------------------------------------------------------------- + +function forwardStream(target: EventStreamImpl, source: AsyncIterable): void { + (async () => { + for await (const event of source) { + target.push(event); + } + target.end(); + })(); +} + +function createLazyLoadErrorMessage(model: Model, error: unknown): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }; +} + +// --------------------------------------------------------------------------- +// Generic lazy stream factory +// --------------------------------------------------------------------------- + +function createLazyStream( + loadModule: () => Promise>, +): (model: Model, context: Context, options: OptionsForApi) => EventStreamImpl { + return (model, context, options) => { + const outer = new EventStreamImpl(); + + loadModule() + .then(module => { + const inner = module.stream(model, context, options); + forwardStream(outer, inner); + }) + .catch(error => { + const message = createLazyLoadErrorMessage(model, error); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + }); + + return outer; + }; +} + +// --------------------------------------------------------------------------- +// Module loaders (one per provider, cached via ||=) +// --------------------------------------------------------------------------- + +function loadAnthropicProviderModule(): Promise> { + anthropicProviderModulePromise ||= import("./anthropic").then(module => { + const provider = module as AnthropicProviderModule; + return { stream: provider.streamAnthropic }; + }); + return anthropicProviderModulePromise; +} + +function loadAzureOpenAIResponsesProviderModule(): Promise> { + azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses").then(module => { + const provider = module as AzureOpenAIResponsesProviderModule; + return { stream: provider.streamAzureOpenAIResponses }; + }); + return azureOpenAIResponsesProviderModulePromise; +} + +function loadGoogleProviderModule(): Promise> { + googleProviderModulePromise ||= import("./google").then(module => { + const provider = module as GoogleProviderModule; + return { stream: provider.streamGoogle }; + }); + return googleProviderModulePromise; +} + +function loadGoogleGeminiCliProviderModule(): Promise> { + googleGeminiCliProviderModulePromise ||= import("./google-gemini-cli").then(module => { + const provider = module as GoogleGeminiCliProviderModule; + return { stream: provider.streamGoogleGeminiCli }; + }); + return googleGeminiCliProviderModulePromise; +} + +function loadGoogleVertexProviderModule(): Promise> { + googleVertexProviderModulePromise ||= import("./google-vertex").then(module => { + const provider = module as GoogleVertexProviderModule; + return { stream: provider.streamGoogleVertex }; + }); + return googleVertexProviderModulePromise; +} + +function loadOpenAICodexResponsesProviderModule(): Promise> { + openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses").then(module => { + const provider = module as OpenAICodexResponsesProviderModule; + return { stream: provider.streamOpenAICodexResponses }; + }); + return openAICodexResponsesProviderModulePromise; +} + +function loadOpenAICompletionsProviderModule(): Promise> { + openAICompletionsProviderModulePromise ||= import("./openai-completions").then(module => { + const provider = module as OpenAICompletionsProviderModule; + return { stream: provider.streamOpenAICompletions }; + }); + return openAICompletionsProviderModulePromise; +} + +function loadOpenAIResponsesProviderModule(): Promise> { + openAIResponsesProviderModulePromise ||= import("./openai-responses").then(module => { + const provider = module as OpenAIResponsesProviderModule; + return { stream: provider.streamOpenAIResponses }; + }); + return openAIResponsesProviderModulePromise; +} + +function loadCursorProviderModule(): Promise> { + cursorProviderModulePromise ||= import("./cursor").then(module => { + const provider = module as CursorProviderModule; + return { stream: provider.streamCursor }; + }); + return cursorProviderModulePromise; +} + +function loadBedrockProviderModule(): Promise> { + if (bedrockProviderModuleOverride) { + return Promise.resolve(bedrockProviderModuleOverride); + } + bedrockProviderModulePromise ||= importNodeOnlyProvider("./amazon-bedrock").then(module => { + const provider = module as BedrockProviderModule; + return { stream: provider.streamBedrock }; + }); + return bedrockProviderModulePromise; +} + +// --------------------------------------------------------------------------- +// Lazy stream function exports +// +// These use the same names as the direct provider stream functions. When +// stream.ts is updated to import from this module instead of individual +// providers, the lazy loading will take effect on the main code path. +// --------------------------------------------------------------------------- + +export const streamAnthropic = createLazyStream(loadAnthropicProviderModule); +export const streamAzureOpenAIResponses = createLazyStream(loadAzureOpenAIResponsesProviderModule); +export const streamGoogle = createLazyStream(loadGoogleProviderModule); +export const streamGoogleGeminiCli = createLazyStream(loadGoogleGeminiCliProviderModule); +export const streamGoogleVertex = createLazyStream(loadGoogleVertexProviderModule); +export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexResponsesProviderModule); +export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule); +export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule); +export const streamCursor = createLazyStream(loadCursorProviderModule); +export const streamBedrock = createLazyStream(loadBedrockProviderModule); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index de610c366..e38224e60 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -338,6 +338,7 @@ export interface AssistantMessage { api: Api; provider: Provider; model: string; + responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one usage: Usage; stopReason: StopReason; errorMessage?: string; @@ -444,8 +445,8 @@ export interface OpenAICompat { requiresThinkingAsText?: boolean; /** 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" }, "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ - thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template"; + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses thinking: { type: "enabled" }, "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ + thinkingFormat?: "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template"; /** 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. */ diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 3b5b1392e..751dd271b 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -28,6 +28,7 @@ export interface Args { mode?: Mode; noSession?: boolean; sessionDir?: string; + fork?: string; models?: string[]; tools?: string[]; noTools?: boolean; @@ -79,6 +80,8 @@ export function parseArgs(args: string[], extensionFlags?: Map 0; + if (!hasInitialContext) { + return { + initialImages: undefined, + }; + } + + let body = ""; + if (fileText !== undefined) { + body += fileText; + } + + if (parsed.messages.length > 0) { + body += parsed.messages[0]; + parsed.messages.shift(); + } + + const initialMessage = + stdinContent !== undefined + ? body.length > 0 + ? `${stdinContent}\n${body}` + : stdinContent + : body.length > 0 + ? body + : fileImages && fileImages.length > 0 + ? "" + : undefined; + + return { + initialMessage, + initialImages: fileImages && fileImages.length > 0 ? fileImages : undefined, + }; +} diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 6fe4ee578..44d954e4a 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -1,120 +1,438 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import * as path from "node:path"; import { - DEFAULT_EDITOR_KEYBINDINGS, - type EditorAction, - type EditorKeybindingsConfig, - EditorKeybindingsManager, + type Keybinding, + type KeybindingDefinitions, + type KeybindingsConfig, type KeyId, - matchesKey, - setEditorKeybindings, + setKeybindings, + TUI_KEYBINDINGS, + KeybindingsManager as TuiKeybindingsManager, } from "@oh-my-pi/pi-tui"; import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; /** - * Application-level actions (coding agent specific). + * Application-level keybindings (coding agent specific). + * Values are always `true` — used for declaration merging. */ -export type AppAction = - | "interrupt" - | "clear" - | "exit" - | "suspend" - | "cycleThinkingLevel" - | "cycleModelForward" - | "cycleModelBackward" - | "selectModel" - | "togglePlanMode" - | "expandTools" - | "toggleThinking" - | "externalEditor" - | "historySearch" - | "followUp" - | "dequeue" - | "pasteImage" - | "copyLine" - | "copyPrompt" - | "newSession" - | "tree" - | "fork" - | "resume" - | "toggleSTT"; +interface AppKeybindings { + "app.interrupt": true; + "app.clear": true; + "app.exit": true; + "app.suspend": true; + "app.thinking.cycle": true; + "app.thinking.toggle": true; + "app.model.cycleForward": true; + "app.model.cycleBackward": true; + "app.model.select": true; + "app.tools.expand": true; + "app.editor.external": true; + "app.message.followUp": true; + "app.message.dequeue": true; + "app.clipboard.pasteImage": true; + "app.clipboard.copyLine": true; + "app.clipboard.copyPrompt": true; + "app.session.new": true; + "app.session.tree": true; + "app.session.fork": true; + "app.session.resume": true; + "app.session.togglePath": true; + "app.session.toggleSort": true; + "app.session.rename": true; + "app.session.delete": true; + "app.session.deleteNoninvasive": true; + "app.tree.foldOrUp": true; + "app.tree.unfoldOrDown": true; + "app.plan.toggle": true; + "app.history.search": true; + "app.stt.toggle": true; +} + +export type AppKeybinding = keyof AppKeybindings; + +declare module "@oh-my-pi/pi-tui" { + interface Keybindings extends AppKeybindings {} +} /** - * All configurable actions. + * All keybindings definitions: TUI + app-specific. */ -export type KeyAction = AppAction | EditorAction; +export const KEYBINDINGS = { + ...TUI_KEYBINDINGS, + "app.interrupt": { + defaultKeys: "escape", + description: "Interrupt current operation", + }, + "app.clear": { + defaultKeys: "ctrl+c", + description: "Clear screen or cancel", + }, + "app.exit": { + defaultKeys: "ctrl+d", + description: "Exit application", + }, + "app.suspend": { + defaultKeys: "ctrl+z", + description: "Suspend application", + }, + "app.thinking.cycle": { + defaultKeys: "shift+tab", + description: "Cycle thinking level", + }, + "app.thinking.toggle": { + defaultKeys: "ctrl+t", + description: "Toggle thinking mode", + }, + "app.model.cycleForward": { + defaultKeys: "ctrl+p", + description: "Cycle to next model", + }, + "app.model.cycleBackward": { + defaultKeys: "shift+ctrl+p", + description: "Cycle to previous model", + }, + "app.model.select": { + defaultKeys: "ctrl+l", + description: "Select model", + }, + "app.tools.expand": { + defaultKeys: "ctrl+o", + description: "Expand tools", + }, + "app.editor.external": { + defaultKeys: "ctrl+g", + description: "Open external editor", + }, + "app.message.followUp": { + defaultKeys: "ctrl+enter", + description: "Send follow-up message", + }, + "app.message.dequeue": { + defaultKeys: "alt+up", + description: "Dequeue message", + }, + "app.clipboard.pasteImage": { + defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v", + description: "Paste image from clipboard", + }, + "app.clipboard.copyLine": { + defaultKeys: "alt+shift+l", + description: "Copy current line", + }, + "app.clipboard.copyPrompt": { + defaultKeys: "alt+shift+c", + description: "Copy prompt", + }, + "app.session.new": { + defaultKeys: [], + description: "Create new session", + }, + "app.session.tree": { + defaultKeys: [], + description: "Show session tree", + }, + "app.session.fork": { + defaultKeys: [], + description: "Fork session", + }, + "app.session.resume": { + defaultKeys: [], + description: "Resume session", + }, + "app.session.togglePath": { + defaultKeys: "ctrl+p", + description: "Toggle session path display", + }, + "app.session.toggleSort": { + defaultKeys: "ctrl+s", + description: "Toggle session sort order", + }, + "app.session.rename": { + defaultKeys: "ctrl+r", + description: "Rename session", + }, + "app.session.delete": { + defaultKeys: "ctrl+d", + description: "Delete session", + }, + "app.session.deleteNoninvasive": { + defaultKeys: "ctrl+backspace", + description: "Delete session (non-invasive)", + }, + "app.tree.foldOrUp": { + defaultKeys: ["ctrl+left", "alt+left"], + description: "Fold or move up", + }, + "app.tree.unfoldOrDown": { + defaultKeys: ["ctrl+right", "alt+right"], + description: "Unfold or move down", + }, + "app.plan.toggle": { + defaultKeys: "alt+shift+p", + description: "Toggle plan mode", + }, + "app.history.search": { + defaultKeys: "ctrl+r", + description: "Search history", + }, + "app.stt.toggle": { + defaultKeys: "alt+h", + description: "Toggle speech-to-text", + }, +} as const satisfies KeybindingDefinitions; /** - * Full keybindings configuration (app + editor actions). + * Migration map from old keybinding names to new namespaced IDs. */ -export type KeybindingsConfig = { - [K in KeyAction]?: KeyId | KeyId[]; -}; +const KEYBINDING_NAME_MIGRATIONS = { + // App-specific (old names) + interrupt: "app.interrupt", + clear: "app.clear", + exit: "app.exit", + suspend: "app.suspend", + cycleThinkingLevel: "app.thinking.cycle", + cycleModelForward: "app.model.cycleForward", + cycleModelBackward: "app.model.cycleBackward", + selectModel: "app.model.select", + togglePlanMode: "app.plan.toggle", + historySearch: "app.history.search", + expandTools: "app.tools.expand", + toggleThinking: "app.thinking.toggle", + externalEditor: "app.editor.external", + followUp: "app.message.followUp", + dequeue: "app.message.dequeue", + pasteImage: "app.clipboard.pasteImage", + copyLine: "app.clipboard.copyLine", + copyPrompt: "app.clipboard.copyPrompt", + newSession: "app.session.new", + tree: "app.session.tree", + fork: "app.session.fork", + resume: "app.session.resume", + toggleSTT: "app.stt.toggle", + // TUI editor (old names for backward compatibility) + cursorUp: "tui.editor.cursorUp", + cursorDown: "tui.editor.cursorDown", + cursorLeft: "tui.editor.cursorLeft", + cursorRight: "tui.editor.cursorRight", + cursorWordLeft: "tui.editor.cursorWordLeft", + cursorWordRight: "tui.editor.cursorWordRight", + cursorLineStart: "tui.editor.cursorLineStart", + cursorLineEnd: "tui.editor.cursorLineEnd", + jumpForward: "tui.editor.jumpForward", + jumpBackward: "tui.editor.jumpBackward", + pageUp: "tui.editor.pageUp", + pageDown: "tui.editor.pageDown", + deleteCharBackward: "tui.editor.deleteCharBackward", + deleteCharForward: "tui.editor.deleteCharForward", + deleteWordBackward: "tui.editor.deleteWordBackward", + deleteWordForward: "tui.editor.deleteWordForward", + deleteToLineStart: "tui.editor.deleteToLineStart", + deleteToLineEnd: "tui.editor.deleteToLineEnd", + yank: "tui.editor.yank", + yankPop: "tui.editor.yankPop", + undo: "tui.editor.undo", + // TUI input (old names for backward compatibility) + newLine: "tui.input.newLine", + submit: "tui.input.submit", + tab: "tui.input.tab", + copy: "tui.input.copy", + // TUI select (old names for backward compatibility) + selectUp: "tui.select.up", + selectDown: "tui.select.down", + selectPageUp: "tui.select.pageUp", + selectPageDown: "tui.select.pageDown", + selectConfirm: "tui.select.confirm", + selectCancel: "tui.select.cancel", + // Upstream additional migrations + toggleSessionNamedFilter: "app.session.togglePath", +} as const satisfies Record; /** - * Default application keybindings. + * Check if a key is a legacy keybinding name. */ -export const DEFAULT_APP_KEYBINDINGS: Record = { - interrupt: "escape", - clear: "ctrl+c", - exit: "ctrl+d", - suspend: "ctrl+z", - cycleThinkingLevel: "shift+tab", - cycleModelForward: "ctrl+p", - cycleModelBackward: "shift+ctrl+p", - selectModel: "ctrl+l", - togglePlanMode: "alt+shift+p", - historySearch: "ctrl+r", - expandTools: "ctrl+o", - toggleThinking: "ctrl+t", - externalEditor: "ctrl+g", - followUp: "ctrl+enter", - dequeue: "alt+up", - pasteImage: "ctrl+v", - copyLine: "alt+shift+l", - copyPrompt: "alt+shift+c", - newSession: [], - tree: [], - fork: [], - resume: [], - toggleSTT: "alt+h", -}; +function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAME_MIGRATIONS { + return key in KEYBINDING_NAME_MIGRATIONS; +} + /** - * All default keybindings (app + editor). + * Normalize input to KeybindingsConfig, validating types. */ -export const DEFAULT_KEYBINDINGS: Required = { - ...DEFAULT_EDITOR_KEYBINDINGS, - ...DEFAULT_APP_KEYBINDINGS, -}; +function toKeybindingsConfig(value: unknown): KeybindingsConfig { + if (typeof value !== "object" || value === null) { + return {}; + } -// App actions list for type checking -const APP_ACTIONS: AppAction[] = [ - "interrupt", - "clear", - "exit", - "suspend", - "cycleThinkingLevel", - "cycleModelForward", - "cycleModelBackward", - "selectModel", - "togglePlanMode", - "historySearch", - "expandTools", - "toggleThinking", - "externalEditor", - "followUp", - "dequeue", - "pasteImage", - "copyLine", - "copyPrompt", - "newSession", - "tree", - "fork", - "resume", - "toggleSTT", -]; + const config: KeybindingsConfig = {}; + for (const [key, val] of Object.entries(value)) { + // Allow undefined, string (KeyId), or array of strings + if (val === undefined) { + config[key] = undefined; + } else if (typeof val === "string") { + config[key] = val as KeyId; + } else if (Array.isArray(val) && val.every(v => typeof v === "string")) { + config[key] = val as string[] as KeyId[]; + } + // Silently skip invalid entries + } + return config; +} -function isAppAction(action: string): action is AppAction { - return APP_ACTIONS.includes(action as AppAction); +/** + * Migrate old keybinding names to new namespaced IDs. + * Returns both the migrated config and a flag indicating if migration occurred. + */ +function migrateKeybindingNames(rawConfig: unknown): { + config: KeybindingsConfig; + migrated: boolean; +} { + const config = toKeybindingsConfig(rawConfig); + const migrated: KeybindingsConfig = {}; + let didMigrate = false; + + for (const [key, value] of Object.entries(config)) { + if (isLegacyKeybindingName(key)) { + const newKey = KEYBINDING_NAME_MIGRATIONS[key]; + migrated[newKey] = value; + didMigrate = true; + } else { + // Already a new-style key + migrated[key] = value; + } + } + + return { config: migrated, migrated: didMigrate }; +} + +/** + * Order keybindings config to match KEYBINDINGS key order. + */ +function orderKeybindingsConfig(config: KeybindingsConfig): KeybindingsConfig { + const ordered: KeybindingsConfig = {}; + for (const key of Object.keys(KEYBINDINGS)) { + const value = config[key]; + if (value !== undefined) { + ordered[key] = value; + } + } + // Add any remaining keys that aren't in KEYBINDINGS + for (const key of Object.keys(config)) { + if (!(key in ordered)) { + ordered[key] = config[key]; + } + } + return ordered; +} + +/** + * Load raw config from a file synchronously. + * Returns parsed JSON or null if file doesn't exist or is invalid. + */ +function loadRawConfig(filePath: string): unknown { + try { + if (!existsSync(filePath)) { + return null; + } + const content = readFileSync(filePath, "utf-8"); + return JSON.parse(content); + } catch (error) { + if (isEnoent(error)) { + return null; + } + logger.warn("Failed to parse keybindings config", { path: filePath, error: String(error) }); + return null; + } +} + +/** + * Migrate keybindings config file from old format to new. + * Reads from agentDir/keybindings.json, migrates old names, and writes back. + */ +function loadKeybindingsConfig(filePath: string, writeBack: boolean): KeybindingsConfig { + const rawConfig = loadRawConfig(filePath); + + if (rawConfig === null) { + return {}; + } + + const { config: migratedConfig, migrated } = migrateKeybindingNames(rawConfig); + if (writeBack && migrated) { + const ordered = orderKeybindingsConfig(migratedConfig); + try { + writeFileSync(filePath, `${JSON.stringify(ordered, null, 2)}\n`, "utf-8"); + logger.debug("Migrated keybindings config", { path: filePath }); + } catch (error) { + logger.warn("Failed to write migrated keybindings config", { path: filePath, error: String(error) }); + } + } + + return migratedConfig; +} + +function migrateKeybindingsConfigFile(agentDir: string): void { + const configPath = path.join(agentDir, "keybindings.json"); + loadKeybindingsConfig(configPath, true); +} + +/** + * Manages all keybindings (app + TUI). + * Extends the TUI KeybindingsManager with app-specific functionality. + */ +export class KeybindingsManager extends TuiKeybindingsManager { + #configPath: string | undefined; + + constructor(userBindings: KeybindingsConfig = {}, configPath?: string) { + super(KEYBINDINGS, userBindings); + this.#configPath = configPath; + } + + /** + * Create from config file at agentDir/keybindings.json. + */ + static create(agentDir: string = getAgentDir()): KeybindingsManager { + const configPath = path.join(agentDir, "keybindings.json"); + const userBindings = KeybindingsManager.#loadFromFile(configPath); + const manager = new KeybindingsManager(userBindings, configPath); + // Set globally so getKeybindings() returns this manager + setKeybindings(manager); + return manager; + } + + /** + * Create an in-memory keybindings manager without file persistence. + */ + static inMemory(userBindings: KeybindingsConfig = {}): KeybindingsManager { + return new KeybindingsManager(userBindings); + } + + /** + * Reload keybindings from the config file. + */ + reload(): void { + if (!this.#configPath) return; + this.setUserBindings(KeybindingsManager.#loadFromFile(this.#configPath)); + } + + /** + * Get the effective resolved bindings (defaults + user overrides). + */ + getEffectiveConfig(): KeybindingsConfig { + return this.getResolvedBindings(); + } + + /** + * Get display string for a keybinding (e.g., "ctrl+c/escape"). + */ + getDisplayString(keybinding: Keybinding): string { + const keys = this.getKeys(keybinding); + return formatKeyHints(keys.length === 0 ? [] : keys); + } + + /** + * Load user bindings from a file, migrating old names if needed. + */ + static #loadFromFile(filePath: string): KeybindingsConfig { + return loadKeybindingsConfig(filePath, true); + } } /** @@ -145,8 +463,6 @@ const KEY_LABELS: Record = { right: "Right", }; -const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId; - function formatKeyPart(part: string): string { const lower = part.toLowerCase(); const modifier = MODIFIER_LABELS[lower]; @@ -166,116 +482,5 @@ export function formatKeyHints(keys: KeyId | KeyId[]): string { return list.map(formatKeyHint).join("/"); } -/** - * Manages all keybindings (app + editor). - */ -export class KeybindingsManager { - #appActionToKeys: Map; - - private constructor(private readonly config: KeybindingsConfig) { - this.#appActionToKeys = new Map(); - this.#buildMaps(); - } - - /** - * Create from config file and set up editor keybindings. - */ - static async create(agentDir: string = getAgentDir()): Promise { - const configPath = path.join(agentDir, "keybindings.json"); - const config = await KeybindingsManager.#loadFromFile(configPath); - const manager = new KeybindingsManager(config); - - // Set up editor keybindings globally - const editorConfig: EditorKeybindingsConfig = {}; - for (const [action, keys] of Object.entries(config)) { - if (!isAppAction(action)) { - editorConfig[action as EditorAction] = keys; - } - } - setEditorKeybindings(new EditorKeybindingsManager(editorConfig)); - - return manager; - } - - /** - * Create in-memory. - */ - static inMemory(config: KeybindingsConfig = {}): KeybindingsManager { - return new KeybindingsManager(config); - } - - static async #loadFromFile(path: string): Promise { - try { - return await Bun.file(path).json(); - } catch (error) { - if (isEnoent(error)) return {}; - logger.warn("Failed to parse keybindings config", { path, error: String(error) }); - return {}; - } - } - - #buildMaps(): void { - this.#appActionToKeys.clear(); - - // Set defaults for app actions - for (const [action, keys] of Object.entries(DEFAULT_APP_KEYBINDINGS)) { - const keyArray = Array.isArray(keys) ? keys : [keys]; - this.#appActionToKeys.set( - action as AppAction, - keyArray.map(key => normalizeKeyId(key as KeyId)), - ); - } - - // Override with user config (app actions only) - for (const [action, keys] of Object.entries(this.config)) { - if (keys === undefined || !isAppAction(action)) continue; - const keyArray = Array.isArray(keys) ? keys : [keys]; - this.#appActionToKeys.set( - action, - keyArray.map(key => normalizeKeyId(key as KeyId)), - ); - } - } - - /** - * Check if input matches an app action. - */ - matches(data: string, action: AppAction): boolean { - const keys = this.#appActionToKeys.get(action); - if (!keys) return false; - for (const key of keys) { - if (matchesKey(data, key)) return true; - } - return false; - } - - /** - * Get keys bound to an app action. - */ - getKeys(action: AppAction): KeyId[] { - return this.#appActionToKeys.get(action) ?? []; - } - - /** - * Get display string for an action. - */ - getDisplayString(action: AppAction): string { - return formatKeyHints(this.getKeys(action)); - } - - /** - * Get the full effective config. - */ - getEffectiveConfig(): Required { - const result = { ...DEFAULT_KEYBINDINGS }; - for (const [action, keys] of Object.entries(this.config)) { - if (keys !== undefined) { - (result as KeybindingsConfig)[action as KeyAction] = keys; - } - } - return result; - } -} - -// Re-export for convenience -export type { EditorAction, KeyId }; +export type { Keybinding, KeybindingsConfig, KeyId }; +export { migrateKeybindingsConfigFile }; diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 251dc2964..29a49a7d8 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -90,6 +90,7 @@ const OpenAICompatSchema = Type.Object({ thinkingFormat: Type.Optional( Type.Union([ Type.Literal("openai"), + Type.Literal("openrouter"), Type.Literal("zai"), Type.Literal("qwen"), Type.Literal("qwen-chat-template"), diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 157b8a118..82d20b3b5 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -141,6 +141,55 @@ function isAlias(id: string): boolean { return !datePattern.test(id); } +/** + * Find an exact model reference match. + * Supports either a bare model id or a canonical provider/modelId reference. + * When matching by bare id, ambiguous matches across providers are rejected. + */ +export function findExactModelReferenceMatch( + modelReference: string, + availableModels: Model[], +): Model | undefined { + const trimmedReference = modelReference.trim(); + if (!trimmedReference) { + return undefined; + } + + const normalizedReference = trimmedReference.toLowerCase(); + + const canonicalMatches = availableModels.filter( + model => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference, + ); + if (canonicalMatches.length === 1) { + return canonicalMatches[0]; + } + if (canonicalMatches.length > 1) { + return undefined; + } + + const slashIndex = trimmedReference.indexOf("/"); + if (slashIndex !== -1) { + const provider = trimmedReference.substring(0, slashIndex).trim(); + const modelId = trimmedReference.substring(slashIndex + 1).trim(); + if (provider && modelId) { + const providerMatches = availableModels.filter( + model => + model.provider.toLowerCase() === provider.toLowerCase() && + model.id.toLowerCase() === modelId.toLowerCase(), + ); + if (providerMatches.length === 1) { + return providerMatches[0]; + } + if (providerMatches.length > 1) { + return undefined; + } + } + } + + const idMatches = availableModels.filter(model => model.id.toLowerCase() === normalizedReference); + return idMatches.length === 1 ? idMatches[0] : undefined; +} + /** * Try to match a pattern to a model from the available models list. * Returns the matched model or undefined if no match found. @@ -150,17 +199,17 @@ function tryMatchModel( availableModels: Model[], context: ModelPreferenceContext, ): Model | undefined { - // Check for provider/modelId format (provider is everything before the first /) + // Try exact reference match first (handles provider/modelId and bare id with ambiguity rejection) + const exactRefMatch = findExactModelReferenceMatch(modelPattern, availableModels); + if (exactRefMatch) { + return exactRefMatch; + } + + // Check for provider/modelId format — fuzzy match within provider const slashIndex = modelPattern.indexOf("/"); if (slashIndex !== -1) { const provider = modelPattern.substring(0, slashIndex); const modelId = modelPattern.substring(slashIndex + 1); - const providerMatch = availableModels.find( - m => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(), - ); - if (providerMatch) { - return providerMatch; - } const providerModels = availableModels.filter(m => m.provider.toLowerCase() === provider.toLowerCase()); if (providerModels.length > 0) { @@ -187,10 +236,9 @@ function tryMatchModel( return scored[0]?.model; } } - // No exact provider/model match - fall through to other matching } - // Check for exact ID match (case-insensitive) + // Exact ID match (case-insensitive) — with ambiguity across providers handled by preference const exactMatches = availableModels.filter(m => m.id.toLowerCase() === modelPattern.toLowerCase()); if (exactMatches.length > 0) { return pickPreferredModel(exactMatches, context); diff --git a/packages/coding-agent/src/export/html/template.css b/packages/coding-agent/src/export/html/template.css index d1d40b63f..4a5287052 100644 --- a/packages/coding-agent/src/export/html/template.css +++ b/packages/coding-agent/src/export/html/template.css @@ -2,6 +2,10 @@ :root { --line-height: 18px; /* 12px font * 1.5 */ + --sidebar-width: 400px; + --sidebar-min-width: 240px; + --sidebar-max-width: 840px; + --sidebar-resizer-width: 6px; } body { @@ -12,6 +16,11 @@ background: var(--body-bg); } + body.sidebar-resizing { + cursor: col-resize; + user-select: none; + } + #app { display: flex; min-height: 100vh; @@ -19,7 +28,9 @@ /* Sidebar */ #sidebar { - width: 400px; + width: var(--sidebar-width); + min-width: var(--sidebar-width); + max-width: var(--sidebar-width); background: var(--container-bg); flex-shrink: 0; display: flex; @@ -203,8 +214,28 @@ flex-shrink: 0; } + #sidebar-resizer { + width: var(--sidebar-resizer-width); + flex-shrink: 0; + position: sticky; + top: 0; + height: 100vh; + cursor: col-resize; + touch-action: none; + background: transparent; + border-right: 1px solid transparent; + } + + #sidebar-resizer:hover, + body.sidebar-resizing #sidebar-resizer { + background: var(--selectedBg); + border-right-color: var(--dim); + } + /* Main content */ #content { + flex: 1; + min-width: 0; flex: 1; overflow-y: auto; padding: var(--line-height) calc(var(--line-height) * 2); @@ -841,17 +872,19 @@ @media (max-width: 900px) { #sidebar { position: fixed; - left: -400px; - width: 400px; + transform: translateX(-100%); + width: min(var(--sidebar-width), 100vw); + min-width: 0; + max-width: 100vw; top: 0; bottom: 0; height: 100vh; z-index: 99; - transition: left 0.3s; + transition: transform 0.3s; } #sidebar.open { - left: 0; + transform: translateX(0); } #sidebar-overlay.open { @@ -866,6 +899,10 @@ display: block; } + #sidebar-resizer { + display: none; + } + #content { padding: var(--line-height) 16px; } @@ -875,15 +912,8 @@ } } - @media (max-width: 500px) { - #sidebar { - width: 100vw; - left: -100vw; - } - } - @media print { - #sidebar, #sidebar-toggle { display: none !important; } + #sidebar, #sidebar-toggle, #sidebar-resizer { display: none !important; } body { background: white; color: black; } #content { max-width: none; } } diff --git a/packages/coding-agent/src/export/html/template.generated.ts b/packages/coding-agent/src/export/html/template.generated.ts index 159081fa8..8be39c480 100644 --- a/packages/coding-agent/src/export/html/template.generated.ts +++ b/packages/coding-agent/src/export/html/template.generated.ts @@ -1,2 +1,2 @@ // Auto-generated by scripts/generate-template.ts - DO NOT EDIT -export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; +export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; diff --git a/packages/coding-agent/src/export/html/template.html b/packages/coding-agent/src/export/html/template.html index 3afb4beb3..0330e1307 100644 --- a/packages/coding-agent/src/export/html/template.html +++ b/packages/coding-agent/src/export/html/template.html @@ -28,6 +28,7 @@
+
diff --git a/packages/coding-agent/src/export/html/template.js b/packages/coding-agent/src/export/html/template.js index 6c3991c9f..06a9a4406 100644 --- a/packages/coding-agent/src/export/html/template.js +++ b/packages/coding-agent/src/export/html/template.js @@ -1279,6 +1279,113 @@ const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('sidebar-overlay'); const hamburger = document.getElementById('hamburger'); + const sidebarResizer = document.getElementById('sidebar-resizer'); + const SIDEBAR_WIDTH_STORAGE_KEY = 'pi-share:v1:sidebar-width'; + const MIN_CONTENT_WIDTH = 320; + + function isMobileLayout() { + return window.matchMedia('(max-width: 900px)').matches; + } + + function getSidebarBounds() { + const rootStyles = getComputedStyle(document.documentElement); + const minWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-min-width')) || 240; + const maxWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-max-width')) || 720; + const viewportMaxWidth = window.innerWidth - MIN_CONTENT_WIDTH; + return { + minWidth, + maxWidth: Math.max(minWidth, Math.min(maxWidth, viewportMaxWidth)) + }; + } + + function clampSidebarWidth(width) { + const { minWidth, maxWidth } = getSidebarBounds(); + return Math.max(minWidth, Math.min(maxWidth, width)); + } + + function applySidebarWidth(width) { + document.documentElement.style.setProperty('--sidebar-width', `${Math.round(clampSidebarWidth(width))}px`); + } + + function loadSidebarWidth() { + try { + const raw = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY); + if (raw === null) return null; + const width = Number(raw); + return Number.isFinite(width) ? width : null; + } catch { + return null; + } + } + + function saveSidebarWidth(width) { + try { + localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clampSidebarWidth(width)))); + } catch { + // Ignore storage failures (e.g. private browsing restrictions) + } + } + + function setupSidebarResize() { + const savedWidth = loadSidebarWidth(); + if (savedWidth !== null) { + applySidebarWidth(savedWidth); + } + + if (!sidebarResizer) return; + + let cleanupDrag = null; + + const stopDrag = (pointerId) => { + if (cleanupDrag) { + cleanupDrag(pointerId); + cleanupDrag = null; + } + }; + + sidebarResizer.addEventListener('pointerdown', (e) => { + if (isMobileLayout()) return; + + e.preventDefault(); + const startX = e.clientX; + const startWidth = sidebar.getBoundingClientRect().width; + document.body.classList.add('sidebar-resizing'); + sidebarResizer.setPointerCapture?.(e.pointerId); + + const onPointerMove = (event) => { + applySidebarWidth(startWidth + (event.clientX - startX)); + }; + + cleanupDrag = (pointerIdToRelease) => { + document.body.classList.remove('sidebar-resizing'); + sidebarResizer.releasePointerCapture?.(pointerIdToRelease); + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerCancel); + saveSidebarWidth(sidebar.getBoundingClientRect().width); + }; + + const onPointerUp = (event) => stopDrag(event.pointerId); + const onPointerCancel = (event) => stopDrag(event.pointerId); + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + window.addEventListener('pointercancel', onPointerCancel); + }); + + sidebarResizer.addEventListener('dblclick', () => { + if (isMobileLayout()) return; + applySidebarWidth(400); + saveSidebarWidth(400); + }); + + window.addEventListener('resize', () => { + if (isMobileLayout()) return; + applySidebarWidth(sidebar.getBoundingClientRect().width); + }); + } + + setupSidebarResize(); hamburger.addEventListener('click', () => { sidebar.classList.add('open'); diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 035597f64..f670e147c 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -56,7 +56,7 @@ import type { TodoItem } from "../../tools/todo-write"; import type { EventBus } from "../../utils/event-bus"; import type { SlashCommandInfo } from "../slash-commands"; -export type { AppAction, KeybindingsManager } from "../../config/keybindings"; +export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings"; export type { ExecOptions, ExecResult } from "../../exec/exec"; export type { AgentToolResult, AgentToolUpdateCallback }; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 54259be9b..4b6090602 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -15,6 +15,7 @@ import { $env, getProjectDir, logger, postmortem, setProjectDir, VERSION } from import chalk from "chalk"; import type { Args } from "./cli/args"; import { processFileArguments } from "./cli/file-processor"; +import { buildInitialMessage } from "./cli/initial-message"; import { listModels } from "./cli/list-models"; import { selectSession } from "./cli/session-picker"; import { findConfigFile } from "./config"; @@ -137,7 +138,7 @@ async function runInteractiveMode( } } - if (initialMessage) { + if (initialMessage !== undefined) { try { await session.prompt(initialMessage, { images: initialImages }); } catch (error: unknown) { @@ -161,33 +162,6 @@ async function runInteractiveMode( } } -async function prepareInitialMessage( - parsed: Args, - autoResizeImages: boolean, -): Promise<{ - initialMessage?: string; - initialImages?: ImageContent[]; -}> { - if (parsed.fileArgs.length === 0) { - return {}; - } - - const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages }); - - let initialMessage: string; - if (parsed.messages.length > 0) { - initialMessage = text + parsed.messages[0]; - parsed.messages.shift(); - } else { - initialMessage = text; - } - - return { - initialMessage, - initialImages: images.length > 0 ? images : undefined, - }; -} - function normalizePathForComparison(value: string): string { const resolved = path.resolve(value); let realPath = resolved; @@ -237,6 +211,21 @@ async function getChangelogForDisplay(parsed: Args): Promise } async function createSessionManager(parsed: Args, cwd: string): Promise { + if (parsed.fork) { + if (parsed.noSession) { + throw new Error("--fork requires session persistence"); + } + const forkSource = parsed.fork; + if (forkSource.includes("/") || forkSource.includes("\\") || forkSource.endsWith(".jsonl")) { + return await SessionManager.forkFrom(forkSource, cwd, parsed.sessionDir); + } + const match = await resolveResumableSession(forkSource, cwd, parsed.sessionDir); + if (!match) { + throw new Error(`Session "${forkSource}" not found.`); + } + return await SessionManager.forkFrom(match.session.path, cwd, parsed.sessionDir); + } + if (parsed.noSession) { return SessionManager.inMemory(); } @@ -565,22 +554,27 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise { + const { pipedInput, fileText, fileImages } = await logger.timeAsync("prepareInitialMessage", async () => { const pipedInput = await readPipedInput(); - let { initialMessage, initialImages } = await prepareInitialMessage( - parsedArgs, - settings.get("images.autoResize"), - ); - if (pipedInput) { - initialMessage = initialMessage ? `${initialMessage}\n${pipedInput}` : pipedInput; + if (parsedArgs.fileArgs.length === 0) { + return { pipedInput }; } - return { pipedInput, initialMessage, initialImages }; + + const { text, images } = await processFileArguments(parsedArgs.fileArgs, { + autoResizeImages: settings.get("images.autoResize"), + }); + return { + pipedInput, + fileText: text, + fileImages: images, + }; + }); + const { initialMessage, initialImages } = buildInitialMessage({ + parsed: parsedArgs, + fileText, + fileImages, + stdinContent: pipedInput, }); - const initialMessage = initMsg; const autoPrint = pipedInput !== undefined && !parsedArgs.print && parsedArgs.mode === undefined; const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined; const mode = parsedArgs.mode || "text"; @@ -626,7 +620,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise createSessionManager(parsedArgs, cwd)); // Handle --resume (no value): show session picker - if (parsedArgs.resume === true) { + if (parsedArgs.resume === true && !parsedArgs.fork) { const sessions = await logger.timeAsync("SessionManager.list", () => SessionManager.list(cwd, parsedArgs.sessionDir), ); diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index be956c20e..94ec30856 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -1,41 +1,41 @@ import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui"; -import type { AppAction } from "../../config/keybindings"; +import type { AppKeybinding } from "../../config/keybindings"; type ConfigurableEditorAction = Extract< - AppAction, - | "interrupt" - | "clear" - | "exit" - | "suspend" - | "cycleThinkingLevel" - | "cycleModelForward" - | "cycleModelBackward" - | "selectModel" - | "expandTools" - | "toggleThinking" - | "externalEditor" - | "historySearch" - | "dequeue" - | "pasteImage" - | "copyPrompt" + AppKeybinding, + | "app.interrupt" + | "app.clear" + | "app.exit" + | "app.suspend" + | "app.thinking.cycle" + | "app.model.cycleForward" + | "app.model.cycleBackward" + | "app.model.select" + | "app.tools.expand" + | "app.thinking.toggle" + | "app.editor.external" + | "app.history.search" + | "app.message.dequeue" + | "app.clipboard.pasteImage" + | "app.clipboard.copyPrompt" >; const DEFAULT_ACTION_KEYS: Record = { - interrupt: ["escape"], - clear: ["ctrl+c"], - exit: ["ctrl+d"], - suspend: ["ctrl+z"], - cycleThinkingLevel: ["shift+tab"], - cycleModelForward: ["ctrl+p"], - cycleModelBackward: ["shift+ctrl+p"], - selectModel: ["ctrl+l"], - expandTools: ["ctrl+o"], - toggleThinking: ["ctrl+t"], - externalEditor: ["ctrl+g"], - historySearch: ["ctrl+r"], - dequeue: ["alt+up"], - pasteImage: ["ctrl+v"], - copyPrompt: ["alt+shift+c"], + "app.interrupt": ["escape"], + "app.clear": ["ctrl+c"], + "app.exit": ["ctrl+d"], + "app.suspend": ["ctrl+z"], + "app.thinking.cycle": ["shift+tab"], + "app.model.cycleForward": ["ctrl+p"], + "app.model.cycleBackward": ["shift+ctrl+p"], + "app.model.select": ["ctrl+l"], + "app.tools.expand": ["ctrl+o"], + "app.thinking.toggle": ["ctrl+t"], + "app.editor.external": ["ctrl+g"], + "app.history.search": ["ctrl+r"], + "app.message.dequeue": ["alt+up"], + "app.clipboard.pasteImage": ["ctrl+v"], + "app.clipboard.copyPrompt": ["alt+shift+c"], }; /** @@ -115,13 +115,13 @@ export class CustomEditor extends Editor { } // Intercept configured image paste (async - fires and handles result) - if (this.#matchesAction(data, "pasteImage") && this.onPasteImage) { + if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) { void this.onPasteImage(); return; } // Intercept configured external editor shortcut - if (this.#matchesAction(data, "externalEditor") && this.onExternalEditor) { + if (this.#matchesAction(data, "app.editor.external") && this.onExternalEditor) { this.onExternalEditor(); return; } @@ -133,56 +133,56 @@ export class CustomEditor extends Editor { } // Intercept configured suspend shortcut - if (this.#matchesAction(data, "suspend") && this.onSuspend) { + if (this.#matchesAction(data, "app.suspend") && this.onSuspend) { this.onSuspend(); return; } // Intercept configured thinking block visibility toggle - if (this.#matchesAction(data, "toggleThinking") && this.onToggleThinking) { + if (this.#matchesAction(data, "app.thinking.toggle") && this.onToggleThinking) { this.onToggleThinking(); return; } // Intercept configured model selector shortcut - if (this.#matchesAction(data, "selectModel") && this.onSelectModel) { + if (this.#matchesAction(data, "app.model.select") && this.onSelectModel) { this.onSelectModel(); return; } // Intercept configured history search shortcut - if (this.#matchesAction(data, "historySearch") && this.onHistorySearch) { + if (this.#matchesAction(data, "app.history.search") && this.onHistorySearch) { this.onHistorySearch(); return; } // Intercept configured tool output expansion shortcut - if (this.#matchesAction(data, "expandTools") && this.onExpandTools) { + if (this.#matchesAction(data, "app.tools.expand") && this.onExpandTools) { this.onExpandTools(); return; } // Intercept configured backward model cycling (check before forward cycling) - if (this.#matchesAction(data, "cycleModelBackward") && this.onCycleModelBackward) { + if (this.#matchesAction(data, "app.model.cycleBackward") && this.onCycleModelBackward) { this.onCycleModelBackward(); return; } // Intercept configured forward model cycling - if (this.#matchesAction(data, "cycleModelForward") && this.onCycleModelForward) { + if (this.#matchesAction(data, "app.model.cycleForward") && this.onCycleModelForward) { this.onCycleModelForward(); return; } // Intercept configured thinking level cycling - if (this.#matchesAction(data, "cycleThinkingLevel") && this.onCycleThinkingLevel) { + if (this.#matchesAction(data, "app.thinking.cycle") && this.onCycleThinkingLevel) { this.onCycleThinkingLevel(); return; } // Intercept configured interrupt shortcut. // Default behavior keeps autocomplete dismissal, but parent can prioritize global interrupt handling. - if (this.#matchesAction(data, "interrupt") && this.onEscape) { + if (this.#matchesAction(data, "app.interrupt") && this.onEscape) { if (!this.isShowingAutocomplete() || this.shouldBypassAutocompleteOnEscape?.()) { this.onEscape(); return; @@ -190,13 +190,13 @@ export class CustomEditor extends Editor { } // Intercept configured clear shortcut - if (this.#matchesAction(data, "clear") && this.onClear) { + if (this.#matchesAction(data, "app.clear") && this.onClear) { this.onClear(); return; } // Intercept configured exit shortcut (only when editor is empty) - if (this.#matchesAction(data, "exit")) { + if (this.#matchesAction(data, "app.exit")) { if (this.getText().length === 0 && this.onExit) { this.onExit(); } @@ -205,13 +205,13 @@ export class CustomEditor extends Editor { } // Intercept configured dequeue shortcut (restore queued message to editor) - if (this.#matchesAction(data, "dequeue") && this.onDequeue) { + if (this.#matchesAction(data, "app.message.dequeue") && this.onDequeue) { this.onDequeue(); return; } // Intercept configured copy-prompt shortcut - if (this.#matchesAction(data, "copyPrompt") && this.onCopyPrompt) { + if (this.#matchesAction(data, "app.clipboard.copyPrompt") && this.onCopyPrompt) { this.onCopyPrompt(); return; } diff --git a/packages/coding-agent/src/modes/components/keybinding-hints.ts b/packages/coding-agent/src/modes/components/keybinding-hints.ts index da567878a..9b1bf14b1 100644 --- a/packages/coding-agent/src/modes/components/keybinding-hints.ts +++ b/packages/coding-agent/src/modes/components/keybinding-hints.ts @@ -1,8 +1,8 @@ /** * Utilities for formatting keybinding hints in the UI. */ -import { type EditorAction, getEditorKeybindings, type KeyId } from "@oh-my-pi/pi-tui"; -import type { AppAction, KeybindingsManager } from "../../config/keybindings"; +import { getKeybindings, type Keybinding, type KeyId } from "@oh-my-pi/pi-tui"; +import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings"; import { theme } from "../../modes/theme/theme"; /** @@ -17,14 +17,14 @@ function formatKeys(keys: KeyId[]): string { /** * Get display string for an editor action. */ -export function editorKey(action: EditorAction): string { - return formatKeys(getEditorKeybindings().getKeys(action)); +export function editorKey(action: Keybinding): string { + return formatKeys(getKeybindings().getKeys(action)); } /** * Get display string for an app action. */ -export function appKey(keybindings: KeybindingsManager, action: AppAction): string { +export function appKey(keybindings: KeybindingsManager, action: AppKeybinding): string { return formatKeys(keybindings.getKeys(action)); } @@ -32,11 +32,11 @@ export function appKey(keybindings: KeybindingsManager, action: AppAction): stri * Format a keybinding hint with consistent styling: dim key, muted description. * Looks up the key from editor keybindings automatically. * - * @param action - Editor action name (e.g., "selectConfirm", "expandTools") + * @param action - Keybinding action name (e.g., "tui.select.confirm", "app.tools.expand") * @param description - Description text (e.g., "to expand", "cancel") * @returns Formatted string with dim key and muted description */ -export function keyHint(action: EditorAction, description: string): string { +export function keyHint(action: Keybinding, description: string): string { return theme.fg("dim", editorKey(action)) + theme.fg("muted", ` ${description}`); } @@ -45,11 +45,11 @@ export function keyHint(action: EditorAction, description: string): string { * Requires the KeybindingsManager instance. * * @param keybindings - KeybindingsManager instance - * @param action - App action name (e.g., "interrupt", "externalEditor") + * @param action - App keybinding name (e.g., "app.interrupt", "app.editor.external") * @param description - Description text * @returns Formatted string with dim key and muted description */ -export function appKeyHint(keybindings: KeybindingsManager, action: AppAction, description: string): string { +export function appKeyHint(keybindings: KeybindingsManager, action: AppKeybinding, description: string): string { return theme.fg("dim", appKey(keybindings, action)) + theme.fg("muted", ` ${description}`); } diff --git a/packages/coding-agent/src/modes/components/login-dialog.ts b/packages/coding-agent/src/modes/components/login-dialog.ts index e7a0e1cdc..d15295a70 100644 --- a/packages/coding-agent/src/modes/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/components/login-dialog.ts @@ -1,5 +1,5 @@ import { getOAuthProviders } from "@oh-my-pi/pi-ai"; -import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { Container, getKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; import { openPath } from "../../utils/open"; import { DynamicBorder } from "./dynamic-border"; @@ -151,9 +151,9 @@ export class LoginDialogComponent extends Container { } handleInput(data: string): void { - const kb = getEditorKeybindings(); + const kb = getKeybindings(); - if (kb.matches(data, "selectCancel")) { + if (kb.matches(data, "tui.select.cancel")) { this.#cancel(); return; } diff --git a/packages/coding-agent/src/modes/components/user-message.ts b/packages/coding-agent/src/modes/components/user-message.ts index 5b05e3789..5f1da9317 100644 --- a/packages/coding-agent/src/modes/components/user-message.ts +++ b/packages/coding-agent/src/modes/components/user-message.ts @@ -1,6 +1,11 @@ import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; +// OSC 133 shell integration: marks prompt zones for terminal multiplexers +const OSC133_ZONE_START = "\x1b]133;A\x07"; +const OSC133_ZONE_END = "\x1b]133;B\x07"; +const OSC133_ZONE_FINAL = "\x1b]133;C\x07"; + /** * Component that renders a user message */ @@ -19,4 +24,15 @@ export class UserMessageComponent extends Container { }), ); } + + override render(width: number): string[] { + const lines = super.render(width); + if (lines.length === 0) { + return lines; + } + + lines[0] = OSC133_ZONE_START + lines[0]; + lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL; + return lines; + } } diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 82f7aa556..9af32c5d3 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -26,7 +26,7 @@ export class InputController { constructor(private ctx: InteractiveModeContext) {} setupKeyHandlers(): void { - this.ctx.editor.setActionKeys("interrupt", this.ctx.keybindings.getKeys("interrupt")); + this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt")); this.ctx.editor.shouldBypassAutocompleteOnEscape = () => Boolean( this.ctx.loadingAnimation || @@ -83,68 +83,74 @@ export class InputController { } }; - this.ctx.editor.setActionKeys("clear", this.ctx.keybindings.getKeys("clear")); + this.ctx.editor.setActionKeys("app.clear", this.ctx.keybindings.getKeys("app.clear")); this.ctx.editor.onClear = () => this.handleCtrlC(); - this.ctx.editor.setActionKeys("exit", this.ctx.keybindings.getKeys("exit")); + this.ctx.editor.setActionKeys("app.exit", this.ctx.keybindings.getKeys("app.exit")); this.ctx.editor.onExit = () => this.handleCtrlD(); - this.ctx.editor.setActionKeys("suspend", this.ctx.keybindings.getKeys("suspend")); + this.ctx.editor.setActionKeys("app.suspend", this.ctx.keybindings.getKeys("app.suspend")); this.ctx.editor.onSuspend = () => this.handleCtrlZ(); - this.ctx.editor.setActionKeys("cycleThinkingLevel", this.ctx.keybindings.getKeys("cycleThinkingLevel")); + this.ctx.editor.setActionKeys("app.thinking.cycle", this.ctx.keybindings.getKeys("app.thinking.cycle")); this.ctx.editor.onCycleThinkingLevel = () => this.cycleThinkingLevel(); - this.ctx.editor.setActionKeys("cycleModelForward", this.ctx.keybindings.getKeys("cycleModelForward")); + this.ctx.editor.setActionKeys("app.model.cycleForward", this.ctx.keybindings.getKeys("app.model.cycleForward")); this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel(); - this.ctx.editor.setActionKeys("cycleModelBackward", this.ctx.keybindings.getKeys("cycleModelBackward")); + this.ctx.editor.setActionKeys("app.model.cycleBackward", this.ctx.keybindings.getKeys("app.model.cycleBackward")); this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel({ temporary: true }); this.ctx.editor.onQuickSelectModel = () => this.ctx.showModelSelector({ temporaryOnly: true }); // Global debug handler on TUI (works regardless of focus) this.ctx.ui.onDebug = () => this.ctx.showDebugSelector(); - this.ctx.editor.setActionKeys("selectModel", this.ctx.keybindings.getKeys("selectModel")); + this.ctx.editor.setActionKeys("app.model.select", this.ctx.keybindings.getKeys("app.model.select")); this.ctx.editor.onSelectModel = () => this.ctx.showModelSelector(); - this.ctx.editor.setActionKeys("historySearch", this.ctx.keybindings.getKeys("historySearch")); + this.ctx.editor.setActionKeys("app.history.search", this.ctx.keybindings.getKeys("app.history.search")); this.ctx.editor.onHistorySearch = () => this.ctx.showHistorySearch(); - this.ctx.editor.setActionKeys("toggleThinking", this.ctx.keybindings.getKeys("toggleThinking")); + this.ctx.editor.setActionKeys("app.thinking.toggle", this.ctx.keybindings.getKeys("app.thinking.toggle")); this.ctx.editor.onToggleThinking = () => this.ctx.toggleThinkingBlockVisibility(); - this.ctx.editor.setActionKeys("externalEditor", this.ctx.keybindings.getKeys("externalEditor")); + this.ctx.editor.setActionKeys("app.editor.external", this.ctx.keybindings.getKeys("app.editor.external")); this.ctx.editor.onExternalEditor = () => void this.openExternalEditor(); this.ctx.editor.onShowHotkeys = () => this.ctx.handleHotkeysCommand(); - this.ctx.editor.setActionKeys("pasteImage", this.ctx.keybindings.getKeys("pasteImage")); + this.ctx.editor.setActionKeys( + "app.clipboard.pasteImage", + this.ctx.keybindings.getKeys("app.clipboard.pasteImage"), + ); this.ctx.editor.onPasteImage = () => this.handleImagePaste(); - this.ctx.editor.setActionKeys("copyPrompt", this.ctx.keybindings.getKeys("copyPrompt")); + this.ctx.editor.setActionKeys( + "app.clipboard.copyPrompt", + this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"), + ); this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt(); - this.ctx.editor.setActionKeys("expandTools", this.ctx.keybindings.getKeys("expandTools")); + this.ctx.editor.setActionKeys("app.tools.expand", this.ctx.keybindings.getKeys("app.tools.expand")); this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion(); - this.ctx.editor.setActionKeys("dequeue", this.ctx.keybindings.getKeys("dequeue")); + this.ctx.editor.setActionKeys("app.message.dequeue", this.ctx.keybindings.getKeys("app.message.dequeue")); this.ctx.editor.onDequeue = () => this.handleDequeue(); this.ctx.editor.clearCustomKeyHandlers(); // Wire up extension shortcuts this.registerExtensionShortcuts(); - const planModeKeys = this.ctx.keybindings.getKeys("togglePlanMode"); + const planModeKeys = this.ctx.keybindings.getKeys("app.plan.toggle"); for (const key of planModeKeys) { this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand()); } - for (const key of this.ctx.keybindings.getKeys("newSession")) { + for (const key of this.ctx.keybindings.getKeys("app.session.new")) { this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.handleClearCommand()); } - for (const key of this.ctx.keybindings.getKeys("tree")) { + for (const key of this.ctx.keybindings.getKeys("app.session.tree")) { this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showTreeSelector()); } - for (const key of this.ctx.keybindings.getKeys("fork")) { + for (const key of this.ctx.keybindings.getKeys("app.session.fork")) { this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showUserMessageSelector()); } - for (const key of this.ctx.keybindings.getKeys("resume")) { + for (const key of this.ctx.keybindings.getKeys("app.session.resume")) { this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionSelector()); } - for (const key of this.ctx.keybindings.getKeys("followUp")) { + for (const key of this.ctx.keybindings.getKeys("app.message.followUp")) { this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp()); } - for (const key of this.ctx.keybindings.getKeys("toggleSTT")) { + for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) { this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle()); } - for (const key of this.ctx.keybindings.getKeys("copyLine")) { + for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) { this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine()); } diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index ab0c53494..841a2654a 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -273,7 +273,7 @@ export class InteractiveMode implements InteractiveModeContext { async init(): Promise { if (this.isInitialized) return; - this.keybindings = await logger.timeAsync("InteractiveMode.init:keybindings", () => KeybindingsManager.create()); + this.keybindings = logger.time("InteractiveMode.init:keybindings", () => KeybindingsManager.create()); // Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP) this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush()); diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 5340ccead..a588713b6 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -146,7 +146,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti }); // Send initial message with attachments - if (initialMessage) { + if (initialMessage !== undefined) { await session.prompt(initialMessage, { images: initialImages }); } diff --git a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts index d991b8b03..f5d20b7df 100644 --- a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts +++ b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts @@ -2,7 +2,7 @@ import { type AutocompleteItem, type AutocompleteProvider, CombinedAutocompleteProvider, - getEditorKeybindings, + getKeybindings, type SlashCommand, } from "@oh-my-pi/pi-tui"; import { formatKeyHints, type KeybindingsManager } from "../config/keybindings"; @@ -174,26 +174,26 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider { export function createPromptActionAutocompleteProvider( options: PromptActionAutocompleteOptions, ): PromptActionAutocompleteProvider { - const editorKeybindings = getEditorKeybindings(); + const editorKeybindings = getKeybindings(); const actions: PromptActionDefinition[] = [ { id: "copy-line", label: "Copy current line", - description: formatKeyHints(options.keybindings.getKeys("copyLine")), + description: formatKeyHints(options.keybindings.getKeys("app.clipboard.copyLine")), keywords: ["copy", "line", "clipboard", "current"], execute: options.copyCurrentLine, }, { id: "copy-prompt", label: "Copy whole prompt", - description: formatKeyHints(options.keybindings.getKeys("copyPrompt")), + description: formatKeyHints(options.keybindings.getKeys("app.clipboard.copyPrompt")), keywords: ["copy", "prompt", "clipboard", "message"], execute: options.copyPrompt, }, { id: "undo", label: "Undo", - description: formatKeyHints(editorKeybindings.getKeys("undo")), + description: formatKeyHints(editorKeybindings.getKeys("tui.editor.undo")), keywords: ["undo", "revert", "edit", "history"], execute: options.undo, }, @@ -214,14 +214,14 @@ export function createPromptActionAutocompleteProvider( { id: "cursor-line-start", label: "Move cursor to beginning of line", - description: formatKeyHints(editorKeybindings.getKeys("cursorLineStart")), + description: formatKeyHints(editorKeybindings.getKeys("tui.editor.cursorLineStart")), keywords: ["move", "cursor", "line", "start", "beginning", "home"], execute: options.moveCursorToLineStart, }, { id: "cursor-line-end", label: "Move cursor to end of line", - description: formatKeyHints(editorKeybindings.getKeys("cursorLineEnd")), + description: formatKeyHints(editorKeybindings.getKeys("tui.editor.cursorLineEnd")), keywords: ["move", "cursor", "line", "end"], execute: options.moveCursorToLineEnd, }, diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 9f80dce91..eef4aa9f3 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -1679,6 +1679,7 @@ export function getCurrentThemeName(): string | undefined { var currentSymbolPresetOverride: SymbolPreset | undefined; var currentColorBlindMode: boolean = false; var themeWatcher: fs.FSWatcher | undefined; +var themeReloadTimer: NodeJS.Timeout | undefined; var sigwinchHandler: (() => void) | undefined; var autoDetectedTheme: boolean = false; var autoDarkTheme: string = "dark"; @@ -1888,11 +1889,7 @@ export function isValidSymbolPreset(preset: string): preset is SymbolPreset { } async function startThemeWatcher(): Promise { - // Stop existing watcher if any - if (themeWatcher) { - themeWatcher.close(); - themeWatcher = undefined; - } + stopThemeWatcher(); // Only watch if it's a custom theme (not built-in) if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") { @@ -1900,54 +1897,62 @@ async function startThemeWatcher(): Promise { } const customThemesDir = getCustomThemesDir(); - const themeFile = path.join(customThemesDir, `${currentThemeName}.json`); + const watchedThemeName = currentThemeName; + const watchedFileName = `${watchedThemeName}.json`; + const themeFile = path.join(customThemesDir, watchedFileName); // Only watch if the file exists if (!fs.existsSync(themeFile)) { return; } - try { - themeWatcher = fs.watch(themeFile, eventType => { - if (eventType === "change") { - // Debounce rapid changes - setTimeout(() => { - loadTheme(currentThemeName!, getCurrentThemeOptions()) - .then(loadedTheme => { - theme = loadedTheme; - if (onThemeChangeCallback) { - onThemeChangeCallback(); - } - }) - .catch(err => { - logger.debug("Theme reload error during file change", { error: String(err) }); - }); - }, 100); - } else if (eventType === "rename") { - // File was deleted or renamed - fall back to default theme - setTimeout(() => { - if (!fs.existsSync(themeFile)) { - currentThemeName = "dark"; - loadTheme("dark", getCurrentThemeOptions()) - .then(loadedTheme => { - theme = loadedTheme; - if (onThemeChangeCallback) { - onThemeChangeCallback(); - } - }) - .catch(err => { - logger.debug("Theme reload error during rename fallback", { error: String(err) }); - }); - if (themeWatcher) { - themeWatcher.close(); - themeWatcher = undefined; - } - } - }, 100); + const scheduleReload = () => { + if (themeReloadTimer) { + clearTimeout(themeReloadTimer); + } + themeReloadTimer = setTimeout(() => { + themeReloadTimer = undefined; + + // Ignore stale timers after switching themes or stopping the watcher + if (currentThemeName !== watchedThemeName) { + return; } + + // Keep the last successfully loaded theme active if the file is temporarily missing + if (!fs.existsSync(themeFile)) { + return; + } + + loadTheme(watchedThemeName, getCurrentThemeOptions()) + .then(loadedTheme => { + theme = loadedTheme; + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } + }) + .catch(() => { + // Ignore errors (file might be in invalid state while being edited) + }); + }, 100); + }; + + try { + themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => { + if (currentThemeName !== watchedThemeName) { + return; + } + if (!filename) { + scheduleReload(); + return; + } + const changedFile = String(filename); + if (changedFile !== watchedFileName) { + return; + } + scheduleReload(); }); - } catch (err) { - logger.debug("Failed to start theme watcher", { error: String(err) }); + } catch { + // Ignore errors starting watcher } } @@ -2023,6 +2028,10 @@ function stopSigwinchListener(): void { } export function stopThemeWatcher(): void { + if (themeReloadTimer) { + clearTimeout(themeReloadTimer); + themeReloadTimer = undefined; + } if (themeWatcher) { themeWatcher.close(); themeWatcher = undefined; diff --git a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts index f8415d443..3f1b75f49 100644 --- a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts +++ b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts @@ -1,10 +1,10 @@ -import type { AppAction, KeybindingsManager } from "../../config/keybindings"; +import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings"; export interface HotkeysMarkdownBindings { keybindings: Pick; } -function appKey(bindings: HotkeysMarkdownBindings, action: AppAction): string { +function appKey(bindings: HotkeysMarkdownBindings, action: AppKeybinding): string { return bindings.keybindings.getDisplayString(action) || "Disabled"; } @@ -26,29 +26,29 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string "| `Ctrl+W` / `Option+Backspace` | Delete word backwards |", "| `Ctrl+U` | Delete to start of line |", "| `Ctrl+K` | Delete to end of line |", - `| \`${appKey(bindings, "copyLine")}\` | Copy current line |`, - `| \`${appKey(bindings, "copyPrompt")}\` | Copy whole prompt |`, + `| \`${appKey(bindings, "app.clipboard.copyLine")}\` | Copy current line |`, + `| \`${appKey(bindings, "app.clipboard.copyPrompt")}\` | Copy whole prompt |`, "", "**Other**", "| Key | Action |", "|-----|--------|", "| `Tab` | Path completion / accept autocomplete |", - `| \`${appKey(bindings, "interrupt")}\` | Cancel autocomplete / interrupt active work |`, - `| \`${appKey(bindings, "clear")}\` | Clear editor (first) / exit (second) |`, - `| \`${appKey(bindings, "exit")}\` | Exit (when editor is empty) |`, - `| \`${appKey(bindings, "suspend")}\` | Suspend to background |`, - `| \`${appKey(bindings, "cycleThinkingLevel")}\` | Cycle thinking level |`, - `| \`${appKey(bindings, "cycleModelForward")}\` | Cycle role models (slow/default/smol) |`, - `| \`${appKey(bindings, "cycleModelBackward")}\` | Cycle role models (temporary) |`, + `| \`${appKey(bindings, "app.interrupt")}\` | Cancel autocomplete / interrupt active work |`, + `| \`${appKey(bindings, "app.clear")}\` | Clear editor (first) / exit (second) |`, + `| \`${appKey(bindings, "app.exit")}\` | Exit (when editor is empty) |`, + `| \`${appKey(bindings, "app.suspend")}\` | Suspend to background |`, + `| \`${appKey(bindings, "app.thinking.cycle")}\` | Cycle thinking level |`, + `| \`${appKey(bindings, "app.model.cycleForward")}\` | Cycle role models (slow/default/smol) |`, + `| \`${appKey(bindings, "app.model.cycleBackward")}\` | Cycle role models (temporary) |`, "| `Alt+P` | Select model (temporary) |", - `| \`${appKey(bindings, "selectModel")}\` | Select model (set roles) |`, - `| \`${appKey(bindings, "togglePlanMode")}\` | Toggle plan mode |`, - `| \`${appKey(bindings, "historySearch")}\` | Search prompt history |`, - `| \`${appKey(bindings, "expandTools")}\` | Toggle tool output expansion |`, - `| \`${appKey(bindings, "toggleThinking")}\` | Toggle thinking block visibility |`, - `| \`${appKey(bindings, "externalEditor")}\` | Edit message in external editor |`, - `| \`${appKey(bindings, "pasteImage")}\` | Paste image from clipboard |`, - `| \`${appKey(bindings, "toggleSTT")}\` | Toggle speech-to-text recording |`, + `| \`${appKey(bindings, "app.model.select")}\` | Select model (set roles) |`, + `| \`${appKey(bindings, "app.plan.toggle")}\` | Toggle plan mode |`, + `| \`${appKey(bindings, "app.history.search")}\` | Search prompt history |`, + `| \`${appKey(bindings, "app.tools.expand")}\` | Toggle tool output expansion |`, + `| \`${appKey(bindings, "app.thinking.toggle")}\` | Toggle thinking block visibility |`, + `| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`, + `| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image from clipboard |`, + `| \`${appKey(bindings, "app.stt.toggle")}\` | Toggle speech-to-text recording |`, "| `#` | Open prompt actions |", "| `/` | Slash commands |", "| `!` | Run bash command |", diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index f3dd0642e..3632053bb 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -474,7 +474,7 @@ export class UiHelpers { const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`); this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0)); } - const dequeueKey = this.ctx.keybindings.getDisplayString("dequeue") || "Alt+Up"; + const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up"; const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`); this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0)); } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 6642d720a..108c777c7 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -782,7 +782,6 @@ export class AgentSession { attempt: this.#retryAttempt, }); this.#retryAttempt = 0; - this.#resolveRetry(); } } @@ -858,6 +857,7 @@ export class AgentSession { const didRetry = await this.#handleRetryableError(msg); if (didRetry) return; // Retry was initiated, don't proceed to compaction } + this.#resolveRetry(); if (msg.stopReason === "aborted" && this.#checkpointState) { this.#checkpointState = undefined; @@ -4495,7 +4495,9 @@ export class AgentSession { } #isTransientErrorMessage(errorMessage: string): boolean { - return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|retry delay|stream stall/i.test( + // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, + // service unavailable, network/connection errors, fetch failed, terminated, retry delay exceeded + return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall/i.test( errorMessage, ); } diff --git a/packages/coding-agent/src/utils/child-process.ts b/packages/coding-agent/src/utils/child-process.ts new file mode 100644 index 000000000..8610ab171 --- /dev/null +++ b/packages/coding-agent/src/utils/child-process.ts @@ -0,0 +1,88 @@ +import type { ChildProcess } from "node:child_process"; + +const EXIT_STDIO_GRACE_MS = 100; + +/** + * Wait for a child process to terminate without hanging on inherited stdio handles. + * + * Daemonized descendants can inherit the child's stdout/stderr pipe handles. In that + * case the child emits `exit`, but `close` can hang forever even though the original + * process is already gone. We wait briefly for stdio to end, then forcibly stop + * tracking the inherited handles. + */ +export function waitForChildProcess(child: ChildProcess): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + + let settled = false; + let exited = false; + let exitCode: number | null = null; + let postExitTimer: NodeJS.Timeout | undefined; + let stdoutEnded = child.stdout === null; + let stderrEnded = child.stderr === null; + + const cleanup = () => { + if (postExitTimer) { + clearTimeout(postExitTimer); + postExitTimer = undefined; + } + child.removeListener("error", onError); + child.removeListener("exit", onExit); + child.removeListener("close", onClose); + child.stdout?.removeListener("end", onStdoutEnd); + child.stderr?.removeListener("end", onStderrEnd); + }; + + const finalize = (code: number | null) => { + if (settled) return; + settled = true; + cleanup(); + child.stdout?.destroy(); + child.stderr?.destroy(); + resolve(code); + }; + + const maybeFinalizeAfterExit = () => { + if (!exited || settled) return; + if (stdoutEnded && stderrEnded) { + finalize(exitCode); + } + }; + + const onStdoutEnd = () => { + stdoutEnded = true; + maybeFinalizeAfterExit(); + }; + + const onStderrEnd = () => { + stderrEnded = true; + maybeFinalizeAfterExit(); + }; + + const onError = (err: Error) => { + if (settled) return; + settled = true; + cleanup(); + reject(err); + }; + + const onExit = (code: number | null) => { + exited = true; + exitCode = code; + maybeFinalizeAfterExit(); + if (!settled) { + postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS); + } + }; + + const onClose = (code: number | null) => { + finalize(code); + }; + + child.stdout?.once("end", onStdoutEnd); + child.stderr?.once("end", onStderrEnd); + child.once("error", onError); + child.once("exit", onExit); + child.once("close", onClose); + + return promise; +} diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 8f439396a..0ebea8143 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -86,6 +86,13 @@ describe("parseArgs", () => { }); }); + describe("--fork flag", () => { + test("parses --fork with session ID", () => { + const result = parseArgs(["--fork", "abc123"]); + expect(result.fork).toBe("abc123"); + }); + }); + describe("flags with values", () => { test("parses --provider", () => { const result = parseArgs(["--provider", "openai"]); diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 9c961c13b..1e6e61187 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -246,7 +246,7 @@ describe("validateLineRef", () => { describe("applyHashlineEdits — replace", () => { it("replaces single line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nBBB\nccc"); @@ -255,7 +255,9 @@ describe("applyHashlineEdits — replace", () => { it("range replace (shrink)", () => { const content = "aaa\nbbb\nccc\nddd"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["ONE"] }]; + const edits: HashlineEdit[] = [ + { op: "replace_range", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["ONE"] }, + ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nONE\nddd"); @@ -264,7 +266,7 @@ describe("applyHashlineEdits — replace", () => { it("range replace (same count)", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ - { op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["XXX", "YYY"] }, + { op: "replace_range", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["XXX", "YYY"] }, ]; const result = applyHashlineEdits(content, edits); @@ -274,7 +276,7 @@ describe("applyHashlineEdits — replace", () => { it("replaces first line", () => { const content = "first\nsecond\nthird"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(1, "first"), lines: ["FIRST"] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(1, "first"), lines: ["FIRST"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("FIRST\nsecond\nthird"); @@ -283,7 +285,7 @@ describe("applyHashlineEdits — replace", () => { it("replaces last line", () => { const content = "first\nsecond\nthird"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "third"), lines: ["THIRD"] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(3, "third"), lines: ["THIRD"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("first\nsecond\nTHIRD"); @@ -298,7 +300,7 @@ describe("applyHashlineEdits — replace", () => { describe("applyHashlineEdits — delete", () => { it("deletes single line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: [] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: [] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nccc"); @@ -307,7 +309,9 @@ describe("applyHashlineEdits — delete", () => { it("deletes range of lines", () => { const content = "aaa\nbbb\nccc\nddd"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: [] }]; + const edits: HashlineEdit[] = [ + { op: "replace_range", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: [] }, + ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nddd"); @@ -315,7 +319,7 @@ describe("applyHashlineEdits — delete", () => { it("deletes first line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(1, "aaa"), lines: [] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(1, "aaa"), lines: [] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("bbb\nccc"); @@ -323,7 +327,7 @@ describe("applyHashlineEdits — delete", () => { it("deletes last line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "ccc"), lines: [] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(3, "ccc"), lines: [] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nbbb"); @@ -331,7 +335,7 @@ describe("applyHashlineEdits — delete", () => { it("replaces line with blank line when lines is ['']", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: [""] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: [""] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\n\nccc"); @@ -380,7 +384,7 @@ describe("applyHashlineEdits — append", () => { it("inserts at EOF without anchors", () => { const content = "aaa\nbbb"; - const edits = [{ op: "append", lines: ["NEW"] }] as unknown as HashlineEdit[]; + const edits: HashlineEdit[] = [{ op: "append_eof", lines: ["NEW"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nbbb\nNEW"); @@ -389,7 +393,7 @@ describe("applyHashlineEdits — append", () => { it("inserts at EOF into empty file without anchors", () => { const content = ""; - const edits = [{ op: "append", lines: ["NEW"] }] as unknown as HashlineEdit[]; + const edits: HashlineEdit[] = [{ op: "append_eof", lines: ["NEW"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("NEW"); @@ -398,7 +402,7 @@ describe("applyHashlineEdits — append", () => { it("insert at EOF with empty dst inserts a trailing empty line", () => { const content = "aaa\nbbb"; - const edits = [{ op: "append", lines: [] }] as unknown as HashlineEdit[]; + const edits: HashlineEdit[] = [{ op: "append_eof", lines: [] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nbbb\n"); @@ -435,7 +439,7 @@ describe("applyHashlineEdits — prepend", () => { it("prepends at BOF without anchor", () => { const content = "aaa\nbbb"; - const edits = [{ op: "prepend", lines: ["NEW"] }] as unknown as HashlineEdit[]; + const edits: HashlineEdit[] = [{ op: "prepend_bof", lines: ["NEW"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("NEW\naaa\nbbb"); expect(result.firstChangedLine).toBe(1); @@ -463,7 +467,7 @@ describe("applyHashlineEdits — prepend", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [ { op: "prepend", pos: makeTag(2, "bbb"), lines: ["BEFORE"] }, - { op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] }, + { op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nBEFORE\nBBB\nccc"); @@ -482,7 +486,7 @@ describe("applyHashlineEdits — heuristics", () => { const srcHash = computeLineHash(2, "bbb"); const edits: HashlineEdit[] = [ { - op: "replace", + op: "replace_line", pos: parseTag(`2#${srcHash}export function foo(a, b) {}`), // comma in trailing content lines: ["BBB"], }, @@ -496,7 +500,7 @@ describe("applyHashlineEdits — heuristics", () => { const content = ["import { foo } from 'x';", "import { bar } from 'y';", "const x = 1;"].join("\n"); const edits: HashlineEdit[] = [ { - op: "replace", + op: "replace_range", pos: makeTag(1, "import { foo } from 'x';"), end: makeTag(2, "import { bar } from 'y';"), lines: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"], @@ -514,7 +518,7 @@ describe("applyHashlineEdits — heuristics", () => { it("treats same-line ranges as single-line replacements", () => { const content = "aaa\nbbb\nccc"; const good = makeTag(2, "bbb"); - const edits: HashlineEdit[] = [{ op: "replace", pos: good, end: good, lines: ["BBB"] }]; + const edits: HashlineEdit[] = [{ op: "replace_range", pos: good, end: good, lines: ["BBB"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nBBB\nccc"); }); @@ -523,7 +527,7 @@ describe("applyHashlineEdits — heuristics", () => { const content = "if (ok) {\n run();\n}\nafter();"; const edits: HashlineEdit[] = [ { - op: "replace", + op: "replace_range", pos: makeTag(1, "if (ok) {"), end: makeTag(2, " run();"), lines: ["if (ok) {", " runSafe();", "}"], @@ -538,7 +542,7 @@ describe("applyHashlineEdits — heuristics", () => { const content = "start\n oldCall();\nnextCall();\nafter();"; const edits: HashlineEdit[] = [ { - op: "replace", + op: "replace_range", pos: makeTag(1, "start"), end: makeTag(2, " oldCall();"), lines: ["start", " newCall();", "nextCall();"], @@ -553,7 +557,7 @@ describe("applyHashlineEdits — heuristics", () => { const content = "if (x) {\n oldBody();\n}\nafter();"; const edits: HashlineEdit[] = [ { - op: "replace", + op: "replace_range", pos: makeTag(2, " oldBody();"), end: makeTag(3, "}"), lines: ["if (x) {", " newBody();", "}"], @@ -569,7 +573,9 @@ describe("applyHashlineEdits — heuristics", () => { delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; try { const content = "root\n\tchild\n\t\tvalue\nend"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }]; + const edits: HashlineEdit[] = [ + { op: "replace_line", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }, + ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("root\n\tchild\n\t\treplaced\nend"); expect(result.warnings).toHaveLength(1); @@ -585,7 +591,9 @@ describe("applyHashlineEdits — heuristics", () => { Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = "0"; try { const content = "root\n\tchild\n\t\tvalue\nend"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }]; + const edits: HashlineEdit[] = [ + { op: "replace_line", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }, + ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("root\n\tchild\n\\t\\treplaced\nend"); expect(result.warnings).toBeUndefined(); @@ -602,7 +610,7 @@ describe("applyHashlineEdits — heuristics", () => { const content = "root\n\tchild\n\t\tvalue\nend"; const edits: HashlineEdit[] = [ { - op: "replace", + op: "replace_line", pos: makeTag(3, "\t\tvalue"), lines: ["\t\talready-tab", "\\t\\tescaped-still-literal"], }, @@ -618,7 +626,7 @@ describe("applyHashlineEdits — heuristics", () => { it("warns on literal \\uDDDD without changing content", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: ["\\uDDDD"] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["\\uDDDD"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\n\\uDDDD\nccc"); expect(result.warnings).toHaveLength(1); @@ -634,8 +642,8 @@ describe("applyHashlineEdits — multiple edits", () => { it("applies two non-overlapping replaces (bottom-up safe)", () => { const content = "aaa\nbbb\nccc\nddd\neee"; const edits: HashlineEdit[] = [ - { op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] }, - { op: "replace", pos: makeTag(4, "ddd"), lines: ["DDD"] }, + { op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] }, + { op: "replace_line", pos: makeTag(4, "ddd"), lines: ["DDD"] }, ]; const result = applyHashlineEdits(content, edits); @@ -646,8 +654,8 @@ describe("applyHashlineEdits — multiple edits", () => { it("applies replace + delete in one call", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ - { op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] }, - { op: "replace", pos: makeTag(4, "ddd"), lines: [] }, + { op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] }, + { op: "replace_line", pos: makeTag(4, "ddd"), lines: [] }, ]; const result = applyHashlineEdits(content, edits); @@ -657,7 +665,7 @@ describe("applyHashlineEdits — multiple edits", () => { it("applies replace + append in one call", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [ - { op: "replace", pos: makeTag(3, "ccc"), lines: ["CCC"] }, + { op: "replace_line", pos: makeTag(3, "ccc"), lines: ["CCC"] }, { op: "append", pos: makeTag(1, "aaa"), lines: ["INSERTED"] }, ]; @@ -669,12 +677,12 @@ describe("applyHashlineEdits — multiple edits", () => { const content = "one\ntwo\nthree\nfour\nfive\nsix"; const edits: HashlineEdit[] = [ { - op: "replace", + op: "replace_range", pos: makeTag(2, "two"), end: makeTag(3, "three"), lines: ["TWO_THREE"], }, - { op: "replace", pos: makeTag(6, "six"), lines: ["SIX"] }, + { op: "replace_line", pos: makeTag(6, "six"), lines: ["SIX"] }, ]; const result = applyHashlineEdits(content, edits); @@ -684,7 +692,9 @@ describe("applyHashlineEdits — multiple edits", () => { it("single-line replace expanding to multiple lines is not a noop", () => { const content = "aaa\n\nccc"; const blankHash = computeLineHash(2, ""); - const edits: HashlineEdit[] = [{ op: "replace", pos: { line: 2, hash: blankHash }, lines: ["", "inserted", ""] }]; + const edits: HashlineEdit[] = [ + { op: "replace_line", pos: { line: 2, hash: blankHash }, lines: ["", "inserted", ""] }, + ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\n\ninserted\n\nccc"); expect(result.firstChangedLine).toBe(2); @@ -706,13 +716,13 @@ describe("applyHashlineEdits — errors", () => { it("rejects stale hash", () => { const content = "aaa\nbbb\nccc"; // Use a hash that doesn't match any line (avoid 00 — ccc hashes to 00) - const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("2#QQ"), lines: ["BBB"] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("2#QQ"), lines: ["BBB"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError); }); it("stale hash error shows >>> markers with correct hashes", () => { const content = "aaa\nbbb\nccc\nddd\neee"; - const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("2#QQ"), lines: ["BBB"] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("2#QQ"), lines: ["BBB"] }]; try { applyHashlineEdits(content, edits); @@ -736,8 +746,8 @@ describe("applyHashlineEdits — errors", () => { const content = "aaa\nbbb\nccc\nddd\neee"; // Use hashes that don't match any line (avoid 00 — ccc hashes to 00) const edits: HashlineEdit[] = [ - { op: "replace", pos: parseTag("2#ZZ"), lines: ["BBB"] }, - { op: "replace", pos: parseTag("4#ZZ"), lines: ["DDD"] }, + { op: "replace_line", pos: parseTag("2#ZZ"), lines: ["BBB"] }, + { op: "replace_line", pos: parseTag("4#ZZ"), lines: ["DDD"] }, ]; try { @@ -758,7 +768,7 @@ describe("applyHashlineEdits — errors", () => { it("does not relocate stale line refs even when hash uniquely matches another line", () => { const content = "aaa\nbbb\nccc"; const staleButUnique = parseTag(`2#${computeLineHash(1, "ccc")}`); - const edits: HashlineEdit[] = [{ op: "replace", pos: staleButUnique, lines: ["CCC"] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: staleButUnique, lines: ["CCC"] }]; try { applyHashlineEdits(content, edits); expect.unreachable("should have thrown"); @@ -772,21 +782,23 @@ describe("applyHashlineEdits — errors", () => { it("does not relocate when expected hash is non-unique", () => { const content = "dup\nmid\ndup"; const staleDuplicate = parseTag(`2#${computeLineHash(1, "dup")}`); - const edits: HashlineEdit[] = [{ op: "replace", pos: staleDuplicate, lines: ["DUP"] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: staleDuplicate, lines: ["DUP"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError); }); it("rejects out-of-range line", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("10#ZZ"), lines: ["X"] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("10#ZZ"), lines: ["X"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/); }); it("rejects range with start > end", () => { const content = "aaa\nbbb\nccc\nddd\neee"; - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(5, "eee"), end: makeTag(2, "bbb"), lines: ["X"] }]; + const edits: HashlineEdit[] = [ + { op: "replace_range", pos: makeTag(5, "eee"), end: makeTag(2, "bbb"), lines: ["X"] }, + ]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); @@ -977,7 +989,7 @@ describe("hashlineParseContent", () => { const fileContent = "# Title\n- old item\n- old item 2\nfooter"; const edits: HashlineEdit[] = [ { - op: "replace", + op: "replace_line", pos: makeTag(2, "- old item"), lines: hashlineParseText("- [x] new item"), }, @@ -990,7 +1002,7 @@ describe("hashlineParseContent", () => { // All replacement lines start with '- ', triggering the 50% heuristic when '-' matched. const fileContent = "- [x] done\n- [ ] pending\n- [ ] also pending"; const newContent = hashlineParseText("- [x] done"); - const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "- [ ] pending"), lines: newContent }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "- [ ] pending"), lines: newContent }]; const result = applyHashlineEdits(fileContent, edits); expect(result.lines).toBe("- [x] done\n- [x] done\n- [ ] also pending"); }); @@ -1014,7 +1026,7 @@ describe("hashlineParseContent", () => { const fileContent = [" # cuDNN section", " # Note: Using version 1.23.0", ' $Version = "1.23.0"'].join("\n"); const edits: HashlineEdit[] = [ { - op: "replace", + op: "replace_line", pos: makeTag(2, " # Note: Using version 1.23.0"), lines: hashlineParseText([" # Note: Using version 1.24.x"]), }, @@ -1029,7 +1041,7 @@ describe("hashlineParseContent", () => { const fileContent = "const x = 1;\n// TODO: old\n# TODO: remove this\nconst y = 2;"; const edits: HashlineEdit[] = [ { - op: "replace", + op: "replace_line", pos: makeTag(3, "# TODO: remove this"), lines: hashlineParseText(["# TODO: remove this -- done"]), }, diff --git a/packages/coding-agent/test/initial-message.test.ts b/packages/coding-agent/test/initial-message.test.ts new file mode 100644 index 000000000..96fc9c6e3 --- /dev/null +++ b/packages/coding-agent/test/initial-message.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; +import type { ImageContent } from "@oh-my-pi/pi-ai"; +import type { Args } from "../src/cli/args"; +import { buildInitialMessage } from "../src/cli/initial-message"; + +function createArgs(messages: string[]): Args { + return { + messages, + fileArgs: [], + unknownFlags: new Map(), + }; +} + +describe("buildInitialMessage", () => { + it("combines stdin, file text, and the first CLI message", () => { + const parsed = createArgs(["first", "second"]); + const images: ImageContent[] = [{ type: "image", data: "abc123", mimeType: "image/png" }]; + + const result = buildInitialMessage({ + parsed, + stdinContent: "stdin", + fileText: "file-", + fileImages: images, + }); + + expect(result.initialMessage).toBe("stdin\nfile-first"); + expect(result.initialImages).toEqual(images); + expect(parsed.messages).toEqual(["second"]); + }); + + it("leaves plain CLI messages untouched when there is no initial file or stdin input", () => { + const parsed = createArgs(["first", "second"]); + + const result = buildInitialMessage({ parsed }); + + expect(result.initialMessage).toBeUndefined(); + expect(result.initialImages).toBeUndefined(); + expect(parsed.messages).toEqual(["first", "second"]); + }); +}); diff --git a/packages/coding-agent/test/keybindings-display.test.ts b/packages/coding-agent/test/keybindings-display.test.ts index 8b1319271..1a0e37bd4 100644 --- a/packages/coding-agent/test/keybindings-display.test.ts +++ b/packages/coding-agent/test/keybindings-display.test.ts @@ -4,25 +4,25 @@ import { KeybindingsManager } from "../src/config/keybindings"; describe("KeybindingsManager.getDisplayString", () => { it("formats a single binding as a human-readable key hint", () => { const keybindings = KeybindingsManager.inMemory({ - dequeue: "alt+up", + "app.message.dequeue": "alt+up", }); - expect(keybindings.getDisplayString("dequeue")).toBe("Alt+Up"); + expect(keybindings.getDisplayString("app.message.dequeue")).toBe("Alt+Up"); }); it("formats multiple bindings with the existing separator", () => { const keybindings = KeybindingsManager.inMemory({ - copyPrompt: ["alt+shift+c", "ctrl+shift+c"], + "app.clipboard.copyPrompt": ["alt+shift+c", "ctrl+shift+c"], }); - expect(keybindings.getDisplayString("copyPrompt")).toBe("Alt+Shift+C/Ctrl+Shift+C"); + expect(keybindings.getDisplayString("app.clipboard.copyPrompt")).toBe("Alt+Shift+C/Ctrl+Shift+C"); }); it("returns an empty string when the action has no binding", () => { const keybindings = KeybindingsManager.inMemory({ - copyPrompt: [], + "app.clipboard.copyPrompt": [], }); - expect(keybindings.getDisplayString("copyPrompt")).toBe(""); + expect(keybindings.getDisplayString("app.clipboard.copyPrompt")).toBe(""); }); }); diff --git a/packages/coding-agent/test/keybindings-migration.test.ts b/packages/coding-agent/test/keybindings-migration.test.ts new file mode 100644 index 000000000..4a83ba06b --- /dev/null +++ b/packages/coding-agent/test/keybindings-migration.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { setKeybindings } from "@oh-my-pi/pi-tui"; +import { KeybindingsManager } from "../src/config/keybindings"; + +describe("KeybindingsManager.create", () => { + beforeEach(() => { + setKeybindings(KeybindingsManager.inMemory()); + }); + + afterEach(() => { + setKeybindings(KeybindingsManager.inMemory()); + }); + + it("migrates legacy keybinding names on disk during create", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-keybindings-")); + const configPath = path.join(agentDir, "keybindings.json"); + + await Bun.write( + configPath, + `${JSON.stringify( + { + fork: "ctrl+f", + selectConfirm: "enter", + cursorUp: "ctrl+p", + }, + null, + 2, + )}\n`, + ); + + try { + const manager = KeybindingsManager.create(agentDir); + const writtenConfig = await Bun.file(configPath).json(); + + expect(manager.getKeys("app.session.fork")).toEqual(["ctrl+f"]); + expect(manager.getKeys("tui.select.confirm")).toEqual(["enter"]); + expect(manager.getKeys("tui.editor.cursorUp")).toEqual(["ctrl+p"]); + expect(writtenConfig).toEqual({ + "app.session.fork": "ctrl+f", + "tui.editor.cursorUp": "ctrl+p", + "tui.select.confirm": "enter", + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts b/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts index 0a74b0d22..cecd42a47 100644 --- a/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts +++ b/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts @@ -4,23 +4,23 @@ import { buildHotkeysMarkdown } from "../../../src/modes/utils/hotkeys-markdown" describe("buildHotkeysMarkdown", () => { it("emits flush-left markdown so headings and tables are parsed instead of treated as indented text", () => { const displayStrings: Record = { - copyLine: "Alt+Shift+L", - copyPrompt: "Ctrl+Shift+P", - togglePlanMode: "Alt+M", - expandTools: "Ctrl+O", - interrupt: "Esc", - clear: "Ctrl+C", - exit: "Ctrl+D", - suspend: "Ctrl+Z", - cycleThinkingLevel: "Shift+Tab", - cycleModelForward: "Ctrl+P", - cycleModelBackward: "Shift+Ctrl+P", - selectModel: "Ctrl+L", - historySearch: "Ctrl+R", - toggleThinking: "Ctrl+T", - externalEditor: "Ctrl+G", - pasteImage: "Ctrl+V", - toggleSTT: "Alt+H", + "app.clipboard.copyLine": "Alt+Shift+L", + "app.clipboard.copyPrompt": "Ctrl+Shift+P", + "app.plan.toggle": "Alt+M", + "app.tools.expand": "Ctrl+O", + "app.interrupt": "Esc", + "app.clear": "Ctrl+C", + "app.exit": "Ctrl+D", + "app.suspend": "Ctrl+Z", + "app.thinking.cycle": "Shift+Tab", + "app.model.cycleForward": "Ctrl+P", + "app.model.cycleBackward": "Shift+Ctrl+P", + "app.model.select": "Ctrl+L", + "app.history.search": "Ctrl+R", + "app.thinking.toggle": "Ctrl+T", + "app.editor.external": "Ctrl+G", + "app.clipboard.pasteImage": "Ctrl+V", + "app.stt.toggle": "Alt+H", }; const markdown = buildHotkeysMarkdown({ keybindings: { diff --git a/packages/coding-agent/test/prompt-action-autocomplete.test.ts b/packages/coding-agent/test/prompt-action-autocomplete.test.ts index 8dda96782..8a075b8d5 100644 --- a/packages/coding-agent/test/prompt-action-autocomplete.test.ts +++ b/packages/coding-agent/test/prompt-action-autocomplete.test.ts @@ -1,30 +1,30 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { EditorKeybindingsManager, setEditorKeybindings } from "../../tui/src/keybindings"; -import { KeybindingsManager } from "../src/config/keybindings"; +import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui"; +import { KeybindingsManager as AppKeybindingsManager } from "../src/config/keybindings"; import { createPromptActionAutocompleteProvider } from "../src/modes/prompt-action-autocomplete"; describe("prompt action autocomplete", () => { beforeEach(() => { - setEditorKeybindings( - new EditorKeybindingsManager({ - cursorLineStart: ["home", "f6"], - cursorLineEnd: "f7", - undo: "f8", + setKeybindings( + new KeybindingsManager({ + "tui.editor.cursorLineStart": { defaultKeys: ["home", "f6"], description: "Move cursor to line start" }, + "tui.editor.cursorLineEnd": { defaultKeys: "f7", description: "Move cursor to line end" }, + "tui.editor.undo": { defaultKeys: "f8", description: "Undo" }, }), ); }); afterEach(() => { - setEditorKeybindings(new EditorKeybindingsManager()); + setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS)); }); it("shows prompt actions with configured shortcut hints", async () => { const provider = createPromptActionAutocompleteProvider({ commands: [], basePath: "/tmp", - keybindings: KeybindingsManager.inMemory({ - copyLine: "ctrl+shift+l", - copyPrompt: ["alt+shift+c", "ctrl+shift+c"], + keybindings: AppKeybindingsManager.inMemory({ + "app.clipboard.copyLine": "ctrl+shift+l", + "app.clipboard.copyPrompt": ["alt+shift+c", "ctrl+shift+c"], }), copyCurrentLine: () => {}, copyPrompt: () => {}, @@ -64,7 +64,7 @@ describe("prompt action autocomplete", () => { const provider = createPromptActionAutocompleteProvider({ commands: [], basePath: "/tmp", - keybindings: KeybindingsManager.inMemory(), + keybindings: AppKeybindingsManager.inMemory(), copyCurrentLine: () => {}, copyPrompt: () => {}, undo: prefix => { @@ -97,7 +97,7 @@ describe("prompt action autocomplete", () => { const provider = createPromptActionAutocompleteProvider({ commands: [], basePath: "/tmp", - keybindings: KeybindingsManager.inMemory(), + keybindings: AppKeybindingsManager.inMemory(), copyCurrentLine: () => {}, copyPrompt: () => {}, undo: () => {}, diff --git a/packages/tui/src/components/cancellable-loader.ts b/packages/tui/src/components/cancellable-loader.ts index 46317ed4c..c82bfd574 100644 --- a/packages/tui/src/components/cancellable-loader.ts +++ b/packages/tui/src/components/cancellable-loader.ts @@ -1,4 +1,4 @@ -import { matchesKey } from "../keys"; +import { getKeybindings } from "../keybindings"; import { Loader } from "./loader"; /** @@ -27,7 +27,8 @@ export class CancellableLoader extends Loader { } handleInput(data: string): void { - if (matchesKey(data, "escape") || matchesKey(data, "esc")) { + const kb = getKeybindings(); + if (kb.matches(data, "tui.select.cancel")) { this.#abortController.abort(); this.onAbort?.(); } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 2840e1b7a..3095d6f3a 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,7 +1,7 @@ import { getProjectDir } from "@oh-my-pi/pi-utils"; import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete"; import { BracketedPasteHandler } from "../bracketed-paste"; -import { type EditorKeybindingsManager, getEditorKeybindings } from "../keybindings"; +import { getKeybindings, type KeybindingsManager } from "../keybindings"; import { extractPrintableText, matchesKey } from "../keys"; import { KillRing } from "../kill-ring"; import type { SymbolTheme } from "../symbols"; @@ -15,7 +15,12 @@ import { truncateToWidth, visibleWidth, } from "../utils"; -import { SelectList, type SelectListTheme } from "./select-list"; +import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list"; + +const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; const segmenter = getSegmenter(); @@ -691,12 +696,12 @@ export class Editor implements Component, Focusable { } handleInput(data: string): void { - const kb = getEditorKeybindings(); + const kb = getKeybindings(); // Handle character jump mode (awaiting next character to jump to) if (this.#jumpMode !== null) { // Cancel if the hotkey is pressed again - if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) { + if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) { this.#jumpMode = null; return; } @@ -728,12 +733,12 @@ export class Editor implements Component, Focusable { // Handle special key combinations first // Ctrl+C - Exit (let parent handle this) - if (matchesKey(data, "ctrl+c")) { + if (kb.matches(data, "tui.input.copy")) { return; } // Undo - if (kb.matches(data, "undo")) { + if (kb.matches(data, "tui.editor.undo")) { this.#applyUndo(); return; } @@ -741,27 +746,26 @@ export class Editor implements Component, Focusable { // Handle autocomplete special keys first (but don't block other input) if (this.#autocompleteState && this.#autocompleteList) { // Escape - cancel autocomplete - if (matchesKey(data, "escape") || matchesKey(data, "esc")) { + if (kb.matches(data, "tui.select.cancel")) { this.#cancelAutocomplete(true); return; } // Let the autocomplete list handle navigation and selection else if ( - matchesKey(data, "up") || - matchesKey(data, "down") || - matchesKey(data, "pageUp") || - matchesKey(data, "pageDown") || - matchesKey(data, "enter") || - matchesKey(data, "return") || + kb.matches(data, "tui.select.up") || + kb.matches(data, "tui.select.down") || + kb.matches(data, "tui.select.pageUp") || + kb.matches(data, "tui.select.pageDown") || + kb.matches(data, "tui.input.submit") || data === "\n" || - matchesKey(data, "tab") + kb.matches(data, "tui.input.tab") ) { // Only pass navigation keys to the list, not Enter/Tab (we handle those directly) if ( - matchesKey(data, "up") || - matchesKey(data, "down") || - matchesKey(data, "pageUp") || - matchesKey(data, "pageDown") + kb.matches(data, "tui.select.up") || + kb.matches(data, "tui.select.down") || + kb.matches(data, "tui.select.pageUp") || + kb.matches(data, "tui.select.pageDown") ) { this.#autocompleteList.handleInput(data); this.onAutocompleteUpdate?.(); @@ -769,7 +773,7 @@ export class Editor implements Component, Focusable { } // If Tab was pressed, always apply the selection - if (matchesKey(data, "tab")) { + if (kb.matches(data, "tui.input.tab")) { const selected = this.#autocompleteList.getSelectedItem(); if (selected && this.#autocompleteProvider) { const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection(); @@ -801,10 +805,7 @@ export class Editor implements Component, Focusable { } // If Enter was pressed on a slash command, apply completion and submit - if ( - (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") && - this.#autocompletePrefix.startsWith("/") - ) { + if ((kb.matches(data, "tui.input.submit") || data === "\n") && this.#autocompletePrefix.startsWith("/")) { // Check for stale autocomplete state due to debounce const currentLine = this.#state.lines[this.#state.cursorLine] ?? ""; const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol); @@ -832,7 +833,7 @@ export class Editor implements Component, Focusable { // Don't return - fall through to submission logic } // If Enter was pressed on a file path, apply completion - else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { + else if (kb.matches(data, "tui.input.submit") || data === "\n") { const selected = this.#autocompleteList.getSelectedItem(); if (selected && this.#autocompleteProvider) { const result = this.#autocompleteProvider.applyCompletion( @@ -863,7 +864,7 @@ export class Editor implements Component, Focusable { } // Tab key - context-aware completion (but not when already autocompleting) - if (matchesKey(data, "tab") && !this.#autocompleteState) { + if (kb.matches(data, "tui.input.tab") && !this.#autocompleteState) { this.#handleTabCompletion(); return; } @@ -920,7 +921,7 @@ export class Editor implements Component, Focusable { data === "\x1b[27;5;13~" || // Ctrl+Enter (legacy format) data === "\x1b\r" || // Option+Enter in some terminals (legacy) data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format) - matchesKey(data, "shift+enter") || // Shift+Enter (Kitty protocol, handles lock bits) + kb.matches(data, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits) (data.length > 1 && data.includes("\x1b") && data.includes("\r")) || (data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping ) { @@ -932,7 +933,7 @@ export class Editor implements Component, Focusable { this.#addNewLine(); } // Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits) - else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { + else if (kb.matches(data, "tui.input.submit") || data === "\n") { // If submit is disabled, do nothing if (this.disableSubmit) { return; @@ -941,17 +942,17 @@ export class Editor implements Component, Focusable { this.#submitValue(); } // Backspace (including Shift+Backspace) - else if (matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) { + else if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) { this.#handleBackspace(); } // Line navigation shortcuts (Home/End keys) - else if (matchesKey(data, "home")) { + else if (kb.matches(data, "tui.editor.cursorLineStart")) { this.#moveToLineStart(); - } else if (matchesKey(data, "end")) { + } else if (kb.matches(data, "tui.editor.cursorLineEnd")) { this.#moveToLineEnd(); } // Page navigation (PageUp/PageDown) - else if (matchesKey(data, "pageUp")) { + else if (kb.matches(data, "tui.editor.pageUp")) { if (this.#isEditorEmpty()) { this.#navigateHistory(-1); } else if (this.#historyIndex > -1 && this.#isOnFirstVisualLine()) { @@ -959,7 +960,7 @@ export class Editor implements Component, Focusable { } else { this.#pageScroll(-1); } - } else if (matchesKey(data, "pageDown")) { + } else if (kb.matches(data, "tui.editor.pageDown")) { if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) { this.#navigateHistory(1); } else { @@ -967,21 +968,21 @@ export class Editor implements Component, Focusable { } } // Forward delete (Fn+Backspace or Delete key, including Shift+Delete) - else if (matchesKey(data, "delete") || matchesKey(data, "shift+delete")) { + else if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) { this.#handleForwardDelete(); } // Word navigation (Option/Alt + Arrow or Ctrl + Arrow) - else if (matchesKey(data, "alt+left") || matchesKey(data, "ctrl+left")) { + else if (kb.matches(data, "tui.editor.cursorWordLeft")) { // Word left this.#resetKillSequence(); this.#moveWordBackwards(); - } else if (matchesKey(data, "alt+right") || matchesKey(data, "ctrl+right")) { + } else if (kb.matches(data, "tui.editor.cursorWordRight")) { // Word right this.#resetKillSequence(); this.#moveWordForwards(); } // Arrow keys - else if (matchesKey(data, "up")) { + else if (kb.matches(data, "tui.editor.cursorUp")) { // Up - history navigation or cursor movement if (this.#isEditorEmpty()) { this.#navigateHistory(-1); // Start browsing history @@ -993,7 +994,7 @@ export class Editor implements Component, Focusable { } else { this.#moveCursor(-1, 0); // Cursor movement (within text or history entry) } - } else if (matchesKey(data, "down")) { + } else if (kb.matches(data, "tui.editor.cursorDown")) { // Down - history navigation or cursor movement if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) { this.#navigateHistory(1); // Navigate to newer history entry or clear @@ -1003,10 +1004,10 @@ export class Editor implements Component, Focusable { } else { this.#moveCursor(1, 0); // Cursor movement (within text or history entry) } - } else if (matchesKey(data, "right")) { + } else if (kb.matches(data, "tui.editor.cursorRight")) { // Right this.#moveCursor(0, 1); - } else if (matchesKey(data, "left")) { + } else if (kb.matches(data, "tui.editor.cursorLeft")) { // Left this.#moveCursor(0, -1); } @@ -1015,9 +1016,9 @@ export class Editor implements Component, Focusable { this.#insertCharacter(" "); } // Character jump mode triggers - else if (kb.matches(data, "jumpForward")) { + else if (kb.matches(data, "tui.editor.jumpForward")) { this.#jumpMode = "forward"; - } else if (kb.matches(data, "jumpBackward")) { + } else if (kb.matches(data, "tui.editor.jumpBackward")) { this.#jumpMode = "backward"; } // Printable keystrokes, including Kitty CSI-u text-producing sequences. @@ -1393,10 +1394,10 @@ export class Editor implements Component, Focusable { } } - #shouldSubmitOnBackslashEnter(data: string, kb: EditorKeybindingsManager): boolean { + #shouldSubmitOnBackslashEnter(data: string, kb: KeybindingsManager): boolean { if (this.disableSubmit) return false; if (!matchesKey(data, "enter")) return false; - const submitKeys = kb.getKeys("submit"); + const submitKeys = kb.getKeys("tui.input.submit"); const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return"); if (!hasShiftEnter) return false; @@ -2184,11 +2185,7 @@ export class Editor implements Component, Focusable { if (suggestions && suggestions.items.length > 0) { this.#autocompletePrefix = suggestions.prefix; - this.#autocompleteList = new SelectList( - suggestions.items, - this.#autocompleteMaxVisible, - this.#theme.selectList, - ); + this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items); this.#autocompleteState = "regular"; this.onAutocompleteUpdate?.(); } else { @@ -2196,6 +2193,16 @@ export class Editor implements Component, Focusable { this.onAutocompleteUpdate?.(); } } + #createAutocompleteList( + prefix: string, + items: Array<{ value: string; label: string; description?: string }>, + ): SelectList { + // Layout options prepared for future SelectList enhancements (e.g., for slash commands) + const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined; + // TODO: Pass layout to SelectList when constructor is updated to support it + void layout; // Use layout variable to avoid lint warnings + return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList); + } #handleTabCompletion(): void { if (!this.#autocompleteProvider) return; @@ -2263,11 +2270,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ } this.#autocompletePrefix = suggestions.prefix; - this.#autocompleteList = new SelectList( - suggestions.items, - this.#autocompleteMaxVisible, - this.#theme.selectList, - ); + this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items); this.#autocompleteState = "force"; this.onAutocompleteUpdate?.(); } else { @@ -2313,11 +2316,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ if (suggestions && suggestions.items.length > 0) { this.#autocompletePrefix = suggestions.prefix; // Always create new SelectList to ensure update - this.#autocompleteList = new SelectList( - suggestions.items, - this.#autocompleteMaxVisible, - this.#theme.selectList, - ); + this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items); this.onAutocompleteUpdate?.(); } else { this.#cancelAutocomplete(); diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index fc7278eb8..e9ff9b721 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,5 +1,5 @@ import { BracketedPasteHandler } from "../bracketed-paste"; -import { getEditorKeybindings } from "../keybindings"; +import { getKeybindings } from "../keybindings"; import { extractPrintableText } from "../keys"; import { KillRing } from "../kill-ring"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui"; @@ -65,69 +65,69 @@ export class Input implements Component, Focusable { return; } - const kb = getEditorKeybindings(); + const kb = getKeybindings(); // Escape/Cancel - if (kb.matches(data, "selectCancel")) { + if (kb.matches(data, "tui.select.cancel")) { if (this.onEscape) this.onEscape(); return; } // Undo - if (kb.matches(data, "undo")) { + if (kb.matches(data, "tui.editor.undo")) { this.#undo(); return; } // Submit - if (kb.matches(data, "submit") || data === "\n") { + if (kb.matches(data, "tui.input.submit") || data === "\n") { if (this.onSubmit) this.onSubmit(this.#value); return; } // Deletion - if (kb.matches(data, "deleteCharBackward")) { + if (kb.matches(data, "tui.editor.deleteCharBackward")) { this.#handleBackspace(); return; } - if (kb.matches(data, "deleteCharForward")) { + if (kb.matches(data, "tui.editor.deleteCharForward")) { this.#handleForwardDelete(); return; } - if (kb.matches(data, "deleteWordBackward")) { + if (kb.matches(data, "tui.editor.deleteWordBackward")) { this.#deleteWordBackwards(); return; } - if (kb.matches(data, "deleteWordForward")) { + if (kb.matches(data, "tui.editor.deleteWordForward")) { this.#deleteWordForward(); return; } - if (kb.matches(data, "deleteToLineStart")) { + if (kb.matches(data, "tui.editor.deleteToLineStart")) { this.#deleteToLineStart(); return; } - if (kb.matches(data, "deleteToLineEnd")) { + if (kb.matches(data, "tui.editor.deleteToLineEnd")) { this.#deleteToLineEnd(); return; } // Kill ring actions - if (kb.matches(data, "yank")) { + if (kb.matches(data, "tui.editor.yank")) { this.#yank(); return; } - if (kb.matches(data, "yankPop")) { + if (kb.matches(data, "tui.editor.yankPop")) { this.#yankPop(); return; } // Cursor movement - if (kb.matches(data, "cursorLeft")) { + if (kb.matches(data, "tui.editor.cursorLeft")) { this.#lastAction = null; if (this.#cursor > 0) { const beforeCursor = this.#value.slice(0, this.#cursor); @@ -138,7 +138,7 @@ export class Input implements Component, Focusable { return; } - if (kb.matches(data, "cursorRight")) { + if (kb.matches(data, "tui.editor.cursorRight")) { this.#lastAction = null; if (this.#cursor < this.#value.length) { const afterCursor = this.#value.slice(this.#cursor); @@ -149,24 +149,24 @@ export class Input implements Component, Focusable { return; } - if (kb.matches(data, "cursorLineStart")) { + if (kb.matches(data, "tui.editor.cursorLineStart")) { this.#lastAction = null; this.#cursor = 0; return; } - if (kb.matches(data, "cursorLineEnd")) { + if (kb.matches(data, "tui.editor.cursorLineEnd")) { this.#lastAction = null; this.#cursor = this.#value.length; return; } - if (kb.matches(data, "cursorWordLeft")) { + if (kb.matches(data, "tui.editor.cursorWordLeft")) { this.#moveWordBackwards(); return; } - if (kb.matches(data, "cursorWordRight")) { + if (kb.matches(data, "tui.editor.cursorWordRight")) { this.#moveWordForwards(); return; } diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 2a8a32ee3..00c30ee41 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -306,7 +306,7 @@ export class Markdown implements Component { styledHeading = this.#theme.heading(this.#theme.bold(headingPrefix + headingText)); } lines.push(styledHeading); - if (nextTokenType !== "space") { + if (nextTokenType && nextTokenType !== "space") { lines.push(""); // Add spacing after headings (unless space token follows) } break; @@ -332,7 +332,7 @@ export class Markdown implements Component { for (const asciiLine of Bun.stripANSI(ascii).split("\n")) { lines.push(asciiLine); } - if (nextTokenType !== "space") { + if (nextTokenType && nextTokenType !== "space") { lines.push(""); } break; @@ -354,7 +354,7 @@ export class Markdown implements Component { } } lines.push(this.#theme.codeBlockBorder("```")); - if (nextTokenType !== "space") { + if (nextTokenType && nextTokenType !== "space") { lines.push(""); // Add spacing after code blocks (unless space token follows) } break; @@ -369,7 +369,7 @@ export class Markdown implements Component { } case "table": { - const tableLines = this.#renderTable(token as TableToken, width, styleContext); + const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext); lines.push(...tableLines); break; } @@ -415,7 +415,7 @@ export class Markdown implements Component { lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine); } } - if (nextTokenType !== "space") { + if (nextTokenType && nextTokenType !== "space") { lines.push(""); // Add spacing after blockquotes (unless space token follows) } break; @@ -423,7 +423,7 @@ export class Markdown implements Component { case "hr": lines.push(this.#theme.hr(this.#theme.symbols.hrChar.repeat(Math.min(width, 80)))); - if (nextTokenType !== "space") { + if (nextTokenType && nextTokenType !== "space") { lines.push(""); // Add spacing after horizontal rules (unless space token follows) } break; @@ -669,7 +669,12 @@ 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: TableToken, availableWidth: number, styleContext?: InlineStyleContext): string[] { + #renderTable( + token: TableToken, + availableWidth: number, + nextTokenType?: string, + styleContext?: InlineStyleContext, + ): string[] { const lines: string[] = []; const numCols = token.header.length; @@ -684,7 +689,9 @@ export class Markdown implements Component { if (availableForCells < numCols) { // Too narrow to render a stable table. Fall back to raw markdown. const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : []; - fallbackLines.push(""); + if (nextTokenType && nextTokenType !== "space") { + fallbackLines.push(""); + } return fallbackLines; } @@ -834,7 +841,9 @@ export class Markdown implements Component { const bottomBorderCells = columnWidths.map(w => h.repeat(w)); lines.push(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`); - lines.push(""); // Add spacing after table + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); // Add spacing after table + } return lines; } } diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index bfd5a8f0b..25ee62f0c 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,8 +1,21 @@ -import { matchesKey } from "../keys"; +import { getKeybindings } from "../keybindings"; import type { SymbolTheme } from "../symbols"; import type { Component } from "../tui"; import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils"; +const DEFAULT_PRIMARY_COLUMN_WIDTH = 32; +const PRIMARY_COLUMN_GAP = 2; +const MIN_DESCRIPTION_WIDTH = 10; + +function sanitizeSingleLine(text: string): string { + return replaceTabs(text) + .replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max)); + export interface SelectItem { value: string; label: string; @@ -20,11 +33,18 @@ export interface SelectListTheme { symbols: SymbolTheme; } -function sanitizeSingleLine(text: string): string { - return replaceTabs(text) - .replace(/[\r\n]+/g, " ") - .replace(/\s+/g, " ") - .trim(); +export interface SelectListTruncatePrimaryContext { + text: string; + maxWidth: number; + columnWidth: number; + item: SelectItem; + isSelected: boolean; +} + +export interface SelectListLayoutOptions { + minPrimaryColumnWidth?: number; + maxPrimaryColumnWidth?: number; + truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string; } export class SelectList implements Component { @@ -39,6 +59,7 @@ export class SelectList implements Component { private readonly items: ReadonlyArray, private readonly maxVisible: number, private readonly theme: SelectListTheme, + private readonly layout: SelectListLayoutOptions = {}, ) { this.#filteredItems = items; } @@ -66,6 +87,8 @@ export class SelectList implements Component { return lines; } + const primaryColumnWidth = this.#getPrimaryColumnWidth(); + // Calculate visible range with scrolling const startIndex = Math.max( 0, @@ -79,71 +102,8 @@ export class SelectList implements Component { if (!item) continue; const isSelected = i === this.#selectedIndex; - const labelText = sanitizeSingleLine(item.label || item.value); const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined; - - let line = ""; - if (isSelected) { - // Use arrow indicator for selection - entire line uses selectedText color - const prefix = `${this.theme.symbols.cursor} `; - const prefixWidth = visibleWidth(prefix); - const displayValue = labelText; - - if (descriptionText && width > 40) { - // Calculate how much space we have for value + description - const maxValueWidth = Math.min(30, width - prefixWidth - 4); - const truncatedValue = truncateToWidth(displayValue, maxValueWidth, Ellipsis.Omit); - const spacing = padding(Math.max(1, 32 - truncatedValue.length)); - - // Calculate remaining space for description using visible widths - const descriptionStart = prefixWidth + truncatedValue.length + spacing.length; - const remainingWidth = width - descriptionStart - 2; // -2 for safety - - if (remainingWidth > 10) { - const truncatedDesc = truncateToWidth(descriptionText, remainingWidth, Ellipsis.Omit); - // Apply selectedText to entire line content - line = this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`); - } else { - // Not enough space for description - const maxWidth = width - prefixWidth - 2; - line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, Ellipsis.Omit)}`); - } - } else { - // No description or not enough width - const maxWidth = width - prefixWidth - 2; - line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, Ellipsis.Omit)}`); - } - } else { - const displayValue = labelText; - const prefix = padding(visibleWidth(this.theme.symbols.cursor) + 1); - - if (descriptionText && width > 40) { - // Calculate how much space we have for value + description - const maxValueWidth = Math.min(30, width - prefix.length - 4); - const truncatedValue = truncateToWidth(displayValue, maxValueWidth, Ellipsis.Omit); - const spacing = padding(Math.max(1, 32 - truncatedValue.length)); - - // Calculate remaining space for description - const descriptionStart = prefix.length + truncatedValue.length + spacing.length; - const remainingWidth = width - descriptionStart - 2; // -2 for safety - - if (remainingWidth > 10) { - const truncatedDesc = truncateToWidth(descriptionText, remainingWidth, Ellipsis.Omit); - const descText = this.theme.description(spacing + truncatedDesc); - line = prefix + truncatedValue + descText; - } else { - // Not enough space for description - const maxWidth = width - prefix.length - 2; - line = prefix + truncateToWidth(displayValue, maxWidth, Ellipsis.Omit); - } - } else { - // No description or not enough width - const maxWidth = width - prefix.length - 2; - line = prefix + truncateToWidth(displayValue, maxWidth, Ellipsis.Omit); - } - } - - lines.push(line); + lines.push(this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth)); } // Add scroll indicators if needed @@ -158,41 +118,123 @@ export class SelectList implements Component { handleInput(keyData: string): void { if (this.#filteredItems.length === 0) return; + const kb = getKeybindings(); // Up arrow - wrap to bottom when at top - if (matchesKey(keyData, "up")) { + if (kb.matches(keyData, "tui.select.up")) { this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1; this.#notifySelectionChange(); } // Down arrow - wrap to top when at bottom - else if (matchesKey(keyData, "down")) { + else if (kb.matches(keyData, "tui.select.down")) { this.#selectedIndex = this.#selectedIndex === this.#filteredItems.length - 1 ? 0 : this.#selectedIndex + 1; this.#notifySelectionChange(); } // PageUp - jump up by one visible page - else if (matchesKey(keyData, "pageUp")) { + else if (kb.matches(keyData, "tui.select.pageUp")) { this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisible); this.#notifySelectionChange(); } // PageDown - jump down by one visible page - else if (matchesKey(keyData, "pageDown")) { + else if (kb.matches(keyData, "tui.select.pageDown")) { this.#selectedIndex = Math.min(this.#filteredItems.length - 1, this.#selectedIndex + this.maxVisible); this.#notifySelectionChange(); } // Enter - else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { + else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { const selectedItem = this.#filteredItems[this.#selectedIndex]; if (selectedItem && this.onSelect) { this.onSelect(selectedItem); } } // Escape or Ctrl+C - else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) { + else if (kb.matches(keyData, "tui.select.cancel")) { if (this.onCancel) { this.onCancel(); } } } + #renderItem( + item: SelectItem, + isSelected: boolean, + width: number, + descriptionSingleLine: string | undefined, + primaryColumnWidth: number, + ): string { + const prefix = isSelected + ? `${this.theme.symbols.cursor} ` + : padding(visibleWidth(this.theme.symbols.cursor) + 1); + const prefixWidth = visibleWidth(prefix); + + if (descriptionSingleLine && width > 40) { + const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4)); + const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP); + const truncatedValue = this.#truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth); + const truncatedValueWidth = visibleWidth(truncatedValue); + const spacing = padding(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth)); + const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length; + const remainingWidth = width - descriptionStart - 2; // -2 for safety + + if (remainingWidth > MIN_DESCRIPTION_WIDTH) { + const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit); + if (isSelected) { + return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`); + } + + const descText = this.theme.description(spacing + truncatedDesc); + return prefix + truncatedValue + descText; + } + } + + const maxWidth = width - prefixWidth - 2; + const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth); + if (isSelected) { + return this.theme.selectedText(`${prefix}${truncatedValue}`); + } + + return prefix + truncatedValue; + } + + #getPrimaryColumnWidth(): number { + const { min, max } = this.#getPrimaryColumnBounds(); + const widestPrimary = this.#filteredItems.reduce((widest, item) => { + return Math.max(widest, visibleWidth(this.#getDisplayValue(item)) + PRIMARY_COLUMN_GAP); + }, 0); + + return clamp(widestPrimary, min, max); + } + + #getPrimaryColumnBounds(): { min: number; max: number } { + const rawMin = + this.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + const rawMax = + this.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + + return { + min: Math.max(1, Math.min(rawMin, rawMax)), + max: Math.max(1, Math.max(rawMin, rawMax)), + }; + } + + #truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string { + const displayValue = this.#getDisplayValue(item); + const truncatedValue = this.layout.truncatePrimary + ? this.layout.truncatePrimary({ + text: displayValue, + maxWidth, + columnWidth, + item, + isSelected, + }) + : truncateToWidth(displayValue, maxWidth, Ellipsis.Omit); + + return truncateToWidth(truncatedValue, maxWidth, Ellipsis.Omit); + } + + #getDisplayValue(item: SelectItem): string { + return sanitizeSingleLine(item.label || item.value); + } + #notifySelectionChange(): void { const selectedItem = this.#filteredItems[this.#selectedIndex]; if (selectedItem && this.onSelectionChange) { diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index dcf333d80..f705095e4 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -1,4 +1,4 @@ -import { matchesKey } from "../keys"; +import { getKeybindings } from "../keybindings"; import type { Component } from "../tui"; import { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils"; @@ -148,13 +148,14 @@ export class SettingsList implements Component { } // Main list input handling - if (matchesKey(data, "up")) { + const kb = getKeybindings(); + if (kb.matches(data, "tui.select.up")) { this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1; - } else if (matchesKey(data, "down")) { + } else if (kb.matches(data, "tui.select.down")) { this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1; - } else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n" || data === " ") { + } else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") { this.#activateItem(); - } else if (matchesKey(data, "escape") || matchesKey(data, "esc") || matchesKey(data, "ctrl+c")) { + } else if (kb.matches(data, "tui.select.cancel")) { this.#onCancel(); } } diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index 9daf484e4..47241210a 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -1,95 +1,145 @@ import { type KeyId, matchesKey, parseKey } from "./keys"; /** - * Editor actions that can be bound to keys. + * Global keybinding registry. + * Downstream packages can add keybindings via declaration merging. */ -export type EditorAction = - // Cursor movement - | "cursorUp" - | "cursorDown" - | "cursorLeft" - | "cursorRight" - | "cursorWordLeft" - | "cursorWordRight" - | "cursorLineStart" - | "cursorLineEnd" - | "jumpForward" - | "jumpBackward" - // Deletion - | "deleteCharBackward" - | "deleteCharForward" - | "deleteWordBackward" - | "deleteWordForward" - | "deleteToLineStart" - | "deleteToLineEnd" - // Text input - | "newLine" - | "submit" - | "tab" - // Selection/autocomplete - | "selectUp" - | "selectDown" - | "selectPageUp" - | "selectPageDown" - | "selectConfirm" - | "selectCancel" - // Clipboard - | "copy" - // Kill ring / undo - | "undo" - | "yank" - | "yankPop"; +export interface Keybindings { + // Editor navigation and editing + "tui.editor.cursorUp": true; + "tui.editor.cursorDown": true; + "tui.editor.cursorLeft": true; + "tui.editor.cursorRight": true; + "tui.editor.cursorWordLeft": true; + "tui.editor.cursorWordRight": true; + "tui.editor.cursorLineStart": true; + "tui.editor.cursorLineEnd": true; + "tui.editor.jumpForward": true; + "tui.editor.jumpBackward": true; + "tui.editor.pageUp": true; + "tui.editor.pageDown": true; + "tui.editor.deleteCharBackward": true; + "tui.editor.deleteCharForward": true; + "tui.editor.deleteWordBackward": true; + "tui.editor.deleteWordForward": true; + "tui.editor.deleteToLineStart": true; + "tui.editor.deleteToLineEnd": true; + "tui.editor.yank": true; + "tui.editor.yankPop": true; + "tui.editor.undo": true; + // Generic input actions + "tui.input.newLine": true; + "tui.input.submit": true; + "tui.input.tab": true; + "tui.input.copy": true; + // Generic selection actions + "tui.select.up": true; + "tui.select.down": true; + "tui.select.pageUp": true; + "tui.select.pageDown": true; + "tui.select.confirm": true; + "tui.select.cancel": true; +} + +export type Keybinding = keyof Keybindings; // Re-export KeyId from keys.ts export type { KeyId }; -/** - * Editor keybindings configuration. - */ -export type EditorKeybindingsConfig = { - [K in EditorAction]?: KeyId | KeyId[]; -}; +export interface KeybindingDefinition { + defaultKeys: KeyId | KeyId[]; + description?: string; +} -/** - * Default editor keybindings. - */ -export const DEFAULT_EDITOR_KEYBINDINGS: Required = { - // Cursor movement - cursorUp: "up", - cursorDown: "down", - cursorLeft: ["left", "ctrl+b"], - cursorRight: ["right", "ctrl+f"], - cursorWordLeft: ["alt+left", "ctrl+left", "alt+b"], - cursorWordRight: ["alt+right", "ctrl+right", "alt+f"], - cursorLineStart: ["home", "ctrl+a"], - cursorLineEnd: ["end", "ctrl+e"], - jumpForward: "ctrl+]", - jumpBackward: "ctrl+alt+]", - // Deletion - deleteCharBackward: "backspace", - deleteCharForward: ["delete", "ctrl+d"], - deleteWordBackward: ["ctrl+w", "alt+backspace", "ctrl+backspace"], - deleteWordForward: ["alt+delete", "alt+d"], - deleteToLineStart: "ctrl+u", - deleteToLineEnd: "ctrl+k", - // Text input - newLine: "shift+enter", - submit: "enter", - tab: "tab", - // Selection/autocomplete - selectUp: "up", - selectDown: "down", - selectPageUp: "pageUp", - selectPageDown: "pageDown", - selectConfirm: "enter", - selectCancel: ["escape", "ctrl+c"], - // Clipboard - copy: "ctrl+c", - // Kill ring / undo - undo: ["ctrl+-", "ctrl+_"], - yank: "ctrl+y", - yankPop: "alt+y", -}; +export type KeybindingDefinitions = Record; +export type KeybindingsConfig = Record; + +export const TUI_KEYBINDINGS = { + "tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" }, + "tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" }, + "tui.editor.cursorLeft": { + defaultKeys: ["left", "ctrl+b"], + description: "Move cursor left", + }, + "tui.editor.cursorRight": { + defaultKeys: ["right", "ctrl+f"], + description: "Move cursor right", + }, + "tui.editor.cursorWordLeft": { + defaultKeys: ["alt+left", "ctrl+left", "alt+b"], + description: "Move cursor word left", + }, + "tui.editor.cursorWordRight": { + defaultKeys: ["alt+right", "ctrl+right", "alt+f"], + description: "Move cursor word right", + }, + "tui.editor.cursorLineStart": { + defaultKeys: ["home", "ctrl+a"], + description: "Move to line start", + }, + "tui.editor.cursorLineEnd": { + defaultKeys: ["end", "ctrl+e"], + description: "Move to line end", + }, + "tui.editor.jumpForward": { + defaultKeys: "ctrl+]", + description: "Jump forward to character", + }, + "tui.editor.jumpBackward": { + defaultKeys: "ctrl+alt+]", + description: "Jump backward to character", + }, + "tui.editor.pageUp": { defaultKeys: "pageUp", description: "Page up" }, + "tui.editor.pageDown": { defaultKeys: "pageDown", description: "Page down" }, + "tui.editor.deleteCharBackward": { + defaultKeys: "backspace", + description: "Delete character backward", + }, + "tui.editor.deleteCharForward": { + defaultKeys: ["delete", "ctrl+d"], + description: "Delete character forward", + }, + "tui.editor.deleteWordBackward": { + defaultKeys: ["ctrl+w", "alt+backspace", "ctrl+backspace"], + description: "Delete word backward", + }, + "tui.editor.deleteWordForward": { + defaultKeys: ["alt+delete", "alt+d"], + description: "Delete word forward", + }, + "tui.editor.deleteToLineStart": { + defaultKeys: "ctrl+u", + description: "Delete to line start", + }, + "tui.editor.deleteToLineEnd": { + defaultKeys: "ctrl+k", + description: "Delete to line end", + }, + "tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" }, + "tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" }, + "tui.editor.undo": { defaultKeys: ["ctrl+-", "ctrl+_"], description: "Undo" }, + "tui.input.newLine": { defaultKeys: "shift+enter", description: "Insert newline" }, + "tui.input.submit": { defaultKeys: "enter", description: "Submit input" }, + "tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" }, + "tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" }, + "tui.select.up": { defaultKeys: "up", description: "Move selection up" }, + "tui.select.down": { defaultKeys: "down", description: "Move selection down" }, + "tui.select.pageUp": { defaultKeys: "pageUp", description: "Selection page up" }, + "tui.select.pageDown": { + defaultKeys: "pageDown", + description: "Selection page down", + }, + "tui.select.confirm": { defaultKeys: "enter", description: "Confirm selection" }, + "tui.select.cancel": { + defaultKeys: ["escape", "ctrl+c"], + description: "Cancel selection", + }, +} as const satisfies KeybindingDefinitions; + +export interface KeybindingConflict { + key: KeyId; + keybindings: string[]; +} const SHIFTED_SYMBOL_KEYS = new Set([ "!", @@ -116,50 +166,67 @@ const SHIFTED_SYMBOL_KEYS = new Set([ const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId; -/** - * Manages keybindings for the editor. - */ -export class EditorKeybindingsManager { - #actionToKeys: Map; +function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] { + if (keys === undefined) return []; + const keyList = Array.isArray(keys) ? keys : [keys]; + const seen = new Set(); + const result: KeyId[] = []; + for (const key of keyList) { + const normalized = normalizeKeyId(key); + if (!seen.has(normalized)) { + seen.add(normalized); + result.push(normalized); + } + } + return result; +} - constructor(config: EditorKeybindingsConfig = {}) { - this.#actionToKeys = new Map(); - this.#buildMaps(config); +export class KeybindingsManager { + #definitions: KeybindingDefinitions; + #userBindings: KeybindingsConfig; + #keysById = new Map(); + #conflicts: KeybindingConflict[] = []; + + constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) { + this.#definitions = definitions; + this.#userBindings = userBindings; + this.#rebuild(); } - #buildMaps(config: EditorKeybindingsConfig): void { - this.#actionToKeys.clear(); + #rebuild(): void { + this.#keysById.clear(); + this.#conflicts = []; - // Start with defaults - for (const [action, keys] of Object.entries(DEFAULT_EDITOR_KEYBINDINGS)) { - const keyArray = Array.isArray(keys) ? keys : [keys]; - this.#actionToKeys.set( - action as EditorAction, - keyArray.map(key => normalizeKeyId(key as KeyId)), - ); + const userClaims = new Map>(); + for (const [keybinding, keys] of Object.entries(this.#userBindings)) { + if (!(keybinding in this.#definitions)) continue; + for (const key of normalizeKeys(keys)) { + const claimants = userClaims.get(key) ?? new Set(); + claimants.add(keybinding as Keybinding); + userClaims.set(key, claimants); + } } - // Override with user config - for (const [action, keys] of Object.entries(config)) { - if (keys === undefined) continue; - const keyArray = Array.isArray(keys) ? keys : [keys]; - this.#actionToKeys.set( - action as EditorAction, - keyArray.map(key => normalizeKeyId(key as KeyId)), - ); + for (const [key, keybindings] of userClaims) { + if (keybindings.size > 1) { + this.#conflicts.push({ key, keybindings: [...keybindings] }); + } + } + + for (const [id, definition] of Object.entries(this.#definitions)) { + const userKeys = this.#userBindings[id]; + const keys = userKeys === undefined ? normalizeKeys(definition.defaultKeys) : normalizeKeys(userKeys); + this.#keysById.set(id as Keybinding, keys); } } - /** - * Check if input matches a specific action. - */ - matches(data: string, action: EditorAction): boolean { - const keys = this.#actionToKeys.get(action); - if (!keys) return false; + matches(data: string, keybinding: Keybinding): boolean { + const keys = this.#keysById.get(keybinding) ?? []; for (const key of keys) { if (matchesKey(data, key)) return true; } + // Handle shifted symbol keys (e.g., shift+- produces _ on US layout) const parsed = parseKey(data); if (!parsed || !parsed.startsWith("shift+")) return false; const keyName = parsed.slice("shift+".length); @@ -167,31 +234,46 @@ export class EditorKeybindingsManager { return keys.includes(keyName as KeyId); } - /** - * Get keys bound to an action. - */ - getKeys(action: EditorAction): KeyId[] { - return this.#actionToKeys.get(action) ?? []; + getKeys(keybinding: Keybinding): KeyId[] { + return [...(this.#keysById.get(keybinding) ?? [])]; } - /** - * Update configuration. - */ - setConfig(config: EditorKeybindingsConfig): void { - this.#buildMaps(config); + getDefinition(keybinding: Keybinding): KeybindingDefinition { + return this.#definitions[keybinding]; + } + + getConflicts(): KeybindingConflict[] { + return this.#conflicts.map(conflict => ({ ...conflict, keybindings: [...conflict.keybindings] })); + } + + setUserBindings(userBindings: KeybindingsConfig): void { + this.#userBindings = userBindings; + this.#rebuild(); + } + + getUserBindings(): KeybindingsConfig { + return { ...this.#userBindings }; + } + + getResolvedBindings(): KeybindingsConfig { + const resolved: KeybindingsConfig = {}; + for (const id of Object.keys(this.#definitions)) { + const keys = this.#keysById.get(id as Keybinding) ?? []; + resolved[id] = keys.length === 1 ? keys[0]! : [...keys]; + } + return resolved; } } -// Global instance -let globalEditorKeybindings: EditorKeybindingsManager | null = null; +let globalKeybindings: KeybindingsManager | null = null; -export function getEditorKeybindings(): EditorKeybindingsManager { - if (!globalEditorKeybindings) { - globalEditorKeybindings = new EditorKeybindingsManager(); +export function setKeybindings(keybindings: KeybindingsManager): void { + globalKeybindings = keybindings; +} + +export function getKeybindings(): KeybindingsManager { + if (!globalKeybindings) { + globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS); } - return globalEditorKeybindings; -} - -export function setEditorKeybindings(manager: EditorKeybindingsManager): void { - globalEditorKeybindings = manager; + return globalKeybindings; } diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index ace1d2286..286d4e20b 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -25,6 +25,34 @@ import { parseKittySequence as parseKittySequenceNative, } from "@oh-my-pi/pi-natives"; +// ============================================================================= +// Platform Detection +// ============================================================================= + +function isWindowsTerminalSession(): boolean { + return ( + Boolean(process.env.WT_SESSION) && !process.env.SSH_CONNECTION && !process.env.SSH_CLIENT && !process.env.SSH_TTY + ); +} + +/** + * Raw 0x08 (BS) is ambiguous in legacy terminals. + * + * - Windows Terminal uses it for Ctrl+Backspace. + * - Some legacy terminals and tmux setups send it for plain Backspace. + * + * Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are + * available. Fall back to a Windows Terminal heuristic only for raw BS bytes. + */ +function matchesRawBackspace(data: string, expectedModifier: number): boolean { + if (data === "\x7f") return expectedModifier === 0; + if (data !== "\x08") return false; + // On Windows Terminal, 0x08 = Ctrl+Backspace. On others, it's plain Backspace. + return isWindowsTerminalSession() ? expectedModifier === 4 : expectedModifier === 0; +} + +export { isWindowsTerminalSession, matchesRawBackspace }; + // ============================================================================= // Global Kitty Protocol State // ============================================================================= diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 1c735b7af..7f3d39b8d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -108,6 +108,10 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu return undefined; } +function isTermuxSession(): boolean { + return Boolean(process.env.TERMUX_VERSION); +} + /** * Options for overlay positioning and sizing. * Values can be absolute numbers or percentage strings (e.g., "50%"). @@ -204,6 +208,7 @@ export class TUI extends Container { terminal: Terminal; #previousLines: string[] = []; #previousWidth = 0; + #previousHeight = 0; #focusedComponent: Component | null = null; #inputListeners = new Set(); @@ -559,6 +564,7 @@ export class TUI extends Container { if (force) { this.#previousLines = []; this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear + this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear this.#cursorRow = 0; this.#hardwareCursorRow = 0; this.#viewportTopRow = 0; @@ -995,12 +1001,13 @@ export class TUI extends Container { // Width changed - need full re-render (line wrapping changes) const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width; + const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height; // Helper to clear scrollback and viewport and render all new lines const fullRender = (clear: boolean): void => { this.#fullRedrawCount += 1; let buffer = "\x1b[?2026h"; // Begin synchronized output - if (clear) buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home + if (clear) buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback const reset = SEGMENT_RESET; for (let i = 0; i < newLines.length; i++) { if (i > 0) buffer += "\r\n"; @@ -1021,6 +1028,7 @@ export class TUI extends Container { this.#positionHardwareCursor(cursorPos, newLines.length); this.#previousLines = newLines; this.#previousWidth = width; + this.#previousHeight = height; }; const debugRedraw = process.env.PI_DEBUG_REDRAW === "1"; @@ -1032,15 +1040,24 @@ export class TUI extends Container { }; // First render - just output everything without clearing (assumes clean screen) - if (this.#previousLines.length === 0 && !widthChanged) { + if (this.#previousLines.length === 0 && !widthChanged && !heightChanged) { logRedraw("first render"); fullRender(false); return; } - // Width changed - full re-render (line wrapping changes) + // Width changes always need a full re-render because wrapping changes. if (widthChanged) { - logRedraw(`width changed (${this.#previousWidth} -> ${width})`); + logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`); + fullRender(true); + return; + } + + // Height changes normally need a full re-render to keep the visible viewport aligned, + // but Termux changes height when the software keyboard shows or hides. + // In that environment, a full redraw causes the entire history to replay on every toggle. + if (heightChanged && !isTermuxSession()) { + logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`); fullRender(true); return; } @@ -1122,6 +1139,7 @@ export class TUI extends Container { this.#positionHardwareCursor(cursorPos, newLines.length); this.#previousLines = newLines; this.#previousWidth = width; + this.#previousHeight = height; this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height); return; } @@ -1270,6 +1288,7 @@ export class TUI extends Container { this.#previousLines = newLines; this.#previousWidth = width; + this.#previousHeight = height; } /** diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index df3930487..b2f38e5e5 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -35,15 +35,25 @@ export function getSegmenter(): Intl.Segmenter { /** * Calculate the visible width of a string in terminal columns. */ +function _isPrintableAscii(str: string): boolean { + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code < 0x20 || code > 0x7e) { + return false; + } + } + return true; +} + export function visibleWidthRaw(str: string): number { if (!str) { return 0; } // Fast path: pure ASCII printable - let isPureAscii = true; let tabLength = 0; const tabWidth = getDefaultTabWidth(); + let isPureAscii = true; for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); if (code === 9) { diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index c271c1f04..36f6faab2 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -3,12 +3,12 @@ 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 { EditorKeybindingsManager, setEditorKeybindings } from "../src/keybindings"; +import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings"; import { defaultEditorTheme } from "./test-themes"; describe("Editor component", () => { afterEach(() => { - setEditorKeybindings(new EditorKeybindingsManager()); + setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS)); }); describe("Prompt history navigation", () => { @@ -1340,9 +1340,9 @@ describe("Editor component", () => { }); it("uses the configured undo binding", () => { - setEditorKeybindings( - new EditorKeybindingsManager({ - undo: "f8", + setKeybindings( + new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.editor.undo": "f8", }), ); diff --git a/packages/tui/test/keybindings.test.ts b/packages/tui/test/keybindings.test.ts new file mode 100644 index 000000000..a6d616c06 --- /dev/null +++ b/packages/tui/test/keybindings.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; +import { KeybindingsManager, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui/keybindings"; + +describe("KeybindingsManager", () => { + it("does not evict selector confirm when input submit is rebound", () => { + const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.input.submit": ["enter", "ctrl+enter"], + }); + + expect(keybindings.getKeys("tui.input.submit")).toEqual(["enter", "ctrl+enter"]); + expect(keybindings.getKeys("tui.select.confirm")).toEqual(["enter"]); + }); + + it("does not evict cursor bindings when another action reuses the same key", () => { + const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.select.up": ["up", "ctrl+p"], + }); + + expect(keybindings.getKeys("tui.select.up")).toEqual(["up", "ctrl+p"]); + expect(keybindings.getKeys("tui.editor.cursorUp")).toEqual(["up"]); + }); + + it("still reports direct user binding conflicts without evicting defaults", () => { + const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.input.submit": "ctrl+x", + "tui.select.confirm": "ctrl+x", + }); + + expect(keybindings.getConflicts()).toEqual([ + { + key: "ctrl+x", + keybindings: ["tui.input.submit", "tui.select.confirm"], + }, + ]); + expect(keybindings.getKeys("tui.editor.cursorLeft")).toEqual(["left", "ctrl+b"]); + }); +}); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 58dc63616..86d89c541 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -471,6 +471,22 @@ describe("Markdown component", () => { const tableRow = plainLines.find(line => line.includes("|")); expect(tableRow?.startsWith(" "), "Table should have left padding").toBeTruthy(); }); + + it("should not add a trailing blank line when table is the last rendered block", () => { + const markdown = new Markdown( + `| Name | +| --- | +| Alice |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + expect(plainLines.at(-1)).not.toBe(""); + }); }); describe("Combined features", () => { @@ -624,6 +640,44 @@ again, hello world`, `Expected 1 empty line after code block, but found ${emptyLineCount}. Lines after backticks: ${JSON.stringify(afterBackticks.slice(0, 5))}`, ).toBe(1); }); + + it("should normalize paragraph and code block spacing to one blank line", () => { + const cases = [ + `hello this is text +\`\`\` +code block +\`\`\` +more text`, + `hello this is text + +\`\`\` +code block +\`\`\` + +more text`, + ]; + const expectedLines = ["hello this is text", "", "```", " code block", "```", "", "more text"]; + + for (const text of cases) { + const markdown = new Markdown(text, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + expect(plainLines).toEqual(expectedLines); + } + }); + + it("should not add a trailing blank line when code block is the last rendered block", () => { + const cases = ["```js\nconst hello = 'world';\n```", "hello world\n\n```js\nconst hello = 'world';\n```"]; + + for (const text of cases) { + const markdown = new Markdown(text, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + expect(plainLines.at(-1)).not.toBe(""); + } + }); }); describe("Spacing after dividers", () => { @@ -653,6 +707,14 @@ again, hello world`, `Expected 1 empty line after divider, but found ${emptyLineCount}. Lines after divider: ${JSON.stringify(afterDivider.slice(0, 5))}`, ).toBe(1); }); + + it("should not add a trailing blank line when divider is the last rendered block", () => { + const markdown = new Markdown("---", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + expect(plainLines.at(-1)).not.toBe(""); + }); }); describe("Spacing after headings", () => { @@ -680,6 +742,14 @@ This is a paragraph`, `Expected 1 empty line after heading, but found ${emptyLineCount}. Lines after heading: ${JSON.stringify(afterHeading.slice(0, 5))}`, ).toBe(1); }); + + it("should not add a trailing blank line when heading is the last rendered block", () => { + const markdown = new Markdown("# Hello", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + expect(plainLines.at(-1)).not.toBe(""); + }); }); describe("Spacing after blockquotes", () => { @@ -709,6 +779,14 @@ again, hello world`, `Expected 1 empty line after blockquote, but found ${emptyLineCount}. Lines after quote: ${JSON.stringify(afterQuote.slice(0, 5))}`, ).toBe(1); }); + + it("should not add a trailing blank line when blockquote is the last rendered block", () => { + const markdown = new Markdown("> This is a quote", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + expect(plainLines.at(-1)).not.toBe(""); + }); }); describe("Blockquotes with multiline content", () => { diff --git a/packages/tui/test/select-list.test.ts b/packages/tui/test/select-list.test.ts new file mode 100644 index 000000000..f9a92765c --- /dev/null +++ b/packages/tui/test/select-list.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; +import { SelectList } from "../src/components/select-list"; +import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings"; + +const testTheme = { + selectedPrefix: (text: string) => text, + selectedText: (text: string) => text, + description: (text: string) => text, + scrollInfo: (text: string) => text, + noMatch: (text: string) => text, + symbols: { + cursor: "→", + inputCursor: "|", + hrChar: "─", + quoteBorder: "│", + boxRound: { topLeft: "╭", topRight: "╮", bottomLeft: "╰", bottomRight: "╯", horizontal: "─", vertical: "│" }, + boxSharp: { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│", + teeDown: "┬", + teeUp: "┴", + teeLeft: "┤", + teeRight: "├", + cross: "┼", + }, + table: { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│", + teeDown: "┬", + teeUp: "┴", + teeLeft: "┤", + teeRight: "├", + cross: "┼", + }, + spinnerFrames: ["|"], + }, +}; + +const visibleIndexOf = (line: string, text: string): number => { + const index = line.indexOf(text); + expect(index).not.toBe(-1); + return visibleWidth(line.slice(0, index)); +}; + +describe("SelectList", () => { + beforeEach(() => { + setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS)); + }); + + afterEach(() => { + setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS)); + }); + + it("normalizes multiline descriptions to single line", () => { + const items = [ + { + value: "test", + label: "test", + description: "Line one\nLine two\nLine three", + }, + ]; + + const list = new SelectList(items, 5, testTheme); + const rendered = list.render(80); + + expect(rendered.length).toBeGreaterThanOrEqual(1); + expect(rendered[0]).not.toContain("\n"); + expect(rendered[0]).toContain("Line one Line two Line three"); + }); + + it("keeps descriptions aligned when the primary text is truncated", () => { + const items = [ + { value: "short", label: "short", description: "short description" }, + { + value: "very-long-command-name-that-needs-truncation", + label: "very-long-command-name-that-needs-truncation", + description: "long description", + }, + ]; + + const list = new SelectList(items, 5, testTheme); + const rendered = list.render(80); + + expect(visibleIndexOf(rendered[0], "short description")).toBe(visibleIndexOf(rendered[1], "long description")); + }); + + it("uses the configured minimum primary column width", () => { + const items = [ + { value: "a", label: "a", description: "first" }, + { value: "bb", label: "bb", description: "second" }, + ]; + + const list = new SelectList(items, 5, testTheme, { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 20, + }); + const rendered = list.render(80); + + expect(rendered[0].indexOf("first")).toBe(14); + expect(rendered[1].indexOf("second")).toBe(14); + }); + + it("uses the configured maximum primary column width", () => { + const items = [ + { + value: "very-long-command-name-that-needs-truncation", + label: "very-long-command-name-that-needs-truncation", + description: "first", + }, + { value: "short", label: "short", description: "second" }, + ]; + + const list = new SelectList(items, 5, testTheme, { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 20, + }); + const rendered = list.render(80); + + expect(visibleIndexOf(rendered[0], "first")).toBe(22); + expect(visibleIndexOf(rendered[1], "second")).toBe(22); + }); + + it("allows overriding primary truncation while preserving description alignment", () => { + const items = [ + { + value: "very-long-command-name-that-needs-truncation", + label: "very-long-command-name-that-needs-truncation", + description: "first", + }, + { value: "short", label: "short", description: "second" }, + ]; + + const list = new SelectList(items, 5, testTheme, { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 12, + truncatePrimary: ({ text, maxWidth }) => { + if (text.length <= maxWidth) { + return text; + } + + return `${text.slice(0, Math.max(0, maxWidth - 1))}…`; + }, + }); + const rendered = list.render(80); + + expect(rendered[0]).toContain("…"); + expect(visibleIndexOf(rendered[0], "first")).toBe(visibleIndexOf(rendered[1], "second")); + }); + + it("confirms the selected item when Enter arrives as LF", () => { + const items = [{ value: "run", label: "run" }]; + const list = new SelectList(items, 5, testTheme); + let selectedValue: string | undefined; + list.onSelect = item => { + selectedValue = item.value; + }; + + list.handleInput("\n"); + + expect(selectedValue).toBe("run"); + }); +}); diff --git a/packages/tui/test/settings-list.test.ts b/packages/tui/test/settings-list.test.ts new file mode 100644 index 000000000..ad7d87134 --- /dev/null +++ b/packages/tui/test/settings-list.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { SettingsList, type SettingsListTheme } from "../src/components/settings-list"; +import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings"; + +const testTheme: SettingsListTheme = { + label: (text: string) => text, + value: (text: string) => text, + description: (text: string) => text, + cursor: "→ ", + hint: (text: string) => text, +}; + +describe("SettingsList", () => { + beforeEach(() => { + setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS)); + }); + + afterEach(() => { + setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS)); + }); + + it("cycles the selected value when Enter arrives as LF", () => { + const changes: Array<[string, string]> = []; + const list = new SettingsList( + [ + { + id: "mode", + label: "Mode", + currentValue: "off", + values: ["off", "on"], + }, + ], + 5, + testTheme, + (id, value) => { + changes.push([id, value]); + }, + () => { + throw new Error("cancel should not be called"); + }, + ); + + list.handleInput("\n"); + + expect(changes).toEqual([["mode", "on"]]); + }); +}); diff --git a/packages/tui/test/truncate-to-width.test.ts b/packages/tui/test/truncate-to-width.test.ts new file mode 100644 index 000000000..8d879f358 --- /dev/null +++ b/packages/tui/test/truncate-to-width.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import { Ellipsis, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils"; + +describe("truncateToWidth", () => { + it("keeps output within width for very large unicode input", () => { + const text = "🙂界".repeat(100_000); + const truncated = truncateToWidth(text, 40, Ellipsis.Unicode); + + expect(visibleWidth(truncated)).toBeLessThanOrEqual(40); + }); + + it("preserves ANSI styling for kept text", () => { + const text = `\x1b[31m${"hello ".repeat(1000)}\x1b[0m`; + const truncated = truncateToWidth(text, 20, Ellipsis.Unicode); + + expect(visibleWidth(truncated)).toBeLessThanOrEqual(20); + expect(truncated.includes("\x1b[31m")).toBe(true); + }); + + it("handles malformed ANSI escape prefixes without hanging", () => { + const text = `abc\x1bnot-ansi ${"🙂".repeat(1000)}`; + // Should complete without hanging — the exact width depends on how the + // native implementation classifies the malformed escape prefix. + const truncated = truncateToWidth(text, 20, Ellipsis.Unicode); + expect(typeof truncated).toBe("string"); + }); + + it("returns the original text when it already fits", () => { + expect(truncateToWidth("a", 2, Ellipsis.Unicode)).toBe("a"); + expect(truncateToWidth("界", 2, Ellipsis.Unicode)).toBe("界"); + }); + + it("pads truncated output to requested width", () => { + const truncated = truncateToWidth("🙂界🙂界🙂界", 8, Ellipsis.Unicode, true); + expect(visibleWidth(truncated)).toBe(8); + }); + + it("adds a trailing reset when truncating without an ellipsis", () => { + const truncated = truncateToWidth(`\x1b[31m${"hello".repeat(100)}`, 10, Ellipsis.Omit); + expect(visibleWidth(truncated)).toBeLessThanOrEqual(10); + expect(truncated.endsWith("\x1b[0m")).toBe(true); + }); +}); + +describe("visibleWidth", () => { + it("counts tabs inline and skips ANSI inline", () => { + expect(visibleWidth("\t\x1b[31m界\x1b[0m")).toBe(5); + }); +});