diff --git a/docs/porting-from-pi-mono.md b/docs/porting-from-pi-mono.md index 0ba86b584..ced54a7b7 100644 --- a/docs/porting-from-pi-mono.md +++ b/docs/porting-from-pi-mono.md @@ -5,8 +5,8 @@ Use it for any merge: single file, feature branch, or full release sync. ## Last Sync Point -**Commit:** `b74535dc` -**Date:** January 2026 +**Commit:** `72047c05` +**Date:** 2026-01-14 When starting a new sync, generate patches from this commit forward: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c5f864c3f..019bc2b37 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,25 @@ # Changelog ## [Unreleased] +### Added + +- Added automatic retry logic for OpenAI Codex responses with configurable delay and max retries +- Added tool call ID sanitization for Amazon Bedrock to ensure valid characters +- Added support for Bedrock Claude thinking budget management with automatic token allocation +- Added numeric string coercion in validation to handle string numbers in tool arguments + +### Changed + +- Improved error handling in stream processing with better message filtering +- Enhanced validation to automatically convert numeric strings to numbers when appropriate + +### Fixed + +- Fixed tool prefix handling in Claude provider to use case-insensitive comparison +- Fixed Gemini 3 model handling to treat unsigned tool calls as context-only +- Fixed message transformation to filter out empty error messages from conversation history +- Fixed OpenAI completions provider to avoid using developer role for opencode provider +- Fixed orphaned tool call handling to skip synthetic results for errored assistant messages ## [5.5.0] - 2026-01-18 ### Changed diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 309fb2bde..1f4b55fb6 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -54,6 +54,10 @@ export interface BedrockOptions extends StreamOptions { type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string }; +function sanitizeToolCallId(id: string): string { + return id.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = ( model: Model<"bedrock-converse-stream">, context: Context, @@ -165,7 +169,7 @@ function handleContentBlockStart( if (start?.toolUse) { const block: Block = { type: "toolCall", - id: start.toolUse.toolUseId || "", + id: sanitizeToolCallId(start.toolUse.toolUseId || ""), name: start.toolUse.name || "", arguments: {}, partialJson: "", @@ -348,7 +352,11 @@ function convertMessages(context: Context, model: Model<"bedrock-converse-stream break; case "toolCall": contentBlocks.push({ - toolUse: { toolUseId: c.id, name: c.name, input: c.arguments }, + toolUse: { + toolUseId: sanitizeToolCallId(c.id), + name: c.name, + input: c.arguments, + }, }); break; case "thinking": @@ -382,7 +390,7 @@ function convertMessages(context: Context, model: Model<"bedrock-converse-stream // Add current tool result with all content blocks combined toolResults.push({ toolResult: { - toolUseId: m.toolCallId, + toolUseId: sanitizeToolCallId(m.toolCallId), content: m.content.map((c) => c.type === "image" ? { image: createImageBlock(c.mimeType, c.data) } @@ -398,7 +406,7 @@ function convertMessages(context: Context, model: Model<"bedrock-converse-stream const nextMsg = transformedMessages[j] as ToolResultMessage; toolResults.push({ toolResult: { - toolUseId: nextMsg.toolCallId, + toolUseId: sanitizeToolCallId(nextMsg.toolCallId), content: nextMsg.content.map((c) => c.type === "image" ? { image: createImageBlock(c.mimeType, c.data) } diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 438e79ccd..4db6ec747 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -47,12 +47,16 @@ export const claudeCodeHeaders = { } as const; export const applyClaudeToolPrefix = (name: string) => { - if (!claudeToolPrefix || name.startsWith(claudeToolPrefix)) return name; + if (!claudeToolPrefix) return name; + const prefix = claudeToolPrefix.toLowerCase(); + if (name.toLowerCase().startsWith(prefix)) return name; return `${claudeToolPrefix}${name}`; }; export const stripClaudeToolPrefix = (name: string) => { - if (!claudeToolPrefix || !name.startsWith(claudeToolPrefix)) return name; + if (!claudeToolPrefix) return name; + const prefix = claudeToolPrefix.toLowerCase(); + if (!name.toLowerCase().startsWith(prefix)) return name; return name.slice(claudeToolPrefix.length); }; diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 2f4f19b1d..b397d6769 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -65,6 +65,10 @@ export function requiresToolCallId(modelId: string): boolean { return modelId.startsWith("claude-"); } +function isGemini3Model(modelId: string): boolean { + return modelId.includes("gemini-3"); +} + /** * Convert internal messages to Gemini Content[] format. */ @@ -131,6 +135,18 @@ export function convertMessages(model: Model, contex }); } } else if (block.type === "toolCall") { + const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature); + if (isGemini3Model(model.id) && !thoughtSignature) { + const args = JSON.stringify(block.arguments ?? {}); + parts.push({ + text: sanitizeSurrogates( + `Unsigned tool call from a previous model: ${block.name}(${args}). ` + + `Treat this as context only and do not attempt to mimic or execute the tool call.`, + ), + }); + continue; + } + const part: Part = { functionCall: { name: block.name, @@ -141,7 +157,6 @@ export function convertMessages(model: Model, contex if (model.provider === "google-vertex" && part?.functionCall?.id) { delete part.functionCall.id; // Vertex AI does not support 'id' in functionCall } - const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature); if (thoughtSignature) { part.thoughtSignature = thoughtSignature; } diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 7e274f364..149c8dd5d 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -50,6 +50,9 @@ export interface OpenAICodexResponsesOptions extends StreamOptions { } const CODEX_DEBUG = process.env.PI_CODEX_DEBUG === "1" || process.env.PI_CODEX_DEBUG === "true"; +const CODEX_MAX_RETRIES = 2; +const CODEX_RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]); +const CODEX_RETRY_DELAY_MS = 500; export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"> = ( model: Model<"openai-codex-responses">, @@ -134,12 +137,15 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" headers: redactHeaders(headers), }); - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(transformedBody), - signal: options?.signal, - }); + const response = await fetchWithRetry( + url, + { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + }, + options?.signal, + ); logCodexDebug("codex response", { url: response.url, @@ -409,6 +415,43 @@ function logCodexDebug(message: string, details?: Record): void console.error(`[codex] ${message}`); } +function getRetryDelayMs(response: Response | null, attempt: number): number { + const retryAfter = response?.headers?.get("retry-after") || null; + if (retryAfter) { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + const parsedDate = Date.parse(retryAfter); + if (!Number.isNaN(parsedDate)) { + return Math.max(0, parsedDate - Date.now()); + } + } + return CODEX_RETRY_DELAY_MS * (attempt + 1); +} + +async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSignal): Promise { + let attempt = 0; + while (true) { + try { + const response = await fetch(url, { ...init, signal: signal ?? init.signal }); + if (!CODEX_RETRYABLE_STATUS.has(response.status) || attempt >= CODEX_MAX_RETRIES) { + return response; + } + if (signal?.aborted) return response; + const delay = getRetryDelayMs(response, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } catch (error) { + if (attempt >= CODEX_MAX_RETRIES || signal?.aborted) { + throw error; + } + const delay = CODEX_RETRY_DELAY_MS * (attempt + 1); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + attempt += 1; + } +} + function redactHeaders(headers: Headers): Record { const redacted: Record = {}; for (const [key, value] of headers.entries()) { diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index b968fbf86..0cefc6bd1 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -460,7 +460,7 @@ function convertMessages( const transformedMessages = transformMessages(context.messages, model); if (context.systemPrompt) { - const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole; + const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole && model.provider !== "opencode"; const role = useDeveloperRole ? "developer" : "system"; params.push({ role: role, content: sanitizeSurrogates(context.systemPrompt) }); } @@ -685,13 +685,15 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"]): Sto */ function detectCompatFromUrl(baseUrl: string): Required { const isZai = baseUrl.includes("api.z.ai"); + const isOpencode = baseUrl.includes("opencode.ai"); const isNonStandard = baseUrl.includes("cerebras.ai") || baseUrl.includes("api.x.ai") || baseUrl.includes("mistral.ai") || baseUrl.includes("chutes.ai") || - isZai; + isZai || + isOpencode; const useMaxTokens = baseUrl.includes("mistral.ai") || baseUrl.includes("chutes.ai"); diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index b0ea8db1f..6db68b436 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -14,19 +14,19 @@ export function transformMessages(messages: Message[], model: const toolCallIdMap = new Map(); // First pass: transform messages (thinking blocks, tool call ID normalization) - const transformed = messages.map((msg) => { + const transformed = messages.flatMap((msg) => { // User messages pass through unchanged if (msg.role === "user") { - return msg; + return [msg]; } // Handle toolResult messages - normalize toolCallId if we have a mapping if (msg.role === "toolResult") { const normalizedId = toolCallIdMap.get(msg.toolCallId); if (normalizedId && normalizedId !== msg.toolCallId) { - return { ...msg, toolCallId: normalizedId }; + return [{ ...msg, toolCallId: normalizedId }]; } - return msg; + return [msg]; } // Assistant messages need transformation check @@ -35,7 +35,10 @@ export function transformMessages(messages: Message[], model: // If message is from the same provider and API, keep as is if (assistantMsg.provider === model.provider && assistantMsg.api === model.api) { - return msg; + if (assistantMsg.stopReason === "error" && assistantMsg.content.length === 0) { + return []; + } + return [msg]; } // Check if we need to normalize tool call IDs @@ -73,13 +76,19 @@ export function transformMessages(messages: Message[], model: return block; }); + if (assistantMsg.stopReason === "error" && transformedContent.length === 0) { + return []; + } + // Return transformed assistant message - return { - ...assistantMsg, - content: transformedContent, - }; + return [ + { + ...assistantMsg, + content: transformedContent, + }, + ]; } - return msg; + return [msg]; }); // Second pass: insert synthetic empty tool results for orphaned tool calls @@ -110,12 +119,16 @@ export function transformMessages(messages: Message[], model: existingToolResultIds = new Set(); } - // Track tool calls from this assistant message + // Track tool calls from this assistant message unless it errored const assistantMsg = msg as AssistantMessage; + const isErroredAssistant = assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted"; const toolCalls = assistantMsg.content.filter((b) => b.type === "toolCall") as ToolCall[]; - if (toolCalls.length > 0) { + if (!isErroredAssistant && toolCalls.length > 0) { pendingToolCalls = toolCalls; existingToolResultIds = new Set(); + } else if (isErroredAssistant) { + pendingToolCalls = []; + existingToolResultIds = new Set(); } result.push(msg); diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index bd8c3799b..2f13b8800 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -223,6 +223,25 @@ const GOOGLE_THINKING: Record = { xhigh: 24575, }; +const BEDROCK_CLAUDE_THINKING: Record = { + minimal: 1024, + low: 2048, + medium: 8192, + high: 16384, + xhigh: 16384, +}; + +function resolveBedrockThinkingBudget( + model: Model<"bedrock-converse-stream">, + options?: SimpleStreamOptions, +): { budget: number; level: ThinkingLevel } | null { + if (!options?.reasoning || !model.reasoning) return null; + if (!model.id.includes("anthropic.claude")) return null; + const level = options.reasoning === "xhigh" ? "high" : options.reasoning; + const budget = options.thinkingBudgets?.[level] ?? BEDROCK_CLAUDE_THINKING[level]; + return { budget, level }; +} + function mapOptionsForApi( model: Model, options?: SimpleStreamOptions, @@ -282,12 +301,28 @@ function mapOptionsForApi( } } - case "bedrock-converse-stream": - return { + case "bedrock-converse-stream": { + const bedrockBase: BedrockOptions = { ...base, reasoning: options?.reasoning, thinkingBudgets: options?.thinkingBudgets, - } satisfies BedrockOptions; + }; + const budgetInfo = resolveBedrockThinkingBudget(model as Model<"bedrock-converse-stream">, options); + if (!budgetInfo) return bedrockBase as OptionsForApi; + let maxTokens = bedrockBase.maxTokens ?? model.maxTokens; + let thinkingBudgets = bedrockBase.thinkingBudgets; + if (maxTokens <= budgetInfo.budget) { + const desiredMaxTokens = Math.min(model.maxTokens, budgetInfo.budget + MIN_OUTPUT_TOKENS); + if (desiredMaxTokens > maxTokens) { + maxTokens = desiredMaxTokens; + } + } + if (maxTokens <= budgetInfo.budget) { + const adjustedBudget = Math.max(0, maxTokens - MIN_OUTPUT_TOKENS); + thinkingBudgets = { ...(thinkingBudgets ?? {}), [budgetInfo.level]: adjustedBudget }; + } + return { ...bedrockBase, maxTokens, thinkingBudgets } satisfies BedrockOptions; + } case "openai-completions": return { diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 7bc94ec39..8edc69f39 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -29,6 +29,9 @@ import type { Tool, ToolCall } from "../types"; /** Regex matching valid JSON number literals (integers, decimals, scientific notation) */ const JSON_NUMBER_PATTERN = /^[+-]?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/; +/** Regex matching numeric strings (allows leading zeros) */ +const NUMERIC_STRING_PATTERN = /^[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/; + /** * Normalizes AJV's `params.type` into a consistent string array. * AJV may report the expected type as a single string or an array of strings @@ -69,6 +72,28 @@ function matchesExpectedType(value: unknown, expectedTypes: string[]): boolean { }); } +function tryParseNumberString(value: string, expectedTypes: string[]): { value: unknown; changed: boolean } { + if (!expectedTypes.includes("number") && !expectedTypes.includes("integer")) { + return { value, changed: false }; + } + + const trimmed = value.trim(); + if (!trimmed || !NUMERIC_STRING_PATTERN.test(trimmed)) { + return { value, changed: false }; + } + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + return { value, changed: false }; + } + + if (!matchesExpectedType(parsed, expectedTypes)) { + return { value, changed: false }; + } + + return { value: parsed, changed: true }; +} + /** * Attempts to parse a string as JSON if it looks like a JSON literal and * the parsed result matches one of the expected types. @@ -86,6 +111,11 @@ function tryParseJsonForTypes(value: string, expectedTypes: string[]): { value: const trimmed = value.trim(); if (!trimmed) return { value, changed: false }; + const numberCoercion = tryParseNumberString(trimmed, expectedTypes); + if (numberCoercion.changed) { + return numberCoercion; + } + // Quick syntactic checks to avoid unnecessary parse attempts const looksJsonObject = trimmed.startsWith("{") && trimmed.endsWith("}"); const looksJsonArray = trimmed.startsWith("[") && trimmed.endsWith("]"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 250f43023..dd6b2dd11 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,38 @@ # Changelog ## [Unreleased] +### Added + +- Added support for input event handlers in extensions to intercept and modify user input +- Added argument completion support for custom commands in extensions +- Added compact() method to extension command context for programmatic session compaction +- Added setLabel() method to extension API for custom display labels +- Added startup quiet setting to suppress welcome screen and startup messages +- Added support for auto-discovering APPEND_SYSTEM.md files +- Added support for piped input in non-interactive mode +- Added global session listing across all project directories +- Added enhanced session search including title, cwd, and path +- Added key hint formatting utilities for UI labels +- Added timeout display in bash tool output +- Added fuzzy text normalization for improved diff matching +- Added $@ argument slicing syntax in prompt templates +- Added configurable keybindings for expand tools and dequeue actions + +### Changed + +- Improved error retry detection to include fetch failures +- Enhanced session selector to search across more metadata fields +- Updated skill command execution to include skill path metadata +- Streamlined session file collection with shared utility function +- Improved extension runner error handling and logging +- Enhanced bash tool renderer to show timeout information + +### Fixed + +- Fixed duplicate custom message rendering in event controller +- Fixed session selector width truncation for long text +- Fixed model selection to respect remembered model roles +- Fixed auto-print mode detection when piped input is present ## [5.5.0] - 2026-01-18 ### Changed diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index ee30f6a3d..8745723d3 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -5,6 +5,8 @@ * * Test with: npx tsx src/cli-new.ts [args...] */ +import { APP_NAME } from "./config"; import { main } from "./main"; +process.title = APP_NAME; main(process.argv.slice(2)); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 72a7d90b7..c2c9e096e 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -967,7 +967,7 @@ export class AgentSession { }); } else { const message = err instanceof Error ? err.message : String(err); - console.error(`Custom command "${commandName}" failed: ${message}`); + logger.error("Custom command failed", { commandName, error: message }); } return ""; // Command was handled (with error) } @@ -2092,8 +2092,8 @@ export class AgentSession { } private _isRetryableErrorMessage(errorMessage: string): boolean { - // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error - return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error/i.test( + // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed + return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|fetch failed/i.test( errorMessage, ); } diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index a711119a2..4bc374cbe 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -7,7 +7,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Model, Usage } from "@oh-my-pi/pi-ai"; -import { complete, completeSimple } from "@oh-my-pi/pi-ai"; +import { completeSimple } from "@oh-my-pi/pi-ai"; import compactionSummaryPrompt from "../../prompts/compaction/compaction-summary.md" with { type: "text" }; import compactionTurnPrefixPrompt from "../../prompts/compaction/compaction-turn-prefix.md" with { type: "text" }; import compactionUpdateSummaryPrompt from "../../prompts/compaction/compaction-update-summary.md" with { type: "text" }; @@ -642,17 +642,22 @@ async function generateTurnPrefixSummary( ): Promise { const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix - const transformedMessages = convertToLlm(messages); + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; const summarizationMessages = [ - ...transformedMessages, { role: "user" as const, - content: [{ type: "text" as const, text: TURN_PREFIX_SUMMARIZATION_PROMPT }], + content: [{ type: "text" as const, text: promptText }], timestamp: Date.now(), }, ]; - const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey }); + const response = await completeSimple( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + { maxTokens, signal, apiKey, reasoning: "high" }, + ); if (response.stopReason === "error") { throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`); diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 4e1f08073..365bc6909 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -54,6 +54,8 @@ export type { GetAllToolsHandler, GetThinkingLevelHandler, GrepToolResultEvent, + InputEvent, + InputEventResult, KeybindingsManager, LoadExtensionsResult, LsToolResultEvent, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index f61bded9e..484188b8d 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -88,10 +88,21 @@ function createExtensionAPI( }); }, - registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void { + registerCommand( + name: string, + options: { + description?: string; + getArgumentCompletions?: RegisteredCommand["getArgumentCompletions"]; + handler: RegisteredCommand["handler"]; + }, + ): void { extension.commands.set(name, { name, ...options }); }, + setLabel(label: string): void { + extension.label = label; + }, + registerShortcut( shortcut: KeyId, options: { diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 290adec79..5c450ac60 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -26,6 +26,8 @@ import type { ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, + InputEvent, + InputEventResult, MessageRenderer, RegisteredCommand, RegisteredTool, @@ -113,6 +115,7 @@ export class ExtensionRunner { private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); private branchHandler: BranchHandler = async () => ({ cancelled: false }); private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); + private compactHandler: (customInstructions?: string) => Promise = async () => {}; private shutdownHandler: ShutdownHandler = () => {}; constructor( @@ -160,6 +163,7 @@ export class ExtensionRunner { this.newSessionHandler = commandContextActions.newSession; this.branchHandler = commandContextActions.branch; this.navigateTreeHandler = commandContextActions.navigateTree; + this.compactHandler = commandContextActions.compact; } this.uiContext = uiContext ?? noOpUIContext; @@ -328,6 +332,7 @@ export class ExtensionRunner { newSession: (options) => this.newSessionHandler(options), branch: (entryId) => this.branchHandler(entryId), navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options), + compact: (customInstructions) => this.compactHandler(customInstructions), }; } @@ -446,6 +451,53 @@ export class ExtensionRunner { return undefined; } + async emitInput(event: InputEvent): Promise { + const ctx = this.createContext(); + let currentText = event.text; + let currentImages = event.images; + let changed = false; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("input"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const handlerResult = await handler({ type: "input", text: currentText, images: currentImages }, ctx); + if (handlerResult) { + const result = handlerResult as InputEventResult; + if (result.text !== undefined) { + currentText = result.text; + changed = true; + } + if (result.images !== undefined) { + currentImages = result.images; + changed = true; + } + if (result.handled) { + return { handled: true, text: currentText, images: currentImages }; + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "input", + error: message, + stack, + }); + } + } + } + + if (!changed) { + return undefined; + } + + return { text: currentText, images: currentImages }; + } + async emitContext(messages: AgentMessage[]): Promise { const ctx = this.createContext(); let currentMessages = structuredClone(messages); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 3d4d730fd..c29eb2383 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -10,7 +10,7 @@ import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; -import type { Component, EditorComponent, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui"; +import type { AutocompleteItem, Component, EditorComponent, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui"; import type { Static, TSchema } from "@sinclair/typebox"; import type * as piCodingAgent from "../../index"; import type { Theme } from "../../modes/interactive/theme/theme"; @@ -172,6 +172,9 @@ export interface ExtensionCommandContext extends ExtensionContext { /** Navigate to a different point in the session tree. */ navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>; + + /** Compact the session context (interactive mode shows UI). */ + compact(customInstructions?: string): Promise; } // ============================================================================ @@ -382,6 +385,17 @@ export interface UserBashEvent { cwd: string; } +// ============================================================================ +// Input Events +// ============================================================================ + +/** Fired when the user submits input (interactive mode only). */ +export interface InputEvent { + type: "input"; + text: string; + images?: ImageContent[]; +} + // ============================================================================ // Tool Events // ============================================================================ @@ -486,6 +500,7 @@ export type ExtensionEvent = | TurnStartEvent | TurnEndEvent | UserBashEvent + | InputEvent | ToolCallEvent | ToolResultEvent; @@ -502,6 +517,16 @@ export interface ToolCallEventResult { reason?: string; } +/** Result from input event handler */ +export interface InputEventResult { + /** If true, the input was handled and should not continue through normal flow */ + handled?: boolean; + /** Replace the input text */ + text?: string; + /** Replace any pending images */ + images?: ImageContent[]; +} + /** Result from user_bash event handler */ export interface UserBashEventResult { /** Custom operations to use for execution */ @@ -565,6 +590,7 @@ export type MessageRenderer = ( export interface RegisteredCommand { name: string; description?: string; + getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null; handler: (args: string, ctx: ExtensionCommandContext) => Promise; } @@ -622,6 +648,7 @@ export interface ExtensionAPI { on(event: "agent_end", handler: ExtensionHandler): void; on(event: "turn_start", handler: ExtensionHandler): void; on(event: "turn_end", handler: ExtensionHandler): void; + on(event: "input", handler: ExtensionHandler): void; on(event: "tool_call", handler: ExtensionHandler): void; on(event: "tool_result", handler: ExtensionHandler): void; on(event: "user_bash", handler: ExtensionHandler): void; @@ -638,7 +665,14 @@ export interface ExtensionAPI { // ========================================================================= /** Register a custom command. */ - registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void; + registerCommand( + name: string, + options: { + description?: string; + getArgumentCompletions?: RegisteredCommand["getArgumentCompletions"]; + handler: RegisteredCommand["handler"]; + }, + ): void; /** Register a keyboard shortcut. */ registerShortcut( @@ -659,6 +693,9 @@ export interface ExtensionAPI { }, ): void; + /** Set the display label for this extension. */ + setLabel(label: string): void; + /** Get the value of a registered CLI flag. */ getFlag(name: string): boolean | string | undefined; @@ -802,6 +839,7 @@ export interface ExtensionCommandContextActions { }) => Promise<{ cancelled: boolean }>; branch: (entryId: string) => Promise<{ cancelled: boolean }>; navigateTree: (targetId: string, options?: { summarize?: boolean }) => Promise<{ cancelled: boolean }>; + compact: (customInstructions?: string) => Promise; } /** Full runtime = state + actions. */ @@ -811,6 +849,7 @@ export interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionAction export interface Extension { path: string; resolvedPath: string; + label?: string; handlers: Map; tools: Map; messageRenderers: Map; diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 92f3ba447..5a3f6d63a 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -93,6 +93,53 @@ function isAppAction(action: string): action is AppAction { return APP_ACTIONS.includes(action as AppAction); } +/** + * Key hint formatting utilities for UI labels. + */ +const MODIFIER_LABELS: Record = { + ctrl: "Ctrl", + shift: "Shift", + alt: "Alt", +}; + +const KEY_LABELS: Record = { + esc: "Esc", + escape: "Esc", + enter: "Enter", + return: "Enter", + space: "Space", + tab: "Tab", + backspace: "Backspace", + delete: "Delete", + home: "Home", + end: "End", + pageup: "PgUp", + pagedown: "PgDn", + up: "Up", + down: "Down", + left: "Left", + right: "Right", +}; + +function formatKeyPart(part: string): string { + const lower = part.toLowerCase(); + const modifier = MODIFIER_LABELS[lower]; + if (modifier) return modifier; + const label = KEY_LABELS[lower]; + if (label) return label; + if (part.length === 1) return part.toUpperCase(); + return `${part.charAt(0).toUpperCase()}${part.slice(1)}`; +} + +export function formatKeyHint(key: KeyId): string { + return key.split("+").map(formatKeyPart).join("+"); +} + +export function formatKeyHints(keys: KeyId | KeyId[]): string { + const list = Array.isArray(keys) ? keys : [keys]; + return list.map(formatKeyHint).join("/"); +} + /** * Manages all keybindings (app + editor). */ diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index ebd65fd53..94ab6ccda 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -335,6 +335,21 @@ export function substituteArgs(content: string, args: string[]): string { return args[index] ?? ""; }); + result = result.replace(/\$@\[(\d+)(?::(\d*)?)?\]/g, (_, startRaw: string, lengthRaw?: string) => { + const start = Number.parseInt(startRaw, 10); + if (!Number.isFinite(start) || start < 1) return ""; + const startIndex = start - 1; + if (startIndex >= args.length) return ""; + + if (lengthRaw === undefined || lengthRaw === "") { + return args.slice(startIndex).join(" "); + } + + const length = Number.parseInt(lengthRaw, 10); + if (!Number.isFinite(length) || length <= 0) return ""; + return args.slice(startIndex, startIndex + length).join(" "); + }); + // Pre-compute all args joined (optimization) const allArgs = args.join(" "); diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 26d31b09a..6ef825e7a 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -726,7 +726,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Log MCP errors for (const { path, error } of mcpResult.errors) { - console.error(`MCP "${path}": ${error}`); + logger.error("MCP tool load failed", { path, error }); } if (mcpResult.tools.length > 0) { @@ -784,7 +784,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} ); time("discoverAndLoadExtensions"); for (const { path, error } of extensionsResult.errors) { - console.error(`Failed to load extension "${path}": ${error}`); + logger.error("Failed to load extension", { path, error }); } } @@ -804,10 +804,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } // Discover custom commands (TypeScript slash commands) - const customCommandsResult = await loadCustomCommandsInternal({ cwd, agentDir }); - time("discoverCustomCommands"); - for (const { path, error } of customCommandsResult.errors) { - console.error(`Failed to load custom command "${path}": ${error}`); + const customCommandsResult: CustomCommandsLoadResult = options.disableExtensionDiscovery + ? { commands: [], errors: [] } + : await loadCustomCommandsInternal({ cwd, agentDir }); + if (!options.disableExtensionDiscovery) { + time("discoverCustomCommands"); + for (const { path, error } of customCommandsResult.errors) { + logger.error("Failed to load custom command", { path, error }); + } } let extensionRunner: ExtensionRunner | undefined; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 12fe206fd..bcd2e99a4 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -456,8 +456,8 @@ export function loadEntriesFromFile(filePath: string, storage: SessionStorage = // Validate session header if (entries.length === 0) return entries; - const header = entries[0]; - if (header.type !== "session" || typeof (header as any).id !== "string") { + const header = entries[0] as SessionHeader; + if (header.type !== "session" || typeof header.id !== "string") { return []; } @@ -834,6 +834,85 @@ function getTaskToolUsage(details: unknown): Usage | undefined { return usage as Usage; } +function extractTextFromContent(content: Message["content"]): string { + if (typeof content === "string") return content; + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join(" "); +} + +function collectSessionsFromFiles(files: string[], storage: SessionStorage): SessionInfo[] { + const sessions: SessionInfo[] = []; + + for (const file of files) { + try { + const content = storage.readTextSync(file); + const lines = content.trim().split("\n"); + if (lines.length === 0) continue; + + // Check first line for valid session header + type SessionHeaderShape = { type: string; id: string; cwd?: string; title?: string; timestamp: string }; + let header: SessionHeaderShape | null = null; + try { + const first = JSON.parse(lines[0]) as SessionHeaderShape; + if (first.type === "session" && first.id) { + header = first; + } + } catch { + // Not valid JSON + } + if (!header) continue; + + const stats = storage.statSync(file); + let messageCount = 0; + let firstMessage = ""; + const allMessages: string[] = []; + + for (let i = 1; i < lines.length; i++) { + try { + const entry = JSON.parse(lines[i]) as { type?: string; message?: Message }; + + if (entry.type === "message" && entry.message) { + messageCount++; + + if (entry.message.role === "user" || entry.message.role === "assistant") { + const textContent = extractTextFromContent(entry.message.content); + + if (textContent) { + allMessages.push(textContent); + + if (!firstMessage && entry.message.role === "user") { + firstMessage = textContent; + } + } + } + } + } catch { + // Skip malformed lines + } + } + + sessions.push({ + path: file, + id: header.id, + cwd: typeof header.cwd === "string" ? header.cwd : "", + title: header.title, + created: new Date(header.timestamp), + modified: stats.mtime, + messageCount, + firstMessage: firstMessage || "(no messages)", + allMessagesText: allMessages.join(" "), + }); + } catch { + // Skip files that can't be read + } + } + + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + return sessions; +} + export class SessionManager { private sessionId: string = ""; private sessionTitle: string | undefined; @@ -1699,82 +1778,26 @@ export class SessionManager { */ static list(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionInfo[] { const dir = sessionDir ?? getDefaultSessionDir(cwd, storage); - const sessions: SessionInfo[] = []; - try { const files = storage.listFilesSync(dir, "*.jsonl"); - - for (const file of files) { - try { - const content = storage.readTextSync(file); - const lines = content.trim().split("\n"); - if (lines.length === 0) continue; - - // Check first line for valid session header - let header: { type: string; id: string; cwd?: string; title?: string; timestamp: string } | null = null; - try { - const first = JSON.parse(lines[0]); - if (first.type === "session" && first.id) { - header = first; - } - } catch { - // Not valid JSON - } - if (!header) continue; - - const stats = storage.statSync(file); - let messageCount = 0; - let firstMessage = ""; - const allMessages: string[] = []; - - for (let i = 1; i < lines.length; i++) { - try { - const entry = JSON.parse(lines[i]); - - if (entry.type === "message") { - messageCount++; - - if (entry.message.role === "user" || entry.message.role === "assistant") { - const textContent = entry.message.content - .filter((c: any) => c.type === "text") - .map((c: any) => c.text) - .join(" "); - - if (textContent) { - allMessages.push(textContent); - - if (!firstMessage && entry.message.role === "user") { - firstMessage = textContent; - } - } - } - } - } catch { - // Skip malformed lines - } - } - - sessions.push({ - path: file, - id: header.id, - cwd: typeof header.cwd === "string" ? header.cwd : "", - title: header.title, - created: new Date(header.timestamp), - modified: stats.mtime, - messageCount, - firstMessage: firstMessage || "(no messages)", - allMessagesText: allMessages.join(" "), - }); - } catch { - // Skip files that can't be read - } - } - - sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + return collectSessionsFromFiles(files, storage); } catch { - // Return empty list on error + return []; } + } - return sessions; + /** + * List all sessions across all project directories. + */ + static listAll(storage: SessionStorage = new FileSessionStorage()): SessionInfo[] { + const sessionsRoot = join(getDefaultAgentDir(), "sessions"); + try { + const files = Array.from(new Bun.Glob("**/*.jsonl").scanSync(sessionsRoot)).map((name) => + join(sessionsRoot, name), + ); + return collectSessionsFromFiles(files, storage); + } catch { + return []; + } } } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index fa780f9e3..e7de4b120 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -46,6 +46,10 @@ export interface TerminalSettings { showImages?: boolean; // default: true (only relevant if terminal supports images) } +export interface StartupSettings { + quiet?: boolean; // default: false - suppress welcome screen and startup info +} + export interface ImageSettings { autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility) blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers @@ -194,6 +198,7 @@ export interface Settings { hideThinkingBlock?: boolean; shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) + startup?: StartupSettings; doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree") thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels /** Environment variables to set automatically on startup */ @@ -843,6 +848,18 @@ export class SettingsManager { await this.save(); } + getStartupQuiet(): boolean { + return this.settings.startup?.quiet ?? false; + } + + async setStartupQuiet(quiet: boolean): Promise { + if (!this.globalSettings.startup) { + this.globalSettings.startup = {}; + } + this.globalSettings.startup.quiet = quiet; + await this.save(); + } + getExtensionPaths(): string[] { return [...(this.settings.extensions ?? [])]; } diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 3456b39f2..445db1f5f 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -178,6 +178,8 @@ interface BashRenderContext { expanded?: boolean; /** Number of preview lines when collapsed */ previewLines?: number; + /** Timeout in seconds */ + timeout?: number; } // Preview line limit when not expanded (matches tool-execution behavior) @@ -236,6 +238,11 @@ export const bashToolRenderer = { // Build truncation warning lines (static, doesn't depend on width) const truncation = details?.truncation; const fullOutputPath = details?.fullOutputPath; + const timeoutSeconds = renderContext?.timeout; + const timeoutLine = + typeof timeoutSeconds === "number" + ? uiTheme.fg("dim", ui.wrapBrackets(`Timeout: ${timeoutSeconds}s`)) + : undefined; let warningLine: string | undefined; if (fullOutputPath || (truncation?.truncated && !showingFullOutput)) { const warnings: string[] = []; @@ -258,7 +265,8 @@ export const bashToolRenderer = { if (!displayOutput) { // No output - just show warning if any - return new Text(warningLine ?? "", 0, 0); + const lines = [timeoutLine, warningLine].filter(Boolean) as string[]; + return new Text(lines.join("\n"), 0, 0); } if (expanded) { @@ -267,7 +275,7 @@ export const bashToolRenderer = { .split("\n") .map((line) => uiTheme.fg("toolOutput", line)) .join("\n"); - const lines = warningLine ? [styledOutput, warningLine] : [styledOutput]; + const lines = [styledOutput, timeoutLine, warningLine].filter(Boolean) as string[]; return new Text(lines.join("\n"), 0, 0); } @@ -300,6 +308,9 @@ export const bashToolRenderer = { outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis))); } outputLines.push(...cachedLines); + if (timeoutLine) { + outputLines.push(truncateToWidth(timeoutLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis))); + } if (warningLine) { outputLines.push(truncateToWidth(warningLine, width, uiTheme.fg("warning", uiTheme.format.ellipsis))); } diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts index 49c17e119..c779e7404 100644 --- a/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -80,6 +80,13 @@ function computeRelativeIndentDepths(lines: string[]): number[] { }); } +function normalizeFuzzyText(text: string): string { + return text + .replace(/[“”„‟«»]/g, '"') + .replace(/[‘’‚‛`´]/g, "'") + .replace(/[‐‑‒–—−]/g, "-"); +} + function normalizeLinesForMatch(lines: string[], includeDepth = true): string[] { const indentDepths = includeDepth ? computeRelativeIndentDepths(lines) : null; return lines.map((line, index) => { @@ -88,7 +95,8 @@ function normalizeLinesForMatch(lines: string[], includeDepth = true): string[] if (trimmed.length === 0) { return prefix; } - const collapsed = trimmed.replace(/[ \t]+/g, " "); + const normalized = normalizeFuzzyText(trimmed); + const collapsed = normalized.replace(/[ \t]+/g, " "); return `${prefix}${collapsed}`; }); } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 4d20c33a7..ba76345a8 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -5,6 +5,7 @@ export { StringEnum } from "@oh-my-pi/pi-ai"; // Re-export TUI components for custom tool rendering export { Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; +export { getAgentDir, VERSION } from "./config"; export { AgentSession, type AgentSessionConfig, @@ -82,6 +83,8 @@ export type { ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, + InputEvent, + InputEventResult, KeybindingsManager, LoadExtensionsResult, MessageRenderer, @@ -107,6 +110,7 @@ export { } from "./core/extensions/index"; // Hook system types (legacy re-export) export type * from "./core/hooks/index"; +export { formatKeyHint, formatKeyHints } from "./core/keybindings"; // Logging export { type Logger, logger } from "./core/logger"; export { convertToLlm } from "./core/messages"; @@ -269,3 +273,4 @@ export { Theme, type ThemeColor, } from "./modes/interactive/theme/theme"; +export { getShellConfig } from "./utils/shell"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index e2c189ece..05019bc00 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -21,12 +21,13 @@ import type { AgentSession } from "./core/agent-session"; import { exportFromFile } from "./core/export-html/index"; import type { ExtensionUIContext } from "./core/index"; import type { ModelRegistry } from "./core/model-registry"; -import { parseModelPattern, resolveModelScope, type ScopedModel } from "./core/model-resolver"; +import { parseModelPattern, parseModelString, resolveModelScope, type ScopedModel } from "./core/model-resolver"; import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./core/sdk"; import { SessionManager } from "./core/session-manager"; import { SettingsManager } from "./core/settings-manager"; import { resolvePromptInput } from "./core/system-prompt"; import { printTimings, time } from "./core/timings"; +import { initializeWithSettings } from "./discovery"; import { runMigrations, showDeprecationWarnings } from "./migrations"; import { InteractiveMode, installTerminalCrashHandlers, runPrintMode, runRpcMode } from "./modes/index"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme"; @@ -50,6 +51,25 @@ async function checkForNewVersion(currentVersion: string): Promise { + process.stdout.write(`${message}\n`); +}; + +const writeStderr = (message: string): void => { + process.stderr.write(`${message}\n`); +}; + +async function readPipedInput(): Promise { + if (process.stdin.isTTY !== false) return undefined; + try { + const text = await Bun.stdin.text(); + if (text.trim().length === 0) return undefined; + return text; + } catch { + return undefined; + } +} + async function runInteractiveMode( session: AgentSession, version: string, @@ -156,7 +176,12 @@ function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string // Try to match as session ID (full or partial UUID) const sessions = SessionManager.list(cwd, sessionDir); - const matches = sessions.filter((session) => session.id.startsWith(sessionArg)); + let matches = sessions.filter((session) => session.id.startsWith(sessionArg)); + + if (matches.length === 0 && !sessionDir) { + const globalSessions = SessionManager.listAll(); + matches = globalSessions.filter((session) => session.id.startsWith(sessionArg)); + } if (matches.length >= 1) { return matches[0].path; // Already sorted by modified time (most recent first) @@ -279,6 +304,19 @@ function discoverSystemPromptFile(): string | undefined { return undefined; } +/** Discover APPEND_SYSTEM.md file if no CLI append system prompt was provided */ +function discoverAppendSystemPromptFile(): string | undefined { + const projectPath = findConfigFile("APPEND_SYSTEM.md", { user: false }); + if (projectPath) { + return projectPath; + } + const globalPath = findConfigFile("APPEND_SYSTEM.md", { user: true }); + if (globalPath) { + return globalPath; + } + return undefined; +} + async function buildSessionOptions( parsed: Args, scopedModels: ScopedModel[], @@ -293,7 +331,8 @@ async function buildSessionOptions( // Auto-discover SYSTEM.md if no CLI system prompt provided const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile(); const resolvedSystemPrompt = resolvePromptInput(systemPromptSource, "system prompt"); - const resolvedAppendPrompt = resolvePromptInput(parsed.appendSystemPrompt, "append system prompt"); + const appendPromptSource = parsed.appendSystemPrompt ?? discoverAppendSystemPromptFile(); + const resolvedAppendPrompt = resolvePromptInput(appendPromptSource, "append system prompt"); if (sessionManager) { options.sessionManager = sessionManager; @@ -304,10 +343,10 @@ async function buildSessionOptions( const available = modelRegistry.getAvailable(); const { model, warning } = parseModelPattern(parsed.model, available); if (warning) { - console.warn(chalk.yellow(`Warning: ${warning}`)); + writeStderr(chalk.yellow(`Warning: ${warning}`)); } if (!model) { - console.error(chalk.red(`Model "${parsed.model}" not found`)); + writeStderr(chalk.red(`Model "${parsed.model}" not found`)); process.exit(1); } options.model = model; @@ -315,7 +354,22 @@ async function buildSessionOptions( modelRoles: { default: `${model.provider}/${model.id}` }, }); } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) { - options.model = scopedModels[0].model; + const remembered = settingsManager.getModelRole("default"); + if (remembered) { + const parsedModel = parseModelString(remembered); + const rememberedModel = parsedModel + ? scopedModels.find( + (scopedModel) => + scopedModel.model.provider === parsedModel.provider && scopedModel.model.id === parsedModel.id, + ) + : scopedModels.find((scopedModel) => scopedModel.model.id.toLowerCase() === remembered.toLowerCase()); + if (rememberedModel) { + options.model = rememberedModel.model; + } + } + if (!options.model) { + options.model = scopedModels[0].model; + } } // Thinking level @@ -378,13 +432,14 @@ async function buildSessionOptions( } // Additional extension paths from CLI - const cliExtensionPaths = [...(parsed.extensions ?? []), ...(parsed.hooks ?? [])]; + const cliExtensionPaths = parsed.noExtensions ? [] : [...(parsed.extensions ?? []), ...(parsed.hooks ?? [])]; if (cliExtensionPaths.length > 0) { options.additionalExtensionPaths = cliExtensionPaths; } if (parsed.noExtensions) { options.disableExtensionDiscovery = true; + options.additionalExtensionPaths = []; } return options; @@ -443,7 +498,7 @@ export async function main(args: string[]) { time("discoverModels"); if (parsed.version) { - console.log(VERSION); + writeStdout(VERSION); return; } @@ -462,17 +517,17 @@ export async function main(args: string[]) { try { const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined; const result = await exportFromFile(parsed.export, outputPath); - console.log(`Exported to: ${result}`); + writeStdout(`Exported to: ${result}`); return; } catch (error: unknown) { const message = error instanceof Error ? error.message : "Failed to export session"; - console.error(chalk.red(`Error: ${message}`)); + writeStderr(chalk.red(`Error: ${message}`)); process.exit(1); } } if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { - console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); + writeStderr(chalk.red("Error: @file arguments are not supported in RPC mode")); process.exit(1); } @@ -480,13 +535,17 @@ export async function main(args: string[]) { const settingsManager = await SettingsManager.create(cwd); settingsManager.applyEnvironmentVariables(); time("SettingsManager.create"); - const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize()); + const pipedInput = await readPipedInput(); + let { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize()); + if (pipedInput) { + initialMessage = initialMessage ? `${initialMessage}\n${pipedInput}` : pipedInput; + } time("prepareInitialMessage"); - const isInteractive = !parsed.print && parsed.mode === undefined; + const autoPrint = pipedInput !== undefined && !parsed.print && parsed.mode === undefined; + const isInteractive = !parsed.print && !autoPrint && parsed.mode === undefined; const mode = parsed.mode || "text"; // Initialize discovery system with settings for provider persistence - const { initializeWithSettings } = await import("./discovery"); initializeWithSettings(settingsManager); time("initializeWithSettings"); @@ -524,13 +583,13 @@ export async function main(args: string[]) { const sessions = SessionManager.list(cwd, parsed.sessionDir); time("SessionManager.list"); if (sessions.length === 0) { - console.log(chalk.dim("No sessions found")); + writeStdout(chalk.dim("No sessions found")); return; } const selectedPath = await selectSession(sessions); time("selectSession"); if (!selectedPath) { - console.log(chalk.dim("No session selected")); + writeStdout(chalk.dim("No session selected")); return; } sessionManager = await SessionManager.open(selectedPath); @@ -551,7 +610,7 @@ export async function main(args: string[]) { // Handle CLI --api-key as runtime override (not persisted) if (parsed.apiKey) { if (!sessionOptions.model) { - console.error(chalk.red("--api-key requires a model to be specified via --provider/--model or -m/--models")); + writeStderr(chalk.red("--api-key requires a model to be specified via --provider/--model or -m/--models")); process.exit(1); } authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey); @@ -578,10 +637,10 @@ export async function main(args: string[]) { time("applyExtensionFlags"); if (!isInteractive && !session.model) { - console.error(chalk.red("No models available.")); - console.error(chalk.yellow("\nSet an API key environment variable:")); - console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc."); - console.error(chalk.yellow(`\nOr create ${getModelsPath()}`)); + writeStderr(chalk.red("No models available.")); + writeStderr(chalk.yellow("\nSet an API key environment variable:")); + writeStderr(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc."); + writeStderr(chalk.yellow(`\nOr create ${getModelsPath()}`)); process.exit(1); } @@ -612,7 +671,7 @@ export async function main(args: string[]) { return `${scopedModel.model.id}${thinkingStr}`; }) .join(", "); - console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`)); + writeStdout(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`)); } installTerminalCrashHandlers(); diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 4012b93a6..46509e776 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -51,11 +51,17 @@ class SessionList implements Component { } private filterSessions(query: string): void { - this.filteredSessions = fuzzyFilter( - this.allSessions, - query, - (session) => `${session.id} ${session.allMessagesText}`, - ); + this.filteredSessions = fuzzyFilter(this.allSessions, query, (session) => { + const parts = [ + session.id, + session.title ?? "", + session.cwd ?? "", + session.firstMessage ?? "", + session.allMessagesText, + session.path, + ]; + return parts.filter(Boolean).join(" "); + }); this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1)); } @@ -73,10 +79,16 @@ class SessionList implements Component { if (this.filteredSessions.length === 0) { if (this.showCwd) { // "All" scope - no sessions anywhere that match filter - lines.push(theme.fg("muted", " No sessions found")); + lines.push(truncateToWidth(theme.fg("muted", " No sessions found"), width, theme.format.ellipsis)); } else { // "Current folder" scope - hint to try "all" - lines.push(theme.fg("muted", " No sessions in current folder. Press Tab to view all.")); + lines.push( + truncateToWidth( + theme.fg("muted", " No sessions in current folder. Press Tab to view all."), + width, + theme.format.ellipsis, + ), + ); } return lines; } @@ -139,7 +151,7 @@ class SessionList implements Component { const modified = formatDate(session.modified); const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`; const metadata = ` ${modified} ${theme.sep.dot} ${msgCount}`; - const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, "")); + const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, theme.format.ellipsis)); lines.push(metadataLine); lines.push(""); // Blank line between sessions @@ -148,7 +160,7 @@ class SessionList implements Component { // Add scroll indicator if needed if (startIndex > 0 || endIndex < this.filteredSessions.length) { const scrollText = ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`; - const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width, "")); + const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width, theme.format.ellipsis)); lines.push(scrollInfo); } diff --git a/packages/coding-agent/src/modes/interactive/components/settings-defs.ts b/packages/coding-agent/src/modes/interactive/components/settings-defs.ts index b742dc7ac..a3573b8e6 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-defs.ts @@ -201,6 +201,15 @@ export const SETTINGS_DEFS: SettingDef[] = [ get: (sm) => sm.getCollapseChangelog(), set: (sm, v) => sm.setCollapseChangelog(v), }, + { + id: "startupQuiet", + tab: "config", + type: "boolean", + label: "Startup quiet", + description: "Skip welcome screen and startup status messages", + get: (sm) => sm.getStartupQuiet(), + set: (sm, v) => sm.setStartupQuiet(v), + }, { id: "doubleEscapeAction", tab: "config", diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index a96ccd37e..f74d79382 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -507,6 +507,7 @@ export class ToolExecutionComponent extends Container { context.output = output; context.expanded = this.expanded; context.previewLines = BASH_DEFAULT_PREVIEW_LINES; + context.timeout = typeof this.args?.timeout === "number" ? this.args.timeout : undefined; } else if (this.toolName === "edit") { // Edit needs diff preview and renderDiff function context.editDiffPreview = this.editDiffPreview; diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 10eea4801..c7758ed12 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -12,6 +12,7 @@ import { isEnter, isEscape, isShiftCtrlO, + matchesKey, Spacer, Text, TruncatedText, @@ -696,6 +697,21 @@ class TreeList implements Component { const currentIndex = modes.indexOf(this.filterMode); this.filterMode = modes[(currentIndex + 1) % modes.length]; this.applyFilter(); + } else if (matchesKey(keyData, "alt+d")) { + this.filterMode = "default"; + this.applyFilter(); + } else if (matchesKey(keyData, "alt+t")) { + this.filterMode = "no-tools"; + this.applyFilter(); + } else if (matchesKey(keyData, "alt+u")) { + this.filterMode = "user-only"; + this.applyFilter(); + } else if (matchesKey(keyData, "alt+l")) { + this.filterMode = "labeled-only"; + this.applyFilter(); + } else if (matchesKey(keyData, "alt+a")) { + this.filterMode = "all"; + this.applyFilter(); } else if (isBackspace(keyData)) { if (this.searchQuery.length > 0) { this.searchQuery = this.searchQuery.slice(0, -1); @@ -815,7 +831,7 @@ export class TreeSelectorComponent extends Container { new TruncatedText( theme.fg( "muted", - " Up/Down: move. Left/Right: page. l: label. Ctrl+O/Shift+Ctrl+O: filter. Type to search", + " Up/Down: move. Left/Right: page. l: label. Ctrl+O/Shift+Ctrl+O: filter. Alt+D/T/U/L/A: filter. Type to search", ), 0, 0, diff --git a/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts b/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts index 1157073ee..0be5e2331 100644 --- a/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts @@ -305,6 +305,7 @@ export class CommandController { } handleHotkeysCommand(): void { + const expandToolsKey = this.ctx.keybindings.getDisplayString("expandTools") || "Ctrl+O"; const hotkeys = ` **Navigation** | Key | Action | @@ -337,7 +338,7 @@ export class CommandController { | \`Alt+P\` | Select model (temporary) | | \`Ctrl+L\` | Select model (set roles) | | \`Ctrl+R\` | Search prompt history | -| \`Ctrl+O\` | Toggle tool output expansion | +| \`${expandToolsKey}\` | Toggle tool output expansion | | \`Ctrl+T\` | Toggle todo list expansion | | \`Ctrl+G\` | Edit message in external editor | | \`/\` | Slash commands | @@ -481,7 +482,11 @@ export class CommandController { try { const content = fs.readFileSync(skillPath, "utf-8"); const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); - const message = args ? `${body}\n\n---\n\nUser: ${args}` : body; + const metaLines = [`Skill: ${skillPath}`]; + if (args) { + metaLines.push(`User: ${args}`); + } + const message = `${body}\n\n---\n\n${metaLines.join("\n")}`; await this.ctx.session.prompt(message); } catch (err) { this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`); diff --git a/packages/coding-agent/src/modes/interactive/controllers/event-controller.ts b/packages/coding-agent/src/modes/interactive/controllers/event-controller.ts index a0935ef00..59cae676a 100644 --- a/packages/coding-agent/src/modes/interactive/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/interactive/controllers/event-controller.ts @@ -12,6 +12,7 @@ import type { InteractiveModeContext, TodoItem } from "../types"; export class EventController { private lastReadGroup: ReadToolGroupComponent | undefined = undefined; private lastThinkingCount = 0; + private renderedCustomMessages = new Set(); constructor(private ctx: InteractiveModeContext) {} @@ -73,6 +74,11 @@ export class EventController { case "message_start": if (event.message.role === "hookMessage" || event.message.role === "custom") { + const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`; + if (this.renderedCustomMessages.has(signature)) { + break; + } + this.renderedCustomMessages.add(signature); this.resetReadGroup(); this.ctx.addMessageToChat(event.message); this.ctx.ui.requestRender(); @@ -324,6 +330,8 @@ export class EventController { }); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); + } else { + this.ctx.showWarning("Auto-compaction failed; continuing without compaction"); } await this.ctx.flushCompactionQueue({ willRetry: event.willRetry }); this.ctx.ui.requestRender(); diff --git a/packages/coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts index b2c00b09c..ba22dd626 100644 --- a/packages/coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts @@ -178,6 +178,13 @@ export class ExtensionUiController { return { cancelled: false }; }, + compact: async (customInstructions) => { + if (this.ctx.isBackgrounded) { + await this.ctx.session.compact(customInstructions); + return; + } + await this.ctx.executeCompaction(customInstructions, false); + }, }; extensionRunner.initialize(actions, contextActions, commandActions, uiContext); @@ -335,6 +342,13 @@ export class ExtensionUiController { return { cancelled: false }; }, + compact: async (customInstructions) => { + if (this.ctx.isBackgrounded) { + await this.ctx.session.compact(customInstructions); + return; + } + await this.ctx.executeCompaction(customInstructions, false); + }, }; extensionRunner.initialize(actions, contextActions, commandActions, uiContext); diff --git a/packages/coding-agent/src/modes/interactive/controllers/input-controller.ts b/packages/coding-agent/src/modes/interactive/controllers/input-controller.ts index 977c5c43c..b33d5ccf8 100644 --- a/packages/coding-agent/src/modes/interactive/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/interactive/controllers/input-controller.ts @@ -59,16 +59,28 @@ export class InputController { this.ctx.ui.onDebug = () => this.ctx.handleDebugCommand(); this.ctx.editor.onCtrlL = () => this.ctx.showModelSelector(); this.ctx.editor.onCtrlR = () => this.ctx.showHistorySearch(); - this.ctx.editor.onCtrlO = () => this.toggleToolOutputExpansion(); this.ctx.editor.onCtrlT = () => this.ctx.toggleTodoExpansion(); this.ctx.editor.onCtrlG = () => this.openExternalEditor(); this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand(); this.ctx.editor.onCtrlV = () => this.handleImagePaste(); - this.ctx.editor.onAltUp = () => this.handleDequeue(); // Wire up extension shortcuts this.registerExtensionShortcuts(); + const expandToolsKeys = this.ctx.keybindings.getKeys("expandTools"); + this.ctx.editor.onCtrlO = expandToolsKeys.includes("ctrl+o") ? () => this.toggleToolOutputExpansion() : undefined; + for (const key of expandToolsKeys) { + if (key === "ctrl+o") continue; + this.ctx.editor.setCustomKeyHandler(key, () => this.toggleToolOutputExpansion()); + } + + const dequeueKeys = this.ctx.keybindings.getKeys("dequeue"); + this.ctx.editor.onAltUp = dequeueKeys.includes("alt+up") ? () => this.handleDequeue() : undefined; + for (const key of dequeueKeys) { + if (key === "alt+up") continue; + this.ctx.editor.setCustomKeyHandler(key, () => this.handleDequeue()); + } + this.ctx.editor.onChange = (text: string) => { const wasBashMode = this.ctx.isBashMode; this.ctx.isBashMode = text.trimStart().startsWith("!"); @@ -122,6 +134,26 @@ export class InputController { if (!text) return; + const runner = this.ctx.session.extensionRunner; + let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined; + + if (runner?.hasHandlers("input")) { + const result = await runner.emitInput({ type: "input", text, images: inputImages }); + if (result?.handled) { + this.ctx.editor.setText(""); + this.ctx.pendingImages = []; + return; + } + if (result?.text !== undefined) { + text = result.text.trim(); + } + if (result?.images !== undefined) { + inputImages = result.images; + } + } + + if (!text) return; + // Handle slash commands if (text === "/settings") { this.ctx.showSettingsSelector(); @@ -246,7 +278,11 @@ export class InputController { try { const content = fs.readFileSync(skillPath, "utf-8"); const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); - const message = args ? `${body}\n\n---\n\nUser: ${args}` : body; + const metaLines = [`Skill: ${skillPath}`]; + if (args) { + metaLines.push(`User: ${args}`); + } + const message = `${body}\n\n---\n\n${metaLines.join("\n")}`; await this.ctx.session.prompt(message); } catch (err) { this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`); @@ -288,7 +324,7 @@ export class InputController { if (this.ctx.session.isStreaming) { this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); - const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined; + const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined; this.ctx.pendingImages = []; await this.ctx.session.prompt(text, { streamingBehavior: "steer", images }); this.ctx.updatePendingMessagesDisplay(); @@ -317,7 +353,7 @@ export class InputController { if (this.ctx.onInputCallback) { // Include any pending images from clipboard paste - const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined; + const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined; this.ctx.pendingImages = []; this.ctx.onInputCallback({ text, images }); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 375244b32..3b9b5eb2d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -20,7 +20,7 @@ import chalk from "chalk"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session"; import type { ExtensionUIContext } from "../../core/extensions/index"; import { HistoryStorage } from "../../core/history-storage"; -import type { KeybindingsManager } from "../../core/keybindings"; +import { KeybindingsManager } from "../../core/keybindings"; import { logger } from "../../core/logger"; import type { SessionContext, SessionManager } from "../../core/session-manager"; import { getRecentSessions } from "../../core/session-manager"; @@ -71,6 +71,7 @@ export class InteractiveMode implements InteractiveModeContext { public session: AgentSession; public sessionManager: SessionManager; public settingsManager: SettingsManager; + public keybindings: KeybindingsManager; public agent: AgentSession["agent"]; public voiceSupervisor: VoiceSupervisor; public historyStorage?: HistoryStorage; @@ -148,6 +149,7 @@ export class InteractiveMode implements InteractiveModeContext { this.session = session; this.sessionManager = session.sessionManager; this.settingsManager = session.settingsManager; + this.keybindings = KeybindingsManager.inMemory(); this.agent = session.agent; this.version = version; this.changelogMarkdown = changelogMarkdown; @@ -226,6 +228,7 @@ export class InteractiveMode implements InteractiveModeContext { const hookCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map((cmd) => ({ name: cmd.name, description: cmd.description ?? "(hook command)", + getArgumentCompletions: cmd.getArgumentCompletions, })); // Convert custom commands (TypeScript) to SlashCommand format @@ -259,6 +262,8 @@ export class InteractiveMode implements InteractiveModeContext { async init(): Promise { if (this.isInitialized) return; + this.keybindings = await KeybindingsManager.create(); + // Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP) this.cleanupUnsubscribe = registerAsyncCleanup(() => this.sessionManager.flush()); @@ -295,8 +300,34 @@ export class InteractiveMode implements InteractiveModeContext { fileTypes: s.fileTypes, })) ?? []; - // Add welcome header - const welcome = new WelcomeComponent(this.version, modelName, providerName, recentSessions, lspServerInfo); + const startupQuiet = this.settingsManager.getStartupQuiet(); + + if (!startupQuiet) { + // Add welcome header + const welcome = new WelcomeComponent(this.version, modelName, providerName, recentSessions, lspServerInfo); + + // Setup UI layout + this.ui.addChild(new Spacer(1)); + this.ui.addChild(welcome); + this.ui.addChild(new Spacer(1)); + + // Add changelog if provided + if (this.changelogMarkdown) { + this.ui.addChild(new DynamicBorder()); + if (this.settingsManager.getCollapseChangelog()) { + const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/); + const latestVersion = versionMatch ? versionMatch[1] : this.version; + const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`; + this.ui.addChild(new Text(condensedText, 1, 0)); + } else { + this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); + this.ui.addChild(new Spacer(1)); + this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme())); + this.ui.addChild(new Spacer(1)); + } + this.ui.addChild(new DynamicBorder()); + } + } // Set terminal title if session already has one (resumed session) const existingTitle = this.sessionManager.getSessionTitle(); @@ -304,28 +335,6 @@ export class InteractiveMode implements InteractiveModeContext { setTerminalTitle(`pi: ${existingTitle}`); } - // Setup UI layout - this.ui.addChild(new Spacer(1)); - this.ui.addChild(welcome); - this.ui.addChild(new Spacer(1)); - - // Add changelog if provided - if (this.changelogMarkdown) { - this.ui.addChild(new DynamicBorder()); - if (this.settingsManager.getCollapseChangelog()) { - const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/); - const latestVersion = versionMatch ? versionMatch[1] : this.version; - const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`; - this.ui.addChild(new Text(condensedText, 1, 0)); - } else { - this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); - this.ui.addChild(new Spacer(1)); - this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme())); - this.ui.addChild(new Spacer(1)); - } - this.ui.addChild(new DynamicBorder()); - } - this.ui.addChild(this.chatContainer); this.ui.addChild(this.pendingMessagesContainer); this.ui.addChild(this.statusContainer); @@ -369,6 +378,15 @@ export class InteractiveMode implements InteractiveModeContext { // Initial top border update this.updateEditorTopBorder(); + + if (!startupQuiet) { + const templateNames = this.session.promptTemplates.map((template) => template.name).sort(); + if (templateNames.length > 0) { + const preview = templateNames.slice(0, 3).join(", "); + const suffix = templateNames.length > 3 ? ` +${templateNames.length - 3} more` : ""; + this.showStatus(`Loaded prompt templates: ${preview}${suffix}`); + } + } } async getUserInput(): Promise<{ text: string; images?: ImageContent[] }> { @@ -510,6 +528,10 @@ export class InteractiveMode implements InteractiveModeContext { // Emit shutdown event to hooks await this.session.emitCustomToolSessionEvent("shutdown"); + if (this.isInitialized) { + await this.ui.waitForRender(); + } + this.stop(); process.exit(0); } diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index a6ada187e..42e1eff97 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Pi Coding Agent Theme", - "description": "Theme schema for OMP coding agent", + "title": "OMP Coding Agent Theme", + "description": "Theme schema for the OMP coding agent", "type": "object", "required": ["name", "colors"], "properties": { diff --git a/packages/coding-agent/src/modes/interactive/types.ts b/packages/coding-agent/src/modes/interactive/types.ts index a53caa5c3..a3312e0c3 100644 --- a/packages/coding-agent/src/modes/interactive/types.ts +++ b/packages/coding-agent/src/modes/interactive/types.ts @@ -45,6 +45,7 @@ export interface InteractiveModeContext { session: AgentSession; sessionManager: SessionManager; settingsManager: SettingsManager; + keybindings: KeybindingsManager; agent: AgentSession["agent"]; voiceSupervisor: VoiceSupervisor; historyStorage?: HistoryStorage; diff --git a/packages/coding-agent/src/modes/interactive/utils/ui-helpers.ts b/packages/coding-agent/src/modes/interactive/utils/ui-helpers.ts index 80f3cc7f0..a258471be 100644 --- a/packages/coding-agent/src/modes/interactive/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/interactive/utils/ui-helpers.ts @@ -340,7 +340,8 @@ export class UiHelpers { const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`); this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0)); } - const hintText = theme.fg("dim", `${theme.tree.hook} Alt+Up to edit`); + const dequeueKey = this.ctx.keybindings.getDisplayString("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/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 3f05e28be..6f8babe4b 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -96,6 +96,9 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti const result = await session.navigateTree(targetId, { summarize: options?.summarize }); return { cancelled: result.cancelled }; }, + compact: async (customInstructions) => { + await session.compact(customInstructions); + }, }, // No UI context ); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 9aea4c819..1b4e7488e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -330,6 +330,9 @@ export async function runRpcMode(session: AgentSession): Promise { const result = await session.navigateTree(targetId, { summarize: options?.summarize }); return { cancelled: result.cancelled }; }, + compact: async (customInstructions) => { + await session.compact(customInstructions); + }, }, createExtensionUIContext(), ); diff --git a/packages/coding-agent/test/prompt-templates.test.ts b/packages/coding-agent/test/prompt-templates.test.ts index 051f85284..1c0a6e035 100644 --- a/packages/coding-agent/test/prompt-templates.test.ts +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -24,6 +24,26 @@ describe("substituteArgs", () => { expect(substituteArgs("Test: $@", ["a", "b", "c"])).toBe("Test: a b c"); }); + test("should support $@ slicing with start offset", () => { + expect(substituteArgs("Test: $@[2]", ["a", "b", "c"])).toBe("Test: b c"); + }); + + test("should support $@ slicing with start and length", () => { + expect(substituteArgs("Test: $@[2:2]", ["a", "b", "c", "d"])).toBe("Test: b c"); + }); + + test("should support $@ slicing with start and trailing colon", () => { + expect(substituteArgs("Test: $@[3:]", ["a", "b", "c", "d"])).toBe("Test: c d"); + }); + + test("should handle out-of-range $@ slicing", () => { + expect(substituteArgs("Test: $@[5:]", ["a", "b"])).toBe("Test: "); + }); + + test("should treat non-positive $@ slicing as empty", () => { + expect(substituteArgs("Test: $@[0:]", ["a", "b"])).toBe("Test: "); + }); + test("should replace $@ and $ARGUMENTS identically", () => { const args = ["foo", "bar", "baz"]; expect(substituteArgs("Test: $@", args)).toBe(substituteArgs("Test: $ARGUMENTS", args)); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 459274858..84d90ca6a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,28 @@ # Changelog ## [Unreleased] +### Added + +- Added configurable editor padding via `editorPaddingX` theme option +- Added `setMaxHeight()` method to limit editor height with scrolling +- Added Emacs-style kill ring for text deletion operations +- Added `Alt+D` keybinding to delete words forward +- Added `Ctrl+Y` keybinding to yank from kill ring +- Added `waitForRender()` method to await pending renders +- Added support for shifted symbol keys in keybindings + +### Changed + +- Enhanced Kitty keyboard protocol support for better key detection +- Improved cursor positioning with safer bounds checking +- Updated editor layout to respect configurable padding +- Refactored scrolling logic for better viewport management + +### Fixed + +- Fixed key detection for shifted symbol characters +- Fixed backspace handling with additional codepoint support +- Fixed Alt+letter key combinations for better recognition ## [5.5.0] - 2026-01-18 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 792a23848..57b9f8b72 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -17,6 +17,7 @@ import { isCtrlRight, isCtrlU, isCtrlW, + isCtrlY, isDelete, isEnd, isEnter, @@ -27,6 +28,7 @@ import { isShiftEnter, isShiftSpace, isTab, + matchesKey, } from "../keys"; import type { SymbolTheme } from "../symbols"; import type { Component } from "../tui"; @@ -231,6 +233,7 @@ export interface EditorTheme { borderColor: (str: string) => string; selectList: SelectListTheme; symbols: SymbolTheme; + editorPaddingX?: number; } export interface EditorTopBorder { @@ -261,6 +264,12 @@ export class Editor implements Component { // Store last render width for cursor navigation private lastWidth: number = 80; + private maxHeight?: number; + private scrollOffset: number = 0; + + // Emacs-style kill ring + private killRing: string[] = []; + private lastKillWasKillCommand: boolean = false; // Border color (can be changed dynamically) public borderColor: (str: string) => string; @@ -318,6 +327,11 @@ export class Editor implements Component { this.useTerminalCursor = useTerminalCursor; } + setMaxHeight(maxHeight: number | undefined): void { + this.maxHeight = maxHeight; + this.scrollOffset = 0; + } + setHistoryStorage(storage: HistoryStorage): void { this.historyStorage = storage; const recent = storage.getRecent(100); @@ -348,18 +362,21 @@ export class Editor implements Component { } private isOnFirstVisualLine(): boolean { - const visualLines = this.buildVisualLineMap(this.lastWidth); + const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX()); + const visualLines = this.buildVisualLineMap(contentWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); return currentVisualLine === 0; } private isOnLastVisualLine(): boolean { - const visualLines = this.buildVisualLineMap(this.lastWidth); + const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX()); + const visualLines = this.buildVisualLineMap(contentWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); return currentVisualLine === visualLines.length - 1; } private navigateHistory(direction: 1 | -1): void { + this.resetKillSequence(); if (this.history.length === 0) return; const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases @@ -391,27 +408,65 @@ export class Editor implements Component { // No cached state to invalidate currently } + private getEditorPaddingX(): number { + const padding = this.theme.editorPaddingX ?? 2; + return Math.max(1, padding); + } + + private getContentWidth(width: number, paddingX: number): number { + return Math.max(0, width - 2 * (paddingX + 1)); + } + + private getVisibleContentHeight(contentLines: number): number { + if (this.maxHeight === undefined) return contentLines; + return Math.max(1, this.maxHeight - 2); + } + + private updateScrollOffset(contentWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void { + if (layoutLines.length <= visibleHeight) { + this.scrollOffset = 0; + return; + } + + const visualLines = this.buildVisualLineMap(contentWidth); + const cursorLine = this.findCurrentVisualLine(visualLines); + if (cursorLine < this.scrollOffset) { + this.scrollOffset = cursorLine; + } else if (cursorLine >= this.scrollOffset + visibleHeight) { + this.scrollOffset = cursorLine - visibleHeight + 1; + } + + const maxOffset = Math.max(0, layoutLines.length - visibleHeight); + this.scrollOffset = Math.min(this.scrollOffset, maxOffset); + } + render(width: number): string[] { // Store width for cursor navigation this.lastWidth = width; // Box-drawing characters for rounded corners const box = this.theme.symbols.boxRound; - const topLeft = this.borderColor(`${box.topLeft}${box.horizontal}`); - const topRight = this.borderColor(`${box.horizontal}${box.topRight}`); - const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}`); - const bottomRight = this.borderColor(`${box.horizontal}${box.bottomRight}`); + const paddingX = this.getEditorPaddingX(); + const borderWidth = paddingX + 1; + const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`); + const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`); + const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${" ".repeat(Math.max(0, paddingX - 1))}`); + const bottomRight = this.borderColor( + `${" ".repeat(Math.max(0, paddingX - 1))}${box.horizontal}${box.bottomRight}`, + ); const horizontal = this.borderColor(box.horizontal); - // Layout the text - content area is width minus 6 for borders (3 left + 3 right) - const contentAreaWidth = width - 6; + // Layout the text + const contentAreaWidth = this.getContentWidth(width, paddingX); const layoutLines = this.layoutText(contentAreaWidth); + const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length); + this.updateScrollOffset(contentAreaWidth, layoutLines, visibleContentHeight); + const visibleLayoutLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + visibleContentHeight); const result: string[] = []; // Render top border: ╭─ [status content] ────────────────╮ - // Reserve: 2 for "╭─", 2 for "─╮" = 4 total for corners - const topFillWidth = width - 4; + const topFillWidth = width - borderWidth * 2; if (this.topBorderContent) { const { content, width: statusWidth } = this.topBorderContent; if (statusWidth <= topFillWidth) { @@ -430,9 +485,8 @@ export class Editor implements Component { } // Render each layout line - // Content area is width - 6 (for "│ " prefix and " │" suffix borders) - const lineContentWidth = width - 6; - for (const layoutLine of layoutLines) { + const lineContentWidth = contentAreaWidth; + for (const layoutLine of visibleLayoutLines) { let displayText = layoutLine.text; let displayWidth = visibleWidth(layoutLine.text); @@ -475,16 +529,15 @@ export class Editor implements Component { } } - // All lines have consistent 6-char borders (3 left + 3 right) - const isLastLine = layoutLine === layoutLines[layoutLines.length - 1]; + // All lines have consistent borders based on padding + const isLastLine = layoutLine === visibleLayoutLines[visibleLayoutLines.length - 1]; const padding = " ".repeat(Math.max(0, lineContentWidth - displayWidth)); if (isLastLine) { - // Last line: "╰─ " (3) + content + padding + " ─╯" (3) = 6 chars border - result.push(`${bottomLeft} ${displayText}${padding} ${bottomRight}`); + result.push(`${bottomLeft}${displayText}${padding}${bottomRight}`); } else { - const leftBorder = this.borderColor(`${box.vertical} `); - const rightBorder = this.borderColor(` ${box.vertical}`); + const leftBorder = this.borderColor(`${box.vertical}${" ".repeat(paddingX)}`); + const rightBorder = this.borderColor(`${" ".repeat(paddingX)}${box.vertical}`); result.push(leftBorder + displayText + padding + rightBorder); } } @@ -501,11 +554,17 @@ export class Editor implements Component { getCursorPosition(width: number): { row: number; col: number } | null { if (!this.useTerminalCursor) return null; - const contentWidth = width - 6; + const paddingX = this.getEditorPaddingX(); + const borderWidth = paddingX + 1; + const contentWidth = this.getContentWidth(width, paddingX); if (contentWidth <= 0) return null; const layoutLines = this.layoutText(contentWidth); + const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length); + this.updateScrollOffset(contentWidth, layoutLines, visibleContentHeight); + for (let i = 0; i < layoutLines.length; i++) { + if (i < this.scrollOffset || i >= this.scrollOffset + visibleContentHeight) continue; const layoutLine = layoutLines[i]; if (!layoutLine || !layoutLine.hasCursor || layoutLine.cursorPos === undefined) continue; @@ -516,13 +575,13 @@ export class Editor implements Component { const graphemes = [...segmenter.segment(layoutLine.text)]; const lastGrapheme = graphemes[graphemes.length - 1]?.segment || ""; const lastWidth = visibleWidth(lastGrapheme) || 1; - const colOffset = 3 + Math.max(0, lineWidth - lastWidth); - return { row: 1 + i, col: colOffset }; + const colOffset = borderWidth + Math.max(0, lineWidth - lastWidth); + return { row: 1 + i - this.scrollOffset, col: colOffset }; } const before = layoutLine.text.slice(0, layoutLine.cursorPos); - const colOffset = 3 + visibleWidth(before); - return { row: 1 + i, col: colOffset }; + const colOffset = borderWidth + visibleWidth(before); + return { row: 1 + i - this.scrollOffset, col: colOffset }; } return null; @@ -705,6 +764,14 @@ export class Editor implements Component { else if (isAltBackspace(data)) { this.deleteWordBackwards(); } + // Option/Alt+D - Delete word forwards + else if (matchesKey(data, "alt+d")) { + this.deleteWordForwards(); + } + // Ctrl+Y - Yank from kill ring + else if (isCtrlY(data)) { + this.yankFromKillRing(); + } // Ctrl+A - Move to start of line else if (isCtrlA(data)) { this.moveToLineStart(); @@ -743,6 +810,8 @@ export class Editor implements Component { return; } + this.resetKillSequence(); + // Get text and substitute paste markers with actual content let result = this.state.lines.join("\n").trim(); @@ -789,9 +858,11 @@ export class Editor implements Component { // Word navigation (Option/Alt + Arrow or Ctrl + Arrow) else if (isAltLeft(data) || isCtrlLeft(data)) { // Word left + this.resetKillSequence(); this.moveWordBackwards(); } else if (isAltRight(data) || isCtrlRight(data)) { // Word right + this.resetKillSequence(); this.moveWordForwards(); } // Arrow keys @@ -943,12 +1014,14 @@ export class Editor implements Component { setText(text: string): void { this.historyIndex = -1; // Exit history browsing mode + this.resetKillSequence(); this.setTextInternal(text); } /** Insert text at the current cursor position */ insertText(text: string): void { this.historyIndex = -1; + this.resetKillSequence(); const line = this.state.lines[this.state.cursorLine] || ""; const before = line.slice(0, this.state.cursorCol); @@ -965,6 +1038,7 @@ export class Editor implements Component { // All the editor methods from before... private insertCharacter(char: string): void { this.historyIndex = -1; // Exit history browsing mode + this.resetKillSequence(); const line = this.state.lines[this.state.cursorLine] || ""; @@ -1014,6 +1088,7 @@ export class Editor implements Component { private handlePaste(pastedText: string): void { this.historyIndex = -1; // Exit history browsing mode + this.resetKillSequence(); // Clean the pasted text const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); @@ -1114,6 +1189,7 @@ export class Editor implements Component { private addNewLine(): void { this.historyIndex = -1; // Exit history browsing mode + this.resetKillSequence(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1135,6 +1211,7 @@ export class Editor implements Component { private handleBackspace(): void { this.historyIndex = -1; // Exit history browsing mode + this.resetKillSequence(); if (this.state.cursorCol > 0) { // Delete grapheme before cursor (handles emojis, combining characters, etc.) @@ -1186,25 +1263,98 @@ export class Editor implements Component { } private moveToLineStart(): void { + this.resetKillSequence(); this.state.cursorCol = 0; } private moveToLineEnd(): void { + this.resetKillSequence(); const currentLine = this.state.lines[this.state.cursorLine] || ""; this.state.cursorCol = currentLine.length; } + private resetKillSequence(): void { + this.lastKillWasKillCommand = false; + this.killRingIndex = 0; + } + + private recordKill(text: string, direction: "forward" | "backward"): void { + if (!text) return; + if (this.lastKillWasKillCommand && this.killRing.length > 0) { + if (direction === "backward") { + this.killRing[0] = text + this.killRing[0]; + } else { + this.killRing[0] = this.killRing[0] + text; + } + } else { + this.killRing.unshift(text); + } + this.killRingIndex = 0; + this.lastKillWasKillCommand = true; + } + + private insertTextAtCursor(text: string): void { + this.historyIndex = -1; + this.resetKillSequence(); + + const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const lines = normalized.split("\n"); + + if (lines.length === 1) { + const line = this.state.lines[this.state.cursorLine] || ""; + const before = line.slice(0, this.state.cursorCol); + const after = line.slice(this.state.cursorCol); + this.state.lines[this.state.cursorLine] = before + normalized + after; + this.state.cursorCol += normalized.length; + } else { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const beforeCursor = currentLine.slice(0, this.state.cursorCol); + const afterCursor = currentLine.slice(this.state.cursorCol); + + const newLines: string[] = []; + for (let i = 0; i < this.state.cursorLine; i++) { + newLines.push(this.state.lines[i] || ""); + } + + newLines.push(beforeCursor + (lines[0] || "")); + for (let i = 1; i < lines.length - 1; i++) { + newLines.push(lines[i] || ""); + } + newLines.push((lines[lines.length - 1] || "") + afterCursor); + + for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) { + newLines.push(this.state.lines[i] || ""); + } + + this.state.lines = newLines; + this.state.cursorLine += lines.length - 1; + this.state.cursorCol = (lines[lines.length - 1] || "").length; + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private yankFromKillRing(): void { + if (this.killRing.length === 0) return; + this.insertTextAtCursor(this.killRing[0] || ""); + } + private deleteToStartOfLine(): void { this.historyIndex = -1; // Exit history browsing mode const currentLine = this.state.lines[this.state.cursorLine] || ""; + let deletedText = ""; if (this.state.cursorCol > 0) { // Delete from start of line up to cursor + deletedText = currentLine.slice(0, this.state.cursorCol); this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol); this.state.cursorCol = 0; } else if (this.state.cursorLine > 0) { // At start of line - merge with previous line + deletedText = "\n"; const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; this.state.lines.splice(this.state.cursorLine, 1); @@ -1212,6 +1362,8 @@ export class Editor implements Component { this.state.cursorCol = previousLine.length; } + this.recordKill(deletedText, "backward"); + if (this.onChange) { this.onChange(this.getText()); } @@ -1221,17 +1373,22 @@ export class Editor implements Component { this.historyIndex = -1; // Exit history browsing mode const currentLine = this.state.lines[this.state.cursorLine] || ""; + let deletedText = ""; if (this.state.cursorCol < currentLine.length) { // Delete from cursor to end of line + deletedText = currentLine.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol); } else if (this.state.cursorLine < this.state.lines.length - 1) { // At end of line - merge with next line const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; + deletedText = `\n${nextLine}`; this.state.lines[this.state.cursorLine] = currentLine + nextLine; this.state.lines.splice(this.state.cursorLine + 1, 1); } + this.recordKill(deletedText, "forward"); + if (this.onChange) { this.onChange(this.getText()); } @@ -1245,6 +1402,7 @@ export class Editor implements Component { // If at start of line, behave like backspace at column 0 (merge with previous line) if (this.state.cursorCol === 0) { if (this.state.cursorLine > 0) { + this.recordKill("\n", "backward"); const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; this.state.lines.splice(this.state.cursorLine, 1); @@ -1257,9 +1415,39 @@ export class Editor implements Component { const deleteFrom = this.state.cursorCol; this.state.cursorCol = oldCursorCol; + const deletedText = currentLine.slice(deleteFrom, oldCursorCol); this.state.lines[this.state.cursorLine] = currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol); this.state.cursorCol = deleteFrom; + this.recordKill(deletedText, "backward"); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private deleteWordForwards(): void { + this.historyIndex = -1; // Exit history browsing mode + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + if (this.state.cursorCol >= currentLine.length) { + if (this.state.cursorLine < this.state.lines.length - 1) { + this.recordKill("\n", "forward"); + const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; + this.state.lines[this.state.cursorLine] = currentLine + nextLine; + this.state.lines.splice(this.state.cursorLine + 1, 1); + } + } else { + const oldCursorCol = this.state.cursorCol; + this.moveWordForwards(); + const deleteTo = this.state.cursorCol; + this.state.cursorCol = oldCursorCol; + + const deletedText = currentLine.slice(oldCursorCol, deleteTo); + this.state.lines[this.state.cursorLine] = currentLine.slice(0, oldCursorCol) + currentLine.slice(deleteTo); + this.recordKill(deletedText, "forward"); } if (this.onChange) { @@ -1269,6 +1457,7 @@ export class Editor implements Component { private handleForwardDelete(): void { this.historyIndex = -1; // Exit history browsing mode + this.resetKillSequence(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1371,11 +1560,12 @@ export class Editor implements Component { } private moveCursor(deltaLine: number, deltaCol: number): void { - const width = this.lastWidth; + this.resetKillSequence(); + const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX()); if (deltaLine !== 0) { // Build visual line map for navigation - const visualLines = this.buildVisualLineMap(width); + const visualLines = this.buildVisualLineMap(contentWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); // Calculate column position within current visual line diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index 16549061b..597ada2f2 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -1,4 +1,4 @@ -import { type KeyId, matchesKey } from "./keys"; +import { type KeyId, matchesKey, parseKey } from "./keys"; /** * Editor actions that can be bound to keys. @@ -77,6 +77,29 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required = { copy: "ctrl+c", }; +const SHIFTED_SYMBOL_KEYS = new Set([ + "!", + "@", + "#", + "$", + "%", + "^", + "&", + "*", + "(", + ")", + "_", + "+", + "{", + "}", + "|", + ":", + "<", + ">", + "?", + "~", +]); + /** * Manages keybindings for the editor. */ @@ -114,7 +137,12 @@ export class EditorKeybindingsManager { for (const key of keys) { if (matchesKey(data, key)) return true; } - return false; + + const parsed = parseKey(data); + if (!parsed || !parsed.startsWith("shift+")) return false; + const keyName = parsed.slice("shift+".length); + if (!SHIFTED_SYMBOL_KEYS.has(keyName)) return false; + return keys.includes(keyName as KeyId); } /** diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 758cfadaa..36ad33e06 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -275,9 +275,12 @@ const CODEPOINTS = { enter: 13, space: 32, backspace: 127, + backspaceCtrl: 8, kpEnter: 57414, // Numpad Enter (Kitty protocol) } as const; +const BACKSPACE_CODEPOINTS = new Set([CODEPOINTS.backspace, CODEPOINTS.backspaceCtrl]); + const ARROW_CODEPOINTS = { up: -1, down: -2, @@ -308,6 +311,8 @@ interface ParsedKittySequence { codepoint: number; modifier: number; eventType: KeyEventType; + shiftedCodepoint?: number; + baseCodepoint?: number; } /** @@ -374,14 +379,26 @@ function parseEventType(eventTypeStr: string | undefined): KeyEventType { return "press"; } +function parseKittyKeyCodes(keyCodes: string): { + codepoint: number; + shiftedCodepoint?: number; + baseCodepoint?: number; +} { + const parts = keyCodes.split(":"); + const codepoint = parseInt(parts[0]!, 10); + const shiftedCodepoint = parts[1] ? parseInt(parts[1], 10) : undefined; + const baseCodepoint = parts.length > 2 && parts[2] ? parseInt(parts[2], 10) : undefined; + return { codepoint, shiftedCodepoint, baseCodepoint }; +} + function parseKittySequence(data: string): ParsedKittySequence | null { // CSI u format: \x1b[u or \x1b[;u or \x1b[;:u - const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?u$/); + const csiUMatch = data.match(/^\x1b\[(\d+(?::\d+)*)(?:;(\d+))?(?::(\d+))?u$/); if (csiUMatch) { - const codepoint = parseInt(csiUMatch[1]!, 10); + const { codepoint, shiftedCodepoint, baseCodepoint } = parseKittyKeyCodes(csiUMatch[1]!); const modValue = csiUMatch[2] ? parseInt(csiUMatch[2], 10) : 1; const eventType = parseEventType(csiUMatch[3]); - return { codepoint, modifier: modValue - 1, eventType }; + return { codepoint, modifier: modValue - 1, eventType, shiftedCodepoint, baseCodepoint }; } // Arrow keys with modifier: \x1b[1;A/B/C/D or \x1b[1;:A/B/C/D @@ -425,12 +442,21 @@ function parseKittySequence(data: string): ParsedKittySequence | null { return null; } +function getKittyCodepoints(parsed: ParsedKittySequence): number[] { + const codepoints = new Set(); + codepoints.add(parsed.codepoint); + if (parsed.shiftedCodepoint !== undefined) codepoints.add(parsed.shiftedCodepoint); + if (parsed.baseCodepoint !== undefined) codepoints.add(parsed.baseCodepoint); + return Array.from(codepoints.values()); +} + function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean { const parsed = parseKittySequence(data); if (!parsed) return false; const actualMod = parsed.modifier & ~LOCK_MASK; const expectedMod = expectedModifier & ~LOCK_MASK; - return parsed.codepoint === expectedCodepoint && actualMod === expectedMod; + if (actualMod !== expectedMod) return false; + return getKittyCodepoints(parsed).includes(expectedCodepoint); } // ============================================================================= @@ -542,12 +568,25 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "backspace": if (alt && !ctrl && !shift) { - return data === "\x1b\x7f" || matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt); + return ( + data === "\x1b\x7f" || + data === "\x1b\x08" || + matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt) || + matchesKittySequence(data, CODEPOINTS.backspaceCtrl, MODIFIERS.alt) + ); } if (modifier === 0) { - return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0); + return ( + data === "\x7f" || + data === "\x08" || + matchesKittySequence(data, CODEPOINTS.backspace, 0) || + matchesKittySequence(data, CODEPOINTS.backspaceCtrl, 0) + ); } - return matchesKittySequence(data, CODEPOINTS.backspace, modifier); + return ( + matchesKittySequence(data, CODEPOINTS.backspace, modifier) || + matchesKittySequence(data, CODEPOINTS.backspaceCtrl, modifier) + ); case "delete": if (modifier === 0) { @@ -655,6 +694,10 @@ export function matchesKey(data: string, keyId: KeyId): boolean { return matchesKittySequence(data, codepoint, MODIFIERS.shift); } + if (alt && !ctrl && !shift && key >= "a" && key <= "z") { + return data === `\x1b${key}` || data === `\x1b${key.toUpperCase()}`; + } + if (modifier !== 0) { return matchesKittySequence(data, codepoint, modifier); } @@ -665,6 +708,42 @@ export function matchesKey(data: string, keyId: KeyId): boolean { return false; } +function keyNameFromCodepoint(codepoint: number): string | undefined { + if (codepoint === CODEPOINTS.escape) return "escape"; + if (codepoint === CODEPOINTS.tab) return "tab"; + if (codepoint === CODEPOINTS.enter || codepoint === CODEPOINTS.kpEnter) return "enter"; + if (codepoint === CODEPOINTS.space) return "space"; + if (BACKSPACE_CODEPOINTS.has(codepoint)) return "backspace"; + if (codepoint === FUNCTIONAL_CODEPOINTS.delete) return "delete"; + if (codepoint === FUNCTIONAL_CODEPOINTS.home) return "home"; + if (codepoint === FUNCTIONAL_CODEPOINTS.end) return "end"; + if (codepoint === FUNCTIONAL_CODEPOINTS.pageUp) return "pageUp"; + if (codepoint === FUNCTIONAL_CODEPOINTS.pageDown) return "pageDown"; + if (codepoint === ARROW_CODEPOINTS.up) return "up"; + if (codepoint === ARROW_CODEPOINTS.down) return "down"; + if (codepoint === ARROW_CODEPOINTS.left) return "left"; + if (codepoint === ARROW_CODEPOINTS.right) return "right"; + if (codepoint >= 97 && codepoint <= 122) return String.fromCharCode(codepoint); + const char = String.fromCharCode(codepoint); + if (SYMBOL_KEYS.has(char)) return char; + return undefined; +} + +function resolveKittyKeyName(parsed: ParsedKittySequence, effectiveMod: number): string | undefined { + if (effectiveMod & MODIFIERS.shift && parsed.shiftedCodepoint !== undefined) { + const keyName = keyNameFromCodepoint(parsed.shiftedCodepoint); + if (keyName) return keyName; + } + if (parsed.baseCodepoint !== undefined) { + const keyName = keyNameFromCodepoint(parsed.baseCodepoint); + if (keyName) return keyName; + } + const fallback = keyNameFromCodepoint(parsed.codepoint); + if (fallback) return fallback; + if (parsed.shiftedCodepoint !== undefined) return keyNameFromCodepoint(parsed.shiftedCodepoint); + return undefined; +} + /** * Parse input data and return the key identifier if recognized. * @@ -674,31 +753,14 @@ export function matchesKey(data: string, keyId: KeyId): boolean { export function parseKey(data: string): string | undefined { const kitty = parseKittySequence(data); if (kitty) { - const { codepoint, modifier } = kitty; + const { modifier } = kitty; const mods: string[] = []; const effectiveMod = modifier & ~LOCK_MASK; if (effectiveMod & MODIFIERS.shift) mods.push("shift"); if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl"); if (effectiveMod & MODIFIERS.alt) mods.push("alt"); - let keyName: string | undefined; - if (codepoint === CODEPOINTS.escape) keyName = "escape"; - else if (codepoint === CODEPOINTS.tab) keyName = "tab"; - else if (codepoint === CODEPOINTS.enter || codepoint === CODEPOINTS.kpEnter) keyName = "enter"; - else if (codepoint === CODEPOINTS.space) keyName = "space"; - else if (codepoint === CODEPOINTS.backspace) keyName = "backspace"; - else if (codepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete"; - else if (codepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home"; - else if (codepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end"; - else if (codepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp"; - else if (codepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown"; - else if (codepoint === ARROW_CODEPOINTS.up) keyName = "up"; - else if (codepoint === ARROW_CODEPOINTS.down) keyName = "down"; - else if (codepoint === ARROW_CODEPOINTS.left) keyName = "left"; - else if (codepoint === ARROW_CODEPOINTS.right) keyName = "right"; - else if (codepoint >= 97 && codepoint <= 122) keyName = String.fromCharCode(codepoint); - else if (SYMBOL_KEYS.has(String.fromCharCode(codepoint))) keyName = String.fromCharCode(codepoint); - + const keyName = resolveKittyKeyName(kitty, effectiveMod); if (keyName) { return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName; } @@ -720,7 +782,12 @@ export function parseKey(data: string): string | undefined { if (data === "\x7f" || data === "\x08") return "backspace"; if (data === "\x1b[Z") return "shift+tab"; if (!kittyProtocolActive && data === "\x1b\r") return "alt+enter"; - if (data === "\x1b\x7f") return "alt+backspace"; + if (data === "\x1b\x7f" || data === "\x1b\x08") return "alt+backspace"; + if (data.length === 2 && data.startsWith("\x1b")) { + const letter = data[1]; + if (letter >= "a" && letter <= "z") return `alt+${letter}`; + if (letter >= "A" && letter <= "Z") return `alt+${letter.toLowerCase()}`; + } if (data === "\x1b[A") return "up"; if (data === "\x1b[B") return "down"; if (data === "\x1b[C") return "right"; diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 55ffa3b44..69c2b4013 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -136,7 +136,8 @@ export class ProcessTerminal implements Terminal { // Enable Kitty keyboard protocol (push flags) // Flag 1 = disambiguate escape codes // Flag 2 = report event types (press/repeat/release) - process.stdout.write("\x1b[>3u"); + // Flag 4 = report alternate keys + process.stdout.write("\x1b[>7u"); return; // Don't forward protocol response to TUI } } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 3a8125c93..8156892dd 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -370,6 +370,20 @@ export class TUI extends Container { }); } + async waitForRender(): Promise { + if (!this.renderRequested && !this.rendering) return; + await new Promise((resolve) => { + const check = () => { + if (!this.renderRequested && !this.rendering) { + resolve(); + return; + } + setTimeout(check, 0); + }; + check(); + }); + } + private areCursorsEqual( left: { row: number; col: number } | null, right: { row: number; col: number } | null, @@ -385,13 +399,11 @@ export class TUI extends Container { cursor: { row: number; col: number } | null, currentCursorRow: number, ): void { - if (!cursor || totalLines <= 0) { - this.terminal.hideCursor(); - return; - } + const safeTotalLines = Math.max(totalLines, 1); + const resolvedCursor = cursor ?? { row: safeTotalLines - 1, col: 0 }; - const targetRow = Math.max(0, Math.min(cursor.row, totalLines - 1)); - const targetCol = Math.max(0, Math.min(cursor.col, width - 1)); + const targetRow = Math.max(0, Math.min(resolvedCursor.row, safeTotalLines - 1)); + const targetCol = Math.max(0, Math.min(resolvedCursor.col, width - 1)); const rowDelta = targetRow - currentCursorRow; let buffer = "";